mirror of
https://github.com/cline/cline.git
synced 2026-09-11 05:47:07 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dad564f3bf |
+88
-2150
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user