Add `on_change='append'` to preserve dynamic instruction prefixes

Merged
DS
12d ago
+13155-117

This pull request was posted by Codex Desktop using gpt-6-astra on behalf of David.

Changing a todo or memory instruction currently rewrites the request prefix. This implements the opt-in append-on-change policy settled in #7900, limited to #7237's always-on instruction sources.

The harness companion #815 is merged; the compatibility check passes.

[!WARNING]
Compatibility impact. Agents with an on-demand capability (the load_capability catalog), or with Harness SubAgents or RepoContext, now get append-on-change instructions without opting in:

  • their first request's prefix wraps that block in <context id="capability:…">…</context>, followed by the one sentence "A later <context> element with the same id replaces the earlier one and stays in effect until replaced again.", so prompts cached before upgrading are rewritten once;
  • all_messages() gains an InstructionBaselinePart on the request that opens each window (append-mode blocks in full, other blocks as an InstructionDigest), and an InstructionDeltaPart when the block changes between runs of a conversation;
  • prepared messages (what a FunctionModel or MCPSamplingModel receives) carry the tagged block in ModelRequest.instructions.

This relies on the version policy's allowance for adding new message parts. Migration: update snapshot tests of messages or instructions for agents using these capabilities; handle the two new ModelRequestPart members in code that matches the union exhaustively (custom serializers or renderers using assert_never), skipping InstructionBaselinePart and rendering InstructionDeltaPart through Model.prepare_messages. There is no opt-out for these framework-owned blocks. The API compatibility check passes without a waiver.

New public surface

  • InstructionPart.on_change and on_change= on Agent.instructions / Capability.instructions.
  • InstructionDeltaPart(id, content, base_hash): a block's complete replacement (or None to withdraw it) in trusted history, with the hash of the text it replaces so a model can send it as a diff.
  • InstructionBaselinePart(instructions): the ordered instruction list recorded on the request that opens a window: append-mode blocks as InstructionParts in full, rewrite-mode blocks as an InstructionDigest(id, dynamic, content_hash). An empty one ends the window. It's never sent to a model.
  • InstructionDigest: a rewrite-mode block as a baseline records it, enough to tell whether it changed; so a long block rewritten every request isn't stored again each time.

