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.
- {distributed_a2a-0.1.26/distributed_a2a.egg-info → distributed_a2a-0.2.1}/PKG-INFO +1 -1
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/__init__.py +3 -1
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/agent.py +21 -3
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/client.py +67 -10
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/executors.py +52 -7
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1/distributed_a2a.egg-info}/PKG-INFO +1 -1
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/SOURCES.txt +2 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/pyproject.toml +1 -1
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/tests/test_app.py +7 -4
- distributed_a2a-0.2.1/tests/test_executor_files.py +182 -0
- distributed_a2a-0.2.1/tests/test_files.py +237 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/tests/test_rejection.py +11 -10
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/tests/test_timeout.py +13 -7
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/LICENSE +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/MANIFEST.in +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/README.md +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/config.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/model.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/py.typed +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/__init__.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/bootstrap.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/dynamo_db.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/in_memory_registry_storage.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/model.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/storage.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/router.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/schemas/agent-schema.json +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/schemas/router-agent-schema.json +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/server.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/dependency_links.txt +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/requires.txt +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/top_level.txt +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/requirements.txt +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/setup.cfg +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/setup.py +0 -0
- {distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/tests/test_client.py +0 -0
|
@@ -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
|
|
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,
|
|
10
|
-
TaskState,
|
|
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,
|
|
73
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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,
|
|
161
|
-
|
|
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:
|
|
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,
|
|
8
|
-
TaskStatus,
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
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:
|
|
@@ -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
|
-
|
|
62
|
+
reply = await client.send_message(message="Hello", context_id="test-context")
|
|
63
63
|
|
|
64
64
|
# Then: Check the response
|
|
65
|
-
assert
|
|
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
|
-
|
|
80
|
+
reply = await client.send_message(message="Hello", context_id="test-context")
|
|
79
81
|
|
|
80
82
|
# Then
|
|
81
|
-
assert
|
|
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) ->
|
|
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
|
|
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) ->
|
|
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) ->
|
|
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,
|
|
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
|
|
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
|
|
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) ->
|
|
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
|
|
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}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/__init__.py
RENAMED
|
File without changes
|
{distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/bootstrap.py
RENAMED
|
File without changes
|
{distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/registry_server/dynamo_db.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a/schemas/router-agent-schema.json
RENAMED
|
File without changes
|
|
File without changes
|
{distributed_a2a-0.1.26 → distributed_a2a-0.2.1}/distributed_a2a.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|