Skip to content

feat(pydantic-ai): migrate onto unified harness surface (PR4)#415

Open
declan-scale wants to merge 14 commits into
declan-scale/agx1-373-conformance-equivalencefrom
declan-scale/pr4-pydantic-ai
Open

feat(pydantic-ai): migrate onto unified harness surface (PR4)#415
declan-scale wants to merge 14 commits into
declan-scale/agx1-373-conformance-equivalencefrom
declan-scale/pr4-pydantic-ai

Conversation

@declan-scale

@declan-scale declan-scale commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Reimplements stream_pydantic_ai_events on top of UnifiedEmitter (default tracing, no bespoke handler required) via the async path
  • Adds PydanticAITurn implementing HarnessTurn, wiring the sync yield path through UnifiedEmitter.yield_turn
  • Adds on_result optional callback to convert_pydantic_ai_to_agentex_events for usage capture (additive only, no breaking change)
  • Makes tool-request coalescing opt-in (coalesce_tool_requests=False by default) to preserve streaming arg delta delivery on sync path (AGX1-377)
  • Deprecates AgentexPydanticAITracingHandler / create_pydantic_ai_tracing_handler via docstring only (no runtime warning, preserving warnings-as-errors safety)
  • Adds 3 example tutorial agent projects (sync, async, temporal) using the unified surface
  • Adds cross-channel conformance fixtures for pydantic-ai event sequences

Test plan

  • All 230 tests pass on Python 3.12 and 3.13
  • ./scripts/lint clean (ruff: 0 errors; pyright: 0 errors on PR4 files; 2 pre-existing errors in pre-existing test files unchanged from base)
  • Public ADK facade unchanged — same 4 pydantic symbols exported, no removals
  • convert_pydantic_ai_to_agentex_events additive-only (new optional on_result kwarg, default None)
  • stream_pydantic_ai_events signature identical to base
  • No runtime DeprecationWarning on tracing handler (docstring-only)
  • Sanity import: PydanticAITurn, UnifiedEmitter importable

🤖 Generated with Claude Code

Greptile Summary

This PR migrates stream_pydantic_ai_events and introduces PydanticAITurn to wire pydantic-ai onto the UnifiedEmitter surface, replacing ~200 lines of bespoke async event handling and adding three new tutorial agents (sync, async base, async temporal).

  • PydanticAITurn adapts a pydantic-ai stream to the HarnessTurn protocol, capturing run-level usage via the new on_result callback added to convert_pydantic_ai_to_agentex_events.
  • stream_pydantic_ai_events is reduced to a thin UnifiedEmitter.auto_send_turn(PydanticAITurn(...)) wrapper; the deprecated AgentexPydanticAITracingHandler remains functional but is docstring-deprecated.
  • Both async tutorial examples (00_base/acp.py and 10_temporal/agent.py) pass coalesce_tool_requests=True to PydanticAITurn, but that parameter is absent from the constructor — these tutorials will raise TypeError at runtime.

Confidence Score: 4/5

Core library code is correct and well-tested, but both new async tutorials will fail immediately at runtime due to a missing constructor parameter.

The PydanticAITurn constructor does not accept coalesce_tool_requests, yet both async tutorial agents pass it — any developer running these tutorials hits an immediate TypeError. The production library path is sound and well-covered by the 230 tests, so the bug is isolated to the tutorial examples rather than the shipped API surface.

Both async tutorial acp.py/agent.py files and the PydanticAITurn.__init__ definition in _pydantic_ai_turn.py need to be aligned — either add the parameter to the constructor or remove it from the tutorial call sites.

Important Files Changed

