Skip to content

feat(agents): HistoryPersistence capability — success-path history custody on after_run - #847

Open
mpfaffenberger wants to merge 3 commits into
mainfrom
feature/history-persistence-capability
Open

feat(agents): HistoryPersistence capability — success-path history custody on after_run#847
mpfaffenberger wants to merge 3 commits into
mainfrom
feature/history-persistence-capability

Conversation

@mpfaffenberger

@mpfaffenberger mpfaffenberger commented Aug 22, 2026

Copy link
Copy Markdown
Owner

HistoryPersistence capability — success-path history custody on after_run

Seventeenth in the capability-conversion series (#828#836, #838#842, #844, #845). Do not merge yet — standalone against main, like its sixteen siblings.

The feature

After each successful Agent.run() on the main conversation path, the durable history on the owning BaseAgent must absorb result.all_messages() — the complete run transcript including the trailing final response, which never passes through any before_model_request hook (there is no subsequent request to carry it).

That writeback was duplicated across seven eager call sites:

Site Spelling
_run_signals.prepare_queued_steer_injection agent._message_history = list(result.all_messages()) (only when a queued steer was pending)
_runtime._do_run hook-retry branch same, before the follow-up run
cli_runner initial-command flow agent.set_message_history(list(response.all_messages()))
cli_runner interactive turn same
cli_runner continuation loop same
cli_runner headless (execute_single_prompt) same

The conversion

code_puppy/agents/_history_persistence.py:

  • HistoryPersistenceafter_run persists list(result.all_messages()) into the agent's durable history the moment the run commits its result, with the identical AgentRunResult object the caller receives (seam contract verified empirically in the feat(agents): promote run-end telemetry to a RunTelemetry capability (after_run seam) #844 round; re-pinned here via the last_result observability slot on the caller's own result object). get_serialization_name() -> None (live agent reference). Default for_run returns self — the main conversation loop is strictly sequential, so the single slot cannot race (documented). This closes a real durability gap: a cancellation landing between run-end and the old turn-end writeback used to lose the completed run's trailing response.
  • persist_result_history(agent, result) — the shared spelling all seven sites now call. It performs the old write verbatim and unconditionally (public setter when present — the cli_runner spelling — else direct attribute assignment — the runtime spelling). Idempotent next to the capability's persist.

Deliberately NO ownership gate. The first cut of this PR had an identity-gated "skip if the capability already persisted this result" fast path; review pass 1 proved it wrong: result identity can't prove the history still holds that transcript, so a post-after_run history mutation (e.g. an agent_run_end plugin) would have survived where the old eager sites clobbered it back. The sites keep their exact historical clobber semantics; the redundant write costs one list copy — the same cost the eager code always paid. Pinned by test_fallback_clobbers_post_run_history_mutations (the reviewer's exact scenario).

Wiring (_builder.build_pydantic_agent): one instance hoisted across both construction passes (the pass-1 probe never runs), appended to capabilities=[...] (only after_run implementer in the list — position inert; reversed-order onion semantics noted in a comment for future joiners). No side-channel stash on the agent — the call sites don't need to know the capability exists.

Scope: main path only. Sub-agent history custody belongs to session persistence and the invocation layer's result-scoped bookkeeping — subagent_invocation.py gets zero new machinery (source-pinned).

Parity notes & bounded divergences

  • Call-site behavior is byte-exact. Every demoted site performs the same unconditional write it always did, via one helper.
  • Model-visible bytes are identical. Follow-up runs (queued steers, hook retries) were already seeded from an explicit eager persist of the same result. Pinned by a wire-level seeding-parity test.
  • Bounded divergence (strictly more durable): history now additionally updates at the run boundary, after every successful run. A cancellation between run-end and the old turn-end writeback now keeps the completed run's transcript. Same flavor as feat(agents): SubagentSessionPersistence capability on the wrap_run seam #842's per-attempt checkpoint divergence.
  • The task-body finally prune now sees the completed transcript. A successful run leaves no dangling tool calls, so the prune is a no-op there — pinned by test_prune_is_noop_on_a_completed_run_transcript. Cancelled/crashed runs produce no after_run, so their checkpoint/prune custody is untouched.
  • agent_run_end observers (and any mid-turn get_message_history() reader) see post-run history instead of stale pre-run history — strictly fresher; and since the sites still clobber, any observer that mutates history keeps its old post-turn fate.

Tests

19 contract tests in tests/agents/test_history_persistence_capability.py: direct seam contract (persist, pass-through, setter preference, message-less results), the post-run-mutation clobber pin, helper triage (None/message-less), real Agent.run() end-to-end (single-step, tool-cycle, CombinedCapability composition), follow-up seeding wire parity, production wiring through build_pydantic_agent (capability-tree membership + end-to-end run), sub-agent-exclusion source pin, no-inline-writebacks-remain source pin, steer-drain interplay (capability + guest), and the prune-no-op divergence pin.

Full suite: green.

Merge-order note

Touches the main capabilities=[...] block in _builder.py like most of the series — whichever of the seventeen lands last eats a trivial rebase. Disjoint from #844 (RunTelemetry, also after_run, main path): different feature, different state; if both merge, the reversed-order onion semantics documented in #845 apply (both are order-insensitive to each other — telemetry reads the result, persistence writes agent state, neither mutates the result).

Review log

  • Pass 1 (code-puppy clone): REQUEST_CHANGES — 1 BLOCKING: the identity gate could wrongly skip the sites' historical clobber after a post-after_run history mutation; my own test enshrined the bug. Fixed in d3882019 by removing the gate entirely (and the now-unneeded agent._history_persistence stash); sites are unconditional again.
  • Pass 2: APPROVE — 1 NIT (stale "fallback" wording in the test file), applied in 8cac281b.
  • Pass 3: APPROVE, zero findings.

Success-path conversation-history custody — persisting
result.all_messages() into the agent's durable _message_history — was
duplicated across seven eager call sites: the queued-steer drain, the
hook-retry branch in _do_run, and four turn-end sites in cli_runner.

Promote it onto pydantic-ai's after_run capability seam as
HistoryPersistence: the write happens exactly once per successful run,
at the moment the run commits its result, with the identical
AgentRunResult object the caller receives. The eager sites are demoted
to a shared persist_result_history guest fallback that steps aside when
the capability already persisted that exact result (identity gate) and
otherwise performs the old write verbatim.

Scope: main construction site only; sub-agent history custody stays
with session persistence (pinned by test).
…clobber

Review pass 1 (BLOCKING): result-object identity cannot prove the durable
history still holds that result's transcript. With the gate, a plugin
mutating history in agent_run_end (between the capability's after_run
persist and the turn-end site) would survive where the old eager write
clobbered it back — a silent site-behavior change the tests had
enshrined rather than caught.

persist_result_history now always writes (idempotent next to the
capability's persist — the same list copy the eager code always paid),
restoring byte-exact call-site semantics. The agent._history_persistence
stash goes with it (nothing reads it anymore); the capability keeps a
last_result observability slot pinning the after_run identity contract.
Pinned by test_fallback_clobbers_post_run_history_mutations.
The writeback helper is unconditional now, not a gated fallback — the
test names and section headers say so too.
@kvandre12-commits

kvandre12-commits commented Aug 22, 2026 via email

Copy link
Copy Markdown
Contributor

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.

2 participants