Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions src/core/assistant-message/presentAssistantMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,11 @@ export async function presentAssistantMessage(cline: Task) {
break
}

if (cline.didAlreadyUseTool) {
// Get parallel tool calling state from experiments
const mcpState = await cline.providerRef.deref()?.getState()
const mcpParallelToolCallsEnabled = mcpState?.experiments?.multipleNativeToolCalls ?? false

if (!mcpParallelToolCallsEnabled && cline.didAlreadyUseTool) {
const toolCallId = mcpBlock.id
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.`

Expand Down Expand Up @@ -193,7 +197,10 @@ export async function presentAssistantMessage(cline: Task) {
}

hasToolResult = true
cline.didAlreadyUseTool = true
// Only set didAlreadyUseTool when parallel tool calling is disabled
if (!mcpParallelToolCallsEnabled) {
cline.didAlreadyUseTool = true
}
}

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

if (cline.didAlreadyUseTool) {
// Get parallel tool calling state from experiments (stateExperiments already fetched above)
const parallelToolCallsEnabled = stateExperiments?.multipleNativeToolCalls ?? false

if (!parallelToolCallsEnabled && cline.didAlreadyUseTool) {
// Ignore any content after a tool has already been used.
// For native tool calling, we must send a tool_result for every tool_use to avoid API errors
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.`
Expand Down Expand Up @@ -530,7 +540,10 @@ export async function presentAssistantMessage(cline: Task) {
}

hasToolResult = true
cline.didAlreadyUseTool = true
// Only set didAlreadyUseTool when parallel tool calling is disabled
if (!parallelToolCallsEnabled) {
cline.didAlreadyUseTool = true
}
}

const askApproval = async (
Expand Down
4 changes: 2 additions & 2 deletions src/core/prompts/sections/__tests__/tool-use.spec.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { getSharedToolUseSection } from "../tool-use"

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

Expand Down
4 changes: 3 additions & 1 deletion src/core/prompts/tools/native-tools/new_task.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type OpenAI from "openai"

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).`
const NEW_TASK_DESCRIPTION = `Create a new task instance in the chosen mode using your provided message and initial todo list (if required).

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.`

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

Expand Down
185 changes: 116 additions & 69 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2983,58 +2983,6 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}

// Finalize any remaining streaming tool calls that weren't explicitly ended
// This is critical for MCP tools which need tool_call_end events to be properly
// converted from ToolUse to McpToolUse via finalizeStreamingToolCall()
const finalizeEvents = NativeToolCallParser.finalizeRawChunks()
for (const event of finalizeEvents) {
if (event.type === "tool_call_end") {
// Finalize the streaming tool call
const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id)

// Get the index for this tool call
const toolUseIndex = this.streamingToolCallIndices.get(event.id)

if (finalToolUse) {
// Store the tool call ID
;(finalToolUse as any).id = event.id

// Get the index and replace partial with final
if (toolUseIndex !== undefined) {
this.assistantMessageContent[toolUseIndex] = finalToolUse
}

// Clean up tracking
this.streamingToolCallIndices.delete(event.id)

// Mark that we have new content to process
this.userMessageContentReady = false

// Present the finalized tool call
presentAssistantMessage(this)
} else if (toolUseIndex !== undefined) {
// finalizeStreamingToolCall returned null (malformed JSON or missing args)
// We still need to mark the tool as non-partial so it gets executed
// The tool's validation will catch any missing required parameters
const existingToolUse = this.assistantMessageContent[toolUseIndex]
if (existingToolUse && existingToolUse.type === "tool_use") {
existingToolUse.partial = false
// Ensure it has the ID for native protocol
;(existingToolUse as any).id = event.id
}

// Clean up tracking
this.streamingToolCallIndices.delete(event.id)

// Mark that we have new content to process
this.userMessageContentReady = false

// Present the tool call - validation will handle missing params
presentAssistantMessage(this)
}
}
}

// Create a copy of current token values to avoid race conditions
const currentTokens = {
input: inputTokens,
Expand Down Expand Up @@ -3282,6 +3230,61 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
// the case, `presentAssistantMessage` relies on these blocks either
// to be completed or the user to reject a block in order to proceed
// and eventually set userMessageContentReady to true.)

// Finalize any remaining streaming tool calls that weren't explicitly ended
// This is critical for MCP tools which need tool_call_end events to be properly
// converted from ToolUse to McpToolUse via finalizeStreamingToolCall()
const finalizeEvents = NativeToolCallParser.finalizeRawChunks()
for (const event of finalizeEvents) {
if (event.type === "tool_call_end") {
// Finalize the streaming tool call
const finalToolUse = NativeToolCallParser.finalizeStreamingToolCall(event.id)

// Get the index for this tool call
const toolUseIndex = this.streamingToolCallIndices.get(event.id)

if (finalToolUse) {
// Store the tool call ID
;(finalToolUse as any).id = event.id

// Get the index and replace partial with final
if (toolUseIndex !== undefined) {
this.assistantMessageContent[toolUseIndex] = finalToolUse
}

// Clean up tracking
this.streamingToolCallIndices.delete(event.id)

// Mark that we have new content to process
this.userMessageContentReady = false

// Present the finalized tool call
presentAssistantMessage(this)
} else if (toolUseIndex !== undefined) {
// finalizeStreamingToolCall returned null (malformed JSON or missing args)
// We still need to mark the tool as non-partial so it gets executed
// The tool's validation will catch any missing required parameters
const existingToolUse = this.assistantMessageContent[toolUseIndex]
if (existingToolUse && existingToolUse.type === "tool_use") {
existingToolUse.partial = false
// Ensure it has the ID for native protocol
;(existingToolUse as any).id = event.id
}

// Clean up tracking
this.streamingToolCallIndices.delete(event.id)

// Mark that we have new content to process
this.userMessageContentReady = false

// Present the tool call - validation will handle missing params
presentAssistantMessage(this)
}
}
}

// IMPORTANT: Capture partialBlocks AFTER finalizeRawChunks() to avoid double-presentation.
// Tools finalized above are already presented, so we only want blocks still partial after finalization.
const partialBlocks = this.assistantMessageContent.filter((block) => block.partial)
partialBlocks.forEach((block) => (block.partial = false))

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

// No legacy streaming parser to finalize.

// Present any partial blocks that were just completed.
// Tool calls are typically presented during streaming via tool_call_partial events,
// but we still present here if any partial blocks remain (e.g., malformed streams).
if (partialBlocks.length > 0) {
// If there is content to update then it will complete and
// update `this.userMessageContentReady` to true, which we
// `pWaitFor` before making the next request.
presentAssistantMessage(this)
}

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

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

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

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

// Enforce new_task isolation: if new_task is called alongside other tools,
// truncate any tools that come after it and inject error tool_results.
// This prevents orphaned tools when delegation disposes the parent task.
const newTaskIndex = assistantContent.findIndex(
(block) => block.type === "tool_use" && block.name === "new_task",
)

if (newTaskIndex !== -1 && newTaskIndex < assistantContent.length - 1) {
// new_task found but not last - truncate subsequent tools
const truncatedTools = assistantContent.slice(newTaskIndex + 1)
assistantContent.length = newTaskIndex + 1 // Truncate API history array

// ALSO truncate the execution array (assistantMessageContent) to prevent
// tools after new_task from being executed by presentAssistantMessage().
// Find new_task index in assistantMessageContent (may differ from assistantContent
// due to text blocks being structured differently).
const executionNewTaskIndex = this.assistantMessageContent.findIndex(
(block) => block.type === "tool_use" && block.name === "new_task",
)
if (executionNewTaskIndex !== -1) {
this.assistantMessageContent.length = executionNewTaskIndex + 1
}

// Pre-inject error tool_results for truncated tools
for (const tool of truncatedTools) {
if (tool.type === "tool_use" && (tool as Anthropic.ToolUseBlockParam).id) {
this.pushToolResultToUserContent({
type: "tool_result",
tool_use_id: (tool as Anthropic.ToolUseBlockParam).id,
content:
"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.",
is_error: true,
})
}
}
}

// Save assistant message BEFORE executing tools
// This is critical for new_task: when it triggers delegation, flushPendingToolResultsToHistory()
// will save the user message with tool_results. The assistant message must already be in history
// so that tool_result blocks appear AFTER their corresponding tool_use blocks.
await this.addToApiConversationHistory(
{ role: "assistant", content: assistantContent },
reasoningMessage || undefined,
)

TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
}

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

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

const shouldIncludeTools = allTools.length > 0

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

const metadata: ApiHandlerCreateMessageMetadata = {
mode: mode,
Expand Down
Loading
Loading