mirror of
https://github.com/cline/cline.git
synced 2026-09-09 23:29:54 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74f8b16210 | ||
|
|
03eebea803 | ||
|
|
bb4d29755e | ||
|
|
cf9e68dbbe |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
REfactoring Tool Executor
|
||||
+244
-2280
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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./ToolConstants"
|
||||
export { ToolDisplayUtils } from "./ToolDisplayUtils"
|
||||
export { ToolResultUtils } from "./ToolResultUtils"
|
||||
@@ -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,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> = {
|
||||
|
||||
Reference in New Issue
Block a user