diff --git a/src/core/Cline.ts b/src/core/Cline.ts index e7b2694c7d..f7a723f336 100644 --- a/src/core/Cline.ts +++ b/src/core/Cline.ts @@ -95,6 +95,10 @@ import { browserActionTool } from "./tools/browserActionTool" import { executeCommandTool } from "./tools/executeCommandTool" import { useMcpToolTool } from "./tools/useMcpToolTool" import { accessMcpResourceTool } from "./tools/accessMcpResourceTool" +import { askFollowupQuestionTool } from "./tools/askFollowupQuestionTool" +import { switchModeTool } from "./tools/switchModeTool" +import { attemptCompletionTool } from "./tools/attemptCompletionTool" +import { newTaskTool } from "./tools/newTaskTool" export type ToolResponse = string | Array type UserContent = Array @@ -137,8 +141,8 @@ export class Cline extends EventEmitter { readonly rootTask: Cline | undefined = undefined readonly parentTask: Cline | undefined = undefined readonly taskNumber: number - private isPaused: boolean = false - private pausedModeSlug: string = defaultModeSlug + isPaused: boolean = false + pausedModeSlug: string = defaultModeSlug private pauseInterval: NodeJS.Timeout | undefined readonly apiConfiguration: ApiConfiguration @@ -182,7 +186,7 @@ export class Cline extends EventEmitter { private assistantMessageContent: AssistantMessageContent[] = [] private presentAssistantMessageLocked = false private presentAssistantMessageHasPendingUpdates = false - private userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] + userMessageContent: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[] = [] private userMessageContentReady = false didRejectTool = false private didAlreadyUseTool = false @@ -379,7 +383,7 @@ export class Cline extends EventEmitter { this.emit("message", { action: "updated", message: partialMessage }) } - private getTokenUsage() { + getTokenUsage() { const usage = getApiMetrics(combineApiRequests(combineCommandSequences(this.clineMessages.slice(1)))) this.emit("taskTokenUsageUpdated", this.taskId, usage) return usage @@ -1647,363 +1651,38 @@ export class Cline extends EventEmitter { break } case "ask_followup_question": { - const question: string | undefined = block.params.question - const follow_up: string | undefined = block.params.follow_up - try { - if (block.partial) { - await this.ask("followup", removeClosingTag("question", question), block.partial).catch( - () => {}, - ) - break - } else { - if (!question) { - this.consecutiveMistakeCount++ - pushToolResult( - await this.sayAndCreateMissingParamError("ask_followup_question", "question"), - ) - break - } - - type Suggest = { - answer: string - } - - let follow_up_json = { - question, - suggest: [] as Suggest[], - } - - if (follow_up) { - let parsedSuggest: { - suggest: Suggest[] | Suggest - } - - try { - parsedSuggest = parseXml(follow_up, ["suggest"]) as { - suggest: Suggest[] | Suggest - } - } catch (error) { - this.consecutiveMistakeCount++ - await this.say("error", `Failed to parse operations: ${error.message}`) - pushToolResult(formatResponse.toolError("Invalid operations xml format")) - break - } - - const normalizedSuggest = Array.isArray(parsedSuggest?.suggest) - ? parsedSuggest.suggest - : [parsedSuggest?.suggest].filter((sug): sug is Suggest => sug !== undefined) - - follow_up_json.suggest = normalizedSuggest - } - - this.consecutiveMistakeCount = 0 - - const { text, images } = await this.ask( - "followup", - JSON.stringify(follow_up_json), - false, - ) - await this.say("user_feedback", text ?? "", images) - pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) - break - } - } catch (error) { - await handleError("asking question", error) - break - } + await askFollowupQuestionTool( + this, + block, + askApproval, + handleError, + pushToolResult, + removeClosingTag, + ) + break } case "switch_mode": { - const mode_slug: string | undefined = block.params.mode_slug - const reason: string | undefined = block.params.reason - try { - if (block.partial) { - const partialMessage = JSON.stringify({ - tool: "switchMode", - mode: removeClosingTag("mode_slug", mode_slug), - reason: removeClosingTag("reason", reason), - }) - await this.ask("tool", partialMessage, block.partial).catch(() => {}) - break - } else { - if (!mode_slug) { - this.consecutiveMistakeCount++ - pushToolResult(await this.sayAndCreateMissingParamError("switch_mode", "mode_slug")) - break - } - this.consecutiveMistakeCount = 0 - - // Verify the mode exists - const targetMode = getModeBySlug( - mode_slug, - (await this.providerRef.deref()?.getState())?.customModes, - ) - if (!targetMode) { - pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`)) - break - } - - // Check if already in requested mode - const currentMode = - (await this.providerRef.deref()?.getState())?.mode ?? defaultModeSlug - if (currentMode === mode_slug) { - pushToolResult(`Already in ${targetMode.name} mode.`) - break - } - - const completeMessage = JSON.stringify({ - tool: "switchMode", - mode: mode_slug, - reason, - }) - - const didApprove = await askApproval("tool", completeMessage) - if (!didApprove) { - break - } - - // Switch the mode using shared handler - await this.providerRef.deref()?.handleModeSwitch(mode_slug) - pushToolResult( - `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ - targetMode.name - } mode${reason ? ` because: ${reason}` : ""}.`, - ) - await delay(500) // delay to allow mode change to take effect before next tool is executed - break - } - } catch (error) { - await handleError("switching mode", error) - break - } + await switchModeTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag) + break } case "new_task": { - const mode: string | undefined = block.params.mode - const message: string | undefined = block.params.message - try { - if (block.partial) { - const partialMessage = JSON.stringify({ - tool: "newTask", - mode: removeClosingTag("mode", mode), - message: removeClosingTag("message", message), - }) - await this.ask("tool", partialMessage, block.partial).catch(() => {}) - break - } else { - if (!mode) { - this.consecutiveMistakeCount++ - pushToolResult(await this.sayAndCreateMissingParamError("new_task", "mode")) - break - } - if (!message) { - this.consecutiveMistakeCount++ - pushToolResult(await this.sayAndCreateMissingParamError("new_task", "message")) - break - } - this.consecutiveMistakeCount = 0 - - // Verify the mode exists - const targetMode = getModeBySlug( - mode, - (await this.providerRef.deref()?.getState())?.customModes, - ) - if (!targetMode) { - pushToolResult(formatResponse.toolError(`Invalid mode: ${mode}`)) - break - } - - const toolMessage = JSON.stringify({ - tool: "newTask", - mode: targetMode.name, - content: message, - }) - const didApprove = await askApproval("tool", toolMessage) - - if (!didApprove) { - break - } - - const provider = this.providerRef.deref() - - if (!provider) { - break - } - - // Preserve the current mode so we can resume with it later. - this.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug - - // Switch mode first, then create new task instance. - await provider.handleModeSwitch(mode) - - // Delay to allow mode change to take effect before next tool is executed. - await delay(500) - - const newCline = await provider.initClineWithTask(message, undefined, this) - this.emit("taskSpawned", newCline.taskId) - - pushToolResult( - `Successfully created new task in ${targetMode.name} mode with message: ${message}`, - ) - - // Set the isPaused flag to true so the parent - // task can wait for the sub-task to finish. - this.isPaused = true - this.emit("taskPaused") - - break - } - } catch (error) { - await handleError("creating new task", error) - break - } + await newTaskTool(this, block, askApproval, handleError, pushToolResult, removeClosingTag) + break } case "attempt_completion": { - const result: string | undefined = block.params.result - const command: string | undefined = block.params.command - try { - const lastMessage = this.clineMessages.at(-1) - if (block.partial) { - if (command) { - // the attempt_completion text is done, now we're getting command - // remove the previous partial attempt_completion ask, replace with say, post state to webview, then stream command - - // const secondLastMessage = this.clineMessages.at(-2) - if (lastMessage && lastMessage.ask === "command") { - // update command - await this.ask( - "command", - removeClosingTag("command", command), - block.partial, - ).catch(() => {}) - } else { - // last message is completion_result - // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) - await this.say( - "completion_result", - removeClosingTag("result", result), - undefined, - false, - ) - - telemetryService.captureTaskCompleted(this.taskId) - this.emit("taskCompleted", this.taskId, this.getTokenUsage()) - - await this.ask( - "command", - removeClosingTag("command", command), - block.partial, - ).catch(() => {}) - } - } else { - // no command, still outputting partial result - await this.say( - "completion_result", - removeClosingTag("result", result), - undefined, - block.partial, - ) - } - break - } else { - if (!result) { - this.consecutiveMistakeCount++ - pushToolResult( - await this.sayAndCreateMissingParamError("attempt_completion", "result"), - ) - break - } - - this.consecutiveMistakeCount = 0 - - let commandResult: ToolResponse | undefined - - if (command) { - if (lastMessage && lastMessage.ask !== "command") { - // Haven't sent a command message yet so first send completion_result then command. - await this.say("completion_result", result, undefined, false) - telemetryService.captureTaskCompleted(this.taskId) - this.emit("taskCompleted", this.taskId, this.getTokenUsage()) - } - - // Complete command message. - const didApprove = await askApproval("command", command) - - if (!didApprove) { - break - } - - const [userRejected, execCommandResult] = await this.executeCommandTool(command!) - - if (userRejected) { - this.didRejectTool = true - pushToolResult(execCommandResult) - break - } - - // User didn't reject, but the command may have output. - commandResult = execCommandResult - } else { - await this.say("completion_result", result, undefined, false) - telemetryService.captureTaskCompleted(this.taskId) - this.emit("taskCompleted", this.taskId, this.getTokenUsage()) - } - - if (this.parentTask) { - const didApprove = await askFinishSubTaskApproval() - - if (!didApprove) { - break - } - - // tell the provider to remove the current subtask and resume the previous task in the stack - await this.providerRef.deref()?.finishSubTask(`Task complete: ${lastMessage?.text}`) - break - } - - // We already sent completion_result says, an - // empty string asks relinquishes control over - // button and field. - const { response, text, images } = await this.ask("completion_result", "", false) - - // Signals to recursive loop to stop (for now - // this never happens since yesButtonClicked - // will trigger a new task). - if (response === "yesButtonClicked") { - pushToolResult("") - break - } - - await this.say("user_feedback", text ?? "", images) - 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\n${text}\n`, - }) - - toolResults.push(...formatResponse.imageBlocks(images)) - - this.userMessageContent.push({ - type: "text", - text: `${toolDescription()} Result:`, - }) - - this.userMessageContent.push(...toolResults) - break - } - } catch (error) { - await handleError("inspecting site", error) - break - } + await attemptCompletionTool( + this, + block, + askApproval, + handleError, + pushToolResult, + removeClosingTag, + toolDescription, + askFinishSubTaskApproval, + ) + break } } diff --git a/src/core/tools/askFollowupQuestionTool.ts b/src/core/tools/askFollowupQuestionTool.ts new file mode 100644 index 0000000000..5ed06e2403 --- /dev/null +++ b/src/core/tools/askFollowupQuestionTool.ts @@ -0,0 +1,71 @@ +import { Cline } from "../Cline" +import { ToolUse } from "../assistant-message" +import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./types" +import { formatResponse } from "../prompts/responses" +import { parseXml } from "../../utils/xml" + +export async function askFollowupQuestionTool( + cline: Cline, + block: ToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, +) { + const question: string | undefined = block.params.question + const follow_up: string | undefined = block.params.follow_up + try { + if (block.partial) { + await cline.ask("followup", removeClosingTag("question", question), block.partial).catch(() => {}) + return + } else { + if (!question) { + cline.consecutiveMistakeCount++ + pushToolResult(await cline.sayAndCreateMissingParamError("ask_followup_question", "question")) + return + } + + type Suggest = { + answer: string + } + + let follow_up_json = { + question, + suggest: [] as Suggest[], + } + + if (follow_up) { + let parsedSuggest: { + suggest: Suggest[] | Suggest + } + + try { + parsedSuggest = parseXml(follow_up, ["suggest"]) as { + suggest: Suggest[] | Suggest + } + } catch (error) { + cline.consecutiveMistakeCount++ + await cline.say("error", `Failed to parse operations: ${error.message}`) + pushToolResult(formatResponse.toolError("Invalid operations xml format")) + return + } + + const normalizedSuggest = Array.isArray(parsedSuggest?.suggest) + ? parsedSuggest.suggest + : [parsedSuggest?.suggest].filter((sug): sug is Suggest => sug !== undefined) + + follow_up_json.suggest = normalizedSuggest + } + + cline.consecutiveMistakeCount = 0 + + const { text, images } = await cline.ask("followup", JSON.stringify(follow_up_json), false) + await cline.say("user_feedback", text ?? "", images) + pushToolResult(formatResponse.toolResult(`\n${text}\n`, images)) + return + } + } catch (error) { + await handleError("asking question", error) + return + } +} diff --git a/src/core/tools/attemptCompletionTool.ts b/src/core/tools/attemptCompletionTool.ts new file mode 100644 index 0000000000..6af96e9154 --- /dev/null +++ b/src/core/tools/attemptCompletionTool.ts @@ -0,0 +1,152 @@ +import { ToolResponse } from "../Cline" + +import { ToolUse } from "../assistant-message" +import { Cline } from "../Cline" +import { + AskApproval, + HandleError, + PushToolResult, + RemoveClosingTag, + ToolDescription, + AskFinishSubTaskApproval, +} from "./types" +import { formatResponse } from "../prompts/responses" +import { telemetryService } from "../../services/telemetry/TelemetryService" +import Anthropic from "@anthropic-ai/sdk" + +export async function attemptCompletionTool( + cline: Cline, + block: ToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, + toolDescription: ToolDescription, + askFinishSubTaskApproval: AskFinishSubTaskApproval, +) { + const result: string | undefined = block.params.result + const command: string | undefined = block.params.command + try { + const lastMessage = cline.clineMessages.at(-1) + if (block.partial) { + if (command) { + // the attempt_completion text is done, now we're getting command + // remove the previous partial attempt_completion ask, replace with say, post state to webview, then stream command + + // const secondLastMessage = cline.clineMessages.at(-2) + if (lastMessage && lastMessage.ask === "command") { + // update command + await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) + } else { + // last message is completion_result + // we have command string, which means we have the result as well, so finish it (doesnt have to exist yet) + await cline.say("completion_result", removeClosingTag("result", result), undefined, false) + + telemetryService.captureTaskCompleted(cline.taskId) + cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage()) + + await cline.ask("command", removeClosingTag("command", command), block.partial).catch(() => {}) + } + } else { + // no command, still outputting partial result + await cline.say("completion_result", removeClosingTag("result", result), undefined, block.partial) + } + return + } else { + if (!result) { + cline.consecutiveMistakeCount++ + pushToolResult(await cline.sayAndCreateMissingParamError("attempt_completion", "result")) + return + } + + cline.consecutiveMistakeCount = 0 + + let commandResult: ToolResponse | undefined + + if (command) { + if (lastMessage && lastMessage.ask !== "command") { + // Haven't sent a command message yet so first send completion_result then command. + await cline.say("completion_result", result, undefined, false) + telemetryService.captureTaskCompleted(cline.taskId) + cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage()) + } + + // Complete command message. + const didApprove = await askApproval("command", command) + + if (!didApprove) { + return + } + + const [userRejected, execCommandResult] = await cline.executeCommandTool(command!) + + if (userRejected) { + cline.didRejectTool = true + pushToolResult(execCommandResult) + return + } + + // User didn't reject, but the command may have output. + commandResult = execCommandResult + } else { + await cline.say("completion_result", result, undefined, false) + telemetryService.captureTaskCompleted(cline.taskId) + cline.emit("taskCompleted", cline.taskId, cline.getTokenUsage()) + } + + if (cline.parentTask) { + const didApprove = await askFinishSubTaskApproval() + + if (!didApprove) { + return + } + + // tell the provider to remove the current subtask and resume the previous task in the stack + await cline.providerRef.deref()?.finishSubTask(`Task complete: ${lastMessage?.text}`) + return + } + + // We already sent completion_result says, an + // empty string asks relinquishes control over + // button and field. + const { response, text, images } = await cline.ask("completion_result", "", false) + + // Signals to recursive loop to stop (for now + // cline never happens since yesButtonClicked + // will trigger a new task). + if (response === "yesButtonClicked") { + pushToolResult("") + return + } + + await cline.say("user_feedback", text ?? "", images) + 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\n${text}\n`, + }) + + toolResults.push(...formatResponse.imageBlocks(images)) + + cline.userMessageContent.push({ + type: "text", + text: `${toolDescription()} Result:`, + }) + + cline.userMessageContent.push(...toolResults) + return + } + } catch (error) { + await handleError("inspecting site", error) + return + } +} diff --git a/src/core/tools/newTaskTool.ts b/src/core/tools/newTaskTool.ts new file mode 100644 index 0000000000..57e290c26b --- /dev/null +++ b/src/core/tools/newTaskTool.ts @@ -0,0 +1,90 @@ +import { ToolUse } from "../assistant-message" +import { HandleError, PushToolResult, RemoveClosingTag } from "./types" +import { Cline } from "../Cline" +import { AskApproval } from "./types" +import { defaultModeSlug, getModeBySlug } from "../../shared/modes" +import { formatResponse } from "../prompts/responses" +import delay from "delay" + +export async function newTaskTool( + cline: Cline, + block: ToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, +) { + const mode: string | undefined = block.params.mode + const message: string | undefined = block.params.message + try { + if (block.partial) { + const partialMessage = JSON.stringify({ + tool: "newTask", + mode: removeClosingTag("mode", mode), + message: removeClosingTag("message", message), + }) + await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + return + } else { + if (!mode) { + cline.consecutiveMistakeCount++ + pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "mode")) + return + } + if (!message) { + cline.consecutiveMistakeCount++ + pushToolResult(await cline.sayAndCreateMissingParamError("new_task", "message")) + return + } + cline.consecutiveMistakeCount = 0 + + // Verify the mode exists + const targetMode = getModeBySlug(mode, (await cline.providerRef.deref()?.getState())?.customModes) + if (!targetMode) { + pushToolResult(formatResponse.toolError(`Invalid mode: ${mode}`)) + return + } + + const toolMessage = JSON.stringify({ + tool: "newTask", + mode: targetMode.name, + content: message, + }) + const didApprove = await askApproval("tool", toolMessage) + + if (!didApprove) { + return + } + + const provider = cline.providerRef.deref() + + if (!provider) { + return + } + + // Preserve the current mode so we can resume with it later. + cline.pausedModeSlug = (await provider.getState()).mode ?? defaultModeSlug + + // Switch mode first, then create new task instance. + await provider.handleModeSwitch(mode) + + // Delay to allow mode change to take effect before next tool is executed. + await delay(500) + + const newCline = await provider.initClineWithTask(message, undefined, cline) + cline.emit("taskSpawned", newCline.taskId) + + pushToolResult(`Successfully created new task in ${targetMode.name} mode with message: ${message}`) + + // Set the isPaused flag to true so the parent + // task can wait for the sub-task to finish. + cline.isPaused = true + cline.emit("taskPaused") + + return + } + } catch (error) { + await handleError("creating new task", error) + return + } +} diff --git a/src/core/tools/switchModeTool.ts b/src/core/tools/switchModeTool.ts new file mode 100644 index 0000000000..48e6e59fe0 --- /dev/null +++ b/src/core/tools/switchModeTool.ts @@ -0,0 +1,75 @@ +import { Cline } from "../Cline" +import { ToolUse } from "../assistant-message" +import { AskApproval, HandleError, PushToolResult, RemoveClosingTag } from "./types" +import { formatResponse } from "../prompts/responses" +import { defaultModeSlug } from "../../shared/modes" +import { getModeBySlug } from "../../shared/modes" +import delay from "delay" + +export async function switchModeTool( + cline: Cline, + block: ToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, +) { + const mode_slug: string | undefined = block.params.mode_slug + const reason: string | undefined = block.params.reason + try { + if (block.partial) { + const partialMessage = JSON.stringify({ + tool: "switchMode", + mode: removeClosingTag("mode_slug", mode_slug), + reason: removeClosingTag("reason", reason), + }) + await cline.ask("tool", partialMessage, block.partial).catch(() => {}) + return + } else { + if (!mode_slug) { + cline.consecutiveMistakeCount++ + pushToolResult(await cline.sayAndCreateMissingParamError("switch_mode", "mode_slug")) + return + } + cline.consecutiveMistakeCount = 0 + + // Verify the mode exists + const targetMode = getModeBySlug(mode_slug, (await cline.providerRef.deref()?.getState())?.customModes) + if (!targetMode) { + pushToolResult(formatResponse.toolError(`Invalid mode: ${mode_slug}`)) + return + } + + // Check if already in requested mode + const currentMode = (await cline.providerRef.deref()?.getState())?.mode ?? defaultModeSlug + if (currentMode === mode_slug) { + pushToolResult(`Already in ${targetMode.name} mode.`) + return + } + + const completeMessage = JSON.stringify({ + tool: "switchMode", + mode: mode_slug, + reason, + }) + + const didApprove = await askApproval("tool", completeMessage) + if (!didApprove) { + return + } + + // Switch the mode using shared handler + await cline.providerRef.deref()?.handleModeSwitch(mode_slug) + pushToolResult( + `Successfully switched from ${getModeBySlug(currentMode)?.name ?? currentMode} mode to ${ + targetMode.name + } mode${reason ? ` because: ${reason}` : ""}.`, + ) + await delay(500) // delay to allow mode change to take effect before next tool is executed + return + } + } catch (error) { + await handleError("switching mode", error) + return + } +} diff --git a/src/core/tools/types.ts b/src/core/tools/types.ts index 0d21b9e3ee..5b027241f6 100644 --- a/src/core/tools/types.ts +++ b/src/core/tools/types.ts @@ -13,3 +13,7 @@ export type HandleError = (action: string, error: Error) => Promise export type PushToolResult = (content: ToolResponse) => void export type RemoveClosingTag = (tag: ToolParamName, content?: string) => string + +export type AskFinishSubTaskApproval = () => Promise + +export type ToolDescription = () => string