Skip to content

Commit 3cc0326

Browse files
declan-scaleclaude
andcommitted
feat(harness): auto_send delivery adapter (canonical stream -> adk.streaming + tracing)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dab044f commit 3cc0326

2 files changed

Lines changed: 366 additions & 0 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Auto-send delivery: canonical stream -> adk.streaming side effects + tracing."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any, AsyncIterator
6+
7+
from agentex.types.task_message_update import (
8+
StreamTaskMessageDelta,
9+
StreamTaskMessageDone,
10+
StreamTaskMessageFull,
11+
StreamTaskMessageStart,
12+
)
13+
14+
from agentex.lib.core.harness.span_derivation import SpanDeriver
15+
from agentex.lib.core.harness.tracer import SpanTracer
16+
from agentex.lib.core.harness.types import StreamTaskMessage, TurnResult, TurnUsage
17+
18+
19+
async def auto_send(
20+
events: AsyncIterator[StreamTaskMessage],
21+
task_id: str,
22+
tracer: SpanTracer | None = None,
23+
streaming: Any = None,
24+
usage: TurnUsage | None = None,
25+
) -> TurnResult:
26+
"""Push the canonical stream to the task stream via adk.streaming.
27+
28+
Opens a streaming context per text/reasoning message, streams deltas via
29+
ctx.stream_update, and closes via ctx.close() on Done. Posts tool
30+
request/response full messages by opening a context with the content and
31+
closing it immediately (no deltas). Derives and traces spans from the same
32+
stream. Returns the accumulated final text + usage.
33+
34+
Mirrors the open/close/stream_update pattern from
35+
src/agentex/lib/adk/_modules/_langgraph_async.py:
36+
- context opened via streaming_task_message_context(...).__aenter__()
37+
- context closed via ctx.close() (not __aexit__)
38+
- deltas pushed as StreamTaskMessageDelta with parent_task_message set
39+
from ctx.task_message
40+
41+
For async + temporal agents (call from inside an activity).
42+
"""
43+
if streaming is None:
44+
from agentex.lib import adk
45+
46+
streaming = adk.streaming
47+
48+
deriver = SpanDeriver() if tracer is not None else None
49+
final_text_parts: list[str] = []
50+
current_ctx: Any = None
51+
current_kind: str | None = None # "text" | "reasoning"
52+
53+
async def _close_current() -> None:
54+
nonlocal current_ctx, current_kind
55+
if current_ctx is not None:
56+
await current_ctx.close()
57+
current_ctx = None
58+
current_kind = None
59+
60+
try:
61+
async for event in events:
62+
if deriver is not None:
63+
for signal in deriver.observe(event):
64+
await tracer.handle(signal) # type: ignore[union-attr]
65+
66+
if isinstance(event, StreamTaskMessageStart):
67+
ctype = getattr(event.content, "type", None)
68+
if ctype in ("text", "reasoning"):
69+
await _close_current()
70+
ctx = streaming.streaming_task_message_context(
71+
task_id=task_id,
72+
initial_content=event.content,
73+
)
74+
current_ctx = await ctx.__aenter__()
75+
current_kind = ctype
76+
77+
elif isinstance(event, StreamTaskMessageDelta):
78+
if current_ctx is not None and event.delta is not None:
79+
# Reconstruct the delta with parent_task_message set from
80+
# the context's task_message (mirrors _langgraph_async.py
81+
# lines 72-78 and 117-127).
82+
delta_with_parent = StreamTaskMessageDelta(
83+
parent_task_message=current_ctx.task_message,
84+
delta=event.delta,
85+
type="delta",
86+
index=event.index,
87+
)
88+
await current_ctx.stream_update(delta_with_parent)
89+
if (
90+
getattr(event.delta, "type", None) == "text"
91+
and event.delta.text_delta
92+
):
93+
final_text_parts.append(event.delta.text_delta)
94+
95+
elif isinstance(event, StreamTaskMessageDone):
96+
await _close_current()
97+
98+
elif isinstance(event, StreamTaskMessageFull):
99+
# Full messages (tool_request / tool_response): close any open
100+
# streaming context first, then post the full message by opening
101+
# a context with the content and closing it immediately
102+
# (no deltas; StreamingTaskMessageContext.close() persists
103+
# initial_content when the accumulator is empty).
104+
await _close_current()
105+
ctx = streaming.streaming_task_message_context(
106+
task_id=task_id,
107+
initial_content=event.content,
108+
)
109+
full_ctx = await ctx.__aenter__()
110+
await full_ctx.close()
111+
112+
finally:
113+
await _close_current()
114+
if deriver is not None:
115+
for signal in deriver.flush():
116+
await tracer.handle(signal) # type: ignore[union-attr]
117+
118+
return TurnResult(final_text="".join(final_text_parts), usage=usage or TurnUsage())
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
"""Tests for auto_send delivery adapter.
2+
3+
The fake mirrors the real StreamingTaskMessageContext API exactly:
4+
- streaming_task_message_context(...) returns a context object (synchronously)
5+
- open the context via __aenter__ (returns self after creating the task message)
6+
- stream deltas via ctx.stream_update(StreamTaskMessageDelta(...))
7+
- close via ctx.close() (NOT __aexit__)
8+
9+
This mirrors _langgraph_async.py lines 62-78 and 100-127.
10+
"""
11+
12+
import types as _types
13+
14+
import pytest
15+
16+
from agentex.lib.core.harness.auto_send import auto_send
17+
from agentex.lib.core.harness.tracer import SpanTracer
18+
from agentex.types.task_message import TaskMessage
19+
from agentex.types.task_message_update import (
20+
StreamTaskMessageStart,
21+
StreamTaskMessageDelta,
22+
StreamTaskMessageDone,
23+
StreamTaskMessageFull,
24+
)
25+
from agentex.types.text_content import TextContent
26+
from agentex.types.task_message_delta import TextDelta
27+
from agentex.types.tool_request_content import ToolRequestContent
28+
from agentex.types.tool_response_content import ToolResponseContent
29+
30+
31+
class _FakeCtx:
32+
"""Mirrors StreamingTaskMessageContext: __aenter__ opens (returns self with task_message set),
33+
close() closes. stream_update records the call.
34+
35+
task_message is a real TaskMessage instance so that auto_send can use it
36+
as parent_task_message in StreamTaskMessageDelta without Pydantic validation errors.
37+
"""
38+
39+
def __init__(self, sink, content_type, initial_content):
40+
self.sink = sink
41+
self.content_type = content_type
42+
# Real TaskMessage so StreamTaskMessageDelta(parent_task_message=...) passes validation
43+
self.task_message = TaskMessage(
44+
id="msg-1", task_id="task1", content=initial_content
45+
)
46+
47+
async def __aenter__(self):
48+
self.sink.append(("open", self.content_type))
49+
return self
50+
51+
async def __aexit__(self, *a):
52+
# __aexit__ delegates to close in the real impl; keep for safety
53+
await self.close()
54+
return False
55+
56+
async def close(self):
57+
self.sink.append(("close", self.content_type))
58+
59+
async def stream_update(self, update):
60+
self.sink.append(("update", update))
61+
return update
62+
63+
64+
class _FakeStreaming:
65+
"""Mirrors StreamingService: streaming_task_message_context returns a context object."""
66+
67+
def __init__(self):
68+
self.sink = []
69+
70+
def streaming_task_message_context(
71+
self, task_id, initial_content, streaming_mode="coalesced", created_at=None
72+
):
73+
ctype = getattr(initial_content, "type", None)
74+
self.sink.append(("ctx", ctype))
75+
return _FakeCtx(self.sink, ctype, initial_content)
76+
77+
78+
async def _gen(events):
79+
for e in events:
80+
yield e
81+
82+
83+
# ---------------------------------------------------------------------------
84+
# Test 1: text streaming — open, stream deltas, close; return accumulated text
85+
# ---------------------------------------------------------------------------
86+
87+
@pytest.mark.asyncio
88+
async def test_auto_send_streams_text_and_returns_final_text():
89+
streaming = _FakeStreaming()
90+
events = [
91+
StreamTaskMessageStart(
92+
type="start", index=0,
93+
content=TextContent(type="text", author="agent", content=""),
94+
),
95+
StreamTaskMessageDelta(
96+
type="delta", index=0,
97+
delta=TextDelta(type="text", text_delta="Hel"),
98+
),
99+
StreamTaskMessageDelta(
100+
type="delta", index=0,
101+
delta=TextDelta(type="text", text_delta="lo"),
102+
),
103+
StreamTaskMessageDone(type="done", index=0),
104+
]
105+
result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming)
106+
107+
assert result.final_text == "Hello"
108+
109+
kinds = [s[0] for s in streaming.sink]
110+
# A context was created for the text content
111+
assert kinds[0] == "ctx"
112+
# It was opened and closed
113+
assert "open" in kinds
114+
assert "close" in kinds
115+
# Exactly two updates were streamed (one per delta)
116+
updates = [s for s in streaming.sink if s[0] == "update"]
117+
assert len(updates) == 2
118+
119+
120+
# ---------------------------------------------------------------------------
121+
# Test 2: tool_request Full + tool_response Full — each posts one full message
122+
# (open context with the content, no deltas, close immediately)
123+
# ---------------------------------------------------------------------------
124+
125+
@pytest.mark.asyncio
126+
async def test_auto_send_posts_full_tool_messages():
127+
streaming = _FakeStreaming()
128+
events = [
129+
StreamTaskMessageFull(
130+
type="full", index=0,
131+
content=ToolRequestContent(
132+
type="tool_request", author="agent",
133+
tool_call_id="c1", name="Bash", arguments={"cmd": "ls"},
134+
),
135+
),
136+
StreamTaskMessageFull(
137+
type="full", index=1,
138+
content=ToolResponseContent(
139+
type="tool_response", author="agent",
140+
tool_call_id="c1", name="Bash", content="file.py",
141+
),
142+
),
143+
]
144+
result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming)
145+
146+
assert result.final_text == ""
147+
148+
# One context per Full event
149+
ctx_events = [s for s in streaming.sink if s[0] == "ctx"]
150+
assert len(ctx_events) == 2
151+
content_types = [s[1] for s in ctx_events]
152+
assert "tool_request" in content_types
153+
assert "tool_response" in content_types
154+
155+
# Each context is opened and closed
156+
opens = [s for s in streaming.sink if s[0] == "open"]
157+
closes = [s for s in streaming.sink if s[0] == "close"]
158+
assert len(opens) == 2
159+
assert len(closes) == 2
160+
161+
# No stream_update calls (full messages have no deltas)
162+
updates = [s for s in streaming.sink if s[0] == "update"]
163+
assert len(updates) == 0
164+
165+
166+
# ---------------------------------------------------------------------------
167+
# Test 3: tracing — spans are derived and handed to the tracer
168+
# ---------------------------------------------------------------------------
169+
170+
class _RecordTracing:
171+
def __init__(self):
172+
self.started, self.ended = [], []
173+
174+
async def start_span(self, *, trace_id, name, input=None, parent_id=None, data=None, task_id=None):
175+
self.started.append(name)
176+
return _types.SimpleNamespace()
177+
178+
async def end_span(self, *, trace_id, span):
179+
self.ended.append(getattr(span, "output", None))
180+
181+
182+
@pytest.mark.asyncio
183+
async def test_auto_send_derives_tool_spans_via_tracer():
184+
fake_tracing = _RecordTracing()
185+
tracer = SpanTracer(trace_id="t", parent_span_id="p", tracing=fake_tracing)
186+
streaming = _FakeStreaming()
187+
188+
events = [
189+
StreamTaskMessageStart(
190+
type="start", index=0,
191+
content=ToolRequestContent(
192+
type="tool_request", author="agent",
193+
tool_call_id="c1", name="Bash", arguments={},
194+
),
195+
),
196+
StreamTaskMessageDone(type="done", index=0),
197+
StreamTaskMessageFull(
198+
type="full", index=1,
199+
content=ToolResponseContent(
200+
type="tool_response", author="agent",
201+
tool_call_id="c1", name="Bash", content="ok",
202+
),
203+
),
204+
]
205+
206+
result = await auto_send(
207+
_gen(events), task_id="task1", tracer=tracer, streaming=streaming
208+
)
209+
210+
assert result.final_text == ""
211+
assert fake_tracing.started == ["Bash"]
212+
assert fake_tracing.ended == ["ok"]
213+
214+
215+
# ---------------------------------------------------------------------------
216+
# Test 4: text followed by a tool Full — text context is closed before Full
217+
# ---------------------------------------------------------------------------
218+
219+
@pytest.mark.asyncio
220+
async def test_auto_send_closes_text_context_before_full_message():
221+
streaming = _FakeStreaming()
222+
events = [
223+
StreamTaskMessageStart(
224+
type="start", index=0,
225+
content=TextContent(type="text", author="agent", content=""),
226+
),
227+
StreamTaskMessageDelta(
228+
type="delta", index=0,
229+
delta=TextDelta(type="text", text_delta="Hi"),
230+
),
231+
StreamTaskMessageDone(type="done", index=0),
232+
StreamTaskMessageFull(
233+
type="full", index=1,
234+
content=ToolRequestContent(
235+
type="tool_request", author="agent",
236+
tool_call_id="c2", name="read_file", arguments={},
237+
),
238+
),
239+
]
240+
result = await auto_send(_gen(events), task_id="task1", tracer=None, streaming=streaming)
241+
assert result.final_text == "Hi"
242+
243+
# Verify ordering: text ctx opens, updates, closes; then tool_request ctx opens, closes
244+
event_sequence = [(s[0], s[1]) for s in streaming.sink]
245+
text_open_idx = next(i for i, s in enumerate(event_sequence) if s == ("open", "text"))
246+
text_close_idx = next(i for i, s in enumerate(event_sequence) if s == ("close", "text"))
247+
tool_open_idx = next(i for i, s in enumerate(event_sequence) if s == ("open", "tool_request"))
248+
assert text_open_idx < text_close_idx < tool_open_idx

0 commit comments

Comments
 (0)