Skip to content

Commit d8116ef

Browse files
declan-scaleclaude
andcommitted
docs(pydantic-ai): document unified sync path; deprecate bespoke tracing handler (docstring)
- _pydantic_ai_sync.py: add "Recommended: unified surface" section to module docstring showing PydanticAITurn + UnifiedEmitter usage with automatic span derivation; bare converter docstring/code unchanged. - _pydantic_ai_tracing.py: deprecation notes (docstring-only) on module, AgentexPydanticAITracingHandler, and create_pydantic_ai_tracing_handler; no runtime warnings.warn so warnings-as-errors does not break callers; NOTE: comment explains the deferral rationale. - tests/lib/adk/test_pydantic_ai_sync_unified.py: 6 new tests covering the unified sync path: passthrough equality + tool/reasoning span derivation via _FakeTracing injection, no-trace-id no-op, tracer=False suppression. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 412d532 commit d8116ef

3 files changed

Lines changed: 267 additions & 0 deletions

File tree

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,25 @@ async def handle_message_send(params):
1616
async with agent.run_stream_events(params.content.content) as stream:
1717
async for event in convert_pydantic_ai_to_agentex_events(stream):
1818
yield event
19+
20+
Recommended: unified surface
21+
-----------------------------
22+
For new handlers, prefer ``UnifiedEmitter`` + ``PydanticAITurn`` over the
23+
bare converter. The unified surface wires tracing automatically when a
24+
``trace_id`` is provided, so tool and reasoning spans are derived from the
25+
same event stream with no extra setup:
26+
27+
from agentex.lib.core.harness import UnifiedEmitter
28+
from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn
29+
30+
emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id)
31+
turn = PydanticAITurn(agent.run_stream_events(prompt), model="openai:gpt-4o")
32+
async for event in emitter.yield_turn(turn):
33+
yield event # forwarded over the ACP streaming response; spans derived automatically
34+
35+
``convert_pydantic_ai_to_agentex_events`` remains the low-level tap for
36+
callers that manage their own tracing or need direct access to the raw
37+
converted stream.
1938
"""
2039

2140
from __future__ import annotations

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,29 @@
11
"""Tracing handler that records Agentex spans for tool calls in a pydantic-ai agent run.
22
3+
.. deprecated::
4+
``AgentexPydanticAITracingHandler`` and ``create_pydantic_ai_tracing_handler``
5+
are superseded by the unified harness surface (``UnifiedEmitter`` in
6+
``agentex.lib.core.harness``). The unified surface derives tool and
7+
reasoning spans directly from the canonical ``StreamTaskMessage*`` stream,
8+
so no separate handler is required. Both symbols remain fully importable
9+
and functional; they will be removed in a future release. New code should
10+
construct a ``UnifiedEmitter`` with a ``trace_id`` instead:
11+
12+
from agentex.lib.core.harness import UnifiedEmitter
13+
from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn
14+
15+
emitter = UnifiedEmitter(task_id=task_id, trace_id=trace_id, parent_span_id=parent_span_id)
16+
turn = PydanticAITurn(agent.run_stream_events(prompt), model="openai:gpt-4o")
17+
async for event in emitter.yield_turn(turn):
18+
yield event
19+
20+
# NOTE: A runtime ``warnings.warn(..., DeprecationWarning)`` is intentionally
21+
# omitted here. The repo's pyproject ``filterwarnings = ["error"]`` would turn
22+
# it into a test/caller failure, and the async helper (``stream_pydantic_ai_events``)
23+
# still threads this handler through for existing callers that lack a ``trace_id``
24+
# on the async path. The runtime warning and caller migration are deferred until
25+
# ``trace_id`` threading lands on the async helper in a future API-versioning change.
26+
327
Mirrors the LangGraph tracing handler pattern: the caller creates a handler
428
bound to a ``trace_id`` and a ``parent_span_id``, then hands it to
529
``stream_pydantic_ai_events(..., tracing_handler=handler)``. The streamer
@@ -63,6 +87,14 @@ def _tool_span_id(trace_id: str, tool_call_id: str) -> str:
6387
class AgentexPydanticAITracingHandler:
6488
"""Records Agentex tracing spans for tool calls observed in a pydantic-ai event stream.
6589
90+
.. deprecated::
91+
Superseded by ``UnifiedEmitter`` (``agentex.lib.core.harness``), which
92+
derives tool and reasoning spans from the canonical ``StreamTaskMessage*``
93+
stream automatically when ``trace_id`` is provided. This class remains
94+
fully functional but will be removed in a future release. New code should
95+
use ``UnifiedEmitter`` with a trace context instead of constructing this
96+
handler directly.
97+
6698
Pass an instance to ``stream_pydantic_ai_events(..., tracing_handler=...)``
6799
or call ``on_tool_start`` / ``on_tool_end`` yourself if you're consuming
68100
the event stream by hand.
@@ -165,6 +197,13 @@ def create_pydantic_ai_tracing_handler(
165197
) -> AgentexPydanticAITracingHandler:
166198
"""Create a tracing handler that records Agentex spans for pydantic-ai tool calls.
167199
200+
.. deprecated::
201+
Superseded by ``UnifiedEmitter`` (``agentex.lib.core.harness``), which
202+
derives tool and reasoning spans from the canonical ``StreamTaskMessage*``
203+
stream automatically when ``trace_id`` is provided. This function remains
204+
fully functional but will be removed in a future release. New code should
205+
construct a ``UnifiedEmitter`` with a trace context instead.
206+
168207
Args:
169208
trace_id: The trace ID. Typically the Agentex task ID.
170209
parent_span_id: Optional parent span ID to nest tool spans under. If
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""Tests for the unified sync (HTTP ACP) path: PydanticAITurn + UnifiedEmitter.
2+
3+
Exercises the path documented in _pydantic_ai_sync.py under "Recommended: unified surface":
4+
- events forwarded by yield_turn equal PydanticAITurn(stream).events (passthrough)
5+
- with a trace context + fake tracing backend, tool spans are derived (start_span / end_span called)
6+
- with a trace context + fake tracing backend, reasoning spans are derived
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from typing import Any, AsyncIterator
12+
13+
from pydantic_ai.run import AgentRunResult, AgentRunResultEvent
14+
from pydantic_ai.usage import RunUsage
15+
from pydantic_ai.messages import (
16+
TextPart,
17+
PartEndEvent,
18+
ThinkingPart,
19+
ToolCallPart,
20+
TextPartDelta,
21+
PartDeltaEvent,
22+
PartStartEvent,
23+
ThinkingPartDelta,
24+
ToolCallPartDelta,
25+
)
26+
27+
from agentex.lib.core.harness import UnifiedEmitter
28+
from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn
29+
30+
31+
async def _aiter(events: list[Any]) -> AsyncIterator[Any]:
32+
for e in events:
33+
yield e
34+
35+
36+
async def _collect(stream: AsyncIterator[Any]) -> list[Any]:
37+
return [e async for e in stream]
38+
39+
40+
class _FakeSpan:
41+
def __init__(self, name: str):
42+
self.name = name
43+
self.output: Any = None
44+
45+
46+
class _FakeTracing:
47+
def __init__(self) -> None:
48+
self.started: list[tuple[str, str | None, Any]] = []
49+
self.ended: list[tuple[str, Any]] = []
50+
51+
async def start_span(self, *, trace_id, name, input=None, parent_id=None, data=None, task_id=None):
52+
self.started.append((name, parent_id, input))
53+
return _FakeSpan(name)
54+
55+
async def end_span(self, *, trace_id, span):
56+
self.ended.append((span.name, span.output))
57+
58+
59+
def _make_result_event(usage: RunUsage | None = None) -> AgentRunResultEvent:
60+
result = AgentRunResult(output="done", _output_tool_name=None)
61+
if usage is not None:
62+
result._state.usage = usage
63+
return AgentRunResultEvent(result=result)
64+
65+
66+
class TestUnifiedSyncPathPassthrough:
67+
"""The events forwarded by yield_turn are identical to PydanticAITurn.events."""
68+
69+
async def test_text_stream_passthrough(self):
70+
raw_events = [
71+
PartStartEvent(index=0, part=TextPart(content="")),
72+
PartDeltaEvent(index=0, delta=TextPartDelta(content_delta="hello")),
73+
PartEndEvent(index=0, part=TextPart(content="hello")),
74+
]
75+
76+
turn_a = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o")
77+
direct = await _collect(turn_a.events)
78+
79+
turn_b = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o")
80+
emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None)
81+
via_emitter = await _collect(emitter.yield_turn(turn_b))
82+
83+
assert len(via_emitter) == len(direct)
84+
for a, b in zip(via_emitter, direct):
85+
assert type(a) is type(b)
86+
assert a.model_dump() == b.model_dump()
87+
88+
async def test_tool_call_stream_passthrough(self):
89+
raw_events = [
90+
PartStartEvent(index=0, part=ToolCallPart(tool_name="Bash", args=None, tool_call_id="c1")),
91+
PartDeltaEvent(index=0, delta=ToolCallPartDelta(args_delta='{"cmd":"ls"}')),
92+
PartEndEvent(
93+
index=0,
94+
part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c1"),
95+
),
96+
]
97+
98+
turn_a = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o")
99+
direct = await _collect(turn_a.events)
100+
101+
turn_b = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o")
102+
emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None)
103+
via_emitter = await _collect(emitter.yield_turn(turn_b))
104+
105+
assert len(via_emitter) == len(direct)
106+
for a, b in zip(via_emitter, direct):
107+
assert type(a) is type(b)
108+
assert a.model_dump() == b.model_dump()
109+
110+
111+
class TestUnifiedSyncPathSpanDerivation:
112+
"""With trace context + fake tracing, spans are derived from the stream."""
113+
114+
async def test_tool_span_opened_and_closed(self):
115+
"""A tool call produces start_span + end_span on the fake tracing backend."""
116+
from pydantic_ai.messages import ToolReturnPart, FunctionToolResultEvent
117+
118+
tool_events = [
119+
PartStartEvent(
120+
index=0,
121+
part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="call_1"),
122+
),
123+
PartEndEvent(
124+
index=0,
125+
part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="call_1"),
126+
),
127+
FunctionToolResultEvent(
128+
part=ToolReturnPart(tool_name="Bash", content="files", tool_call_id="call_1"),
129+
),
130+
]
131+
132+
fake = _FakeTracing()
133+
turn = PydanticAITurn(_aiter(tool_events), model="openai:gpt-4o")
134+
emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracing=fake)
135+
136+
events = await _collect(emitter.yield_turn(turn))
137+
138+
assert len(events) >= 2, "at least Start(tool) + Done + Full(response)"
139+
assert len(fake.started) == 1, "one tool span opened"
140+
assert len(fake.ended) == 1, "one tool span closed"
141+
span_name, parent_id, span_input = fake.started[0]
142+
assert span_name == "Bash"
143+
assert parent_id == "p"
144+
closed_name, closed_output = fake.ended[0]
145+
assert closed_name == "Bash"
146+
147+
async def test_reasoning_span_opened_and_closed(self):
148+
"""A thinking/reasoning block produces start_span + end_span."""
149+
reasoning_events = [
150+
PartStartEvent(index=0, part=ThinkingPart(content="")),
151+
PartDeltaEvent(index=0, delta=ThinkingPartDelta(content_delta="let me think")),
152+
PartEndEvent(index=0, part=ThinkingPart(content="let me think")),
153+
]
154+
155+
fake = _FakeTracing()
156+
turn = PydanticAITurn(_aiter(reasoning_events), model="openai:gpt-4o")
157+
emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracing=fake)
158+
159+
await _collect(emitter.yield_turn(turn))
160+
161+
assert len(fake.started) == 1, "one reasoning span opened"
162+
assert len(fake.ended) == 1, "one reasoning span closed"
163+
span_name, parent_id, _ = fake.started[0]
164+
assert span_name == "reasoning"
165+
assert parent_id == "p"
166+
167+
async def test_no_trace_id_means_no_spans(self):
168+
"""When trace_id is None, no spans are derived even with a fake tracing backend."""
169+
raw_events = [
170+
PartStartEvent(
171+
index=0,
172+
part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="c2"),
173+
),
174+
PartEndEvent(
175+
index=0,
176+
part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c2"),
177+
),
178+
]
179+
180+
fake = _FakeTracing()
181+
turn = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o")
182+
emitter = UnifiedEmitter(task_id="t", trace_id=None, parent_span_id=None, tracing=fake)
183+
184+
await _collect(emitter.yield_turn(turn))
185+
186+
assert fake.started == [], "no spans when trace_id is absent"
187+
assert fake.ended == []
188+
189+
async def test_tracer_false_suppresses_spans_even_with_trace_id(self):
190+
"""tracer=False disables span derivation regardless of trace_id."""
191+
raw_events = [
192+
PartStartEvent(
193+
index=0,
194+
part=ToolCallPart(tool_name="Bash", args={"cmd": "ls"}, tool_call_id="c3"),
195+
),
196+
PartEndEvent(
197+
index=0,
198+
part=ToolCallPart(tool_name="Bash", args='{"cmd":"ls"}', tool_call_id="c3"),
199+
),
200+
]
201+
202+
fake = _FakeTracing()
203+
turn = PydanticAITurn(_aiter(raw_events), model="openai:gpt-4o")
204+
emitter = UnifiedEmitter(task_id="t", trace_id="tr", parent_span_id="p", tracer=False, tracing=fake)
205+
206+
await _collect(emitter.yield_turn(turn))
207+
208+
assert fake.started == []
209+
assert fake.ended == []

0 commit comments

Comments
 (0)