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; diff --git a/packages/core/src/compaction.ts b/packages/core/src/compaction.ts index 8b1fb1b..d9ef971 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,15 @@ export async function summarizeTranscript(opts: SummarizeOptions): Promise[\s\S]*?<\/tool_call>/g, "").trim(); + // 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(); 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..6387d15 100644 --- a/packages/core/test/compaction.test.ts +++ b/packages/core/test/compaction.test.ts @@ -58,13 +58,61 @@ 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", 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"); + }); + + 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, + }]); + await expect( + 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"); }); }); @@ -137,4 +185,46 @@ 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.", + reasoningContent: "", + 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 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); + }, + compaction: { limitTokens: 4000 }, + }); + 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"); + }); });