Skip to content

pi-plugin: forced system prompt hides later extensions' prompt sections from the provider #649

Description

@st0nie

Problem

On Pi, the pi-plugin's before_agent_start handler 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:

Prefer changing prompt sections, selected tools, or guidelines so Pi can append a transcript delta. Returning systemPrompt, or setting forceSystemPrompt, replaces the whole prompt for that run while the transcript continues recording the structured sections. Providers receive the forced text as their leading system prompt. — Pi extension docs

Mechanics in Pi core (@earendil-works/pi-coding-agent):

  1. emitBeforeAgentStart runs before_agent_start handlers in extension load order; a handler returning systemPrompt sets currentOptions.forceSystemPrompt (dist/core/extensions/runner.js).
  2. buildSystemPromptState then 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.sections has 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:

...
"npm:@cortexkit/pi-magic-context",     // position N
...
"<any extension using opts.sections>"  // any position > N

Reproduced with a memory-index extension that injects its snapshot with opts.sections in before_agent_start (the documented API):

  • Session transcript's system message contains its section (Pi keeps recording it).
  • The model's actual context does not — the provider receives magic-context's forced text, rendered before the later handler ran. The model cannot see the section at all.
  • Moving that extension before magic-context in packages makes 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:

event.systemPromptOptions.sections.magic_context = block;
// no `return { systemPrompt }`

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 the Today'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 — processSystemPromptForCache still 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.

Activity

  1. magic-alfonso commented on Oct 10, 2026

    @magic-alfonso

    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.sections does 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.

  2. st0nie commented on Oct 10, 2026

    @st0nie
    ContributorAuthor

    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-agent distribution returns zero files (also checked Current date). Across both codebases, the only occurrence of that string anywhere is our own DATE_PATTERN — the freeze branch targets OpenCode's prompt shape and is inert on Pi: liveDate is always null, so result.systemPrompt === composedPrompt on every turn, and the forced return never rewrites a single byte. (Source comment in packages/plugin/src/hooks/magic-context/system-prompt-hash.ts confirms 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, diffSystemPromptSections from dist/core/system-prompt.js, getSystemMessageText from pi-ai) — no mocks, the exact functions Pi calls at runtime. later_ext below stands for any extension registered after magic-context that injects via opts.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_context key; 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 dispatches before_agent_start through the real extension with a second later-registered handler writing its own section, asserting both sections coexist in the shared systemPromptOptions.sections map.

  3. st0nie commented on Oct 10, 2026

    @st0nie
    ContributorAuthor

    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.sections does 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?

  4. magic-alfonso commented on Oct 10, 2026

    @magic-alfonso

    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_start has no event.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 system field was rewritten when the Magic Context block changed, and stayed stable after that. Pi records only a magic_context delta 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.

  5. st0nie commented on Oct 10, 2026

    @st0nie
    ContributorAuthor

    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_context unconditionally, which throws on OMP (no systemPromptOptions), and our own catch would 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.

  6. added
    design-approvedDesign agreed by a maintainer; a PR referencing this issue can be reviewed
    on Oct 10, 2026
  7. magic-alfonso commented on Oct 10, 2026

    @magic-alfonso

    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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    design-approvedDesign agreed by a maintainer; a PR referencing this issue can be reviewed

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions