From 66652e60270bc0cabc3aad11ab020d934039e1f1 Mon Sep 17 00:00:00 2001 From: TaewoooPark Date: Sat, 17 Oct 2026 20:32:51 +0900 Subject: [PATCH 1/3] 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"); + }); }); From 35e2f8c19e01683a592a403760f907ac3997b013 Mon Sep 17 00:00:00 2001 From: TaewoooPark Date: Mon, 2 Nov 2026 09:18:32 +0900 Subject: [PATCH 2/3] fix(core): preserve quoted tool markers in compaction summaries --- packages/core/src/compaction.ts | 7 +++- packages/core/test/compaction.test.ts | 50 +++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/core/src/compaction.ts b/packages/core/src/compaction.ts index d4a932a..d9ef971 100644 --- a/packages/core/src/compaction.ts +++ b/packages/core/src/compaction.ts @@ -123,7 +123,12 @@ export async function summarizeTranscript(opts: SummarizeOptions): Promise 0 || body.includes("")) { + // A handoff about the parser can quote the marker itself, including in a + // source string. Exclude only that quoted token from detection; a quoted or + // fenced call with a payload still counts, as does an unfinished call. + // Keep the original body intact for the summary. + const callBody = body.replace(/(["'`])\1/g, ""); + if ((response.toolCalls?.length ?? 0) > 0 || callBody.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(); diff --git a/packages/core/test/compaction.test.ts b/packages/core/test/compaction.test.ts index 58ec906..6387d15 100644 --- a/packages/core/test/compaction.test.ts +++ b/packages/core/test/compaction.test.ts @@ -58,19 +58,33 @@ describe("the summary request", () => { expect(String(last.content)).toContain("concentrate on: the failing test"); }); - it("refuses an empty summary, and a reply that calls a tool", async () => { + it("refuses an empty summary", async () => { const empty = new ScriptedTransport([""]); await expect( summarizeTranscript({ transport: empty, messages: [{ role: "user", content: "t" }], tools: [] }), ).rejects.toThrow(/no summary/); - // 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"}}`]); + }); + + it.each([ + 'Let me check first.{"name":"bash","arguments":{"command":"ls"}}', + 'Let me check first.{"name":"bash","arguments":', + 'Let me check first.', + 'Let me check first.not JSON', + '```json\n{"name":"bash","arguments":{"command":"ls"}}\n```', + '`{"name":"bash","arguments":{"command":"ls"}}`', + 'The parser recognizes ``. Now inspect.{"name":"bash","arguments":', + ])("refuses an inline call without stripping it into a summary: %s", async (body) => { + const inline = new ScriptedTransport([`${body}`]); await expect( summarizeTranscript({ transport: inline, messages: [{ role: "user", content: "t" }], tools: [] }), ).rejects.toThrow(/called a tool/); + }); + + it("refuses a parsed call even when the server provides nonempty summary text", async () => { const parsed = new ScriptedTransport([{ content: "Let me check the state first.", rawText: "Let me check the state first.", + reasoningContent: "", toolCalls: [{ function: { name: "bash", arguments: { command: "git status" } } }], ms: 1, }]); @@ -78,6 +92,28 @@ describe("the summary request", () => { summarizeTranscript({ transport: parsed, messages: [{ role: "user", content: "t" }], tools: [] }), ).rejects.toThrow(/called a tool/); }); + + it.each([ + 'FILES AND CODE: the parser recognizes ``. NEXT STEPS: test it.', + 'FILES AND CODE: the parser recognizes ````. NEXT STEPS: test it.', + 'FILES AND CODE: the opener is "". NEXT STEPS: test it.', + "FILES AND CODE: the opener is ''. NEXT STEPS: test it.", + 'FILES AND CODE:\n```ts\nbody.includes("")\n```\nNEXT STEPS: test it.', + ])("preserves a handoff that quotes a marker instead of calling a tool: %s", async (summary) => { + for (const separated of [false, true]) { + const body = separated ? summary : `${summary}`; + const t = new ScriptedTransport([{ + content: body, rawText: body, ms: 1, + ...(separated ? { reasoningContent: "", toolCalls: [] } : {}), + }]); + expect(await summarizeTranscript({ transport: t, messages: [{ role: "user", content: "fix the parser" }], tools: [] })).toBe(summary); + } + }); + + it("does not treat a marker in reasoning as a tool call", async () => { + const t = new ScriptedTransport(["Do not emit here.HANDOFF SUMMARY"]); + expect(await summarizeTranscript({ transport: t, messages: [{ role: "user", content: "t" }], tools: [] })).toBe("HANDOFF SUMMARY"); + }); }); describe("in the loop", () => { @@ -156,6 +192,7 @@ describe("in the loop", () => { { content: "Let me check the current state first.", rawText: "Let me check the current state first.", + reasoningContent: "", toolCalls: [{ function: { name: "bash", arguments: { command: "git status" } } }], ms: 1, }, @@ -166,9 +203,14 @@ describe("in the loop", () => { ]); const summaries: string[] = []; const notices: string[] = []; + const executed: string[] = []; const r = await runLoop({ ...base, transport: t, + executor: { run: async (call) => { + executed.push(String(call.arguments.command)); + return { ok: true, output: "ORIGINAL TOOL RESULT" }; + } }, emit: (e) => { if (e.type === "compaction") summaries.push(e.summary); if (e.type === "notice") notices.push(e.text); @@ -178,7 +220,9 @@ describe("in the loop", () => { expect(r.reason).toBe("done"); expect(notices.some((n) => n.includes("called a tool"))).toBe(true); expect(summaries).toEqual(["HANDOFF SUMMARY"]); + expect(executed).toEqual(["ls", "pwd"]); // After the refused reply the transcript is whole; after the real summary it is replaced. + expect(t.seen[2]!.messages).toEqual(t.seen[1]!.messages.slice(0, -1)); 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"); From 5fc07ae5d368aa84ee8b9a9c12053a8b6d74147c Mon Sep 17 00:00:00 2001 From: TaewoooPark Date: Mon, 2 Nov 2026 09:18:32 +0900 Subject: [PATCH 3/3] chore: release 0.4.4 --- package.json | 2 +- packages/cli/package.json | 2 +- packages/cli/src/main.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 98f5cf5..d484730 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "motifcode", - "version": "0.4.3", + "version": "0.4.4", "private": true, "description": "A coding agent harness built specifically for Motif-3", "type": "module", diff --git a/packages/cli/package.json b/packages/cli/package.json index 10d1d53..4a5bff4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "motifcode", - "version": "0.4.3", + "version": "0.4.4", "type": "module", "description": "Unofficial coding agent harness built for Motif-3: a Claude Code-style terminal session over the hosted endpoint", "keywords": [ diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 8794a29..c0e65b3 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -90,7 +90,7 @@ import { expandSkillInput } from "./skill-input.js"; import { localMcpAuthTarget } from "./mcp-connect.js"; import { findCommand, parseSlash } from "./commands.js"; -const VERSION = "0.4.3"; +const VERSION = "0.4.4"; interface Args { command: string;