From 66652e60270bc0cabc3aad11ab020d934039e1f1 Mon Sep 17 00:00:00 2001 From: TaewoooPark Date: Sat, 17 Oct 2026 20:32:51 +0900 Subject: [PATCH] fix(core): refuse a compaction reply that calls a tool The summary request tells the model not to call a tool. A model that keeps working instead answers with one line of intent and a call, such as "Let me check the current state first." With the call stripped, that line was not empty, so it became the summary and replaced the whole transcript: the next turn started with the task and one sentence. A reply that calls a tool, as a parsed tool call or as an inline block, is now a failed compaction. The loop already handles that case: it keeps the full transcript, warns, and compacts again once the next request crosses the limit. --- packages/core/src/compaction.ts | 11 ++++-- packages/core/test/compaction.test.ts | 52 +++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/packages/core/src/compaction.ts b/packages/core/src/compaction.ts index 8b1fb1b..d4a932a 100644 --- a/packages/core/src/compaction.ts +++ b/packages/core/src/compaction.ts @@ -103,8 +103,12 @@ export interface SummarizeOptions { * * The transcript goes as is, with the prompt as one more user turn. No tool * may be called — the prompt says so and the reply is read as text; a model - * that answers with a tool call has produced an empty summary, and the caller + * that answers with a tool call has not written the handoff, and the caller * treats that as a failed compaction rather than as a history of nothing. + * That holds whatever prose came with the call: a model that carries on + * working opens with a line like "Let me check the current state first", + * and taking that line as the summary would replace the whole transcript + * with it. */ export async function summarizeTranscript(opts: SummarizeOptions): Promise { const prompt = opts.focus @@ -119,7 +123,10 @@ export async function summarizeTranscript(opts: SummarizeOptions): Promise[\s\S]*?<\/tool_call>/g, "").trim(); + if ((response.toolCalls?.length ?? 0) > 0 || body.includes("")) { + throw new Error("the model called a tool instead of writing a summary; the context was left as it was"); + } + const text = body.trim(); if (text === "") throw new Error("the model returned no summary; the context was left as it was"); return text; } diff --git a/packages/core/test/compaction.test.ts b/packages/core/test/compaction.test.ts index 8a7884e..58ec906 100644 --- a/packages/core/test/compaction.test.ts +++ b/packages/core/test/compaction.test.ts @@ -58,13 +58,25 @@ describe("the summary request", () => { expect(String(last.content)).toContain("concentrate on: the failing test"); }); - it("refuses an empty summary, and drops a tool call the model made anyway", async () => { + it("refuses an empty summary, and a reply that calls a tool", async () => { const empty = new ScriptedTransport([""]); await expect( summarizeTranscript({ transport: empty, messages: [{ role: "user", content: "t" }], tools: [] }), ).rejects.toThrow(/no summary/); - const withCall = new ScriptedTransport([`Summary here{"name":"bash","arguments":{"command":"ls"}}`]); - expect(await summarizeTranscript({ transport: withCall, messages: [{ role: "user", content: "t" }], tools: [] })).toBe("Summary here"); + // The prose that comes with a call opens a working turn; it is not a handoff. + const inline = new ScriptedTransport([`Let me check the state first.{"name":"bash","arguments":{"command":"ls"}}`]); + await expect( + summarizeTranscript({ transport: inline, messages: [{ role: "user", content: "t" }], tools: [] }), + ).rejects.toThrow(/called a tool/); + const parsed = new ScriptedTransport([{ + content: "Let me check the state first.", + rawText: "Let me check the state first.", + toolCalls: [{ function: { name: "bash", arguments: { command: "git status" } } }], + ms: 1, + }]); + await expect( + summarizeTranscript({ transport: parsed, messages: [{ role: "user", content: "t" }], tools: [] }), + ).rejects.toThrow(/called a tool/); }); }); @@ -137,4 +149,38 @@ describe("in the loop", () => { // The tool result is still there. expect(t.seen[2]!.messages.some((m) => m.role === "tool")).toBe(true); }); + + it("keeps the transcript when the model starts working instead, and compacts on the next turn", async () => { + const t = new ScriptedTransport([ + withUsage(toolCallBody("bash", { command: "ls" }), 5000), + { + content: "Let me check the current state first.", + rawText: "Let me check the current state first.", + toolCalls: [{ function: { name: "bash", arguments: { command: "git status" } } }], + ms: 1, + }, + withUsage(toolCallBody("bash", { command: "pwd" }), 5000), + "HANDOFF SUMMARY", + withUsage(doneBody("d"), 100), + withUsage(doneBody("d", { confirm: true }), 100), + ]); + const summaries: string[] = []; + const notices: string[] = []; + const r = await runLoop({ + ...base, + transport: t, + emit: (e) => { + if (e.type === "compaction") summaries.push(e.summary); + if (e.type === "notice") notices.push(e.text); + }, + compaction: { limitTokens: 4000 }, + }); + expect(r.reason).toBe("done"); + expect(notices.some((n) => n.includes("called a tool"))).toBe(true); + expect(summaries).toEqual(["HANDOFF SUMMARY"]); + // After the refused reply the transcript is whole; after the real summary it is replaced. + expect(t.seen[2]!.messages.some((m) => m.role === "tool")).toBe(true); + expect(t.seen[4]!.messages.some((m) => m.role === "tool")).toBe(false); + expect(String(t.seen[4]!.messages.at(-1)!.content)).toContain("HANDOFF SUMMARY"); + }); });