Skip to content

feat(opencode): interrupt a running subagent — steer / cancel / abort - #32425

Open
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:subagent-interrupt
Open

feat(opencode): interrupt a running subagent — steer / cancel / abort#32425
iceteaSA wants to merge 1 commit into
anomalyco:devfrom
iceteaSA:subagent-interrupt

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Jun 15, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #38966

Related: #32431 (stuck subagents can't be nudged — the steer case, but this PR does not claim to close it without demonstrating stuck-session recovery), #27511 (suspend/resume — overlapping on graceful stop, but resumable suspension is a different guarantee than cancel).

Type of change

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

What does this PR do?

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. Off by default behind OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT.

A small per-instance Interrupt service holds one pending interrupt per child plus a terminal record. The child consumes it at its own runLoop turn boundary (session/prompt.ts): steer injects a <steer> frame and the child adapts and continues; cancel injects a <cancel> frame, records a terminal, and force-breaks within a small grace window; abort records a terminal and cancels the BackgroundJob immediately. Cancel is turn-boundary-soft — a child inside a long-running tool call only breaks once that tool returns, so task_abort is the hard fallback.

Surfaces:

  • Tools task_steer / task_cancel / task_abort, gated by permission.interrupt.
  • POST /session/:id/interrupt (subagent sessions only; rejects non-running children).
  • TUI: esc on a subagent opens a Steer/Cancel/Abort menu, then a reason prompt.

Interrupt tools reach any descendant, not just direct children, through a bounded ancestry walk that fails closed off the caller's subtree — otherwise a runaway grandchild is unreachable from the only session that can see it. They also accept a slug task_id; a resolved slug still passes the same ancestry check, so a process-global handle cannot be used to reach outside the subtree.

Every interrupt drops a distinct visible marker into the subagent transcript — ⊘ Steered / Cancelled / Aborted by user or by parent, with the reason — attributed to its origin (a human TUI action shows "by user"; an agent tool shows "by parent"). Untrusted reasons are XML-escaped at every sink and length-capped; both the tool task_id path and the HTTP endpoint verify the target is the caller's own subagent before doing anything.

Why it can't hang or deadlock: the child only reads a pending interrupt at a turn boundary and never blocks on one, so there's no new wait. Cancel always terminates (grace window → force-break, or task_abort as the hard fallback). With the flag off the tools are unregistered, the endpoint rejects, and TUI esc falls back to its existing behavior.

How did you verify your code works?

New tests (no mocks): the Interrupt registry (request/consume/terminal/clear, cancel-overrides-steer, reason escaping, length cap), the runLoop consume + frame injection, the three tools (not_found / already_finished / running, permission routing, origin attribution), the ancestry walk including its exact hop bound, slug resolution still passing the descendant check, the abort marker for a model-less subagent (model derived from the latest user message), and a frame-breakout reason that must reach the model only escaped.

  • packages/opencode targeted interrupt/tool/prompt/server suites: 3242 pass / 0 fail across the full packages/opencode suite (dev baseline 3207)
  • bun typecheck clean in packages/opencode and packages/tui; packages/core's pre-existing dev-baseline failures are unchanged by this branch
  • regenerated the SDK with ./packages/sdk/js/script/build.ts
  • live-tested end-to-end in a real build: the esc menu, Enter-to-send, markers for both user and parent origin, slug-addressed steering, and that a hard abort actually stops the child

Screenshots / recordings

Subagent controls + interrupt menu

subagent_controls interrupt_dialog

Steer — course-correct, then the subagent continues

steer_prompt steer_result steer_parent_side

Cancel — graceful wrap-up within the grace window

cancel_prompt cancel_user cancel_parent cancel_result

Abort — immediate hard stop

abort_prompt abort_user abort_parent abort_result

Checklist

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

