fix(pi): accept ordered system prompt blocks and keep four cache slots - #203
fix(pi): accept ordered system prompt blocks and keep four cache slots#203tomolom wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
systemPromptText() currently accepts any object with a text field (not just { type: 'text' } blocks), which can unintentionally change the flattened prompt content for non-text block shapes.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR fixes a runtime incompatibility between Pi’s Context.systemPrompt typing and Oh My Pi’s actual runtime shape, and resolves an Anthropic cache breakpoint budgeting error that could cause 400 rejections when cache mode is enabled.
Changes:
- Normalize
context.systemPromptto text inbuildAnthropicRequest()so ordered block arrays don’t crash.trim()and split behavior matches the joined-string prompt. - Rework
applyCacheMode()soautomatic/hybriddon’t exceed Anthropic’s max cache breakpoint count, and ensureautomaticusesttl: '1h'. - Add targeted tests for host system prompt shapes and cache breakpoint budgeting.
File summaries
| File | Description |
|---|---|
packages/pi/src/convert.ts |
Adds system prompt flattening, introduces cache_control traversal helpers, and adjusts cache mode application to respect Anthropic breakpoint limits. |
packages/pi/src/tests/convert.test.ts |
Adds regression tests for array/structured systemPrompt inputs and cache breakpoint counting across cache modes. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| for (const block of prompt) { | ||
| if (typeof block === 'string') { | ||
| parts.push(block) | ||
| continue | ||
| } | ||
| const text = (block as { text?: unknown } | null)?.text | ||
| if (typeof text === 'string') parts.push(text) | ||
| } |
There was a problem hiding this comment.
Valid, fixed in the pushed amend — the implementation was looser than its own doc comment.
systemPromptText() now requires type === 'text' before reading text, so a tool, image, or any future structured block is dropped rather than having its metadata flattened into the prompt. The comment says why the check is on type rather than on the presence of a text field.
New test drops non-text blocks instead of flattening their metadata mixes a text block, an { type: 'image', text }, a { type: 'tool_use', name, text }, a bare string and a final text block. It asserts the three real paragraphs survive and neither metadata string appears anywhere in the request. Against the previous ungated read it fails with the metadata spliced into system[]:
"text":"KEEP ONE: you are an assistant.\n\nIMAGE METADATA\n\nTOOL METADATA\n\nKEEP TWO: available tools."
packages/pi now 101 pass / 0 fail; typecheck, build and biome clean.
7c17531 to
491e425
Compare
|
@cubic-dev-ai review — the automatic review was skipped on this PR ("Automatic AI review not started after branch rewrite") because the fix for Copilot's |
@tomolom I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
No issues found across 2 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Architecture diagram
sequenceDiagram
participant Host as Host Provider (Oh My Pi)
participant C as convert.ts
participant S as splitPiSystemPrompt
participant A as Anthropic API
participant U as User
Note over Host,C: Request build with systemPrompt block array
Host->>C: buildAnthropicRequest(systemPrompt: string[])
C->>C: systemPromptText(prompt)
alt Array of strings
C->>C: Join blocks with '\n\n'
else Array with text blocks
C->>C: Extract text from { type: 'text', text } blocks
else Empty/unrecognized
C->>C: Return empty string
end
C->>S: splitPiSystemPrompt(joinedPrompt)
S-->>C: systemText + messageText
alt systemText present
C->>A: system[] with split prompt blocks
end
C->>A: messages[] with messageText
Note over C,A: Cache breakpoint budget (max 4)
C->>C: applyCacheMode(body, enabled, mode)
alt explicit mode
C->>C: Keep 4 block breakpoints (ephemeral)
else hybrid mode
C->>C: Extend 4 block breakpoints to ttl: '1h'
else automatic mode
C->>C: Clear all block breakpoints
C->>C: Add only top-level cache_control ttl: '1h'
end
C->>A: Send request with valid cache breakpoint count
alt Success (2xx)
A-->>C: Response
C-->>U: Return response
else 400 "out of extra usage"
A-->>C: Error
C-->>U: NEW: Rejected before reaching model
end
Oh My Pi types `Context.systemPrompt` as `string[]` and hands the array to
the provider, so `context.systemPrompt?.trim()` threw — `?.` does not
short-circuit a non-nullish value — and every subagent spawn failed before
any request was built. `systemPromptText()` flattens string blocks (and
`{ type: 'text', text }` blocks) on a blank line, matching how the host's
own providers flatten them, so the documentation-paragraph split
classifies the same text on either host.
Flattening alone only moved the failure: with `/claude-cache` in automatic
or hybrid mode this path added a top-level `cache_control` on top of the
four block breakpoints it had already placed, sending five cache_control
sites in one body — "A maximum of 4 blocks with cache_control may be
provided. Found 5." Each mode now places only its own breakpoints, as the
OpenCode rewrite path does: automatic clears the block breakpoints and
keeps the top-level control (now at 1h, which it never set), hybrid keeps
the four blocks at 1h with no top-level control.
Breakpoints are located by visiting the objects that can hold one — the
body root, system blocks, tools, messages and their content blocks —
rather than by walking the body recursively. Tool `input_schema`s and
replayed `tool_use.input`s are arbitrary caller data, so a nested field
named `cache_control` there is a tool parameter or argument that must not
be deleted or given a TTL.
Fixes cortexkit#201
491e425 to
f018740
Compare
|
Pushed a correctness fix to this PR — thanks to whoever prompts these bots, but this one came from re-reading my own diff. Defect: Fix: breakpoints are now located by visiting only the objects that can hold one — body root, Regression test:
|
Fixes #201.
Root cause —
systemPromptis a block list on the current hostContext.systemPromptis declaredstringin@earendil-works/pi-ai0.84.2 (dist/types.d.ts:378), but the current host declares ordered blocks:@oh-my-pi/pi-ai@18.1.14dist/types/types.d.ts:1106—systemPrompt?: string[]@oh-my-pi/pi-coding-agentdist/types/system-prompt.d.ts:196—systemPrompt: string[], "Ordered system prompt blocks. Providers should preserve entries as distinct messages/blocks."@oh-my-pi/pi-agent-coretypes.ts—AgentState.systemPrompt: string[],AgentContext.systemPrompt: string[], andagent.ts:1330builds the provider context straight from that array.context.systemPrompt?.trim()therefore threw:?.does not short-circuit a non-nullish value. Every host provider flattens this field throughnormalizeSystemPrompts()before use (anthropic.ts:3154,openai-completions.ts:2008,devin.ts:603,google-shared.ts:809), so this converter was the only one assuming a bare string.systemPromptText()acceptsstring | readonly string[]: a string passes through, blocks join on a blank line (join('\n\n')— the host's own convention), sosplitPiSystemPromptclassifies identical text on either host. The first test asserts blocks and the joined string produce byte-identicalsystem[]andmessages[], not merely that nothing throws.On the host's "preserve entries as distinct blocks" guidance, deliberately not followed here, and now documented at the function: an unrecognized prompt shape is carried in
messages[]precisely because putting the host prompt into top-levelsystem[]is the request shape Anthropic rejects with 400 "You're out of extra usage" (see the long note abovesplitPiSystemPrompt— that relocation is the entire reason this converter exists in its current form). Joining loses no text and no paragraph boundary; re-emitting the entries assystem[]blocks would reintroduce that rejection. If the intent is to honour block boundaries, that is a separate change that needs its own measurement against that 400, not a rider on this fix.A
{ type: 'text', text }element is also read for its text. The host declares strings, so that branch is only for the next drift in this same field; returning''there would silently ship requests with no host prompt at all, which is a worse failure than the crash being fixed.Second change — cache breakpoint budget
Framing first, because the issue's follow-on 400 is not independently reproduced here: I did not verify that Anthropic counts the top-level
cache_controltoward the four-block limit, and I did not reproduce the 400 with a stock generated body. What is measured is the body this path produced.The converter places four cache breakpoints itself:
addEphemeralCacheControl's last tool, lastsystem[]block and last user block, plusprependCachedPromptBlock's cached prompt block on the first user message.applyCacheModethen added a fifth — the top-levelcache_control— forautomaticandhybrid, without clearing anything first. Measured onmain(claude-sonnet-5, one user message, one tool):{"type":"ephemeral"}{"type":"ephemeral","ttl":"1h"}Two things are wrong there independently of the reported 400:
/claude-cachehelp: "automatic = top-level cache_control only; hybrid = system + messages[0] + top-level"). Both emit the explicit set plus extra.automaticreturned before the TTL walk, so the 1h mode never actually sentttl: '1h'.The OpenCode path has neither problem:
applyAutomaticCache1handapplyHybridCache1hboth callremoveAllCacheControls(parsed)before placing their own anchors, and hybrid's comment states outright that it has only four slots.applyCacheModenow follows the same contract per mode:automatic— block breakpoints cleared, top-level control only, atttl: '1h'.hybrid— the four block breakpoints extended tottl: '1h', no top-level control: the placement OpenCode's hybrid anchors (system +messages[0]+ latest) reconstruct by hand and that this converter emits natively.explicit— unchanged: the same four breakpoints, TTL only.Post-fix, every prompt shape × mode:
explicit4,hybrid4,automatic1 — at or under the documented limit in all cases.Why it belongs in this PR: the reporter's persisted state is
claudeCache: { enabled: true, mode: "hybrid" }, and theirFound 5appeared as soon as their local flatten let a request be built at all. Whatever the API-side accounting, shipping thesystemPromptfix alone would hand them a body that carries fivecache_controlsites and matches no documented mode. Treat this half as budget/contract hardening consistent with the observed 400, not as a proven cause of it.Verification
packages/pi/src/tests/convert.test.ts. Reverting onlysrc/convert.tsand re-running: 5 fail — three with the reportedTypeError: context.systemPrompt?.trim is not a function, two withexpected 4, received 5andexpected 1, received 5. With the fix: 66 pass in that file.bun run types(core build + opencode + pi + scripts) clean;packages/pibun test src/tests: 100 pass / 0 fail;bun run buildclean;biome checkclean.Also not claimed
I could not reproduce the reporter's asymmetry ("main session works, subagents crash") on 18.1.14, where
systemPromptisstring[]on every path. It is consistent with their environment (@earendil-works/pi-agent-core0.80.6, whereAgentContext.systemPromptisstring, while omp's own task executor builds the 18.xstring[]context), but I did not measure it and the fix does not depend on it.