Keep mid-run system prompts when `ReinjectSystemPrompt(replace_existing=True)` replaces the client's
ReinjectSystemPrompt(replace_existing=True), which the UI adapters add in manage_system_prompt='server' mode, stripped every SystemPromptPart from the request and the persisted history on every model request. Its job is to keep a system prompt the caller didn't author (e.g. one a UI client sent) from reaching the model, but it also deleted the ones the server adds during the run: ctx.enqueue(SystemPromptPart(...)) steering, compaction summaries, harness reminders and announcements. They never reached the next request, vanished from all_messages(), and the history rewrite busted the prompt cache.
Repro (fails on main): a tool calls ctx.enqueue(SystemPromptPart('Mid-run steering')) on step 1; with the capability, step 2's request and the final history have no steering. The same happens through UIAdapter.run_stream_native in server mode.
No issue exists; this came from an internal audit.
Fix
for_run snapshots the SystemPromptParts in the history the run starts with. before_model_request strips only those, plus the configured prompt at the head:
- what this capability prepended on an earlier step;
- on a fresh conversation, the agent's own standing prompt;
- a head part with the configured prompt's content, e.g. one another reinjector already prepended this step.
Parts added during the run are kept. That includes a compaction summary folded into the head request, even on the run's first step. The default replace_existing=False path is unchanged.
Before the first request the framework re-renders dynamic system prompts (dynamic_ref) from the history as new parts, so a part sharing a dynamic_ref with a snapshotted one is stripped too. Without that, a re-rendered prompt in the history would sit next to the freshly prepended one.
Options considered
- Strip only on the run's first request: on later steps it couldn't tell its own earlier injection from a mid-run part without the same bookkeeping, and anything enqueued before the first request would still be lost.
- Use
run_idto tell pre-run messages apart: requests that history processors or compaction create during the run have norun_id, so their system prompts (e.g. a compaction summary) would still be stripped. - Strip only the standing prompt prefix (
_standing_system_prompt_count): this weakens the protection, because a client-supplied system prompt in the middle of the history would survive when the capability is used directly on untrusted history (the documented use case). It would also strip a compaction summary that becomes the new head request. The fix still uses the standing prefix, but only to find what sits in the head slot. - Rely on the adapter's
sanitize_messages(strip_system_prompts=True): that covers only the frontend messages. In server mode,replace_existingalso replaces stale prompts in the server-sidemessage_history(test_adapter_server_mode_with_non_replacing_agent_reinjector), so the capability still has to act on the initial history.
Server-side history from earlier runs: open design question
System prompts already in message_history when the run starts are still all replaced, including ones an earlier server-side run persisted. For example, SummarizingCompaction's summary, a SystemPromptPart it folds into the head request, is lost on the next run. This PR keeps that behavior unchanged; the class docstring and docs/capabilities/reinject-system-prompt.md now say so.
In the UI adapter, trusted server history and the client payload are already separate: run_stream_native concatenates message_history and sanitize_messages(...), which already strips the client's system prompts. Options for keeping server-persisted ones, all beyond this fix:
- Have the adapter's reinjector replace only the server history's standing prompt. This doesn't help the summary, which
SummarizingCompactionputs inside the standing prefix of the head request. - Treat a non-empty server
message_historyas authoritative and skip replacement (or usereplace_existing=False). This reverses the documented guarantee that a stale prompt in the server history doesn't win (test_adapter_server_mode_with_non_replacing_agent_reinjector). - Let the adapter tell the reinjector which messages came from the client, e.g. a private boundary so it only replaces the standing prompt and leaves server-persisted non-configured parts alone. This still needs a rule for "stale standing prompt vs. summary" in the same prefix, so it comes down to part provenance (marking framework-authored parts such as summaries).
- Part provenance: mark framework-authored
SystemPromptParts so the reinjector keeps them. This is the general fix, and also what the compaction-summary round-trip through UI clients needs.
Tests
In tests/test_ui.py:
- an enqueued mid-run prompt survives with the capability used directly
- the same through the UI adapter in server mode (the client and stale server prompts are still replaced)
- a fresh conversation doesn't get a duplicated system prompt across steps
- a re-rendered dynamic prompt deeper in the history doesn't get duplicated (fails without the
dynamic_refhandling) - a summary a capability folds into the head request on the first step of a run with history is kept
Checklist
- No breaking changes in accordance with the version policy.
- Any permitted compatibility impact has a label, warning, migration, release note, and exact API-check waiver.
- PR title is fit for the release changelog.
š¤ Generated with Claude Code