feat(agents): HistoryPersistence capability — success-path history custody on after_run - #847
Open
mpfaffenberger wants to merge 3 commits into
Open
feat(agents): HistoryPersistence capability — success-path history custody on after_run#847mpfaffenberger wants to merge 3 commits into
mpfaffenberger wants to merge 3 commits into
Conversation
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.
This was referenced Aug 22, 2026
Contributor
|
Love it! Can we use this to give clear ownership of durable conversation
history? Ie should HistoryPersistence include provider injected preambles?
I think #839 + #847 together are where we need to define that invariant
shape. I can see future providers messages mutating already persistent
canonical history and maybe a cross-capability test would show it
Cheers
(Boros dwarf equipment is crazy fun rn)
…On Sat, Aug 22, 2026, 7:26 AM Mike Pfaffenberger ***@***.***> wrote:
HistoryPersistence capability — success-path history custody on after_run
Seventeenth in the capability-conversion series (#828
<#828>–#836
<#836>, #838
<#838>–#842
<#842>, #844
<#844>, #845
<#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:
- *HistoryPersistence* — after_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 #844
<#844> round;
re-pinned here by cap.persisted(result) 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 capture slot cannot race (documented).
- *persist_result_history(agent, result)* — the guest fallback all
seven sites now call. Identity-gated (is) on the capability's
last-persisted result: capability-owned results skip the redundant rewrite;
guest wrappers that bypass capabilities, None results, and
message-less doubles all get the old write *verbatim* (public setter
when present — the cli_runner spelling — else direct attribute assignment —
the runtime spelling).
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), and stashed as agent._history_persistence (documented build
side effect, mirroring the established pattern).
*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
- *Model-visible bytes are identical.* Follow-up runs (queued steers,
hook retries) were already seeded from an explicit eager persist of the
same result; the capability writes the same value earlier. Pinned by a
wire-level seeding-parity test.
- *Bounded divergence (strictly more durable):* history now updates
after *every* successful run, not only when a follow-up or turn-end
site happened to persist it. A cancellation landing between run-end and the
old turn-end writeback used to lose the completed run's trailing response
from durable history; now it survives. Same flavor as #842
<#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 (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, same final state.
Tests
21 contract tests in tests/agents/test_history_persistence_capability.py:
direct seam contract (persist, pass-through, setter preference,
message-less results), identity gate, guest-fallback triage, real
Agent.run() end-to-end (single-step, tool-cycle, CombinedCapability
composition), follow-up seeding wire parity, production wiring through
build_pydantic_agent (stash + capability-tree membership + end-to-end
run), sub-agent-exclusion source pin, no-inline-writebacks-remain source
pin, steer-drain interplay (owned skip + guest fallback), and the
prune-no-op divergence pin.
Full suite: green (see CI); targeted neighborhood suites (tests/agents,
span naming, transform, sub-agent usage — 467 tests) pass unchanged.
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 <#844>
(RunTelemetry, also after_run, main path): different feature, different
state; if both merge, the reversed-order onion semantics documented in
#845 <#845> apply (both
are order-insensitive to each other — telemetry reads, persistence writes
agent state, neither mutates the result).
------------------------------
You can view, comment on, or merge this pull request online at:
#847
Commit Summary
- 07e9cb4
<07e9cb4>
feat(agents): promote history persistence to an after_run capability
File Changes
(6 files <https://github.com/mpfaffenberger/code_puppy/pull/847/files>)
- *M* code_puppy/agents/_builder.py
<https://github.com/mpfaffenberger/code_puppy/pull/847/files#diff-b3f86d756579dcb545622a0ae6e023fbc569c33a59742b691790320c00e83ef4>
(13)
- *A* code_puppy/agents/_history_persistence.py
<https://github.com/mpfaffenberger/code_puppy/pull/847/files#diff-4927b1f42948dafbe24ae54d9544fc8f6fef585bfdb61e7f0b642d86579e2ca3>
(115)
- *M* code_puppy/agents/_run_signals.py
<https://github.com/mpfaffenberger/code_puppy/pull/847/files#diff-dbf7848cfaa690ec44932739a2231b07e5d368762b9540171ba0ae9c24178d52>
(11)
- *M* code_puppy/agents/_runtime.py
<https://github.com/mpfaffenberger/code_puppy/pull/847/files#diff-de99bf647c8e25c083c0f793ab59f77020af91f7e4ed79118408262e427ec638>
(6)
- *M* code_puppy/cli_runner.py
<https://github.com/mpfaffenberger/code_puppy/pull/847/files#diff-85bb11e2e5f2ee59739aa25da6cbd27db6867fee6d519916357b41c66e290c3d>
(27)
- *A* tests/agents/test_history_persistence_capability.py
<https://github.com/mpfaffenberger/code_puppy/pull/847/files#diff-e8280f31f42ee336024551bea40fa41bb56570a7372144466ed66d7ccfaaf44d>
(425)
Patch Links:
- https://github.com/mpfaffenberger/code_puppy/pull/847.patch
- https://github.com/mpfaffenberger/code_puppy/pull/847.diff
—
Reply to this email directly, view it on GitHub
<#847?email_source=notifications&email_token=B5P36NXSTGLYCTSJFA2ZAXL5LF7P5A5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF42DGMZYGA2DENRWGSTHEZLBONXW5KTTOVRHGY3SNFRGKZFFMV3GK3TUVRTG633UMVZF6Y3MNFRWW>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/B5P36NSYN67GGWBBQF7OATL5LF7P5AVCNFSNUABFKJSXA33TNF2G64TZHM4TSMJQGI4DMNZTHNEXG43VMU5TKMRSGE3DMMJYHEZKC5QC>
.
Triage notifications, keep track of coding agent tasks and review pull
requests on the go with GitHub Mobile for iOS
<https://github.com/notifications/mobile/ios/B5P36NW33WHUVMJ447GLXU35LF7P5A5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF42DGMZYGA2DENRWGSTHEZLBONXW5KTTOVRHGY3SNFRGKZFFMV3GK3TUVJTG633UMVZF62LPOM>
and Android
<https://github.com/notifications/mobile/android/B5P36NTWWJX62AC3LRQMPQL5LF7P5A5CNFSNUABEM5UWIORPF5TWS5BNNB2WEL2QOVWGYUTFOF2WK43UF42DGMZYGA2DENRWGSTHEZLBONXW5KTTOVRHGY3SNFRGKZFFMV3GK3TUVZTG633UMVZF6YLOMRZG62LE>.
Download it today!
You are receiving this because you are subscribed to this thread.Message
ID: ***@***.***>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HistoryPersistencecapability — success-path history custody onafter_runSeventeenth 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 owningBaseAgentmust absorbresult.all_messages()— the complete run transcript including the trailing final response, which never passes through anybefore_model_requesthook (there is no subsequent request to carry it).That writeback was duplicated across seven eager call sites:
_run_signals.prepare_queued_steer_injectionagent._message_history = list(result.all_messages())(only when a queued steer was pending)_runtime._do_runhook-retry branchcli_runnerinitial-command flowagent.set_message_history(list(response.all_messages()))cli_runnerinteractive turncli_runnercontinuation loopcli_runnerheadless (execute_single_prompt)The conversion
code_puppy/agents/_history_persistence.py:HistoryPersistence—after_runpersistslist(result.all_messages())into the agent's durable history the moment the run commits its result, with the identicalAgentRunResultobject 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 thelast_resultobservability slot on the caller's own result object).get_serialization_name() -> None(live agent reference). Defaultfor_runreturnsself— 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_runhistory mutation (e.g. anagent_run_endplugin) 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 bytest_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 tocapabilities=[...](onlyafter_runimplementer 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.pygets zero new machinery (source-pinned).Parity notes & bounded divergences
finallyprune now sees the completed transcript. A successful run leaves no dangling tool calls, so the prune is a no-op there — pinned bytest_prune_is_noop_on_a_completed_run_transcript. Cancelled/crashed runs produce noafter_run, so their checkpoint/prune custody is untouched.agent_run_endobservers (and any mid-turnget_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), realAgent.run()end-to-end (single-step, tool-cycle,CombinedCapabilitycomposition), follow-up seeding wire parity, production wiring throughbuild_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.pylike most of the series — whichever of the seventeen lands last eats a trivial rebase. Disjoint from #844 (RunTelemetry, alsoafter_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
after_runhistory mutation; my own test enshrined the bug. Fixed ind3882019by removing the gate entirely (and the now-unneededagent._history_persistencestash); sites are unconditional again.8cac281b.