distributed-a2a 0.1.26__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {distributed_a2a-0.1.26/distributed_a2a.egg-info → distributed_a2a-0.2.1}/PKG-INFO +1 -1
  2. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/__init__.py +3 -1
  3. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/agent.py +21 -3
  4. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/client.py +67 -10
  5. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/executors.py +52 -7
  6. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1/distributed_a2a.egg-info}/PKG-INFO +1 -1
  7. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/SOURCES.txt +2 -0
  8. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/pyproject.toml +1 -1
  9. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/tests/test_app.py +7 -4
  10. distributed_a2a-0.2.1/tests/test_executor_files.py +182 -0
  11. distributed_a2a-0.2.1/tests/test_files.py +237 -0
  12. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/tests/test_rejection.py +11 -10
  13. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/tests/test_timeout.py +13 -7
  14. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/LICENSE +0 -0
  15. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/MANIFEST.in +0 -0
  16. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/README.md +0 -0
  17. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/config.py +0 -0
  18. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/model.py +0 -0
  19. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/py.typed +0 -0
  20. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry.py +0 -0
  21. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/__init__.py +0 -0
  22. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/bootstrap.py +0 -0
  23. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/dynamo_db.py +0 -0
  24. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/in_memory_registry_storage.py +0 -0
  25. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/model.py +0 -0
  26. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/storage.py +0 -0
  27. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/router.py +0 -0
  28. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/schemas/agent-schema.json +0 -0
  29. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/schemas/router-agent-schema.json +0 -0
  30. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/server.py +0 -0
  31. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/dependency_links.txt +0 -0
  32. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/requires.txt +0 -0
  33. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/top_level.txt +0 -0
  34. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/requirements.txt +0 -0
  35. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/setup.cfg +0 -0
  36. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/setup.py +0 -0
  37. {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/tests/test_client.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distributed_a2a
3
- Version: 0.1.26
3
+ Version: 0.2.1
4
4
  Summary: A library for building A2A agents with routing capabilities
5
5
  Home-page: https://github.com/Barra-Technologies/distributed-a2a
6
6
  Author: Fabian Bell
@@ -1,4 +1,4 @@
1
- from .client import A2ATimeoutError, RoutingA2AClient
1
+ from .client import A2ATimeoutError, AgentReply, FileRef, RoutingA2AClient
2
2
  from .model import (AgentConfig, AgentItem, CardConfig, LLMConfig,
3
3
  RegistryConfig, RegistryItemConfig, RouterConfig,
4
4
  RouterItem, SkillConfig)
@@ -15,6 +15,8 @@ __all__ = [
15
15
  "load_router",
16
16
  "RoutingA2AClient",
17
17
  "A2ATimeoutError",
18
+ "AgentReply",
19
+ "FileRef",
18
20
  "load_registry",
19
21
  "AgentConfig",
20
22
  "SkillConfig",
@@ -3,7 +3,7 @@ from typing import Any, Literal, cast
3
3
 
4
4
  from a2a.types import TaskState
5
5
  from langchain.agents import create_agent
6
- from langchain_core.messages import HumanMessage
6
+ from langchain_core.messages import BaseMessage, HumanMessage
7
7
  from langchain_core.runnables import RunnableConfig
8
8
  from langchain_core.tools import BaseTool
9
9
  from langgraph.checkpoint.base import BaseCheckpointSaver
@@ -38,6 +38,21 @@ class StringResponse(AgentResponse):
38
38
  response: str = Field(description="The main response to be returned to the user")
39
39
 
40
40
 
41
+ class AgentInvocation[ResponseT: AgentResponse](BaseModel):
42
+ """Result of invoking a :class:`StatusAgent`.
43
+
44
+ Exposes both the structured response (LLM-visible content the agent decided
45
+ to return) and the raw message list produced by the underlying LangGraph
46
+ agent, so callers can mine ``ToolMessage.artifact`` for out-of-band binary
47
+ payloads (e.g. files returned by MCP tools) without those bytes ever passing
48
+ through the LLM context.
49
+ """
50
+
51
+ structured: ResponseT
52
+ messages: list[BaseMessage]
53
+ model_config = {"arbitrary_types_allowed": True}
54
+
55
+
41
56
  class StatusAgent[ResponseT: AgentResponse]:
42
57
 
43
58
  def __init__(self,
@@ -77,7 +92,7 @@ class StatusAgent[ResponseT: AgentResponse]:
77
92
 
78
93
  async def __call__(self,
79
94
  message: str,
80
- context_id: str | None = None) -> ResponseT:
95
+ context_id: str | None = None) -> AgentInvocation[ResponseT]:
81
96
  config: RunnableConfig = RunnableConfig(
82
97
  configurable={'thread_id': context_id}
83
98
  )
@@ -86,4 +101,7 @@ class StatusAgent[ResponseT: AgentResponse]:
86
101
  config
87
102
  )
88
103
  logging.info("agent response: %s", response)
89
- return cast(ResponseT, response['structured_response'])
104
+ return AgentInvocation[ResponseT](
105
+ structured=cast(ResponseT, response['structured_response']),
106
+ messages=list(response.get('messages', [])),
107
+ )
@@ -1,18 +1,48 @@
1
1
  import asyncio
2
2
  import json
3
3
  import time
4
+ from dataclasses import dataclass, field
4
5
  from uuid import uuid4
5
6
 
6
7
  import httpx
7
8
  from a2a.client import (A2ACardResolver, ClientConfig, ClientEvent,
8
9
  ClientFactory, create_text_message_object)
9
- from a2a.types import (AgentCard, Message, Part, Task, TaskQueryParams,
10
- TaskState, TextPart)
10
+ from a2a.types import (AgentCard, FilePart, FileWithBytes, FileWithUri,
11
+ Message, Part, Task, TaskQueryParams, TaskState,
12
+ TextPart)
11
13
 
12
14
  DEFAULT_MAX_POLLS = 50
13
15
  DEFAULT_POLL_INTERVAL = 1.0
14
16
 
15
17
 
18
+ @dataclass
19
+ class FileRef:
20
+ """A file payload received as part of an A2A agent reply.
21
+
22
+ Exactly one of ``bytes_b64`` (for ``FileWithBytes``) or ``uri`` (for
23
+ ``FileWithUri``) is populated. ``bytes_b64`` is the raw base64 string
24
+ delivered over the wire by the A2A SDK — the caller is responsible for
25
+ decoding before forwarding the bytes.
26
+ """
27
+
28
+ name: str
29
+ mime_type: str
30
+ bytes_b64: str = ""
31
+ uri: str | None = None
32
+
33
+
34
+ @dataclass
35
+ class AgentReply:
36
+ """Structured reply from a routing-aware agent.
37
+
38
+ Carries the user-visible text (if any) plus zero-or-more files that the
39
+ agent emitted out-of-band as ``FilePart`` artifacts.
40
+ """
41
+
42
+ text: str | None = None
43
+ files: list[FileRef] = field(default_factory=list)
44
+
45
+
16
46
  class A2ATimeoutError(Exception):
17
47
  """Raised when polling a remote agent task exceeds max_polls."""
18
48
 
@@ -69,8 +99,10 @@ class RemoteAgentConnection:
69
99
  response: Task = await self.agent_client.get_task(query_params)
70
100
  return response
71
101
 
72
- async def send_message(self, message_to_send: str, context_id: str,
73
- task_id: None | str = None) -> str | AgentCard | TaskState:
102
+ async def send_message(self,
103
+ message_to_send: str,
104
+ context_id: str,
105
+ task_id: None | str = None) -> AgentReply | AgentCard | TaskState:
74
106
  message: Message = create_text_message_object(content=message_to_send)
75
107
  message.message_id = str(uuid4())
76
108
  message.context_id = context_id
@@ -112,13 +144,36 @@ class RemoteAgentConnection:
112
144
  for artifact in response.artifacts or []:
113
145
  match artifact.name, artifact.parts:
114
146
  case 'routing_error', [Part(root=TextPart(text=error_msg)), *_]:
115
- return error_msg
147
+ return AgentReply(text=error_msg)
116
148
  case 'rejected', [Part(root=TextPart()), *_]:
117
149
  return TaskState.rejected
118
150
  case 'target_agent', [Part(root=TextPart(text=agent_card_str)), *_]:
119
151
  return AgentCard(**json.loads(agent_card_str))
120
- case 'current_result', [Part(root=TextPart(text=result)), *_]:
121
- return result
152
+
153
+ text_out: str | None = None
154
+ files_out: list[FileRef] = []
155
+ for artifact in response.artifacts or []:
156
+ for part in artifact.parts or []:
157
+ root = getattr(part, "root", None)
158
+ if isinstance(root, TextPart) and artifact.name == "current_result":
159
+ text_out = root.text
160
+ elif isinstance(root, FilePart):
161
+ root_file = root.file
162
+ if isinstance(root_file, FileWithBytes):
163
+ files_out.append(FileRef(
164
+ name=root_file.name or artifact.name or "file.bin",
165
+ mime_type=root_file.mime_type or "application/octet-stream",
166
+ bytes_b64=root_file.bytes,
167
+ ))
168
+ elif isinstance(root_file, FileWithUri):
169
+ files_out.append(FileRef(
170
+ name=root_file.name or artifact.name or "file.bin",
171
+ mime_type=root_file.mime_type or "application/octet-stream",
172
+ uri=root_file.uri,
173
+ ))
174
+
175
+ if text_out is not None or files_out:
176
+ return AgentReply(text=text_out, files=files_out)
122
177
 
123
178
  if task_state == TaskState.rejected:
124
179
  return TaskState.rejected
@@ -157,8 +212,10 @@ class RoutingA2AClient:
157
212
  await card_resolver.get_agent_card()
158
213
  )
