Skip to content
Merged
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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
16 changes: 14 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,15 @@ 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();
// 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(/(["'`])<tool_call>\1/g, "");
if ((response.toolCalls?.length ?? 0) > 0 || callBody.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;
}
96 changes: 93 additions & 3 deletions packages/core/test/compaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(["</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");
});

it.each([
'Let me check first.<tool_call>{"name":"bash","arguments":{"command":"ls"}}</tool_call>',
'Let me check first.<tool_call>{"name":"bash","arguments":',
'Let me check first.<tool_call>',
'Let me check first.<tool_call>not JSON</tool_call>',
'```json\n<tool_call>{"name":"bash","arguments":{"command":"ls"}}</tool_call>\n```',
'`<tool_call>{"name":"bash","arguments":{"command":"ls"}}</tool_call>`',
'The parser recognizes `<tool_call>`. Now inspect.<tool_call>{"name":"bash","arguments":',
])("refuses an inline call without stripping it into a summary: %s", async (body) => {
const inline = new ScriptedTransport([`</think>${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 `<tool_call>`. NEXT STEPS: test it.',
'FILES AND CODE: the parser recognizes ``<tool_call>``. NEXT STEPS: test it.',
'FILES AND CODE: the opener is "<tool_call>". NEXT STEPS: test it.',
"FILES AND CODE: the opener is '<tool_call>'. NEXT STEPS: test it.",
'FILES AND CODE:\n```ts\nbody.includes("<tool_call>")\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 : `</think>${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 <tool_call> here.</think>HANDOFF SUMMARY"]);
expect(await summarizeTranscript({ transport: t, messages: [{ role: "user", content: "t" }], tools: [] })).toBe("HANDOFF SUMMARY");
});
});

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