Repository navigation
pi-plugin: forced system prompt hides later extensions' prompt sections from the provider #649
Description
Activity
Thanks for the detailed report and the PR. The load-order effect is plausible: when the handler returns
systemPrompt, Pi treats the whole prompt as forced, and a section added by a later extension wouldn't be rendered.Before changing it, I want to check two things on real Pi and Oh My Pi hosts, because this path also keeps the provider's prompt cache stable:
- whether either host's system prompt has a date line that our cache code freezes (the plugin freezes a
Today's date:line; if Pi's prompt has no matching line, that part is inert there); - what switching to
systemPromptOptions.sectionsdoes to the bytes sent for existing sessions, both after an upgrade and when the Magic Context block changes mid-session.
I'll follow up here with what we find.
- whether either host's system prompt has a date line that our cache code freezes (the plugin freezes a
Thanks for looking at it. I can supply evidence for both checks — I ran them against the installed Pi host while preparing the PR.
1. Date line in the host prompt
Pi 1.1.0: no date line exists.
grep -r "Today's date" dist/over the entire installed@earendil-works/pi-coding-agentdistribution returns zero files (also checkedCurrent date). Across both codebases, the only occurrence of that string anywhere is our ownDATE_PATTERN— the freeze branch targets OpenCode's prompt shape and is inert on Pi:liveDateis always null, soresult.systemPrompt === composedPrompton every turn, and the forced return never rewrites a single byte. (Source comment inpackages/plugin/src/hooks/magic-context/system-prompt-hash.tsconfirms the origin: "OpenCode 2 stores instruction updates ("Today's date is now: ...")".)I don't have an Oh My Pi host locally, so I can't grep that one — if OMP's prompt does carry a matching line, note that forcing there still breaks later extensions' sections on every date-drift turn, so a host-side date knob would be the better long-term answer regardless.
2. Bytes sent when switching to sections
Tested with a script that drives Pi 1.1.0's own prompt machinery (
buildSystemPromptSections,buildSystemPromptState,diffSystemPromptSectionsfromdist/core/system-prompt.js,getSystemMessageTextfrompi-ai) — no mocks, the exact functions Pi calls at runtime.later_extbelow stands for any extension registered after magic-context that injects viaopts.sections:A. old behaviour (forced prompt): provider sees magic-context block: true provider sees later_ext section: false ← the bug, reproduced with Pi's own code B. new behaviour (sections): provider sees magic-context block: true provider sees later_ext section: true C. mid-session block change (new): changed keys in transcript delta: [ "magic_context" ] leading system prompt re-sent: false D. stable turn (new): diffSystemPromptSections result: undefined leading prompt byte-identical: true E. upgrade boundary: one-time delta keys: [ "magic_context" ]Reading per scenario:
- Stable turns (the common case): zero byte changes. The guidance block is byte-stable within a session, Pi's section diff returns
undefined, the provider-visible prefix stays identical — exactly the cache behaviour the forced path was protecting (D). - Magic Context block changes mid-session (guidance epoch switch): Pi appends a mid-transcript system delta carrying only the
magic_contextkey; the leading system prompt is not re-sent (C). Under the forced path this same event replaces the entire leading prompt — total prefix invalidation. Sections are strictly better here. - Upgrade boundary (one time): Pi kept recording structured sections in the transcript even while forcing, so after upgrade exactly one delta is appended (
+magic_context) and previously-hidden sections become visible (E). The unavoidable one-time change is the MC block moving from the forced leading-prompt suffix into a structured section — inherent to any fix for this bug, and after it the prefix is stable going forward.
Tests on the PR cover both review findings: the forced-prompt ban is a regex (
/return\s*\{\s*systemPrompt\b/), and a behavioural test dispatchesbefore_agent_startthrough the real extension with a second later-registered handler writing its own section, asserting both sections coexist in the sharedsystemPromptOptions.sectionsmap.- Stable turns (the common case): zero byte changes. The guidance block is byte-stable within a session, Pi's section diff returns
Thanks for the detailed report and the PR. The load-order effect is plausible: when the handler returns
systemPrompt, Pi treats the whole prompt as forced, and a section added by a later extension wouldn't be rendered.Before changing it, I want to check two things on real Pi and Oh My Pi hosts, because this path also keeps the provider's prompt cache stable:
- whether either host's system prompt has a date line that our cache code freezes (the plugin freezes a
Today's date:line; if Pi's prompt has no matching line, that part is inert there); - what switching to
systemPromptOptions.sectionsdoes to the bytes sent for existing sessions, both after an upgrade and when the Magic Context block changes mid-session.
I'll follow up here with what we find.
any idea?
- whether either host's system prompt has a date line that our cache code freezes (the plugin freezes a
Thanks, that matches most of what we found. We ran the checks against real hosts with a mock provider that captures each request body: Pi 0.87.1 and 1.1.0, and Oh My Pi 18.2.6 and 18.8.7.
- The bug is confirmed on both Pi versions. A section added by an extension that loads after Magic Context never reaches the provider; with sections-based injection, both extensions' text arrives in either load order.
- No date line. Neither host's default system prompt has the
Today's date:line our cache code freezes, so dropping the forced prompt costs nothing there. - Oh My Pi is different. On both Oh My Pi versions,
before_agent_starthas noevent.systemPromptOptions, so the PR's assignment throws inside our handler. Our handler catches the error, which means Oh My Pi requests would go out with no Magic Context guidance at all. Oh My Pi needs to keep a prompt-return path. - Upgrade cost. Existing Pi sessions change their leading prompt once on upgrade, and unchanged turns are byte-stable afterwards, as you found.
- When the block changes mid-session. In the captured request bodies on the Anthropic path, the provider's leading
systemfield was rewritten when the Magic Context block changed, and stayed stable after that. Pi records only amagic_contextdelta in the transcript, but the bytes sent to the provider still changed at the start of the request. So we can't claim that part avoids a prefix change, at least on that provider.
We'll follow up with the approach for the fix.
- added a commit that references this issue
on Oct 10, 2026 Thanks for running the full host matrix — that's a much stronger verification than my single-host checks. Replies per finding:
Bug confirmed on Pi 0.87.1 / 1.1.0; no date line on either host — matches what I found. Good to have the OMP date-line data point too.
Oh My Pi regression — owned, and fixed. You're absolutely right: my PR assigned
event.systemPromptOptions.sections.magic_contextunconditionally, which throws on OMP (nosystemPromptOptions), and our owncatchwould then turn that into requests going out with no guidance block at all — strictly worse than the status quo there. I've updated the PR with capability-detected injection:const hostSections = event.systemPromptOptions?.sections; if (block && hostSections) { hostSections.magic_context = block; } // ... forced `return { systemPrompt }` only on the no-sections branch
Pi gets composable sections; OMP keeps the forced return as its only injection path. The updated tests cover both host shapes through the real extension: a Pi-shaped event (asserting a later-registered extension's section coexists with ours in the shared map) and an OMP-shaped event without
systemPromptOptions(asserting the handler doesn't throw and still returns a forced prompt carrying the block). A source-contract test additionally requires every forced return in the handler to sit on the no-sections branch, so a future refactor can't quietly reintroduce either failure mode.Mid-session block change — conceded, my "strictly better" claim was wrong. Your captured Anthropic-path bodies show the provider's leading system field gets rewritten when the block changes even with sections (Pi collapses the transcript delta into the request head). The accurate claim is no worse than the forced path there — forced also rewrites the whole leading prompt on every block change — with unchanged turns byte-stable under both. I've corrected the PR description.
Upgrade cost — agreed: one-time leading-prompt change for existing sessions, byte-stable afterwards.
The PR (branch
st0nie:fix/pi-system-prompt-section-injection, single commit) is updated with all of the above. Happy to reshape it further once you share the approach you have in mind — or feel free to take the design in whatever direction fits; the issue has the full mechanics either way.- added a commit that references this issue
on Oct 10, 2026 - addeddesign-approvedDesign agreed by a maintainer; a PR referencing this issue can be reviewedDesign agreed by a maintainer; a PR referencing this issue can be reviewed
on Oct 10, 2026 - added a commit that references this issue
on Oct 10, 2026 Thanks again for the fix in #648. It is merged on master and will ship in the next release.
We checked it on real hosts with a mock provider capturing each request:
- Pi 0.87.1 and 1.1.0: with a probe extension loaded both before and after Magic Context, both the probe's section and Magic Context's guidance reach the model on every turn, and the system prompt stays byte-identical across turns.
- Oh My Pi 18.2.6 and 18.8.7: no sections API, so Magic Context keeps its existing prompt path. Its guidance is present and unchanged on every turn, with no handler errors.
- Pi 0.83.0 (before the sections API): unchanged behaviour.
Problem
On Pi, the pi-plugin's
before_agent_starthandler injects the Magic Context guidance block by returning{ systemPrompt }, i.e. forcing the whole system prompt to opaque text. Per Pi's documented semantics, a forced prompt replaces the entire prompt for the run while the transcript keeps recording structured sections:Mechanics in Pi core (
@earendil-works/pi-coding-agent):emitBeforeAgentStartrunsbefore_agent_starthandlers in extension load order; a handler returningsystemPromptsetscurrentOptions.forceSystemPrompt(dist/core/extensions/runner.js).buildSystemPromptStatethen takes the forced branch —return { content: input.forceSystemPrompt }— an opaque string with no sections (dist/core/system-prompt.js).Consequence: any extension registered after magic-context that follows Pi's own recommendation and injects via
systemPromptOptions.sectionshas its section diffed into the transcript but never rendered into the provider request. Its system-prompt content silently never reaches the model.Concrete repro
~/.pi/agent/settings.json→packages:Reproduced with a memory-index extension that injects its snapshot with
opts.sectionsinbefore_agent_start(the documented API):packagesmakes the section appear — load-order-dependent breakage of the host contract, not a bug in the affected extension.Any third-party extension using the recommended sections API and loading after magic-context breaks the same way.
Proposed design
Inject the guidance block through the structured sections API instead of forcing:
Why forcing is unnecessary on Pi: the forced return is only load-bearing for
processSystemPromptForCache's date freezing, which rewrites prompt text sections cannot express. But theToday's date:line it freezes is an OpenCode artifact (packages/plugin/src/hooks/magic-context/system-prompt-hash.ts: "OpenCode 2 stores instruction updates"); Pi's system prompt has no such line, so on Pi the frozen prompt always equals the composed prompt and forcing buys nothing.Cache stability: unchanged or better. The block is byte-stable within a session, so Pi's section diffing produces no transcript delta and the provider-visible prefix stays identical; when content genuinely changes, Pi appends a mid-transcript system delta instead of replacing the leading prompt.
Hash bookkeeping: untouched —
processSystemPromptForCachestill runs on the composed reference prompt;hashChanged→ refresh signals work as before.Scope: pi-plugin only. The OpenCode plugin (
packages/plugin) has its own injection path and is not affected by this change.Implementation ready in PR #648.