Skip to content

fix(pi): accept ordered system prompt blocks and keep four cache slots - #203

Open
tomolom wants to merge 1 commit into
cortexkit:mainfrom
tomolom:fix/pi-system-prompt-blocks
Open

fix(pi): accept ordered system prompt blocks and keep four cache slots#203
tomolom wants to merge 1 commit into
cortexkit:mainfrom
tomolom:fix/pi-system-prompt-blocks

Conversation

@tomolom

@tomolom tomolom commented Sep 8, 2026

Copy link
Copy Markdown

Fixes #201.

Root cause — systemPrompt is a block list on the current host

Context.systemPrompt is declared string in @earendil-works/pi-ai 0.84.2 (dist/types.d.ts:378), but the current host declares ordered blocks:

  • @oh-my-pi/pi-ai@18.1.14 dist/types/types.d.ts:1106systemPrompt?: string[]
  • @oh-my-pi/pi-coding-agent dist/types/system-prompt.d.ts:196systemPrompt: string[], "Ordered system prompt blocks. Providers should preserve entries as distinct messages/blocks."
  • @oh-my-pi/pi-agent-core types.tsAgentState.systemPrompt: string[], AgentContext.systemPrompt: string[], and agent.ts:1330 builds 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 through normalizeSystemPrompts() 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() accepts string | readonly string[]: a string passes through, blocks join on a blank line (join('\n\n') — the host's own convention), so splitPiSystemPrompt classifies identical text on either host. The first test asserts blocks and the joined string produce byte-identical system[] and messages[], 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-level system[] is the request shape Anthropic rejects with 400 "You're out of extra usage" (see the long note above splitPiSystemPrompt — 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 as system[] 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_control toward 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, last system[] block and last user block, plus prependCachedPromptBlock's cached prompt block on the first user message. applyCacheMode then added a fifth — the top-level cache_control — for automatic and hybrid, without clearing anything first. Measured on main (claude-sonnet-5, one user message, one tool):

mode enabled cache_control sites in body top-level
explicit true 4
automatic true 5 {"type":"ephemeral"}
hybrid true 5 {"type":"ephemeral","ttl":"1h"}

Two things are wrong there independently of the reported 400:

  1. Neither mode emits its documented placement (/claude-cache help: "automatic = top-level cache_control only; hybrid = system + messages[0] + top-level"). Both emit the explicit set plus extra.
  2. automatic returned before the TTL walk, so the 1h mode never actually sent ttl: '1h'.

The OpenCode path has neither problem: applyAutomaticCache1h and applyHybridCache1h both call removeAllCacheControls(parsed) before placing their own anchors, and hybrid's comment states outright that it has only four slots. applyCacheMode now follows the same contract per mode:

  • automatic — block breakpoints cleared, top-level control only, at ttl: '1h'.
  • hybrid — the four block breakpoints extended to ttl: '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: explicit 4, hybrid 4, automatic 1 — 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 their Found 5 appeared as soon as their local flatten let a request be built at all. Whatever the API-side accounting, shipping the systemPrompt fix alone would hand them a body that carries five cache_control sites 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

  • 6 new tests in packages/pi/src/tests/convert.test.ts. Reverting only src/convert.ts and re-running: 5 fail — three with the reported TypeError: context.systemPrompt?.trim is not a function, two with expected 4, received 5 and expected 1, received 5. With the fix: 66 pass in that file.
  • bun run types (core build + opencode + pi + scripts) clean; packages/pi bun test src/tests: 100 pass / 0 fail; bun run build clean; biome check clean.

Also not claimed

I could not reproduce the reporter's asymmetry ("main session works, subagents crash") on 18.1.14, where systemPrompt is string[] on every path. It is consistent with their environment (@earendil-works/pi-agent-core 0.80.6, where AgentContext.systemPrompt is string, while omp's own task executor builds the 18.x string[] context), but I did not measure it and the fix does not depend on it.

Copilot AI lite review requested due to automatic review settings September 8, 2026 03:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.systemPrompt to text in buildAnthropicRequest() so ordered block arrays don’t crash .trim() and split behavior matches the joined-string prompt.
  • Rework applyCacheMode() so automatic/hybrid don’t exceed Anthropic’s max cache breakpoint count, and ensure automatic uses ttl: '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.

Comment on lines +406 to +413
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)
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@tomolom
tomolom force-pushed the fix/pi-system-prompt-blocks branch 2 times, most recently from 7c17531 to 491e425 Compare September 8, 2026 03:31
@tomolom

tomolom commented Sep 8, 2026

Copy link
Copy Markdown
Author

@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 systemPromptText finding was applied as an amend. Head is 491e425; the guard now requires type === 'text' and there is a new mixed-array test. A pass over that commit would be useful before merge.

@cubic-dev-ai

cubic-dev-ai Bot commented Sep 8, 2026

Copy link
Copy Markdown

@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 systemPromptText finding was applied as an amend. Head is 491e425; the guard now requires type === 'text' and there is a new mixed-array test. A pass over that commit would be useful before merge.

@tomolom I have started the AI code review. It will take a few minutes to complete.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Loading

Re-trigger cubic

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
@tomolom
tomolom force-pushed the fix/pi-system-prompt-blocks branch from 491e425 to f018740 Compare September 8, 2026 03:45
@tomolom

tomolom commented Sep 8, 2026

Copy link
Copy Markdown
Author

Pushed a correctness fix to this PR — thanks to whoever prompts these bots, but this one came from re-reading my own diff.

Defect: walkCacheControlled walked the body recursively and treated any nested object property named cache_control as a breakpoint. Tool input_schemas and replayed tool_use.inputs are arbitrary caller data (MCP tools especially), so a tool parameter or argument called cache_control would be deleted in automatic mode and given ttl: '1h' in hybrid/explicit — silently corrupting the tool contract. The ttl half of that predates this PR (the old addTtl recursed the same way); the deletion was introduced by my first version of this change.

Fix: breakpoints are now located by visiting only the objects that can hold one — body root, system[] blocks, tools[] entries, messages and their content blocks — which is exactly where addEphemeralCacheControl and prependCachedPromptBlock place them. Same shape as OpenCode's walkCacheControlTargets, plus tools, which this path uses and that one does not.

Regression test: leaves a tool parameter named cache_control untouched in %s runs for all three modes with a tool declaring a cache_control parameter and an assistant tool_use whose arguments include cache_control. It asserts both survive byte-identical. Against the previous recursive walk it fails in all three modes:

explicit  — expected 0 differences, received 1   (ttl written into the tool schema property)
hybrid    — expected 0 differences, received 1
automatic — expected 4 keys, received 1          (schema property deleted)

packages/pi: 104 pass / 0 fail. Typecheck, build and biome clean. Head is now f018740.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] convert.js:371 throws when systemPrompt is an array (subagent spawns fail on opus-5 / sonnet-5)

2 participants