Skip to content

A2A (Agent2Agent)

Agent2Agent is an open protocol for agent-to-agent interop: an Agent Card for discovery, Tasks with a lifecycle, and Messages carrying text/file/data parts, over JSON-RPC + SSE. reactifact.a2a speaks it in both directions — call a remote agent, or serve your own — with no extra dependency (the client uses httpx, the server FastAPI from the existing web extra, imported lazily).

The JSON-RPC binding implemented here is the A2A 0.3 wire shape (camelCase fields, kind-discriminated parts).

Call a remote agent

A2AClient is the protocol surface; A2AAgentTool wraps it as a reactifact Tool, so an LLMAgent can delegate to a remote agent like any other tool:

from reactifact.a2a import A2AClient, A2AAgentTool

client = A2AClient("https://agent.example.com/a2a", headers={"x-api-key": "…"})
card = await client.fetch_agent_card()          # AgentCard
task = await client.send("Summarise the incident")
print(task.status.state, task.artifacts[0].parts[0].text)

researcher = A2AAgentTool(client, name="researcher")
# agent = create_agent("lead", steps=[...researcher...])

A2AClient also exposes stream(message) (an async iterator of task/message events over SSE), get_task(id) and cancel_task(id).

Run a remote agent as a node

A2AAgentTool is for an LLM loop. To have the runtime schedule a remote agent alongside local ones — the analogue of LangGraph's A2ARemoteGraph — use remote_agent(...): a first-class node that consumes a local artifact, calls message/send, and produces a local artifact, so provenance, budget, guardrails and eval apply as usual:

from reactifact import Consume, Runtime
from reactifact.a2a import remote_agent

translator = remote_agent(
    "translator",
    "https://agent.example.com/a2a",
    consumes=[Consume(Question)],
    output_type=Answer,          # built as Answer(text=reply)
)
Runtime(ctx, agents=[translator, …]).arun()

A remote task in input-required becomes a local PendingQuestion; a later context.resume(...) continues the same remote task and lands the final artifact. Pass message_of= (input → text) and build_output= (reply, input → artifact) when the types have no text field. The lower-level A2ARemoteStep is the Step behind it.

Serve reactifact agents

create_a2a_router(agents, ...) returns a FastAPI router: an Agent Card plus a JSON-RPC endpoint where a Task is one conversation:

from fastapi import FastAPI
from reactifact.a2a import create_a2a_router

app = FastAPI()
app.include_router(create_a2a_router([RouteAgent(), …], name="ops-agent"))

It serves GET /.well-known/agent-card.json (and the legacy /.well-known/agent.json) and POST / with message/send, message/stream, tasks/get, tasks/cancel.

A2A reactifact
Agent Card (skills) built from each agent's produces
Task one conversation over a Context
message/send seed via create_message(ctx, text), run the agents to a fixpoint, read the reply with reply(ctx, seed_id)
message/stream (SSE) the working task, a status update per context.announce() progress event, then the final task
input-required the run paused on a PendingQuestion; the next message/send with the same taskId resumes it
Artifact the reply text as a Part

create_message and reply are the domain hooks (exactly like reactifact.chat): without them the server seeds a UserMessage and replies with the last artifact that has a text field. Pass your own to use the app's real input/output types:

def create_message(ctx, text):
    return ctx.create(Question(text=text)).id

def reply(ctx, seed_id):
    answer = ctx.latest(Answer)
    return answer.data.text if answer else ""

app.include_router(create_a2a_router(AGENTS, create_message=create_message, reply=reply))

Interop

The server is verified against the official a2a-sdk client (a dev-extra test, not runtime): the reference client resolves our Agent Card and calls message/send, receiving a completed task with the reply artifact — see tests/test_a2a_interop.py. a2a-sdk is only a test dependency (its own a2a group); the runtime keeps httpx + FastAPI and nothing else.

Scope

Implemented: Agent Card, message/send, message/stream (SSE), tasks/get, tasks/cancel, the input-required ↔ PendingQuestion mapping, and the JSON-RPC binding 0.3. Out of scope: push-notification config, tasks/resubscribe, authenticated extended card, the gRPC and HTTP+JSON (REST) bindings, and any securitySchemes (put auth in front of the router). Tasks are kept in-process (like reactifact.chat) — persist the Context with a Session yourself for durability.