Skip to content

feat(a2a): serve and call A2A agents - #42

Draft
dbernheisel wants to merge 12 commits into
mainfrom
dbern/a2a
Draft

dbernheisel wants to merge 12 commits into
mainfrom
dbern/a2a

Conversation

@dbernheisel

Copy link
Copy Markdown
Owner

Summary

  • Add the agent router macro and the Phantom.A2A.Agent behaviour. A router serves A2A agents and uses the same connect/2 for MCP and A2A.
  • Add Phantom.A2A.Plug. It serves the JSON-RPC binding and the HTTP+JSON binding, agent cards, extended cards, and SSE streams through PubSub.
  • Resolve agents at runtime from routes with path params, such as ":org/:agent_key". Static agents match first. A static agent path followed by one segment gives the tenant.
  • Add push notifications. Phantom.A2A.Push checks each webhook URL against private networks and connects to the address that it checked. Phantom.A2A.Push.Delivery sends the events of a task to each webhook in order, with retries. A maximum of 100 events wait for each webhook.
  • Add Phantom.A2A.Client (optional :req) for calls to other agents, and list_agents/2.
  • Add Phantom.A2A.Bridge. It serves the MCP tools of a router as an A2A agent and changes MCP tasks into A2A tasks.
  • Add the A2A guide (guides/a2a.md), a README section, telemetry spans, and the conformance tests for the server, the client, and the bridge.
  • Deferred: run Router.call_tool/4 in a supervised task, so that late tool replies do not stay in the HTTP process. Add demand control to Client.stream_message/3. Send 401 instead of -32007 when an anonymous client asks for an extended card.

Evidence

Phantom.A2A.Plug runs in Bandit at /a2a. Phantom.A2A.Client calls it over HTTP. The router has an echo agent and a bridge for one MCP tool, add.

iex
iex> {:ok, client} = Client.new("http://127.0.0.1:4099/a2a/echo/agent-card.json")
iex> {client.binding, client.url, client.card.name}
{:jsonrpc, "http://127.0.0.1:4099/a2a/echo", "Echo"}

iex> Client.send_message(client, "hello")
{:ok,
 %Phantom.A2A.Message{
   message_id: "01a19cc4-00d3-734f-9892-b645808248fc",
   role: :agent,
   context_id: "PNA46Re8WPFEWDEPcG1NM8Zc1Z7GAVky",
   parts: [%Phantom.A2A.Part{text: "echo: hello", ...}],
   ...
 }}

iex> {:ok, tools} = Client.new("http://127.0.0.1:4099/a2a/tools/agent-card.json")
iex> Enum.map(tools.card.skills, & &1.id)
["add"]

iex> Client.send_message(tools, Message.user([Part.data(%{"tool" => "add", "arguments" => %{"a" => 2, "b" => 40}})]))
{:ok,
 %Phantom.A2A.Message{
   role: :agent,
   parts: [%Phantom.A2A.Part{text: "42", ...}],
   ...
 }}

iex> Client.send_message(tools, Message.user([Part.data(%{"tool" => "add", "arguments" => %{"a" => "two"}})]))
{:error, %Phantom.A2A.Error{code: -32602, message: "Invalid Params"}}

Configuration: the router option a2a: [push_retry_backoff: [...]] sets the retry delays in milliseconds. The default is 1, 5, and 30 seconds. a2a: [allow_private_push_urls: true] permits private webhook addresses. The default is false.

Routers declare A2A agents with `agent`, implemented with
`Phantom.A2A.Agent` and served by `Phantom.A2A.Plug` over JSON-RPC:
SendMessage, SendStreamingMessage, GetTask, ListTasks, CancelTask, and
SubscribeToTask, streaming over SSE with a pubsub. Agents store their own
tasks and report progress with `Phantom.A2A.publish/2`.

The A2A conformance suite (ACTS) runs against a test agent with
`mix test --only acts`.
Agent routes with path params, such as `agent ":org/:agent_key", Mod,
:resolve`, resolve agents per request after `connect/2`. Agents with
`push_notifications: true` serve the push config methods, and
`Phantom.A2A.publish/2` posts each event to the task's webhooks over
:httpc. Webhook URLs must be https and resolve to public addresses, checked
when a config is created and again before each delivery, which connects to
the checked address without redirects.
Agents with `extended_card: true` answer GetExtendedAgentCard through an
`extended_card/2` callback, and agents can declare protocol extensions,
with required ones enforced from the `A2A-Extensions` header. Requests and
push deliveries emit `[:phantom, :a2a, :dispatch]` and
`[:phantom, :a2a, :push]` telemetry spans with the client's W3C trace
context. `Phantom.Router.call_tool/4` calls a router's tool from an agent or
any other code.
`Phantom.A2A.Events.apply/2` now numbers each event after the task's last
one and returns `{:ok, task, event}`; the task keeps the number in
`sequence`. Streams skip events the task already includes, so a client that
subscribes while an agent publishes doesn't get an event twice, which for an
appended artifact chunk corrupted the artifact.
`Phantom.A2A.Plug` serves the HTTP+JSON binding next to JSON-RPC, with the
routes from the A2A protobuf under each agent's path and an optional tenant
segment. Both bindings share every method; HTTP+JSON answers with
application/a2a+json and `google.rpc.Status` errors under their HTTP status.
Cards list both interfaces, and `bindings:` limits a mount to one. Agent
paths can no longer use the segments the binding routes on. ACTS now grades
both bindings.
`Phantom.A2A.Client` fetches an agent's card, picks a binding it lists, and
calls every A2A method over JSON-RPC or HTTP+JSON, streaming lazily. It
needs the optional Req dependency, and checks the card before optional
operations. `c:Phantom.Router.list_agents/2` lists the agents a session may
use. The ACTS agent parses canned responses with the client, so Phantom now
passes every ACTS test on both bindings.
`Phantom.A2A.Bridge`, mounted with `agent "tools", Phantom.A2A.Bridge`,
lists a router's tools as skills, with their input schemas in an "MCP
tools" card extension. A message whose data part names a tool calls it with
`Phantom.Router.call_tool/4` and answers with its result as message parts.
Messages that name no tool go to a fallback agent or get the list of tools.
`c:Phantom.A2A.Agent.card/2` lets any agent adjust its card for its route.
When the router implements get_task/2, the bridge calls tools with the
Tasks extension (`call_tool/4` takes `tasks: true`), and a tool's
`Phantom.Tasks` becomes an A2A task with the same ID. Input requests become
input_required and are answered with an "inputResponses" data part that
goes to update_task/3; GetTask and CancelTask go to the router. The A2A plug
now follows a task whose ID the handler chose, subscribing to it and
fetching it again so no event is missed.
Agents whose tasks are MCP tasks (`follows_mcp_tasks?/0`, true for
`Phantom.A2A.Bridge`) listen through `Phantom.Tracker` while a request
follows one of their tasks, the way MCP `subscriptions/listen` streams do.
After each `Phantom.Tracker.notify_task_updated/1`, a waiting SendMessage or
a stream fetches the task again with `get_task/2` and sends it. Requests stop
listening when they end.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant