Tool Execution Domain
The tool execution domain owns how a task’s tool blocks actually run. It is the first executor extracted from Task along the target runtime architecture: the reducer decides, the executor performs, and the two only meet through commands and events.
Source: src/core/task/executors/tool/.
Why split by responsibility
Section titled “Why split by responsibility”An earlier attempt moved tool-related methods out of src/core/task/index.ts one by one, and it stalled: members such as the assistant-message presenter serve streaming output and tool preview in the same function, and the final turn also handles provider accounting, context-compaction bookkeeping, and successor-task handoff. Splitting by member either dragged unrelated duties along or left the tool work behind.
Splitting by responsibility removes the conflict: the mixed members stay where they are and act as orchestration, and the tool work leaves as a command.
The command
Section titled “The command”Each admitted tool block corresponds to one command:
interface ToolRunCommand { commandId: string turnId?: string dlineTid: string mode?: "serial" | "parallel"}The command carries only identities and the execution mode, not the tool input; production code looks up the prepared admission record by dlineTid before running it. mode is chosen by TurnDriver from the task setting when the turn is created, and the reducer copies it into the execution effect. The domain never makes a scheduling decision from mode: it receives one block at a time or a batch, and runs what it is given. turnId travels with the command so a result belonging to a turn the reducer has already left can be recognized instead of silently applied.
The executor contract
Section titled “The executor contract”handle(command: ToolRunCommand): voidhalt(order: HaltOrder): Promise<void>handle returns immediately. The caller is the serialized runtime dispatch path; awaiting a tool there would block every later event behind the slowest tool.
Every outcome becomes an event. Success emits tool.block_result, a thrown tool emits tool.block_failed, and a command arriving after a stop emits tool.rejected. Nothing escapes as an unhandled rejection, and the domain never writes state directly.
halt is a drain barrier. The receipt tool.halted is emitted only after in-flight work has settled and owned resources have been released. Results produced before the stop are dropped rather than applied, so a tool that happens to finish during teardown cannot resurrect a cancelled turn. Calling handle after a stop yields tool.rejected; when the runtime starts a new generation of work, rearm admits commands again.
A repeated stop with the same haltId has no effect, which guarantees a single release per stop generation. haltToolDomain(), which production code uses, gives each stop a 5-second budget: on timeout or error it reports the failure and runs a fallback release, in which case there may be no normal drain receipt.
Resource ownership
Section titled “Resource ownership”The domain describes the workspace resources it uses through OwnedToolResources:
interface OwnedToolResources { revertDiff(): Promise<void> cancelCommand(): Promise<void>}Previously the same duty was spread across the abort, interrupt, terminate, and provider-retry paths, each of which had to remember to revert the diff: a missed path left an editor open, and a duplicated path reverted twice. Routing every stop through one release point makes “exactly one reset per stop” structural.
The release adapter provided by createToolDomainRunner() attempts both resets even when the first fails, and reports failures without rethrowing them. If a directly injected runner throws from release, halt still emits its receipt in finally but rejects with that error itself.
The production Task currently injects a no-op cancelCommand; cancelling the foreground command remains the responsibility of existing call sites in Task.
Capability by assembly
Section titled “Capability by assembly”UI-bound capability is granted when the domain is assembled, not checked inside each handler:
type ToolDomainSurface = UserFacingSurface | DeniedSurfaceIn a domain assembled with DeniedSurface, ask, say, openInteraction, and the visible diff simply do not exist. The alternative is enumeration: overriding a known list of callbacks protects only the members someone remembered to override, and every new callback reopens the question. Withholding the surface removes the capability itself.
Denial is explicit: work that genuinely needs a user throws SurfaceDeniedError, naming the missing capability and the reason, instead of appearing to succeed while its output lands somewhere else. Denying the surface does not disable the work itself; files are still written, they are just not rendered.
Today the only production assembly of the domain uses UserFacingSurface; subagents run admitted handlers through their own runner. Assembly with DeniedSurface is currently exercised by unit tests.
Relationship to the wider runtime convergence
Section titled “Relationship to the wider runtime convergence”This domain is an early implementation of one executor in the target architecture, not a replacement for it. It delivers the tool domain and the tool.* command and event vocabulary, plus resource ownership and the halt receipt for this domain only. The unified kernel, the event loop that replaces the current runtime dispatch, and the provider, dialog, compose, background, and cotask executors remain part of the broader runtime convergence work.
Testing
Section titled “Testing”Behavior is covered by unit tests in src/core/task/executors/tool/__tests__/:
handlereturns before a block settles, and a batch runs concurrently;- a thrown tool becomes a failure event rather than an escaping rejection;
- the halt receipt follows the drain, and pre-stop results are dropped;
- a repeated cancel releases resources exactly once, while a new halt generation releases again;
- a denied surface refuses every UI capability yet still executes tools.