Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit f8b0fe6

Browse files
committed
Enable parallel tool calling with new_task isolation safeguards
- Add CRITICAL warning to new_task tool description - Implement double array truncation to prevent tools after new_task - Save assistant message before tool execution to prevent orphaned results - Make didAlreadyUseTool enforcement conditional on experiment flag - Add 14 comprehensive tests for new_task isolation - Enable feature toggle (default: OFF) - Make settings UI toggle visible Fixes Linear issue EXT-629
1 parent 953c777 commit f8b0fe6

6 files changed

Lines changed: 500 additions & 26 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: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3290,16 +3290,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
32903290

32913291
// No legacy streaming parser to finalize.
32923292

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-
33033293
// Note: updateApiReqMsg() is now called from within drainStreamInBackgroundToFindAllUsage
33043294
// to ensure usage data is captured even when the stream is interrupted. The background task
33053295
// uses local variables to accumulate usage data before atomically updating the shared state.
@@ -3324,10 +3314,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
33243314

33253315
// No legacy text-stream tool parser state to reset.
33263316

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.
3317+
// CRITICAL: Save assistant message to API history BEFORE executing tools.
3318+
// This ensures that when new_task triggers delegation and calls flushPendingToolResultsToHistory(),
3319+
// the assistant message is already in history. Otherwise, tool_result blocks would appear
3320+
// BEFORE their corresponding tool_use blocks, causing API errors.
33313321

33323322
// Check if we have any content to process (text or tool uses)
33333323
const hasTextContent = assistantMessage.length > 0
@@ -3424,13 +3414,69 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
34243414
}
34253415
}
34263416

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

34323463
TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
3464+
}
3465+
3466+
// Present any partial blocks that were just completed.
3467+
// Tool calls are typically presented during streaming via tool_call_partial events,
3468+
// but we still present here if any partial blocks remain (e.g., malformed streams).
3469+
// NOTE: This MUST happen AFTER saving the assistant message to API history.
3470+
// When new_task is in the batch, it triggers delegation which calls flushPendingToolResultsToHistory().
3471+
// If the assistant message isn't saved yet, tool_results would appear before tool_use blocks.
3472+
if (partialBlocks.length > 0) {
3473+
// If there is content to update then it will complete and
3474+
// update `this.userMessageContentReady` to true, which we
3475+
// `pWaitFor` before making the next request.
3476+
presentAssistantMessage(this)
3477+
}
34333478

3479+
if (hasTextContent || hasToolUses) {
34343480
// NOTE: This comment is here for future reference - this was a
34353481
// workaround for `userMessageContent` not getting set to true.
34363482
// It was due to it not recursively calling for partial blocks
@@ -4128,9 +4174,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
41284174

41294175
const shouldIncludeTools = allTools.length > 0
41304176

4131-
// Parallel tool calls are disabled - feature is on hold
4132-
// Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS)
4133-
const parallelToolCallsEnabled = false
4177+
// Parallel tool calls can now be enabled safely with new_task isolation enforcement
4178+
// The runtime enforcement at line ~3427 prevents tools after new_task from executing
4179+
const parallelToolCallsEnabled = state?.experiments?.multipleNativeToolCalls ?? false
41344180

41354181
const metadata: ApiHandlerCreateMessageMetadata = {
41364182
mode: mode,

0 commit comments

Comments
 (0)