Compare commits

...
Author SHA1 Message Date
Arafatkatze dad564f3bf refactoring tool executor 2025-08-18 14:52:42 -07:00
30 changed files with 2709 additions and 2150 deletions
File diff suppressed because it is too large Load Diff
+512
View File
@@ -0,0 +1,512 @@
import * as path from "path"
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import { ClineAskResponse } from "@shared/WebviewMessage"
import { ToolUse, ToolUseName } from "../../assistant-message"
import { ToolExecutorCoordinator } from "./ToolExecutorCoordinator"
import { ToolValidator } from "./ToolValidator"
import { ToolDisplayUtils } from "./utils/ToolDisplayUtils"
import { ToolValidationUtils } from "./utils/ToolValidationUtils"
import { ToolMessageUtils } from "./utils/ToolMessageUtils"
import { ToolApprovalManager } from "./utils/ToolApprovalManager"
import { ToolErrorHandler } from "./utils/ToolErrorHandler"
import { ToolExecutionStrategies } from "./utils/ToolExecutionStrategies"
import { ListFilesToolHandler } from "./handlers/ListFilesToolHandler"
import { ReadFileToolHandler } from "./handlers/ReadFileToolHandler"
import { BrowserToolHandler } from "./handlers/BrowserToolHandler"
import { AskFollowupQuestionToolHandler } from "./handlers/AskFollowupQuestionToolHandler"
import { WebFetchToolHandler } from "./handlers/WebFetchToolHandler"
import { WriteToFileToolHandler } from "./handlers/WriteToFileToolHandler"
import { ListCodeDefinitionNamesToolHandler } from "./handlers/ListCodeDefinitionNamesToolHandler"
import { SearchFilesToolHandler } from "./handlers/SearchFilesToolHandler"
import { ExecuteCommandToolHandler } from "./handlers/ExecuteCommandToolHandler"
import { UseMcpToolHandler } from "./handlers/UseMcpToolHandler"
import { AccessMcpResourceHandler } from "./handlers/AccessMcpResourceHandler"
import { LoadMcpDocumentationHandler } from "./handlers/LoadMcpDocumentationHandler"
import { PlanModeRespondHandler } from "./handlers/PlanModeRespondHandler"
import { NewTaskHandler } from "./handlers/NewTaskHandler"
import { AttemptCompletionHandler } from "./handlers/AttemptCompletionHandler"
import { CondenseHandler } from "./handlers/CondenseHandler"
import { SummarizeTaskHandler } from "./handlers/SummarizeTaskHandler"
import { ReportBugHandler } from "./handlers/ReportBugHandler"
/**
* Manages the execution of tools registered with the coordinator.
* This class encapsulates all the approval flow, UI updates, telemetry,
* and orchestration logic, keeping the main ToolExecutor thin and focused.
*/
export class ToolExecutionManager {
private approvalManager: ToolApprovalManager
constructor(
private coordinator: ToolExecutorCoordinator,
private config: any,
private pushToolResult: (content: any, block: ToolUse) => void,
private removeClosingTag: (block: ToolUse, tag: any, text?: string) => string,
private shouldAutoApproveToolWithPath: (toolName: ToolUseName, path?: string) => Promise<boolean>,
private sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string) => Promise<any>,
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: any) => Promise<void>,
private say: (
type: ClineSay,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
) => Promise<number | undefined>,
private ask: (
type: ClineAsk,
text?: string,
partial?: boolean,
) => Promise<{
response: ClineAskResponse
text?: string
images?: string[]
files?: string[]
}>,
private askApproval: (type: ClineAsk, block: ToolUse, message: string) => Promise<boolean>,
private saveCheckpoint: () => Promise<void>,
private updateFCListFromToolResponse: (taskProgress?: string) => Promise<void>,
private handleError: (action: string, error: Error, block: ToolUse) => Promise<void>,
) {
// Initialize the approval manager
this.approvalManager = new ToolApprovalManager(
config,
shouldAutoApproveToolWithPath,
removeLastPartialMessageIfExistsWithType,
say,
ask,
askApproval,
)
}
/**
* Factory method to create a ToolExecutionManager with all tool handlers registered
*/
static create(
config: any,
pushToolResult: (content: any, block: ToolUse) => void,
shouldAutoApproveToolWithPath: (toolName: ToolUseName, path?: string) => Promise<boolean>,
sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string) => Promise<any>,
removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: any) => Promise<void>,
say: (
type: ClineSay,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
) => Promise<number | undefined>,
ask: (
type: ClineAsk,
text?: string,
partial?: boolean,
) => Promise<{
response: ClineAskResponse
text?: string
images?: string[]
files?: string[]
}>,
askApproval: (type: ClineAsk, block: ToolUse, message: string) => Promise<boolean>,
saveCheckpoint: () => Promise<void>,
updateFCListFromToolResponse: (taskProgress?: string) => Promise<void>,
handleError: (action: string, error: Error, block: ToolUse) => Promise<void>,
): ToolExecutionManager {
// Create and configure the coordinator
const coordinator = new ToolExecutorCoordinator()
// Register tool handlers
const validator = new ToolValidator(config.services.clineIgnoreController)
coordinator.register(new ListFilesToolHandler(validator))
coordinator.register(new ReadFileToolHandler(validator))
coordinator.register(new BrowserToolHandler())
coordinator.register(new AskFollowupQuestionToolHandler())
coordinator.register(new WebFetchToolHandler())
// Register WriteToFileToolHandler for all three file tools
const writeHandler = new WriteToFileToolHandler(validator)
coordinator.register(writeHandler) // registers as "write_to_file"
coordinator.register({ name: "replace_in_file", execute: writeHandler.execute.bind(writeHandler) })
coordinator.register({ name: "new_rule", execute: writeHandler.execute.bind(writeHandler) })
coordinator.register(new ListCodeDefinitionNamesToolHandler(validator))
coordinator.register(new SearchFilesToolHandler(validator))
coordinator.register(new ExecuteCommandToolHandler(validator))
coordinator.register(new UseMcpToolHandler())
coordinator.register(new AccessMcpResourceHandler())
coordinator.register(new LoadMcpDocumentationHandler())
coordinator.register(new PlanModeRespondHandler())
coordinator.register(new NewTaskHandler())
coordinator.register(new AttemptCompletionHandler())
coordinator.register(new CondenseHandler())
coordinator.register(new SummarizeTaskHandler())
coordinator.register(new ReportBugHandler())
// Create and return the execution manager
return new ToolExecutionManager(
coordinator,
config,
pushToolResult,
ToolDisplayUtils.removeClosingTag,
shouldAutoApproveToolWithPath,
sayAndCreateMissingParamError,
removeLastPartialMessageIfExistsWithType,
say,
ask,
askApproval,
saveCheckpoint,
updateFCListFromToolResponse,
handleError,
)
}
/**
* Execute a tool through the coordinator if it's registered
*/
async execute(block: ToolUse): Promise<boolean> {
if (!this.coordinator.has(block.name)) {
return false // Tool not handled by coordinator
}
try {
// Handle partial blocks
if (block.partial) {
await this.handlePartialBlock(block)
return true
}
// Handle complete blocks
await this.handleCompleteBlock(block)
return true
} catch (error) {
await this.handleError(`executing ${block.name}`, error as Error, block)
await this.saveCheckpoint()
return true
}
}
/**
* Handle partial block streaming UI updates
*/
private async handlePartialBlock(block: ToolUse): Promise<void> {
// Handle different tools that support partial streaming
switch (block.name) {
case "read_file":
case "list_files":
case "list_code_definition_names":
case "search_files":
await this.handleFileToolPartialBlock(block)
break
case "write_to_file":
case "replace_in_file":
case "new_rule":
await this.handleWriteToolPartialBlock(block)
break
case "execute_command":
await this.handleCommandPartialBlock(block)
break
case "use_mcp_tool":
case "access_mcp_resource":
await this.handleMcpToolPartialBlock(block)
break
case "load_mcp_documentation":
// load_mcp_documentation doesn't support partial streaming
return
default:
// Other tools don't support partial streaming yet
return
}
}
/**
* Handle partial blocks for file-related tools
*/
private async handleFileToolPartialBlock(block: ToolUse): Promise<void> {
const sharedMessageProps = await ToolMessageUtils.createFileToolMessageProps(
block,
this.config.cwd,
this.removeClosingTag,
)
const partialMessage = JSON.stringify(sharedMessageProps)
if (await this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
await this.removeLastPartialMessageIfExistsWithType("ask", "tool")
await this.say("tool" as ClineSay, partialMessage, undefined, undefined, block.partial)
} else {
await this.removeLastPartialMessageIfExistsWithType("say", "tool")
await this.ask("tool" as ClineAsk, partialMessage, block.partial).catch(() => {})
}
}
/**
* Handle partial blocks for write-related tools
*/
private async handleWriteToolPartialBlock(block: ToolUse): Promise<void> {
const fileExists = this.config.services.diffViewProvider.editType === "modify"
const sharedMessageProps = await ToolMessageUtils.createWriteToolMessageProps(
block,
this.config.cwd,
fileExists,
this.removeClosingTag,
)
const partialMessage = JSON.stringify(sharedMessageProps)
if (await this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
await this.removeLastPartialMessageIfExistsWithType("ask", "tool")
await this.say("tool" as ClineSay, partialMessage, undefined, undefined, block.partial)
} else {
await this.removeLastPartialMessageIfExistsWithType("say", "tool")
await this.ask("tool" as ClineAsk, partialMessage, block.partial).catch(() => {})
}
}
/**
* Handle partial blocks for command execution
*/
private async handleCommandPartialBlock(block: ToolUse): Promise<void> {
const command = block.params.command
// For commands, we need to wait for the requires_approval parameter before showing UI
// This is because the approval flow depends on that parameter
if (!block.params.requires_approval) {
return // Wait for complete block
}
// Command partial streaming is handled differently - just show the command
const partialCommand = this.removeClosingTag(block, "command", command)
// Don't auto-approve partial commands - wait for complete block
await this.removeLastPartialMessageIfExistsWithType("say", "command")
await this.ask("command" as ClineAsk, partialCommand, block.partial).catch(() => {})
}
/**
* Handle partial blocks for MCP tools
*/
private async handleMcpToolPartialBlock(block: ToolUse): Promise<void> {
const partialMessage = JSON.stringify(ToolMessageUtils.createMcpToolMessageProps(block, this.removeClosingTag))
// MCP tools use a different message type
if (this.config.autoApprovalSettings.enabled) {
await this.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
await this.say("use_mcp_server" as ClineSay, partialMessage, undefined, undefined, block.partial)
} else {
await this.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
await this.ask("use_mcp_server" as ClineAsk, partialMessage, block.partial).catch(() => {})
}
}
/**
* Handle complete block execution with approval flow
*/
private async handleCompleteBlock(block: ToolUse): Promise<void> {
// Handle different tool types with their specific approval flows
switch (block.name) {
case "read_file":
case "list_files":
case "list_code_definition_names":
case "search_files":
await this.handleFileToolExecution(block)
break
case "write_to_file":
case "replace_in_file":
case "new_rule":
await this.handleWriteToolExecution(block)
break
case "execute_command":
await this.handleCommandExecution(block)
break
case "use_mcp_tool":
case "access_mcp_resource":
await this.handleMcpToolExecution(block)
break
case "load_mcp_documentation":
await this.handleLoadMcpDocumentationExecution(block)
break
case "plan_mode_respond":
case "attempt_completion":
case "new_task":
await this.handleTaskManagementExecution(block)
break
case "condense":
case "summarize_task":
case "report_bug":
await this.handleContextAndUtilityExecution(block)
break
case "ask_followup_question":
case "web_fetch":
case "browser_action":
// These tools have simpler approval flows - just execute and push result
await ToolExecutionStrategies.executeSimpleTool(block, this.coordinator, this.config, this.pushToolResult)
break
default:
// For any other tools that might be added, just execute and push result
await ToolExecutionStrategies.executeSimpleTool(block, this.coordinator, this.config, this.pushToolResult)
break
}
// Handle focus chain updates
if (!block.partial && this.config.focusChainSettings.enabled) {
await this.updateFCListFromToolResponse(block.params.task_progress)
}
await this.saveCheckpoint()
}
/**
* Handle execution of file-related tools (read_file, list_files)
*/
private async handleFileToolExecution(block: ToolUse): Promise<void> {
const relPath = block.params.path
// Execute the tool to get the result (handlers validate params and check clineignore)
const result = await this.coordinator.execute(this.config, block)
// Handle validation errors using the error handler
if (
await ToolErrorHandler.handleValidationError(
block,
result,
this.config,
this.pushToolResult,
this.saveCheckpoint,
this.sayAndCreateMissingParamError,
)
) {
return // Error was handled
}
const absolutePath = path.resolve(this.config.cwd, relPath || "")
const tool = ToolDisplayUtils.getToolDisplayName(block)
// Handle approval flow using the approval manager
const approved = await this.approvalManager.handleFileToolApproval(block, relPath || "", absolutePath, tool, result)
if (!approved) {
await this.saveCheckpoint()
return
}
// Tool was approved, push the result
this.pushToolResult(result, block)
}
/**
* Handle execution of write-related tools (write_to_file, replace_in_file, new_rule)
*/
private async handleWriteToolExecution(block: ToolUse): Promise<void> {
const relPath = block.params.path
const content = block.params.content || block.params.diff
// Validate path parameter using error handler
if (
await ToolErrorHandler.handleValidationError(
block,
null, // No result yet, just checking params
this.config,
this.pushToolResult,
this.saveCheckpoint,
this.sayAndCreateMissingParamError,
)
) {
return // Error was handled
}
// Check if file exists for UI messaging
const absolutePath = path.resolve(this.config.cwd, relPath || "")
const fileExists =
this.config.services.diffViewProvider.editType === "modify" || (await this.config.services.diffViewProvider.isEditing)
? this.config.services.diffViewProvider.editType === "modify"
: await require("@utils/fs").fileExistsAtPath(absolutePath)
// Handle approval flow using the approval manager
const approved = await this.approvalManager.handleWriteToolApproval(block, relPath || "", fileExists, content || "")
if (!approved) {
// Reset diff view if user rejected
await ToolErrorHandler.handleDiffViewReset(this.config)
return
}
// User approved or auto-approved, now execute the tool
const result = await this.coordinator.execute(this.config, block)
// Check if handler returned an error
if (ToolValidationUtils.isValidationError(result)) {
this.pushToolResult(result, block)
return
}
// Push the successful result
this.pushToolResult(result, block)
}
/**
* Handle execution of command tool
*/
private async handleCommandExecution(block: ToolUse): Promise<void> {
// Execute the command through the handler
const result = await this.coordinator.execute(this.config, block)
// Check if handler returned an error
if (ToolValidationUtils.isValidationError(result)) {
this.pushToolResult(result, block)
return
}
// For commands, the handler manages the approval flow and execution
// The result is already the final formatted response
this.pushToolResult(result, block)
}
/**
* Handle execution of MCP tools (use_mcp_tool, access_mcp_resource)
*/
private async handleMcpToolExecution(block: ToolUse): Promise<void> {
// Handle approval flow using the approval manager
const approved = await this.approvalManager.handleMcpToolApproval(block)
if (!approved) {
return
}
// Show MCP request started message
await this.say("mcp_server_request_started" as ClineSay)
// Execute the MCP tool through the handler
const result = await this.coordinator.execute(this.config, block)
// Check if handler returned an error
if (ToolValidationUtils.isValidationError(result)) {
this.pushToolResult(result, block)
return
}
// Push the successful result
this.pushToolResult(result, block)
}
/**
* Handle execution of load_mcp_documentation tool
*/
private async handleLoadMcpDocumentationExecution(block: ToolUse): Promise<void> {
await ToolExecutionStrategies.executeToolWithLoadingMessage(
block,
this.coordinator,
this.config,
this.pushToolResult,
this.say,
"load_mcp_documentation" as ClineSay,
)
}
/**
* Handle execution of task management tools (plan_mode_respond, attempt_completion, new_task)
*/
private async handleTaskManagementExecution(block: ToolUse): Promise<void> {
await ToolExecutionStrategies.executeToolWithValidation(block, this.coordinator, this.config, this.pushToolResult)
}
/**
* Handle execution of context and utility tools (condense, summarize_task, report_bug)
*/
private async handleContextAndUtilityExecution(block: ToolUse): Promise<void> {
await ToolExecutionStrategies.executeToolWithValidation(block, this.coordinator, this.config, this.pushToolResult)
}
}
@@ -0,0 +1,40 @@
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../index"
export interface IToolHandler {
readonly name: string
execute(config: any, block: ToolUse): Promise<ToolResponse>
}
/**
* Coordinates tool execution by routing to registered handlers.
* Falls back to legacy switch for unregistered tools.
*/
export class ToolExecutorCoordinator {
private handlers = new Map<string, IToolHandler>()
/**
* Register a tool handler
*/
register(handler: IToolHandler): void {
this.handlers.set(handler.name, handler)
}
/**
* Check if a handler is registered for the given tool
*/
has(toolName: string): boolean {
return this.handlers.has(toolName)
}
/**
* Execute a tool through its registered handler
*/
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
const handler = this.handlers.get(block.name)
if (!handler) {
throw new Error(`No handler registered for tool: ${block.name}`)
}
return handler.execute(config, block)
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { ToolUse, ToolParamName } from "@core/assistant-message"
import type { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
export type ValidationResult = { ok: true } | { ok: false; error: string }
/**
* Lightweight validator used by new tool handlers.
* The legacy ToolExecutor switch remains unchanged and does not depend on this.
*/
export class ToolValidator {
constructor(private readonly clineIgnoreController: ClineIgnoreController) {}
/**
* Verifies required parameters exist on the tool block.
* Returns a message suitable for displaying in an error.
*/
assertRequiredParams(block: ToolUse, ...params: ToolParamName[]): ValidationResult {
for (const p of params) {
// params are stored under block.params using their tag name
const val = (block as any)?.params?.[p]
if (val === undefined || val === null || String(val).trim() === "") {
return { ok: false, error: `Missing required parameter '${p}' for tool '${block.name}'.` }
}
}
return { ok: true }
}
/**
* Verifies access is allowed to a given path via .clineignore rules.
* Callers should pass a repo-relative (workspace-relative) path.
*/
checkClineIgnorePath(relPath: string): ValidationResult {
const accessAllowed = this.clineIgnoreController.validateAccess(relPath)
if (!accessAllowed) {
return {
ok: false,
error: `Access to path '${relPath}' is blocked by .clineignore settings.`,
}
}
return { ok: true }
}
}
@@ -0,0 +1,58 @@
import { formatResponse } from "@core/prompts/responses"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class AccessMcpResourceHandler implements IToolHandler {
readonly name = "access_mcp_resource"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const server_name: string | undefined = block.params.server_name
const uri: string | undefined = block.params.uri
// Validate required parameters
if (!server_name) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: server_name"
}
if (!uri) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: uri"
}
config.taskState.consecutiveMistakeCount = 0
try {
// Execute the MCP resource access
const resourceResult = await config.services.mcpHub.readResource(server_name, uri)
// Process the resource result
const resourceResultPretty =
resourceResult?.contents
.map((item: any) => {
if (item.text) {
return item.text
}
return ""
})
.filter(Boolean)
.join("\n\n") || "(Empty response)"
// Display result to user
await config.callbacks.say("mcp_server_response", resourceResultPretty)
// Return formatted result
return formatResponse.toolResult(resourceResultPretty)
} catch (error) {
return `Error accessing MCP resource: ${(error as Error)?.message}`
}
}
}
@@ -0,0 +1,66 @@
import { ClineAskQuestion } from "@shared/ExtensionMessage"
import { parsePartialArrayString, findLast } from "@shared/array"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { ToolResponse } from "../.."
import { ToolUse } from "../../../assistant-message"
import { formatResponse } from "../../../prompts/responses"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import { ToolUseName } from "../../../assistant-message"
export class AskFollowupQuestionToolHandler implements IToolHandler {
name = "ask_followup_question"
supportedTools: ToolUseName[] = ["ask_followup_question"]
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
const question: string | undefined = block.params.question
const optionsRaw: string | undefined = block.params.options
if (!question) {
throw new Error("Question is required for ask_followup_question")
}
// Store the number of options for telemetry
const options = parsePartialArrayString(optionsRaw || "[]")
const sharedMessage = {
question: question,
options: options,
} satisfies ClineAskQuestion
// Ask the question
const {
text,
images,
files: followupFiles,
} = await config.callbacks.ask("followup", JSON.stringify(sharedMessage), false)
// Check if options contains the text response
if (optionsRaw && text && options.includes(text)) {
telemetryService.captureOptionSelected(config.ulid, options.length, "act")
// Valid option selected, update last followup message with selected option
const clineMessages = config.messageState.getClineMessages()
const lastFollowupMessage = findLast(clineMessages, (m: any) => m.ask === "followup")
if (lastFollowupMessage) {
lastFollowupMessage.text = JSON.stringify({
...sharedMessage,
selected: text,
} satisfies ClineAskQuestion)
await config.messageState.saveClineMessagesAndUpdateHistory()
}
} else {
// Option not selected, send user feedback
telemetryService.captureOptionsIgnored(config.ulid, options.length, "act")
await config.callbacks.say("user_feedback", text ?? "", images, followupFiles)
}
// Process any attached files
let fileContentString = ""
if (followupFiles && followupFiles.length > 0) {
fileContentString = await processFilesIntoText(followupFiles)
}
return formatResponse.toolResult(`<answer>\n${text}\n</answer>`, images, fileContentString)
}
}
@@ -0,0 +1,159 @@
import { showSystemNotification } from "@integrations/notifications"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { findLastIndex } from "@shared/array"
import { formatResponse } from "@core/prompts/responses"
import { COMPLETION_RESULT_CHANGES_FLAG } from "@shared/ExtensionMessage"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import type Anthropic from "@anthropic-ai/sdk"
export class AttemptCompletionHandler implements IToolHandler {
readonly name = "attempt_completion"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const result: string | undefined = block.params.result
const command: string | undefined = block.params.command
// Validate required parameters
if (!result) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: result"
}
config.taskState.consecutiveMistakeCount = 0
// Show notification if auto-approval is enabled
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
showSystemNotification({
subtitle: "Task Completed",
message: result.replace(/\n/g, " "),
})
}
const addNewChangesFlagToLastCompletionResultMessage = async () => {
// Add newchanges flag if there are new changes to the workspace
const hasNewChanges = await config.callbacks.doesLatestTaskCompletionHaveNewChanges()
const clineMessages = config.messageState.getClineMessages()
const lastCompletionResultMessageIndex = findLastIndex(clineMessages, (m: any) => m.say === "completion_result")
const lastCompletionResultMessage =
lastCompletionResultMessageIndex !== -1 ? clineMessages[lastCompletionResultMessageIndex] : undefined
if (
lastCompletionResultMessage &&
lastCompletionResultMessageIndex !== -1 &&
hasNewChanges &&
!lastCompletionResultMessage.text?.endsWith(COMPLETION_RESULT_CHANGES_FLAG)
) {
await config.messageState.updateClineMessage(lastCompletionResultMessageIndex, {
text: lastCompletionResultMessage.text + COMPLETION_RESULT_CHANGES_FLAG,
})
}
}
let commandResult: any = undefined
const lastMessage = config.messageState.getClineMessages().at(-1)
if (command) {
if (lastMessage && lastMessage.ask !== "command") {
// haven't sent a command message yet so first send completion_result then command
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
await config.callbacks.saveCheckpoint(true, completionMessageTs)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(config.ulid)
if (config.focusChainSettings.enabled) {
await config.callbacks.updateFCListFromToolResponse(block.params.task_progress)
}
} else {
// we already sent a command message, meaning the complete completion message has also been sent
await config.callbacks.saveCheckpoint(true)
if (config.focusChainSettings.enabled) {
await config.callbacks.updateFCListFromToolResponse(block.params.task_progress)
}
}
// complete command message - need to ask for approval
const { response, text, images, files } = await config.callbacks.ask("command", command, false)
if (response !== "yesButtonClicked") {
// User rejected the command
return "The user denied the command execution."
}
// User approved, execute the command
const [userRejected, execCommandResult] = await config.callbacks.executeCommandTool(command!)
if (userRejected) {
config.taskState.didRejectTool = true
return execCommandResult
}
// user didn't reject, but the command may have output
commandResult = execCommandResult
} else {
const completionMessageTs = await config.callbacks.say("completion_result", result, undefined, undefined, false)
await config.callbacks.saveCheckpoint(true, completionMessageTs)
await addNewChangesFlagToLastCompletionResultMessage()
telemetryService.captureTaskCompleted(config.ulid)
if (config.focusChainSettings.enabled) {
await config.callbacks.updateFCListFromToolResponse(block.params.task_progress)
}
}
// we already sent completion_result says, an empty string asks relinquishes control over button and field
const { response, text, images, files: completionFiles } = await config.callbacks.ask("completion_result", "", false)
if (response === "yesButtonClicked") {
return "" // signals to recursive loop to stop (for now this never happens since yesButtonClicked will trigger a new task)
}
await config.callbacks.say("user_feedback", text ?? "", images, completionFiles)
await config.callbacks.saveCheckpoint()
const toolResults: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = []
if (commandResult) {
if (typeof commandResult === "string") {
toolResults.push({
type: "text",
text: commandResult,
})
} else if (Array.isArray(commandResult)) {
toolResults.push(...commandResult)
}
}
toolResults.push({
type: "text",
text: `The user has provided feedback on the results. Consider their input to continue the task, and then attempt completion again.\n<feedback>\n${text}\n</feedback>`,
})
toolResults.push(...formatResponse.imageBlocks(images))
let fileContentString = ""
if (completionFiles && completionFiles.length > 0) {
fileContentString = await processFilesIntoText(completionFiles)
}
// Return the tool results as a complex response
return [
{
type: "text" as const,
text: `[attempt_completion] Result:`,
},
...toolResults,
...(fileContentString
? [
{
type: "text" as const,
text: fileContentString,
},
]
: []),
]
}
}
@@ -0,0 +1,99 @@
import { BrowserSession } from "@services/browser/BrowserSession"
import { BrowserAction, BrowserActionResult, browserActions } from "@shared/ExtensionMessage"
import { modelDoesntSupportWebp } from "@utils/model-utils"
import { ToolResponse } from "../.."
import { ToolUse } from "../../../assistant-message"
import { formatResponse } from "../../../prompts/responses"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import { ToolUseName } from "../../../assistant-message"
export class BrowserToolHandler implements IToolHandler {
name = "browser"
supportedTools: ToolUseName[] = ["browser_action"]
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
const action: BrowserAction | undefined = block.params.action as BrowserAction
const url: string | undefined = block.params.url
const coordinate: string | undefined = block.params.coordinate
const text: string | undefined = block.params.text
// Validate action
if (!action || !browserActions.includes(action)) {
throw new Error(`Invalid or missing browser action: ${action}`)
}
const browserSession: BrowserSession = config.services.browserSession
let browserActionResult: BrowserActionResult
switch (action) {
case "launch":
if (!url) {
throw new Error("URL is required for browser launch action")
}
// Re-make browserSession to make sure latest settings apply
if (config.context) {
await browserSession.dispose()
const useWebp = config.api ? !modelDoesntSupportWebp(config.api) : true
const newBrowserSession = new BrowserSession(config.context, config.browserSettings, useWebp)
// Update the browserSession reference
config.services.browserSession = newBrowserSession
await newBrowserSession.launchBrowser()
browserActionResult = await newBrowserSession.navigateToUrl(url)
} else {
console.warn("no controller context available for browserSession")
await browserSession.launchBrowser()
browserActionResult = await browserSession.navigateToUrl(url)
}
break
case "click":
if (!coordinate) {
throw new Error("Coordinate is required for click action")
}
browserActionResult = await browserSession.click(coordinate)
break
case "type":
if (!text) {
throw new Error("Text is required for type action")
}
browserActionResult = await browserSession.type(text)
break
case "scroll_down":
browserActionResult = await browserSession.scrollDown()
break
case "scroll_up":
browserActionResult = await browserSession.scrollUp()
break
case "close":
browserActionResult = await browserSession.closeBrowser()
break
default:
throw new Error(`Unknown browser action: ${action}`)
}
// Return appropriate result based on action
switch (action) {
case "launch":
case "click":
case "type":
case "scroll_down":
case "scroll_up":
return formatResponse.toolResult(
`The browser action has been executed. The console logs and screenshot have been captured for your analysis.\n\nConsole logs:\n${
browserActionResult.logs || "(No new logs)"
}\n\n(REMEMBER: if you need to proceed to using non-\`browser_action\` tools or launch a new browser, you MUST first close this browser. For example, if after analyzing the logs and screenshot you need to edit a file, you must first close the browser before you can use the write_to_file tool.)`,
browserActionResult.screenshot ? [browserActionResult.screenshot] : [],
)
case "close":
return formatResponse.toolResult(`The browser has been closed. You may now proceed to using other tools.`)
}
}
}
@@ -0,0 +1,76 @@
import { showSystemNotification } from "@integrations/notifications"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { formatResponse } from "@core/prompts/responses"
import { ensureTaskDirectoryExists } from "@core/storage/disk"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class CondenseHandler implements IToolHandler {
readonly name = "condense"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const context: string | undefined = block.params.context
// Validate required parameters
if (!context) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: context"
}
config.taskState.consecutiveMistakeCount = 0
// Show notification if auto-approval is enabled
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
showSystemNotification({
subtitle: "Cline wants to condense the conversation...",
message: `Cline is suggesting to condense your conversation with: ${context}`,
})
}
// Ask user for response
const { text, images, files: condenseFiles } = await config.callbacks.ask("condense", context, false)
// If the user provided a response, treat it as feedback
if (text || (images && images.length > 0) || (condenseFiles && condenseFiles.length > 0)) {
let fileContentString = ""
if (condenseFiles && condenseFiles.length > 0) {
fileContentString = await processFilesIntoText(condenseFiles)
}
await config.callbacks.say("user_feedback", text ?? "", images, condenseFiles)
return formatResponse.toolResult(
`The user provided feedback on the condensed conversation summary:\n<feedback>\n${text}\n</feedback>`,
images,
fileContentString,
)
} else {
// If no response, the user accepted the condensed version
const apiConversationHistory = config.messageState.getApiConversationHistory()
const lastMessage = apiConversationHistory[apiConversationHistory.length - 1]
const summaryAlreadyAppended = lastMessage && lastMessage.role === "assistant"
const keepStrategy = summaryAlreadyAppended ? "lastTwo" : "none"
// clear the context history at this point in time
config.taskState.conversationHistoryDeletedRange = config.services.contextManager.getNextTruncationRange(
apiConversationHistory,
config.taskState.conversationHistoryDeletedRange,
keepStrategy,
)
await config.messageState.saveClineMessagesAndUpdateHistory()
await config.services.contextManager.triggerApplyStandardContextTruncationNoticeChange(
Date.now(),
await ensureTaskDirectoryExists(config.context, config.taskId),
)
return formatResponse.toolResult(formatResponse.condense())
}
}
}
@@ -0,0 +1,66 @@
import { fixModelHtmlEscaping } from "@utils/string"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
export class ExecuteCommandToolHandler implements IToolHandler {
readonly name = "execute_command"
constructor(private validator: ToolValidator) {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
let command: string | undefined = block.params.command
const requiresApprovalRaw: string | undefined = block.params.requires_approval
const requiresApprovalPerLLM = requiresApprovalRaw?.toLowerCase() === "true"
// Validate required parameters
if (!command) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("execute_command", "command")
}
if (!requiresApprovalRaw) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("execute_command", "requires_approval")
}
config.taskState.consecutiveMistakeCount = 0
// Pre-process command for certain models
if (config.api.getModel().id.includes("gemini")) {
command = fixModelHtmlEscaping(command)
}
// Check clineignore validation for command
const ignoredFileAttemptedToAccess = config.services.clineIgnoreController.validateCommand(command)
if (ignoredFileAttemptedToAccess) {
await config.callbacks.say("clineignore_error", ignoredFileAttemptedToAccess)
return `Error: Command blocked by .clineignore rules. The command attempted to access: ${ignoredFileAttemptedToAccess}`
}
// Execute the command using the callback
const [userRejected, result] = await config.callbacks.executeCommandTool(command)
if (userRejected) {
config.taskState.didRejectTool = true
}
return result
}
/**
* Check if this command should be auto-approved based on the dual approval system
* Returns [autoApproveSafe, autoApproveAll] tuple
*/
shouldAutoApprove(config: any, requiresApprovalPerLLM: boolean): [boolean, boolean] {
// This logic is handled by the AutoApprove class in the main ToolExecutor
// The handler just executes the command - approval logic is handled by the coordinator
return [false, false]
}
}
@@ -0,0 +1,36 @@
import * as path from "path"
import { parseSourceCodeForDefinitionsTopLevel } from "@services/tree-sitter"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
export class ListCodeDefinitionNamesToolHandler implements IToolHandler {
readonly name = "list_code_definition_names"
constructor(private validator: ToolValidator) {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const relDirPath: string | undefined = block.params.path
// Validate required parameters
const pathValidation = this.validator.assertRequiredParams(block, "path")
if (!pathValidation.ok) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("list_code_definition_names", "path")
}
config.taskState.consecutiveMistakeCount = 0
const absolutePath = path.resolve(config.cwd, relDirPath!)
// Execute the actual parse source code operation
const result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, config.services.clineIgnoreController)
return result
}
}
@@ -0,0 +1,41 @@
import * as path from "path"
import { listFiles } from "@services/glob/list-files"
import { formatResponse } from "@core/prompts/responses"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
export class ListFilesToolHandler implements IToolHandler {
readonly name = "list_files"
constructor(private validator: ToolValidator) {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const relDirPath: string | undefined = block.params.path
const recursiveRaw: string | undefined = block.params.recursive
const recursive = recursiveRaw?.toLowerCase() === "true"
// Validate required parameters
const pathValidation = this.validator.assertRequiredParams(block, "path")
if (!pathValidation.ok) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("list_files", "path")
}
config.taskState.consecutiveMistakeCount = 0
const absolutePath = path.resolve(config.cwd, relDirPath!)
// Execute the actual list files operation
const [files, didHitLimit] = await listFiles(absolutePath, recursive, 200)
const result = formatResponse.formatFilesList(absolutePath, files, didHitLimit, config.services.clineIgnoreController)
return result
}
}
@@ -0,0 +1,27 @@
import { loadMcpDocumentation } from "@core/prompts/loadMcpDocumentation"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class LoadMcpDocumentationHandler implements IToolHandler {
readonly name = "load_mcp_documentation"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet (though this tool shouldn't have partial blocks)
if (block.partial) {
return ""
}
config.taskState.consecutiveMistakeCount = 0
try {
// Load MCP documentation
const documentation = await loadMcpDocumentation(config.services.mcpHub)
return documentation
} catch (error) {
return `Error loading MCP documentation: ${(error as Error)?.message}`
}
}
}
@@ -0,0 +1,58 @@
import { showSystemNotification } from "@integrations/notifications"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { formatResponse } from "@core/prompts/responses"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class NewTaskHandler implements IToolHandler {
readonly name = "new_task"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const context: string | undefined = block.params.context
// Validate required parameters
if (!context) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: context"
}
config.taskState.consecutiveMistakeCount = 0
// Show notification if auto-approval is enabled
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
showSystemNotification({
subtitle: "Cline wants to start a new task...",
message: `Cline is suggesting to start a new task with: ${context}`,
})
}
// Ask user for response
const { text, images, files: newTaskFiles } = await config.callbacks.ask("new_task", context, false)
// If the user provided a response, treat it as feedback
if (text || (images && images.length > 0) || (newTaskFiles && newTaskFiles.length > 0)) {
let fileContentString = ""
if (newTaskFiles && newTaskFiles.length > 0) {
fileContentString = await processFilesIntoText(newTaskFiles)
}
await config.callbacks.say("user_feedback", text ?? "", images, newTaskFiles)
return formatResponse.toolResult(
`The user provided feedback instead of creating a new task:\n<feedback>\n${text}\n</feedback>`,
images,
fileContentString,
)
} else {
// If no response, the user clicked the "Create New Task" button
return formatResponse.toolResult(`The user has created a new task with the provided context.`)
}
}
}
@@ -0,0 +1,114 @@
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { findLast, parsePartialArrayString } from "@shared/array"
import { formatResponse } from "@core/prompts/responses"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class PlanModeRespondHandler implements IToolHandler {
readonly name = "plan_mode_respond"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const response: string | undefined = block.params.response
const optionsRaw: string | undefined = block.params.options
const needsMoreExploration: boolean = block.params.needs_more_exploration === "true"
// Validate required parameters
if (!response) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: response"
}
config.taskState.consecutiveMistakeCount = 0
// Handle needs_more_exploration escape hatch
if (needsMoreExploration) {
return formatResponse.toolResult(
`[You have indicated that you need more exploration. Proceed with calling tools to continue the planning process.]`,
)
}
// Store the number of options for telemetry
const options = parsePartialArrayString(optionsRaw || "[]")
// Handle focus chain updates
if (!block.partial && config.focusChainSettings.enabled) {
await config.callbacks.updateFCListFromToolResponse(block.params.task_progress)
}
// Set awaiting plan response state
config.taskState.isAwaitingPlanResponse = true
const sharedMessage = {
response: response,
options: options,
}
// Ask for user response
let {
text,
images,
files: planResponseFiles,
} = await config.callbacks.ask("plan_mode_respond", JSON.stringify(sharedMessage), false)
config.taskState.isAwaitingPlanResponse = false
// Handle mode toggle marker
if (text === "PLAN_MODE_TOGGLE_RESPONSE") {
text = ""
}
// Check if options contains the text response
if (optionsRaw && text && parsePartialArrayString(optionsRaw).includes(text)) {
telemetryService.captureOptionSelected(config.ulid, options.length, "plan")
// Valid option selected, don't show user message in UI
// Update last plan message with selected option
const lastPlanMessage = findLast(config.messageState.getClineMessages(), (m: any) => m.ask === "plan_mode_respond")
if (lastPlanMessage) {
lastPlanMessage.text = JSON.stringify({
...sharedMessage,
selected: text,
})
await config.messageState.saveClineMessagesAndUpdateHistory()
}
} else {
// Option not selected, send user feedback
if (text || (images && images.length > 0) || (planResponseFiles && planResponseFiles.length > 0)) {
telemetryService.captureOptionsIgnored(config.ulid, options.length, "plan")
await config.callbacks.say("user_feedback", text ?? "", images, planResponseFiles)
await config.callbacks.saveCheckpoint()
}
}
let fileContentString = ""
if (planResponseFiles && planResponseFiles.length > 0) {
const { processFilesIntoText } = await import("@integrations/misc/extract-text")
fileContentString = await processFilesIntoText(planResponseFiles)
}
// Handle mode switching response
if (config.taskState.didRespondToPlanAskBySwitchingMode) {
const result = formatResponse.toolResult(
`[The user has switched to ACT MODE, so you may now proceed with the task.]` +
(text
? `\n\nThe user also provided the following message when switching to ACT MODE:\n<user_message>\n${text}\n</user_message>`
: ""),
images,
fileContentString,
)
// Reset the flag after using it to prevent it from persisting
config.taskState.didRespondToPlanAskBySwitchingMode = false
return result
} else {
// if we didn't switch to ACT MODE, then we can just send the user_feedback message
return formatResponse.toolResult(`<user_message>\n${text}\n</user_message>`, images, fileContentString)
}
}
}
@@ -0,0 +1,53 @@
import * as path from "path"
import { formatResponse } from "@core/prompts/responses"
import { extractFileContent } from "@integrations/misc/extract-file-content"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
export class ReadFileToolHandler implements IToolHandler {
readonly name = "read_file"
constructor(private validator: ToolValidator) {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const relPath: string | undefined = block.params.path
// Validate required parameters
const pathValidation = this.validator.assertRequiredParams(block, "path")
if (!pathValidation.ok) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("read_file", "path")
}
// Check clineignore access
const accessValidation = this.validator.checkClineIgnorePath(relPath!)
if (!accessValidation.ok) {
await config.callbacks.say("clineignore_error", relPath)
return formatResponse.toolError(formatResponse.clineIgnoreError(relPath!))
}
config.taskState.consecutiveMistakeCount = 0
const absolutePath = path.resolve(config.cwd, relPath!)
// Execute the actual file read operation
const supportsImages = config.api.getModel().info.supportsImages ?? false
const result = await extractFileContent(absolutePath, supportsImages)
// Track file read operation
await config.services.fileContextTracker.trackFileContext(relPath!, "read_tool")
// Handle image blocks separately - they need to be pushed to userMessageContent
if (result.imageBlock) {
config.taskState.userMessageContent.push(result.imageBlock)
}
return result.text
}
}
@@ -0,0 +1,123 @@
import { showSystemNotification } from "@integrations/notifications"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { createAndOpenGitHubIssue } from "@utils/github-url-utils"
import { formatResponse } from "@core/prompts/responses"
import * as os from "os"
import * as vscode from "vscode"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class ReportBugHandler implements IToolHandler {
readonly name = "report_bug"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const title = block.params.title
const what_happened = block.params.what_happened
const steps_to_reproduce = block.params.steps_to_reproduce
const api_request_output = block.params.api_request_output
const additional_context = block.params.additional_context
// Validate required parameters
if (!title) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: title"
}
if (!what_happened) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: what_happened"
}
if (!steps_to_reproduce) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: steps_to_reproduce"
}
if (!api_request_output) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: api_request_output"
}
if (!additional_context) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: additional_context"
}
config.taskState.consecutiveMistakeCount = 0
// Show notification if auto-approval is enabled
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
showSystemNotification({
subtitle: "Cline wants to create a github issue...",
message: `Cline is suggesting to create a github issue with the title: ${title}`,
})
}
// Derive system information values algorithmically
const operatingSystem = os.platform() + " " + os.release()
const clineVersion = vscode.extensions.getExtension("saoudrizwan.claude-dev")?.packageJSON.version || "Unknown"
const systemInfo = `VSCode: ${vscode.version}, Node.js: ${process.version}, Architecture: ${os.arch()}`
const currentMode = config.mode
const apiConfig = config.services.cacheService.getApiConfiguration()
const apiProvider = currentMode === "plan" ? apiConfig.planModeApiProvider : apiConfig.actModeApiProvider
const providerAndModel = `${apiProvider} / ${config.api.getModel().id}`
// Ask user for confirmation
const bugReportData = JSON.stringify({
title,
what_happened,
steps_to_reproduce,
api_request_output,
additional_context,
// Include derived values in the JSON for display purposes
provider_and_model: providerAndModel,
operating_system: operatingSystem,
system_info: systemInfo,
cline_version: clineVersion,
})
const { text, images, files: reportBugFiles } = await config.callbacks.ask("report_bug", bugReportData, false)
// If the user provided a response, treat it as feedback
if (text || (images && images.length > 0) || (reportBugFiles && reportBugFiles.length > 0)) {
let fileContentString = ""
if (reportBugFiles && reportBugFiles.length > 0) {
fileContentString = await processFilesIntoText(reportBugFiles)
}
await config.callbacks.say("user_feedback", text ?? "", images, reportBugFiles)
return formatResponse.toolResult(
`The user did not submit the bug, and provided feedback on the Github issue generated instead:\n<feedback>\n${text}\n</feedback>`,
images,
fileContentString,
)
} else {
// If no response, the user accepted the bug report
try {
// Create a Map of parameters for the GitHub issue
const params = new Map<string, string>()
params.set("title", title)
params.set("operating-system", operatingSystem)
params.set("cline-version", clineVersion)
params.set("system-info", systemInfo)
params.set("additional-context", additional_context)
params.set("what-happened", what_happened)
params.set("steps", steps_to_reproduce)
params.set("provider-model", providerAndModel)
params.set("logs", api_request_output)
// Use our utility function to create and open the GitHub issue URL
// This bypasses VS Code's URI handling issues with special characters
await createAndOpenGitHubIssue("cline", "cline", "bug_report.yml", params)
} catch (error) {
console.error(`An error occurred while attempting to report the bug: ${error}`)
}
return formatResponse.toolResult(`The user accepted the creation of the Github issue.`)
}
}
}
@@ -0,0 +1,49 @@
import * as path from "path"
import { regexSearchFiles } from "@services/ripgrep"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
export class SearchFilesToolHandler implements IToolHandler {
readonly name = "search_files"
constructor(private validator: ToolValidator) {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const relDirPath: string | undefined = block.params.path
const regex: string | undefined = block.params.regex
const filePattern: string | undefined = block.params.file_pattern
// Validate required parameters
const pathValidation = this.validator.assertRequiredParams(block, "path")
if (!pathValidation.ok) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("search_files", "path")
}
if (!regex) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("search_files", "regex")
}
config.taskState.consecutiveMistakeCount = 0
const absolutePath = path.resolve(config.cwd, relDirPath!)
// Execute the actual regex search operation
const results = await regexSearchFiles(
config.cwd,
absolutePath,
regex,
filePattern,
config.services.clineIgnoreController,
)
return results
}
}
@@ -0,0 +1,66 @@
import { formatResponse } from "@core/prompts/responses"
import { continuationPrompt } from "@core/prompts/contextManagement"
import { ensureTaskDirectoryExists } from "@core/storage/disk"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class SummarizeTaskHandler implements IToolHandler {
readonly name = "summarize_task"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const context: string | undefined = block.params.context
// Validate required parameters
if (!context) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: context"
}
config.taskState.consecutiveMistakeCount = 0
// Show completed summary in tool UI
await config.callbacks.say(
"tool",
JSON.stringify({
tool: "summarizeTask",
content: context,
}),
undefined,
undefined,
false,
)
// Use the continuationPrompt to format the tool result
const toolResult = formatResponse.toolResult(continuationPrompt(context))
// Handle context management
const apiConversationHistory = config.messageState.getApiConversationHistory()
const keepStrategy = "none"
// clear the context history at this point in time. note that this will not include the assistant message
// for summarizing, which we will need to delete later
config.taskState.conversationHistoryDeletedRange = config.services.contextManager.getNextTruncationRange(
apiConversationHistory,
config.taskState.conversationHistoryDeletedRange,
keepStrategy,
)
await config.messageState.saveClineMessagesAndUpdateHistory()
await config.services.contextManager.triggerApplyStandardContextTruncationNoticeChange(
Date.now(),
await ensureTaskDirectoryExists(config.context, config.taskId),
)
// Set summarizing state
config.taskState.currentlySummarizing = true
return toolResult
}
}
@@ -0,0 +1,99 @@
import { formatResponse } from "@core/prompts/responses"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class UseMcpToolHandler implements IToolHandler {
readonly name = "use_mcp_tool"
constructor() {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
const server_name: string | undefined = block.params.server_name
const tool_name: string | undefined = block.params.tool_name
const mcp_arguments: string | undefined = block.params.arguments
// Validate required parameters
if (!server_name) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: server_name"
}
if (!tool_name) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: tool_name"
}
// Parse and validate arguments if provided
let parsedArguments: Record<string, unknown> | undefined
if (mcp_arguments) {
try {
parsedArguments = JSON.parse(mcp_arguments)
} catch (error) {
config.taskState.consecutiveMistakeCount++
return `Error: Invalid JSON arguments for ${tool_name} on ${server_name}`
}
}
config.taskState.consecutiveMistakeCount = 0
try {
// Check for any pending notifications before the tool call
const notificationsBefore = config.services.mcpHub.getPendingNotifications()
for (const notification of notificationsBefore) {
await config.callbacks.say("mcp_notification", `[${notification.serverName}] ${notification.message}`)
}
// Execute the MCP tool
const toolResult = await config.services.mcpHub.callTool(server_name, tool_name, parsedArguments)
// Check for any pending notifications after the tool call
const notificationsAfter = config.services.mcpHub.getPendingNotifications()
for (const notification of notificationsAfter) {
await config.callbacks.say("mcp_notification", `[${notification.serverName}] ${notification.message}`)
}
// Process tool result
const toolResultImages =
toolResult?.content
.filter((item: any) => item.type === "image")
.map((item: any) => `data:${item.mimeType};base64,${item.data}`) || []
let toolResultText =
(toolResult?.isError ? "Error:\n" : "") +
toolResult?.content
.map((item: any) => {
if (item.type === "text") {
return item.text
}
if (item.type === "resource") {
const { blob, ...rest } = item.resource
return JSON.stringify(rest, null, 2)
}
return ""
})
.filter(Boolean)
.join("\n\n") || "(No response)"
// Display result to user
const toolResultToDisplay = toolResultText + toolResultImages?.map((image: any) => `\n\n${image}`).join("")
await config.callbacks.say("mcp_server_response", toolResultToDisplay)
// Handle model image support
const supportsImages = config.api.getModel().info.supportsImages ?? false
if (toolResultImages.length > 0 && !supportsImages) {
toolResultText += `\n\n[${toolResultImages.length} images were provided in the response, and while they are displayed to the user, you do not have the ability to view them.]`
}
// Return formatted result (only pass images if model supports them)
return formatResponse.toolResult(toolResultText, supportsImages ? toolResultImages : undefined)
} catch (error) {
return `Error executing MCP tool: ${(error as Error)?.message}`
}
}
}
@@ -0,0 +1,39 @@
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { ToolResponse } from "../.."
import { ToolUse, ToolUseName } from "../../../assistant-message"
import { formatResponse } from "../../../prompts/responses"
import type { IToolHandler } from "../ToolExecutorCoordinator"
export class WebFetchToolHandler implements IToolHandler {
name = "web_fetch"
supportedTools: ToolUseName[] = ["web_fetch"]
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
const url: string | undefined = block.params.url
if (!url) {
throw new Error("URL is required for web_fetch")
}
const urlContentFetcher: UrlContentFetcher = config.urlContentFetcher
try {
// Fetch Markdown content
await urlContentFetcher.launchBrowser()
const markdownContent = await urlContentFetcher.urlToMarkdown(url)
await urlContentFetcher.closeBrowser()
// TODO: Implement secondary AI call to process markdownContent with prompt
// For now, returning markdown directly.
// This will be a significant sub-task.
// Placeholder for processed summary:
const processedSummary = `Fetched Markdown for ${url}:\n\n${markdownContent}`
return formatResponse.toolResult(processedSummary)
} catch (error) {
// Ensure browser is closed on error
await urlContentFetcher.closeBrowser()
throw error
}
}
}
@@ -0,0 +1,163 @@
import * as path from "path"
import { formatResponse } from "@core/prompts/responses"
import { fileExistsAtPath } from "@utils/fs"
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import { constructNewFileContent } from "@core/assistant-message/diff"
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../../index"
import type { IToolHandler } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
export class WriteToFileToolHandler implements IToolHandler {
readonly name = "write_to_file" // This handler supports write_to_file, replace_in_file, and new_rule
constructor(private validator: ToolValidator) {}
async execute(config: any, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, return empty string to let coordinator handle UI
if (block.partial) {
return ""
}
const relPath: string | undefined = block.params.path
let content: string | undefined = block.params.content // for write_to_file and new_rule
let diff: string | undefined = block.params.diff // for replace_in_file
// Validate required parameters based on tool type
if (!relPath) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: path"
}
if (block.name === "replace_in_file" && !diff) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: diff"
}
if ((block.name === "write_to_file" || block.name === "new_rule") && !content) {
config.taskState.consecutiveMistakeCount++
return "Missing required parameter: content"
}
// Check clineignore access
const accessValidation = this.validator.checkClineIgnorePath(relPath)
if (!accessValidation.ok) {
return `Error: File access blocked by .clineignore rules: ${relPath}`
}
config.taskState.consecutiveMistakeCount = 0
// Check if file exists
const absolutePath = path.resolve(config.cwd, relPath)
let fileExists: boolean
if (config.services.diffViewProvider.editType !== undefined) {
fileExists = config.services.diffViewProvider.editType === "modify"
} else {
fileExists = await fileExistsAtPath(absolutePath)
config.services.diffViewProvider.editType = fileExists ? "modify" : "create"
}
try {
// Construct newContent from diff or content
let newContent: string = ""
if (diff) {
// Handle replace_in_file with diff construction
if (!config.api.getModel().id.includes("claude")) {
// deepseek models tend to use unescaped html entities in diffs
diff = fixModelHtmlEscaping(diff)
diff = removeInvalidChars(diff)
}
// Open the editor if not done already
if (!config.services.diffViewProvider.isEditing) {
await config.services.diffViewProvider.open(relPath)
}
try {
newContent = await constructNewFileContent(
diff,
config.services.diffViewProvider.originalContent || "",
true, // isFinal = true since we're not streaming
)
} catch (error) {
await config.services.diffViewProvider.revertChanges()
await config.services.diffViewProvider.reset()
return `Error: ${(error as Error)?.message}\n\nDiff parsing failed for ${relPath}`
}
} else if (content) {
// Handle write_to_file and new_rule with direct content
newContent = content
// Pre-processing newContent for cases where weaker models might add artifacts
if (newContent.startsWith("```")) {
newContent = newContent.split("\n").slice(1).join("\n").trim()
}
if (newContent.endsWith("```")) {
newContent = newContent.split("\n").slice(0, -1).join("\n").trim()
}
if (!config.api.getModel().id.includes("claude")) {
newContent = fixModelHtmlEscaping(newContent)
newContent = removeInvalidChars(newContent)
}
}
// Remove trailing newlines
newContent = newContent.trimEnd()
// Open the diff view if not already editing
if (!config.services.diffViewProvider.isEditing) {
await config.services.diffViewProvider.open(relPath)
}
// Update the diff view with the new content
await config.services.diffViewProvider.update(newContent, true)
await setTimeoutPromise(300) // wait for diff view to update
await config.services.diffViewProvider.scrollToFirstDiff()
// Mark the file as edited by Cline
config.services.fileContextTracker.markFileAsEditedByCline(relPath)
// Save the changes and get the result
const { newProblemsMessage, userEdits, autoFormattingEdits, finalContent } =
await config.services.diffViewProvider.saveChanges()
config.taskState.didEditFile = true
// Track file edit operation
await config.services.fileContextTracker.trackFileContext(relPath, "cline_edited")
// Reset the diff view
await config.services.diffViewProvider.reset()
// Handle user edits if any
if (userEdits) {
await config.services.fileContextTracker.trackFileContext(relPath, "user_edited")
await config.callbacks.say(
"user_feedback_diff",
JSON.stringify({
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: relPath,
diff: userEdits,
}),
)
return formatResponse.fileEditWithUserChanges(
relPath,
userEdits,
autoFormattingEdits,
finalContent,
newProblemsMessage,
)
} else {
return formatResponse.fileEditWithoutUserChanges(relPath, autoFormattingEdits, finalContent, newProblemsMessage)
}
} catch (error) {
// Reset diff view on error
await config.services.diffViewProvider.revertChanges()
await config.services.diffViewProvider.reset()
return `Error: ${(error as Error)?.message}`
}
}
}
@@ -0,0 +1,205 @@
import * as path from "path"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import { ClineAskResponse } from "@shared/WebviewMessage"
import { ToolUse, ToolUseName } from "../../../assistant-message"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
/**
* Manages the approval flow for tool executions, including auto-approval logic,
* notification generation, telemetry capture, and UI message routing.
*/
export class ToolApprovalManager {
constructor(
private config: any,
private shouldAutoApproveToolWithPath: (toolName: ToolUseName, path?: string) => Promise<boolean>,
private removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: any) => Promise<void>,
private say: (
type: ClineSay,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
) => Promise<number | undefined>,
private ask: (
type: ClineAsk,
text?: string,
partial?: boolean,
) => Promise<{
response: ClineAskResponse
text?: string
images?: string[]
files?: string[]
}>,
private askApproval: (type: ClineAsk, block: ToolUse, message: string) => Promise<boolean>,
) {}
/**
* Handle approval flow for file-related tools (read_file, list_files, etc.)
*/
async handleFileToolApproval(
block: ToolUse,
relPath: string,
absolutePath: string,
tool: string,
result: any,
): Promise<boolean> {
const sharedMessageProps = {
tool,
path: getReadablePath(this.config.cwd, relPath),
content: block.name === "list_files" ? result : absolutePath,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
const completeMessage = JSON.stringify(sharedMessageProps)
if (await this.shouldAutoApproveToolWithPath(block.name, block.params.path)) {
await this.handleAutoApproval("tool", completeMessage, block)
return true
} else {
const notificationMessage = this.createFileToolNotificationMessage(block, absolutePath)
return await this.handleManualApproval("tool", completeMessage, block, notificationMessage)
}
}
/**
* Handle approval flow for write-related tools (write_to_file, replace_in_file, new_rule)
*/
async handleWriteToolApproval(block: ToolUse, relPath: string, fileExists: boolean, content: string): Promise<boolean> {
const sharedMessageProps = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(this.config.cwd, relPath),
content: content,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
const completeMessage = JSON.stringify(sharedMessageProps)
if (await this.shouldAutoApproveToolWithPath(block.name, relPath)) {
await this.handleAutoApproval("tool", completeMessage, block)
return true
} else {
const notificationMessage = `Cline wants to ${fileExists ? "edit" : "create"} ${path.basename(relPath)}`
return await this.handleManualApproval("tool", completeMessage, block, notificationMessage)
}
}
/**
* Handle approval flow for MCP tools (use_mcp_tool, access_mcp_resource)
*/
async handleMcpToolApproval(block: ToolUse): Promise<boolean> {
const server_name = block.params.server_name
const tool_name = block.params.tool_name
const uri = block.params.uri
const mcp_arguments = block.params.arguments
const completeMessage = JSON.stringify({
type: block.name === "use_mcp_tool" ? "use_mcp_tool" : "access_mcp_resource",
serverName: server_name,
toolName: tool_name,
uri: uri,
arguments: mcp_arguments,
})
const shouldAutoApprove = this.shouldAutoApproveMcpTool(block, server_name || "", tool_name || "")
if (shouldAutoApprove) {
await this.handleAutoApproval("use_mcp_server", completeMessage, block)
return true
} else {
const notificationMessage = this.createMcpToolNotificationMessage(block, tool_name, server_name, uri)
return await this.handleManualApproval("use_mcp_server", completeMessage, block, notificationMessage)
}
}
/**
* Handle auto-approval flow
*/
private async handleAutoApproval(messageType: string, message: string, block: ToolUse): Promise<void> {
await this.removeLastPartialMessageIfExistsWithType("ask", messageType)
await this.say(messageType as ClineSay, message, undefined, undefined, false)
this.config.taskState.consecutiveAutoApprovedRequestsCount++
this.captureTelemetry(block, true, true)
}
/**
* Handle manual approval flow
*/
private async handleManualApproval(
messageType: string,
message: string,
block: ToolUse,
notificationMessage: string,
): Promise<boolean> {
showNotificationForApprovalIfAutoApprovalEnabled(
notificationMessage,
this.config.autoApprovalSettings.enabled,
this.config.autoApprovalSettings.enableNotifications,
)
await this.removeLastPartialMessageIfExistsWithType("say", messageType)
const didApprove = await this.askApproval(messageType as ClineAsk, block, message)
if (!didApprove) {
this.captureTelemetry(block, false, false)
return false
}
this.captureTelemetry(block, false, true)
return true
}
/**
* Determine if MCP tool should be auto-approved
*/
private shouldAutoApproveMcpTool(block: ToolUse, server_name: string, tool_name: string): boolean {
if (block.name === "use_mcp_tool") {
// Check if this specific tool is auto-approved on the server
const isToolAutoApproved = this.config.services.mcpHub.connections
?.find((conn: any) => conn.server.name === server_name)
?.server.tools?.find((tool: any) => tool.name === tool_name)?.autoApprove
return this.config.autoApprovalSettings.enabled && isToolAutoApproved
} else {
// access_mcp_resource uses general auto-approval
return this.config.autoApprovalSettings.enabled
}
}
/**
* Create notification message for file tools
*/
private createFileToolNotificationMessage(block: ToolUse, absolutePath: string): string {
return block.name === "list_files"
? `Cline wants to view directory ${path.basename(absolutePath)}/`
: `Cline wants to read ${path.basename(absolutePath)}`
}
/**
* Create notification message for MCP tools
*/
private createMcpToolNotificationMessage(
block: ToolUse,
tool_name: string | undefined,
server_name: string | undefined,
uri: string | undefined,
): string {
return block.name === "use_mcp_tool"
? `Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`
: `Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`
}
/**
* Capture telemetry for tool usage
*/
private captureTelemetry(block: ToolUse, isAutoApproved: boolean, wasApproved: boolean): void {
telemetryService.captureToolUsage(
this.config.ulid,
block.name,
this.config.api.getModel().id,
isAutoApproved,
wasApproved,
)
}
}
@@ -0,0 +1,90 @@
import { ToolUse, ToolUseName, ToolParamName } from "@core/assistant-message"
/**
* Utility functions for tool display and formatting
*/
export class ToolDisplayUtils {
/**
* Get the display name for a tool based on its parameters
*/
static getToolDisplayName(block: ToolUse): string {
if (block.name === "list_files") {
return block.params.recursive?.toLowerCase() === "true" ? "listFilesRecursive" : "listFilesTopLevel"
}
return "readFile"
}
/**
* Generate a descriptive string for a tool execution
*/
static getToolDescription(block: ToolUse): string {
switch (block.name) {
case "execute_command":
return `[${block.name} for '${block.params.command}']`
case "read_file":
return `[${block.name} for '${block.params.path}']`
case "write_to_file":
return `[${block.name} for '${block.params.path}']`
case "replace_in_file":
return `[${block.name} for '${block.params.path}']`
case "search_files":
return `[${block.name} for '${block.params.regex}'${
block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
}]`
case "list_files":
return `[${block.name} for '${block.params.path}']`
case "list_code_definition_names":
return `[${block.name} for '${block.params.path}']`
case "browser_action":
return `[${block.name} for '${block.params.action}']`
case "use_mcp_tool":
return `[${block.name} for '${block.params.server_name}']`
case "access_mcp_resource":
return `[${block.name} for '${block.params.server_name}']`
case "ask_followup_question":
return `[${block.name} for '${block.params.question}']`
case "plan_mode_respond":
return `[${block.name}]`
case "load_mcp_documentation":
return `[${block.name}]`
case "attempt_completion":
return `[${block.name}]`
case "new_task":
return `[${block.name} for creating a new task]`
case "condense":
return `[${block.name}]`
case "summarize_task":
return `[${block.name}]`
case "report_bug":
return `[${block.name}]`
case "new_rule":
return `[${block.name} for '${block.params.path}']`
case "web_fetch":
return `[${block.name} for '${block.params.url}']`
}
}
/**
* Remove partial closing tag from tool parameter text
* If block is partial, remove partial closing tag so it's not presented to user
*/
static removeClosingTag(block: ToolUse, tag: ToolParamName, text?: string): string {
if (!block.partial) {
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, "")
}
}
@@ -0,0 +1,60 @@
import { ToolUse } from "../../../assistant-message"
import { ToolValidationUtils } from "./ToolValidationUtils"
/**
* Centralized error handling for tool execution
*/
export class ToolErrorHandler {
/**
* Handle validation errors and parameter validation
*/
static async handleValidationError(
block: ToolUse,
result: any,
config: any,
pushToolResult: (content: any, block: ToolUse) => void,
saveCheckpoint: () => Promise<void>,
sayAndCreateMissingParamError: (toolName: any, paramName: string) => Promise<any>,
): Promise<boolean> {
// Check for missing path parameter (common across file tools)
if (!block.params.path && this.requiresPathParameter(block.name)) {
config.taskState.consecutiveMistakeCount++
pushToolResult(await sayAndCreateMissingParamError(block.name, "path"), block)
await saveCheckpoint()
return true // Error was handled
}
// Check if handler returned a validation error
if (ToolValidationUtils.isValidationError(result)) {
pushToolResult(result, block)
await saveCheckpoint()
return true // Error was handled
}
return false // No error to handle
}
/**
* Check if a tool requires a path parameter
*/
private static requiresPathParameter(toolName: string): boolean {
const pathRequiredTools = [
"read_file",
"write_to_file",
"replace_in_file",
"new_rule",
"list_files",
"list_code_definition_names",
"search_files",
]
return pathRequiredTools.includes(toolName)
}
/**
* Handle diff view reset on tool rejection
*/
static async handleDiffViewReset(config: any): Promise<void> {
await config.services.diffViewProvider.revertChanges()
await config.services.diffViewProvider.reset()
}
}
@@ -0,0 +1,75 @@
import { ToolUse } from "../../../assistant-message"
import { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
import { ToolValidationUtils } from "./ToolValidationUtils"
import { ClineSay } from "@shared/ExtensionMessage"
/**
* Simple execution strategies for different tool categories
*/
export class ToolExecutionStrategies {
/**
* Execute simple tools that don't require complex approval flows
*/
static async executeSimpleTool(
block: ToolUse,
coordinator: ToolExecutorCoordinator,
config: any,
pushToolResult: (content: any, block: ToolUse) => void,
): Promise<void> {
const result = await coordinator.execute(config, block)
pushToolResult(result, block)
}
/**
* Execute tools that require validation error checking
*/
static async executeToolWithValidation(
block: ToolUse,
coordinator: ToolExecutorCoordinator,
config: any,
pushToolResult: (content: any, block: ToolUse) => void,
): Promise<void> {
const result = await coordinator.execute(config, block)
// Check if handler returned an error
if (ToolValidationUtils.isValidationError(result)) {
pushToolResult(result, block)
return
}
// Push the successful result
pushToolResult(result, block)
}
/**
* Execute tools that show a loading message first
*/
static async executeToolWithLoadingMessage(
block: ToolUse,
coordinator: ToolExecutorCoordinator,
config: any,
pushToolResult: (content: any, block: ToolUse) => void,
say: (
type: ClineSay,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
) => Promise<number | undefined>,
messageType: ClineSay = "load_mcp_documentation" as ClineSay,
): Promise<void> {
// Show loading message
await say(messageType, "", undefined, undefined, false)
const result = await coordinator.execute(config, block)
// Check if handler returned an error
if (ToolValidationUtils.isValidationError(result)) {
pushToolResult(result, block)
return
}
// Push the successful result
pushToolResult(result, block)
}
}
@@ -0,0 +1,91 @@
import * as path from "path"
import { ToolUse } from "@core/assistant-message"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { ToolDisplayUtils } from "./ToolDisplayUtils"
/**
* Utility functions for creating tool-related UI messages
*/
export class ToolMessageUtils {
/**
* Create shared message properties for file-related tools
*/
static async createFileToolMessageProps(
block: ToolUse,
cwd: string,
removeClosingTag: (block: ToolUse, tag: any, text?: string) => string,
result?: any,
): Promise<any> {
const relPath = block.params.path
const tool = ToolDisplayUtils.getToolDisplayName(block)
return {
tool,
path: getReadablePath(cwd, removeClosingTag(block, "path", relPath)),
content: block.name === "list_files" ? result || "" : undefined,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
}
/**
* Create shared message properties for write-related tools
*/
static async createWriteToolMessageProps(
block: ToolUse,
cwd: string,
fileExists: boolean,
removeClosingTag: (block: ToolUse, tag: any, text?: string) => string,
): Promise<any> {
const relPath = block.params.path
const content = block.params.content || block.params.diff
return {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(cwd, removeClosingTag(block, "path", relPath)),
content: removeClosingTag(block, block.name === "replace_in_file" ? "diff" : "content", content),
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
}
/**
* Create message properties for MCP tools
*/
static createMcpToolMessageProps(block: ToolUse, removeClosingTag: (block: ToolUse, tag: any, text?: string) => string): any {
const server_name = block.params.server_name
const tool_name = block.params.tool_name
const uri = block.params.uri
const mcp_arguments = block.params.arguments
return {
type: block.name === "use_mcp_tool" ? "use_mcp_tool" : "access_mcp_resource",
serverName: removeClosingTag(block, "server_name", server_name),
toolName: removeClosingTag(block, "tool_name", tool_name),
uri: removeClosingTag(block, "uri", uri),
arguments: removeClosingTag(block, "arguments", mcp_arguments),
}
}
/**
* Create notification message for tool approval
*/
static createNotificationMessage(block: ToolUse, relPath?: string, fileExists?: boolean): string {
switch (block.name) {
case "list_files":
return `Cline wants to view directory ${path.basename(path.resolve(relPath || ""))}/`
case "read_file":
case "list_code_definition_names":
case "search_files":
return `Cline wants to read ${path.basename(path.resolve(relPath || ""))}`
case "write_to_file":
case "replace_in_file":
case "new_rule":
return `Cline wants to ${fileExists ? "edit" : "create"} ${path.basename(relPath || "")}`
case "use_mcp_tool":
return `Cline wants to use ${block.params.tool_name} on ${block.params.server_name}`
case "access_mcp_resource":
return `Cline wants to access ${block.params.uri} on ${block.params.server_name}`
default:
return `Cline wants to use ${block.name}`
}
}
}
@@ -0,0 +1,80 @@
import { ToolUse } from "@core/assistant-message"
import { ToolResponse } from "@core/task"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { isNextGenModelFamily } from "@utils/model-utils"
import { ApiHandler } from "@api/index"
/**
* Utility functions for handling tool results and feedback
*/
export class ToolResultUtils {
/**
* Push tool result to user message content with proper formatting
*/
static pushToolResult(
content: ToolResponse,
block: ToolUse,
userMessageContent: any[],
toolDescription: (block: ToolUse) => string,
api: ApiHandler,
markToolAsUsed: () => void,
): void {
const isNextGenModel = isNextGenModelFamily(api)
if (typeof content === "string") {
const resultText = content || "(tool did not return anything)"
// Non-Claude 4: Use traditional format with header
userMessageContent.push({
type: "text",
text: `${toolDescription(block)} Result:`,
})
userMessageContent.push({
type: "text",
text: resultText,
})
} else {
userMessageContent.push(...content)
}
// once a tool result has been collected, ignore all other tool uses since we should only ever present one tool result per message
markToolAsUsed()
}
/**
* Push additional tool feedback from user to message content
*/
static pushAdditionalToolFeedback(
userMessageContent: any[],
feedback?: string,
images?: string[],
fileContentString?: string,
): void {
if (!feedback && (!images || images.length === 0) && !fileContentString) {
return
}
const content = formatResponse.toolResult(
`The user provided the following feedback:\n<feedback>\n${feedback}\n</feedback>`,
images,
fileContentString,
)
if (typeof content === "string") {
userMessageContent.push({
type: "text",
text: content,
})
} else {
userMessageContent.push(...content)
}
}
/**
* Process files into text content for feedback
*/
static async processFilesForFeedback(files?: string[]): Promise<string> {
if (!files || files.length === 0) {
return ""
}
return await processFilesIntoText(files)
}
}
@@ -0,0 +1,27 @@
/**
* Utility functions for tool validation and error checking
*/
export class ToolValidationUtils {
/**
* Check if a result is a validation error
*/
static isValidationError(result: any): boolean {
return (
typeof result === "string" &&
(result.includes("Missing required parameter") || result.includes("blocked by .clineignore"))
)
}
/**
* Check if a tool result indicates an error condition
*/
static isToolError(result: any): boolean {
return (
typeof result === "string" &&
(result.includes("Error") ||
result.includes("Failed") ||
result.includes("blocked by .clineignore") ||
result.includes("Missing required parameter"))
)
}
}
+7
View File
@@ -0,0 +1,7 @@
export { ToolDisplayUtils } from "./ToolDisplayUtils"
export { ToolValidationUtils } from "./ToolValidationUtils"
export { ToolResultUtils } from "./ToolResultUtils"
export { ToolMessageUtils } from "./ToolMessageUtils"
export { ToolApprovalManager } from "./ToolApprovalManager"
export { ToolErrorHandler } from "./ToolErrorHandler"
export { ToolExecutionStrategies } from "./ToolExecutionStrategies"