distributed-a2a 0.2.4__tar.gz → 0.2.5__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.4/distributed_a2a.egg-info → distributed_a2a-0.2.5}/PKG-INFO +1 -1
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/file_extractors.py +101 -28
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/mcp_interceptors.py +2 -2
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5/distributed_a2a.egg-info}/PKG-INFO +1 -1
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/pyproject.toml +1 -1
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/tests/test_mcp_interceptors.py +56 -4
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/LICENSE +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/MANIFEST.in +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/README.md +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/__init__.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/agent.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/client.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/config.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/executors.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/model.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/py.typed +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/registry.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/registry_server/__init__.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/registry_server/bootstrap.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/registry_server/dynamo_db.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/registry_server/in_memory_registry_storage.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/registry_server/model.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/registry_server/storage.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/router.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/schemas/agent-schema.json +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/schemas/router-agent-schema.json +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/server.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a.egg-info/SOURCES.txt +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a.egg-info/dependency_links.txt +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a.egg-info/requires.txt +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a.egg-info/top_level.txt +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/requirements.txt +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/setup.cfg +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/setup.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/tests/test_app.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/tests/test_client.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/tests/test_executor_files.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/tests/test_file_extractors.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/tests/test_rejection.py +0 -0
- {distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/tests/test_timeout.py +0 -0
|
@@ -48,37 +48,110 @@ def _extract_from_mcp_blocks(blocks: list[Any]) -> list[tuple[str, FilePart]]:
|
|
|
48
48
|
out: list[tuple[str, FilePart]] = []
|
|
49
49
|
counters: dict[str, int] = {"attachment": 0, "image": 0}
|
|
50
50
|
for block in blocks:
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
))))
|
|
51
|
+
result = _mcp_block_to_file_part(block, counters)
|
|
52
|
+
if result is not None:
|
|
53
|
+
out.append(result)
|
|
79
54
|
return out
|
|
80
55
|
|
|
81
56
|
|
|
57
|
+
def _mcp_block_to_file_part(
|
|
58
|
+
block: Any, counters: dict[str, int],
|
|
59
|
+
) -> tuple[str, FilePart] | None:
|
|
60
|
+
# Normalise both shapes into (type_tag, extractor-friendly view).
|
|
61
|
+
if isinstance(block, EmbeddedResource) and isinstance(
|
|
62
|
+
block.resource, BlobResourceContents,
|
|
63
|
+
):
|
|
64
|
+
mime_type = block.resource.mimeType or "application/octet-stream"
|
|
65
|
+
uri = str(block.resource.uri) if block.resource.uri is not None else ""
|
|
66
|
+
return _blob_resource_to_file_part(
|
|
67
|
+
mime_type, uri, block.resource.blob, counters,
|
|
68
|
+
)
|
|
69
|
+
if isinstance(block, ImageContent):
|
|
70
|
+
return _image_bytes_to_file_part(
|
|
71
|
+
block.mimeType or "application/octet-stream", block.data, counters,
|
|
72
|
+
)
|
|
73
|
+
if isinstance(block, ResourceLink):
|
|
74
|
+
return _resource_link_to_file_part(
|
|
75
|
+
block.mimeType or "application/octet-stream",
|
|
76
|
+
str(block.uri),
|
|
77
|
+
counters,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if not isinstance(block, dict):
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
block_type = block.get("type")
|
|
84
|
+
if block_type == "resource":
|
|
85
|
+
resource = block.get("resource")
|
|
86
|
+
if not isinstance(resource, dict):
|
|
87
|
+
return None
|
|
88
|
+
blob = resource.get("blob")
|
|
89
|
+
if not isinstance(blob, str) or not blob:
|
|
90
|
+
return None
|
|
91
|
+
mime_type_str = _mime_type_or_default(resource.get("mimeType"))
|
|
92
|
+
raw_uri = resource.get("uri")
|
|
93
|
+
uri_str = str(raw_uri) if raw_uri else ""
|
|
94
|
+
return _blob_resource_to_file_part(
|
|
95
|
+
mime_type_str, uri_str, blob, counters,
|
|
96
|
+
)
|
|
97
|
+
if block_type == "image":
|
|
98
|
+
data = block.get("data")
|
|
99
|
+
if not isinstance(data, str) or not data:
|
|
100
|
+
return None
|
|
101
|
+
mime_type_str = _mime_type_or_default(block.get("mimeType"))
|
|
102
|
+
return _image_bytes_to_file_part(mime_type_str, data, counters)
|
|
103
|
+
if block_type == "resource_link":
|
|
104
|
+
raw_uri = block.get("uri")
|
|
105
|
+
if not raw_uri:
|
|
106
|
+
return None
|
|
107
|
+
mime_type_str = _mime_type_or_default(block.get("mimeType"))
|
|
108
|
+
return _resource_link_to_file_part(
|
|
109
|
+
mime_type_str, str(raw_uri), counters,
|
|
110
|
+
)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _mime_type_or_default(value: Any) -> str:
|
|
115
|
+
if isinstance(value, str) and value:
|
|
116
|
+
return value
|
|
117
|
+
return "application/octet-stream"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _blob_resource_to_file_part(
|
|
121
|
+
mime_type: str, uri: str, blob: str, counters: dict[str, int],
|
|
122
|
+
) -> tuple[str, FilePart]:
|
|
123
|
+
kind = "image" if mime_type.startswith("image/") else "attachment"
|
|
124
|
+
if uri:
|
|
125
|
+
name = _name_from_uri(uri, kind, counters[kind], mime_type)
|
|
126
|
+
else:
|
|
127
|
+
name = _synthetic_name(kind, counters[kind], mime_type)
|
|
128
|
+
counters[kind] += 1
|
|
129
|
+
return name, FilePart(file=FileWithBytes(
|
|
130
|
+
name=name, mime_type=mime_type, bytes=blob,
|
|
131
|
+
))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _image_bytes_to_file_part(
|
|
135
|
+
mime_type: str, data: str, counters: dict[str, int],
|
|
136
|
+
) -> tuple[str, FilePart]:
|
|
137
|
+
name = _synthetic_name("image", counters["image"], mime_type)
|
|
138
|
+
counters["image"] += 1
|
|
139
|
+
return name, FilePart(file=FileWithBytes(
|
|
140
|
+
name=name, mime_type=mime_type, bytes=data,
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _resource_link_to_file_part(
|
|
145
|
+
mime_type: str, uri: str, counters: dict[str, int],
|
|
146
|
+
) -> tuple[str, FilePart]:
|
|
147
|
+
kind = "image" if mime_type.startswith("image/") else "attachment"
|
|
148
|
+
name = _name_from_uri(uri, kind, counters[kind], mime_type)
|
|
149
|
+
counters[kind] += 1
|
|
150
|
+
return name, FilePart(file=FileWithUri(
|
|
151
|
+
name=name, mime_type=mime_type, uri=uri,
|
|
152
|
+
))
|
|
153
|
+
|
|
154
|
+
|
|
82
155
|
def _extract_from_langchain_content_blocks(content: list[Any]) -> list[tuple[str, FilePart]]:
|
|
83
156
|
out: list[tuple[str, FilePart]] = []
|
|
84
157
|
pending_name: str | None = None
|
|
@@ -26,12 +26,12 @@ async def hide_binary_content_from_llm(
|
|
|
26
26
|
return result
|
|
27
27
|
|
|
28
28
|
text_blocks: list[TextContent] = []
|
|
29
|
-
non_text_blocks: list[Any] = []
|
|
29
|
+
non_text_blocks: list[dict[str, Any]] = []
|
|
30
30
|
for block in result.content:
|
|
31
31
|
if isinstance(block, TextContent):
|
|
32
32
|
text_blocks.append(block)
|
|
33
33
|
else:
|
|
34
|
-
non_text_blocks.append(block)
|
|
34
|
+
non_text_blocks.append(block.model_dump(mode="json"))
|
|
35
35
|
|
|
36
36
|
merged_structured: dict[str, Any] = (
|
|
37
37
|
dict(result.structuredContent) if result.structuredContent else {}
|
|
@@ -7,6 +7,7 @@ from langchain_core.messages import ToolMessage
|
|
|
7
7
|
from langchain_core.tools import StructuredTool
|
|
8
8
|
from langchain_mcp_adapters.interceptors import MCPToolCallRequest
|
|
9
9
|
from langchain_mcp_adapters.tools import _convert_call_tool_result
|
|
10
|
+
from langgraph.checkpoint.serde.jsonplus import JsonPlusSerializer
|
|
10
11
|
from mcp.types import (BlobResourceContents, CallToolResult, EmbeddedResource,
|
|
11
12
|
ImageContent, TextContent)
|
|
12
13
|
from pydantic import AnyUrl
|
|
@@ -72,7 +73,11 @@ async def test_mixed_result_moves_binary_into_structured_content() -> None:
|
|
|
72
73
|
assert isinstance(result.content[0], TextContent)
|
|
73
74
|
assert result.content[0].text == '{"filename": "alice.docx"}'
|
|
74
75
|
assert result.structuredContent is not None
|
|
75
|
-
|
|
76
|
+
# Non-text blocks are stored as JSON-safe dicts (not raw pydantic
|
|
77
|
+
# instances) so downstream LangGraph msgpack checkpointing works.
|
|
78
|
+
assert result.structuredContent[NON_TEXT_CONTENT_KEY] == [
|
|
79
|
+
embedded.model_dump(mode="json"),
|
|
80
|
+
]
|
|
76
81
|
|
|
77
82
|
|
|
78
83
|
@pytest.mark.asyncio
|
|
@@ -89,7 +94,9 @@ async def test_binary_only_result_produces_empty_content_list() -> None:
|
|
|
89
94
|
"Binary-only tool output should leave content empty — the model "
|
|
90
95
|
"receives no text, and the block is only reachable via artifact."
|
|
91
96
|
)
|
|
92
|
-
assert result.structuredContent == {
|
|
97
|
+
assert result.structuredContent == {
|
|
98
|
+
NON_TEXT_CONTENT_KEY: [embedded.model_dump(mode="json")],
|
|
99
|
+
}
|
|
93
100
|
|
|
94
101
|
|
|
95
102
|
@pytest.mark.asyncio
|
|
@@ -129,7 +136,7 @@ async def test_preserves_existing_structured_content() -> None:
|
|
|
129
136
|
assert result.structuredContent == {
|
|
130
137
|
"foo": 1,
|
|
131
138
|
"nested": {"bar": 2},
|
|
132
|
-
NON_TEXT_CONTENT_KEY: [embedded],
|
|
139
|
+
NON_TEXT_CONTENT_KEY: [embedded.model_dump(mode="json")],
|
|
133
140
|
}
|
|
134
141
|
assert original.structuredContent == {"foo": 1, "nested": {"bar": 2}}
|
|
135
142
|
|
|
@@ -151,7 +158,9 @@ async def test_image_content_is_hidden() -> None:
|
|
|
151
158
|
result = await hide_binary_content_from_llm(_request(), _make_handler(original))
|
|
152
159
|
assert isinstance(result, CallToolResult)
|
|
153
160
|
assert result.content == [TextContent(type="text", text="see below")]
|
|
154
|
-
assert result.structuredContent == {
|
|
161
|
+
assert result.structuredContent == {
|
|
162
|
+
NON_TEXT_CONTENT_KEY: [image.model_dump(mode="json")],
|
|
163
|
+
}
|
|
155
164
|
|
|
156
165
|
|
|
157
166
|
def test_adapter_forwards_structured_content_into_tool_message_artifact() -> None:
|
|
@@ -195,3 +204,46 @@ def test_base_tool_invoke_sets_tool_call_id_when_content_is_not_tool_message() -
|
|
|
195
204
|
assert result.artifact == {
|
|
196
205
|
"structured_content": {NON_TEXT_CONTENT_KEY: ["placeholder"]},
|
|
197
206
|
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@pytest.mark.asyncio
|
|
210
|
+
async def test_tool_message_from_interceptor_is_msgpack_serializable() -> None:
|
|
211
|
+
embedded = _embedded_docx()
|
|
212
|
+
image = ImageContent(type="image", data="AA==", mimeType="image/png")
|
|
213
|
+
original = CallToolResult(
|
|
214
|
+
content=[
|
|
215
|
+
TextContent(type="text", text='{"filename": "alice.docx"}'),
|
|
216
|
+
embedded,
|
|
217
|
+
image,
|
|
218
|
+
],
|
|
219
|
+
isError=False,
|
|
220
|
+
)
|
|
221
|
+
stashed = await hide_binary_content_from_llm(
|
|
222
|
+
_request(), _make_handler(original),
|
|
223
|
+
)
|
|
224
|
+
assert isinstance(stashed, CallToolResult)
|
|
225
|
+
|
|
226
|
+
content, artifact = _convert_call_tool_result(stashed)
|
|
227
|
+
tool_msg = ToolMessage(
|
|
228
|
+
content=content, # type: ignore[arg-type]
|
|
229
|
+
tool_call_id="call-1",
|
|
230
|
+
name="render_file",
|
|
231
|
+
artifact=artifact,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
serde = JsonPlusSerializer()
|
|
235
|
+
kind, blob = serde.dumps_typed({"messages": [tool_msg]})
|
|
236
|
+
assert kind == "msgpack"
|
|
237
|
+
loaded = serde.loads_typed((kind, blob))
|
|
238
|
+
assert isinstance(loaded, dict)
|
|
239
|
+
loaded_messages = loaded["messages"]
|
|
240
|
+
assert len(loaded_messages) == 1
|
|
241
|
+
assert isinstance(loaded_messages[0], ToolMessage)
|
|
242
|
+
assert loaded_messages[0].tool_call_id == "call-1"
|
|
243
|
+
assert loaded_messages[0].artifact is not None
|
|
244
|
+
non_text = loaded_messages[0].artifact["structured_content"][
|
|
245
|
+
NON_TEXT_CONTENT_KEY
|
|
246
|
+
]
|
|
247
|
+
assert len(non_text) == 2
|
|
248
|
+
assert non_text[0]["type"] == "resource"
|
|
249
|
+
assert non_text[1]["type"] == "image"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/distributed_a2a/registry_server/bootstrap.py
RENAMED
|
File without changes
|
{distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/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.2.4 → distributed_a2a-0.2.5}/distributed_a2a/schemas/router-agent-schema.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{distributed_a2a-0.2.4 → distributed_a2a-0.2.5}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|