mirror of
https://github.com/cline/cline.git
synced 2026-09-02 15:52:29 +08:00
Compare commits
20 Commits
fixer
...
bee/subagents
| Author | SHA1 | Date | |
|---|---|---|---|
| fae0c03b7a | |||
| 6106758e95 | |||
| c78835945f | |||
| 2f0dbb2293 | |||
| 06b05ddfe9 | |||
| 2670a4a171 | |||
| 1699c9a63a | |||
| 808dd42ae9 | |||
| d9c6ba57f7 | |||
| 90dae0f820 | |||
| 7e9e714cef | |||
| 4e0d242453 | |||
| c3b31bc225 | |||
| 6775ce0085 | |||
| 603470b1c1 | |||
| 7f1486a975 | |||
| f0af58437b | |||
| 2ad585caea | |||
| ceaa889acf | |||
| 391c07c998 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
Lock the LiteLLM Api Key input when it's remotely configured
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
updated welcome card content and added ability to close each card
|
||||
@@ -1,5 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## [3.55.0]
|
||||
|
||||
- Add new model: Arcee Trinity Large Preview
|
||||
- Add new model: Moonshot Kimi K2.5
|
||||
- Add MCP prompts support - prompts from connected MCP servers now appear in slash command autocomplete as `/mcp:<server>:<prompt>`
|
||||
|
||||
## [3.54.0]
|
||||
|
||||
### Added
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "claude-dev",
|
||||
"version": "3.53.1",
|
||||
"version": "3.55.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "claude-dev",
|
||||
"version": "3.53.1",
|
||||
"version": "3.55.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.37.0",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "3.54.0",
|
||||
"version": "3.55.0",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.84.0"
|
||||
|
||||
@@ -0,0 +1,671 @@
|
||||
import { ApiHandler } from "@/core/api"
|
||||
import { ClineHandler } from "@/core/api/providers/cline"
|
||||
import type { ApiStream } from "@/core/api/transform/stream"
|
||||
import {
|
||||
AgentActions,
|
||||
AgentContext,
|
||||
AgentIterationUpdate,
|
||||
ClineAgentConfig,
|
||||
GeneralToolResult,
|
||||
SubagentApiHandler,
|
||||
SubagentStatusEntry,
|
||||
} from "@/shared/cline/subagent"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import type { ToolResponse } from "../task"
|
||||
import type { TaskConfig } from "../task/tools/types/TaskConfig"
|
||||
import { SubAgentToolDefinition, SubAgentToolResult } from "./tools"
|
||||
import { extractTagContent } from "./utils"
|
||||
|
||||
/**
|
||||
* Abstract base class for agentic loops using ClineHandler.
|
||||
* Subclasses implement domain-specific logic for context management, tool execution, and result formatting.
|
||||
*/
|
||||
export abstract class ClineAgent {
|
||||
protected readonly client: ApiHandler | SubagentApiHandler
|
||||
protected currentIteration: number = 0
|
||||
protected readonly maxIterations: number
|
||||
protected readonly prompt: string
|
||||
protected cost = 0
|
||||
protected readonly tools: Map<string, SubAgentToolDefinition> = new Map()
|
||||
protected taskConfig?: TaskConfig
|
||||
protected readonly abortSignal?: AbortSignal
|
||||
private statusHistory: SubagentStatusEntry[] = []
|
||||
private toolFormatErrors: string[] = []
|
||||
private static readonly MAX_FORMAT_ERRORS = 3
|
||||
|
||||
private static activeAgents = new Set<ClineAgent>()
|
||||
|
||||
/**
|
||||
* Returns the accumulated status history for this agent
|
||||
*/
|
||||
public getStatusHistory(): SubagentStatusEntry[] {
|
||||
return this.statusHistory
|
||||
}
|
||||
|
||||
constructor(private config: ClineAgentConfig) {
|
||||
const modelClient = new ClineHandler({ openRouterModelId: config.modelId, ...config.apiParams })
|
||||
const mainClient = config.client ?? modelClient
|
||||
this.client = !config.modelId ? modelClient : mainClient
|
||||
this.maxIterations = config.maxIterations ?? 3
|
||||
this.prompt = config.prompt
|
||||
this.abortSignal = config.abortSignal
|
||||
ClineAgent.activeAgents.add(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects and resets costs from all active agents
|
||||
*/
|
||||
static getAllAgentCosts(): number {
|
||||
let totalCost = 0
|
||||
for (const agent of ClineAgent.activeAgents) {
|
||||
totalCost += agent.cost
|
||||
agent.cost = 0
|
||||
}
|
||||
ClineAgent.activeAgents.clear()
|
||||
if (totalCost > 0) {
|
||||
Logger.debug(`Total cost across all agents: $${totalCost.toFixed(4)}`)
|
||||
}
|
||||
return totalCost
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers tools for this agent
|
||||
* @param toolDefinitions - Array of tool definitions to register
|
||||
*/
|
||||
protected registerTools(toolDefinitions: SubAgentToolDefinition[]): void {
|
||||
for (const tool of toolDefinitions) {
|
||||
this.tools.set(tool.title, tool)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the task config for this agent (required for tool execution)
|
||||
* @param taskConfig - The task configuration
|
||||
*/
|
||||
public setTaskConfig(taskConfig: TaskConfig): void {
|
||||
this.taskConfig = taskConfig
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts tool calls from agent response based on registered tools.
|
||||
* Parses the response for tool tags and extracts subtag values.
|
||||
* Tracks format errors when subtags are missing and provides feedback.
|
||||
* @param response - The agent's response text
|
||||
* @returns Map of tool tag to array of extracted input values
|
||||
*/
|
||||
protected extractToolCalls(response: string): Map<string, string[]> {
|
||||
const toolCallsMap = new Map<string, string[]>()
|
||||
|
||||
for (const [toolTag, toolDef] of this.tools) {
|
||||
const toolPattern = new RegExp(`<${toolTag}>([\\s\\S]*?)</${toolTag}>`, "gs")
|
||||
const subTagPattern = new RegExp(`<${toolDef.tag}>([\\s\\S]*?)</${toolDef.tag}>`, "gs")
|
||||
const inputs: string[] = []
|
||||
|
||||
for (const toolMatch of response.matchAll(toolPattern)) {
|
||||
const toolContent = toolMatch[1]
|
||||
|
||||
// First try to extract from subtag (correct format)
|
||||
const subTagMatches = [...toolContent.matchAll(subTagPattern)]
|
||||
if (subTagMatches.length > 0) {
|
||||
for (const subTagMatch of subTagMatches) {
|
||||
const value = subTagMatch[1].trim()
|
||||
if (value) {
|
||||
inputs.push(value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Missing subtag - record the error but still try to use the content
|
||||
const value = toolContent.trim()
|
||||
if (value) {
|
||||
const errorMsg = `<${toolTag}> missing required <${toolDef.tag}> subtag. You wrote: <${toolTag}>${value.slice(0, 50)}${value.length > 50 ? "..." : ""}</${toolTag}>. Correct format: <${toolTag}><${toolDef.tag}>${value.slice(0, 30)}${value.length > 30 ? "..." : ""}</${toolDef.tag}></${toolTag}>`
|
||||
this.toolFormatErrors.push(errorMsg)
|
||||
Logger.warn(`[ClineAgent] Tool format error: ${errorMsg}`)
|
||||
// Still use the content as fallback so the agent can make progress
|
||||
inputs.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.length > 0) {
|
||||
toolCallsMap.set(toolTag, inputs)
|
||||
}
|
||||
}
|
||||
|
||||
return toolCallsMap
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns accumulated tool format errors and whether max errors reached
|
||||
*/
|
||||
protected getToolFormatErrorFeedback(): { errors: string[]; maxErrorsReached: boolean } {
|
||||
return {
|
||||
errors: [...this.toolFormatErrors],
|
||||
maxErrorsReached: this.toolFormatErrors.length >= ClineAgent.MAX_FORMAT_ERRORS,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears tool format errors (call after providing feedback)
|
||||
*/
|
||||
protected clearToolFormatErrors(): void {
|
||||
this.toolFormatErrors = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a tool by its tag name
|
||||
* @param toolTag - The tool tag (e.g., "TOOLFILE", "TOOLSEARCH")
|
||||
* @param inputs - Array of input values for the tool
|
||||
* @returns Promise resolving to the tool execution result
|
||||
*/
|
||||
protected async executeToolByTag(toolTag: string, inputs: string[]): Promise<SubAgentToolResult> {
|
||||
const tool = this.tools.get(toolTag)
|
||||
if (!tool) {
|
||||
throw new Error(`Tool with tag "${toolTag}" not found in registered tools`)
|
||||
}
|
||||
if (!this.taskConfig) {
|
||||
throw new Error(`TaskConfig not set. Call setTaskConfig() before executing tools.`)
|
||||
}
|
||||
return await tool.execute(inputs, this.taskConfig)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the system prompt for the agent
|
||||
* @param userInput - The user's input/query
|
||||
* @param contextPrompt - The current context prompt
|
||||
*/
|
||||
abstract buildSystemPrompt(userInput: string, contextPrompt: string): string
|
||||
|
||||
/**
|
||||
* Builds the context prompt for the current iteration
|
||||
* @param context - The current agent context
|
||||
* @param iteration - Current iteration number (0-indexed)
|
||||
* @param formatErrorFeedback - Optional feedback about tool format errors
|
||||
*/
|
||||
abstract buildContextPrompt(context: AgentContext, iteration: number, formatErrorFeedback?: string): string
|
||||
|
||||
/**
|
||||
* Generic implementation of extractActions using registered tools and config tags.
|
||||
* Extracts tool calls based on registered tools, context files, and ready-to-answer status.
|
||||
* @param response - The full response text from the agent
|
||||
*/
|
||||
protected extractActions(response: string): AgentActions {
|
||||
// Extract result content if contextTag is configured (this is the agent's answer/result text)
|
||||
const resultContent = this.config.contextTag ? extractTagContent(response, this.config.contextTag) : []
|
||||
|
||||
// Check if ready to answer if answerTag is configured
|
||||
const isReadyToAnswer = this.config.answerTag ? response.includes(`<${this.config.answerTag}>`) : false
|
||||
|
||||
// Extract tool calls based on registered tools
|
||||
const toolCallsMap = this.extractToolCalls(response)
|
||||
|
||||
// Convert tool calls map to array format
|
||||
const toolCalls: unknown[] = []
|
||||
for (const [toolTag, inputs] of toolCallsMap) {
|
||||
for (const input of inputs) {
|
||||
toolCalls.push({ toolTag, input })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
toolCalls,
|
||||
resultContent,
|
||||
isReadyToAnswer,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic tool execution that groups tool calls by tag and executes them in parallel.
|
||||
* This is the recommended implementation for most agents.
|
||||
* @param toolCallsMap - Map of tool tag to array of input values (use extractToolCalls to get this)
|
||||
* @returns Promise resolving to map of tool tag to results
|
||||
*/
|
||||
protected async executeToolsByTag(toolCallsMap: Map<string, string[]>): Promise<Map<string, unknown>> {
|
||||
const startTime = performance.now()
|
||||
const entries = Array.from(toolCallsMap.entries())
|
||||
|
||||
// Execute all tools in parallel
|
||||
const results = await Promise.all(entries.map(([toolTag, inputs]) => this.executeToolByTag(toolTag, inputs)))
|
||||
|
||||
// Build result map
|
||||
const resultsByTag = new Map(entries.map(([toolTag], i) => [toolTag, results[i]]))
|
||||
|
||||
const totalCalls = entries.reduce((sum, [, inputs]) => sum + inputs.length, 0)
|
||||
const duration = performance.now() - startTime
|
||||
await this.onIterationUpdate({
|
||||
message: `Executed ${totalCalls} tool calls in ${duration.toFixed(0)}ms`,
|
||||
})
|
||||
|
||||
return resultsByTag
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes tool calls in parallel
|
||||
* @param toolCalls - Array of tool calls to execute
|
||||
* @returns Promise resolving to array of tool results
|
||||
*/
|
||||
async executeTools(toolCalls: unknown[]): Promise<unknown[]> {
|
||||
try {
|
||||
// Group tool calls by toolTag
|
||||
const toolsByTag = new Map<string, string[]>()
|
||||
for (const toolCall of toolCalls) {
|
||||
if (typeof toolCall === "object" && toolCall !== null) {
|
||||
const { toolTag, input } = toolCall as { toolTag: string; input: string }
|
||||
const existing = toolsByTag.get(toolTag)
|
||||
if (existing) {
|
||||
existing.push(input)
|
||||
} else {
|
||||
toolsByTag.set(toolTag, [input])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute all tools in parallel
|
||||
const resultsByTag = await this.executeToolsByTag(toolsByTag)
|
||||
|
||||
// Reconstruct results in original order
|
||||
const results: unknown[] = []
|
||||
const indexByTag = new Map<string, number>()
|
||||
for (const toolCall of toolCalls) {
|
||||
if (typeof toolCall === "object" && toolCall !== null) {
|
||||
const { toolTag } = toolCall as { toolTag: string; input: string }
|
||||
const index = indexByTag.get(toolTag) ?? 0
|
||||
const toolResults = resultsByTag.get(toolTag) as unknown[]
|
||||
results.push(toolResults[index])
|
||||
indexByTag.set(toolTag, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
} catch (error) {
|
||||
Logger.error(`[ClineAgent] failed with ${error.toString()}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads context files in parallel
|
||||
* @param filePaths - Array of file paths to read
|
||||
* @returns Promise resolving to a map of file path to content
|
||||
*/
|
||||
abstract readContextFiles(filePaths: string[]): Promise<Map<string, string>>
|
||||
|
||||
/**
|
||||
* Updates the context with new tool results
|
||||
* @param context - Current context
|
||||
* @param toolCalls - Tool calls that were executed
|
||||
* @param toolResults - Results from tool execution
|
||||
* @returns Whether new context was found
|
||||
*/
|
||||
abstract updateContextWithToolResults(context: AgentContext, toolCalls: unknown[], toolResults: unknown[]): boolean
|
||||
|
||||
/**
|
||||
* Updates the context with file contents
|
||||
* @param context - Current context
|
||||
* @param fileContents - Map of file path to content
|
||||
*/
|
||||
protected updateContextWithFiles(context: AgentContext, fileContents: Map<string, string>): void {
|
||||
for (const [filePath, content] of fileContents) {
|
||||
context.fileContents.set(filePath, content)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the agent should continue iterating
|
||||
* @param context - Current context
|
||||
* @param iteration - Current iteration number
|
||||
* @param foundNewContext - Whether new context was found in this iteration
|
||||
* @param isReadyToAnswer - Whether the agent is ready to answer
|
||||
*/
|
||||
abstract shouldContinue(context: AgentContext, foundNewContext: boolean, isReadyToAnswer: boolean): boolean
|
||||
|
||||
/**
|
||||
* Formats the final result from the context
|
||||
* @param context - Final context state
|
||||
*/
|
||||
abstract formatResult(context: AgentContext): ToolResponse
|
||||
|
||||
/**
|
||||
* Creates the initial context for the agent
|
||||
*/
|
||||
private createInitialContext(): AgentContext {
|
||||
return {
|
||||
filePaths: new Set<string>(),
|
||||
searchResults: new Map<string, GeneralToolResult>(),
|
||||
fileContents: new Map<string, string>(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the task has been aborted/cancelled.
|
||||
* Checks both the AbortSignal (if provided) and taskState.abort as fallback.
|
||||
* @returns true if the task should stop execution
|
||||
*/
|
||||
protected isAborted(): boolean {
|
||||
// Check AbortSignal first (preferred method)
|
||||
if (this.abortSignal?.aborted) {
|
||||
return true
|
||||
}
|
||||
// Fallback to taskState.abort for backwards compatibility
|
||||
return this.taskConfig?.taskState.abort ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a streaming response and accumulates text and cost.
|
||||
* Will exit early if task is aborted.
|
||||
*/
|
||||
private async processStream(stream: ApiStream): Promise<string> {
|
||||
const parts: string[] = []
|
||||
|
||||
for await (const msg of stream) {
|
||||
// Check for cancellation during streaming
|
||||
if (this.isAborted()) {
|
||||
break
|
||||
}
|
||||
|
||||
if (msg.type === "text") {
|
||||
parts.push(msg.text)
|
||||
}
|
||||
|
||||
if (msg.type === "usage" && msg.totalCost) {
|
||||
this.cost += msg.totalCost
|
||||
await this.onIterationUpdate({
|
||||
cost: msg.totalCost,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("")
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the agentic loop
|
||||
* @param userInput - The user's input/query
|
||||
* @returns Promise resolving to the final result
|
||||
*/
|
||||
public async execute(userInput: string): Promise<ToolResponse> {
|
||||
const startTime = performance.now()
|
||||
const context = this.createInitialContext()
|
||||
|
||||
for (let iteration = 0; iteration < this.maxIterations; iteration++) {
|
||||
// Check for cancellation at the start of each iteration
|
||||
if (this.isAborted()) {
|
||||
Logger.debug("[ClineAgent] execution aborted before iteration " + (iteration + 1))
|
||||
break
|
||||
}
|
||||
|
||||
// Check if max format errors reached
|
||||
const { errors: formatErrors, maxErrorsReached } = this.getToolFormatErrorFeedback()
|
||||
if (maxErrorsReached) {
|
||||
Logger.error(`[ClineAgent] Max tool format errors (${ClineAgent.MAX_FORMAT_ERRORS}) reached, ending loop`)
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
this.currentIteration = iteration
|
||||
|
||||
// Build format error feedback if any
|
||||
let formatErrorFeedback: string | undefined
|
||||
if (formatErrors.length > 0) {
|
||||
formatErrorFeedback = `## ⚠️ TOOL FORMAT ERRORS (${formatErrors.length}/${ClineAgent.MAX_FORMAT_ERRORS} max)\nYour previous tool calls had incorrect format. Fix these issues:\n${formatErrors.map((e, i) => `${i + 1}. ${e}`).join("\n")}\n\nREMEMBER: Always use <TOOL><subtag>value</subtag></TOOL> format!`
|
||||
|
||||
// Add error entries to status history for UI display
|
||||
for (const error of formatErrors) {
|
||||
this.statusHistory.push({
|
||||
iteration: this.currentIteration + 1,
|
||||
maxIterations: this.maxIterations,
|
||||
timestamp: Date.now(),
|
||||
status: `Format error: ${error.slice(0, 100)}${error.length > 100 ? "..." : ""}`,
|
||||
type: "error",
|
||||
})
|
||||
}
|
||||
|
||||
this.clearToolFormatErrors()
|
||||
}
|
||||
|
||||
// Build context prompt and system prompt
|
||||
const contextPrompt = this.buildContextPrompt(context, iteration, formatErrorFeedback)
|
||||
const systemPrompt = this.buildSystemPrompt(userInput, contextPrompt)
|
||||
|
||||
// Create messages
|
||||
const messages: ClineStorageMessage[] = this.config.messages
|
||||
? [...(this.config.messages as ClineStorageMessage[]), { role: "user", content: userInput }]
|
||||
: [{ role: "user", content: userInput }]
|
||||
|
||||
// Stream the LLM response
|
||||
const stream = this.client.createMessage(systemPrompt, messages) as ApiStream
|
||||
const fullResponse = await this.processStream(stream)
|
||||
|
||||
// Check for cancellation after streaming completes
|
||||
if (this.isAborted()) {
|
||||
Logger.debug("[ClineAgent] execution aborted after streaming")
|
||||
break
|
||||
}
|
||||
|
||||
Logger.debug(`[ClineAgent] ${this.config.callId} Iteration ${this.currentIteration} response:`, fullResponse)
|
||||
|
||||
// Extract actions from response
|
||||
const actions = this.extractActions(fullResponse)
|
||||
|
||||
// Send iteration update
|
||||
await this.onIterationUpdate({
|
||||
actions,
|
||||
context,
|
||||
})
|
||||
|
||||
// If ready to answer and has context files, read them first
|
||||
if (actions.isReadyToAnswer && actions.resultContent.length > 0) {
|
||||
Logger.debug(
|
||||
`[ClineAgent] Reading ${actions.resultContent.length} files: ${actions.resultContent.join(", ")}`,
|
||||
)
|
||||
// Add context files to filePaths so they're available in formatResult
|
||||
for (const file of actions.resultContent) {
|
||||
context.filePaths.add(file)
|
||||
}
|
||||
const fileContents = await this.readContextFiles(actions.resultContent)
|
||||
this.updateContextWithFiles(context, fileContents)
|
||||
break
|
||||
}
|
||||
|
||||
// If ready to answer without context files, break immediately
|
||||
if (actions.isReadyToAnswer) {
|
||||
Logger.log("Agent determined it has enough context to answer.")
|
||||
break
|
||||
}
|
||||
|
||||
// If no tool calls, end the loop
|
||||
if (actions.toolCalls.length === 0) {
|
||||
Logger.log("No tool calls generated, ending loop.")
|
||||
break
|
||||
}
|
||||
|
||||
// Check for cancellation before tool execution
|
||||
if (this.isAborted()) {
|
||||
Logger.debug("Agent execution aborted before tool execution")
|
||||
break
|
||||
}
|
||||
|
||||
// Execute tools in parallel
|
||||
const toolResults = await this.executeTools(actions.toolCalls)
|
||||
|
||||
// Check for cancellation after tool execution
|
||||
if (this.isAborted()) {
|
||||
Logger.debug("Agent execution aborted after tool execution")
|
||||
break
|
||||
}
|
||||
|
||||
// Update context with tool results
|
||||
const foundNewContext = this.updateContextWithToolResults(context, actions.toolCalls, toolResults)
|
||||
|
||||
// Check if we should continue
|
||||
if (!this.shouldContinue(context, foundNewContext, false)) {
|
||||
Logger.log("Agent determined it should stop iterating.")
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error("[ClineAgent] Error during agent iteration: ", error as Error)
|
||||
}
|
||||
}
|
||||
const duration = performance.now() - startTime
|
||||
Logger.debug("Agent completed in " + duration)
|
||||
|
||||
// Format and return final result
|
||||
return this.formatResult(context)
|
||||
}
|
||||
|
||||
private async onIterationUpdate(update: AgentIterationUpdate) {
|
||||
// Create status entries based on the update
|
||||
const entries = this.createStatusEntries(update)
|
||||
this.statusHistory.push(...entries)
|
||||
|
||||
const partialMessage: ClineSayTool = {
|
||||
tool: "subagent",
|
||||
path: undefined,
|
||||
content: JSON.stringify(this.statusHistory),
|
||||
regex: undefined,
|
||||
filePattern: this.prompt,
|
||||
operationIsLocatedInWorkspace: true,
|
||||
}
|
||||
|
||||
// Try to replace existing message content by call id
|
||||
const replaced = await this.taskConfig?.callbacks.replaceMessageContentByUid(
|
||||
this.config.callId,
|
||||
JSON.stringify(partialMessage),
|
||||
!update.actions?.isReadyToAnswer,
|
||||
)
|
||||
// Fall back to creating a new partial message if ts not found
|
||||
if (!replaced) {
|
||||
await this.taskConfig?.callbacks.say(
|
||||
"tool",
|
||||
JSON.stringify(partialMessage),
|
||||
undefined,
|
||||
undefined,
|
||||
update.actions?.isReadyToAnswer,
|
||||
this.config.callId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates status entries from an AgentIterationUpdate
|
||||
*/
|
||||
private createStatusEntries(update: AgentIterationUpdate): SubagentStatusEntry[] {
|
||||
const entries: SubagentStatusEntry[] = []
|
||||
const baseEntry = {
|
||||
iteration: this.currentIteration + 1,
|
||||
maxIterations: this.maxIterations,
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
if (update.message) {
|
||||
entries.push({
|
||||
...baseEntry,
|
||||
status: update.message,
|
||||
type: "message",
|
||||
})
|
||||
}
|
||||
|
||||
if (update.cost !== undefined) {
|
||||
entries.push({
|
||||
...baseEntry,
|
||||
status: `Cost: $${update.cost.toFixed(4)}`,
|
||||
type: "cost",
|
||||
})
|
||||
}
|
||||
|
||||
if (update.actions) {
|
||||
const contextFileCount = update.actions.resultContent.length
|
||||
|
||||
if (update.actions.isReadyToAnswer) {
|
||||
let status = "Ready to answer"
|
||||
if (contextFileCount > 0) {
|
||||
status += ` with ${contextFileCount} file${contextFileCount > 1 ? "s" : ""}`
|
||||
}
|
||||
entries.push({
|
||||
...baseEntry,
|
||||
status,
|
||||
type: "ready",
|
||||
})
|
||||
} else if (update.actions.toolCalls.length > 0) {
|
||||
// Create individual entries for each tool call type
|
||||
const toolEntries = this.createToolCallEntries(update.actions.toolCalls, baseEntry)
|
||||
entries.push(...toolEntries)
|
||||
}
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates status entries for tool calls, grouped by type
|
||||
*/
|
||||
private createToolCallEntries(
|
||||
toolCalls: unknown[],
|
||||
baseEntry: { iteration: number; maxIterations: number; timestamp: number },
|
||||
): SubagentStatusEntry[] {
|
||||
const MAX_INPUT_LENGTH = 50
|
||||
const truncate = (str: string, maxLen: number) => (str.length > maxLen ? str.slice(0, maxLen - 1) + "…" : str)
|
||||
|
||||
const entries: SubagentStatusEntry[] = []
|
||||
|
||||
// Group tool calls by type
|
||||
const searches: string[] = []
|
||||
const files: string[] = []
|
||||
const commands: string[] = []
|
||||
const webFetches: string[] = []
|
||||
|
||||
for (const call of toolCalls) {
|
||||
if (typeof call === "object" && call !== null) {
|
||||
const { toolTag, input } = call as { toolTag: string; input: string }
|
||||
switch (toolTag) {
|
||||
case "TOOLSEARCH":
|
||||
searches.push(input)
|
||||
break
|
||||
case "TOOLFILE":
|
||||
files.push(input)
|
||||
break
|
||||
case "TOOLBASH":
|
||||
commands.push(input)
|
||||
break
|
||||
case "TOOLWEBFETCH":
|
||||
webFetches.push(input)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searches.length > 0) {
|
||||
const firstQuery = truncate(searches[0], MAX_INPUT_LENGTH)
|
||||
const status =
|
||||
searches.length === 1 ? `Searching: "${firstQuery}"` : `Searching: "${firstQuery}" +${searches.length - 1} more`
|
||||
entries.push({ ...baseEntry, status, type: "searching" })
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
const fileName = files[0].split("/").pop() || files[0]
|
||||
const status = files.length === 1 ? `Reading: ${fileName}` : `Reading: ${fileName} +${files.length - 1} more`
|
||||
entries.push({ ...baseEntry, status, type: "reading" })
|
||||
}
|
||||
|
||||
if (commands.length > 0) {
|
||||
const firstCmd = truncate(commands[0], MAX_INPUT_LENGTH)
|
||||
const status = commands.length === 1 ? `Running: ${firstCmd}` : `Running: ${firstCmd} +${commands.length - 1} more`
|
||||
entries.push({ ...baseEntry, status, type: "running" })
|
||||
}
|
||||
|
||||
if (webFetches.length > 0) {
|
||||
let status: string
|
||||
try {
|
||||
const parsed = JSON.parse(webFetches[0])
|
||||
const url = new URL(parsed.url).hostname
|
||||
status = webFetches.length === 1 ? `Fetching: ${url}` : `Fetching: ${url} +${webFetches.length - 1} more`
|
||||
} catch {
|
||||
status = `Fetching ${webFetches.length} URL${webFetches.length > 1 ? "s" : ""}`
|
||||
}
|
||||
entries.push({ ...baseEntry, status, type: "fetching" })
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
import type { ApiHandler } from "@/core/api"
|
||||
import { AgentContext, GeneralToolResult } from "@/shared/cline/subagent"
|
||||
import { ToolResponse } from "../task"
|
||||
import type { TaskConfig } from "../task/tools/types/TaskConfig"
|
||||
import { ClineAgent } from "./ClineAgent"
|
||||
import { TASK_AGENT_TOOLS } from "./tools"
|
||||
import { buildToolsPlaceholder } from "./utils"
|
||||
|
||||
export const TASK_ACTIONS_TAGS = {
|
||||
ANSWER: `task_complete`,
|
||||
RESULT: `task_result`,
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskAgent extends ClineAgent to provide autonomous task execution functionality.
|
||||
* It can perform multi-step research and exploration tasks using search and bash tools,
|
||||
* returning a final result to the calling agent.
|
||||
*/
|
||||
export class Subagent extends ClineAgent {
|
||||
constructor(
|
||||
callId: string,
|
||||
prompt: string,
|
||||
taskConfig: TaskConfig,
|
||||
maxIterations: number = 30,
|
||||
systemPrompt?: string,
|
||||
client?: ApiHandler,
|
||||
abortSignal?: AbortSignal,
|
||||
) {
|
||||
super({
|
||||
callId,
|
||||
client,
|
||||
modelId: "moonshotai/kimi-k2.5", // Not used when client is provided
|
||||
maxIterations,
|
||||
prompt,
|
||||
systemPrompt,
|
||||
contextTag: TASK_ACTIONS_TAGS.RESULT,
|
||||
answerTag: TASK_ACTIONS_TAGS.ANSWER,
|
||||
abortSignal,
|
||||
})
|
||||
this.setTaskConfig(taskConfig)
|
||||
this.registerTools(TASK_AGENT_TOOLS)
|
||||
}
|
||||
|
||||
buildSystemPrompt(userInput: string, contextPrompt: string): string {
|
||||
return buildTaskAgentSystemPrompt(userInput, contextPrompt, TASK_ACTIONS_TAGS)
|
||||
}
|
||||
|
||||
buildContextPrompt(context: AgentContext, iteration: number, formatErrorFeedback?: string): string {
|
||||
// Include format error feedback at the top if present
|
||||
const errorSection = formatErrorFeedback ? `${formatErrorFeedback}\n\n` : ""
|
||||
|
||||
if (iteration === 0 || (context.searchResults.size === 0 && context.fileContents.size === 0)) {
|
||||
return errorSection + "No context retrieved yet. Use the available tools to gather information."
|
||||
}
|
||||
|
||||
const MAX_RESULTS_TO_SHOW = 10
|
||||
const contextParts: string[] = []
|
||||
const successfulSearches: string[] = []
|
||||
const failedSearches: { query: string; error?: string }[] = []
|
||||
const successfulCommands: string[] = []
|
||||
const failedCommands: { query: string; error?: string }[] = []
|
||||
|
||||
// Process search results
|
||||
let shownSearchResults = 0
|
||||
for (const [query, result] of context.searchResults) {
|
||||
if (result.agent === "TOOLSEARCH") {
|
||||
if (result.success) {
|
||||
successfulSearches.push(query)
|
||||
if (shownSearchResults < MAX_RESULTS_TO_SHOW) {
|
||||
contextParts.push(`### Search: "${query}"\n${result.result}`)
|
||||
shownSearchResults++
|
||||
} else {
|
||||
contextParts.push(`### Search: "${query}"\nFound results (truncated)`)
|
||||
}
|
||||
} else {
|
||||
failedSearches.push({ query, error: result.error })
|
||||
}
|
||||
} else if (result.agent === "TOOLBASH") {
|
||||
if (result.success) {
|
||||
successfulCommands.push(query)
|
||||
contextParts.push(`### Command: \`${query}\`\n\`\`\`\n${result.result}\n\`\`\``)
|
||||
} else {
|
||||
failedCommands.push({ query, error: result.error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add file contents
|
||||
for (const [filePath, content] of context.fileContents) {
|
||||
contextParts.push(`### File: ${filePath}\n\`\`\`\n${content}\n\`\`\``)
|
||||
}
|
||||
|
||||
// Build header
|
||||
const totalSearches = successfulSearches.length + failedSearches.length
|
||||
const totalFiles = context.fileContents.size
|
||||
const totalCommands = successfulCommands.length + failedCommands.length
|
||||
const parts = [`## Retrieved Context\nSearches: ${totalSearches} | Files: ${totalFiles} | Commands: ${totalCommands}\n`]
|
||||
|
||||
// Add search history
|
||||
if (successfulSearches.length > 0 || failedSearches.length > 0) {
|
||||
parts.push("\n**Previously executed searches (avoid duplicating):**")
|
||||
successfulSearches.forEach((q) => parts.push(`- "${q}" ✓`))
|
||||
failedSearches.forEach(({ query, error }) =>
|
||||
parts.push(`- "${query}" ✗ ${error ? `(error: ${error})` : "(no results)"}`),
|
||||
)
|
||||
parts.push("")
|
||||
}
|
||||
|
||||
// Add command history
|
||||
if (successfulCommands.length > 0 || failedCommands.length > 0) {
|
||||
parts.push("\n**Previously executed commands:**")
|
||||
successfulCommands.forEach((cmd) => parts.push(`- \`${cmd}\` ✓`))
|
||||
failedCommands.forEach(({ query, error }) =>
|
||||
parts.push(`- \`${query}\` ✗ ${error ? `(error: ${error})` : "(failed)"}`),
|
||||
)
|
||||
parts.push("")
|
||||
}
|
||||
|
||||
return errorSection + parts.join("\n") + contextParts.join("\n\n")
|
||||
}
|
||||
|
||||
async readContextFiles(filePaths: string[]): Promise<Map<string, string>> {
|
||||
const fileResults = (await this.executeToolByTag("TOOLFILE", filePaths)) as GeneralToolResult[]
|
||||
const fileContents = new Map<string, string>()
|
||||
|
||||
for (const fileResult of fileResults) {
|
||||
if (fileResult.success) {
|
||||
fileContents.set(fileResult.query, fileResult.result)
|
||||
}
|
||||
}
|
||||
|
||||
return fileContents
|
||||
}
|
||||
|
||||
public updateContextWithToolResults(context: AgentContext, toolCalls: unknown[], toolResults: unknown[]): boolean {
|
||||
const initialSearchCount = context.searchResults.size
|
||||
const initialFileCount = context.fileContents.size
|
||||
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
const toolCall = toolCalls[i]
|
||||
const toolResult = toolResults[i]
|
||||
|
||||
if (typeof toolCall === "object" && toolCall !== null) {
|
||||
const { toolTag, input } = toolCall as { toolTag: string; input: string }
|
||||
|
||||
if (toolTag === "TOOLSEARCH" && toolResult) {
|
||||
const result = toolResult as GeneralToolResult
|
||||
// Store both successful and failed results so the agent knows what was tried
|
||||
if (result.success) {
|
||||
this.extractFilePathsFromResult(result).forEach((fp) => context.filePaths.add(fp))
|
||||
}
|
||||
context.searchResults.set(input, result)
|
||||
} else if (toolTag === "TOOLFILE" && toolResult) {
|
||||
const fileResult = toolResult as GeneralToolResult
|
||||
if (fileResult.success && !context.fileContents.has(fileResult.query)) {
|
||||
context.fileContents.set(fileResult.query, fileResult.result)
|
||||
}
|
||||
} else if (toolTag === "TOOLBASH" && toolResult) {
|
||||
const bashResult = toolResult as GeneralToolResult
|
||||
// Store bash results in searchResults map for context tracking
|
||||
context.searchResults.set(input, bashResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return context.searchResults.size > initialSearchCount || context.fileContents.size > initialFileCount
|
||||
}
|
||||
|
||||
public shouldContinue(_context: AgentContext, foundNewContext: boolean, isReadyToAnswer: boolean): boolean {
|
||||
// Continue if not ready to answer and either found new context or haven't exhausted iterations
|
||||
return !isReadyToAnswer && foundNewContext && this.currentIteration < this.maxIterations - 1
|
||||
}
|
||||
|
||||
public formatResult(context: AgentContext): ToolResponse {
|
||||
// If the agent provided a result text, return it directly
|
||||
if (context.resultText) {
|
||||
return context.resultText
|
||||
}
|
||||
|
||||
// Fallback: collect all gathered information
|
||||
const parts: string[] = []
|
||||
|
||||
// Add file contents
|
||||
if (context.fileContents.size > 0) {
|
||||
parts.push(`## Files Read (${context.fileContents.size})`)
|
||||
for (const [filePath, content] of context.fileContents) {
|
||||
parts.push(`### ${filePath}\n\`\`\`\n${content}\n\`\`\``)
|
||||
}
|
||||
}
|
||||
|
||||
// Add search results summary
|
||||
const searchResults = Array.from(context.searchResults.entries()).filter(([_, r]) => r.agent === "TOOLSEARCH")
|
||||
if (searchResults.length > 0) {
|
||||
parts.push(`## Search Results (${searchResults.length})`)
|
||||
for (const [query, result] of searchResults) {
|
||||
if (result.success) {
|
||||
parts.push(`### Search: "${query}"\n${result.result}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add bash command results
|
||||
const bashResults = Array.from(context.searchResults.entries()).filter(([_, r]) => r.agent === "TOOLBASH")
|
||||
if (bashResults.length > 0) {
|
||||
parts.push(`## Command Results (${bashResults.length})`)
|
||||
for (const [cmd, result] of bashResults) {
|
||||
if (result.success) {
|
||||
parts.push(`### \`${cmd}\`\n\`\`\`\n${result.result}\n\`\`\``)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return "Task completed but no results were gathered."
|
||||
}
|
||||
|
||||
return parts.join("\n\n")
|
||||
}
|
||||
|
||||
private extractFilePathsFromResult(result: GeneralToolResult): string[] {
|
||||
return result.result
|
||||
.split("\n")
|
||||
.map((line: string) => line.trim())
|
||||
.filter((line: string) => line && !line.startsWith("│") && !line.startsWith("Found ") && !line.startsWith("Showing "))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete system prompt for TaskAgent.
|
||||
*/
|
||||
function buildTaskAgentSystemPrompt(userInput: string, contextPrompt: string, actionsTags: typeof TASK_ACTIONS_TAGS): string {
|
||||
const toolsPlaceholder = buildToolsPlaceholder(TASK_AGENT_TOOLS)
|
||||
|
||||
return `You are an autonomous task execution agent. Your job is to complete the given task by gathering information and performing research using the available tools.
|
||||
|
||||
## YOUR TASK
|
||||
${userInput}
|
||||
|
||||
## CURRENT CONTEXT
|
||||
${contextPrompt}
|
||||
|
||||
## TOOLS
|
||||
Available tools:
|
||||
${toolsPlaceholder}
|
||||
|
||||
## RESPONSE FORMAT - CRITICAL
|
||||
Your response must contain ONLY XML tags. No explanations, no markdown, no text outside tags.
|
||||
|
||||
### Tool Format (REQUIRED):
|
||||
Each tool MUST use its outer tag AND inner subtag. The subtag contains the actual input.
|
||||
|
||||
CORRECT format examples:
|
||||
- <TOOLSEARCH><query>class DatabaseController</query></TOOLSEARCH>
|
||||
- <TOOLFILE><name>src/config.ts</name></TOOLFILE>
|
||||
- <TOOLBASH><command>ls -la</command></TOOLBASH>
|
||||
- <TOOLWEBFETCH><url>https://example.com</url></TOOLWEBFETCH>
|
||||
|
||||
WRONG (missing subtag - will NOT work):
|
||||
- <TOOLSEARCH>class DatabaseController</TOOLSEARCH>
|
||||
- <TOOLBASH>ls -la</TOOLBASH>
|
||||
|
||||
### When task is complete:
|
||||
<${actionsTags.RESULT}>Your detailed findings here</${actionsTags.RESULT}><${actionsTags.ANSWER}>
|
||||
|
||||
### When you need more information:
|
||||
Use one or more tool tags with proper subtags.
|
||||
|
||||
## RULES
|
||||
1. Work autonomously - gather all information needed to complete the task
|
||||
2. Use search to find relevant files, then read them to understand the code
|
||||
3. Use bash commands for system operations, git commands, or exploring the filesystem
|
||||
4. Be thorough - check multiple sources before concluding
|
||||
5. Your final <${actionsTags.RESULT}> should contain a complete, actionable answer
|
||||
6. Response must be ONLY tags with correct subtag structure
|
||||
7. DO NOT repeat searches or commands you've already executed
|
||||
|
||||
## IMPORTANT
|
||||
- You cannot ask questions - work with what you have
|
||||
- Your output will be returned to the calling agent, so be comprehensive
|
||||
- If you cannot complete the task, explain what you found and what's missing
|
||||
- ALWAYS use the correct tag format: <TOOL><subtag>value</subtag></TOOL>
|
||||
|
||||
Remember: Your response will be parsed by a bot. Only include the expected tags with proper subtag structure.`
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { spawn } from "node:child_process"
|
||||
import { extractFileContent } from "@/integrations/misc/extract-file-content"
|
||||
import { regexSearchFiles } from "@/services/ripgrep"
|
||||
import { GeneralToolResult } from "@/shared/cline/subagent"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { webfetch } from "../task/tools/handlers/WebFetchToolHandler"
|
||||
import { TaskConfig } from "../task/tools/types/TaskConfig"
|
||||
import { resolveWorkspacePath } from "../workspace"
|
||||
|
||||
export type SubAgentToolResult = GeneralToolResult[]
|
||||
|
||||
export interface SubAgentToolDefinition {
|
||||
title: string
|
||||
tag: string
|
||||
instruction: string
|
||||
placeholder: string
|
||||
examples?: string[]
|
||||
execute: (inputs: string[], taskConfig: TaskConfig) => Promise<SubAgentToolResult>
|
||||
}
|
||||
|
||||
const FILE_READ_TIMEOUT_MS = 10_000 // 10 second timeout per file read
|
||||
|
||||
const TOOLFILE: SubAgentToolDefinition = {
|
||||
title: "TOOLFILE",
|
||||
tag: "name",
|
||||
instruction:
|
||||
"To retrieve full content of a codebase file using absolute path filename-DO NOT retrieve files that may contain secrets",
|
||||
placeholder: "ABSOLUTE_PATH",
|
||||
examples: [`See the content of different files: \`<TOOLFILE><name>path/foo.ts</name><name>path/bar.ts</name></TOOLFILE>\``],
|
||||
execute: async (filePaths: string[], taskConfig: TaskConfig): Promise<SubAgentToolResult> => {
|
||||
const fileReadPromises = filePaths.map(async (filePath): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
// Create a timeout promise to prevent hanging on large/problematic files
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(
|
||||
() => reject(new Error(`File read timed out after ${FILE_READ_TIMEOUT_MS}ms`)),
|
||||
FILE_READ_TIMEOUT_MS,
|
||||
)
|
||||
})
|
||||
|
||||
// Create the actual file read promise
|
||||
const readPromise = (async () => {
|
||||
// Resolve the file path relative to the workspace
|
||||
const pathResult = resolveWorkspacePath(taskConfig, filePath, "SubAgent.executeParallelFileReads")
|
||||
const absolutePath = typeof pathResult === "string" ? pathResult : pathResult.absolutePath
|
||||
|
||||
// Read the file content
|
||||
const supportsImages = taskConfig.api.getModel().info.supportsImages ?? false
|
||||
return await extractFileContent(absolutePath, supportsImages)
|
||||
})()
|
||||
|
||||
// Race between file read and timeout
|
||||
const fileContent = await Promise.race([readPromise, timeoutPromise])
|
||||
|
||||
Logger.info(`Read file content for "${filePath}" successfully.`)
|
||||
|
||||
return {
|
||||
agent: "TOOLFILE",
|
||||
query: filePath,
|
||||
result: fileContent.text,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`File read failed for "${filePath}": ${error instanceof Error ? error.message : String(error)}`)
|
||||
return {
|
||||
agent: "TOOLFILE",
|
||||
query: filePath,
|
||||
result: "",
|
||||
error: `Error reading file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return await Promise.all(fileReadPromises)
|
||||
},
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_SEARCHES = 3
|
||||
|
||||
const TOOLSEARCH: SubAgentToolDefinition = {
|
||||
title: "TOOLSEARCH",
|
||||
tag: "query",
|
||||
instruction:
|
||||
"Perform regex pattern searches across the codebase. Supports multiple parallel searches by including multiple query tags. Searches execute with controlled concurrency for optimal performance",
|
||||
placeholder: "SEARCH_QUERY",
|
||||
examples: [
|
||||
`Single search: \`<TOOLSEARCH><query>symbol name</query></TOOLSEARCH>\``,
|
||||
`Single search with REGEX query: \`<TOOLSEARCH><query>class \w+Handler.*ApiHandler|export.*ApiHandler|ApiProvider|ModelProvider</query></TOOLSEARCH>\``,
|
||||
`Multiple parallel searches: \`<TOOLSEARCH><query>getController</query></TOOLSEARCH><TOOLSEARCH><query>AuthService</query></TOOLSEARCH>\``,
|
||||
`Search for a class definition: \`<TOOLSEARCH><query>class UserController</query></TOOLSEARCH>\``,
|
||||
],
|
||||
execute: async (queries: string[], taskConfig: TaskConfig): Promise<SubAgentToolResult> => {
|
||||
const executeSearch = async (absolutePath: string, query: string): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
const workspaceResults = await regexSearchFiles(
|
||||
taskConfig.cwd,
|
||||
absolutePath,
|
||||
query,
|
||||
undefined,
|
||||
taskConfig.services.clineIgnoreController,
|
||||
false, // exclude hidden files
|
||||
)
|
||||
|
||||
const firstLine = workspaceResults.split("\n")[0]
|
||||
// Match either "Found X result(s)" or "Showing first X of X+ results"
|
||||
const resultMatch = firstLine.match(/Found (\d+) result|Showing first (\d+) of/)
|
||||
const resultCount = resultMatch ? parseInt(resultMatch[1] || resultMatch[2], 10) : 0
|
||||
Logger.info(`Search for "${query}" found ${resultCount} results in ${absolutePath}`)
|
||||
return {
|
||||
agent: "TOOLSEARCH",
|
||||
query,
|
||||
result: workspaceResults,
|
||||
success: resultCount > 0,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
Logger.error(`Search failed in ${absolutePath}: ${errorMsg}`)
|
||||
return {
|
||||
agent: "TOOLSEARCH",
|
||||
query,
|
||||
result: errorMsg,
|
||||
error: errorMsg,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute searches with concurrency limit to prevent too many ripgrep processes
|
||||
const results: GeneralToolResult[] = []
|
||||
for (let i = 0; i < queries.length; i += MAX_CONCURRENT_SEARCHES) {
|
||||
const batch = queries.slice(i, i + MAX_CONCURRENT_SEARCHES)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (query): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
const searchPath = taskConfig.cwd
|
||||
return await executeSearch(searchPath, query)
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Search failed for query "${query}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
return {
|
||||
agent: "TOOLSEARCH",
|
||||
query,
|
||||
result: "",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
results.push(...batchResults)
|
||||
}
|
||||
return results
|
||||
},
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_BASH_COMMANDS = 3
|
||||
|
||||
const TOOLBASH: SubAgentToolDefinition = {
|
||||
title: "TOOLBASH",
|
||||
tag: "command",
|
||||
instruction:
|
||||
"Run an arbitrary terminal command at the root of the users project. E.g. `ls -la` for listing files, or `find` for searching latest version of the codebase files locally. The command to run in the root of the users project. Must be shell escaped.",
|
||||
placeholder: "COMMAND",
|
||||
examples: [
|
||||
`Single command: \`<TOOLBASH>ls -la</TOOLBASH>\``,
|
||||
`Multiple commands: \`<TOOLBASH>ls -la</TOOLBASH><TOOLBASH>gh pr list</TOOLBASH>\``,
|
||||
],
|
||||
execute: async (commands: string[], taskConfig: TaskConfig): Promise<SubAgentToolResult> => {
|
||||
// Execute commands with concurrency limit to prevent too many parallel processes
|
||||
const results: GeneralToolResult[] = []
|
||||
for (let i = 0; i < commands.length; i += MAX_CONCURRENT_BASH_COMMANDS) {
|
||||
const batch = commands.slice(i, i + MAX_CONCURRENT_BASH_COMMANDS)
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (command): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
const result = await runShellCommand(command, { cwd: taskConfig.cwd })
|
||||
return {
|
||||
agent: "TOOLBASH",
|
||||
query: command,
|
||||
result: result.stdout,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Bash command failed for "${command}": ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
return {
|
||||
agent: "TOOLBASH",
|
||||
query: command,
|
||||
result: "",
|
||||
error: `Command failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
results.push(...batchResults)
|
||||
}
|
||||
return results satisfies SubAgentToolResult
|
||||
},
|
||||
}
|
||||
|
||||
export async function runShellCommand(
|
||||
command: string,
|
||||
options: {
|
||||
cwd?: string
|
||||
env?: Record<string, string>
|
||||
} = {},
|
||||
): Promise<{ command: string; stdout: string; stderr: string; code: number | null; signal: NodeJS.Signals | null }> {
|
||||
const { cwd = process.cwd(), env = process.env } = options
|
||||
const timeout = 15_000
|
||||
const maxBuffer = 1024 * 1024 * 10
|
||||
const encoding = "utf8"
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const childProcess = spawn(command, [], {
|
||||
shell: true,
|
||||
cwd,
|
||||
env,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
let killed = false
|
||||
let sigkillTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
// Cleanup function to properly terminate the process and all its children
|
||||
const cleanup = () => {
|
||||
if (sigkillTimeout) {
|
||||
clearTimeout(sigkillTimeout)
|
||||
sigkillTimeout = null
|
||||
}
|
||||
if (childProcess && !childProcess.killed) {
|
||||
// First try SIGTERM for graceful shutdown
|
||||
childProcess.kill("SIGTERM")
|
||||
// Force kill if still running after a short delay
|
||||
sigkillTimeout = setTimeout(() => {
|
||||
if (childProcess && !childProcess.killed) {
|
||||
childProcess.kill("SIGKILL")
|
||||
}
|
||||
sigkillTimeout = null
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
killed = true
|
||||
cleanup()
|
||||
reject(new Error(`Command timed out after ${timeout}ms`))
|
||||
}, timeout)
|
||||
|
||||
let stdoutLength = 0
|
||||
let stderrLength = 0
|
||||
|
||||
childProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString(encoding)
|
||||
stdoutLength += chunk.length
|
||||
if (stdoutLength > maxBuffer) {
|
||||
killed = true
|
||||
cleanup()
|
||||
reject(new Error("stdout maxBuffer exceeded"))
|
||||
return
|
||||
}
|
||||
stdout += chunk
|
||||
})
|
||||
|
||||
childProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const chunk = data.toString(encoding)
|
||||
stderrLength += chunk.length
|
||||
if (stderrLength > maxBuffer) {
|
||||
killed = true
|
||||
cleanup()
|
||||
reject(new Error("stderr maxBuffer exceeded"))
|
||||
return
|
||||
}
|
||||
stderr += chunk
|
||||
})
|
||||
|
||||
childProcess.on("error", (error: Error) => {
|
||||
clearTimeout(timeoutId)
|
||||
if (sigkillTimeout) {
|
||||
clearTimeout(sigkillTimeout)
|
||||
}
|
||||
reject(new Error(`Failed to start process: ${error.message}`))
|
||||
})
|
||||
|
||||
childProcess.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
clearTimeout(timeoutId)
|
||||
if (sigkillTimeout) {
|
||||
clearTimeout(sigkillTimeout)
|
||||
}
|
||||
if (killed) {
|
||||
return
|
||||
}
|
||||
|
||||
const result = { command, stdout, stderr, code, signal }
|
||||
if (code === 0) {
|
||||
resolve(result)
|
||||
} else {
|
||||
reject(`Command failed with exit code ${code}${stderr ? `: ${stderr}` : result}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
interface WebFetchInput {
|
||||
url: string
|
||||
prompt: string
|
||||
}
|
||||
|
||||
const TOOLWEBFETCH: SubAgentToolDefinition = {
|
||||
title: "TOOLWEBFETCH",
|
||||
tag: "request",
|
||||
instruction: `Fetches content from a specified URL and analyzes it using your prompt.
|
||||
- Takes a URL and analysis prompt as input via JSON object
|
||||
- Fetches the URL content and processes based on your prompt
|
||||
- Use this tool when you need to retrieve and analyze web content
|
||||
- IMPORTANT: If an MCP-provided web fetch tool is available, prefer using that tool instead
|
||||
- The URL must be a fully-formed valid URL
|
||||
- The prompt must be at least 2 characters
|
||||
- HTTP URLs will be automatically upgraded to HTTPS
|
||||
- This tool is read-only and does not modify any files`,
|
||||
placeholder: '{"url": "URL", "prompt": "ANALYSIS_PROMPT"}',
|
||||
examples: [
|
||||
`Fetch and analyze a webpage: \`<TOOLWEBFETCH><request>{"url": "https://example.com/docs", "prompt": "Extract the API endpoints"}</request></TOOLWEBFETCH>\``,
|
||||
`Multiple fetches: \`<TOOLWEBFETCH><request>{"url": "https://api.example.com/v1", "prompt": "List all available methods"}</request></TOOLWEBFETCH><TOOLWEBFETCH><request>{"url": "https://docs.example.com", "prompt": "Find authentication instructions"}</request></TOOLWEBFETCH>\``,
|
||||
],
|
||||
execute: async (inputs: string[], taskConfig: TaskConfig): Promise<SubAgentToolResult> => {
|
||||
const fetchPromises = inputs.map(async (input): Promise<GeneralToolResult> => {
|
||||
try {
|
||||
const parsed: WebFetchInput = JSON.parse(input)
|
||||
const { url, prompt } = parsed
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
throw new Error("Missing or invalid 'url' field")
|
||||
}
|
||||
if (!prompt || typeof prompt !== "string" || prompt.length < 2) {
|
||||
throw new Error("Missing or invalid 'prompt' field (must be at least 2 characters)")
|
||||
}
|
||||
|
||||
const result = await webfetch(url, prompt, taskConfig.ulid)
|
||||
|
||||
Logger.info(`Fetched web content for "${url}" with prompt "${prompt}" successfully.`)
|
||||
|
||||
return {
|
||||
agent: "TOOLWEBFETCH",
|
||||
query: url,
|
||||
result,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
Logger.error(`Web fetch failed for input "${input}": ${errorMsg}`)
|
||||
return {
|
||||
agent: "TOOLWEBFETCH",
|
||||
query: input,
|
||||
result: "",
|
||||
error: `Error fetching web content: ${errorMsg}`,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return await Promise.all(fetchPromises)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools configuration for SearchAgent.
|
||||
*/
|
||||
export const SEARCH_AGENT_TOOLS: SubAgentToolDefinition[] = [TOOLFILE, TOOLSEARCH]
|
||||
export const TASK_AGENT_TOOLS: SubAgentToolDefinition[] = [TOOLWEBFETCH, TOOLFILE, TOOLSEARCH, TOOLBASH]
|
||||
@@ -0,0 +1,26 @@
|
||||
import { SubAgentToolDefinition } from "./tools"
|
||||
|
||||
/**
|
||||
* Builds the tools placeholder string for the system prompt.
|
||||
* Formats all tool definitions into a readable instruction format.
|
||||
*/
|
||||
export function buildToolsPlaceholder(tools: SubAgentToolDefinition[]): string {
|
||||
const toolsPrompts: string[] = []
|
||||
|
||||
for (const tool of tools) {
|
||||
const prompt = `\`<${tool.title}><${tool.tag}>${tool.placeholder}</${tool.tag}></${tool.title}>\`: ${tool.instruction}.`
|
||||
|
||||
if (tool.examples && tool.examples.length > 0) {
|
||||
toolsPrompts.push(`${prompt}\n\t- ${tool.examples.join("\n\t- ")}`)
|
||||
} else {
|
||||
toolsPrompts.push(prompt)
|
||||
}
|
||||
}
|
||||
|
||||
return toolsPrompts.join("\n")
|
||||
}
|
||||
|
||||
export function extractTagContent(response: string, tag: string): string[] {
|
||||
const tagLength = tag.length
|
||||
return response.match(new RegExp(`<${tag}>(.*?)</${tag}>`, "g"))?.map((m) => m.slice(tagLength + 2, -(tagLength + 3))) || []
|
||||
}
|
||||
@@ -849,6 +849,7 @@ export class Controller {
|
||||
const lastDismissedInfoBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedInfoBannerVersion") || 0
|
||||
const lastDismissedModelBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedModelBannerVersion") || 0
|
||||
const lastDismissedCliBannerVersion = this.stateManager.getGlobalStateKey("lastDismissedCliBannerVersion") || 0
|
||||
const dismissedBanners = this.stateManager.getGlobalStateKey("dismissedBanners")
|
||||
const subagentsEnabled = this.stateManager.getGlobalSettingsKey("subagentsEnabled")
|
||||
const skillsEnabled = this.stateManager.getGlobalSettingsKey("skillsEnabled")
|
||||
|
||||
@@ -860,7 +861,7 @@ export class Controller {
|
||||
const autoCondenseThreshold = this.stateManager.getGlobalSettingsKey("autoCondenseThreshold")
|
||||
|
||||
const currentTaskItem = this.task?.taskId ? (taskHistory || []).find((item) => item.id === this.task?.taskId) : undefined
|
||||
const clineMessages = this.task?.messageStateHandler.getClineMessages() || []
|
||||
const clineMessages = [...(this.task?.messageStateHandler.getClineMessages() || [])]
|
||||
const checkpointManagerErrorMessage = this.task?.taskState.checkpointManagerErrorMessage
|
||||
|
||||
const processedTaskHistory = (taskHistory || [])
|
||||
@@ -963,6 +964,7 @@ export class Controller {
|
||||
lastDismissedModelBannerVersion,
|
||||
remoteConfigSettings: this.stateManager.getRemoteConfigSettings(),
|
||||
lastDismissedCliBannerVersion,
|
||||
dismissedBanners,
|
||||
subagentsEnabled,
|
||||
nativeToolCallSetting: this.stateManager.getGlobalStateKey("nativeToolCallEnabled"),
|
||||
enableParallelToolCalling: this.stateManager.getGlobalSettingsKey("enableParallelToolCalling"),
|
||||
|
||||
@@ -77,12 +77,12 @@ const NATIVE_NEXT_GEN: ClineToolSpec = {
|
||||
id,
|
||||
name: "attempt_completion",
|
||||
description:
|
||||
"Once you've completed the user's task, use this tool to present the final result to the user, including a brief and very short (1-2 paragraph) summary of the task and what was done to resolve it. Provide the basics, hitting the highlights, but do delve into the specifics. You should only call this tool when you have completed all tasks in the task_progress list, and completed all changes that are necessary to satisfy the user's request. You should not provide the contents of the task_progress list in the result parameter, it must be included in the task_progress parameter.",
|
||||
"Once you've completed the user's task, or have all the information you need to to answer user's question, ALWAYS use this tool to present the final result to the user, including a brief and very short (1-2 paragraph) summary of the task and what was done to resolve it. Provide the basics, hitting the highlights, but do delve into the specifics. You should only call this tool when you have completed all tasks in the task_progress list, and completed all changes that are necessary to satisfy the user's request. You should not provide the contents of the task_progress list in the result parameter, it must be included in the task_progress parameter.",
|
||||
parameters: [
|
||||
{
|
||||
name: "result",
|
||||
required: true,
|
||||
instruction: "A clear, brief and very short (1-2 paragraph) summary of the final result of the task.",
|
||||
instruction: "Summary of the final result of the task, or the final answer to the user's question.",
|
||||
},
|
||||
{
|
||||
name: "command",
|
||||
|
||||
@@ -15,6 +15,7 @@ export * from "./plan_mode_respond"
|
||||
export * from "./read_file"
|
||||
export * from "./replace_in_file"
|
||||
export * from "./search_files"
|
||||
export * from "./subagent"
|
||||
export * from "./use_mcp_tool"
|
||||
export * from "./use_skill"
|
||||
export * from "./web_fetch"
|
||||
|
||||
@@ -17,6 +17,7 @@ import { plan_mode_respond_variants } from "./plan_mode_respond"
|
||||
import { read_file_variants } from "./read_file"
|
||||
import { replace_in_file_variants } from "./replace_in_file"
|
||||
import { search_files_variants } from "./search_files"
|
||||
import { subagent_variants } from "./subagent"
|
||||
import { use_mcp_tool_variants } from "./use_mcp_tool"
|
||||
import { use_skill_variants } from "./use_skill"
|
||||
import { web_fetch_variants } from "./web_fetch"
|
||||
@@ -46,6 +47,7 @@ export function registerClineToolSets(): void {
|
||||
...plan_mode_respond_variants,
|
||||
...read_file_variants,
|
||||
...replace_in_file_variants,
|
||||
...subagent_variants,
|
||||
...search_files_variants,
|
||||
...use_mcp_tool_variants,
|
||||
...use_skill_variants,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ClineToolSpec } from "../spec"
|
||||
import { TASK_PROGRESS_PARAMETER } from "../types"
|
||||
|
||||
const id = ClineDefaultTool.SUBAGENT
|
||||
|
||||
const NATIVE_NEXT_GEN: ClineToolSpec = {
|
||||
variant: ModelFamily.NATIVE_NEXT_GEN,
|
||||
id,
|
||||
name: id,
|
||||
description:
|
||||
"Launch a new agent to handle complex, multi-step tasks autonomously. The agent has access to search and bash tools to gather information from inside and outside the codebase. Use this for tasks that require multiple steps of exploration or research before reaching a conclusion.",
|
||||
parameters: [
|
||||
{
|
||||
name: "prompt",
|
||||
required: true,
|
||||
instruction: `A highly detailed task description for the agent to perform autonomously. The prompt should include:
|
||||
1. What the agent needs to accomplish
|
||||
2. Whether the agent should write code or just do research (search, file reads, etc.)
|
||||
3. Exactly what information should be returned in the agent's final response
|
||||
|
||||
IMPORTANT:
|
||||
- Each agent invocation is stateless - you cannot send follow-up messages. Make your prompt comprehensive and self-contained.
|
||||
- Each agent should have a UNIQUE, non-overlapping mission. Do NOT launch multiple agents that could search for the same things or perform similar work.
|
||||
- Before launching an agent, consider what other agents you're launching in parallel - ensure their tasks are distinct and won't duplicate effort.
|
||||
- Bad example: Agent 1 "find auth code", Agent 2 "find login handlers" - these overlap and will do redundant searches.
|
||||
- Good example: Agent 1 "find frontend auth components", Agent 2 "find backend API auth middleware" - distinct, non-overlapping scopes.`,
|
||||
},
|
||||
TASK_PROGRESS_PARAMETER,
|
||||
],
|
||||
}
|
||||
|
||||
export const subagent_variants = [NATIVE_NEXT_GEN]
|
||||
@@ -50,7 +50,7 @@ export const config = createVariant(ModelFamily.NATIVE_NEXT_GEN)
|
||||
ClineDefaultTool.FILE_READ,
|
||||
ClineDefaultTool.FILE_NEW,
|
||||
ClineDefaultTool.FILE_EDIT,
|
||||
ClineDefaultTool.SEARCH,
|
||||
ClineDefaultTool.SUBAGENT,
|
||||
ClineDefaultTool.LIST_FILES,
|
||||
ClineDefaultTool.LIST_CODE_DEF,
|
||||
ClineDefaultTool.BROWSER,
|
||||
|
||||
@@ -88,15 +88,16 @@ In each user message, the environment_details will specify the current mode. The
|
||||
- Then you might ask the user if they are pleased with this plan, or if they would like to make any changes. Think of this as a brainstorming session where you can discuss the task and plan the best way to accomplish it.
|
||||
- Finally once it seems like you've reached a good plan, ask the user to switch you back to ACT MODE to implement the solution.`
|
||||
|
||||
const OBJECTIVE = (context: SystemPromptContext) => `OBJECTIVE
|
||||
const OBJECTIVE = (_context: SystemPromptContext) => `OBJECTIVE
|
||||
|
||||
You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically.
|
||||
|
||||
1. Analyze the user's task and set clear, achievable goals to accomplish it. Prioritize these goals in a logical order.
|
||||
2. Work through these goals sequentially, utilizing available tools ${context.enableParallelToolCalling ? "as necessary. You may call multiple independent tools in a single response to work efficiently." : "one at a time as necessary."} Each goal should correspond to a distinct step in your problem-solving process. You will be informed on the work completed and what's remaining as you go.
|
||||
3. Remember, you have extensive capabilities with access to a wide range of tools that can be used in powerful and clever ways as necessary to accomplish each goal. First, analyze the file structure provided in environment_details to gain context and insights for proceeding effectively. Then, think about which of the provided tools is the most relevant tool to accomplish the user's task. Next, go through each of the required parameters of the relevant tool and determine if the user has directly provided or given enough information to infer a value. When deciding if the parameter can be inferred, carefully consider all the context to see if it supports a specific value. If all of the required parameters are present or can be reasonably inferred, close the thinking tag and proceed with the tool use. BUT, if one of the values for a required parameter is missing, DO NOT invoke the tool (not even with fillers for the missing params)${context.yoloModeToggled !== true ? " and instead, ask the user to provide the missing parameters using the ask_followup_question tool" : ""}. DO NOT ask for more information on optional parameters if it is not provided.
|
||||
4. Once you've completed the user's task, you must use the attempt_completion tool to present the result of the task to the user. You may also provide a CLI command to showcase the result of your task; this can be particularly useful for web development tasks, where you can run e.g. \`open index.html\` to show the website you've built.
|
||||
5. If the task is not actionable, you may use the attempt_completion tool to explain to the user why the task cannot be completed, or provide a simple answer if that is what the user is looking for.`
|
||||
1. Always gather all the necessary context before starting to work on a task. For example, if you are generating a unit test or new code, make sure you understand the requirement, the naming conventions, frameworks and libraries used and aligned in the current codebase, and the environment and commands used to run and test the code etc. Always validate the new unit test at the end including running the code if possible for live feedback.
|
||||
2. Review each question carefully and answer it with detailed, accurate information.
|
||||
3. If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.
|
||||
4. If the task is not actionable, you may use the attempt_completion tool to explain to the user why the task cannot be completed, or provide a simple answer if that is what the user is looking for.
|
||||
|
||||
IMPORTANT: Always uses the attempt_completion tool when you've completed all tasks, including giving your answer to the user question.`
|
||||
|
||||
const FEEDBACK = (_context: SystemPromptContext) => `FEEDBACK
|
||||
|
||||
|
||||
@@ -79,4 +79,5 @@ export const rules_template = (context: SystemPromptContext) => `RULES
|
||||
- When using the replace_in_file tool, if you use multiple SEARCH/REPLACE blocks, list them in the order they appear in the file. For example if you need to make changes to both line 10 and line 50, first include the SEARCH/REPLACE block for line 10, followed by the SEARCH/REPLACE block for line 50.
|
||||
- When using the replace_in_file tool, Do NOT add extra characters to the markers (e.g., ------- SEARCH> is INVALID). Do NOT forget to use the closing +++++++ REPLACE marker. Do NOT modify the marker format in any way. Malformed XML will cause complete tool failure and break the entire editing process.
|
||||
- It is critical you wait for the user's response after each tool use, in order to confirm the success of the tool use. For example, if asked to make a todo app, you would create a file, wait for the user's response it was created successfully, then create another file if needed, wait for the user's response it was created successfully, etc.{{BROWSER_WAIT_RULES}}
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.`
|
||||
- MCP operations should be used one at a time, similar to other tool usage. Wait for confirmation of success before proceeding with additional operations.
|
||||
- When user asked a question, always provide the final answer using the attempt_completion tool rather than answering directly in your response.`
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Controller } from "@/core/controller"
|
||||
import { buildBasicClineHeaders } from "@/services/EnvUtils"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ConfiguredAPIKeys } from "@/shared/storage/state-keys"
|
||||
import { ClineEnv } from "../../../config"
|
||||
import { AuthService } from "../../../services/auth/AuthService"
|
||||
import { CLINE_API_ENDPOINT } from "../../../shared/cline/api"
|
||||
@@ -217,12 +218,14 @@ async function ensureUserInOrgWithRemoteConfig(controller: Controller): Promise<
|
||||
await controller.accountService.switchAccount(organizationId)
|
||||
}
|
||||
|
||||
const configuredApiKeys: ConfiguredAPIKeys = {}
|
||||
// Fetch and store API keys for configured providers
|
||||
const hasConfiguredProviders = config.providerSettings && Object.keys(config.providerSettings).length > 0
|
||||
if (hasConfiguredProviders) {
|
||||
const apiKeys = await fetchApiKeysForOrganization(organizationId)
|
||||
if (config.providerSettings?.LiteLLM) {
|
||||
if (apiKeys.litellm) {
|
||||
configuredApiKeys["litellm"] = true
|
||||
controller.stateManager.setSecret("remoteLiteLlmApiKey", apiKeys.litellm)
|
||||
} else {
|
||||
controller.stateManager.setSecret("remoteLiteLlmApiKey", undefined)
|
||||
@@ -237,7 +240,7 @@ async function ensureUserInOrgWithRemoteConfig(controller: Controller): Promise<
|
||||
// Cache and apply the remote config
|
||||
await writeRemoteConfigToCache(organizationId, config)
|
||||
if (isRemoteConfigEnabled(organizationId)) {
|
||||
await applyRemoteConfig(config, undefined, controller.mcpHub)
|
||||
await applyRemoteConfig(config, configuredApiKeys, controller.mcpHub)
|
||||
} else {
|
||||
clearRemoteConfig()
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { synchronizeRemoteRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
|
||||
import { RemoteConfig } from "@shared/remote-config/schema"
|
||||
import { GlobalStateAndSettings, RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import { ConfiguredAPIKeys, GlobalStateAndSettings, RemoteConfigFields } from "@shared/storage/state-keys"
|
||||
import { AuthService } from "@/services/auth/AuthService"
|
||||
import { getDistinctId } from "@/services/logging/distinctId"
|
||||
import { getTelemetryService, telemetryService } from "@/services/telemetry"
|
||||
import { type McpHub } from "@/services/mcp/McpHub"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { OpenTelemetryClientProvider } from "@/services/telemetry/providers/opentelemetry/OpenTelemetryClientProvider"
|
||||
import { OpenTelemetryTelemetryProvider } from "@/services/telemetry/providers/opentelemetry/OpenTelemetryTelemetryProvider"
|
||||
import { type TelemetryService } from "@/services/telemetry/TelemetryService"
|
||||
@@ -266,17 +267,14 @@ export function clearRemoteConfig() {
|
||||
/**
|
||||
* Applies remote config to the StateManager's remote config cache
|
||||
* @param remoteConfig The remote configuration object to apply
|
||||
* @param settingsDirectoryPath Path to the settings directory
|
||||
* @param mcpHub Optional McpHub instance to prevent watcher triggers during sync
|
||||
* @param mcpHub McpHub instance to prevent watcher triggers during sync
|
||||
*/
|
||||
export async function applyRemoteConfig(
|
||||
remoteConfig?: RemoteConfig,
|
||||
settingsDirectoryPath?: string,
|
||||
mcpHub?: any,
|
||||
remoteConfig: RemoteConfig,
|
||||
configuredKeys: ConfiguredAPIKeys,
|
||||
mcpHub: McpHub,
|
||||
): Promise<void> {
|
||||
const stateManager = StateManager.get()
|
||||
const telemetryService = await getTelemetryService()
|
||||
|
||||
// If no remote config provided, clear the cache and relevant state
|
||||
if (!remoteConfig) {
|
||||
clearRemoteConfig()
|
||||
@@ -318,6 +316,7 @@ export async function applyRemoteConfig(
|
||||
for (const [key, value] of Object.entries(transformed)) {
|
||||
stateManager.setRemoteConfigField(key as keyof RemoteConfigFields, value)
|
||||
}
|
||||
stateManager.setRemoteConfigField("configuredApiKeys", configuredKeys)
|
||||
|
||||
// Restore previousRemoteMCPServers across cache clears
|
||||
if (previousRemoteMCPServers !== undefined) {
|
||||
@@ -330,7 +329,7 @@ export async function applyRemoteConfig(
|
||||
if (remoteConfig.remoteMCPServers !== undefined) {
|
||||
try {
|
||||
// Get settings directory path - use provided path or get it from disk helper
|
||||
const settingsPath = settingsDirectoryPath || (await ensureSettingsDirectoryExists())
|
||||
const settingsPath = await ensureSettingsDirectoryExists()
|
||||
await syncRemoteMcpServersToSettings(remoteConfig.remoteMCPServers, settingsPath, mcpHub)
|
||||
// Store current remote servers list for next sync to detect removals
|
||||
stateManager.setRemoteConfigField("previousRemoteMCPServers", remoteConfig.remoteMCPServers)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { JSONParser } from "@streamparser/json"
|
||||
import { nanoid } from "nanoid"
|
||||
import { McpHub } from "@/services/mcp/McpHub"
|
||||
import { CLINE_MCP_TOOL_IDENTIFIER } from "@/shared/mcp"
|
||||
import {
|
||||
@@ -225,8 +226,9 @@ class ToolUseHandler {
|
||||
this.pendingToolUses.clear()
|
||||
}
|
||||
|
||||
private createPendingToolUse(id: string, name: string, call_id?: string): PendingToolUse {
|
||||
private createPendingToolUse(id: string, name: string, callId?: string): PendingToolUse {
|
||||
const jsonParser = new JSONParser()
|
||||
const call_id = callId || nanoid(8)
|
||||
const pending: PendingToolUse = {
|
||||
id,
|
||||
name,
|
||||
|
||||
@@ -39,6 +39,7 @@ import { PlanModeRespondHandler } from "./tools/handlers/PlanModeRespondHandler"
|
||||
import { ReadFileToolHandler } from "./tools/handlers/ReadFileToolHandler"
|
||||
import { ReportBugHandler } from "./tools/handlers/ReportBugHandler"
|
||||
import { SearchFilesToolHandler } from "./tools/handlers/SearchFilesToolHandler"
|
||||
import { SubagentHandler } from "./tools/handlers/SubagentHandler"
|
||||
import { SummarizeTaskHandler } from "./tools/handlers/SummarizeTaskHandler"
|
||||
import { UseMcpToolHandler } from "./tools/handlers/UseMcpToolHandler"
|
||||
import { UseSkillToolHandler } from "./tools/handlers/UseSkillToolHandler"
|
||||
@@ -192,6 +193,7 @@ export class ToolExecutor {
|
||||
clearActiveHookExecution: this.clearActiveHookExecution,
|
||||
getActiveHookExecution: this.getActiveHookExecution,
|
||||
runUserPromptSubmitHook: this.runUserPromptSubmitHook,
|
||||
replaceMessageContentByUid: this.messageStateHandler.replaceMessageContentByUid.bind(this.messageStateHandler),
|
||||
},
|
||||
coordinator: this.coordinator,
|
||||
}
|
||||
@@ -221,8 +223,10 @@ export class ToolExecutor {
|
||||
this.coordinator.register(new SharedToolHandler(ClineDefaultTool.FILE_EDIT, writeHandler))
|
||||
this.coordinator.register(new SharedToolHandler(ClineDefaultTool.NEW_RULE, writeHandler))
|
||||
|
||||
this.coordinator.register(new ListCodeDefinitionNamesToolHandler(validator))
|
||||
this.coordinator.register(new SubagentHandler())
|
||||
this.coordinator.register(new SearchFilesToolHandler(validator))
|
||||
|
||||
this.coordinator.register(new ListCodeDefinitionNamesToolHandler(validator))
|
||||
this.coordinator.register(new ExecuteCommandToolHandler(validator))
|
||||
this.coordinator.register(new UseMcpToolHandler())
|
||||
this.coordinator.register(new AccessMcpResourceHandler())
|
||||
|
||||
+102
-7
@@ -96,6 +96,7 @@ import { ApiFormat } from "@/shared/proto/cline/models"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
|
||||
import { ClineAgent } from "../agents/ClineAgent"
|
||||
import { RuleContextBuilder } from "../context/instructions/user-instructions/RuleContextBuilder"
|
||||
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
|
||||
import { discoverSkills, getAvailableSkills } from "../context/instructions/user-instructions/skills"
|
||||
@@ -718,6 +719,7 @@ export class Task {
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
uid?: string,
|
||||
): Promise<number | undefined> {
|
||||
// Allow hook messages even when aborted to enable proper cleanup
|
||||
if (this.taskState.abort && type !== "hook_status" && type !== "hook_output_stream") {
|
||||
@@ -733,10 +735,32 @@ export class Task {
|
||||
|
||||
if (partial !== undefined) {
|
||||
const lastMessage = this.messageStateHandler.getClineMessages().at(-1)
|
||||
const isUpdatingPreviousPartial =
|
||||
lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type
|
||||
|
||||
// For tool messages, check if the content matches to distinguish between parallel tool executions
|
||||
let isUpdatingPreviousPartial = false
|
||||
if (lastMessage && lastMessage.partial && lastMessage.type === "say" && lastMessage.say === type) {
|
||||
if (type === "tool" && text && lastMessage.text) {
|
||||
// For tool messages, parse and compare the tool identifier (e.g., filePattern for subagents)
|
||||
try {
|
||||
const newToolData = JSON.parse(text)
|
||||
const lastToolData = JSON.parse(lastMessage.text)
|
||||
// Check if this is the same tool execution by comparing identifying fields
|
||||
// For subagents, filePattern is the unique identifier (the prompt)
|
||||
if (newToolData.tool === lastToolData.tool && newToolData.filePattern === lastToolData.filePattern) {
|
||||
isUpdatingPreviousPartial = true
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, fall back to basic check (non-tool messages)
|
||||
isUpdatingPreviousPartial = true
|
||||
}
|
||||
} else {
|
||||
// For non-tool messages, use the basic check
|
||||
isUpdatingPreviousPartial = true
|
||||
}
|
||||
}
|
||||
|
||||
if (partial) {
|
||||
if (isUpdatingPreviousPartial) {
|
||||
if (isUpdatingPreviousPartial && lastMessage) {
|
||||
// existing partial message, so update it
|
||||
lastMessage.text = text
|
||||
lastMessage.images = images
|
||||
@@ -758,13 +782,14 @@ export class Task {
|
||||
files,
|
||||
partial,
|
||||
modelInfo,
|
||||
uid,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
}
|
||||
} else {
|
||||
// partial=false means its a complete version of a previously partial message
|
||||
if (isUpdatingPreviousPartial) {
|
||||
if (isUpdatingPreviousPartial && lastMessage) {
|
||||
// this is the complete version of a previously partial message, so replace the partial with the complete version
|
||||
this.taskState.lastMessageTs = lastMessage.ts
|
||||
// lastMessage.ts = sayTs
|
||||
@@ -791,6 +816,7 @@ export class Task {
|
||||
images,
|
||||
files,
|
||||
modelInfo,
|
||||
uid,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
@@ -808,6 +834,7 @@ export class Task {
|
||||
images,
|
||||
files,
|
||||
modelInfo,
|
||||
uid,
|
||||
})
|
||||
await this.postStateToWebview()
|
||||
return sayTs
|
||||
@@ -848,6 +875,42 @@ export class Task {
|
||||
return this.stateManager.getGlobalSettingsKey("enableParallelToolCalling") || isGPT5ModelFamily(modelId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that can be executed in parallel when multiple appear consecutively.
|
||||
* These are tools that don't have side effects that would conflict with each other.
|
||||
*/
|
||||
private static readonly PARALLELIZABLE_TOOLS: ClineDefaultTool[] = [ClineDefaultTool.SUBAGENT]
|
||||
|
||||
/**
|
||||
* Check if a tool block can be executed in parallel with other parallelizable tools.
|
||||
* Only complete (non-partial) blocks of specific tool types are parallelizable.
|
||||
*/
|
||||
private isParallelizableToolBlock(block: AssistantMessageContent): boolean {
|
||||
return block.type === "tool_use" && !block.partial && Task.PARALLELIZABLE_TOOLS.includes(block.name as ClineDefaultTool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect consecutive parallelizable tool blocks starting from the current index.
|
||||
* Returns an array of tool blocks that can be executed in parallel.
|
||||
*/
|
||||
private collectParallelizableBlocks(): ToolUse[] {
|
||||
const blocks: ToolUse[] = []
|
||||
const startIndex = this.taskState.currentStreamingContentIndex
|
||||
const content = this.taskState.assistantMessageContent
|
||||
|
||||
for (let i = startIndex; i < content.length; i++) {
|
||||
const block = content[i]
|
||||
if (this.isParallelizableToolBlock(block)) {
|
||||
blocks.push(cloneDeep(block) as ToolUse)
|
||||
} else {
|
||||
// Stop at first non-parallelizable block
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return blocks
|
||||
}
|
||||
|
||||
private async switchToActModeCallback(): Promise<boolean> {
|
||||
return await this.controller.toggleActModeForYoloMode()
|
||||
}
|
||||
@@ -2131,7 +2194,7 @@ export class Task {
|
||||
await this.say("text", content, undefined, undefined, block.partial)
|
||||
break
|
||||
}
|
||||
case "tool_use":
|
||||
case "tool_use": {
|
||||
// If we have a pending initial commit, we must block unsafe tools until it finishes.
|
||||
// Safe tools (read-only) can run in parallel.
|
||||
if (this.initialCheckpointCommitPromise) {
|
||||
@@ -2140,8 +2203,20 @@ export class Task {
|
||||
this.initialCheckpointCommitPromise = undefined
|
||||
}
|
||||
}
|
||||
await this.toolExecutor.executeTool(block)
|
||||
|
||||
// Check if we can execute multiple parallelizable tool blocks (e.g., subagents) in parallel
|
||||
const parallelBlocks = this.collectParallelizableBlocks()
|
||||
if (parallelBlocks.length > 1) {
|
||||
// Execute all parallelizable blocks concurrently
|
||||
await Promise.all(parallelBlocks.map((b) => this.toolExecutor.executeTool(b)))
|
||||
// Skip past all the blocks we just executed (minus 1 since the normal flow will increment once)
|
||||
this.taskState.currentStreamingContentIndex += parallelBlocks.length - 1
|
||||
} else {
|
||||
// Single tool or non-parallelizable - execute normally
|
||||
await this.toolExecutor.executeTool(block)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -2151,8 +2226,13 @@ export class Task {
|
||||
this.taskState.presentAssistantMessageLocked = false // this needs to be placed here, if not then calling this.presentAssistantMessage below would fail (sometimes) since it's locked
|
||||
// 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)
|
||||
// Also advance when a tool was used and parallel calling is disabled
|
||||
// For parallel blocks, we use the last block in the batch to determine completion
|
||||
const effectiveBlock =
|
||||
block.type === "tool_use"
|
||||
? (this.taskState.assistantMessageContent[this.taskState.currentStreamingContentIndex] ?? block)
|
||||
: block
|
||||
if (
|
||||
!block.partial ||
|
||||
!effectiveBlock.partial ||
|
||||
this.taskState.didRejectTool ||
|
||||
(!this.isParallelToolCallingEnabled() && this.taskState.didAlreadyUseTool)
|
||||
) {
|
||||
@@ -2496,6 +2576,10 @@ export class Task {
|
||||
Logger.log("updating partial message", lastMessage)
|
||||
// await this.saveClineMessagesAndUpdateHistory()
|
||||
}
|
||||
const subAgentCosts = ClineAgent.getAllAgentCosts()
|
||||
if (subAgentCosts) {
|
||||
taskMetrics.totalCost = (taskMetrics.totalCost ?? 0) + subAgentCosts
|
||||
}
|
||||
// update api_req_started to have cancelled and cost, so that we can display the cost of the partial stream
|
||||
await updateApiReqMsg({
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
@@ -2594,6 +2678,7 @@ export class Task {
|
||||
taskMetrics.cacheWriteTokens += chunk.cacheWriteTokens ?? 0
|
||||
taskMetrics.cacheReadTokens += chunk.cacheReadTokens ?? 0
|
||||
taskMetrics.totalCost = chunk.totalCost ?? taskMetrics.totalCost
|
||||
|
||||
break
|
||||
case "reasoning": {
|
||||
// Process the reasoning delta through the handler
|
||||
@@ -2668,6 +2753,11 @@ export class Task {
|
||||
}
|
||||
}
|
||||
|
||||
const subAgentCosts = ClineAgent.getAllAgentCosts()
|
||||
if (subAgentCosts) {
|
||||
taskMetrics.totalCost = (taskMetrics.totalCost ?? 0) + subAgentCosts
|
||||
}
|
||||
|
||||
// present content to user - we don't want the stream to break if present fails, so we catch errors here
|
||||
await this.presentAssistantMessage().catch((error) =>
|
||||
Logger.debug("[Task] Failed to present message: " + error),
|
||||
@@ -2770,6 +2860,11 @@ export class Task {
|
||||
})
|
||||
}
|
||||
|
||||
const subAgentCosts = ClineAgent.getAllAgentCosts()
|
||||
if (subAgentCosts) {
|
||||
taskMetrics.totalCost = (taskMetrics.totalCost ?? 0) + subAgentCosts
|
||||
}
|
||||
|
||||
// Update the api_req_started message with final usage and cost details
|
||||
await updateApiReqMsg({
|
||||
messageStateHandler: this.messageStateHandler,
|
||||
|
||||
@@ -8,8 +8,10 @@ import { ClineMessage } from "@/shared/ExtensionMessage"
|
||||
import { getApiMetrics } from "@/shared/getApiMetrics"
|
||||
import { HistoryItem } from "@/shared/HistoryItem"
|
||||
import { ClineStorageMessage } from "@/shared/messages/content"
|
||||
import { convertClineMessageToProto } from "@/shared/proto-conversions/cline-message"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getCwd, getDesktopDir } from "@/utils/path"
|
||||
import { sendPartialMessageEvent } from "../controller/ui/subscribeToPartialMessage"
|
||||
import { ensureTaskDirectoryExists, saveApiConversationHistory, saveClineMessages } from "../storage/disk"
|
||||
import { TaskState } from "./TaskState"
|
||||
|
||||
@@ -217,4 +219,34 @@ export class MessageStateHandler {
|
||||
await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the content of a message identified by its timestamp
|
||||
* Finds the message by ts and replaces its text content
|
||||
* The entire operation is atomic to prevent races (RC-4)
|
||||
* @param ts - The timestamp of the message to update
|
||||
* @param content - The new content to set
|
||||
* @returns true if the message was found and updated, false otherwise
|
||||
*/
|
||||
async replaceMessageContentByUid(uid: string, content: string, partial = true): Promise<boolean> {
|
||||
const index = this.clineMessages.findIndex((m) => m.uid === uid)
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Update the message content
|
||||
this.clineMessages[index].text = content
|
||||
this.clineMessages[index].partial = partial
|
||||
|
||||
// // Save changes and update history
|
||||
// await this.saveClineMessagesAndUpdateHistoryInternal()
|
||||
|
||||
// Send partial message event to update the webview in real-time
|
||||
// This is necessary because replaceMessageContentByUid is used for streaming updates
|
||||
// (e.g., Subagent progress updates) that need to be reflected in the UI immediately
|
||||
const protoMessage = convertClineMessageToProto(this.clineMessages[index])
|
||||
sendPartialMessageEvent(protoMessage)
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,13 @@ export interface IFullyManagedTool extends IToolHandler, IPartialBlockHandler {
|
||||
// Marker interface for tools that handle their own complete approval flow
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for tool handlers that support cancellation via abort.
|
||||
*/
|
||||
export interface IAbortableToolHandler {
|
||||
abort(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -84,4 +91,23 @@ export class ToolExecutorCoordinator {
|
||||
}
|
||||
return handler.execute(config, block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort all running abortable tool handlers.
|
||||
* This is called when the task is cancelled to stop any in-progress agent executions.
|
||||
*/
|
||||
abortAll(): void {
|
||||
for (const handler of this.handlers.values()) {
|
||||
if (this.isAbortable(handler)) {
|
||||
handler.abort()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a handler implements IAbortableToolHandler
|
||||
*/
|
||||
private isAbortable(handler: IToolHandler): handler is IToolHandler & IAbortableToolHandler {
|
||||
return "abort" in handler && typeof (handler as IAbortableToolHandler).abort === "function"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { formatResponse } from "@core/prompts/responses"
|
||||
import { Subagent } from "@/core/agents/Subagent"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IAbortableToolHandler, IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
|
||||
/**
|
||||
* Handler for the task_subagent tool.
|
||||
* Launches a TaskAgent to perform complex, multi-step tasks autonomously.
|
||||
* The agent has access to search and bash tools to gather information.
|
||||
*/
|
||||
export class SubagentHandler implements IFullyManagedTool, IAbortableToolHandler {
|
||||
readonly name = ClineDefaultTool.SUBAGENT
|
||||
private abortController?: AbortController
|
||||
|
||||
getDescription(block: ToolUse): string {
|
||||
return `[${block.name}]`
|
||||
}
|
||||
|
||||
private buildToolMessage(prompt: string, content: string): string {
|
||||
const sharedProps: ClineSayTool = {
|
||||
tool: "subagent",
|
||||
path: undefined,
|
||||
content,
|
||||
regex: undefined,
|
||||
filePattern: prompt,
|
||||
operationIsLocatedInWorkspace: true,
|
||||
}
|
||||
|
||||
return JSON.stringify(sharedProps)
|
||||
}
|
||||
|
||||
private buildToolMessageWithHistory(
|
||||
prompt: string,
|
||||
statusHistory: import("@/shared/cline/subagent").SubagentStatusEntry[],
|
||||
): string {
|
||||
const sharedProps: ClineSayTool = {
|
||||
tool: "subagent",
|
||||
path: undefined,
|
||||
content: JSON.stringify(statusHistory),
|
||||
regex: undefined,
|
||||
filePattern: prompt,
|
||||
operationIsLocatedInWorkspace: true,
|
||||
}
|
||||
|
||||
return JSON.stringify(sharedProps)
|
||||
}
|
||||
|
||||
buildPartialToolMessage(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): string {
|
||||
const prompt = uiHelpers.removeClosingTag(block, "prompt", block.params.prompt)
|
||||
const sharedProps: ClineSayTool = {
|
||||
tool: "subagent",
|
||||
path: undefined,
|
||||
content: "",
|
||||
regex: undefined,
|
||||
filePattern: prompt,
|
||||
operationIsLocatedInWorkspace: true,
|
||||
}
|
||||
|
||||
return JSON.stringify(sharedProps)
|
||||
}
|
||||
|
||||
async handlePartialBlock(block: ToolUse, _uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
if (!block.params.prompt) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
|
||||
const prompt: string | undefined = block.params.prompt
|
||||
|
||||
// Validate required parameter
|
||||
if (!prompt || !block.call_id) {
|
||||
config.taskState.consecutiveMistakeCount++
|
||||
return await config.callbacks.sayAndCreateMissingParamError(block.name, "prompt")
|
||||
}
|
||||
|
||||
config.taskState.consecutiveMistakeCount = 0
|
||||
|
||||
// Run PreToolUse hook after approval but before execution
|
||||
try {
|
||||
const { ToolHookUtils } = await import("../utils/ToolHookUtils")
|
||||
await ToolHookUtils.runPreToolUseIfEnabled(config, block)
|
||||
} catch (error) {
|
||||
const { PreToolUseHookCancellationError } = await import("@core/hooks/PreToolUseHookCancellationError")
|
||||
if (error instanceof PreToolUseHookCancellationError) {
|
||||
return formatResponse.toolDenied()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
// Execute the task agent
|
||||
return await this.performTask(config, prompt, block.call_id ?? "")
|
||||
}
|
||||
|
||||
private async performTask(config: TaskConfig, prompt: string, callId: string): Promise<ToolResponse> {
|
||||
try {
|
||||
// Create AbortController for this task execution
|
||||
this.abortController = new AbortController()
|
||||
|
||||
// Create agent with max 50 iterations for complex tasks
|
||||
const agent = new Subagent(callId, prompt, config, 30, undefined, config.api, this.abortController?.signal)
|
||||
|
||||
const taskResults = await agent.execute(prompt)
|
||||
// Check if aborted
|
||||
if (this.abortController?.signal?.aborted) {
|
||||
const abortMessage = "[Subagent] Task was cancelled."
|
||||
const abortToolMessage = this.buildToolMessage(prompt, abortMessage)
|
||||
await config.callbacks.replaceMessageContentByUid(callId, abortToolMessage, false)
|
||||
return abortMessage
|
||||
}
|
||||
|
||||
// Use the agent's status history for the final message to preserve the timeline UI
|
||||
const completeMessage = this.buildToolMessageWithHistory(prompt, agent.getStatusHistory())
|
||||
await config.callbacks.replaceMessageContentByUid(callId, completeMessage, false)
|
||||
|
||||
return taskResults
|
||||
} catch (error) {
|
||||
const errorMessage = `[Subagent] Task Failed ${error instanceof Error ? error.message : String(error)}`
|
||||
Logger.error(errorMessage)
|
||||
const errorToolMessage = this.buildToolMessage(prompt, errorMessage)
|
||||
await config.callbacks.replaceMessageContentByUid(callId, errorToolMessage, false)
|
||||
return errorMessage
|
||||
} finally {
|
||||
this.abortController = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aborts the currently running task agent, if any.
|
||||
*/
|
||||
public abort(): void {
|
||||
this.abortController?.abort()
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { AuthService } from "@/services/auth/AuthService"
|
||||
import { buildClineExtraHeaders } from "@/services/EnvUtils"
|
||||
import { featureFlagsService } from "@/services/feature-flags"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { CLINE_ACCOUNT_AUTH_ERROR_MESSAGE } from "@/shared/ClineAccount"
|
||||
import { getAxiosSettings } from "@/shared/net"
|
||||
import { ToolUse } from "../../../assistant-message"
|
||||
import { formatResponse } from "../../../prompts/responses"
|
||||
@@ -139,35 +138,7 @@ export class WebFetchToolHandler implements IFullyManagedTool {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Execute the actual fetch
|
||||
const baseUrl = ClineEnv.config().apiBaseUrl
|
||||
const authToken = await AuthService.getInstance().getAuthToken()
|
||||
|
||||
if (!authToken) {
|
||||
throw new Error(CLINE_ACCOUNT_AUTH_ERROR_MESSAGE)
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/api/v1/search/webfetch`,
|
||||
{
|
||||
Url: url,
|
||||
Prompt: prompt,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Task-ID": config.ulid || "",
|
||||
...(await buildClineExtraHeaders()),
|
||||
},
|
||||
timeout: 15000,
|
||||
...getAxiosSettings(),
|
||||
},
|
||||
)
|
||||
|
||||
// Parse response
|
||||
// Axios will throw on non-200 status, so no need to check fetchStatus
|
||||
const result = response.data.data.result
|
||||
const result = await webfetch(url, prompt, config.ulid)
|
||||
|
||||
return formatResponse.toolResult(result)
|
||||
} catch (error) {
|
||||
@@ -175,3 +146,35 @@ export class WebFetchToolHandler implements IFullyManagedTool {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function webfetch(url: string, prompt: string, ulid = ""): Promise<string> {
|
||||
try {
|
||||
// Execute the actual fetch
|
||||
const baseUrl = ClineEnv.config().apiBaseUrl
|
||||
const authToken = await AuthService.getInstance().getAuthToken()
|
||||
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/api/v1/search/webfetch`,
|
||||
{
|
||||
Url: url,
|
||||
Prompt: prompt,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${authToken}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Task-ID": ulid,
|
||||
...(await buildClineExtraHeaders()),
|
||||
},
|
||||
timeout: 15000,
|
||||
...getAxiosSettings(),
|
||||
},
|
||||
)
|
||||
|
||||
// Parse response
|
||||
// Axios will throw on non-200 status, so no need to check fetchStatus
|
||||
return response?.data?.data?.result
|
||||
} catch (error: any) {
|
||||
return `Error fetching web content: ${(error as Error).message}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,14 @@ export interface TaskServices {
|
||||
* All callback functions available to tool handlers
|
||||
*/
|
||||
export interface TaskCallbacks {
|
||||
say: (type: ClineSay, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
|
||||
say: (
|
||||
type: ClineSay,
|
||||
text?: string,
|
||||
images?: string[],
|
||||
files?: string[],
|
||||
partial?: boolean,
|
||||
uid?: string,
|
||||
) => Promise<number | undefined>
|
||||
|
||||
ask: (
|
||||
type: ClineAsk,
|
||||
@@ -132,6 +139,9 @@ export interface TaskCallbacks {
|
||||
userContent: ClineContent[],
|
||||
context: "initial_task" | "resume" | "feedback",
|
||||
) => Promise<{ cancel?: boolean; wasCancelled?: boolean; contextModification?: string; errorMessage?: string }>
|
||||
|
||||
// Message content replacement by timestamp
|
||||
replaceMessageContentByUid: (uid: string, content: string, partial?: boolean) => Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,6 +70,7 @@ export const TASK_CALLBACKS_KEYS = [
|
||||
"clearActiveHookExecution",
|
||||
"getActiveHookExecution",
|
||||
"runUserPromptSubmitHook",
|
||||
"replaceMessageContentByUid",
|
||||
] as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -623,7 +623,7 @@ export class OpenAiCodexOAuthManager {
|
||||
<svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"></polyline></svg>
|
||||
</div>
|
||||
<h1>Authentication Successful</h1>
|
||||
<p>You're now signed in to OpenAI Codex. You can close this window and return to VS Code.</p>
|
||||
<p>You're now signed in to OpenAI Codex. You can close this window and return to your IDE.</p>
|
||||
<p class="closing">This window will close automatically...</p>
|
||||
</div>
|
||||
<script>setTimeout(() => window.close(), 3000);</script>
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as path from "path"
|
||||
import * as readline from "readline"
|
||||
import { Logger } from "@/shared/services/Logger"
|
||||
import { getBinaryLocation } from "@/utils/fs"
|
||||
import "@/utils/path" // necessary to have access to String.prototype.toPosix
|
||||
|
||||
/*
|
||||
This file provides functionality to perform regex searches on files using ripgrep.
|
||||
@@ -56,8 +57,10 @@ interface SearchResult {
|
||||
}
|
||||
|
||||
const MAX_RESULTS = 300
|
||||
// Default timeout for ripgrep searches (30 seconds)
|
||||
const DEFAULT_RIPGREP_TIMEOUT_MS = 30_000
|
||||
|
||||
async function execRipgrep(args: string[]): Promise<string> {
|
||||
async function execRipgrep(args: string[], timeoutMs: number = DEFAULT_RIPGREP_TIMEOUT_MS): Promise<string> {
|
||||
const binPath: string = await getBinaryLocation("rg")
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -70,31 +73,101 @@ async function execRipgrep(args: string[]): Promise<string> {
|
||||
|
||||
let output = ""
|
||||
let lineCount = 0
|
||||
let isResolved = false
|
||||
let sigkillTimeout: NodeJS.Timeout | null = null
|
||||
const maxLines = MAX_RESULTS * 5 // limiting ripgrep output with max lines since there's no other way to limit results. it's okay that we're outputting as json, since we're parsing it line by line and ignore anything that's not part of a match. This assumes each result is at most 5 lines.
|
||||
|
||||
// Cleanup function to ensure process and listeners are properly cleaned up
|
||||
const cleanup = () => {
|
||||
// Clear any pending SIGKILL timeout
|
||||
if (sigkillTimeout) {
|
||||
clearTimeout(sigkillTimeout)
|
||||
sigkillTimeout = null
|
||||
}
|
||||
if (rgProcess && !rgProcess.killed) {
|
||||
rgProcess.kill("SIGTERM")
|
||||
// Force kill if still running after a short delay
|
||||
sigkillTimeout = setTimeout(() => {
|
||||
if (rgProcess && !rgProcess.killed) {
|
||||
rgProcess.kill("SIGKILL")
|
||||
}
|
||||
sigkillTimeout = null
|
||||
}, 1000)
|
||||
}
|
||||
rl.removeAllListeners()
|
||||
rgProcess.stdout?.removeAllListeners()
|
||||
rgProcess.stderr?.removeAllListeners()
|
||||
rgProcess.removeAllListeners()
|
||||
}
|
||||
|
||||
// Set up timeout
|
||||
const timeoutHandle = setTimeout(() => {
|
||||
if (!isResolved) {
|
||||
isResolved = true
|
||||
cleanup()
|
||||
reject(new Error(`ripgrep process timed out after ${timeoutMs}ms`))
|
||||
}
|
||||
}, timeoutMs)
|
||||
|
||||
// Helper to resolve/reject once
|
||||
const finish = (result: "resolve" | "reject", value?: string | Error) => {
|
||||
if (isResolved) {
|
||||
return
|
||||
}
|
||||
isResolved = true
|
||||
clearTimeout(timeoutHandle)
|
||||
cleanup()
|
||||
if (result === "resolve") {
|
||||
resolve(value as string)
|
||||
} else {
|
||||
reject(value as Error)
|
||||
}
|
||||
}
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (isResolved) {
|
||||
return
|
||||
}
|
||||
if (lineCount < maxLines) {
|
||||
output += line + "\n"
|
||||
lineCount++
|
||||
} else {
|
||||
rl.close()
|
||||
rgProcess.kill()
|
||||
rgProcess.kill("SIGTERM")
|
||||
}
|
||||
})
|
||||
|
||||
let errorOutput = ""
|
||||
rgProcess.stderr.on("data", (data) => {
|
||||
if (isResolved) {
|
||||
return
|
||||
}
|
||||
errorOutput += data.toString()
|
||||
})
|
||||
|
||||
rl.on("close", () => {
|
||||
if (isResolved) {
|
||||
return
|
||||
}
|
||||
if (errorOutput) {
|
||||
reject(new Error(`ripgrep process error: ${errorOutput}`))
|
||||
finish("reject", new Error(`ripgrep process error: ${errorOutput}`))
|
||||
} else {
|
||||
resolve(output)
|
||||
finish("resolve", output)
|
||||
}
|
||||
})
|
||||
|
||||
rgProcess.on("error", (error) => {
|
||||
reject(new Error(`ripgrep process error: ${error.message}`))
|
||||
if (isResolved) {
|
||||
return
|
||||
}
|
||||
finish("reject", new Error(`ripgrep process error: ${error.message}`))
|
||||
})
|
||||
|
||||
rgProcess.on("exit", (code, signal) => {
|
||||
// If process exits before we've resolved, handle it
|
||||
if (!isResolved && code !== null && code !== 0) {
|
||||
finish("reject", new Error(`ripgrep process exited with code ${code}${signal ? ` (signal: ${signal})` : ""}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -105,12 +178,18 @@ export async function regexSearchFiles(
|
||||
regex: string,
|
||||
filePattern?: string,
|
||||
clineIgnoreController?: ClineIgnoreController,
|
||||
includesHiddenFiles = true, // Includes hidden files and ignored files by default
|
||||
timeoutMs?: number, // Optional timeout in milliseconds (default: 30 seconds)
|
||||
): Promise<string> {
|
||||
const args = ["--json", "-e", regex, "--glob", filePattern || "*", "--context", "1", directoryPath]
|
||||
|
||||
if (!includesHiddenFiles) {
|
||||
args.push("--no-hidden")
|
||||
}
|
||||
|
||||
let output: string
|
||||
try {
|
||||
output = await execRipgrep(args)
|
||||
output = await execRipgrep(args, timeoutMs)
|
||||
} catch (error) {
|
||||
throw Error("Error calling ripgrep", { cause: error })
|
||||
}
|
||||
|
||||
@@ -103,6 +103,7 @@ export interface ExtensionState {
|
||||
lastDismissedInfoBannerVersion: number
|
||||
lastDismissedModelBannerVersion: number
|
||||
lastDismissedCliBannerVersion: number
|
||||
dismissedBanners?: Array<{ bannerId: string; dismissedAt: number }>
|
||||
hooksEnabled?: boolean
|
||||
remoteConfigSettings?: Partial<RemoteConfigFields>
|
||||
subagentsEnabled?: boolean
|
||||
@@ -134,6 +135,7 @@ export interface ClineMessage {
|
||||
conversationHistoryIndex?: number
|
||||
conversationHistoryDeletedRange?: [number, number] // for when conversation history is truncated for API requests
|
||||
modelInfo?: ClineMessageModelInfo
|
||||
uid?: string
|
||||
}
|
||||
|
||||
export type ClineAsk =
|
||||
@@ -201,10 +203,12 @@ export interface ClineSayTool {
|
||||
| "listFilesTopLevel"
|
||||
| "listFilesRecursive"
|
||||
| "listCodeDefinitionNames"
|
||||
| "searchAgent"
|
||||
| "searchFiles"
|
||||
| "webFetch"
|
||||
| "webSearch"
|
||||
| "summarizeTask"
|
||||
| "subagent"
|
||||
| "useSkill"
|
||||
path?: string
|
||||
diff?: string
|
||||
|
||||
+25
-50
@@ -81,65 +81,31 @@ export interface BannerAction {
|
||||
* TODO: Backend would return a similar JSON structure in the future which we will replace this with.
|
||||
*/
|
||||
export const BANNER_DATA: BannerCardData[] = [
|
||||
// Info banner with inline link
|
||||
// ChatGPT integration banner
|
||||
{
|
||||
id: "info-banner-v1",
|
||||
icon: "lightbulb",
|
||||
title: "Use Cline in Right Sidebar",
|
||||
description:
|
||||
"For the best experience, drag the Cline icon to your right sidebar. This keeps your file explorer and editor visible while you chat with Cline, making it easier to navigate your codebase and see changes in real-time. [See how →](https://docs.cline.bot/features/customization/opening-cline-in-sidebar)",
|
||||
},
|
||||
|
||||
// Announcement with conditional actions based on user auth state
|
||||
{
|
||||
id: "new-model-opus-4-5-cline-users",
|
||||
id: "chatgpt-integration-v1",
|
||||
icon: "megaphone",
|
||||
title: "Claude Opus 4.5 Now Available",
|
||||
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
|
||||
actions: [
|
||||
{
|
||||
title: "Try Now",
|
||||
action: BannerActionType.SetModel,
|
||||
arg: "anthropic/claude-opus-4.5",
|
||||
},
|
||||
],
|
||||
isClineUserOnly: true, // Only Cline users see this
|
||||
},
|
||||
|
||||
{
|
||||
id: "new-model-opus-4-5-non-cline-users",
|
||||
icon: "megaphone",
|
||||
title: "Claude Opus 4.5 Now Available",
|
||||
description: "State-of-the-art performance at 3x lower cost than Opus 4.1. Available now in the Cline provider.",
|
||||
actions: [
|
||||
{
|
||||
title: "Get Started",
|
||||
action: BannerActionType.ShowAccount,
|
||||
},
|
||||
],
|
||||
isClineUserOnly: false, // Only non-Cline users see this
|
||||
},
|
||||
|
||||
// Platform-specific banner (macOS/Linux)
|
||||
{
|
||||
id: "cli-install-unix-v1",
|
||||
icon: "terminal",
|
||||
title: "CLI & Subagents Available",
|
||||
platforms: ["mac", "linux"] satisfies BannerCardData["platforms"],
|
||||
title: "Use ChatGPT with Cline",
|
||||
description:
|
||||
"Use Cline in your terminal and enable subagent capabilities. [Learn more](https://docs.cline.bot/cline-cli/overview)",
|
||||
"Bring your ChatGPT subscription to Cline! Use your existing plan directly with no per token costs or API keys to manage.",
|
||||
actions: [
|
||||
{
|
||||
title: "Install",
|
||||
action: BannerActionType.InstallCli,
|
||||
},
|
||||
{
|
||||
title: "Enable Subagents",
|
||||
action: BannerActionType.ShowFeatureSettings,
|
||||
title: "Connect",
|
||||
action: BannerActionType.ShowApiSettings,
|
||||
arg: "openai-codex", // Pre-select OpenAI Codex provider
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Jupyter Notebooks banner
|
||||
{
|
||||
id: "jupyter-notebooks-v1",
|
||||
icon: "book-open",
|
||||
title: "Jupyter Notebooks",
|
||||
description:
|
||||
"Comprehensive AI-assisted editing of `.ipynb` files with full cell-level context awareness. [Learn More →](https://docs.cline.bot/features/jupyter-notebooks)",
|
||||
},
|
||||
|
||||
// Platform-specific banner (Windows)
|
||||
{
|
||||
id: "cli-info-windows-v1",
|
||||
@@ -149,4 +115,13 @@ export const BANNER_DATA: BannerCardData[] = [
|
||||
description:
|
||||
"Available for macOS and Linux. Coming soon to other platforms. [Learn more](https://docs.cline.bot/cline-cli/overview)",
|
||||
},
|
||||
|
||||
// Info banner with inline link
|
||||
{
|
||||
id: "info-banner-v1",
|
||||
icon: "lightbulb",
|
||||
title: "Use Cline in Right Sidebar",
|
||||
description:
|
||||
"For the best experience, drag the Cline icon to your right sidebar. This keeps your file explorer and editor visible while you chat with Cline, making it easier to navigate your codebase and see changes in real-time. [See how →](https://docs.cline.bot/features/customization/opening-cline-in-sidebar)",
|
||||
},
|
||||
]
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Minimal interface for API handler used by subagents.
|
||||
* This is a subset of the full ApiHandler interface from @/core/api,
|
||||
* defined here to avoid importing extension-only code into shared modules.
|
||||
*/
|
||||
export interface SubagentApiHandler {
|
||||
createMessage(systemPrompt: string, messages: SubagentMessage[], tools?: unknown[], useResponseApi?: boolean): unknown
|
||||
getModel(): { id: string; info: unknown }
|
||||
abort?(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal message interface for subagent communication.
|
||||
* Compatible with ClineStorageMessage from @/shared/messages.
|
||||
*/
|
||||
export interface SubagentMessage {
|
||||
role: "user" | "assistant"
|
||||
content: string | unknown[]
|
||||
id?: string
|
||||
}
|
||||
|
||||
export interface AgentContext {
|
||||
filePaths: Set<string>
|
||||
searchResults: Map<string, GeneralToolResult>
|
||||
fileContents: Map<string, string>
|
||||
/** The agent's final result/answer text */
|
||||
resultText?: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
query: string
|
||||
workspaceName?: string
|
||||
workspaceResults: string
|
||||
resultCount: number
|
||||
success: boolean
|
||||
}
|
||||
export interface GeneralToolResult {
|
||||
agent: string
|
||||
query: string
|
||||
result: string
|
||||
error?: string
|
||||
success: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents actions extracted from an agent's response
|
||||
*/
|
||||
export interface AgentActions {
|
||||
/** Tool calls to execute (e.g., search queries, file reads) */
|
||||
toolCalls: unknown[]
|
||||
/** The agent's result/answer content extracted from the contextTag */
|
||||
resultContent: string[]
|
||||
/** Whether the agent is ready to provide a final answer */
|
||||
isReadyToAnswer: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress update sent during agent iteration
|
||||
*/
|
||||
export interface AgentIterationUpdate {
|
||||
/** Actions extracted from the agent's response */
|
||||
actions?: AgentActions
|
||||
/** Current context state */
|
||||
context?: unknown
|
||||
/** Cost incurred in this iteration */
|
||||
cost?: number
|
||||
/** Message describing the current status etc */
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A single status entry for subagent timeline display
|
||||
*/
|
||||
export interface SubagentStatusEntry {
|
||||
/** Iteration number (1-indexed) */
|
||||
iteration: number
|
||||
/** Maximum iterations */
|
||||
maxIterations: number
|
||||
/** Timestamp when this entry was created */
|
||||
timestamp: number
|
||||
/** Status message */
|
||||
status: string
|
||||
/** Type of status entry */
|
||||
type: "searching" | "reading" | "running" | "fetching" | "ready" | "message" | "cost" | "error"
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a ClineAgent instance
|
||||
*/
|
||||
export interface ClineAgentConfig {
|
||||
/** Model ID to use (e.g., "x-ai/grok-code-fast-1") */
|
||||
modelId: string
|
||||
/** Maximum number of iterations in the agentic loop */
|
||||
maxIterations?: number
|
||||
/** Prompt for the agent */
|
||||
prompt: string
|
||||
/** System Prompt for the agent */
|
||||
systemPrompt?: string
|
||||
/** Starting messages for the agent */
|
||||
messages?: SubagentMessage[]
|
||||
/** API Request Params */
|
||||
apiParams?: Record<string, unknown>
|
||||
/** Optional API client to use instead of the default ClineHandler */
|
||||
client?: SubagentApiHandler
|
||||
/** Optional tag name for context files extraction (default: no context extraction) */
|
||||
contextTag?: string
|
||||
/** Optional tag name for ready-to-answer check (default: no ready check) */
|
||||
answerTag?: string
|
||||
/** Optional AbortSignal to allow cancellation of the agent's execution */
|
||||
abortSignal?: AbortSignal
|
||||
/** The tool call id associated with the original message */
|
||||
callId: string
|
||||
}
|
||||
@@ -78,7 +78,19 @@ function filterPartialToolMessages(messages: ClineMessage[]): ClineMessage[] {
|
||||
|
||||
// Filter out partial tool/command messages only
|
||||
const isToolOrCommand = isToolOrCommandMessage(msg)
|
||||
return !(isToolOrCommand && msg.partial === true)
|
||||
if (isToolOrCommand && msg.partial === true) {
|
||||
// Allow partial subagent tools through so they show progress while running
|
||||
try {
|
||||
const toolData = JSON.parse(msg.text || "{}")
|
||||
if (toolData.tool === "subagent") {
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// If parsing fails, filter it out
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ type FieldDefinition<T> = {
|
||||
|
||||
type FieldDefinitions = Record<string, FieldDefinition<any>>
|
||||
|
||||
export type ConfiguredAPIKeys = Partial<Record<ApiProvider, boolean>>
|
||||
const REMOTE_CONFIG_EXTRA_FIELDS = {
|
||||
remoteConfiguredProviders: { default: [] as ApiProvider[] },
|
||||
allowedMCPServers: { default: [] as Array<{ id: string }> },
|
||||
@@ -58,6 +59,7 @@ const REMOTE_CONFIG_EXTRA_FIELDS = {
|
||||
blockPersonalRemoteMCPServers: { default: false as boolean },
|
||||
openTelemetryOtlpHeaders: { default: undefined as Record<string, string> | undefined },
|
||||
blobStoreConfig: { default: undefined as BlobStoreSettings | undefined },
|
||||
configuredApiKeys: { default: {} as ConfiguredAPIKeys | undefined },
|
||||
} satisfies FieldDefinitions
|
||||
|
||||
const GLOBAL_STATE_FIELDS = {
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum ClineDefaultTool {
|
||||
FILE_READ = "read_file",
|
||||
FILE_NEW = "write_to_file",
|
||||
SEARCH = "search_files",
|
||||
SUBAGENT = "subagent",
|
||||
LIST_FILES = "list_files",
|
||||
LIST_CODE_DEF = "list_code_definition_names",
|
||||
BROWSER = "browser_action",
|
||||
@@ -50,4 +51,5 @@ export const READ_ONLY_TOOLS = [
|
||||
ClineDefaultTool.WEB_SEARCH,
|
||||
ClineDefaultTool.WEB_FETCH,
|
||||
ClineDefaultTool.USE_SKILL,
|
||||
ClineDefaultTool.SUBAGENT,
|
||||
] as const
|
||||
|
||||
@@ -61,6 +61,7 @@ import QuoteButton from "./QuoteButton"
|
||||
import ReportBugPreview from "./ReportBugPreview"
|
||||
import { RequestStartRow } from "./RequestStartRow"
|
||||
import SearchResultsDisplay from "./SearchResultsDisplay"
|
||||
import SubagentRow from "./SubagentRow"
|
||||
import { ThinkingRow } from "./ThinkingRow"
|
||||
import UserMessage from "./UserMessage"
|
||||
|
||||
@@ -595,6 +596,12 @@ export const ChatRowContent = memo(
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
case "subagent":
|
||||
return (
|
||||
<div key={message.ts}>
|
||||
<SubagentRow className={HEADER_CLASSNAMES} message={message} tool={tool} />
|
||||
</div>
|
||||
)
|
||||
case "searchFiles":
|
||||
return (
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { SubagentStatusEntry } from "@shared/cline/subagent"
|
||||
import { ClineMessage, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
CheckCircle2Icon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
Loader2Icon,
|
||||
PlayIcon,
|
||||
ScanSearchIcon,
|
||||
SearchIcon,
|
||||
} from "lucide-react"
|
||||
import React, { useMemo, useState } from "react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface SubagentRowProps {
|
||||
message: ClineMessage
|
||||
tool: ClineSayTool
|
||||
className?: string
|
||||
}
|
||||
|
||||
const getStatusIcon = (type: SubagentStatusEntry["type"], isLast: boolean) => {
|
||||
const iconClass = isLast ? "size-3 animate-pulse" : "size-3"
|
||||
switch (type) {
|
||||
case "searching":
|
||||
return <SearchIcon className={iconClass} />
|
||||
case "reading":
|
||||
return <FileTextIcon className={iconClass} />
|
||||
case "running":
|
||||
return <PlayIcon className={iconClass} />
|
||||
case "fetching":
|
||||
return <GlobeIcon className={iconClass} />
|
||||
case "ready":
|
||||
return <CheckCircle2Icon className={iconClass} />
|
||||
case "error":
|
||||
return <AlertTriangleIcon className={`${iconClass} text-warning`} />
|
||||
default:
|
||||
return <ScanSearchIcon className={iconClass} />
|
||||
}
|
||||
}
|
||||
|
||||
const SubagentRow: React.FC<SubagentRowProps> = ({ className, message, tool }) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
const statusEntries = useMemo((): SubagentStatusEntry[] => {
|
||||
if (!tool.content) {
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(tool.content)
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as SubagentStatusEntry[]
|
||||
}
|
||||
} catch {
|
||||
// Fallback for old format (plain string)
|
||||
}
|
||||
return []
|
||||
}, [tool.content])
|
||||
|
||||
const latestStatus = statusEntries.length > 0 ? statusEntries[statusEntries.length - 1] : null
|
||||
const isRunning = message.partial !== false
|
||||
|
||||
return (
|
||||
<div key={message.uid}>
|
||||
<div className={className}>
|
||||
{isRunning ? <Loader2Icon className="size-2 animate-spin" /> : <ScanSearchIcon className="size-2" />}
|
||||
<span className="bold">Subagent:</span>
|
||||
<span className="text-description truncate">{tool.filePattern}</span>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-code-block-background text-description border border-editor-group-border rounded-xs overflow-hidden w-full flex flex-col justify-start items-start text-left p-2"
|
||||
key={message.ts}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
variant="ghost">
|
||||
{/* Current status summary */}
|
||||
{latestStatus && (
|
||||
<div className="w-full flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
[{latestStatus.iteration}/{latestStatus.maxIterations}]
|
||||
</span>
|
||||
<span className="truncate">{latestStatus.status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timeline when expanded */}
|
||||
{isExpanded && statusEntries.length > 0 && (
|
||||
<div className="w-full flex flex-col gap-0.5 text-left select-text pt-2 max-h-60 overflow-y-auto">
|
||||
{statusEntries.map((entry, index) => {
|
||||
const isLast = index === statusEntries.length - 1
|
||||
const isError = entry.type === "error"
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start gap-2 text-xs py-0.5 ${isError ? "text-warning" : isLast && isRunning ? "text-foreground" : "text-muted-foreground"}`}
|
||||
key={`${entry.timestamp}-${index}`}>
|
||||
<div className="flex items-center gap-1.5 min-w-[60px]">
|
||||
{getStatusIcon(entry.type, isLast && isRunning)}
|
||||
<span className={isError ? "text-warning" : "text-muted-foreground"}>
|
||||
[{entry.iteration}/{entry.maxIterations}]
|
||||
</span>
|
||||
</div>
|
||||
<span className="break-words">{entry.status}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fallback for old format or empty */}
|
||||
{statusEntries.length === 0 && tool.content && (
|
||||
<div className="w-full text-xs text-muted-foreground">{tool.content}</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubagentRow
|
||||
@@ -31,7 +31,8 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
taskHistory,
|
||||
shouldShowQuickWins,
|
||||
}) => {
|
||||
const { lastDismissedInfoBannerVersion, lastDismissedCliBannerVersion, lastDismissedModelBannerVersion } = useExtensionState()
|
||||
const { lastDismissedInfoBannerVersion, lastDismissedCliBannerVersion, lastDismissedModelBannerVersion, dismissedBanners } =
|
||||
useExtensionState()
|
||||
|
||||
// Track if we've shown the "What's New" modal this session
|
||||
const [hasShownWhatsNewModal, setHasShownWhatsNewModal] = useState(false)
|
||||
@@ -91,11 +92,18 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
}, [navigateToWorktrees])
|
||||
|
||||
/**
|
||||
* Check if a banner has been dismissed based on its version
|
||||
* Check if a banner has been dismissed based on its ID or legacy version
|
||||
*/
|
||||
const isBannerDismissed = useCallback(
|
||||
(bannerId: string): boolean => {
|
||||
// !! Do not keep tracking the banner versions like this. !!
|
||||
// Check if banner is in the dismissed banners list (new approach)
|
||||
if (
|
||||
dismissedBanners?.some((dismissed: { bannerId: string; dismissedAt: number }) => dismissed.bannerId === bannerId)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Legacy version-based tracking (deprecated)
|
||||
if (bannerId.startsWith("info-banner")) {
|
||||
return (lastDismissedInfoBannerVersion ?? 0) >= 1
|
||||
}
|
||||
@@ -107,7 +115,7 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
}
|
||||
return false
|
||||
},
|
||||
[lastDismissedInfoBannerVersion, lastDismissedModelBannerVersion, lastDismissedCliBannerVersion],
|
||||
[dismissedBanners, lastDismissedInfoBannerVersion, lastDismissedModelBannerVersion, lastDismissedCliBannerVersion],
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -165,6 +173,13 @@ export const WelcomeSection: React.FC<WelcomeSectionProps> = ({
|
||||
break
|
||||
|
||||
case BannerActionType.ShowApiSettings:
|
||||
if (action.arg) {
|
||||
// Pre-select the provider before navigating
|
||||
handleFieldsChange({
|
||||
planModeApiProvider: action.arg as any,
|
||||
actModeApiProvider: action.arg as any,
|
||||
})
|
||||
}
|
||||
navigateToSettings("api-config")
|
||||
break
|
||||
|
||||
|
||||
+89
-22
@@ -45,6 +45,11 @@ const getActivityText = (tool: ClineSayTool): string | null => {
|
||||
case "listFilesTopLevel":
|
||||
case "listFilesRecursive":
|
||||
return tool.path ? `Exploring ${cleanedPath}/...` : null
|
||||
case "subagent": {
|
||||
// Subagent uses content for progress text, filePattern for the task description
|
||||
const subagentText = tool.content || tool.filePattern
|
||||
return subagentText ? "Subagent is working..." : "Starting Subagent..."
|
||||
}
|
||||
case "searchFiles":
|
||||
return tool.regex && tool.path ? `Searching ${formatSearchRegex(tool.regex, tool.path, tool.filePattern)}...` : null
|
||||
case "listCodeDefinitionNames":
|
||||
@@ -55,44 +60,90 @@ const getActivityText = (tool: ClineSayTool): string | null => {
|
||||
}
|
||||
|
||||
// Calculate current activities (from RequestStartRow logic)
|
||||
// Must match the same range logic as getToolsNotInCurrentActivities in messageUtils.ts
|
||||
const getCurrentActivities = (allMessages: ClineMessage[]): ClineMessage[] => {
|
||||
// Find current api_req
|
||||
let currentApiReqIndex = -1
|
||||
if (allMessages.at(-1)?.say !== "tool") {
|
||||
return []
|
||||
}
|
||||
// Find the most recent api_req_started
|
||||
let mostRecentApiReqIndex = -1
|
||||
let mostRecentHasCost = false
|
||||
for (let i = allMessages.length - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const info = JSON.parse(msg.text)
|
||||
const hasCost = info.cost != null
|
||||
if (!hasCost) {
|
||||
currentApiReqIndex = i
|
||||
break
|
||||
}
|
||||
mostRecentApiReqIndex = i
|
||||
mostRecentHasCost = info.cost != null
|
||||
break
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (currentApiReqIndex === -1) {
|
||||
if (mostRecentApiReqIndex === -1) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Collect tools AFTER the current api_req_started
|
||||
const activities: ClineMessage[] = []
|
||||
for (let i = currentApiReqIndex + 1; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i]
|
||||
// Only collect tools that are currently executing (ask === "tool")
|
||||
// Skip completed tools (say === "tool") - they should be in the completed list
|
||||
if (msg.say === "tool" || msg.ask !== "tool") {
|
||||
continue
|
||||
if (!mostRecentHasCost) {
|
||||
// CASE A: Most recent api_req is INCOMPLETE (loading state active)
|
||||
// Find the previous COMPLETED api_req
|
||||
let prevCompletedApiReqIndex = -1
|
||||
for (let i = mostRecentApiReqIndex - 1; i >= 0; i--) {
|
||||
const msg = allMessages[i]
|
||||
if (msg.say === "api_req_started" && msg.text) {
|
||||
try {
|
||||
const prevInfo = JSON.parse(msg.text)
|
||||
if (prevInfo.cost != null) {
|
||||
prevCompletedApiReqIndex = i
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isLowStakesTool(msg)) {
|
||||
activities.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return activities
|
||||
if (prevCompletedApiReqIndex === -1) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Collect tools BETWEEN prev completed and current incomplete
|
||||
// This matches the range filtered by getToolsNotInCurrentActivities CASE A
|
||||
const activities: ClineMessage[] = []
|
||||
for (let i = prevCompletedApiReqIndex + 1; i < mostRecentApiReqIndex; i++) {
|
||||
const msg = allMessages[i]
|
||||
// Only collect tools that are currently executing (ask === "tool")
|
||||
// Skip completed tools (say === "tool") - they should be in the completed list
|
||||
if (msg.say === "tool" || msg.ask !== "tool") {
|
||||
continue
|
||||
}
|
||||
if (isLowStakesTool(msg)) {
|
||||
activities.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return activities
|
||||
} else {
|
||||
// CASE B: Most recent api_req is COMPLETE (has cost)
|
||||
// Collect tools AFTER the completed api_req
|
||||
// This matches the range filtered by getToolsNotInCurrentActivities CASE B
|
||||
const activities: ClineMessage[] = []
|
||||
for (let i = mostRecentApiReqIndex + 1; i < allMessages.length; i++) {
|
||||
const msg = allMessages[i]
|
||||
// Only collect tools that are currently executing (ask === "tool")
|
||||
// Skip completed tools (say === "tool") - they should be in the completed list
|
||||
if (msg.say === "tool" || msg.ask !== "tool") {
|
||||
continue
|
||||
}
|
||||
if (isLowStakesTool(msg)) {
|
||||
activities.push(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return activities
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -291,6 +342,16 @@ function getToolDisplayInfo(tool: ClineSayTool) {
|
||||
label: `search: ${tool.regex}`,
|
||||
displayText: formatSearchDisplay(tool.regex || "", filePath, tool.filePattern),
|
||||
}
|
||||
case "subagent": {
|
||||
// Subagent uses content for progress text, filePattern for the task description
|
||||
const subagentDisplayText = tool.filePattern || "Subagent working..."
|
||||
return {
|
||||
icon,
|
||||
path: folderPath,
|
||||
label: "subagent",
|
||||
displayText: subagentDisplayText,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
@@ -320,7 +381,7 @@ function formatSearchDisplay(regex: string, path: string, filePattern?: string):
|
||||
* Get summary label for a tool group - shows what's been added to context.
|
||||
*/
|
||||
function getToolGroupSummary(messages: ClineMessage[]): string {
|
||||
const counts = { read: 0, list: 0, search: 0, def: 0 }
|
||||
const counts = { read: 0, list: 0, search: 0, def: 0, subagent: 0 }
|
||||
|
||||
for (const msg of messages) {
|
||||
if (!isLowStakesTool(msg)) {
|
||||
@@ -339,6 +400,9 @@ function getToolGroupSummary(messages: ClineMessage[]): string {
|
||||
case "searchFiles":
|
||||
counts.search++
|
||||
break
|
||||
case "subagent":
|
||||
counts.subagent++
|
||||
break
|
||||
case "listCodeDefinitionNames":
|
||||
counts.def++
|
||||
break
|
||||
@@ -360,6 +424,9 @@ function getToolGroupSummary(messages: ClineMessage[]): string {
|
||||
if (counts.search > 0) {
|
||||
parts.push(`performed ${counts.search} search${counts.search > 1 ? "es" : ""}`)
|
||||
}
|
||||
if (counts.subagent > 0) {
|
||||
parts.push(`consulted ${counts.subagent} subagent${counts.subagent > 1 ? "s" : ""}`)
|
||||
}
|
||||
|
||||
return parts.length === 0 ? "Context" : "Cline" + action + parts.join(", ")
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { combineApiRequests } from "@shared/combineApiRequests"
|
||||
import { combineCommandSequences } from "@shared/combineCommandSequences"
|
||||
import { ClineMessage, ClineSayBrowserAction, ClineSayTool } from "@shared/ExtensionMessage"
|
||||
import { FileIcon, FolderOpenDotIcon, FolderOpenIcon, SearchIcon, ShapesIcon, WrenchIcon } from "lucide-react"
|
||||
import { FileIcon, FolderOpenDotIcon, FolderOpenIcon, SearchCodeIcon, SearchIcon, ShapesIcon, WrenchIcon } from "lucide-react"
|
||||
|
||||
/**
|
||||
* Low-stakes tool types that should be grouped together
|
||||
@@ -836,6 +836,8 @@ export function getIconByToolName(toolName: string) {
|
||||
return SearchIcon
|
||||
case "listCodeDefinitionNames":
|
||||
return ShapesIcon
|
||||
case "subagent":
|
||||
return SearchCodeIcon
|
||||
default:
|
||||
return WrenchIcon
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ const BannerCardContent: React.FC<BannerCardContentProps> = ({ banner, isActive,
|
||||
})}
|
||||
style={{
|
||||
gridArea: "stack",
|
||||
pointerEvents: isActive ? "auto" : "none", // Disable interaction on inactive cards
|
||||
}}>
|
||||
{/* Title with optional icon */}
|
||||
<h3
|
||||
@@ -122,7 +123,7 @@ export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
|
||||
autoPlayIntervalRef.current = setInterval(() => {
|
||||
setCurrentIndex((prevIndex) => (prevIndex + 1) % banners.length)
|
||||
}, 5000) // Rotate every 5 seconds
|
||||
}, 6500) // Rotate every 6.5 seconds
|
||||
|
||||
return () => {
|
||||
if (autoPlayIntervalRef.current) {
|
||||
@@ -144,7 +145,7 @@ export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
return null
|
||||
}
|
||||
|
||||
const showDismissButton = safeCurrentIndex === banners.length - 1 && currentBanner.onDismiss
|
||||
const showDismissButton = !!currentBanner.onDismiss
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -157,16 +158,16 @@ export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
role="region">
|
||||
{/* Card container */}
|
||||
<div className="relative bg-muted rounded-sm">
|
||||
{/* Dismiss button - only show on last card, dismisses ALL banners */}
|
||||
{/* Dismiss button - shows on each card that has onDismiss defined */}
|
||||
{showDismissButton && (
|
||||
<Button
|
||||
aria-label="Dismiss all banners"
|
||||
aria-label="Dismiss banner"
|
||||
className="absolute top-2.5 right-2 z-10"
|
||||
data-testid="banner-dismiss-button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Dismiss ALL banners, not just the current one
|
||||
banners.forEach((banner) => banner.onDismiss?.())
|
||||
// Dismiss only the current banner
|
||||
currentBanner.onDismiss?.()
|
||||
}}
|
||||
size="icon"
|
||||
variant="icon">
|
||||
@@ -178,8 +179,7 @@ export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
<div className="grid" style={{ gridTemplateAreas: "'stack'" }}>
|
||||
{banners.map((banner, idx) => {
|
||||
const isActive = idx === safeCurrentIndex
|
||||
const isLastBanner = idx === banners.length - 1
|
||||
const showDismiss = isLastBanner && banner.onDismiss
|
||||
const showDismiss = !!banner.onDismiss
|
||||
|
||||
return (
|
||||
<BannerCardContent
|
||||
@@ -187,7 +187,7 @@ export const BannerCarousel: React.FC<BannerCarouselProps> = ({ banners }) => {
|
||||
isActive={isActive}
|
||||
isTransitioning={isTransitioning}
|
||||
key={banner.id}
|
||||
showDismissButton={!!showDismiss}
|
||||
showDismissButton={showDismiss}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -16,7 +16,13 @@ interface WhatsNewModalProps {
|
||||
|
||||
export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, version }) => {
|
||||
const { clineUser } = useClineAuth()
|
||||
const { openRouterModels, setShowChatModelSelector, refreshOpenRouterModels, navigateToSettings } = useExtensionState()
|
||||
const {
|
||||
openRouterModels,
|
||||
setShowChatModelSelector,
|
||||
refreshOpenRouterModels,
|
||||
navigateToSettings,
|
||||
navigateToSettingsModelPicker,
|
||||
} = useExtensionState()
|
||||
const { handleFieldsChange } = useApiConfigurationHandlers()
|
||||
|
||||
const clickedModelsRef = useRef<Set<string>>(new Set())
|
||||
@@ -42,6 +48,19 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
[handleFieldsChange, openRouterModels, setShowChatModelSelector, onClose],
|
||||
)
|
||||
|
||||
const navigateToModelPicker = useCallback(
|
||||
(initialModelTab: "recommended" | "free") => {
|
||||
// Switch to Cline provider first so the model picker tab works
|
||||
handleFieldsChange({
|
||||
planModeApiProvider: "cline",
|
||||
actModeApiProvider: "cline",
|
||||
})
|
||||
onClose()
|
||||
navigateToSettingsModelPicker({ targetSection: "api-config", initialModelTab })
|
||||
},
|
||||
[handleFieldsChange, navigateToSettingsModelPicker, onClose],
|
||||
)
|
||||
|
||||
const setOpenAiCodexProvider = useCallback(() => {
|
||||
handleFieldsChange({
|
||||
planModeApiProvider: "openai-codex",
|
||||
@@ -79,6 +98,35 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
</Button>
|
||||
)
|
||||
|
||||
type InlineModelLinkProps =
|
||||
| { type: "model"; modelId: string; label: string }
|
||||
| { type: "picker"; pickerTab: "recommended" | "free"; label: string }
|
||||
|
||||
const InlineModelLink: React.FC<InlineModelLinkProps> = (props) => {
|
||||
if (props.type === "picker") {
|
||||
return (
|
||||
<span
|
||||
onClick={() => navigateToModelPicker(props.pickerTab)}
|
||||
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
|
||||
{props.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const isClicked = clickedModelsRef.current.has(props.modelId)
|
||||
if (isClicked) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
onClick={() => setModel(props.modelId)}
|
||||
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
|
||||
{props.label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={(isOpen) => !isOpen && onClose()} open={open}>
|
||||
<DialogContent
|
||||
@@ -96,25 +144,28 @@ export const WhatsNewModal: React.FC<WhatsNewModalProps> = ({ open, onClose, ver
|
||||
{/* Description */}
|
||||
<ul className="text-sm pl-3 list-disc" style={{ color: "var(--vscode-descriptionForeground)" }}>
|
||||
<li className="mb-2">
|
||||
<strong>OpenAI ChatGPT Subscription Integration:</strong> Use your ChatGPT subscription directly in
|
||||
Cline with no additional token cost and no api keys to manage.{" "}
|
||||
<strong>New free model: Arcee Trinity Large:</strong> strong coding performance with an open-weight
|
||||
model.{" "}
|
||||
<InlineModelLink label="Try free" modelId="cline:arcee-ai/trinity-large-preview:free" type="model" />
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Try Kimi K2.5:</strong> Moonshot's latest with advanced reasoning for complex, multi-step
|
||||
coding tasks. Great for front-end tasks.{" "}
|
||||
<InlineModelLink label="Try now" modelId="cline:moonshotai/kimi-k2.5" type="model" />
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Bring your ChatGPT subscription to Cline!</strong> Use your existing plan directly with no per
|
||||
token costs or API keys to manage.{" "}
|
||||
<span
|
||||
onClick={setOpenAiCodexProvider}
|
||||
style={{ color: "var(--vscode-textLink-foreground)", cursor: "pointer" }}>
|
||||
Sign in
|
||||
Connect
|
||||
</span>
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong>Jupyter Notebooks:</strong> Comprehensive AI-assisted editing of <code>.ipynb</code> files
|
||||
with full cell-level context awareness.{" "}
|
||||
<a
|
||||
href="https://docs.cline.bot/features/jupyter-notebooks"
|
||||
style={{ color: "var(--vscode-textLink-foreground)" }}>
|
||||
Learn More
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<strong>Grok Code Fast 1</strong> and <strong>Devstral-2512</strong> are no longer free to use.
|
||||
<strong>Grok Code Fast 1 & Devstral are saying goodbye (to free):</strong> free promotion is done but
|
||||
there are plenty models in our free tier.{" "}
|
||||
<InlineModelLink label="See alternatives" pickerTab="free" type="picker" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -61,6 +61,7 @@ interface ApiOptionsProps {
|
||||
modelIdErrorMessage?: string
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
initialModelTab?: "recommended" | "free"
|
||||
}
|
||||
|
||||
// This is necessary to ensure dropdown opens downward, important for when this is used in popup
|
||||
@@ -87,7 +88,14 @@ declare module "vscode" {
|
||||
}
|
||||
}
|
||||
|
||||
const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, isPopup, currentMode }: ApiOptionsProps) => {
|
||||
const ApiOptions = ({
|
||||
showModelOptions,
|
||||
apiErrorMessage,
|
||||
modelIdErrorMessage,
|
||||
isPopup,
|
||||
currentMode,
|
||||
initialModelTab,
|
||||
}: ApiOptionsProps) => {
|
||||
// Use full context state for immediate save payload
|
||||
const { apiConfiguration, remoteConfigSettings } = useExtensionState()
|
||||
|
||||
@@ -349,7 +357,12 @@ const ApiOptions = ({ showModelOptions, apiErrorMessage, modelIdErrorMessage, is
|
||||
)}
|
||||
|
||||
{apiConfiguration && selectedProvider === "cline" && (
|
||||
<ClineProvider currentMode={currentMode} isPopup={isPopup} showModelOptions={showModelOptions} />
|
||||
<ClineProvider
|
||||
currentMode={currentMode}
|
||||
initialModelTab={initialModelTab}
|
||||
isPopup={isPopup}
|
||||
showModelOptions={showModelOptions}
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiConfiguration && selectedProvider === "asksage" && (
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface OpenRouterModelPickerProps {
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
showProviderRouting?: boolean
|
||||
initialTab?: "recommended" | "free"
|
||||
}
|
||||
|
||||
// Featured models for Cline provider organized by tabs
|
||||
@@ -89,7 +90,12 @@ export const freeModels = [
|
||||
|
||||
const FREE_CLINE_MODELS = freeModels.map((m) => m.id)
|
||||
|
||||
const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup, currentMode, showProviderRouting }) => {
|
||||
const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({
|
||||
isPopup,
|
||||
currentMode,
|
||||
showProviderRouting,
|
||||
initialTab,
|
||||
}) => {
|
||||
const { handleModeFieldChange, handleModeFieldsChange, handleFieldChange } = useApiConfigurationHandlers()
|
||||
const { apiConfiguration, favoritedModelIds, openRouterModels, refreshOpenRouterModels } = useExtensionState()
|
||||
const modeFields = getModeSpecificFields(apiConfiguration, currentMode)
|
||||
@@ -97,9 +103,19 @@ const OpenRouterModelPicker: React.FC<OpenRouterModelPickerProps> = ({ isPopup,
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false)
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1)
|
||||
const [activeTab, setActiveTab] = useState<"recommended" | "free">(() => {
|
||||
if (initialTab) {
|
||||
return initialTab
|
||||
}
|
||||
const currentModelId = modeFields.openRouterModelId || openRouterDefaultModelId
|
||||
return freeModels.some((m) => m.id === currentModelId) ? "free" : "recommended"
|
||||
})
|
||||
|
||||
// If a caller wants to deep-link to the Free tab (or Recommended), honor that.
|
||||
useEffect(() => {
|
||||
if (initialTab) {
|
||||
setActiveTab(initialTab)
|
||||
}
|
||||
}, [initialTab])
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
const dropdownListRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
@@ -131,7 +131,7 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
[],
|
||||
) // Empty deps - these imports never change
|
||||
|
||||
const { version, environment } = useExtensionState()
|
||||
const { version, environment, settingsInitialModelTab } = useExtensionState()
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>(targetSection || SETTINGS_TABS[0].id)
|
||||
|
||||
@@ -233,10 +233,12 @@ const SettingsView = ({ onDone, targetSection }: SettingsViewProps) => {
|
||||
props.onResetState = handleResetState
|
||||
} else if (activeTab === "about") {
|
||||
props.version = version
|
||||
} else if (activeTab === "api-config") {
|
||||
props.initialModelTab = settingsInitialModelTab
|
||||
}
|
||||
|
||||
return <Component {...props} />
|
||||
}, [activeTab, handleResetState, version])
|
||||
}, [activeTab, handleResetState, settingsInitialModelTab, version])
|
||||
|
||||
const titleColor = getEnvironmentColor(environment)
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@ interface ClineProviderProps {
|
||||
showModelOptions: boolean
|
||||
isPopup?: boolean
|
||||
currentMode: Mode
|
||||
initialModelTab?: "recommended" | "free"
|
||||
}
|
||||
|
||||
/**
|
||||
* The Cline provider configuration component
|
||||
*/
|
||||
export const ClineProvider = ({ showModelOptions, isPopup, currentMode }: ClineProviderProps) => {
|
||||
export const ClineProvider = ({ showModelOptions, isPopup, currentMode, initialModelTab }: ClineProviderProps) => {
|
||||
return (
|
||||
<div>
|
||||
{/* Cline Account Info Card */}
|
||||
@@ -25,7 +26,12 @@ export const ClineProvider = ({ showModelOptions, isPopup, currentMode }: ClineP
|
||||
{showModelOptions && (
|
||||
<>
|
||||
{/* OpenRouter Model Picker - includes Provider Routing in Advanced section */}
|
||||
<OpenRouterModelPicker currentMode={currentMode} isPopup={isPopup} showProviderRouting={true} />
|
||||
<OpenRouterModelPicker
|
||||
currentMode={currentMode}
|
||||
initialTab={initialModelTab}
|
||||
isPopup={isPopup}
|
||||
showProviderRouting={true}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -72,25 +72,31 @@ export const LiteLlmProvider = ({ showModelOptions, isPopup, currentMode }: Lite
|
||||
</div>
|
||||
</DebouncedTextField>
|
||||
</RemotelyConfiguredInputWrapper>
|
||||
<DebouncedTextField
|
||||
initialValue={apiConfiguration?.liteLlmApiKey || ""}
|
||||
onChange={async (value) => {
|
||||
await ModelsServiceClient.updateApiConfiguration(
|
||||
UpdateApiConfigurationRequestNew.create({
|
||||
updates: {
|
||||
secrets: {
|
||||
liteLlmApiKey: value,
|
||||
<RemotelyConfiguredInputWrapper hidden={!remoteConfigSettings?.configuredApiKeys?.litellm}>
|
||||
<DebouncedTextField
|
||||
disabled={remoteConfigSettings?.configuredApiKeys?.litellm}
|
||||
initialValue={apiConfiguration?.liteLlmApiKey || ""}
|
||||
onChange={async (value) => {
|
||||
await ModelsServiceClient.updateApiConfiguration(
|
||||
UpdateApiConfigurationRequestNew.create({
|
||||
updates: {
|
||||
secrets: {
|
||||
liteLlmApiKey: value,
|
||||
},
|
||||
},
|
||||
},
|
||||
updateMask: ["secrets.liteLlmApiKey"],
|
||||
}),
|
||||
)
|
||||
}}
|
||||
placeholder="Default: noop"
|
||||
style={{ width: "100%" }}
|
||||
type="password">
|
||||
<span style={{ fontWeight: 500 }}>API Key</span>
|
||||
</DebouncedTextField>
|
||||
updateMask: ["secrets.liteLlmApiKey"],
|
||||
}),
|
||||
)
|
||||
}}
|
||||
placeholder="Default: noop"
|
||||
style={{ width: "100%" }}
|
||||
type="password">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span style={{ fontWeight: 500 }}>API Key</span>
|
||||
{remoteConfigSettings?.configuredApiKeys?.litellm && <LockIcon />}
|
||||
</div>
|
||||
</DebouncedTextField>
|
||||
</RemotelyConfiguredInputWrapper>
|
||||
{showModelOptions && (
|
||||
<>
|
||||
<ModelSelector
|
||||
|
||||
@@ -12,9 +12,10 @@ import { useApiConfigurationHandlers } from "../utils/useApiConfigurationHandler
|
||||
|
||||
interface ApiConfigurationSectionProps {
|
||||
renderSectionHeader?: (tabId: string) => JSX.Element | null
|
||||
initialModelTab?: "recommended" | "free"
|
||||
}
|
||||
|
||||
const ApiConfigurationSection = ({ renderSectionHeader }: ApiConfigurationSectionProps) => {
|
||||
const ApiConfigurationSection = ({ renderSectionHeader, initialModelTab }: ApiConfigurationSectionProps) => {
|
||||
const { planActSeparateModelsSetting, mode, apiConfiguration } = useExtensionState()
|
||||
const [currentTab, setCurrentTab] = useState<Mode>(mode)
|
||||
const { handleFieldsChange } = useApiConfigurationHandlers()
|
||||
@@ -50,11 +51,11 @@ const ApiConfigurationSection = ({ renderSectionHeader }: ApiConfigurationSectio
|
||||
|
||||
{/* Content container */}
|
||||
<div className="-mb-3">
|
||||
<ApiOptions currentMode={currentTab} showModelOptions={true} />
|
||||
<ApiOptions currentMode={currentTab} initialModelTab={initialModelTab} showModelOptions={true} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ApiOptions currentMode={mode} showModelOptions={true} />
|
||||
<ApiOptions currentMode={mode} initialModelTab={initialModelTab} showModelOptions={true} />
|
||||
)}
|
||||
|
||||
<div className="mb-[5px]">
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
mcpMarketplaceCatalog: McpMarketplaceCatalog
|
||||
totalTasksSize: number | null
|
||||
lastDismissedCliBannerVersion: number
|
||||
dismissedBanners?: Array<{ bannerId: string; dismissedAt: number }>
|
||||
|
||||
availableTerminalProfiles: TerminalProfile[]
|
||||
|
||||
@@ -54,6 +55,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
mcpTab?: McpViewTab
|
||||
showSettings: boolean
|
||||
settingsTargetSection?: string
|
||||
settingsInitialModelTab?: "recommended" | "free"
|
||||
showHistory: boolean
|
||||
showAccount: boolean
|
||||
showWorktrees: boolean
|
||||
@@ -102,6 +104,7 @@ export interface ExtensionStateContextType extends ExtensionState {
|
||||
// Navigation functions
|
||||
navigateToMcp: (tab?: McpViewTab) => void
|
||||
navigateToSettings: (targetSection?: string) => void
|
||||
navigateToSettingsModelPicker: (opts: { targetSection?: string; initialModelTab?: "recommended" | "free" }) => void
|
||||
navigateToHistory: () => void
|
||||
navigateToAccount: () => void
|
||||
navigateToWorktrees: () => void
|
||||
@@ -130,6 +133,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const [mcpTab, setMcpTab] = useState<McpViewTab | undefined>(undefined)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [settingsTargetSection, setSettingsTargetSection] = useState<string | undefined>(undefined)
|
||||
const [settingsInitialModelTab, setSettingsInitialModelTab] = useState<"recommended" | "free" | undefined>(undefined)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showAccount, setShowAccount] = useState(false)
|
||||
const [showWorktrees, setShowWorktrees] = useState(false)
|
||||
@@ -146,6 +150,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
const hideSettings = useCallback(() => {
|
||||
setShowSettings(false)
|
||||
setSettingsTargetSection(undefined)
|
||||
setSettingsInitialModelTab(undefined)
|
||||
}, [])
|
||||
const hideHistory = useCallback(() => setShowHistory(false), [setShowHistory])
|
||||
const hideAccount = useCallback(() => setShowAccount(false), [setShowAccount])
|
||||
@@ -175,6 +180,20 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setSettingsTargetSection(targetSection)
|
||||
setSettingsInitialModelTab(undefined)
|
||||
setShowSettings(true)
|
||||
},
|
||||
[closeMcpView],
|
||||
)
|
||||
|
||||
const navigateToSettingsModelPicker = useCallback(
|
||||
(opts: { targetSection?: string; initialModelTab?: "recommended" | "free" }) => {
|
||||
setShowHistory(false)
|
||||
closeMcpView()
|
||||
setShowAccount(false)
|
||||
setShowWorktrees(false)
|
||||
setSettingsTargetSection(opts.targetSection)
|
||||
setSettingsInitialModelTab(opts.initialModelTab)
|
||||
setShowSettings(true)
|
||||
},
|
||||
[closeMcpView],
|
||||
@@ -773,6 +792,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
mcpTab,
|
||||
showSettings,
|
||||
settingsTargetSection,
|
||||
settingsInitialModelTab,
|
||||
showHistory,
|
||||
showAccount,
|
||||
showWorktrees,
|
||||
@@ -793,6 +813,7 @@ export const ExtensionStateContextProvider: React.FC<{
|
||||
// Navigation functions
|
||||
navigateToMcp,
|
||||
navigateToSettings,
|
||||
navigateToSettingsModelPicker,
|
||||
navigateToHistory,
|
||||
navigateToAccount,
|
||||
navigateToWorktrees,
|
||||
|
||||
Reference in New Issue
Block a user