From 6dcad551751fc1437e25eb5efe1e327b7ed688d8 Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Mon, 5 Oct 2026 23:52:19 +0300 Subject: [PATCH] compaction: the leading system message is request chrome, never cut (fix the no-tools request) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner report (OpenRouter logs, 2026-10-05): the post-compaction request arrived with NO system prompt and NO tool definitions — everything before the cut replaced by the compaction message. Root cause: pi-agent-core's Agent seeds a SystemMessage carrying the system prompt AND the tool declarations at state.messages[0] (createInitialSystemMessage — 'messages declare the provider-visible loadout'). The record never persists it (ui-stream's settleWith filters the role), but convertToLlm DOES receive it — and the cut projection sliced from firstKeptMessageId, dropping [0] from the wire: no prompt, no tools. Fix (pi's own law — verified against pi-agent-core + pi's coding-agent): the projection now carries the leading system message through AHEAD of the compaction head, exactly as pi's prepareRequest re-applies context.systemPrompt + context.tools around its compaction projection. The cut slices the RECORD's history; the chrome is never compactable payload. Loop start skips [0] when chrome is present (no duplicate); record-path callers are unchanged. Pinned: chrome-first-through-cut, exactly-once without a cut, record-path head-first. --- src/core/native-messages.ts | 19 ++++++++++-- src/server/compaction.ts | 6 +++- tests/compaction.test.ts | 60 +++++++++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 61033a2..9725fd9 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -491,8 +491,23 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { }; } } - const out: Message[] = head ? [head] : []; - for (let i = start; i < messages.length; i++) { + // THE LEADING SYSTEM MESSAGE IS REQUEST CHROME, NEVER COMPACTABLE + // PAYLOAD (the OpenRouter no-tools incident, owner 2026-10-05): + // pi-agent-core's Agent seeds a SystemMessage carrying the system + // prompt AND the tool declarations at state.messages[0] + // (createInitialSystemMessage — "messages declare the provider-visible + // loadout"). The record never persists it (ui-stream's settleWith + // filters the role), so RECORD-path callers never see it — but the + // Agent's convertToLlm DOES. The cut slices the RECORD's history; + // when [0] is the chrome it must carry through AHEAD of the + // compaction head, exactly as pi's own prepareRequest re-applies + // context.systemPrompt + context.tools around its projection. + const leading = + (messages[0] as { role?: string } | undefined)?.role === "system" ? (messages[0] as Message) : null; + const out: Message[] = []; + if (leading) out.push(leading); + if (head) out.push(head); + for (let i = leading ? Math.max(start, 1) : start; i < messages.length; i++) { const m = messages[i]!; const role = (m as { role?: string }).role; if (role === "custom") { diff --git a/src/server/compaction.ts b/src/server/compaction.ts index e10b3a2..7fbee10 100644 --- a/src/server/compaction.ts +++ b/src/server/compaction.ts @@ -143,7 +143,11 @@ export function effectiveThresholdTokens(settingTokens: number | undefined, cont // ─── the estimate over the ACTIVE projection ──────────────────────────────── /** The projected context estimate: what the next LLM call would carry — - * toLlmMessages output (cut honored) + the system prompt. chars/4. */ + * toLlmMessages output (cut honored) + the system prompt. The record + * path carries no leading system message (settleWith filters it), so + * the chrome rides as the additive term instead: prompt text here, + * tool declarations absorbed by the 16k reserve (the declarations are + * a few k and stable per session). chars/4. */ export function projectedContextTokens(messages: readonly LhMessage[], systemPromptTokens: number): number { return estimateTokens(toLlmMessages(messages as readonly AgentMessage[]) as unknown as readonly LhMessage[]) + systemPromptTokens; } diff --git a/tests/compaction.test.ts b/tests/compaction.test.ts index f7fc398..56957e9 100644 --- a/tests/compaction.test.ts +++ b/tests/compaction.test.ts @@ -283,3 +283,63 @@ describe("projectView compaction dividers", () => { assert.ok(view.indexOf(moment!) > keptIdx); }); }); + +// --------------------------------------------------------------------------- +// THE CHROME LAW (the OpenRouter no-tools incident, owner 2026-10-05): +// pi-agent-core seeds a leading SystemMessage (prompt + tool +// declarations) at state.messages[0]; the record never persists it. The +// cut slices the RECORD's history — the chrome carries through ahead of +// the compaction head, exactly as pi's prepareRequest re-applies +// context.systemPrompt + context.tools around its projection. +// --------------------------------------------------------------------------- +describe("toLlmMessages carries the leading system message (request chrome)", () => { + const chrome = { + id: "sys", + role: "system", + content: "SYSTEM PROMPT + tool declarations", + toolsAdded: [{ name: "quiz" }, { name: "plan_init" }], + timestamp: 0, + }; + const record = (): LhMessage[] => [ + { id: "old", role: "user", content: "dropped history", timestamp: 1 }, + { id: "kept", role: "user", content: "kept tail", timestamp: 2 }, + newCompactionMarker("SUMMARY", { + firstKeptMessageId: "kept", + tokensBefore: 99, + at: new Date(0).toISOString(), + }) as unknown as LhMessage, + ]; + + it("chrome FIRST, then the compaction head, then the kept tail — never dropped by the cut", () => { + const out = toLlmMessages([chrome, ...record()] as never); + assert.equal(out[0]!.role, "system"); + assert.equal((out[0] as { toolsAdded?: unknown[] }).toolsAdded?.length, 2); + assert.equal(out[1]!.role, "user"); + assert.ok((out[1]!.content as { text: string }[])[0]!.text.includes("SUMMARY")); + const textOf = (m: (typeof out)[number]): string => + typeof m.content === "string" + ? m.content + : ((m.content as { text?: string }[])[0]?.text ?? ""); + assert.ok(out.slice(2).some((m) => m.role === "user" && textOf(m) === "kept tail")); + assert.ok(!out.some((m) => m.role === "user" && textOf(m) === "dropped history")); + }); + + it("without a cut the chrome renders EXACTLY ONCE (no duplicate)", () => { + const plain = [ + { id: "u1", role: "user", content: "hello", timestamp: 1 }, + ] as unknown as LhMessage[]; + const out = toLlmMessages([chrome, ...plain] as never); + assert.equal(out.filter((m) => m.role === "system").length, 1); + assert.equal(out[0]!.role, "system"); + }); + + it("record-path callers (no chrome in the record) are unchanged: head first", () => { + const out = toLlmMessages(record()); + assert.notEqual(out[0]!.role, "system"); + const headText = + typeof out[0]!.content === "string" + ? out[0]!.content + : ((out[0]!.content as { text?: string }[])[0]?.text ?? ""); + assert.ok(headText.includes("SUMMARY")); + }); +});