Skip to content

Commit bf2b243

Browse files
Python: Address A2A channel CI failures and review feedback
- Fix HostAgentExecutor streaming artifact coalescing by using one stable per-task artifact id, even when update.message_id is missing or varies - Add streaming fallback to text updates when contents are empty - Fix workflow result projection in non-streaming mode by consuming get_outputs() values in addition to response.messages - Add _value_to_parts helper to convert workflow outputs and fallback values into A2A Parts - Expand tests to validate stable artifact ids and workflow output projection from HostedRunResult[WorkflowRunResult] - Harden A2A test fakes for strict typing (agent protocol shape, context/request casts, event queue override) - Add package-local test dependency group with uvicorn to make test execution self-contained Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a5da807 commit bf2b243

4 files changed

Lines changed: 104 additions & 24 deletions

File tree

python/packages/hosting-a2a/agent_framework_hosting_a2a/_executor.py

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from a2a.server.tasks import TaskUpdater
2323
from a2a.types import Part, Task, TaskState
2424
from agent_framework import Content
25+
from agent_framework import Message as AFMessage
2526
from agent_framework_hosting import (
2627
ChannelContext,
2728
ChannelIdentity,
@@ -59,6 +60,17 @@ def _contents_to_parts(contents: list[Content]) -> list[Part]:
5960
return parts
6061

6162

63+
def _value_to_parts(value: Any) -> list[Part]:
64+
"""Convert workflow outputs and fallback values into A2A parts."""
65+
if isinstance(value, Content):
66+
return _contents_to_parts([value])
67+
if isinstance(value, AFMessage):
68+
return _contents_to_parts(list(value.contents))
69+
if isinstance(value, str):
70+
return [Part(text=value)]
71+
return [Part(text=str(value))]
72+
73+
6274
class HostAgentExecutor(AgentExecutor):
6375
"""A2A executor that drives the hosted target through :class:`ChannelContext`."""
6476

@@ -158,11 +170,13 @@ async def _run(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_
158170
)
159171
response: Any = result.result
160172
messages: list[Any] = list(getattr(response, "messages", None) or [])
173+
get_outputs = cast("Any", getattr(response, "get_outputs", None))
174+
if callable(get_outputs):
175+
messages.extend(cast("list[Any]", get_outputs()))
161176
for message in messages:
162177
if getattr(message, "role", None) == "user":
163178
continue
164-
contents: list[Content] = list(getattr(message, "contents", None) or [])
165-
parts = _contents_to_parts(contents)
179+
parts = _value_to_parts(message)
166180
if parts:
167181
await updater.update_status(
168182
state=TaskState.TASK_STATE_WORKING,
@@ -171,7 +185,8 @@ async def _run(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_
171185

172186
async def _run_stream(self, request: ChannelRequest, updater: TaskUpdater, *, protocol_request: Any) -> None:
173187
"""Streaming: publish incremental updates as task artifacts."""
174-
streamed_ids: set[str] = set()
188+
stream_artifact_id = f"{request.attributes.get('task_id', 'stream')}:stream"
189+
appended = False
175190
stream = await self._ctx.run_stream(
176191
request,
177192
run_hook=self._run_hook,
@@ -182,14 +197,15 @@ async def _run_stream(self, request: ChannelRequest, updater: TaskUpdater, *, pr
182197
async for update in stream:
183198
contents: list[Content] = list(getattr(update, "contents", None) or [])
184199
parts = _contents_to_parts(contents)
200+
text = getattr(update, "text", None)
201+
if not parts and isinstance(text, str) and text:
202+
parts = [Part(text=text)]
185203
if not parts:
186204
continue
187-
message_id: str | None = getattr(update, "message_id", None)
188205
await updater.add_artifact(
189206
parts=parts,
190-
artifact_id=message_id,
191-
append=True if message_id is not None and message_id in streamed_ids else None,
207+
artifact_id=stream_artifact_id,
208+
append=True if appended else None,
192209
)
193-
if message_id is not None:
194-
streamed_ids.add(message_id)
210+
appended = True
195211
await stream.get_final_response()

python/packages/hosting-a2a/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,8 @@ cmd = 'pytest -m "not integration" --cov=agent_framework_hosting_a2a --cov-repor
7979
[build-system]
8080
requires = ["flit-core >= 3.11,<4.0"]
8181
build-backend = "flit_core.buildapi"
82+
83+
[dependency-groups]
84+
dev = [
85+
"uvicorn[standard]>=0.34.0",
86+
]

python/packages/hosting-a2a/tests/hosting_a2a/test_channel.py

Lines changed: 67 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from collections.abc import AsyncIterator, Awaitable
99
from contextlib import asynccontextmanager
1010
from dataclasses import dataclass, field
11-
from typing import Any
11+
from typing import Any, cast
1212

1313
import pytest
1414
import uvicorn
@@ -126,7 +126,6 @@ def __init__(self) -> None:
126126

127127
async def enqueue_event(self, event: Any) -> None:
128128
self.events.append(event)
129-
await super().enqueue_event(event)
130129

131130

132131
class _FakeRequestContext:
@@ -147,13 +146,20 @@ def get_user_input(self) -> str:
147146

148147

149148
class _HostedAgent:
150-
name = "HostedAssistant"
151-
description = "A hosted test assistant."
149+
id = "hosted-agent"
150+
name: str | None = "HostedAssistant"
151+
description: str | None = "A hosted test assistant."
152152

153153
async def run(self, messages: Any = None, *, stream: bool = False, **_kwargs: Any) -> AgentResponse[Any]:
154154
text = messages.text if isinstance(messages, AFMessage) else str(messages)
155155
return AgentResponse(messages=[AFMessage(role="assistant", contents=[Content.from_text(text=f"host: {text}")])])
156156

157+
def create_session(self, *, session_id: str | None = None) -> Any:
158+
return {"session_id": session_id}
159+
160+
def get_session(self, service_session_id: str, *, session_id: str | None = None) -> Any:
161+
return {"service_session_id": service_session_id, "session_id": session_id}
162+
157163

158164
@asynccontextmanager
159165
async def _serve_app(app: ASGIApp, *, port: int) -> AsyncIterator[str]:
@@ -182,6 +188,18 @@ def _status_states(events: list[Any]) -> list[int]:
182188
return states
183189

184190

191+
def _status_texts(events: list[Any]) -> list[str]:
192+
texts: list[str] = []
193+
for event in events:
194+
status = getattr(event, "status", None)
195+
message = getattr(status, "message", None)
196+
for part in cast("list[Any]", getattr(message, "parts", None) or []):
197+
text = getattr(part, "text", None)
198+
if isinstance(text, str):
199+
texts.append(text)
200+
return texts
201+
202+
185203
# --------------------------------------------------------------------------- #
186204
# A2AChannel tests #
187205
# --------------------------------------------------------------------------- #
@@ -195,7 +213,7 @@ def test_default_name_and_root_path() -> None:
195213

196214
def test_build_agent_card_defaults_from_target() -> None:
197215
channel = A2AChannel(url="https://example.com/")
198-
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
216+
card = channel._build_agent_card(cast(Any, _FakeContext()))
199217
assert card.name == "Assistant"
200218
assert card.description == "A helpful assistant."
201219
assert card.capabilities.streaming is True
@@ -208,21 +226,21 @@ def test_build_agent_card_accepts_supported_interfaces() -> None:
208226
AgentInterface(url="https://example.com/grpc", protocol_binding="GRPC"),
209227
]
210228
channel = A2AChannel(supported_interfaces=interfaces)
211-
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
229+
card = channel._build_agent_card(cast(Any, _FakeContext()))
212230
assert card.supported_interfaces == interfaces
213231

214232

215233
def test_build_agent_card_override_wins() -> None:
216234
custom = AgentCard(name="Custom", description="custom card", version="9.9.9")
217235
channel = A2AChannel(agent_card=custom)
218-
card = channel._build_agent_card(_FakeContext()) # type: ignore[arg-type]
236+
card = channel._build_agent_card(cast(Any, _FakeContext()))
219237
assert card.name == "Custom"
220238
assert card.version == "9.9.9"
221239

222240

223241
def test_contribute_returns_card_and_jsonrpc_routes() -> None:
224242
channel = A2AChannel(url="https://example.com/")
225-
contribution = channel.contribute(_FakeContext()) # type: ignore[arg-type]
243+
contribution = channel.contribute(cast(Any, _FakeContext()))
226244
assert isinstance(contribution, ChannelContribution)
227245
paths = {getattr(r, "path", None) for r in contribution.routes}
228246
assert "/.well-known/agent-card.json" in paths
@@ -236,11 +254,11 @@ def test_contribute_returns_card_and_jsonrpc_routes() -> None:
236254

237255
async def test_execute_routes_through_host_and_completes() -> None:
238256
ctx = _FakeContext(reply="hi back")
239-
executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=False) # type: ignore[arg-type]
257+
executor = HostAgentExecutor(cast(Any, ctx), channel_name="a2a", streaming=False)
240258
queue = _RecordingEventQueue()
241259
request_context = _FakeRequestContext(context_id="conv-1", text="hello")
242260

243-
await executor.execute(request_context, queue) # type: ignore[arg-type]
261+
await executor.execute(cast(Any, request_context), queue)
244262

245263
# Routed through the host with the context id mapped onto the session.
246264
assert len(ctx.requests) == 1
@@ -257,31 +275,34 @@ async def test_execute_routes_through_host_and_completes() -> None:
257275

258276
async def test_execute_streaming_emits_artifacts() -> None:
259277
ctx = _FakeContext(chunks=["foo", "bar"])
260-
executor = HostAgentExecutor(ctx, channel_name="a2a", streaming=True) # type: ignore[arg-type]
278+
executor = HostAgentExecutor(cast(Any, ctx), channel_name="a2a", streaming=True)
261279
queue = _RecordingEventQueue()
262280
request_context = _FakeRequestContext(context_id="conv-2", text="hello")
263281

264-
await executor.execute(request_context, queue) # type: ignore[arg-type]
282+
await executor.execute(cast(Any, request_context), queue)
265283

266284
artifact_events = [e for e in queue.events if getattr(e, "artifact", None)]
267285
assert artifact_events, "expected at least one artifact update event"
286+
artifact_ids = {getattr(getattr(e, "artifact", None), "artifact_id", None) for e in artifact_events}
287+
assert len(artifact_ids) == 1
288+
assert None not in artifact_ids
268289
assert ctx.requests[0].stream is True
269290
assert TaskState.TASK_STATE_COMPLETED in _status_states(queue.events)
270291

271292

272293
async def test_execute_requires_context_id() -> None:
273294
ctx = _FakeContext()
274-
executor = HostAgentExecutor(ctx, channel_name="a2a") # type: ignore[arg-type]
295+
executor = HostAgentExecutor(cast(Any, ctx), channel_name="a2a")
275296
queue = _RecordingEventQueue()
276297
request_context = _FakeRequestContext(context_id="x", text="hello")
277-
request_context.context_id = None # type: ignore[assignment]
298+
cast(Any, request_context).context_id = None
278299

279300
with pytest.raises(ValueError, match="Context ID"):
280-
await executor.execute(request_context, queue) # type: ignore[arg-type]
301+
await executor.execute(cast(Any, request_context), queue)
281302

282303

283304
async def test_a2a_agent_can_call_hosted_channel(unused_tcp_port: int) -> None:
284-
host = AgentFrameworkHost(target=_HostedAgent(), channels=[A2AChannel(streaming=False)])
305+
host = AgentFrameworkHost(target=cast(Any, _HostedAgent()), channels=[A2AChannel(streaming=False)])
285306

286307
async with (
287308
_serve_app(host.app, port=unused_tcp_port) as base_url,
@@ -295,6 +316,36 @@ async def test_a2a_agent_can_call_hosted_channel(unused_tcp_port: int) -> None:
295316
assert response.messages[0].text == "host: hello"
296317

297318

319+
async def test_execute_projects_workflow_outputs() -> None:
320+
class _WorkflowResult:
321+
value = None
322+
323+
def get_outputs(self) -> list[AFMessage]:
324+
return [AFMessage(role="assistant", contents=[Content.from_text("workflow output")])]
325+
326+
class _WorkflowContext(_FakeContext):
327+
async def run(
328+
self,
329+
request: ChannelRequest,
330+
*,
331+
run_hook: Any | None = None,
332+
protocol_request: Any | None = None,
333+
response_hook: Any | None = None,
334+
channel_name: str | None = None,
335+
) -> HostedRunResult[Any]:
336+
self.requests.append(request)
337+
return HostedRunResult(_WorkflowResult())
338+
339+
ctx = _WorkflowContext()
340+
executor = HostAgentExecutor(cast(Any, ctx), channel_name="a2a", streaming=False)
341+
queue = _RecordingEventQueue()
342+
request_context = _FakeRequestContext(context_id="conv-workflow", text="hello")
343+
344+
await executor.execute(cast(Any, request_context), queue)
345+
346+
assert "workflow output" in _status_texts(queue.events)
347+
348+
298349
def test_contents_to_parts_conversion() -> None:
299350
from agent_framework_hosting_a2a._executor import _contents_to_parts
300351

python/uv.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)