Skip to content

Commit 7a30e6e

Browse files
declan-scaleclaude
andcommitted
feat(pydantic-ai): optional on_result callback to expose run result for usage capture
Adds an `on_result: Callable[[AgentRunResultEvent], Any] | None = None` parameter to `convert_pydantic_ai_to_agentex_events`. When set, the callback is invoked (sync or async) with the terminal `AgentRunResultEvent` that carries the run result and usage. Streaming output is unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent c30b40f commit 7a30e6e

2 files changed

Lines changed: 78 additions & 1 deletion

File tree

src/agentex/lib/adk/_modules/_pydantic_ai_sync.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ async def handle_message_send(params):
2121
from __future__ import annotations
2222

2323
import json
24-
from typing import TYPE_CHECKING, Any, AsyncIterator
24+
import inspect
25+
from typing import TYPE_CHECKING, Any, Callable, AsyncIterator
2526

2627
from pydantic_ai.run import AgentRunResultEvent
2728

@@ -105,6 +106,7 @@ def _tool_return_content(result: ToolReturnPart | Any) -> Any:
105106
async def convert_pydantic_ai_to_agentex_events(
106107
stream_response: AsyncIterator[Any],
107108
tracing_handler: "AgentexPydanticAITracingHandler | None" = None,
109+
on_result: Callable[[AgentRunResultEvent], Any] | None = None,
108110
) -> AsyncIterator[StreamTaskMessageStart | StreamTaskMessageDelta | StreamTaskMessageFull | StreamTaskMessageDone]:
109111
"""Convert a Pydantic AI agent event stream into Agentex stream events.
110112
@@ -132,6 +134,12 @@ async def convert_pydantic_ai_to_agentex_events(
132134
tool call in the run is also recorded as an Agentex child span
133135
beneath the handler's configured ``parent_span_id``. Streaming
134136
behavior is unchanged when omitted.
137+
on_result: Optional callback invoked with the terminal
138+
``AgentRunResultEvent`` when the run completes. Both sync and
139+
async callables are accepted. No ``StreamTaskMessage*`` events are
140+
yielded for this terminal event; the callback is the only side
141+
effect. Useful for capturing run-level usage without altering the
142+
streaming output.
135143
136144
Yields:
137145
Agentex ``StreamTaskMessage*`` events suitable for forwarding back over
@@ -328,6 +336,10 @@ async def convert_pydantic_ai_to_agentex_events(
328336
# Already covered by PartStart/PartDelta/PartEnd events above, or
329337
# informational only (FinalResultEvent / AgentRunResultEvent signal
330338
# run-level state, not new content to surface).
339+
if isinstance(event, AgentRunResultEvent) and on_result is not None:
340+
ret = on_result(event)
341+
if inspect.iscoroutine(ret):
342+
await ret
331343
continue
332344

333345
else:

tests/lib/adk/test_pydantic_ai_sync.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import Any, AsyncIterator
77

88
import pytest
9+
from pydantic_ai.run import AgentRunResult, AgentRunResultEvent
910
from pydantic_ai.messages import (
1011
TextPart,
1112
PartEndEvent,
@@ -481,3 +482,67 @@ async def test_author_is_agent(self, events: list[Any]):
481482
content = getattr(e, "content", None)
482483
if content is not None and hasattr(content, "author"):
483484
assert content.author == "agent"
485+
486+
487+
class TestOnResultCallback:
488+
"""on_result callback: captures the terminal AgentRunResultEvent without
489+
altering streaming output."""
490+
491+
def _make_result_event(self, output: Any = "hello") -> AgentRunResultEvent:
492+
result = AgentRunResult(output=output, _output_tool_name=None)
493+
return AgentRunResultEvent(result=result)
494+
495+
async def test_callback_invoked_once_with_result_event(self):
496+
"""on_result is called exactly once, with the AgentRunResultEvent."""
497+
captured: list[AgentRunResultEvent] = []
498+
499+
def on_result(event: AgentRunResultEvent) -> None:
500+
captured.append(event)
501+
502+
result_event = self._make_result_event("the answer")
503+
events = [result_event]
504+
await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=on_result))
505+
506+
assert len(captured) == 1
507+
assert captured[0] is result_event
508+
assert captured[0].result.output == "the answer"
509+
510+
async def test_streaming_output_unchanged_with_callback(self):
511+
"""Yielded StreamTaskMessage* sequence is identical whether on_result is set or not."""
512+
result_event = self._make_result_event()
513+
events = [
514+
PartStartEvent(index=0, part=TextPart(content="")),
515+
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hi")),
516+
PartEndEvent(index=0, part=TextPart(content="hi")),
517+
result_event,
518+
]
519+
520+
captured: list[AgentRunResultEvent] = []
521+
out_with = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=captured.append))
522+
out_without = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events)))
523+
524+
assert len(out_with) == len(out_without)
525+
for a, b in zip(out_with, out_without):
526+
assert type(a) is type(b)
527+
assert len(captured) == 1
528+
529+
async def test_no_callback_no_error(self):
530+
"""AgentRunResultEvent is silently ignored when on_result is None."""
531+
result_event = self._make_result_event()
532+
events = [result_event]
533+
out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events)))
534+
assert out == []
535+
536+
async def test_async_callback_is_awaited(self):
537+
"""An async on_result callable is properly awaited."""
538+
captured: list[AgentRunResultEvent] = []
539+
540+
async def on_result_async(event: AgentRunResultEvent) -> None:
541+
captured.append(event)
542+
543+
result_event = self._make_result_event("async_output")
544+
events = [result_event]
545+
await _collect(convert_pydantic_ai_to_agentex_events(_aiter(events), on_result=on_result_async))
546+
547+
assert len(captured) == 1
548+
assert captured[0].result.output == "async_output"

0 commit comments

Comments
 (0)