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:
amosblomqvist
2026-10-05 23:52:19 +03:00
parent ffc4df1938
commit 6dcad55175
3 changed files with 82 additions and 3 deletions
+17 -2
View File
@@ -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") {
+5 -1
View File
@@ -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;
}