跳转到内容

Tool Dispatch

此内容尚不支持你的语言。

Tool dispatch is the mechanism that bridges Claude’s intent (expressed as tool_use blocks in the API response) with actual code execution. This chapter covers the dispatch pipeline from block detection through result injection.

graph TD
A[API Stream] --> B{tool_use block?}
B -- yes --> C{StreamingToolExecutor enabled?}
C -- yes --> D[addTool to executor]
C -- no --> E[Collect in toolUseBlocks array]
B -- no --> F[Continue streaming]
D --> G{isConcurrencySafe?}
G -- yes --> H[Start immediately if safe]
G -- no --> I[Queue for serial execution]
E --> J[Stream ends]
J --> K[runTools - batch execution]
H --> L[Yield results during stream]
I --> M[getRemainingResults after stream]
K --> M

As the API response streams in, the queryLoop inspects each assistant message for tool_use content blocks:

// src/query.ts — inside the streaming loop
if (message.type === 'assistant') {
assistantMessages.push(message);
const msgToolUseBlocks = message.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[];
if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks);
needsFollowUp = true; // signals the loop to continue after this iteration
}
// Feed to streaming executor for immediate execution
if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) {
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message);
}
}
}

A ToolUseBlock has this shape:

type ToolUseBlock = {
type: 'tool_use';
id: string; // e.g., "toolu_01XFDUDYJgAACzvnptvVer6u"
name: string; // e.g., "Bash", "FileRead", "mcp__server__tool"
input: unknown; // tool-specific parameters
};

The StreamingToolExecutor in src/services/tools/StreamingToolExecutor.ts is the default execution engine. It starts tool execution while the API is still streaming, which can save seconds per turn.

Each tool tracked by the executor moves through these states:

stateDiagram-v2
[*] --> queued: addTool()
queued --> executing: processQueue()
executing --> completed: tool finishes
completed --> yielded: getCompletedResults()
queued --> completed: abort (synthetic error)
type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded';
type TrackedTool = {
id: string;
block: ToolUseBlock;
assistantMessage: AssistantMessage;
status: ToolStatus;
isConcurrencySafe: boolean;
promise?: Promise<void>;
results?: Message[];
pendingProgress: Message[];
contextModifiers?: Array<(context: ToolUseContext) => ToolUseContext>;
};

The executor uses a simple but effective concurrency model:

private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing');
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
);
}

Rules:

  • If nothing is executing, any tool can start
  • Concurrent-safe tools can run in parallel with other concurrent-safe tools
  • Non-concurrent tools require exclusive access (no other tools executing)

Which tools are concurrent-safe? It depends on the tool’s isConcurrencySafe(input) method. Generally:

  • Concurrent-safe: FileRead, Glob, Grep, WebFetch — read-only operations
  • Not concurrent-safe: Bash, FileWrite, FileEdit — side-effecting operations

When a tool is added, the executor attempts to process the queue immediately:

addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void {
const toolDefinition = findToolByName(this.toolDefinitions, block.name);
// ... setup TrackedTool ...
this.tools.push(tracked);
void this.processQueue();
}
private async processQueue(): Promise<void> {
for (const tool of this.tools) {
if (tool.status !== 'queued') continue;
if (this.canExecuteTool(tool.isConcurrencySafe)) {
await this.executeTool(tool);
} else {
if (!tool.isConcurrencySafe) break; // maintain order for non-concurrent
}
}
}

When a tool completes, it re-triggers processQueue() to start any tools that were waiting:

const promise = collectResults();
tool.promise = promise;
void promise.finally(() => {
void this.processQueue();
});

When a Bash tool errors, all sibling tools are cancelled:

if (isErrorResult && tool.block.name === BASH_TOOL_NAME) {
this.hasErrored = true;
this.erroredToolDescription = this.getToolDescription(tool);
this.siblingAbortController.abort('sibling_error');
}

The siblingAbortController is a child of the main toolUseContext.abortController — aborting it kills sibling tools but does NOT end the turn. Only Bash errors trigger this; read-only tool failures (e.g., FileRead on a missing file) are independent.

When StreamingToolExecutor is disabled, the fallback path in src/services/tools/toolOrchestration.ts runs tools after the stream completes:

