Skip to content

feat(agents): promote run-end telemetry to a RunTelemetry capability (after_run seam) - #844

Open
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/run-telemetry-capability
Open

feat(agents): promote run-end telemetry to a RunTelemetry capability (after_run seam)#844
mpfaffenberger wants to merge 2 commits into
mainfrom
feature/run-telemetry-capability

Conversation

@mpfaffenberger

Copy link
Copy Markdown
Owner

What

Fifteenth entry in the capability series (#828#836, #838#842). The agent_run_end payload extraction — human-readable response text plus the six-figure usage token metadata dict — moves from eager post-await bookkeeping at the tail of _runtime.run_with_mcp to a first-class capability, RunTelemetry, on pydantic-ai 2.31.0's after_run seam. This is the first claim of after_run in 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_mcp fishes response_text and usage numbers out of the returned AgentRunResult and feeds them to on_agent_run_end consumers (run-stats calibration, the kennel, token-tracking plugins). That extraction describes the result of a run — exactly what after_run exists to observe.

Scouting note (kennel'd): the round's first candidate — the main-run cancellation checkpoint (_checkpoint_cancelled_history) via wrap_run — was rejected on empirical evidence: at the wrap_run boundary the delivered CancelledError is a fresh injected exception without the RunCancelled snapshot (_translate_cancellation's _attach_to runs above the seam), and the seam's RunContext is 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 successful Agent.run() and receives the identical AgentRunResult object the caller gets back — verified under direct registration and under CombinedCapability composition.
  • It is not called when the run ends without a result (failure/cancellation) — matching the eager code, which only extracted after a successful await.
  • A turn may contain several runs (initial call + queued-steer / hook-retry follow-ups). Each capture overwrites the last, so the surviving snapshot always describes the run whose result run_with_mcp ultimately returns.

Design: identity-gated custody

RunTelemetry holds one last-capture slot (result, text, usage). The consumer calls consume(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:

  • guest wrappers that bypass capabilities (wrap_pydantic_agent swaps),
  • None results (a swallowed UsageLimitExceeded returns None from the task body),
  • stale captures from an earlier turn whose task failed before consuming.

The fallback is the same pair of helper functions the capability itself uses (extract_response_text / extract_usage_metadata, moved verbatim from _runtime — including the int(value) or None zero-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

Feature parity checklist

  • response text extraction: same branch order (None"", .data wins over .output, str fallback)
  • usage extraction: same alias chains, same int(value) or None zero→None short-circuit, same all-None dict on any failure (property access, not the deprecated callable)
  • success-only: failed/cancelled turns report "" + all-None exactly as before (after_run doesn't fire; fallback sees None/no capture)
  • UsageLimitExceeded swallowed → task returns None"" + all-None (identity gate can't match None)
  • multi-run turns: last capture == returned result (verified identity per run)
  • no behavioral divergences found — extraction is pure and runs on the same object either way; the only delta is when (run boundary vs. post-await), which is unobservable to consumers

Tests

21 contract tests in tests/agents/test_run_telemetry_capability.py:

  • extraction-helper parity (incl. the zero-token quirk pin and alias fallbacks)
  • seam contract: identical-object capture, CombinedCapability composition, read-and-clear + identity-gate semantics, result pass-through, from_spec
  • builder wiring: real build_pydantic_agent drive proving the stashed instance is the one riding the built agent
  • run_with_mcp custody: capability-owns (sentinel-proof the fallback wasn't used), guest-bypass fallback, stale-capture rejection, no-attribute fallback
  • sub-agent scope pin

Full 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.

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).
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