Compare commits

...
Author SHA1 Message Date
Arafatkatze 74f8b16210 Fixing ordering of diff stuff 2025-08-27 07:41:13 -07:00
Arafatkatze 03eebea803 Fixing ordering of diff stuff 2025-08-27 07:31:10 -07:00
Arafatkatze bb4d29755e Adding diff fix 2025-08-27 07:11:56 -07:00
Arafatkatze cf9e68dbbe Refactor Tool Executor 2025-08-26 16:27:22 -07:00
33 changed files with 3558 additions and 2320 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
REfactoring Tool Executor
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,81 @@
import type { ToolUse } from "@core/assistant-message"
import type { ToolResponse } from "../index"
import type { TaskConfig } from "./types/TaskConfig"
import type { StronglyTypedUIHelpers } from "./types/UIHelpers"
export interface IToolHandler {
readonly name: string
execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse>
getDescription(block: ToolUse): string
}
export interface IPartialBlockHandler {
handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void>
}
export interface IFullyManagedTool extends IToolHandler, IPartialBlockHandler {
// Marker interface for tools that handle their own complete approval flow
}
/**
* A wrapper class that allows a single tool handler to be registered under multiple names.
* This provides proper typing for tools that share the same implementation logic.
*/
export class SharedToolHandler implements IFullyManagedTool {
constructor(
public readonly name: string,
private baseHandler: IFullyManagedTool,
) {}
getDescription(block: ToolUse): string {
return this.baseHandler.getDescription(block)
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
return this.baseHandler.execute(config, block)
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
return this.baseHandler.handlePartialBlock(block, uiHelpers)
}
}
/**
* 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)
}
/**
* Get a handler for the given tool name
*/
getHandler(toolName: string): IToolHandler | undefined {
return this.handlers.get(toolName)
}
/**
* Execute a tool through its registered handler
*/
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
const handler = this.handlers.get(block.name)
if (!handler) {
throw new Error(`No handler registered for tool: ${block.name}`)
}
return handler.execute(config, block)
}
}
+42
View File
@@ -0,0 +1,42 @@
import type { ToolParamName, ToolUse } 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 }
}
}
+34
View File
@@ -1,3 +1,37 @@
/**
* TODO: Refactor Auto-Approval Behavior for Consistency
*
* CURRENT ISSUE:
* The auto-approval logic is currently split and inconsistent between two execution contexts:
*
* 1. UIHelpers (used in handlePartialBlock):
* - Makes approval decisions during streaming/partial updates
* - Uses shouldAutoApproveToolWithPath() and other helper methods
* - Logic is embedded in the UIHelpers factory pattern
*
* 2. Execute functions (used in handleCompleteBlock):
* - Makes approval decisions after tool completion
* - Uses different approval patterns and checks
* - Logic is scattered across individual tool handlers
*
* This split creates several problems:
* - Inconsistent approval behavior between streaming and final execution
* - Duplicate approval logic that can drift apart
* - Harder to maintain and reason about approval flow
* - Potential for approval bypasses or double-approvals
*
* PROPOSED SOLUTION:
* - Consolidate all auto-approval logic into this AutoApprove class
* - Create a single source of truth for approval decisions
* - Ensure both partial and complete blocks use the same approval flow
* - Make approval behavior predictable and testable
*
* AFFECTED FILES TO REFACTOR:
* - src/core/task/tools/types/UIHelpers.ts (shouldAutoApproveToolWithPath logic)
* - src/core/task/tools/handlers/* (individual handler approval logic)
* - src/core/task/ToolExecutor.ts (approval flow coordination)
*/
import { ToolUseName } from "@core/assistant-message"
import { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import * as path from "path"
@@ -0,0 +1,145 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { ClineAsk } from "@shared/ExtensionMessage"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class AccessMcpResourceHandler implements IFullyManagedTool {
readonly name = "access_mcp_resource"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.server_name}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const server_name = block.params.server_name
const uri = block.params.uri
// Early return if we don't have enough data yet
if (!server_name || !uri) {
return
}
const partialMessage = JSON.stringify({
type: "access_mcp_resource",
serverName: uiHelpers.removeClosingTag(block, "server_name", server_name),
toolName: undefined,
uri: uiHelpers.removeClosingTag(block, "uri", uri),
arguments: undefined,
})
// Check if tool should be auto-approved (access_mcp_resource uses general auto-approval)
const shouldAutoApprove = uiHelpers.shouldAutoApproveTool("access_mcp_resource")
if (shouldAutoApprove) {
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
await uiHelpers.say("use_mcp_server" as any, partialMessage, undefined, undefined, block.partial)
} else {
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
await uiHelpers.ask("use_mcp_server" as ClineAsk, partialMessage, block.partial).catch(() => {})
}
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, return empty string to let coordinator handle UI
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 await config.callbacks.sayAndCreateMissingParamError("access_mcp_resource", "server_name")
}
if (!uri) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("access_mcp_resource", "uri")
}
config.taskState.consecutiveMistakeCount = 0
// Handle approval flow
const completeMessage = JSON.stringify({
type: "access_mcp_resource",
serverName: server_name,
toolName: undefined,
uri: uri,
arguments: undefined,
})
// access_mcp_resource uses general auto-approval
const shouldAutoApprove = config.autoApprovalSettings.actions.useMcp
if (shouldAutoApprove) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to access ${uri || "unknown resource"} on ${server_name || "unknown server"}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
notificationMessage,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
// Ask for approval
const { response } = await config.callbacks.ask("use_mcp_server", completeMessage, false)
if (response !== "yesButtonClicked") {
// Handle rejection
config.taskState.didRejectTool = true
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
return "The user denied this operation."
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
}
}
// Show MCP request started message
await config.callbacks.say("mcp_server_request_started")
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,104 @@
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { showSystemNotification } from "@integrations/notifications"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { findLast, parsePartialArrayString } from "@shared/array"
import { ClineAsk, ClineAskQuestion } from "@shared/ExtensionMessage"
import { ToolUse, ToolUseName } from "../../../assistant-message"
import { formatResponse } from "../../../prompts/responses"
import { ToolResponse } from "../.."
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class AskFollowupQuestionToolHandler implements IToolHandler, IPartialBlockHandler {
name = "ask_followup_question"
supportedTools: ToolUseName[] = ["ask_followup_question"]
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.question}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const question = block.params.question || ""
const optionsRaw = block.params.options || "[]"
const sharedMessage = {
question: uiHelpers.removeClosingTag(block, "question", question),
options: parsePartialArrayString(uiHelpers.removeClosingTag(block, "options", optionsRaw)),
} satisfies ClineAskQuestion
// For followup, just stream ask messages
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "followup")
await uiHelpers.ask("followup" as ClineAsk, JSON.stringify(sharedMessage), block.partial).catch(() => {})
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
try {
const question: string | undefined = block.params.question
const optionsRaw: string | undefined = block.params.options
// Validate required parameter
if (!question) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("ask_followup_question", "question")
}
config.taskState.consecutiveMistakeCount = 0
// Show notification if auto-approval is enabled
if (config.autoApprovalSettings.enabled && config.autoApprovalSettings.enableNotifications) {
showSystemNotification({
subtitle: "Cline has a question...",
message: question.replace(/\n/g, " "),
})
}
const sharedMessage = {
question: question,
options: parsePartialArrayString(optionsRaw || "[]"),
} satisfies ClineAskQuestion
const options = parsePartialArrayString(optionsRaw || "[]")
// 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)
} catch (error) {
return `Error asking question: ${(error as Error).message}`
}
}
}
@@ -0,0 +1,179 @@
import type Anthropic from "@anthropic-ai/sdk"
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { showSystemNotification } from "@integrations/notifications"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { findLastIndex } from "@shared/array"
import { COMPLETION_RESULT_CHANGES_FLAG } from "@shared/ExtensionMessage"
import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class AttemptCompletionHandler implements IToolHandler, IPartialBlockHandler {
readonly name = "attempt_completion"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name}]`
}
/**
* Handle partial block streaming for attempt_completion
* Matches the original conditional logic structure for command vs no-command cases
*/
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const result = block.params.result
const command = block.params.command
if (command) {
// the attempt_completion text is done, now we're getting command
// Original had complex logic here but most was commented out
// For now, we'll keep it simple and not stream command (matching original's disabled approach)
// But we can still stream result if we have it
if (result) {
const cleanResult = uiHelpers.removeClosingTag(block, "result", result)
await uiHelpers.say("completion_result", cleanResult, undefined, undefined, true)
}
} else {
// no command, still outputting partial result - MATCH ORIGINAL EXACTLY
if (result) {
const cleanResult = uiHelpers.removeClosingTag(block, "result", result)
await uiHelpers.say("completion_result", cleanResult, undefined, undefined, true)
}
}
}
async execute(config: TaskConfig, 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
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)
} else {
// we already sent a command message, meaning the complete completion message has also been sent
await config.callbacks.saveCheckpoint(true)
}
// complete command message - need to ask for approval
const { response } = 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)
}
// 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,221 @@
import { BrowserSession } from "@services/browser/BrowserSession"
import { BrowserAction, BrowserActionResult, browserActions, ClineSayBrowserAction } from "@shared/ExtensionMessage"
import { modelDoesntSupportWebp } from "@utils/model-utils"
import { ToolUse } from "../../../assistant-message"
import { formatResponse } from "../../../prompts/responses"
import { ToolResponse } from "../.."
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class BrowserToolHandler implements IFullyManagedTool {
readonly name = "browser_action"
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.action}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
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 parameter
if (!action || !browserActions.includes(action)) {
return // Wait for more content
}
// Handle partial block streaming - exact original logic
if (action === "launch") {
if (uiHelpers.shouldAutoApproveTool(block.name)) {
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "browser_action_launch")
await uiHelpers.say(
"browser_action_launch",
uiHelpers.removeClosingTag(block, "url", url),
undefined,
undefined,
block.partial,
)
} else {
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "browser_action_launch")
await uiHelpers
.ask("browser_action_launch", uiHelpers.removeClosingTag(block, "url", url), block.partial)
.catch(() => {})
}
} else {
await uiHelpers.say(
"browser_action",
JSON.stringify({
action: action as BrowserAction,
coordinate: uiHelpers.removeClosingTag(block, "coordinate", coordinate),
text: uiHelpers.removeClosingTag(block, "text", text),
} satisfies ClineSayBrowserAction),
undefined,
undefined,
block.partial,
)
}
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
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 parameter - following original pattern
if (!action || !browserActions.includes(action)) {
// if the block is complete and we don't have a valid action this is a mistake
config.taskState.consecutiveMistakeCount++
const errorResult = await config.callbacks.sayAndCreateMissingParamError("browser_action", "action")
await config.services.browserSession.closeBrowser()
await config.callbacks.saveCheckpoint()
return errorResult
}
try {
// Handle complete block execution
let browserActionResult: BrowserActionResult
if (action === "launch") {
if (!url) {
config.taskState.consecutiveMistakeCount++
const errorResult = await config.callbacks.sayAndCreateMissingParamError("browser_action", "url")
await config.services.browserSession.closeBrowser()
await config.callbacks.saveCheckpoint()
return errorResult
}
config.taskState.consecutiveMistakeCount = 0
// Handle approval flow for launch using callbacks
const autoApprover = config.autoApprover || { shouldAutoApproveTool: () => false }
if (autoApprover.shouldAutoApproveTool(block.name)) {
await config.callbacks.say("browser_action_launch", url, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
} else {
// Show notification for approval if auto approval enabled
const { showNotificationForApprovalIfAutoApprovalEnabled } = require("../../utils")
showNotificationForApprovalIfAutoApprovalEnabled(
`Cline wants to use a browser and launch ${url}`,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
const { response } = await config.callbacks.ask("browser_action_launch", url, false)
if (response !== "yesButtonClicked") {
await config.callbacks.saveCheckpoint()
return formatResponse.toolResult("The user rejected this browser action.")
}
}
// Start loading spinner
await config.callbacks.say("browser_action_result", "")
// Re-make browserSession to make sure latest settings apply
const browserSession = config.services.browserSession
if (config.context) {
await browserSession.dispose()
const apiHandlerModel = config.api.getModel()
const useWebp = config.api ? !modelDoesntSupportWebp(apiHandlerModel) : true
config.services.browserSession = new BrowserSession(config.context, config.browserSettings, useWebp)
} else {
console.warn("no controller context available for browserSession")
}
await config.services.browserSession.launchBrowser()
browserActionResult = await config.services.browserSession.navigateToUrl(url)
} else {
// Handle other actions (click, type, scroll, close)
if (action === "click") {
if (!coordinate) {
config.taskState.consecutiveMistakeCount++
const errorResult = await config.callbacks.sayAndCreateMissingParamError("browser_action", "coordinate")
await config.services.browserSession.closeBrowser()
await config.callbacks.saveCheckpoint()
return errorResult
}
}
if (action === "type") {
if (!text) {
config.taskState.consecutiveMistakeCount++
const errorResult = await config.callbacks.sayAndCreateMissingParamError("browser_action", "text")
await config.services.browserSession.closeBrowser()
await config.callbacks.saveCheckpoint()
return errorResult
}
}
config.taskState.consecutiveMistakeCount = 0
// Send browser action message
await config.callbacks.say(
"browser_action",
JSON.stringify({
action: action as BrowserAction,
coordinate,
text,
} satisfies ClineSayBrowserAction),
undefined,
undefined,
false,
)
// Execute the action
const browserSession = config.services.browserSession
switch (action) {
case "click":
browserActionResult = await browserSession.click(coordinate!)
break
case "type":
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
}
}
// Handle results based on action type
switch (action) {
case "launch":
case "click":
case "type":
case "scroll_down":
case "scroll_up":
await config.callbacks.say("browser_action_result", JSON.stringify(browserActionResult))
const result = 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] : [],
)
await config.callbacks.saveCheckpoint()
return result
case "close":
const closeResult = formatResponse.toolResult(
`The browser has been closed. You may now proceed to using other tools.`,
)
await config.callbacks.saveCheckpoint()
return closeResult
}
} catch (error) {
await config.services.browserSession.closeBrowser() // if any error occurs, the browser session is terminated
return `Error executing browser action: ${(error as Error).message}`
}
// This should never be reached, but TypeScript requires a return
return ""
}
}
@@ -0,0 +1,92 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { ensureTaskDirectoryExists } from "@core/storage/disk"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { showSystemNotification } from "@integrations/notifications"
import { ClineAsk } from "@shared/ExtensionMessage"
import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class CondenseHandler implements IToolHandler, IPartialBlockHandler {
readonly name = "condense"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name}]`
}
async execute(config: TaskConfig, 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),
apiConversationHistory,
)
return formatResponse.toolResult(formatResponse.condense())
}
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const context = block.params.context || ""
const cleanedContext = uiHelpers.removeClosingTag(block, "context", context)
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "condense")
await uiHelpers.ask("condense" as ClineAsk, cleanedContext, block.partial).catch(() => {})
}
}
@@ -0,0 +1,150 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { showSystemNotification } from "@integrations/notifications"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { COMMAND_REQ_APP_STRING } from "@shared/combineCommandSequences"
import { ClineAsk } from "@shared/ExtensionMessage"
import { fixModelHtmlEscaping } from "@utils/string"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class ExecuteCommandToolHandler implements IFullyManagedTool {
readonly name = "execute_command"
constructor(_validator: ToolValidator) {}
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.command}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): 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 = uiHelpers.removeClosingTag(block, "command", command)
// Check if this should be auto-approved to determine UI flow
const shouldAutoApprove = uiHelpers.shouldAutoApproveTool("execute_command")
if (shouldAutoApprove) {
// For auto-approved commands, we can't partially stream a say prematurely
// since it may become an ask based on the requires_approval parameter
// So we wait for the complete block
return
} else {
// For manual approval, stream the ask message
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "command")
await uiHelpers.ask("command" as ClineAsk, partialCommand, block.partial).catch(() => {})
}
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
try {
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 formatResponse.toolError(formatResponse.clineIgnoreError(ignoredFileAttemptedToAccess))
}
let didAutoApprove = false
// Complex dual approval system for commands
const autoApproveResult = config.autoApprover?.shouldAutoApproveTool(block.name)
const [autoApproveSafe, autoApproveAll] = Array.isArray(autoApproveResult)
? autoApproveResult
: [autoApproveResult, false]
if ((!requiresApprovalPerLLM && autoApproveSafe) || (requiresApprovalPerLLM && autoApproveSafe && autoApproveAll)) {
// Auto-approve flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "command")
await config.callbacks.say("command", command, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
didAutoApprove = true
telemetryService.captureToolUsage(config.ulid, "execute_command", config.api.getModel().id, true, true)
} else {
// Manual approval flow
showNotificationForApprovalIfAutoApprovalEnabled(
`Cline wants to execute a command: ${command}`,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
const { response } = await config.callbacks.ask(
"command",
command + `${autoApproveSafe && requiresApprovalPerLLM ? COMMAND_REQ_APP_STRING : ""}`,
false,
)
if (response !== "yesButtonClicked") {
telemetryService.captureToolUsage(config.ulid, "execute_command", config.api.getModel().id, false, false)
return "The user denied this operation."
}
telemetryService.captureToolUsage(config.ulid, "execute_command", config.api.getModel().id, false, true)
}
// Setup timeout notification for long-running auto-approved commands
let timeoutId: NodeJS.Timeout | undefined
if (didAutoApprove && config.autoApprovalSettings.enableNotifications) {
timeoutId = setTimeout(() => {
showSystemNotification({
subtitle: "Command is still running",
message: "An auto-approved command has been running for 30s, and may need your attention.",
})
}, 30_000)
}
// Execute the command
const [userRejected, result] = await config.callbacks.executeCommandTool(command)
if (timeoutId) {
clearTimeout(timeoutId)
}
if (userRejected) {
config.taskState.didRejectTool = true
}
return result
} catch (error) {
return `Error executing command: ${(error as Error).message}`
}
}
}
@@ -0,0 +1,121 @@
import type { ToolUse } from "@core/assistant-message"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { parseSourceCodeForDefinitionsTopLevel } from "@services/tree-sitter"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class ListCodeDefinitionNamesToolHandler implements IFullyManagedTool {
readonly name = "list_code_definition_names"
constructor(private validator: ToolValidator) {}
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.path}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const relPath = block.params.path
// Early return if we don't have enough data yet
if (!relPath) {
return
}
// Get config access for services
const config = uiHelpers.getConfig()
// Create and show partial UI message
const absolutePath = path.resolve(config.cwd, relPath)
const sharedMessageProps = {
tool: "listCodeDefinitionNames",
path: getReadablePath(config.cwd, uiHelpers.removeClosingTag(block, "path", relPath)),
content: absolutePath,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
const partialMessage = JSON.stringify(sharedMessageProps)
// Handle auto-approval vs manual approval for partial
if (await uiHelpers.shouldAutoApproveToolWithPath(block.name, relPath)) {
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "tool")
await uiHelpers.say("tool", partialMessage, undefined, undefined, block.partial)
} else {
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "tool")
await uiHelpers.ask("tool", partialMessage, block.partial).catch(() => {})
}
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, return empty string to let coordinator handle UI
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!)
// Handle approval flow
const sharedMessageProps = {
tool: "listCodeDefinitionNames",
path: getReadablePath(config.cwd, relDirPath!),
content: absolutePath,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relDirPath!),
}
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relDirPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to analyze code definitions in ${path.basename(absolutePath)}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
notificationMessage,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
// Ask for approval
const { response } = await config.callbacks.ask("tool", completeMessage, false)
if (response !== "yesButtonClicked") {
// Handle rejection
config.taskState.didRejectTool = true
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
return "The user denied this operation."
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
}
}
// Execute the actual parse source code operation
const result = await parseSourceCodeForDefinitionsTopLevel(absolutePath, config.services.clineIgnoreController)
return result
}
}
@@ -0,0 +1,128 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { listFiles } from "@services/glob/list-files"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class ListFilesToolHandler implements IFullyManagedTool {
readonly name = "list_files"
constructor(private validator: ToolValidator) {}
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.path}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const relPath = block.params.path
// Early return if we don't have enough data yet
if (!relPath) {
return
}
// Get config access for services
const config = uiHelpers.getConfig()
// Create and show partial UI message
const absolutePath = path.resolve(config.cwd, relPath)
const recursiveRaw = block.params.recursive
const recursive = recursiveRaw?.toLowerCase() === "true"
const sharedMessageProps = {
tool: recursive ? "listFilesRecursive" : "listFilesTopLevel",
path: getReadablePath(config.cwd, uiHelpers.removeClosingTag(block, "path", relPath)),
content: absolutePath,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
const partialMessage = JSON.stringify(sharedMessageProps)
// Handle auto-approval vs manual approval for partial
if (await uiHelpers.shouldAutoApproveToolWithPath(block.name, relPath)) {
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "tool")
await uiHelpers.say("tool", partialMessage, undefined, undefined, block.partial)
} else {
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "tool")
await uiHelpers.ask("tool", partialMessage, block.partial).catch(() => {})
}
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, return empty string to let coordinator handle UI
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!)
// Handle approval flow
const sharedMessageProps = {
tool: recursive ? "listFilesRecursive" : "listFilesTopLevel",
path: getReadablePath(config.cwd, relDirPath!),
content: absolutePath,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relDirPath!),
}
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relDirPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to view directory ${path.basename(absolutePath)}/`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
notificationMessage,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
// Ask for approval
const { response } = await config.callbacks.ask("tool", completeMessage, false)
if (response !== "yesButtonClicked") {
// Handle rejection
config.taskState.didRejectTool = true
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
return "The user denied this operation."
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
}
}
// 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,41 @@
import type { ToolUse } from "@core/assistant-message"
import { loadMcpDocumentation } from "@core/prompts/loadMcpDocumentation"
import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class LoadMcpDocumentationHandler implements IToolHandler, IPartialBlockHandler {
readonly name = "load_mcp_documentation"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name}]`
}
async handlePartialBlock(_block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
// Show loading message for partial blocks (though this tool probably won't have partials)
await uiHelpers.say("load_mcp_documentation", "", undefined, undefined, true)
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet (though this tool shouldn't have partial blocks)
if (block.partial) {
return ""
}
// Show loading message at start of execution (self-managed now)
await config.callbacks.say("load_mcp_documentation", "", undefined, undefined, false)
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,72 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { showSystemNotification } from "@integrations/notifications"
import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class NewTaskHandler implements IToolHandler, IPartialBlockHandler {
readonly name = "new_task"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name} for creating a new task]`
}
/**
* Handle partial block streaming for new_task
*/
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const context = uiHelpers.removeClosingTag(block, "context", block.params.context)
await uiHelpers.ask("new_task", context, true).catch(() => {})
}
async execute(config: TaskConfig, 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,131 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { findLast, parsePartialArrayString } from "@shared/array"
import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class PlanModeRespondHandler implements IToolHandler, IPartialBlockHandler {
readonly name = "plan_mode_respond"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name}]`
}
/**
* Handle partial block streaming for plan_mode_respond
*/
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const response = block.params.response
const optionsRaw = block.params.options
const sharedMessage = {
response: uiHelpers.removeClosingTag(block, "response", response),
options: parsePartialArrayString(uiHelpers.removeClosingTag(block, "options", optionsRaw)),
}
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "plan_mode_respond")
await uiHelpers.ask("plan_mode_respond", JSON.stringify(sharedMessage), true).catch(() => {})
}
async execute(config: TaskConfig, 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 || "[]")
// 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,146 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { extractFileContent } from "@integrations/misc/extract-file-content"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class ReadFileToolHandler implements IFullyManagedTool {
readonly name = "read_file"
constructor(private validator: ToolValidator) {}
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.path}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const relPath = block.params.path
// Early return if we don't have enough data yet
if (!relPath) {
return
}
// Get config access for services
const config = uiHelpers.getConfig()
// Check clineignore access first
const accessValidation = this.validator.checkClineIgnorePath(relPath)
if (!accessValidation.ok) {
// Show error and return early
await uiHelpers.say("clineignore_error", relPath)
return
}
// Create and show partial UI message
const absolutePath = path.resolve(config.cwd, relPath)
const sharedMessageProps = {
tool: "readFile",
path: getReadablePath(config.cwd, uiHelpers.removeClosingTag(block, "path", relPath)),
content: absolutePath,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
const partialMessage = JSON.stringify(sharedMessageProps)
// Handle auto-approval vs manual approval for partial
if (await uiHelpers.shouldAutoApproveToolWithPath(block.name, relPath)) {
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "tool")
await uiHelpers.say("tool", partialMessage, undefined, undefined, block.partial)
} else {
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "tool")
await uiHelpers.ask("tool", partialMessage, block.partial).catch(() => {})
}
}
async execute(config: TaskConfig, 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
// 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!)
// Handle approval flow
const sharedMessageProps = {
tool: "readFile",
path: getReadablePath(config.cwd, relPath!),
content: absolutePath,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath!),
}
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to read ${path.basename(absolutePath)}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
notificationMessage,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
// Ask for approval
const { response } = await config.callbacks.ask("tool", completeMessage, false)
if (response !== "yesButtonClicked") {
// Handle rejection
config.taskState.didRejectTool = true
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
return "The user denied this operation."
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
}
}
// 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,143 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { showSystemNotification } from "@integrations/notifications"
import { ClineAsk } from "@shared/ExtensionMessage"
import { createAndOpenGitHubIssue } from "@utils/github-url-utils"
import * as os from "os"
import * as vscode from "vscode"
import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class ReportBugHandler implements IToolHandler, IPartialBlockHandler {
readonly name = "report_bug"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name}]`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const partialMessage = JSON.stringify({
title: uiHelpers.removeClosingTag(block, "title", block.params.title),
what_happened: uiHelpers.removeClosingTag(block, "what_happened", block.params.what_happened),
steps_to_reproduce: uiHelpers.removeClosingTag(block, "steps_to_reproduce", block.params.steps_to_reproduce),
api_request_output: uiHelpers.removeClosingTag(block, "api_request_output", block.params.api_request_output),
additional_context: uiHelpers.removeClosingTag(block, "additional_context", block.params.additional_context),
})
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "report_bug")
await uiHelpers.ask("report_bug" as ClineAsk, partialMessage, block.partial).catch(() => {})
}
async execute(config: TaskConfig, 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.stateManager.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,144 @@
import type { ToolUse } from "@core/assistant-message"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { regexSearchFiles } from "@services/ripgrep"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import * as path from "path"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class SearchFilesToolHandler implements IFullyManagedTool {
readonly name = "search_files"
constructor(private validator: ToolValidator) {}
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.regex}'${
block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
}]`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const relPath = block.params.path
const regex = block.params.regex
// Early return if we don't have enough data yet
if (!relPath || !regex) {
return
}
// Get config access for services
const config = uiHelpers.getConfig()
// Create and show partial UI message
const filePattern = block.params.file_pattern
const searchDescription = `'${regex}'${filePattern ? ` in '${filePattern}'` : ""}`
const sharedMessageProps = {
tool: "searchFiles",
path: getReadablePath(config.cwd, uiHelpers.removeClosingTag(block, "path", relPath)),
content: `Searching for ${searchDescription}`,
regex: uiHelpers.removeClosingTag(block, "regex", regex),
filePattern: filePattern ? uiHelpers.removeClosingTag(block, "file_pattern", filePattern) : undefined,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
const partialMessage = JSON.stringify(sharedMessageProps)
// Handle auto-approval vs manual approval for partial
if (await uiHelpers.shouldAutoApproveToolWithPath(block.name, relPath)) {
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "tool")
await uiHelpers.say("tool", partialMessage, undefined, undefined, block.partial)
} else {
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "tool")
await uiHelpers.ask("tool", partialMessage, block.partial).catch(() => {})
}
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, return empty string to let coordinator handle UI
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!)
// Handle approval flow
const searchDescription = `'${regex}'${filePattern ? ` in '${filePattern}'` : ""}`
const sharedMessageProps = {
tool: "searchFiles",
path: getReadablePath(config.cwd, relDirPath!),
content: `Searching for ${searchDescription}`,
regex: regex,
filePattern: filePattern,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relDirPath!),
}
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relDirPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to search files for ${regex}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
notificationMessage,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
// Ask for approval
const { response } = await config.callbacks.ask("tool", completeMessage, false)
if (response !== "yesButtonClicked") {
// Handle rejection
config.taskState.didRejectTool = true
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
return "The user denied this operation."
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
}
}
// Execute the actual regex search operation
const results = await regexSearchFiles(
config.cwd,
absolutePath,
regex,
filePattern,
config.services.clineIgnoreController,
)
return results
}
}
@@ -0,0 +1,103 @@
import type { ToolUse } from "@core/assistant-message"
import { continuationPrompt } from "@core/prompts/contextManagement"
import { formatResponse } from "@core/prompts/responses"
import { ensureTaskDirectoryExists } from "@core/storage/disk"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { ClineSayTool } from "@shared/ExtensionMessage"
import type { ToolResponse } from "../../index"
import type { IPartialBlockHandler, IToolHandler } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class SummarizeTaskHandler implements IToolHandler, IPartialBlockHandler {
readonly name = "summarize_task"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name}]`
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
try {
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
const completeMessage = JSON.stringify({
tool: "summarizeTask",
content: context,
} satisfies ClineSayTool)
await config.callbacks.say("tool", completeMessage, 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),
apiConversationHistory,
)
// Set summarizing state
config.taskState.currentlySummarizing = true
// Capture telemetry after main business logic is complete
const telemetryData = config.services.contextManager.getContextTelemetryData(
config.messageState.getClineMessages(),
config.api,
config.taskState.lastAutoCompactTriggerIndex,
)
if (telemetryData) {
telemetryService.captureSummarizeTask(
config.ulid,
config.api.getModel().id,
telemetryData.tokensUsed,
telemetryData.maxContextWindow,
)
}
return toolResult
} catch (error) {
return `Error summarizing context window: ${(error as Error).message}`
}
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const context = block.params.context || ""
// Show streaming summary generation in tool UI
const partialMessage = JSON.stringify({
tool: "summarizeTask",
content: uiHelpers.removeClosingTag(block, "context", context),
} satisfies ClineSayTool)
await uiHelpers.say("tool", partialMessage, undefined, undefined, block.partial)
}
}
@@ -0,0 +1,193 @@
import type { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { ClineAsk, ClineAskUseMcpServer } from "@shared/ExtensionMessage"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class UseMcpToolHandler implements IFullyManagedTool {
readonly name = "use_mcp_tool"
constructor() {}
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.server_name}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const server_name = block.params.server_name
const tool_name = block.params.tool_name
const mcp_arguments = block.params.arguments
const partialMessage = JSON.stringify({
type: "use_mcp_tool",
serverName: uiHelpers.removeClosingTag(block, "server_name", server_name),
toolName: uiHelpers.removeClosingTag(block, "tool_name", tool_name),
arguments: uiHelpers.removeClosingTag(block, "arguments", mcp_arguments),
} satisfies ClineAskUseMcpServer)
// Check if tool should be auto-approved using MCP-specific logic
const config = uiHelpers.getConfig()
const shouldAutoApprove = this.shouldAutoApproveMcpTool(config, server_name || "", tool_name || "")
if (shouldAutoApprove) {
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
await uiHelpers.say("use_mcp_server" as any, partialMessage, undefined, undefined, block.partial)
} else {
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
await uiHelpers.ask("use_mcp_server" as ClineAsk, partialMessage, block.partial).catch(() => {})
}
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, return empty string to let coordinator handle UI
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
// Handle approval flow
const completeMessage = JSON.stringify({
type: "use_mcp_tool",
serverName: server_name,
toolName: tool_name,
uri: undefined,
arguments: mcp_arguments,
})
const shouldAutoApprove = this.shouldAutoApproveMcpTool(config, server_name, tool_name)
if (shouldAutoApprove) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "use_mcp_server")
await config.callbacks.say("use_mcp_server", completeMessage, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
} else {
// Manual approval flow
const notificationMessage = `Cline wants to use ${tool_name || "unknown tool"} on ${server_name || "unknown server"}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
notificationMessage,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "use_mcp_server")
// Ask for approval
const { response } = await config.callbacks.ask("use_mcp_server", completeMessage, false)
if (response !== "yesButtonClicked") {
// Handle rejection
config.taskState.didRejectTool = true
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
return "The user denied this operation."
} else {
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
}
}
// Show MCP request started message
await config.callbacks.say("mcp_server_request_started")
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, config.ulid)
// 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: _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}`
}
}
/**
* Determine if MCP tool should be auto-approved (moved from ToolApprovalManager)
*/
private shouldAutoApproveMcpTool(config: TaskConfig, server_name: string, tool_name: string): boolean {
// Check if this specific tool is auto-approved on the server
const isToolAutoApproved = config.services.mcpHub.connections
?.find((conn: any) => conn.server.name === server_name)
?.server.tools?.find((tool: any) => tool.name === tool_name)?.autoApprove
return config.autoApprovalSettings.enabled && (isToolAutoApproved ?? false)
}
}
@@ -0,0 +1,111 @@
import { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { ClineAsk, ClineSayTool } from "@shared/ExtensionMessage"
import { ToolUse, ToolUseName } from "../../../assistant-message"
import { formatResponse } from "../../../prompts/responses"
import { ToolResponse } from "../.."
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
export class WebFetchToolHandler implements IFullyManagedTool {
name = "web_fetch"
supportedTools: ToolUseName[] = ["web_fetch"]
getDescription(block: ToolUse): string {
return `[${block.name} for '${block.params.url}']`
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const url = block.params.url || ""
const sharedMessageProps: ClineSayTool = {
tool: "webFetch",
path: uiHelpers.removeClosingTag(block, "url", url),
content: `Fetching URL: ${uiHelpers.removeClosingTag(block, "url", url)}`,
operationIsLocatedInWorkspace: false, // web_fetch is always external
}
const partialMessage = JSON.stringify(sharedMessageProps)
// For partial blocks, we'll let the ToolExecutor handle auto-approval logic
// Just stream the UI update for now
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "tool")
await uiHelpers.ask("tool" as ClineAsk, partialMessage, block.partial).catch(() => {})
}
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
// For partial blocks, don't execute yet
if (block.partial) {
return ""
}
try {
const url: string | undefined = block.params.url
// Validate required parameter
if (!url) {
config.taskState.consecutiveMistakeCount++
return await config.callbacks.sayAndCreateMissingParamError("web_fetch", "url")
}
config.taskState.consecutiveMistakeCount = 0
// Create message for approval
const sharedMessageProps: ClineSayTool = {
tool: "webFetch",
path: url,
content: `Fetching URL: ${url}`,
operationIsLocatedInWorkspace: false,
}
const completeMessage = JSON.stringify(sharedMessageProps)
// Check auto-approval (web_fetch uses simple boolean, not array)
const autoApprove = config.autoApprovalSettings.enabled && config.autoApprovalSettings.actions.useBrowser
if (autoApprove) {
// Auto-approve flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
telemetryService.captureToolUsage(config.ulid, "web_fetch", config.api.getModel().id, true, true)
} else {
// Manual approval flow
showNotificationForApprovalIfAutoApprovalEnabled(
`Cline wants to fetch content from ${url}`,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
const { response } = await config.callbacks.ask("tool", completeMessage, false)
if (response !== "yesButtonClicked") {
telemetryService.captureToolUsage(config.ulid, "web_fetch", config.api.getModel().id, false, false)
return "The user denied this operation."
}
telemetryService.captureToolUsage(config.ulid, "web_fetch", config.api.getModel().id, false, true)
}
// Execute the actual fetch
const urlContentFetcher = config.services?.urlContentFetcher as UrlContentFetcher
await urlContentFetcher.launchBrowser()
try {
// Fetch Markdown content
const markdownContent = await urlContentFetcher.urlToMarkdown(url)
// 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)
} finally {
// Ensure browser is closed even on error
await urlContentFetcher.closeBrowser()
}
} catch (error) {
return `Error fetching web content: ${(error as Error).message}`
}
}
}
@@ -0,0 +1,464 @@
import { setTimeout as setTimeoutPromise } from "node:timers/promises"
import type { ToolUse } from "@core/assistant-message"
import { constructNewFileContent } from "@core/assistant-message/diff"
import { formatResponse } from "@core/prompts/responses"
import { processFilesIntoText } from "@integrations/misc/extract-text"
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import { ClineSayTool } from "@shared/ExtensionMessage"
import { fileExistsAtPath } from "@utils/fs"
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
import { fixModelHtmlEscaping, removeInvalidChars } from "@utils/string"
import * as path from "path"
import type { ToolResponse } from "../../index"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
import type { ToolValidator } from "../ToolValidator"
import type { TaskConfig } from "../types/TaskConfig"
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
import { ToolDisplayUtils } from "../utils/ToolDisplayUtils"
import { ToolResultUtils } from "../utils/ToolResultUtils"
export class WriteToFileToolHandler implements IFullyManagedTool {
readonly name = "write_to_file" // This handler supports write_to_file, replace_in_file, and new_rule
constructor(private validator: ToolValidator) {}
getDescription(block: ToolUse): string {
switch (block.name) {
case "write_to_file":
return `[${block.name} for '${block.params.path}']`
case "replace_in_file":
return `[${block.name} for '${block.params.path}']`
case "new_rule":
return `[${block.name} for '${block.params.path}']`
default:
return `[${block.name} for '${block.params.path}']`
}
}
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
const relPath = block.params.path
const content = block.params.content // for write_to_file
let diff = block.params.diff // for replace_in_file
// Early return if we don't have enough data yet
if (!relPath || (!content && !diff)) {
// Wait until we have the path and either content or diff
return
}
// Get config access for services
const config = uiHelpers.getConfig()
// Check clineignore access first
const accessValidation = this.validator.checkClineIgnorePath(relPath)
if (!accessValidation.ok) {
// Show error and return early (full original behavior)
await uiHelpers.say("clineignore_error", relPath)
// Push tool result and save checkpoint using existing utilities
const errorResponse = formatResponse.toolError(formatResponse.clineIgnoreError(relPath))
ToolResultUtils.pushToolResult(
errorResponse,
block,
config.taskState.userMessageContent,
ToolDisplayUtils.getToolDescription,
config.api,
() => {
config.taskState.didAlreadyUseTool = true
},
config.coordinator,
)
await config.callbacks.saveCheckpoint()
return
}
// Check if file exists to determine the correct UI message
let fileExists: boolean
if (config.services.diffViewProvider.editType !== undefined) {
fileExists = config.services.diffViewProvider.editType === "modify"
} else {
const absolutePath = path.resolve(config.cwd, relPath)
fileExists = await fileExistsAtPath(absolutePath)
config.services.diffViewProvider.editType = fileExists ? "modify" : "create"
}
// Create and show partial UI message
const sharedMessageProps: ClineSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(config.cwd, uiHelpers.removeClosingTag(block, "path", relPath)),
content: uiHelpers.removeClosingTag(block, block.name === "replace_in_file" ? "diff" : "content", content || diff),
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
const partialMessage = JSON.stringify(sharedMessageProps)
// Handle auto-approval vs manual approval for partial
if (await uiHelpers.shouldAutoApproveToolWithPath(block.name, relPath)) {
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "tool")
await uiHelpers.say("tool", partialMessage, undefined, undefined, block.partial)
} else {
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "tool")
await uiHelpers.ask("tool", partialMessage, block.partial).catch(() => {})
}
// CRITICAL: Add the missing real-time diff view streaming logic from original code
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 - CRITICAL for real-time streaming
if (!config.services.diffViewProvider.isEditing) {
await config.services.diffViewProvider.open(relPath)
}
try {
newContent = await constructNewFileContent(
diff,
config.services.diffViewProvider.originalContent || "",
!block.partial, // Pass the partial flag correctly
)
} catch (error) {
// Full original behavior - comprehensive error handling even for partial blocks
await config.callbacks.say("diff_error", relPath)
// Extract error type from error message if possible
const errorType =
error instanceof Error && error.message.includes("does not match anything")
? "search_not_found"
: "other_diff_error"
// Add telemetry for diff edit failure
telemetryService.captureDiffEditFailure(config.ulid, config.api.getModel().id, errorType)
// Push tool result with detailed error using existing utilities
const errorResponse = formatResponse.toolError(
`${(error as Error)?.message}\n\n` +
formatResponse.diffError(relPath, config.services.diffViewProvider.originalContent),
)
ToolResultUtils.pushToolResult(
errorResponse,
block,
config.taskState.userMessageContent,
ToolDisplayUtils.getToolDescription,
config.api,
() => {
config.taskState.didAlreadyUseTool = true
},
config.coordinator,
)
// Revert changes and reset diff view
await config.services.diffViewProvider.revertChanges()
await config.services.diffViewProvider.reset()
// Save checkpoint after error
await config.callbacks.saveCheckpoint()
return
}
} else if (content) {
// Handle write_to_file 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)
}
}
// CRITICAL: Open editor and stream content in real-time (from original code)
if (!config.services.diffViewProvider.isEditing) {
// Open the editor and prepare to stream content in
await config.services.diffViewProvider.open(relPath)
}
// Editor is open, stream content in real-time (false = don't finalize yet)
await config.services.diffViewProvider.update(newContent, false)
} catch (error) {
// For partial blocks, we'll silently handle errors and wait for more content
// The complete block handler will handle actual errors
if (!block.partial) {
console.error("Error in partial write tool block:", error)
}
}
}
async execute(config: TaskConfig, 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
const 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) {
await config.callbacks.say("clineignore_error", relPath)
return formatResponse.toolError(formatResponse.clineIgnoreError(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"
}
// Handle approval flow
const sharedMessageProps: ClineSayTool = {
tool: fileExists ? "editedExistingFile" : "newFileCreated",
path: getReadablePath(config.cwd, relPath),
content: diff || content,
operationIsLocatedInWorkspace: await isLocatedInWorkspace(relPath),
}
const completeMessage = JSON.stringify(sharedMessageProps)
if (await config.callbacks.shouldAutoApproveToolWithPath(block.name, relPath)) {
// Auto-approval flow
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
config.taskState.consecutiveAutoApprovedRequestsCount++
// Capture telemetry
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, true, true)
// Add diagnostic delay
await setTimeoutPromise(3_500)
} else {
// Manual approval flow with detailed feedback handling
const notificationMessage = `Cline wants to ${fileExists ? "edit" : "create"} ${path.basename(relPath)}`
// Show notification
showNotificationForApprovalIfAutoApprovalEnabled(
notificationMessage,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
await config.callbacks.removeLastPartialMessageIfExistsWithType("say", "tool")
// Ask for approval with full feedback handling
const { response, text, images, files } = await config.callbacks.ask("tool", completeMessage, false)
if (response !== "yesButtonClicked") {
// Handle rejection with detailed messages
const fileDeniedNote = fileExists
? "The file was not updated, and maintains its original contents."
: "The file was not created."
// Process user feedback if provided (with file content processing)
if (text || (images && images.length > 0) || (files && files.length > 0)) {
let fileContentString = ""
if (files && files.length > 0) {
fileContentString = await processFilesIntoText(files)
}
// Push additional tool feedback using existing utilities
ToolResultUtils.pushAdditionalToolFeedback(
config.taskState.userMessageContent,
text,
images,
fileContentString,
)
await config.callbacks.say("user_feedback", text, images, files)
await config.callbacks.saveCheckpoint()
}
// Clean up the diff view when operation is rejected
await config.services.diffViewProvider.revertChanges()
await config.services.diffViewProvider.reset()
config.taskState.didRejectTool = true
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, false)
return `The user denied this operation. ${fileDeniedNote}`
} else {
// Handle approval feedback if provided (with file content processing)
if (text || (images && images.length > 0) || (files && files.length > 0)) {
let fileContentString = ""
if (files && files.length > 0) {
fileContentString = await processFilesIntoText(files)
}
// Push additional tool feedback using existing utilities
ToolResultUtils.pushAdditionalToolFeedback(
config.taskState.userMessageContent,
text,
images,
fileContentString,
)
await config.callbacks.say("user_feedback", text, images, files)
await config.callbacks.saveCheckpoint()
}
telemetryService.captureToolUsage(config.ulid, block.name, config.api.getModel().id, false, true)
}
}
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) {
// Show diff error UI message (from original implementation)
await config.callbacks.say("diff_error", relPath)
// Extract error type from error message if possible
const errorType =
error instanceof Error && error.message.includes("does not match anything")
? "search_not_found"
: "other_diff_error"
// Add telemetry for diff edit failure
telemetryService.captureDiffEditFailure(config.ulid, config.api.getModel().id, errorType)
await config.services.diffViewProvider.revertChanges()
await config.services.diffViewProvider.reset()
// Save checkpoint after error (from original implementation)
await config.callbacks.saveCheckpoint()
// Return detailed error with original content for context
return formatResponse.toolError(
`${(error as Error)?.message}\n\n` +
formatResponse.diffError(relPath, config.services.diffViewProvider.originalContent),
)
}
} 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()
// CRITICAL: Handle the UI animation logic from original code
// "it's important to note how this function works, you can't make the assumption that the block.partial conditional will always be called since it may immediately get complete, non-partial data. So this part of the logic will always be called.
// in other words, you must always repeat the block.partial logic here"
if (!config.services.diffViewProvider.isEditing) {
// Show GUI message before showing edit animation
const partialMessage = JSON.stringify(sharedMessageProps)
await config.callbacks.ask("tool", partialMessage, true).catch(() => {}) // sending true for partial even though it's not a partial, this shows the edit row before the content is streamed into the editor
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}`
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import type { ApiHandler } from "@core/api"
import type { FileContextTracker } from "@core/context/context-tracking/FileContextTracker"
import type { ClineIgnoreController } from "@core/ignore/ClineIgnoreController"
import type { DiffViewProvider } from "@integrations/editor/DiffViewProvider"
import type { BrowserSession } from "@services/browser/BrowserSession"
import type { UrlContentFetcher } from "@services/browser/UrlContentFetcher"
import type { McpHub } from "@services/mcp/McpHub"
import type { AutoApprovalSettings } from "@shared/AutoApprovalSettings"
import type { BrowserSettings } from "@shared/BrowserSettings"
import type { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import type { FocusChainSettings } from "@shared/FocusChainSettings"
import type { Mode } from "@shared/storage/types"
import type { ClineAskResponse } from "@shared/WebviewMessage"
import * as vscode from "vscode"
import type { ToolUseName } from "../../../assistant-message"
import type { ContextManager } from "../../../context/context-management/ContextManager"
import type { StateManager } from "../../../storage/StateManager"
import type { MessageStateHandler } from "../../message-state"
import type { TaskState } from "../../TaskState"
import type { AutoApprove } from "../../tools/autoApprove"
import type { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
import { TASK_CALLBACKS_KEYS, TASK_CONFIG_KEYS, TASK_SERVICES_KEYS } from "../utils/ToolConstants"
/**
* Strongly-typed configuration object passed to tool handlers
*/
export interface TaskConfig {
// Core identifiers
taskId: string
ulid: string
cwd: string
mode: Mode
strictPlanModeEnabled: boolean
context: vscode.ExtensionContext
// State management
taskState: TaskState
messageState: MessageStateHandler
// API and services
api: ApiHandler
services: TaskServices
// Settings
autoApprovalSettings: AutoApprovalSettings
autoApprover: AutoApprove
browserSettings: BrowserSettings
focusChainSettings: FocusChainSettings
// Callbacks (strongly typed)
callbacks: TaskCallbacks
// Tool coordination
coordinator: ToolExecutorCoordinator
}
/**
* All services available to tool handlers
*/
export interface TaskServices {
mcpHub: McpHub
browserSession: BrowserSession
urlContentFetcher: UrlContentFetcher
diffViewProvider: DiffViewProvider
fileContextTracker: FileContextTracker
clineIgnoreController: ClineIgnoreController
contextManager: ContextManager
stateManager: StateManager
}
/**
* All callback functions available to tool handlers
*/
export interface TaskCallbacks {
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[]
}>
saveCheckpoint: (isAttemptCompletionMessage?: boolean, completionMessageTs?: number) => Promise<void>
sayAndCreateMissingParamError: (toolName: ToolUseName, paramName: string, relPath?: string) => Promise<any>
removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>
executeCommandTool: (command: string) => Promise<[boolean, any]>
doesLatestTaskCompletionHaveNewChanges: () => Promise<boolean>
updateFCListFromToolResponse: (taskProgress: string | undefined) => Promise<void>
shouldAutoApproveToolWithPath: (toolName: ToolUseName, path?: string) => Promise<boolean>
// Additional callbacks for task management
postStateToWebview: () => Promise<void>
reinitExistingTaskFromId: (taskId: string) => Promise<void>
cancelTask: () => Promise<void>
updateTaskHistory: (update: any) => Promise<any[]>
}
/**
* Runtime validation function to ensure config has all required properties
* Automatically derives expected keys from the interface definitions
*/
export function validateTaskConfig(config: any): asserts config is TaskConfig {
if (!config) {
throw new Error("TaskConfig is null or undefined")
}
// Validate all expected keys exist
for (const key of TASK_CONFIG_KEYS) {
if (!(key in config)) {
throw new Error(`Missing ${key} in TaskConfig`)
}
}
// Special validation for boolean type
if (typeof config.strictPlanModeEnabled !== "boolean") {
throw new Error("strictPlanModeEnabled must be a boolean in TaskConfig")
}
// Validate services object
if (config.services) {
for (const key of TASK_SERVICES_KEYS) {
if (!(key in config.services)) {
throw new Error(`Missing services.${key} in TaskConfig`)
}
}
}
// Validate callbacks object
if (config.callbacks) {
for (const key of TASK_CALLBACKS_KEYS) {
if (typeof config.callbacks[key] !== "function") {
throw new Error(`Missing or invalid callbacks.${key} in TaskConfig (must be a function)`)
}
}
}
}
+71
View File
@@ -0,0 +1,71 @@
import { telemetryService } from "@services/posthog/PostHogClientProvider"
import type { ClineAsk, ClineSay } from "@shared/ExtensionMessage"
import type { ClineAskResponse } from "@shared/WebviewMessage"
import type { ToolParamName, ToolUse, ToolUseName } from "../../../assistant-message"
import { showNotificationForApprovalIfAutoApprovalEnabled } from "../../utils"
import { removeClosingTag } from "../utils/ToolConstants"
import type { TaskConfig } from "./TaskConfig"
/**
* Strongly-typed UI helper functions for tool handlers
*/
export interface StronglyTypedUIHelpers {
// Core UI methods
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[]
}>
// Utility methods
removeClosingTag: (block: ToolUse, tag: ToolParamName, text?: string) => string
removeLastPartialMessageIfExistsWithType: (type: "ask" | "say", askOrSay: ClineAsk | ClineSay) => Promise<void>
// Approval methods
shouldAutoApproveTool: (toolName: ToolUseName) => boolean | [boolean, boolean]
shouldAutoApproveToolWithPath: (toolName: ToolUseName, path?: string) => Promise<boolean>
askApproval: (messageType: ClineAsk, message: string) => Promise<boolean>
// Telemetry and notifications
captureTelemetry: (toolName: ToolUseName, autoApproved: boolean, approved: boolean) => void
showNotificationIfEnabled: (message: string) => void
// Config access - returns the proper typed config
getConfig: () => TaskConfig
}
/**
* Creates strongly-typed UI helpers from a TaskConfig
*/
export function createUIHelpers(config: TaskConfig): StronglyTypedUIHelpers {
return {
say: config.callbacks.say,
ask: config.callbacks.ask,
removeClosingTag: (block: ToolUse, tag: ToolParamName, text?: string) => removeClosingTag(block, tag, text),
removeLastPartialMessageIfExistsWithType: config.callbacks.removeLastPartialMessageIfExistsWithType,
shouldAutoApproveTool: (toolName: ToolUseName) => config.autoApprover.shouldAutoApproveTool(toolName),
shouldAutoApproveToolWithPath: config.callbacks.shouldAutoApproveToolWithPath,
askApproval: async (messageType: ClineAsk, message: string): Promise<boolean> => {
const { response } = await config.callbacks.ask(messageType, message, false)
return response === "yesButtonClicked"
},
captureTelemetry: (toolName: ToolUseName, autoApproved: boolean, approved: boolean) => {
telemetryService.captureToolUsage(config.ulid, toolName, config.api.getModel().id, autoApproved, approved)
},
showNotificationIfEnabled: (message: string) => {
showNotificationForApprovalIfAutoApprovalEnabled(
message,
config.autoApprovalSettings.enabled,
config.autoApprovalSettings.enableNotifications,
)
},
getConfig: () => config,
}
}
+127
View File
@@ -0,0 +1,127 @@
import type { ToolParamName, ToolUse } from "@core/assistant-message"
/**
* Shared constants for tool validation and configuration
* This file serves as a single source of truth for tool-related constants
*/
/**
* Expected keys for TaskConfig interface validation
* Keep this in sync with the TaskConfig interface
*/
export const TASK_CONFIG_KEYS = [
"taskId",
"ulid",
"cwd",
"mode",
"strictPlanModeEnabled",
"context",
"taskState",
"messageState",
"api",
"services",
"autoApprovalSettings",
"autoApprover",
"browserSettings",
"focusChainSettings",
"callbacks",
"coordinator",
] as const
/**
* Expected keys for TaskServices interface validation
* Keep this in sync with the TaskServices interface
*/
export const TASK_SERVICES_KEYS = [
"mcpHub",
"browserSession",
"urlContentFetcher",
"diffViewProvider",
"fileContextTracker",
"clineIgnoreController",
"contextManager",
"stateManager",
] as const
/**
* Expected keys for TaskCallbacks interface validation
* Keep this in sync with the TaskCallbacks interface
*/
export const TASK_CALLBACKS_KEYS = [
"say",
"ask",
"saveCheckpoint",
"sayAndCreateMissingParamError",
"removeLastPartialMessageIfExistsWithType",
"executeCommandTool",
"doesLatestTaskCompletionHaveNewChanges",
"updateFCListFromToolResponse",
"shouldAutoApproveToolWithPath",
"postStateToWebview",
"reinitExistingTaskFromId",
"cancelTask",
"updateTaskHistory",
] as const
/**
* Tools that require a path parameter
* Used for validation in ToolErrorHandler
*/
export const PATH_REQUIRED_TOOLS = [
"read_file",
"write_to_file",
"replace_in_file",
"new_rule",
"list_files",
"list_code_definition_names",
"search_files",
] as const
/**
* Browser action types for validation
*/
export const BROWSER_ACTIONS = ["launch", "click", "type", "scroll_down", "scroll_up", "close"] as const
/**
* Common validation error patterns
*/
export const VALIDATION_ERROR_PATTERNS = ["Missing required parameter", "blocked by .clineignore"] as const
/**
* Type helpers for better type safety
*/
export type TaskConfigKey = (typeof TASK_CONFIG_KEYS)[number]
export type TaskServicesKey = (typeof TASK_SERVICES_KEYS)[number]
export type TaskCallbacksKey = (typeof TASK_CALLBACKS_KEYS)[number]
export type PathRequiredTool = (typeof PATH_REQUIRED_TOOLS)[number]
export type BrowserAction = (typeof BROWSER_ACTIONS)[number]
/**
* Shared utility functions for tools
*/
/**
* Remove partial closing tag from tool parameter text
* If block is partial, remove partial closing tag so it's not presented to user
*
* 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
*/
export function removeClosingTag(block: ToolUse, tag: ToolParamName, text?: string): string {
if (!block.partial) {
return text || ""
}
if (!text) {
return ""
}
const tagRegex = new RegExp(
`\\s?<\/?${tag
.split("")
.map((char) => `(?:${char})?`)
.join("")}$`,
"g",
)
return text.replace(tagRegex, "")
}
@@ -0,0 +1,33 @@
import { ToolParamName, ToolUse } from "@core/assistant-message"
import type { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
import { removeClosingTag } from "./ToolConstants"
/**
* Utility functions for tool display and formatting
*/
export class ToolDisplayUtils {
/**
* Generate a descriptive string for a tool execution
* @param block - The tool use block
* @param coordinator - Optional tool coordinator to get description from tool handler
*/
static getToolDescription(block: ToolUse, coordinator?: ToolExecutorCoordinator): string {
// Try to get description from the tool handler first
if (coordinator) {
const handler = coordinator.getHandler(block.name)
if (handler) {
return handler.getDescription(block)
}
}
return `[${block.name}]`
}
/**
* 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 {
return removeClosingTag(block, tag, text)
}
}
@@ -0,0 +1,76 @@
import { ApiHandler } from "@core/api"
import { ToolUse } from "@core/assistant-message"
import { formatResponse } from "@core/prompts/responses"
import { ToolResponse } from "@core/task"
import type { ToolExecutorCoordinator } from "../ToolExecutorCoordinator"
/**
* 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,
coordinator?: ToolExecutorCoordinator,
): void {
if (typeof content === "string") {
const resultText = content || "(tool did not return anything)"
// Try to get description from coordinator first, otherwise use the provided function
const description = coordinator
? (() => {
const handler = coordinator.getHandler(block.name)
return handler ? handler.getDescription(block) : toolDescription(block)
})()
: toolDescription(block)
// Non-Claude 4: Use traditional format with header
userMessageContent.push({
type: "text",
text: `${description} 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)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./ToolConstants"
export { ToolDisplayUtils } from "./ToolDisplayUtils"
export { ToolResultUtils } from "./ToolResultUtils"
+1 -35
View File
@@ -4,7 +4,7 @@ import { BrowserSettings } from "@shared/BrowserSettings"
import { BrowserActionResult } from "@shared/ExtensionMessage"
import { fileExistsAtPath } from "@utils/fs"
import axios from "axios"
import { exec, spawn } from "child_process"
import { spawn } from "child_process"
import * as chromeLauncher from "chrome-launcher"
import * as fs from "fs/promises"
import os from "os"
@@ -371,40 +371,6 @@ export class BrowserSession {
)
}
/**
* Kill all Chrome instances, including those not launched by chrome-launcher
*/
private async killAllChromeBrowsers(): Promise<void> {
// First try chrome-launcher's killAll to handle instances it launched
try {
await chromeLauncher.killAll()
} catch (err: unknown) {
console.log("Error in chrome-launcher killAll:", err)
}
// Then kill other Chrome instances using platform-specific commands
try {
if (process.platform === "win32") {
// Windows: Use taskkill to forcefully terminate Chrome processes
await new Promise<void>((resolve, reject) => {
exec("taskkill /F /IM chrome.exe /T", () => resolve())
})
} else if (process.platform === "darwin") {
// macOS: Use pkill to terminate Chrome processes
await new Promise<void>((resolve) => {
exec('pkill -x "Google Chrome"', () => resolve())
})
} else {
// Linux: Use pkill for Chrome and chromium
await new Promise<void>((resolve) => {
exec('pkill -f "chrome|chromium"', () => resolve())
})
}
} catch (error) {
console.error("Error killing Chrome processes:", error)
}
}
async closeBrowser(): Promise<BrowserActionResult> {
if (this.browser || this.page) {
// Send telemetry for browser tool end if we have a task ID and session was started
-1
View File
@@ -1,6 +1,5 @@
import os from "os"
import * as path from "path"
import * as vscode from "vscode"
import { HostProvider } from "@/hosts/host-provider"
/*
@@ -1,6 +1,5 @@
import { geminiModels, ModelInfo } from "@shared/api"
import { Fragment, useState } from "react"
import { useExtensionState } from "@/context/ExtensionStateContext"
import { ModelDescriptionMarkdown } from "../OpenRouterModelPicker"
import {
formatPrice,
@@ -11,7 +10,6 @@ import {
supportsImages,
supportsPromptCache,
} from "../utils/pricingUtils"
import { normalizeApiConfiguration } from "../utils/providerUtils"
/**
* Returns an array of formatted tier strings
@@ -476,12 +476,16 @@ export async function syncModeConfigurations(
sourceMode: Mode,
handleFieldsChange: (updates: Partial<ApiConfiguration>) => Promise<void>,
): Promise<void> {
if (!apiConfiguration) return
if (!apiConfiguration) {
return
}
const sourceFields = getModeSpecificFields(apiConfiguration, sourceMode)
const { apiProvider } = sourceFields
if (!apiProvider) return
if (!apiProvider) {
return
}
// Build the complete update object with both plan and act mode fields
const updates: Partial<ApiConfiguration> = {