159
214
 
160
- async def send_message(self, message: str, context_id: str, depth: int = 0,
161
- rejected_agents: list[str] | None = None) -> str:
215
+ async def send_message(self, message: str,
216
+ context_id: str,
217
+ depth: int = 0,
218
+ rejected_agents: list[str] | None = None) -> AgentReply:
162
219
  if depth > MAX_RECURSION_DEPTH:
163
220
  raise Exception("Maximum recursion depth exceeded. This is likely due to an infinite loop in your agent.")
164
221
 
@@ -182,7 +239,7 @@ class RoutingA2AClient:
182
239
  if rejection_msg not in message:
183
240
  message_to_send = f"{message}\n\n{rejection_msg}"
184
241
 
185
- agent_response: str | AgentCard | TaskState = await agent_connection.send_message(message_to_send, context_id)
242
+ agent_response: AgentReply | AgentCard | TaskState = await agent_connection.send_message(message_to_send, context_id)
186
243
  if isinstance(agent_response, AgentCard):
187
244
  if agent_response.url == self.current_card.url:
188
245
  raise Exception("Agent redirected to itself.")
@@ -4,12 +4,15 @@ from typing import Any
4
4
 
5
5
  from a2a.server.agent_execution import AgentExecutor, RequestContext
6
6
  from a2a.server.events import EventQueue
