Skip to content

Commit 2584504

Browse files
daniel-lxsmrubenshannesrudolph
authored
Enable parallel tool calling with new_task isolation safeguards (#10979)
Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com> Co-authored-by: Hannes Rudolph <hrudolph@gmail.com>
1 parent f5d32e7 commit 2584504

6 files changed

Lines changed: 553 additions & 78 deletions

File tree

src/core/assistant-message/presentAssistantMessage.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,11 @@ export async function presentAssistantMessage(cline: Task) {
125125
break
126126
}
127127

128-
if (cline.didAlreadyUseTool) {
128+
// Get parallel tool calling state from experiments
129+
const mcpState = await cline.providerRef.deref()?.getState()
130+
const mcpParallelToolCallsEnabled = mcpState?.experiments?.multipleNativeToolCalls ?? false
131+
132+
if (!mcpParallelToolCallsEnabled && cline.didAlreadyUseTool) {
129133
const toolCallId = mcpBlock.id
130134
const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.`
131135

@@ -193,7 +197,10 @@ export async function presentAssistantMessage(cline: Task) {
193197
}
194198

195199
hasToolResult = true
196-
cline.didAlreadyUseTool = true
200+
// Only set didAlreadyUseTool when parallel tool calling is disabled
201+
if (!mcpParallelToolCallsEnabled) {
202+
cline.didAlreadyUseTool = true
203+
}
197204
}
198205

199206
const toolDescription = () => `[mcp_tool: ${mcpBlock.serverName}/${mcpBlock.toolName}]`
@@ -431,7 +438,10 @@ export async function presentAssistantMessage(cline: Task) {
431438
break
432439
}
433440

434-
if (cline.didAlreadyUseTool) {
441+
// Get parallel tool calling state from experiments (stateExperiments already fetched above)
442+
const parallelToolCallsEnabled = stateExperiments?.multipleNativeToolCalls ?? false
443+
444+
if (!parallelToolCallsEnabled && cline.didAlreadyUseTool) {
435445
// Ignore any content after a tool has already been used.
436446
// For native tool calling, we must send a tool_result for every tool_use to avoid API errors
437447
const errorMessage = `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`
@@ -530,7 +540,10 @@ export async function presentAssistantMessage(cline: Task) {
530540
}
531541

532542
hasToolResult = true
533-
cline.didAlreadyUseTool = true
543+
// Only set didAlreadyUseTool when parallel tool calling is disabled
544+
if (!parallelToolCallsEnabled) {
545+
cline.didAlreadyUseTool = true
546+
}
534547
}
535548

536549
const askApproval = async (

src/core/prompts/sections/__tests__/tool-use.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { getSharedToolUseSection } from "../tool-use"
22

33
describe("getSharedToolUseSection", () => {
4-
describe("native tool calling", () => {
5-
it("should include one tool per message requirement when experiment is disabled", () => {
4+
describe("with MULTIPLE_NATIVE_TOOL_CALLS disabled (default)", () => {
5+
it("should include one tool per message requirement when experiment is disabled (default)", () => {
66
// No experiment flags passed (default: disabled)
77
const section = getSharedToolUseSection()
88

src/core/prompts/tools/native-tools/new_task.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import type OpenAI from "openai"
22

3-
const NEW_TASK_DESCRIPTION = `This will let you create a new task instance in the chosen mode using your provided message and initial todo list (if required).`
3+
const NEW_TASK_DESCRIPTION = `Create a new task instance in the chosen mode using your provided message and initial todo list (if required).
4+
5+
CRITICAL: This tool MUST be called alone. Do NOT call this tool alongside other tools in the same message turn. If you need to gather information before delegating, use other tools in a separate turn first, then call new_task by itself in the next turn.`
46

57
const MODE_PARAMETER_DESCRIPTION = `Slug of the mode to begin the new task in (e.g., code, debug, architect)`
68

src/core/task/Task.ts

Lines changed: 116 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -2983,58 +2983,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
29832983
}
29842984
}
29852985

2986-
// Finalize any remaining streaming tool calls that weren't explicitly ended
2987-
// This is critical for MCP tools which need tool_call_end events to be properly
2988-
// converted from ToolUse to McpToolUse via finalizeStreamingToolCall()
2989-
const finalizeEvents = NativeToolCallParser.finalizeRawChunks()
2990-
for (const event of finalizeEvents) {
2991-
if (event.type === "tool_call_end") {
2992-
// Finalize the streaming tool call
2993-
const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id)
2994-
2995-
// Get the index for this tool call
2996-
const toolUseIndex = this.streamingToolCallIndices.get(event.id)
2997-
2998-
if (finalToolUse) {
2999-
// Store the tool call ID
3000-
;(finalToolUse as any).id = event.id
3001-
3002-
// Get the index and replace partial with final
3003-
if (toolUseIndex !== undefined) {
3004-
this.assistantMessageContent[toolUseIndex] = finalToolUse
3005-
}
3006-
3007-
// Clean up tracking
3008-
this.streamingToolCallIndices.delete(event.id)
3009-
3010-
// Mark that we have new content to process
3011-
this.userMessageContentReady = false
3012-
3013-
// Present the finalized tool call
3014-
presentAssistantMessage(this)
3015-
} else if (toolUseIndex !== undefined) {
3016-
// finalizeStreamingToolCall returned null (malformed JSON or missing args)
3017-
// We still need to mark the tool as non-partial so it gets executed
3018-
// The tool's validation will catch any missing required parameters
3019-
const existingToolUse = this.assistantMessageContent[toolUseIndex]
3020-
if (existingToolUse && existingToolUse.type === "tool_use") {
3021-
existingToolUse.partial = false
3022-
// Ensure it has the ID for native protocol
3023-
;(existingToolUse as any).id = event.id
3024-
}
3025-
3026-
// Clean up tracking
3027-
this.streamingToolCallIndices.delete(event.id)
3028-
3029-
// Mark that we have new content to process
3030-
this.userMessageContentReady = false
3031-
3032-
// Present the tool call - validation will handle missing params
3033-
presentAssistantMessage(this)
3034-
}
3035-
}
3036-
}
3037-
30382986
// Create a copy of current token values to avoid race conditions
30392987
const currentTokens = {
30402988
input: inputTokens,
@@ -3282,6 +3230,61 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
32823230
// the case, `presentAssistantMessage` relies on these blocks either
32833231
// to be completed or the user to reject a block in order to proceed
32843232
// and eventually set userMessageContentReady to true.)
3233+
3234+
// Finalize any remaining streaming tool calls that weren't explicitly ended
3235+
// This is critical for MCP tools which need tool_call_end events to be properly
3236+
// converted from ToolUse to McpToolUse via finalizeStreamingToolCall()
3237+
const finalizeEvents = NativeToolCallParser.finalizeRawChunks()
3238+
for (const event of finalizeEvents) {
3239+
if (event.type === "tool_call_end") {
3240+
// Finalize the streaming tool call
3241+
const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id)
3242+
3243+
// Get the index for this tool call
3244+
const toolUseIndex = this.streamingToolCallIndices.get(event.id)
3245+
3246+
if (finalToolUse) {
3247+
// Store the tool call ID
3248+
;(finalToolUse as any).id = event.id
3249+
3250+
// Get the index and replace partial with final
3251+
if (toolUseIndex !== undefined) {
3252+
this.assistantMessageContent[toolUseIndex] = finalToolUse
3253+
}
3254+
3255+
// Clean up tracking
3256+
this.streamingToolCallIndices.delete(event.id)
3257+
3258+
// Mark that we have new content to process
3259+
this.userMessageContentReady = false
3260+
3261+
// Present the finalized tool call
3262+
presentAssistantMessage(this)
3263+
} else if (toolUseIndex !== undefined) {
3264+
// finalizeStreamingToolCall returned null (malformed JSON or missing args)
3265+
// We still need to mark the tool as non-partial so it gets executed
3266+
// The tool's validation will catch any missing required parameters
3267+
const existingToolUse = this.assistantMessageContent[toolUseIndex]
3268+
if (existingToolUse && existingToolUse.type === "tool_use") {
3269+
existingToolUse.partial = false
3270+
// Ensure it has the ID for native protocol
3271+
;(existingToolUse as any).id = event.id
3272+
}
3273+
3274+
// Clean up tracking
3275+
this.streamingToolCallIndices.delete(event.id)
3276+
3277+
// Mark that we have new content to process
3278+
this.userMessageContentReady = false
3279+
3280+
// Present the tool call - validation will handle missing params
3281+
presentAssistantMessage(this)
3282+
}
3283+
}
3284+
}
3285+
3286+
// IMPORTANT: Capture partialBlocks AFTER finalizeRawChunks() to avoid double-presentation.
3287+
// Tools finalized above are already presented, so we only want blocks still partial after finalization.
32853288
const partialBlocks = this.assistantMessageContent.filter((block) => block.partial)
32863289
partialBlocks.forEach((block) => (block.partial = false))
32873290

@@ -3290,16 +3293,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
32903293

32913294
// No legacy streaming parser to finalize.
32923295

3293-
// Present any partial blocks that were just completed.
3294-
// Tool calls are typically presented during streaming via tool_call_partial events,
3295-
// but we still present here if any partial blocks remain (e.g., malformed streams).
3296-
if (partialBlocks.length > 0) {
3297-
// If there is content to update then it will complete and
3298-
// update `this.userMessageContentReady` to true, which we
3299-
// `pWaitFor` before making the next request.
3300-
presentAssistantMessage(this)
3301-
}
3302-
33033296
// Note: updateApiReqMsg() is now called from within drainStreamInBackgroundToFindAllUsage
33043297
// to ensure usage data is captured even when the stream is interrupted. The background task
33053298
// uses local variables to accumulate usage data before atomically updating the shared state.
@@ -3324,10 +3317,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
33243317

33253318
// No legacy text-stream tool parser state to reset.
33263319

3327-
// Now add to apiConversationHistory.
3328-
// Need to save assistant responses to file before proceeding to
3329-
// tool use since user can exit at any moment and we wouldn't be
3330-
// able to save the assistant's response.
3320+
// CRITICAL: Save assistant message to API history BEFORE executing tools.
3321+
// This ensures that when new_task triggers delegation and calls flushPendingToolResultsToHistory(),
3322+
// the assistant message is already in history. Otherwise, tool_result blocks would appear
3323+
// BEFORE their corresponding tool_use blocks, causing API errors.
33313324

33323325
// Check if we have any content to process (text or tool uses)
33333326
const hasTextContent = assistantMessage.length > 0
@@ -3424,13 +3417,69 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
34243417
}
34253418
}
34263419

3420+
// Enforce new_task isolation: if new_task is called alongside other tools,
3421+
// truncate any tools that come after it and inject error tool_results.
3422+
// This prevents orphaned tools when delegation disposes the parent task.
3423+
const newTaskIndex = assistantContent.findIndex(
3424+
(block) => block.type === "tool_use" && block.name === "new_task",
3425+
)
3426+
3427+
if (newTaskIndex !== -1 && newTaskIndex < assistantContent.length - 1) {
3428+
// new_task found but not last - truncate subsequent tools
3429+
const truncatedTools = assistantContent.slice(newTaskIndex + 1)
3430+
assistantContent.length = newTaskIndex + 1 // Truncate API history array
3431+
3432+
// ALSO truncate the execution array (assistantMessageContent) to prevent
3433+
// tools after new_task from being executed by presentAssistantMessage().
3434+
// Find new_task index in assistantMessageContent (may differ from assistantContent
3435+
// due to text blocks being structured differently).
3436+
const executionNewTaskIndex = this.assistantMessageContent.findIndex(
3437+
(block) => block.type === "tool_use" && block.name === "new_task",
3438+
)
3439+
if (executionNewTaskIndex !== -1) {
3440+
this.assistantMessageContent.length = executionNewTaskIndex + 1
3441+
}
3442+
3443+
// Pre-inject error tool_results for truncated tools
3444+
for (const tool of truncatedTools) {
3445+
if (tool.type === "tool_use" && (tool as Anthropic.ToolUseBlockParam).id) {
3446+
this.pushToolResultToUserContent({
3447+
type: "tool_result",
3448+
tool_use_id: (tool as Anthropic.ToolUseBlockParam).id,
3449+
content:
3450+
"This tool was not executed because new_task was called in the same message turn. The new_task tool must be the last tool in a message.",
3451+
is_error: true,
3452+
})
3453+
}
3454+
}
3455+
}
3456+
3457+
// Save assistant message BEFORE executing tools
3458+
// This is critical for new_task: when it triggers delegation, flushPendingToolResultsToHistory()
3459+
// will save the user message with tool_results. The assistant message must already be in history
3460+
// so that tool_result blocks appear AFTER their corresponding tool_use blocks.
34273461
await this.addToApiConversationHistory(
34283462
{ role: "assistant", content: assistantContent },
34293463
reasoningMessage || undefined,
34303464
)
34313465

34323466
TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
3467+
}
34333468

3469+
// Present any partial blocks that were just completed.
3470+
// Tool calls are typically presented during streaming via tool_call_partial events,
3471+
// but we still present here if any partial blocks remain (e.g., malformed streams).
3472+
// NOTE: This MUST happen AFTER saving the assistant message to API history.
3473+
// When new_task is in the batch, it triggers delegation which calls flushPendingToolResultsToHistory().
3474+
// If the assistant message isn't saved yet, tool_results would appear before tool_use blocks.
3475+
if (partialBlocks.length > 0) {
3476+
// If there is content to update then it will complete and
3477+
// update `this.userMessageContentReady` to true, which we
3478+
// `pWaitFor` before making the next request.
3479+
presentAssistantMessage(this)
3480+
}
3481+
3482+
if (hasTextContent || hasToolUses) {
34343483
// NOTE: This comment is here for future reference - this was a
34353484
// workaround for `userMessageContent` not getting set to true.
34363485
// It was due to it not recursively calling for partial blocks
@@ -4129,9 +4178,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
41294178

41304179
const shouldIncludeTools = allTools.length > 0
41314180

4132-
// Parallel tool calls are disabled - feature is on hold
4133-
// Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS)
4134-
const parallelToolCallsEnabled = false
4181+
const parallelToolCallsEnabled = state?.experiments?.multipleNativeToolCalls ?? false
41354182

41364183
const metadata: ApiHandlerCreateMessageMetadata = {
41374184
mode: mode,

0 commit comments

Comments
 (0)