src/services/tools/toolOrchestration.ts
export async function* runTools(
toolUseMessages: ToolUseBlock[],
assistantMessages: AssistantMessage[],
canUseTool: CanUseToolFn,
toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void> {
for (const { isConcurrencySafe, blocks } of partitionToolCalls(
toolUseMessages, toolUseContext
)) {
if (isConcurrencySafe) {
yield* runToolsConcurrently(blocks, assistantMessages, canUseTool, toolUseContext);
} else {
yield* runToolsSerially(blocks, assistantMessages, canUseTool, toolUseContext);
}
}
}

Tool calls are partitioned into batches:

function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] {
// Groups consecutive concurrent-safe tools into one batch
// Non-concurrent tools get their own batch
// Example: [Read, Read, Write, Read, Read] → [Read,Read], [Write], [Read,Read]
}

Concurrent batches use all() from src/utils/generators.ts with a max concurrency of 10:

async function* runToolsConcurrently(
toolUseMessages, assistantMessages, canUseTool, toolUseContext,
): AsyncGenerator<MessageUpdateLazy, void> {
yield* all(
toolUseMessages.map(async function* (toolUse) {
yield* runToolUse(toolUse, assistantMessage, canUseTool, toolUseContext);
}),
getMaxToolUseConcurrency(), // default: 10
);
}

Whether using StreamingToolExecutor or runTools, each individual tool call goes through runToolUse() in src/services/tools/toolExecution.ts:

graph TD
A[runToolUse] --> B[Parse input with Zod]
B --> C{Valid?}
C -- no --> D[Return parse error]
C -- yes --> E[Run PreToolUse hooks]
E --> F{Hook decision?}
F -- deny --> G[Return denial message]
F -- allow/ask --> H[Check permissions]
H --> I{Allowed?}
I -- no --> J[Return permission denial]
I -- yes --> K[Execute tool.call()]
K --> L[Run PostToolUse hooks]
L --> M[Format result]
M --> N[Yield result message]

Tool input is validated against the tool’s Zod schema:

const parsedInput = tool.inputSchema.safeParse(block.input);
if (!parsedInput.success) {
// Return error with Zod validation details
}

Permissions go through a multi-layer check (see resolveHookPermissionDecision in toolHooks.ts):

  1. PreToolUse hooks may return allow, deny, or ask
  2. Hook allow does NOT bypass settings.json deny/ask rules
  3. Rule-based permissions (checkRuleBasedPermissions) check settings
  4. canUseTool callback shows the permission dialog if needed

Tool results are converted to ToolResultBlockParam via mapToolResultToToolResultBlockParam:

const resultContent = tool.mapToolResultToToolResultBlockParam(
result.data, toolUseID
);
// → { type: 'tool_result', tool_use_id: '...', content: '...', is_error: false }

Large results (exceeding tool.maxResultSizeChars) are persisted to disk and replaced with a preview + file path.

After all tools complete, their results flow back into the message array:

// src/query.ts — at the continue site
state = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
// ...
transition: { reason: 'next_turn' },
};

The next API call sees:

[prior messages]
[assistant message with tool_use blocks]
[user message with tool_result blocks] ← injected here
[user message with attachment blocks] ← if any

When a user interrupts (Ctrl+C or submit-interrupt), tool dispatch handles cleanup:

// In StreamingToolExecutor
private getAbortReason(tool): 'sibling_error' | 'user_interrupted' | 'streaming_fallback' | null {
if (this.discarded) return 'streaming_fallback';
if (this.hasErrored) return 'sibling_error';
if (this.toolUseContext.abortController.signal.aborted) {
if (this.toolUseContext.abortController.signal.reason === 'interrupt') {
return this.getToolInterruptBehavior(tool) === 'cancel'
? 'user_interrupted' : null;
}
return 'user_interrupted';
}
return null;
}

Tools with interruptBehavior: 'block' (the default) keep running through interrupts. Tools with interruptBehavior: 'cancel' receive synthetic error results.

After abort, the query loop emits an interruption message:

if (toolUseContext.abortController.signal.reason !== 'interrupt') {
yield createUserInterruptionMessage({ toolUse: true });
}
return { reason: 'aborted_tools' };