feat(agents): promote run-end telemetry to a RunTelemetry capability (after_run seam) - #844
Open
mpfaffenberger wants to merge 2 commits into
Open
feat(agents): promote run-end telemetry to a RunTelemetry capability (after_run seam)#844mpfaffenberger wants to merge 2 commits into
mpfaffenberger wants to merge 2 commits into
Conversation
Fifteenth entry in the capability series (#828-#836, #838-#842): the agent_run_end payload extraction — response text + usage token metadata — moves from eager post-await bookkeeping in _runtime.run_with_mcp to a first-class capability on pydantic-ai 2.31.0's after_run seam (its first claim in this codebase). - New code_puppy/agents/_run_telemetry.py: RunTelemetry captures (result, text, usage) on after_run; consume() is read-and-clear and identity-gated on the exact AgentRunResult object, so guest wrappers that bypass capabilities, None results, and stale captures from failed turns all converge on the eager fallback instead of reporting the wrong run's telemetry. - Explicit-when-ours, fallback-for-guests (#838/#841/#842 pattern): the run_with_mcp tail consumes the capture when the identity matches and otherwise falls back to the same extraction helpers the capability uses, so both paths produce byte-identical payloads. - Extraction helpers moved verbatim (incl. the 'int(value) or None' zero-token quirk and alias fallback chains), now shared instead of inlined. - Scope: main path only — the sub-agent invoker keeps its separate include_usage_metrics capture (pinned by source test). Verified against pydantic-ai 2.31.0: after_run fires once per successful Agent.run() with the identical result object the caller receives, also under CombinedCapability composition; it is not called for failed or cancelled runs, matching the eager success-only extraction. 21 contract tests. Full suite 7618 passed, 0 failed.
Review finding (pass 1, NIT): 'cancelled turns never capture' overstated the seam contract — after_run IS called when a result was produced while a cancellation was pending. Document why that capture is harmless (the cancelled task yields no result, so consume(None) clears it unmatched).
This was referenced Aug 22, 2026
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.
What
Fifteenth entry in the capability series (#828–#836, #838–#842). The
agent_run_endpayload extraction — human-readable response text plus the six-figure usage token metadata dict — moves from eager post-awaitbookkeeping at the tail of_runtime.run_with_mcpto a first-class capability,RunTelemetry, on pydantic-ai 2.31.0'safter_runseam. This is the first claim ofafter_runin this codebase.Why this feature
After #842 converted the sub-agent invocation layer's around-the-run bookkeeping, the main path's mirror-image bookkeeping was the last unclaimed custody on the run boundary: the tail of
run_with_mcpfishesresponse_textand usage numbers out of the returnedAgentRunResultand feeds them toon_agent_run_endconsumers (run-stats calibration, the kennel, token-tracking plugins). That extraction describes the result of a run — exactly whatafter_runexists to observe.Scouting note (kennel'd): the round's first candidate — the main-run cancellation checkpoint (
_checkpoint_cancelled_history) viawrap_run— was rejected on empirical evidence: at thewrap_runboundary the deliveredCancelledErroris a fresh injected exception without theRunCancelledsnapshot (_translate_cancellation's_attach_toruns above the seam), and the seam'sRunContextis pre-run state (ctx.messages == []even mid-run). Cancelled-run history custody cannot move inside the run boundary with parity. Spiked before writing, saved to the kennel so no future round chases it.Seam mechanics (verified against installed pydantic-ai 2.31.0)
after_run(ctx, *, result)fires once per successfulAgent.run()and receives the identicalAgentRunResultobject the caller gets back — verified under direct registration and underCombinedCapabilitycomposition.await.run_with_mcpultimately returns.Design: identity-gated custody
RunTelemetryholds one last-capture slot(result, text, usage). The consumer callsconsume(result)— read-and-clear, returning telemetry only when the captured result is (object identity) the result about to be reported. Everything else converges on the fallback:wrap_pydantic_agentswaps),Noneresults (a swallowedUsageLimitExceededreturnsNonefrom the task body),The fallback is the same pair of helper functions the capability itself uses (
extract_response_text/extract_usage_metadata, moved verbatim from_runtime— including theint(value) or Nonezero-token quirk, which is pinned by a dedicated test, and the provider alias fallback chains). Both paths produce byte-identical payloads; ownership only changes where the extraction runs, not what it produces. This is the #838/#841/#842 explicit-when-ours, fallback-for-guests split with the cheapest ownership check in the series:is.Wiring
_builder.build_pydantic_agenthoists one instance shared by the probe + final construction passes (series precedent from Promote per-model settings to a PerModelSettings capability #831/refactor: promote system-prompt delivery to an AssembledInstructions capability #832/Promote model delivery to a ResolvedModel capability #833; the probe never runs, so its slot stays empty) and stashes it asagent._run_telemetry— documented alongside the other build side effects in the docstring.run_with_mcpsuccess tail consumes-or-falls-back; init uses the sharedempty_usage_metadata()factory instead of a hand-rolled literal.include_usage_metricscapture; pinned by a source test assertingsubagent_invocation.pynever mentionsRunTelemetry(mirrors feat(agents): SubagentSessionPersistence capability on the wrap_run seam #842's scope pin, in reverse).Feature parity checklist
None→"",.datawins over.output,strfallback)int(value) or Nonezero→Noneshort-circuit, same all-Nonedict on any failure (property access, not the deprecated callable)""+ all-Noneexactly as before (after_rundoesn't fire; fallback seesNone/no capture)UsageLimitExceededswallowed → task returnsNone→""+ all-None(identity gate can't matchNone)Tests
21 contract tests in
tests/agents/test_run_telemetry_capability.py:CombinedCapabilitycomposition, read-and-clear + identity-gate semantics, result pass-through,from_specbuild_pydantic_agentdrive proving the stashed instance is the one riding the built agentrun_with_mcpcustody: capability-owns (sentinel-proof the fallback wasn't used), guest-bypass fallback, stale-capture rejection, no-attribute fallbackFull suite: 7618 passed, 0 failed (up from 7617 on the previous branch — the 21 new tests net against the moved helpers' old coverage).
Series context
Fifteen capability PRs (#828–#836, #838–#842, this one) now share the
capabilities=[...]blocks. This one touches only the main builder block, so it collides mildly with the main-path subset. Rebases remain trivial in any merge order.Not merged — awaiting human review, per standing orders.