Skip to content

refactor: promote first-turn prompt preparation to a PromptPreparation capability - #839

Open
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/prompt-preparation-capability
Open

refactor: promote first-turn prompt preparation to a PromptPreparation capability#839
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/prompt-preparation-capability

Conversation

@mpfaffenberger

@mpfaffenberger mpfaffenberger commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Eleventh in the capability series (#828#836, #838). This one converts the last feature still smuggling itself past pydantic_agent.run() inside the prompt argument: the first-turn system-prompt fold for claude-code-style models.

The feature

Providers like claude-code OAuth pin their own instruction string, so code_puppy delivers the real system prompt (+ puppy rules) folded into the first user message. Previously _runtime._should_prepend_system_prompt (and the twin prepare_prompt_for_model call in subagent_invocation) baked that fold into the prompt string before the run started — invisible to the capability system, and baked into recorded history as a side effect.

The conversion

New code_puppy/agents/_prompt_preparation.py:

  • build_prompt_observation() computes the raw -> prepared substitution once per turn, at the exact old call site — identical hook arguments (prepare_model_prompt / get_model_system_prompt), identical timing, identical call count (non-qualifying turns fire zero hooks, as before).
  • PromptPreparation (stateless AbstractCapability):
    • before_model_request — swaps the conversation's first user message raw -> prepared (send side; fresh copies, positional + content-guarded, attachments payloads handled),
    • after_run — mirrors the same swap into the recorded history in place (persist side), so bytes at rest match the old baked-in prompt.
  • Per-turn state rides a ContextVar observation installed around the run task (Deliver stream rendering via a StreamRendering capability #835's pattern), so:
    • streaming-retry re-entries that resume from a checkpointed raw first message still fold at request time — model-visible bytes stay byte-identical to the baked behaviour on every path,
    • nested sub-agent runs shadow the outer observation (pinned by test).
  • Ordering: PromptPreparation sits first in both capabilities=[...] blocks so compaction sees the folded first message exactly as it did when the fold arrived pre-baked (pinned by test).
  • Core mirrors at its state-custody boundaries — the main run-task finally and the sub-agent partial-session save — so cancellation/crash checkpoints also persist the prepared form (idempotent; refactor: promote steer injection to a first-class pydantic-ai capability #828's injectable-mirror precedent).

Feature-parity notes (the honest bits)

  • Empty-prompt degenerate case: payload building drops empty prompts, so there's no user part to anchor a request-time swap on. The fold is baked eagerly for that case, exactly as before.
  • Resumed sub-agent sessions: prepare_prompt_for_model(prepend_system_to_user=False) leaves the user prompt untouched under the plugin contract (verified against the claude_code_oauth plugin), so the eager pass-through stays — an identity transform either way.
  • Sub-agent session saves (initial_prompt=) now pass the prepared string explicitly, matching what the old code saved.
  • One bounded divergence: during a turn, in-memory checkpoints hold the raw first message until a custody boundary mirrors it (the old code baked it up front). Model-visible bytes are unaffected (the swap applies per request); the window closes at run end / cancellation / crash-save.

Tests

19 contract tests in tests/agents/test_prompt_preparation_capability.py, including wire + stored-history parity against the old baked-prompt behaviour through a real Agent.run(), checkpoint-resume folding, compaction-ordering, and nested-observation shadowing.

Full suite: 7616 passed, 0 failed (28 skipped, 1 xpassed).

Review

code-puppy clone, two passes: APPROVE pass 1 with 2 non-blocking findings + 1 nit (both findings applied in e5623438: exact checkpoint-copy semantics pinned, production-shaped custody-boundary tests for the main-task finally and the sub-agent partial save; full _run_with_mcp_impl integration test declined — would mostly test the mocks, YAGNI). Final verdict pass 2: APPROVE, zero new findings.

…n capability

The claude-code-style system-prompt fold (real system prompt delivered
inside the first user message because the provider pins its own
instructions string) was baked into the prompt argument by
_runtime._should_prepend_system_prompt before pydantic_agent.run() ever
saw it. Promote it to a first-class pydantic-ai capability:

- code_puppy/agents/_prompt_preparation.py: build_prompt_observation()
  computes the raw -> prepared substitution once per turn at the exact
  old call site (identical hook arguments, timing, and call count);
  PromptPreparation swaps it onto the conversation's first user message
  at before_model_request (send side) and mirrors the swap into recorded
  history at after_run (persist side). Per-turn state rides a ContextVar
  installed around the run task, so retries and follow-up runs within
  the turn keep folding even from checkpointed raw history, and nested
  sub-agent runs shadow the outer observation.
- Both construction sites list PromptPreparation FIRST so compaction
  sees the folded first message exactly as it did when the fold was
  baked into the prompt string.
- Core mirrors at its state-custody boundaries (main run-task finally,
  sub-agent partial-session save) so bytes at rest stay byte-identical
  to the old behaviour on every exit path, including cancellation.
- Degenerate empty-prompt folds are still baked eagerly (payload
  building drops empty prompts, so there is no user part to anchor the
  request-time swap on); resumed sub-agent sessions keep the eager
  pass-through, which is an identity transform under the prepend flag
  contract.

17 contract tests, including wire + history parity against the old
baked-prompt behaviour through a real Agent.run().
…iew findings 1-2)

- test_checkpoint_resume_rerun_still_folds now asserts the exact
  post-run state: recorded history mirrored by after_run, caller's
  checkpoint list untouched (pydantic-ai copies supplied history) --
  which is precisely why the core custody-boundary mirror exists.
- New production-shaped tests replicating the two core custody
  boundaries: the main run-task finally (mirror then prune) and the
  sub-agent partial-save except block.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant