mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
1217 lines
42 KiB
TypeScript
1217 lines
42 KiB
TypeScript
import cloneDeep from "clone-deep"
|
|
import { serializeError } from "serialize-error"
|
|
import { Anthropic } from "@anthropic-ai/sdk"
|
|
|
|
import type { ToolName, ClineAsk, ToolProgressStatus } from "@roo-code/types"
|
|
import { ConsecutiveMistakeError } from "@roo-code/types"
|
|
import { TelemetryService } from "@roo-code/telemetry"
|
|
import { customToolRegistry } from "@roo-code/core"
|
|
|
|
import { t } from "../../i18n"
|
|
|
|
import { defaultModeSlug, getModeBySlug } from "../../shared/modes"
|
|
import type { ToolParamName, ToolResponse, ToolUse, McpToolUse } from "../../shared/tools"
|
|
import { experiments, EXPERIMENT_IDS } from "../../shared/experiments"
|
|
|
|
import { AskIgnoredError } from "../task/AskIgnoredError"
|
|
import { Task } from "../task/Task"
|
|
|
|
import { fetchInstructionsTool } from "../tools/FetchInstructionsTool"
|
|
import { listFilesTool } from "../tools/ListFilesTool"
|
|
import { readFileTool } from "../tools/ReadFileTool"
|
|
import { getSimpleReadFileToolDescription, simpleReadFileTool } from "../tools/simpleReadFileTool"
|
|
import { shouldUseSingleFileRead, TOOL_PROTOCOL } from "@roo-code/types"
|
|
import { writeToFileTool } from "../tools/WriteToFileTool"
|
|
import { applyDiffTool } from "../tools/MultiApplyDiffTool"
|
|
import { searchAndReplaceTool } from "../tools/SearchAndReplaceTool"
|
|
import { searchReplaceTool } from "../tools/SearchReplaceTool"
|
|
import { editFileTool } from "../tools/EditFileTool"
|
|
import { applyPatchTool } from "../tools/ApplyPatchTool"
|
|
import { searchFilesTool } from "../tools/SearchFilesTool"
|
|
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, AttemptCompletionCallbacks } from "../tools/AttemptCompletionTool"
|
|
import { newTaskTool } from "../tools/NewTaskTool"
|
|
import { updateTodoListTool } from "../tools/UpdateTodoListTool"
|
|
import { runSlashCommandTool } from "../tools/RunSlashCommandTool"
|
|
import { generateImageTool } from "../tools/GenerateImageTool"
|
|
import { applyDiffTool as applyDiffToolClass } from "../tools/ApplyDiffTool"
|
|
import { validateToolUse } from "../tools/validateToolUse"
|
|
import { codebaseSearchTool } from "../tools/CodebaseSearchTool"
|
|
|
|
import { formatResponse } from "../prompts/responses"
|
|
|
|
/**
|
|
* Processes and presents assistant message content to the user interface.
|
|
*
|
|
* This function is the core message handling system that:
|
|
* - Sequentially processes content blocks from the assistant's response.
|
|
* - Displays text content to the user.
|
|
* - Executes tool use requests with appropriate user approval.
|
|
* - Manages the flow of conversation by determining when to proceed to the next content block.
|
|
* - Coordinates file system checkpointing for modified files.
|
|
* - Controls the conversation state to determine when to continue to the next request.
|
|
*
|
|
* The function uses a locking mechanism to prevent concurrent execution and handles
|
|
* partial content blocks during streaming. It's designed to work with the streaming
|
|
* API response pattern, where content arrives incrementally and needs to be processed
|
|
* as it becomes available.
|
|
*/
|
|
|
|
export async function presentAssistantMessage(cline: Task) {
|
|
if (cline.abort) {
|
|
throw new Error(`[Task#presentAssistantMessage] task ${cline.taskId}.${cline.instanceId} aborted`)
|
|
}
|
|
|
|
if (cline.presentAssistantMessageLocked) {
|
|
cline.presentAssistantMessageHasPendingUpdates = true
|
|
return
|
|
}
|
|
|
|
cline.presentAssistantMessageLocked = true
|
|
cline.presentAssistantMessageHasPendingUpdates = false
|
|
|
|
if (cline.currentStreamingContentIndex >= cline.assistantMessageContent.length) {
|
|
// This may happen if the last content block was completed before
|
|
// streaming could finish. If streaming is finished, and we're out of
|
|
// bounds then this means we already presented/executed the last
|
|
// content block and are ready to continue to next request.
|
|
if (cline.didCompleteReadingStream) {
|
|
cline.userMessageContentReady = true
|
|
}
|
|
|
|
cline.presentAssistantMessageLocked = false
|
|
return
|
|
}
|
|
|
|
let block: any
|
|
try {
|
|
block = cloneDeep(cline.assistantMessageContent[cline.currentStreamingContentIndex]) // need to create copy bc while stream is updating the array, it could be updating the reference block properties too
|
|
} catch (error) {
|
|
console.error(`ERROR cloning block:`, error)
|
|
console.error(
|
|
`Block content:`,
|
|
JSON.stringify(cline.assistantMessageContent[cline.currentStreamingContentIndex], null, 2),
|
|
)
|
|
cline.presentAssistantMessageLocked = false
|
|
return
|
|
}
|
|
|
|
switch (block.type) {
|
|
case "mcp_tool_use": {
|
|
// Handle native MCP tool calls (from mcp_serverName_toolName dynamic tools)
|
|
// These are converted to the same execution path as use_mcp_tool but preserve
|
|
// their original name in API history
|
|
const mcpBlock = block as McpToolUse
|
|
|
|
if (cline.didRejectTool) {
|
|
// For native protocol, we must send a tool_result for every tool_use to avoid API errors
|
|
const toolCallId = mcpBlock.id
|
|
const errorMessage = !mcpBlock.partial
|
|
? `Skipping MCP tool ${mcpBlock.name} due to user rejecting a previous tool.`
|
|
: `MCP tool ${mcpBlock.name} was interrupted and not executed due to user rejecting a previous tool.`
|
|
|
|
if (toolCallId) {
|
|
cline.userMessageContent.push({
|
|
type: "tool_result",
|
|
tool_use_id: toolCallId,
|
|
content: errorMessage,
|
|
is_error: true,
|
|
} as Anthropic.ToolResultBlockParam)
|
|
}
|
|
break
|
|
}
|
|
|
|
if (cline.didAlreadyUseTool) {
|
|
const toolCallId = mcpBlock.id
|
|
const errorMessage = `MCP tool [${mcpBlock.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message.`
|
|
|
|
if (toolCallId) {
|
|
cline.userMessageContent.push({
|
|
type: "tool_result",
|
|
tool_use_id: toolCallId,
|
|
content: errorMessage,
|
|
is_error: true,
|
|
} as Anthropic.ToolResultBlockParam)
|
|
}
|
|
break
|
|
}
|
|
|
|
// Track if we've already pushed a tool result
|
|
let hasToolResult = false
|
|
const toolCallId = mcpBlock.id
|
|
const toolProtocol = TOOL_PROTOCOL.NATIVE // MCP tools in native mode always use native protocol
|
|
|
|
const pushToolResult = (content: ToolResponse) => {
|
|
if (hasToolResult) {
|
|
console.warn(
|
|
`[presentAssistantMessage] Skipping duplicate tool_result for mcp_tool_use: ${toolCallId}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
let resultContent: string
|
|
let imageBlocks: Anthropic.ImageBlockParam[] = []
|
|
|
|
if (typeof content === "string") {
|
|
resultContent = content || "(tool did not return anything)"
|
|
} else {
|
|
const textBlocks = content.filter((item) => item.type === "text")
|
|
imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[]
|
|
resultContent =
|
|
textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
|
|
"(tool did not return anything)"
|
|
}
|
|
|
|
if (toolCallId) {
|
|
cline.userMessageContent.push({
|
|
type: "tool_result",
|
|
tool_use_id: toolCallId,
|
|
content: resultContent,
|
|
} as Anthropic.ToolResultBlockParam)
|
|
|
|
if (imageBlocks.length > 0) {
|
|
cline.userMessageContent.push(...imageBlocks)
|
|
}
|
|
}
|
|
|
|
hasToolResult = true
|
|
cline.didAlreadyUseTool = true
|
|
}
|
|
|
|
const toolDescription = () => `[mcp_tool: ${mcpBlock.serverName}/${mcpBlock.toolName}]`
|
|
|
|
const askApproval = async (
|
|
type: ClineAsk,
|
|
partialMessage?: string,
|
|
progressStatus?: ToolProgressStatus,
|
|
isProtected?: boolean,
|
|
) => {
|
|
const { response, text, images } = await cline.ask(
|
|
type,
|
|
partialMessage,
|
|
false,
|
|
progressStatus,
|
|
isProtected || false,
|
|
)
|
|
|
|
if (response !== "yesButtonClicked") {
|
|
if (text) {
|
|
await cline.say("user_feedback", text, images)
|
|
pushToolResult(
|
|
formatResponse.toolResult(
|
|
formatResponse.toolDeniedWithFeedback(text, toolProtocol),
|
|
images,
|
|
),
|
|
)
|
|
} else {
|
|
pushToolResult(formatResponse.toolDenied(toolProtocol))
|
|
}
|
|
cline.didRejectTool = true
|
|
return false
|
|
}
|
|
|
|
if (text) {
|
|
await cline.say("user_feedback", text, images)
|
|
pushToolResult(
|
|
formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text, toolProtocol), images),
|
|
)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
const handleError = async (action: string, error: Error) => {
|
|
// Silently ignore AskIgnoredError - this is an internal control flow
|
|
// signal, not an actual error. It occurs when a newer ask supersedes an older one.
|
|
if (error instanceof AskIgnoredError) {
|
|
return
|
|
}
|
|
const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}`
|
|
await cline.say(
|
|
"error",
|
|
`Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`,
|
|
)
|
|
pushToolResult(formatResponse.toolError(errorString, toolProtocol))
|
|
}
|
|
|
|
if (!mcpBlock.partial) {
|
|
cline.recordToolUsage("use_mcp_tool") // Record as use_mcp_tool for analytics
|
|
TelemetryService.instance.captureToolUsage(cline.taskId, "use_mcp_tool", toolProtocol)
|
|
}
|
|
|
|
// Resolve sanitized server name back to original server name
|
|
// The serverName from parsing is sanitized (e.g., "my_server" from "my server")
|
|
// We need the original name to find the actual MCP connection
|
|
const mcpHub = cline.providerRef.deref()?.getMcpHub()
|
|
let resolvedServerName = mcpBlock.serverName
|
|
if (mcpHub) {
|
|
const originalName = mcpHub.findServerNameBySanitizedName(mcpBlock.serverName)
|
|
if (originalName) {
|
|
resolvedServerName = originalName
|
|
}
|
|
}
|
|
|
|
// Execute the MCP tool using the same handler as use_mcp_tool
|
|
// Create a synthetic ToolUse block that the useMcpToolTool can handle
|
|
const syntheticToolUse: ToolUse<"use_mcp_tool"> = {
|
|
type: "tool_use",
|
|
id: mcpBlock.id,
|
|
name: "use_mcp_tool",
|
|
params: {
|
|
server_name: resolvedServerName,
|
|
tool_name: mcpBlock.toolName,
|
|
arguments: JSON.stringify(mcpBlock.arguments),
|
|
},
|
|
partial: mcpBlock.partial,
|
|
nativeArgs: {
|
|
server_name: resolvedServerName,
|
|
tool_name: mcpBlock.toolName,
|
|
arguments: mcpBlock.arguments,
|
|
},
|
|
}
|
|
|
|
await useMcpToolTool.handle(cline, syntheticToolUse, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag: (tag, text) => text || "",
|
|
toolProtocol,
|
|
})
|
|
break
|
|
}
|
|
case "text": {
|
|
if (cline.didRejectTool || cline.didAlreadyUseTool) {
|
|
break
|
|
}
|
|
|
|
let content = block.content
|
|
|
|
if (content) {
|
|
// Have to do this for partial and complete since sending
|
|
// content in thinking tags to markdown renderer will
|
|
// automatically be removed.
|
|
// Remove end substrings of <thinking or </thinking (below xml
|
|
// parsing is only for opening tags).
|
|
// Tthis is done with the xml parsing below now, but keeping
|
|
// here for reference.
|
|
// content = content.replace(/<\/?t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?$/, "")
|
|
//
|
|
// Remove all instances of <thinking> (with optional line break
|
|
// after) and </thinking> (with optional line break before).
|
|
// - Needs to be separate since we dont want to remove the line
|
|
// break before the first tag.
|
|
// - Needs to happen before the xml parsing below.
|
|
content = content.replace(/<thinking>\s?/g, "")
|
|
content = content.replace(/\s?<\/thinking>/g, "")
|
|
|
|
// Remove partial XML tag at the very end of the content (for
|
|
// tool use and thinking tags), Prevents scrollview from
|
|
// jumping when tags are automatically removed.
|
|
const lastOpenBracketIndex = content.lastIndexOf("<")
|
|
|
|
if (lastOpenBracketIndex !== -1) {
|
|
const possibleTag = content.slice(lastOpenBracketIndex)
|
|
|
|
// Check if there's a '>' after the last '<' (i.e., if the
|
|
// tag is complete) (complete thinking and tool tags will
|
|
// have been removed by now.)
|
|
const hasCloseBracket = possibleTag.includes(">")
|
|
|
|
if (!hasCloseBracket) {
|
|
// Extract the potential tag name.
|
|
let tagContent: string
|
|
|
|
if (possibleTag.startsWith("</")) {
|
|
tagContent = possibleTag.slice(2).trim()
|
|
} else {
|
|
tagContent = possibleTag.slice(1).trim()
|
|
}
|
|
|
|
// Check if tagContent is likely an incomplete tag name
|
|
// (letters and underscores only).
|
|
const isLikelyTagName = /^[a-zA-Z_]+$/.test(tagContent)
|
|
|
|
// Preemptively remove < or </ to keep from these
|
|
// artifacts showing up in chat (also handles closing
|
|
// thinking tags).
|
|
const isOpeningOrClosing = possibleTag === "<" || possibleTag === "</"
|
|
|
|
// If the tag is incomplete and at the end, remove it
|
|
// from the content.
|
|
if (isOpeningOrClosing || isLikelyTagName) {
|
|
content = content.slice(0, lastOpenBracketIndex).trim()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
await cline.say("text", content, undefined, block.partial)
|
|
break
|
|
}
|
|
case "tool_use": {
|
|
// Fetch state early so it's available for toolDescription and validation
|
|
const state = await cline.providerRef.deref()?.getState()
|
|
const { mode, customModes, experiments: stateExperiments } = state ?? {}
|
|
|
|
const toolDescription = (): string => {
|
|
switch (block.name) {
|
|
case "execute_command":
|
|
return `[${block.name} for '${block.params.command}']`
|
|
case "read_file":
|
|
// Check if this model should use the simplified description
|
|
const modelId = cline.api.getModel().id
|
|
if (shouldUseSingleFileRead(modelId)) {
|
|
return getSimpleReadFileToolDescription(block.name, block.params)
|
|
} else {
|
|
// Prefer native typed args when available; fall back to legacy params
|
|
// Check if nativeArgs exists (native protocol)
|
|
if (block.nativeArgs) {
|
|
return readFileTool.getReadFileToolDescription(block.name, block.nativeArgs)
|
|
}
|
|
return readFileTool.getReadFileToolDescription(block.name, block.params)
|
|
}
|
|
case "fetch_instructions":
|
|
return `[${block.name} for '${block.params.task}']`
|
|
case "write_to_file":
|
|
return `[${block.name} for '${block.params.path}']`
|
|
case "apply_diff":
|
|
// Handle both legacy format and new multi-file format
|
|
if (block.params.path) {
|
|
return `[${block.name} for '${block.params.path}']`
|
|
} else if (block.params.args) {
|
|
// Try to extract first file path from args for display
|
|
const match = block.params.args.match(/<file>.*?<path>([^<]+)<\/path>/s)
|
|
if (match) {
|
|
const firstPath = match[1]
|
|
// Check if there are multiple files
|
|
const fileCount = (block.params.args.match(/<file>/g) || []).length
|
|
if (fileCount > 1) {
|
|
return `[${block.name} for '${firstPath}' and ${fileCount - 1} more file${fileCount > 2 ? "s" : ""}]`
|
|
} else {
|
|
return `[${block.name} for '${firstPath}']`
|
|
}
|
|
}
|
|
}
|
|
return `[${block.name}]`
|
|
case "search_files":
|
|
return `[${block.name} for '${block.params.regex}'${
|
|
block.params.file_pattern ? ` in '${block.params.file_pattern}'` : ""
|
|
}]`
|
|
case "search_and_replace":
|
|
return `[${block.name} for '${block.params.path}']`
|
|
case "search_replace":
|
|
return `[${block.name} for '${block.params.file_path}']`
|
|
case "edit_file":
|
|
return `[${block.name} for '${block.params.file_path}']`
|
|
case "apply_patch":
|
|
return `[${block.name}]`
|
|
case "list_files":
|
|
return `[${block.name} for '${block.params.path}']`
|
|
case "browser_action":
|
|
return `[${block.name} for '${block.params.action}']`
|
|
case "use_mcp_tool":
|
|
return `[${block.name} for '${block.params.server_name}']`
|
|
case "access_mcp_resource":
|
|
return `[${block.name} for '${block.params.server_name}']`
|
|
case "ask_followup_question":
|
|
return `[${block.name} for '${block.params.question}']`
|
|
case "attempt_completion":
|
|
return `[${block.name}]`
|
|
case "switch_mode":
|
|
return `[${block.name} to '${block.params.mode_slug}'${block.params.reason ? ` because: ${block.params.reason}` : ""}]`
|
|
case "codebase_search": // Add case for the new tool
|
|
return `[${block.name} for '${block.params.query}']`
|
|
case "update_todo_list":
|
|
return `[${block.name}]`
|
|
case "new_task": {
|
|
const mode = block.params.mode ?? defaultModeSlug
|
|
const message = block.params.message ?? "(no message)"
|
|
const modeName = getModeBySlug(mode, customModes)?.name ?? mode
|
|
return `[${block.name} in ${modeName} mode: '${message}']`
|
|
}
|
|
case "run_slash_command":
|
|
return `[${block.name} for '${block.params.command}'${block.params.args ? ` with args: ${block.params.args}` : ""}]`
|
|
case "generate_image":
|
|
return `[${block.name} for '${block.params.path}']`
|
|
default:
|
|
return `[${block.name}]`
|
|
}
|
|
}
|
|
|
|
if (cline.didRejectTool) {
|
|
// Ignore any tool content after user has rejected tool once.
|
|
// For native protocol, we must send a tool_result for every tool_use to avoid API errors
|
|
const toolCallId = block.id
|
|
const errorMessage = !block.partial
|
|
? `Skipping tool ${toolDescription()} due to user rejecting a previous tool.`
|
|
: `Tool ${toolDescription()} was interrupted and not executed due to user rejecting a previous tool.`
|
|
|
|
if (toolCallId) {
|
|
// Native protocol: MUST send tool_result for every tool_use
|
|
cline.userMessageContent.push({
|
|
type: "tool_result",
|
|
tool_use_id: toolCallId,
|
|
content: errorMessage,
|
|
is_error: true,
|
|
} as Anthropic.ToolResultBlockParam)
|
|
} else {
|
|
// XML protocol: send as text
|
|
cline.userMessageContent.push({
|
|
type: "text",
|
|
text: errorMessage,
|
|
})
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
if (cline.didAlreadyUseTool) {
|
|
// Ignore any content after a tool has already been used.
|
|
// For native protocol, we must send a tool_result for every tool_use to avoid API errors
|
|
const toolCallId = block.id
|
|
const errorMessage = `Tool [${block.name}] was not executed because a tool has already been used in this message. Only one tool may be used per message. You must assess the first tool's result before proceeding to use the next tool.`
|
|
|
|
if (toolCallId) {
|
|
// Native protocol: MUST send tool_result for every tool_use
|
|
cline.userMessageContent.push({
|
|
type: "tool_result",
|
|
tool_use_id: toolCallId,
|
|
content: errorMessage,
|
|
is_error: true,
|
|
} as Anthropic.ToolResultBlockParam)
|
|
} else {
|
|
// XML protocol: send as text
|
|
cline.userMessageContent.push({
|
|
type: "text",
|
|
text: errorMessage,
|
|
})
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
// Track if we've already pushed a tool result for this tool call (native protocol only)
|
|
let hasToolResult = false
|
|
|
|
// Determine protocol by checking if this tool call has an ID.
|
|
// Native protocol tool calls ALWAYS have an ID (set when parsed from tool_call chunks).
|
|
// XML protocol tool calls NEVER have an ID (parsed from XML text).
|
|
const toolCallId = (block as any).id
|
|
const toolProtocol = toolCallId ? TOOL_PROTOCOL.NATIVE : TOOL_PROTOCOL.XML
|
|
|
|
// Multiple native tool calls feature is on hold - always disabled
|
|
// Previously resolved from experiments.isEnabled(..., EXPERIMENT_IDS.MULTIPLE_NATIVE_TOOL_CALLS)
|
|
const isMultipleNativeToolCallsEnabled = false
|
|
|
|
const pushToolResult = (content: ToolResponse) => {
|
|
if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
|
|
// For native protocol, only allow ONE tool_result per tool call
|
|
if (hasToolResult) {
|
|
console.warn(
|
|
`[presentAssistantMessage] Skipping duplicate tool_result for tool_use_id: ${toolCallId}`,
|
|
)
|
|
return
|
|
}
|
|
|
|
// For native protocol, tool_result content must be a string
|
|
// Images are added as separate blocks in the user message
|
|
let resultContent: string
|
|
let imageBlocks: Anthropic.ImageBlockParam[] = []
|
|
|
|
if (typeof content === "string") {
|
|
resultContent = content || "(tool did not return anything)"
|
|
} else {
|
|
// Separate text and image blocks
|
|
const textBlocks = content.filter((item) => item.type === "text")
|
|
imageBlocks = content.filter((item) => item.type === "image") as Anthropic.ImageBlockParam[]
|
|
|
|
// Convert text blocks to string for tool_result
|
|
resultContent =
|
|
textBlocks.map((item) => (item as Anthropic.TextBlockParam).text).join("\n") ||
|
|
"(tool did not return anything)"
|
|
}
|
|
|
|
// Add tool_result with text content only
|
|
cline.userMessageContent.push({
|
|
type: "tool_result",
|
|
tool_use_id: toolCallId,
|
|
content: resultContent,
|
|
} as Anthropic.ToolResultBlockParam)
|
|
|
|
// Add image blocks separately after tool_result
|
|
if (imageBlocks.length > 0) {
|
|
cline.userMessageContent.push(...imageBlocks)
|
|
}
|
|
|
|
hasToolResult = true
|
|
} else {
|
|
// For XML protocol, add as text blocks (legacy behavior)
|
|
cline.userMessageContent.push({ type: "text", text: `${toolDescription()} Result:` })
|
|
|
|
if (typeof content === "string") {
|
|
cline.userMessageContent.push({
|
|
type: "text",
|
|
text: content || "(tool did not return anything)",
|
|
})
|
|
} else {
|
|
cline.userMessageContent.push(...content)
|
|
}
|
|
}
|
|
|
|
// For XML protocol: Only one tool per message is allowed
|
|
// For native protocol with experimental flag enabled: Multiple tools can be executed in sequence
|
|
// For native protocol with experimental flag disabled: Single tool per message (default safe behavior)
|
|
if (toolProtocol === TOOL_PROTOCOL.XML) {
|
|
// Once a tool result has been collected, ignore all other tool
|
|
// uses since we should only ever present one tool result per
|
|
// message (XML protocol only).
|
|
cline.didAlreadyUseTool = true
|
|
} else if (toolProtocol === TOOL_PROTOCOL.NATIVE && !isMultipleNativeToolCallsEnabled) {
|
|
// For native protocol with experimental flag disabled, enforce single tool per message
|
|
cline.didAlreadyUseTool = true
|
|
}
|
|
// If toolProtocol is NATIVE and isMultipleNativeToolCallsEnabled is true,
|
|
// allow multiple tool calls in sequence (don't set didAlreadyUseTool)
|
|
}
|
|
|
|
const askApproval = async (
|
|
type: ClineAsk,
|
|
partialMessage?: string,
|
|
progressStatus?: ToolProgressStatus,
|
|
isProtected?: boolean,
|
|
) => {
|
|
const { response, text, images } = await cline.ask(
|
|
type,
|
|
partialMessage,
|
|
false,
|
|
progressStatus,
|
|
isProtected || false,
|
|
)
|
|
|
|
if (response !== "yesButtonClicked") {
|
|
// Handle both messageResponse and noButtonClicked with text.
|
|
if (text) {
|
|
await cline.say("user_feedback", text, images)
|
|
pushToolResult(
|
|
formatResponse.toolResult(
|
|
formatResponse.toolDeniedWithFeedback(text, toolProtocol),
|
|
images,
|
|
),
|
|
)
|
|
} else {
|
|
pushToolResult(formatResponse.toolDenied(toolProtocol))
|
|
}
|
|
cline.didRejectTool = true
|
|
return false
|
|
}
|
|
|
|
// Handle yesButtonClicked with text.
|
|
if (text) {
|
|
await cline.say("user_feedback", text, images)
|
|
pushToolResult(
|
|
formatResponse.toolResult(formatResponse.toolApprovedWithFeedback(text, toolProtocol), images),
|
|
)
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
const askFinishSubTaskApproval = async () => {
|
|
// Ask the user to approve this task has completed, and he has
|
|
// reviewed it, and we can declare task is finished and return
|
|
// control to the parent task to continue running the rest of
|
|
// the sub-tasks.
|
|
const toolMessage = JSON.stringify({ tool: "finishTask" })
|
|
return await askApproval("tool", toolMessage)
|
|
}
|
|
|
|
const handleError = async (action: string, error: Error) => {
|
|
// Silently ignore AskIgnoredError - this is an internal control flow
|
|
// signal, not an actual error. It occurs when a newer ask supersedes an older one.
|
|
if (error instanceof AskIgnoredError) {
|
|
return
|
|
}
|
|
const errorString = `Error ${action}: ${JSON.stringify(serializeError(error))}`
|
|
|
|
await cline.say(
|
|
"error",
|
|
`Error ${action}:\n${error.message ?? JSON.stringify(serializeError(error), null, 2)}`,
|
|
)
|
|
|
|
pushToolResult(formatResponse.toolError(errorString, toolProtocol))
|
|
}
|
|
|
|
// If block is partial, remove partial closing tag so its not
|
|
// presented to user.
|
|
const removeClosingTag = (tag: ToolParamName, text?: string): string => {
|
|
if (!block.partial) {
|
|
return text || ""
|
|
}
|
|
|
|
if (!text) {
|
|
return ""
|
|
}
|
|
|
|
// This regex dynamically constructs a pattern to match the
|
|
// closing tag:
|
|
// - Optionally matches whitespace before the tag.
|
|
// - Matches '<' or '</' optionally followed by any subset of
|
|
// characters from the tag name.
|
|
const tagRegex = new RegExp(
|
|
`\\s?<\/?${tag
|
|
.split("")
|
|
.map((char) => `(?:${char})?`)
|
|
.join("")}$`,
|
|
"g",
|
|
)
|
|
|
|
return text.replace(tagRegex, "")
|
|
}
|
|
|
|
// Keep browser open during an active session so other tools can run.
|
|
// Session is active if we've seen any browser_action_result and the last browser_action is not "close".
|
|
try {
|
|
const messages = cline.clineMessages || []
|
|
const hasStarted = messages.some((m: any) => m.say === "browser_action_result")
|
|
let isClosed = false
|
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
const m = messages[i]
|
|
if (m.say === "browser_action") {
|
|
try {
|
|
const act = JSON.parse(m.text || "{}")
|
|
isClosed = act.action === "close"
|
|
} catch {}
|
|
break
|
|
}
|
|
}
|
|
const sessionActive = hasStarted && !isClosed
|
|
// Only auto-close when no active browser session is present, and this isn't a browser_action
|
|
if (!sessionActive && block.name !== "browser_action") {
|
|
await cline.browserSession.closeBrowser()
|
|
}
|
|
} catch {
|
|
// On any unexpected error, fall back to conservative behavior
|
|
if (block.name !== "browser_action") {
|
|
await cline.browserSession.closeBrowser()
|
|
}
|
|
}
|
|
|
|
if (!block.partial) {
|
|
cline.recordToolUsage(block.name)
|
|
TelemetryService.instance.captureToolUsage(cline.taskId, block.name, toolProtocol)
|
|
}
|
|
|
|
// Validate tool use before execution - ONLY for complete (non-partial) blocks.
|
|
// Validating partial blocks would cause validation errors to be thrown repeatedly
|
|
// during streaming, pushing multiple tool_results for the same tool_use_id and
|
|
// potentially causing the stream to appear frozen.
|
|
if (!block.partial) {
|
|
const modelInfo = cline.api.getModel()
|
|
// Resolve aliases in includedTools before validation
|
|
// e.g., "edit_file" should resolve to "apply_diff"
|
|
const rawIncludedTools = modelInfo?.info?.includedTools
|
|
const { resolveToolAlias } = await import("../prompts/tools/filter-tools-for-mode")
|
|
const includedTools = rawIncludedTools?.map((tool) => resolveToolAlias(tool))
|
|
|
|
try {
|
|
validateToolUse(
|
|
block.name as ToolName,
|
|
mode ?? defaultModeSlug,
|
|
customModes ?? [],
|
|
{ apply_diff: cline.diffEnabled },
|
|
block.params,
|
|
stateExperiments,
|
|
includedTools,
|
|
)
|
|
} catch (error) {
|
|
cline.consecutiveMistakeCount++
|
|
// For validation errors (unknown tool, tool not allowed for mode), we need to:
|
|
// 1. Send a tool_result with the error (required for native protocol)
|
|
// 2. NOT set didAlreadyUseTool = true (the tool was never executed, just failed validation)
|
|
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
|
|
// which would cause the extension to appear to hang
|
|
const errorContent = formatResponse.toolError(error.message, toolProtocol)
|
|
|
|
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
|
|
// For native protocol, push tool_result directly without setting didAlreadyUseTool
|
|
cline.userMessageContent.push({
|
|
type: "tool_result",
|
|
tool_use_id: toolCallId,
|
|
content: typeof errorContent === "string" ? errorContent : "(validation error)",
|
|
is_error: true,
|
|
} as Anthropic.ToolResultBlockParam)
|
|
} else {
|
|
// For XML protocol, use the standard pushToolResult
|
|
pushToolResult(errorContent)
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check for identical consecutive tool calls.
|
|
if (!block.partial) {
|
|
// Use the detector to check for repetition, passing the ToolUse
|
|
// block directly.
|
|
const repetitionCheck = cline.toolRepetitionDetector.check(block)
|
|
|
|
// If execution is not allowed, notify user and break.
|
|
if (!repetitionCheck.allowExecution && repetitionCheck.askUser) {
|
|
// Handle repetition similar to mistake_limit_reached pattern.
|
|
const { response, text, images } = await cline.ask(
|
|
repetitionCheck.askUser.messageKey as ClineAsk,
|
|
repetitionCheck.askUser.messageDetail.replace("{toolName}", block.name),
|
|
)
|
|
|
|
if (response === "messageResponse") {
|
|
// Add user feedback to userContent.
|
|
cline.userMessageContent.push(
|
|
{
|
|
type: "text" as const,
|
|
text: `Tool repetition limit reached. User feedback: ${text}`,
|
|
},
|
|
...formatResponse.imageBlocks(images),
|
|
)
|
|
|
|
// Add user feedback to chat.
|
|
await cline.say("user_feedback", text, images)
|
|
}
|
|
|
|
// Track tool repetition in telemetry via PostHog exception tracking and event.
|
|
TelemetryService.instance.captureConsecutiveMistakeError(cline.taskId)
|
|
TelemetryService.instance.captureException(
|
|
new ConsecutiveMistakeError(
|
|
`Tool repetition limit reached for ${block.name}`,
|
|
cline.taskId,
|
|
cline.consecutiveMistakeCount,
|
|
cline.consecutiveMistakeLimit,
|
|
"tool_repetition",
|
|
cline.apiConfiguration.apiProvider,
|
|
cline.api.getModel().id,
|
|
),
|
|
)
|
|
|
|
// Return tool result message about the repetition
|
|
pushToolResult(
|
|
formatResponse.toolError(
|
|
`Tool call repetition limit reached for ${block.name}. Please try a different approach.`,
|
|
toolProtocol,
|
|
),
|
|
)
|
|
break
|
|
}
|
|
}
|
|
|
|
switch (block.name) {
|
|
case "write_to_file":
|
|
await checkpointSaveAndMark(cline)
|
|
await writeToFileTool.handle(cline, block as ToolUse<"write_to_file">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "update_todo_list":
|
|
await updateTodoListTool.handle(cline, block as ToolUse<"update_todo_list">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "apply_diff": {
|
|
await checkpointSaveAndMark(cline)
|
|
|
|
// Check if this tool call came from native protocol by checking for ID
|
|
// Native calls always have IDs, XML calls never do
|
|
if (toolProtocol === TOOL_PROTOCOL.NATIVE) {
|
|
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
}
|
|
|
|
// Get the provider and state to check experiment settings
|
|
const provider = cline.providerRef.deref()
|
|
let isMultiFileApplyDiffEnabled = false
|
|
|
|
if (provider) {
|
|
const state = await provider.getState()
|
|
isMultiFileApplyDiffEnabled = experiments.isEnabled(
|
|
state.experiments ?? {},
|
|
EXPERIMENT_IDS.MULTI_FILE_APPLY_DIFF,
|
|
)
|
|
}
|
|
|
|
if (isMultiFileApplyDiffEnabled) {
|
|
await applyDiffTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag)
|
|
} else {
|
|
await applyDiffToolClass.handle(cline, block as ToolUse<"apply_diff">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
}
|
|
break
|
|
}
|
|
case "search_and_replace":
|
|
await checkpointSaveAndMark(cline)
|
|
await searchAndReplaceTool.handle(cline, block as ToolUse<"search_and_replace">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "search_replace":
|
|
await checkpointSaveAndMark(cline)
|
|
await searchReplaceTool.handle(cline, block as ToolUse<"search_replace">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "edit_file":
|
|
await checkpointSaveAndMark(cline)
|
|
await editFileTool.handle(cline, block as ToolUse<"edit_file">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "apply_patch":
|
|
await checkpointSaveAndMark(cline)
|
|
await applyPatchTool.handle(cline, block as ToolUse<"apply_patch">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "read_file":
|
|
// Check if this model should use the simplified single-file read tool
|
|
// Only use simplified tool for XML protocol - native protocol works with standard tool
|
|
const modelId = cline.api.getModel().id
|
|
if (shouldUseSingleFileRead(modelId) && toolProtocol !== TOOL_PROTOCOL.NATIVE) {
|
|
await simpleReadFileTool(
|
|
cline,
|
|
block,
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
)
|
|
} else {
|
|
// Type assertion is safe here because we're in the "read_file" case
|
|
await readFileTool.handle(cline, block as ToolUse<"read_file">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
}
|
|
break
|
|
case "fetch_instructions":
|
|
await fetchInstructionsTool.handle(cline, block as ToolUse<"fetch_instructions">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "list_files":
|
|
await listFilesTool.handle(cline, block as ToolUse<"list_files">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "codebase_search":
|
|
await codebaseSearchTool.handle(cline, block as ToolUse<"codebase_search">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "search_files":
|
|
await searchFilesTool.handle(cline, block as ToolUse<"search_files">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "browser_action":
|
|
await browserActionTool(
|
|
cline,
|
|
block as ToolUse<"browser_action">,
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
)
|
|
break
|
|
case "execute_command":
|
|
await executeCommandTool.handle(cline, block as ToolUse<"execute_command">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "use_mcp_tool":
|
|
await useMcpToolTool.handle(cline, block as ToolUse<"use_mcp_tool">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "access_mcp_resource":
|
|
await accessMcpResourceTool.handle(cline, block as ToolUse<"access_mcp_resource">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "ask_followup_question":
|
|
await askFollowupQuestionTool.handle(cline, block as ToolUse<"ask_followup_question">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "switch_mode":
|
|
await switchModeTool.handle(cline, block as ToolUse<"switch_mode">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "new_task":
|
|
await newTaskTool.handle(cline, block as ToolUse<"new_task">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
toolCallId: block.id,
|
|
})
|
|
break
|
|
case "attempt_completion": {
|
|
const completionCallbacks: AttemptCompletionCallbacks = {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
askFinishSubTaskApproval,
|
|
toolDescription,
|
|
toolProtocol,
|
|
}
|
|
await attemptCompletionTool.handle(
|
|
cline,
|
|
block as ToolUse<"attempt_completion">,
|
|
completionCallbacks,
|
|
)
|
|
break
|
|
}
|
|
case "run_slash_command":
|
|
await runSlashCommandTool.handle(cline, block as ToolUse<"run_slash_command">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
case "generate_image":
|
|
await checkpointSaveAndMark(cline)
|
|
await generateImageTool.handle(cline, block as ToolUse<"generate_image">, {
|
|
askApproval,
|
|
handleError,
|
|
pushToolResult,
|
|
removeClosingTag,
|
|
toolProtocol,
|
|
})
|
|
break
|
|
default: {
|
|
// Handle unknown/invalid tool names OR custom tools
|
|
// This is critical for native protocol where every tool_use MUST have a tool_result
|
|
|
|
// CRITICAL: Don't process partial blocks for unknown tools - just let them stream in.
|
|
// If we try to show errors for partial blocks, we'd show the error on every streaming chunk,
|
|
// creating a loop that appears to freeze the extension. Only handle complete blocks.
|
|
if (block.partial) {
|
|
break
|
|
}
|
|
|
|
const customTool = stateExperiments?.customTools ? customToolRegistry.get(block.name) : undefined
|
|
|
|
if (customTool) {
|
|
try {
|
|
let customToolArgs
|
|
|
|
if (customTool.parameters) {
|
|
try {
|
|
customToolArgs = customTool.parameters.parse(block.nativeArgs || block.params || {})
|
|
} catch (parseParamsError) {
|
|
const message = `Custom tool "${block.name}" argument validation failed: ${parseParamsError.message}`
|
|
console.error(message)
|
|
cline.consecutiveMistakeCount++
|
|
await cline.say("error", message)
|
|
pushToolResult(formatResponse.toolError(message, toolProtocol))
|
|
break
|
|
}
|
|
}
|
|
|
|
const result = await customTool.execute(customToolArgs, {
|
|
mode: mode ?? defaultModeSlug,
|
|
task: cline,
|
|
})
|
|
|
|
console.log(
|
|
`${customTool.name}.execute(): ${JSON.stringify(customToolArgs)} -> ${JSON.stringify(result)}`,
|
|
)
|
|
|
|
pushToolResult(result)
|
|
cline.consecutiveMistakeCount = 0
|
|
} catch (executionError: any) {
|
|
cline.consecutiveMistakeCount++
|
|
await handleError(`executing custom tool "${block.name}"`, executionError)
|
|
}
|
|
|
|
break
|
|
}
|
|
|
|
// Not a custom tool - handle as unknown tool error
|
|
const errorMessage = `Unknown tool "${block.name}". This tool does not exist. Please use one of the available tools.`
|
|
cline.consecutiveMistakeCount++
|
|
cline.recordToolError(block.name as ToolName, errorMessage)
|
|
await cline.say("error", t("tools:unknownToolError", { toolName: block.name }))
|
|
// Push tool_result directly for native protocol WITHOUT setting didAlreadyUseTool
|
|
// This prevents the stream from being interrupted with "Response interrupted by tool use result"
|
|
if (toolProtocol === TOOL_PROTOCOL.NATIVE && toolCallId) {
|
|
cline.userMessageContent.push({
|
|
type: "tool_result",
|
|
tool_use_id: toolCallId,
|
|
content: formatResponse.toolError(errorMessage, toolProtocol),
|
|
is_error: true,
|
|
} as Anthropic.ToolResultBlockParam)
|
|
} else {
|
|
pushToolResult(formatResponse.toolError(errorMessage, toolProtocol))
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
// Seeing out of bounds is fine, it means that the next too call is being
|
|
// built up and ready to add to assistantMessageContent to present.
|
|
// When you see the UI inactive during this, it means that a tool is
|
|
// breaking without presenting any UI. For example the write_to_file tool
|
|
// was breaking when relpath was undefined, and for invalid relpath it never
|
|
// presented UI.
|
|
// This needs to be placed here, if not then calling
|
|
// cline.presentAssistantMessage below would fail (sometimes) since it's
|
|
// locked.
|
|
cline.presentAssistantMessageLocked = false
|
|
|
|
// NOTE: When tool is rejected, iterator stream is interrupted and it waits
|
|
// for `userMessageContentReady` to be true. Future calls to present will
|
|
// skip execution since `didRejectTool` and iterate until `contentIndex` is
|
|
// set to message length and it sets userMessageContentReady to true itself
|
|
// (instead of preemptively doing it in iterator).
|
|
if (!block.partial || cline.didRejectTool || cline.didAlreadyUseTool) {
|
|
// Block is finished streaming and executing.
|
|
if (cline.currentStreamingContentIndex === cline.assistantMessageContent.length - 1) {
|
|
// It's okay that we increment if !didCompleteReadingStream, it'll
|
|
// just return because out of bounds and as streaming continues it
|
|
// will call `presentAssitantMessage` if a new block is ready. If
|
|
// streaming is finished then we set `userMessageContentReady` to
|
|
// true when out of bounds. This gracefully allows the stream to
|
|
// continue on and all potential content blocks be presented.
|
|
// Last block is complete and it is finished executing
|
|
cline.userMessageContentReady = true // Will allow `pWaitFor` to continue.
|
|
}
|
|
|
|
// Call next block if it exists (if not then read stream will call it
|
|
// when it's ready).
|
|
// Need to increment regardless, so when read stream calls this function
|
|
// again it will be streaming the next block.
|
|
cline.currentStreamingContentIndex++
|
|
|
|
if (cline.currentStreamingContentIndex < cline.assistantMessageContent.length) {
|
|
// There are already more content blocks to stream, so we'll call
|
|
// this function ourselves.
|
|
presentAssistantMessage(cline)
|
|
return
|
|
} else {
|
|
// CRITICAL FIX: If we're out of bounds and the stream is complete, set userMessageContentReady
|
|
// This handles the case where assistantMessageContent is empty or becomes empty after processing
|
|
if (cline.didCompleteReadingStream) {
|
|
cline.userMessageContentReady = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Block is partial, but the read stream may have finished.
|
|
if (cline.presentAssistantMessageHasPendingUpdates) {
|
|
presentAssistantMessage(cline)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* save checkpoint and mark done in the current streaming task.
|
|
* @param task The Task instance to checkpoint save and mark.
|
|
* @returns
|
|
*/
|
|
async function checkpointSaveAndMark(task: Task) {
|
|
if (task.currentStreamingDidCheckpoint) {
|
|
return
|
|
}
|
|
try {
|
|
await task.checkpointSave(true)
|
|
task.currentStreamingDidCheckpoint = true
|
|
} catch (error) {
|
|
console.error(`[Task#presentAssistantMessage] Error saving checkpoint: ${error.message}`, error)
|
|
}
|
|
}
|