Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/core/src/compaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const prompt = opts.focus
Expand All @@ -119,7 +123,10 @@ export async function summarizeTranscript(opts: SummarizeOptions): Promise<strin
// Reasoning is separated the way the loop separates it: by the server when
// it does, and by the scrubber when the body carries the think block itself.
const body = response.reasoningContent !== undefined ? response.content : splitThinking(response.rawText).content;
const text = body.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, "").trim();
if ((response.toolCalls?.length ?? 0) > 0 || body.includes("<tool_call>")) {
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;
}
52 changes: 49 additions & 3 deletions packages/core/test/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(["</think>"]);
await expect(
summarizeTranscript({ transport: empty, messages: [{ role: "user", content: "t" }], tools: [] }),
).rejects.toThrow(/no summary/);
const withCall = new ScriptedTransport([`</think>Summary here<tool_call>{"name":"bash","arguments":{"command":"ls"}}</tool_call>`]);
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([`</think>Let me check the state first.<tool_call>{"name":"bash","arguments":{"command":"ls"}}</tool_call>`]);
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/);
});
});

Expand Down Expand Up @@ -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),
"</think>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");
});
});
Loading