StreamingToolExecutor
- Entity ID:
ent-20260409-574ade986de1 - Type:
service - Scope:
shared - Status:
active - Aliases: streaming tool executor, tool concurrency, parallel tool execution
Description
The concurrency system inside the query loop that makes Claude Code feel fast. It begins executing tools while the API is still generating a response — the moment a complete tool_use block arrives in the stream, execution starts, even if the model is still generating additional tool calls. Results are buffered and yielded in request order despite out-of-order execution.
File: src/services/tools/StreamingToolExecutor.ts (532 lines)
Concurrency model
Every tool has an isConcurrencySafe flag evaluated per invocation (Tool.ts:402):
| Category | Tools | Concurrent? |
|---|---|---|
| Read-only | FileReadTool, GlobTool, GrepTool, WebFetchTool, WebSearchTool | Yes — always true |
| Dynamic | BashTool | Depends — parses the command for write operations (isReadOnly() at BashTool.tsx:434-441) |
| State-mutating | FileEditTool, FileWriteTool, ExitPlanModeTool | No — always false |
| Default | Any tool without explicit override | No (Tool.ts:759) |
The concurrency check (StreamingToolExecutor.ts:129-135):
canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
)
}
Rules: read-only tools run in parallel with each other. State-mutating tools run alone. Mixed batches block — a write tool waits for reads to finish, and reads wait for a write to finish.
Max concurrency: 10 (default, configurable via CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY env var). Implemented with Promise.race() in src/utils/generators.ts:32-72 to maintain exactly N concurrent executions.
Tool partitioning
src/services/tools/toolOrchestration.ts:85-116 — the partitioning algorithm converts tool_use blocks into batches of consecutive same-safety tools:
Input: [Read, Read, Bash(write), Grep, Bash(write), Read]
Output: [{safe, [Read,Read]}, {unsafe, [Bash]}, {safe, [Grep]}, {unsafe, [Bash]}, {safe, [Read]}]
Safe batches run via runToolsConcurrently() (all tools in parallel up to concurrency cap). Unsafe batches run via runToolsSerially() (one at a time).
Streaming integration
The executor integrates with the query loop across three phases:
Phase 1: Tool registration during streaming (query.ts:838-844)
As tool_use blocks arrive in the API stream, each is immediately registered via addTool(). The executor evaluates concurrency safety and calls processQueue() — if conditions allow, the tool starts executing while the API continues generating.
Phase 2: Result collection during streaming (query.ts:847-862)
Between API messages, getCompletedResults() is called non-blockingly. It walks the tools array in request order, yielding completed results sequentially. Progress messages are yielded immediately regardless of ordering.
Phase 3: Remaining results after streaming (query.ts:1380-1408)
After the API stream completes, getRemainingResults() consumes all remaining tool executions. It loops: process queue → yield completed results → Promise.race() on executing tools + progress promise → repeat until all tools are done.
Result ordering
Execute out-of-order, yield in-order.
Each tracked tool has a status: queued → executing → completed → yielded.
getCompletedResults() (lines 412-440) iterates tools in request order. For each tool:
- Yield progress messages immediately (always)
- If completed: yield results, mark as yielded
- If executing and non-concurrent: stop — don't yield later tools yet
This means if tools [A, B, C] are submitted and B finishes first, B's result is held until A completes and yields. This preserves conversational coherence.
Error handling
Bash-only cascade
Only Bash tool errors cancel sibling tools (lines 355-364). Rationale: Bash commands often have implicit dependency chains (mkdir fails → subsequent commands are pointless). Read-only tools are independent — one WebFetch failure shouldn't cancel a concurrent GrepTool.
When a Bash tool errors:
1. siblingAbortController.abort('sibling_error') fires
2. All running tool processes receive the abort signal and are killed
3. Pending queued tools receive synthetic error messages
4. The parent abort controller is NOT fired — the turn continues
Sibling abort chain
toolUseContext.abortController (parent — query-level)
└── siblingAbortController (StreamingToolExecutor — Bash errors only)
└── toolAbortController (per-tool — process-level)
This isolation means a Bash error kills sibling tools but doesn't end the conversation turn.
Synthetic error messages
Three abort reasons generate synthetic tool_result error blocks (lines 153-205):
1. sibling_error — another tool (Bash) errored
2. user_interrupted — user pressed ESC or typed a new message
3. streaming_fallback — API stream fallback triggered; discard pending results
Streaming fallback handling
When the API stream fails and falls back to a new response (query.ts:712-740):
1. Yield tombstone markers for orphaned assistant messages
2. Call streamingToolExecutor.discard() — marks executor as discarded, prevents results from yielding
3. Create a fresh executor for the fallback response
This prevents tool_result messages with stale tool_use_ids from the failed attempt from leaking into the fallback response.
Interrupt behavior
Tools define how they respond to user interrupts (Tool.ts:416):
- 'block' (default) — tool continues running; user must wait
- 'cancel' — tool is aborted and receives a synthetic error
When the user types a new message while tools are executing (abort reason 'interrupt'), each tool's interrupt behavior determines whether it's cancelled or allowed to finish.
Progress messages
Progress messages are stored separately from results and yielded immediately (lines 367-374, 418-422). They bypass the ordering guarantee — users see real-time "Tool executing... 3s elapsed" feedback even if earlier tools haven't completed yet.
Progress availability wakes up the getRemainingResults() loop via a progressAvailableResolve promise, preventing unnecessary blocking.
Context modifiers
Sequential (state-mutating) tools can return context modifiers that update the ToolUseContext between executions (lines 388-395). This is not supported for concurrent tools — the comment notes this is a known limitation, but no concurrent tools currently use context modifiers.
What depends on it
- query.ts — the query loop creates and manages the StreamingToolExecutor per turn
- Tool system — every tool's
isConcurrencySafe()determines batching behavior - BashTool — the most complex concurrency case (dynamic read-only evaluation per command)
- UI/progress — the executor's progress messages drive the tool execution display
Design trade-offs
| Decision | Trade-off |
|---|---|
| Start tools during streaming | Significant latency savings, but tool results may be wasted if the API stream fails/falls back |
| In-order yielding despite out-of-order execution | Coherent conversation, but fast tools blocked behind slow ones |
| Bash-only error cascade | Prevents spurious cancellation of independent tools, but non-Bash dependency chains won't cascade |
| Max 10 concurrent tools | Prevents resource exhaustion, but could bottleneck on I/O-heavy turns with many reads |
| Per-invocation concurrency check (not per-tool-type) | BashTool can run concurrently for read-only commands, but requires command parsing overhead |
| Separate siblingAbortController | Bash errors don't end the turn, but the turn continues with partial results |
| Context modifiers sequential-only | Simple implementation, but limits future concurrent tools that might need context mutation |
Key claims
- Parallel read-only tool execution during API generation is the primary reason Claude Code feels faster than other agentic tools
- Results are always yielded in request order despite out-of-order execution
- Only Bash errors cascade to siblings — read-only tool failures are independent
- Max 10 concurrent tools, configurable via environment variable
Relations
rel-executor-query: query loop --[creates]--> StreamingToolExecutorrel-executor-tools: StreamingToolExecutor --[evaluates]--> isConcurrencySafe per toolrel-executor-bash: BashTool --[dynamically-evaluates]--> read-only status per command
Sources
src-20260409-6913a0b93c8b, src-20260409-037a8abb6277, source code at src/services/tools/StreamingToolExecutor.ts, src/services/tools/toolOrchestration.ts, src/utils/generators.ts