Roo-Code/src/core/tools/BaseTool.ts

214 lines
7 KiB
TypeScript

import type { ToolName, ToolProtocol } from "@roo-code/types"
import { Task } from "../task/Task"
import type {
ToolUse,
HandleError,
PushToolResult,
RemoveClosingTag,
AskApproval,
NativeToolArgs,
} from "../../shared/tools"
/**
* Callbacks passed to tool execution
*/
export interface ToolCallbacks {
askApproval: AskApproval
handleError: HandleError
pushToolResult: PushToolResult
removeClosingTag: RemoveClosingTag
toolProtocol: ToolProtocol
toolCallId?: string
}
/**
* Helper type to extract the parameter type for a tool based on its name.
* If the tool has native args defined in NativeToolArgs, use those; otherwise fall back to any.
*/
type ToolParams<TName extends ToolName> = TName extends keyof NativeToolArgs ? NativeToolArgs[TName] : any
/**
* Abstract base class for all tools.
*
* Provides a consistent architecture where:
* - XML/legacy protocol: params → parseLegacy() → typed params → execute()
* - Native protocol: nativeArgs already contain typed data → execute()
*
* Each tool extends this class and implements:
* - parseLegacy(): Convert XML/legacy string params to typed params
* - execute(): Protocol-agnostic core logic using typed params
* - handlePartial(): (optional) Handle streaming partial messages
*
* @template TName - The specific tool name, which determines native arg types
*/
export abstract class BaseTool<TName extends ToolName> {
/**
* The tool's name (must match ToolName type)
*/
abstract readonly name: TName
/**
* Track the last seen path during streaming to detect when the path has stabilized.
* Used by hasPathStabilized() to prevent displaying truncated paths from partial-json parsing.
*/
protected lastSeenPartialPath: string | undefined = undefined
/**
* Parse XML/legacy string-based parameters into typed parameters.
*
* For XML protocol, this converts params.args (XML string) or params.path (legacy)
* into a typed structure that execute() can use.
*
* @param params - Raw ToolUse.params from XML protocol
* @returns Typed parameters for execute()
* @throws Error if parsing fails
*/
abstract parseLegacy(params: Partial<Record<string, string>>): ToolParams<TName>
/**
* Execute the tool with typed parameters.
*
* This is the protocol-agnostic core logic. It receives typed parameters
* (from parseLegacy for XML, or directly from native protocol) and performs
* the tool's operation.
*
* @param params - Typed parameters
* @param task - Task instance with state and API access
* @param callbacks - Tool execution callbacks (approval, error handling, results)
*/
abstract execute(params: ToolParams<TName>, task: Task, callbacks: ToolCallbacks): Promise<void>
/**
* Handle partial (streaming) tool messages.
*
* Default implementation does nothing. Tools that support streaming
* partial messages should override this.
*
* @param task - Task instance
* @param block - Partial ToolUse block
*/
async handlePartial(task: Task, block: ToolUse<TName>): Promise<void> {
// Default: no-op for partial messages
// Tools can override to show streaming UI updates
}
/**
* Remove partial closing XML tags from text during streaming.
*
* This utility helps clean up partial XML tag artifacts that can appear
* at the end of streamed content, preventing them from being displayed to users.
*
* @param tag - The tag name to check for partial closing
* @param text - The text content to clean
* @param isPartial - Whether this is a partial message (if false, returns text as-is)
* @returns Cleaned text with partial closing tags removed
*/
protected removeClosingTag(tag: string, text: string | undefined, isPartial: boolean): string {
if (!isPartial) {
return text || ""
}
if (!text) {
return ""
}
// This regex dynamically constructs a pattern to match the closing tag:
// - Optionally matches whitespace before the tag
// - Matches '<' or '</' optionally followed by any subset of characters from the tag name
const tagRegex = new RegExp(
`\\s?<\/?${tag
.split("")
.map((char) => `(?:${char})?`)
.join("")}$`,
"g",
)
return text.replace(tagRegex, "")
}
/**
* Check if a path parameter has stabilized during streaming.
*
* During native tool call streaming, the partial-json library may return truncated
* string values when chunk boundaries fall mid-value. This method tracks the path
* value between consecutive handlePartial() calls and returns true only when the
* path has stopped changing (stabilized).
*
* Usage in handlePartial():
* ```typescript
* if (!this.hasPathStabilized(block.params.path)) {
* return // Path still changing, wait for it to stabilize
* }
* // Path is stable, proceed with UI updates
* ```
*
* @param path - The current path value from the partial block
* @returns true if path has stabilized (same value seen twice) and is non-empty, false otherwise
*/
protected hasPathStabilized(path: string | undefined): boolean {
const pathHasStabilized = this.lastSeenPartialPath !== undefined && this.lastSeenPartialPath === path
this.lastSeenPartialPath = path
return pathHasStabilized && !!path
}
/**
* Reset the partial state tracking.
*
* Should be called at the end of execute() (both success and error paths)
* to ensure clean state for the next tool invocation.
*/
resetPartialState(): void {
this.lastSeenPartialPath = undefined
}
/**
* Main entry point for tool execution.
*
* Handles the complete flow:
* 1. Partial message handling (if partial)
* 2. Parameter parsing (parseLegacy for XML, or use nativeArgs directly)
* 3. Core execution (execute)
*
* @param task - Task instance
* @param block - ToolUse block from assistant message
* @param callbacks - Tool execution callbacks
*/
async handle(task: Task, block: ToolUse<TName>, callbacks: ToolCallbacks): Promise<void> {
// Handle partial messages
if (block.partial) {
try {
await this.handlePartial(task, block)
} catch (error) {
console.error(`Error in handlePartial:`, error)
await callbacks.handleError(
`handling partial ${this.name}`,
error instanceof Error ? error : new Error(String(error)),
)
}
return
}
// Determine protocol and parse parameters accordingly
let params: ToolParams<TName>
try {
if (block.nativeArgs !== undefined) {
// Native protocol: typed args provided by NativeToolCallParser
// TypeScript knows nativeArgs is properly typed based on TName
params = block.nativeArgs as ToolParams<TName>
} else {
// XML/legacy protocol: parse string params into typed params
params = this.parseLegacy(block.params)
}
} catch (error) {
console.error(`Error parsing parameters:`, error)
const errorMessage = `Failed to parse ${this.name} parameters: ${error instanceof Error ? error.message : String(error)}`
await callbacks.handleError(`parsing ${this.name} args`, new Error(errorMessage))
callbacks.pushToolResult(`<error>${errorMessage}</error>`)
return
}
// Execute with typed parameters
await this.execute(params, task, callbacks)
}
}