diff --git a/docs/mkdocs/en/a2a.md b/docs/mkdocs/en/a2a.md index 5bea91de9..b02db6860 100644 --- a/docs/mkdocs/en/a2a.md +++ b/docs/mkdocs/en/a2a.md @@ -55,20 +55,16 @@ root_agent = LlmAgent( ### 2. Create the A2A Service and Start It -Use `TrpcA2aAgentService` to wrap the Agent as an A2A service, then run it over standard HTTP with the A2A SDK’s `A2AStarletteApplication`: +Use `TrpcA2aAgentService` to wrap the Agent as an A2A service, then assemble a Starlette app with `create_a2a_application` (which wraps the a2a-sdk 1.x route factories): ```python # run_server.py import uvicorn -# HTTP application components from the A2A SDK -from a2a.server.apps import A2AStarletteApplication -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore - -# A2A service wrapper from the SDK +# A2A service wrapper and convenience app assembly from the SDK from trpc_agent_sdk.server.a2a import TrpcA2aAgentService from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig +from trpc_agent_sdk.server.a2a import create_a2a_application HOST = "127.0.0.1" PORT = 18081 @@ -80,10 +76,13 @@ def create_a2a_service() -> TrpcA2aAgentService: # Executor configuration (optional); configure user_id_extractor, event_callback, etc. executor_config = TrpcA2aAgentExecutorConfig() - # Wrap the Agent as an A2A service implementing the A2A SDK AgentExecutor interface + # Wrap the Agent as an A2A service implementing the A2A SDK AgentExecutor interface. + # rpc_url is the public address advertised in the agent card; discovery-based + # clients call this url. a2a_svc = TrpcA2aAgentService( service_name="weather_agent_service", # Service identifier agent=root_agent, # Agent to deploy + rpc_url=f"http://{HOST}:{PORT}", # Public address for the agent card executor_config=executor_config, ) a2a_svc.initialize() # Required: builds Agent Card and completes initialization @@ -93,40 +92,106 @@ def create_a2a_service() -> TrpcA2aAgentService: def serve(): a2a_svc = create_a2a_service() - # DefaultRequestHandler handles A2A protocol requests - request_handler = DefaultRequestHandler( - agent_executor=a2a_svc, # Our A2A service as the executor - task_store=InMemoryTaskStore(), # Task store; replace with a persistent implementation in production - ) - - # Starlette HTTP app: registers Agent Card and A2A protocol endpoints - server = A2AStarletteApplication( - agent_card=a2a_svc.agent_card, # Agent Card is served at /.well-known/agent.json - http_handler=request_handler, - ) + # Assemble a Starlette app with the agent-card and JSON-RPC routes. + app = create_a2a_application(a2a_svc) print(f"Starting A2A server on http://{HOST}:{PORT}") - print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent.json") + print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent-card.json") - uvicorn.run(server.build(), host=HOST, port=PORT) + uvicorn.run(app, host=HOST, port=PORT) if __name__ == "__main__": serve() ``` -After startup, the service publishes the Agent Card at `/.well-known/agent.json`; clients discover and invoke the Agent from that URL. +After startup, the service publishes the Agent Card at `/.well-known/agent-card.json`; clients discover and invoke the Agent from that URL. ### 3. Server Essentials | Topic | Description | |------|------| -| `TrpcA2aAgentService` | Implements the A2A SDK `AgentExecutor` interface and can be passed directly as the executor to `DefaultRequestHandler` | +| `TrpcA2aAgentService` | Implements the A2A SDK `AgentExecutor` interface and can be passed directly as the executor to `create_a2a_application` | +| `rpc_url` | The public address advertised in `supported_interfaces[].url`; set it when the server knows its own address (see [Agent Card URL](#agent-card-url)) | | `agent_card` | Built automatically from the Agent’s name, description, tools, etc.; can also be supplied manually | | `initialize()` | Must be called before use; builds the Agent Card and completes internal setup | +| `create_a2a_application()` | Convenience wrapper that mounts the agent-card and JSON-RPC routes into a Starlette app. Optional: for full control, compose a2a-sdk’s `create_agent_card_routes` / `create_jsonrpc_routes` yourself | +| `enable_v0_3_compat` | `create_a2a_application(..., enable_v0_3_compat=True)` also accepts legacy v0.3 clients on the same endpoint | | `session_service` | Optional; defaults to `InMemorySessionService`; can be replaced with a persistent implementation | | `executor_config` | Optional; configures `user_id_extractor`, `event_callback`, `cancel_wait_timeout`, and related behavior | +#### Agent Card URL + +The server does not know its own public address, so `supported_interfaces[].url` is left empty unless you provide one. The single configuration point is `TrpcA2aAgentService(rpc_url=...)` (or a fully custom `agent_card`): + +```python +# The url is written into the agent card as-is. +svc = TrpcA2aAgentService( + service_name="weather", + agent=root_agent, + rpc_url="https://agent.example.com/a2a", +) +``` + +`create_a2a_application()` derives the JSON-RPC mount path from that url (`https://agent.example.com/a2a` → `/a2a`, a bare origin → `/`), so the advertised path and the mounted path can never diverge. If no url is configured anywhere, the app still starts — direct JSON-RPC callers never read the card — but a warning is logged because discovery-based clients cannot call the agent. + +--- + +## Upgrading from v0.3 + +The SDK moved from the a2a 0.3 protocol to 1.0. Two things matter for application code: **code changes** (below) and **runtime compatibility** (the compat switches). **The card path is unchanged**: both 0.3 and 1.0 publish the Agent Card at `/.well-known/agent-card.json`, so discovery needs no migration. + +### Code migration (0.3 → 1.0) + +a2a-sdk 0.3 → 1.0 was an architectural rewrite; several key call sites in business code must change: + +| 0.3 usage | 1.0 usage | Notes | +|---|---|---| +| `from a2a.server.apps import A2AStarletteApplication` + `server = A2AStarletteApplication(agent_card=..., http_handler=...)` | `from trpc_agent_sdk.server.a2a import create_a2a_application` + `app = create_a2a_application(a2a_svc)` | **`A2AStarletteApplication` was removed in 1.0**; use the SDK convenience layer | +| `DefaultRequestHandler(agent_executor=..., task_store=...)` | Not needed (assembled inside `create_a2a_application`); only for custom handlers: `DefaultRequestHandler(agent_executor=..., task_store=..., agent_card=...)` | **`DefaultRequestHandler` gained a required `agent_card`** | +| `TrpcA2aAgentService(service_name=..., agent=..., executor_config=...)` | add `rpc_url=...` | **`rpc_url` is required**, see below | +| top-level card `url` | `supported_interfaces[].url` | card layout changed; 0.3 clients discover via top-level `url`, 1.0 via `supported_interfaces` | + +> The table above is what **business code** must change. a2a-sdk also removed other low-level APIs (`A2AClient` → `await create_client()`, `ClientFactory` sync → async), but they are hidden inside the SDK, so business code does not need to handle them. Business code usually only needs: add `rpc_url` + switch to `create_a2a_application` on the server, and use `TrpcRemoteA2aAgent` on the client. + +### Server: enable `enable_v0_3_compat` for legacy clients + +A 1.0 server accepts only 1.0 clients by default. If you still have un-upgraded 0.3 clients in production, enable the switch so the server accepts both 1.0 and 0.3 traffic on the **same endpoint**: + +```python +app = create_a2a_application(a2a_svc, enable_v0_3_compat=True) +``` + +The framework automatically appends a `protocol_version="0.3"` interface (reusing the same url) so 0.3 clients can discover and call the agent. Legacy 0.3 clients need **no changes**. + +### Client: `enable_v0_3_compat=True` for old servers + +When the remote may be a pure v0.3 server (its card has no `supportedInterfaces` or empty interface urls, so 1.0 discovery fails with `no compatible transports found`), enable compat mode so the client **negotiates automatically**: it uses the 1.0 wire when it reads a 1.0 interface, and the v0.3 wire for a v0.3 interface: + +```python +remote_agent = TrpcRemoteA2aAgent( + name="weather_agent", + agent_base_url="http://127.0.0.1:18081", + enable_v0_3_compat=True, # compat with old servers: auto-negotiate 1.0/0.3 +) +``` + +### The most important change: configure `rpc_url` + +v0.3 did not require a card url, so old servers worked without one; **a 1.0 Agent Card must carry a reachable `supported_interfaces[].url`**, otherwise discovery-based clients fail with `no compatible transports found`. On upgrade, **make sure** to configure `rpc_url` when constructing `TrpcA2aAgentService` (or provide a custom `agent_card`) — see [Agent Card URL](#agent-card-url) above. + +### Protocol combination matrix + +| Scenario | Server | Client | +|---|---|---| +| **1.0 → 1.0** (recommended) | `create_a2a_application(a2a_svc)` | default | +| **0.3 client → 1.0 server** | `create_a2a_application(a2a_svc, enable_v0_3_compat=True)` | legacy 0.3 client, no changes | +| **1.0 client → 0.3 server** | legacy 0.3 server | `TrpcRemoteA2aAgent(..., enable_v0_3_compat=True)` | + +> **`enable_v0_3_compat=True` auto-adapts**: the client negotiates the protocol from the card — a 1.0 interface uses the 1.0 wire, a v0.3 interface uses the v0.3 wire. When the **card cannot be fetched, has no `supportedInterfaces`, or has empty interface urls** (a pure v0.3 server, whose 0.3 layout leaves the address to the client), it uses the v0.3 wire directly. So one client can call both a 1.0 server and a pure v0.3 server without switching. + +> Runnable example: [examples/a2a](../../../examples/a2a/README.md) — the same example covers all three combinations via the `A2A_V03_COMPAT` environment variable. + --- ## Client Usage @@ -151,7 +216,7 @@ AGENT_BASE_URL = "http://127.0.0.1:18081" async def main(): - # Remote Agent with service URL; discovers Agent Card from /.well-known/agent.json + # Remote Agent with service URL; discovers Agent Card from /.well-known/agent-card.json remote_agent = TrpcRemoteA2aAgent( name="weather_agent", agent_base_url=AGENT_BASE_URL, @@ -243,7 +308,7 @@ The server can read this metadata in the `user_id_extractor` callback (see the c | Topic | Description | |------|------| | `TrpcRemoteA2aAgent` | Extends `BaseAgent`; use with `Runner` like a local Agent | -| `agent_base_url` | HTTP base URL of the remote A2A service; client discovers the Agent Card from `/.well-known/agent.json` | +| `agent_base_url` | HTTP base URL of the remote A2A service; client discovers the Agent Card from `/.well-known/agent-card.json` | | `initialize()` | Async initialization: Agent Card discovery and client construction | | `agent_card` / `a2a_client` | Optional; pass an existing AgentCard or A2AClient to skip auto-discovery | | `RunConfig` | Business parameters (e.g. `user_id`) via `metadata`; server reads them in callbacks | @@ -486,10 +551,10 @@ def custom_event_callback(event: Event, context: RequestContext) -> Event | None ┌─────────────────▼──────────────────────────────┐ │ Server │ │ ┌──────────────────────────────────────────┐ │ -│ │ A2AStarletteApplication (a2a-sdk) │ │ -│ │ └─ DefaultRequestHandler │ │ -│ │ └─ TrpcA2aAgentService │ │ -│ │ └─ LlmAgent (your Agent) │ │ +│ │ create_a2a_application (trpc-agent) │ │ +│ │ └─ DefaultRequestHandler │ │ +│ │ └─ TrpcA2aAgentService │ │ +│ │ └─ LlmAgent (your Agent)│ │ │ └──────────────────────────────────────────┘ │ └────────────────────────────────────────────────┘ ``` diff --git a/docs/mkdocs/en/cancel.md b/docs/mkdocs/en/cancel.md index b988e75b0..51e1e62ae 100644 --- a/docs/mkdocs/en/cancel.md +++ b/docs/mkdocs/en/cancel.md @@ -514,10 +514,7 @@ run_server.py: import uvicorn from dotenv import load_dotenv -from a2a.server.apps import A2AStarletteApplication -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore - +from trpc_agent_sdk.server.a2a import create_a2a_application from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig from trpc_agent_sdk.server.a2a import TrpcA2aAgentService @@ -542,6 +539,7 @@ def create_a2a_service() -> TrpcA2aAgentService: a2a_svc = TrpcA2aAgentService( service_name="weather_agent_cancel_service", agent=root_agent, + rpc_url=f"http://{HOST}:{PORT}", # Public address advertised in the agent card executor_config=executor_config, ) a2a_svc.initialize() @@ -553,18 +551,10 @@ def serve(): """Start the A2A service""" a2a_svc = create_a2a_service() - # Assemble the service using a2a-sdk standard components - request_handler = DefaultRequestHandler( - agent_executor=a2a_svc, - task_store=InMemoryTaskStore(), - ) - - server = A2AStarletteApplication( - agent_card=a2a_svc.agent_card, - http_handler=request_handler, - ) + # Assemble the Starlette app (agent-card + JSON-RPC routes) + app = create_a2a_application(a2a_svc) - uvicorn.run(server.build(), host=HOST, port=PORT) + uvicorn.run(app, host=HOST, port=PORT) if __name__ == "__main__": diff --git a/docs/mkdocs/zh/a2a.md b/docs/mkdocs/zh/a2a.md index 18e1286dd..9c6ebd90a 100644 --- a/docs/mkdocs/zh/a2a.md +++ b/docs/mkdocs/zh/a2a.md @@ -55,20 +55,16 @@ root_agent = LlmAgent( ### 2. 创建 A2A 服务并启动 -使用 `TrpcA2aAgentService` 将 Agent 包装为 A2A 服务,然后通过 A2A SDK 的 `A2AStarletteApplication` 以标准 HTTP 方式运行: +使用 `TrpcA2aAgentService` 将 Agent 包装为 A2A 服务,再通过 `create_a2a_application`(封装了 a2a-sdk 1.x 路由工厂)组装 Starlette 应用: ```python # run_server.py import uvicorn -# A2A SDK 提供的 HTTP 服务框架组件 -from a2a.server.apps import A2AStarletteApplication -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore - -# SDK 提供的 A2A 服务封装 +# SDK 提供的 A2A 服务封装与便利层应用组装 from trpc_agent_sdk.server.a2a import TrpcA2aAgentService from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig +from trpc_agent_sdk.server.a2a import create_a2a_application HOST = "127.0.0.1" PORT = 18081 @@ -80,10 +76,12 @@ def create_a2a_service() -> TrpcA2aAgentService: # 执行器配置(可选),可在此配置 user_id_extractor、event_callback 等 executor_config = TrpcA2aAgentExecutorConfig() - # 将 Agent 包装为 A2A 服务,实现了 A2A SDK 的 AgentExecutor 接口 + # 将 Agent 包装为 A2A 服务,实现了 A2A SDK 的 AgentExecutor 接口。 + # rpc_url 是写入 Agent Card 的对外地址,依赖卡片发现的客户端会调用它。 a2a_svc = TrpcA2aAgentService( service_name="weather_agent_service", # 服务名称,用于标识服务 agent=root_agent, # 要部署的 Agent + rpc_url=f"http://{HOST}:{PORT}", # Agent Card 中声明的对外地址 executor_config=executor_config, ) a2a_svc.initialize() # 必须调用,完成 Agent Card 构建等初始化 @@ -93,40 +91,106 @@ def create_a2a_service() -> TrpcA2aAgentService: def serve(): a2a_svc = create_a2a_service() - # 使用 A2A SDK 的 DefaultRequestHandler 处理 A2A 协议请求 - request_handler = DefaultRequestHandler( - agent_executor=a2a_svc, # 传入我们的 A2A 服务作为执行器 - task_store=InMemoryTaskStore(), # 任务存储,生产环境可替换为持久化实现 - ) - - # 构建 Starlette HTTP 应用,自动注册 Agent Card 和 A2A 协议端点 - server = A2AStarletteApplication( - agent_card=a2a_svc.agent_card, # Agent Card 会发布到 /.well-known/agent.json - http_handler=request_handler, - ) + # 组装 Starlette 应用,自动注册 Agent Card 和 JSON-RPC 端点 + app = create_a2a_application(a2a_svc) print(f"Starting A2A server on http://{HOST}:{PORT}") - print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent.json") + print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent-card.json") - uvicorn.run(server.build(), host=HOST, port=PORT) + uvicorn.run(app, host=HOST, port=PORT) if __name__ == "__main__": serve() ``` -启动后,服务会自动发布 Agent Card 到 `/.well-known/agent.json`,客户端可通过该地址发现并调用 Agent。 +启动后,服务会自动发布 Agent Card 到 `/.well-known/agent-card.json`,客户端可通过该地址发现并调用 Agent。 ### 3. 服务端关键要点 | 要点 | 说明 | |------|------| -| `TrpcA2aAgentService` | 实现了 A2A SDK 的 `AgentExecutor` 接口,可直接作为 `DefaultRequestHandler` 的执行器 | +| `TrpcA2aAgentService` | 实现了 A2A SDK 的 `AgentExecutor` 接口,可直接作为 `create_a2a_application` 的执行器 | +| `rpc_url` | 写入 `supported_interfaces[].url` 的对外地址;服务端知道自己地址时配置(见 [Agent Card URL](#agent-card-url)) | | `agent_card` | 自动根据 Agent 的 name、description、tools 等信息构建,也可手动传入 | | `initialize()` | 必须在使用前调用,完成 Agent Card 构建和内部初始化 | +| `create_a2a_application()` | 便利层,把 Agent Card 与 JSON-RPC 路由挂载成 Starlette 应用。可选:需要完全控制时可直接用 a2a-sdk 的 `create_agent_card_routes` / `create_jsonrpc_routes` 自己拼 | +| `enable_v0_3_compat` | `create_a2a_application(..., enable_v0_3_compat=True)` 在同一端点同时接受旧版 0.3 客户端 | | `session_service` | 可选,默认使用 `InMemorySessionService`;可替换为持久化实现 | | `executor_config` | 可选,用于配置 `user_id_extractor`、`event_callback`、`cancel_wait_timeout` 等行为 | +#### Agent Card URL + +服务端**不知道自己的对外地址**,因此 `supported_interfaces[].url` 默认留空,需要你提供一个。url 只有**一个配置入口**:`TrpcA2aAgentService(rpc_url=...)`(或完全自定义的 `agent_card`): + +```python +# 该 url 会原样写入 Agent Card +svc = TrpcA2aAgentService( + service_name="weather", + agent=root_agent, + rpc_url="https://agent.example.com/a2a", +) +``` + +`create_a2a_application()` 会从这个 url 推导 JSON-RPC 的挂载路径(`https://agent.example.com/a2a` → `/a2a`,纯域名则 `/`),保证"卡片声明的路径"与"实际挂载路径"永不不一致。若任何地方都没配置 url,服务仍能启动——JSON-RPC 直连的客户端不读卡片——但会打出一条 warning,因为依赖卡片发现的客户端无法调用该 Agent。 + +--- + +## 从 v0.3 升级 + +SDK 底层协议从 a2a 0.3 升级到 1.0,对应用层主要有两方面:**代码写法要迁移**(下节),**运行时可平滑兼容**(兼容开关)。**卡片路径不变**:0.3 和 1.0 的 Agent Card 都发布在 `/.well-known/agent-card.json`,发现机制无需迁移。 + +### 代码写法迁移(0.3 → 1.0) + +a2a-sdk 从 0.3 到 1.0 是一次架构重写,业务代码里几处关键写法要改: + +| 0.3 写法 | 1.0 写法 | 说明 | +|---|---|---| +| `from a2a.server.apps import A2AStarletteApplication` + `server = A2AStarletteApplication(agent_card=..., http_handler=...)` | `from trpc_agent_sdk.server.a2a import create_a2a_application` + `app = create_a2a_application(a2a_svc)` | **`A2AStarletteApplication` 在 1.0 已删除**,改用 SDK 便利层装配 | +| `DefaultRequestHandler(agent_executor=..., task_store=...)` | 无需手拼(`create_a2a_application` 内部构造);需自定义时才 `DefaultRequestHandler(agent_executor=..., task_store=..., agent_card=...)` | **`DefaultRequestHandler` 新增必填 `agent_card`** | +| `TrpcA2aAgentService(service_name=..., agent=..., executor_config=...)` | 增加 `rpc_url=...` | **必须配 `rpc_url`**,见下文 | +| 卡片顶层 `url` | `supported_interfaces[].url` | 卡片布局变了;0.3 客户端发现用顶层 `url`,1.0 用 `supported_interfaces` | + +> 上表是**业务代码**要改的。此外 a2a-sdk 底层还有 `A2AClient`(已删除 → `await create_client()`)等 API 变化,但都被封装在 SDK 内部,业务代码无需处理。业务代码通常只需:服务端加 `rpc_url` + 改用 `create_a2a_application`;客户端用 `TrpcRemoteA2aAgent`。 + +### 服务端:开启 `enable_v0_3_compat` 兼容旧客户端 + +1.0 服务端默认只接受 1.0 客户端。如果线上仍有旧版 0.3 客户端(尚未升级),服务端在**同一端点**同时接受 1.0 和 0.3 报文: + +```python +app = create_a2a_application(a2a_svc, enable_v0_3_compat=True) +``` + +框架会自动往 Agent Card 追加一个 `protocol_version="0.3"` 的接口(复用同一个 url),使 0.3 客户端能正确发现并调用。旧 0.3 客户端**无需改动**。 + +### 客户端:`enable_v0_3_compat=True` 兼容旧服务端 + +当远端可能是纯 0.3 老服务端时(老卡片没有 `supportedInterfaces` 或接口 url 为空,1.0 默认发现会报 `no compatible transports found`),开启兼容模式让客户端**自动协商**:能读到 1.0 接口就走 1.0,读到 0.3 接口就走 0.3 报文: + +```python +remote_agent = TrpcRemoteA2aAgent( + name="weather_agent", + agent_base_url="http://127.0.0.1:18081", + enable_v0_3_compat=True, # 兼容旧服务端:按卡片自动协商 1.0/0.3 +) +``` + +### 最重要的变化:必须配置 `rpc_url` + +0.3 对卡片 url 不做强制要求,旧服务端不填 url 仍能工作;**1.0 的 Agent Card 必须携带可达的 `supported_interfaces[].url`**,否则发现型客户端会报 `no compatible transports found`。升级时**务必**在 `TrpcA2aAgentService` 构造时配置 `rpc_url`(或提供自定义 `agent_card`),详见上文 [Agent Card URL](#agent-card-url)。 + +### 三种协议组合对照 + +| 场景 | 服务端 | 客户端 | +|---|---|---| +| **1.0 → 1.0**(推荐) | `create_a2a_application(a2a_svc)` | 默认 | +| **0.3 客户端 → 1.0 服务端** | `create_a2a_application(a2a_svc, enable_v0_3_compat=True)` | 旧 0.3 客户端,无需改动 | +| **1.0 客户端 → 0.3 服务端** | 旧 0.3 服务端 | `TrpcRemoteA2aAgent(..., enable_v0_3_compat=True)` | + +> **`enable_v0_3_compat=True` 自动适应**:客户端优先按卡片自动协商协议——读到 1.0 接口走 1.0 报文,读到 0.3 接口走 0.3 报文。当**卡片拉不到、没有 `supportedInterfaces`、或接口 url 为空**(纯 0.3 老服务端,0.3 布局把地址留给客户端)时,直接走 0.3 报文。所以它能同时调 1.0 服务端和纯 0.3 老服务端,无需手动切换。 + +> 完整可运行示例见 [examples/a2a](../../../examples/a2a/README.md)(同一个 example 通过 `A2A_V03_COMPAT` 环境变量覆盖三种组合)。 + --- ## 客户端调用 @@ -151,7 +215,7 @@ AGENT_BASE_URL = "http://127.0.0.1:18081" async def main(): - # 创建远程 Agent,指定服务 URL;客户端会自动从 /.well-known/agent.json 发现 Agent Card + # 创建远程 Agent,指定服务 URL;客户端会自动从 /.well-known/agent-card.json 发现 Agent Card remote_agent = TrpcRemoteA2aAgent( name="weather_agent", agent_base_url=AGENT_BASE_URL, @@ -243,7 +307,7 @@ run_config = RunConfig( | 要点 | 说明 | |------|------| | `TrpcRemoteA2aAgent` | 继承 `BaseAgent`,可像本地 Agent 一样通过 `Runner` 使用 | -| `agent_base_url` | 远程 A2A 服务的 HTTP 地址,客户端会自动从 `/.well-known/agent.json` 发现 Agent Card | +| `agent_base_url` | 远程 A2A 服务的 HTTP 地址,客户端会自动从 `/.well-known/agent-card.json` 发现 Agent Card | | `initialize()` | 异步初始化,完成 Agent Card 发现和客户端创建 | | `agent_card` / `a2a_client` | 可选参数,如果已有 AgentCard 或 A2AClient 实例可直接传入,跳过自动发现 | | `RunConfig` | 通过 `metadata` 字段传递业务参数(如 `user_id`),服务端可通过回调读取 | @@ -486,10 +550,10 @@ def custom_event_callback(event: Event, context: RequestContext) -> Event | None ┌─────────────────▼──────────────────────────────┐ │ 服务端 │ │ ┌──────────────────────────────────────────┐ │ -│ │ A2AStarletteApplication (a2a-sdk) │ │ -│ │ └─ DefaultRequestHandler │ │ -│ │ └─ TrpcA2aAgentService │ │ -│ │ └─ LlmAgent (你的 Agent) │ │ +│ │ create_a2a_application (trpc-agent) │ │ +│ │ └─ DefaultRequestHandler │ │ +│ │ └─ TrpcA2aAgentService │ │ +│ │ └─ LlmAgent (你的 Agent)│ │ │ └──────────────────────────────────────────┘ │ └────────────────────────────────────────────────┘ ``` diff --git a/docs/mkdocs/zh/cancel.md b/docs/mkdocs/zh/cancel.md index 222f49d99..2c15bc314 100644 --- a/docs/mkdocs/zh/cancel.md +++ b/docs/mkdocs/zh/cancel.md @@ -514,10 +514,7 @@ run_server.py: import uvicorn from dotenv import load_dotenv -from a2a.server.apps import A2AStarletteApplication -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore - +from trpc_agent_sdk.server.a2a import create_a2a_application from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig from trpc_agent_sdk.server.a2a import TrpcA2aAgentService @@ -542,6 +539,7 @@ def create_a2a_service() -> TrpcA2aAgentService: a2a_svc = TrpcA2aAgentService( service_name="weather_agent_cancel_service", agent=root_agent, + rpc_url=f"http://{HOST}:{PORT}", # 写入 Agent Card 的对外地址 executor_config=executor_config, ) a2a_svc.initialize() @@ -553,18 +551,10 @@ def serve(): """启动 A2A 服务""" a2a_svc = create_a2a_service() - # 使用 a2a-sdk 标准组件组装服务 - request_handler = DefaultRequestHandler( - agent_executor=a2a_svc, - task_store=InMemoryTaskStore(), - ) - - server = A2AStarletteApplication( - agent_card=a2a_svc.agent_card, - http_handler=request_handler, - ) + # 组装 Starlette 应用(Agent Card + JSON-RPC 路由) + app = create_a2a_application(a2a_svc) - uvicorn.run(server.build(), host=HOST, port=PORT) + uvicorn.run(app, host=HOST, port=PORT) if __name__ == "__main__": diff --git a/examples/a2a/README.md b/examples/a2a/README.md index 9c45540c0..e6a55ade1 100644 --- a/examples/a2a/README.md +++ b/examples/a2a/README.md @@ -4,7 +4,7 @@ ## 功能说明 -- 使用 `A2AStarletteApplication` 提供 A2A HTTP 服务 +- 使用 SDK 内置的 `create_a2a_application()` 提供 A2A HTTP 服务(1.x 路由装配封装) - 使用 `TrpcRemoteA2aAgent` 作为远程客户端 - 演示三轮会话上下文保持 - 演示工具调用(`get_weather_report`) @@ -44,10 +44,13 @@ cd examples/a2a python3 run_server.py ``` +- 默认:纯 1.0 服务端 +- `A2A_V03_COMPAT=1 python3 run_server.py`:1.0 服务端**同时接受 0.3 客户端**(开启 v0.3 compat) + 服务地址: - API:`http://127.0.0.1:18081` -- Agent Card:`http://127.0.0.1:18081/.well-known/agent.json` +- Agent Card:`http://127.0.0.1:18081/.well-known/agent-card.json`(1.x 路径) ### 4. 启动客户端 @@ -58,6 +61,66 @@ cd examples/a2a python3 test_a2a.py ``` +## 三种调用链路 + +同一个 example(`run_server.py` + `test_a2a.py`)通过 `A2A_V03_COMPAT` 环境变量覆盖三种协议组合: + +| 场景 | 服务端命令 | 客户端命令 | +|---|---|---| +| **1.0 → 1.0**(默认)| `python3 run_server.py` | `python3 test_a2a.py` | +| **0.3 客户端 → 1.0** | `A2A_V03_COMPAT=1 python3 run_server.py` | 旧版 0.3 客户端 | +| **1.0 → 0.3 服务端** | 旧版 0.3 服务端 | `A2A_V03_COMPAT=1 python3 test_a2a.py` | + +### 场景 1:1.0 客户端 → 1.0 服务端(默认) + +```bash +# 终端 A:1.0 服务端 +python3 run_server.py +# 终端 B:1.0 客户端 +python3 test_a2a.py +``` + +### 场景 2:0.3 客户端 → 1.0 服务端 + +服务端开 compat(同时接受 1.0 和 0.3 客户端),0.3 客户端无需改动: + +```bash +# 终端 A:1.0 服务端 + v0.3 compat +A2A_V03_COMPAT=1 python3 run_server.py +# 终端 B:旧版(v0.3)客户端,例如旧版 trpc-agent-python 的 test_a2a.py +cd old_version/trpc-agent-python/examples/a2a +python3 test_a2a.py +``` + +服务端卡片会同时声明 `1.0` 和 `0.3` 接口,0.3 客户端能正确发现并调用。 + +### 场景 3:1.0 客户端 → 0.3 服务端 + +客户端开 `A2A_V03_COMPAT=1` 兼容模式,按卡片自动协商协议(读到 0.3 接口走 0.3 报文;卡片没有可用的接口 url 时也走 0.3): + +```bash +# 终端 A:旧版(v0.3)服务端 +cd old_version/trpc-agent-python/examples/a2a +python3 run_server.py +# 终端 B:1.0 客户端兼容模式 +cd examples/a2a +A2A_V03_COMPAT=1 python3 test_a2a.py +``` + +等价于在代码里: + +```python +remote_agent = TrpcRemoteA2aAgent( + name="weather_agent", + agent_base_url="http://127.0.0.1:18081", + enable_v0_3_compat=True, # 兼容旧服务端:按卡片自动协商 1.0/0.3 +) +``` + +> 为什么场景 3 需要开启:`create_client()` 只有从对方卡片读到 `supportedInterfaces[].protocol_version=0.3` 才会自动降级;纯 v0.3 老服务端的卡片没有 `supportedInterfaces`(老布局:顶层 `url`/`preferredTransport`),会报 `no compatible transports found`。`enable_v0_3_compat=True` 让客户端在**卡片没有可用的接口 url 时直接走 0.3 wire**(用 `agent_base_url`),从而能调纯 0.3 老服务端。 + +> **`enable_v0_3_compat=True` 自动适应**:客户端优先按卡片自动协商协议——读到 1.0 接口走 1.0 报文,读到 0.3 接口走 0.3 报文。当**卡片拉不到、没有 `supportedInterfaces`、或接口 url 为空**(纯 0.3 老服务端,0.3 布局把地址留给客户端)时,直接走 0.3 报文。所以它能同时调 1.0 服务端和纯 0.3 老服务端,无需手动切换。 + ## 运行结果(实测) ### 服务端输出 @@ -66,7 +129,7 @@ python3 test_a2a.py [2026-04-01 16:23:05][INFO][trpc_agent_sdk][trpc_agent_sdk/server/a2a/_agent_service.py:108][1706047] Initialized A2A Agent Service weather_agent_standard_service for weather_agent Starting A2A server (standard protocol over HTTP)... Listening on: http://127.0.0.1:18081 -Agent card: http://127.0.0.1:18081/.well-known/agent.json +Agent card: http://127.0.0.1:18081/.well-known/agent-card.json INFO: Started server process [1706047] INFO: Waiting for application startup. INFO: Application startup complete. @@ -124,8 +187,8 @@ Demo completed! | 文件 | 说明 | |---|---| -| `run_server.py` | A2A 服务端入口(Starlette + Uvicorn) | -| `test_a2a.py` | A2A 客户端示例(3 轮对话) | +| `run_server.py` | A2A 服务端入口(Starlette + Uvicorn;`A2A_V03_COMPAT=1` 开 v0.3 compat) | +| `test_a2a.py` | A2A 客户端示例(3 轮对话;`A2A_V03_COMPAT=1` 开启兼容模式自动协商) | | `agent/agent.py` | Agent 定义(LlmAgent + 天气工具) | | `agent/config.py` | 模型配置(从环境变量读取) | | `agent/prompts.py` | Agent 提示词 | diff --git a/examples/a2a/run_server.py b/examples/a2a/run_server.py index 2a966e423..0d2d25d13 100644 --- a/examples/a2a/run_server.py +++ b/examples/a2a/run_server.py @@ -5,18 +5,21 @@ # tRPC-Agent-Python is licensed under Apache-2.0. """A2A Server Example -This example uses the standard A2A SDK server (A2AStarletteApplication) to serve -a trpc-agent as an A2A service over plain HTTP, with the standard protocol -(artifact-first streaming and unprefixed metadata keys). +This example uses the SDK's ``create_a2a_application`` (which wraps the a2a-sdk +1.x route factories) to serve a trpc-agent as an A2A service over plain HTTP, +with the standard protocol (artifact-first streaming and unprefixed metadata keys). + +Set ``A2A_V03_COMPAT=1`` to also accept legacy v0.3 clients on the same endpoint: + + A2A_V03_COMPAT=1 python3 run_server.py """ +import os + import uvicorn from dotenv import load_dotenv -from a2a.server.apps import A2AStarletteApplication -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore - +from trpc_agent_sdk.server.a2a import create_a2a_application from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig from trpc_agent_sdk.server.a2a import TrpcA2aAgentService @@ -39,6 +42,8 @@ def create_a2a_service() -> TrpcA2aAgentService: a2a_svc = TrpcA2aAgentService( service_name="weather_agent_standard_service", agent=root_agent, + # Public address advertised in the agent card; clients call this url. + rpc_url=f"http://{HOST}:{PORT}", executor_config=executor_config, ) a2a_svc.initialize() @@ -50,21 +55,19 @@ def serve(): """Start the A2A server using standard HTTP (uvicorn + Starlette).""" a2a_svc = create_a2a_service() - request_handler = DefaultRequestHandler( - agent_executor=a2a_svc, - task_store=InMemoryTaskStore(), - ) - - server = A2AStarletteApplication( - agent_card=a2a_svc.agent_card, - http_handler=request_handler, + # A2A_V03_COMPAT=1 also accepts legacy v0.3 clients on the same endpoint. + enable_v0_3_compat = os.getenv("A2A_V03_COMPAT", "").strip().lower() in ("1", "true", "yes") + app = create_a2a_application( + a2a_svc, + enable_v0_3_compat=enable_v0_3_compat, ) print("Starting A2A server (standard protocol over HTTP)...") print(f"Listening on: http://{HOST}:{PORT}") - print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent.json") + print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent-card.json") + print(f"v0.3 compatibility: {'ENABLED' if enable_v0_3_compat else 'disabled'}") - uvicorn.run(server.build(), host=HOST, port=PORT) + uvicorn.run(app, host=HOST, port=PORT) if __name__ == "__main__": diff --git a/examples/a2a/test_a2a.py b/examples/a2a/test_a2a.py index 9044f0e6f..331b2ffb0 100644 --- a/examples/a2a/test_a2a.py +++ b/examples/a2a/test_a2a.py @@ -11,9 +11,16 @@ remote A2A service (standard protocol) over standard HTTP and interact with it using the Runner interface. The standard protocol uses artifact-first streaming and unprefixed metadata keys. + +Protocol combinations (paired with ``run_server.py``): + +- 1.0 client -> 1.0 server (default): ``python3 test_a2a.py`` +- 1.0 client -> v0.3 server: ``A2A_V03_COMPAT=1 python3 test_a2a.py`` + (compat mode auto-negotiates the v0.3 wire for a not-yet-upgraded server) """ import asyncio +import os import uuid from dotenv import load_dotenv @@ -37,6 +44,9 @@ async def run_remote_agent( ) -> None: """Run remote agent with a single query and handle events. + Both the 1.0 and v0.3 wires deliver the assistant text as streaming + ``partial=True`` artifact chunks, so the printing logic is protocol-agnostic. + Args: runner: The runner instance user_id: User identifier @@ -64,6 +74,8 @@ async def run_remote_agent( if event.partial: for part in event.content.parts: + if part.thought: + continue if part.text: print(part.text, end="", flush=True) continue @@ -142,10 +154,15 @@ async def main(): print("Note: Ensure the A2A server is running (python run_server.py)") print() + # Default to the 1.0 wire (auto negotiation). Set A2A_V03_COMPAT=1 to + # enable compat mode, which auto-negotiates 1.0/0.3 and falls back to the + # v0.3 wire for a not-yet-upgraded v0.3 server. + enable_v0_3_compat = os.getenv("A2A_V03_COMPAT", "").strip().lower() in ("1", "true", "yes") remote_agent = TrpcRemoteA2aAgent( name="weather_agent", agent_base_url=AGENT_BASE_URL, description="Professional weather query assistant", + enable_v0_3_compat=enable_v0_3_compat, ) await remote_agent.initialize() diff --git a/examples/a2a_with_cancel/README.md b/examples/a2a_with_cancel/README.md index 6e4ab3e7d..8587a4856 100644 --- a/examples/a2a_with_cancel/README.md +++ b/examples/a2a_with_cancel/README.md @@ -70,6 +70,7 @@ executor_config = TrpcA2aAgentExecutorConfig( a2a_svc = TrpcA2aAgentService( service_name="weather_agent_cancel_service", agent=root_agent, + rpc_url="http://127.0.0.1:18082", # 写入 Agent Card 的对外地址 executor_config=executor_config, ) ``` @@ -134,7 +135,7 @@ python3 run_server.py 服务地址: - API:`http://127.0.0.1:18082` -- Agent Card:`http://127.0.0.1:18082/.well-known/agent.json` +- Agent Card:`http://127.0.0.1:18082/.well-known/agent-card.json` #### 2. 启动客户端(新开终端) @@ -151,7 +152,7 @@ python3 test_a2a_cancel.py [2026-04-02 15:19:26][INFO][trpc_agent_sdk][trpc_agent_sdk/server/a2a/_agent_service.py:108][66551] Initialized A2A Agent Service weather_agent_cancel_service for weather_agent Starting A2A server with cancel support... Listening on: http://127.0.0.1:18082 -Agent card: http://127.0.0.1:18082/.well-known/agent.json +Agent card: http://127.0.0.1:18082/.well-known/agent-card.json Cancel wait timeout: 3.0s INFO: Started server process [66551] INFO: Waiting for application startup. diff --git a/examples/a2a_with_cancel/run_server.py b/examples/a2a_with_cancel/run_server.py index 1a205685b..3c6bdd122 100644 --- a/examples/a2a_with_cancel/run_server.py +++ b/examples/a2a_with_cancel/run_server.py @@ -14,10 +14,7 @@ import uvicorn from dotenv import load_dotenv -from a2a.server.apps import A2AStarletteApplication -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore - +from trpc_agent_sdk.server.a2a import create_a2a_application from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig from trpc_agent_sdk.server.a2a import TrpcA2aAgentService @@ -44,6 +41,8 @@ def create_a2a_service() -> TrpcA2aAgentService: a2a_svc = TrpcA2aAgentService( service_name="weather_agent_cancel_service", agent=root_agent, + # Public address advertised in the agent card; clients call this url. + rpc_url=f"http://{HOST}:{PORT}", executor_config=executor_config, ) a2a_svc.initialize() @@ -55,22 +54,14 @@ def serve(): """Start the A2A server with cancel support.""" a2a_svc = create_a2a_service() - request_handler = DefaultRequestHandler( - agent_executor=a2a_svc, - task_store=InMemoryTaskStore(), - ) - - server = A2AStarletteApplication( - agent_card=a2a_svc.agent_card, - http_handler=request_handler, - ) + app = create_a2a_application(a2a_svc) print("Starting A2A server with cancel support...") print(f"Listening on: http://{HOST}:{PORT}") - print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent.json") + print(f"Agent card: http://{HOST}:{PORT}/.well-known/agent-card.json") print(f"Cancel wait timeout: {CANCEL_WAIT_TIMEOUT}s") - uvicorn.run(server.build(), host=HOST, port=PORT) + uvicorn.run(app, host=HOST, port=PORT) if __name__ == "__main__": diff --git a/examples/agui/run_server.py b/examples/agui/run_server.py index 1e0fd6dea..dd20dbf9a 100644 --- a/examples/agui/run_server.py +++ b/examples/agui/run_server.py @@ -3,11 +3,10 @@ # Copyright (C) 2026 Tencent. All rights reserved. # # tRPC-Agent-Python is licensed under Apache-2.0. -"""A2A Server Example +"""AG-UI Server Example -This example uses the standard A2A SDK server (A2AStarletteApplication) to serve -a trpc-agent as an A2A service over plain HTTP, with the standard protocol -(artifact-first streaming and unprefixed metadata keys). +This example serves a trpc-agent as an AG-UI service over plain HTTP (SSE), +using the SDK's ``create_agui_runner`` helper. """ from dotenv import load_dotenv diff --git a/examples/transfer_agent/README.md b/examples/transfer_agent/README.md index ec63d4d84..3c69fde54 100644 --- a/examples/transfer_agent/README.md +++ b/examples/transfer_agent/README.md @@ -67,7 +67,7 @@ pip3 install -e . - `TRPC_AGENT_API_KEY` - `TRPC_AGENT_BASE_URL` - `TRPC_AGENT_MODEL_NAME` -- `REMOTE_A2A_BASE_URL`(可选,默认 `http://127.0.0.1:18081`) +- `REMOTE_A2A_BASE_URL`(可选,默认 `http://127.0.0.1:18081`):远程 A2A 服务地址。当它指向本地端口时,`run_agent.py` 会在同一端口自动拉起内嵌 A2A 服务(内嵌服务的 `rpc_url` 即此地址),保证"客户端 `agent_base_url`"与"内嵌服务卡片地址"始终一致 - `TRPC_TRANSFER_AUTO_START_REMOTE_A2A`(可选,默认 `1`,当目标地址是本地且端口未占用时自动拉起内嵌 A2A 服务) ### 启动顺序(自动/手动两种方式) diff --git a/examples/transfer_agent/agent/agent.py b/examples/transfer_agent/agent/agent.py index 38fbb07ef..faf6db382 100644 --- a/examples/transfer_agent/agent/agent.py +++ b/examples/transfer_agent/agent/agent.py @@ -30,6 +30,9 @@ def create_agent() -> TransferAgent: model = _create_model() + # The remote A2A service address. When it points at a local port, run_agent.py + # auto-starts an embedded server on that same port (its rpc_url), so the card + # advertised by the embedded server and this client's agent_base_url always agree. remote_a2a_base_url = os.getenv("REMOTE_A2A_BASE_URL", "http://127.0.0.1:18081") remote_agent = TrpcRemoteA2aAgent( name="remote-weather-assistant", diff --git a/examples/transfer_agent/run_agent.py b/examples/transfer_agent/run_agent.py index b10e62d14..fedf7b3a9 100644 --- a/examples/transfer_agent/run_agent.py +++ b/examples/transfer_agent/run_agent.py @@ -20,11 +20,8 @@ from dotenv import load_dotenv import uvicorn -from a2a.server.apps import A2AStarletteApplication -from a2a.server.request_handlers import DefaultRequestHandler -from a2a.server.tasks import InMemoryTaskStore - from trpc_agent_sdk.runners import Runner +from trpc_agent_sdk.server.a2a import create_a2a_application from trpc_agent_sdk.server.a2a import TrpcA2aAgentExecutorConfig from trpc_agent_sdk.server.a2a import TrpcA2aAgentService from trpc_agent_sdk.sessions import InMemorySessionService @@ -65,18 +62,13 @@ def _build_uvicorn_server(self) -> uvicorn.Server: a2a_svc = TrpcA2aAgentService( service_name="embedded_weather_agent_service", agent=a2a_root_agent, + # Public address advertised in the agent card; clients call this url. + rpc_url=f"http://{self.host}:{self.port}", executor_config=TrpcA2aAgentExecutorConfig(), ) a2a_svc.initialize() - request_handler = DefaultRequestHandler( - agent_executor=a2a_svc, - task_store=InMemoryTaskStore(), - ) - app = A2AStarletteApplication( - agent_card=a2a_svc.agent_card, - http_handler=request_handler, - ).build() + app = create_a2a_application(a2a_svc) config = uvicorn.Config(app=app, host=self.host, port=self.port, log_level="warning") return uvicorn.Server(config) diff --git a/pyproject.toml b/pyproject.toml index 33c466a5e..893ae92d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,9 @@ knowledge = [ ] a2a = [ - "a2a-sdk<1.0.0,>=0.3.22", + "a2a-sdk>=1.0.0", + "google-api-core", + "googleapis-common-protos", "protobuf>=5.29.5", ] @@ -154,7 +156,9 @@ all = [ "nanobot-ai>=0.1.4.post6; python_full_version >= '3.11'", "aiofiles", "wecom-aibot-sdk-python>=0.1.5", - "a2a-sdk<1.0.0,>=0.3.22", + "a2a-sdk>=1.0.0", + "google-api-core", + "googleapis-common-protos", "e2b-code-interpreter>=2.0.0", "gepa>=0.0.7", "rich>=13.0.0", diff --git a/requirements-test.txt b/requirements-test.txt index c54dad067..35d610345 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -45,7 +45,9 @@ nanobot-ai>=0.1.4.post5 wecom-aibot-sdk-python>=0.1.5 # Test Core Dependencies -a2a-sdk<1.0.0,>=0.3.22 +a2a-sdk>=1.0.0 +google-api-core +googleapis-common-protos protobuf>=5.29.5 claude-agent-sdk>=0.1.3,<0.1.64 cloudpickle>=2.0.0 diff --git a/tests/server/a2a/converters/test_event_converter.py b/tests/server/a2a/converters/test_event_converter.py index 3ce2024a7..7a61f730f 100644 --- a/tests/server/a2a/converters/test_event_converter.py +++ b/tests/server/a2a/converters/test_event_converter.py @@ -11,26 +11,20 @@ from unittest.mock import MagicMock, patch import pytest -try: - from a2a.types import ( - Artifact, - DataPart, - Message, - Part as A2APart, - Role, - Task, - TaskArtifactUpdateEvent, - TaskState, - TaskStatus, - TaskStatusUpdateEvent, - TextPart, - ) -except ImportError: - pytest.skip( - "Installed a2a.types does not export DataPart/TextPart; skip legacy A2A tests.", - allow_module_level=True, - ) +from a2a.types import ( + Artifact, + Message, + Part as A2APart, + Role, + Task, + TaskArtifactUpdateEvent, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, +) from google.genai import types as genai_types +from google.protobuf import struct_pb2 +from google.protobuf.json_format import MessageToDict, ParseDict from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.events import Event @@ -122,6 +116,18 @@ def _make_event(*, text=None, function_call=None, function_response=None, ) +def _data_part(data: dict, metadata: dict | None = None) -> A2APart: + """Build a Part with a structured ``data`` field.""" + return A2APart( + data=ParseDict(data, struct_pb2.Value()), + metadata=metadata, + ) + + +def _meta_dict(message) -> dict: + return MessageToDict(message.metadata) + + # --------------------------------------------------------------------------- # build_request_message_metadata # --------------------------------------------------------------------------- @@ -318,24 +324,22 @@ def test_includes_object_type_and_tag(self): # --------------------------------------------------------------------------- class TestMarkLongRunningTools: def test_marks_matching_tool_ids(self): - dp = DataPart( - data={"id": "tool1", "name": "fn"}, - metadata={A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, + a2a_part = _data_part( + {"id": "tool1", "name": "fn"}, + {A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, ) - a2a_part = A2APart(root=dp) event = _make_event(function_call=FunctionCall(name="fn", args={}), long_running_tool_ids={"tool1"}) _mark_long_running_tools([a2a_part], event) - assert dp.metadata[A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY] is True + assert MessageToDict(a2a_part.metadata)[A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY] is True def test_does_nothing_without_long_running_ids(self): - dp = DataPart( - data={"id": "tool1"}, - metadata={A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, + a2a_part = _data_part( + {"id": "tool1"}, + {A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, ) - a2a_part = A2APart(root=dp) event = _make_event(function_call=FunctionCall(name="fn", args={})) _mark_long_running_tools([a2a_part], event) - assert A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY not in dp.metadata + assert A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY not in MessageToDict(a2a_part.metadata) # --------------------------------------------------------------------------- @@ -344,14 +348,14 @@ def test_does_nothing_without_long_running_ids(self): class TestBuildMessage: def test_returns_none_for_empty_parts(self): event = _make_event(text="hi") - assert _build_message(event, [], Role.agent, "e1") is None + assert _build_message(event, [], Role.ROLE_AGENT, "e1") is None def test_returns_message_with_parts(self): event = _make_event(text="hi", response_id="resp-1") - parts = [A2APart(root=TextPart(text="hi"))] - msg = _build_message(event, parts, Role.agent, "resp-1") + parts = [A2APart(text="hi")] + msg = _build_message(event, parts, Role.ROLE_AGENT, "resp-1") assert msg is not None - assert msg.role == Role.agent + assert msg.role == Role.ROLE_AGENT assert msg.message_id == "resp-1" assert len(msg.parts) == 1 @@ -361,18 +365,18 @@ def test_returns_message_with_parts(self): # --------------------------------------------------------------------------- class TestIsStreamingDelta: def test_true(self): - dp = DataPart( - data={}, - metadata={A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA}, + part = _data_part( + {}, + {A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA}, ) - assert _is_streaming_delta(A2APart(root=dp)) is True + assert _is_streaming_delta(part) is True def test_false(self): - dp = DataPart( - data={}, - metadata={A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, + part = _data_part( + {}, + {A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, ) - assert _is_streaming_delta(A2APart(root=dp)) is False + assert _is_streaming_delta(part) is False # --------------------------------------------------------------------------- @@ -416,7 +420,7 @@ def test_basic(self): content = genai_types.Content(role="user", parts=[genai_types.Part(text="hi")]) msg = convert_content_to_a2a_message([content]) assert msg is not None - assert msg.role == Role.agent + assert msg.role == Role.ROLE_AGENT def test_empty_raises(self): with pytest.raises(ValueError, match="Contents cannot be None or empty"): @@ -433,8 +437,8 @@ def test_empty_parts_returns_none(self): def test_custom_role(self): content = genai_types.Content(role="user", parts=[genai_types.Part(text="hi")]) - msg = convert_content_to_a2a_message([content], role=Role.user) - assert msg.role == Role.user + msg = convert_content_to_a2a_message([content], role=Role.ROLE_USER) + assert msg.role == Role.ROLE_USER # --------------------------------------------------------------------------- @@ -449,11 +453,11 @@ def test_task_with_artifacts(self): task = Task( id="t1", context_id="ctx1", - status=TaskStatus(state=TaskState.completed), + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), artifacts=[ Artifact( artifact_id="a1", - parts=[A2APart(root=TextPart(text="result"))], + parts=[A2APart(text="result")], ) ], ) @@ -464,13 +468,13 @@ def test_task_with_artifacts(self): def test_task_with_status_message(self): msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="status"))], + role=Role.ROLE_AGENT, + parts=[A2APart(text="status")], ) task = Task( id="t1", context_id="ctx1", - status=TaskStatus(state=TaskState.working, message=msg), + status=TaskStatus(state=TaskState.TASK_STATE_WORKING, message=msg), ) event = convert_a2a_task_to_event(task) assert event.content is not None @@ -478,13 +482,13 @@ def test_task_with_status_message(self): def test_task_with_history(self): msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="history"))], + role=Role.ROLE_AGENT, + parts=[A2APart(text="history")], ) task = Task( id="t1", context_id="ctx1", - status=TaskStatus(state=TaskState.completed), + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), history=[msg], ) event = convert_a2a_task_to_event(task) @@ -494,7 +498,7 @@ def test_task_without_message(self): task = Task( id="t1", context_id="ctx1", - status=TaskStatus(state=TaskState.working), + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), ) ctx = _make_invocation_context() event = convert_a2a_task_to_event(task, invocation_context=ctx) @@ -512,23 +516,23 @@ def test_none_raises(self): def test_basic_text(self): msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="hello"))], + role=Role.ROLE_AGENT, + parts=[A2APart(text="hello")], ) event = convert_a2a_message_to_event(msg, author="bot") assert event.author == "bot" assert event.content.parts[0].text == "hello" def test_empty_parts(self): - msg = Message(message_id="m1", role=Role.agent, parts=[]) + msg = Message(message_id="m1", role=Role.ROLE_AGENT, parts=[]) event = convert_a2a_message_to_event(msg, author="bot") assert event.content is not None def test_partial_flag(self): msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="hi"))], + role=Role.ROLE_AGENT, + parts=[A2APart(text="hi")], ) event = convert_a2a_message_to_event(msg, partial=True) assert event.partial is True @@ -536,8 +540,8 @@ def test_partial_flag(self): def test_with_invocation_context(self): msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="hi"))], + role=Role.ROLE_AGENT, + parts=[A2APart(text="hi")], ) ctx = _make_invocation_context(invocation_id="inv-99", branch="b1") event = convert_a2a_message_to_event(msg, invocation_context=ctx) @@ -545,17 +549,17 @@ def test_with_invocation_context(self): assert event.branch == "b1" def test_long_running_tool_detected(self): - dp = DataPart( - data={"name": "fn", "id": "tool1", "args": "{}"}, - metadata={ + dp = _data_part( + {"name": "fn", "id": "tool1", "args": "{}"}, + { A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY: True, }, ) msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=dp)], + role=Role.ROLE_AGENT, + parts=[dp], ) event = convert_a2a_message_to_event(msg) assert event.long_running_tool_ids is not None @@ -563,8 +567,8 @@ def test_long_running_tool_detected(self): def test_metadata_object_type_used(self): msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="hi"))], + role=Role.ROLE_AGENT, + parts=[A2APart(text="hi")], metadata={MESSAGE_METADATA_OBJECT_TYPE_KEY: "custom.type"}, ) event = convert_a2a_message_to_event(msg) @@ -577,39 +581,34 @@ def test_metadata_object_type_used(self): class TestCreateStatusEvents: def test_cancellation_event(self): evt = create_cancellation_event("t1", "ctx1", "cancelled") - assert evt.status.state == TaskState.canceled + assert evt.status.state == TaskState.TASK_STATE_CANCELED assert evt.task_id == "t1" - assert evt.final is True def test_exception_status_event(self): evt = create_exception_status_event("t1", "ctx1", "error occurred") - assert evt.status.state == TaskState.failed - assert evt.final is True + assert evt.status.state == TaskState.TASK_STATE_FAILED def test_submitted_status_event(self): - msg = Message(message_id="m1", role=Role.user, parts=[]) + msg = Message(message_id="m1", role=Role.ROLE_USER, parts=[]) evt = create_submitted_status_event("t1", "ctx1", msg) - assert evt.status.state == TaskState.submitted - assert evt.final is False + assert evt.status.state == TaskState.TASK_STATE_SUBMITTED def test_working_status_event(self): evt = create_working_status_event("t1", "ctx1") - assert evt.status.state == TaskState.working - assert evt.final is False + assert evt.status.state == TaskState.TASK_STATE_WORKING def test_working_status_event_with_metadata(self): evt = create_working_status_event("t1", "ctx1", metadata={"k": "v"}) - assert evt.metadata == {"k": "v"} + assert _meta_dict(evt) == {"k": "v"} def test_completed_status_event(self): evt = create_completed_status_event("t1", "ctx1") - assert evt.status.state == TaskState.completed - assert evt.final is True + assert evt.status.state == TaskState.TASK_STATE_COMPLETED def test_final_status_event(self): - msg = Message(message_id="m1", role=Role.agent, parts=[]) - evt = create_final_status_event("t1", "ctx1", TaskState.input_required, message=msg) - assert evt.status.state == TaskState.input_required + msg = Message(message_id="m1", role=Role.ROLE_AGENT, parts=[]) + evt = create_final_status_event("t1", "ctx1", TaskState.TASK_STATE_INPUT_REQUIRED, message=msg) + assert evt.status.state == TaskState.TASK_STATE_INPUT_REQUIRED assert evt.status.message == msg @@ -621,14 +620,14 @@ def test_basic_error(self): event = _make_event(text="hi", error_code="500", error_message="Server error") ctx = _make_invocation_context() result = _create_error_status_event(event, ctx, "t1", "ctx1") - assert result.status.state == TaskState.failed - assert "Server error" in result.status.message.parts[0].root.text + assert result.status.state == TaskState.TASK_STATE_FAILED + assert "Server error" in result.status.message.parts[0].text def test_default_error_message(self): event = _make_event(error_code="500") ctx = _make_invocation_context() result = _create_error_status_event(event, ctx, "t1", "ctx1") - assert DEFAULT_ERROR_MESSAGE in result.status.message.parts[0].root.text + assert DEFAULT_ERROR_MESSAGE in result.status.message.parts[0].text # --------------------------------------------------------------------------- @@ -638,41 +637,41 @@ class TestCreateStatusUpdateEvent: def test_basic_working(self): msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="hi"))], + role=Role.ROLE_AGENT, + parts=[A2APart(text="hi")], ) event = _make_event(text="hi") ctx = _make_invocation_context() result = _create_status_update_event(msg, ctx, event, "t1", "ctx1", effective_id="m1") - assert result.status.state == TaskState.working + assert result.status.state == TaskState.TASK_STATE_WORKING def test_auth_required_for_euc(self): - dp = DataPart( - data={"id": "t1", "name": REQUEST_EUC_FUNCTION_CALL_NAME}, - metadata={ + dp = _data_part( + {"id": "t1", "name": REQUEST_EUC_FUNCTION_CALL_NAME}, + { A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY: True, }, ) - msg = Message(message_id="m1", role=Role.agent, parts=[A2APart(root=dp)]) + msg = Message(message_id="m1", role=Role.ROLE_AGENT, parts=[dp]) event = _make_event(function_call=FunctionCall(name=REQUEST_EUC_FUNCTION_CALL_NAME, args={})) ctx = _make_invocation_context() result = _create_status_update_event(msg, ctx, event, "t1", "ctx1", effective_id="m1") - assert result.status.state == TaskState.auth_required + assert result.status.state == TaskState.TASK_STATE_AUTH_REQUIRED def test_input_required_for_long_running(self): - dp = DataPart( - data={"id": "t1", "name": "other_tool"}, - metadata={ + dp = _data_part( + {"id": "t1", "name": "other_tool"}, + { A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY: True, }, ) - msg = Message(message_id="m1", role=Role.agent, parts=[A2APart(root=dp)]) + msg = Message(message_id="m1", role=Role.ROLE_AGENT, parts=[dp]) event = _make_event(function_call=FunctionCall(name="other_tool", args={})) ctx = _make_invocation_context() result = _create_status_update_event(msg, ctx, event, "t1", "ctx1", effective_id="m1") - assert result.status.state == TaskState.input_required + assert result.status.state == TaskState.TASK_STATE_INPUT_REQUIRED # --------------------------------------------------------------------------- @@ -682,8 +681,8 @@ class TestCreateArtifactUpdateEvent: def test_basic(self): msg = Message( message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="hi"))], + role=Role.ROLE_AGENT, + parts=[A2APart(text="hi")], ) event = _make_event(text="hi", response_id="resp-1") ctx = _make_invocation_context() @@ -694,7 +693,7 @@ def test_basic(self): assert result.last_chunk is False def test_last_chunk(self): - msg = Message(message_id="m1", role=Role.agent, parts=[A2APart(root=TextPart(text="hi"))]) + msg = Message(message_id="m1", role=Role.ROLE_AGENT, parts=[A2APart(text="hi")]) event = _make_event(text="hi") ctx = _make_invocation_context() result = _create_artifact_update_event( @@ -702,7 +701,7 @@ def test_last_chunk(self): ) assert result.last_chunk is True assert result.artifact.artifact_id == "" - assert result.artifact.parts == [] + assert len(result.artifact.parts) == 0 # --------------------------------------------------------------------------- @@ -727,12 +726,20 @@ def test_text_event_produces_artifact(self): has_artifact = any(isinstance(e, TaskArtifactUpdateEvent) for e in events) assert has_artifact - def test_error_event_produces_message(self): + def test_error_event_produces_status_update(self): event = _make_event(text="hi", error_code="500", error_message="fail") ctx = _make_invocation_context() events = convert_event_to_a2a_events(event, ctx, task_id="t1", context_id="ctx1") - has_message = any(isinstance(e, Message) for e in events) - assert has_message + # a2a-sdk 1.x forbids a bare Message in task mode; the failure is carried + # through the failed TaskStatusUpdateEvent. + has_status = any( + isinstance(e, TaskStatusUpdateEvent) + and e.status.state == TaskState.TASK_STATE_FAILED + and e.status.HasField("message") + for e in events + ) + assert has_status + assert not any(isinstance(e, Message) for e in events) def test_on_event_callback_called(self): event = _make_event(text="hello", partial=True) diff --git a/tests/server/a2a/converters/test_part_converter.py b/tests/server/a2a/converters/test_part_converter.py index 865e631ea..86c6b4171 100644 --- a/tests/server/a2a/converters/test_part_converter.py +++ b/tests/server/a2a/converters/test_part_converter.py @@ -13,16 +13,9 @@ from unittest.mock import MagicMock import pytest -try: - from a2a import types as a2a_types - _ = a2a_types.DataPart - _ = a2a_types.TextPart -except (ImportError, AttributeError): - pytest.skip( - "Installed a2a.types does not export DataPart/TextPart; skip legacy A2A tests.", - allow_module_level=True, - ) +from a2a.types import Part as A2APart from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict from trpc_agent_sdk.models import TOOL_STREAMING_ARGS from trpc_agent_sdk.server.a2a._constants import ( @@ -64,6 +57,31 @@ ) +def _data_dict(part: A2APart) -> dict: + """Extract the data field of a Part as a plain dict.""" + return MessageToDict(part.data) + + +def _meta_dict(part: A2APart) -> dict: + """Extract the metadata field of a Part as a plain dict.""" + return MessageToDict(part.metadata) + + +def _data_part(data: dict, metadata: dict | None = None) -> A2APart: + """Build a Part whose ``data`` field holds structured data. + + The protobuf ``data`` field is a ``google.protobuf.Value`` and must be + constructed via ``ParseDict`` (a raw dict is not accepted). + """ + from google.protobuf import struct_pb2 + from google.protobuf.json_format import ParseDict + + return A2APart( + data=ParseDict(data, struct_pb2.Value()), + metadata=metadata, + ) + + # --------------------------------------------------------------------------- # _to_bool_metadata # --------------------------------------------------------------------------- @@ -192,27 +210,27 @@ class TestGenaiTextToA2a: def test_basic_text(self): part = genai_types.Part(text="hello") result = _genai_text_to_a2a(part) - assert isinstance(result.root, a2a_types.TextPart) - assert result.root.text == "hello" + assert result.HasField("text") + assert result.text == "hello" def test_text_with_thought(self): part = genai_types.Part(text="thinking...", thought=True) result = _genai_text_to_a2a(part) - assert result.root.metadata == {"thought": True} + assert _meta_dict(result) == {"thought": True} def test_text_without_thought(self): part = genai_types.Part(text="no thought") result = _genai_text_to_a2a(part) - assert result.root.metadata is None + assert not result.HasField("metadata") class TestGenaiFileUriToA2a: def test_basic(self): part = genai_types.Part(file_data=genai_types.FileData(file_uri="gs://b/f", mime_type="image/png")) result = _genai_file_uri_to_a2a(part) - assert isinstance(result.root, a2a_types.FilePart) - assert isinstance(result.root.file, a2a_types.FileWithUri) - assert result.root.file.uri == "gs://b/f" + assert result.HasField("url") + assert result.url == "gs://b/f" + assert result.media_type == "image/png" class TestGenaiInlineFileToA2a: @@ -220,9 +238,8 @@ def test_basic(self): data = b"binary_data" part = genai_types.Part(inline_data=genai_types.Blob(data=data, mime_type="application/octet-stream")) result = _genai_inline_file_to_a2a(part) - assert isinstance(result.root, a2a_types.FilePart) - assert isinstance(result.root.file, a2a_types.FileWithBytes) - assert base64.b64decode(result.root.file.bytes) == data + assert result.HasField("raw") + assert result.raw == data class TestGenaiStreamingFunctionCallToA2a: @@ -230,42 +247,43 @@ def test_basic(self): part = genai_types.Part(function_call=genai_types.FunctionCall( id="tool1", name="fn", args={TOOL_STREAMING_ARGS: "partial"})) result = _genai_streaming_function_call_to_a2a(part) - assert isinstance(result.root, a2a_types.DataPart) - assert result.root.data["name"] == "fn" - assert result.root.data["delta_args"] == "partial" - assert result.root.metadata[A2A_DATA_PART_METADATA_TYPE_KEY] == A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA + assert result.HasField("data") + data = _data_dict(result) + assert data["name"] == "fn" + assert data["delta_args"] == "partial" + assert _meta_dict(result)[A2A_DATA_PART_METADATA_TYPE_KEY] == A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA class TestGenaiFunctionCallToA2a: def test_basic(self): part = genai_types.Part(function_call=genai_types.FunctionCall(name="fn", args={"x": 1})) result = _genai_function_call_to_a2a(part) - assert isinstance(result.root, a2a_types.DataPart) - assert result.root.metadata[A2A_DATA_PART_METADATA_TYPE_KEY] == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL + assert result.HasField("data") + assert _meta_dict(result)[A2A_DATA_PART_METADATA_TYPE_KEY] == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL class TestGenaiFunctionResponseToA2a: def test_basic(self): part = genai_types.Part(function_response=genai_types.FunctionResponse(name="fn", response={"r": "ok"})) result = _genai_function_response_to_a2a(part) - assert isinstance(result.root, a2a_types.DataPart) - assert result.root.metadata[A2A_DATA_PART_METADATA_TYPE_KEY] == A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE + assert result.HasField("data") + assert _meta_dict(result)[A2A_DATA_PART_METADATA_TYPE_KEY] == A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE class TestGenaiCodeExecutionResultToA2a: def test_basic(self): part = genai_types.Part(code_execution_result=genai_types.CodeExecutionResult(output="result", outcome="OUTCOME_OK")) result = _genai_code_execution_result_to_a2a(part) - assert isinstance(result.root, a2a_types.DataPart) - assert result.root.data[A2A_DATA_FIELD_CODE_EXECUTION_OUTPUT] == "result" + assert result.HasField("data") + assert _data_dict(result)[A2A_DATA_FIELD_CODE_EXECUTION_OUTPUT] == "result" class TestGenaiExecutableCodeToA2a: def test_basic(self): part = genai_types.Part(executable_code=genai_types.ExecutableCode(code="print(1)", language="PYTHON")) result = _genai_executable_code_to_a2a(part) - assert isinstance(result.root, a2a_types.DataPart) - assert result.root.data[A2A_DATA_FIELD_CODE_EXECUTION_CODE] == "print(1)" + assert result.HasField("data") + assert _data_dict(result)[A2A_DATA_FIELD_CODE_EXECUTION_CODE] == "print(1)" # --------------------------------------------------------------------------- @@ -275,7 +293,7 @@ class TestConvertGenaiPartToA2aPart: def test_text_dispatch(self): part = genai_types.Part(text="hi") result = convert_genai_part_to_a2a_part(part) - assert isinstance(result.root, a2a_types.TextPart) + assert result.HasField("text") def test_unknown_returns_none(self): part = genai_types.Part() @@ -357,24 +375,24 @@ def test_none_data(self): # --------------------------------------------------------------------------- class TestConvertA2aDataPart: def test_function_call(self): - dp = a2a_types.DataPart( - data={"name": "fn", "args": '{"x": 1}'}, - metadata={A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, + part = _data_part( + {"name": "fn", "args": '{"x": 1}'}, + {A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, ) - result = _convert_a2a_data_part(dp) + result = _convert_a2a_data_part(part) assert result.function_call is not None assert result.function_call.name == "fn" def test_unknown_type_falls_back_to_json_text(self): - dp = a2a_types.DataPart(data={"custom": "value"}, metadata={"type": "unknown"}) - result = _convert_a2a_data_part(dp) + part = _data_part({"custom": "value"}, {"type": "unknown"}) + result = _convert_a2a_data_part(part) assert result.text is not None parsed = json.loads(result.text) assert parsed["custom"] == "value" def test_no_metadata_type(self): - dp = a2a_types.DataPart(data={"k": "v"}) - result = _convert_a2a_data_part(dp) + part = _data_part({"k": "v"}) + result = _convert_a2a_data_part(part) assert result.text is not None @@ -383,47 +401,37 @@ def test_no_metadata_type(self): # --------------------------------------------------------------------------- class TestConvertA2aPartToGenaiPart: def test_text_part(self): - a2a_part = a2a_types.Part(root=a2a_types.TextPart(text="hello")) + a2a_part = A2APart(text="hello") result = convert_a2a_part_to_genai_part(a2a_part) assert result.text == "hello" def test_text_part_with_thought(self): - tp = a2a_types.TextPart(text="thinking") - tp.metadata = {"thought": "true"} - a2a_part = a2a_types.Part(root=tp) + a2a_part = A2APart(text="thinking", metadata={"thought": "true"}) result = convert_a2a_part_to_genai_part(a2a_part) assert result.text == "thinking" assert result.thought is True def test_file_with_uri(self): - fp = a2a_types.FilePart(file=a2a_types.FileWithUri(uri="gs://b/f", mime_type="text/plain")) - a2a_part = a2a_types.Part(root=fp) + a2a_part = A2APart(url="gs://b/f", media_type="text/plain") result = convert_a2a_part_to_genai_part(a2a_part) assert result.file_data.file_uri == "gs://b/f" def test_file_with_bytes(self): data = b"hello" - fp = a2a_types.FilePart(file=a2a_types.FileWithBytes( - bytes=base64.b64encode(data).decode("utf-8"), - mime_type="text/plain", - )) - a2a_part = a2a_types.Part(root=fp) + a2a_part = A2APart(raw=data, media_type="text/plain") result = convert_a2a_part_to_genai_part(a2a_part) assert result.inline_data.data == data def test_data_part(self): - dp = a2a_types.DataPart( - data={"name": "fn", "args": "{}"}, - metadata={A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, + a2a_part = _data_part( + {"name": "fn", "args": "{}"}, + {A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL}, ) - a2a_part = a2a_types.Part(root=dp) result = convert_a2a_part_to_genai_part(a2a_part) assert result.function_call is not None - def test_unsupported_file_type_returns_none(self): - mock_file_part = MagicMock(spec=a2a_types.FilePart) - mock_file_part.file = MagicMock() - a2a_part = MagicMock(spec=a2a_types.Part) - a2a_part.root = mock_file_part - result = convert_a2a_part_to_genai_part(a2a_part) + def test_unsupported_part_returns_none(self): + mock_part = MagicMock(spec=A2APart) + mock_part.HasField.side_effect = lambda f: False + result = convert_a2a_part_to_genai_part(mock_part) assert result is None diff --git a/tests/server/a2a/converters/test_request_converter.py b/tests/server/a2a/converters/test_request_converter.py index 377b426ea..7d01882bd 100644 --- a/tests/server/a2a/converters/test_request_converter.py +++ b/tests/server/a2a/converters/test_request_converter.py @@ -11,13 +11,9 @@ import pytest from a2a.server.agent_execution.context import RequestContext -try: - from a2a.types import Message, Part, Role, TextPart -except ImportError: - pytest.skip( - "Installed a2a.types does not export TextPart; skip legacy A2A tests.", - allow_module_level=True, - ) +from a2a.types import Message, Part, Role +from google.protobuf.struct_pb2 import Struct +from google.protobuf.json_format import MessageToDict, ParseDict from trpc_agent_sdk.server.a2a.converters._request_converter import ( _get_user_id_default, @@ -115,8 +111,8 @@ class TestConvertA2aRequestToRunArgs: async def test_basic_conversion(self): msg = Message( message_id="m1", - role=Role.user, - parts=[Part(root=TextPart(text="hello"))], + role=Role.ROLE_USER, + parts=[Part(text="hello")], ) ctx = _make_context(user_name="alice", context_id="s1", message=msg) result = await convert_a2a_request_to_trpc_agent_run_args(ctx) @@ -134,24 +130,27 @@ async def test_raises_on_none_message(self): async def test_message_metadata_included(self): msg = Message( message_id="m1", - role=Role.user, - parts=[Part(root=TextPart(text="hi"))], + role=Role.ROLE_USER, + parts=[Part(text="hi")], metadata={"key": "val"}, ) ctx = _make_context(message=msg) result = await convert_a2a_request_to_trpc_agent_run_args(ctx) assert result["run_config"].agent_run_config["metadata"]["key"] == "val" - async def test_message_metadata_non_dict_treated_as_empty(self): + async def test_message_metadata_struct(self): + # In 1.x the message metadata is a protobuf Struct. + struct_meta = Struct() + struct_meta.update({"key": "val"}) msg = Message( message_id="m1", - role=Role.user, - parts=[Part(root=TextPart(text="hi"))], + role=Role.ROLE_USER, + parts=[Part(text="hi")], ) - msg.metadata = "not_a_dict" + msg.metadata.CopyFrom(struct_meta) ctx = _make_context(message=msg) result = await convert_a2a_request_to_trpc_agent_run_args(ctx) - assert result["run_config"].agent_run_config["metadata"] == {} + assert result["run_config"].agent_run_config["metadata"]["key"] == "val" # --------------------------------------------------------------------------- diff --git a/tests/server/a2a/executor/test_a2a_agent_executor.py b/tests/server/a2a/executor/test_a2a_agent_executor.py index ea91ae3e8..260bf6470 100644 --- a/tests/server/a2a/executor/test_a2a_agent_executor.py +++ b/tests/server/a2a/executor/test_a2a_agent_executor.py @@ -16,9 +16,9 @@ Message, Part as A2APart, Role, + Task, TaskState, TaskStatus, - TextPart, ) from trpc_agent_sdk.runners import Runner @@ -234,7 +234,7 @@ async def test_raises_on_no_message(self): await executor.execute(ctx, queue) async def test_submitted_event_when_no_current_task(self): - msg = Message(message_id="m1", role=Role.user, parts=[A2APart(root=TextPart(text="hi"))]) + msg = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) runner = _make_runner() async def empty_run(**kwargs): @@ -268,9 +268,13 @@ async def empty_run(**kwargs): await executor.execute(ctx, queue) calls = queue.enqueue_event.call_args_list assert len(calls) >= 2 + # In 1.x the first event must be a Task (submission signal). + first_event = calls[0].args[0] + assert isinstance(first_event, Task) + assert first_event.id == "task-1" async def test_cancelled_session_enqueues_cancellation(self): - msg = Message(message_id="m1", role=Role.user, parts=[A2APart(root=TextPart(text="hi"))]) + msg = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) runner = _make_runner() executor = TrpcA2aAgentExecutor(runner=runner) ctx = _make_context(message=msg, current_task=MagicMock()) @@ -289,9 +293,52 @@ async def test_cancelled_session_enqueues_cancellation(self): await executor.execute(ctx, queue) enqueued_args = [call.args[0] for call in queue.enqueue_event.call_args_list] # Should have a cancellation event - assert any(hasattr(e, "status") and e.status.state == TaskState.canceled + assert any(hasattr(e, "status") and e.status.state == TaskState.TASK_STATE_CANCELED for e in enqueued_args if hasattr(e, "status")) + async def test_execution_error_enqueues_status_event(self): + msg = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) + runner = _make_runner() + + async def failing_run(**kwargs): + raise RuntimeError("boom") + yield # pragma: no cover + + runner.run_async = failing_run + executor = TrpcA2aAgentExecutor(runner=runner) + ctx = _make_context(message=msg, current_task=None) + ctx.call_context = None + queue = _make_event_queue() + + with patch( + "trpc_agent_sdk.server.a2a.executor._a2a_agent_executor.convert_a2a_request_to_trpc_agent_run_args", + new_callable=AsyncMock, + return_value={ + "user_id": "u1", + "session_id": "s1", + "new_message": MagicMock(), + "run_config": MagicMock(), + }, + ), patch( + "trpc_agent_sdk.server.a2a.executor._a2a_agent_executor.is_run_cancelled", + new_callable=AsyncMock, + return_value=False, + ), patch( + "trpc_agent_sdk.server.a2a.executor._a2a_agent_executor.new_agent_context", + return_value=MagicMock(), + ): + runner._new_invocation_context = MagicMock() + await executor.execute(ctx, queue) + enqueued_args = [call.args[0] for call in queue.enqueue_event.call_args_list] + # a2a-sdk 1.x task-mode forbids a bare Message after the initial Task; + # the failure must be delivered as a TaskStatusUpdateEvent. + assert any( + hasattr(e, "status") + and e.status.state == TaskState.TASK_STATE_FAILED + and e.status.HasField("message") + for e in enqueued_args + ) + # --------------------------------------------------------------------------- # _prepare_session diff --git a/tests/server/a2a/executor/test_task_result_aggregator.py b/tests/server/a2a/executor/test_task_result_aggregator.py index 1afc6b1d7..7a71c5d5e 100644 --- a/tests/server/a2a/executor/test_task_result_aggregator.py +++ b/tests/server/a2a/executor/test_task_result_aggregator.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock import pytest -from a2a.types import Message, Role, TaskState, TaskStatus, TaskStatusUpdateEvent, TextPart +from a2a.types import Message, Part, Role, TaskState, TaskStatus, TaskStatusUpdateEvent from trpc_agent_sdk.server.a2a.executor._task_result_aggregator import TaskResultAggregator @@ -19,13 +19,12 @@ def _make_status_event(state: TaskState, text: str = "msg") -> TaskStatusUpdateE return TaskStatusUpdateEvent( task_id="t1", context_id="ctx1", - final=False, status=TaskStatus( state=state, message=Message( message_id="m1", - role=Role.agent, - parts=[TextPart(text=text)], + role=Role.ROLE_AGENT, + parts=[Part(text=text)], ), ), ) @@ -34,7 +33,7 @@ def _make_status_event(state: TaskState, text: str = "msg") -> TaskStatusUpdateE class TestTaskResultAggregatorInit: def test_initial_state_is_working(self): agg = TaskResultAggregator() - assert agg.task_state == TaskState.working + assert agg.task_state == TaskState.TASK_STATE_WORKING def test_initial_message_is_none(self): agg = TaskResultAggregator() @@ -44,103 +43,107 @@ def test_initial_message_is_none(self): class TestProcessEventWorking: def test_working_event_updates_message(self): agg = TaskResultAggregator() - evt = _make_status_event(TaskState.working, "working msg") + evt = _make_status_event(TaskState.TASK_STATE_WORKING, "working msg") agg.process_event(evt) - assert agg.task_state == TaskState.working - assert agg.task_status_message.parts[0].root.text == "working msg" + assert agg.task_state == TaskState.TASK_STATE_WORKING + assert agg.task_status_message.parts[0].text == "working msg" - def test_working_event_state_is_rewritten(self): + def test_working_event_state_not_rewritten(self): + # 1.x events are shared protobuf messages; the aggregator observes but + # does not mutate the event state. agg = TaskResultAggregator() - evt = _make_status_event(TaskState.working) + evt = _make_status_event(TaskState.TASK_STATE_WORKING) agg.process_event(evt) - assert evt.status.state == TaskState.working + assert evt.status.state == TaskState.TASK_STATE_WORKING class TestProcessEventFailed: def test_failed_sets_state(self): agg = TaskResultAggregator() - evt = _make_status_event(TaskState.failed, "error") + evt = _make_status_event(TaskState.TASK_STATE_FAILED, "error") agg.process_event(evt) - assert agg.task_state == TaskState.failed - assert agg.task_status_message.parts[0].root.text == "error" + assert agg.task_state == TaskState.TASK_STATE_FAILED + assert agg.task_status_message.parts[0].text == "error" def test_failed_is_highest_priority(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.auth_required, "auth")) - agg.process_event(_make_status_event(TaskState.failed, "fail")) - assert agg.task_state == TaskState.failed - assert agg.task_status_message.parts[0].root.text == "fail" + agg.process_event(_make_status_event(TaskState.TASK_STATE_AUTH_REQUIRED, "auth")) + agg.process_event(_make_status_event(TaskState.TASK_STATE_FAILED, "fail")) + assert agg.task_state == TaskState.TASK_STATE_FAILED + assert agg.task_status_message.parts[0].text == "fail" def test_failed_not_overwritten_by_auth_required(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.failed, "fail")) - agg.process_event(_make_status_event(TaskState.auth_required, "auth")) - assert agg.task_state == TaskState.failed - assert agg.task_status_message.parts[0].root.text == "fail" + agg.process_event(_make_status_event(TaskState.TASK_STATE_FAILED, "fail")) + agg.process_event(_make_status_event(TaskState.TASK_STATE_AUTH_REQUIRED, "auth")) + assert agg.task_state == TaskState.TASK_STATE_FAILED + assert agg.task_status_message.parts[0].text == "fail" def test_failed_not_overwritten_by_input_required(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.failed, "fail")) - agg.process_event(_make_status_event(TaskState.input_required, "input")) - assert agg.task_state == TaskState.failed + agg.process_event(_make_status_event(TaskState.TASK_STATE_FAILED, "fail")) + agg.process_event(_make_status_event(TaskState.TASK_STATE_INPUT_REQUIRED, "input")) + assert agg.task_state == TaskState.TASK_STATE_FAILED def test_failed_not_overwritten_by_working(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.failed, "fail")) - agg.process_event(_make_status_event(TaskState.working, "work")) - assert agg.task_state == TaskState.failed - assert agg.task_status_message.parts[0].root.text == "fail" + agg.process_event(_make_status_event(TaskState.TASK_STATE_FAILED, "fail")) + agg.process_event(_make_status_event(TaskState.TASK_STATE_WORKING, "work")) + assert agg.task_state == TaskState.TASK_STATE_FAILED + assert agg.task_status_message.parts[0].text == "fail" - def test_event_state_rewritten_to_working(self): + def test_event_state_not_rewritten(self): + # 1.x events are shared protobuf messages; the aggregator does not + # rewrite the event's state. agg = TaskResultAggregator() - evt = _make_status_event(TaskState.failed) + evt = _make_status_event(TaskState.TASK_STATE_FAILED) agg.process_event(evt) - assert evt.status.state == TaskState.working + assert evt.status.state == TaskState.TASK_STATE_FAILED class TestProcessEventAuthRequired: def test_auth_required_sets_state(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.auth_required, "auth")) - assert agg.task_state == TaskState.auth_required + agg.process_event(_make_status_event(TaskState.TASK_STATE_AUTH_REQUIRED, "auth")) + assert agg.task_state == TaskState.TASK_STATE_AUTH_REQUIRED def test_auth_required_not_overwritten_by_input_required(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.auth_required, "auth")) - agg.process_event(_make_status_event(TaskState.input_required, "input")) - assert agg.task_state == TaskState.auth_required + agg.process_event(_make_status_event(TaskState.TASK_STATE_AUTH_REQUIRED, "auth")) + agg.process_event(_make_status_event(TaskState.TASK_STATE_INPUT_REQUIRED, "input")) + assert agg.task_state == TaskState.TASK_STATE_AUTH_REQUIRED class TestProcessEventInputRequired: def test_input_required_sets_state(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.input_required, "input")) - assert agg.task_state == TaskState.input_required + agg.process_event(_make_status_event(TaskState.TASK_STATE_INPUT_REQUIRED, "input")) + assert agg.task_state == TaskState.TASK_STATE_INPUT_REQUIRED def test_input_required_overridden_by_failed(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.input_required, "input")) - agg.process_event(_make_status_event(TaskState.failed, "fail")) - assert agg.task_state == TaskState.failed + agg.process_event(_make_status_event(TaskState.TASK_STATE_INPUT_REQUIRED, "input")) + agg.process_event(_make_status_event(TaskState.TASK_STATE_FAILED, "fail")) + assert agg.task_state == TaskState.TASK_STATE_FAILED class TestProcessEventNonStatusUpdate: def test_non_status_event_is_ignored(self): agg = TaskResultAggregator() agg.process_event(MagicMock()) - assert agg.task_state == TaskState.working + assert agg.task_state == TaskState.TASK_STATE_WORKING assert agg.task_status_message is None class TestProcessEventSequence: def test_multiple_working_events_keep_last_message(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.working, "first")) - agg.process_event(_make_status_event(TaskState.working, "second")) - assert agg.task_status_message.parts[0].root.text == "second" + agg.process_event(_make_status_event(TaskState.TASK_STATE_WORKING, "first")) + agg.process_event(_make_status_event(TaskState.TASK_STATE_WORKING, "second")) + assert agg.task_status_message.parts[0].text == "second" def test_working_after_failed_does_not_update_message(self): agg = TaskResultAggregator() - agg.process_event(_make_status_event(TaskState.failed, "error")) - agg.process_event(_make_status_event(TaskState.working, "work")) - assert agg.task_status_message.parts[0].root.text == "error" + agg.process_event(_make_status_event(TaskState.TASK_STATE_FAILED, "error")) + agg.process_event(_make_status_event(TaskState.TASK_STATE_WORKING, "work")) + assert agg.task_status_message.parts[0].text == "error" diff --git a/tests/server/a2a/logs/test_log_utils.py b/tests/server/a2a/logs/test_log_utils.py index ee086b1cd..3d0d571ba 100644 --- a/tests/server/a2a/logs/test_log_utils.py +++ b/tests/server/a2a/logs/test_log_utils.py @@ -12,12 +12,7 @@ import pytest from a2a.types import ( - DataPart, - FilePart, - FileWithBytes, - FileWithUri, Message, - MessageSendParams, Part, Role, SendMessageRequest, @@ -25,8 +20,9 @@ Task, TaskState, TaskStatus, - TextPart, ) +from google.protobuf import struct_pb2 +from google.protobuf.json_format import ParseDict from trpc_agent_sdk.server.a2a.logs._log_utils import ( _is_a2a_data_part, @@ -39,6 +35,10 @@ ) +def _data_part(data: dict) -> Part: + return Part(data=ParseDict(data, struct_pb2.Value())) + + # --------------------------------------------------------------------------- # Type guard helpers # --------------------------------------------------------------------------- @@ -47,7 +47,7 @@ def test_real_task(self): task = Task( id="t1", context_id="ctx1", - status=TaskStatus(state=TaskState.completed), + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED), ) assert _is_a2a_task(task) is True @@ -63,7 +63,7 @@ def test_duck_type_fallback(self): class TestIsA2aMessage: def test_real_message(self): - msg = Message(message_id="m1", role=Role.agent, parts=[]) + msg = Message(message_id="m1", role=Role.ROLE_AGENT, parts=[]) assert _is_a2a_message(msg) is True def test_non_message(self): @@ -72,18 +72,18 @@ def test_non_message(self): class TestIsA2aTextPart: def test_real_text_part(self): - assert _is_a2a_text_part(TextPart(text="hello")) is True + assert _is_a2a_text_part(Part(text="hello")) is True def test_non_text_part(self): - assert _is_a2a_text_part(DataPart(data={})) is False + assert _is_a2a_text_part(_data_part({"k": "v"})) is False class TestIsA2aDataPart: def test_real_data_part(self): - assert _is_a2a_data_part(DataPart(data={"k": "v"})) is True + assert _is_a2a_data_part(_data_part({"k": "v"})) is True def test_non_data_part(self): - assert _is_a2a_data_part(TextPart(text="hi")) is False + assert _is_a2a_data_part(Part(text="hi")) is False # --------------------------------------------------------------------------- @@ -91,37 +91,36 @@ def test_non_data_part(self): # --------------------------------------------------------------------------- class TestBuildMessagePartLog: def test_text_part_short(self): - part = Part(root=TextPart(text="short text")) + part = Part(text="short text") log = build_message_part_log(part) assert "TextPart: short text" in log def test_text_part_long_truncated(self): long_text = "x" * 200 - part = Part(root=TextPart(text=long_text)) + part = Part(text=long_text) log = build_message_part_log(part) assert "..." in log assert len(long_text[:100]) == 100 def test_data_part(self): - part = Part(root=DataPart(data={"name": "tool1", "id": "t1"})) + part = _data_part({"name": "tool1", "id": "t1"}) log = build_message_part_log(part) assert "DataPart:" in log assert "tool1" in log def test_data_part_large_value(self): large_dict = {"key": {"nested": "v" * 200}} - part = Part(root=DataPart(data=large_dict)) + part = _data_part(large_dict) log = build_message_part_log(part) assert "" in log - def test_file_part_fallback(self): - part = Part(root=FilePart(file=FileWithUri(uri="http://example.com/file.png", mime_type="image/png"))) + def test_url_part(self): + part = Part(url="http://example.com/file.png", media_type="image/png") log = build_message_part_log(part) assert "FilePart:" in log def test_metadata_included(self): - part = Part(root=TextPart(text="hi")) - part.root.metadata = {"thought": True} + part = Part(text="hi", metadata={"thought": True}) log = build_message_part_log(part) assert "Part Metadata" in log assert "thought" in log @@ -134,24 +133,22 @@ class TestBuildA2aRequestLog: def _make_request(self, *, parts=None, configuration=None, metadata=None, msg_metadata=None): msg = Message( message_id="msg-1", - role=Role.user, - parts=parts if parts is not None else [Part(root=TextPart(text="hello"))], - metadata=msg_metadata, + role=Role.ROLE_USER, + parts=parts if parts is not None else [Part(text="hello")], ) + if msg_metadata: + msg.metadata.update(msg_metadata) return SendMessageRequest( - id="req-1", - params=MessageSendParams( - message=msg, - configuration=configuration, - metadata=metadata, - ), + tenant="", + message=msg, + configuration=configuration, + metadata=metadata, ) def test_basic_request(self): req = self._make_request() log = build_a2a_request_log(req) assert "A2A Request:" in log - assert "req-1" in log assert "msg-1" in log def test_request_with_no_parts(self): @@ -179,55 +176,39 @@ def _make_task_response(self, *, status_msg=None, history=None, artifacts=None, id="t1", context_id="ctx1", status=TaskStatus( - state=TaskState.completed, + state=TaskState.TASK_STATE_COMPLETED, message=status_msg, ), history=history, artifacts=artifacts, - metadata=metadata, ) - resp_data = {"id": "resp-1", "jsonrpc": "2.0", "result": task.model_dump(by_alias=True, exclude_none=True)} - return SendMessageResponse.model_validate(resp_data) + if metadata: + task.metadata.update(metadata) + return SendMessageResponse(task=task) def _make_message_response(self, *, parts=None, metadata=None): msg = Message( message_id="m1", - role=Role.agent, - parts=parts or [Part(root=TextPart(text="answer"))], - metadata=metadata, + role=Role.ROLE_AGENT, + parts=parts or [Part(text="answer")], ) - resp_data = {"id": "resp-1", "jsonrpc": "2.0", "result": msg.model_dump(by_alias=True, exclude_none=True)} - return SendMessageResponse.model_validate(resp_data) - - def _make_error_response(self): - resp_data = { - "id": "resp-1", - "jsonrpc": "2.0", - "error": { - "code": -32600, - "message": "Invalid request", - }, - } - return SendMessageResponse.model_validate(resp_data) - - def test_error_response(self): - resp = self._make_error_response() - log = build_a2a_response_log(resp) - assert "Type: ERROR" in log - assert "Invalid request" in log + if metadata: + msg.metadata.update(metadata) + return SendMessageResponse(message=msg) def test_task_response_basic(self): resp = self._make_task_response() log = build_a2a_response_log(resp) assert "Type: SUCCESS" in log assert "Task" in log - assert "completed" in log + # Protobuf enum values serialize as ints (TASK_STATE_COMPLETED == 3). + assert f"Status State: {int(TaskState.TASK_STATE_COMPLETED)}" in log def test_task_response_with_status_message(self): status_msg = Message( message_id="sm-1", - role=Role.agent, - parts=[Part(root=TextPart(text="done"))], + role=Role.ROLE_AGENT, + parts=[Part(text="done")], ) resp = self._make_task_response(status_msg=status_msg) log = build_a2a_response_log(resp) @@ -235,8 +216,8 @@ def test_task_response_with_status_message(self): def test_task_response_with_history(self): history = [ - Message(message_id="h1", role=Role.user, parts=[Part(root=TextPart(text="q"))]), - Message(message_id="h2", role=Role.agent, parts=[Part(root=TextPart(text="a"))]), + Message(message_id="h1", role=Role.ROLE_USER, parts=[Part(text="q")]), + Message(message_id="h2", role=Role.ROLE_AGENT, parts=[Part(text="a")]), ] resp = self._make_task_response(history=history) log = build_a2a_response_log(resp) diff --git a/tests/server/a2a/test_agent_card_builder.py b/tests/server/a2a/test_agent_card_builder.py index c0037fe7b..fabeebc5a 100644 --- a/tests/server/a2a/test_agent_card_builder.py +++ b/tests/server/a2a/test_agent_card_builder.py @@ -120,7 +120,9 @@ async def test_basic_build(self): assert isinstance(card, AgentCard) assert card.name == "my-agent" assert card.description == "A test agent" - assert card.url == "http://localhost:8080" + # In 1.x the card exposes interfaces (url + protocol binding) instead of + # a single top-level url. + assert card.supported_interfaces[0].url == "http://localhost:8080" async def test_build_with_no_description(self): agent = _make_llm_agent(name="agent", description=None) diff --git a/tests/server/a2a/test_agent_service.py b/tests/server/a2a/test_agent_service.py index e8c9a9c1b..9309ded8c 100644 --- a/tests/server/a2a/test_agent_service.py +++ b/tests/server/a2a/test_agent_service.py @@ -12,7 +12,7 @@ import pytest from a2a.server.agent_execution.context import RequestContext from a2a.server.events.event_queue import EventQueue -from a2a.types import AgentCapabilities, AgentCard +from a2a.types import AgentCapabilities, AgentCard, AgentInterface from trpc_agent_sdk.agents import BaseAgent from trpc_agent_sdk.server.a2a._agent_service import TrpcA2aAgentService @@ -31,11 +31,13 @@ def _make_card(name="test-agent"): return AgentCard( name=name, description="Test agent", - url="http://localhost", version="0.0.1", capabilities=AgentCapabilities(streaming=True), - defaultInputModes=["text/plain"], - defaultOutputModes=["text/plain"], + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + supported_interfaces=[ + AgentInterface(protocol_binding="JSONRPC", protocol_version="1.0", url="http://localhost"), + ], skills=[], ) @@ -95,6 +97,16 @@ async def test_builds_card_if_none(self): assert service._agent_card is not None assert service._agent_card.capabilities.streaming is True + async def test_rpc_url_passed_to_card_builder(self): + service = TrpcA2aAgentService( + service_name="svc", + agent=_make_agent(), + rpc_url="https://agent.example.com/a2a", + ) + await service._initialize() + assert service._agent_card is not None + assert service._agent_card.supported_interfaces[0].url == "https://agent.example.com/a2a" + async def test_preserves_existing_card(self): card = _make_card() service = TrpcA2aAgentService(service_name="svc", agent=_make_agent(), agent_card=card) diff --git a/tests/server/a2a/test_application.py b/tests/server/a2a/test_application.py new file mode 100644 index 000000000..9c8b53fee --- /dev/null +++ b/tests/server/a2a/test_application.py @@ -0,0 +1,170 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +"""Unit tests for trpc_agent_sdk.server.a2a._application.""" + +from __future__ import annotations + +from unittest.mock import MagicMock +from unittest.mock import patch + +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import AgentCapabilities, AgentCard, AgentInterface + +from trpc_agent_sdk.server.a2a._application import _ensure_v0_3_interface +from trpc_agent_sdk.server.a2a._application import _jsonrpc_path_from_card +from trpc_agent_sdk.server.a2a._application import create_a2a_application + + +def _make_card(url: str = ""): + return AgentCard( + name="svc", + description="Test agent", + version="0.0.1", + capabilities=AgentCapabilities(streaming=True), + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + supported_interfaces=[ + AgentInterface(protocol_binding="JSONRPC", protocol_version="1.0", url=url), + ], + skills=[], + ) + + +def _make_service(card: AgentCard): + svc = MagicMock() + svc.agent_card = card + return svc + + +# --------------------------------------------------------------------------- +# _ensure_v0_3_interface +# --------------------------------------------------------------------------- +class TestEnsureV03Interface: + def test_adds_v03_interface(self): + card = _make_card(url="http://host:18081/") + _ensure_v0_3_interface(card) + versions = [ + (i.protocol_binding, i.protocol_version, i.url) + for i in card.supported_interfaces + ] + assert ("JSONRPC", "1.0", "http://host:18081/") in versions + assert ("JSONRPC", "0.3", "http://host:18081/") in versions + + def test_does_not_duplicate_v03_interface(self): + card = _make_card(url="http://host:18081/") + _ensure_v0_3_interface(card) + _ensure_v0_3_interface(card) + count = sum( + 1 + for i in card.supported_interfaces + if i.protocol_binding == "JSONRPC" and i.protocol_version == "0.3" + ) + assert count == 1 + + def test_v03_interface_reuses_existing_url(self): + # The appended 0.3 interface must point at the advertised url. + card = _make_card(url="https://agent.example.com/a2a") + _ensure_v0_3_interface(card) + for i in card.supported_interfaces: + if i.protocol_version == "0.3": + assert i.url == "https://agent.example.com/a2a" + + +# --------------------------------------------------------------------------- +# _jsonrpc_path_from_card +# --------------------------------------------------------------------------- +class TestJsonrpcPathFromCard: + def test_bare_origin_defaults_to_root(self): + assert _jsonrpc_path_from_card(_make_card(url="http://host:18081")) == "/" + + def test_path_url(self): + assert _jsonrpc_path_from_card(_make_card(url="https://agent.example.com/a2a")) == "/a2a" + + def test_path_with_trailing_slash(self): + assert _jsonrpc_path_from_card(_make_card(url="https://agent.example.com/a2a/")) == "/a2a/" + + def test_empty_url_defaults_to_root(self): + assert _jsonrpc_path_from_card(_make_card(url="")) == "/" + + def test_prefers_10_interface_url(self): + # A framework-built card has a single interface; the first JSONRPC url + # advertised is the one clients discover and the mount must follow it. + card = _make_card(url="") + card.ClearField("supported_interfaces") + card.supported_interfaces.extend([ + AgentInterface(protocol_binding="JSONRPC", protocol_version="1.0", url="https://x.com/a2a"), + AgentInterface(protocol_binding="JSONRPC", protocol_version="0.3", url="https://x.com/legacy"), + ]) + assert _jsonrpc_path_from_card(card) == "/a2a" + + def test_falls_back_when_10_has_no_url(self): + # 1.0 interface empty -> fall back to any advertised url. + card = _make_card(url="") + card.ClearField("supported_interfaces") + card.supported_interfaces.extend([ + AgentInterface(protocol_binding="JSONRPC", protocol_version="1.0", url=""), + AgentInterface(protocol_binding="JSONRPC", protocol_version="0.3", url="https://x.com/legacy"), + ]) + assert _jsonrpc_path_from_card(card) == "/legacy" + + +# --------------------------------------------------------------------------- +# create_a2a_application +# --------------------------------------------------------------------------- +class TestCreateA2aApplication: + def test_default_builds_default_handler(self): + svc = _make_service(_make_card(url="http://host:18081")) + + with patch("trpc_agent_sdk.server.a2a._application.DefaultRequestHandler") as MockHandler: + create_a2a_application(svc) + call_kwargs = MockHandler.call_args.kwargs + assert call_kwargs["agent_executor"] is svc + assert isinstance(call_kwargs["task_store"], InMemoryTaskStore) + assert call_kwargs["agent_card"] is svc.agent_card + + def test_uses_custom_request_handler(self): + custom_handler = MagicMock() + svc = _make_service(_make_card(url="http://host:18081")) + + with patch("trpc_agent_sdk.server.a2a._application.DefaultRequestHandler") as MockHandler: + app = create_a2a_application(svc, request_handler=custom_handler) + # The provided handler must be used as-is; DefaultRequestHandler is not + # constructed. + MockHandler.assert_not_called() + assert app is not None + + def test_missing_url_warns_but_starts(self): + # No rpc_url configured anywhere: the server must still start (JSON-RPC + # direct callers don't read the card), but a warning points out the + # card is undiscoverable. + svc = _make_service(_make_card(url="")) + with patch("trpc_agent_sdk.server.a2a._application.logger.warning") as mock_warn: + app = create_a2a_application(svc) + assert app is not None + mock_warn.assert_called_once() + assert "no reachable url" in mock_warn.call_args.args[0] + + def test_ok_when_card_has_url(self): + svc = _make_service(_make_card(url="http://host:18081")) + app = create_a2a_application(svc) + assert app is not None + + def test_compat_adds_v03_interface(self): + svc = _make_service(_make_card(url="http://host:18081")) + create_a2a_application(svc, enable_v0_3_compat=True) + versions = [ + (i.protocol_binding, i.protocol_version) + for i in svc.agent_card.supported_interfaces + ] + assert ("JSONRPC", "0.3") in versions + + def test_compat_with_missing_url_warns_but_starts(self): + # Compat must not paper over a missing url, but must not block startup. + svc = _make_service(_make_card(url="")) + with patch("trpc_agent_sdk.server.a2a._application.logger.warning") as mock_warn: + app = create_a2a_application(svc, enable_v0_3_compat=True) + assert app is not None + assert mock_warn.call_count == 1 diff --git a/tests/server/a2a/test_remote_a2a_agent.py b/tests/server/a2a/test_remote_a2a_agent.py index 83582bdf8..a032e0a92 100644 --- a/tests/server/a2a/test_remote_a2a_agent.py +++ b/tests/server/a2a/test_remote_a2a_agent.py @@ -10,10 +10,12 @@ import asyncio from unittest.mock import AsyncMock, MagicMock, patch +import httpx import pytest from a2a.types import ( AgentCapabilities, AgentCard, + AgentInterface, Artifact, Message, Part as A2APart, @@ -23,7 +25,6 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) from trpc_agent_sdk.context import InvocationContext @@ -37,11 +38,13 @@ def _make_agent_card(): return AgentCard( name="remote", description="A remote agent", - url="http://remote:8080", version="1.0", capabilities=AgentCapabilities(streaming=True), - defaultInputModes=["text/plain"], - defaultOutputModes=["text/plain"], + default_input_modes=["text/plain"], + default_output_modes=["text/plain"], + supported_interfaces=[ + AgentInterface(protocol_binding="JSONRPC", protocol_version="1.0", url="http://remote:8080"), + ], skills=[], ) @@ -62,6 +65,36 @@ def _make_invocation_context(**overrides): return ctx +def _artifact_event(**overrides): + return TaskArtifactUpdateEvent( + task_id=overrides.get("task_id", "t1"), + context_id=overrides.get("context_id", "ctx1"), + artifact=overrides.get( + "artifact", + Artifact(artifact_id="a1", parts=[A2APart(text="result")]), + ), + last_chunk=overrides.get("last_chunk", False), + metadata=overrides.get("metadata"), + ) + + +def _status_event(state: TaskState, **overrides): + return TaskStatusUpdateEvent( + task_id=overrides.get("task_id", "t1"), + context_id=overrides.get("context_id", "ctx1"), + status=overrides.get( + "status", + TaskStatus( + state=state, + message=overrides.get( + "message", + Message(message_id="m1", role=Role.ROLE_AGENT, parts=[A2APart(text="msg")]), + ), + ), + ), + ) + + # --------------------------------------------------------------------------- # __init__ # --------------------------------------------------------------------------- @@ -108,6 +141,34 @@ async def test_already_initialized(self): result = await agent.initialize() assert result is True + async def test_injected_client_skips_card_and_httpx(self): + client = MagicMock() + agent = TrpcRemoteA2aAgent(name="remote", a2a_client=client) + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.create_client") as MockCreateClient, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.httpx.AsyncClient") as MockHttpx: + result = await agent.initialize() + assert result is True + assert agent._initialized is True + assert agent._a2a_client is client + assert agent._agent_card is None + assert agent._httpx_client is None + MockResolver.assert_not_called() + MockCreateClient.assert_not_called() + MockHttpx.assert_not_called() + + async def test_compat_injected_client_does_not_require_url(self): + client = MagicMock() + agent = TrpcRemoteA2aAgent( + name="remote", + a2a_client=client, + enable_v0_3_compat=True, + ) + result = await agent.initialize() + assert result is True + assert agent._a2a_client is client + assert agent._httpx_client is None + async def test_with_agent_card_creates_client(self): card = _make_agent_card() agent = TrpcRemoteA2aAgent(name="remote", agent_card=card, agent_base_url="http://x") @@ -119,19 +180,25 @@ async def test_with_agent_card_creates_client(self): await agent._httpx_client.aclose() async def test_without_card_resolves(self): + # No card provided -> discover via A2ACardResolver, then build the + # client from the discovered card. mock_card = _make_agent_card() - with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver: + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.create_client", new_callable=AsyncMock) as MockCreateClient: MockResolver.return_value.get_agent_card = AsyncMock(return_value=mock_card) + MockCreateClient.return_value = MagicMock() agent = TrpcRemoteA2aAgent(name="remote", agent_base_url="http://remote:8080") result = await agent.initialize() assert result is True + MockCreateClient.assert_awaited_once() + assert MockCreateClient.call_args.args[0] is mock_card assert agent._agent_card is mock_card if agent._httpx_client: await agent._httpx_client.aclose() async def test_failure_returns_false(self): - with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver: - MockResolver.return_value.get_agent_card = AsyncMock(side_effect=Exception("connection failed")) + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.create_client", + side_effect=Exception("connection failed")): agent = TrpcRemoteA2aAgent(name="remote", agent_base_url="http://bad:1234") result = await agent.initialize() assert result is False @@ -155,6 +222,204 @@ async def test_without_base_url_and_card_raises(self): result = await agent.initialize() assert result is False + async def test_explicit_v03_uses_create_client_auto_negotiation(self): + # enable_v0_3_compat=True prefers a2a-sdk's automatic negotiation: + # when a card resolves, create_client() selects the transport by + # protocol_version (JsonRpcTransport for 1.0, CompatJsonRpcTransport + # for v0.3). Assert it goes through create_client with the resolved card. + mock_card = _make_agent_card() + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.TrpcRemoteA2aAgent._resolve_legacy_card", + new=AsyncMock(return_value=mock_card)) as MockResolve, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.create_client", new_callable=AsyncMock) as MockCreateClient: + MockCreateClient.return_value = MagicMock() + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://127.0.0.1:18081", + enable_v0_3_compat=True, + ) + result = await agent.initialize() + assert result is True + MockResolve.assert_awaited_once() + MockCreateClient.assert_awaited_once() + assert MockCreateClient.call_args.args[0] is mock_card + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_explicit_v03_without_base_url_raises(self): + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://x", + enable_v0_3_compat=True, + ) + agent.agent_base_url = None + result = await agent.initialize() + assert result is False + assert agent._a2a_client is None + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_explicit_v03_empty_interfaces_skips_negotiation(self): + # A pure v0.3 server's card has no supportedInterfaces, so create_client() + # would fail to negotiate; compat must short-circuit straight to the + # CompatJsonRpcTransport wire without calling create_client. + empty_card = _make_agent_card() + empty_card.ClearField("supported_interfaces") + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://127.0.0.1:18081", + enable_v0_3_compat=True, + ) + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.TrpcRemoteA2aAgent._resolve_legacy_card", + new=AsyncMock(return_value=empty_card)) as MockResolve, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.create_client", new_callable=AsyncMock) as MockCreateClient: + result = await agent.initialize() + assert result is True + MockResolve.assert_awaited_once() + MockCreateClient.assert_not_awaited() + assert type(agent._a2a_client._transport).__name__ == "CompatJsonRpcTransport" + assert agent._a2a_client._transport.url == "http://127.0.0.1:18081" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_explicit_v03_empty_interface_url_skips_negotiation(self): + # A pure v0.3 server's card has an interface whose url is "" (the 0.3 + # layout leaves the address to the client). create_client would build a + # transport with an empty url and fail at request time, so compat must + # fall back to the CompatJsonRpcTransport wire posting to agent_base_url. + card = _make_agent_card() + card.supported_interfaces[0].url = "" + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://127.0.0.1:18081", + enable_v0_3_compat=True, + ) + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.TrpcRemoteA2aAgent._resolve_legacy_card", + new=AsyncMock(return_value=card)) as MockResolve, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.create_client", new_callable=AsyncMock) as MockCreateClient: + result = await agent.initialize() + assert result is True + MockResolve.assert_awaited_once() + MockCreateClient.assert_not_awaited() + assert type(agent._a2a_client._transport).__name__ == "CompatJsonRpcTransport" + assert agent._a2a_client._transport.url == "http://127.0.0.1:18081" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_explicit_v03_no_card_falls_back_to_compat(self): + # When the card cannot be resolved (None), there is nothing to + # negotiate; compat falls back to the CompatJsonRpcTransport wire. + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://127.0.0.1:18081", + enable_v0_3_compat=True, + ) + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.TrpcRemoteA2aAgent._resolve_legacy_card", + new=AsyncMock(return_value=None)) as MockResolve, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.create_client", new_callable=AsyncMock) as MockCreateClient: + result = await agent.initialize() + assert result is True + MockResolve.assert_awaited_once() + MockCreateClient.assert_not_awaited() + assert type(agent._a2a_client._transport).__name__ == "CompatJsonRpcTransport" + assert agent._a2a_client._transport.url == "http://127.0.0.1:18081" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_default_version_still_uses_create_client(self): + mock_card = _make_agent_card() + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.create_client", new_callable=AsyncMock) as MockCreateClient: + MockResolver.return_value.get_agent_card = AsyncMock(return_value=mock_card) + MockCreateClient.return_value = MagicMock() + agent = TrpcRemoteA2aAgent(name="remote", agent_base_url="http://remote:8080") + result = await agent.initialize() + assert result is True + MockCreateClient.assert_awaited_once() + assert MockCreateClient.call_args.args[0] is mock_card + if agent._httpx_client: + await agent._httpx_client.aclose() + + +# --------------------------------------------------------------------------- +# _resolve_legacy_card +# --------------------------------------------------------------------------- +class TestResolveLegacyCard: + def _agent(self, url="http://127.0.0.1:18081"): + agent = TrpcRemoteA2aAgent(name="remote", agent_base_url=url, enable_v0_3_compat=True) + agent._httpx_client = AsyncMock() + return agent + + async def test_parses_v03_card_and_converts_to_v10(self): + # A pure v0.3 server serves a top-level-url card; it must be parsed as a + # 0.3 card and converted to the 1.x protobuf form (url/capabilities kept). + agent = self._agent() + agent._httpx_client.get.return_value = AsyncMock( + status_code=200, + json=lambda: { + "name": "weather", + "description": "Weather agent", + "version": "0.0.1", + "url": "http://127.0.0.1:18081", + "preferredTransport": "JSONRPC", + "capabilities": {"streaming": True}, + "defaultInputModes": ["text/plain"], + "defaultOutputModes": ["text/plain"], + "skills": [], + }, + raise_for_status=lambda: None, + ) + card = await agent._resolve_legacy_card() + assert card is not None + assert card.name == "weather" + assert card.description == "Weather agent" + assert card.capabilities.streaming is True + interfaces = [ + (i.protocol_binding, i.protocol_version, i.url) + for i in card.supported_interfaces + ] + assert interfaces == [("JSONRPC", "0.3.0", "http://127.0.0.1:18081")] + + async def test_falls_back_to_v10_resolver_when_card_is_v10_layout(self): + # A 1.x server running with compat serves a 1.x-layout card; the 0.3 + # parse fails and we fall back to the 1.x resolver. + agent = self._agent() + # Raw body fails 0.3 validation, so the v0.3 branch is skipped. + agent._httpx_client.get.return_value = AsyncMock( + status_code=200, + json=lambda: {"name": "x"}, # not a valid 0.3 card + raise_for_status=lambda: None, + ) + v10_card = _make_agent_card() + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver: + MockResolver.return_value.get_agent_card = AsyncMock(return_value=v10_card) + card = await agent._resolve_legacy_card() + assert card is v10_card + + async def test_http_failure_returns_none(self): + # The v0.3 wire can still work without a card; a failed fetch must not + # propagate. + agent = self._agent() + agent._httpx_client.get.side_effect = httpx.ConnectError("boom") + card = await agent._resolve_legacy_card() + assert card is None + + async def test_unparseable_card_warns_and_returns_none(self): + # The card was fetched but is neither 0.3 nor 1.0 layout: warn (it can + # mask a compatibility issue) but still continue without a card. + agent = self._agent() + agent._httpx_client.get.return_value = AsyncMock( + status_code=200, + json=lambda: {"unexpected": "shape"}, + raise_for_status=lambda: None, + ) + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.logger.warning") as MockWarn, \ + patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver: + MockResolver.return_value.get_agent_card = AsyncMock(side_effect=ValueError("bad")) + card = await agent._resolve_legacy_card() + assert card is None + MockWarn.assert_called_once() + assert "could not be parsed" in MockWarn.call_args.args[0] + # --------------------------------------------------------------------------- # _build_outgoing_message @@ -198,29 +463,20 @@ def test_no_user_event_returns_none(self): # --------------------------------------------------------------------------- class TestBuildMessageFromArtifactEvent: def test_with_artifact(self): - event = TaskArtifactUpdateEvent( - task_id="t1", - context_id="ctx1", - artifact=Artifact( - artifact_id="a1", - parts=[A2APart(root=TextPart(text="result"))], - ), - last_chunk=False, - ) + event = _artifact_event() agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) msg = agent._build_message_from_artifact_event(event) - assert msg.role == Role.agent + assert msg.role == Role.ROLE_AGENT assert len(msg.parts) == 1 def test_without_artifact(self): - from pydantic import ValidationError - event = MagicMock() event.artifact = None delattr(event, "artifact") agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) - with pytest.raises(ValidationError): - agent._build_message_from_artifact_event(event) + msg = agent._build_message_from_artifact_event(event) + assert msg.role == Role.ROLE_AGENT + assert len(msg.parts) == 0 # --------------------------------------------------------------------------- @@ -290,6 +546,39 @@ def test_unknown_value(self): assert agent._resolve_partial({"partial": 42}) is True +# --------------------------------------------------------------------------- +# _response_payload +# --------------------------------------------------------------------------- +class TestResponsePayload: + def test_task_payload(self): + task = Task(id="t1", context_id="ctx1", status=TaskStatus(state=TaskState.TASK_STATE_WORKING)) + from a2a.types import StreamResponse + resp = StreamResponse(task=task) + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + assert agent._response_payload(resp) == task + + def test_message_payload(self): + from a2a.types import StreamResponse + msg = Message(message_id="m1", role=Role.ROLE_AGENT, parts=[A2APart(text="hi")]) + resp = StreamResponse(message=msg) + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + assert agent._response_payload(resp) == msg + + def test_status_update_payload(self): + from a2a.types import StreamResponse + status = _status_event(TaskState.TASK_STATE_WORKING) + resp = StreamResponse(status_update=status) + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + assert agent._response_payload(resp) == status + + def test_artifact_update_payload(self): + from a2a.types import StreamResponse + artifact = _artifact_event() + resp = StreamResponse(artifact_update=artifact) + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + assert agent._response_payload(resp) == artifact + + # --------------------------------------------------------------------------- # _events_from_response # --------------------------------------------------------------------------- @@ -300,24 +589,13 @@ def _make_agent(self): def test_artifact_event_with_parts(self): agent = self._make_agent() ctx = _make_invocation_context() - artifact_event = TaskArtifactUpdateEvent( - task_id="t1", - context_id="ctx1", - artifact=Artifact( - artifact_id="a1", - parts=[A2APart(root=TextPart(text="result"))], - ), - last_chunk=False, - ) - events = agent._events_from_response(artifact_event, 1, ctx) + events = agent._events_from_response(_artifact_event(), 1, ctx) assert len(events) == 1 def test_artifact_event_empty_last_chunk_skipped(self): agent = self._make_agent() ctx = _make_invocation_context() - artifact_event = TaskArtifactUpdateEvent( - task_id="t1", - context_id="ctx1", + artifact_event = _artifact_event( artifact=Artifact(artifact_id="a1", parts=[]), last_chunk=True, ) @@ -327,18 +605,9 @@ def test_artifact_event_empty_last_chunk_skipped(self): def test_status_event_with_agent_message(self): agent = self._make_agent() ctx = _make_invocation_context() - status_event = TaskStatusUpdateEvent( - task_id="t1", - context_id="ctx1", - final=False, - status=TaskStatus( - state=TaskState.input_required, - message=Message( - message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="need input"))], - ), - ), + status_event = _status_event( + TaskState.TASK_STATE_INPUT_REQUIRED, + message=Message(message_id="m1", role=Role.ROLE_AGENT, parts=[A2APart(text="need input")]), ) events = agent._events_from_response(status_event, 1, ctx) assert len(events) == 1 @@ -346,18 +615,9 @@ def test_status_event_with_agent_message(self): def test_status_event_user_message_skipped(self): agent = self._make_agent() ctx = _make_invocation_context() - status_event = TaskStatusUpdateEvent( - task_id="t1", - context_id="ctx1", - final=False, - status=TaskStatus( - state=TaskState.working, - message=Message( - message_id="m1", - role=Role.user, - parts=[A2APart(root=TextPart(text="user msg"))], - ), - ), + status_event = _status_event( + TaskState.TASK_STATE_WORKING, + message=Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="user msg")]), ) events = agent._events_from_response(status_event, 1, ctx) assert len(events) == 0 @@ -365,11 +625,9 @@ def test_status_event_user_message_skipped(self): def test_status_event_no_message_skipped(self): agent = self._make_agent() ctx = _make_invocation_context() - status_event = TaskStatusUpdateEvent( - task_id="t1", - context_id="ctx1", - final=False, - status=TaskStatus(state=TaskState.working), + status_event = _status_event( + TaskState.TASK_STATE_WORKING, + message=None, ) events = agent._events_from_response(status_event, 1, ctx) assert len(events) == 0 @@ -377,18 +635,9 @@ def test_status_event_no_message_skipped(self): def test_status_working_state_skipped(self): agent = self._make_agent() ctx = _make_invocation_context() - status_event = TaskStatusUpdateEvent( - task_id="t1", - context_id="ctx1", - final=False, - status=TaskStatus( - state=TaskState.working, - message=Message( - message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="working"))], - ), - ), + status_event = _status_event( + TaskState.TASK_STATE_WORKING, + message=Message(message_id="m1", role=Role.ROLE_AGENT, parts=[A2APart(text="working")]), ) events = agent._events_from_response(status_event, 1, ctx) assert len(events) == 0 @@ -400,12 +649,8 @@ def test_task_result(self): id="t1", context_id="ctx1", status=TaskStatus( - state=TaskState.completed, - message=Message( - message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="done"))], - ), + state=TaskState.TASK_STATE_COMPLETED, + message=Message(message_id="m1", role=Role.ROLE_AGENT, parts=[A2APart(text="done")]), ), ) events = agent._events_from_response(task, 1, ctx) @@ -414,11 +659,7 @@ def test_task_result(self): def test_message_result(self): agent = self._make_agent() ctx = _make_invocation_context() - msg = Message( - message_id="m1", - role=Role.agent, - parts=[A2APart(root=TextPart(text="hello"))], - ) + msg = Message(message_id="m1", role=Role.ROLE_AGENT, parts=[A2APart(text="hello")]) events = agent._events_from_response(msg, 1, ctx) assert len(events) == 1 @@ -432,16 +673,7 @@ def test_unknown_result(self): def test_artifact_with_streaming_tool_call_metadata(self): agent = self._make_agent() ctx = _make_invocation_context() - artifact_event = TaskArtifactUpdateEvent( - task_id="t1", - context_id="ctx1", - artifact=Artifact( - artifact_id="a1", - parts=[A2APart(root=TextPart(text="result"))], - ), - last_chunk=False, - metadata={"streaming_tool_call": "true"}, - ) + artifact_event = _artifact_event(metadata={"streaming_tool_call": "true"}) events = agent._events_from_response(artifact_event, 1, ctx) assert len(events) == 1 assert events[0].partial is True diff --git a/trpc_agent_sdk/server/a2a/README.md b/trpc_agent_sdk/server/a2a/README.md index 2018de59b..7255e883b 100644 --- a/trpc_agent_sdk/server/a2a/README.md +++ b/trpc_agent_sdk/server/a2a/README.md @@ -18,9 +18,9 @@ flowchart LR U[User / Caller] C[TrpcRemoteA2aAgent\n客户端适配层] - A2AC[A2AClient] + A2AC[A2A Client\ncreate_client / BaseClient] HTTP[HTTP + A2A Protocol] - A2AS[A2AStarletteApplication\n+ DefaultRequestHandler] + A2AS[create_a2a_application\n+ DefaultRequestHandler] SVC[TrpcA2aAgentService] EXE[TrpcA2aAgentExecutor] RUN[Runner] @@ -72,14 +72,13 @@ def bootstrap_a2a_service(base_agent): ) svc.initialize() # 构建 AgentCard,开启 streaming capability - # 3) 交给 A2A SDK 的 HTTP App - app = A2AStarletteApplication( - agent_card=svc.agent_card, - http_handler=DefaultRequestHandler(agent_executor=svc), - ) + # 3) 交给 SDK 的 1.x 路由装配封装 + app = create_a2a_application(svc) return app ``` +> `create_a2a_application()` 是**可选便利层**——它打包了 a2a-sdk 1.x 的路由装配(卡片 url、0.3 兼容接口等默认处理)。需要深度定制 Starlette 时,可直接绕过它、用 a2a-sdk 的公开组件(`DefaultRequestHandler` / `create_agent_card_routes` / `create_jsonrpc_routes`)自己拼。 + ### 3.2 请求执行路径(A2A -> Runner -> A2A) 对应核心文件: @@ -92,7 +91,8 @@ def bootstrap_a2a_service(base_agent): async def execute(context, event_queue): ensure context.message exists if first request: - enqueue submitted status + # a2a-sdk 1.x 强制"先 Task 后 update":首个事件必须是 Task + enqueue Task(id=context.task_id, status=SUBMITTED, history=[user_message]) # A2A RequestContext -> trpc run_args run_args = convert_a2a_request_to_trpc_agent_run_args(context) @@ -129,24 +129,24 @@ async def execute(context, event_queue): ```python async def remote_agent_run(invocation_ctx): ensure initialized: - discover AgentCard (if needed) - create A2AClient + discover AgentCard (if needed) and create client via create_client(...) outgoing_msg = convert local content/event to A2A Message outgoing_msg.context_id = session_id outgoing_msg.metadata = build_request_message_metadata(invocation_ctx) - streaming_req = SendStreamingMessageRequest(message=outgoing_msg, metadata=run_config.metadata) - stream = a2a_client.send_message_streaming(streaming_req) + # a2a-sdk 1.x:SendMessageRequest(tenant, message, ...),返回 StreamResponse(oneof) + req = SendMessageRequest(message=outgoing_msg, metadata=run_config.metadata) + stream = a2a_client.send_message(req) async for response in stream_with_cancel_check(stream, invocation_ctx.cancel_event): - result = response.result + result = response_payload(response) # HasField 选择 task/message/status_update/artifact_update # TaskArtifactUpdateEvent / TaskStatusUpdateEvent / Task / Message for event in _events_from_response(result): yield convert_to_local_Event(event) if cancelled and task_id known: - call a2a_client.cancel_task(task_id) + call a2a_client.cancel_task(CancelTaskRequest(id=task_id)) ``` ## 4. 关键设计点 @@ -156,6 +156,50 @@ async def remote_agent_run(invocation_ctx): - **取消语义打通**:本地 cancel event 与远端 `cancel_task` 同步。 - **可插拔扩展**:`TrpcA2aAgentExecutorConfig` 支持 `user_id_extractor`、`event_callback`。 +### 4.1 AgentCard 的对外 URL 配置 + +服务端**不知道自己的对外地址**,AgentCard 里 `supported_interfaces[].url`(以及 v0.3 兼容的顶层 `url`)必须由部署方指定。url 只有**一个配置入口**:`TrpcA2aAgentService(rpc_url=...)`(或完全自定义的 `agent_card`)。 + +```python +# 方式 1:固定域名(推荐,有反代/域名时) +svc = TrpcA2aAgentService( + service_name="weather", + agent=root_agent, + rpc_url="https://agent.example.com/a2a", # 直接写进 AgentCard +) + +# 方式 2:完全自定义卡片 +from a2a.types import AgentCard, AgentInterface +card = AgentCard( + name="weather", description="...", version="1.0", + supported_interfaces=[AgentInterface( + protocol_binding="JSONRPC", protocol_version="1.0", + url="https://agent.example.com/a2a", + )], +) +svc = TrpcA2aAgentService(service_name="weather", agent=root_agent, agent_card=card) + +# 方式 3:本地/无固定域名,直接把监听地址当 rpc_url +svc = TrpcA2aAgentService( + service_name="weather", + agent=root_agent, + rpc_url="http://127.0.0.1:18081", +) +``` + +**规则**:`create_a2a_application()` 装配时不因卡片 url 空而阻断启动——JSON-RPC 直连的客户端不读卡片。但若所有接口的 url 都为空(没配 `rpc_url` 也没自定义 `agent_card`),会打出一条 **warning** 提示配置缺失,因为依赖卡片发现的客户端会连不上。 + +**挂载路径自动推导**:`create_a2a_application()` **不接收挂载路径参数**——JSON-RPC 路由挂到哪由卡片 url 的 path 推导(`https://x.com/a2a` → `/a2a`,无路径则 `/`),保证"卡片声明的路径"与"实际挂载路径"永远一致,客户端不会发现 A 调 B。 + +开启 `enable_v0_3_compat=True` 时,框架自动追加一个 `protocol_version="0.3"` 的接口(复用已有的 url),这样 a2a-sdk 才能为 0.3 客户端生成带顶层 `url` 的兼容卡片。若只声明 1.0 接口,0.3 客户端会发现卡片缺顶层 `url` 而校验失败。 + +```python +svc = TrpcA2aAgentService(..., rpc_url="http://127.0.0.1:18081") +app = create_a2a_application(svc, enable_v0_3_compat=True) +``` + +> 完整运行示例见 [examples/a2a](../../../examples/a2a/README.md)。 + ## 5. 与 `examples/a2a` 的对应关系 示例目录(可直接运行): @@ -167,6 +211,6 @@ async def remote_agent_run(invocation_ctx): 运行映射: -1. `run_server.py` 创建 `TrpcA2aAgentService` 并挂到 `A2AStarletteApplication`。 +1. `run_server.py` 创建 `TrpcA2aAgentService` 并通过 `create_a2a_application()` 挂载为 A2A 服务。 2. `test_a2a.py` 创建 `TrpcRemoteA2aAgent`,通过 `Runner` 发起 3 轮对话。 3. 第 2 轮触发 `get_weather_report` 工具调用,展示工具事件与文本分片的 A2A 流式传输。 diff --git a/trpc_agent_sdk/server/a2a/__init__.py b/trpc_agent_sdk/server/a2a/__init__.py index d580189af..c98b6730c 100644 --- a/trpc_agent_sdk/server/a2a/__init__.py +++ b/trpc_agent_sdk/server/a2a/__init__.py @@ -6,6 +6,7 @@ from ._agent_card_builder import AgentCardBuilder from ._agent_service import TrpcA2aAgentService +from ._application import create_a2a_application from ._remote_a2a_agent import TrpcRemoteA2aAgent from ._utils import get_metadata from ._utils import metadata_is_true @@ -17,6 +18,7 @@ "AgentCardBuilder", "TrpcA2aAgentService", "TrpcRemoteA2aAgent", + "create_a2a_application", "get_metadata", "metadata_is_true", "set_metadata", diff --git a/trpc_agent_sdk/server/a2a/_agent_card_builder.py b/trpc_agent_sdk/server/a2a/_agent_card_builder.py index 99eaa783a..92e38c753 100644 --- a/trpc_agent_sdk/server/a2a/_agent_card_builder.py +++ b/trpc_agent_sdk/server/a2a/_agent_card_builder.py @@ -30,6 +30,7 @@ from a2a.types import AgentCapabilities from a2a.types import AgentCard from a2a.types import AgentExtension +from a2a.types import AgentInterface from a2a.types import AgentProvider from a2a.types import AgentSkill from a2a.types import SecurityScheme @@ -70,7 +71,9 @@ def __init__( raise ValueError('Agent cannot be None or empty.') self._agent = agent - # keep it empty, trpc-a2a server will replace it with yaml config + # Kept empty by default; the deployer supplies the public endpoint via + # ``rpc_url`` (TrpcA2aAgentService). ``create_a2a_application`` warns if + # the card is assembled without any url. self._rpc_url = rpc_url or '' self._capabilities = capabilities or AgentCapabilities() self._doc_url = doc_url @@ -91,14 +94,19 @@ async def build(self) -> AgentCard: return AgentCard( name=self._agent.name, description=self._agent.description or 'An A2A Agent', - doc_url=self._doc_url, - url=f"{self._rpc_url.rstrip('/')}", version=self._agent_version, + documentation_url=self._doc_url, capabilities=capabilities, skills=all_skills, default_input_modes=['text/plain'], default_output_modes=['text/plain'], - supports_authenticated_extended_card=False, + supported_interfaces=[ + AgentInterface( + protocol_binding='JSONRPC', + protocol_version='1.0', + url=self._rpc_url.rstrip('/'), + ), + ], provider=self._provider, security_schemes=self._security_schemes, ) @@ -109,13 +117,13 @@ async def build(self) -> AgentCard: def _capabilities_with_trpc_extension(capabilities: Optional[AgentCapabilities]) -> AgentCapabilities: """Ensure capabilities includes the trpc-a2a-version extension.""" base = capabilities or AgentCapabilities() - exts = list(base.extensions) if base.extensions else [] - if not any(getattr(e, "uri", None) == EXTENSION_TRPC_A2A_VERSION for e in exts): - exts.append(AgentExtension( - uri=EXTENSION_TRPC_A2A_VERSION, - params={"version": INTERACTION_SPEC_VERSION}, - )) - return base.model_copy(update={"extensions": exts}) + if not any(getattr(e, "uri", None) == EXTENSION_TRPC_A2A_VERSION for e in base.extensions): + base.extensions.append( + AgentExtension( + uri=EXTENSION_TRPC_A2A_VERSION, + params={"version": INTERACTION_SPEC_VERSION}, + )) + return base # Module-level helper functions @@ -177,10 +185,10 @@ async def _build_sub_agent_skills(agent: BaseAgent) -> List[AgentSkill]: id=f'{sub_agent.name}_{skill.id}', name=f'{sub_agent.name}: {skill.name}', description=skill.description, - examples=skill.examples, - input_modes=skill.input_modes, - output_modes=skill.output_modes, - tags=[f'sub_agent:{sub_agent.name}'] + (skill.tags or []), + examples=list(skill.examples), + input_modes=list(skill.input_modes), + output_modes=list(skill.output_modes), + tags=[f'sub_agent:{sub_agent.name}'] + list(skill.tags or []), ) sub_agent_skills.append(aggregated_skill) except Exception as ex: # pylint: disable=broad-except diff --git a/trpc_agent_sdk/server/a2a/_agent_service.py b/trpc_agent_sdk/server/a2a/_agent_service.py index c7e9df543..6a5868906 100644 --- a/trpc_agent_sdk/server/a2a/_agent_service.py +++ b/trpc_agent_sdk/server/a2a/_agent_service.py @@ -23,7 +23,7 @@ This service provides a bridge between trpc-agent and the A2A protocol, allowing users to easily deploy trpc-agent as an A2A service. It extends ``AgentExecutor`` -from the A2A SDK so it can be used directly with ``A2AStarletteApplication`` or +from the A2A SDK so it can be used directly with ``create_a2a_application`` or any other A2A-compatible server. """ @@ -55,7 +55,7 @@ class TrpcA2aAgentService(AgentExecutor): This service provides a bridge between trpc-agent and the A2A protocol using unprefixed metadata keys and artifact-first streaming. It extends ``AgentExecutor`` - from the A2A SDK so it can be used directly with ``A2AStarletteApplication``. + from the A2A SDK so it can be used directly with ``create_a2a_application``. Attributes: agent: The trpc-agent BaseAgent to use (required). @@ -70,6 +70,7 @@ def __init__( agent: BaseAgent, app_name: Optional[str] = None, agent_card: Optional[AgentCard] = None, + rpc_url: Optional[str] = None, session_service: Optional[BaseSessionService] = None, memory_service: Optional[BaseMemoryService] = None, executor_config: Optional[TrpcA2aAgentExecutorConfig] = None, @@ -79,6 +80,7 @@ def __init__( self._agent_card = agent_card self._service_name = service_name self._app_name = app_name + self._rpc_url = rpc_url self._session_service = session_service self._memory_service = memory_service self._executor_config = executor_config @@ -105,7 +107,7 @@ async def _initialize(self) -> None: self._session_service = InMemorySessionService() if self._agent_card is None: - builder = AgentCardBuilder(agent=self._agent) + builder = AgentCardBuilder(agent=self._agent, rpc_url=self._rpc_url) self._agent_card = await builder.build() self._agent_card.capabilities.streaming = True diff --git a/trpc_agent_sdk/server/a2a/_application.py b/trpc_agent_sdk/server/a2a/_application.py new file mode 100644 index 000000000..bf0a254dc --- /dev/null +++ b/trpc_agent_sdk/server/a2a/_application.py @@ -0,0 +1,181 @@ +# Tencent is pleased to support the open source community by making tRPC-Agent-Python available. +# +# Copyright (C) 2026 Tencent. All rights reserved. +# +# tRPC-Agent-Python is licensed under Apache-2.0. +# +# Below code are copy and modified from https://github.com/google/adk-python.git +# +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Server application assembly for trpc-agent as an A2A service. + +This module wraps the a2a-sdk 1.x route factories +(``create_agent_card_routes`` / ``create_jsonrpc_routes``) so that business +code and examples never need to import ``a2a.server.*`` directly. It also +exposes the ``enable_v0_3_compat`` switch for accepting legacy 0.3 clients. + +``create_a2a_application`` is an *optional convenience layer*, not the only +path: every a2a-sdk component it uses is a public API, so callers who need full +control over the assembled ``Starlette`` app may bypass it and compose the route +factories themselves (see ``trpc_agent_sdk/server/a2a/README.md`` §3.1b). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes +from a2a.server.routes import create_jsonrpc_routes +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import AgentInterface + +from trpc_agent_sdk.log import logger + +if TYPE_CHECKING: + from starlette.applications import Starlette + + from ._agent_service import TrpcA2aAgentService + + +def create_a2a_application( + a2a_svc: "TrpcA2aAgentService", + *, + enable_v0_3_compat: bool = False, + request_handler: "DefaultRequestHandler | None" = None, +) -> "Starlette": + """Assemble a Starlette app that serves a trpc-agent as an A2A agent. + + Args: + a2a_svc: The initialized :class:`TrpcA2aAgentService` to serve. Its + agent card may leave ``supported_interfaces[].url`` empty (built + without ``TrpcA2aAgentService(rpc_url=...)``); this is logged as a + warning so discovery-based clients fail loudly, while JSON-RPC calls + that do not use the card are unaffected. + enable_v0_3_compat: Whether to accept legacy v0.3 clients on the same + endpoint (see the a2a-sdk migration guide). + request_handler: Optional fully-customized A2A request handler (advanced + usage). When provided it is used as-is, giving full control over the + handler's configuration (task store, push notifications, extended + cards, ...). Defaults to a ``DefaultRequestHandler`` built from + ``a2a_svc`` with an in-memory task store. + + Returns: + A Starlette application wired with the agent-card and JSON-RPC routes. + """ + from starlette.applications import Starlette + + request_handler = request_handler or DefaultRequestHandler( + agent_executor=a2a_svc, + task_store=InMemoryTaskStore(), + agent_card=a2a_svc.agent_card, + ) + if a2a_svc.agent_card is not None: + _ensure_card_has_url(a2a_svc.agent_card) + if enable_v0_3_compat: + _ensure_v0_3_interface(a2a_svc.agent_card) + routes: list[Any] = [] + routes.extend(create_agent_card_routes(a2a_svc.agent_card)) + routes.extend( + create_jsonrpc_routes( + request_handler, + rpc_url=_jsonrpc_path_from_card(a2a_svc.agent_card), + enable_v0_3_compat=enable_v0_3_compat, + )) + return Starlette(routes=routes) + + +def _jsonrpc_path_from_card(card: Any) -> str: + """Derive the JSON-RPC mount path from the card's advertised url. + + The path where the JSON-RPC endpoint is mounted must match the url advertised + in ``supported_interfaces[].url``, otherwise clients discover one path and + call another. Rather than letting the caller configure a second path that + has to be kept in sync with the card, derive it from the card itself so the + two can never diverge: take the path component of the first advertised + JSONRPC/HTTP+JSON url (defaulting to ``/`` for a bare origin). A card built + by the framework has a single interface, so "first" is the one 1.x clients + discover; multi-endpoint cards are outside this convenience layer's scope. + + Args: + card: The agent card to derive the path from (a2a-sdk protobuf message). + + Returns: + The mount path (e.g. ``/`` or ``/a2a``), starting with ``/``. + """ + advertised = next( + (i.url for i in card.supported_interfaces if i.protocol_binding in ("JSONRPC", "HTTP+JSON") and i.url), + None, + ) + if advertised is None: + return "/" + from urllib.parse import urlparse + + path = urlparse(advertised).path + return path if path.startswith("/") else "/" + + +def _ensure_card_has_url(card: Any) -> None: + """Warn if the card advertises no reachable JSON-RPC url. + + a2a-sdk 1.x clients that rely on card discovery read the reachable endpoint + from ``supported_interfaces[].url``; an empty one breaks discovery + (``no compatible transports found``). The card built by + :class:`AgentCardBuilder` leaves the url empty because the server does not + know its own public address -- the deployer should supply it via + ``TrpcA2aAgentService(rpc_url=...)`` or a custom ``agent_card``. However, + JSON-RPC clients that call the endpoint directly never read the card, so a + missing url must not prevent the server from starting: warn instead of + raising. + + Args: + card: The agent card to inspect (a2a-sdk protobuf message). + """ + if not any(i.url for i in card.supported_interfaces): + logger.warning("Agent card advertises no reachable url; discovery-based clients " + "won't be able to call it. Configure TrpcA2aAgentService(" + "rpc_url='http://host:port') or pass a custom agent_card whose " + "interfaces carry a url.") + + +def _ensure_v0_3_interface(card: Any) -> None: + """Advertise a v0.3 JSONRPC interface on the card (in place). + + The a2a-sdk's ``agent_card_to_dict`` only generates the legacy v0.3 card + (with a top-level ``url``) when at least one interface declares + ``protocol_version`` <= ``0.3``; without it, a 0.3 client that validates the + card against the v0.3 pydantic model fails with a missing ``url`` field. + This appends a ``0.3`` interface that reuses an already-advertised url (so + the 0.3 and 1.0 interfaces always point at the same endpoint). + + Args: + card: The agent card to mutate (a2a-sdk protobuf message). + """ + if any(i.protocol_binding == "JSONRPC" and i.protocol_version == "0.3" for i in card.supported_interfaces): + return + # Reuse an already-advertised url so the 0.3 and 1.0 interfaces point at the + # same endpoint when one exists; otherwise the 0.3 interface inherits the + # same missing-url state and the warning from ``_ensure_card_has_url`` + # already covers it. + advertised_url = next( + (i.url for i in card.supported_interfaces if i.url), + "", + ) + card.supported_interfaces.append( + AgentInterface( + protocol_binding="JSONRPC", + protocol_version="0.3", + url=advertised_url, + )) diff --git a/trpc_agent_sdk/server/a2a/_constants.py b/trpc_agent_sdk/server/a2a/_constants.py index 8f3c27939..08e02f8ff 100644 --- a/trpc_agent_sdk/server/a2a/_constants.py +++ b/trpc_agent_sdk/server/a2a/_constants.py @@ -43,18 +43,12 @@ """Constants for function response type.""" A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA = 'streaming_function_call_delta' """Constants for streaming function call delta type.""" +A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL = "streaming_function_call" +"""Constants for streaming function call type.""" A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY = 'is_long_running' """Constants for data part metadata is long running key.""" A2A_DATA_PART_METADATA_TYPE_KEY = 'type' """Constants for data part metadata type key.""" -A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY = 'is_long_running' -"""Constants for A2A data part metadata is long running.""" - -A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL = 'function_call' -"""Constants for A2A data part metadata type.""" - -A2A_DATA_PART_METADATA_TYPE_KEY = 'type' -"""Constants for A2A data part metadata type.""" ARTIFACT_ID_SEPARATOR = "-" """Constants for artifact id separator.""" @@ -62,26 +56,6 @@ DEFAULT_ERROR_MESSAGE = "An error occurred during processing" """Constants for default error message.""" -# Streaming function call type constants -A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL = "streaming_function_call" -"""Constants for streaming function call type.""" - -A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA = "streaming_function_call_delta" -"""Constants for streaming function call delta type.""" - -A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT = 'code_execution_result' -"""Constants for code execution result type.""" - -A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE = 'executable_code' -"""Constants for executable code type.""" - -A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE = 'function_response' -"""Constants for function response type.""" - -ARTIFACT_ID_SEPARATOR = "-" -"""Constants for artifact id separator.""" -DEFAULT_ERROR_MESSAGE = "An error occurred during processing" -"""Constants for default error message.""" INTERACTION_SPEC_VERSION = "0.1" """Constants for interaction spec version.""" MESSAGE_METADATA_INTERACTION_SPEC_VERSION_KEY = "interaction_spec_version" diff --git a/trpc_agent_sdk/server/a2a/_remote_a2a_agent.py b/trpc_agent_sdk/server/a2a/_remote_a2a_agent.py index ac09e69d3..a4d558db0 100644 --- a/trpc_agent_sdk/server/a2a/_remote_a2a_agent.py +++ b/trpc_agent_sdk/server/a2a/_remote_a2a_agent.py @@ -30,7 +30,6 @@ from __future__ import annotations import asyncio -import uuid from typing import Any from typing import AsyncGenerator from typing import List @@ -39,18 +38,21 @@ import httpx from a2a.client import A2ACardResolver -from a2a.client import A2AClient -from a2a.client.middleware import ClientCallContext +from a2a.client import BaseClient +from a2a.client import ClientCallContext +from a2a.client import ClientConfig +from a2a.client import create_client +from a2a.compat.v0_3.conversions import to_core_agent_card +from a2a.compat.v0_3.jsonrpc_transport import CompatJsonRpcTransport +from a2a.compat.v0_3.types import AgentCard as V03AgentCard from a2a.types import AgentCard from a2a.types import CancelTaskRequest from a2a.types import Message -from a2a.types import MessageSendParams from a2a.types import Role -from a2a.types import SendStreamingMessageRequest -from a2a.types import SendStreamingMessageResponse +from a2a.types import SendMessageRequest +from a2a.types import StreamResponse from a2a.types import Task from a2a.types import TaskArtifactUpdateEvent -from a2a.types import TaskIdParams from a2a.types import TaskState from a2a.types import TaskStatusUpdateEvent @@ -94,8 +96,9 @@ def __init__( name: str, description: str = "", agent_card: Optional[AgentCard] = None, - a2a_client: Optional[A2AClient] = None, + a2a_client: Optional[Any] = None, agent_base_url: Optional[str] = None, + enable_v0_3_compat: bool = False, **kwargs: Any, ) -> None: super().__init__(name=name, description=description, **kwargs) @@ -105,14 +108,19 @@ def __init__( raise ValueError("Either agent_card, a2a_client, or agent_base_url must be provided") self.agent_base_url = agent_base_url.strip() if agent_base_url else None + self._enable_v0_3_compat = enable_v0_3_compat self._agent_card: Optional[AgentCard] = agent_card - self._a2a_client: Optional[A2AClient] = a2a_client + self._a2a_client: Optional[Any] = a2a_client self._httpx_client: Optional[httpx.AsyncClient] = None self._initialized = False async def initialize(self) -> bool: """Initialize the client with agent card discovery (if needed). + An injected ``a2a_client`` is used as-is: card discovery and HTTP + client creation are skipped. Otherwise the sequence is HTTP client + -> agent card (discover if missing) -> A2A client. + Returns: bool: True if initialization successful, False otherwise """ @@ -121,39 +129,14 @@ async def initialize(self) -> bool: logger.debug("Initializing Remote A2A agent...") try: - if self._httpx_client is None: - self._httpx_client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=None)) - - self._httpx_client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=None)) - - self._httpx_client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=None)) - # add close method to class( needed define in class definition) - - if self._agent_card is None: - if not self.agent_base_url: - raise ValueError("agent_base_url is required when agent_card is not provided") - - card_resolver = A2ACardResolver( - httpx_client=self._httpx_client, - base_url=self.agent_base_url, - ) - self._agent_card = await card_resolver.get_agent_card() - - logger.debug("Agent Name: %s", self._agent_card.name) - logger.debug("Description: %s", self._agent_card.description) - logger.debug("Agent Card URL: %s", self._agent_card.url) - logger.debug("Capabilities: %s", self._agent_card.capabilities.model_dump_json()) - if self._a2a_client is None: - self._a2a_client = A2AClient( - httpx_client=self._httpx_client, - agent_card=self._agent_card, - url=self._agent_card.url or self.agent_base_url, - ) - - if not self.description and self._agent_card and self._agent_card.description: - self.description = self._agent_card.description + if self._httpx_client is None: + self._httpx_client = httpx.AsyncClient(timeout=httpx.Timeout(timeout=None)) + if self._agent_card is None: + self._agent_card = await self._discover_card() + self._a2a_client = await self._build_a2a_client() + self._apply_card_defaults() self._initialized = True logger.debug("Successfully initialized remote A2A agent: %s", self.name) return True @@ -162,11 +145,143 @@ async def initialize(self) -> bool: logger.error("Failed to initialize remote A2A agent %s: %s", self.name, ex) return False + async def _discover_card(self) -> Optional[AgentCard]: + """Fetch an AgentCard when the caller did not provide one.""" + if not self.agent_base_url: + if self._enable_v0_3_compat: + raise ValueError("agent_base_url is required for enable_v0_3_compat=True") + raise ValueError("agent_base_url is required when agent_card is not provided") + if self._enable_v0_3_compat: + return await self._resolve_legacy_card() + resolver = A2ACardResolver( + httpx_client=self._httpx_client, + base_url=self.agent_base_url, + ) + return await resolver.get_agent_card() + + async def _build_a2a_client(self) -> Any: + """Build the A2A client from the resolved card (or compat transport). + + With ``enable_v0_3_compat``, prefer a2a-sdk's automatic transport + negotiation: ``create_client`` picks ``JsonRpcTransport`` for a 1.0 card + and ``CompatJsonRpcTransport`` for a v0.3 card, so a single client can + talk to both a 1.0 server and a pure v0.3 server. We fall back to the + compat wire when the card is unusable for negotiation: no card, or no + interface with a reachable ``url``. A pure v0.3 server's card often + has a ``url`` of ``""`` (the 0.3 layout leaves it to the client), which + would make ``create_client`` build a transport with an empty url and + fail at request time. + """ + if self._enable_v0_3_compat: + if self._agent_card is None or not any(i.url for i in self._agent_card.supported_interfaces): + # No card, or no interface carries a usable url: create_client + # would negotiate a transport with an empty url. Fall back to + # the compat wire, which posts to agent_base_url directly. + return await self._build_compat_v03_client(self._agent_card) + client_config = ClientConfig(httpx_client=self._httpx_client) + return await create_client(self._agent_card, client_config=client_config) + if self._agent_card is None: + raise ValueError("agent_base_url is required when agent_card is not provided") + client_config = ClientConfig(httpx_client=self._httpx_client) + return await create_client(self._agent_card, client_config=client_config) + + def _apply_card_defaults(self) -> None: + if self._agent_card is None: + return + logger.debug("Agent Name: %s", self._agent_card.name) + logger.debug("Description: %s", self._agent_card.description) + if self._agent_card.supported_interfaces: + logger.debug("Agent Card URL: %s", self._agent_card.supported_interfaces[0].url) + logger.debug("Capabilities: %s", self._agent_card.capabilities) + if not self.description and self._agent_card.description: + self.description = self._agent_card.description + + async def _build_compat_v03_client(self, card: Optional[AgentCard]) -> BaseClient: + """Build a legacy v0.3 client against ``agent_base_url``. + + The transport speaks the v0.2.x JSON-RPC wire (``message/send`` / + ``tasks/cancel``) straight to ``agent_base_url``. ``create_client()`` + is skipped when the card is unusable for negotiation: no card, no + ``supported_interfaces``, or every interface has an empty ``url``. + A converted v0.3 card typically still has one JSONRPC interface whose + ``url`` is ``""`` (the 0.3 layout left the address to the client); + ``create_client()`` would then build a transport with that empty url + and fail at request time. + + The card is resolved beforehand (``_discover_card``); it drives the + *real* advertised capabilities (e.g. ``streaming``) and is ``None`` when + the remote card could not be fetched (the legacy wire still works). + Only the transport selection is overridden. + """ + transport = CompatJsonRpcTransport( + self._httpx_client, + card, # may be None: the legacy wire needs no card + self.agent_base_url, + ) + return BaseClient( + card=card or AgentCard(), + config=ClientConfig(httpx_client=self._httpx_client), + transport=transport, + interceptors=[], + ) + + async def _resolve_legacy_card(self) -> Optional[AgentCard]: + """Fetch the remote AgentCard, preferring the legacy v0.3 layout. + + A pure v0.3 server serves a card in the 0.3 layout (top-level ``url`` / + ``preferred_transport``, no ``supportedInterfaces``), which the 1.x + ``A2ACardResolver`` cannot validate. So we fetch the raw JSON and first + try to parse it as a 0.3 card (``types_v03.AgentCard``), converting it to + the 1.x protobuf form with ``to_core_agent_card``. If that fails, fall + back to the 1.x resolver so a 1.x server running with compat still works. + Returns ``None`` if the card cannot be fetched (the v0.3 transport can + still work without one): a network failure is logged at debug level (the + server may simply not serve a card), while a card that *was* fetched but + parsed as neither 0.3 nor 1.0 is logged as a warning (a compatibility + issue worth surfacing). + + ``agent_base_url`` is guaranteed non-empty by the caller + (``_discover_card`` raises before delegating here). + """ + try: + response = await self._httpx_client.get(f"{self.agent_base_url.rstrip('/')}/.well-known/agent-card.json") + response.raise_for_status() + raw = response.json() + except Exception as ex: # pylint: disable=broad-except + logger.debug("Failed to fetch card from %s: %s", self.agent_base_url, ex) + return None + # The 1.x resolver always serves ``agent-card.json``; the legacy path is + # only ever parsed as 0.3, so fall back to the 1.x layout when the raw + # body does not look like a 0.3 card. + try: + compat_card = V03AgentCard.model_validate(raw) + core_card = to_core_agent_card(compat_card) + logger.debug("Resolved legacy v0.3 card from %s for %s", self.agent_base_url, self.name) + return core_card + except Exception: # pylint: disable=broad-except + pass + try: + resolver = A2ACardResolver( + httpx_client=self._httpx_client, + base_url=self.agent_base_url, + ) + card = await resolver.get_agent_card() + logger.debug("Resolved card from %s for %s", self.agent_base_url, self.name) + return card + except Exception as ex: # pylint: disable=broad-except + # The card was fetched but neither parsed as 0.3 nor 1.0; the server + # is responding but its card layout is incompatible. Warn (vs the + # silent network-failure path above) since this can mask a + # compatibility problem. + logger.warning("Agent card from %s was fetched but could not be parsed as " + "v0.3 or v1.0: %s", self.agent_base_url, ex) + return None + async def _stream_with_cancel_check( self, ctx: InvocationContext, - streaming_generator: AsyncGenerator[SendStreamingMessageResponse, None], - ) -> AsyncGenerator[SendStreamingMessageResponse, None]: + streaming_generator: AsyncGenerator[StreamResponse, None], + ) -> AsyncGenerator[StreamResponse, None]: """Wrap a streaming generator with concurrent cancel checking.""" cancel_event = await ctx.get_cancel_event() stream_iter = streaming_generator.__aiter__() @@ -223,10 +338,10 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, a2a_message.context_id = ctx.session.id request_meta = build_request_message_metadata(ctx) - existing = getattr(a2a_message, "metadata", None) or {} - if isinstance(existing, dict): - request_meta.update(existing) - a2a_message.metadata = request_meta + if a2a_message.metadata: + request_meta.update(a2a_message.metadata) + # In 1.x the message metadata is a protobuf Struct; update() merges keys. + a2a_message.metadata.update(request_meta) metadata = None configuration = None @@ -234,9 +349,11 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, metadata = ctx.run_config.agent_run_config.get("metadata", None) configuration = ctx.run_config.agent_run_config.get("configuration", None) - streaming_request = SendStreamingMessageRequest( - id=str(uuid.uuid4()), - params=MessageSendParams(message=a2a_message, metadata=metadata, configuration=configuration), + streaming_request = SendMessageRequest( + tenant="", + message=a2a_message, + metadata=metadata, + configuration=configuration, ) logger.debug("Sending A2A streaming request: %s", streaming_request) @@ -261,12 +378,11 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, pass if ctx.user_id: out_headers["X-User-ID"] = ctx.user_id - http_kwargs = {"headers": out_headers} if out_headers else {} - call_context = ClientCallContext(state={"http_kwargs": http_kwargs}) + call_context = ClientCallContext(service_parameters=out_headers or None) try: event_count = 0 - streaming_gen = self._a2a_client.send_message_streaming( + streaming_gen = self._a2a_client.send_message( streaming_request, context=call_context, ) @@ -274,9 +390,12 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, async for response in self._stream_with_cancel_check(ctx, streaming_gen): await ctx.raise_if_cancelled() event_count += 1 - result = response.root.result - if task_id is None and hasattr(result, "task_id"): + # In 1.x a StreamResponse is a oneof; the task/message/update is + # selected via HasField(). + result = self._response_payload(response) + + if task_id is None and hasattr(result, "task_id") and result.task_id: task_id = result.task_id logger.debug("Captured task_id for cancellation: %s", task_id) @@ -295,8 +414,8 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, if task_id: try: cancel_request = CancelTaskRequest( - id=str(uuid.uuid4()), - params=TaskIdParams(id=task_id), + tenant="", + id=task_id, ) cancel_response = await self._a2a_client.cancel_task(cancel_request, context=call_context) logger.info("Successfully sent cancel request for session_id: %s", ctx.session.id) @@ -319,7 +438,7 @@ def _build_outgoing_message(self, ctx: InvocationContext) -> Optional[Message]: """Build the outgoing A2A message from ctx.override_messages or session events.""" if ctx.override_messages is not None: logger.debug("Using override_messages for remote A2A agent: %s", self.name) - return convert_content_to_a2a_message(ctx.override_messages, role=Role.user) + return convert_content_to_a2a_message(ctx.override_messages, role=Role.ROLE_USER) user_event = None for event in reversed(ctx.session.events): @@ -330,18 +449,36 @@ def _build_outgoing_message(self, ctx: InvocationContext) -> Optional[Message]: logger.warning("No content to send to remote A2A agent. Emitting empty event.") return None - return convert_event_to_a2a_message(user_event, ctx, role=Role.user) + return convert_event_to_a2a_message(user_event, ctx, role=Role.ROLE_USER) + + def _response_payload(self, response: StreamResponse) -> Any: + """Extract the active payload from a oneof ``StreamResponse``. + + In a2a-sdk 1.x ``StreamResponse`` is a protobuf oneof over + task / message / status_update / artifact_update; ``HasField()`` selects + the active member. + """ + if response.HasField("task"): + return response.task + if response.HasField("message"): + return response.message + if response.HasField("status_update"): + return response.status_update + if response.HasField("artifact_update"): + return response.artifact_update + return response def _build_message_from_artifact_event(self, event: TaskArtifactUpdateEvent) -> Message: artifact = event.artifact if hasattr(event, "artifact") else None if not artifact: - return Message(role=Role.agent, parts=[]) + return Message(role=Role.ROLE_AGENT, parts=[]) msg = Message( - role=Role.agent, + role=Role.ROLE_AGENT, parts=artifact.parts or [], message_id=getattr(artifact, "artifact_id", "") or "", ) - msg.metadata = getattr(event, "metadata", None) + if event.metadata: + msg.metadata.update(event.metadata) return msg def _ensure_non_streaming_for_discrete_events(self, event: Event) -> None: @@ -402,17 +539,18 @@ def _events_from_response(self, result: Any, event_count: int, ctx: InvocationCo elif isinstance(result, TaskStatusUpdateEvent): logger.debug("[Event %s] Status: %s", event_count, result.status.state) - if not result.status.message: + if not result.status.HasField("message"): return events msg = result.status.message - if msg.role == Role.user: + if msg.role == Role.ROLE_USER: return events state = result.status.state - if state not in (TaskState.submitted, TaskState.working, TaskState.completed): + if state not in (TaskState.TASK_STATE_SUBMITTED, TaskState.TASK_STATE_WORKING, + TaskState.TASK_STATE_COMPLETED): partial = self._resolve_partial(result.metadata) ev = convert_a2a_message_to_event(msg, author=self.name, invocation_context=ctx, partial=partial) - if state == TaskState.failed: + if state == TaskState.TASK_STATE_FAILED: error_code = get_metadata(result.metadata, "error_code") or get_metadata(msg.metadata, "error_code") ev.error_code = error_code or "a2a_task_failed" ev.error_message = ev.get_text() or "Remote A2A task failed" diff --git a/trpc_agent_sdk/server/a2a/_utils.py b/trpc_agent_sdk/server/a2a/_utils.py index 0353cd108..18101f381 100644 --- a/trpc_agent_sdk/server/a2a/_utils.py +++ b/trpc_agent_sdk/server/a2a/_utils.py @@ -19,31 +19,58 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Metadata utilities using unprefixed keys.""" +"""Metadata utilities using unprefixed keys. + +In a2a-sdk 1.x metadata fields are ``google.protobuf.Struct`` instances +instead of plain ``dict``. These helpers duck-type both so callers keep +working whether they hold a ``dict`` or a ``Struct``. +""" from __future__ import annotations from typing import Any from typing import Optional +from google.protobuf import struct_pb2 + -def set_metadata(metadata: dict[str, Any], key: str, value: Any) -> None: - """Set a metadata value for the given key.""" +def set_metadata(metadata: Any, key: str, value: Any) -> None: + """Set a metadata value for the given key. + + Works on both plain ``dict`` and ``google.protobuf.Struct``. For a + ``Struct`` the value is converted via ``ParseDict`` (plain dict) or + ``Struct``-compatible assignment (scalars/lists), so structured values + are persisted correctly on the wire. + """ + if isinstance(metadata, struct_pb2.Struct): + # Struct supports mapping-style updates for dict/list/scalar values. + metadata.update({key: value}) + return metadata[key] = value def get_metadata( - metadata: Optional[dict[str, Any]], + metadata: Optional[Any], key: str, default: Any = None, ) -> Any: - """Get a metadata value by key.""" + """Get a metadata value by key. + + Works on both plain ``dict`` and ``google.protobuf.Struct``. For a + ``Struct``, numeric values round-trip through protobuf ``Value`` and may + arrive as ``float``. + """ if not metadata: return default - return metadata.get(key, default) + try: + if key in metadata: + return metadata[key] + except TypeError: # pragma: no cover - defensive + pass + return default -def metadata_is_true(metadata: Optional[dict[str, Any]], key: str) -> bool: +def metadata_is_true(metadata: Optional[Any], key: str) -> bool: """Return whether a metadata key is set to a truthy boolean value.""" value = get_metadata(metadata, key) if isinstance(value, bool): diff --git a/trpc_agent_sdk/server/a2a/converters/_event_converter.py b/trpc_agent_sdk/server/a2a/converters/_event_converter.py index 7e8874640..642bd8013 100644 --- a/trpc_agent_sdk/server/a2a/converters/_event_converter.py +++ b/trpc_agent_sdk/server/a2a/converters/_event_converter.py @@ -34,14 +34,13 @@ _TYPE_STREAMING_TOOL_CALL = "streaming_tool_call" _TYPE_TEXT = "text" -from datetime import datetime, timezone from typing import Any, Callable, Dict, List, Optional import uuid +from a2a.helpers.proto_helpers import new_text_message from a2a.server.events import Event as A2AEvent from a2a.types import ( Artifact, - DataPart, Message, Part as A2APart, Role, @@ -50,9 +49,9 @@ TaskState, TaskStatus, TaskStatusUpdateEvent, - TextPart, ) from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict from trpc_agent_sdk.context import InvocationContext from trpc_agent_sdk.events import Event @@ -76,6 +75,15 @@ from ._part_converter import convert_genai_part_to_a2a_part +def _metadata_to_dict(metadata: Any) -> Dict[str, Any]: + """Normalize a Struct/dict metadata value to a plain dict for helpers.""" + if metadata is None: + return {} + if isinstance(metadata, dict): + return metadata + return MessageToDict(metadata) + + def build_request_message_metadata(invocation_context: InvocationContext) -> Dict[str, Any]: """Build ``Message.metadata`` for an outgoing A2A request.""" metadata: Dict[str, Any] = { @@ -222,10 +230,10 @@ def _build_event_metadata(event: Event, message: Message, ctx: InvocationContext set_metadata(metadata, MESSAGE_METADATA_TAG_KEY, msg_meta.get(MESSAGE_METADATA_TAG_KEY) or "") set_metadata(metadata, MESSAGE_METADATA_RESPONSE_ID_KEY, msg_meta.get(MESSAGE_METADATA_RESPONSE_ID_KEY) or "") streaming_delta = A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA - if any( - get_metadata(p.root.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) == streaming_delta for p in message.parts - if p.root.metadata): - set_metadata(metadata, "streaming_tool_call", "true") + for p in message.parts: + if get_metadata(p.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) == streaming_delta: + set_metadata(metadata, "streaming_tool_call", "true") + break return metadata @@ -234,14 +242,15 @@ def _mark_long_running_tools(a2a_parts: List[A2APart], event: Event) -> None: if not event.long_running_tool_ids: return for a2a_part in a2a_parts: - root = a2a_part.root - if not isinstance(root, DataPart) or not root.metadata: + if not a2a_part.HasField("data"): continue - if get_metadata(root.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL: + if get_metadata(a2a_part.metadata, + A2A_DATA_PART_METADATA_TYPE_KEY) != A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL: continue - if root.data.get("id") not in event.long_running_tool_ids: + data = _metadata_to_dict(a2a_part.data) + if data.get("id") not in event.long_running_tool_ids: continue - set_metadata(root.metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, True) + set_metadata(a2a_part.metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY, True) def _effective_response_id(event: Event) -> str: @@ -260,15 +269,12 @@ def _build_message(event: Event, a2a_parts: List[A2APart], role: Role, effective message = Message(message_id=effective_id, role=role, parts=a2a_parts) msg_meta = _build_message_metadata(event, effective_id) if msg_meta: - message.metadata = msg_meta + message.metadata.update(msg_meta) return message def _is_streaming_delta(a2a_part: A2APart) -> bool: - meta = a2a_part.root.metadata - if meta is None: - return False - t = get_metadata(meta, A2A_DATA_PART_METADATA_TYPE_KEY) + t = get_metadata(a2a_part.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) return t == A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA @@ -315,7 +321,7 @@ def _collect_parts( def convert_event_to_a2a_message( event: Event, invocation_context: InvocationContext, - role: Role = Role.agent, + role: Role = Role.ROLE_AGENT, ) -> Optional[Message]: """Convert a TrpcAgent Event to an A2A Message. @@ -341,7 +347,7 @@ def convert_event_to_a2a_message( def convert_content_to_a2a_message( contents: List[genai_types.Content], - role: Role = Role.agent, + role: Role = Role.ROLE_AGENT, ) -> Optional[Message]: """Convert a list of Content objects to a single A2A Message. @@ -382,11 +388,11 @@ def convert_a2a_task_to_event( if a2a_task.artifacts: message = Message( message_id="", - role=Role.agent, + role=Role.ROLE_AGENT, parts=a2a_task.artifacts[-1].parts, - metadata=getattr(a2a_task.artifacts[-1], "metadata", None), + metadata=a2a_task.artifacts[-1].metadata, ) - elif a2a_task.status and a2a_task.status.message: + elif a2a_task.status and a2a_task.status.HasField("message"): message = a2a_task.status.message elif a2a_task.history: message = a2a_task.history[-1] @@ -417,7 +423,7 @@ def convert_a2a_message_to_event( inv_id = invocation_context.invocation_id if invocation_context else str(uuid.uuid4()) branch = invocation_context.branch if invocation_context else None - msg_meta = getattr(a2a_message, "metadata", None) + msg_meta = _metadata_to_dict(a2a_message.metadata) if not a2a_message.parts: logger.warning("A2A message has no parts, creating event with empty content") @@ -441,7 +447,7 @@ def convert_a2a_message_to_event( if gpart is None: logger.warning("Failed to convert A2A part, skipping: %s", a2a_part) continue - is_lr = metadata_is_true(a2a_part.root.metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) + is_lr = metadata_is_true(a2a_part.metadata, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) if is_lr and gpart.function_call: long_running_tool_ids.add(gpart.function_call.id) parts.append(gpart) @@ -468,8 +474,21 @@ def convert_a2a_message_to_event( ) -def _now_iso() -> str: - return datetime.now(timezone.utc).isoformat() +def _now_timestamp() -> Any: + """Return a protobuf ``Timestamp`` set to the current UTC time.""" + from google.protobuf.timestamp_pb2 import Timestamp + + ts = Timestamp() + ts.GetCurrentTime() + return ts + + +def _status_message(text: str, metadata: Optional[Dict[str, Any]] = None) -> Message: + """Build a small agent Message with a single text part.""" + msg = new_text_message(text=text, role=Role.ROLE_AGENT) + if metadata: + msg.metadata.update(metadata) + return msg def create_cancellation_event( @@ -478,19 +497,16 @@ def create_cancellation_event( message_text: str, final: bool = True, ) -> TaskStatusUpdateEvent: + # In a2a-sdk 1.x TaskStatusUpdateEvent has no ``final`` field; the parameter + # is kept for backward compatibility but is not serialized. return TaskStatusUpdateEvent( task_id=task_id, status=TaskStatus( - state=TaskState.canceled, - timestamp=_now_iso(), - message=Message( - message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[TextPart(text=message_text)], - ), + state=TaskState.TASK_STATE_CANCELED, + timestamp=_now_timestamp(), + message=_status_message(message_text), ), context_id=context_id, - final=final, ) @@ -505,17 +521,11 @@ def create_exception_status_event( return TaskStatusUpdateEvent( task_id=task_id, status=TaskStatus( - state=TaskState.failed, - timestamp=_now_iso(), - message=Message( - message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[TextPart(text=message_text)], - metadata=metadata, - ), + state=TaskState.TASK_STATE_FAILED, + timestamp=_now_timestamp(), + message=_status_message(message_text, metadata), ), context_id=context_id, - final=final, metadata=metadata, ) @@ -528,9 +538,8 @@ def create_submitted_status_event( ) -> TaskStatusUpdateEvent: return TaskStatusUpdateEvent( task_id=task_id, - status=TaskStatus(state=TaskState.submitted, message=message, timestamp=_now_iso()), + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED, message=message, timestamp=_now_timestamp()), context_id=context_id, - final=final, ) @@ -542,9 +551,8 @@ def create_working_status_event( ) -> TaskStatusUpdateEvent: return TaskStatusUpdateEvent( task_id=task_id, - status=TaskStatus(state=TaskState.working, timestamp=_now_iso()), + status=TaskStatus(state=TaskState.TASK_STATE_WORKING, timestamp=_now_timestamp()), context_id=context_id, - final=final, metadata=metadata, ) @@ -556,9 +564,8 @@ def create_completed_status_event( ) -> TaskStatusUpdateEvent: return TaskStatusUpdateEvent( task_id=task_id, - status=TaskStatus(state=TaskState.completed, timestamp=_now_iso()), + status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED, timestamp=_now_timestamp()), context_id=context_id, - final=final, ) @@ -571,9 +578,8 @@ def create_final_status_event( ) -> TaskStatusUpdateEvent: return TaskStatusUpdateEvent( task_id=task_id, - status=TaskStatus(state=state, timestamp=_now_iso(), message=message), + status=TaskStatus(state=state, timestamp=_now_timestamp(), message=message), context_id=context_id, - final=final, ) @@ -597,33 +603,26 @@ def _create_error_status_event( context_id=context_id, metadata=event_metadata, status=TaskStatus( - state=TaskState.failed, - message=Message( - message_id=str(uuid.uuid4()), - role=Role.agent, - parts=[TextPart(text=error_message)], - metadata=error_msg_metadata, - ), - timestamp=_now_iso(), + state=TaskState.TASK_STATE_FAILED, + message=_status_message(error_message, error_msg_metadata), + timestamp=_now_timestamp(), ), - final=False, ) def _a2a_part_requests_euc_auth(part: A2APart) -> bool: - root = part.root - md = root.metadata + md = part.metadata if not md: return False t = get_metadata(md, A2A_DATA_PART_METADATA_TYPE_KEY) + data = _metadata_to_dict(part.data) return (t == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL and metadata_is_true(md, A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY) - and root.data.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME) + and data.get("name") == REQUEST_EUC_FUNCTION_CALL_NAME) def _a2a_part_is_long_running_function_call(part: A2APart) -> bool: - root = part.root - md = root.metadata + md = part.metadata if not md: return False t = get_metadata(md, A2A_DATA_PART_METADATA_TYPE_KEY) @@ -639,19 +638,18 @@ def _create_status_update_event( context_id: Optional[str], effective_id: str = "", ) -> TaskStatusUpdateEvent: - status = TaskStatus(state=TaskState.working, message=message, timestamp=_now_iso()) + status = TaskStatus(state=TaskState.TASK_STATE_WORKING, message=message, timestamp=_now_timestamp()) if any(_a2a_part_requests_euc_auth(p) for p in message.parts): - status.state = TaskState.auth_required + status.state = TaskState.TASK_STATE_AUTH_REQUIRED elif any(_a2a_part_is_long_running_function_call(p) for p in message.parts): - status.state = TaskState.input_required + status.state = TaskState.TASK_STATE_INPUT_REQUIRED return TaskStatusUpdateEvent( task_id=task_id, context_id=context_id, status=status, metadata=_build_event_metadata(event, message, ctx, effective_id), - final=False, ) @@ -706,8 +704,10 @@ def _notify(evt: A2AEvent) -> None: if event.error_code: error_event = _create_error_status_event(event, invocation_context, task_id, context_id) _notify(error_event) - if error_event.status and error_event.status.message: - a2a_events.append(error_event.status.message) + # a2a-sdk 1.x task-mode streaming forbids a bare `Message` after the + # initial `Task`; carry the failure through the status event instead. + if error_event.status and error_event.status.HasField("message"): + a2a_events.append(error_event) message = convert_event_to_a2a_message(event, invocation_context) if message: diff --git a/trpc_agent_sdk/server/a2a/converters/_part_converter.py b/trpc_agent_sdk/server/a2a/converters/_part_converter.py index aaa56cede..470c3fe9a 100644 --- a/trpc_agent_sdk/server/a2a/converters/_part_converter.py +++ b/trpc_agent_sdk/server/a2a/converters/_part_converter.py @@ -19,17 +19,23 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Conversion between A2A Part and Google GenAI Part.""" +"""Conversion between A2A Part and Google GenAI Part. + +In a2a-sdk 1.x ``Part`` is a protobuf message with a oneof content field +(``text`` / ``raw`` / ``url`` / ``data``) instead of a pydantic wrapper around +``TextPart``/``FilePart``/``DataPart``. This module converts between the +GenAI ``Part`` model and the protobuf ``Part``. +""" from __future__ import annotations -import base64 import json from typing import Any from typing import Optional -from a2a import types as a2a_types from google.genai import types as genai_types +from google.protobuf import struct_pb2 +from google.protobuf.json_format import MessageToDict, ParseDict from trpc_agent_sdk.log import logger from trpc_agent_sdk.models import TOOL_STREAMING_ARGS @@ -48,6 +54,18 @@ from .._utils import get_metadata from .._utils import set_metadata +_A2A_PART_MODULE = None + + +def _a2a_part_type() -> Any: + """Lazily import the A2A Part type to avoid a hard dependency.""" + global _A2A_PART_MODULE # pylint: disable=global-statement + if _A2A_PART_MODULE is None: + from a2a.types import Part as A2APart + + _A2A_PART_MODULE = A2APart + return _A2A_PART_MODULE + def _to_bool_metadata(value: Any) -> Optional[bool]: """Convert metadata values to bool when possible.""" @@ -109,33 +127,47 @@ def _get_genai_part_kind(part: genai_types.Part) -> Optional[str]: return None -def _genai_text_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: - a2a_part = a2a_types.TextPart(text=part.text) +def _new_a2a_part(**kwargs: Any) -> Any: + """Build a protobuf A2A Part with the given oneof fields. + + The ``data`` field is a ``google.protobuf.Value``; a plain dict must be + wrapped via ``ParseDict`` before being passed to the constructor. + """ + data = kwargs.pop("data", None) + part = _a2a_part_type()(**kwargs) + if data is not None: + part.data.CopyFrom(ParseDict(data, struct_pb2.Value())) + return part + + +def _genai_text_to_a2a(part: genai_types.Part) -> Optional[Any]: + metadata = None if part.thought is not None: - a2a_part.metadata = {"thought": part.thought} - return a2a_types.Part(root=a2a_part) + metadata = {"thought": part.thought} + return _new_a2a_part(text=part.text, metadata=metadata) -def _genai_file_uri_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: - return a2a_types.Part(root=a2a_types.FilePart(file=a2a_types.FileWithUri( - uri=part.file_data.file_uri, - mime_type=part.file_data.mime_type, - ))) +def _genai_file_uri_to_a2a(part: genai_types.Part) -> Optional[Any]: + return _new_a2a_part( + url=part.file_data.file_uri, + media_type=part.file_data.mime_type, + ) -def _genai_inline_file_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: - a2a_part = a2a_types.FilePart(file=a2a_types.FileWithBytes( - bytes=base64.b64encode(part.inline_data.data).decode("utf-8"), - mime_type=part.inline_data.mime_type, - )) +def _genai_inline_file_to_a2a(part: genai_types.Part) -> Optional[Any]: + metadata = None if part.video_metadata: - a2a_part.metadata = { + metadata = { "video_metadata": part.video_metadata.model_dump(by_alias=True, exclude_none=True), } - return a2a_types.Part(root=a2a_part) + return _new_a2a_part( + raw=part.inline_data.data, + media_type=part.inline_data.mime_type, + metadata=metadata, + ) -def _genai_streaming_function_call_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: +def _genai_streaming_function_call_to_a2a(part: genai_types.Part) -> Optional[Any]: fc = part.function_call tool_id = fc.id or f"tool_{fc.name}_{id(fc)}" data: dict[str, Any] = { @@ -145,7 +177,7 @@ def _genai_streaming_function_call_to_a2a(part: genai_types.Part) -> Optional[a2 } metadata = _typed_metadata(A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA) set_metadata(metadata, "streaming", True) - return a2a_types.Part(root=a2a_types.DataPart(data=data, metadata=metadata)) + return _new_a2a_part(data=data, metadata=metadata) def _function_call_data_for_a2a(raw: Any) -> dict[str, Any]: @@ -160,12 +192,12 @@ def _function_call_data_for_a2a(raw: Any) -> dict[str, Any]: return out -def _genai_function_call_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: +def _genai_function_call_to_a2a(part: genai_types.Part) -> Optional[Any]: data = _function_call_data_for_a2a(part.function_call) - return a2a_types.Part(root=a2a_types.DataPart( + return _new_a2a_part( data=data, metadata=_typed_metadata(A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL), - )) + ) def _function_response_data_for_a2a(raw: Any) -> dict[str, Any]: @@ -178,32 +210,32 @@ def _function_response_data_for_a2a(raw: Any) -> dict[str, Any]: return out -def _genai_function_response_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: +def _genai_function_response_to_a2a(part: genai_types.Part) -> Optional[Any]: data = _function_response_data_for_a2a(part.function_response) - return a2a_types.Part(root=a2a_types.DataPart( + return _new_a2a_part( data=data, metadata=_typed_metadata(A2A_DATA_PART_METADATA_TYPE_FUNCTION_RESPONSE), - )) + ) -def _genai_code_execution_result_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: - return a2a_types.Part(root=a2a_types.DataPart( +def _genai_code_execution_result_to_a2a(part: genai_types.Part) -> Optional[Any]: + return _new_a2a_part( data={ A2A_DATA_FIELD_CODE_EXECUTION_OUTPUT: _stringify(part.code_execution_result.output), A2A_DATA_FIELD_CODE_EXECUTION_OUTCOME: _stringify(part.code_execution_result.outcome), }, metadata=_typed_metadata(A2A_DATA_PART_METADATA_TYPE_CODE_EXECUTION_RESULT), - )) + ) -def _genai_executable_code_to_a2a(part: genai_types.Part) -> Optional[a2a_types.Part]: - return a2a_types.Part(root=a2a_types.DataPart( +def _genai_executable_code_to_a2a(part: genai_types.Part) -> Optional[Any]: + return _new_a2a_part( data={ A2A_DATA_FIELD_CODE_EXECUTION_CODE: _stringify(part.executable_code.code), A2A_DATA_FIELD_CODE_EXECUTION_LANGUAGE: _stringify(part.executable_code.language) or "unknown", }, metadata=_typed_metadata(A2A_DATA_PART_METADATA_TYPE_EXECUTABLE_CODE), - )) + ) _GENAI_KIND_CONVERTERS: dict[str, callable] = { @@ -218,7 +250,7 @@ def _genai_executable_code_to_a2a(part: genai_types.Part) -> Optional[a2a_types. } -def convert_genai_part_to_a2a_part(part: genai_types.Part) -> Optional[a2a_types.Part]: +def convert_genai_part_to_a2a_part(part: genai_types.Part) -> Optional[Any]: """Convert a Google GenAI Part to an A2A Part.""" kind = _get_genai_part_kind(part) converter = _GENAI_KIND_CONVERTERS.get(kind) if kind else None @@ -265,6 +297,15 @@ def _convert_streaming_function_call_delta(data: Any) -> genai_types.Part: ), ) +def _a2a_data_to_dict(data: Any) -> dict[str, Any]: + """Convert a protobuf ``Value`` data field back to a plain dict.""" + if isinstance(data, struct_pb2.Value): + return MessageToDict(data) + if isinstance(data, dict): + return data + return {} + + _A2A_DATA_TYPE_CONVERTERS: dict[str, callable] = { A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL: lambda d: genai_types.Part(function_call=genai_types.FunctionCall.model_validate(_normalize_function_call_data(d), @@ -282,40 +323,41 @@ def _convert_streaming_function_call_delta(data: Any) -> genai_types.Part: } -def _convert_a2a_data_part(part: a2a_types.DataPart) -> Optional[genai_types.Part]: - """Convert an A2A DataPart to a GenAI Part based on metadata type.""" - metadata_type = get_metadata(part.metadata, A2A_DATA_PART_METADATA_TYPE_KEY) +def _convert_a2a_data_part(part: Any) -> Optional[genai_types.Part]: + """Convert an A2A data Part to a GenAI Part based on metadata type. + + In 1.x the data is a ``google.protobuf.Value``; it is read back via + ``MessageToDict`` so the converter lambdas receive plain dicts. + """ + metadata = getattr(part, "metadata", None) + metadata_type = get_metadata(metadata, A2A_DATA_PART_METADATA_TYPE_KEY) + data = _a2a_data_to_dict(getattr(part, "data", None)) converter = _A2A_DATA_TYPE_CONVERTERS.get(metadata_type) if converter: - return converter(part.data) - return genai_types.Part(text=json.dumps(part.data)) + return converter(data) + return genai_types.Part(text=json.dumps(data)) -def convert_a2a_part_to_genai_part(a2a_part: a2a_types.Part) -> Optional[genai_types.Part]: +def convert_a2a_part_to_genai_part(a2a_part: Any) -> Optional[genai_types.Part]: """Convert an A2A Part to a Google GenAI Part.""" - part = a2a_part.root - - if isinstance(part, a2a_types.TextPart): - thought = _to_bool_metadata(get_metadata(getattr(part, "metadata", None), "thought")) - kwargs: dict[str, Any] = {"text": part.text} + if a2a_part.HasField("text"): + thought = _to_bool_metadata(get_metadata(getattr(a2a_part, "metadata", None), "thought")) + kwargs: dict[str, Any] = {"text": a2a_part.text} if thought is not None: kwargs["thought"] = thought return genai_types.Part(**kwargs) - if isinstance(part, a2a_types.FilePart): - if isinstance(part.file, a2a_types.FileWithUri): - return genai_types.Part(file_data=genai_types.FileData(file_uri=part.file.uri, - mime_type=part.file.mime_type), ) - if isinstance(part.file, a2a_types.FileWithBytes): - return genai_types.Part(inline_data=genai_types.Blob( - data=base64.b64decode(part.file.bytes), - mime_type=part.file.mime_type, - )) - logger.warning("Cannot convert unsupported file type: %s for A2A part: %s", type(part.file), a2a_part) - return None - - if isinstance(part, a2a_types.DataPart): - return _convert_a2a_data_part(part) - - logger.warning("Cannot convert unsupported part type: %s for A2A part: %s", type(part), a2a_part) + if a2a_part.HasField("url"): + return genai_types.Part(file_data=genai_types.FileData(file_uri=a2a_part.url, mime_type=a2a_part.media_type), ) + + if a2a_part.HasField("raw"): + return genai_types.Part(inline_data=genai_types.Blob( + data=a2a_part.raw, + mime_type=a2a_part.media_type, + )) + + if a2a_part.HasField("data"): + return _convert_a2a_data_part(a2a_part) + + logger.warning("Cannot convert unsupported part type for A2A part: %s", a2a_part) return None diff --git a/trpc_agent_sdk/server/a2a/converters/_request_converter.py b/trpc_agent_sdk/server/a2a/converters/_request_converter.py index 453a80c09..a50af3a16 100644 --- a/trpc_agent_sdk/server/a2a/converters/_request_converter.py +++ b/trpc_agent_sdk/server/a2a/converters/_request_converter.py @@ -32,6 +32,7 @@ from a2a.server.agent_execution import RequestContext from google.genai import types as genai_types +from google.protobuf.json_format import MessageToDict from trpc_agent_sdk.configs import RunConfig from ._part_converter import convert_a2a_part_to_genai_part @@ -81,8 +82,12 @@ async def convert_a2a_request_to_trpc_agent_run_args( user_id = await _resolve_user_id(request, user_id_extractor) - raw_meta = getattr(request.message, "metadata", None) - request_metadata = dict(raw_meta) if isinstance(raw_meta, dict) else {} + message_metadata = getattr(request.message, "metadata", None) + if isinstance(message_metadata, dict): + request_metadata = message_metadata + else: + # In 1.x the message metadata is a protobuf Struct. + request_metadata = MessageToDict(message_metadata) if message_metadata else {} return { "user_id": diff --git a/trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py b/trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py index dafb5cbfa..64657d3b6 100644 --- a/trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py +++ b/trpc_agent_sdk/server/a2a/executor/_a2a_agent_executor.py @@ -36,8 +36,10 @@ from a2a.server.agent_execution.context import RequestContext from a2a.server.events.event_queue import EventQueue from a2a.types import Artifact +from a2a.types import Task from a2a.types import TaskArtifactUpdateEvent from a2a.types import TaskState +from a2a.types import TaskStatus from pydantic import BaseModel from trpc_agent_sdk.cancel import SessionKey from trpc_agent_sdk.cancel import is_run_cancelled @@ -56,7 +58,6 @@ from ..converters import create_completed_status_event from ..converters import create_exception_status_event from ..converters import create_final_status_event -from ..converters import create_submitted_status_event from ..converters import create_working_status_event from ..converters import get_user_session_id from ._task_result_aggregator import TaskResultAggregator @@ -68,6 +69,17 @@ RunConfigFactory = Callable[[RequestContext], Union[RunConfig, Awaitable[RunConfig]]] +def _metadata_to_dict(metadata: Any) -> dict[str, Any]: + """Normalize a Struct/dict metadata value to a plain dict.""" + if metadata is None: + return {} + if isinstance(metadata, dict): + return metadata + from google.protobuf.json_format import MessageToDict + + return MessageToDict(metadata) + + class TrpcA2aAgentExecutorConfig(BaseModel): """Configuration for TrpcA2aAgentExecutor. @@ -143,7 +155,7 @@ def _get_user_session_from_task_metadata( """Extract (app_name, user_id, session_id) from task metadata written by execute().""" if not context.current_task or not context.current_task.metadata: return None, None, None - metadata = context.current_task.metadata + metadata = _metadata_to_dict(context.current_task.metadata) return ( get_metadata(metadata, "app_name"), get_metadata(metadata, "user_id"), @@ -210,11 +222,16 @@ async def execute(self, context: RequestContext, event_queue: EventQueue): raise ValueError("A2A request must have a message") if not context.current_task: + # a2a-sdk 1.x enforces that the first event is a `Task`, followed by + # `TaskStatusUpdateEvent`/`TaskArtifactUpdateEvent` events. Enqueue + # the Task (seeded with the user message) as the submission signal + # instead of a bare submitted status event. await event_queue.enqueue_event( - create_submitted_status_event( - task_id=context.task_id, + Task( + id=context.task_id, context_id=context.context_id, - message=context.message, + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), + history=[context.message] if context.message else [], )) try: @@ -252,13 +269,16 @@ async def execute(self, context: RequestContext, event_queue: EventQueue): except Exception as ex: # pylint: disable=broad-except logger.error("Error handling A2A request: %s", ex, exc_info=True) try: - except_event = create_exception_status_event( - task_id=context.task_id, - context_id=context.context_id, - message_text=str(ex), - ) - if except_event.status and except_event.status.message: - await event_queue.enqueue_event(except_event.status.message) + # a2a-sdk 1.x enforces task-mode streaming: after the initial + # `Task`, only `TaskStatusUpdateEvent`/`TaskArtifactUpdateEvent` + # may follow. Enqueue the whole failed-status event (not the + # bare ``status.message``) so the response stays valid. + await event_queue.enqueue_event( + create_exception_status_event( + task_id=context.task_id, + context_id=context.context_id, + message_text=str(ex), + )) except Exception as enqueue_error: # pylint: disable=broad-except logger.error("Failed to publish failure event: %s", enqueue_error, exc_info=True) finally: @@ -301,6 +321,12 @@ async def _handle_request(self, context: RequestContext, event_queue: EventQueue "user_id": run_args["user_id"], "session_id": run_args["session_id"], } + + # a2a-sdk 1.x enforces that the first event is a `Task`, followed by + # `TaskStatusUpdateEvent`/`TaskArtifactUpdateEvent` events. The initial + # Task is enqueued by ``execute()`` (when no current task exists); here we + # only emit the working status update that transitions the task into the + # executing state. await event_queue.enqueue_event( create_working_status_event( task_id=context.task_id, @@ -337,7 +363,7 @@ async def _handle_request(self, context: RequestContext, event_queue: EventQueue ): await event_queue.enqueue_event(a2a_event) - if (aggregator.task_state == TaskState.working and aggregator.task_status_message is not None + if (aggregator.task_state == TaskState.TASK_STATE_WORKING and aggregator.task_status_message is not None and aggregator.task_status_message.parts): final_meta: dict[str, Any] = {"partial": False} await event_queue.enqueue_event( diff --git a/trpc_agent_sdk/server/a2a/executor/_task_result_aggregator.py b/trpc_agent_sdk/server/a2a/executor/_task_result_aggregator.py index 4a812173d..120ddeabb 100644 --- a/trpc_agent_sdk/server/a2a/executor/_task_result_aggregator.py +++ b/trpc_agent_sdk/server/a2a/executor/_task_result_aggregator.py @@ -29,10 +29,16 @@ class TaskResultAggregator: - """Aggregates the task status updates and provides the final task state.""" + """Aggregates the task status updates and provides the final task state. + + In a2a-sdk 1.x the events are shared protobuf messages, so this aggregator + only *observes* them and tracks the highest-priority state internally; it + never mutates an event in place (unlike the 0.3 implementation, which + rewrote ``event.status.state``). + """ def __init__(self): - self._task_state = TaskState.working + self._task_state = TaskState.TASK_STATE_WORKING self._task_status_message = None def process_event(self, event: A2AEvent): @@ -44,22 +50,19 @@ def process_event(self, event: A2AEvent): - working """ if isinstance(event, TaskStatusUpdateEvent): - if event.status.state == TaskState.failed: - self._task_state = TaskState.failed + if event.status.state == TaskState.TASK_STATE_FAILED: + self._task_state = TaskState.TASK_STATE_FAILED self._task_status_message = event.status.message - elif (event.status.state == TaskState.auth_required and self._task_state != TaskState.failed): - self._task_state = TaskState.auth_required + elif (event.status.state == TaskState.TASK_STATE_AUTH_REQUIRED + and self._task_state != TaskState.TASK_STATE_FAILED): + self._task_state = TaskState.TASK_STATE_AUTH_REQUIRED self._task_status_message = event.status.message - elif (event.status.state == TaskState.input_required - and self._task_state not in (TaskState.failed, TaskState.auth_required)): - self._task_state = TaskState.input_required + elif (event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED + and self._task_state not in (TaskState.TASK_STATE_FAILED, TaskState.TASK_STATE_AUTH_REQUIRED)): + self._task_state = TaskState.TASK_STATE_INPUT_REQUIRED self._task_status_message = event.status.message - # final state is already recorded and make sure the intermediate state is - # always working because other state may terminate the event aggregation - # in a2a request handler - elif self._task_state == TaskState.working: + elif self._task_state == TaskState.TASK_STATE_WORKING: self._task_status_message = event.status.message - event.status.state = TaskState.working @property def task_state(self) -> TaskState: diff --git a/trpc_agent_sdk/server/a2a/logs/_log_utils.py b/trpc_agent_sdk/server/a2a/logs/_log_utils.py index 2a52bf7bc..42dc962e7 100644 --- a/trpc_agent_sdk/server/a2a/logs/_log_utils.py +++ b/trpc_agent_sdk/server/a2a/logs/_log_utils.py @@ -19,23 +19,26 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -"""Utility functions for structured A2A request and response logging.""" +"""Utility functions for structured A2A request and response logging. + +In a2a-sdk 1.x the A2A types are protobuf messages. ``Part`` uses a oneof +content field (``text`` / ``url`` / ``raw`` / ``data``) and metadata is a +``google.protobuf.Struct``. +""" from __future__ import annotations import json -from a2a.types import DataPart as A2ADataPart from a2a.types import Message as A2AMessage from a2a.types import Part as A2APart from a2a.types import SendMessageRequest from a2a.types import SendMessageResponse from a2a.types import Task as A2ATask -from a2a.types import TextPart as A2ATextPart +from google.protobuf.json_format import MessageToDict # Constants _NEW_LINE = "\n" -_EXCLUDED_PART_FIELD = {"file": {"bytes"}} def _is_a2a_task(obj) -> bool: @@ -54,20 +57,23 @@ def _is_a2a_message(obj) -> bool: return type(obj).__name__ == "Message" and hasattr(obj, "role") -def _is_a2a_text_part(obj) -> bool: - """Check if an object is an A2A TextPart, with fallback for isinstance issues.""" - try: - return isinstance(obj, A2ATextPart) - except (TypeError, AttributeError): - return type(obj).__name__ == "TextPart" and hasattr(obj, "text") +def _metadata_dict(metadata) -> dict: + """Convert a Struct/dict metadata value to a plain dict for logging.""" + if metadata is None: + return {} + if isinstance(metadata, dict): + return metadata + return MessageToDict(metadata) -def _is_a2a_data_part(obj) -> bool: - """Check if an object is an A2A DataPart, with fallback for isinstance issues.""" - try: - return isinstance(obj, A2ADataPart) - except (TypeError, AttributeError): - return type(obj).__name__ == "DataPart" and hasattr(obj, "data") +def _is_a2a_text_part(part: A2APart) -> bool: + """Check if a protobuf Part is a text part.""" + return part.HasField("text") + + +def _is_a2a_data_part(part: A2APart) -> bool: + """Check if a protobuf Part is a data part.""" + return part.HasField("data") def build_message_part_log(part: A2APart) -> str: @@ -80,27 +86,57 @@ def build_message_part_log(part: A2APart) -> str: A string representation of the part. """ part_content = "" - if _is_a2a_text_part(part.root): - part_content = f"TextPart: {part.root.text[:100]}" + ("..." if len(part.root.text) > 100 else "") - elif _is_a2a_data_part(part.root): + if _is_a2a_text_part(part): + text = part.text + part_content = f"TextPart: {text[:100]}" + ("..." if len(text) > 100 else "") + elif _is_a2a_data_part(part): # For data parts, show the data keys but exclude large values - data_summary = { + data_summary = _metadata_dict(part.data) + if not isinstance(data_summary, dict): + data_summary = {"value": data_summary} + summarized = { k: (f"<{type(v).__name__}>" if isinstance(v, (dict, list)) and len(str(v)) > 100 else v) - for k, v in part.root.data.items() + for k, v in data_summary.items() } - part_content = f"DataPart: {json.dumps(data_summary, indent=2)}" + part_content = f"DataPart: {json.dumps(summarized, indent=2)}" + elif part.HasField("url"): + part_content = f"FilePart: url={part.url}, media_type={part.media_type}" + elif part.HasField("raw"): + part_content = f"FilePart: raw bytes ({len(part.raw)} bytes), media_type={part.media_type}" else: - part_content = (f"{type(part.root).__name__}:" - f" {part.model_dump_json(exclude_none=True, exclude=_EXCLUDED_PART_FIELD)}") + part_content = f"Part: {type(part).__name__}" # Add part metadata if it exists - if hasattr(part.root, "metadata") and part.root.metadata: - metadata_str = json.dumps(part.root.metadata, indent=2).replace("\n", "\n ") + if part.HasField("metadata") and part.metadata: + metadata_str = json.dumps(_metadata_dict(part.metadata), indent=2).replace("\n", "\n ") part_content += f"\n Part Metadata: {metadata_str}" return part_content +def _build_message_section(message: A2AMessage, indent: str = "") -> str: + """Build a structured log section for an A2A Message.""" + parts_logs = [] + for i, part in enumerate(message.parts): + part_log = build_message_part_log(part) + part_log_formatted = part_log.replace("\n", "\n" + indent + " ") + parts_logs.append(f"{indent} Part {i}: {part_log_formatted}") + + metadata_section = "" + if message.metadata: + meta = _metadata_dict(message.metadata) + metadata_section = f""" +{indent} Metadata: +{indent} {json.dumps(meta, indent=2).replace(chr(10), chr(10) + indent + ' ')}""" + + return f"""{indent} ID: {message.message_id} +{indent} Role: {message.role} +{indent} Task ID: {message.task_id} +{indent} Context ID: {message.context_id} +{indent} Message Parts: +{_NEW_LINE.join(parts_logs) if parts_logs else indent + ' No parts'}{metadata_section}""" + + def build_a2a_request_log(req: SendMessageRequest) -> str: """Builds a structured log representation of an A2A request. @@ -110,58 +146,37 @@ def build_a2a_request_log(req: SendMessageRequest) -> str: Returns: A formatted string representation of the request. """ - # Message parts logs - message_parts_logs = [] - if req.params.message.parts: - for i, part in enumerate(req.params.message.parts): - part_log = build_message_part_log(part) - # Replace any internal newlines with indented newlines to maintain formatting - part_log_formatted = part_log.replace("\n", "\n ") - message_parts_logs.append(f"Part {i}: {part_log_formatted}") + message = req.message if req.HasField("message") else None + message_section = _build_message_section(message) if message else " No message" # Configuration logs config_log = "None" - if req.params.configuration: + if req.HasField("configuration"): + config = req.configuration config_data = { - "accepted_output_modes": req.params.configuration.accepted_output_modes, - "blocking": req.params.configuration.blocking, - "history_length": req.params.configuration.history_length, - "push_notification_config": bool(req.params.configuration.push_notification_config), + "accepted_output_modes": list(config.accepted_output_modes), + "return_immediately": config.return_immediately, + "history_length": config.history_length, + "push_notification_config": bool(config.HasField("task_push_notification_config")), } config_log = json.dumps(config_data, indent=2) - # Build message metadata section - message_metadata_section = "" - if req.params.message.metadata: - message_metadata_section = f""" - Metadata: - {json.dumps(req.params.message.metadata, indent=2).replace(chr(10), chr(10) + ' ')}""" - # Build optional sections optional_sections = [] - if req.params.metadata: + if req.HasField("metadata") and req.metadata: optional_sections.append(f"""----------------------------------------------------------- Metadata: -{json.dumps(req.params.metadata, indent=2)}""") +{json.dumps(_metadata_dict(req.metadata), indent=2)}""") optional_sections_str = _NEW_LINE.join(optional_sections) return f""" A2A Request: ----------------------------------------------------------- -Request ID: {req.id} -Method: {req.method} -JSON-RPC: {req.jsonrpc} ------------------------------------------------------------ +Tenant: {req.tenant} Message: - ID: {req.params.message.message_id} - Role: {req.params.message.role} - Task ID: {req.params.message.task_id} - Context ID: {req.params.message.context_id}{message_metadata_section} ------------------------------------------------------------ -Message Parts: -{_NEW_LINE.join(message_parts_logs) if message_parts_logs else "No parts"} +{message_section} ----------------------------------------------------------- Configuration: {config_log} @@ -179,29 +194,18 @@ def build_a2a_response_log(resp: SendMessageResponse) -> str: Returns: A formatted string representation of the response. """ - # Handle error responses - if hasattr(resp.root, "error"): - return f""" -A2A Response: ------------------------------------------------------------ -Type: ERROR -Error Code: {resp.root.error.code} -Error Message: {resp.root.error.message} -Error Data: {json.dumps(resp.root.error.data, indent=2) if resp.root.error.data else "None"} ------------------------------------------------------------ -Response ID: {resp.root.id} -JSON-RPC: {resp.root.jsonrpc} ------------------------------------------------------------ -""" - - # Handle success responses - result = resp.root.result - result_type = type(result).__name__ + result = None + if resp.HasField("task"): + result = resp.task + elif resp.HasField("message"): + result = resp.message + result_type = type(result).__name__ if result else "None" - # Build result details based on type result_details = [] + if result is None: + result_details.append("No result") - if _is_a2a_task(result): + elif _is_a2a_task(result): result_details.extend([ f"Task ID: {result.id}", f"Context ID: {result.context_id}", @@ -211,10 +215,9 @@ def build_a2a_response_log(resp: SendMessageResponse) -> str: f"Artifacts Count: {len(result.artifacts) if result.artifacts else 0}", ]) - # Add task metadata if it exists if result.metadata: result_details.append("Task Metadata:") - metadata_formatted = json.dumps(result.metadata, indent=2).replace("\n", "\n ") + metadata_formatted = json.dumps(_metadata_dict(result.metadata), indent=2).replace("\n", "\n ") result_details.append(f" {metadata_formatted}") elif _is_a2a_message(result): @@ -225,83 +228,29 @@ def build_a2a_response_log(resp: SendMessageResponse) -> str: f"Context ID: {result.context_id}", ]) - # Add message parts if result.parts: result_details.append("Message Parts:") for i, part in enumerate(result.parts): part_log = build_message_part_log(part) - # Replace any internal newlines with indented newlines to maintain formatting part_log_formatted = part_log.replace("\n", "\n ") result_details.append(f" Part {i}: {part_log_formatted}") - # Add metadata if it exists if result.metadata: result_details.append("Metadata:") - metadata_formatted = json.dumps(result.metadata, indent=2).replace("\n", "\n ") + metadata_formatted = json.dumps(_metadata_dict(result.metadata), indent=2).replace("\n", "\n ") result_details.append(f" {metadata_formatted}") - else: - # Handle other result types by showing their JSON representation - if hasattr(result, "model_dump_json"): - try: - result_json = result.model_dump_json() - result_details.append(f"JSON Data: {result_json}") - except Exception: # pylint: disable=broad-except - result_details.append("JSON Data: ") - # Build status message section status_message_section = "None" - if _is_a2a_task(result) and result.status.message: - status_parts_logs = [] - if result.status.message.parts: - for i, part in enumerate(result.status.message.parts): - part_log = build_message_part_log(part) - # Replace any internal newlines with indented newlines to maintain formatting - part_log_formatted = part_log.replace("\n", "\n ") - status_parts_logs.append(f"Part {i}: {part_log_formatted}") - - # Build status message metadata section - status_metadata_section = "" - if result.status.message.metadata: - status_metadata_section = f""" -Metadata: -{json.dumps(result.status.message.metadata, indent=2)}""" - - status_message_section = f"""ID: {result.status.message.message_id} -Role: {result.status.message.role} -Task ID: {result.status.message.task_id} -Context ID: {result.status.message.context_id} -Message Parts: -{_NEW_LINE.join(status_parts_logs) if status_parts_logs else "No parts"}{status_metadata_section}""" + if _is_a2a_task(result) and result.status.HasField("message"): + status_message_section = _build_message_section(result.status.message, indent="") # Build history section history_section = "No history" if _is_a2a_task(result) and result.history: history_logs = [] for i, message in enumerate(result.history): - message_parts_logs = [] - if message.parts: - for j, part in enumerate(message.parts): - part_log = build_message_part_log(part) - # Replace any internal newlines with indented newlines to maintain formatting - part_log_formatted = part_log.replace("\n", "\n ") - message_parts_logs.append(f" Part {j}: {part_log_formatted}") - - # Build message metadata section - message_metadata_section = "" - if message.metadata: - message_metadata_section = f""" - Metadata: - {json.dumps(message.metadata, indent=2).replace(chr(10), chr(10) + ' ')}""" - - history_logs.append(f"""Message {i + 1}: - ID: {message.message_id} - Role: {message.role} - Task ID: {message.task_id} - Context ID: {message.context_id} - Message Parts: -{_NEW_LINE.join(message_parts_logs) if message_parts_logs else " No parts"}{message_metadata_section}""") - + history_logs.append(f"Message {i + 1}:\n{_build_message_section(message, indent=' ')}") history_section = _NEW_LINE.join(history_logs) return f""" @@ -319,7 +268,4 @@ def build_a2a_response_log(resp: SendMessageResponse) -> str: History: {history_section} ----------------------------------------------------------- -Response ID: {resp.root.id} -JSON-RPC: {resp.root.jsonrpc} ------------------------------------------------------------ """