926dfdea39
A subagent's send await:true to Main during a blocking task spawn was a structural deadlock: deliverIrcMessage queues mid-turn messages as step-boundary asides, but Main's next boundary requires the sender's own batch to finish, so the sender always burned the full irc.timeoutMs. Awaited sends now pass expectsReply through IrcBus.send; a mid-turn recipient with async.enabled off generates an ephemeral no-tools reply via runEphemeralTurn, records an irc:autoreply aside in its own history, and delivers it back over the bus with replyTo threading so the sender's waiter resolves.
9.6 KiB
9.6 KiB
irc
Send and receive messages between agents over a process-global mailbox bus.
Source
- Entry:
packages/coding-agent/src/tools/irc.ts - Model-facing prompt:
packages/coding-agent/src/prompts/tools/irc.md - Key collaborators:
packages/coding-agent/src/irc/bus.ts— process-globalIrcBus: per-agent mailboxes, delivery, waiter matching.packages/coding-agent/src/registry/agent-registry.ts— process-global agent directory and status.packages/coding-agent/src/registry/agent-lifecycle.ts— revival of parked recipients on direct send.packages/coding-agent/src/session/agent-session.ts—deliverIrcMessage(...): recipient-side injection and wake turns.packages/coding-agent/src/prompts/system/irc-incoming.md— incoming-message rendering for the recipient.packages/coding-agent/src/prompts/system/irc-autoreply.md— prompt for the ephemeral auto-reply side turn (busy recipient, async disabled).packages/coding-agent/src/config/settings-schema.ts—irc.timeoutMs.packages/coding-agent/src/modes/controllers/event-controller.ts— renders IRC events into chat UI.
Inputs
| Field | Type | Required | Description |
|---|---|---|---|
op |
"send" | "wait" | "inbox" | "list" |
Yes | Operation. |
to |
string |
send |
Recipient agent id, or "all" for broadcast. Whitespace trimmed; self-send rejected. |
message |
string |
send |
Message body. Empty-after-trim is rejected. |
replyTo |
string |
No | send: message id being answered. |
await |
boolean |
No | send: after delivery, block until the next message from that peer arrives (round-trip sugar). Invalid with to: "all". |
from |
string |
No | wait: only accept a message from this agent id. |
timeoutMs |
number |
No | wait / send await:true: timeout in milliseconds; 0 waits indefinitely. Defaults to irc.timeoutMs. |
peek |
boolean |
No | inbox: list messages without consuming them. |
Outputs
- Single-shot
AgentToolResult; no streaming updates. contentis one text block:list:No other agents.or<n> peer(s):bullets —id [displayName · kind · status]plus unread count, parent, and last-activity age; a footer notes that parked agents are revived automatically when messaged.send: per-recipient delivery receipts (injected/woken/revived/failed — <error>); withawait: true, the reply body or a clean no-reply timeout note.wait: the consumed message as[<msgId>] <from>: <body>(with a reply-to tag), orNo message within <duration>.inbox:Inbox empty.or<n> message(s):bullets.
details: IrcDetails:{ op, from?, to?, receipts?, waited?, inbox?, peers? }.waitedisnullwhen a wait timed out;receiptscarry{ to, outcome, error? }.
Flow
IrcTool.createIfconstructs the tool only whenisIrcEnabledpasses and the session has both anAgentRegistryandgetAgentId. There is noirc.enabledsetting: availability is derived — true for every subagent (taskDepth > 0; a parent always exists) and for any session that can still spawn subagents through the task tool. Only a top-level session with task spawning unavailable has no peers, hence no irc.executeresolves the registry and sender id; missing either returns a text error result instead of throwing.op: "list":registry.list()minus self and minusabortedagents —parkedpeers ARE listed. Each row includes the unread count fromIrcBus.unreadCount(...)and last activity.op: "send"validatesto/message, rejects self-sends, and rejectsawaitwithto: "all".- Target resolution: broadcasts fan out to
registry.listVisibleTo(senderId)(live peers only —running/idle; reviving every parked agent on a broadcast would be a stampede). Direct sends go through the bus unfiltered, so a parked recipient is revived. IrcBus.send(...)is fire-and-forget — it never blocks on the recipient generating anything. Delivery by recipient status:running→ message enqueued and injected as a non-interrupting aside at the recipient's next step boundary (AgentSession.deliverIrcMessage, rendered fromirc-incoming.md, persisted as anirc:incomingcustom message) — receiptinjected. If the sender awaits a reply (expectsReplyfromawait: true) and the recipient hasasync.enabledoff, the recipient also generates an ephemeral no-tools auto-reply (runEphemeralTurn, the/btwpipeline) and sends it back over the bus withreplyToset, recording anirc:autoreplyaside in its own history — a recipient blocked in a synchronous task spawn can never reach a step boundary before the sender's timeout otherwise;idle(live session) → enqueued and a real turn is started — the message wakes the agent — receiptwoken;parked→AgentLifecycleManager.global().ensureLive(to)revives the session first, then the wake path — receiptrevived;- resolution/revival failure → receipt
failedwith the error; other recipients still complete.
sendwithawait: truethen callsIrcBus.wait(senderId, { from: to }, timeoutMs, signal)and appends the reply (or a no-reply note suggestinginbox/wait) to the result. Awaited sends pass{ expectsReply: true }toIrcBus.sendso a busy recipient can auto-reply (see step 6).op: "wait"blocks until a message for the caller (optionally filtered byfrom) arrives, consumes it, and returns it. Timeout returns a clean "no message" result, not an error.op: "inbox"drains pending messages (or peeks withpeek: true) without blocking.- Timeouts resolve as
params.timeoutMs ?? irc.timeoutMs, normalized:0disables the timeout, negative/non-finite values fall back to the default120_000, positive values are truncated and clamped to ≥ 1 ms.
Modes / Variants
list: enumerate peers with status (running/idle/parked), unread counts, and last activity.senddirect: one exact peer id; wakes idle peers, revives parked ones.sendbroadcast:to: "all"to every live peer; parked peers are skipped.send+await: true: round-trip convenience — send, then wait for the next message from that peer. Marks the sendexpectsReply, enabling the busy-recipient auto-reply path when async execution is disabled.wait: block for an incoming message, optionally filtered by sender.inbox: non-blocking drain or peek.
Side Effects
- Session state
- Reads the process-global
AgentRegistry; direct sends to parked agents revive their sessions through the lifecycle manager. - Persists
irc:incomingcustom messages into recipient history; replies are ordinary turns in the recipient's own session. - Waking an idle/parked recipient starts a real agent turn (model requests, tool use) in that recipient.
- Reads the process-global
- User-visible prompts / interactive UI
- IRC events render as transcript cards in the TUI; the Agent Hub shows per-agent unread counts.
- Background work / cancellation
senditself never blocks on reply generation; onlywait(andawait: true) blocks, bounded by the resolved timeout and the caller'sAbortSignal.
- Network
- No IRC server connection. Woken recipients make their own model-provider calls as part of their turn.
- Filesystem
- No direct filesystem writes in the tool itself; recipient turns persist to their session JSONL as usual.
Limits & Caps
- Availability gates:
isIrcEnabled(running as a subagent, or task spawning available — there is noirc.enabledsetting), anAgentRegistry, and a caller agent id. - Mailboxes are bounded at 100 messages per agent (
MAILBOX_CAPinpackages/coding-agent/src/irc/bus.ts); oldest messages are dropped beyond the cap. irc.timeoutMsdefaults to120_000and is the defaultwait/send await:truetimeout;0disables the timeout, non-finite or negative values fall back to the default, positive values are truncated and clamped to at least1ms.- Broadcast scope: live peers only (
running/idle) vialistVisibleTo; direct sends address any non-aborted agent, including parked ones.
Errors
- The tool returns text errors (with
isError: true), not thrown exceptions, for:- missing registry:
IRC is unavailable in this session. - missing sender id:
IRC is unavailable: caller has no agent id. - missing
to/messageonsend - self-send:
Cannot send an IRC message to yourself. awaitwithto: "all"- unknown op
- missing registry:
- Per-recipient delivery failures surface as
failedreceipts with the error message;sendis markedisErroronly when no recipient received the message. waittimeout is a normal result (waited: null), not an error.
Notes
- This is IRC-like naming only: no servers, sockets, channels, or join/part state. Addressing is by exact registry agent id.
- Replies are real turns by the recipient, with one exception: an awaited send to a mid-turn recipient with
async.enabledoff triggers an ephemeral no-tools auto-reply (the oldrespondAsBackgroundpath), because a recipient blocked in a synchronous task spawn whose batch includes the sender can never run a real turn before the sender's timeout. A recipient may otherwise keep working before answering; checkinboxorwaitagain rather than re-sending. - Wake-on-message is the only resume primitive: messaging a parked agent revives it (same
ensureLivepath as the Agent Hub). The task tool has noresumeparameter. - Message ids are Snowflakes; pass them as
replyToto thread an answer to a specific message. - Persistence is per recipient history: the sender gets receipts in the tool result; the recipient sees the injected
irc:incomingmessage in its own transcript (visible viahistory://<id>).