distributed-a2a 0.2.1__tar.gz → 0.2.3__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.2.1/distributed_a2a.egg-info → distributed_a2a-0.2.3}/PKG-INFO +22 -1
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/README.md +21 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/__init__.py +5 -1
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/executors.py +17 -36
- distributed_a2a-0.2.3/distributed_a2a/file_extractors.py +142 -0
- distributed_a2a-0.2.3/distributed_a2a/mcp_interceptors.py +44 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3/distributed_a2a.egg-info}/PKG-INFO +22 -1
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a.egg-info/SOURCES.txt +4 -1
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/pyproject.toml +1 -1
- distributed_a2a-0.2.3/tests/test_executor_files.py +283 -0
- distributed_a2a-0.2.3/tests/test_file_extractors.py +493 -0
- distributed_a2a-0.2.3/tests/test_mcp_interceptors.py +197 -0
- distributed_a2a-0.2.1/tests/test_executor_files.py +0 -182
- distributed_a2a-0.2.1/tests/test_files.py +0 -237
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/LICENSE +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/MANIFEST.in +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/agent.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/client.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/config.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/model.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/py.typed +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/__init__.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/bootstrap.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/dynamo_db.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/in_memory_registry_storage.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/model.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/storage.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/router.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/schemas/agent-schema.json +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/schemas/router-agent-schema.json +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/server.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a.egg-info/dependency_links.txt +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a.egg-info/requires.txt +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a.egg-info/top_level.txt +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/requirements.txt +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/setup.cfg +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/setup.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/tests/test_app.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/tests/test_client.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/tests/test_rejection.py +0 -0
- {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/tests/test_timeout.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: distributed_a2a
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -211,6 +211,27 @@ if __name__ == "__main__":
|
|
|
211
211
|
asyncio.run(main())
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
+
### Binary content handling
|
|
215
|
+
|
|
216
|
+
When an agent invokes an MCP tool that returns non-text content (files, images,
|
|
217
|
+
`EmbeddedResource`, `ResourceLink`), the library keeps those payloads out of the
|
|
218
|
+
LLM's context window and delivers them out-of-band as A2A `FilePart` artifacts.
|
|
219
|
+
|
|
220
|
+
Concretely, `RoutingAgentExecutor` installs the
|
|
221
|
+
`hide_binary_content_from_llm` tool-call interceptor on every
|
|
222
|
+
`MultiServerMCPClient` it builds. The interceptor moves any non-`TextContent`
|
|
223
|
+
block from `CallToolResult.content` into `CallToolResult.structuredContent`
|
|
224
|
+
under the `non_text_content` key. The upstream adapter then carries that dict
|
|
225
|
+
into `ToolMessage.artifact['structured_content']`, which LangChain does **not**
|
|
226
|
+
surface to the model. After the graph run, the executor walks the message list,
|
|
227
|
+
extracts the stashed blocks, and emits one `TaskArtifactUpdateEvent` per file
|
|
228
|
+
before the terminating text artifact.
|
|
229
|
+
|
|
230
|
+
Client-side, `RoutingA2AClient.send_message` returns an `AgentReply` that
|
|
231
|
+
exposes both the LLM's text summary and any `FileRef` payloads (with either
|
|
232
|
+
inline `bytes_b64` or a `uri`), so downstream integrations (e.g. Slack file
|
|
233
|
+
uploads) can forward the bytes without ever routing them through a model.
|
|
234
|
+
|
|
214
235
|
### Environment Variables
|
|
215
236
|
The library uses several environment variables for configuration. These can be set in your shell or via a `.env` file.
|
|
216
237
|
|
|
@@ -172,6 +172,27 @@ if __name__ == "__main__":
|
|
|
172
172
|
asyncio.run(main())
|
|
173
173
|
```
|
|
174
174
|
|
|
175
|
+
### Binary content handling
|
|
176
|
+
|
|
177
|
+
When an agent invokes an MCP tool that returns non-text content (files, images,
|
|
178
|
+
`EmbeddedResource`, `ResourceLink`), the library keeps those payloads out of the
|
|
179
|
+
LLM's context window and delivers them out-of-band as A2A `FilePart` artifacts.
|
|
180
|
+
|
|
181
|
+
Concretely, `RoutingAgentExecutor` installs the
|
|
182
|
+
`hide_binary_content_from_llm` tool-call interceptor on every
|
|
183
|
+
`MultiServerMCPClient` it builds. The interceptor moves any non-`TextContent`
|
|
184
|
+
block from `CallToolResult.content` into `CallToolResult.structuredContent`
|
|
185
|
+
under the `non_text_content` key. The upstream adapter then carries that dict
|
|
186
|
+
into `ToolMessage.artifact['structured_content']`, which LangChain does **not**
|
|
187
|
+
surface to the model. After the graph run, the executor walks the message list,
|
|
188
|
+
extracts the stashed blocks, and emits one `TaskArtifactUpdateEvent` per file
|
|
189
|
+
before the terminating text artifact.
|
|
190
|
+
|
|
191
|
+
Client-side, `RoutingA2AClient.send_message` returns an `AgentReply` that
|
|
192
|
+
exposes both the LLM's text summary and any `FileRef` payloads (with either
|
|
193
|
+
inline `bytes_b64` or a `uri`), so downstream integrations (e.g. Slack file
|
|
194
|
+
uploads) can forward the bytes without ever routing them through a model.
|
|
195
|
+
|
|
175
196
|
### Environment Variables
|
|
176
197
|
The library uses several environment variables for configuration. These can be set in your shell or via a `.env` file.
|
|
177
198
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
from .client import A2ATimeoutError, AgentReply, FileRef, RoutingA2AClient
|
|
2
|
+
from .mcp_interceptors import (NON_TEXT_CONTENT_KEY,
|
|
3
|
+
hide_binary_content_from_llm)
|
|
2
4
|
from .model import (AgentConfig, AgentItem, CardConfig, LLMConfig,
|
|
3
5
|
RegistryConfig, RegistryItemConfig, RouterConfig,
|
|
4
6
|
RouterItem, SkillConfig)
|
|
@@ -31,5 +33,7 @@ __all__ = [
|
|
|
31
33
|
"AgentRegistryClient",
|
|
32
34
|
"McpRegistryClient",
|
|
33
35
|
"InMemoryAgentRegistry",
|
|
34
|
-
"InMemoryMcpRegistry"
|
|
36
|
+
"InMemoryMcpRegistry",
|
|
37
|
+
"hide_binary_content_from_llm",
|
|
38
|
+
"NON_TEXT_CONTENT_KEY",
|
|
35
39
|
]
|
|
@@ -4,50 +4,23 @@ 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, FilePart,
|
|
8
|
-
|
|
9
|
-
TaskStatusUpdateEvent)
|
|
7
|
+
from a2a.types import (Artifact, FilePart, Part, TaskArtifactUpdateEvent,
|
|
8
|
+
TaskState, TaskStatus, TaskStatusUpdateEvent)
|
|
10
9
|
from a2a.utils import new_text_artifact
|
|
11
|
-
from langchain_core.messages import BaseMessage, ToolMessage
|
|
12
10
|
from langchain_core.tools import BaseTool
|
|
13
11
|
from langchain_mcp_adapters.client import MultiServerMCPClient
|
|
14
12
|
from langgraph.checkpoint.base import BaseCheckpointSaver
|
|
15
|
-
from mcp.types import BlobResourceContents, EmbeddedResource, ImageContent
|
|
16
13
|
|
|
17
14
|
from .agent import RoutingResponse, StatusAgent, StringResponse
|
|
18
15
|
from .config import settings
|
|
16
|
+
from .file_extractors import extract_file_parts
|
|
17
|
+
from .mcp_interceptors import hide_binary_content_from_llm
|
|
19
18
|
from .model import AgentConfig, RouterConfig
|
|
20
19
|
from .registry import AgentRegistryLookupClient, McpRegistryLookup
|
|
21
20
|
|
|
22
21
|
logger = logging.getLogger(__name__)
|
|
23
22
|
|
|
24
23
|
|
|
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
|
-
|
|
51
24
|
class RoutingFailed(Exception):
|
|
52
25
|
def __init__(self, message: str) -> None:
|
|
53
26
|
super().__init__(message)
|
|
@@ -155,7 +128,7 @@ class RoutingAgentExecutor(AgentExecutor):
|
|
|
155
128
|
artifact = await _route_request_to_matching_agent(self.routing_agent, self.agent_registry, context)
|
|
156
129
|
else:
|
|
157
130
|
logger.info(f"Request with id {context.context_id} was successfully processed by agent.")
|
|
158
|
-
file_parts =
|
|
131
|
+
file_parts = extract_file_parts(invocation.messages)
|
|
159
132
|
artifact = new_text_artifact(
|
|
160
133
|
name='current_result',
|
|
161
134
|
description='Result of request to agent.',
|
|
@@ -234,10 +207,18 @@ class RoutingAgentExecutor(AgentExecutor):
|
|
|
234
207
|
return
|
|
235
208
|
|
|
236
209
|
logger.info(f"Agent {self.agent_config.agent.card.name} has access to the following tools: {mcp_server_raw}")
|
|
237
|
-
mcp_servers
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
210
|
+
mcp_servers: dict[str, Any] = {
|
|
211
|
+
tool["name"]: {
|
|
212
|
+
"url": tool["url"],
|
|
213
|
+
"transport": tool["protocol"],
|
|
214
|
+
"headers": settings.get_mcp_auth_headers(tool["name"])
|
|
215
|
+
}
|
|
216
|
+
for tool in mcp_server_raw
|
|
217
|
+
}
|
|
218
|
+
mcp_client = MultiServerMCPClient(
|
|
219
|
+
connections=mcp_servers,
|
|
220
|
+
tool_interceptors=[hide_binary_content_from_llm],
|
|
221
|
+
)
|
|
241
222
|
mcp_tools = await mcp_client.get_tools()
|
|
242
223
|
|
|
243
224
|
self.agent = StatusAgent[StringResponse](
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import mimetypes
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from a2a.types import FilePart, FileWithBytes, FileWithUri
|
|
6
|
+
from langchain_core.messages import BaseMessage, ToolMessage
|
|
7
|
+
from mcp.types import (BlobResourceContents, EmbeddedResource, ImageContent,
|
|
8
|
+
ResourceLink)
|
|
9
|
+
|
|
10
|
+
from .mcp_interceptors import NON_TEXT_CONTENT_KEY
|
|
11
|
+
|
|
12
|
+
_LANGCHAIN_BINARY_BLOCK_TYPES: dict[str, str] = {
|
|
13
|
+
"file": "attachment",
|
|
14
|
+
"image": "image",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _filename_from_text_block(block: dict[str, Any]) -> str | None:
|
|
19
|
+
text = block.get("text")
|
|
20
|
+
if not isinstance(text, str):
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
payload = json.loads(text)
|
|
24
|
+
except (ValueError, TypeError):
|
|
25
|
+
return None
|
|
26
|
+
if isinstance(payload, dict):
|
|
27
|
+
name = payload.get("filename")
|
|
28
|
+
if isinstance(name, str) and name:
|
|
29
|
+
return name
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _synthetic_name(kind: str, index: int, mime_type: str) -> str:
|
|
34
|
+
guessed_ext = mimetypes.guess_extension(mime_type)
|
|
35
|
+
ext = guessed_ext if guessed_ext is not None else ""
|
|
36
|
+
suffix = f"-{index}" if index > 0 else ""
|
|
37
|
+
return f"{kind}{suffix}{ext}"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _name_from_uri(uri: str, fallback_kind: str, index: int, mime_type: str) -> str:
|
|
41
|
+
tail = uri.rsplit("/", 1)[-1]
|
|
42
|
+
if tail:
|
|
43
|
+
return tail
|
|
44
|
+
return _synthetic_name(fallback_kind, index, mime_type)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _extract_from_mcp_blocks(blocks: list[Any]) -> list[tuple[str, FilePart]]:
|
|
48
|
+
out: list[tuple[str, FilePart]] = []
|
|
49
|
+
counters: dict[str, int] = {"attachment": 0, "image": 0}
|
|
50
|
+
for block in blocks:
|
|
51
|
+
if isinstance(block, EmbeddedResource) and isinstance(block.resource, BlobResourceContents):
|
|
52
|
+
mime_type = block.resource.mimeType or "application/octet-stream"
|
|
53
|
+
uri = str(block.resource.uri) if block.resource.uri is not None else ""
|
|
54
|
+
kind = "image" if mime_type.startswith("image/") else "attachment"
|
|
55
|
+
if uri:
|
|
56
|
+
name = _name_from_uri(uri, kind, counters[kind], mime_type)
|
|
57
|
+
else:
|
|
58
|
+
name = _synthetic_name(kind, counters[kind], mime_type)
|
|
59
|
+
counters[kind] += 1
|
|
60
|
+
out.append((name, FilePart(file=FileWithBytes(
|
|
61
|
+
name=name, mime_type=mime_type, bytes=block.resource.blob,
|
|
62
|
+
))))
|
|
63
|
+
elif isinstance(block, ImageContent):
|
|
64
|
+
mime_type = block.mimeType or "application/octet-stream"
|
|
65
|
+
name = _synthetic_name("image", counters["image"], mime_type)
|
|
66
|
+
counters["image"] += 1
|
|
67
|
+
out.append((name, FilePart(file=FileWithBytes(
|
|
68
|
+
name=name, mime_type=mime_type, bytes=block.data,
|
|
69
|
+
))))
|
|
70
|
+
elif isinstance(block, ResourceLink):
|
|
71
|
+
mime_type = block.mimeType or "application/octet-stream"
|
|
72
|
+
uri = str(block.uri)
|
|
73
|
+
kind = "image" if mime_type.startswith("image/") else "attachment"
|
|
74
|
+
name = _name_from_uri(uri, kind, counters[kind], mime_type)
|
|
75
|
+
counters[kind] += 1
|
|
76
|
+
out.append((name, FilePart(file=FileWithUri(
|
|
77
|
+
name=name, mime_type=mime_type, uri=uri,
|
|
78
|
+
))))
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _extract_from_langchain_content_blocks(content: list[Any]) -> list[tuple[str, FilePart]]:
|
|
83
|
+
out: list[tuple[str, FilePart]] = []
|
|
84
|
+
pending_name: str | None = None
|
|
85
|
+
counters: dict[str, int] = {"file": 0, "image": 0}
|
|
86
|
+
for block in content:
|
|
87
|
+
if not isinstance(block, dict):
|
|
88
|
+
continue
|
|
89
|
+
block_type = block.get("type")
|
|
90
|
+
if not isinstance(block_type, str):
|
|
91
|
+
continue
|
|
92
|
+
if block_type == "text":
|
|
93
|
+
hint = _filename_from_text_block(block)
|
|
94
|
+
if hint:
|
|
95
|
+
pending_name = hint
|
|
96
|
+
continue
|
|
97
|
+
kind = _LANGCHAIN_BINARY_BLOCK_TYPES.get(block_type)
|
|
98
|
+
if kind is None:
|
|
99
|
+
continue
|
|
100
|
+
b64 = block.get("base64")
|
|
101
|
+
if not isinstance(b64, str) or not b64:
|
|
102
|
+
continue
|
|
103
|
+
mime_type = block.get("mime_type") or "application/octet-stream"
|
|
104
|
+
if pending_name is not None:
|
|
105
|
+
name = pending_name
|
|
106
|
+
pending_name = None
|
|
107
|
+
else:
|
|
108
|
+
index = counters[block_type]
|
|
109
|
+
counters[block_type] = index + 1
|
|
110
|
+
name = _synthetic_name(kind, index, mime_type)
|
|
111
|
+
out.append((name, FilePart(file=FileWithBytes(
|
|
112
|
+
name=name, mime_type=mime_type, bytes=b64,
|
|
113
|
+
))))
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _mcp_blocks_from_artifact(artifact: Any) -> list[Any] | None:
|
|
118
|
+
if not isinstance(artifact, dict):
|
|
119
|
+
return None
|
|
120
|
+
structured = artifact.get("structured_content")
|
|
121
|
+
if not isinstance(structured, dict):
|
|
122
|
+
return None
|
|
123
|
+
blocks = structured.get(NON_TEXT_CONTENT_KEY)
|
|
124
|
+
if not isinstance(blocks, list) or not blocks:
|
|
125
|
+
return None
|
|
126
|
+
return blocks
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def extract_file_parts(messages: list[BaseMessage]) -> list[tuple[str, FilePart]]:
|
|
130
|
+
parts: list[tuple[str, FilePart]] = []
|
|
131
|
+
for message in messages:
|
|
132
|
+
if not isinstance(message, ToolMessage):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
mcp_blocks = _mcp_blocks_from_artifact(message.artifact)
|
|
136
|
+
if mcp_blocks is not None:
|
|
137
|
+
parts.extend(_extract_from_mcp_blocks(mcp_blocks))
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if isinstance(message.content, list):
|
|
141
|
+
parts.extend(_extract_from_langchain_content_blocks(message.content))
|
|
142
|
+
return parts
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from langchain_mcp_adapters.interceptors import (MCPToolCallRequest,
|
|
7
|
+
MCPToolCallResult)
|
|
8
|
+
from mcp.types import CallToolResult, TextContent
|
|
9
|
+
|
|
10
|
+
"""Key under ``CallToolResult.structuredContent`` where the interceptor stashes
|
|
11
|
+
any non-text MCP content blocks. Also the key under
|
|
12
|
+
``ToolMessage.artifact['structured_content']`` where downstream extraction
|
|
13
|
+
code (:func:`distributed_a2a.files.extract_file_parts`) reads them back."""
|
|
14
|
+
NON_TEXT_CONTENT_KEY = "non_text_content"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def hide_binary_content_from_llm(
|
|
18
|
+
request: MCPToolCallRequest,
|
|
19
|
+
handler: Callable[
|
|
20
|
+
[MCPToolCallRequest],
|
|
21
|
+
Awaitable[MCPToolCallResult], # pyright: ignore[reportInvalidTypeForm]
|
|
22
|
+
],
|
|
23
|
+
) -> MCPToolCallResult: # pyright: ignore[reportInvalidTypeForm]
|
|
24
|
+
result = await handler(request)
|
|
25
|
+
if not isinstance(result, CallToolResult) or result.isError:
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
text_blocks: list[TextContent] = []
|
|
29
|
+
non_text_blocks: list[Any] = []
|
|
30
|
+
for block in result.content:
|
|
31
|
+
if isinstance(block, TextContent):
|
|
32
|
+
text_blocks.append(block)
|
|
33
|
+
else:
|
|
34
|
+
non_text_blocks.append(block)
|
|
35
|
+
|
|
36
|
+
merged_structured: dict[str, Any] = (
|
|
37
|
+
dict(result.structuredContent) if result.structuredContent else {}
|
|
38
|
+
)
|
|
39
|
+
merged_structured[NON_TEXT_CONTENT_KEY] = non_text_blocks
|
|
40
|
+
|
|
41
|
+
return result.model_copy(update={
|
|
42
|
+
"content": text_blocks,
|
|
43
|
+
"structuredContent": merged_structured,
|
|
44
|
+
})
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: distributed_a2a
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -211,6 +211,27 @@ if __name__ == "__main__":
|
|
|
211
211
|
asyncio.run(main())
|
|
212
212
|
```
|
|
213
213
|
|
|
214
|
+
### Binary content handling
|
|
215
|
+
|
|
216
|
+
When an agent invokes an MCP tool that returns non-text content (files, images,
|
|
217
|
+
`EmbeddedResource`, `ResourceLink`), the library keeps those payloads out of the
|
|
218
|
+
LLM's context window and delivers them out-of-band as A2A `FilePart` artifacts.
|
|
219
|
+
|
|
220
|
+
Concretely, `RoutingAgentExecutor` installs the
|
|
221
|
+
`hide_binary_content_from_llm` tool-call interceptor on every
|
|
222
|
+
`MultiServerMCPClient` it builds. The interceptor moves any non-`TextContent`
|
|
223
|
+
block from `CallToolResult.content` into `CallToolResult.structuredContent`
|
|
224
|
+
under the `non_text_content` key. The upstream adapter then carries that dict
|
|
225
|
+
into `ToolMessage.artifact['structured_content']`, which LangChain does **not**
|
|
226
|
+
surface to the model. After the graph run, the executor walks the message list,
|
|
227
|
+
extracts the stashed blocks, and emits one `TaskArtifactUpdateEvent` per file
|
|
228
|
+
before the terminating text artifact.
|
|
229
|
+
|
|
230
|
+
Client-side, `RoutingA2AClient.send_message` returns an `AgentReply` that
|
|
231
|
+
exposes both the LLM's text summary and any `FileRef` payloads (with either
|
|
232
|
+
inline `bytes_b64` or a `uri`), so downstream integrations (e.g. Slack file
|
|
233
|
+
uploads) can forward the bytes without ever routing them through a model.
|
|
234
|
+
|
|
214
235
|
### Environment Variables
|
|
215
236
|
The library uses several environment variables for configuration. These can be set in your shell or via a `.env` file.
|
|
216
237
|
|
|
@@ -9,6 +9,8 @@ distributed_a2a/agent.py
|
|
|
9
9
|
distributed_a2a/client.py
|
|
10
10
|
distributed_a2a/config.py
|
|
11
11
|
distributed_a2a/executors.py
|
|
12
|
+
distributed_a2a/file_extractors.py
|
|
13
|
+
distributed_a2a/mcp_interceptors.py
|
|
12
14
|
distributed_a2a/model.py
|
|
13
15
|
distributed_a2a/py.typed
|
|
14
16
|
distributed_a2a/registry.py
|
|
@@ -30,6 +32,7 @@ distributed_a2a/schemas/router-agent-schema.json
|
|
|
30
32
|
tests/test_app.py
|
|
31
33
|
tests/test_client.py
|
|
32
34
|
tests/test_executor_files.py
|
|
33
|
-
tests/
|
|
35
|
+
tests/test_file_extractors.py
|
|
36
|
+
tests/test_mcp_interceptors.py
|
|
34
37
|
tests/test_rejection.py
|
|
35
38
|
tests/test_timeout.py
|