diff --git a/src/main/services/agent-run-loop.ts b/src/main/services/agent-run-loop.ts index 416f414e..f36a34eb 100644 --- a/src/main/services/agent-run-loop.ts +++ b/src/main/services/agent-run-loop.ts @@ -6,6 +6,7 @@ import { executeWorkspaceTool, isWorkspaceTool, WORKSPACE_TOOLS } from "./agent- import { canonicalMentionHandle } from "../../shared/mention-handles"; import type { AgentTurnMessage, ToolDefinition } from "./provider-tool-turn"; import { isComposioTool, executeComposioTool } from "./composio/composio-tool-bridge"; +import { compactToolHistory } from "./run-context-compaction"; const MAX_ITERATIONS = 12; @@ -50,6 +51,11 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn let lastRequestId = ""; for (let iteration = 1; iteration <= MAX_ITERATIONS; iteration += 1) { + const compaction = compactToolHistory(messages); + if (compaction.elided > 0) { + messages.splice(0, messages.length, ...compaction.messages); + console.info(`[run-loop] iteration ${iteration}: elided ${compaction.elided} earlier tool result(s)`); + } const turn = await deps.providers.runToolTurn({ orgId: input.orgId, providerId: input.providerId, diff --git a/src/main/services/run-context-compaction.ts b/src/main/services/run-context-compaction.ts new file mode 100644 index 00000000..c343822d --- /dev/null +++ b/src/main/services/run-context-compaction.ts @@ -0,0 +1,27 @@ +import type { AgentTurnMessage } from "./provider-tool-turn"; + +const KEEP_RECENT_TOOL_TURNS = 3; +const ELIDE_ABOVE_CHARS = 600; +const PREVIEW_CHARS = 300; + +/** Older tool results shrink to a preview; the latest turns stay verbatim. */ +export function compactToolHistory(messages: AgentTurnMessage[]): { messages: AgentTurnMessage[]; elided: number } { + const toolTurnIndexes = messages.flatMap((message, index) => (message.role === "tool" ? [index] : [])); + const compactBefore = toolTurnIndexes.length > KEEP_RECENT_TOOL_TURNS + ? toolTurnIndexes[toolTurnIndexes.length - KEEP_RECENT_TOOL_TURNS] + : 0; + let elided = 0; + const compacted = messages.map((message, index) => { + if (message.role !== "tool" || index >= compactBefore) return message; + const results = message.results.map((result) => { + if (result.output.length <= ELIDE_ABOVE_CHARS || result.output.startsWith("[Earlier result of ")) return result; + elided += 1; + return { + ...result, + output: `[Earlier result of ${result.toolName} elided to save context (${result.output.length} chars). It began: ${result.output.slice(0, PREVIEW_CHARS)}… Call ${result.toolName} again if you need the full result.]`, + }; + }); + return { ...message, results }; + }); + return { messages: compacted, elided }; +} diff --git a/tests/unit/run-context-compaction.test.ts b/tests/unit/run-context-compaction.test.ts new file mode 100644 index 00000000..2df589a0 --- /dev/null +++ b/tests/unit/run-context-compaction.test.ts @@ -0,0 +1,86 @@ +import { randomUUID } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; +import { compactToolHistory } from "../../src/main/services/run-context-compaction"; +import { runAgentLoop } from "../../src/main/services/agent-run-loop"; +import { DatabaseConnection } from "../../src/main/database/connection"; +import { OrganizationService } from "../../src/main/services/organization-service"; +import { ChatService } from "../../src/main/services/chat-service"; +import { IntegrationKeyStore } from "../../src/main/services/integration-key-store"; +import type { AgentTurnMessage, AgentTurnResult } from "../../src/main/services/provider-tool-turn"; + +function toolTurn(name: string, output: string): AgentTurnMessage[] { + const id = `c_${randomUUID()}`; + return [ + { role: "assistant", text: "", toolCalls: [{ id, name, arguments: {} }] }, + { role: "tool", results: [{ toolCallId: id, toolName: name, output }] }, + ]; +} + +describe("tool history compaction", () => { + test("keeps the three latest tool turns verbatim and previews older long outputs", () => { + const big = (tag: string) => `${tag}:${"x".repeat(2000)}`; + const messages: AgentTurnMessage[] = [ + { role: "user", text: "task" }, + ...toolTurn("read_file", big("first")), + ...toolTurn("read_file", "short"), + ...toolTurn("fetch_url", big("third")), + ...toolTurn("read_file", big("fourth")), + ...toolTurn("read_file", big("fifth")), + ]; + const { messages: compacted, elided } = compactToolHistory(messages); + expect(elided).toBe(1); + const outputs = compacted.flatMap((message) => (message.role === "tool" ? message.results.map((result) => result.output) : [])); + expect(outputs[0]).toMatch(/^\[Earlier result of read_file elided to save context \(2006 chars\)\. It began: first:x+… Call read_file again/); + expect(outputs[1]).toBe("short"); + expect(outputs[2]).toBe(big("third")); + expect(outputs[3]).toBe(big("fourth")); + expect(outputs[4]).toBe(big("fifth")); + expect(compactToolHistory(compacted).elided).toBe(0); + }); + + test("the run loop sends compacted history to the provider", async () => { + const connection = new DatabaseConnection(join(tmpdir(), `ac-compact-${randomUUID()}.db`)); + const organization = new OrganizationService(connection.db); + let orgId = ""; + let agentId = ""; + connection.transaction(() => { + orgId = organization.createOrganization({ organizationName: "C", employeeName: "Y", mission: "m", goal: "g" }); + agentId = organization.createAgent({ + orgId, name: "Ana", role: "Analyst", department: "Operations", + modelProviderId: null, modelId: null, budgetCents: 1000, managerAgentId: null, + }); + }); + const chat = new ChatService(connection.db); + const general = connection.db.prepare("select id from chat_spaces where org_id = ? and name = 'general'").get(orgId) as { id: string }; + chat.sendMessage({ orgId, spaceId: general.id, threadId: null, senderType: "agent", senderId: agentId, bodyMd: "y".repeat(3000), attachmentFileIds: [] }); + const sent: AgentTurnMessage[][] = []; + let turn = 0; + const result = await runAgentLoop( + { + providers: { + integrationKeys: new IntegrationKeyStore(connection.db), + runToolTurn: async (input): Promise => { + sent.push(input.messages.map((message) => ({ ...message }))); + turn += 1; + return turn <= 5 + ? { text: "", toolCalls: [{ id: `c${turn}`, name: "read_messages", arguments: { channel: "general" } }], rawResponseJson: "{}", requestId: `r${turn}` } + : { text: "done", toolCalls: [], rawResponseJson: "{}", requestId: `r${turn}` }; + }, + }, + tools: { db: connection.db, organization, chat }, + }, + { + orgId, providerId: "p", workspaceId: null, instructions: "i", initialUserTurn: "go", + toolContext: { orgId, agent: { id: agentId, modelProviderId: null, modelId: null }, taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null }, + }, + ); + expect(result.finalText).toBe("done"); + const outputs = sent[5].flatMap((message) => (message.role === "tool" ? message.results.map((item) => item.output) : [])); + expect(outputs).toHaveLength(5); + expect(outputs.slice(0, 2).every((output) => output.startsWith("[Earlier result of read_messages elided"))).toBe(true); + expect(outputs.slice(2).every((output) => output.includes("y".repeat(3000)))).toBe(true); + connection.close(); + }); +});