mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
973 lines
26 KiB
TypeScript
973 lines
26 KiB
TypeScript
import { parseJSON } from "partial-json"
|
|
|
|
import { type ToolName, toolNames, type FileEntry } from "@roo-code/types"
|
|
import { customToolRegistry } from "@roo-code/core"
|
|
|
|
import {
|
|
type ToolUse,
|
|
type McpToolUse,
|
|
type ToolParamName,
|
|
type NativeToolArgs,
|
|
toolParamNames,
|
|
} from "../../shared/tools"
|
|
import { resolveToolAlias } from "../prompts/tools/filter-tools-for-mode"
|
|
import type {
|
|
ApiStreamToolCallStartChunk,
|
|
ApiStreamToolCallDeltaChunk,
|
|
ApiStreamToolCallEndChunk,
|
|
} from "../../api/transform/stream"
|
|
import { MCP_TOOL_PREFIX, MCP_TOOL_SEPARATOR, parseMcpToolName, normalizeMcpToolName } from "../../utils/mcp-name"
|
|
|
|
/**
|
|
* Helper type to extract properly typed native arguments for a given tool.
|
|
* Returns the type from NativeToolArgs if the tool is defined there, otherwise never.
|
|
*/
|
|
type NativeArgsFor<TName extends ToolName> = TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : never
|
|
|
|
/**
|
|
* Parser for native tool calls (OpenAI-style function calling).
|
|
* Converts native tool call format to ToolUse format for compatibility
|
|
* with existing tool execution infrastructure.
|
|
*
|
|
* For tools with refactored parsers (e.g., read_file), this parser provides
|
|
* typed arguments via nativeArgs. Tool-specific handlers should consume
|
|
* nativeArgs directly rather than relying on synthesized legacy params.
|
|
*/
|
|
/**
|
|
* Event types returned from raw chunk processing.
|
|
*/
|
|
export type ToolCallStreamEvent = ApiStreamToolCallStartChunk | ApiStreamToolCallDeltaChunk | ApiStreamToolCallEndChunk
|
|
|
|
/**
|
|
* Parser for native tool calls (OpenAI-style function calling).
|
|
* Converts native tool call format to ToolUse format for compatibility
|
|
* with existing tool execution infrastructure.
|
|
*
|
|
* For tools with refactored parsers (e.g., read_file), this parser provides
|
|
* typed arguments via nativeArgs. Tool-specific handlers should consume
|
|
* nativeArgs directly rather than relying on synthesized legacy params.
|
|
*
|
|
* This class also handles raw tool call chunk processing, converting
|
|
* provider-level raw chunks into start/delta/end events.
|
|
*/
|
|
export class NativeToolCallParser {
|
|
// Streaming state management for argument accumulation (keyed by tool call id)
|
|
// Note: name is string to accommodate dynamic MCP tools (mcp--serverName--toolName)
|
|
private static streamingToolCalls = new Map<
|
|
string,
|
|
{
|
|
id: string
|
|
name: string
|
|
argumentsAccumulator: string
|
|
}
|
|
>()
|
|
|
|
// Raw chunk tracking state (keyed by index from API stream)
|
|
private static rawChunkTracker = new Map<
|
|
number,
|
|
{
|
|
id: string
|
|
name: string
|
|
hasStarted: boolean
|
|
deltaBuffer: string[]
|
|
}
|
|
>()
|
|
|
|
private static coerceOptionalBoolean(value: unknown): boolean | undefined {
|
|
if (typeof value === "boolean") {
|
|
return value
|
|
}
|
|
if (typeof value === "string") {
|
|
const lower = value.trim().toLowerCase()
|
|
if (lower === "true") {
|
|
return true
|
|
}
|
|
if (lower === "false") {
|
|
return false
|
|
}
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/**
|
|
* Process a raw tool call chunk from the API stream.
|
|
* Handles tracking, buffering, and emits start/delta/end events.
|
|
*
|
|
* This is the entry point for providers that emit tool_call_partial chunks.
|
|
* Returns an array of events to be processed by the consumer.
|
|
*/
|
|
public static processRawChunk(chunk: {
|
|
index: number
|
|
id?: string
|
|
name?: string
|
|
arguments?: string
|
|
}): ToolCallStreamEvent[] {
|
|
const events: ToolCallStreamEvent[] = []
|
|
const { index, id, name, arguments: args } = chunk
|
|
|
|
let tracked = this.rawChunkTracker.get(index)
|
|
|
|
// Initialize new tool call tracking when we receive an id
|
|
if (id && !tracked) {
|
|
tracked = {
|
|
id,
|
|
name: name || "",
|
|
hasStarted: false,
|
|
deltaBuffer: [],
|
|
}
|
|
this.rawChunkTracker.set(index, tracked)
|
|
}
|
|
|
|
if (!tracked) {
|
|
return events
|
|
}
|
|
|
|
// Update name if present in chunk and not yet set
|
|
if (name) {
|
|
tracked.name = name
|
|
}
|
|
|
|
// Emit start event when we have the name
|
|
if (!tracked.hasStarted && tracked.name) {
|
|
events.push({
|
|
type: "tool_call_start",
|
|
id: tracked.id,
|
|
name: tracked.name,
|
|
})
|
|
tracked.hasStarted = true
|
|
|
|
// Flush buffered deltas
|
|
for (const bufferedDelta of tracked.deltaBuffer) {
|
|
events.push({
|
|
type: "tool_call_delta",
|
|
id: tracked.id,
|
|
delta: bufferedDelta,
|
|
})
|
|
}
|
|
tracked.deltaBuffer = []
|
|
}
|
|
|
|
// Emit delta event for argument chunks
|
|
if (args) {
|
|
if (tracked.hasStarted) {
|
|
events.push({
|
|
type: "tool_call_delta",
|
|
id: tracked.id,
|
|
delta: args,
|
|
})
|
|
} else {
|
|
tracked.deltaBuffer.push(args)
|
|
}
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
/**
|
|
* Process stream finish reason.
|
|
* Emits end events when finish_reason is 'tool_calls'.
|
|
*/
|
|
public static processFinishReason(finishReason: string | null | undefined): ToolCallStreamEvent[] {
|
|
const events: ToolCallStreamEvent[] = []
|
|
|
|
if (finishReason === "tool_calls" && this.rawChunkTracker.size > 0) {
|
|
for (const [, tracked] of this.rawChunkTracker.entries()) {
|
|
events.push({
|
|
type: "tool_call_end",
|
|
id: tracked.id,
|
|
})
|
|
}
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
/**
|
|
* Finalize any remaining tool calls that weren't explicitly ended.
|
|
* Should be called at the end of stream processing.
|
|
*/
|
|
public static finalizeRawChunks(): ToolCallStreamEvent[] {
|
|
const events: ToolCallStreamEvent[] = []
|
|
|
|
if (this.rawChunkTracker.size > 0) {
|
|
for (const [, tracked] of this.rawChunkTracker.entries()) {
|
|
if (tracked.hasStarted) {
|
|
events.push({
|
|
type: "tool_call_end",
|
|
id: tracked.id,
|
|
})
|
|
}
|
|
}
|
|
this.rawChunkTracker.clear()
|
|
}
|
|
|
|
return events
|
|
}
|
|
|
|
/**
|
|
* Clear all raw chunk tracking state.
|
|
* Should be called when a new API request starts.
|
|
*/
|
|
public static clearRawChunkState(): void {
|
|
this.rawChunkTracker.clear()
|
|
}
|
|
|
|
/**
|
|
* Start streaming a new tool call.
|
|
* Initializes tracking for incremental argument parsing.
|
|
* Accepts string to support both ToolName and dynamic MCP tools (mcp--serverName--toolName).
|
|
*/
|
|
public static startStreamingToolCall(id: string, name: string): void {
|
|
this.streamingToolCalls.set(id, {
|
|
id,
|
|
name,
|
|
argumentsAccumulator: "",
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Clear all streaming tool call state.
|
|
* Should be called when a new API request starts to prevent memory leaks
|
|
* from interrupted streams.
|
|
*/
|
|
public static clearAllStreamingToolCalls(): void {
|
|
this.streamingToolCalls.clear()
|
|
}
|
|
|
|
/**
|
|
* Check if there are any active streaming tool calls.
|
|
* Useful for debugging and testing.
|
|
*/
|
|
public static hasActiveStreamingToolCalls(): boolean {
|
|
return this.streamingToolCalls.size > 0
|
|
}
|
|
|
|
/**
|
|
* Process a chunk of JSON arguments for a streaming tool call.
|
|
* Uses partial-json-parser to extract values from incomplete JSON immediately.
|
|
* Returns a partial ToolUse with currently parsed parameters.
|
|
*/
|
|
public static processStreamingChunk(id: string, chunk: string): ToolUse | null {
|
|
const toolCall = this.streamingToolCalls.get(id)
|
|
if (!toolCall) {
|
|
console.warn(`[NativeToolCallParser] Received chunk for unknown tool call: ${id}`)
|
|
return null
|
|
}
|
|
|
|
// Accumulate the JSON string
|
|
toolCall.argumentsAccumulator += chunk
|
|
|
|
// For dynamic MCP tools, we don't return partial updates - wait for final
|
|
const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR
|
|
if (toolCall.name.startsWith(mcpPrefix)) {
|
|
return null
|
|
}
|
|
|
|
// Parse whatever we can from the incomplete JSON!
|
|
// partial-json-parser extracts partial values (strings, arrays, objects) immediately
|
|
try {
|
|
const partialArgs = parseJSON(toolCall.argumentsAccumulator)
|
|
|
|
// Resolve tool alias to canonical name
|
|
const resolvedName = resolveToolAlias(toolCall.name) as ToolName
|
|
// Preserve original name if it differs from resolved (i.e., it was an alias)
|
|
const originalName = toolCall.name !== resolvedName ? toolCall.name : undefined
|
|
|
|
// Create partial ToolUse with extracted values
|
|
return this.createPartialToolUse(
|
|
toolCall.id,
|
|
resolvedName,
|
|
partialArgs || {},
|
|
true, // partial
|
|
originalName,
|
|
)
|
|
} catch {
|
|
// Even partial-json-parser can fail on severely malformed JSON
|
|
// Return null and wait for next chunk
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Finalize a streaming tool call.
|
|
* Parses the complete JSON and returns the final ToolUse or McpToolUse.
|
|
*/
|
|
public static finalizeStreamingToolCall(id: string): ToolUse | McpToolUse | null {
|
|
const toolCall = this.streamingToolCalls.get(id)
|
|
if (!toolCall) {
|
|
console.warn(`[NativeToolCallParser] Attempting to finalize unknown tool call: ${id}`)
|
|
return null
|
|
}
|
|
|
|
// Parse the complete accumulated JSON
|
|
// Cast to any for the name since parseToolCall handles both ToolName and dynamic MCP tools
|
|
const finalToolUse = this.parseToolCall({
|
|
id: toolCall.id,
|
|
name: toolCall.name as ToolName,
|
|
arguments: toolCall.argumentsAccumulator,
|
|
})
|
|
|
|
// Clean up streaming state
|
|
this.streamingToolCalls.delete(id)
|
|
|
|
return finalToolUse
|
|
}
|
|
|
|
/**
|
|
* Convert raw file entries from API (with line_ranges) to FileEntry objects
|
|
* (with lineRanges). Handles multiple formats for compatibility:
|
|
*
|
|
* New tuple format: { path: string, line_ranges: [[1, 50], [100, 150]] }
|
|
* Object format: { path: string, line_ranges: [{ start: 1, end: 50 }] }
|
|
* Legacy string format: { path: string, line_ranges: ["1-50"] }
|
|
*
|
|
* Returns: { path: string, lineRanges: [{ start: 1, end: 50 }] }
|
|
*/
|
|
private static convertFileEntries(files: any[]): FileEntry[] {
|
|
return files.map((file: any) => {
|
|
const entry: FileEntry = { path: file.path }
|
|
if (file.line_ranges && Array.isArray(file.line_ranges)) {
|
|
entry.lineRanges = file.line_ranges
|
|
.map((range: any) => {
|
|
// Handle tuple format: [start, end]
|
|
if (Array.isArray(range) && range.length >= 2) {
|
|
return { start: Number(range[0]), end: Number(range[1]) }
|
|
}
|
|
// Handle object format: { start: number, end: number }
|
|
if (typeof range === "object" && range !== null && "start" in range && "end" in range) {
|
|
return { start: Number(range.start), end: Number(range.end) }
|
|
}
|
|
// Handle legacy string format: "1-50"
|
|
if (typeof range === "string") {
|
|
const match = range.match(/^(\d+)-(\d+)$/)
|
|
if (match) {
|
|
return { start: parseInt(match[1], 10), end: parseInt(match[2], 10) }
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
.filter(Boolean)
|
|
}
|
|
return entry
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Create a partial ToolUse from currently parsed arguments.
|
|
* Used during streaming to show progress.
|
|
* @param originalName - The original tool name as called by the model (if different from canonical name)
|
|
*/
|
|
private static createPartialToolUse(
|
|
id: string,
|
|
name: ToolName,
|
|
partialArgs: Record<string, any>,
|
|
partial: boolean,
|
|
originalName?: string,
|
|
): ToolUse | null {
|
|
// Build stringified params for display/partial-progress UI.
|
|
// NOTE: For streaming partial updates, we MUST populate params even for complex types
|
|
// because tool.handlePartial() methods rely on params to show UI updates.
|
|
const params: Partial<Record<ToolParamName, string>> = {}
|
|
|
|
for (const [key, value] of Object.entries(partialArgs)) {
|
|
if (toolParamNames.includes(key as ToolParamName)) {
|
|
params[key as ToolParamName] = typeof value === "string" ? value : JSON.stringify(value)
|
|
}
|
|
}
|
|
|
|
// Build partial nativeArgs based on what we have so far
|
|
let nativeArgs: any = undefined
|
|
|
|
switch (name) {
|
|
case "read_file":
|
|
if (partialArgs.files && Array.isArray(partialArgs.files)) {
|
|
nativeArgs = { files: this.convertFileEntries(partialArgs.files) }
|
|
}
|
|
break
|
|
|
|
case "attempt_completion":
|
|
if (partialArgs.result) {
|
|
nativeArgs = { result: partialArgs.result }
|
|
}
|
|
break
|
|
|
|
case "execute_command":
|
|
if (partialArgs.command) {
|
|
nativeArgs = {
|
|
command: partialArgs.command,
|
|
cwd: partialArgs.cwd,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "write_to_file":
|
|
if (partialArgs.path || partialArgs.content) {
|
|
nativeArgs = {
|
|
path: partialArgs.path,
|
|
content: partialArgs.content,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "ask_followup_question":
|
|
if (partialArgs.question !== undefined || partialArgs.follow_up !== undefined) {
|
|
nativeArgs = {
|
|
question: partialArgs.question,
|
|
follow_up: Array.isArray(partialArgs.follow_up) ? partialArgs.follow_up : undefined,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "apply_diff":
|
|
if (partialArgs.path !== undefined || partialArgs.diff !== undefined) {
|
|
nativeArgs = {
|
|
path: partialArgs.path,
|
|
diff: partialArgs.diff,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "browser_action":
|
|
if (partialArgs.action !== undefined) {
|
|
nativeArgs = {
|
|
action: partialArgs.action,
|
|
url: partialArgs.url,
|
|
coordinate: partialArgs.coordinate,
|
|
size: partialArgs.size,
|
|
text: partialArgs.text,
|
|
path: partialArgs.path,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "codebase_search":
|
|
if (partialArgs.query !== undefined) {
|
|
nativeArgs = {
|
|
query: partialArgs.query,
|
|
path: partialArgs.path,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "generate_image":
|
|
if (partialArgs.prompt !== undefined || partialArgs.path !== undefined) {
|
|
nativeArgs = {
|
|
prompt: partialArgs.prompt,
|
|
path: partialArgs.path,
|
|
image: partialArgs.image,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "run_slash_command":
|
|
if (partialArgs.command !== undefined) {
|
|
nativeArgs = {
|
|
command: partialArgs.command,
|
|
args: partialArgs.args,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "skill":
|
|
if (partialArgs.skill !== undefined) {
|
|
nativeArgs = {
|
|
skill: partialArgs.skill,
|
|
args: partialArgs.args,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "search_files":
|
|
if (partialArgs.path !== undefined || partialArgs.regex !== undefined) {
|
|
nativeArgs = {
|
|
path: partialArgs.path,
|
|
regex: partialArgs.regex,
|
|
file_pattern: partialArgs.file_pattern,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "switch_mode":
|
|
if (partialArgs.mode_slug !== undefined || partialArgs.reason !== undefined) {
|
|
nativeArgs = {
|
|
mode_slug: partialArgs.mode_slug,
|
|
reason: partialArgs.reason,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "update_todo_list":
|
|
if (partialArgs.todos !== undefined) {
|
|
nativeArgs = {
|
|
todos: partialArgs.todos,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "use_mcp_tool":
|
|
if (partialArgs.server_name !== undefined || partialArgs.tool_name !== undefined) {
|
|
nativeArgs = {
|
|
server_name: partialArgs.server_name,
|
|
tool_name: partialArgs.tool_name,
|
|
arguments: partialArgs.arguments,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "apply_patch":
|
|
if (partialArgs.patch !== undefined) {
|
|
nativeArgs = {
|
|
patch: partialArgs.patch,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "search_replace":
|
|
if (
|
|
partialArgs.file_path !== undefined ||
|
|
partialArgs.old_string !== undefined ||
|
|
partialArgs.new_string !== undefined
|
|
) {
|
|
nativeArgs = {
|
|
file_path: partialArgs.file_path,
|
|
old_string: partialArgs.old_string,
|
|
new_string: partialArgs.new_string,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "search_and_replace":
|
|
if (partialArgs.path !== undefined || partialArgs.operations !== undefined) {
|
|
nativeArgs = {
|
|
path: partialArgs.path,
|
|
operations: partialArgs.operations,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "edit_file":
|
|
if (
|
|
partialArgs.file_path !== undefined ||
|
|
partialArgs.old_string !== undefined ||
|
|
partialArgs.new_string !== undefined
|
|
) {
|
|
nativeArgs = {
|
|
file_path: partialArgs.file_path,
|
|
old_string: partialArgs.old_string,
|
|
new_string: partialArgs.new_string,
|
|
expected_replacements: partialArgs.expected_replacements,
|
|
}
|
|
}
|
|
break
|
|
|
|
case "list_files":
|
|
if (partialArgs.path !== undefined) {
|
|
nativeArgs = {
|
|
path: partialArgs.path,
|
|
recursive: this.coerceOptionalBoolean(partialArgs.recursive),
|
|
}
|
|
}
|
|
break
|
|
|
|
case "new_task":
|
|
if (partialArgs.mode !== undefined || partialArgs.message !== undefined) {
|
|
nativeArgs = {
|
|
mode: partialArgs.mode,
|
|
message: partialArgs.message,
|
|
todos: partialArgs.todos,
|
|
}
|
|
}
|
|
break
|
|
|
|
default:
|
|
break
|
|
}
|
|
|
|
const result: ToolUse = {
|
|
type: "tool_use" as const,
|
|
name,
|
|
params,
|
|
partial,
|
|
nativeArgs,
|
|
}
|
|
|
|
// Preserve original name for API history when an alias was used
|
|
if (originalName) {
|
|
result.originalName = originalName
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* Convert a native tool call chunk to a ToolUse object.
|
|
*
|
|
* @param toolCall - The native tool call from the API stream
|
|
* @returns A properly typed ToolUse object
|
|
*/
|
|
public static parseToolCall<TName extends ToolName>(toolCall: {
|
|
id: string
|
|
name: TName
|
|
arguments: string
|
|
}): ToolUse<TName> | McpToolUse | null {
|
|
// Check if this is a dynamic MCP tool (mcp--serverName--toolName)
|
|
// Also handle models that output underscores instead of hyphens (mcp__serverName__toolName)
|
|
const mcpPrefix = MCP_TOOL_PREFIX + MCP_TOOL_SEPARATOR
|
|
|
|
if (typeof toolCall.name === "string") {
|
|
// Normalize the tool name to handle models that output underscores instead of hyphens
|
|
const normalizedName = normalizeMcpToolName(toolCall.name)
|
|
if (normalizedName.startsWith(mcpPrefix)) {
|
|
// Pass the original tool call but with normalized name for parsing
|
|
return this.parseDynamicMcpTool({ ...toolCall, name: normalizedName })
|
|
}
|
|
}
|
|
|
|
// Resolve tool alias to canonical name
|
|
const resolvedName = resolveToolAlias(toolCall.name as string) as TName
|
|
|
|
// Validate tool name (after alias resolution).
|
|
if (!toolNames.includes(resolvedName as ToolName) && !customToolRegistry.has(resolvedName)) {
|
|
console.error(`Invalid tool name: ${toolCall.name} (resolved: ${resolvedName})`)
|
|
console.error(`Valid tool names:`, toolNames)
|
|
return null
|
|
}
|
|
|
|
try {
|
|
// Parse the arguments JSON string
|
|
const args = toolCall.arguments === "" ? {} : JSON.parse(toolCall.arguments)
|
|
|
|
// Build stringified params for display/logging.
|
|
// Tool execution MUST use nativeArgs (typed) and does not support legacy fallbacks.
|
|
const params: Partial<Record<ToolParamName, string>> = {}
|
|
|
|
for (const [key, value] of Object.entries(args)) {
|
|
// Skip complex parameters that have been migrated to nativeArgs.
|
|
// For read_file, the 'files' parameter is a FileEntry[] array that can't be
|
|
// meaningfully stringified. The properly typed data is in nativeArgs instead.
|
|
if (resolvedName === "read_file" && key === "files") {
|
|
continue
|
|
}
|
|
|
|
// Validate parameter name
|
|
if (!toolParamNames.includes(key as ToolParamName) && !customToolRegistry.has(resolvedName)) {
|
|
console.warn(`Unknown parameter '${key}' for tool '${resolvedName}'`)
|
|
console.warn(`Valid param names:`, toolParamNames)
|
|
continue
|
|
}
|
|
|
|
// Convert to string for legacy params format
|
|
const stringValue = typeof value === "string" ? value : JSON.stringify(value)
|
|
params[key as ToolParamName] = stringValue
|
|
}
|
|
|
|
// Build typed nativeArgs for tool execution.
|
|
// Each case validates the minimum required parameters and constructs a properly typed
|
|
// nativeArgs object. If validation fails, we treat the tool call as invalid and fail fast.
|
|
let nativeArgs: NativeArgsFor<TName> | undefined = undefined
|
|
|
|
switch (resolvedName) {
|
|
case "read_file":
|
|
if (args.files && Array.isArray(args.files)) {
|
|
nativeArgs = { files: this.convertFileEntries(args.files) } as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "attempt_completion":
|
|
if (args.result) {
|
|
nativeArgs = { result: args.result } as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "execute_command":
|
|
if (args.command) {
|
|
nativeArgs = {
|
|
command: args.command,
|
|
cwd: args.cwd,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "apply_diff":
|
|
if (args.path !== undefined && args.diff !== undefined) {
|
|
nativeArgs = {
|
|
path: args.path,
|
|
diff: args.diff,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "search_and_replace":
|
|
if (args.path !== undefined && args.operations !== undefined && Array.isArray(args.operations)) {
|
|
nativeArgs = {
|
|
path: args.path,
|
|
operations: args.operations,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "ask_followup_question":
|
|
if (args.question !== undefined && args.follow_up !== undefined) {
|
|
nativeArgs = {
|
|
question: args.question,
|
|
follow_up: args.follow_up,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "browser_action":
|
|
if (args.action !== undefined) {
|
|
nativeArgs = {
|
|
action: args.action,
|
|
url: args.url,
|
|
coordinate: args.coordinate,
|
|
size: args.size,
|
|
text: args.text,
|
|
path: args.path,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "codebase_search":
|
|
if (args.query !== undefined) {
|
|
nativeArgs = {
|
|
query: args.query,
|
|
path: args.path,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "generate_image":
|
|
if (args.prompt !== undefined && args.path !== undefined) {
|
|
nativeArgs = {
|
|
prompt: args.prompt,
|
|
path: args.path,
|
|
image: args.image,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "run_slash_command":
|
|
if (args.command !== undefined) {
|
|
nativeArgs = {
|
|
command: args.command,
|
|
args: args.args,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "skill":
|
|
if (args.skill !== undefined) {
|
|
nativeArgs = {
|
|
skill: args.skill,
|
|
args: args.args,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "search_files":
|
|
if (args.path !== undefined && args.regex !== undefined) {
|
|
nativeArgs = {
|
|
path: args.path,
|
|
regex: args.regex,
|
|
file_pattern: args.file_pattern,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "switch_mode":
|
|
if (args.mode_slug !== undefined && args.reason !== undefined) {
|
|
nativeArgs = {
|
|
mode_slug: args.mode_slug,
|
|
reason: args.reason,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "update_todo_list":
|
|
if (args.todos !== undefined) {
|
|
nativeArgs = {
|
|
todos: args.todos,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "read_command_output":
|
|
if (args.artifact_id !== undefined) {
|
|
nativeArgs = {
|
|
artifact_id: args.artifact_id,
|
|
search: args.search,
|
|
offset: args.offset,
|
|
limit: args.limit,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "write_to_file":
|
|
if (args.path !== undefined && args.content !== undefined) {
|
|
nativeArgs = {
|
|
path: args.path,
|
|
content: args.content,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "use_mcp_tool":
|
|
if (args.server_name !== undefined && args.tool_name !== undefined) {
|
|
nativeArgs = {
|
|
server_name: args.server_name,
|
|
tool_name: args.tool_name,
|
|
arguments: args.arguments,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "access_mcp_resource":
|
|
if (args.server_name !== undefined && args.uri !== undefined) {
|
|
nativeArgs = {
|
|
server_name: args.server_name,
|
|
uri: args.uri,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "apply_patch":
|
|
if (args.patch !== undefined) {
|
|
nativeArgs = {
|
|
patch: args.patch,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "search_replace":
|
|
if (
|
|
args.file_path !== undefined &&
|
|
args.old_string !== undefined &&
|
|
args.new_string !== undefined
|
|
) {
|
|
nativeArgs = {
|
|
file_path: args.file_path,
|
|
old_string: args.old_string,
|
|
new_string: args.new_string,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "edit_file":
|
|
if (
|
|
args.file_path !== undefined &&
|
|
args.old_string !== undefined &&
|
|
args.new_string !== undefined
|
|
) {
|
|
nativeArgs = {
|
|
file_path: args.file_path,
|
|
old_string: args.old_string,
|
|
new_string: args.new_string,
|
|
expected_replacements: args.expected_replacements,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "list_files":
|
|
if (args.path !== undefined) {
|
|
nativeArgs = {
|
|
path: args.path,
|
|
recursive: this.coerceOptionalBoolean(args.recursive),
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
case "new_task":
|
|
if (args.mode !== undefined && args.message !== undefined) {
|
|
nativeArgs = {
|
|
mode: args.mode,
|
|
message: args.message,
|
|
todos: args.todos,
|
|
} as NativeArgsFor<TName>
|
|
}
|
|
break
|
|
|
|
default:
|
|
if (customToolRegistry.has(resolvedName)) {
|
|
nativeArgs = args as NativeArgsFor<TName>
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
// Native-only: core tools must always have typed nativeArgs.
|
|
// If we couldn't construct it, the model produced an invalid tool call payload.
|
|
if (!nativeArgs && !customToolRegistry.has(resolvedName)) {
|
|
throw new Error(
|
|
`[NativeToolCallParser] Invalid arguments for tool '${resolvedName}'. ` +
|
|
`Native tool calls require a valid JSON payload matching the tool schema. ` +
|
|
`Received: ${JSON.stringify(args)}`,
|
|
)
|
|
}
|
|
|
|
const result: ToolUse<TName> = {
|
|
type: "tool_use" as const,
|
|
name: resolvedName,
|
|
params,
|
|
partial: false, // Native tool calls are always complete when yielded
|
|
nativeArgs,
|
|
}
|
|
|
|
// Preserve original name for API history when an alias was used
|
|
if (toolCall.name !== resolvedName) {
|
|
result.originalName = toolCall.name
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
console.error(
|
|
`Failed to parse tool call arguments: ${error instanceof Error ? error.message : String(error)}`,
|
|
)
|
|
|
|
console.error(`Tool call: ${JSON.stringify(toolCall, null, 2)}`)
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse dynamic MCP tools (named mcp--serverName--toolName).
|
|
* These are generated dynamically by getMcpServerTools() and are returned
|
|
* as McpToolUse objects that preserve the original tool name.
|
|
*/
|
|
public static parseDynamicMcpTool(toolCall: { id: string; name: string; arguments: string }): McpToolUse | null {
|
|
try {
|
|
// Parse the arguments - these are the actual tool arguments passed directly
|
|
const args = JSON.parse(toolCall.arguments || "{}")
|
|
|
|
// Normalize the tool name to handle models that output underscores instead of hyphens
|
|
// e.g., mcp__serverName__toolName -> mcp--serverName--toolName
|
|
const normalizedName = normalizeMcpToolName(toolCall.name)
|
|
|
|
// Extract server_name and tool_name from the tool name itself
|
|
// Format: mcp--serverName--toolName (using -- separator)
|
|
const parsed = parseMcpToolName(normalizedName)
|
|
if (!parsed) {
|
|
console.error(`Invalid dynamic MCP tool name format: ${toolCall.name} (normalized: ${normalizedName})`)
|
|
return null
|
|
}
|
|
|
|
const { serverName, toolName } = parsed
|
|
|
|
const result: McpToolUse = {
|
|
type: "mcp_tool_use" as const,
|
|
id: toolCall.id,
|
|
// Keep the original tool name (e.g., "mcp--serverName--toolName") for API history
|
|
name: toolCall.name,
|
|
serverName,
|
|
toolName,
|
|
arguments: args,
|
|
partial: false,
|
|
}
|
|
|
|
return result
|
|
} catch (error) {
|
|
console.error(`Failed to parse dynamic MCP tool:`, error)
|
|
return null
|
|
}
|
|
}
|
|
}
|