mirror of
https://github.com/strands-agents/harness-sdk.git
synced 2026-10-02 02:44:48 +08:00
feat(vended-tools): port a2a_client tool to TypeScript (#4575)
This commit is contained in:
@@ -76,3 +76,9 @@ import { Agent } from '@strands-agents/sdk'
|
||||
import { makeWebFetch } from '@strands-agents/sdk/vended-tools/web-fetch'
|
||||
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
|
||||
// --8<-- [end:web_fetch_custom_import]
|
||||
|
||||
// --8<-- [start:a2a_client_import]
|
||||
import { Agent } from '@strands-agents/sdk'
|
||||
import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory, RestTransportFactory, createAuthenticatingFetchWithRetry } from '@a2a-js/sdk/client'
|
||||
import { makeA2AClient } from '@strands-agents/sdk/vended-tools/a2a-client'
|
||||
// --8<-- [end:a2a_client_import]
|
||||
|
||||
@@ -13,6 +13,7 @@ sourceLinks:
|
||||
- path: strands-ts/src/vended-tools/sleep/sleep.ts
|
||||
- path: strands-ts/src/vended-tools/web-fetch/web-fetch.ts
|
||||
- path: strands-ts/src/experimental/vended-tools/stop/stop.ts
|
||||
- path: strands-ts/src/vended-tools/a2a-client/a2a-client.ts
|
||||
- path: strands-py/src/strands/vended_tools/handoff_to_user/handoff_to_user.py
|
||||
- path: strands-py/src/strands/vended_tools/http_request/http_request.py
|
||||
- path: strands-py/src/strands/vended_tools/notebook/notebook.py
|
||||
@@ -51,7 +52,7 @@ Each tool is imported from its own subpath under `@strands-agents/sdk/vended-too
|
||||
| [Handoff to User](#handoff-to-user) | Pause the agent loop and surface a message to the user | Python, TypeScript (Node.js, browsers) |
|
||||
| [Stop](#stop-experimental) | Gracefully end the agent loop when the task is complete | Python, TypeScript (Node.js, browsers) |
|
||||
| [Web Fetch](#web-fetch) | Fetch a URL and return cleaned markdown for a model to read | Python, TypeScript (Node.js) |
|
||||
| [A2A Client](#a2a-client) | Discover and send messages to remote A2A-protocol agents | Python |
|
||||
| [A2A Client](#a2a-client) | Discover and send messages to remote A2A-protocol agents | Python, TypeScript (Node.js, browsers) |
|
||||
|
||||
### File editor
|
||||
|
||||
@@ -648,17 +649,48 @@ Two operations are available:
|
||||
- **`send_message`** — sends a text message to a remote A2A agent and returns the response.
|
||||
|
||||
The tool is stateless: a fresh `A2AAgent` is created on every call using the
|
||||
`ClientConfig` configured for that endpoint.
|
||||
Use ``make_a2a_client`` to control which endpoints the model may contact, with
|
||||
per-endpoint configuration. Use `make_a2a_client` / `makeA2AClient` to control which endpoints the model may contact, with
|
||||
per-endpoint authentication, and to tune size limits.
|
||||
Requires `pip install 'strands-agents[a2a]'`.
|
||||
|
||||
_Supported in: Python._
|
||||
_Supported in: Python, TypeScript (Node.js, browsers)._
|
||||
|
||||
:::note[Install required]
|
||||
<Tabs>
|
||||
<Tab label="Python">
|
||||
`a2a_client` requires the optional `a2a` extra:
|
||||
|
||||
```bash
|
||||
pip install 'strands-agents[a2a]'
|
||||
```
|
||||
</Tab>
|
||||
<Tab label="TypeScript">
|
||||
Requires the optional `@a2a-js/sdk` peer dependency:
|
||||
|
||||
```bash
|
||||
npm install @a2a-js/sdk
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
:::
|
||||
|
||||
:::caution[Endpoint security]
|
||||
`allowed_endpoints` is required and checked before any network connection is made, but it only gates the agent-card fetch. `send_message` is delivered to the `url` in that card, which may point to a different host, and HTTP redirects are not checked either. For full egress control, enforce it at the network layer.
|
||||
`allowed_endpoints` / `allowedEndpoints` is required and checked before any network connection is made, but it only gates the agent-card fetch. `send_message` is delivered to the `url` in that card, which may point to a different host, and HTTP redirects are not checked either. For full egress control, enforce it at the network layer.
|
||||
:::
|
||||
|
||||
**Example:**
|
||||
|
||||
<Tabs>
|
||||
<Tab label="TypeScript">
|
||||
|
||||
```typescript
|
||||
--8<-- "user-guide/sdk/tools/vended-tools-imports.ts:a2a_client_import"
|
||||
|
||||
--8<-- "user-guide/sdk/tools/vended-tools.ts:a2a_client_example"
|
||||
```
|
||||
|
||||
</Tab>
|
||||
<Tab label="Python">
|
||||
|
||||
```python
|
||||
import httpx
|
||||
from a2a.client import ClientConfig
|
||||
@@ -666,21 +698,25 @@ from strands import Agent
|
||||
from strands.vended_tools import make_a2a_client
|
||||
|
||||
a2a_client = make_a2a_client(
|
||||
allowed_endpoints={
|
||||
"https://agent.example.com": None,
|
||||
"https://researcher.example.com": ClientConfig(
|
||||
allowed_endpoints=[
|
||||
"https://agent.example.com",
|
||||
("https://researcher.example.com", ClientConfig(
|
||||
httpx_client=httpx.AsyncClient(
|
||||
headers={"Authorization": "Bearer your-token"},
|
||||
timeout=60.0,
|
||||
),
|
||||
),
|
||||
},
|
||||
max_bytes=1 * 1024 * 1024,
|
||||
)),
|
||||
],
|
||||
)
|
||||
agent = Agent(tools=[a2a_client])
|
||||
agent("What has the research agent found recently?")
|
||||
```
|
||||
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Full API reference: [TypeScript](https://github.com/strands-agents/harness-sdk/blob/main/strands-ts/src/vended-tools/a2a-client/README.md)
|
||||
|
||||
---
|
||||
|
||||
## Using multiple tools together
|
||||
|
||||
@@ -15,6 +15,8 @@ import { sleep, makeSleep } from '@strands-agents/sdk/vended-tools/sleep'
|
||||
import { stop } from '@strands-agents/sdk/experimental/vended-tools/stop'
|
||||
import { webFetch, makeWebFetch } from '@strands-agents/sdk/vended-tools/web-fetch'
|
||||
import { BedrockModel } from '@strands-agents/sdk/models/bedrock'
|
||||
import { makeA2AClient } from '@strands-agents/sdk/vended-tools/a2a-client'
|
||||
import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory, RestTransportFactory, createAuthenticatingFetchWithRetry } from '@a2a-js/sdk/client'
|
||||
|
||||
// Agent with vended tools example
|
||||
async function agentWithVendedToolsExample() {
|
||||
@@ -231,3 +233,31 @@ async function webFetchCustomExample() {
|
||||
// --8<-- [end:web_fetch_custom_example]
|
||||
void agent
|
||||
}
|
||||
|
||||
// A2A client example
|
||||
async function a2aClientExample() {
|
||||
// --8<-- [start:a2a_client_example]
|
||||
const authFetch = createAuthenticatingFetchWithRetry(fetch, {
|
||||
headers: async () => ({ Authorization: 'Bearer your-token' }),
|
||||
shouldRetryWithHeaders: async () => undefined,
|
||||
})
|
||||
|
||||
const a2aClient = makeA2AClient({
|
||||
allowedEndpoints: [
|
||||
// No auth needed
|
||||
'https://agent.example.com',
|
||||
// Custom ClientFactory for authenticated requests
|
||||
['https://researcher.example.com', new ClientFactory({
|
||||
transports: [
|
||||
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
|
||||
new RestTransportFactory({ fetchImpl: authFetch }),
|
||||
],
|
||||
cardResolver: new DefaultAgentCardResolver({ fetchImpl: authFetch }),
|
||||
})],
|
||||
],
|
||||
})
|
||||
|
||||
const agent = new Agent({ tools: [a2aClient] })
|
||||
await agent.invoke('What has the research agent found recently?')
|
||||
// --8<-- [end:a2a_client_example]
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ requires the optional ``web-fetch`` extra (``pip install 'strands-agents[web-fet
|
||||
and is imported lazily, so accessing it without that extra raises :class:`ImportError`.
|
||||
|
||||
The :func:`make_a2a_client` factory creates a tool that discovers and sends messages to remote A2A-protocol
|
||||
agents. Supply the required ``allowed_endpoints`` dict plus optional
|
||||
authentication via a :class:`~a2a.client.ClientConfig`, or custom size limits.
|
||||
agents. Supply the required ``allowed_endpoints`` list — each entry is either a bare URL string or a
|
||||
``(url, ClientConfig)`` tuple.
|
||||
It requires the optional ``a2a`` extra (``pip install 'strands-agents[a2a]'``)
|
||||
and is imported lazily, so accessing it without that extra raises :class:`ImportError`.
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@ Example Usage:
|
||||
from strands.vended_tools import make_a2a_client
|
||||
|
||||
tool = make_a2a_client(
|
||||
allowed_endpoints={
|
||||
"https://agent.example.com": None,
|
||||
"https://secure-agent.example.com": ClientConfig(
|
||||
allowed_endpoints=[
|
||||
"https://agent.example.com",
|
||||
("https://secure-agent.example.com", ClientConfig(
|
||||
httpx_client=httpx.AsyncClient(
|
||||
headers={"Authorization": "Bearer your-token"},
|
||||
),
|
||||
),
|
||||
}
|
||||
)),
|
||||
]
|
||||
)
|
||||
agent = Agent(tools=[tool])
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""A2A client tool for communicating with remote A2A-protocol agents.
|
||||
|
||||
Provides :func:`make_a2a_client`, a factory that requires an explicit mapping of
|
||||
permitted endpoints to their :class:`~a2a.client.ClientConfig`, plus optional size limits.
|
||||
Provides :func:`make_a2a_client`, a factory that requires an explicit list of
|
||||
permitted endpoints (with optional :class:`~a2a.client.ClientConfig`).
|
||||
|
||||
The tool is a stateless shim over :class:`~strands.agent.a2a_agent.A2AAgent`.
|
||||
A fresh ``A2AAgent`` is constructed on every call so the tool carries no session
|
||||
@@ -12,7 +12,6 @@ state between invocations. Each endpoint may carry its own
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
try:
|
||||
@@ -26,8 +25,6 @@ from ...tools.decorator import tool
|
||||
if TYPE_CHECKING:
|
||||
from ...tools.decorator import DecoratedFunctionTool
|
||||
|
||||
_DEFAULT_MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
_A2AClientOutput = dict[str, Any]
|
||||
|
||||
DEFAULT_A2A_CLIENT_DESCRIPTION = (
|
||||
@@ -42,12 +39,15 @@ class A2AClientError(RuntimeError):
|
||||
"""Raised when an A2A operation fails."""
|
||||
|
||||
|
||||
AllowedEndpoint = str | tuple[str, ClientConfig]
|
||||
"""A permitted endpoint: a bare URL string, or a ``(url, ClientConfig)`` tuple."""
|
||||
|
||||
|
||||
def make_a2a_client(
|
||||
*,
|
||||
name: str = "a2a_client",
|
||||
description: str | None = None,
|
||||
allowed_endpoints: dict[str, ClientConfig | None],
|
||||
max_bytes: int = _DEFAULT_MAX_BYTES,
|
||||
allowed_endpoints: list[AllowedEndpoint],
|
||||
) -> DecoratedFunctionTool:
|
||||
"""Create an A2A client tool.
|
||||
|
||||
@@ -56,24 +56,29 @@ def make_a2a_client(
|
||||
description: Tool description shown to the model. When ``None``,
|
||||
generated from ``DEFAULT_A2A_CLIENT_DESCRIPTION`` plus the
|
||||
permitted endpoints list.
|
||||
allowed_endpoints: Mapping of permitted base URLs to their
|
||||
:class:`~a2a.client.ClientConfig`. Use ``None`` as the value for
|
||||
endpoints that need no custom configuration. Any endpoint not in this
|
||||
mapping is rejected before a network connection is made.
|
||||
max_bytes: Maximum size in bytes of the result dict returned to the model.
|
||||
Does not cap the network transfer or binary parts.
|
||||
Results larger than this cap are rejected with an error.
|
||||
allowed_endpoints: Permitted base URLs. Each entry is either a bare URL
|
||||
string (no custom config) or a ``(url, ClientConfig)`` tuple for
|
||||
per-endpoint authentication. Any endpoint not in this list is
|
||||
rejected before a network connection is made.
|
||||
|
||||
Returns:
|
||||
A decorated tool that communicates with A2A agents.
|
||||
"""
|
||||
if not allowed_endpoints:
|
||||
raise ValueError("allowed_endpoints must contain at least one endpoint")
|
||||
if max_bytes <= 0:
|
||||
raise ValueError(f"max_bytes must be positive, got {max_bytes}")
|
||||
|
||||
for entry in allowed_endpoints:
|
||||
if isinstance(entry, str):
|
||||
continue
|
||||
if not isinstance(entry, tuple) or len(entry) != 2 or not isinstance(entry[0], str):
|
||||
raise TypeError(
|
||||
f"Each allowed endpoint must be a string URL or (str, ClientConfig) tuple, got {type(entry).__name__}"
|
||||
)
|
||||
|
||||
endpoints_map = _normalize_endpoints(allowed_endpoints)
|
||||
|
||||
if description is None:
|
||||
endpoints_list = ", ".join(sorted(allowed_endpoints))
|
||||
endpoints_list = ", ".join(sorted(endpoints_map))
|
||||
description = f"{DEFAULT_A2A_CLIENT_DESCRIPTION} Permitted endpoints: {endpoints_list}."
|
||||
|
||||
@tool(name=name, description=description)
|
||||
@@ -100,42 +105,50 @@ def make_a2a_client(
|
||||
underlying A2A call fails.
|
||||
"""
|
||||
# Check if the endpoint is allowed via exact-match.
|
||||
if endpoint not in allowed_endpoints:
|
||||
if endpoint not in endpoints_map:
|
||||
raise A2AClientError(
|
||||
f"Endpoint '{endpoint}' is not in the allowed endpoints list. "
|
||||
f"Permitted endpoints: {sorted(allowed_endpoints)}"
|
||||
f"Permitted endpoints: {sorted(endpoints_map)}"
|
||||
)
|
||||
|
||||
agent = A2AAgent(endpoint, client_config=allowed_endpoints[endpoint])
|
||||
agent = A2AAgent(endpoint, client_config=endpoints_map[endpoint])
|
||||
|
||||
if operation == "discover":
|
||||
return await _handle_discover(agent, max_bytes)
|
||||
return await _handle_discover(agent)
|
||||
|
||||
if operation == "send_message":
|
||||
if not message:
|
||||
raise A2AClientError("'message' is required for send_message operation")
|
||||
return await _handle_send_message(agent, message, max_bytes)
|
||||
return await _handle_send_message(agent, message)
|
||||
|
||||
raise A2AClientError(f"Unknown operation: {operation!r}")
|
||||
|
||||
return a2a_client_tool
|
||||
|
||||
|
||||
async def _handle_discover(agent: A2AAgent, max_bytes: int) -> _A2AClientOutput:
|
||||
def _normalize_endpoints(entries: list[AllowedEndpoint]) -> dict[str, ClientConfig | None]:
|
||||
"""Convert the user-facing list into an internal ``{url: config}`` mapping."""
|
||||
result: dict[str, ClientConfig | None] = {}
|
||||
for entry in entries:
|
||||
if isinstance(entry, str):
|
||||
result[entry] = None
|
||||
else:
|
||||
url, config = entry
|
||||
result[url] = config
|
||||
return result
|
||||
|
||||
|
||||
async def _handle_discover(agent: A2AAgent) -> _A2AClientOutput:
|
||||
"""Fetch the agent card via *agent* and return it as a dict."""
|
||||
try:
|
||||
agent_card = await agent.get_agent_card()
|
||||
except Exception as error:
|
||||
raise A2AClientError(f"Failed to discover agent card at {agent.endpoint!r}: {error}") from error
|
||||
|
||||
result: dict[str, Any] = agent_card.model_dump(mode="json", exclude_none=True)
|
||||
size = len(json.dumps(result).encode())
|
||||
if size > max_bytes:
|
||||
raise A2AClientError(f"Agent card response exceeds max_bytes limit ({size} > {max_bytes})")
|
||||
return result
|
||||
return agent_card.model_dump(mode="json", exclude_none=True)
|
||||
|
||||
|
||||
async def _handle_send_message(agent: A2AAgent, message_text: str, max_bytes: int) -> _A2AClientOutput:
|
||||
async def _handle_send_message(agent: A2AAgent, message_text: str) -> _A2AClientOutput:
|
||||
"""Send *message_text* via *agent* and return the response as a dict."""
|
||||
try:
|
||||
agent_result = await agent.invoke_async(message_text)
|
||||
@@ -150,7 +163,4 @@ async def _handle_send_message(agent: A2AAgent, message_text: str, max_bytes: in
|
||||
)
|
||||
|
||||
result: dict[str, Any] = {"message": agent_result.message}
|
||||
size = len(json.dumps(result).encode())
|
||||
if size > max_bytes:
|
||||
raise A2AClientError(f"Response exceeds max_bytes limit ({size} > {max_bytes})")
|
||||
return result
|
||||
|
||||
@@ -25,7 +25,7 @@ _FAKE_CARD = {
|
||||
_FAKE_MESSAGE = {"role": "assistant", "content": [{"text": "Hello from agent"}]}
|
||||
|
||||
_ENDPOINT = "https://agent.example.com"
|
||||
_ENDPOINTS: dict[str, ClientConfig | None] = {_ENDPOINT: None}
|
||||
_ENDPOINTS: list[str] = [_ENDPOINT]
|
||||
|
||||
|
||||
class _FakeAgentCard:
|
||||
@@ -58,7 +58,7 @@ def fake_agent(monkeypatch):
|
||||
class TestAllowlist:
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_endpoint_not_in_allowlist(self):
|
||||
tool = make_a2a_client(allowed_endpoints={"https://a.example.com": None, "https://b.example.com": None})
|
||||
tool = make_a2a_client(allowed_endpoints=["https://a.example.com", "https://b.example.com"])
|
||||
with pytest.raises(A2AClientError, match="not in the allowed endpoints list") as exc_info:
|
||||
await tool(operation="discover", endpoint="https://evil.example.com")
|
||||
assert "https://a.example.com" in str(exc_info.value)
|
||||
@@ -85,19 +85,6 @@ class TestDiscover:
|
||||
await tool(operation="discover", endpoint=_ENDPOINT)
|
||||
assert exc_info.value.__cause__ is original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_oversized_agent_card(self, monkeypatch):
|
||||
class _BigCardAgent(_FakeA2AAgent):
|
||||
async def get_agent_card(self) -> _FakeAgentCard:
|
||||
card = _FakeAgentCard()
|
||||
card.model_dump = lambda **_: {"data": "x" * 1000}
|
||||
return card
|
||||
|
||||
monkeypatch.setattr(a2a_client_module, "A2AAgent", _BigCardAgent)
|
||||
tool = make_a2a_client(allowed_endpoints=_ENDPOINTS, max_bytes=100)
|
||||
with pytest.raises(A2AClientError, match="exceeds max_bytes limit"):
|
||||
await tool(operation="discover", endpoint=_ENDPOINT)
|
||||
|
||||
|
||||
class TestSendMessage:
|
||||
@pytest.mark.asyncio
|
||||
@@ -155,35 +142,26 @@ class TestSendMessage:
|
||||
await tool(operation="send_message", endpoint=_ENDPOINT, message="Hello")
|
||||
assert exc_info.value.__cause__ is original
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rejects_oversized_response(self, monkeypatch):
|
||||
class _BigResponseAgent(_FakeA2AAgent):
|
||||
async def invoke_async(self, prompt: str) -> _FakeAgentResult:
|
||||
result = _FakeAgentResult()
|
||||
result.message = {"role": "assistant", "content": [{"text": "x" * 1000}]}
|
||||
return result
|
||||
|
||||
monkeypatch.setattr(a2a_client_module, "A2AAgent", _BigResponseAgent)
|
||||
tool = make_a2a_client(allowed_endpoints=_ENDPOINTS, max_bytes=100)
|
||||
with pytest.raises(A2AClientError, match="exceeds max_bytes limit"):
|
||||
await tool(operation="send_message", endpoint=_ENDPOINT, message="Hello")
|
||||
|
||||
|
||||
class TestFactory:
|
||||
def test_empty_allowed_endpoints_raises(self):
|
||||
with pytest.raises(ValueError, match="allowed_endpoints must contain at least one endpoint"):
|
||||
make_a2a_client(allowed_endpoints={})
|
||||
make_a2a_client(allowed_endpoints=[])
|
||||
|
||||
def test_non_positive_max_bytes_raises(self):
|
||||
with pytest.raises(ValueError, match="max_bytes must be positive"):
|
||||
make_a2a_client(allowed_endpoints=_ENDPOINTS, max_bytes=0)
|
||||
def test_non_string_endpoint_raises(self):
|
||||
with pytest.raises(TypeError, match="Each allowed endpoint must be a string URL"):
|
||||
make_a2a_client(allowed_endpoints=[123])
|
||||
|
||||
def test_non_string_tuple_endpoint_raises(self):
|
||||
with pytest.raises(TypeError, match="Each allowed endpoint must be a string URL"):
|
||||
make_a2a_client(allowed_endpoints=[(123, ClientConfig())])
|
||||
|
||||
def test_custom_name(self):
|
||||
tool = make_a2a_client(name="my_agent", allowed_endpoints=_ENDPOINTS)
|
||||
assert tool.tool_name == "my_agent"
|
||||
|
||||
def test_description_includes_endpoints(self):
|
||||
tool = make_a2a_client(allowed_endpoints={"https://a.example.com": None, "https://b.example.com": None})
|
||||
tool = make_a2a_client(allowed_endpoints=["https://a.example.com", "https://b.example.com"])
|
||||
desc = tool.tool_spec["description"]
|
||||
assert "https://a.example.com" in desc
|
||||
assert "https://b.example.com" in desc
|
||||
@@ -206,7 +184,7 @@ class TestFactory:
|
||||
|
||||
monkeypatch.setattr(a2a_client_module, "A2AAgent", _CapturingAgent)
|
||||
config = ClientConfig()
|
||||
tool = make_a2a_client(allowed_endpoints={_ENDPOINT: config})
|
||||
tool = make_a2a_client(allowed_endpoints=[(_ENDPOINT, config)])
|
||||
await tool(operation="discover", endpoint=_ENDPOINT)
|
||||
assert seen_config[0] is config
|
||||
|
||||
|
||||
@@ -84,6 +84,10 @@
|
||||
"types": "./dist/src/vended-tools/sleep/index.d.ts",
|
||||
"default": "./dist/src/vended-tools/sleep/index.js"
|
||||
},
|
||||
"./vended-tools/a2a-client": {
|
||||
"types": "./dist/src/vended-tools/a2a-client/index.d.ts",
|
||||
"default": "./dist/src/vended-tools/a2a-client/index.js"
|
||||
},
|
||||
"./experimental/vended-tools/stop": {
|
||||
"types": "./dist/src/experimental/vended-tools/stop/index.d.ts",
|
||||
"default": "./dist/src/experimental/vended-tools/stop/index.js"
|
||||
|
||||
@@ -163,6 +163,16 @@ export class A2AAgent implements InvokableAgent {
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the remote agent's card, connecting lazily on first call.
|
||||
*
|
||||
* @returns The remote agent's AgentCard
|
||||
*/
|
||||
async getAgentCard(): Promise<AgentCard> {
|
||||
await this._getClient()
|
||||
return this._agentCard!
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached A2A SDK client, creating one lazily on first use.
|
||||
* Also fetches and caches the agent card for name/description.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# A2A Client Tool
|
||||
|
||||
Communicates with remote A2A (Agent-to-Agent) protocol agents. Exposes `discover` (fetch agent card) and `send_message` (send text, receive response) operations behind an endpoint allowlist.
|
||||
|
||||
## ⚠️ Security Warning
|
||||
|
||||
**This tool makes network requests to remote A2A agent endpoints.**
|
||||
|
||||
- Only allow endpoints you trust — the allowlist is enforced before any network connection is made
|
||||
- Requests execute with the network access of the host process
|
||||
- For production deployments, consider running in a sandboxed environment (containers, VMs, etc.)
|
||||
- Never expose this tool to untrusted users or untrusted prompt input without additional security measures
|
||||
|
||||
## Usage
|
||||
|
||||
```typescript
|
||||
import { Agent } from '@strands-agents/sdk'
|
||||
import {
|
||||
ClientFactory,
|
||||
DefaultAgentCardResolver,
|
||||
JsonRpcTransportFactory,
|
||||
RestTransportFactory,
|
||||
createAuthenticatingFetchWithRetry,
|
||||
} from '@a2a-js/sdk/client'
|
||||
import { makeA2AClient } from '@strands-agents/sdk/vended-tools/a2a-client'
|
||||
|
||||
const authFetch = createAuthenticatingFetchWithRetry(fetch, {
|
||||
headers: async () => ({ Authorization: 'Bearer your-token' }),
|
||||
shouldRetryWithHeaders: async () => undefined,
|
||||
})
|
||||
|
||||
const a2aClient = makeA2AClient({
|
||||
allowedEndpoints: [
|
||||
// No auth needed
|
||||
'https://agent.example.com',
|
||||
// Custom ClientFactory for authenticated requests
|
||||
[
|
||||
'https://secure-agent.example.com',
|
||||
new ClientFactory({
|
||||
transports: [
|
||||
new JsonRpcTransportFactory({ fetchImpl: authFetch }),
|
||||
new RestTransportFactory({ fetchImpl: authFetch }),
|
||||
],
|
||||
cardResolver: new DefaultAgentCardResolver({ fetchImpl: authFetch }),
|
||||
}),
|
||||
],
|
||||
],
|
||||
})
|
||||
|
||||
const agent = new Agent({
|
||||
model,
|
||||
tools: [a2aClient],
|
||||
systemPrompt: 'You can talk to remote agents. Discover them first, then send messages.',
|
||||
})
|
||||
|
||||
const result = await agent.invoke('Ask the agent at https://agent.example.com to summarize the news')
|
||||
```
|
||||
|
||||
## Input schema
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ----------- | ------------------------------ | ------------------ | --------------------------------------------------- |
|
||||
| `operation` | `'discover' \| 'send_message'` | Yes | Action to perform |
|
||||
| `endpoint` | `string` | Yes | Base URL of the target agent (must be in allowlist) |
|
||||
| `message` | `string \| null` | For `send_message` | Text to send |
|
||||
|
||||
## How it works
|
||||
|
||||
- **Discover** connects to the endpoint and returns the agent card as a plain JSON object.
|
||||
- **Send message** calls the A2A agent and returns the serialized response message.
|
||||
|
||||
Both operations enforce the endpoint allowlist before making any network connection.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Text only.** Binary parts, images, and other modalities are not supported.
|
||||
- **Stateless.** Each call creates a fresh connection with no session continuity.
|
||||
- **No streaming.** The tool waits for the full response before returning.
|
||||
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { makeA2AClient } from '../a2a-client.js'
|
||||
import type { AgentCard } from '@a2a-js/sdk'
|
||||
import type { ClientFactory as ClientFactoryType } from '@a2a-js/sdk/client'
|
||||
|
||||
const mockGetAgentCard = vi.fn()
|
||||
const mockSendMessageStream = vi.fn()
|
||||
|
||||
vi.mock('@a2a-js/sdk/client', () => ({
|
||||
ClientFactory: class MockClientFactory {
|
||||
async createFromUrl(): Promise<{
|
||||
sendMessageStream: typeof mockSendMessageStream
|
||||
getAgentCard: typeof mockGetAgentCard
|
||||
}> {
|
||||
return {
|
||||
sendMessageStream: mockSendMessageStream,
|
||||
getAgentCard: mockGetAgentCard,
|
||||
}
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const FAKE_CARD: AgentCard = {
|
||||
name: 'Test Agent',
|
||||
description: 'A test agent',
|
||||
version: '1.0.0',
|
||||
protocolVersion: '0.2.0',
|
||||
url: 'https://agent.example.com',
|
||||
defaultInputModes: ['text/plain'],
|
||||
defaultOutputModes: ['text/plain'],
|
||||
skills: [],
|
||||
capabilities: {},
|
||||
}
|
||||
|
||||
const ENDPOINT = 'https://agent.example.com'
|
||||
const ENDPOINTS: string[] = [ENDPOINT]
|
||||
|
||||
async function* mockStream(...events: unknown[]): AsyncGenerator<unknown, void, undefined> {
|
||||
for (const event of events) {
|
||||
yield event
|
||||
}
|
||||
}
|
||||
|
||||
function setupSendMessageResponse(text: string): void {
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
mockStream({
|
||||
kind: 'task',
|
||||
id: 'task-1',
|
||||
contextId: 'ctx-1',
|
||||
status: { state: 'completed' },
|
||||
artifacts: [
|
||||
{
|
||||
artifactId: 'art-1',
|
||||
parts: [{ kind: 'text', text }],
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
describe('a2a-client tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAgentCard.mockResolvedValue(FAKE_CARD)
|
||||
setupSendMessageResponse('Hello from agent')
|
||||
})
|
||||
|
||||
describe('factory', () => {
|
||||
it('throws when allowedEndpoints is empty', () => {
|
||||
expect(() => makeA2AClient({ allowedEndpoints: [] })).toThrow(
|
||||
'allowedEndpoints must contain at least one endpoint'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses custom name', () => {
|
||||
const t = makeA2AClient({ name: 'my_agent', allowedEndpoints: ENDPOINTS })
|
||||
expect(t.name).toBe('my_agent')
|
||||
})
|
||||
|
||||
it('default description includes endpoints', () => {
|
||||
const t = makeA2AClient({
|
||||
allowedEndpoints: ['https://a.example.com', 'https://b.example.com'],
|
||||
})
|
||||
expect(t.description).toContain('https://a.example.com')
|
||||
expect(t.description).toContain('https://b.example.com')
|
||||
})
|
||||
|
||||
it('custom description overrides the default', () => {
|
||||
const t = makeA2AClient({
|
||||
description: 'My custom description',
|
||||
allowedEndpoints: ENDPOINTS,
|
||||
})
|
||||
expect(t.description).toBe('My custom description')
|
||||
})
|
||||
})
|
||||
|
||||
describe('allowlist', () => {
|
||||
it('rejects an endpoint not in the allowlist', async () => {
|
||||
const t = makeA2AClient({
|
||||
allowedEndpoints: ['https://a.example.com', 'https://b.example.com'],
|
||||
})
|
||||
await expect(t.invoke({ operation: 'discover', endpoint: 'https://evil.example.com' })).rejects.toThrow(
|
||||
'not in the allowed endpoints list'
|
||||
)
|
||||
})
|
||||
|
||||
it('includes permitted endpoints in the rejection message', async () => {
|
||||
const t = makeA2AClient({
|
||||
allowedEndpoints: ['https://a.example.com'],
|
||||
})
|
||||
await expect(t.invoke({ operation: 'discover', endpoint: 'https://evil.example.com' })).rejects.toThrow(
|
||||
'https://a.example.com'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('discover', () => {
|
||||
it('returns the agent card as a plain object', async () => {
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
const result = await t.invoke({ operation: 'discover', endpoint: ENDPOINT })
|
||||
expect(result).toEqual(FAKE_CARD)
|
||||
})
|
||||
|
||||
it('wraps discovery errors with cause', async () => {
|
||||
const original = new Error('connection refused')
|
||||
mockGetAgentCard.mockRejectedValue(original)
|
||||
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
await expect(t.invoke({ operation: 'discover', endpoint: ENDPOINT })).rejects.toSatisfy((err: Error) => {
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.message).toContain('Failed to discover agent card')
|
||||
expect(err.cause).toBe(original)
|
||||
return true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('send_message', () => {
|
||||
it('returns the response message', async () => {
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
const result = (await t.invoke({
|
||||
operation: 'send_message',
|
||||
endpoint: ENDPOINT,
|
||||
message: 'Hello',
|
||||
})) as Record<string, unknown>
|
||||
expect(result).toHaveProperty('message')
|
||||
const msg = result['message'] as Record<string, unknown>
|
||||
expect(msg).toHaveProperty('role', 'assistant')
|
||||
})
|
||||
|
||||
it("requires 'message' parameter", async () => {
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT })).rejects.toThrow("'message' is required")
|
||||
})
|
||||
|
||||
it("rejects empty string as 'message'", async () => {
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: '' })).rejects.toThrow(
|
||||
"'message' is required"
|
||||
)
|
||||
})
|
||||
|
||||
it("rejects null as 'message'", async () => {
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: null })).rejects.toThrow(
|
||||
"'message' is required"
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps send errors with cause', async () => {
|
||||
const original = new Error('timeout')
|
||||
mockSendMessageStream.mockImplementation(() => {
|
||||
throw original
|
||||
})
|
||||
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: 'Hello' })).rejects.toSatisfy(
|
||||
(err: Error) => {
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
expect(err.message).toContain('Failed to send message')
|
||||
expect(err.cause).toBe(original)
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['failed', 'rejected', 'canceled', 'input-required', 'auth-required'])(
|
||||
'throws when remote task state is %s',
|
||||
async (state) => {
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
mockStream({
|
||||
kind: 'task',
|
||||
id: 'task-1',
|
||||
contextId: 'ctx-1',
|
||||
status: {
|
||||
state,
|
||||
message: {
|
||||
kind: 'message',
|
||||
messageId: 'msg-1',
|
||||
role: 'agent',
|
||||
parts: [{ kind: 'text', text: 'something went wrong' }],
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: 'Hello' })).rejects.toThrow(
|
||||
`did not complete: task state is '${state}'. something went wrong`
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('throws for a failed task with no status message', async () => {
|
||||
mockSendMessageStream.mockReturnValue(
|
||||
mockStream({
|
||||
kind: 'task',
|
||||
id: 'task-1',
|
||||
contextId: 'ctx-1',
|
||||
status: { state: 'failed' },
|
||||
})
|
||||
)
|
||||
|
||||
const t = makeA2AClient({ allowedEndpoints: ENDPOINTS })
|
||||
await expect(t.invoke({ operation: 'send_message', endpoint: ENDPOINT, message: 'Hello' })).rejects.toThrow(
|
||||
"did not complete: task state is 'failed'"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('per-endpoint config', () => {
|
||||
it('uses the provided ClientFactory for the endpoint', async () => {
|
||||
const customGetAgentCard = vi.fn().mockResolvedValue(FAKE_CARD)
|
||||
const customFactory = {
|
||||
createFromUrl: vi.fn().mockResolvedValue({
|
||||
getAgentCard: customGetAgentCard,
|
||||
sendMessageStream: mockSendMessageStream,
|
||||
}),
|
||||
}
|
||||
|
||||
const t = makeA2AClient({ allowedEndpoints: [[ENDPOINT, customFactory as unknown as ClientFactoryType]] })
|
||||
await t.invoke({ operation: 'discover', endpoint: ENDPOINT })
|
||||
|
||||
expect(customFactory.createFromUrl).toHaveBeenCalledWith(ENDPOINT, undefined)
|
||||
expect(customGetAgentCard).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { ClientFactory as ClientFactoryType } from '@a2a-js/sdk/client'
|
||||
import type { AgentCard, TextPart } from '@a2a-js/sdk'
|
||||
import { tool } from '../../tools/tool-factory.js'
|
||||
import type { JSONValue } from '../../types/json.js'
|
||||
import type { MessageData } from '../../types/messages.js'
|
||||
import { A2AAgent } from '../../a2a/a2a-agent.js'
|
||||
import { A2AStreamUpdateEvent } from '../../a2a/events.js'
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* Zod schema for A2A client input validation.
|
||||
*/
|
||||
const a2aClientInputSchema = z.object({
|
||||
operation: z
|
||||
.enum(['discover', 'send_message'])
|
||||
.describe("Action to perform — 'discover' to fetch the agent card, or 'send_message' to send a message"),
|
||||
endpoint: z.string().describe('Base URL of the target A2A agent. Must be one of the permitted endpoints.'),
|
||||
message: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Text to send to the agent. Required when operation is 'send_message'; ignored otherwise."),
|
||||
})
|
||||
|
||||
/**
|
||||
* A single endpoint entry: either a bare URL string, or a
|
||||
* `[url, ClientFactory]` tuple for endpoints that need custom configuration.
|
||||
*/
|
||||
type EndpointEntry = string | [string, ClientFactoryType]
|
||||
|
||||
/**
|
||||
* Default description shown to the model for the A2A client tool.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { ClientFactory, DefaultAgentCardResolver, JsonRpcTransportFactory, RestTransportFactory, createAuthenticatingFetchWithRetry } from '@a2a-js/sdk/client'
|
||||
*
|
||||
* const authFetch = createAuthenticatingFetchWithRetry(fetch, {
|
||||
* headers: async () => ({ Authorization: 'Bearer your-token' }),
|
||||
* shouldRetryWithHeaders: async () => undefined,
|
||||
* })
|
||||
*
|
||||
* const a2aClient = makeA2AClient({
|
||||
* allowedEndpoints: [
|
||||
* 'https://agent.example.com',
|
||||
* ['https://secure-agent.example.com', new ClientFactory({
|
||||
* transports: [
|
||||
* new JsonRpcTransportFactory({ fetchImpl: authFetch }),
|
||||
* new RestTransportFactory({ fetchImpl: authFetch }),
|
||||
* ],
|
||||
* cardResolver: new DefaultAgentCardResolver({ fetchImpl: authFetch }),
|
||||
* })],
|
||||
* ],
|
||||
* })
|
||||
* const agent = new Agent({ model, tools: [a2aClient] })
|
||||
* ```
|
||||
*/
|
||||
export const DEFAULT_A2A_CLIENT_DESCRIPTION =
|
||||
'Interacts with remote A2A (Agent-to-Agent) protocol agents. ' +
|
||||
"Use operation='discover' to fetch an agent card from an endpoint. " +
|
||||
"Use operation='send_message' to send a message and receive a response. " +
|
||||
'Only the listed endpoints are permitted.'
|
||||
|
||||
export interface MakeA2AClientOptions {
|
||||
name?: string
|
||||
description?: string
|
||||
allowedEndpoints: EndpointEntry[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an A2A client tool that communicates with remote A2A-protocol agents.
|
||||
* Each endpoint may carry its own ClientFactory for per-endpoint authentication.
|
||||
* A fresh A2AAgent is constructed on every call (stateless).
|
||||
*/
|
||||
export function makeA2AClient(options: MakeA2AClientOptions): ReturnType<typeof tool> {
|
||||
if (options.allowedEndpoints.length === 0) {
|
||||
throw new Error('allowedEndpoints must contain at least one endpoint')
|
||||
}
|
||||
|
||||
const endpointsMap = normalizeEndpoints(options.allowedEndpoints)
|
||||
const endpoints = Object.keys(endpointsMap)
|
||||
|
||||
const description =
|
||||
options.description ?? `${DEFAULT_A2A_CLIENT_DESCRIPTION} Permitted endpoints: ${[...endpoints].sort().join(', ')}.`
|
||||
|
||||
return tool({
|
||||
name: options.name ?? 'a2a_client',
|
||||
description,
|
||||
inputSchema: a2aClientInputSchema,
|
||||
callback: async (input) => {
|
||||
const { operation, endpoint, message } = input
|
||||
|
||||
if (!Object.hasOwn(endpointsMap, endpoint)) {
|
||||
throw new Error(
|
||||
`Endpoint '${endpoint}' is not in the allowed endpoints list. ` +
|
||||
`Permitted endpoints: ${[...endpoints].sort().join(', ')}`
|
||||
)
|
||||
}
|
||||
|
||||
const clientFactory = endpointsMap[endpoint]
|
||||
const agent = new A2AAgent({
|
||||
url: endpoint,
|
||||
...(clientFactory !== undefined ? { clientFactory } : {}),
|
||||
})
|
||||
|
||||
if (operation === 'discover') {
|
||||
return handleDiscover(agent) as unknown as Promise<JSONValue>
|
||||
}
|
||||
|
||||
if (operation === 'send_message') {
|
||||
if (!message) {
|
||||
throw new Error("'message' is required for send_message operation")
|
||||
}
|
||||
return handleSendMessage(agent, message) as unknown as Promise<JSONValue>
|
||||
}
|
||||
|
||||
throw new Error(`Unknown operation: '${String(operation)}'`)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeEndpoints(entries: EndpointEntry[]): Record<string, ClientFactoryType | undefined> {
|
||||
const result: Record<string, ClientFactoryType | undefined> = {}
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === 'string') {
|
||||
result[entry] = undefined
|
||||
} else if (Array.isArray(entry) && entry.length === 2 && typeof entry[0] === 'string') {
|
||||
result[entry[0]] = entry[1]
|
||||
} else {
|
||||
throw new Error(`Each endpoint entry must be a string or [string, ClientFactory] tuple`)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
async function handleDiscover(agent: A2AAgent): Promise<AgentCard> {
|
||||
let agentCard: AgentCard
|
||||
try {
|
||||
agentCard = await agent.getAgentCard()
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to discover agent card at '${agent.id}': ${String(error)}`, { cause: error })
|
||||
}
|
||||
|
||||
return agentCard
|
||||
}
|
||||
|
||||
async function handleSendMessage(agent: A2AAgent, message: string): Promise<{ message: MessageData }> {
|
||||
let taskState: string | undefined
|
||||
let statusText = ''
|
||||
let resultMessage: MessageData
|
||||
|
||||
try {
|
||||
const gen = agent.stream(message)
|
||||
let next = await gen.next()
|
||||
while (!next.done) {
|
||||
const event = next.value instanceof A2AStreamUpdateEvent ? next.value.event : undefined
|
||||
if (event?.kind === 'task' || event?.kind === 'status-update') {
|
||||
taskState = event.status.state
|
||||
statusText = (event.status.message?.parts ?? [])
|
||||
.filter((p): p is TextPart => p.kind === 'text')
|
||||
.map((p) => p.text)
|
||||
.join(' ')
|
||||
}
|
||||
next = await gen.next()
|
||||
}
|
||||
const { role, content } = next.value.lastMessage.toJSON()
|
||||
resultMessage = { role, content }
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to send message to '${agent.id}': ${String(error)}`, { cause: error })
|
||||
}
|
||||
|
||||
if (taskState !== undefined && taskState !== 'completed') {
|
||||
throw new Error(
|
||||
`Remote agent at '${agent.id}' did not complete: task state is '${taskState}'. ${statusText}`.trimEnd()
|
||||
)
|
||||
}
|
||||
|
||||
return { message: resultMessage }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* A2A client tool for communicating with remote A2A-protocol agents.
|
||||
*/
|
||||
|
||||
export { makeA2AClient, DEFAULT_A2A_CLIENT_DESCRIPTION } from './a2a-client.js'
|
||||
export type { MakeA2AClientOptions } from './a2a-client.js'
|
||||
Reference in New Issue
Block a user