@iceteaSA
iceteaSA force-pushed the subagent-interrupt branch from 0603d35 to 579eef5 Compare June 15, 2026 10:43
@iceteaSA
iceteaSA marked this pull request as ready for review June 15, 2026 11:39
@iceteaSA
iceteaSA force-pushed the subagent-interrupt branch from 579eef5 to 1ee67f1 Compare June 17, 2026 12:01
dzianisv pushed a commit to dzianisv/opencode that referenced this pull request Jun 17, 2026
…anomalyco#32425)

Ports the subagent interrupt mechanism from the upstream anomalyco/opencode
PR anomalyco#32425. Allows a parent session to steer, cancel, or abort a running
subagent session mid-turn.

Three interrupt modes:
- task_steer: injects a <steer> frame into the subagent's context and
  continues execution. The subagent reads the guidance and can adjust course.
- task_cancel: injects a <cancel> frame and starts a grace countdown
  (CANCEL_GRACE_TURNS turns). After grace expires the loop breaks cleanly.
- task_abort: hard-stops the session via SessionRunState.cancel.

Implementation:
- interrupt.ts: Interrupt service with consume(), recordTerminal(),
  renderMarker(), renderCancel(), renderSteer(), CANCEL_GRACE_TURNS.
  Stores pending interrupts in a per-session in-memory queue.
- task-interrupt.ts: TaskAbortTool, TaskCancelTool, TaskSteerTool definitions
  that parent sessions can call to interrupt child sessions.
- prompt.ts: added interrupt consume loop at runLoop turn boundary. Only
  fires when session.parentID is set AND OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT
  flag is enabled. Tracks cancelGrace countdown.
- registry.ts: registers interrupt tools in tool catalog, conditionally adds
  steer/cancel/abort to builtin tool list behind the experimental flag.
  Added SessionRunState, SessionStatus, Permission, and Interrupt to the
  defaultLayer dependency chain.
- flag.ts: added OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT feature flag.

Enable with: OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT=1 or
OPENCODE_EXPERIMENTAL=1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dzianisv pushed a commit to dzianisv/opencode that referenced this pull request Jun 19, 2026
…anomalyco#32425)

Ports the subagent interrupt mechanism from the upstream anomalyco/opencode
PR anomalyco#32425. Allows a parent session to steer, cancel, or abort a running
subagent session mid-turn.

Three interrupt modes:
- task_steer: injects a <steer> frame into the subagent's context and
  continues execution. The subagent reads the guidance and can adjust course.
- task_cancel: injects a <cancel> frame and starts a grace countdown
  (CANCEL_GRACE_TURNS turns). After grace expires the loop breaks cleanly.
- task_abort: hard-stops the session via SessionRunState.cancel.

Implementation:
- interrupt.ts: Interrupt service with consume(), recordTerminal(),
  renderMarker(), renderCancel(), renderSteer(), CANCEL_GRACE_TURNS.
  Stores pending interrupts in a per-session in-memory queue.
- task-interrupt.ts: TaskAbortTool, TaskCancelTool, TaskSteerTool definitions
  that parent sessions can call to interrupt child sessions.
- prompt.ts: added interrupt consume loop at runLoop turn boundary. Only
  fires when session.parentID is set AND OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT
  flag is enabled. Tracks cancelGrace countdown.
- registry.ts: registers interrupt tools in tool catalog, conditionally adds
  steer/cancel/abort to builtin tool list behind the experimental flag.
  Added SessionRunState, SessionStatus, Permission, and Interrupt to the
  defaultLayer dependency chain.
- flag.ts: added OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT feature flag.

Enable with: OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT=1 or
OPENCODE_EXPERIMENTAL=1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
randomvariable added a commit to randomvariable/opencode that referenced this pull request Jun 25, 2026
Add an experimental subagent-interrupt capability (behind
OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT): an Interrupt service with
pending/terminal state, task_steer/task_cancel/task_abort tools (gated by a
new `interrupt` permission), a POST /session/:id/interrupt endpoint, and a
turn-boundary consume in the prompt loop that injects an escaped steer/cancel
frame plus a visible transcript marker. Cancel records a terminal reason and
force-breaks after a grace window; abort writes a marker, records the
terminal, and cancels the BackgroundJob immediately. The TUI gains an
esc-with-reason flow, footer button, and distinct interrupt markers.

