Agent-to-agent

Agent-to-agent (A2A) is the surface through which one Covenant agent dispatches a task to another, receives a result, and reconstructs a task graph across many such exchanges. The wire types are minimal; storage and routing are 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,
  idempotency: {
    duplicate_safety: "unsafe" | "idempotent",
    key:              string
  } | 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_for(&self, recipient)     -> Result<Option<A2ATask>>;
  async fn recent_tasks(&self, limit: usize)       -> Result<Vec<A2ATask>>;
  async fn task_queue(&self, limit: usize)         -> Result<Vec<A2ATaskQueueEntry>>;
  async fn repair_task(&self, request)             -> Result<A2ARepairOutcome>;

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

The blocking recv_* variants are appropriate for in-process agents on long-lived connections. The non-blocking try_recv_* variants are appropriate for RPC-style callers that poll over a single round-trip. The non-consuming recent_* and task_queue variants support operator dashboards that inspect the queue without draining it.

A received task becomes an explicit in_flight lease. Leases survive daemon restart and are not automatically redelivered; operators inspect them through the queue-status surface and repair them explicitly when needed. The CLI status surface also has a one-object JSON mode for supervisors that need a stable queue snapshot.

Lease repair

Repair is explicit and operator-controlled. The mailbox supports two repair commands for in-flight tasks:

  • requeue returns a leased task to queued, preserves the last attempt counter, and requires an explicit duplicate-work posture: idempotent or operator_accepted.
  • force_error clears the lease and posts an error result for the original sender to drain.

Both commands require a non-empty reason and may include the observed lease_id as a guard against repairing a newer lease than the operator inspected. Daemon, HTTP, and CLI exposure exists for manual repair paths; automatic background retry remains disabled.

Tasks may carry optional idempotency metadata: duplicate safety plus a stable key. Missing metadata is treated as unsafe. The daemon persists and validates the metadata. An explicit retry-stale scan can requeue only stale idempotent tasks when the operator passes --enable; skipped tasks stay visible in the report. See A2A idempotency policy.

Daemon-mediated flow

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

GET  /a2a/tasks/next              # leases 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            # drains 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": [ ... ] }

GET  /a2a/queue?limit=N           # queued tasks, in-flight leases, pending results
  → 200 { "kind": "a2a_queue", "tasks": [ ... ], "results": [ ... ] }
covenant a2a status --min-lease-age-ms 300000 --json
  → { "kind": "a2a_status", "limit": 10, "min_lease_age_ms": 300000, "tasks": [ ... ], "results": [ ... ] }

Equivalent IPC variants exist: SendA2ATask, TryRecvA2ATask, RecentA2ATasks, PostA2AResult, TryRecvA2AResult, RecentA2AResults, A2AQueue. 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>.

Non-empty A2A scopes are enforced at dispatch. peer_pubkey_b58 pins the counterparty, task_id pins one task, lease_id pins manual repair to one in-flight lease, and duplicate_risk pins requeue posture.

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

Orchestration patterns

Fan-out

An orchestrator receives a root intent, generates a set of child A2ATask envelopes (each with parent = root_intent.id), submits them via POST /a2a/tasks, and polls GET /a2a/results/next until results have been received for every dispatched child.

Pipeline

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

Implementation notes

  • Persistence. The daemon uses an append-only JSONL mailbox. Queued tasks, in-flight leases, sender lookup state, and pending results replay after restart.
  • Routing. Peer-scoped receives only drain tasks addressed to the authenticated recipient. Global FIFO receives remain available for in-process orchestrators.
  • Authentication. Both write paths are gated by capability tokens (a2a.send.<recipient> and a2a.respond.<sender>) checked against the daemon's local identity. The capability is not yet bound to the calling HTTP/IPC peer; per-call peer authentication is tracked separately.
  • Retry posture. A2A delivery avoids automatic redelivery of leased tasks after restart. The explicit retry gate and opt-in scheduler can requeue stale in-flight tasks only when an operator enables bounded policy and the task carries idempotent duplicate-safety metadata.

Related