7
- from a2a.types import (Artifact, TaskArtifactUpdateEvent, TaskState,
8
- TaskStatus, TaskStatusUpdateEvent)
7
+ from a2a.types import (Artifact, FilePart, FileWithBytes, Part,
8
+ TaskArtifactUpdateEvent, TaskState, TaskStatus,
9
+ TaskStatusUpdateEvent)
9
10
  from a2a.utils import new_text_artifact
11
+ from langchain_core.messages import BaseMessage, ToolMessage
10
12
  from langchain_core.tools import BaseTool
11
13
  from langchain_mcp_adapters.client import MultiServerMCPClient
12
14
  from langgraph.checkpoint.base import BaseCheckpointSaver
15
+ from mcp.types import BlobResourceContents, EmbeddedResource, ImageContent
13
16
 
14
17
  from .agent import RoutingResponse, StatusAgent, StringResponse
15
18
  from .config import settings
@@ -19,6 +22,32 @@ from .registry import AgentRegistryLookupClient, McpRegistryLookup
19
22
  logger = logging.getLogger(__name__)
20
23
 
21
24
 
25
+ def _extract_file_parts(messages: list[BaseMessage]) -> list[tuple[str, FilePart]]:
26
+ out: list[tuple[str, FilePart]] = []
27
+ for message in messages:
28
+ if not isinstance(message, ToolMessage):
29
+ continue
30
+ artifact = getattr(message, "artifact", None)
31
+ if not artifact:
32
+ continue
33
+ for block in artifact:
34
+ if isinstance(block, EmbeddedResource) and isinstance(block.resource, BlobResourceContents):
35
+ uri_str = str(block.resource.uri)
36
+ name = uri_str.rsplit("/", 1)[-1] or "file.bin"
37
+ out.append((name, FilePart(file=FileWithBytes(
38
+ name=name,
39
+ mime_type=block.resource.mimeType or "application/octet-stream",
40
+ bytes=block.resource.blob,
41
+ ))))
42
+ elif isinstance(block, ImageContent):
43
+ out.append(("image", FilePart(file=FileWithBytes(
44
+ name="image",
45
+ mime_type=block.mimeType,
46
+ bytes=block.data,
47
+ ))))
48
+ return out
49
+
50
+
22
51
  class RoutingFailed(Exception):
23
52
  def __init__(self, message: str) -> None:
24
53
  super().__init__(message)
@@ -116,21 +145,36 @@ class RoutingAgentExecutor(AgentExecutor):
116
145
  task_id=context.task_id
117
146
  ))
118
147
  await self.reinitialize_agent_with_tools()
119
- agent_response: StringResponse = await self.agent(message=context.get_user_input(),
120
- context_id=context.context_id)
148
+ invocation = await self.agent(message=context.get_user_input(),
149
+ context_id=context.context_id)
150
+ agent_response: StringResponse = invocation.structured
121
151
 
122
152
  artifact: Artifact
153
+ file_parts: list[tuple[str, FilePart]] = []
123
154
  if agent_response.status == TaskState.rejected:
124
155
  artifact = await _route_request_to_matching_agent(self.routing_agent, self.agent_registry, context)
125
156
  else:
126
157
  logger.info(f"Request with id {context.context_id} was successfully processed by agent.")
158
+ file_parts = _extract_file_parts(invocation.messages)
127
159
  artifact = new_text_artifact(
128
160
  name='current_result',
129
161
  description='Result of request to agent.',
130
162
  text=f"*{self.agent_config.agent.card.name}*: {agent_response.response}"
131
163
  )
132
164
 
