Keep mid-run system prompts when `ReinjectSystemPrompt(replace_existing=True)` replaces the client's

Merged
DO
16d ago
+224-18

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_id to tell pre-run messages apart: requests that history processors or compaction create during the run have no run_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_existing also replaces stale prompts in the server-side message_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:

  1. Have the adapter's reinjector replace only the server history's standing prompt. This doesn't help the summary, which SummarizingCompaction puts inside the standing prefix of the head request.
  2. Treat a non-empty server message_history as authoritative and skip replacement (or use replace_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).
  3. 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).
  4. 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_ref handling)
  • 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