Skip to content

feat(opencode): coordinator-messaging — sibling/coordinator subagent communication#38943

Draft
iceteaSA wants to merge 4 commits into
anomalyco:devfrom
iceteaSA:coordinator-messaging
Draft

feat(opencode): coordinator-messaging — sibling/coordinator subagent communication#38943
iceteaSA wants to merge 4 commits into
anomalyco:devfrom
iceteaSA:coordinator-messaging

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 26, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38964

Related: #12711 and #19999 (both describe a team messaging layer; this settles peer authorization and delivery-to-an-idle-agent, which neither specifies).

Related to #19215 (agent-team coordination) — this adds the sibling/coordinator layer that proposal needs.

Re-submission of #32517, closed by the automated cleanup bot on 2026-07-16 while still active. The branch has been force-pushed since (rebased onto current dev), so GitHub refuses an author-side reopen — same situation as #35195. No maintainer decision was involved in the closure.

Stacked on #38942 (agent-messaging, itself a re-submission of #32192) and #32425 (subagent-interrupt) — it builds directly on both. Until those merge, this PR's diff includes their commits; the net-new surface here is the sibling/coordinator layer. Please review after the two base PRs land. Draft until then.

Type of change

  • Bug fix
  • New feature
  • Refactor / code improvement
  • Documentation

What does this PR do?

Lets the sibling subagents one parent spawns message each other directly, instead of routing every message back through the parent. Same experimental flag as #38942 (OPENCODE_EXPERIMENTAL_AGENT_MESSAGING); off by default.

