feat(opencode): task signals — structured returns, terse completion, sparse context, wake-on-message - #35400
Draft
iceteaSA wants to merge 7 commits into
Draft
feat(opencode): task signals — structured returns, terse completion, sparse context, wake-on-message#35400iceteaSA wants to merge 7 commits into
iceteaSA wants to merge 7 commits into
Conversation
This was referenced Jul 25, 2026
iceteaSA
force-pushed
the
task-signals
branch
3 times, most recently
from
July 26, 2026 10:38
5c7b1b5 to
0b84f29
Compare
This was referenced Jul 26, 2026
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Type of change
What does this PR do?
Four controls over what flows in and out of a Task dispatch. All default to today's behavior.
task_returntool — structured child results. A subagent callstask_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 optionalresultfield of thetask.completedevent. This is the output symmetry to themetadatainput 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.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 structuredtask_returnresult, 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.context: "full" | "sparse". Ageneral-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: 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
taskconfig 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.
bun testacrosstest/tool/ test/session/ test/messaging/ test/s2s/— 3371 pass / 0 fail across the full suite; typecheck clean onopencode,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.Effect.runFork, which runs against the default runtime and does not carry the per-requestInstanceReftheMessaging/SessionStatusservices resolve through — soprompt.loopwould die on a missing-InstanceRefdefect in production while every spy-handler unit test passed green. A live probe (child-ref= MISSING) confirmed it. The fix captures the instance context withattach(...).pipe(Effect.forkIn(scope))(the pattern the s2s poller uses) and adds an integration test that stands up the realSessionPromptlayer and drives a realprompt.loopdrain — it goes red if either the registration or the context-carrying fork is reverted.Live measurements (rebuilt binary). End to end on real dispatches:
generaldispatch assembled 38,266 tokens on full vs 26,524 on sparse — ~31% off, matching the predicted ~12K drop (global instructions + skills + MCP blocks).context_moderound-trips the session row, so a sparse child stays sparse across resume.task_return: a live dispatch set a structured result that persisted to thesession.resultcolumn ({"verdict":"ok",...}) and rendered in both the completion frame and the tool call.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:
task.completedresultfield. In one run a reviewer emitted only atask_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 sametask_returnobject appeared in all three sinks — completion frame,session.resultcolumn, and the parsed event field — and the enrichment payload was well-formed with no off-contract fields (small sample).wake_on_message: truereturned tocompleted, 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_returnresult now shows in the tool call output; no new UI surface).Checklist