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.
Files changed (42) hide show
  1. {distributed_a2a-0.2.1/distributed_a2a.egg-info → distributed_a2a-0.2.3}/PKG-INFO +22 -1
  2. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/README.md +21 -0
  3. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/__init__.py +5 -1
  4. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/executors.py +17 -36
  5. distributed_a2a-0.2.3/distributed_a2a/file_extractors.py +142 -0
  6. distributed_a2a-0.2.3/distributed_a2a/mcp_interceptors.py +44 -0
  7. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3/distributed_a2a.egg-info}/PKG-INFO +22 -1
  8. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a.egg-info/SOURCES.txt +4 -1
  9. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/pyproject.toml +1 -1
  10. distributed_a2a-0.2.3/tests/test_executor_files.py +283 -0
  11. distributed_a2a-0.2.3/tests/test_file_extractors.py +493 -0
  12. distributed_a2a-0.2.3/tests/test_mcp_interceptors.py +197 -0
  13. distributed_a2a-0.2.1/tests/test_executor_files.py +0 -182
  14. distributed_a2a-0.2.1/tests/test_files.py +0 -237
  15. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/LICENSE +0 -0
  16. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/MANIFEST.in +0 -0
  17. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/agent.py +0 -0
  18. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/client.py +0 -0
  19. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/config.py +0 -0
  20. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/model.py +0 -0
  21. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/py.typed +0 -0
  22. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry.py +0 -0
  23. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/__init__.py +0 -0
  24. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/bootstrap.py +0 -0
  25. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/dynamo_db.py +0 -0
  26. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/in_memory_registry_storage.py +0 -0
  27. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/model.py +0 -0
  28. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/registry_server/storage.py +0 -0
  29. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/router.py +0 -0
  30. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/schemas/agent-schema.json +0 -0
  31. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/schemas/router-agent-schema.json +0 -0
  32. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a/server.py +0 -0
  33. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a.egg-info/dependency_links.txt +0 -0
  34. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a.egg-info/requires.txt +0 -0
  35. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/distributed_a2a.egg-info/top_level.txt +0 -0
  36. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/requirements.txt +0 -0
  37. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/setup.cfg +0 -0
  38. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/setup.py +0 -0
  39. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/tests/test_app.py +0 -0
  40. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/tests/test_client.py +0 -0
  41. {distributed_a2a-0.2.1 → distributed_a2a-0.2.3}/tests/test_rejection.py +0 -0
  42. {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.1
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, FileWithBytes, Part,
8
- TaskArtifactUpdateEvent, TaskState, TaskStatus,
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 = _extract_file_parts(invocation.messages)
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 = {tool["name"]: {"url": tool["url"], "transport": tool["protocol"],
238
- "headers": settings.get_mcp_auth_headers(tool["name"])} for tool in
239
- mcp_server_raw}
240
- mcp_client = MultiServerMCPClient(mcp_servers) # type: ignore[arg-type]
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.1
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/test_files.py
35
+ tests/test_file_extractors.py
36
+ tests/test_mcp_interceptors.py
34
37
  tests/test_rejection.py
35
38
  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.2.1"
7
+ version = "0.2.3"
8
8
  description = "A library for building A2A agents with routing capabilities"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.14"