The surface is one composable primitive — a per-child allow-list — so the parent builds whatever communication graph it wants (hub/coordinator, mesh, chain) with no special-casing:

  • New Task param message_allow: string[] — the sibling slugs (their task_ids) a spawned child may message. Empty/omitted → parent-only (today's behavior, backward compatible). Child-immutable; only the parent sets it, at spawn.
  • New message target — a peer slug: message({ target: "<slug>", body }). Fire-and-forget only; expect_reply is rejected for any non-parent target (the synchronous round-trip stays parent-only — that's what keeps siblings deadlock-free). The parent and subagent targets are unchanged.
  • Slugs are parent-owned handles, never session ids — a slug→SessionID registry in the Messaging service resolves them. A send is authorized at send time iff the target is on the sender's allow-list and target.parentID === sender.parentID (true sibling-hood — closes a grandchild-escalation hole). Unknown-but-allow-listed slug → has not spawned yet.
  • Per-session FIFO inbox. A send enqueues and returns immediately. The recipient drains its inbox non-blocking at its own runLoop turn boundary — reusing the interrupt.consume site pattern from feat(opencode): interrupt a running subagent — steer / cancel / abort #32425 (the site, not the single-slot storage). A drain pulls all queued messages and injects them as one synthetic user message with 2N parts (a model-readable agent_message frame + a visible ✉ Inbox from <slug> marker per message), so an M-member graph costs O(1) turns per drain, not O(N). The drain is skipped on the turn that consumes a cancel interrupt.
  • Anti-abuse: outbound budget (20/sender/task), inbox cap (50/recipient), LRU dedup of (sender, body), and a tree-wide message cap as a backstop.
  • Bounded awaitInbox for a coordinator to fan results in without busy-looping.

Why it can't deadlock: every peer send is fire-and-forget (returns at once) and every drain is non-blocking at a turn boundary, so no agent ever waits on a specific inbound message — no wait-cycle can form, even in a mesh with simultaneous A↔B sends. With the flag off the message tool is unregistered and the drain block is dead code.

Since the original submission, the interrupt tools also accept a slug task_id: an id starting with ses_ resolves directly, anything else goes through the same messaging.resolveSlug registry. A resolved slug still passes the full ancestry check, so a process-global slug naming a session outside the caller's subtree is rejected rather than reachable.

How did you verify your code works?

New tests (no mocks): the slug registry + allow-list, the FIFO inbox with budget/cap/dedup, bounded awaitInbox, peer-send authorization (allow-list + parentID sibling check + expect_reply rejection), the runLoop drain (batched 2N-part injection, skip-on-cancel, flag-off no-op), and an end-to-end collaborative-sibling flow + gating.

  • cd packages/opencode && bun test → 3287 pass / 0 fail; bun typecheck clean in packages/opencode and packages/tui

Live-tested end-to-end on a real build across every topology the allow-list expresses, plus the refusal paths and cross-feature interaction:

Scenario Exercises Result
1→1 sibling send basic enqueue → drain → deliver pass
N→1 fan-in (hub) concurrent enqueue (race-free), batched drain, per-sender attribution pass
N→coordinator→parent→coordinator→N full round-trip incl. the parent-decision step pass
Full mesh (every edge) deadlock-freedom under simultaneous A↔B 2-cycles pass
Allow-list denial unauthorized sibling target rejected pass
Unspawned target allow-listed-but-not-spawned → has not spawned yet pass
expect_reply on a peer rejected at the tool boundary (fire-and-forget only) pass
Outbound budget 21st send → per-agent outbound budget (20) reached pass
Dedup identical (sender, body) ×2 → recipient receives one pass
task_steer during messaging interrupt (#32425) + inbox drain coexist at the same boundary pass

Screenshots / recordings

UI surface is small and reuses #38942's marker render path: inbox deliveries show a muted ✉ Inbox from <slug> row in the recipient's transcript (same mechanism/styling as ✉ Message from subagent). No new components.

Checklist

@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

No duplicate PRs found

The searches show that PR #38943 (current PR) is properly declared as stacked on its dependencies (#38942 and #32425) and related to the agent-team coordination feature (#19215). The only related open PR found is #38944 (session-to-session messaging), which addresses a different layer of communication. All other results are either the current PR itself or its explicitly declared dependencies/references.

@iceteaSA
iceteaSA force-pushed the coordinator-messaging branch from b713708 to c78a116 Compare July 26, 2026 10:13
Lets a parent agent (via tools) or the human (via TUI esc) steer, gracefully
cancel, or hard-abort a single running Task subagent mid-run, without
affecting the parent or sibling subagents.

A per-instance Interrupt service holds one pending interrupt per child plus a
terminal record; the child consumes it at its own runLoop turn boundary.
Steer injects a frame and the child adapts and continues; cancel injects a
frame, records a terminal, and force-breaks within a grace window; abort
cancels the BackgroundJob immediately. Interrupt tools reach any descendant
through a bounded ancestry walk that fails closed off the caller's subtree.

Off by default behind OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT.
iceteaSA added 3 commits July 26, 2026 13:27
Lets a subagent message the agent that spawned it — ask a question and block
for the answer, or send a fire-and-forget update — instead of only being able
to ask the human. The parent replies with the same tool.

A send wakes the parent's parked task wait through a background-job channel
and parks on a reply Deferred held by a per-instance Messaging service, so no
new session state is needed. Replies are bounded by a timeout, the channel is
separate from the promotion channel, and a missing parent fails fast.

Off by default behind OPENCODE_EXPERIMENTAL_AGENT_MESSAGING.


Injected parent messages carry the parent session model and variant
explicitly, so an injected message cannot fall through to the agent
default and switch the session model.
…communication

Lets the sibling subagents one parent spawns message each other directly
instead of routing everything through the parent. The surface is one
composable primitive — a per-child allow-list (`message_allow`) — so the
parent builds whatever graph it wants: hub, mesh, or chain.

Peer sends are fire-and-forget; the synchronous round-trip stays parent-only,
which is what keeps siblings deadlock-free. Slugs are parent-owned handles
resolved through a registry, authorized at send time against both the
allow-list and true sibling-hood. Recipients drain a FIFO inbox at their own
turn boundary, batched so an M-member graph costs O(1) turns per drain.

The interrupt tools also accept a slug task_id, resolved through the same
registry and still subject to the full ancestry check.

Off by default behind OPENCODE_EXPERIMENTAL_AGENT_MESSAGING.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE]: Sibling subagents cannot talk to each other without routing through the parent

1 participant