Q16: compact older tool results inside a run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
469f2f2c35
commit
f42ab62dbd
3 files changed
+119
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<AgentTurnResult & { requestId: string }> => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user