Skip to content

Commit 1c64ca5

Browse files
declan-scaleclaude
andcommitted
refactor(pydantic-ai): make tool-request coalescing opt-in (preserve sync arg streaming); ref AGX1-377
PydanticAITurn.events feeds BOTH delivery channels (yield_turn for sync, auto_send_turn for async). Applying _coalesce_tool_requests unconditionally would deliver tool requests as a single Full with no ToolRequestDelta tokens, losing the sync converter's documented tool-call-argument token streaming (Task 4 routes the sync/HTTP path through emitter.yield_turn(PydanticAITurn(...))). - Add constructor param coalesce_tool_requests: bool = False. Default OFF means PydanticAITurn(...).events == bare convert_pydantic_ai_to_agentex_events output (Start+Delta+Done for tool calls, arg streaming preserved on yield/sync). - stream_pydantic_ai_events builds the Turn with coalesce_tool_requests=True, because the foundation auto_send currently DROPS tool requests delivered as Start+Delta+Done (AGX1-377). Comment cites AGX1-377 as a temporary workaround to be removed once auto_send handles the streamed tool-request shape natively. - Tests: default-off Turn yields a ToolRequestDelta for streamed args (matches bare converter); coalesce-on Turn yields a single Full(tool_request) with fully-accumulated args and no ToolRequestDelta. Async characterization test still passes (goes through coalesce=True). - Add parts-manager invariant comment to the two corrected async tests. auto_send.py is unchanged (final_text last-segment fix stays; AGX1-377 covers the Start+Delta+Done handling). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 636b4ca commit 1c64ca5

4 files changed

Lines changed: 123 additions & 7 deletions

