Visitar URL original
feat(core): agent tunnel messaging between sessions by WaelRabah · Pull Request #62237 · anomalyco/opencode · GitHub
Skip to content

feat(core): agent tunnel messaging between sessions - #62237

Closed
WaelRabah wants to merge 6 commits into
anomalyco:devfrom
WaelRabah:agent-tunnel
Closed

WaelRabah wants to merge 6 commits into
anomalyco:devfrom
WaelRabah:agent-tunnel

Conversation

@WaelRabah

@WaelRabah WaelRabah commented Nov 6, 2026 •

Copy link
Copy Markdown

What

Agent-to-agent messaging between sessions in the same family, plus report-to-parent, as the backend for the agent graph view.

A running session can use the new built-in tunnel tool to send a directed message to another session in its family:

  • kind: "message" targets any family member (sibling, ancestor, or descendant) via to.
  • Omitting to (or passing kind: "report") reports back to the parent session; report requires a strict ancestor.

Every send is permission-gated: permission.assert({ action: "tunnel", resources: [<target's agent ?? session id>], ...Permission.invocation(context) }), so per-agent allow/ask/deny rules control who may message whom. The tool's context hook only advertises family members the caller is permitted to reach.

Delivery reuses the existing synthetic inbox admission: the message id doubles as the synthetic inbox item id, so resends are idempotent and cross-recipient id reuse fails. Running targets receive it as a steer; idle targets are woken (session.inbox.enqueued / session.inbox.delivered record delivery — no new execution path). The send itself is recorded as one minimal durable event, session.tunnel.sent, on the recipient aggregate, projected into a new session_tunnel table that serves family queries.

Feature flag

Gated by agent_tunnel (in packages/schema/src/config/flag.ts). On in local and dev builds, off in releases; override with experimental.agent_tunnel: true in config or OPENCODE_FLAGS=agent_tunnel. While the flag is off the tunnel tool is absent from the registry (reloaded on config change) and the experimental endpoints return 503.

Endpoints / identifiers

Path Identifier Behavior
GET /api/experimental/agent-tunnel?sessionID=<family member>&after=<seq> experimental.agentTunnel.list Family messages ordered by (time.created, sequence) with an exclusive after cursor; 404 SessionNotFoundError for unknown sessions
GET /api/experimental/agent-tunnel/log?sessionID=<member>&after=<seq>&follow=true|false experimental.agentTunnel.log SSE: replay → one agent-tunnel.synced marker → live messages when follow=true; replay-and-close otherwise

Both are experimental (may change without compatibility guarantees). The subscription is acquired before the replay read, so nothing is missed or duplicated across the sync boundary. The generated client exposes them as client.experimental.agentTunnel.list(...) / client.experimental.agentTunnel.log(...) (promise, Effect, and solid flavors).

Pieces

  • packages/schema — AgentTunnel.Message wire/storage contract (agent-tunnel.ts), SessionEvent.TunnelSent (session.tunnel.sent), the agent_tunnel flag.
  • packages/core — SessionTunnel service (family validation, event publish, delivery), session_tunnel table + migration, projector fold, the tunnel tool plugin, 8 test cases.
  • packages/protocol + packages/server — experimental endpoints, handler wiring, SSE follow stream, flag gate, smoke tests.
  • packages/client — regenerated for the new event and endpoints, plus the group-coverage test.

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none">
  <rect x="14" y="23" width="62" height="26" rx="7" stroke="currentColor" stroke-opacity="0.85" stroke-width="2"/>
  <text x="45" y="40" text-anchor="middle" font-family="ui-monospace,monospace" font-size="11" fill="currentColor" fill-opacity="0.85">parent</text>
  <rect x="164" y="23" width="62" height="26" rx="7" stroke="currentColor" stroke-opacity="0.85" stroke-width="2"/>
  <text x="195" y="40" text-anchor="middle" font-family="ui-monospace,monospace" font-size="11" fill="currentColor" fill-opacity="0.85">subagent</text>
  <line x1="80" y1="29" x2="154" y2="29" stroke="currentColor" stroke-opacity="0.3" stroke-width="2"/>
  <path d="M164 29 l-10 -4.5 v9 z" fill="currentColor" fill-opacity="0.3"/>
  <line x1="160" y1="47" x2="86" y2="47" stroke="currentColor" stroke-opacity="0.3" stroke-width="2" stroke-dasharray="4 3"/>
  <path d="M76 47 l10 -4.5 v9 z" fill="currentColor" fill-opacity="0.3"/>
  <circle r="3.5" fill="currentColor" fill-opacity="0.95">
    <animateMotion dur="1.4s" repeatCount="indefinite" path="M80 29 L153 29"/>
  </circle>
  <circle r="3.5" fill="currentColor" fill-opacity="0.95">
    <animateMotion dur="1.4s" begin="0.7s" repeatCount="indefinite" path="M160 47 L87 47"/>
  </circle>
</svg>

Test plan

  • bun run check from repo root (35/35 tasks)
  • bun test from packages/schema (97 pass), packages/protocol (14 pass), packages/server (126 pass), packages/client (259 pass), packages/core (7285 pass; 3 shell failures reproduce identically on origin/dev — pre-existing on this machine)
  • Migration check (bun run script/migration.ts --check) in core

Added by the opencode bot (AI-generated).

Related: #52297, #52795

Public API: adds the tunnel tool and permission action, the agent_tunnel flag, the session.tunnel.sent event, experimental /api/experimental/agent-tunnel list and SSE log endpoints with client methods, and a session_tunnel table (backwards compatible)

The session.tunnel.sent payload was the first event to carry a DateTime
runtime value, which the protocol wire-shape invariant requires to stay
JSON-shaped. The encoded contract is unchanged: { created: number }.
Regenerates the client event types from the updated schema.
Adds the agent tunnel: a built-in tunnel tool a running session uses to
send a message to another session in its family (sibling, ancestor, or
descendant), with report-to-parent semantics when `to` is omitted.

- SessionTunnel service (src/session/tunnel.ts): family validation via
  the parentID chain, one durable session.tunnel.sent event on the
  recipient aggregate, and delivery through the existing synthetic
  inbox admission. The message id doubles as the synthetic inbox item
  id, so resent messages reconcile idempotently per recipient and
  cross-recipient id reuse fails. Idle recipients wake through the
  synthetic admission's advisory execution.wake; running ones receive
  a steer.
- session_tunnel table (migration 20261106020704): one row per send,
  projected from session.tunnel.sent with the assigned durable seq as
  the authoritative per-recipient sequence; the body travels with the
  row so family queries never fold event logs.
- Tunnel tool (src/tool/plugin/tunnel.ts): permission-gated like the
  subagent tool (action "tunnel" against the target's agent, or the
  session id when it has none), with a context hook that lists the
  family members the session may message.
- Gated by the agent_tunnel flag: the tool registers only while the
  flag is on, and each execution re-checks Flag.enabled against live
  config entries.
Add the experimental agent-tunnel HTTP surface: a family-scoped message
list with an after cursor and an SSE log that replays, emits an
agent-tunnel.synced marker, then follows live messages when follow=true
(subscribe-before-read with id dedupe, mirroring the session log).

Endpoints live under /api/experimental/agent-tunnel with identifiers
experimental.agentTunnel.list/.log, handled in the server.experimental
group and gated per request on the agent_tunnel schema flag. Surfacing
SessionTunnel.node in routes gives the handler layer the service; core
exports SessionTunnel.make so the handler test can capture it.
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026

Copy link
Copy Markdown
Contributor

This PR doesn't fully meet our contributing guidelines and PR template.

What needs to be fixed:

  • PR description is missing required template sections. Please use the PR template.

Please edit this PR description to address the above within 72 hours, or it will be automatically closed.

If you believe this was flagged incorrectly, please let a maintainer know.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The core of this works: I ran the new core, server and schema tests (all pass) and a small probe of SessionTunnel.list. Sends are checked for family membership and permissions, delivered through the synthetic inbox, and recorded. However, the after cursor that the list and log endpoints are built on gives wrong results once a family has more than one recipient (see inline). The type-check could not run here because the installed Bun has no bun check.

Public API: all additions, nothing changed or removed, so it is backwards compatible.

  • New tunnel built-in tool and a tunnel permission action. With no rule configured, this action defaults to ask.
  • New agent_tunnel flag (experimental.agent_tunnel / OPENCODE_FLAGS=agent_tunnel).
  • New durable event session.tunnel.sent and schema AgentTunnel.Message.
  • New experimental endpoints GET /api/experimental/agent-tunnel and GET /api/experimental/agent-tunnel/log (SSE), exposed in the client as experimental.agentTunnel.list / experimental.agentTunnel.log.
  • New session_tunnel table and migration.

Concerns:

  1. Cursor across the family: sequence counts events per recipient, but it is used as a cursor for the whole family. In my probe it dropped a message (inline).
  2. Live messages carry a guessed sequence: the log stream sends the sender's predicted sequence, not the one stored at commit, so the same message can show different sequences in the replay and the live stream (inline).
  3. Overlap with other open PRs: the report kind overlaps with the report-to-parent work in #61833 and #60360. It would help to say how the two are meant to fit together, so a parent doesn't get two report mechanisms for one subagent.

Related: #52297, #52795

.where(
and(
or(inArray(SessionTunnelTable.from_session_id, ids), inArray(SessionTunnelTable.to_session_id, ids)),
input.after === undefined ? undefined : gt(SessionTunnelTable.sequence, input.after),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sequence is the recipient's own event sequence, but this query covers every recipient in the family and filters sequence > after. The two don't line up once a family has more than one recipient. I reproduced it: two messages to writer were stored with sequences 1 and 3, and one to grandchild with sequence 1. list({ sessionID: root }) returns all three, but list({ sessionID: root, after: 3 }) returns [], so the grandchild message is lost. Messages can also come back twice in other orderings. The existing test only sends to one recipient, so it doesn't catch this. The cursor needs a value that grows across the whole family: for example the row's commit order (an autoincrement rowid) or a composite (time_created, recipient, sequence) cursor. Please add a test that sends to two recipients.

const seen = new Set(replay.map((message) => message.id))
const live = Stream.fromPull(Effect.succeed(subscribe)).pipe(
Stream.map((event) => {
const message: AgentTunnel.Message = event.data.message

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Live messages are sent with event.data.message.sequence. That is the sender's guess (latestSequence + 1, made before publishing), while the replay uses the sequence stored at commit (event.durable.seq). If the recipient emits other events in between, which is likely while it is running, the same message gets different sequences in the live stream and in a later replay. A client that resumes from a live sequence then skips or repeats messages. Use event.durable.seq here, or leave the guessed value out of the payload.

@WaelRabah

Copy link
Copy Markdown
Author

Closing: the agent-graph / agent-tunnel work is being retargeted to the AgentTunnel Go implementation in its own repo; this opencode PR will not be pursued.

@WaelRabah WaelRabah closed this Nov 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs:compliance This means the issue will auto-close after 2 hours.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant