Move new_task, switch_mode, attempt_completion, and ask_followup_question to tool files (#2102)

This commit is contained in:
Matt Rubens 2025-03-30 01:58:35 -04:00 committed by GitHub
parent 9b0790fce7
commit 253d0fecb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 424 additions and 353 deletions

View file

@ -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<Anthropic.TextBlockParam | Anthropic.ImageBlockParam>
type UserContent = Array<Anthropic.Messages.ContentBlockParam>
@ -137,8 +141,8 @@ export class Cline extends EventEmitter<ClineEvents> {
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<ClineEvents> {
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<ClineEvents> {
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<ClineEvents> {
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(`<answer>\n${text}\n</answer>`, 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<feedback>\n${text}\n</feedback>`,
})
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
}
}

View file

@ -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(`<answer>\n${text}\n</answer>`, images))
return
}
} catch (error) {
await handleError("asking question", error)
return
}
}

View file

@ -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<feedback>\n${text}\n</feedback>`,
})
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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -13,3 +13,7 @@ export type HandleError = (action: string, error: Error) => Promise<void>
export type PushToolResult = (content: ToolResponse) => void
export type RemoveClosingTag = (tag: ToolParamName, content?: string) => string
export type AskFinishSubTaskApproval = () => Promise<boolean>
export type ToolDescription = () => string