No new fields on ModelRequest. A window opens on the first request with an append-mode block, and again whenever the prefix has to move anyway (a rewrite-mode block changed, a block's policy changed, compaction), with current values. So every request in a window is sent its baseline's prefix: a resumed suspended turn and MCPSamplingModel read the structure from the baseline, and a turn without append-mode blocks resumes exactly as on main.

User-visible behavior

With @capability.instructions(name='state', on_change='append'), a state change from A to B produces:

- request 2 prefix: … state: B
+ request 2 prefix: … <context id="capability:todos:state">state: A</context> + sentence
+ request 2 history tail: <context id="capability:todos:state">state: B</context>

Unchanged values add nothing, and None withdraws the block with a tagged withdrawal. When a change is shorter as a unified diff against the text the model already has (a one-line edit to a long memory file), it is sent as <context-diff id="…"> instead. History always records the full replacement; if a history processor dropped an earlier change, the base hash no longer matches and the model gets the full replacement. Closing </context>, </context-diff> and </system> tags in block text are escaped so content can't end its element.

The last tagged block in the prefix is followed once by "A later <context> element with the same id replaces the earlier one and stays in effect until replaced again.", the same on every delivery path. Updates arrive as native mid-conversation system messages where supported, otherwise <system>-wrapped in a user turn, where a withdrawal also says that it stays in effect.

History stores only the content. Each model renders its own path in prepare_request/prepare_messages, so each FallbackModel member gets its own wording, and a given model's prefix is byte-identical on every request. A baseline block from a source namespace this version doesn't know starts a new window, because its id can't be rendered. When a rewrite-mode block changes, the request opens a new window with the append-mode blocks' current values, since the prefix moves at that point regardless. Fresh agents reconstruct state from serialized history; compaction or baseline loss starts a new window. UI history must be persisted server-side to retain the baseline.

Deferred capabilities (#7236), legacy dynamic system prompts (#10521), realtime append delivery (#7303), and turning append mode on by default for user-written blocks in V3 (#10520) are outside this change. sanitize_messages(strip_system_prompts=True) strips client-submitted instruction metadata along with system prompts. Durable callbacks reading external state use recorded durable operations.

Verification

Proving tests: provider wire-prefix recordings, post-tool mutation recordings, and Temporal replay.

The tests cover source identities, changes and withdrawal, serialization, compaction, sanitization, suspended resume, and actual provider request prefixes. Recordings exercise streaming and non-streaming <system>-wrapped fallback delivery (DeepSeek through OpenRouter, and Claude Sonnet 4.5), native inline delivery (Claude Opus 5), and OpenAI Responses, including previous_response_id. The cassette checker reconstructs stateful Responses input before assessing continuation prefixes.

On the fallback path, an update in a tool loop lands as <system> text right after the tool_result blocks in the same user message. Anthropic documents that pattern as a trigger for empty responses, so I measured it live. Claude Haiku 4.5, Sonnet 4.5 and Sonnet 4.6 all take the fallback path; Haiku 4.5 rejects a mid-conversation system role outright. Across 5- and 12-step tool loops and 4-run conversations replaying earlier loops, append mode and rewrite mode both produced 0 empty responses and 0 "Please return text or call a tool" retries (runs per mode: Haiku 4.5 20, Sonnet 4.6 14, Sonnet 4.5 6).

Claude Opus 5 on the native inline path now passes every recorded case without xfail. Its reasoning_extraction refusals had two sources. The earlier prose framing refused on the first update. Separately, repeating the same user prompt while earlier reasoning is in context refuses in rewrite mode too, so the wire-prefix test now asks a different question each turn.

Decisions (DouweM, 2026-10-15)

Settled after a live matrix of about 5,900 requests across 15 model/delivery-path combinations (Claude Opus 5, Opus 5.5, Fable 5.1, Sonnet 5.5 native; Haiku 4.5, Sonnet 4.6 fallback; GPT-5.6 Responses and Chat; Grok 4.7; DeepSeek and Kimi K3 via OpenRouter; Gemini 3.8 Flash and Gemini 4 Argon fallback):

  • Keep this wording, with one sentence. Tagged baseline, bare tagged updates: no failure attributable to wording on any model (DeepSeek on the fallback path 14/14 conversations; Fable 5.1 14/14, no refusals). Narrating the update instead (The context under "…" changed and supersedes…) made Fable 5.1 refuse in 4/14 conversations. An untagged baseline got updates ignored (Gemini 4 Argon 0/8, Sonnet 4.6 kept the old value, Fable 5.1 refused 8/14). <system> vs <system-update> as the fallback wrapper made no difference, so <system> stays.
  • Text diffs for long blocks. A 3-hunk unified diff against a 3.8k-token memory file was applied correctly by 12 models × 4 wordings, 1,008/1,008 answers, including a removed fact. A catalog diff (one entry removed, one added) scored 864/864. Lists such as the deferred capability catalog stay one block diffed as text.
  • Content only in history, rendered per model; wording is pinned by snapshot tests per path.
  • One sentence, once (DouweM, 2026-10-16): the persistence wording after the last tagged block, on every path. In the matrix the native path took it 5/5 conversations on Opus 5, Opus 5.5, Fable 5.1 and Sonnet 5.5. A follow-up live probe on the final rendering (2 append-mode blocks changing independently, one withdrawn, 5 turns, 3 conversations per model, a different parcel each turn) followed every update: Opus 5.5 15/15, Fable 5.1 15/15, Sonnet 4.6 (fallback path) 15/15, GPT-5.6 Responses 15/15, no refusals or errors.
  • Slim baseline (DouweM, 2026-10-16): rewrite-mode blocks are recorded as an InstructionDigest; their text comes from the current instruction parts (the request's own, and the projection's request parameters).
  • Resume rebuilds from history (DouweM, 2026-10-19): a suspended turn's continuation splits its recorded instructions back into the window baseline's blocks at the blank lines that join them, each verified against its recorded content (append-mode) or digest hash (rewrite-mode), so it's byte-identical to the turn even if the instructions changed while it was suspended. Instruction functions aren't re-run on resume, as on main; if no split matches exactly, it resends the recorded text as one part, as on main.
  • Baseline as a part, not ModelRequest fields (DouweM, 2026-10-16): InstructionBaselinePart replaces the earlier ModelRequest.instruction_baseline/instruction_parts and InstructionBaselineEntry; no state in __pydantic_ai__ metadata.
  • Framework-owned blocks append by default. User-authored blocks stay opt-in.
  • Cache: the request carrying an update read 100% of the prefix on Anthropic (native and fallback) and OpenAI, 92–100% on DeepSeek.

Existing users

User-authored instructions keep on_change='rewrite'; callback frequency and dynamic cache placement are unchanged.

Compatibility impact: instructions Pydantic AI and Pydantic AI Harness compute themselves now append their changes, so their prefix text changes once: they are wrapped as <context id="…">…</context>, with the one supersession sentence after the last of them, from the first request. Blocks affected:

BlockKeyChanges when
Deferred capability catalog (load_capability)capability:deferred_capabilitiesa deferred capability (e.g. a workspace skill) is added or removed between runs
Harness SubAgents listingcapability:sub_agents (or the capability's id)sub-agent definitions in the workspace change
Harness RepoContext context filescapability:repo_context (or the capability's id)an AGENTS.md/CLAUDE.md is edited

Other built-in and harness capabilities return static text, or (Planning, Memory, CodeMode's dynamic catalog, ManagedPrompt, Playwright, BackgroundTools) don't change within a conversation in ways this covers, so they're left as they are. Realtime sessions and loaded deferred capabilities fall back to rewriting for framework-owned blocks without a warning.

Release note: "Instructions computed by Pydantic AI and Harness (the deferred capability catalog, SubAgents, RepoContext) now append changes instead of rewriting the cached prompt; their first request is tagged with <context id=…>, so prompts cached before upgrading are rewritten once."

  • Closes #7900
  • Part of #7237; does not close its deferred-capability work.

Follow-ups

  • CodeMode's dynamic_catalog block has no id, so it still rewrites: give it one and append by default (#8965).
  • Append mode by default for user-written blocks in V3 (#10520); legacy system_prompt(dynamic=True) (#10521); realtime (#7303); deferred capability instructions (#7236).

Checklist

  • Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it.
  • 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.