Ported from anomalyco#32425 (adapted for the Effect layer; merged with the prior
task.ts/registry.ts/prompt.ts changes from anomalyco#29447/anomalyco#32122/anomalyco#12520/anomalyco#32192/anomalyco#19961).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
randomvariable added a commit to randomvariable/opencode that referenced this pull request Jun 25, 2026
Add an experimental subagent-interrupt capability (behind
OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT): an Interrupt service with
pending/terminal state, task_steer/task_cancel/task_abort tools (gated by a
new `interrupt` permission), a POST /session/:id/interrupt endpoint, and a
turn-boundary consume in the prompt loop that injects an escaped steer/cancel
frame plus a visible transcript marker. Cancel records a terminal reason and
force-breaks after a grace window; abort writes a marker, records the
terminal, and cancels the BackgroundJob immediately. The TUI gains an
esc-with-reason flow, footer button, and distinct interrupt markers.

Ported from anomalyco#32425 (adapted for the Effect layer; merged with the prior
task.ts/registry.ts/prompt.ts changes from anomalyco#29447/anomalyco#32122/anomalyco#12520/anomalyco#32192/anomalyco#19961).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@tobwen

tobwen commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

@iceteaSA I don't think your very nice PR or my slimmer one will ever get any attention :(

@iceteaSA

Copy link
Copy Markdown
Author

@iceteaSA I don't think your very nice PR or my slimmer one will ever get any attention :(

Yeah, I have a couple of other nice ones as well, won't hold my breath.

@iceteaSA
iceteaSA force-pushed the subagent-interrupt branch from 1ee67f1 to 7129abe Compare June 26, 2026 19:33
randomvariable added a commit to randomvariable/opencode that referenced this pull request Jun 30, 2026
Add an experimental subagent-interrupt capability (behind
OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT): an Interrupt service with
pending/terminal state, task_steer/task_cancel/task_abort tools (gated by a
new `interrupt` permission), a POST /session/:id/interrupt endpoint, and a
turn-boundary consume in the prompt loop that injects an escaped steer/cancel
frame plus a visible transcript marker. Cancel records a terminal reason and
force-breaks after a grace window; abort writes a marker, records the
terminal, and cancels the BackgroundJob immediately. The TUI gains an
esc-with-reason flow, footer button, and distinct interrupt markers.

Ported from anomalyco#32425 (adapted for the Effect layer; merged with the prior
task.ts/registry.ts/prompt.ts changes from anomalyco#29447/anomalyco#32122/anomalyco#12520/anomalyco#32192/anomalyco#19961).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@iceteaSA
iceteaSA force-pushed the subagent-interrupt branch from 7129abe to 7cb9635 Compare July 2, 2026 11:16
@iceteaSA
iceteaSA force-pushed the subagent-interrupt branch from 7cb9635 to 7d3915b Compare July 8, 2026 21:19
randomvariable added a commit to randomvariable/opencode that referenced this pull request Jul 10, 2026
Add an experimental subagent-interrupt capability (behind
OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT): an Interrupt service with
pending/terminal state, task_steer/task_cancel/task_abort tools (gated by a
new `interrupt` permission), a POST /session/:id/interrupt endpoint, and a
turn-boundary consume in the prompt loop that injects an escaped steer/cancel
frame plus a visible transcript marker. Cancel records a terminal reason and
force-breaks after a grace window; abort writes a marker, records the
terminal, and cancels the BackgroundJob immediately. The TUI gains an
esc-with-reason flow, footer button, and distinct interrupt markers.