Filename Overview
src/agentex/lib/adk/_modules/_pydantic_ai_turn.py New HarnessTurn wrapper for pydantic-ai streams; missing coalesce_tool_requests parameter that both async tutorials pass, causing TypeError at runtime; docstring incorrectly claims auto_send delivers streamed tool-request shapes natively.
src/agentex/lib/adk/_modules/_pydantic_ai_async.py Reimplements stream_pydantic_ai_events on top of UnifiedEmitter+PydanticAITurn, removing ~200 lines of bespoke event handling; backward-compatible signature preserved.
src/agentex/lib/adk/_modules/_pydantic_ai_sync.py Additive change: adds optional on_result callback to convert_pydantic_ai_to_agentex_events; supports both sync and async callables; no breaking changes.
src/agentex/lib/adk/_modules/_pydantic_ai_tracing.py Docstring-only deprecation of AgentexPydanticAITracingHandler and create_pydantic_ai_tracing_handler; no runtime DeprecationWarning (intentional); both symbols remain fully functional.
examples/tutorials/10_async/00_base/harness_pydantic_ai/project/acp.py Async base tutorial using PydanticAITurn with coalesce_tool_requests=True, which does not exist in the PydanticAITurn constructor — will raise TypeError at runtime.
examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/agent.py Temporal tutorial event_stream_handler passes coalesce_tool_requests=True to PydanticAITurn, which does not accept this parameter — will raise TypeError at runtime.
tests/lib/adk/test_pydantic_ai_turn.py Comprehensive tests for PydanticAITurn and pydantic_ai_usage_to_turn_usage covering usage normalization, pre/post exhaustion state, event passthrough equality, and defensive getattr degradation.
tests/lib/core/harness/conformance/test_pydantic_ai_conformance.py Cross-channel conformance fixtures for pydantic-ai event sequences; honestly documents that streamed tool-request delivery is not yet asserted pending AGX1-377.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Agent as Agent Author
    participant PAI as PydanticAITurn
    participant Conv as convert_pydantic_ai_to_agentex_events
    participant UE as UnifiedEmitter
    participant AS as auto_send / yield_events

    Note over Agent,AS: Sync (HTTP ACP) path
    Agent->>PAI: "PydanticAITurn(stream, model=...)"
    Agent->>UE: UnifiedEmitter(task_id, trace_id, parent_span_id)
    Agent->>UE: yield_turn(turn)
    UE->>AS: yield_events(turn.events, tracer)
    AS->>PAI: iterate turn.events
    PAI->>Conv: "convert_pydantic_ai_to_agentex_events(stream, on_result=_capture)"
    Conv-->>AS: "StreamTaskMessage* events"
    AS-->>Agent: forwarded events (HTTP stream)
    Conv->>PAI: _capture sets _usage

    Note over Agent,AS: Async / Temporal path
    Agent->>PAI: "PydanticAITurn(stream, model=...)"
    Agent->>UE: UnifiedEmitter(task_id, trace_id, parent_span_id)
    Agent->>UE: auto_send_turn(turn)
    UE->>AS: "auto_send(turn.events, usage=turn.usage() read early)"
    AS->>PAI: iterate turn.events
    PAI->>Conv: "convert + on_result=_capture"
    Conv-->>AS: "StreamTaskMessage* events pushed to Redis"
    Conv->>PAI: _capture sets _usage after iteration
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Agent as Agent Author
    participant PAI as PydanticAITurn
    participant Conv as convert_pydantic_ai_to_agentex_events
    participant UE as UnifiedEmitter
    participant AS as auto_send / yield_events

    Note over Agent,AS: Sync (HTTP ACP) path
    Agent->>PAI: "PydanticAITurn(stream, model=...)"
    Agent->>UE: UnifiedEmitter(task_id, trace_id, parent_span_id)
    Agent->>UE: yield_turn(turn)
    UE->>AS: yield_events(turn.events, tracer)
    AS->>PAI: iterate turn.events
    PAI->>Conv: "convert_pydantic_ai_to_agentex_events(stream, on_result=_capture)"
    Conv-->>AS: "StreamTaskMessage* events"
    AS-->>Agent: forwarded events (HTTP stream)
    Conv->>PAI: _capture sets _usage

    Note over Agent,AS: Async / Temporal path
    Agent->>PAI: "PydanticAITurn(stream, model=...)"
    Agent->>UE: UnifiedEmitter(task_id, trace_id, parent_span_id)
    Agent->>UE: auto_send_turn(turn)
    UE->>AS: "auto_send(turn.events, usage=turn.usage() read early)"
    AS->>PAI: iterate turn.events
    PAI->>Conv: "convert + on_result=_capture"
    Conv-->>AS: "StreamTaskMessage* events pushed to Redis"
    Conv->>PAI: _capture sets _usage after iteration
