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

Related to #21458 (a running subagent can't be steered/guided), #23534 ([sub-agents] not killed on cancel), and #28738 (interrupting the main agent doesn't stop background subagents). This adds the small experimental primitive those depend on rather than fully resolving each.

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.

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 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: 365 pass / 0 fail; full bun test passes
  • 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, 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

Experimental capability for a parent agent or human operator to steer,
gracefully cancel, or hard-abort a specific running Task subagent mid-run,
without affecting the parent or sibling subagents.

Core:
- Interrupt service (session/interrupt.ts): process-local registry holding
  one pending interrupt per child plus a terminal record; steer/cancel frame
  renderers and a visible-marker renderer, both with origin attribution
  (user vs parent); reason length-capped and XML-escaped at every sink
  (frames AND the visible marker).
- The child consumes pending interrupts at the runLoop turn boundary: steer
  injects a <steer> frame and a visible "Steered by ..." marker and continues;
  cancel injects <cancel> + a visible marker, records a terminal, and
  force-breaks within a grace window. abortChild writes a visible "Aborted
  by ..." marker (model/agent derived from the child's latest user message),
  records a terminal, and cancels the BackgroundJob.

Agent tools (gated by permission.interrupt):
- task_steer / task_cancel / task_abort (origin=parent).

Human paths:
- POST /session/:id/interrupt (intent steer|cancel|abort, origin=user),
  restricted to subagent sessions, gated by the experimental flag, and
  rejecting non-running children.
- TUI: esc on a subagent opens a Steer/Cancel/Abort menu, then a reason
  prompt; markers render as "... by user". Bound at the session route via a
  uniquely-named gather bucket (the keymap gather() caches by name). Visible
  interrupt markers render as a distinct "Interrupt" line (tagged via
  part.metadata.interrupt), not as user prose.

Whole feature gated by OPENCODE_EXPERIMENTAL_SUBAGENT_INTERRUPT (off by
default): agent tools, HTTP endpoint, and TUI affordance.

Limitations: agent-driven steer/cancel applies to background children only
(a foreground child blocks the parent turn); cancel is boundary-soft (use
task_abort / Abort for a child stuck in a long tool call).
@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
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.

1 participant