Ported from anomalyco#32425 (adapted for the Effect layer; merged with the prior
task.ts/registry.ts/prompt.ts changes from anomalyco#29447/anomalyco#32122/anomalyco#12520/anomalyco#32192/anomalyco#19961).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@iceteaSA
iceteaSA force-pushed the subagent-interrupt branch from 7d3915b to e110d4a Compare July 16, 2026 15:31
randomvariable added a commit to randomvariable/opencode that referenced this pull request Jul 17, 2026
Add an experimental subagent-interrupt capability (behind
OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT): an Interrupt service with
pending/terminal state, task_steer/task_cancel/task_abort tools (gated by a
new `interrupt` permission), a POST /session/:id/interrupt endpoint, and a
turn-boundary consume in the prompt loop that injects an escaped steer/cancel
frame plus a visible transcript marker. Cancel records a terminal reason and
force-breaks after a grace window; abort writes a marker, records the
terminal, and cancels the BackgroundJob immediately. The TUI gains an
esc-with-reason flow, footer button, and distinct interrupt markers.

Ported from anomalyco#32425 (adapted for the Effect layer; merged with the prior
task.ts/registry.ts/prompt.ts changes from anomalyco#29447/anomalyco#32122/anomalyco#12520/anomalyco#32192/anomalyco#19961).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@iceteaSA
iceteaSA force-pushed the subagent-interrupt branch from e110d4a to 0291a5e Compare July 25, 2026 06:38
@iceteaSA
iceteaSA force-pushed the subagent-interrupt branch 2 times, most recently from 9fbffb2 to 885e2f8 Compare July 26, 2026 10:38
@bukit-kronik

Copy link
Copy Markdown

A hard stop is what we need for the subagent session, manually by user or by the lead agent.

@iceteaSA

iceteaSA commented Aug 2, 2026

Copy link
Copy Markdown
Author

@bukit-kronik that's what this does — three levels, since "stop it" turns out to mean different things depending on how stuck the subagent is:

  • task_steer — push a correction into a running subagent; it adapts and keeps going, keeping its work.
  • task_cancel — graceful stop: it gets the reason, wraps up, summarises partial work, then stops.
  • task_abort — immediate hard kill, no wrap-up.

All three are parentage-checked, so an agent can only interrupt its own descendants.

The distinction that took a while to get right: task_cancel only lands at a turn boundary, so a subagent wedged inside a long-running tool call (a 10-minute build, say) won't stop until that tool returns. task_abort is the answer there — it's the difference between "stop when you can" and "stop now."

On the lead-agent-vs-user split you raise: the tools are available to whichever agent spawned the subagent, so a lead agent can stop its own children. A user-facing hard stop is a separate surface — the TUI would need to expose it — and isn't in this PR.

Running in a fork for a while now; the abort path is the one that gets used, usually on a subagent looping without progress.

@tobwen on attention — no movement since June, but the PR's been rebased onto current dev a few times and the tests still pass, so it stays mergeable if a maintainer picks it up.

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
iceteaSA force-pushed the subagent-interrupt branch from 091f26a to 54e4249 Compare August 2, 2026 11:44
randomvariable added a commit to randomvariable/opencode that referenced this pull request Aug 3, 2026
Add an experimental subagent-interrupt capability (behind
OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT): an Interrupt service with
pending/terminal state, task_steer/task_cancel/task_abort tools (gated by a
new `interrupt` permission), a POST /session/:id/interrupt endpoint, and a
turn-boundary consume in the prompt loop that injects an escaped steer/cancel
frame plus a visible transcript marker. Cancel records a terminal reason and
force-breaks after a grace window; abort writes a marker, records the
terminal, and cancels the BackgroundJob immediately. The TUI gains an
esc-with-reason flow, footer button, and distinct interrupt markers.

Ported from anomalyco#32425 (adapted for the Effect layer; merged with the prior
task.ts/registry.ts/prompt.ts changes from anomalyco#29447/anomalyco#32122/anomalyco#12520/anomalyco#32192/anomalyco#19961).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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 running subagent cannot be steered, cancelled, or aborted individually

3 participants