Loading

Comments Outside Diff (6)

  1. src/agentex/lib/adk/_modules/_pydantic_ai_sync.py, line 257-261 (link)

    P2 Delta-first tools drop

    This early continue makes the fallback in the ToolCallPartDelta branch unreachable. If a provider emits a tool-call delta before a PartStartEvent, message_index is missing, so the converter skips the event before it can synthesize the tool request from the delta. That drops the tool call and its argument stream for the provider edge this code is trying to handle. Please allocate an Agentex message index and emit a synthetic tool-request start when the first event for an index is a tool-call delta.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agentex/lib/adk/_modules/_pydantic_ai_sync.py
    Line: 257-261
    
    Comment:
    **Delta-first tools drop**
    
    This early `continue` makes the fallback in the `ToolCallPartDelta` branch unreachable. If a provider emits a tool-call delta before a `PartStartEvent`, `message_index` is missing, so the converter skips the event before it can synthesize the tool request from the delta. That drops the tool call and its argument stream for the provider edge this code is trying to handle. Please allocate an Agentex message index and emit a synthetic tool-request start when the first event for an index is a tool-call delta.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

  2. src/agentex/lib/adk/_modules/_pydantic_ai_sync.py, line 280-299 (link)

    P2 Tool metadata stays blank

    Once tool_call_meta is initialized from PartStartEvent, later deltas cannot fill in missing metadata. If the start event has an empty tool_call_id or tool name and a later ToolCallPartDelta supplies tool_call_id or tool_name_delta, this branch keeps using the stale empty values. The emitted ToolRequestDelta then has blank identifiers, and the later tool response or span close cannot match the request. Please merge non-empty delta metadata into tool_call_meta before constructing the delta.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agentex/lib/adk/_modules/_pydantic_ai_sync.py
    Line: 280-299
    
    Comment:
    **Tool metadata stays blank**
    
    Once `tool_call_meta` is initialized from `PartStartEvent`, later deltas cannot fill in missing metadata. If the start event has an empty `tool_call_id` or tool name and a later `ToolCallPartDelta` supplies `tool_call_id` or `tool_name_delta`, this branch keeps using the stale empty values. The emitted `ToolRequestDelta` then has blank identifiers, and the later tool response or span close cannot match the request. Please merge non-empty delta metadata into `tool_call_meta` before constructing the delta.
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

  3. src/agentex/lib/core/harness/emitter.py, line 66 (link)

    P1 auto_send_turn returns stale empty usage — token counts always lost

    • Bug
      • UnifiedEmitter.auto_send_turn evaluates turn.usage() before consuming turn.events, so auto_send always receives the initial empty TurnUsage with None tokens and 0 LLM calls.
    • Cause
      • In emitter.py line 66, usage=turn.usage() is evaluated eagerly as a keyword argument before auto_send iterates turn.events. PydanticAITurn only populates usage via the on_result callback during stream iteration.
    • Fix
      • Move usage capture after stream consumption: have auto_send call turn.usage() after exhausting turn.events, or pass the turn object to auto_send so it can read usage post-iteration.
    Artifacts

    Supporting artifact from the T-Rex run

    • Contains supporting evidence from the run (text/x-python; charset=utf-8).

    Repro output showing stale usage in TurnResult

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

  4. src/agentex/lib/core/harness/emitter.py, line 66 (link)

    P1 auto_send_turn returns stale empty usage instead of captured token counts

    • auto_send_turn reads turn.usage() before the event stream is consumed.
    • PydanticAITurn updates usage only after the terminal run-result event is consumed.
    • The returned TurnResult.usage keeps the initial empty usage instead of the captured token counts.
    • A focused repro confirmed result.usage had empty token fields while turn.usage() after iteration contained the expected values.
    Artifacts

    Supporting artifact from the T-Rex run

    • Contains supporting evidence from the run (text/x-python; charset=utf-8).

    Verbose output showing stale vs real usage values

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

  5. src/agentex/lib/core/harness/emitter.py, line 66 (link)

    P1 auto_send_turn returns stale empty usage instead of captured token counts

    • Bug
      • auto_send_turn calls turn.usage() on line 66 before passing it to auto_send, but PydanticAITurn only populates usage when the terminal AgentRunResultEvent is consumed during event iteration. The returned TurnResult.usage always has None tokens and 0 LLM calls.
    • Cause
      • Line 66 evaluates usage=turn.usage() eagerly before auto_send consumes turn.events. Since PydanticAITurn._capture only fires when AgentRunResultEvent is yielded during iteration, the usage snapshot is taken too early.
    • Fix
      • Move turn.usage() after event consumption: have auto_send call a usage callback after exhausting the event iterator, or change auto_send_turn to call auto_send without usage, then patch result.usage = turn.usage() after awaiting.
    Artifacts

    Supporting artifact from the T-Rex run

    • Contains supporting evidence from the run (text/x-python; charset=utf-8).

    Verbose output showing stale vs real usage values

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

  6. src/agentex/lib/core/harness/emitter.py, line 61-67 (link)

    P1 Usage is captured early

    PydanticAITurn fills its usage only after turn.events is fully consumed. This call reads turn.usage() before auto_send starts iterating the stream, so async Pydantic AI runs return a TurnResult with empty token fields even when the terminal result event contains real usage.

    Artifacts

    Supporting artifact from the T-Rex run

    • Contains supporting evidence from the run (text/x-python; charset=utf-8).

    Repro output confirming the usage timing bug

    • Keeps the command output available without making the summary code-heavy.

    Supporting artifact from the T-Rex run

    • Contains supporting evidence from the run (text/x-python; charset=utf-8).

    Repro output confirming the bug

    • Keeps the command output available without making the summary code-heavy.

    View artifacts

    T-Rex Ran code and verified through T-Rex

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: src/agentex/lib/core/harness/emitter.py
    Line: 61-67
    
    Comment:
    **Usage is captured early**
    
    `PydanticAITurn` fills its usage only after `turn.events` is fully consumed. This call reads `turn.usage()` before `auto_send` starts iterating the stream, so async Pydantic AI runs return a `TurnResult` with empty token fields even when the terminal result event contains real usage.
    
    
    
    How can I resolve this? If you propose a fix, please make it concise.

    Fix in Claude Code

