mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: lock task tool protocol for consistent task resumption (#10192)
Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
parent
8251170b84
commit
3e0d9c65e8
12 changed files with 531 additions and 83 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue