Skip to content

feat(opencode): add message tool for agent-to-agent communication#38942

Draft
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:agent-messaging
Draft

feat(opencode): add message tool for agent-to-agent communication#38942
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:agent-messaging

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 26, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38963

Related: #12711 and #19999 (broad agent-team proposals that include inter-agent messaging; this is the parent<->child primitive either would need).

Related to #19215 (agent-team coordination). This PR doesn't implement that whole proposal — it adds the smaller parent↔child messaging primitive it depends on.

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

Type of change

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

What does this PR do?

Adds a message tool so a subagent can talk to the agent that spawned it, instead of only being able to ask the human (question). A subagent can ask its parent a question and block for the answer, or send a fire-and-forget update; the parent answers with the same tool. Off by default behind OPENCODE_EXPERIMENTAL_AGENT_MESSAGING.

The tricky part is that during a foreground task the parent's turn is parked waiting on the Task tool result, so there's no live parent turn to answer — and if the child also blocks waiting on the parent, they deadlock.

The fix reuses machinery that already exists rather than adding a new session state. When a subagent sends to its parent, it wakes the parent's parked task wait through a new background-job channel (background.message() / waitForMessage()), and parks on a reply Deferred held by a small per-instance Messaging service that's a near-copy of the existing Question service. Waking the parent returns the message (plus the child's task_id) out of the task tool; the parent replies with message(target:"subagent", task_id, …), which resolves the Deferred and the child continues. The child's later completion is delivered by the existing background-subagent inject path. This is why there's no loop change: the only edit to existing control flow is adding a third arm to the task await race (waitForMessage) next to wait/waitForPromotion.

Why it doesn't deadlock or hang: the message channel is deliberately separate from the promotion channel (it sets its own metadata.messaged marker, not background, so waitForPromotion's short-circuit can't race it). A parked reply is always bounded by a timeout (default 300s → the child proceeds without an answer), and cancelling either session cancels the child's background job and interrupts the parked fiber via the existing finalizer. If there's no parent at all (top-level/headless), the tool fails fast instead of blocking.

Security: reply only resolves a pending entry when the caller is the parent recorded at send time, and task_id resume in the task tool now checks the target is actually the caller's child — otherwise an agent that guessed a session id could drive or inject into someone else's subagent. Bodies are XML-escaped into their frame and size-capped; there's a per-child in-flight cap and a cumulative round-trip cap so a child can't spin the parent.

Sibling↔sibling is parent-mediated only for now (the parent relays); the registry is keyed by session id so direct edges can be added later.

Visible transcript markers (TUI). Messages were previously injected as synthetic parts — the model saw them, but the human watching the TUI saw nothing. Each message flow now also writes a distinct, non-synthetic marker into the relevant transcript so the user can follow what's happening: the recipient sees the incoming message/reply (✉ Message from subagent, ✉ Reply from parent) and the sender sees an echo of what it sent (✉ Sent to parent, ✉ Replied to subagent). The marker is additive — the model still receives the original frame; the marker is a separate part tagged via metadata.message and rendered as its own muted row. Bodies are XML-escaped in the marker too.

One detail worth calling out because it is easy to get wrong: MessageTool.inject forwards the parent's current model and variant explicitly rather than letting the injected prompt fall through to the agent's configured default. Without that, delivering a message to a parent silently switches the parent's model mid-conversation, which invalidates the provider's cached prompt prefix and re-uploads the whole context. Injected prompts carry no model of their own, so createUserMessage resolves ag.model — the agent's default — before the session's actual model. (Same class as #38959 and upstream #35195, at a site that only exists on this branch.)

How did you verify your code works?

New tests (no mocks): request/reply, fire-and-forget, no-parent fail-fast, reply timeout, parent/child authorization, the abuse caps, a concurrent-send race, body escaping, the new background-job channel, the task_id parentage check, an integration test across the channel + reply seam, and the visible-marker flows (each direction/peer, with frame-breakout bodies asserted escaped, plus the Channel-A parked-parent marker).

  • cd packages/opencode && bun test → 3228 pass / 0 fail (22 skip, 1 todo) — dev baseline 3207
  • cd packages/core && bun test → the failures on this branch also fail on a clean dev checkout (DB-migration sync, file-picker model/variant, SessionRunnerLLM Location, ripgrep git metadata, env-var plugin tests, locked edit/write schema docstrings); this branch adds no new failures
  • bun typecheck clean in packages/opencode and packages/tui; regenerated the SDK with ./packages/sdk/js/script/build.ts
  • rebased onto current dev

Screenshots / recordings

UI change — the visible message markers in the subagent/parent transcript:

Subagent → parent

image

Parent → subagent (reply)

image

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

@github-actions

Copy link
Copy Markdown
Contributor

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

Based on my search results, I found one related PR that is not a duplicate but is closely related:

Related PR (not a duplicate):

Key findings:

No duplicate PRs found

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.
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]: A subagent cannot ask the agent that spawned it a question

1 participant