Fix All in Claude Code

Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 2
src/agentex/lib/adk/_modules/_pydantic_ai_turn.py:82-89
**`coalesce_tool_requests` missing from constructor — TypeError in both async tutorials**

`PydanticAITurn.__init__` accepts only `stream`, `model`, and `tracing_handler`, but both new tutorial agents pass `coalesce_tool_requests=True`:

- `examples/tutorials/10_async/00_base/harness_pydantic_ai/project/acp.py` line 141
- `examples/tutorials/10_async/10_temporal/harness_pydantic_ai/project/agent.py` line 99

Both will raise `TypeError: __init__() got an unexpected keyword argument 'coalesce_tool_requests'` at runtime. The PR description explicitly calls out making this parameter opt-in (`coalesce_tool_requests=False` by default), but neither the `PydanticAITurn` constructor nor `convert_pydantic_ai_to_agentex_events` implements it.

### Issue 2 of 2
src/agentex/lib/adk/_modules/_pydantic_ai_turn.py:65-70
**Docstring contradicts conformance-test comment and both async tutorials**

The class docstring states: "The foundation `auto_send` delivers the streamed tool-request shape natively (AGX1-377), so no coalescing is needed on either channel." However, `test_pydantic_ai_conformance.py` explicitly says the opposite: "auto_send likewise drops the Start+Delta+Done(tool_request) shape." The async tutorials also both note that `coalesce_tool_requests=True` is required on the auto_send path until AGX1-377 lands. The docstring should be updated to reflect the current state of AGX1-377 rather than the post-fix state.

Reviews (5): Last reviewed commit: "refactor(pydantic-ai): drop coalesce_too..." | Re-trigger Greptile

@declan-scale declan-scale force-pushed the declan-scale/agx1-373-conformance-equivalence branch 2 times, most recently from b4c53ca to cae14d4 Compare June 18, 2026 19:24
@declan-scale declan-scale force-pushed the declan-scale/pr4-pydantic-ai branch from 724120b to 6d0f0e8 Compare June 18, 2026 19:25
Comment thread src/agentex/lib/adk/_modules/_pydantic_ai_turn.py Outdated
@declan-scale

