Просмотр исходного кода

feat: lock task tool protocol for consistent task resumption (#10192)

Co-authored-by: Roo Code <[email protected]>
Daniel 1 неделя назад
Родитель
Сommit
3e0d9c65e8

+ 10 - 0
packages/types/src/history.ts

@@ -19,6 +19,16 @@ export const historyItemSchema = z.object({
 	size: z.number().optional(),
 	workspace: z.string().optional(),
 	mode: z.string().optional(),
+	/**
+	 * The tool protocol used by this task. Once a task uses tools with a specific
+	 * protocol (XML or Native), it is permanently locked to that protocol.
+	 *
+	 * - "xml": Tool calls are parsed from XML text (no tool IDs)
+	 * - "native": Tool calls come as tool_call chunks with IDs
+	 *
+	 * This ensures task resumption works correctly even when NTC settings change.
+	 */
+	toolProtocol: z.enum(["xml", "native"]).optional(),
 	status: z.enum(["active", "completed", "delegated"]).optional(),
 	delegatedToId: z.string().optional(), // Last child this parent delegated to
 	childIds: z.array(z.string()).optional(), // All children spawned by this task

+ 2 - 1
src/api/providers/anthropic.ts

@@ -73,8 +73,9 @@ export class AnthropicHandler extends BaseProvider implements SingleCompletionHa
 		// Enable native tools by default using resolveToolProtocol (which checks model's defaultToolProtocol)
 		// This matches OpenRouter's approach of always including tools when provided
 		// Also exclude tools when tool_choice is "none" since that means "don't use tools"
+		// IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency
 		const model = this.getModel()
-		const toolProtocol = resolveToolProtocol(this.options, model.info)
+		const toolProtocol = resolveToolProtocol(this.options, model.info, metadata?.toolProtocol)
 		const shouldIncludeNativeTools =
 			metadata?.tools &&
 			metadata.tools.length > 0 &&

+ 2 - 1
src/api/providers/openrouter.ts

@@ -244,7 +244,8 @@ export class OpenRouterHandler extends BaseProvider implements SingleCompletionH
 		}
 
 		// Process reasoning_details when switching models to Gemini for native tool call compatibility
-		const toolProtocol = resolveToolProtocol(this.options, model.info)
+		// IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency
+		const toolProtocol = resolveToolProtocol(this.options, model.info, metadata?.toolProtocol)
 		const isNativeProtocol = toolProtocol === TOOL_PROTOCOL.NATIVE
 		const isGemini = modelId.startsWith("google/gemini")
 

+ 2 - 1
src/api/providers/requesty.ts

@@ -139,7 +139,8 @@ export class RequestyHandler extends BaseProvider implements SingleCompletionHan
 			: undefined
 
 		// Check if native tool protocol is enabled
-		const toolProtocol = resolveToolProtocol(this.options, info)
+		// IMPORTANT: Use metadata.toolProtocol if provided (task's locked protocol) for consistency
+		const toolProtocol = resolveToolProtocol(this.options, info, metadata?.toolProtocol)
 		const useNativeTools = toolProtocol === TOOL_PROTOCOL.NATIVE
 
 		const completionParams: RequestyChatCompletionParamsStreaming = {

+ 5 - 2
src/core/environment/getEnvironmentDetails.ts

@@ -236,9 +236,12 @@ export async function getEnvironmentDetails(cline: Task, includeFileDetails: boo
 		language: language ?? formatLanguage(vscode.env.language),
 	})
 
-	// Resolve and add tool protocol information
+	// Use the task's locked tool protocol for consistent environment details.
+	// This ensures the model sees the same tool format it was started with,
+	// even if user settings have changed. Fall back to resolving fresh if
+	// the task hasn't been fully initialized yet (shouldn't happen in practice).
 	const modelInfo = cline.api.getModel().info
-	const toolProtocol = resolveToolProtocol(state?.apiConfiguration ?? {}, modelInfo)
+	const toolProtocol = resolveToolProtocol(state?.apiConfiguration ?? {}, modelInfo, cline.taskToolProtocol)
 
 	details += `\n\n# Current Mode\n`
 	details += `<slug>${currentMode}</slug>\n`

+ 10 - 1
src/core/task-persistence/taskMetadata.ts

@@ -1,7 +1,7 @@
 import NodeCache from "node-cache"
 import getFolderSize from "get-folder-size"
 
-import type { ClineMessage, HistoryItem } from "@roo-code/types"
+import type { ClineMessage, HistoryItem, ToolProtocol } from "@roo-code/types"
 
 import { combineApiRequests } from "../../shared/combineApiRequests"
 import { combineCommandSequences } from "../../shared/combineCommandSequences"
@@ -23,6 +23,11 @@ export type TaskMetadataOptions = {
 	mode?: string
 	/** Initial status for the task (e.g., "active" for child tasks) */
 	initialStatus?: "active" | "delegated" | "completed"
+	/**
+	 * The tool protocol locked to this task. Once set, the task will
+	 * continue using this protocol even if user settings change.
+	 */
+	toolProtocol?: ToolProtocol
 }
 
 export async function taskMetadata({
@@ -35,6 +40,7 @@ export async function taskMetadata({
 	workspace,
 	mode,
 	initialStatus,
+	toolProtocol,
 }: TaskMetadataOptions) {
 	const taskDir = await getTaskDirectoryPath(globalStoragePath, id)
 
@@ -90,6 +96,8 @@ export async function taskMetadata({
 	// initialStatus is included when provided (e.g., "active" for child tasks)
 	// to ensure the status is set from the very first save, avoiding race conditions
 	// where attempt_completion might run before a separate status update.
+	// toolProtocol is persisted to ensure tasks resume with the correct protocol
+	// even if user settings have changed.
 	const historyItem: HistoryItem = {
 		id,
 		rootTaskId,
@@ -107,6 +115,7 @@ export async function taskMetadata({
 		size: taskDirSize,
 		workspace,
 		mode,
+		...(toolProtocol && { toolProtocol }),
 		...(initialStatus && { status: initialStatus }),
 	}
 

+ 136 - 68
src/core/task/Task.ts

@@ -32,6 +32,7 @@ import {
 	type HistoryItem,
 	type CreateTaskOptions,
 	type ModelInfo,
+	type ToolProtocol,
 	RooCodeEventName,
 	TelemetryEventName,
 	TaskStatus,
@@ -51,7 +52,7 @@ import {
 } from "@roo-code/types"
 import { TelemetryService } from "@roo-code/telemetry"
 import { CloudService, BridgeOrchestrator } from "@roo-code/cloud"
-import { resolveToolProtocol } from "../../utils/resolveToolProtocol"
+import { resolveToolProtocol, detectToolProtocolFromHistory } from "../../utils/resolveToolProtocol"
 
 // api
 import { ApiHandler, ApiHandlerCreateMessageMetadata, buildApiHandler } from "../../api"
@@ -203,6 +204,30 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 	 */
 	private _taskMode: string | undefined
 
+	/**
+	 * The tool protocol locked to this task. Once set, the task will continue
+	 * using this protocol even if user settings change.
+	 *
+	 * ## Why This Matters
+	 * When NTC (Native Tool Calling) is enabled, XML parsing does NOT occur.
+	 * If a task previously used XML tools, resuming it with NTC enabled would
+	 * break because the tool calls in the history would not be parseable.
+	 *
+	 * ## Lifecycle
+	 *
+	 * ### For new tasks:
+	 * 1. Set immediately in constructor via `resolveToolProtocol()`
+	 * 2. Locked for the lifetime of the task
+	 *
+	 * ### For history items:
+	 * 1. If `historyItem.toolProtocol` exists, use it
+	 * 2. Otherwise, detect from API history via `detectToolProtocolFromHistory()`
+	 * 3. If no tools in history, use `resolveToolProtocol()` from current settings
+	 *
+	 * @private
+	 */
+	private _taskToolProtocol: ToolProtocol | undefined
+
 	/**
 	 * Promise that resolves when the task mode has been initialized.
 	 * This ensures async mode initialization completes before the task is used.
@@ -459,19 +484,29 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			this._taskMode = historyItem.mode || defaultModeSlug
 			this.taskModeReady = Promise.resolve()
 			TelemetryService.instance.captureTaskRestarted(this.taskId)
+
+			// For history items, use the persisted tool protocol if available.
+			// If not available (old tasks), it will be detected in resumeTaskFromHistory.
+			this._taskToolProtocol = historyItem.toolProtocol
 		} else {
 			// For new tasks, don't set the mode yet - wait for async initialization.
 			this._taskMode = undefined
 			this.taskModeReady = this.initializeTaskMode(provider)
 			TelemetryService.instance.captureTaskCreated(this.taskId)
+
+			// For new tasks, resolve and lock the tool protocol immediately.
+			// This ensures the task will continue using this protocol even if
+			// user settings change.
+			const modelInfo = this.api.getModel().info
+			this._taskToolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
 		}
 
-		// Initialize the assistant message parser only for XML protocol.
+		// Initialize the assistant message parser based on the locked tool protocol.
 		// For native protocol, tool calls come as tool_call chunks, not XML.
-		// experiments is always provided via TaskOptions (defaults to experimentDefault in provider)
-		const modelInfo = this.api.getModel().info
-		const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
-		this.assistantMessageParser = toolProtocol !== "native" ? new AssistantMessageParser() : undefined
+		// For history items without a persisted protocol, we default to XML parser
+		// and will update it in resumeTaskFromHistory after detection.
+		const effectiveProtocol = this._taskToolProtocol || "xml"
+		this.assistantMessageParser = effectiveProtocol !== "native" ? new AssistantMessageParser() : undefined
 
 		this.messageQueueService = new MessageQueueService()
 
@@ -977,6 +1012,7 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				workspace: this.cwd,
 				mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode.
 				initialStatus: this.initialStatus,
+				toolProtocol: this._taskToolProtocol, // Persist the locked tool protocol.
 			})
 
 			// Emit token/tool usage updates using debounced function
@@ -1286,9 +1322,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 	}
 
 	/**
-	 * Updates the API configuration and reinitializes the parser based on the new tool protocol.
-	 * This should be called when switching between models/profiles with different tool protocols
-	 * to prevent the parser from being left in an inconsistent state.
+	 * Updates the API configuration but preserves the locked tool protocol.
+	 * The task's tool protocol is locked at creation time and should NOT change
+	 * even when switching between models/profiles with different settings.
 	 *
 	 * @param newApiConfiguration - The new API configuration to use
 	 */
@@ -1297,26 +1333,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		this.apiConfiguration = newApiConfiguration
 		this.api = buildApiHandler(newApiConfiguration)
 
-		// Determine what the tool protocol should be
-		const modelInfo = this.api.getModel().info
-		const protocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
-		const shouldUseXmlParser = protocol === "xml"
-
-		// Ensure parser state matches protocol requirement
-		const parserStateCorrect =
-			(shouldUseXmlParser && this.assistantMessageParser) || (!shouldUseXmlParser && !this.assistantMessageParser)
-
-		if (parserStateCorrect) {
-			return
-		}
-
-		// Fix parser state
-		if (shouldUseXmlParser && !this.assistantMessageParser) {
-			this.assistantMessageParser = new AssistantMessageParser()
-		} else if (!shouldUseXmlParser && this.assistantMessageParser) {
-			this.assistantMessageParser.reset()
-			this.assistantMessageParser = undefined
-		}
+		// IMPORTANT: Do NOT change the parser based on the new configuration!
+		// The task's tool protocol is locked at creation time and must remain
+		// consistent throughout the task's lifetime to ensure history can be
+		// properly resumed.
 	}
 
 	public async submitUserMessage(
@@ -1399,9 +1419,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		const { contextTokens: prevContextTokens } = this.getTokenUsage()
 
 		// Determine if we're using native tool protocol for proper message handling
-		const modelInfo = this.api.getModel().info
-		const protocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
-		const useNativeTools = isNativeProtocol(protocol)
+		// Use the task's locked protocol, NOT the current settings (fallback to xml if not set)
+		const useNativeTools = isNativeProtocol(this._taskToolProtocol ?? "xml")
 
 		const {
 			messages,
@@ -1583,10 +1602,10 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				relPath ? ` for '${relPath.toPosix()}'` : ""
 			} without value for required parameter '${paramName}'. Retrying...`,
 		)
-		const modelInfo = this.api.getModel().info
-		const state = await this.providerRef.deref()?.getState()
-		const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
-		return formatResponse.toolError(formatResponse.missingToolParameterError(paramName, toolProtocol))
+		// Use the task's locked protocol, NOT the current settings (fallback to xml if not set)
+		return formatResponse.toolError(
+			formatResponse.missingToolParameterError(paramName, this._taskToolProtocol ?? "xml"),
+		)
 	}
 
 	// Lifecycle
@@ -1702,6 +1721,31 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		// the task first.
 		this.apiConversationHistory = await this.getSavedApiConversationHistory()
 
+		// If we don't have a persisted tool protocol (old tasks before this feature),
+		// detect it from the API history. This ensures tasks that previously used
+		// XML tools will continue using XML even if NTC is now enabled.
+		if (!this._taskToolProtocol) {
+			const detectedProtocol = detectToolProtocolFromHistory(this.apiConversationHistory)
+			if (detectedProtocol) {
+				// Found tool calls in history - lock to that protocol
+				this._taskToolProtocol = detectedProtocol
+			} else {
+				// No tool calls in history yet - use current settings
+				const modelInfo = this.api.getModel().info
+				this._taskToolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
+			}
+
+			// Update parser state to match the detected/resolved protocol
+			const shouldUseXmlParser = this._taskToolProtocol === "xml"
+			if (shouldUseXmlParser && !this.assistantMessageParser) {
+				this.assistantMessageParser = new AssistantMessageParser()
+			} else if (!shouldUseXmlParser && this.assistantMessageParser) {
+				this.assistantMessageParser.reset()
+				this.assistantMessageParser = undefined
+			}
+		} else {
+		}
+
 		const lastClineMessage = this.clineMessages
 			.slice()
 			.reverse()
@@ -1735,9 +1779,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		// we need to replace all tool use blocks with a text block since the API disallows
 		// conversations with tool uses and no tool schema.
 		// For native protocol, we preserve tool_use and tool_result blocks as they're expected by the API.
-		const state = await this.providerRef.deref()?.getState()
-		const protocol = resolveToolProtocol(this.apiConfiguration, this.api.getModel().info)
-		const useNative = isNativeProtocol(protocol)
+		// IMPORTANT: Use the task's locked protocol, NOT the current settings!
+		const useNative = isNativeProtocol(this._taskToolProtocol)
 
 		// Only convert tool blocks to text for XML protocol
 		// For native protocol, the API expects proper tool_use/tool_result structure
@@ -1746,9 +1789,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				if (Array.isArray(message.content)) {
 					const newContent = message.content.map((block) => {
 						if (block.type === "tool_use") {
-							// Format tool invocation based on protocol
+							// Format tool invocation based on the task's locked protocol
 							const params = block.input as Record<string, any>
-							const formattedText = formatToolInvocation(block.name, params, protocol)
+							const formattedText = formatToolInvocation(block.name, params, this._taskToolProtocol)
 
 							return {
 								type: "text",
@@ -2190,9 +2233,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				// the user hits max requests and denies resetting the count.
 				break
 			} else {
-				const modelInfo = this.api.getModel().info
-				const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
-				nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed(toolProtocol) }]
+				// Use the task's locked protocol, NOT the current settings (fallback to xml if not set)
+				nextUserContent = [{ type: "text", text: formatResponse.noToolsUsed(this._taskToolProtocol ?? "xml") }]
 				this.consecutiveMistakeCount++
 			}
 		}
@@ -2434,7 +2476,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 				this.cachedStreamingModel = this.api.getModel()
 				const streamModelInfo = this.cachedStreamingModel.info
 				const cachedModelId = this.cachedStreamingModel.id
-				const streamProtocol = resolveToolProtocol(this.apiConfiguration, streamModelInfo)
+				// Use the task's locked protocol instead of resolving fresh.
+				// This ensures task resumption works correctly even if NTC settings changed.
+				// Fallback to resolving if somehow _taskToolProtocol is not set (should not happen).
+				const streamProtocol = resolveToolProtocol(
+					this.apiConfiguration,
+					streamModelInfo,
+					this._taskToolProtocol,
+				)
 				const shouldUseXmlParser = streamProtocol === "xml"
 
 				// Yields only if the first chunk is successful, otherwise will
@@ -3187,10 +3236,11 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 					)
 
 					if (!didToolUse) {
-						const modelInfo = this.api.getModel().info
-						const state = await this.providerRef.deref()?.getState()
-						const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
-						this.userMessageContent.push({ type: "text", text: formatResponse.noToolsUsed(toolProtocol) })
+						// Use the task's locked protocol for consistent behavior
+						this.userMessageContent.push({
+							type: "text",
+							text: formatResponse.noToolsUsed(this._taskToolProtocol ?? "xml"),
+						})
 						this.consecutiveMistakeCount++
 					}
 
@@ -3217,10 +3267,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 					// we need to remove that message before retrying to avoid having two consecutive
 					// user messages (which would cause tool_result validation errors).
 					let state = await this.providerRef.deref()?.getState()
-					if (
-						isNativeProtocol(resolveToolProtocol(this.apiConfiguration, this.api.getModel().info)) &&
-						this.apiConversationHistory.length > 0
-					) {
+					// Use the task's locked protocol, NOT current settings
+					if (isNativeProtocol(this._taskToolProtocol ?? "xml") && this.apiConversationHistory.length > 0) {
 						const lastMessage = this.apiConversationHistory[this.apiConversationHistory.length - 1]
 						if (lastMessage.role === "user") {
 							// Remove the last user message that we added earlier
@@ -3280,10 +3328,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 						} else {
 							// User declined to retry
 							// For native protocol, re-add the user message we removed
-							// Reuse the state variable from above
-							if (
-								isNativeProtocol(resolveToolProtocol(this.apiConfiguration, this.api.getModel().info))
-							) {
+							// Use the task's locked protocol, NOT current settings
+							if (isNativeProtocol(this._taskToolProtocol ?? "xml")) {
 								await this.addToApiConversationHistory({
 									role: "user",
 									content: currentUserContent,
@@ -3380,8 +3426,14 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 
 			const canUseBrowserTool = modelSupportsBrowser && modeSupportsBrowser && (browserToolEnabled ?? true)
 
-			// Resolve the tool protocol based on profile, model, and provider settings
-			const toolProtocol = resolveToolProtocol(apiConfiguration ?? this.apiConfiguration, modelInfo)
+			// Use the task's locked protocol for system prompt consistency.
+			// This ensures the system prompt matches the protocol the task was started with,
+			// even if user settings have changed since then.
+			const toolProtocol = resolveToolProtocol(
+				apiConfiguration ?? this.apiConfiguration,
+				modelInfo,
+				this._taskToolProtocol,
+			)
 
 			return SYSTEM_PROMPT(
 				provider.context,
@@ -3451,8 +3503,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		)
 
 		// Determine if we're using native tool protocol for proper message handling
-		const protocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
-		const useNativeTools = isNativeProtocol(protocol)
+		// Use the task's locked protocol, NOT the current settings
+		const useNativeTools = isNativeProtocol(this._taskToolProtocol ?? "xml")
 
 		// Send condenseTaskContextStarted to show in-progress indicator
 		await this.providerRef.deref()?.postMessageToWebview({ type: "condenseTaskContextStarted", text: this.taskId })
@@ -3595,9 +3647,8 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			const currentProfileId = this.getCurrentProfileId(state)
 
 			// Determine if we're using native tool protocol for proper message handling
-			const modelInfoForProtocol = this.api.getModel().info
-			const protocol = resolveToolProtocol(this.apiConfiguration, modelInfoForProtocol)
-			const useNativeTools = isNativeProtocol(protocol)
+			// Use the task's locked protocol, NOT the current settings
+			const useNativeTools = isNativeProtocol(this._taskToolProtocol ?? "xml")
 
 			// Check if context management will likely run (threshold check)
 			// This allows us to show an in-progress indicator to the user
@@ -3722,11 +3773,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		}
 
 		// Determine if we should include native tools based on:
-		// 1. Tool protocol is set to NATIVE
+		// 1. Task's locked tool protocol is set to NATIVE
 		// 2. Model supports native tools
+		// CRITICAL: Use the task's locked protocol to ensure tasks that started with XML
+		// tools continue using XML even if NTC settings have since changed.
 		const modelInfo = this.api.getModel().info
-		const toolProtocol = resolveToolProtocol(this.apiConfiguration, modelInfo)
-		const shouldIncludeTools = toolProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false)
+		const taskProtocol = this._taskToolProtocol ?? "xml"
+		const shouldIncludeTools = taskProtocol === TOOL_PROTOCOL.NATIVE && (modelInfo.supportsNativeTools ?? false)
 
 		// Build complete tools array: native tools + dynamic MCP tools, filtered by mode restrictions
 		let allTools: OpenAI.Chat.ChatCompletionTool[] = []
@@ -3760,7 +3813,12 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 			suppressPreviousResponseId: this.skipPrevResponseIdOnce,
 			// Include tools and tool protocol when using native protocol and model supports it
 			...(shouldIncludeTools
-				? { tools: allTools, tool_choice: "auto", toolProtocol, parallelToolCalls: parallelToolCallsEnabled }
+				? {
+						tools: allTools,
+						tool_choice: "auto",
+						toolProtocol: taskProtocol,
+						parallelToolCalls: parallelToolCallsEnabled,
+					}
 				: {}),
 		}
 
@@ -4164,6 +4222,16 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
 		return this.workspacePath
 	}
 
+	/**
+	 * Get the tool protocol locked to this task.
+	 * Returns undefined only if the task hasn't been fully initialized yet.
+	 *
+	 * @see {@link _taskToolProtocol} for lifecycle details
+	 */
+	public get taskToolProtocol() {
+		return this._taskToolProtocol
+	}
+
 	/**
 	 * Provides convenient access to high-level message operations.
 	 * Uses lazy initialization - the MessageManager is only created when first accessed.

+ 9 - 4
src/core/tools/MultiApplyDiffTool.ts

@@ -62,7 +62,8 @@ export async function applyDiffTool(
 	removeClosingTag: RemoveClosingTag,
 ) {
 	// Check if native protocol is enabled - if so, always use single-file class-based tool
-	const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info)
+	// Use the task's locked protocol for consistency throughout the task lifetime
+	const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info, cline.taskToolProtocol)
 	if (isNativeProtocol(toolProtocol)) {
 		return applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
 			askApproval,
@@ -736,11 +737,15 @@ ${errorDetails ? `\nTechnical details:\n${errorDetails}\n` : ""}
 			}
 		}
 
-		// Check protocol for notice formatting
-		const toolProtocol = resolveToolProtocol(cline.apiConfiguration, cline.api.getModel().info)
+		// Check protocol for notice formatting - reuse the task's locked protocol
+		const noticeProtocol = resolveToolProtocol(
+			cline.apiConfiguration,
+			cline.api.getModel().info,
+			cline.taskToolProtocol,
+		)
 		const singleBlockNotice =
 			totalSearchBlocks === 1
-				? isNativeProtocol(toolProtocol)
+				? isNativeProtocol(noticeProtocol)
 					? "\n" +
 						JSON.stringify({
 							notice: "Making multiple related changes in a single apply_diff is more efficient. If other changes are needed in this file, please include them as additional SEARCH/REPLACE blocks.",

+ 2 - 1
src/core/tools/ReadFileTool.ts

@@ -110,7 +110,8 @@ export class ReadFileTool extends BaseTool<"read_file"> {
 		const { handleError, pushToolResult, toolProtocol } = callbacks
 		const fileEntries = params.files
 		const modelInfo = task.api.getModel().info
-		const protocol = resolveToolProtocol(task.apiConfiguration, modelInfo)
+		// Use the task's locked protocol for consistent output formatting throughout the task
+		const protocol = resolveToolProtocol(task.apiConfiguration, modelInfo, task.taskToolProtocol)
 		const useNative = isNativeProtocol(protocol)
 
 		if (!fileEntries || fileEntries.length === 0) {

+ 2 - 2
src/integrations/editor/DiffViewProvider.ts

@@ -326,8 +326,8 @@ export class DiffViewProvider {
 			await task.say("user_feedback_diff", JSON.stringify(say))
 		}
 
-		// Check which protocol we're using
-		const toolProtocol = resolveToolProtocol(task.apiConfiguration, task.api.getModel().info)
+		// Check which protocol we're using - use the task's locked protocol for consistency
+		const toolProtocol = resolveToolProtocol(task.apiConfiguration, task.api.getModel().info, task.taskToolProtocol)
 		const useNative = isNativeProtocol(toolProtocol)
 
 		// Build notices array

+ 274 - 1
src/utils/__tests__/resolveToolProtocol.spec.ts

@@ -1,7 +1,8 @@
 import { describe, it, expect } from "vitest"
-import { resolveToolProtocol } from "../resolveToolProtocol"
+import { resolveToolProtocol, detectToolProtocolFromHistory } from "../resolveToolProtocol"
 import { TOOL_PROTOCOL, openAiModelInfoSaneDefaults } from "@roo-code/types"
 import type { ProviderSettings, ModelInfo } from "@roo-code/types"
+import type { Anthropic } from "@anthropic-ai/sdk"
 
 describe("resolveToolProtocol", () => {
 	describe("Precedence Level 1: User Profile Setting", () => {
@@ -218,6 +219,58 @@ describe("resolveToolProtocol", () => {
 		})
 	})
 
+	describe("Locked Protocol (Precedence Level 0)", () => {
+		it("should return lockedProtocol when provided, ignoring all other settings", () => {
+			const settings: ProviderSettings = {
+				toolProtocol: "xml", // User wants XML
+				apiProvider: "openai-native",
+			}
+			const modelInfo: ModelInfo = {
+				maxTokens: 4096,
+				contextWindow: 128000,
+				supportsPromptCache: false,
+				supportsNativeTools: true,
+				defaultToolProtocol: "xml",
+			}
+			// lockedProtocol overrides everything
+			const result = resolveToolProtocol(settings, modelInfo, "native")
+			expect(result).toBe(TOOL_PROTOCOL.NATIVE)
+		})
+
+		it("should return XML lockedProtocol even when model supports native", () => {
+			const settings: ProviderSettings = {
+				toolProtocol: "native", // User wants native
+				apiProvider: "anthropic",
+			}
+			const modelInfo: ModelInfo = {
+				maxTokens: 4096,
+				contextWindow: 128000,
+				supportsPromptCache: false,
+				supportsNativeTools: true, // Model supports native
+				defaultToolProtocol: "native",
+			}
+			// lockedProtocol forces XML
+			const result = resolveToolProtocol(settings, modelInfo, "xml")
+			expect(result).toBe(TOOL_PROTOCOL.XML)
+		})
+
+		it("should fall through to normal resolution when lockedProtocol is undefined", () => {
+			const settings: ProviderSettings = {
+				toolProtocol: "xml",
+				apiProvider: "anthropic",
+			}
+			const modelInfo: ModelInfo = {
+				maxTokens: 4096,
+				contextWindow: 128000,
+				supportsPromptCache: false,
+				supportsNativeTools: true,
+			}
+			// undefined lockedProtocol should use normal precedence
+			const result = resolveToolProtocol(settings, modelInfo, undefined)
+			expect(result).toBe(TOOL_PROTOCOL.XML) // User setting wins
+		})
+	})
+
 	describe("Edge Cases", () => {
 		it("should handle missing provider name gracefully", () => {
 			const settings: ProviderSettings = {}
@@ -333,3 +386,223 @@ describe("resolveToolProtocol", () => {
 		})
 	})
 })
+
+describe("detectToolProtocolFromHistory", () => {
+	// Helper type for API messages in tests
+	type ApiMessageForTest = Anthropic.MessageParam & { ts?: number }
+
+	describe("Native Protocol Detection", () => {
+		it("should detect native protocol when tool_use block has an id", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Hello" },
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: "toolu_01abc123", // Native protocol always has an ID
+							name: "read_file",
+							input: { path: "test.ts" },
+						},
+					],
+				},
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBe(TOOL_PROTOCOL.NATIVE)
+		})
+
+		it("should detect native protocol from the first tool_use block found", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "First message" },
+				{ role: "assistant", content: "Let me help you" },
+				{ role: "user", content: "Second message" },
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: "toolu_first",
+							name: "read_file",
+							input: { path: "first.ts" },
+						},
+					],
+				},
+				{ role: "user", content: "Third message" },
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: "toolu_second",
+							name: "write_to_file",
+							input: { path: "second.ts", content: "test" },
+						},
+					],
+				},
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBe(TOOL_PROTOCOL.NATIVE)
+		})
+	})
+
+	describe("XML Protocol Detection", () => {
+		it("should detect XML protocol when tool_use block has no id", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Hello" },
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							// No id field - XML protocol tool calls never have an ID
+							name: "read_file",
+							input: { path: "test.ts" },
+						} as Anthropic.ToolUseBlock, // Cast to bypass type check for missing id
+					],
+				},
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBe(TOOL_PROTOCOL.XML)
+		})
+
+		it("should detect XML protocol when id is empty string", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Hello" },
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: "", // Empty string should be treated as no id
+							name: "read_file",
+							input: { path: "test.ts" },
+						},
+					],
+				},
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBe(TOOL_PROTOCOL.XML)
+		})
+	})
+
+	describe("No Tool Calls", () => {
+		it("should return undefined when no messages", () => {
+			const messages: ApiMessageForTest[] = []
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBeUndefined()
+		})
+
+		it("should return undefined when only user messages", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Hello" },
+				{ role: "user", content: "How are you?" },
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBeUndefined()
+		})
+
+		it("should return undefined when assistant messages have no tool_use", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Hello" },
+				{ role: "assistant", content: "Hi! How can I help?" },
+				{ role: "user", content: "What's the weather?" },
+				{
+					role: "assistant",
+					content: [{ type: "text", text: "I don't have access to weather data." }],
+				},
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBeUndefined()
+		})
+
+		it("should return undefined when content is string", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Hello" },
+				{ role: "assistant", content: "Hi there!" },
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBeUndefined()
+		})
+	})
+
+	describe("Mixed Content", () => {
+		it("should detect protocol from tool_use even with mixed content", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Read this file" },
+				{
+					role: "assistant",
+					content: [
+						{ type: "text", text: "I'll read that file for you." },
+						{
+							type: "tool_use",
+							id: "toolu_mixed",
+							name: "read_file",
+							input: { path: "test.ts" },
+						},
+					],
+				},
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBe(TOOL_PROTOCOL.NATIVE)
+		})
+
+		it("should skip user messages and only check assistant messages", () => {
+			const messages: ApiMessageForTest[] = [
+				{
+					role: "user",
+					content: [
+						{
+							type: "tool_result",
+							tool_use_id: "toolu_user",
+							content: "result",
+						},
+					],
+				},
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: "toolu_assistant",
+							name: "write_to_file",
+							input: { path: "out.ts", content: "test" },
+						},
+					],
+				},
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBe(TOOL_PROTOCOL.NATIVE)
+		})
+	})
+
+	describe("Edge Cases", () => {
+		it("should handle messages with empty content array", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Hello" },
+				{ role: "assistant", content: [] },
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBeUndefined()
+		})
+
+		it("should handle messages with ts field (ApiMessage format)", () => {
+			const messages: ApiMessageForTest[] = [
+				{ role: "user", content: "Hello", ts: Date.now() },
+				{
+					role: "assistant",
+					content: [
+						{
+							type: "tool_use",
+							id: "toolu_with_ts",
+							name: "read_file",
+							input: { path: "test.ts" },
+						},
+					],
+					ts: Date.now(),
+				},
+			]
+			const result = detectToolProtocolFromHistory(messages)
+			expect(result).toBe(TOOL_PROTOCOL.NATIVE)
+		})
+	})
+})

+ 77 - 1
src/utils/resolveToolProtocol.ts

@@ -1,9 +1,20 @@
 import { ToolProtocol, TOOL_PROTOCOL } from "@roo-code/types"
 import type { ProviderSettings, ModelInfo } from "@roo-code/types"
+import type { Anthropic } from "@anthropic-ai/sdk"
+import { findLast, findLastIndex } from "../shared/array"
+
+/**
+ * Represents an API message in the conversation history.
+ * This is a minimal type definition for the detection function.
+ */
+type ApiMessageForDetection = Anthropic.MessageParam & {
+	ts?: number
+}
 
 /**
  * Resolve the effective tool protocol based on the precedence hierarchy:
  *
+ * 0. Locked Protocol (task-level lock, if provided - highest priority)
  * 1. User Preference - Per-Profile (explicit profile setting)
  * 2. Model Default (defaultToolProtocol in ModelInfo)
  * 3. Native Fallback (final fallback)
@@ -12,9 +23,20 @@ import type { ProviderSettings, ModelInfo } from "@roo-code/types"
  *
  * @param providerSettings - The provider settings for the current profile
  * @param modelInfo - Optional model information containing capabilities
+ * @param lockedProtocol - Optional task-locked protocol that takes absolute precedence
  * @returns The resolved tool protocol (either "xml" or "native")
  */
-export function resolveToolProtocol(providerSettings: ProviderSettings, modelInfo?: ModelInfo): ToolProtocol {
+export function resolveToolProtocol(
+	providerSettings: ProviderSettings,
+	modelInfo?: ModelInfo,
+	lockedProtocol?: ToolProtocol,
+): ToolProtocol {
+	// 0. Locked Protocol - task-level lock takes absolute precedence
+	// This ensures tasks continue using their original protocol even if settings change
+	if (lockedProtocol) {
+		return lockedProtocol
+	}
+
 	// If model doesn't support native tools, return XML immediately
 	// Treat undefined as unsupported (only allow native when explicitly true)
 	if (modelInfo?.supportsNativeTools !== true) {
@@ -34,3 +56,57 @@ export function resolveToolProtocol(providerSettings: ProviderSettings, modelInf
 	// 3. Native Fallback
 	return TOOL_PROTOCOL.NATIVE
 }
+
+/**
+ * Detect the tool protocol used in an existing conversation history.
+ *
+ * This function scans the API conversation history for tool_use blocks
+ * and determines which protocol was used based on their structure:
+ *
+ * - Native protocol: tool_use blocks ALWAYS have an `id` field
+ * - XML protocol: tool_use blocks NEVER have an `id` field
+ *
+ * This is critical for task resumption: if a task previously used tools
+ * with a specific protocol, we must continue using that protocol even
+ * if the user's NTC settings have changed.
+ *
+ * The function searches from the most recent message backwards to find
+ * the last tool call, which represents the task's current protocol state.
+ *
+ * @param messages - The API conversation history to scan
+ * @returns The detected protocol, or undefined if no tool calls were found
+ */
+export function detectToolProtocolFromHistory(messages: ApiMessageForDetection[]): ToolProtocol | undefined {
+	// Find the last assistant message that contains a tool_use block
+	const lastAssistantWithTool = findLast(messages, (message) => {
+		if (message.role !== "assistant") {
+			return false
+		}
+		const content = message.content
+		if (!Array.isArray(content)) {
+			return false
+		}
+		return content.some((block) => block.type === "tool_use")
+	})
+
+	if (!lastAssistantWithTool) {
+		return undefined
+	}
+
+	// Find the last tool_use block in that message's content
+	const content = lastAssistantWithTool.content as Anthropic.ContentBlock[]
+	const lastToolUseIndex = findLastIndex(content, (block) => block.type === "tool_use")
+
+	if (lastToolUseIndex === -1) {
+		return undefined
+	}
+
+	const lastToolUse = content[lastToolUseIndex]
+
+	// The presence or absence of `id` determines the protocol:
+	// - Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks)
+	// - XML protocol tool calls NEVER have an ID (parsed from XML text)
+	// This pattern is used in presentAssistantMessage.ts:497-500
+	const hasId = "id" in lastToolUse && !!lastToolUse.id
+	return hasId ? TOOL_PROTOCOL.NATIVE : TOOL_PROTOCOL.XML
+}