File tree

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,17 @@ async def stream_pydantic_ai_events(
5151
from agentex.lib.core.harness.emitter import UnifiedEmitter
5252
from agentex.lib.adk._modules._pydantic_ai_turn import PydanticAITurn
5353

54-
turn = PydanticAITurn(stream, model=None, tracing_handler=tracing_handler)
54+
# coalesce_tool_requests=True is a temporary workaround (AGX1-377): the
55+
# foundation auto_send currently DROPS tool requests delivered as the
56+
# streamed Start+ToolRequestDelta+Done shape, so we collapse them into a
57+
# single Full here. Once auto_send handles the streamed tool-request shape
58+
# natively, this flag should be removed and the default (off) used.
59+
turn = PydanticAITurn(
60+
stream,
61+
model=None,
62+
tracing_handler=tracing_handler,
63+
coalesce_tool_requests=True,
64+
)
5565
emitter = UnifiedEmitter(
5666
task_id=task_id,
5767
trace_id=None,

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

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -154,21 +154,25 @@ class PydanticAITurn:
154154
canonical ``StreamTaskMessage*`` stream; ``usage()`` returns a normalized
155155
``TurnUsage`` (valid only after ``events`` is exhausted).
156156
157-
Tool-request events emitted by ``convert_pydantic_ai_to_agentex_events``
158-
as ``Start+Done`` pairs are coalesced into ``StreamTaskMessageFull``
159-
events so that ``auto_send`` can deliver them as atomic messages (Option A
160-
— no streaming of argument tokens in the async path).
157+
By default ``events`` is identical to the bare
158+
``convert_pydantic_ai_to_agentex_events`` output (tool calls stream as
159+
``Start + ToolRequestDelta + Done``, preserving argument-token streaming on
160+
the sync/yield channel). When ``coalesce_tool_requests=True``, tool-request
161+
sequences are collapsed into a single ``StreamTaskMessageFull`` (Option A —
162+
no streaming of argument tokens) for the async/auto_send path.
161163
"""
162164

163165
def __init__(
164166
self,
165167
stream: AsyncIterator[Any],
166168
model: str | None = None,
167169
tracing_handler: "AgentexPydanticAITracingHandler | None" = None,
170+
coalesce_tool_requests: bool = False,
168171
) -> None:
169172
self._stream = stream
170173
self._model = model
171174
self._tracing_handler = tracing_handler
175+
self._coalesce_tool_requests = coalesce_tool_requests
172176
self._usage = TurnUsage(model=model)
173177

174178
@property
@@ -195,8 +199,12 @@ def _capture(result_event: AgentRunResultEvent) -> None:
195199
tracing_handler=self._tracing_handler,
196200
on_result=_capture,
197201
)
198-
async for ev in _coalesce_tool_requests(raw_stream):
199-
yield ev
202+
if self._coalesce_tool_requests:
203+
async for ev in _coalesce_tool_requests(raw_stream):
204+
yield ev
205+
else:
206+
async for ev in raw_stream:
207+
yield ev
200208

201209
def usage(self) -> TurnUsage:
202210
"""Return the normalized usage for this turn.

tests/lib/adk/test_pydantic_ai_async.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,10 @@ async def test_tool_call_emits_full_tool_request_message_on_part_end(
268268
This test uses a realistic pydantic-ai event sequence: args arrive as a
269269
PartDeltaEvent fragment (the way OpenAI/Anthropic actually stream JSON
270270
tool-call arguments). The new implementation accumulates them correctly.
271+
272+
Parts-manager invariant: PartEnd.part is the accumulated snapshot; real
273+
pydantic-ai conveys args via PartStart + PartDeltaEvent, so a
274+
PartStart(None)+PartEnd(json) with no delta is not realizable.
271275
"""
272276
from pydantic_ai.messages import ToolCallPartDelta
273277

@@ -334,6 +338,10 @@ async def test_tool_call_with_invalid_json_args_surfaces_raw(
334338
335339
Uses a PartDeltaEvent to deliver the invalid string (the way pydantic-ai
336340
actually surfaces arg tokens) so the coalescer picks it up.
341+
342+
Parts-manager invariant: PartEnd.part is the accumulated snapshot; real
343+
pydantic-ai conveys args via PartStart + PartDeltaEvent, so a
344+
PartStart(None)+PartEnd(json) with no delta is not realizable.
337345
"""
338346
from pydantic_ai.messages import ToolCallPartDelta
339347

tests/lib/adk/test_pydantic_ai_turn.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,93 @@ async def test_no_usage_event_leaves_default_usage(self):
228228
assert usage.model == "openai:gpt-4o"
229229
assert usage.input_tokens is None
230230
assert usage.num_llm_calls == 0
231+
232+
233+
class TestToolRequestCoalescing:
234+
"""The ``coalesce_tool_requests`` flag controls tool-call delivery shape.
235+
236+
Default (off): tool calls stream as Start + ToolRequestDelta + Done,
237+
matching the bare converter and preserving argument-token streaming on the
238+
sync/yield channel.
239+
240+
On: tool-request sequences collapse into a single StreamTaskMessageFull
241+
(Option A) for the async/auto_send path (a temporary AGX1-377 workaround).
242+
"""
243+
244+
async def test_default_off_matches_bare_converter_for_streamed_tool_call(self):
245+
"""Default Turn (coalesce off) yields a ToolRequestDelta for a streamed-args
246+
tool call — i.e. it is byte-for-byte the bare converter output, so the
247+
sync/yield channel keeps its argument-token streaming."""
248+
from pydantic_ai.messages import ToolCallPart, ToolCallPartDelta
249+
250+
from agentex.types.tool_request_delta import ToolRequestDelta
251+
from agentex.types.task_message_update import StreamTaskMessageDelta
252+
from agentex.lib.adk._modules._pydantic_ai_sync import convert_pydantic_ai_to_agentex_events
253+
254+
tool_events = [
255+
PartStartEvent(index=0, part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1")),
256+
PartDeltaEvent(index=0, delta=ToolCallPartDelta(args_delta='{"city":"Paris"}')),
257+
PartEndEvent(
258+
index=0,
259+
part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="c1"),
260+
),
261+
]
262+
263+
turn = PydanticAITurn(_aiter(tool_events), model="openai:gpt-4o")
264+
turn_out = await _collect(turn.events)
265+
266+
bare_out = await _collect(convert_pydantic_ai_to_agentex_events(_aiter(tool_events)))
267+
268+
# Default Turn is identical to the bare converter.
269+
assert len(turn_out) == len(bare_out)
270+
for a, b in zip(turn_out, bare_out):
271+
assert type(a) is type(b)
272+
assert a.model_dump() == b.model_dump()
273+
274+
# And the arg-streaming delta is present (not coalesced away).
275+
deltas = [
276+
e for e in turn_out if isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ToolRequestDelta)
277+
]
278+
assert len(deltas) == 1, "streamed tool-call args must surface as a ToolRequestDelta when coalesce is off"
279+
assert deltas[0].delta.arguments_delta == '{"city":"Paris"}'
280+
281+
async def test_coalesce_on_emits_single_full_with_accumulated_args_and_no_delta(self):
282+
"""coalesce_tool_requests=True yields one StreamTaskMessageFull(tool_request)
283+
with fully-accumulated arguments and NO ToolRequestDelta."""
284+
from pydantic_ai.messages import ToolCallPart, ToolCallPartDelta
285+
286+
from agentex.types.tool_request_delta import ToolRequestDelta
287+
from agentex.types.task_message_update import (
288+
StreamTaskMessageDone,
289+
StreamTaskMessageFull,
290+
StreamTaskMessageDelta,
291+
StreamTaskMessageStart,
292+
)
293+
from agentex.types.tool_request_content import ToolRequestContent
294+
295+
tool_events = [
296+
PartStartEvent(index=0, part=ToolCallPart(tool_name="get_weather", args=None, tool_call_id="c1")),
297+
PartDeltaEvent(index=0, delta=ToolCallPartDelta(args_delta='{"city":"Paris"}')),
298+
PartEndEvent(
299+
index=0,
300+
part=ToolCallPart(tool_name="get_weather", args='{"city":"Paris"}', tool_call_id="c1"),
301+
),
302+
]
303+
304+
turn = PydanticAITurn(_aiter(tool_events), model="openai:gpt-4o", coalesce_tool_requests=True)
305+
turn_out = await _collect(turn.events)
306+
307+
# Exactly one event: a Full(tool_request). No Start/Delta/Done leak through.
308+
assert len(turn_out) == 1
309+
full = turn_out[0]
310+
assert isinstance(full, StreamTaskMessageFull)
311+
assert isinstance(full.content, ToolRequestContent)
312+
assert full.content.tool_call_id == "c1"
313+
assert full.content.name == "get_weather"
314+
assert full.content.arguments == {"city": "Paris"}, "args must be fully accumulated"
315+
316+
assert not any(isinstance(e, StreamTaskMessageStart) for e in turn_out)
317+
assert not any(isinstance(e, StreamTaskMessageDone) for e in turn_out)
318+
assert not any(
319+
isinstance(e, StreamTaskMessageDelta) and isinstance(e.delta, ToolRequestDelta) for e in turn_out
320+
), "no ToolRequestDelta when coalescing is on"

0 commit comments

Comments
 (0)