The Streaming Tool Executor: How to Safely Let AI Operate Multiple Tools Simultaneously
Deep dive into StreamingToolExecutor's concurrency model — isConcurrencySafe declarations, queue scheduling, Sibling Abort cascading cancellation, progress buffering and ordered emission
~45 min read 3 modules covered04 / 31
The Problem
Imagine this scenario: you ask Claude Code to refactor a module. The model returns 5 tool_use calls in a single response — 3 file reads, 1 Bash command execution, and 1 file write. Now the questions arise:
Should these 5 tools run serially or in parallel?
If the Bash command fails, should the file reads running in parallel be cancelled?
The file write depends on the Bash result — should it wait for Bash to complete before executing?
The user presses ESC during tool execution — which tools should stop, and which should continue?
Multiple tools produce progress messages simultaneously — how should the UI display them in order?
These questions seem simple, but each one involves core challenges of concurrency control. Serial execution is too slow — users don't want to wait for 3 independent file reads to complete one after another. Full parallelism is too dangerous — a write operation and a read operation accessing the same file simultaneously could cause a data race.
Claude Code's solution is StreamingToolExecutor — a carefully designed concurrency orchestrator that lets each tool declare whether it can run in parallel, then dynamically schedules execution based on those declarations. This article will dissect every design decision in detail.
Why a Streaming Tool Executor?
In the previous article, we covered the overall architecture of the tool system. But one key question was intentionally deferred to this article: when the model returns multiple tool calls in a single streaming response, how does the executor manage their lifecycles?
Traditional approaches fall into two extremes:
Approach A: Fully Serial
Safe but extremely slow. Each tool waits for the previous one to finish before starting. For 3 independent file reads, this means 3x the wait time.
Approach B: Fully Parallel
Fast but dangerous. If Tool 1 is rm -rf build/ and Tool 2 is cat build/output.js, the result of parallel execution is unpredictable.
Approach C: Claude Code's Hybrid Scheduling
Reads run in parallel, writes get exclusive access. Safe and efficient.
This is the core problem StreamingToolExecutor solves.
Architecture Overview
StreamingToolExecutor lives in src/services/tools/StreamingToolExecutor.ts and is a class of roughly 530 lines. Its responsibilities are:
Receive tool calls — accept tool_use blocks one by one as the streaming response arrives
Determine scheduling strategy — based on each tool's concurrency safety declaration, decide whether to execute immediately or queue
Manage lifecycles — track each tool from queuing to completion
Handle error cascading — one tool's failure may require cancelling its sibling tools
Emit results in order — progress messages are sent immediately, final results are emitted in sequence
Here is the overall architecture diagram:
...
TrackedTool: The Complete Lifecycle of a Tool
Every tool call that enters the executor is wrapped in a TrackedTool object. This structure is defined at lines 21-32 of StreamingToolExecutor.ts:
src/services/tools/StreamingToolExecutor.ts:19-32
TypeScript
19// The four possible lifecycle states of a tool within the executor.
ToolStatus is a four-value enum, and each tool flows strictly through queued -> executing -> completed -> yielded:
...
queued (waiting): The tool was just added by addTool() and hasn't started executing yet. There may be other non-concurrency-safe tools currently running exclusively, so it must wait.
executing (running): The tool has started execution. Its promise field holds the execution Promise, and progress messages are collected in real time via the pendingProgress array.
completed (finished): Tool execution has ended (success, failure, or cancellation), and results are stored in the results field but haven't been emitted to the caller yet. This is the key to ordered emission — even if Tool 3 finishes first, it waits for Tool 1 and Tool 2's results to be emitted first.
yielded (emitted): Results have been emitted to the caller via getCompletedResults(), and this tool's lifecycle is completely over.
Key Field Analysis
pendingProgress is a field worth special attention. Progress messages (like real-time output from a Bash command) need to be shown to the user immediately and can't wait until the tool completes. So progress messages and final results are stored separately — progress messages can be emitted at any time, while final results must be emitted in order.
contextModifiers stores the tool's modifications to the execution context. For example, a tool might need to update file history state. But note an important restriction in the code (lines 391-395):
Only non-concurrency-safe tools can modify the context. This is a deliberate design constraint — concurrent tools modifying shared context would introduce race conditions, so it's simply prohibited.
isConcurrencySafe: Tools Decide for Themselves Whether They Can Run in Parallel
The most fundamental design principle of StreamingToolExecutor is that tools declare their own concurrency safety. Not guessed by the scheduler, not defined in a global configuration table, but implemented by each tool in its isConcurrencySafe() method.
This method is defined at line 402 of src/Tool.ts:
src/Tool.ts:402
TypeScript
402// Each tool implements this method to declare whether it can safely run
403// alongside other tools. The input parameter is key — it allows the SAME
404// tool to return different answers depending on what it's being asked to do
405// (e.g., a Bash tool running "ls" is safe, but "rm -rf" is not).
373// File reads are purely read-only — no side effects, no shared state mutation.
374isConcurrencySafe() {
375returntrue
376},
File reading is a purely read-only operation; multiple reads running simultaneously produce no side effects.
GrepTool (search) — always concurrency-safe:
src/tools/GrepTool/GrepTool.ts:183-185
TypeScript
183// Search/grep is read-only, safe to run multiple searches in parallel.
184isConcurrencySafe() {
185returntrue
186},
Search operations are likewise read-only, naturally supporting parallelism.
AgentTool (sub-agent) — always concurrency-safe:
src/tools/AgentTool/AgentTool.tsx:1273-1275
TypeScript
1273// Sub-agents run in fully isolated contexts (separate message history,
1274// separate tool instances), so they cannot interfere with each other.
1275isConcurrencySafe() {
1276returntrue;
1277},
The sub-agent tool declares itself as concurrency-safe because each sub-agent runs in its own isolated context.
BashTool (command execution) — depends on input:
src/tools/BashTool/BashTool.tsx:434-436
TypeScript
434// The Bash tool's safety depends on the COMMAND being run. The isReadOnly
435// helper analyzes the command string to determine if it only reads data
436// (e.g., ls, cat, grep) or has side effects (e.g., rm, mv, git commit).
437// If isReadOnly is not defined or throws, the nullish coalescing (??)
438// falls back to false — the conservative "assume unsafe" default.
439isConcurrencySafe(input) {
440returnthis.isReadOnly?.(input) ??false;
441},
This is the most interesting case. The Bash tool's concurrency safety depends on whether the command itself is read-only. ls, cat, grep are read-only and can run in parallel; rm, mv, git commit have side effects and must run exclusively.
Default behavior — assume unsafe (line 759):
src/Tool.ts:757-759
TypeScript
757// Default values applied to all tools created via buildTool().
758// The underscore prefix on _input signals that the parameter is
759// intentionally unused — the default always returns false.
760constTOOL_DEFAULTS= {
761// ...
762// Conservative default: any tool that doesn't explicitly opt in to
763// concurrency safety is treated as unsafe and will run exclusively.
764// This "fail-safe" approach means new tools are safe by default —
765// developers must consciously declare parallelism support.
766isConcurrencySafe: (_input?:unknown) =>false,
767// ...
768}
Tools built through buildTool() that don't explicitly declare isConcurrencySafe default to returning false. This is a conservatively safe design — better to sacrifice performance than risk concurrency issues.
Safety Calculation in addTool
When a tool is added to the executor, the isConcurrencySafe calculation process is worth careful examination. See lines 104-121 of StreamingToolExecutor.ts:
121// Any exception in the safety check defaults to unsafe —
122// if we can't determine safety, assume the worst.
123returnfalse
124 }
125 })()
126:false// Invalid input => treat as unsafe (will likely error during execution anyway)
127
128// Create the TrackedTool entry and add it to the queue.
129// Every tool starts in the 'queued' state and waits for processQueue()
130// to determine when it can begin executing.
131this.tools.push({
132 id: block.id,
133 block,
134 assistantMessage,
135 status: 'queued',
136 isConcurrencySafe,
137 pendingProgress: [],
138})
There are three layers of defense here:
Input validation: First validate input using the Zod schema. If the input format is invalid, it's immediately marked as non-concurrency-safe.
try-catch wrapper: Even if the input is valid, isConcurrencySafe() itself might throw an exception (e.g., a bug in the tool definition). Any exception falls back to false.
Boolean coercion: The result is wrapped in Boolean() to prevent tools from accidentally returning truthy values (like non-empty strings).
This "defense in depth" pattern is ubiquitous in Claude Code — on code paths related to concurrency and safety, always assume the worst case.
canExecuteTool: The Core Scheduling Decision
Given each tool's concurrency safety declaration, how does the scheduler decide whether a tool can execute immediately? The logic is remarkably concise, just 6 lines of code (lines 129-135):
In plain language: a tool can execute if and only if one of the following two conditions holds:
No tools are currently executing (idle state, any tool can start)
The current tool is concurrency-safe, and all currently executing tools are also concurrency-safe
This logic implies an important corollary: as long as any non-concurrency-safe tool is executing, all other tools must wait. Non-concurrency-safe tools get exclusive access.
Let's visualize with a table:
Currently Executing Tools
New Tool (safe)
New Tool (unsafe)
None (idle)
Can execute
Can execute
All safe
Can execute
Wait
Includes unsafe
Wait
Wait
This is a classic read-write lock pattern: concurrency-safe tools are like read locks (multiple can coexist), non-concurrency-safe tools are like write locks (must be exclusive).
processQueue: The Subtleties of Queue Scheduling
The processQueue() method (lines 140-151) is responsible for traversing the queue and starting executable tools:
150// Tool can run now — start it. Note: executeTool() sets up the
151// Promise but does NOT await completion, so multiple tools can
152// be kicked off within a single processQueue() pass.
153awaitthis.executeTool(tool)
154 } else {
155// Can't execute this tool yet, and since we need to maintain
156// order for non-concurrent tools, stop here
157//
158// CRITICAL: For unsafe tools, we BREAK — not continue. This prevents
159// tools AFTER this unsafe tool from being scheduled out of order.
160// Without this break, a later safe tool might leapfrog this unsafe
161// tool and execute before it, violating ordering guarantees.
162// Safe tools just skip (implicit continue) because they don't
163// impose ordering constraints on subsequent tools.
164if (!tool.isConcurrencySafe) break
165 }
166 }
167}
This code has an easily overlooked but critically important detail — the break statement. When it encounters a non-concurrency-safe tool that can't execute, the scheduler stops traversal. Why?
Consider the following tool sequence:
Without the break, the scheduler would skip Bash "git add ." when it can't execute and continue checking Read C. Read C is concurrency-safe and might be started. But this is problematic — Read C would execute beforegit add ., potentially reading file contents not yet staged.
The break ensures ordering between non-concurrency-safe tools. Once a queued non-concurrency-safe tool is encountered, no subsequent tools (safe or not) will be started.
Conversely: what if the tool that can't execute is a concurrency-safe one? It's simply skipped (continue) and doesn't prevent scheduling of subsequent tools. When would a concurrency-safe tool be unable to execute? When a non-concurrency-safe tool currently has exclusive access. Once the exclusive tool completes, all queued concurrency-safe tools can start together.
When processQueue Is Triggered
processQueue() is called in two places:
In addTool() (line 123): every time a new tool is added, immediately try to schedule it.
When executeTool() completes (lines 402-404): after a tool finishes, trigger a new round of scheduling.
398// Start the tool's execution and capture the resulting Promise.
399// This Promise resolves when the tool finishes (success, error, or abort).
400constpromise=collectResults()
401// Store the Promise on the TrackedTool so getRemainingResults() can
402// use Promise.race() to efficiently wait for the next tool to complete.
403tool.promise = promise
404
405// Process more queue when done
406// This .finally() callback creates the self-driving scheduling loop:
407// when this tool completes, it triggers another scheduling pass, which
408// may start queued tools that were waiting for this one to finish.
409// The void keyword discards the returned Promise — we don't need to
410// await the next processQueue() call here.
411void promise.finally(() => {
412voidthis.processQueue()
413})
This creates a self-driving loop: tool completes -> try to schedule -> new tool starts -> new tool completes -> schedule again... until the queue is empty.
Sibling AbortController: Cascading Cancellation of Errors
One of the trickiest problems with concurrent execution is error handling. When multiple tools are running in parallel, how should one tool's failure affect the others?
Claude Code's design is: only Bash tool errors cascade-cancel sibling tools. This design stems from a practical observation — Bash commands often have implicit dependency chains (mkdir fails, so the subsequent cd and touch are pointless), while Read, Grep, WebFetch and other tools are independent — one file read failure shouldn't affect another file's read.
Three-Layer AbortController Architecture
Error cascading relies on a carefully designed three-layer AbortController architecture:
This is the lifecycle controller for the entire query turn. When the user presses ESC or submits a new message, this controller is aborted, causing the entire turn to end.
Key property: aborting the sibling-level controller does not abort the parent controller. This means a Bash error can cancel all sibling tools without terminating the entire query turn — the model will still receive the error information and continue reasoning.
This code means: if the tool is aborted for a reason other than a sibling error (such as permission denial), then this abort needs to bubble up to the query-level controller to terminate the entire turn. The code comments mention #21056 regression — this upward bubbling logic was added to fix a specific regression bug.
Synthetic Error Messages
Cancelled tools aren't simply discarded — they receive a synthetic error message so the model knows these tools didn't execute successfully. The createSyntheticErrorMessage method (lines 153-205) generates different error messages based on the cancellation reason:
328// Track if this specific tool has produced an error result.
329// This prevents the tool from receiving a duplicate "sibling error"
330// message when it is the one that caused the error.
331// Without this flag, a Bash tool that errors would: (1) produce its own
332// error result, (2) trigger siblingAbortController.abort(), (3) then on
333// the next loop iteration, getAbortReason() returns 'sibling_error' for
334// THIS tool too, generating a SECOND spurious error. The flag prevents step 3.
335let thisToolErrored =false
336
337// Consume the tool's execution generator, processing each update as it arrives.
338// This is the main per-tool execution loop where progress, results, and abort
339// signals are all handled in real time.
340forawait (constupdateof generator) {
341// On every iteration, check if this tool should be cancelled.
342// getAbortReason() checks (in priority order): discard mode, sibling
343// errors, and user interrupts.
344constabortReason=this.getAbortReason(tool)
345// Only inject a synthetic error if the tool hasn't already produced its
346// own error. This is the deduplication guard — the originating error tool
347// already has a real error message and doesn't need a synthetic one.
348if (abortReason &&!thisToolErrored) {
349 messages.push(
350this.createSyntheticErrorMessage(
351 tool.id,
352 abortReason,
353 tool.assistantMessage,
354 ),
355 )
356// Stop consuming updates — this tool is done.
357break
358 }
359// ...
360if (isErrorResult) {
361// Mark this tool as errored BEFORE the abort cascades, so the
362// deduplication guard above will prevent a synthetic message.
363 thisToolErrored =true
364// ...
365 }
366}
If Tool A is a Bash tool that errors, it triggers siblingAbortController.abort(). At this point, getAbortReason() would also return sibling_error for Tool A itself. But because thisToolErrored has already been set to true, Tool A won't receive an additional synthetic error message — it already has its own real error result.
Progress Buffering and Ordered Emission
Concurrent execution introduces an output ordering problem. Suppose Tool 1 and Tool 2 are running in parallel, and Tool 2 finishes first — should its results be emitted before Tool 1's?
Claude Code's answer is to treat two types of output differently:
Progress messages: emitted immediately, no ordering required
Final results: must be emitted in tool addition order
Immediate Emission of Progress Messages
In the execution loop of the executeTool() method (lines 366-374), progress messages are stored in the pendingProgress array:
Tool 4: queued, doesn't match any condition, natural end
What if Tool 3 were non-concurrency-safe?
Tool
Type
Concurrency
Status
Note
Tool 1
Read
safe
yielded
Tool 2
Read
safe
completed
Tool 3
Bash
unsafe
executing
still running
Tool 4
Read
safe
completed
Traversal process:
Tool 1: yielded, skip
Tool 2: completed, emit results
Tool 3: executing and !isConcurrencySafe, break!
Tool 4's results will NOT be emitted, even though it's already completed
Why? Because the non-concurrency-safe tool's results may have changed the context (via contextModifiers), and Tool 4's results might depend on this modified context. So we must wait for Tool 3 to complete and the context to update before emitting Tool 4's results.
getRemainingResults Wait Mechanism
getRemainingResults() is an AsyncGenerator (lines 453-490) that continuously waits until all tools have finished:
Promise.race is the key — it simultaneously waits for two types of events:
Any executing tool to complete
Any tool to produce new progress messages
Whichever happens first wakes up the loop, allowing it to emit new results or progress. This implements an event-driven reactive loop — not polling, but passively waiting for notifications.
interruptBehavior: Strategy Selection on User Interruption
When a user presses ESC or submits a new message during tool execution, different tools should react differently. Some tools should stop immediately (like a long-running search), while others should continue running to completion (like a file write in progress — stopping midway could corrupt the file).
cancel vs block
The interruptBehavior method is defined at lines 408-416 of src/Tool.ts:
src/Tool.ts:408-416
TypeScript
408// Declares how this tool should behave when the user interrupts execution
409// (e.g., pressing ESC or submitting a new message). This is optional —
410// tools that don't implement it inherit the safe default of 'block'.
411/**
412 * What should happen when the user submits a new message while this tool
413 * is running.
414 *
415 * - 'cancel' — stop the tool and discard its result
416 * - 'block' — keep running; the new message waits
417 *
418 * Defaults to 'block' when not implemented.
419 */
420interruptBehavior?():'cancel'|'block'
cancel: The tool can safely stop midway. On user interruption, a synthetic error message is generated and partial results are discarded.
block: The tool is performing a non-interruptible operation. The user's new message must wait until this tool completes before being sent.
The default behavior is block, which is again a conservatively safe design.
Implementation in StreamingToolExecutor
The getAbortReason() method (lines 210-230) handles interruptBehavior:
Only when all executing tools are of the cancel type does the UI show an "interruptible" indicator. If any block tool is running, the entire turn is considered non-interruptible.
Discardable Mode: Tool Discard During Streaming Fallback
Claude Code uses streaming to receive model responses, but streaming can fail (network errors, server issues, etc.). When a streaming fallback occurs, the executor needs to discard results from tools that have already started but haven't completed.
The discard() method (lines 69-71) is very simple:
src/services/tools/StreamingToolExecutor.ts:64-71
TypeScript
64/**
65 * Discards all pending and in-progress tools. Called when streaming fallback
66 * occurs and results from the failed attempt should be abandoned.
67 * Queued tools won't start, and in-progress tools will receive synthetic errors.
68 */
69// Remarkably simple: just sets a boolean flag. The actual cleanup happens
70// lazily — each tool checks this flag via getAbortReason() on its next
71// loop iteration. This avoids the complexity of forcefully terminating
72// running tools and instead lets them self-terminate gracefully.
73discard(): void {
74this.discarded =true
75}
It only sets a flag. This flag propagates to all tools through getAbortReason():
This guarantees that after a streaming fallback, no residual results leak into subsequent processing.
Complete Execution Flow
Let's tie all the components together with an end-to-end example. Suppose the model returns the following tool calls:
Phase 1-3: Concurrent Reads + Queuing
Three concurrency-safe tools Read and Grep pass through addTool() → processQueue() → canExecuteTool() and begin executing simultaneously. The subsequently arriving Bash("npm test") (unsafe) and Edit("src/main.ts") (unsafe) enter the queue — Bash can't acquire exclusive access while safe tools are executing, and Edit is blocked behind the queued Bash due to break.
Phase 4: Reads Complete, Bash Starts
Once all reads complete, processQueue() triggers. The execution queue is now empty, so Bash can acquire exclusive execution access.
Phase 5: Ordered Result Emission
getRemainingResults() emits results strictly in tool addition order: Read → Grep → Read → wait for Bash → Bash result → wait for Edit → Edit result.
Exception Path: Bash Fails
If npm test returns is_error: true:
hasErrored = true → siblingAbortController.abort('sibling_error') → Edit detects abort at executeTool() entry → generates synthetic error message "Cancelled: parallel tool call Bash(npm test) errored". The model receives two error messages — one with Bash's real error, one with Edit's cancellation notice — and decides its next steps accordingly.
Comparison with toolOrchestration
There's another tool orchestration implementation in src/services/tools/toolOrchestration.ts called runTools(). How does it differ from StreamingToolExecutor?
runTools() uses a partition-batch model (lines 19-80):
src/services/tools/toolOrchestration.ts:19-30
TypeScript
19// A simpler, non-streaming alternative to StreamingToolExecutor.
20// This function requires ALL tool calls upfront (no incremental addition)
21// and uses a batch-partition model: group tools by concurrency safety,
22// then execute each group sequentially (with safe groups running in parallel).
23exportasyncfunction*runTools(
24toolUseMessages:ToolUseBlock[], // All tool calls, known in advance
25assistantMessages:AssistantMessage[],
26canUseTool:CanUseToolFn,
27toolUseContext:ToolUseContext,
28):AsyncGenerator<MessageUpdate, void> {
29// Mutable context reference — updated by non-concurrent tools' modifiers
30let currentContext = toolUseContext
31// partitionToolCalls groups consecutive tools: safe tools are batched
32// together for parallel execution, unsafe tools form single-item groups.
It first partitions all tool calls by concurrency safety, then executes them batch by batch. This is a simpler model — but it requires all tool calls to be known before execution begins.
StreamingToolExecutor's advantage is its support for incremental addition — tool calls are added one by one as the streaming response arrives, without waiting for all tool calls to be parsed. This is critical in streaming scenarios, because the model may still be generating the 5th tool call while the first 3 can already start executing.
Feature
runTools()
StreamingToolExecutor
Tool addition timing
All at once
Incremental
Scheduling strategy
Partition-batch
Real-time queue scheduling
Progress messages
No special handling
Separate storage, immediate emission
Error cascading
None
Sibling AbortController
Discard mode
None
Supported
Interrupt behavior
None
cancel/block strategy
Memory Safety of createChildAbortController
StreamingToolExecutor makes extensive use of createChildAbortController() (defined in src/utils/abortController.ts). This utility method deserves a closer look because it solves an easily overlooked memory leak problem.
The standard parent-child AbortController relationship is typically implemented like this:
TypeScript
1// Naive implementation — demonstrates the memory leak problem.
2// The closure captures `child`, creating a strong reference from parent to child.
3// As long as `parent` is alive, `child` can never be garbage collected,
4// even if nothing else references it.
5parent.signal.addEventListener('abort', () => {
6 child.abort(parent.signal.reason)
7})
The problem is: parent holds a strong reference to child through the closure. Even if child is discarded at the application level, as long as parent is alive, child can't be garbage collected. In StreamingToolExecutor, each tool creates a toolAbortController (child), while siblingAbortController (parent) lives throughout the entire tool execution phase. If the model returns 20 tool calls, there are 20 children strongly held by the parent.
createChildAbortController() solves this with WeakRef (lines 68-99):
src/utils/abortController.ts:68-99
TypeScript
68// Creates a child AbortController that is automatically aborted when the
69// parent is aborted, but uses WeakRef to avoid memory leaks. This is the
70// foundation of the three-layer abort architecture used by StreamingToolExecutor.
71exportfunctioncreateChildAbortController(
72parent:AbortController,
73maxListeners?:number,
74):AbortController {
75// Create a fresh controller, optionally with a higher max listener count
76// (Node.js warns at 10 listeners by default; high-concurrency tools may exceed this).
77constchild=createAbortController(maxListeners)
78
79// Edge case: if the parent is ALREADY aborted at creation time,
80// immediately abort the child and return — no need to set up listeners.
81if (parent.signal.aborted) {
82 child.abort(parent.signal.reason)
83return child
84 }
85
86// KEY INSIGHT: Use WeakRef for BOTH directions to prevent memory leaks.
87// Without WeakRef, the parent's event listener closure would hold a strong
88// reference to child, preventing GC even after the child is no longer needed.
89constweakChild=newWeakRef(child)
90constweakParent=newWeakRef(parent)
91// propagateAbort is a standalone function (not a closure) that dereferences
92// the WeakRefs at call time. If the child has been GC'd, it's a no-op.
93// Using .bind() avoids creating a closure that captures strong references.
WeakRef holds child: The parent's event listener references child through WeakRef, not preventing GC
WeakRef holds parent: The child's cleanup logic also references parent through WeakRef, avoiding reverse strong references
Auto-cleanup: When child is aborted, it automatically removes its listener from parent, preventing listener accumulation
{once: true}: Ensures the event handler is called only once
These measures ensure no memory leaks occur in high-concurrency tool execution scenarios.
Transferable Patterns: Implementing Similar Architecture in Your Projects
StreamingToolExecutor's concurrency model isn't unique to Claude Code — it's fundamentally a declarative concurrency scheduler. If you need to implement similar tool orchestration in your own projects, here are the core patterns you can adopt:
Pattern 1: Self-Declared Concurrency Safety
Let each operation declare for itself whether it can run in parallel, rather than hard-coding rules in the scheduler:
TypeScript
1// Pattern: each operation encapsulates its own concurrency knowledge.
2// The scheduler never needs to know implementation details — it just
3// asks "can you run in parallel?" and respects the answer.
4interfaceOperation {
5// The operation decides for itself whether it can run in parallel.
6// Accepts input so the decision can be context-dependent
7// (e.g., read-only commands vs. write commands).
8isConcurrencySafe(input:unknown):boolean
9// AbortSignal allows the scheduler to cancel the operation externally
Benefit: the scheduler doesn't need to understand the details of each operation, and adding new operations doesn't require modifying the scheduler code.
Pattern 2: Read-Write Lock Scheduling
TypeScript
1// Implements the read-write lock pattern: safe operations are "readers"
2// that can coexist, unsafe operations are "writers" that need exclusive access.
3// This single function encapsulates the entire scheduling policy.
4functioncanExecute(
5newOp:Operation,
6executingOps:Operation[]
7):boolean {
8// No operations executing: always allowed (idle state)
9if (executingOps.length===0) returntrue
10// New operation and all executing operations are concurrency-safe: allowed
11// (multiple readers can coexist without conflict)
27// Non-safe operations block subsequent result emission.
28// We can't emit later results because this unsafe operation
29// might modify shared context that later results depend on.
30break
31 }
32// Safe operations that are still executing are simply skipped —
33// they don't block emission of later completed operations.
34 }
35}
Pattern 5: Conservative Defaults
TypeScript
1// Conservative defaults: in safety-critical systems, the safest behavior
2// should require zero configuration. Developers must explicitly OPT IN
3// to more permissive behavior (parallelism, interruptibility).
4constDEFAULTS= {
5isConcurrencySafe: () =>false, // Default to unsafe — runs exclusively
6interruptBehavior: () =>'block', // Default to non-interruptible — finishes before new input
7}
In safety-related scenarios, always make the default behavior the most conservative. Tool developers must proactively declare safety, rather than safety being assumed by default.
Complete Mini Implementation
Combining the patterns above, a minimal viable concurrency scheduler is roughly 200 lines of code:
TypeScript
1// A minimal but complete implementation combining all patterns above.
2// This ~60-line scheduler demonstrates the core concepts of
3// StreamingToolExecutor without the streaming, progress, or interrupt
4// complexity. Suitable as a starting point for your own projects.
5
6// Four-state lifecycle — same as StreamingToolExecutor's TrackedTool
83// Unsafe operation still running — stop emission to preserve
84// ordering. Results after this point may depend on its outcome.
85break
86 }
87 }
88 }
89}
Design Trade-offs
Looking back at the entire StreamingToolExecutor design, there are several trade-offs worth discussing:
Why Do Only Bash Errors Cascade?
The code comment says it clearly (lines 357-359):
Bash commands often have implicit dependency chains (e.g. mkdir fails -> subsequent commands pointless). Read/WebFetch/etc are independent — one failure shouldn't nuke the rest.
This is a pragmatic choice. In theory, each tool could declare "whether my errors should cascade," but in practice, only the Bash tool has this kind of implicit dependency relationship. Over-engineering would only increase the cognitive burden on tool developers.
Why Not Support contextModifier for Concurrent Tools?
The code comment (lines 389-390) acknowledges this is a feature gap:
NOTE: we currently don't support context modifiers for concurrent tools. None are actively being used, but if we want to use them in concurrent tools, we need to support that here.
Concurrent tools modifying shared context requires solving race conditions — what happens when two tools simultaneously modify the same context field? The current approach simply prohibits it, waiting for actual demand before designing a solution. This is a textbook application of "YAGNI" (You Aren't Gonna Need It).
Why Does interruptBehavior Default to block?
Because cancelling a write operation midway could cause data corruption. block means "let the tool finish," which in the worst case only means waiting a few more seconds. cancel in the worst case could result in a half-written file. Safety > performance.
Why Generators Instead of Callbacks?
getCompletedResults() returns a Generator, and getRemainingResults() returns an AsyncGenerator. This design lets callers naturally consume results using for...of and for await...of, without needing to register callbacks. The lazy evaluation property of Generators also means unneeded results won't be computed.
Summary
StreamingToolExecutor is an elegant concurrency orchestration component in Claude Code that solves the seemingly simple but actually complex problem of "letting AI operate multiple tools simultaneously." Its core design principles include:
Self-declared concurrency safety: Tools know whether they can run in parallel; the scheduler merely executes their declarations
Layered cancellation: Three-layer AbortController architecture for precise error cascading
Ordered emission: Progress is immediately visible, results are output in order
Conservative defaults: Without a declaration, assume unsafe and non-interruptible
These principles apply not only to AI tool orchestration but to any system requiring mixed concurrency strategies — database operation scheduling, microservice orchestration, CI/CD pipeline management, and more. The 530 lines of StreamingToolExecutor distill the core wisdom of production-grade concurrency orchestration.
In the next article, we'll dive into the permission system — exploring how Claude Code ensures every tool call undergoes a security review through its six-layer evaluation chain.