Agent-to-agent

Agent-to-agent (A2A) is the surface one Covenant agent uses to send a task to another, get the result back, and reconstruct a task graph across many such exchanges. The wire types are small; the storage and routing are intentionally pluggable.

Wire types

A task is a request from one agent to another. A result is the response. Tasks form a tree via parent so an orchestrator can fan a root intent across child agents and reconstruct the result graph.

A2ATask {
  id:          uuid,
  sender:      AgentId,
  recipient:   AgentId,
  intent_text: "do the thing",
  parent:      uuid | null,
  deadline_ms: u64 | null
}

A2ATaskResult {
  task_id:       uuid,            // matches A2ATask.id
  status:        "ok" | "error" | "partial",
  content:       [ Content ],     // same Content blocks MCP uses
  error_message: string | null
}

Mailbox

The Mailbox trait abstracts the queue between agents. The daemon holds one mailbox; agents send and receive through the daemon's IPC or HTTP surface.

trait Mailbox {
  async fn send_task(&self, task: A2ATask)         -> Result<()>;
  async fn recv_task(&self)                        -> Result<A2ATask>;
  async fn try_recv_task(&self)                    -> Result<Option<A2ATask>>;
  async fn recent_tasks(&self, limit: usize)       -> Result<Vec<A2ATask>>;

  async fn send_result(&self, result: A2ATaskResult)   -> Result<()>;
  async fn recv_result(&self)                          -> Result<A2ATaskResult>;
  async fn try_recv_result(&self)                      -> Result<Option<A2ATaskResult>>;
  async fn recent_results(&self, limit: usize)         -> Result<Vec<A2ATaskResult>>;
}

The blocking recv_* variants suit in-process agents that idle on a long-lived connection; the non-blocking try_recv_* variants suit RPC-style callers that prefer to poll over a single round-trip; the non-consuming recent_* variants suit operator dashboards that need to inspect the queue without draining it.

Daemon-mediated flow

POST /a2a/tasks                   # body: A2ATask JSON
  → 200 { "kind": "a2a_task_queued", "task_id": "uuid" }

GET  /a2a/tasks/next              # consumes the next queued task
  → 200 { "kind": "a2a_task_opt", "task": { ... } | null }

GET  /a2a/tasks/recent?limit=N    # non-consuming snapshot
  → 200 { "kind": "a2a_tasks", "tasks": [ ... ] }

POST /a2a/results                 # body: A2ATaskResult JSON
  → 200 { "kind": "a2a_result_posted", "task_id": "uuid" }

GET  /a2a/results/next            # consumes the next queued result
  → 200 { "kind": "a2a_result_opt", "result": { ... } | null }

GET  /a2a/results/recent?limit=N  # non-consuming snapshot
  → 200 { "kind": "a2a_results", "results": [ ... ] }

Equivalent IPC variants exist: SendA2ATask, TryRecvA2ATask, RecentA2ATasks, PostA2AResult, TryRecvA2AResult, RecentA2AResults. See Local IPC for the full request/ response shapes.

Capability gating

Both write paths are gated by capability tokens, audited via the standard CapabilityCheck event:

  • SendA2ATask requires a2a.send.<recipient.display>. The audit row carries scope id a2a-send:<recipient>.
  • PostA2AResult requires a2a.respond.<sender.display>, where sender is the original sender of the task identified by result.task_id. The daemon looks the sender up via the mailbox; results whose task_id was never dispatched through this daemon are rejected before the capability check, so the attacker cannot probe for granted caps with arbitrary task ids. The audit row carries scope id a2a-respond:<task_id>.

Read paths (TryRecv*, Recent*) are not gated. Drain operations on the operator's own daemon are treated as a local-trust action.

Orchestration patterns

Fan-out

An orchestrator receives a root intent, generates several child A2ATask envelopes (each with parent = root_intent.id), sends them via POST /a2a/tasks, and polls GET /a2a/results/next until it has results for every dispatched child.

Pipeline

Two agents form a producer-consumer pipeline. The producer sends tasks tagged for the consumer's recipient; the consumer pulls them off the mailbox via recv_task and posts results back.

Implementation notes

  • Persistence. The default mailbox is in memory. A daemon restart drops every queued task and result. A disk-backed mailbox is on the roadmap.
  • Routing. The default mailbox is global FIFO — every recv_task caller pulls from the same queue regardless of recipient. Per-recipient routing is on the roadmap.
  • Authentication. Both write paths are gated by capability tokens (a2a.send.<recipient> and a2a.respond.<sender>) checked against the daemon's local identity. The cap is not yet bound to the calling HTTP/IPC peer — closing that gap requires per-call peer authentication, which is a separate piece of work.
  • Sender record. The mailbox keeps a permanent record of the sender for every dispatched task so that respond capabilities can be sender-scoped even after the task has been recv'd. For long-running daemons this map grows unboundedly; a TTL or LRU policy lands with the disk-backed mailbox.

Related