Agent Messaging (Subagent-to-Subagent Communication)¶
Status: implemented. This page is the governing contract for agent-to-agent
messaging through the send_message tool. User-facing settings live in
Agent Messaging [messaging]; the tool contract is
summarized in the tool reference.
Goals¶
- Let any agent in a delegation tree send a durable message to any other agent in the same tree (parent ↔ child, sibling ↔ sibling, child → grandparent).
- Delivery semantics: all delivery is producer-backed. The recipient receives the message either steered into its next safe model-request boundary (if running) or queued durably (if idle or queued). Messaging an idle or finished agent wakes it through the producer reconcile path: the message is admitted durably and the session resumes with it as input.
- Model-visible tool surface, permission-gated, observable by the TUI and plugins.
Non-Goals (v1)¶
- Cross-daemon / cross-process agent mesh. The engine is in-process; one daemon owns all sessions.
- Arbitrary cross-session messaging outside the delegation tree. No discovery or authorization basis exists for that yet.
- Request/response RPC between agents. Async mail only; replies are new messages.
- Broadcast topics / pub-sub. Can be layered later on the same envelope.
Concepts¶
Addressing¶
send_message accepts either a full session UUID or the recipient's short
handle (explore_1a2b3c4d) — the same raw-session addressing model the
removed steer_subagent tool used. A non-UUID reference is resolved as a
handle against the sender's tree only; if it matches no live tree peer the
send fails with a self-repairing candidate list, and a handle from another tree
never resolves. No labels, aliases, or special targets (parent, root).
There is no discovery mechanism. An agent learns a peer's session ID only
because the parent disclosed it — in the delegation prompt, in a steering
message, or via delegation tool results (a parent naturally knows its
children's IDs from delegate_subagent). The parent therefore controls the
communication graph: siblings can only talk if the parent chose to introduce
them, and an agent cannot enumerate the tree to find targets on its own.
Every inbound message envelope carries the sender's handle when one exists,
so a recipient can reply directly with send_message. Older sessions without
a handle use the full sender UUID in the reply hint.
Message Envelope¶
The durable body (ProducerMessageAccepted.body) is readable text rendered
into the recipient prompt as a user-turn materialization (repo convention:
tool-emitted system input materializes as a user turn):
<agent_message from="explore_1a2b3c4d">
Message written by the sending agent
[use `send_message(to="explore_1a2b3c4d", ...)` to reply]
</agent_message>
The from attribute identifies the sender in both variants. The reply hint
appears only when the recipient's send_message tool is exposed. The original
body is preserved as written; the engine adds only the envelope and optional
hint. The recipient is implicit — the envelope only ever
appears in the session it was delivered to. Message IDs, sender UUIDs, agent
hop counts, and other delivery metadata remain in the durable event record and
are not rendered into the model-facing body.
Tool Surface¶
MessageToolProvider (provider id builtin.message), modeled on
DelegateToolProvider, is registered via try_register_tool_provider in
crates/cookie_agent/src/main.rs after engine open.
| Tool | Args | Behavior |
|---|---|---|
send_message |
to: <handle or session_id>, body, mode? (steer/queue, default steer) |
Resolve the recipient reference (UUID, or handle within the sender's tree), check permission, register/find the sender's agent-producer on the recipient session, send with idempotency key derived from (sender session, run, tool_call) (same derivation pattern as invocation_id) |
Model-facing arguments are exactly to, body, and mode; the argument
object is strict. The recipient argument is named to; it was renamed from
recipient_session_id during implementation with no alias — see
Migration notes.
Delivery is fully described by mode: steer (default) joins the recipient's
in-flight run at the next safe boundary through producer delivery; queue
admits the message durably and it is claimed into the recipient's prompt at
the next safe boundary. There is no inbox tool — agents never need to poll.
Results, errors, and retries¶
A successful send_message result means durably accepted (decided): the
ProducerMessageAccepted event is persisted in the recipient's event log, so
delivery survives a daemon restart. It does not mean the recipient has seen
or consumed the message. The result carries:
message_id— the durable producer message ID,mode— the effective delivery mode,recipient_state— the recipient's state at send time (running,queued,waking_finished).
Failures are explicit tool errors with stable codes in the send_message:
namespace, one per case, so the sending model can react predictably (retry,
give up, or report to its parent):
send_message:disabled—[messaging] enabled = false, even when permission rules allow the send,send_message:invalid_arguments— missing, unknown, or wrongly typed arguments, including the removedrecipient_session_idspelling,send_message:invalid_body— empty or overmax_body_bytes,send_message:unknown_session— a well-formed session UUID that names no session,send_message:not_tree_peer— a well-formed session UUID whoseroot_session_iddiffers from the sender's,- a non-UUID recipient is treated as a subagent handle: a handle that
matches no live peer in the sender's tree fails with the self-repairing
unknown subagent reference …tool error, whose candidate list renders each addressable peer ashandle — description — status (session_id <uuid>)(pre-handle peers print their UUID once), send_message:self_send— sender targeted its own session,send_message:inbox_full— recipient hasmax_pending_per_sessionpending agent messages (see Configuration),send_message:max_hops_exceeded— the hop guard rejected the send (see Guards),send_message:inflight_full— the per-pair in-flight window is full (see Guards),send_message:engine_shutdown— send raced daemon shutdown.
Permission failures are not messaging-specific: a denied send follows the
generic permission-denied flow, and an ask rule routes through the existing
approval flow instead of erroring.
Retries are safe: the idempotency key derives from (sender session, run,
tool_call), so a retried call returns the original message_id instead
of delivering twice (existing producer dedup behavior).
steer_subagent removal¶
send_message is a strict superset of the removed steer_subagent tool
(decided): steering a running child is send_message(mode=steer); steering a
queued child durably accepts the message and it is claimed when the child's
run starts; finished children, unreachable by steer_subagent, are reachable
via wake. The model-visible steer_subagent tool is removed from
DelegateToolProvider; delegate_subagent, get_subagent_result, and
cancel_subagent remain. The underlying SessionCommand::Steer machinery
stays for user steering (run.steer) only — send_message does not use it.
Consequences:
- Parent→child authority is expressed entirely through
messagepermission rules on thechildrelationship (e.g."child": allowin the agent doc). - Intentional breaking change (decided): combined with deny-by-default,
the out-of-box default agent can delegate subagents but cannot steer them
until the user adds
message: { "child": allow }to their agent doc. This is accepted as-is; see Migration notes. There is no built-in exemption. - Agent docs, prompt sections, and guides were updated in the same change.
send_message uses bounded result truncation and ToolConcurrency::Parallel;
guard checks and acceptance are serialized inside the recipient actor.
Engine Behavior¶
ProducerOwner::Agent { session_id }variant (protocol/src/producer.rs). Additive payload change; the versionless, best-effort event log tolerates it and old readers degrade gracefully.- Authorization predicate beside
ensure_subagent_owned:ensure_tree_peer(sender, recipient)— both sessions are tree peers iff their storedSessionOriginmetadata carries the sameroot_session_id. That is the entire contract (decided): no live-registry walk, no ancestry or taint validation. Known, accepted limitation: after a parent reverts history, "ghost" sessions from the reverted branch remain reachable by agents that hold their session IDs. Sender identity is always derived from the executing tool context, never from model-supplied arguments. The recipient may be in any state, including finished.Runningrecipients get steer/queue delivery;Queued/idle recipients get a durable admission claimed at start;Finished/Completedrecipients are woken. - Engine API:
engine.send_agent_message(invocation) -> AgentMessageHandleinmessaging_api.rs, internally using the atomicProducerCommand::SendAgentMessagepath. Sender-side auto-registration of itsAgentproducer on the recipient mirrors how background monitors registerProducerOwner::Delegationon parents. - Wake and delivery are producer-backed everywhere. Idle and finished
recipients wake through the producer reconcile path — the same reconcile
that restarts delivery of accepted-but-unconsumed producer mail after idle
eviction or restart — never through
SessionCommand::Steeror a runlessUserInputAdmitted. Messaging an idle-evicted actor therefore reuses the existing producer wake machinery (spawn_actor) with no special casing. - Guards — see Guards.
Guards¶
Two loop-safety guards reject sends at send time; both are opt-out:
- Hop guard. The engine tracks an internal hop count per message chain,
carried in producer metadata and never rendered in the agent-visible
envelope. A send that would exceed
messaging.max_hopsfails withsend_message:max_hops_exceeded. The default is unlimited:max_hops <= 0disables the guard. - Per-pair in-flight window. At most
max_inflight_per_pairunacknowledged messages per directed pair (default 4) to stop ping-pong storms between two agents. A pair message is unacknowledged while it is accepted, admitted, claimed, or released, and acknowledged once it is consumed or discarded. A full window rejects the send withsend_message:inflight_full.max_inflight_per_pair = 0disables the guard.
Permissions¶
A dedicated PermissionAction::Message gates send_message (decided) —
messaging is not overloaded onto Delegate, so steering a child and messaging
an agent remain independently controllable.
Rule shape (agent-doc frontmatter):
permissions:
message:
"child": allow # my delegated children
"parent": allow # the agent that delegated me
"sibling": ask # other children of my parent
"*": deny # any other same-tree peer (grandparent, uncle, ...)
- The permission resource label is the recipient's relationship to the
sender:
parent,child,sibling, or*(catch-all for any other same-tree peer). The label is derived from storedSessionOriginmetadata at send time and matched by the existingPermissionPipeline+WildcardPattern. This is purely a labeling step — there is no relationship-based authority check anywhere; permissions alone decide. - Deny by default, including built-in agents. Built-in agent definitions
ship without
messagerules, so deny-by-default applies and thesend_messagetool is not even visible to the model (tool_visiblerequires an allow/ask rule). Messaging is strictly opt-in: an agent doc must declare rules for the tool to appear. askroutes through the existing approval store and TUI approval flow.
Configuration¶
[messaging]
enabled = true
max_hops = 0 # <= 0 disables the hop guard (default); hop counting is internal metadata
max_body_bytes = 32768 # tighter than the 256 KiB plugin cap; agent mail should be terse
max_pending_per_session = 32 # pending agent mail per recipient; overflow → send rejected
max_inflight_per_pair = 4 # 0 disables the per-pair in-flight window
Inbox overflow (decided): max_pending_per_session counts only pending
agent mail (accepted, admitted, claimed, or released, but not yet consumed or
discarded) — plugin, delegation-notification, and goal producer messages do
not count against it, and no agent message is ever discarded to make room. When
the cap is reached, send_message fails with the send_message:inbox_full
tool error and the new message is not admitted; the sender may retry later. No
silent drops.
Observability, TUI, Plugins¶
- TUI: message lifecycle events are durable, so
events.subscribealready delivers them. Both delivery modes surface as producer events —steerandqueuesends alike appear asProducerMessage*rows (owner == Agent) in the transcript, so users see steering and queueing exactly like existing producer traffic. - Plugins: zero new hooks —
plugin/eventobserves all mail traffic;tool_before_callcan gate/audit sends;plugin/producer/sendalready lets external systems inject into any session (precedent for external→agent mail). - Audit: bodies are durable in the recipient's event log, so full mail history is reconstructable per session.
Migration notes¶
There is no separate changelog; these notes are the migration record for the messaging rollout.
steer_subagentis removed. The model-visible tool no longer exists. Its replacements:
| Former call | Replacement |
|---|---|
| Steer a running child | send_message { to, body, mode: "steer" } (the default mode) |
| Steer a queued child | send_message { to, body }; the message is durably accepted and claimed when the child's run starts |
| Reach a finished child | Newly possible: send_message wakes finished sessions through producer reconcile |
Parent→child authority moves from the delegate action to the message
action with relationship labels. Agents that previously steered children
need an explicit rule such as message: { "child": allow }; the default
agent ships without one, so out-of-box subagent steering is denied until
the rule is added. User steering through run.steer is unchanged.
2. Recipient argument renamed without an alias. The send_message
recipient argument is to; the earlier recipient_session_id spelling is
not accepted and fails with send_message:invalid_arguments. Because tool
argument objects are strict, old calls fail closed, and prepared-operation
grants issued under the old argument identity do not carry over — a
previously granted "allow for the session tree" must be approved again
under the new identity.
3. Protocol version 19. ProducerMessageAccepted gained the optional,
internal agent_hop field in live RPC responses and durable events, so
PROTOCOL_VERSION was bumped and the TS/JSON-schema bindings regenerated in
the same change, per the repo's version-bump ritual.
Resolved Decisions¶
- Scope: tree-only, equality check only. Two sessions are tree peers iff
their stored
SessionOrigin.root_session_idvalues are equal. No live registry walk, ancestry validation, or taint reconciliation; sessions left over from a reverted branch remain messageable by agents holding their IDs. Any-session addressing is out of scope (it would need a global agent registry and new ownership rules) and conflicts with the no-discovery model. - Permission action: dedicated
Messageaction. Messaging gets its ownPermissionActionvariant rather than reusingDelegate, so messaging permissions are controlled independently of delegation. - Finished agents are messageable. Sending to a
Finished/Completedsession wakes it through the producer reconcile path: the message is admitted durably and the session resumes with the message as input, enabling follow-ups without the parent re-delegating withresume_session_id. steer_subagentis removed.send_messageis a strict superset: it covers steering running and queued children and additionally reaches finished children (wake), siblings, and ancestors. Only the model-visible tool is removed; the engine's steer machinery stays for user steering. Parent→child authority moves toMessagepermission rules.- Permissions: relationship labels, deny by default. The
messageresource label is the recipient's relationship to the sender (parent,child,sibling,*), matched by ordinary wildcard rules — no relationship-based authority checks in the engine. Built-in agents ship withoutmessagerules: deny-by-default hides the tool entirely, so messaging is opt-in per agent doc. Combined with decision 4, the default agent loses out-of-box subagent steering until the user addsmessage: { "child": allow }— an accepted breaking change, documented in Migration notes. - Send result = durably accepted. Success means the message is persisted
(
ProducerMessageAccepted) and returnsmessage_id, effectivemode, andrecipient_state; failures are explicitsend_message:-namespaced tool errors plus the generic permission-denied flow; retries return the originalmessage_idvia idempotency dedup. - Inbox overflow rejects the sender.
max_pending_per_sessioncounts pending agent mail only (including claimed and released mail until it is consumed or discarded); overflow fails the send withsend_message:inbox_fulland nothing is admitted or silently discarded. - Guards are enforced and opt-out. The hop guard (
max_hops) and the per-pair in-flight window (max_inflight_per_pair) reject overshooting sends withsend_message:max_hops_exceededandsend_message:inflight_full.max_hops <= 0andmax_inflight_per_pair = 0disable their guard. Hop counting is internal metadata, never part of the delivered envelope, and a pair message counts as unacknowledged from acceptance until it is consumed or discarded.
Phased Rollout¶
- Phase 1 (implemented):
ProducerOwner::Agent,send_messagewith bothsteer(default) andqueuemodes, tree-scoped auth, relationship-labeledMessagepermissions (deny-by-default, tool hidden without rules),steer_subagenttool removal with doc/prompt migration. TUI read-only visibility. - Phase 2 (implemented): the hop guard and per-pair in-flight window are
enforced with the semantics under Guards;
askapprovals use the existing approval flow. - Phase 3 (future): plugin mediation recipes, optional broadcast-to-children
(
to: "children"), cross-daemon story if it ever becomes real.
Acceptance Criteria¶
The implementation is correct when all of the following hold, covered by tests:
- An agent with
message: { "child": allow }cansend_messagea running child (steer joins the next safe boundary) and a queued child (durable admission claimed at run start); an agent withoutmessagerules does not see the tool. - Sibling→sibling and child→parent sends work when rules allow them;
parent/child/sibling/*labels resolve correctly from origin metadata. - A message to a finished session wakes it through producer reconcile: the session resumes with the message as input; the completed delegation is untouched.
- Tree authorization: equal
root_session_idsucceeds; mismatched roots fail withsend_message:not_tree_peer; self-send fails withsend_message:self_send. - Result contract: success returns
message_id, effectivemode, andrecipient_state; a retried tool call returns the originalmessage_idand delivers once; restart before delivery still delivers. - Limits and guards:
send_message:invalid_bodyat size boundaries,send_message:inbox_fullatmax_pending_per_session(agent mail only),send_message:max_hops_exceededandsend_message:inflight_fullat their configured limits withmax_hops <= 0/max_inflight_per_pair = 0disabling each guard, and deny-by-default visibility. steer_subagentis gone from the tool surface; docs, prompt sections, and guides no longer reference it; the breaking change is in Migration notes.- TUI renders
steerandqueuedeliveries asProducerMessage*mail rows in both live and replayed streams. PROTOCOL_VERSIONbumped to 19;check-bindings.shand docs stay in sync.
Implementation Notes¶
Deliberately shallow — the mechanisms already exist; do not re-specify them:
- Wake lifecycle: waking an idle or finished session reuses the producer
reconcile path: accepted-but-unconsumed mail makes the session deliverable,
the actor is spawned if evicted, and a fresh run starts on the same session
with the message as input. No
SessionCommand::Steeror runlessUserInputAdmittedis involved. The completed delegation is not reopened; no new completion notification fires unless the woken agent sends one. - Delivery semantics:
steerandqueuefollow the existing producer machinery unchanged in all recipient states. Races with completion or compaction resolve the same way they do for other producer traffic. - Revert and fork: mail events follow the event log's existing revert and fork behavior. No special handling, no cross-branch dedup or wake suppression.
- Hop counting: meaningful only when
max_hops > 0; the count travels as internal producer metadata between chained sends and is rejected atsend_message:max_hops_exceededwhen it would exceed the limit. - Compatibility:
ProducerMessageAcceptedgained the optional, internalagent_hopfield in live RPC responses and durable events, soPROTOCOL_VERSIONwas bumped to 19 and TS/JSON-schema bindings regenerated in the same change, per the repo's version-bump ritual.