Copy link
Copy Markdown
Contributor Author

@greptile review

@declan-scale declan-scale force-pushed the declan-scale/agx1-373-conformance-equivalence branch from 8cd851c to 2e820c7 Compare June 18, 2026 21:08
declan-scale and others added 14 commits June 18, 2026 17:10
…or 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>
…tests

Strengthen backward-compat guarantees for the on_result callback:
- test_streaming_output_unchanged_with_callback now asserts model_dump()
  equality per yielded pair, not just type, proving the callback does not
  alter streamed message content.
- test_async_callback_is_awaited adds a real suspension point
  (await asyncio.sleep(0)) before its side effect, so the assertion only
  passes if the converter actually awaits the returned coroutine.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds PydanticAITurn, a HarnessTurn wrapping a pydantic-ai event stream,
with pydantic_ai_usage_to_turn_usage mapping verified RunUsage fields
(requests, input_tokens, output_tokens, cache_read_tokens, total_tokens)
onto TurnUsage via defensive getattr; usage() populates after events exhaustion.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ion + cover real usage accessor

Pass getattr results straight through so a real 0 (e.g. a cache-hit with
0 output tokens) stays 0 while a MISSING attribute still degrades to None.
Previously `x if x else None` coerced legitimate zeros to None. Adds tests
for the 0->0 mapping, the missing-field->None defensive guarantee, and the
real result.usage property accessor path the converter uses.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds TestCharacterizeWireShapeCurrent to lock the current wire-level
delivery shape: text via streaming_task_message_context, tool messages
via adk.messages.create. Serves as the before-snapshot for the
UnifiedEmitter reimplementation that follows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…edEmitter (default tracing)

Replaces the hand-rolled event loop in _pydantic_ai_async.py with a
three-line delegation to UnifiedEmitter.auto_send_turn:

  turn = PydanticAITurn(stream, model=None, tracing_handler=tracing_handler)
  emitter = UnifiedEmitter(task_id=task_id, trace_id=None, parent_span_id=None)
  return (await emitter.auto_send_turn(turn)).final_text

Public signature unchanged: stream_pydantic_ai_events(stream, task_id, tracing_handler=None) -> str.

Supporting changes:

- _pydantic_ai_turn.py: add optional tracing_handler arg (threaded to
  convert_pydantic_ai_to_agentex_events); add _coalesce_tool_requests()
  which converts Start(tool_request)+deltas+Done into Full(tool_request)
  so auto_send receives tool messages in the shape it expects (Option A:
  no streaming of argument tokens in the async/temporal path).

- auto_send.py: reset final_text_parts on Start(text) so multi-step
  runs return only the last text segment, matching stream_langgraph_events
  and the existing stream_pydantic_ai_events convention.

Wire shape change (AGX1-373 accepted envelope change):
  Before: tool messages via adk.messages.create
  After:  tool messages via streaming_task_message_context open+close pairs
  Logical content (tool_call_id, name, arguments, result) is identical;
  only the delivery channel changed.

Test updates: all test assertions updated to the new delivery channel.
Two tool_call_with_*_args tests updated to include PartDeltaEvent (the
realistic pydantic-ai event sequence for streamed JSON args).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…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>
…ing 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>
Register 4 pydantic-ai conformance fixtures (text-only, single tool call,
reasoning block, multi-step) that drive both yield_events and auto_send
channels and assert logical-delivery + span-signal equivalence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… live-matrix rows

Add 3 offline integration tests (TestModel + fake streaming/tracing, no API keys or
live infra needed) that prove the unified harness surface is correctly wired for each
delivery channel:

- test_harness_pydantic_ai_sync.py  — yield_turn path (12 tests): event ordering
  (tool_request before tool_response before text), accumulated text, Start/Done
  pairing, SpanDeriver wiring (OpenSpan/CloseSpan for tool calls on sync path).
