compaction: the leading system message is request chrome, never cut (fix the no-tools request)
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.
This commit is contained in:
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user