Skip to content

feat(opencode): task signals — structured returns, terse completion, sparse context, wake-on-message - #35400

Draft
iceteaSA wants to merge 7 commits into
anomalyco:devfrom
iceteaSA:task-signals
Draft

feat(opencode): task signals — structured returns, terse completion, sparse context, wake-on-message#35400
iceteaSA wants to merge 7 commits into
anomalyco:devfrom
iceteaSA:task-signals

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jul 5, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38967

Related to #19215 (agent-team coordination) — the wake-on-message piece. The other three features are new task-tool capabilities without a tracking issue.

Stacked PR. This sits on top of #38944 (session-to-session), which sits on #38943#38942, with #32425 (subagent-interrupt) merged in alongside. Against dev the diff shows 123 files, but only the top commit (task-tool + tests) are new here — everything below is the #32693 stack. Review against #32693, or wait for the base stack to land. Of the four features, only wake-on-message depends on the base stack (it uses the Messaging service from #32192/#32517); task_return, terse completion, and sparse context are self-contained and would apply on dev directly.

Type of change

  • New feature

What does this PR do?

Four controls over what flows in and out of a Task dispatch. All default to today's behavior.

  • task_return tool — structured child results. A subagent calls task_return({ result: {...} }) to set a free-form JSON object (≤4KB) on its own session row; it lands in the completion frame and on a new optional result field of the task.completed event. This is the output symmetry to the metadata input param — reviewers/coordinators can read a machine result instead of regex-parsing a "VERDICT:" line out of prose. Oversize is a model-visible tool error, not a silent truncation. The result also renders in the tool call itself, not only the completion frame.
  • Terse completion frames — completion: "full" | "terse". A background subagent's full completion body is injected verbatim into the parent, which rewrites the parent's cache tail every completion — on orchestration-heavy runs that's dozens of 2–6K-token cache busts. Terse mode replaces the frame body with a digest: the structured task_return result, the last 500 chars of the final message (verdict lines live at the tail), and a pointer to the child session for the full output. The parent can always resume the child to read the rest.
  • Sparse dispatch context — context: "full" | "sparse". A general-style dispatch pays ~35K first-turn input tokens; ~12–14K of it is instructional payload a scoped child never uses. Sparse keeps the project AGENTS.md chain, the agent prompt, tool schemas, and env; it drops global instruction files, the skills block, and the MCP-instructions block, and skips assembling them rather than discarding the output. Plugin system-prompt transforms still run (they're post-assembly), so plugins keep their say.
  • Wake-on-message — wake_on_message: true. Today a sibling/coordinator message to an idle child sits undelivered until something else wakes it. With this flag, an idle child is woken to drain its inbox when a message lands. Opt-in per dispatch (an always-on wake reintroduces interrupt storms), budgeted at 5 wakes per run (refreshed when the parent resumes the child), and never wakes a child whose ancestor session chain is gone. The inbox drops an identical sender+body redelivery within a bounded window before it can re-wake, so distinct messages each wake but a duplicate does not.

Terse and sparse both read from a new task config family (task.completion / task.context) with precedence global config → project config → agent frontmatter → dispatch param, so an orchestration-heavy setup sets the default once and overrides per dispatch.

How did you verify your code works?

Red-first throughout.

  • Full suite green: bun test across test/tool/ test/session/ test/messaging/ test/s2s/ — 3371 pass / 0 fail across the full suite; typecheck clean on opencode, core, schema, tui. Two migrations (result, context_mode), each column mapped through all six parallel session-row sites (V1 fromRow/toRow, projector, V1 SessionInfo, V2 fromRow, V2 Info) — a missed site is silent data loss, and one was caught on the V2 read path in review before it shipped.
  • Wake-on-message — the instance-context trap. The first implementation forked the wake via a bare Effect.runFork, which runs against the default runtime and does not carry the per-request InstanceRef the Messaging/SessionStatus services resolve through — so prompt.loop would die on a missing-InstanceRef defect in production while every spy-handler unit test passed green. A live probe (child-ref= MISSING) confirmed it. The fix captures the instance context with attach(...).pipe(Effect.forkIn(scope)) (the pattern the s2s poller uses) and adds an integration test that stands up the real SessionPrompt layer and drives a real prompt.loop drain — it goes red if either the registration or the context-carrying fork is reverted.
  • Mutation-checked seams: the terse-frame branch, the sparse skills/mcp-drop branch, and the wake idle-predicate each fail their test when reverted.

Live measurements (rebuilt binary). End to end on real dispatches:

  • Sparse context: a general dispatch assembled 38,266 tokens on full vs 26,524 on sparse — ~31% off, matching the predicted ~12K drop (global instructions + skills + MCP blocks). context_mode round-trips the session row, so a sparse child stays sparse across resume.
  • Terse completion: an A/B on the same child confirmed the terse frame drops the early body and keeps the last 500 chars + the session pointer. Across 111 real completion frames this session, terse would save ~7.1K tokens / ~35% of frame volume — concentrated, though: the average frame was only 183 tokens (small returns dominate) and a single 5,967-token frame was ~5.8K of that total. Terse pays off on the occasional large return, not uniformly per call.
  • task_return: a live dispatch set a structured result that persisted to the session.result column ({"verdict":"ok",...}) and rendered in both the completion frame and the tool call.
  • Wake-on-message: a background child was prompted once, went idle, and a sibling message to its inbox woke it with no resume from the parent — the database shows three assistant turns on that child and it echoed the exact message body back. The wake fires on the enqueue.

Independent cross-process corroboration. An independent OpenCode configuration deployment running as a separate process verified several of these against this feature branch, from the other side:

  • A downstream consumer plugin (33/33 tests, typecheck clean) reads the parsed task.completed result field. In one run a reviewer emitted only a task_return — no prose "VERDICT:" line — and the plugin still wrote a correct scored row (REJECT / must=3 / should=1) computed purely from the structured result, with no regex text to fall back on. The same task_return object appeared in all three sinks — completion frame, session.result column, and the parsed event field — and the enrichment payload was well-formed with no off-contract fields (small sample).
  • An independent sparse A/B on a different agent measured ~22% assembled-context reduction (76,937 → 59,769) — the same order as the 31% above, on a different process. It also surfaced a caveat: sparse cold-starts the prompt cache. The first sparse dispatch pays its input fresh (28,153 tokens vs a warm full dispatch's 265) and only collapses to ~120 on an identical second sparse call, so sparse's saving is amortization-gated — order-of-ten dispatches to break even at these cache rates. Tool registration survived sparse (a sparse child still discovered and used a custom tool whose doc was dropped).
  • An independent wake round-trip extends the single observation above. A worker dispatched with wake_on_message: true returned to completed, then a sibling coordinator sent four distinct work items fire-and-forget. The worker woke four times and processed all four in send order — including two sent in the same second, delivered ordered with none dropped — reaching 13 assistant turns on a child prompted once. Its own message-sequence counter ran 1→4 across the wakes, so session context persisted between wakes rather than resetting per delivery, and the worker collected results to a file the parent read rather than messaging back.

Limits: terse frame-token savings were measured only here, not independently reproduced. The numbers above are assembled-token and frame-volume counts, not a direct measurement of cache-tail-rewrite cost on a long-lived parent, which the tool-test harness can't observe. Duplicate delivery was exercised only through the enqueue dedup (identical sender+body within the window); a duplicate outside the window, or two distinct items with identical body text, is not covered — a concern only for mutating work modes.

Screenshots / recordings

Not a UI change of note (the task_return result now shows in the tool call output; no new UI surface).

Checklist

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.
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.
…ions

Lets two separate top-level sessions — different windows or OS processes on
the same machine — send each other messages. There is no parent/child
relationship, so both sides opt in with an explicit mutual-consent handshake:
invite mints a one-time token, accept consumes it and writes durable
bidirectional consent, then either side can msg. Peers are addressed by
session_id, never by slug, because slugs carry no uniqueness guarantee.

Send persists a capsule row to a shared table; receive has two halves — a
session taking a turn drains its inbox at the turn boundary, and an idle
session is woken by a per-instance poller that claims its rows. Delivered
rows are hard-deleted and a reaper reopens rows claimed by a crashed process.
Incoming messages render as an external-context frame and are treated as
untrusted input.

The poller forks lazily from inside the run loop so it captures the live
fiber's instance context; forking at layer-build time leaves that context
absent and the receive path dies silently while unit tests still pass.

Off by default behind OPENCODE_EXPERIMENTAL_S2S.
…sparse context, wake-on-message

Four controls over what flows in and out of a Task dispatch, all defaulting
to today's behavior.

task_return lets a subagent set a free-form JSON result on its own session
row, delivered in the completion frame and on the task.completed event, so a
coordinator can read a machine result instead of parsing prose.

Terse completion replaces a background child's full completion body with a
digest plus a pointer to the child session, which stops large returns from
rewriting the parent's cache tail on every completion.

Sparse dispatch context drops global instruction files, the skills block, and
the MCP block, and skips assembling them rather than discarding the output.

Wake-on-message wakes an idle child to drain its inbox when a sibling or
coordinator message lands, budgeted per run and never waking a child whose
ancestor chain is gone.

Terse and sparse read from a task config family with precedence global →
project → agent frontmatter → dispatch param.
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]: No control over what enters or leaves a Task dispatch

1 participant