diff --git a/docs/mkdocs/en/a2a.md b/docs/mkdocs/en/a2a.md index 5bea91de9..98357d76c 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,123 @@ 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` | Default `False`: 1.0-only; `/.well-known/agent.json` returns 404. Set `True` to also accept 0.3 clients on the same endpoint **and** publish the legacy card path | | `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, a 1.0-only app still starts — direct JSON-RPC callers never read the card — but a warning is logged because discovery-based clients cannot call the agent. With `enable_v0_3_compat=True`, a missing url is a `ValueError`: 0.3 discovery cannot work without a top-level `url`. + +--- + +## 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). **Default discovery is 1.0-only**: the server publishes the Agent Card at `/.well-known/agent-card.json`. Legacy 0.3 clients (`A2ACardResolver`) fetch `/.well-known/agent.json`, which is **not** served (404) unless you set `enable_v0_3_compat=True`. + +### 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=...)` | When the card is auto-built, add `rpc_url=...`; if you pass `agent_card` yourself, write 1.0 fields on that card (next row) | **`rpc_url` is required for auto-built cards**, see below | +| Hand-written / passed-in `AgentCard` (top-level `url`, `preferredTransport`) | `supported_interfaces[]` (`url`, `protocol_binding`, `protocol_version`) | **A custom `agent_card=` must use the 1.0 layout** as well; a top-level `url` alone is not enough. 0.3 clients still discover via top-level `url`; 1.0 uses `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` (or rewrite a custom `agent_card` to the 1.0 layout) + 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 (`/.well-known/agent-card.json` only; `/.well-known/agent.json` is 404). 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) +``` + +With the switch on, the framework: + +- publishes the same card at `/.well-known/agent.json` (the path 0.3 `A2ACardResolver` uses by default) +- appends a `protocol_version="0.3"` interface (reusing the same url) on the **card copy used for well-known discovery** +- enables 0.3 JSON-RPC decoding + +Once that switch is on, legacy 0.3 clients need **no changes**. Without it, 0.3 clients cannot discover the service. + +Compat **requires** a reachable interface url (`TrpcA2aAgentService(rpc_url=...)` or a custom `agent_card`). The 0.3 well-known card's top-level `url` is copied from that interface; an empty url cannot be invented. `create_a2a_application(..., enable_v0_3_compat=True)` raises `ValueError` if every interface url is empty, instead of starting in a silently undiscoverable state. + +When this function builds the default `DefaultRequestHandler`, that handler receives the same patched copy. A **custom `request_handler` is not rewritten**: 0.3 JSON-RPC still works, but if the handler inspects its own `supported_interfaces` for versioning, **the caller must advertise a 0.3 interface on that card**. + +### Client: `force_v0_3` (usually leave off) + +The default follows the AgentCard. A complete 1.0 card or a complete 0.3 card does **not** need this flag. + +Set `force_v0_3=True` only when you **know** the peer is a 0.3 server and following the card would fail (or the card should not be trusted), for example: + +- A legacy 0.3 server with an empty card url (allowed in 0.3), so discovery yields no usable JSONRPC address +- The card does not match the process, and you know the peer accepts 0.3 + +The 0.3 transport posts to the card's JSONRPC url when present, otherwise `agent_base_url`. + +```python +remote_agent = TrpcRemoteA2aAgent( + name="weather_agent", + agent_base_url="http://127.0.0.1:18081", + force_v0_3=True, # the peer is a 0.3 server; force the legacy wire +) +``` + +### 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 | complete 0.3 card: default; otherwise `force_v0_3=True` | + +> On the client, `force_v0_3=True` means "the peer is 0.3; force the legacy wire". Leave it off in the usual case. Server `enable_v0_3_compat` still means "a 1.0 server also accepts 0.3 clients". Do not mix the two. + +> Runnable example: [examples/a2a](../../../examples/a2a/README.md) — the same example covers all three combinations via two environment variables: `A2A_V03_COMPAT` on the server and `A2A_FORCE_V03` on the client. + --- ## Client Usage @@ -151,7 +233,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 +325,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 +568,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..9bea7640c 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,123 @@ 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` | 默认 `False`:只服务 1.0,`/.well-known/agent.json` 返回 404。设为 `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,**仅 1.0** 时服务仍能启动——JSON-RPC 直连的客户端不读卡片——但会打出一条 warning,因为依赖卡片发现的客户端无法调用该 Agent。开启 `enable_v0_3_compat=True` 时,空 url 会 **raise `ValueError`**:0.3 发现不能没有顶层 `url`。 + +--- + +## 从 v0.3 升级 + +SDK 底层协议从 a2a 0.3 升级到 1.0,对应用层主要有两方面:**代码写法要迁移**(下节),**运行时兼容需显式开启**(兼容开关)。**默认发现只服务 1.0 客户端**:卡片发布在 `/.well-known/agent-card.json`。旧 0.3 客户端(`A2ACardResolver`)默认拉取 `/.well-known/agent.json`,该路径**默认不发布**(404);要兼容旧客户端必须设置 `enable_v0_3_compat=True`。 + +### 代码写法迁移(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=...`;若自己传入 `agent_card`,则在卡上写 1.0 字段(见下行) | **自动建卡必须配 `rpc_url`**,见下文 | +| 手写 / 传入的 `AgentCard`(顶层 `url`、`preferredTransport`) | `supported_interfaces[]`(`url`、`protocol_binding`、`protocol_version`) | **自定义 `agent_card=` 也要改成 1.0 布局**,不能只填顶层 `url`;0.3 客户端发现仍用顶层 `url`,1.0 用 `supported_interfaces` | + +> 上表是**业务代码**要改的。此外 a2a-sdk 底层还有 `A2AClient`(已删除 → `await create_client()`)等 API 变化,但都被封装在 SDK 内部,业务代码无需处理。业务代码通常只需:服务端加 `rpc_url`(或把自定义 `agent_card` 改成 1.0 布局)+ 改用 `create_a2a_application`;客户端用 `TrpcRemoteA2aAgent`。 + +### 服务端:开启 `enable_v0_3_compat` 兼容旧客户端 + +1.0 服务端默认只接受 1.0 客户端(只发布 `/.well-known/agent-card.json`,`/.well-known/agent.json` 为 404)。如果线上仍有旧版 0.3 客户端(尚未升级),服务端在**同一端点**同时接受 1.0 和 0.3 报文: + +```python +app = create_a2a_application(a2a_svc, enable_v0_3_compat=True) +``` + +开启开关后,框架会: + +- 在 `/.well-known/agent.json` 发布同一张卡(0.3 `A2ACardResolver` 的默认发现路径) +- 往 **well-known 发现用的卡片副本**追加 `protocol_version="0.3"` 接口(复用同一个 url) +- 打开 JSON-RPC 的 0.3 解码 + +**仅在该开关开启时**,旧 0.3 客户端**无需改动**。默认关闭时,0.3 客户端无法发现该服务。 + +Compat **要求**卡片上有可达 url(`TrpcA2aAgentService(rpc_url=...)` 或自定义 `agent_card`)。0.3 well-known 卡的顶层 `url` 从该接口复制而来,空 url 无法凭空补上。`create_a2a_application(..., enable_v0_3_compat=True)` 在所有接口 url 都为空时会 **raise `ValueError`**,避免服务看似启动成功、旧客户端却发现失败。 + +未传入自定义 `request_handler` 时,内部构造的 `DefaultRequestHandler` 使用同一份带 0.3 接口的副本。传入自定义 handler 时,开关**不会**改写 `handler.agent_card`:JSON-RPC 仍可按 0.3 报文工作,但若 handler 自己读 `supported_interfaces` 做版本判断,**须由调用方在该卡上自行声明 0.3 接口**。 + +### 客户端:`force_v0_3`(通常不用开) + +默认跟 AgentCard 走:完整的 1.0 卡、完整的 0.3 卡都不必开这个开关。 + +只有你**明确知道对端是 0.3 服务端**,且默认跟卡走不通(或不该信这张卡)时再开,例如: + +- 旧 0.3 服务端没填卡片 url(0.3 允许空 url),发现后没有可用的 JSONRPC 地址 +- 卡片声明和进程不一致,你确认对端收的是 0.3 报文 + +0.3 transport 的 POST 地址优先用卡片上的 JSONRPC url,没有时才回退 `agent_base_url`。 + +```python +remote_agent = TrpcRemoteA2aAgent( + name="weather_agent", + agent_base_url="http://127.0.0.1:18081", + force_v0_3=True, # 明确对端是 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 服务端 | 完整 0.3 卡用默认;卡不可用时 `force_v0_3=True` | + +> 客户端 `force_v0_3=True` 表示「我确认对端是 0.3,强制旧报文」,通常不用开。服务端 `enable_v0_3_compat` 仍表示「1.0 服务同时收 0.3 客户端」,两者不要混用。 + +> 完整可运行示例见 [examples/a2a](../../../examples/a2a/README.md)(同一个 example 通过服务端 `A2A_V03_COMPAT` 和客户端 `A2A_FORCE_V03` 两个环境变量覆盖三种组合)。 + --- ## 客户端调用 @@ -151,7 +232,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 +324,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 +567,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..d33d9fc32 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,14 @@ 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(1.x):`http://127.0.0.1:18081/.well-known/agent-card.json` +- Agent Card(0.3,仅 `A2A_V03_COMPAT=1` 时发布):`http://127.0.0.1:18081/.well-known/agent.json` ### 4. 启动客户端 @@ -58,6 +62,64 @@ cd examples/a2a python3 test_a2a.py ``` +## 三种调用链路 + +同一个 example(`run_server.py` + `test_a2a.py`)通过两个环境变量覆盖三种协议组合:服务端 `A2A_V03_COMPAT`(同时接受 0.3 客户端),客户端 `A2A_FORCE_V03`(强制打 0.3 报文)。 + +| 场景 | 服务端命令 | 客户端命令 | +|---|---|---| +| **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_FORCE_V03=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 客户端,并发布 `/.well-known/agent.json`),**此时** 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 服务端 + +对端是旧 0.3 服务端、且卡片没法按默认路径用时(例如没填 url),客户端打开 `force_v0_3=True`(本示例用 `A2A_FORCE_V03=1`),强制走 0.3 报文。完整的 0.3 卡走默认即可。 + +```bash +# 终端 A:旧版(v0.3)服务端 +cd old_version/trpc-agent-python/examples/a2a +python3 run_server.py +# 终端 B:1.0 客户端,指定对端是 0.3 +cd examples/a2a +A2A_FORCE_V03=1 python3 test_a2a.py +``` + +等价于在代码里: + +```python +remote_agent = TrpcRemoteA2aAgent( + name="weather_agent", + agent_base_url="http://127.0.0.1:18081", + force_v0_3=True, # 明确对端是 0.3,强制旧报文 +) +``` + +> 通常不用开:完整 1.0 / 0.3 卡跟卡片走即可。`force_v0_3=True` 用于你确认对端是 0.3、但卡不可用的情况(例如 0.3 空 url)。发现失败则初始化失败。 + ## 运行结果(实测) ### 服务端输出 @@ -66,7 +128,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 +186,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_FORCE_V03=1` 强制走 0.3 报文,不做协商) | | `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..9e6abab4a 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 (when the 0.3 card cannot be used as-is): + ``A2A_FORCE_V03=1 python3 test_a2a.py`` (``force_v0_3=True``) """ 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,14 @@ async def main(): print("Note: Ensure the A2A server is running (python run_server.py)") print() + # Leave force_v0_3 off unless the peer is a 0.3 server whose card + # cannot be used as-is (A2A_FORCE_V03=1). + force_v0_3 = os.getenv("A2A_FORCE_V03", "").strip().lower() in ("1", "true", "yes") remote_agent = TrpcRemoteA2aAgent( name="weather_agent", agent_base_url=AGENT_BASE_URL, description="Professional weather query assistant", + force_v0_3=force_v0_3, ) 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/pipeline_test/requirements-ecosystem.txt b/pipeline_test/requirements-ecosystem.txt index b748a2b1c..85faa0a87 100644 --- a/pipeline_test/requirements-ecosystem.txt +++ b/pipeline_test/requirements-ecosystem.txt @@ -7,7 +7,7 @@ trpc_redis==0.2.0a0 trpc_a2a[redis]>=0.2.7 trpc_mcp==0.2.1a0 -a2a-sdk<1.0.0,>=0.3.22 +a2a-sdk<2.0.0,>=1.0.0 claude-agent-sdk>=0.1.3,<0.1.64 cloudpickle>=2.0.0 ag-ui-protocol>=0.1.8 diff --git a/pipeline_test/requirements.txt b/pipeline_test/requirements.txt index e1c03a7d7..6efc191e5 100644 --- a/pipeline_test/requirements.txt +++ b/pipeline_test/requirements.txt @@ -3,7 +3,7 @@ # private pypi --extra-index-url https://mirrors.tencent.com/repository/pypi/tencent_pypi/simple/ -a2a-sdk<1.0.0,>=0.3.22 +a2a-sdk<2.0.0,>=1.0.0 claude-agent-sdk>=0.1.3,<0.1.64 cloudpickle>=2.0.0 ag-ui-protocol>=0.1.8 diff --git a/pyproject.toml b/pyproject.toml index 33c466a5e..6db2fd5ce 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,9 @@ knowledge = [ ] a2a = [ - "a2a-sdk<1.0.0,>=0.3.22", + "a2a-sdk<2.0.0,>=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<2.0.0,>=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..b29c62061 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<2.0.0,>=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..51b97c628 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 @@ -48,6 +42,7 @@ REQUEST_EUC_FUNCTION_CALL_NAME, ) from trpc_agent_sdk.server.a2a.converters._event_converter import ( + _a2a_part_requests_euc_auth, _build_context_metadata, _build_event_metadata, _build_message, @@ -63,6 +58,7 @@ _infer_message_tag, _is_streaming_delta, _mark_long_running_tools, + _metadata_to_dict, build_request_message_metadata, convert_a2a_message_to_event, convert_a2a_task_to_event, @@ -122,6 +118,35 @@ 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) + + +# --------------------------------------------------------------------------- +# _metadata_to_dict +# --------------------------------------------------------------------------- +class TestMetadataToDict: + def test_none_returns_empty(self): + assert _metadata_to_dict(None) == {} + + def test_dict_returned_as_is(self): + payload = {"k": "v"} + assert _metadata_to_dict(payload) is payload + + def test_struct_converted(self): + struct = struct_pb2.Struct() + struct.update({"k": "v"}) + assert _metadata_to_dict(struct) == {"k": "v"} + + # --------------------------------------------------------------------------- # build_request_message_metadata # --------------------------------------------------------------------------- @@ -318,24 +343,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 +367,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 +384,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 +439,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 +456,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 +472,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 +487,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 +501,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 +517,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 +535,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 +559,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 +568,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 +586,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 +600,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 +639,71 @@ 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 + + +# --------------------------------------------------------------------------- +# _a2a_part_requests_euc_auth +# --------------------------------------------------------------------------- +def _non_data_part(kind: str, metadata: dict | None = None) -> A2APart: + if kind == "text": + return A2APart(text="hello", metadata=metadata) + if kind == "url": + return A2APart(url="https://example.com/file", metadata=metadata) + raise ValueError(f"unsupported non-data part kind: {kind}") + + +@pytest.mark.parametrize("kind", ["text", "url"]) +class TestA2aPartRequestsEucAuth: + def test_false_for_non_data_part(self, kind): + part = _non_data_part(kind, {"k": "v"}) + assert not part.HasField("data") + assert _a2a_part_requests_euc_auth(part) is False + + def test_false_when_function_call_metadata_without_data(self, kind): + part = _non_data_part( + kind, + { + A2A_DATA_PART_METADATA_TYPE_KEY: A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL, + A2A_DATA_PART_METADATA_IS_LONG_RUNNING_KEY: True, + }, + ) + assert _a2a_part_requests_euc_auth(part) is False + + def test_skips_metadata_to_dict_for_non_data_part(self, kind): + part = _non_data_part(kind, {"k": "v"}) + with patch( + "trpc_agent_sdk.server.a2a.converters._event_converter._metadata_to_dict" + ) as mock_to_dict: + assert _a2a_part_requests_euc_auth(part) is False + mock_to_dict.assert_not_called() + + +class TestA2aPartRequestsEucAuthDuckTyped: + def test_false_for_non_data_part_without_hasfield(self): + from types import SimpleNamespace + + part = SimpleNamespace(metadata={"k": "v"}) + assert _a2a_part_requests_euc_auth(part) is False + + +class TestA2aPartRequestsEucAuthDataPart: + def test_true_for_euc_function_call_data_part(self): + part = _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, + }, + ) + assert _a2a_part_requests_euc_auth(part) is True # --------------------------------------------------------------------------- @@ -638,41 +713,59 @@ 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_text_and_url_parts_stay_working(self): + msg = Message( + message_id="m1", + role=Role.ROLE_AGENT, + parts=[ + A2APart(text="hi"), + A2APart(url="https://example.com/file"), + ], + ) + event = _make_event(text="hi") + ctx = _make_invocation_context() + with patch( + "trpc_agent_sdk.server.a2a.converters._event_converter._metadata_to_dict" + ) as mock_to_dict: + result = _create_status_update_event(msg, ctx, event, "t1", "ctx1", effective_id="m1") + assert result.status.state == TaskState.TASK_STATE_WORKING + mock_to_dict.assert_not_called() 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 +775,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 +787,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 +795,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 +820,58 @@ 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_error_event_does_not_emit_followup_working_or_artifact(self): + """An error Event must not also produce working/status/artifact follow-ups. + + After TASK_STATE_FAILED, converting the same Event would regress the + stream to working (or emit an artifact) and diverge from the aggregator, + which keeps the first failed state. + """ + event = _make_event(text="hi", error_code="500", error_message="fail") + ctx = _make_invocation_context() + notified = [] + events = convert_event_to_a2a_events( + event, ctx, task_id="t1", context_id="ctx1", on_event=notified.append + ) + assert len(events) == 1 + assert isinstance(events[0], TaskStatusUpdateEvent) + assert events[0].status.state == TaskState.TASK_STATE_FAILED + assert not any(isinstance(e, TaskArtifactUpdateEvent) for e in events) + + assert len(notified) == 1 + assert notified[0].status.state == TaskState.TASK_STATE_FAILED + assert not any( + isinstance(e, TaskStatusUpdateEvent) and e.status.state != TaskState.TASK_STATE_FAILED + for e in notified + ) + + def test_partial_error_event_does_not_emit_artifact(self): + event = _make_event(text="hi", error_code="500", error_message="fail", partial=True) + ctx = _make_invocation_context() + notified = [] + events = convert_event_to_a2a_events( + event, ctx, task_id="t1", context_id="ctx1", on_event=notified.append + ) + assert len(events) == 1 + assert events[0].status.state == TaskState.TASK_STATE_FAILED + assert not any(isinstance(e, TaskArtifactUpdateEvent) for e in events) + assert len(notified) == 1 + assert notified[0].status.state == TaskState.TASK_STATE_FAILED 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..590027631 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 ( @@ -40,6 +33,7 @@ A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA, ) from trpc_agent_sdk.server.a2a.converters._part_converter import ( + _a2a_data_to_dict, _a2a_string_field, _convert_a2a_data_part, _convert_streaming_function_call_delta, @@ -64,6 +58,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 +211,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 +239,34 @@ 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 + + def test_with_video_metadata(self): + data = b"video_bytes" + part = genai_types.Part( + inline_data=genai_types.Blob(data=data, mime_type="video/mp4"), + video_metadata=genai_types.VideoMetadata(fps=24.0, start_offset="0s"), + ) + result = _genai_inline_file_to_a2a(part) + assert result.HasField("raw") + assert result.raw == data + video_meta = _meta_dict(result)["video_metadata"] + assert video_meta["fps"] == 24.0 + assert video_meta["startOffset"] == "0s" + + +# --------------------------------------------------------------------------- +# _a2a_data_to_dict +# --------------------------------------------------------------------------- +class TestA2aDataToDict: + def test_dict_returned_as_is(self): + payload = {"k": "v"} + assert _a2a_data_to_dict(payload) is payload + + def test_unsupported_returns_empty(self): + assert _a2a_data_to_dict(None) == {} + assert _a2a_data_to_dict("not-a-dict") == {} class TestGenaiStreamingFunctionCallToA2a: @@ -230,42 +274,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 +320,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 +402,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 +428,50 @@ 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 + + def test_duck_typed_text_part_without_hasfield(self): + from types import SimpleNamespace + + result = convert_a2a_part_to_genai_part(SimpleNamespace(text="hello", metadata=None)) + assert result is not None + assert result.text == "hello" + + def test_duck_typed_unsupported_part_without_hasfield_returns_none(self): + from types import SimpleNamespace + + result = convert_a2a_part_to_genai_part(SimpleNamespace()) 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..cc14ee704 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,36 @@ 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_plain_dict(self): + # Duck-typed messages may still carry a plain dict (0.3 / tests). + msg = MagicMock() + msg.parts = [Part(text="hi")] + msg.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"] is msg.metadata + + 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..fea64af9d 100644 --- a/tests/server/a2a/executor/test_a2a_agent_executor.py +++ b/tests/server/a2a/executor/test_a2a_agent_executor.py @@ -16,16 +16,20 @@ Message, Part as A2APart, Role, + Task, TaskState, TaskStatus, - TextPart, ) +from trpc_agent_sdk.events import Event +from trpc_agent_sdk.exceptions import RunLimitException, RunLimitType from trpc_agent_sdk.runners import Runner from trpc_agent_sdk.server.a2a.executor._a2a_agent_executor import ( TrpcA2aAgentExecutor, TrpcA2aAgentExecutorConfig, + _metadata_to_dict, ) +from trpc_agent_sdk.types import Content, Part as GenaiPart def _make_runner(): @@ -59,6 +63,58 @@ def _make_event_queue(): return queue +def _execute_patches(): + return ( + 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(), + ), + ) + + +def _status_states(enqueued) -> list: + return [ + e.status.state + for e in enqueued + if hasattr(e, "status") and e.status is not None + ] + + +def _struct_metadata(**fields): + from google.protobuf.struct_pb2 import Struct + + meta = Struct() + meta.update(fields) + return meta + + +# --------------------------------------------------------------------------- +# _metadata_to_dict +# --------------------------------------------------------------------------- +class TestMetadataToDict: + def test_none_returns_empty(self): + assert _metadata_to_dict(None) == {} + + def test_dict_returned_as_is(self): + payload = {"k": "v"} + assert _metadata_to_dict(payload) is payload + + # --------------------------------------------------------------------------- # TrpcA2aAgentExecutorConfig # --------------------------------------------------------------------------- @@ -151,6 +207,22 @@ def test_returns_none_when_no_config(self): # --------------------------------------------------------------------------- class TestGetUserSessionFromTaskMetadata: def test_with_metadata(self): + # 1.x Task.metadata is a protobuf Struct; this is the production path + # through _metadata_to_dict -> MessageToDict. + ctx = _make_context() + ctx.current_task = Task( + id="task-1", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), + metadata=_struct_metadata(app_name="app", user_id="u1", session_id="s1"), + ) + executor = TrpcA2aAgentExecutor(runner=_make_runner()) + app, user, session = executor._get_user_session_from_task_metadata(ctx) + assert app == "app" + assert user == "u1" + assert session == "s1" + + def test_with_dict_metadata(self): ctx = _make_context() ctx.current_task = MagicMock() ctx.current_task.metadata = { @@ -189,12 +261,12 @@ async def test_cancel_with_task_metadata(self): runner = _make_runner() executor = TrpcA2aAgentExecutor(runner=runner) ctx = _make_context() - ctx.current_task = MagicMock() - ctx.current_task.metadata = { - "app_name": "app", - "user_id": "u1", - "session_id": "s1", - } + ctx.current_task = Task( + id="task-1", + context_id="ctx-1", + status=TaskStatus(state=TaskState.TASK_STATE_WORKING), + metadata=_struct_metadata(app_name="app", user_id="u1", session_id="s1"), + ) queue = _make_event_queue() await executor.cancel(ctx, queue) runner.cancel_run_async.assert_awaited_once() @@ -234,7 +306,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 +340,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 +365,142 @@ 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 + ) + + async def test_empty_run_ends_with_completed(self): + msg = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) + runner = _make_runner() + + async def empty_run(**kwargs): + return + yield + + runner.run_async = empty_run + executor = TrpcA2aAgentExecutor(runner=runner) + ctx = _make_context(message=msg, current_task=None) + ctx.call_context = None + queue = _make_event_queue() + + p_convert, p_cancelled, p_ctx = _execute_patches() + with p_convert, p_cancelled, p_ctx: + runner._new_invocation_context = MagicMock() + await executor.execute(ctx, queue) + + enqueued = [call.args[0] for call in queue.enqueue_event.call_args_list] + states = _status_states(enqueued) + assert states[-1] == TaskState.TASK_STATE_COMPLETED + assert TaskState.TASK_STATE_WORKING in states + assert states[-1] != TaskState.TASK_STATE_WORKING + + async def test_execution_error_does_not_append_completed(self): + msg = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) + runner = _make_runner() + + async def content_then_fail(**kwargs): + yield Event( + invocation_id="inv-1", + author="agent", + content=Content(role="model", parts=[GenaiPart(text="hello")]), + partial=True, + ) + raise RuntimeError("boom") + + runner.run_async = content_then_fail + executor = TrpcA2aAgentExecutor(runner=runner) + ctx = _make_context(message=msg, current_task=None) + ctx.call_context = None + queue = _make_event_queue() + + p_convert, p_cancelled, p_ctx = _execute_patches() + with p_convert, p_cancelled, p_ctx: + runner._new_invocation_context = MagicMock() + await executor.execute(ctx, queue) + + enqueued = [call.args[0] for call in queue.enqueue_event.call_args_list] + states = _status_states(enqueued) + assert TaskState.TASK_STATE_FAILED in states + failed_idx = max(i for i, s in enumerate(states) if s == TaskState.TASK_STATE_FAILED) + assert TaskState.TASK_STATE_COMPLETED not in states[failed_idx:] + + async def test_run_limit_error_does_not_append_completed(self): + msg = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) + runner = _make_runner() + + async def hit_limit(**kwargs): + yield Event( + invocation_id="inv-1", + author="agent", + content=Content(role="model", parts=[GenaiPart(text="hello")]), + partial=True, + ) + raise RunLimitException( + agent_name="agent", + limit_type=RunLimitType.MAX_LLM_CALLS, + configured_value=1, + observed_value=2, + ) + + runner.run_async = hit_limit + executor = TrpcA2aAgentExecutor(runner=runner) + ctx = _make_context(message=msg, current_task=None) + ctx.call_context = None + queue = _make_event_queue() + + p_convert, p_cancelled, p_ctx = _execute_patches() + with p_convert, p_cancelled, p_ctx: + runner._new_invocation_context = MagicMock() + await executor.execute(ctx, queue) + + enqueued = [call.args[0] for call in queue.enqueue_event.call_args_list] + states = _status_states(enqueued) + assert TaskState.TASK_STATE_FAILED in states + failed_idx = max(i for i, s in enumerate(states) if s == TaskState.TASK_STATE_FAILED) + assert TaskState.TASK_STATE_COMPLETED not in states[failed_idx:] + # --------------------------------------------------------------------------- # _prepare_session diff --git a/tests/server/a2a/executor/test_request_handler_stream_contract.py b/tests/server/a2a/executor/test_request_handler_stream_contract.py new file mode 100644 index 000000000..ca1c6f7e8 --- /dev/null +++ b/tests/server/a2a/executor/test_request_handler_stream_contract.py @@ -0,0 +1,192 @@ +# 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. +"""Contract tests: non-working intermediate states must not truncate the A2A stream. + +``TaskResultAggregator`` no longer rewrites intermediate ``TaskStatusUpdateEvent`` +states to ``working`` (a2a-sdk 1.x protobuf events are shared and must not be +mutated). That is safe only if ``DefaultRequestHandler`` keeps consuming after +``input_required`` / ``auth_required``. These tests drive a real handler and +its ``EventQueue`` to lock that SDK contract in. +""" + +from __future__ import annotations + +import asyncio + +import pytest +from a2a.server.agent_execution import AgentExecutor +from a2a.server.context import ServerCallContext +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentInterface, + Artifact, + Message, + Part, + Role, + SendMessageRequest, + Task, + TaskArtifactUpdateEvent, + TaskState, + TaskStatus, + TaskStatusUpdateEvent, +) + +from trpc_agent_sdk.server.a2a.executor._task_result_aggregator import TaskResultAggregator + + +def _agent_card() -> AgentCard: + return AgentCard( + name="stream-contract", + 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=""), + ], + skills=[], + ) + + +def _status_event(task_id: str, context_id: str, state: TaskState, text: str) -> TaskStatusUpdateEvent: + return TaskStatusUpdateEvent( + task_id=task_id, + context_id=context_id, + status=TaskStatus( + state=state, + message=Message( + message_id="status-msg", + role=Role.ROLE_AGENT, + parts=[Part(text=text)], + ), + ), + ) + + +class _ScriptedExecutor(AgentExecutor): + """Enqueue Task → working → interrupted status → artifact → completed. + + The interrupted status is observed by ``TaskResultAggregator`` before it is + published, matching production: the aggregator must not rewrite the event. + """ + + def __init__(self, interrupted_state: TaskState): + self._interrupted_state = interrupted_state + self.observed_interrupted_state: TaskState | None = None + + async def execute(self, context, event_queue) -> None: + task_id = context.task_id + context_id = context.context_id + await event_queue.enqueue_event( + Task( + id=task_id, + context_id=context_id, + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), + history=[context.message] if context.message else [], + ) + ) + await event_queue.enqueue_event( + _status_event(task_id, context_id, TaskState.TASK_STATE_WORKING, "working") + ) + + interrupted = _status_event( + task_id, context_id, self._interrupted_state, "need input or auth" + ) + aggregator = TaskResultAggregator() + aggregator.process_event(interrupted) + self.observed_interrupted_state = interrupted.status.state + await event_queue.enqueue_event(interrupted) + + await event_queue.enqueue_event( + TaskArtifactUpdateEvent( + task_id=task_id, + context_id=context_id, + artifact=Artifact( + artifact_id="art-1", + parts=[Part(text="later artifact")], + ), + last_chunk=False, + ) + ) + await event_queue.enqueue_event( + _status_event(task_id, context_id, TaskState.TASK_STATE_COMPLETED, "done") + ) + + async def cancel(self, context, event_queue) -> None: + return + + +async def _collect_stream(interrupted_state: TaskState) -> tuple[_ScriptedExecutor, list]: + executor = _ScriptedExecutor(interrupted_state) + handler = DefaultRequestHandler( + agent_executor=executor, + task_store=InMemoryTaskStore(), + agent_card=_agent_card(), + ) + params = SendMessageRequest( + tenant="", + message=Message( + message_id="user-1", + role=Role.ROLE_USER, + parts=[Part(text="hello")], + ), + ) + try: + events = [] + async for event in handler.on_message_send_stream(params, ServerCallContext()): + events.append(event) + return executor, events + finally: + await handler.aclose() + + +def _status_states(events: list) -> list[TaskState]: + return [e.status.state for e in events if isinstance(e, TaskStatusUpdateEvent)] + + +@pytest.mark.parametrize( + "interrupted_state", + [ + pytest.param(TaskState.TASK_STATE_INPUT_REQUIRED, id="input_required"), + pytest.param(TaskState.TASK_STATE_AUTH_REQUIRED, id="auth_required"), + ], +) +@pytest.mark.asyncio +async def test_non_working_intermediate_status_does_not_truncate_stream(interrupted_state): + executor, events = await asyncio.wait_for( + _collect_stream(interrupted_state), + timeout=5, + ) + + # Aggregator observes but does not rewrite the shared protobuf event. + assert executor.observed_interrupted_state == interrupted_state + + states = _status_states(events) + assert interrupted_state in states + assert TaskState.TASK_STATE_COMPLETED in states + + interrupted_idx = next( + i for i, e in enumerate(events) + if isinstance(e, TaskStatusUpdateEvent) and e.status.state == interrupted_state + ) + later = events[interrupted_idx + 1:] + assert any(isinstance(e, TaskArtifactUpdateEvent) for e in later), ( + f"{interrupted_state} truncated the stream; later artifact was dropped. " + f"events after interrupt: {[type(e).__name__ for e in later]}" + ) + assert any( + isinstance(e, TaskStatusUpdateEvent) and e.status.state == TaskState.TASK_STATE_COMPLETED + for e in later + ), ( + f"{interrupted_state} truncated the stream; completed status was dropped. " + f"events after interrupt: {[type(e).__name__ for e in later]}" + ) + artifact = next(e for e in later if isinstance(e, TaskArtifactUpdateEvent)) + assert artifact.artifact.parts[0].text == "later artifact" 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..0f161d5cc 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,20 +20,26 @@ 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, _is_a2a_message, _is_a2a_task, _is_a2a_text_part, + _metadata_dict, build_a2a_request_log, build_a2a_response_log, build_message_part_log, ) +def _data_part(data: dict) -> Part: + return Part(data=ParseDict(data, struct_pb2.Value())) + + # --------------------------------------------------------------------------- # Type guard helpers # --------------------------------------------------------------------------- @@ -47,7 +48,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,27 +64,50 @@ 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): assert _is_a2a_message(42) is False + def test_duck_type_fallback(self): + FakeMessage = type("Message", (), {"role": "agent"}) + obj = FakeMessage() + with patch("trpc_agent_sdk.server.a2a.logs._log_utils.A2AMessage", "not_a_type"): + assert _is_a2a_message(obj) is True + + +# --------------------------------------------------------------------------- +# _metadata_dict +# --------------------------------------------------------------------------- +class TestMetadataDict: + def test_none_returns_empty(self): + assert _metadata_dict(None) == {} + + def test_dict_returned_as_is(self): + payload = {"k": "v"} + assert _metadata_dict(payload) is payload + + def test_struct_converted(self): + struct = struct_pb2.Struct() + struct.update({"k": "v"}) + assert _metadata_dict(struct) == {"k": "v"} + 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 +115,49 @@ 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_data_part_scalar_value(self): + # MessageToDict(Value) of a non-object is a scalar/list, not a dict. + part = Part(data=ParseDict("scalar", struct_pb2.Value())) + log = build_message_part_log(part) + assert "DataPart:" in log + assert "scalar" in log + + 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_raw_part(self): + part = Part(raw=b"hello", media_type="application/octet-stream") + log = build_message_part_log(part) + assert "FilePart: raw bytes (5 bytes)" in log + assert "application/octet-stream" 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 +170,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 +213,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 +253,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) @@ -258,3 +276,52 @@ def test_message_response_with_metadata(self): resp = self._make_message_response(metadata={"k": "v"}) log = build_a2a_response_log(resp) assert "Metadata:" in log + + def test_error_response(self): + # a2a-sdk 1.x protobuf SendMessageResponse has no error oneof; JSON-RPC + # errors arrive as JSONRPCErrorResponse (compat) / JSONRPCError. + from a2a.compat.v0_3.types import JSONRPCErrorResponse + + resp = JSONRPCErrorResponse.model_validate({ + "id": "resp-1", + "jsonrpc": "2.0", + "error": { + "code": -32600, + "message": "Invalid request", + "data": {"reason": "bad payload"}, + }, + }) + log = build_a2a_response_log(resp) + assert "Type: ERROR" in log + assert "Type: SUCCESS" not in log + assert "Error Code: -32600" in log + assert "Invalid request" in log + assert "bad payload" in log + assert "Response ID: resp-1" in log + assert "JSON-RPC: 2.0" in log + + def test_error_response_proto_oneof(self): + class _Error: + code = -32001 + message = "Task not found" + data = None + + class _ProtoErrorResponse: + error = _Error() + id = "resp-2" + jsonrpc = "2.0" + + def HasField(self, name): + return name == "error" + + log = build_a2a_response_log(_ProtoErrorResponse()) + assert "Type: ERROR" in log + assert "Error Code: -32001" in log + assert "Task not found" in log + assert "Error Data: None" in log + + def test_empty_protobuf_response_is_success(self): + # 1.x SendMessageResponse has no error field; empty payload is not ERROR. + log = build_a2a_response_log(SendMessageResponse()) + assert "Type: SUCCESS" in log + assert "No result" in log diff --git a/tests/server/a2a/test_agent_card_builder.py b/tests/server/a2a/test_agent_card_builder.py index c0037fe7b..e3a930250 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) @@ -168,6 +170,14 @@ def test_does_not_duplicate(self): uris = [e.uri for e in caps.extensions] assert uris.count(EXTENSION_TRPC_A2A_VERSION) == 1 + def test_does_not_mutate_input(self): + original = AgentCapabilities(streaming=True) + result = _capabilities_with_trpc_extension(original) + assert result is not original + assert [e.uri for e in original.extensions] == [] + assert original.streaming is True + assert EXTENSION_TRPC_A2A_VERSION in [e.uri for e in result.extensions] + def test_none_input(self): caps = _capabilities_with_trpc_extension(None) assert caps.extensions is not 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..e73bbf8a4 --- /dev/null +++ b/tests/server/a2a/test_application.py @@ -0,0 +1,289 @@ +# 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 + +import pytest +from a2a.server.request_handlers import DefaultRequestHandler +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 | None): + 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_origin_slash_stays_root(self): + assert _jsonrpc_path_from_card(_make_card(url="http://host:18081/")) == "/" + + def test_empty_url_defaults_to_root(self): + assert _jsonrpc_path_from_card(_make_card(url="")) == "/" + + def test_origin_with_query_defaults_to_root(self): + assert _jsonrpc_path_from_card(_make_card(url="http://host?q=1")) == "/" + + def test_origin_with_fragment_defaults_to_root(self): + assert _jsonrpc_path_from_card(_make_card(url="http://host#frag")) == "/" + + def test_path_ignores_query_and_fragment(self): + assert _jsonrpc_path_from_card(_make_card(url="https://agent.example.com/a2a?x=1#y")) == "/a2a" + + 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_none_agent_card_raises(self): + # Card is auto-built in initialize(); assembling before that (or without + # passing agent_card=) must fail with a clear contract error, not an + # AttributeError inside the route factories. + svc = _make_service(None) + with pytest.raises(ValueError, match="agent_card is None"): + create_a2a_application(svc) + + 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_default_does_not_serve_legacy_agent_json(self): + from starlette.testclient import TestClient + + client = TestClient(create_a2a_application(_make_service(_make_card(url="http://host:18081")))) + assert client.get("/.well-known/agent-card.json").status_code == 200 + assert client.get("/.well-known/agent.json").status_code == 404 + + def test_compat_serves_legacy_agent_json(self): + from starlette.testclient import TestClient + + client = TestClient( + create_a2a_application( + _make_service(_make_card(url="http://host:18081")), + enable_v0_3_compat=True, + )) + modern = client.get("/.well-known/agent-card.json") + legacy = client.get("/.well-known/agent.json") + assert modern.status_code == 200 + assert legacy.status_code == 200 + assert modern.json() == legacy.json() + + def test_compat_does_not_rewrite_custom_handler_card(self): + from starlette.testclient import TestClient + + card = _make_card(url="http://host:18081") + svc = _make_service(card) + handler = DefaultRequestHandler( + agent_executor=svc, + task_store=InMemoryTaskStore(), + agent_card=card, + ) + app = create_a2a_application( + svc, enable_v0_3_compat=True, request_handler=handler) + handler_versions = [ + (i.protocol_binding, i.protocol_version) + for i in handler._agent_card.supported_interfaces + ] + # Custom handlers are used as-is: compat patches the well-known card + # copy only. Callers that need 0.3 on handler.agent_card must add it. + assert ("JSONRPC", "0.3") not in handler_versions + served = TestClient(app).get("/.well-known/agent-card.json").json() + assert served.get("url") == "http://host:18081" + + def test_compat_adds_v03_interface_without_mutating_service_card(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, enable_v0_3_compat=True) + service_versions = [ + (i.protocol_binding, i.protocol_version) + for i in svc.agent_card.supported_interfaces + ] + assert ("JSONRPC", "0.3") not in service_versions + app_card = MockHandler.call_args.kwargs["agent_card"] + assert app_card is not svc.agent_card + app_versions = [ + (i.protocol_binding, i.protocol_version) + for i in app_card.supported_interfaces + ] + assert ("JSONRPC", "1.0") in app_versions + assert ("JSONRPC", "0.3") in app_versions + + def test_compat_with_missing_url_raises(self): + # Compat exists so 0.3 clients can discover the service. An empty + # interface url cannot be patched into a usable top-level url, so + # starting would look successful while discovery still fails. + svc = _make_service(_make_card(url="")) + with pytest.raises(ValueError, match="enable_v0_3_compat requires a reachable"): + create_a2a_application(svc, enable_v0_3_compat=True) + + def test_jsonrpc_mount_matches_card_url_path(self): + # Deriving the path in _jsonrpc_path_from_card is not enough: the + # Starlette JSON-RPC route must actually be mounted there, or clients + # discover /a2a and POST to /. + from urllib.parse import urlparse + + advertised = "https://x/a2a" + card = _make_card(url=advertised) + app = create_a2a_application(_make_service(card)) + + expected_path = urlparse(advertised).path + assert card.supported_interfaces[0].url == advertised + assert expected_path == _jsonrpc_path_from_card(card) + + post_paths = [ + route.path + for route in app.routes + if "POST" in (getattr(route, "methods", None) or set()) + ] + assert expected_path in post_paths + assert "/" not in post_paths + + from starlette.testclient import TestClient + + client = TestClient(app) + assert client.post(expected_path, json={}).status_code != 404 + assert client.post("/", json={}).status_code == 404 + + def test_jsonrpc_mount_strips_trailing_slash(self): + card = _make_card(url="https://x/a2a/") + app = create_a2a_application(_make_service(card)) + assert _jsonrpc_path_from_card(card) == "/a2a" + post_paths = [ + route.path + for route in app.routes + if "POST" in (getattr(route, "methods", None) or set()) + ] + assert "/a2a" in post_paths + assert "/a2a/" not in post_paths + + from starlette.testclient import TestClient + + client = TestClient(app, follow_redirects=False) + assert client.post("/a2a", json={}).status_code != 404 diff --git a/tests/server/a2a/test_remote_a2a_agent.py b/tests/server/a2a/test_remote_a2a_agent.py index 83582bdf8..b533df6fb 100644 --- a/tests/server/a2a/test_remote_a2a_agent.py +++ b/tests/server/a2a/test_remote_a2a_agent.py @@ -14,16 +14,17 @@ from a2a.types import ( AgentCapabilities, AgentCard, + AgentInterface, Artifact, Message, Part as A2APart, Role, + StreamResponse, Task, TaskArtifactUpdateEvent, 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_legacy_injected_client_does_not_require_url(self): + client = MagicMock() + agent = TrpcRemoteA2aAgent( + name="remote", + a2a_client=client, + force_v0_3=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") @@ -126,6 +187,8 @@ async def test_without_card_resolves(self): result = await agent.initialize() assert result is True assert agent._agent_card is mock_card + assert type(agent._a2a_client._transport).__name__ == "JsonRpcTransport" + assert agent._a2a_client._transport.url == "http://remote:8080" if agent._httpx_client: await agent._httpx_client.aclose() @@ -155,6 +218,246 @@ async def test_without_base_url_and_card_raises(self): result = await agent.initialize() assert result is False + async def test_force_v0_3_uses_compat_wire_even_for_1_0_card(self): + # force_v0_3=True means the peer is 0.3. A 1.0-shaped card must not + # switch the client onto JsonRpcTransport / create_client negotiation. + mock_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=mock_card) + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://127.0.0.1:18081", + force_v0_3=True, + ) + result = await agent.initialize() + assert result is True + MockResolver.assert_called_once() + assert type(agent._a2a_client._transport).__name__ == "CompatJsonRpcTransport" + assert agent._a2a_client._transport.url == "http://remote:8080" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_force_v0_3_without_base_url_raises(self): + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://x", + force_v0_3=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_build_v0_3_client_requires_url(self): + # Card with no JSONRPC url and no agent_base_url: the raise at + # _build_v0_3_a2a_client must fire (initialize() would swallow it). + card = _make_agent_card() + card.ClearField("supported_interfaces") + agent = TrpcRemoteA2aAgent(name="remote", agent_card=card, force_v0_3=True) + with pytest.raises(ValueError, match="agent_base_url is required for force_v0_3"): + await agent._build_v0_3_a2a_client() + empty_card = _make_agent_card() + empty_card.ClearField("supported_interfaces") + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://127.0.0.1:18081", + force_v0_3=True, + ) + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver: + MockResolver.return_value.get_agent_card = AsyncMock(return_value=empty_card) + result = await agent.initialize() + assert result is True + assert list(empty_card.supported_interfaces) == [] + 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_force_v0_3_empty_interface_url_uses_agent_base_url(self): + card = _make_agent_card() + card.supported_interfaces[0].url = "" + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://127.0.0.1:18081", + force_v0_3=True, + ) + with patch("trpc_agent_sdk.server.a2a._remote_a2a_agent.A2ACardResolver") as MockResolver: + MockResolver.return_value.get_agent_card = AsyncMock(return_value=card) + result = await agent.initialize() + assert result is True + 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_force_v0_3_discovery_failure_returns_false(self): + # Both flags require a card. A failed fetch must not fall back to + # posting 0.3 JSON-RPC at agent_base_url with no AgentCard. + 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")) + agent = TrpcRemoteA2aAgent( + name="remote", + agent_base_url="http://127.0.0.1:18081", + force_v0_3=True, + ) + 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_default_uses_1_0_jsonrpc_transport(self): + mock_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=mock_card) + agent = TrpcRemoteA2aAgent(name="remote", agent_base_url="http://remote:8080") + result = await agent.initialize() + assert result is True + assert type(agent._a2a_client._transport).__name__ == "JsonRpcTransport" + assert agent._a2a_client._transport.url == "http://remote:8080" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_default_create_client_follows_card_protocol_version(self): + # Default path leaves transport selection to create_client: a JSONRPC + # interface with protocol_version=0.3 uses CompatJsonRpcTransport. + card = _make_agent_card() + card.supported_interfaces[0].protocol_version = "0.3" + agent = TrpcRemoteA2aAgent( + name="remote", + agent_card=card, + agent_base_url="http://127.0.0.1:18081", + ) + result = await agent.initialize() + assert result is True + assert type(agent._a2a_client._transport).__name__ == "CompatJsonRpcTransport" + assert agent._a2a_client._transport.url == "http://remote:8080" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_empty_jsonrpc_url_filled_from_agent_base_url(self): + card = _make_agent_card() + card.supported_interfaces[0].url = "" + agent = TrpcRemoteA2aAgent( + name="remote", + agent_card=card, + agent_base_url="http://127.0.0.1:18081", + ) + result = await agent.initialize() + assert result is True + assert card.supported_interfaces[0].url == "" + assert agent._agent_card.supported_interfaces[0].protocol_binding == "JSONRPC" + assert agent._agent_card.supported_interfaces[0].url == "http://127.0.0.1:18081" + assert type(agent._a2a_client._transport).__name__ == "JsonRpcTransport" + assert agent._a2a_client._transport.url == "http://127.0.0.1:18081" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_non_empty_jsonrpc_url_not_overwritten(self): + card = _make_agent_card() + agent = TrpcRemoteA2aAgent( + name="remote", + agent_card=card, + agent_base_url="http://127.0.0.1:18081", + ) + result = await agent.initialize() + assert result is True + assert card.supported_interfaces[0].url == "http://remote:8080" + assert agent._a2a_client._transport.url == "http://remote:8080" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_empty_grpc_url_not_filled(self): + card = _make_agent_card() + card.supported_interfaces[0].url = "" + card.supported_interfaces.append( + AgentInterface(protocol_binding="GRPC", protocol_version="1.0", url=""), + ) + agent = TrpcRemoteA2aAgent( + name="remote", + agent_card=card, + agent_base_url="http://127.0.0.1:18081", + ) + result = await agent.initialize() + assert result is True + assert {i.protocol_binding: i.url for i in card.supported_interfaces} == { + "JSONRPC": "", + "GRPC": "", + } + urls_by_binding = {i.protocol_binding: i.url for i in agent._agent_card.supported_interfaces} + assert urls_by_binding["JSONRPC"] == "http://127.0.0.1:18081" + assert urls_by_binding["GRPC"] == "" + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_empty_interfaces_default_does_not_synthesize_jsonrpc(self): + # A 0.3 card with empty top-level url parses to no interfaces. Default + # does not invent a 0.3 JSONRPC binding; create_client has nothing to + # connect with. Use force_v0_3=True to post at agent_base_url. + empty_card = _make_agent_card() + empty_card.ClearField("supported_interfaces") + agent = TrpcRemoteA2aAgent( + name="remote", + agent_card=empty_card, + agent_base_url="http://127.0.0.1:18081", + ) + result = await agent.initialize() + assert result is False + assert list(empty_card.supported_interfaces) == [] + assert agent._a2a_client is None + if agent._httpx_client: + await agent._httpx_client.aclose() + + async def test_grpc_only_card_does_not_synthesize_jsonrpc(self): + card = _make_agent_card() + card.ClearField("supported_interfaces") + card.supported_interfaces.append( + AgentInterface(protocol_binding="GRPC", protocol_version="1.0", url=""), + ) + agent = TrpcRemoteA2aAgent( + name="remote", + agent_card=card, + agent_base_url="http://127.0.0.1:18081", + ) + agent._fill_empty_jsonrpc_urls() + assert [i.protocol_binding for i in card.supported_interfaces] == ["GRPC"] + assert card.supported_interfaces[0].url == "" + + def test_fill_empty_jsonrpc_urls_skips_without_base_url(self): + card = _make_agent_card() + card.supported_interfaces[0].url = "" + agent = TrpcRemoteA2aAgent(name="remote", agent_card=card) + agent._fill_empty_jsonrpc_urls() + assert card.supported_interfaces[0].url == "" + + def test_fill_empty_jsonrpc_urls_does_not_mutate_input_card(self): + card = _make_agent_card() + card.supported_interfaces[0].url = "" + agent_a = TrpcRemoteA2aAgent( + name="a", + agent_card=card, + agent_base_url="http://agent-a:8080", + ) + agent_b = TrpcRemoteA2aAgent( + name="b", + agent_card=card, + agent_base_url="http://agent-b:8080", + ) + agent_a._fill_empty_jsonrpc_urls() + agent_b._fill_empty_jsonrpc_urls() + assert card.supported_interfaces[0].url == "" + assert agent_a._agent_card is not card + assert agent_b._agent_card is not card + assert agent_a._agent_card.supported_interfaces[0].url == "http://agent-a:8080" + assert agent_b._agent_card.supported_interfaces[0].url == "http://agent-b:8080" + + def test_first_jsonrpc_url_none_when_card_missing(self): + agent = TrpcRemoteA2aAgent(name="remote", agent_base_url="http://x") + assert agent._agent_card is None + assert agent._first_jsonrpc_url() is None + # --------------------------------------------------------------------------- # _build_outgoing_message @@ -198,29 +501,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 +584,44 @@ 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 + + def test_empty_payload_returns_none(self): + resp = StreamResponse() + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + assert agent._response_payload(resp) is None + + # --------------------------------------------------------------------------- # _events_from_response # --------------------------------------------------------------------------- @@ -300,24 +632,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 +648,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 +658,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 +668,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 +678,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 +692,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 +702,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 @@ -429,24 +713,46 @@ def test_unknown_result(self): assert len(events) == 1 assert "unknown" in events[0].content.parts[0].text.lower() + def test_none_result_skipped(self): + agent = self._make_agent() + ctx = _make_invocation_context() + assert agent._events_from_response(None, 1, ctx) == [] + 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 +# --------------------------------------------------------------------------- +# _task_id_from_payload +# --------------------------------------------------------------------------- +class TestTaskIdFromPayload: + def _make_agent(self): + return TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + + def test_task_uses_id(self): + # 1.x Task has `id`, not `task_id`. The initial stream event is a Task. + agent = self._make_agent() + task = Task(id="task-from-id", context_id="ctx1", status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED)) + assert agent._task_id_from_payload(task) == "task-from-id" + + def test_status_update_uses_task_id(self): + agent = self._make_agent() + assert agent._task_id_from_payload(_status_event(TaskState.TASK_STATE_WORKING)) == "t1" + + def test_artifact_update_uses_task_id(self): + agent = self._make_agent() + assert agent._task_id_from_payload(_artifact_event()) == "t1" + + def test_unknown_payload_returns_none(self): + agent = self._make_agent() + assert agent._task_id_from_payload("not-a-payload") is None + + # --------------------------------------------------------------------------- # _run_async_impl # --------------------------------------------------------------------------- @@ -470,3 +776,114 @@ async def test_no_message_yields_empty_event(self): events.append(event) assert len(events) == 1 assert events[0].content is not None + + async def test_merges_existing_message_metadata(self): + from google.protobuf.json_format import MessageToDict + + outgoing = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) + outgoing.metadata.update({ + "custom_key": "custom_val", + "nested": {"a": [1, 2, 3]}, + "nullable": None, + }) + + async def empty_stream(): + if False: + yield StreamResponse() + + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + agent._initialized = True + agent._a2a_client = MagicMock() + agent._a2a_client.send_message = MagicMock(return_value=empty_stream()) + ctx = _make_invocation_context() + + with patch.object(agent, "_build_outgoing_message", return_value=outgoing): + async for _ in agent._run_async_impl(ctx): + pass + + request = agent._a2a_client.send_message.call_args.args[0] + merged = MessageToDict(request.message.metadata) + # Framework keys are filled only when absent (same as 0.3). + assert merged["custom_key"] == "custom_val" + assert merged["nested"] == {"a": [1.0, 2.0, 3.0]} + assert merged["user_id"] == "user-1" + assert "nullable" in request.message.metadata + assert request.message.metadata["nullable"] is None + + async def test_existing_user_id_is_not_overwritten_by_context(self): + from google.protobuf.json_format import MessageToDict + + outgoing = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) + outgoing.metadata.update({"user_id": "biz-user", "custom_key": "keep-me"}) + + async def empty_stream(): + if False: + yield StreamResponse() + + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + agent._initialized = True + agent._a2a_client = MagicMock() + agent._a2a_client.send_message = MagicMock(return_value=empty_stream()) + ctx = _make_invocation_context(user_id="user-1") + + with patch.object(agent, "_build_outgoing_message", return_value=outgoing): + async for _ in agent._run_async_impl(ctx): + pass + + request = agent._a2a_client.send_message.call_args.args[0] + merged = MessageToDict(request.message.metadata) + assert merged["user_id"] == "biz-user" + assert merged["custom_key"] == "keep-me" + assert merged["invocation_id"] == "inv-1" + + async def test_empty_stream_response_is_skipped(self): + outgoing = Message(message_id="m1", role=Role.ROLE_USER, parts=[A2APart(text="hi")]) + + async def stream(): + yield StreamResponse() + + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + agent._initialized = True + agent._a2a_client = MagicMock() + agent._a2a_client.send_message = MagicMock(return_value=stream()) + ctx = _make_invocation_context() + + events = [] + with patch.object(agent, "_build_outgoing_message", return_value=outgoing): + async for event in agent._run_async_impl(ctx): + events.append(event) + + assert events == [] + + async def test_cancel_uses_id_from_initial_task(self): + # 1.x streams Task first (field `id`). If cancel arrives before any + # TaskStatusUpdateEvent/TaskArtifactUpdateEvent, cancel must still use + # that id — not depend on `task_id`. + from google.genai import types as genai_types + + task = Task( + id="task-from-id", + context_id="ctx1", + status=TaskStatus(state=TaskState.TASK_STATE_SUBMITTED), + ) + + async def stream(): + yield StreamResponse(task=task) + raise RunCancelledException("cancelled") + + agent = TrpcRemoteA2aAgent(name="remote", agent_card=_make_agent_card()) + agent._initialized = True + agent._a2a_client = MagicMock() + agent._a2a_client.send_message = MagicMock(return_value=stream()) + agent._a2a_client.cancel_task = AsyncMock() + ctx = _make_invocation_context( + override_messages=[genai_types.Content(role="user", parts=[genai_types.Part(text="hi")])] + ) + + with pytest.raises(RunCancelledException): + async for _ in agent._run_async_impl(ctx): + pass + + agent._a2a_client.cancel_task.assert_awaited() + cancel_request = agent._a2a_client.cancel_task.call_args.args[0] + assert cancel_request.id == "task-from-id" diff --git a/tests/server/a2a/test_utils.py b/tests/server/a2a/test_utils.py index 265428927..264da6abc 100644 --- a/tests/server/a2a/test_utils.py +++ b/tests/server/a2a/test_utils.py @@ -8,8 +8,10 @@ from __future__ import annotations import pytest +from google.protobuf import struct_pb2 +from google.protobuf.json_format import MessageToDict -from trpc_agent_sdk.server.a2a._utils import get_metadata, metadata_is_true, set_metadata +from trpc_agent_sdk.server.a2a._utils import get_metadata, has_field, metadata_is_true, set_metadata class TestSetMetadata: @@ -90,3 +92,109 @@ def test_integer_value(self): def test_none_value(self): assert metadata_is_true({"k": None}, "k") is False + + +class TestStructMetadata: + def test_set_and_get_scalar_round_trip(self): + s = struct_pb2.Struct() + set_metadata(s, "key", "value") + assert "key" in s + assert s["key"] == "value" + assert get_metadata(s, "key") == "value" + + def test_overwrite_existing(self): + s = struct_pb2.Struct() + set_metadata(s, "key", "old") + set_metadata(s, "key", "new") + assert s["key"] == "new" + assert get_metadata(s, "key") == "new" + + def test_none_value_round_trip(self): + s = struct_pb2.Struct() + set_metadata(s, "key", None) + assert "key" in s + assert get_metadata(s, "key", "fallback") is None + + def test_nested_dict_round_trip(self): + s = struct_pb2.Struct() + set_metadata(s, "nested", {"a": [1, 2, 3]}) + assert "nested" in s + # Protobuf Value stores numbers as double. + assert MessageToDict(s)["nested"] == {"a": [1.0, 2.0, 3.0]} + nested = get_metadata(s, "nested") + assert list(nested["a"]) == [1.0, 2.0, 3.0] + + def test_empty_struct_returns_default(self): + assert get_metadata(struct_pb2.Struct(), "key", "fallback") == "fallback" + + def test_missing_key_returns_default(self): + s = struct_pb2.Struct() + set_metadata(s, "other", 1) + assert get_metadata(s, "key", "default") == "default" + + def test_numeric_round_trips_as_float(self): + s = struct_pb2.Struct() + set_metadata(s, "key", 0) + assert get_metadata(s, "key", 42) == 0.0 + + def test_falsy_string_and_bool_preserved(self): + s = struct_pb2.Struct() + set_metadata(s, "empty", "") + set_metadata(s, "flag", False) + assert get_metadata(s, "empty", "x") == "" + assert get_metadata(s, "flag", True) is False + + def test_metadata_is_true_bool(self): + s = struct_pb2.Struct() + set_metadata(s, "k", True) + assert metadata_is_true(s, "k") is True + set_metadata(s, "k", False) + assert metadata_is_true(s, "k") is False + + def test_metadata_is_true_string(self): + s = struct_pb2.Struct() + set_metadata(s, "k", "true") + assert metadata_is_true(s, "k") is True + set_metadata(s, "k", " TRUE ") + assert metadata_is_true(s, "k") is True + set_metadata(s, "k", "false") + assert metadata_is_true(s, "k") is False + + def test_metadata_is_true_number_is_false(self): + s = struct_pb2.Struct() + set_metadata(s, "k", 1) + assert metadata_is_true(s, "k") is False + + def test_metadata_is_true_missing_and_none(self): + s = struct_pb2.Struct() + assert metadata_is_true(s, "k") is False + set_metadata(s, "k", None) + assert metadata_is_true(s, "k") is False + + +class TestHasField: + def test_protobuf_oneof_empty_is_unset(self): + from a2a.types import Part as A2APart + + part = A2APart() + assert has_field(part, "text") is False + assert has_field(part, "data") is False + + def test_protobuf_oneof_text_is_set(self): + from a2a.types import Part as A2APart + + part = A2APart(text="") + assert has_field(part, "text") is True + assert has_field(part, "data") is False + + def test_duck_typed_without_hasfield_uses_attribute(self): + from types import SimpleNamespace + + part = SimpleNamespace(text="hello") + assert has_field(part, "text") is True + assert has_field(part, "data") is False + + def test_unknown_protobuf_field_is_unset(self): + from a2a.types import Part as A2APart + + assert has_field(A2APart(text="hi"), "not_a_field") is False diff --git a/trpc_agent_sdk/server/a2a/README.md b/trpc_agent_sdk/server/a2a/README.md index 2018de59b..dcf0a4ecb 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 / CompatJsonRpcTransport] 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,25 @@ 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), fill empty JSONRPC urls, then create_client + (force_v0_3=True: CompatJsonRpcTransport to card JSONRPC url, else agent_base_url) 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 +157,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 空而阻断 **1.0-only** 启动——JSON-RPC 直连的客户端不读卡片。但若所有接口的 url 都为空(没配 `rpc_url` 也没自定义 `agent_card`),会打出一条 **warning** 提示配置缺失,因为依赖卡片发现的客户端会连不上。开启 `enable_v0_3_compat=True` 时则不同:0.3 客户端靠 well-known 卡片的顶层 `url` 发现服务,空 url **无法补救**,装配会 **raise `ValueError`**,避免服务看似起来了但旧客户端发现失败。 + +**挂载路径自动推导**:`create_a2a_application()` **不接收挂载路径参数**——JSON-RPC 路由挂到哪由卡片 url 的 path 推导(`https://x.com/a2a` → `/a2a`,无路径则 `/`),保证"卡片声明的路径"与"实际挂载路径"永远一致,客户端不会发现 A 调 B。 + +开启 `enable_v0_3_compat=True` 时,框架还会在 `/.well-known/agent.json` 发布同一张卡(0.3 `A2ACardResolver` 的默认发现路径),在**用于 well-known 发现的卡片副本**上追加 `protocol_version="0.3"` 接口(复用已有 url),并打开 JSON-RPC 的 0.3 解码。**须先配置可达 url**(见上文);url 全空时装配会 raise。**默认关闭该开关**(只发布 `/.well-known/agent-card.json`,`agent.json` 为 404),旧 0.3 客户端无法发现服务。默认由本函数构造的 `DefaultRequestHandler` 也会拿到这份副本。若调用方传入**自定义 `request_handler`**,框架**不会**改写该 handler 自己的 `agent_card`——0.3 客户端仍可走 JSON-RPC 路由,但 handler 若按其 `supported_interfaces` 做版本判断,需要调用方自行在那张卡上声明 0.3 接口。 + +```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 +212,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..ce084000a 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,10 @@ 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 + # a 1.0-only card is assembled without any url, and raises if + # ``enable_v0_3_compat`` is on (0.3 discovery cannot invent a url). self._rpc_url = rpc_url or '' self._capabilities = capabilities or AgentCapabilities() self._doc_url = doc_url @@ -91,14 +95,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, ) @@ -107,15 +116,21 @@ 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}) + """Return a copy of capabilities that includes the trpc-a2a-version extension. + + The input is not mutated. AgentCapabilities is a protobuf message (no + ``model_copy``); copy via ``CopyFrom`` then append on the clone. + """ + base = AgentCapabilities() + if capabilities is not None: + base.CopyFrom(capabilities) + 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 +192,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..e48e11b35 --- /dev/null +++ b/trpc_agent_sdk/server/a2a/_application.py @@ -0,0 +1,238 @@ +# 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 AgentCard +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. Call + ``a2a_svc.initialize()`` first (or pass ``agent_card=`` when + constructing the service). The 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. When ``enable_v0_3_compat`` + is True a missing url is a ``ValueError`` instead: 0.3 discovery + cannot work without a top-level ``url``. + enable_v0_3_compat: Whether to accept legacy v0.3 clients on the same + endpoint (see the a2a-sdk migration guide). When ``True``, also + publishes the card at ``/.well-known/agent.json`` (the path 0.3 + ``A2ACardResolver`` fetches by default). When ``False`` (the + default), only ``/.well-known/agent-card.json`` is served and + ``agent.json`` returns 404 — 0.3 clients cannot discover the + service. ``True`` also requires a reachable interface url + (otherwise ``ValueError``: an empty url cannot be turned into a + usable 0.3 top-level ``url``). The service's ``agent_card`` + is not mutated; a copy is used for well-known routes (and, when + this function constructs the default handler, for that handler). + Passing a custom ``request_handler`` does **not** append a 0.3 + interface to that handler's own ``agent_card`` — the caller must + advertise ``protocol_version="0.3"`` on it if the handler (or + anything else) inspects ``supported_interfaces``. + 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, ...). ``enable_v0_3_compat`` still enables 0.3 JSON-RPC + decoding and still patches the well-known card copy so 0.3 clients + can discover a top-level ``url``; it does not rewrite the custom + handler's ``agent_card``. 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. + + Raises: + ValueError: If ``a2a_svc.agent_card`` is ``None`` (service not + initialized and no card was supplied). Also raised when + ``enable_v0_3_compat`` is True and no interface advertises a url + (0.3 card discovery cannot invent a reachable endpoint). + """ + from starlette.applications import Starlette + + if a2a_svc.agent_card is None: + raise ValueError("a2a_svc.agent_card is None; call TrpcA2aAgentService.initialize() " + "first (or pass agent_card= when constructing the service).") + + card = a2a_svc.agent_card + if enable_v0_3_compat: + card = AgentCard() + card.CopyFrom(a2a_svc.agent_card) + _ensure_v0_3_interface(card) + + request_handler = request_handler or DefaultRequestHandler( + agent_executor=a2a_svc, + task_store=InMemoryTaskStore(), + agent_card=card, + ) + _ensure_card_has_url(card, required=enable_v0_3_compat) + routes: list[Any] = [] + routes.extend(create_agent_card_routes(card)) + if enable_v0_3_compat: + routes.extend(create_agent_card_routes(card, card_url="/.well-known/agent.json")) + routes.extend( + create_jsonrpc_routes( + request_handler, + rpc_url=_jsonrpc_path_from_card(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. + Query strings and fragments are ignored. An empty path (including + ``http://host?q=1``) normalizes to ``/``. A trailing slash on a non-root + path is stripped so ``/a2a/`` and ``/a2a`` mount the same endpoint. + + 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 + + # Empty path (bare origin, or origin plus query/fragment only) is `/`. + path = urlparse(advertised).path or "/" + if not path.startswith("/"): + return "/" + return path.rstrip("/") or "/" + + +def _ensure_card_has_url(card: Any, *, required: bool = False) -> None: + """Require or 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 a 1.0-only server from starting: warn instead + of raising. + + ``enable_v0_3_compat`` is different: 0.3 clients discover via the well-known + card's top-level ``url``, which is generated from the 0.3 interface url. + Reusing an empty 1.0 url does not give them a usable endpoint, so a missing + url is a configuration error (``required=True``). + + Args: + card: The agent card to inspect (a2a-sdk protobuf message). + required: When True, raise ``ValueError`` instead of logging a warning. + + Raises: + ValueError: If ``required`` is True and no interface advertises a url. + """ + if any(i.url for i in card.supported_interfaces): + return + hint = ("Configure TrpcA2aAgentService(rpc_url='http://host:port') or pass a " + "custom agent_card whose interfaces carry a url.") + if required: + raise ValueError("enable_v0_3_compat requires a reachable interface url " + f"so 0.3 clients can discover the service. {hint}") + logger.warning("Agent card advertises no reachable url; discovery-based clients " + f"won't be able to call it. {hint}") + + +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). An empty + url is not invented here; ``create_a2a_application`` rejects that case + when compat is on. + + 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 + # empty url. ``create_a2a_application`` then raises when compat is on. + 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..6f64ff531 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,19 @@ 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.jsonrpc_transport import CompatJsonRpcTransport 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 @@ -72,6 +72,19 @@ from .converters import convert_event_to_a2a_message +def _merge_outgoing_request_metadata(a2a_message: Message, ctx: InvocationContext) -> None: + """Fill framework request metadata without overwriting caller keys. + + Matches 0.3 ``request_meta.update(existing)``: keys already present on + ``a2a_message.metadata`` (for example a business ``user_id``) win; + ``build_request_message_metadata`` only supplies missing keys. + """ + request_meta = build_request_message_metadata(ctx) + for key, value in request_meta.items(): + if key not in a2a_message.metadata: + a2a_message.metadata[key] = value + + class TrpcRemoteA2aAgent(BaseAgent): """Agent that communicates with a remote A2A agent via the standard A2A SDK client. @@ -85,6 +98,9 @@ class TrpcRemoteA2aAgent(BaseAgent): - description: Agent description (auto-populated from card if empty) - agent_card: Optional AgentCard object (if not provided, will be discovered) - a2a_client: Optional A2AClient object (if not provided, will be created) + - force_v0_3: Leave False in the usual case; follow the AgentCard. + Set True only when you know the peer is an A2A 0.3 server and the card + cannot be used as-is (for example a 0.3 card with an empty url). """ agent_base_url: Optional[str] = None @@ -94,8 +110,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, + force_v0_3: bool = False, **kwargs: Any, ) -> None: super().__init__(name=name, description=description, **kwargs) @@ -105,14 +122,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._force_v0_3 = force_v0_3 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 +143,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 +159,93 @@ 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) -> AgentCard: + """Fetch an AgentCard when the caller did not provide one.""" + if not self.agent_base_url: + raise ValueError("agent_base_url is required when agent_card is not provided") + 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. + + Default: fill empty JSONRPC urls, then ``create_client``. + ``force_v0_3=True``: always the 0.3 JSON-RPC wire (``message/send``). + """ + self._fill_empty_jsonrpc_urls() + if self._force_v0_3: + return await self._build_v0_3_a2a_client() + return await self._build_standard_a2a_client() + + async def _build_standard_a2a_client(self) -> Any: + """Build a client via ``create_client`` from the resolved card. + + Transport choice (1.0 vs 0.3 JSON-RPC) is left to the SDK. + """ + 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) + logger.debug("JSONRPC URL: %s", self._first_jsonrpc_url() or self.agent_base_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_v0_3_a2a_client(self) -> BaseClient: + """Build a legacy v0.3 client (``message/send`` / ``tasks/cancel``). + + Empty JSONRPC urls are filled beforehand. If the card still has no + JSONRPC url, the transport posts to ``agent_base_url`` directly. + """ + url = self._first_jsonrpc_url() or self.agent_base_url + if not url: + raise ValueError("agent_base_url is required for force_v0_3=True") + transport = CompatJsonRpcTransport(self._httpx_client, self._agent_card, url) + return BaseClient( + card=self._agent_card or AgentCard(), + config=ClientConfig(httpx_client=self._httpx_client), + transport=transport, + interceptors=[], + ) + + def _fill_empty_jsonrpc_urls(self) -> None: + """Fill empty JSONRPC interface urls from ``agent_base_url``. + + gRPC/REST urls are left untouched; missing JSONRPC interfaces are + not synthesized. The caller-supplied AgentCard is copied before any + write so shared cards are not mutated. + """ + if not self.agent_base_url or self._agent_card is None: + return + if not any(i.protocol_binding == "JSONRPC" and not i.url for i in self._agent_card.supported_interfaces): + return + card = AgentCard() + card.CopyFrom(self._agent_card) + self._agent_card = card + for interface in self._agent_card.supported_interfaces: + if interface.protocol_binding == "JSONRPC" and not interface.url: + interface.url = self.agent_base_url + + def _first_jsonrpc_url(self) -> Optional[str]: + if self._agent_card is None: + return None + for interface in self._agent_card.supported_interfaces: + if interface.protocol_binding == "JSONRPC" and interface.url: + return interface.url + 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__() @@ -222,11 +301,7 @@ async def _run_async_impl(self, ctx: InvocationContext) -> AsyncGenerator[Event, return 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 + _merge_outgoing_request_metadata(a2a_message, ctx) metadata = None configuration = None @@ -234,9 +309,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,24 +338,29 @@ 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, ) async for response in self._stream_with_cancel_check(ctx, streaming_gen): await ctx.raise_if_cancelled() + + result = self._response_payload(response) + if result is None: + continue + event_count += 1 - result = response.root.result - if task_id is None and hasattr(result, "task_id"): - task_id = result.task_id - logger.debug("Captured task_id for cancellation: %s", task_id) + if task_id is None: + captured = self._task_id_from_payload(result) + if captured: + task_id = captured + logger.debug("Captured task_id for cancellation: %s", task_id) for event in self._events_from_response(result, event_count, ctx): trace_reporter.trace_event(ctx, event) @@ -295,8 +377,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 +401,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 +412,52 @@ 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 _task_id_from_payload(self, result: Any) -> Optional[str]: + """Read the remote task id from a stream payload. + + ``TaskStatusUpdateEvent`` / ``TaskArtifactUpdateEvent`` / ``Message`` + carry ``task_id``. The initial 1.x ``Task`` uses ``id`` instead, so + cancel must fall back to that field or the first event cannot seed + ``CancelTaskRequest``. + """ + task_id = getattr(result, "task_id", None) + if task_id: + return task_id + if isinstance(result, Task) and result.id: + return result.id + return None + + 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. An unset payload (keepalive / empty frame) returns + ``None`` so the caller can skip it. + """ + 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 None 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: @@ -374,6 +490,8 @@ def _resolve_partial(self, metadata: Any) -> bool: def _events_from_response(self, result: Any, event_count: int, ctx: InvocationContext) -> List[Event]: """Produce TrpcAgent events from one streaming response.""" events: List[Event] = [] + if result is None: + return events if isinstance(result, TaskArtifactUpdateEvent): artifact = result.artifact if hasattr(result, "artifact") else None @@ -402,17 +520,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..9133ab277 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): @@ -51,3 +78,20 @@ def metadata_is_true(metadata: Optional[dict[str, Any]], key: str) -> bool: if isinstance(value, str): return value.strip().lower() == "true" return False + + +def has_field(message: Any, name: str) -> bool: + """Return whether ``name`` is set on ``message``. + + Prefers protobuf ``HasField`` so oneof defaults (empty ``text`` / empty + ``Value``) are not treated as set. Duck-typed objects without ``HasField`` + fall back to a present, non-``None`` attribute. Unknown protobuf field + names raise ``ValueError`` from ``HasField``; those are treated as unset. + """ + has_field_fn = getattr(message, "HasField", None) + if callable(has_field_fn): + try: + return bool(has_field_fn(name)) + except (ValueError, AttributeError, TypeError): + return False + return getattr(message, name, None) is not None diff --git a/trpc_agent_sdk/server/a2a/converters/_event_converter.py b/trpc_agent_sdk/server/a2a/converters/_event_converter.py index 7e8874640..fa306b1b7 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 @@ -70,12 +69,22 @@ from .._constants import A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA from .._constants import DEFAULT_ERROR_MESSAGE from .._utils import get_metadata +from .._utils import has_field from .._utils import metadata_is_true from .._utils import set_metadata from ._part_converter import convert_a2a_part_to_genai_part 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 +231,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 +243,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 has_field(a2a_part, "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 +270,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 +322,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 +348,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 +389,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 +424,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 +448,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 +475,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 +498,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 +522,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 +539,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 +552,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 +565,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 +579,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,34 +604,27 @@ 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 - if not md: + md = part.metadata + if not md or not has_field(part, "data"): 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 - if not md: + md = part.metadata + if not md or not has_field(part, "data"): return False t = get_metadata(md, A2A_DATA_PART_METADATA_TYPE_KEY) return (t == A2A_DATA_PART_METADATA_TYPE_FUNCTION_CALL @@ -639,19 +639,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, ) @@ -686,7 +685,7 @@ def convert_event_to_a2a_events( ) -> List[A2AEvent]: """Convert a TrpcAgent Event to A2A events using the artifact-first flow. - - Errors emit the error message (not wrapped in a status event). + - Errors emit a ``TASK_STATE_FAILED`` status event and return immediately. - Non-final content emits a ``TaskArtifactUpdateEvent``. - The ``on_event`` callback (if provided) receives the internal ``TaskStatusUpdateEvent`` for state aggregation, regardless of what is @@ -706,8 +705,11 @@ 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) + return a2a_events 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..13b754fdf 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 @@ -46,8 +52,21 @@ from .._constants import A2A_DATA_PART_METADATA_TYPE_KEY from .._constants import A2A_DATA_PART_METADATA_TYPE_STREAMING_FUNCTION_CALL_DELTA from .._utils import get_metadata +from .._utils import has_field 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 +128,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 +178,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 +193,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 +211,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 +251,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 +298,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 +324,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 has_field(a2a_part, "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 has_field(a2a_part, "url"): + return genai_types.Part(file_data=genai_types.FileData(file_uri=a2a_part.url, mime_type=a2a_part.media_type), ) + + if has_field(a2a_part, "raw"): + return genai_types.Part(inline_data=genai_types.Blob( + data=a2a_part.raw, + mime_type=a2a_part.media_type, + )) + + if has_field(a2a_part, "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..ffb1e0992 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: @@ -239,28 +256,19 @@ async def execute(self, context: RequestContext, event_queue: EventQueue): logger.warning("A2A task %s exceeded a configured run limit: %s", context.task_id, ex) metadata = ex.get_custom_metadata() metadata["error_code"] = ex.error_code - try: - await event_queue.enqueue_event( - create_exception_status_event( - task_id=context.task_id, - context_id=context.context_id, - message_text=str(ex), - metadata=metadata, - )) - except Exception as enqueue_error: # pylint: disable=broad-except - logger.error("Failed to publish run-limit failure event: %s", enqueue_error, exc_info=True) + await self._enqueue_failure_event( + event_queue, + context, + str(ex), + metadata=metadata, + ) 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) - except Exception as enqueue_error: # pylint: disable=broad-except - logger.error("Failed to publish failure event: %s", enqueue_error, exc_info=True) + await self._enqueue_failure_event( + event_queue, + context, + str(ex), + ) finally: if token is not None: try: @@ -269,6 +277,33 @@ async def execute(self, context: RequestContext, event_queue: EventQueue): except Exception: # pylint: disable=broad-except pass + async def _enqueue_failure_event( + self, + event_queue: EventQueue, + context: RequestContext, + message_text: str, + *, + aggregator: Optional[TaskResultAggregator] = None, + metadata: Optional[dict[str, Any]] = None, + ) -> None: + """Publish a terminal failed status, optionally recording it on the aggregator. + + a2a-sdk 1.x task-mode streaming forbids a bare ``Message`` after the + initial ``Task``; the whole failed-status event is enqueued instead. + """ + failure = create_exception_status_event( + task_id=context.task_id, + context_id=context.context_id, + message_text=message_text, + metadata=metadata, + ) + if aggregator is not None: + aggregator.process_event(failure) + try: + await event_queue.enqueue_event(failure) + except Exception as enqueue_error: # pylint: disable=broad-except + logger.error("Failed to publish failure event: %s", enqueue_error, exc_info=True) + async def _handle_request(self, context: RequestContext, event_queue: EventQueue): runner = await self._resolve_runner() run_args = await convert_a2a_request_to_trpc_agent_run_args(context, self._user_id_extractor) @@ -301,6 +336,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, @@ -310,60 +351,83 @@ async def _handle_request(self, context: RequestContext, event_queue: EventQueue aggregator = TaskResultAggregator() event_callback = self._config.event_callback if self._config else None - async for trpc_event in runner.run_async(**run_args): - if isinstance(trpc_event, AgentCancelledEvent): + try: + async for trpc_event in runner.run_async(**run_args): + if isinstance(trpc_event, AgentCancelledEvent): + await event_queue.enqueue_event( + create_cancellation_event( + task_id=context.task_id, + context_id=context.context_id, + message_text="Task was cancelled", + )) + return + + if event_callback is not None: + result = event_callback(trpc_event, context) + if inspect.isawaitable(result): + result = await result + if result is None: + continue + trpc_event = result + + for a2a_event in convert_event_to_a2a_events( + trpc_event, + invocation_context, + context.task_id, + context.context_id, + on_event=aggregator.process_event, + ): + await event_queue.enqueue_event(a2a_event) + except RunLimitException as ex: + logger.warning("A2A task %s exceeded a configured run limit: %s", context.task_id, ex) + await self._enqueue_failure_event( + event_queue, + context, + str(ex), + aggregator=aggregator, + metadata={ + **ex.get_custom_metadata(), "error_code": ex.error_code + }, + ) + return + except Exception as ex: # pylint: disable=broad-except + logger.error("Error handling A2A request: %s", ex, exc_info=True) + await self._enqueue_failure_event( + event_queue, + context, + str(ex), + aggregator=aggregator, + ) + return + + if aggregator.task_state == TaskState.TASK_STATE_WORKING: + if (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( - create_cancellation_event( + TaskArtifactUpdateEvent( task_id=context.task_id, + last_chunk=True, context_id=context.context_id, - message_text="Task was cancelled", + artifact=Artifact( + artifact_id=str(uuid.uuid4()), + parts=aggregator.task_status_message.parts, + ), + metadata=final_meta, )) - return - - if event_callback is not None: - result = event_callback(trpc_event, context) - if inspect.isawaitable(result): - result = await result - if result is None: - continue - trpc_event = result - - for a2a_event in convert_event_to_a2a_events( - trpc_event, - invocation_context, - context.task_id, - context.context_id, - on_event=aggregator.process_event, - ): - await event_queue.enqueue_event(a2a_event) - - if (aggregator.task_state == TaskState.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( - TaskArtifactUpdateEvent( - task_id=context.task_id, - last_chunk=True, - context_id=context.context_id, - artifact=Artifact( - artifact_id=str(uuid.uuid4()), - parts=aggregator.task_status_message.parts, - ), - metadata=final_meta, - )) await event_queue.enqueue_event( create_completed_status_event( task_id=context.task_id, context_id=context.context_id, )) - else: - await event_queue.enqueue_event( - create_final_status_event( - task_id=context.task_id, - context_id=context.context_id, - state=aggregator.task_state, - message=aggregator.task_status_message, - )) + return + + await event_queue.enqueue_event( + create_final_status_event( + task_id=context.task_id, + context_id=context.context_id, + state=aggregator.task_state, + message=aggregator.task_status_message, + )) async def _prepare_session(self, run_args: dict[str, Any], runner: Runner): session_id = run_args["session_id"] 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..9e587a989 100644 --- a/trpc_agent_sdk/server/a2a/logs/_log_utils.py +++ b/trpc_agent_sdk/server/a2a/logs/_log_utils.py @@ -19,23 +19,28 @@ # 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 + +from trpc_agent_sdk.server.a2a._utils import has_field # Constants _NEW_LINE = "\n" -_EXCLUDED_PART_FIELD = {"file": {"bytes"}} def _is_a2a_task(obj) -> bool: @@ -54,20 +59,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 has_field(part, "text") + + +def _is_a2a_data_part(part: A2APart) -> bool: + """Check if a protobuf Part is a data part.""" + return has_field(part, "data") def build_message_part_log(part: A2APart) -> str: @@ -80,27 +88,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 has_field(part, "url"): + part_content = f"FilePart: url={part.url}, media_type={part.media_type}" + elif has_field(part, "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 has_field(part, "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 +148,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} @@ -170,38 +187,95 @@ def build_a2a_request_log(req: SendMessageRequest) -> str: """ +def _jsonrpc_error_attr(error, name: str, default=None): + """Read ``code`` / ``message`` / ``data`` from an error object or dict.""" + if isinstance(error, dict): + return error.get(name, default) + return getattr(error, name, default) + + +def _format_error_data(data) -> str: + """Serialize JSON-RPC error data for logging.""" + if data is None: + return "None" + if hasattr(data, "DESCRIPTOR"): + try: + data = MessageToDict(data) + except (TypeError, ValueError, AttributeError): + return str(data) + try: + return json.dumps(data, indent=2) + except TypeError: + return str(data) + + +def _extract_jsonrpc_error(resp) -> tuple[object, object | None, object | None] | None: + """Extract ``(error, id, jsonrpc)`` from a JSON-RPC error envelope. + + 1.x protobuf ``SendMessageResponse`` has no error payload; errors arrive as + ``JSONRPCErrorResponse``, 0.3 RootModel ``SendMessageResponse``, a dict + from ``build_error_response``, or a proto with an ``error`` oneof. + """ + if has_field(resp, "error"): + return ( + resp.error, + getattr(resp, "id", None), + getattr(resp, "jsonrpc", None), + ) + + root = getattr(resp, "root", None) + if root is not None: + error = getattr(root, "error", None) + if error is not None: + return error, getattr(root, "id", None), getattr(root, "jsonrpc", None) + + error = getattr(resp, "error", None) + if error is not None: + return error, getattr(resp, "id", None), getattr(resp, "jsonrpc", None) + + if isinstance(resp, dict) and resp.get("error") is not None: + return resp["error"], resp.get("id"), resp.get("jsonrpc") + + return None + + def build_a2a_response_log(resp: SendMessageResponse) -> str: """Builds a structured log representation of an A2A response. Args: - resp: The A2A SendMessageResponse to log. + resp: The A2A SendMessageResponse to log, or a JSON-RPC error envelope. Returns: A formatted string representation of the response. """ - # Handle error responses - if hasattr(resp.root, "error"): + error_info = _extract_jsonrpc_error(resp) + if error_info is not None: + error, response_id, jsonrpc = error_info 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"} +Error Code: {_jsonrpc_error_attr(error, "code")} +Error Message: {_jsonrpc_error_attr(error, "message")} +Error Data: {_format_error_data(_jsonrpc_error_attr(error, "data"))} ----------------------------------------------------------- -Response ID: {resp.root.id} -JSON-RPC: {resp.root.jsonrpc} +Response ID: {response_id} +JSON-RPC: {jsonrpc} ----------------------------------------------------------- """ - # Handle success responses - result = resp.root.result - result_type = type(result).__name__ + result = None + if has_field(resp, "task"): + result = resp.task + elif has_field(resp, "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 +285,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 +298,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 +338,4 @@ def build_a2a_response_log(resp: SendMessageResponse) -> str: History: {history_section} ----------------------------------------------------------- -Response ID: {resp.root.id} -JSON-RPC: {resp.root.jsonrpc} ------------------------------------------------------------ """