- test_harness_pydantic_ai_async.py — auto_send_turn path (13 tests): message
  ordering, ToolRequestContent/ToolResponseContent content verification, matching
  tool_call_ids, final_text, context open/close lifecycle; documents that span
  derivation is suppressed when coalesce_tool_requests=True (AGX1-377 note).
- test_harness_pydantic_ai_temporal.py — TemporalAgent event_stream_handler path
  (12 tests + 1 intentional skip): drives TemporalAgent.run_stream_events offline,
  feeds into _fake_stream_pydantic_ai_events (PydanticAITurn + UnifiedEmitter with
  injected FakeStreaming), asserts same canonical message order; skip placeholder
  documents what requires live Temporal+Redis infra.

Enable harness-integration.yml live-matrix job (was `if: false`) with a 3-way
matrix over [sync, async, temporal], each running its test file via ./scripts/test.
Add test file glob to PR path trigger so the workflow re-runs when tests change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ing the unified surface

Add 3 minimal, deployable tutorial agent projects, each a tiny pydantic-ai agent
with one get_weather(city) tool whose message handler goes through the unified
harness surface (UnifiedEmitter + PydanticAITurn) EXPLICITLY:

- examples/tutorials/00_sync/harness_pydantic_ai (s-harness-pydantic-ai)
  sync ACP: `async for ev in emitter.yield_turn(PydanticAITurn(stream, model=...))`.
  Unlike 040_pydantic_ai (bare converter), this gives the sync channel real
  unified-yield coverage (coalesce off → tool-call arg-token streaming + auto
  span derivation under the per-turn span).

- examples/tutorials/10_async/00_base/harness_pydantic_ai (ab-harness-pydantic-ai)
  async ACP: `await emitter.auto_send_turn(PydanticAITurn(..., coalesce_tool_requests=True))`
  called directly (not via stream_pydantic_ai_events). Persists pydantic-ai
  message history via adk.state.

- examples/tutorials/10_async/10_temporal/harness_pydantic_ai (at-harness-pydantic-ai)
  temporal: TemporalAgent event_stream_handler builds a UnifiedEmitter from
  RunContext.deps and calls auto_send_turn inside the model activity. Durable
  workflow + run_worker structured like the temporal-pydantic-ai template.

Each UnifiedEmitter is constructed from the ACP/streaming context (task_id +
trace_id + parent_span_id) so tracing is automatic.

CI discovery: both agentex-tutorials-test.yml and build-and-push-tutorial-agent.yml
discover agents dynamically via `find examples/tutorials -name manifest.yaml`, so
the 3 agents are picked up with no workflow edits. Directory placement keeps the
build-and-push ACP-type inference (`*10_async*` → async) correct: sync under
00_sync, async/temporal under 10_async. Each ships tests/test_agent.py (required
by the build validator) as the live integration test.

Verified structurally: all 3 manifests parse; `from project.acp import acp`
imports cleanly for all 3 under CI-style env; temporal agent/workflow/run_worker
import; the sync handler driven offline with TestModel emits the expected
tool_request → tool_response → text sequence through yield_turn.

Keeps the 3 offline integration tests and the harness-integration.yml live-matrix
from the previous commit. tests/lib/core/harness + tests/lib/adk: 230 passed, 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fix 22 pyright errors introduced in PR 4's new test files:
- isinstance narrowing before union-member attribute access (ToolRequestDelta.arguments_delta,
  TextDelta.text_delta, ToolResponseContent.content, FunctionToolResultEvent.part.content)
- reportReturnType in _run_yield_turn: hoist result variable out of async-with
- reportImplicitOverride on _RecordingTracer.handle: add @OverRide
- reportMissingImports in conformance tests: switch absolute tests.lib... imports to
  relative .runner imports so pyright's executionEnvironments root matches

All 230 tests pass on 3.12 and 3.13. Ruff: clean.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Folds the plan doc (previously the separate #413) into this PR so plan +
implementation land together.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation auto_send delivers streamed tool requests natively (AGX1-377/378)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@declan-scale declan-scale force-pushed the declan-scale/pr4-pydantic-ai branch from 2c4fc88 to a0f4fd2 Compare June 18, 2026 21:19
@declan-scale

Copy link
Copy Markdown
Contributor Author

@greptile review

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