Compare commits

...

20 Commits

Author SHA1 Message Date
abeatrix fae0c03b7a time-line display 2026-01-30 23:01:49 +08:00
abeatrix 6106758e95 parallel subagents 2026-01-30 22:32:51 +08:00
abeatrix c78835945f Fix Subagent stream rendering issues 2026-01-28 23:34:41 +09:00
abeatrix 2f0dbb2293 feat: add subagent framework with ClineAgent base class
Implement a flexible subagent system that enables autonomous agentic loops:

- Add ClineAgent abstract base class for creating domain-specific agents
  - Supports configurable max iterations and tool registration
  - Tracks costs across all active agent instances
  - Handles tool execution with automatic retry logic
  - Provides hooks for context management and result formatting

- Implement SubAgentToolHandler for spawning child agents
  - Enables hierarchical agent delegation
  - Supports custom prompts and tool configurations

- Extract webfetch functionality into reusable utility
  - Refactor WebFetchToolHandler to use shared webfetch function
  - Enable webfetch usage in subagent contexts

- Add comprehensive type definitions for agent configuration
  - Define AgentContext, AgentActions, and iteration update types
  - Support flexible tool result handling

This framework allows creating specialized agents that can autonomously
iterate on tasks, use tools, and delegate to other agents while maintaining
cost tracking and proper error handling.
2026-01-27 21:01:20 -08:00
github-actions[bot] 06b05ddfe9 Changeset version bump (#8895)
* changeset version bump

* Updating CHANGELOG.md format

* release(3.55.0): Version bump and update WhatsNewModal

* feat(settings): Support linking to recommended or free model picker.

* Send to cline provider

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: cline-test <132302818+candieduniverse@users.noreply.github.com>
Co-authored-by: Arafatkatze <arafat.da.khan@gmail.com>
2026-01-27 19:02:59 -08:00
Jose R. Perez 2670a4a171 feat: updated welcome card content and added ability to close each card (#8900)
* feat: updated welcome card content and added ability to close each card

* feat: change set

* feat: fix
2026-01-27 18:35:18 -08:00
Renee Huang 1699c9a63a wording for Codex login (#8835) 2026-01-27 18:29:34 -08:00
Tomás Barreiro 808dd42ae9 Lock the LiteLLM api key input when it's remotely configured (#8899) 2026-01-28 02:34:06 +01:00
abeatrix d9c6ba57f7 timeout ripgrep 2026-01-07 14:58:52 -08:00
abeatrix 90dae0f820 type 2026-01-07 14:58:32 -08:00
abeatrix 7e9e714cef generic 2025-12-31 14:26:36 -08:00
abeatrix 4e0d242453 split 2025-12-31 14:23:20 -08:00
abeatrix c3b31bc225 Includes Subagent cost in total cost 2025-12-31 12:39:19 -08:00
abeatrix 6775ce0085 use --no-hidden flag for search 2025-12-30 11:06:19 -08:00
abeatrix 603470b1c1 Merge branch 'main' into bee/agent-poc 2025-12-29 20:55:17 -08:00
abeatrix 7f1486a975 Merge branch 'main' into bee/agent-poc 2025-12-29 14:15:31 -08:00
abeatrix f0af58437b track serach queries and live updates 2025-12-19 16:43:48 -08:00
abeatrix 2ad585caea update to gemini 3 flash 2025-12-19 10:57:00 -08:00
abeatrix ceaa889acf wip 2025-12-06 05:41:11 -08:00
abeatrix 391c07c998 pod - agent 2025-12-06 05:40:48 -08:00
51 changed files with 2440 additions and 206 deletions
-5
View File
@@ -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>`
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
Lock the LiteLLM Api Key input when it's remotely configured
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": patch
---
updated welcome card content and added ability to close each card
+6
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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"
+671
View File
@@ -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
}
}
+284
View File
@@ -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.`
}
+375
View File
@@ -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]
+26
View File
@@ -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))) || []
}
+3 -1
View File
@@ -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.`
+4 -1
View File
@@ -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()
}
+9 -10
View File
@@ -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)
+3 -1
View File
@@ -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,
+5 -1
View File
@@ -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
View File
@@ -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,
+32
View File
@@ -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}`
}
}
+11 -1
View File
@@ -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
/**
+1 -1
View File
@@ -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>
+85 -6
View File
@@ -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 })
}
+4
View File
@@ -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
View File
@@ -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)",
},
]
+113
View File
@@ -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
}
+13 -1
View File
@@ -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
})
}
+2
View File
@@ -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 = {
+2
View File
@@ -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
@@ -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,