133
- # publish actual result
165
+ for name, file_part in file_parts:
166
+ await event_queue.enqueue_event(TaskArtifactUpdateEvent(
167
+ append=False,
168
+ last_chunk=False,
169
+ context_id=context.context_id,
170
+ task_id=context.task_id,
171
+ artifact=Artifact(
172
+ artifact_id=name,
173
+ name=name,
174
+ parts=[Part(root=file_part)],
175
+ ),
176
+ ))
177
+
134
178
  await event_queue.enqueue_event(TaskArtifactUpdateEvent(
135
179
  append=False,
136
180
  context_id=context.context_id,
@@ -297,8 +341,9 @@ class RoutingExecutor(AgentExecutor):
297
341
  async def _route_request_to_matching_agent(routing_agent: StatusAgent[RoutingResponse],
298
342
  agent_registry: AgentRegistryLookupClient,
299
343
  context: RequestContext) -> Artifact:
300
- routing_agent_response: RoutingResponse = await routing_agent(message=context.get_user_input(),
301
- context_id=context.context_id)
344
+ invocation = await routing_agent(message=context.get_user_input(),
345
+ context_id=context.context_id)
346
+ routing_agent_response: RoutingResponse = invocation.structured
302
347
  agent_name: str | None = routing_agent_response.agent_name
303
348
  logger.info(f"routing response received: {routing_agent_response}")
304
349
  if agent_name is None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: distributed_a2a
3
- Version: 0.1.26
3
+ Version: 0.2.1
4
4
  Summary: A library for building A2A agents with routing capabilities
5
5
  Home-page: https://github.com/Barra-Technologies/distributed-a2a
6
6
  Author: Fabian Bell
@@ -29,5 +29,7 @@ distributed_a2a/schemas/agent-schema.json
29
29
  distributed_a2a/schemas/router-agent-schema.json
30
30
  tests/test_app.py
31
31
  tests/test_client.py
32
+ tests/test_executor_files.py
33
+ tests/test_files.py
32
34
  tests/test_rejection.py
33
35
  tests/test_timeout.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "distributed_a2a"
7
- version = "0.1.26"
7
+ version = "0.2.1"
8
8
  description = "A library for building A2A agents with routing capabilities"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"
@@ -59,10 +59,12 @@ async def test_app_completed_path(fake_registry_server: str, fake_completed_llm:
59
59
  with FakeAgent(fake_registry_server, fake_completed_llm, "test-agent") as agent:
60
60
  # When
61
61
  client = RoutingA2AClient(initial_url=f"http://127.0.0.1:{agent.app_port}/{agent.name}")
62
- response = await client.send_message(message="Hello", context_id="test-context")
62
+ reply = await client.send_message(message="Hello", context_id="test-context")
63
63
 
64
64
  # Then: Check the response
65
- assert "This is a mock response from the fake OpenAI server." in response
65
+ assert reply.text is not None
66
+ assert "This is a mock response from the fake OpenAI server." in reply.text
67
+ assert reply.files == []
66
68
 
67
69
 
68
70
  @pytest.mark.asyncio
@@ -75,7 +77,8 @@ async def test_app_redirect_path(fake_registry_server: str, fake_completed_llm:
75
77
  client = RoutingA2AClient(initial_url=f"http://127.0.0.1:{first_agent.app_port}")
76
78
 
77
79
  # When
78
- response = await client.send_message(message="Hello", context_id="test-context")
80
+ reply = await client.send_message(message="Hello", context_id="test-context")
79
81
 
80
82
  # Then
81
- assert FINAL_RESPONSE in response
83
+ assert reply.text is not None
84
+ assert FINAL_RESPONSE in reply.text
@@ -0,0 +1,182 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from typing import Any
5
+
6
+ import pytest
7
+ from a2a.server.agent_execution import RequestContext
8
+ from a2a.server.events import EventQueue
9
+ from a2a.types import FilePart, FileWithBytes
10
+ from a2a.types import Message as A2AMessage
11
+ from a2a.types import (MessageSendParams, Part, Role, TaskArtifactUpdateEvent,
12
+ TaskState, TaskStatusUpdateEvent, TextPart)
13
+ from langchain_core.messages import BaseMessage, HumanMessage, ToolMessage
14
+ from mcp.types import BlobResourceContents, EmbeddedResource
15
+ from pydantic import AnyUrl
16
+
17
+ from distributed_a2a.agent import AgentInvocation, StringResponse
18
+ from distributed_a2a.executors import RoutingAgentExecutor
19
+
20
+
21
+ class _StubStatusAgent:
22
+ """Minimal stand-in for ``StatusAgent`` used in the executor."""
23
+
24
+ def __init__(self, response: StringResponse, messages: list[BaseMessage]) -> None:
25
+ self._response = response
26
+ self._messages = messages
27
+
28
+ async def __call__(self, message: str, context_id: str | None = None) -> AgentInvocation[StringResponse]:
29
+ return AgentInvocation[StringResponse](
30
+ structured=self._response,
31
+ messages=self._messages,
32
+ )
33
+
34
+
35
+ def _make_request_context() -> RequestContext:
36
+ msg = A2AMessage(
37
+ message_id="m-1",
38
+ context_id="ctx-1",
39
+ task_id="task-1",
40
+ role=Role.user,
41
+ parts=[Part(root=TextPart(text="render a CV please"))],
42
+ )
43
+ return RequestContext(
44
+ request=MessageSendParams(message=msg),
45
+ task_id="task-1",
46
+ context_id="ctx-1",
47
+ )
48
+
49
+
50
+ def _make_tool_message_with_docx(b64: str) -> ToolMessage:
51
+ return ToolMessage(
52
+ content='{"filename": "cv-foo.docx"}',
53
+ tool_call_id="call-1",
54
+ artifact=[
55
+ EmbeddedResource(
56
+ type="resource",
57
+ resource=BlobResourceContents(
58
+ uri=AnyUrl("cv://cv-foo.docx"),
59
+ mimeType=(
60
+ "application/vnd.openxmlformats-officedocument."
61
+ "wordprocessingml.document"
62
+ ),
63
+ blob=b64,
64
+ ),
65
+ ),
66
+ ],
67
+ )
68
+
69
+
70
+ async def _drain_queue(queue: EventQueue) -> list[Any]:
71
+ """Dequeue every event currently in ``queue`` without blocking.
72
+
73
+ Uses ``no_wait=True`` and stops on the first empty/closed exception. We
74
+ intentionally avoid ``await queue.close()`` because the graceful close
75
+ waits for ``queue.join()`` — which requires the consumer to call
76
+ ``task_done()`` for every enqueued item — and would otherwise deadlock
77
+ the test once we've finished dequeuing.
78
+ """
79
+ events: list[Any] = []
80
+ while True:
81
+ try:
82
+ evt = await queue.dequeue_event(no_wait=True)
83
+ except Exception:
84
+ break
85
+ events.append(evt)
86
+ queue.task_done()
87
+ return events
88
+
89
+
90
+ @pytest.mark.asyncio
91
+ async def test_executor_emits_file_part_event_for_embedded_resource(
92
+ monkeypatch: pytest.MonkeyPatch) -> None:
93
+ docx_b64 = base64.b64encode(b"PK\x03\x04 fake docx").decode("ascii")
94
+ tool_msg = _make_tool_message_with_docx(docx_b64)
95
+
96
+ # Build the executor without invoking its real __init__ (which requires
97
+ # API keys + registry network calls). We only need ``execute`` to use:
98
+ # - self.agent (stubbed)
99
+ # - self.reinitialize_agent_with_tools (stubbed no-op)
100
+ # - self.agent_config.agent.card.name (stubbed via SimpleNamespace)
101
+ from types import SimpleNamespace
102
+
103
+ executor = RoutingAgentExecutor.__new__(RoutingAgentExecutor)
104
+ executor.agent_config = SimpleNamespace( # type: ignore[assignment]
105
+ agent=SimpleNamespace(card=SimpleNamespace(name="cv-agent")),
106
+ )
107
+ executor.agent = _StubStatusAgent( # type: ignore[assignment]
108
+ StringResponse(status=TaskState.completed,
109
+ response="Here is your CV."),
110
+ [HumanMessage(content="render a CV please"), tool_msg],
111
+ )
112
+
113
+ async def _noop_reinit() -> None:
114
+ return None
115
+
116
+ executor.reinitialize_agent_with_tools = _noop_reinit # type: ignore[method-assign]
117
+
118
+ ctx = _make_request_context()
119
+ queue = EventQueue()
120
+ await executor.execute(ctx, queue)
121
+ events = await _drain_queue(queue)
122
+
123
+ # Expected sequence: working status, file artifact, text artifact, final status.
124
+ artifact_events = [e for e in events if isinstance(e, TaskArtifactUpdateEvent)]
125
+ status_events = [e for e in events if isinstance(e, TaskStatusUpdateEvent)]
126
+
127
+ assert len(artifact_events) == 2, (
128
+ f"Expected 2 artifact events (file + text), got: "
129
+ f"{[(e.artifact.name, e.last_chunk) for e in artifact_events]}"
130
+ )
131
+
132
+ file_event, text_event = artifact_events
133
+ # 1) file artifact carries a FilePart, is NOT the final chunk
134
+ assert file_event.last_chunk is False
135
+ assert file_event.artifact.name == "cv-foo.docx"
136
+ assert len(file_event.artifact.parts) == 1
137
+ file_part = file_event.artifact.parts[0].root
138
+ assert isinstance(file_part, FilePart)
139
+ assert isinstance(file_part.file, FileWithBytes)
140
+ assert file_part.file.name == "cv-foo.docx"
141
+ assert file_part.file.bytes == docx_b64
142
+
143
+ # 2) text artifact carries the LLM-visible summary, IS the final chunk
144
+ assert text_event.last_chunk is True
145
+ assert text_event.artifact.name == "current_result"
146
+ text_part = text_event.artifact.parts[0].root
147
+ assert isinstance(text_part, TextPart)
148
+ assert text_part.text == "*cv-agent*: Here is your CV."
149
+
150
+ # 3) Final status event is `completed`
151
+ final_status = [e for e in status_events if e.final]
152
+ assert len(final_status) == 1
153
+ assert final_status[0].status.state == TaskState.completed
154
+
155
+
156
+ @pytest.mark.asyncio
157
+ async def test_executor_emits_no_file_event_when_no_artifacts() -> None:
158
+ from types import SimpleNamespace
159
+
160
+ executor = RoutingAgentExecutor.__new__(RoutingAgentExecutor)
161
+ executor.agent_config = SimpleNamespace( # type: ignore[assignment]
162
+ agent=SimpleNamespace(card=SimpleNamespace(name="plain-agent")),
163
+ )
164
+ executor.agent = _StubStatusAgent( # type: ignore[assignment]
165
+ StringResponse(status=TaskState.completed, response="No files here."),
166
+ [HumanMessage(content="hi")],
167
+ )
168
+
169
+ async def _noop_reinit() -> None:
170
+ return None
171
+
172
+ executor.reinitialize_agent_with_tools = _noop_reinit # type: ignore[method-assign]
173
+
174
+ ctx = _make_request_context()
175
+ queue = EventQueue()
176
+ await executor.execute(ctx, queue)
177
+ events = await _drain_queue(queue)
178
+
179
+ artifact_events = [e for e in events if isinstance(e, TaskArtifactUpdateEvent)]
180
+ assert len(artifact_events) == 1
181
+ assert artifact_events[0].artifact.name == "current_result"
182
+ assert artifact_events[0].last_chunk is True
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ from collections.abc import AsyncGenerator
5
+
6
+ import httpx
7
+ import pytest
8
+ from a2a.types import (AgentCapabilities, AgentCard, Artifact, FilePart,
9
+ FileWithBytes, FileWithUri, Message, Part, Task,
10
+ TaskState, TaskStatus, TextPart)
11
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
12
+ from mcp.types import (BlobResourceContents, EmbeddedResource, ImageContent,
13
+ TextResourceContents)
14
+ from pydantic import AnyUrl
15
+
16
+ from distributed_a2a.client import AgentReply, RemoteAgentConnection
17
+ from distributed_a2a.executors import _extract_file_parts
18
+
19
+
20
+ def _b64(payload: bytes) -> str:
21
+ return base64.b64encode(payload).decode("ascii")
22
+
23
+
24
+ def test_extract_file_parts_picks_up_embedded_resource() -> None:
25
+ docx_b64 = _b64(b"PK\x03\x04 fake docx bytes")
26
+ tool_msg = ToolMessage(
27
+ content="{\"filename\": \"cv-foo.docx\"}",
28
+ tool_call_id="call-1",
29
+ artifact=[
30
+ EmbeddedResource(
31
+ type="resource",
32
+ resource=BlobResourceContents(
33
+ uri=AnyUrl("cv://cv-foo.docx"),
34
+ mimeType="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
35
+ blob=docx_b64,
36
+ ),
37
+ ),
38
+ ],
39
+ )
40
+
41
+ parts = _extract_file_parts([HumanMessage(content="hi"), tool_msg])
42
+
43
+ assert len(parts) == 1
44
+ name, file_part = parts[0]
45
+ assert name == "cv-foo.docx"
46
+ assert isinstance(file_part, FilePart)
47
+ assert isinstance(file_part.file, FileWithBytes)
48
+ assert file_part.file.name == "cv-foo.docx"
49
+ assert file_part.file.mime_type == (
50
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
51
+ )
52
+ assert file_part.file.bytes == docx_b64
53
+
54
+
55
+ def test_extract_file_parts_handles_image_content() -> None:
56
+ png_b64 = _b64(b"\x89PNG\r\n\x1a\n fake")
57
+ tool_msg = ToolMessage(
58
+ content="",
59
+ tool_call_id="call-img",
60
+ artifact=[
61
+ ImageContent(type="image", data=png_b64, mimeType="image/png"),
62
+ ],
63
+ )
64
+
65
+ parts = _extract_file_parts([tool_msg])
66
+
67
+ assert len(parts) == 1
68
+ name, file_part = parts[0]
69
+ assert name == "image"
70
+ assert isinstance(file_part.file, FileWithBytes)
71
+ assert file_part.file.mime_type == "image/png"
72
+ assert file_part.file.bytes == png_b64
73
+
74
+
75
+ def test_extract_file_parts_ignores_non_tool_messages_and_empty_artifacts() -> None:
76
+ messages = [
77
+ HumanMessage(content="hi"),
78
+ AIMessage(content="hello"),
79
+ ToolMessage(content="just text", tool_call_id="c1"),
80
+ ToolMessage(content="empty artifact", tool_call_id="c2", artifact=[]),
81
+ ]
82
+ assert _extract_file_parts(messages) == []
83
+
84
+
85
+ def test_extract_file_parts_skips_text_resource_contents() -> None:
86
+ tool_msg = ToolMessage(
87
+ content="",
88
+ tool_call_id="call-x",
89
+ artifact=[
90
+ EmbeddedResource(
91
+ type="resource",
92
+ resource=TextResourceContents(
93
+ uri=AnyUrl("text://note.txt"),
94
+ mimeType="text/plain",
95
+ text="some inline text",
96
+ ),
97
+ ),
98
+ ],
99
+ )
100
+ assert _extract_file_parts([tool_msg]) == []
101
+
102
+
103
+ def test_extract_file_parts_falls_back_to_octet_stream_when_mime_missing() -> None:
104
+ tool_msg = ToolMessage(
105
+ content="",
106
+ tool_call_id="call-x",
107
+ artifact=[
108
+ EmbeddedResource(
109
+ type="resource",
110
+ resource=BlobResourceContents(
111
+ uri=AnyUrl("file:///tmp/x.bin"),
112
+ blob=_b64(b"x"),
113
+ ),
114
+ ),
115
+ ],
116
+ )
117
+ parts = _extract_file_parts([tool_msg])
118
+ assert len(parts) == 1
119
+ name, file_part = parts[0]
120
+ assert name == "x.bin"
121
+ assert isinstance(file_part.file, FileWithBytes)
122
+ assert file_part.file.mime_type == "application/octet-stream"
123
+
124
+
125
+ class _StubAgentClient:
126
+ def __init__(self, task: Task):
127
+ self._task = task
128
+
129
+ async def send_message(self, _message: Message) -> AsyncGenerator[tuple[Task, None], None]:
130
+ yield self._task, None
131
+
132
+
133
+ class _StubClientFactory:
134
+ def __init__(self, _config: object, task: Task):
135
+ self._task = task
136
+
137
+ def create(self, _agent_card: AgentCard) -> _StubAgentClient:
138
+ return _StubAgentClient(self._task)
139
+
140
+
141
+ def _patch_client_factory(monkeypatch: pytest.MonkeyPatch, task: Task) -> None:
142
+ def _ctor(config: object) -> _StubClientFactory:
143
+ return _StubClientFactory(config, task)
144
+ monkeypatch.setattr("distributed_a2a.client.ClientFactory", _ctor)
145
+
146
+
147
+ def _agent_card() -> AgentCard:
148
+ return AgentCard(
149
+ name="stub",
150
+ description="stub",
151
+ url="http://127.0.0.1:0",
152
+ version="1.0.0",
153
+ default_input_modes=["text"],
154
+ default_output_modes=["text"],
155
+ capabilities=AgentCapabilities(streaming=False, push_notifications=False),
156
+ skills=[],
157
+ preferred_transport="JSONRPC",
158
+ )
159
+
160
+
161
+ @pytest.mark.asyncio
162
+ async def test_remote_agent_connection_returns_text_and_files_in_agent_reply(
163
+ monkeypatch: pytest.MonkeyPatch) -> None:
164
+ docx_b64 = _b64(b"PK\x03\x04 docx-bytes")
165
+ completed_task = Task(
166
+ id="task-files",
167
+ context_id="ctx-files",
168
+ status=TaskStatus(state=TaskState.completed),
169
+ artifacts=[
170
+ Artifact(
171
+ artifact_id="cv-foo.docx",
172
+ name="cv-foo.docx",
173
+ parts=[Part(root=FilePart(file=FileWithBytes(
174
+ name="cv-foo.docx",
175
+ mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
176
+ bytes=docx_b64,
177
+ )))],
178
+ ),
179
+ Artifact(
180
+ artifact_id="current_result",
181
+ name="current_result",
182
+ parts=[Part(root=TextPart(text="Here is your CV."))],
183
+ ),
184
+ ],
185
+ )
186
+ factory_task = completed_task
187
+ _patch_client_factory(monkeypatch, factory_task)
188
+
189
+ async with httpx.AsyncClient() as http_client:
190
+ conn = RemoteAgentConnection(_agent_card(), http_client)
191
+ reply = await conn.send_message("hello", "ctx-files")
192
+
193
+ assert isinstance(reply, AgentReply)
194
+ assert reply.text == "Here is your CV."
195
+ assert len(reply.files) == 1
196
+ f = reply.files[0]
197
+ assert f.name == "cv-foo.docx"
198
+ assert f.mime_type == (
199
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
200
+ )
201
+ assert f.bytes_b64 == docx_b64
202
+ assert f.uri is None
203
+
204
+
205
+ @pytest.mark.asyncio
206
+ async def test_remote_agent_connection_handles_file_with_uri(
207
+ monkeypatch: pytest.MonkeyPatch) -> None:
208
+ completed_task = Task(
209
+ id="task-uri",
210
+ context_id="ctx-uri",
211
+ status=TaskStatus(state=TaskState.completed),
212
+ artifacts=[
213
+ Artifact(
214
+ artifact_id="report",
215
+ name="report",
216
+ parts=[Part(root=FilePart(file=FileWithUri(
217
+ name="report.pdf",
218
+ mime_type="application/pdf",
219
+ uri="https://example.com/report.pdf",
220
+ )))],
221
+ ),
222
+ ],
223
+ )
224
+ _patch_client_factory(monkeypatch, completed_task)
225
+
226
+ async with httpx.AsyncClient() as http_client:
227
+ conn = RemoteAgentConnection(_agent_card(), http_client)
228
+ reply = await conn.send_message("hello", "ctx-uri")
229
+
230
+ assert isinstance(reply, AgentReply)
231
+ assert reply.text is None
232
+ assert len(reply.files) == 1
233
+ f = reply.files[0]
234
+ assert f.name == "report.pdf"
235
+ assert f.mime_type == "application/pdf"
236
+ assert f.bytes_b64 == ""
237
+ assert f.uri == "https://example.com/report.pdf"
@@ -1,7 +1,7 @@
1
1
  import pytest
2
2
  from a2a.types import AgentCapabilities, AgentCard, TaskState
3
3
 
4
- from distributed_a2a.client import RoutingA2AClient
4
+ from distributed_a2a.client import AgentReply, RoutingA2AClient
5
5
 
6
6
 
7
7
  def build_card(name: str, url: str) -> AgentCard:
@@ -38,7 +38,7 @@ async def test_rejection_triggers_automated_rerouting(monkeypatch: pytest.Monkey
38
38
  def __init__(self, agent_card: AgentCard, _client: object, **_kwargs: object) -> None:
39
39
  self.agent_card = agent_card
40
40
 
41
- async def send_message(self, message_to_send: str, _context_id: str) -> str | AgentCard | TaskState:
41
+ async def send_message(self, message_to_send: str, _context_id: str) -> AgentReply | AgentCard | TaskState:
42
42
  if self.agent_card.name == "Router":
43
43
  router_messages.append(message_to_send)
44
44
  if "Please exclude the following agents from routing: rejecting-agent" in message_to_send:
@@ -49,7 +49,7 @@ async def test_rejection_triggers_automated_rerouting(monkeypatch: pytest.Monkey
49
49
  return TaskState.rejected
50
50
 
51
51
  if self.agent_card.name == "success-agent":
52
- return "final answer"
52
+ return AgentReply(text="final answer")
53
53
 
54
54
  raise AssertionError(f"Unexpected agent {self.agent_card.name}")
55
55
 
@@ -57,7 +57,8 @@ async def test_rejection_triggers_automated_rerouting(monkeypatch: pytest.Monkey
57
57
 
58
58
  result = await client.send_message("Hello", context_id="ctx-1")
59
59
 
60
- assert result == "final answer"
60
+ assert isinstance(result, AgentReply)
61
+ assert result.text == "final answer"
61
62
  assert len(router_messages) == 2
62
63
  assert "Please exclude the following agents from routing" not in router_messages[0]
63
64
  assert "Please exclude the following agents from routing: rejecting-agent" in router_messages[1]
@@ -88,7 +89,7 @@ async def test_rejected_agents_reset_between_calls(monkeypatch: pytest.MonkeyPat
88
89
  def __init__(self, agent_card: AgentCard, _client: object, **_kwargs: object) -> None:
89
90
  self.agent_card = agent_card
90
91
 
91
- async def send_message(self, message_to_send: str, _context_id: str) -> str | AgentCard | TaskState:
92
+ async def send_message(self, message_to_send: str, _context_id: str) -> AgentReply | AgentCard | TaskState:
92
93
  idx = next_step()
93
94
  match idx:
94
95
  case 0:
@@ -104,7 +105,7 @@ async def test_rejected_agents_reset_between_calls(monkeypatch: pytest.MonkeyPat
104
105
  return success_card
105
106
  case 3:
106
107
  assert self.agent_card.name == "success-agent"
107
- return "first response"
108
+ return AgentReply(text="first response")
108
109
  case 4:
109
110
  assert self.agent_card.name == "success-agent"
110
111
  return TaskState.rejected
@@ -115,7 +116,7 @@ async def test_rejected_agents_reset_between_calls(monkeypatch: pytest.MonkeyPat
115
116
  return rejecting_card
116
117
  case 6:
117
118
  assert self.agent_card.name == "rejecting-agent"
118
- return "second response"
119
+ return AgentReply(text="second response")
119
120
  case _:
120
121
  raise AssertionError(f"Unexpected step {idx}")
121
122
 
@@ -124,8 +125,8 @@ async def test_rejected_agents_reset_between_calls(monkeypatch: pytest.MonkeyPat
124
125
  first = await client.send_message("First", context_id="ctx-1")
125
126
  second = await client.send_message("Second", context_id="ctx-2")
126
127
 
127
- assert first == "first response"
128
- assert second == "second response"
128
+ assert isinstance(first, AgentReply) and first.text == "first response"
129
+ assert isinstance(second, AgentReply) and second.text == "second response"
129
130
 
130
131
 
131
132
  @pytest.mark.asyncio
@@ -147,7 +148,7 @@ async def test_fails_when_router_returns_already_rejected_agent(monkeypatch: pyt
147
148
  def __init__(self, agent_card: AgentCard, _client: object, **_kwargs: object) -> None:
148
149
  self.agent_card = agent_card
149
150
 
150
- async def send_message(self, message_to_send: str, _context_id: str) -> str | AgentCard | TaskState:
151
+ async def send_message(self, message_to_send: str, _context_id: str) -> AgentReply | AgentCard | TaskState:
151
152
  idx = step["idx"]
152
153
  step["idx"] += 1
153
154
  if idx == 0:
@@ -8,8 +8,8 @@ import pytest
8
8
  from a2a.types import (AgentCapabilities, AgentCard, Artifact, Message, Part,
9
9
  Role, Task, TaskState, TaskStatus, TextPart)
10
10
 
11
- from distributed_a2a.client import (A2ATimeoutError, RemoteAgentConnection,
12
- RoutingA2AClient)
11
+ from distributed_a2a.client import (A2ATimeoutError, AgentReply,
12
+ RemoteAgentConnection, RoutingA2AClient)
13
13
 
14
14
 
15
15
  def _agent_card() -> AgentCard:
@@ -124,7 +124,9 @@ async def test_send_message_honors_completion_on_final_allowed_poll(
124
124
  )
125
125
  result = await connection.send_message("hi", "ctx-x")
126
126
 
127
- assert result == "all done"
127
+ assert isinstance(result, AgentReply)
128
+ assert result.text == "all done"
129
+ assert result.files == []
128
130
  assert scripted.get_task_calls == 3
129
131
  assert scripted._get_states == []
130
132
 
@@ -156,7 +158,9 @@ async def test_send_message_returns_immediately_if_already_completed(
156
158
  )
157
159
  result = await connection.send_message("hi", "ctx-x")
158
160
 
159
- assert result == "all done"
161
+ assert isinstance(result, AgentReply)
162
+ assert result.text == "all done"
163
+ assert result.files == []
160
164
 
161
165
 
162
166
  def test_a2a_timeout_error_message_includes_diagnostics() -> None:
@@ -182,8 +186,8 @@ async def test_routing_a2a_client_propagates_poll_kwargs(
182
186
  captured["poll_interval"] = poll_interval
183
187
  self.agent_card = agent_card
184
188
 
185
- async def send_message(self, _message: str, _context_id: str) -> str:
186
- return "done"
189
+ async def send_message(self, _message: str, _context_id: str) -> AgentReply:
190
+ return AgentReply(text="done")
187
191
 
188
192
  monkeypatch.setattr("distributed_a2a.client.RemoteAgentConnection", _Capturing)
189
193
 
@@ -197,5 +201,7 @@ async def test_routing_a2a_client_propagates_poll_kwargs(
197
201
  monkeypatch.setattr(client, "fetch_initial_card", _fetch)
198
202
 
199
203
  result = await client.send_message("hi", context_id="ctx-1")
200
- assert result == "done"
204
+ assert isinstance(result, AgentReply)
205
+ assert result.text == "done"
206
+ assert result.files == []
201
207
  assert captured == {"max_polls": 7, "poll_interval": 0.25}