diff --git a/docs/a2a.md b/docs/a2a.md index 53ad6b52f..3aa9c31fc 100644 --- a/docs/a2a.md +++ b/docs/a2a.md @@ -38,6 +38,7 @@ You can add A2A-compatible agents through the LiteLLM Admin UI. 1. Navigate to the **Agents** tab 2. Click **Add Agent** 3. Enter the agent name (e.g., `ij-local`) and the URL of your A2A agent +4. Choose a **Protocol Version** (`1.0` or `0.3`) - the wire format LiteLLM serves to clients for this agent = 1.1.0** (included in the `proxy` / `proxy-dev` dependency groups). If you call agents from your own code, install the matching SDK version: + +```bash +pip install "a2a-sdk>=1.1.0,<2.0" +``` + ## Invoking your Agents See the [Invoking A2A Agents](./a2a_invoking_agents) guide to learn how to call your agents using: @@ -233,7 +287,6 @@ Send any of these in the `method` field of `POST /a2a/{agent_id}`: | `tasks/pushNotificationConfig/delete` | Delete push config | | `agent/getAuthenticatedExtendedCard` | Extended agent card | -PascalCase SDK names (`GetTask`, `ListTasks`, …) are normalized to the slash form automatically. **Routing:** `message/send` and `message/stream` go through LiteLLM's A2A client (logging, guardrails, spend). All other methods are forwarded to the upstream URL in `agent_card_params.url`. Task APIs require that URL; completion-bridge-only agents support messaging methods only. @@ -283,9 +336,12 @@ The caller's **virtual key** and **end-user ID** are not automatically forwarded ### Request Format -LiteLLM follows the [A2A JSON-RPC 2.0 specification](https://github.com/google/A2A): +LiteLLM follows the [A2A JSON-RPC 2.0 specification](https://github.com/google/A2A). The message body shape depends on the agent's pinned `protocolVersion` (or the client signals above when unpinned). -```json title="Request Body" + + + +```json title="Request Body (0.3)" { "jsonrpc": "2.0", "id": "unique-request-id", @@ -300,9 +356,24 @@ LiteLLM follows the [A2A JSON-RPC 2.0 specification](https://github.com/google/A } ``` + + + +Use the [a2a-sdk 1.x client](./a2a_invoking_agents#a2a-sdk) (recommended) or send JSON-RPC with PascalCase methods / an `a2a-version: 1.0` header when the agent is pinned to `1.0`. + +```json title="Request Body (1.0 SDK — protobuf types)" +// Build with a2a.types.Message, Part, Role, then wrap in SendMessageRequest +``` + + + + ### Response Format -```json title="Response" + + + +```json title="Response (0.3 task result)" { "jsonrpc": "2.0", "id": "unique-request-id", @@ -322,6 +393,28 @@ LiteLLM follows the [A2A JSON-RPC 2.0 specification](https://github.com/google/A } ``` + + + +```json title="Response (1.0 message envelope)" +{ + "jsonrpc": "2.0", + "id": "unique-request-id", + "result": { + "message": { + "role": "ROLE_AGENT", + "messageId": "msg-abc", + "parts": [{"text": "Agent response here"}] + } + } +} +``` + +Streaming events use `statusUpdate` / `artifactUpdate` keys instead of `kind: "status-update"`. + + + + Agent JSON-RPC errors are returned in the `error` field with the same `id` as the request when possible. Poll long-running work with `tasks/get` after `message/send` returns a `submitted` task. ### Example: `tasks/get` diff --git a/docs/a2a_agent_card.md b/docs/a2a_agent_card.md index 5d7013355..89d5b5c54 100644 --- a/docs/a2a_agent_card.md +++ b/docs/a2a_agent_card.md @@ -16,6 +16,7 @@ The fields below mirror the A2A v1.0 specification ([§4.4 Agent Discovery Objec | Field | Supported | |---|---| +| `protocolVersion` | ✅ | | `name` | ✅ | | `description` | ✅ | | `supportedInterfaces` | ✅ | @@ -92,7 +93,7 @@ When you register an A2A agent in LiteLLM: 1. You provide a base URL (and, for some providers, an assistant identifier). 2. LiteLLM fetches the upstream agent card from the agent's `/.well-known/agent-card.json` (or the provider-specific equivalent). -3. You review the parsed card in the LiteLLM UI and choose which skills and fields to expose. +3. You review the parsed card in the LiteLLM UI, choose which skills and fields to expose, and pick a **Protocol Version** (`1.0` or `0.3`) for clients. 4. LiteLLM saves the curated card and serves it at: ``` @@ -107,6 +108,22 @@ When you register an A2A agent in LiteLLM: using A2A JSON-RPC 2.0 (see [Supported A2A methods](#supported-a2a-methods) below). + +## Protocol versioning + +LiteLLM converts upstream agent responses to the `protocolVersion` pinned on each agent. Clients always see the version you choose, regardless of what the upstream agent speaks natively. + +| `protocolVersion` | Served to clients | +|-------------------|-------------------| +| `"1.0"` (default on new cards) | Protobuf JSON envelopes — `result.message`, stream `statusUpdate` / `artifactUpdate` | +| `"0.3"` | Legacy `kind`-discriminated JSON — `result.kind == "message"` | + +Set this in the agent card UI or in `agent_card_params` at registration. Unsupported values are rejected with HTTP 400. + +Completion-bridge agents (LangGraph, Bedrock AgentCore, etc.) do not need extra provider config — pin `protocolVersion` only if your client expects a specific wire format. + +See [Protocol versioning](./a2a#protocol-versioning) for client negotiation when `protocolVersion` is not pinned. + ## Supported A2A methods All methods below are accepted on `POST /a2a/{agent_id}` (and `POST /a2a/{agent_id}/message/send` for `message/send`). LiteLLM also accepts the PascalCase aliases from the A2A SDK (for example `GetTask` → `tasks/get`). @@ -129,6 +146,8 @@ All methods below are accepted on `POST /a2a/{agent_id}` (and `POST /a2a/{agent_ | SDK / alias name | Wire method | |---|---| +| `SendMessage` | `message/send` | +| `SendStreamingMessage` | `message/stream` | | `GetTask` | `tasks/get` | | `ListTasks` | `tasks/list` | | `CancelTask` | `tasks/cancel` | @@ -147,7 +166,7 @@ All methods below are accepted on `POST /a2a/{agent_id}` (and `POST /a2a/{agent_ ### Example: two-step task flow -```bash title="1. Send a message" +```bash title="1. Send a message (0.3 wire format — pin protocolVersion: 0.3)" curl -X POST "http://localhost:4000/a2a/my-agent" \ -H "Authorization: Bearer sk-1234" \ -H "Content-Type: application/json" \ diff --git a/docs/a2a_invoking_agents.md b/docs/a2a_invoking_agents.md index f04779135..e46b1c7c2 100644 --- a/docs/a2a_invoking_agents.md +++ b/docs/a2a_invoking_agents.md @@ -15,7 +15,19 @@ Want to test with your own agent? Deploy this template A2A agent powered by Goog ## A2A SDK -Use the [A2A Python SDK](https://pypi.org/project/a2a-sdk) to invoke agents through LiteLLM using the A2A protocol. +Use the [A2A Python SDK](https://pypi.org/project/a2a-sdk) (**>= 1.1.0**) to invoke agents through LiteLLM using the A2A protocol. + +```bash +pip install "a2a-sdk>=1.1.0,<2.0" httpx +``` + +Pin `protocolVersion: "1.0"` on the agent (recommended) so responses match the 1.x SDK. For legacy `0.3` wire format, pin `"0.3"` instead — see [Protocol versioning](./a2a#protocol-versioning). + +:::info Migration from a2a-sdk 0.3.x + +a2a-sdk 1.x replaces `A2AClient` + dict `MessageSendParams` with `ClientFactory`, protobuf `Message` / `Part` types, and `send_message` as an async generator of stream events. See the examples below. + +::: ### Non-Streaming @@ -25,57 +37,81 @@ This example shows how to: 3. **Invoke via A2A** - Use the A2A protocol to send messages to the agent ```python showLineNumbers title="invoke_a2a_agent.py" +import asyncio from uuid import uuid4 + import httpx -import asyncio -from a2a.client import A2ACardResolver, A2AClient -from a2a.types import MessageSendParams, SendMessageRequest +from a2a.client import A2ACardResolver, ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from a2a.utils.constants import TransportProtocol # === CONFIGURE THESE === LITELLM_BASE_URL = "http://localhost:4000" # Your LiteLLM proxy URL LITELLM_VIRTUAL_KEY = "sk-1234" # Your LiteLLM Virtual Key # ======================= + +def extract_text(parts) -> str: + return "".join(getattr(p, "text", "") or "" for p in (parts or [])) + + +def handle_event(event) -> None: + populated = event.ListFields() + if not populated: + return + field, value = populated[0] + if field.name in ("message", "msg"): + print(f"[message] {extract_text(value.parts)}") + elif field.name == "task": + print(f"[task {value.id}] {value.status.state}") + + async def main(): headers = {"Authorization": f"Bearer {LITELLM_VIRTUAL_KEY}"} - - async with httpx.AsyncClient(headers=headers) as client: + + async with httpx.AsyncClient(headers=headers, timeout=60.0) as http_client: # Step 1: List available agents - response = await client.get(f"{LITELLM_BASE_URL}/v1/agents") + response = await http_client.get(f"{LITELLM_BASE_URL}/v1/agents") agents = response.json() - + print("Available agents:") for agent in agents: print(f" - {agent['agent_name']} (ID: {agent['agent_id']})") - + if not agents: print("No agents available for this key") return - + # Step 2: Select an agent and invoke it selected_agent = agents[0] agent_id = selected_agent["agent_id"] - agent_name = selected_agent["agent_name"] - print(f"\nInvoking: {agent_name}") - - # Step 3: Use A2A protocol to invoke the agent + print(f"\nInvoking: {selected_agent['agent_name']}") + + # Step 3: Discover agent card and create a2a-sdk 1.x client base_url = f"{LITELLM_BASE_URL}/a2a/{agent_id}" - resolver = A2ACardResolver(httpx_client=client, base_url=base_url) + resolver = A2ACardResolver(httpx_client=http_client, base_url=base_url) agent_card = await resolver.get_agent_card() - a2a_client = A2AClient(httpx_client=client, agent_card=agent_card) - - request = SendMessageRequest( - id=str(uuid4()), - params=MessageSendParams( - message={ - "role": "user", - "parts": [{"kind": "text", "text": "Hello, what can you do?"}], - "messageId": uuid4().hex, - } - ), + + config = ClientConfig( + httpx_client=http_client, + streaming=False, + supported_protocol_bindings=[ + TransportProtocol.JSONRPC, + TransportProtocol.HTTP_JSON, + ], + ) + client = ClientFactory(config).create(agent_card) + + msg = Message( + message_id=uuid4().hex, + role=Role.ROLE_USER, + parts=[Part(text="Hello, what can you do?")], ) - response = await a2a_client.send_message(request) - print(f"Response: {response.model_dump(mode='json', exclude_none=True, indent=4)}") + request = SendMessageRequest(message=msg) + + async for event in client.send_message(request): + handle_event(event) + if __name__ == "__main__": asyncio.run(main()) @@ -83,14 +119,16 @@ if __name__ == "__main__": ### Streaming -For streaming responses, use `send_message_streaming`: +In a2a-sdk 1.x, set `streaming=True` on `ClientConfig` and iterate `send_message` — the same API handles streaming and non-streaming: ```python showLineNumbers title="invoke_a2a_agent_streaming.py" +import asyncio from uuid import uuid4 + import httpx -import asyncio -from a2a.client import A2ACardResolver, A2AClient -from a2a.types import MessageSendParams, SendStreamingMessageRequest +from a2a.client import A2ACardResolver, ClientConfig, ClientFactory +from a2a.types import Message, Part, Role, SendMessageRequest +from a2a.utils.constants import TransportProtocol # === CONFIGURE THESE === LITELLM_BASE_URL = "http://localhost:4000" # Your LiteLLM proxy URL @@ -98,31 +136,41 @@ LITELLM_VIRTUAL_KEY = "sk-1234" # Your LiteLLM Virtual Key LITELLM_AGENT_NAME = "ij-local" # Agent name registered in LiteLLM # ======================= + async def main(): base_url = f"{LITELLM_BASE_URL}/a2a/{LITELLM_AGENT_NAME}" headers = {"Authorization": f"Bearer {LITELLM_VIRTUAL_KEY}"} - - async with httpx.AsyncClient(headers=headers) as httpx_client: - # Resolve agent card and create client - resolver = A2ACardResolver(httpx_client=httpx_client, base_url=base_url) + + async with httpx.AsyncClient(headers=headers, timeout=60.0) as http_client: + resolver = A2ACardResolver(httpx_client=http_client, base_url=base_url) agent_card = await resolver.get_agent_card() - client = A2AClient(httpx_client=httpx_client, agent_card=agent_card) - - # Send a streaming message - request = SendStreamingMessageRequest( - id=str(uuid4()), - params=MessageSendParams( - message={ - "role": "user", - "parts": [{"kind": "text", "text": "Tell me a long story"}], - "messageId": uuid4().hex, - } - ), + + config = ClientConfig( + httpx_client=http_client, + streaming=True, + supported_protocol_bindings=[ + TransportProtocol.JSONRPC, + TransportProtocol.HTTP_JSON, + ], ) - - # Stream the response - async for chunk in client.send_message_streaming(request): - print(chunk.model_dump(mode="json", exclude_none=True)) + client = ClientFactory(config).create(agent_card) + + msg = Message( + message_id=uuid4().hex, + role=Role.ROLE_USER, + parts=[Part(text="Tell me a long story")], + ) + request = SendMessageRequest(message=msg) + + async for event in client.send_message(request): + populated = event.ListFields() + if populated: + field, value = populated[0] + if field.name in ("message", "msg"): + text = "".join(getattr(p, "text", "") or "" for p in value.parts) + print(text, end="", flush=True) + print() + if __name__ == "__main__": asyncio.run(main())