Skip to content

feat: InterruptedSubagentNotes capability (capability series, round 12) - #840

Open
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/interrupt-notes-capability
Open

feat: InterruptedSubagentNotes capability (capability series, round 12)#840
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/interrupt-notes-capability

Conversation

@mpfaffenberger

@mpfaffenberger mpfaffenberger commented Aug 21, 2026

Copy link
Copy Markdown
Owner

What

Twelfth entry in the capability-conversion series (#828#836, #838, #839). After round 11 declared the candidate well "genuinely dry at the seams", this round found the feature that was hiding in _run_signals.py the whole time: interrupted-sub-agent notes — the run-start injection that tells the model a Ctrl+C'd delegation existed and where to resume it.

Previously inject_interrupted_subagent_notes eagerly appended the notes to agent._message_history at run start. Now the notes ride a first-class pydantic-ai capability, InterruptedSubagentNotes, on the before_model_request seam.

How

  • New code_puppy/agents/_interrupt_notes.py

    • build_interrupt_note_observation(agent) runs at the exact old call site (run start, never nested) — drain timing, emit_info text/count, and the no-_message_history early-out are byte-identical. It packages the notes into a per-turn InterruptNoteObservation instead of mutating history.
    • InterruptedSubagentNotes (stateless dataclass, shared across turns) resolves the observation from a ContextVar installed around create_task (Deliver stream rendering via a StreamRendering capability #835/refactor: promote first-turn prompt preparation to a PromptPreparation capability #839 pattern) and splices the notes immediately before the turn's own user request — the exact position the eager append produced. The splice is anchored on the turn's built prompt payload (observation.turn_prompt), so a nested run inheriting the ambient observation can't steal the injection.
    • Key unlock (verified empirically against pydantic-ai 2.31.0): before_model_request's returned messages feed the run's state — injected messages persist into subsequent requests of the same run and into result.all_messages(). So success-path custody is automatic.
    • Injection-time mirror into agent._message_history (the steer-processor pattern) keeps the notes across streaming_retry re-entries, which re-seed from that list.
    • mirror_uninjected is the custody-boundary fallback in the run task's finally (before the interrupted-tool-call prune, matching the existing boundary order): a turn that dies before any model request still persists the notes for the next turn — exactly what the eager append gave.
  • _builder.py: capability wired before the compaction ProcessHistory, so compaction sees the notes exactly as it saw the old eager append. Ordering pinned by test.

  • _run_signals.py: old injector removed; fixed a pre-existing all____all__ typo while editing that list (the module's exports were silently never declared).

  • Sub-agent site deliberately untouched: the notes belong to the main conversation; injecting them into a sub-agent's transcript while mirroring into the main agent's history would be wrong on both ends. Pinned by test.

Bounded divergences (documented in the module docstring)

  • The model_select hook fires before any model request, so it now sees pre-note history. Notes are a few short user messages; nothing in tree routes models off them.
  • During the sliver between run start and the first model request, agent._message_history holds no notes (the old path baked them eagerly). Model-visible bytes are unaffected; every exit path converges via mirror or fallback.

Tests

19 contract tests in tests/agents/test_interrupt_notes_capability.py: exact note-text/emit parity, splice position end-to-end through a real Agent + FunctionModel, single-injection across multi-step tool runs, empty-prompt append-at-end parity, nested-run anchor guard, ContextVar shadowing, mirror idempotence, multimodal [prompt, *attachments] anchor pinning, and production-shaped runs through run_with_mcp (happy path, cancel-before-request custody, nested-run no-drain, and both nested-run note-stealing sequences from review pass 1).

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

Notes

Do not merge yet — same review protocol as the rest of the series. Like its eleven siblings, this grazes the shared capabilities=[...] block in _builder.py; whichever lands last eats a trivial rebase.

Review

Reviewed by code-puppy clone (session pr-840-review-f76d4d-52543e), two passes: REQUEST_CHANGES on pass 1 (real find: install_interrupt_note_observation(None) was a no-op, so nested runs inherited the outer observation through the ContextVar and could steal the one-shot injection — fixed at the root in ca39aad8 by making None installs shadow, plus regression tests for both exact stealing sequences). Final verdict pass 2: APPROVE, zero findings.

…es capability

The run-start eager append in _run_signals.inject_interrupted_subagent_notes
becomes a first-class pydantic-ai capability on the before_model_request
seam, delivered per-turn via a ContextVar observation built at the exact
old call site (drain + emit timing unchanged). Injection-time mirror plus
a custody-boundary fallback in the run task's finally preserve the old
persistence guarantees across retries, cancels, and crashes.
Review findings (PR #840 pass 1): installing None was a no-op, so a
nested run_with_mcp task inherited the outer turn's observation through
the ContextVar and could consume its one-shot injection state (empty
outer anchor or identical prompts). None installs now shadow; the
prompt anchor stays as defense in depth for runs that bypass
run_with_mcp. Adds regression tests for both stealing sequences plus a
multimodal [prompt, *attachments] anchor pin.
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