mirror of
https://github.com/cline/cline.git
synced 2026-09-02 07:42:19 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9c6ba57f7 | |||
| 90dae0f820 | |||
| 7e9e714cef | |||
| 4e0d242453 | |||
| c3b31bc225 | |||
| 6775ce0085 | |||
| 603470b1c1 | |||
| 7f1486a975 | |||
| f0af58437b | |||
| 2ad585caea | |||
| ceaa889acf | |||
| 391c07c998 |
@@ -0,0 +1,384 @@
|
||||
import { ApiHandler } from "@/core/api"
|
||||
import { ClineHandler } from "@/core/api/providers/cline"
|
||||
import type { ApiStream } from "@/core/api/transform/stream"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { AgentActions, AgentContext, AgentIterationUpdate, ClineAgentConfig, SearchResult } from "@/shared/cline/subagent"
|
||||
import type { ClineStorageMessage } from "@/shared/messages/content"
|
||||
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
|
||||
protected currentIteration: number = 0
|
||||
protected readonly maxIterations: number
|
||||
protected readonly onIterationUpdate: (update: AgentIterationUpdate) => void | Promise<void>
|
||||
protected cost = 0
|
||||
protected readonly tools: Map<string, SubAgentToolDefinition> = new Map()
|
||||
protected taskConfig?: TaskConfig
|
||||
|
||||
private static activeAgents = new Set<ClineAgent>()
|
||||
|
||||
constructor(private config: ClineAgentConfig) {
|
||||
this.client = config.client ?? new ClineHandler({ openRouterModelId: config.modelId, ...config.apiParams })
|
||||
this.maxIterations = config.maxIterations ?? 3
|
||||
this.onIterationUpdate = config.onIterationUpdate
|
||||
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.
|
||||
* @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}>(.*?)</${toolTag}>`, "gs")
|
||||
const subTagPattern = new RegExp(`<${toolDef.tag}>(.*?)</${toolDef.tag}>`, "gs")
|
||||
const inputs: string[] = []
|
||||
|
||||
for (const toolMatch of response.matchAll(toolPattern)) {
|
||||
const toolContent = toolMatch[1]
|
||||
for (const subTagMatch of toolContent.matchAll(subTagPattern)) {
|
||||
const value = subTagMatch[1].trim()
|
||||
if (value) {
|
||||
inputs.push(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (inputs.length > 0) {
|
||||
toolCallsMap.set(toolTag, inputs)
|
||||
}
|
||||
}
|
||||
|
||||
return toolCallsMap
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
abstract buildContextPrompt(context: AgentContext, iteration: number): 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 context files if contextTag is configured
|
||||
const contextFiles = 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,
|
||||
contextFiles,
|
||||
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({
|
||||
iteration: this.currentIteration,
|
||||
maxIterations: this.maxIterations,
|
||||
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[]> {
|
||||
// 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, SearchResult>(),
|
||||
fileContents: new Map<string, string>(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes a streaming response and accumulates text and cost
|
||||
*/
|
||||
private async processStream(stream: ApiStream): Promise<string> {
|
||||
const parts: string[] = []
|
||||
|
||||
for await (const msg of stream) {
|
||||
if (msg.type === "text") {
|
||||
parts.push(msg.text)
|
||||
}
|
||||
|
||||
if (msg.type === "usage" && msg.totalCost) {
|
||||
this.cost += msg.totalCost
|
||||
await this.onIterationUpdate({
|
||||
iteration: this.currentIteration,
|
||||
maxIterations: this.maxIterations,
|
||||
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++) {
|
||||
this.currentIteration = iteration + 1
|
||||
|
||||
// Build context prompt and system prompt
|
||||
const contextPrompt = this.buildContextPrompt(context, iteration)
|
||||
const systemPrompt = this.buildSystemPrompt(userInput, contextPrompt)
|
||||
|
||||
// Create messages
|
||||
const messages: ClineStorageMessage[] = this.config.messages
|
||||
? [...this.config.messages, { role: "user", content: userInput }]
|
||||
: [{ role: "user", content: userInput }]
|
||||
|
||||
// Stream the LLM response
|
||||
const stream = this.client.createMessage(systemPrompt, messages)
|
||||
const fullResponse = await this.processStream(stream)
|
||||
|
||||
Logger.log(`Iteration ${this.currentIteration} response: ${fullResponse.substring(0, 200)}`)
|
||||
|
||||
// Extract actions from response
|
||||
const actions = this.extractActions(fullResponse)
|
||||
|
||||
// Send iteration update
|
||||
await this.onIterationUpdate({
|
||||
iteration,
|
||||
maxIterations: this.maxIterations,
|
||||
actions,
|
||||
context,
|
||||
})
|
||||
|
||||
// If ready to answer and has context files, read them first
|
||||
if (actions.isReadyToAnswer && actions.contextFiles.length > 0) {
|
||||
Logger.log(
|
||||
`Reading ${actions.contextFiles.length} context files before answering: ${actions.contextFiles.join(", ")}`,
|
||||
)
|
||||
const fileContents = await this.readContextFiles(actions.contextFiles)
|
||||
this.updateContextWithFiles(context, fileContents)
|
||||
Logger.log("Agent determined it has enough context to answer.")
|
||||
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
|
||||
}
|
||||
|
||||
// Execute tools in parallel
|
||||
Logger.log(`Executing ${actions.toolCalls.length} tool calls`)
|
||||
const toolResults = await this.executeTools(actions.toolCalls)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
const duration = performance.now() - startTime
|
||||
Logger.debug("Agent completed in " + duration)
|
||||
|
||||
// Format and return final result
|
||||
return this.formatResult(context)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { AgentContext, AgentIterationUpdate, FileReadResult, SearchResult } from "@/shared/cline/subagent"
|
||||
import { ToolResponse } from "../task"
|
||||
import type { TaskConfig } from "../task/tools/types/TaskConfig"
|
||||
import { ClineAgent } from "./ClineAgent"
|
||||
import { SEARCH_AGENT_TOOLS } from "./tools"
|
||||
import { buildToolsPlaceholder } from "./utils"
|
||||
|
||||
export const ACTIONS_TAGS = {
|
||||
ANSWER: `next_step`,
|
||||
CONTEXT: `context_list`,
|
||||
}
|
||||
|
||||
const SEARCH_MODELS = {
|
||||
grok: "x-ai/grok-code-fast-1",
|
||||
gemini: "google/gemini-3-flash-preview",
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchAgent extends ClineAgent to provide natural language search functionality
|
||||
* across codebases using an agentic loop.
|
||||
*/
|
||||
export class SearchAgent extends ClineAgent {
|
||||
constructor(
|
||||
taskConfig: TaskConfig,
|
||||
maxIterations: number = 3,
|
||||
onIterationUpdate: (update: AgentIterationUpdate) => void | Promise<void>,
|
||||
systemPrompt?: string,
|
||||
modelId: string = SEARCH_MODELS.gemini,
|
||||
) {
|
||||
super({
|
||||
modelId,
|
||||
maxIterations,
|
||||
onIterationUpdate,
|
||||
systemPrompt,
|
||||
contextTag: ACTIONS_TAGS.CONTEXT,
|
||||
answerTag: ACTIONS_TAGS.ANSWER,
|
||||
})
|
||||
this.setTaskConfig(taskConfig)
|
||||
this.registerTools(SEARCH_AGENT_TOOLS)
|
||||
}
|
||||
|
||||
buildSystemPrompt(userInput: string, contextPrompt: string): string {
|
||||
return buildSearchAgentSystemPrompt(userInput, contextPrompt, ACTIONS_TAGS)
|
||||
}
|
||||
|
||||
buildContextPrompt(context: AgentContext, iteration: number): string {
|
||||
if (iteration === 0 || (context.searchResults.size === 0 && context.fileContents.size === 0)) {
|
||||
return "No context retrieved yet."
|
||||
}
|
||||
|
||||
const MAX_RESULTS_TO_SHOW = 5
|
||||
const contextParts: string[] = []
|
||||
const successfulQueries: string[] = []
|
||||
const unsuccessfulQueries: string[] = []
|
||||
|
||||
// Process search results
|
||||
let shownResults = 0
|
||||
for (const [query, result] of context.searchResults) {
|
||||
if (result.success && result.resultCount > 0) {
|
||||
successfulQueries.push(query)
|
||||
if (shownResults < MAX_RESULTS_TO_SHOW) {
|
||||
contextParts.push(`### Search: "${query}"\n${result.workspaceResults}`)
|
||||
shownResults++
|
||||
} else {
|
||||
contextParts.push(
|
||||
`### Search: "${query}"\nFound ${result.resultCount} result${result.resultCount > 1 ? "s" : ""}`,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
unsuccessfulQueries.push(query)
|
||||
}
|
||||
}
|
||||
|
||||
// Add file contents
|
||||
for (const [filePath, content] of context.fileContents) {
|
||||
contextParts.push(`### File: ${filePath}\n\`\`\`\n${content}\n\`\`\``)
|
||||
}
|
||||
|
||||
// Build header
|
||||
const totalSearches = context.searchResults.size
|
||||
const totalFiles = context.filePaths.size
|
||||
const totalFileContents = context.fileContents.size
|
||||
const parts = [
|
||||
`Retrieved context from ${totalSearches} search${totalSearches > 1 ? "es" : ""} (${totalFiles} unique file${totalFiles > 1 ? "s" : ""}) and ${totalFileContents} file content${totalFileContents > 1 ? "s" : ""}:\n`,
|
||||
]
|
||||
|
||||
// Add search history
|
||||
if (successfulQueries.length > 0 || unsuccessfulQueries.length > 0) {
|
||||
parts.push("\n**Previously searched queries (DO NOT search these again):**")
|
||||
successfulQueries.forEach((q) => parts.push(`- "${q}" ✓ (found results)`))
|
||||
unsuccessfulQueries.forEach((q) => parts.push(`- "${q}" ✗ (no results)`))
|
||||
parts.push("")
|
||||
}
|
||||
|
||||
return parts.join("\n") + contextParts.join("\n\n")
|
||||
}
|
||||
|
||||
async readContextFiles(filePaths: string[]): Promise<Map<string, string>> {
|
||||
const fileResults = (await this.executeToolByTag("TOOLFILE", filePaths)) as FileReadResult[]
|
||||
const fileContents = new Map<string, string>()
|
||||
|
||||
for (const fileResult of fileResults) {
|
||||
if (fileResult.success) {
|
||||
fileContents.set(fileResult.path, fileResult.content)
|
||||
}
|
||||
}
|
||||
|
||||
return fileContents
|
||||
}
|
||||
|
||||
public updateContextWithToolResults(context: AgentContext, toolCalls: unknown[], toolResults: unknown[]): boolean {
|
||||
const initialFileCount = context.filePaths.size
|
||||
const initialContentCount = 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 SearchResult
|
||||
if (result.success && result.resultCount > 0) {
|
||||
this.extractFilePathsFromResult(result).forEach((fp) => context.filePaths.add(fp))
|
||||
context.searchResults.set(input, result)
|
||||
}
|
||||
} else if (toolTag === "TOOLFILE" && toolResult) {
|
||||
const fileResult = toolResult as FileReadResult
|
||||
if (fileResult.success && !context.fileContents.has(fileResult.path)) {
|
||||
context.fileContents.set(fileResult.path, fileResult.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return context.filePaths.size > initialFileCount || context.fileContents.size > initialContentCount
|
||||
}
|
||||
|
||||
public shouldContinue(_context: AgentContext, foundNewContext: boolean, isReadyToAnswer: boolean): boolean {
|
||||
return !isReadyToAnswer && foundNewContext && this.currentIteration < this.maxIterations - 1
|
||||
}
|
||||
|
||||
public formatResult(context: AgentContext, pathOnly = true): ToolResponse {
|
||||
// Return file contents if available
|
||||
if (context.fileContents.size > 0) {
|
||||
const paths = Array.from(context.fileContents.keys())
|
||||
if (pathOnly) {
|
||||
const count = paths.length
|
||||
const label = count === 1 ? "1 file" : `${count} files`
|
||||
return `Search Agent returned ${label}:\n${paths.map((p) => `- ${p}`).join("\n")}`
|
||||
}
|
||||
return [...context.fileContents].map(([filePath, content]) => ({ type: "text", text: `${filePath}\n${content}` }))
|
||||
}
|
||||
|
||||
// Return search results
|
||||
if (context.filePaths.size === 0) {
|
||||
return "No results found after searching."
|
||||
}
|
||||
|
||||
const paths = Array.from(context.filePaths)
|
||||
const count = paths.length
|
||||
const label = count === 1 ? "1 file" : `${count} files`
|
||||
return `Found ${label} across multiple searches:\n${paths.map((p) => `- ${p}`).join("\n")}`
|
||||
}
|
||||
|
||||
private extractFilePathsFromResult(result: SearchResult): string[] {
|
||||
return result.workspaceResults
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line && !line.startsWith("│") && !line.startsWith("Found ") && !line.startsWith("Showing "))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the complete system prompt for SearchAgent.
|
||||
* Replaces all template placeholders with actual values.
|
||||
*/
|
||||
function buildSearchAgentSystemPrompt(userInput: string, contextPrompt: string, actionsTags: typeof ACTIONS_TAGS): string {
|
||||
const toolsPlaceholder = buildToolsPlaceholder(SEARCH_AGENT_TOOLS)
|
||||
|
||||
return `You are a context review agent. Evaluate the shared context and determine if you can answer the user's request.
|
||||
|
||||
## CURRENT CONTEXT
|
||||
${contextPrompt}
|
||||
|
||||
## TOOLS
|
||||
Available tools to fetch additional context:
|
||||
- ${toolsPlaceholder}
|
||||
|
||||
## RESPONSE FORMAT
|
||||
Your response must contain ONLY tags (no explanations, no markdown blocks). Choose one:
|
||||
|
||||
**If you have enough context:**
|
||||
- List relevant files/contexts with <${actionsTags.CONTEXT}> tags (only from CURRENT CONTEXT above)
|
||||
- End with <${actionsTags.ANSWER}>
|
||||
- Example: <${actionsTags.CONTEXT}>file1.ts</${actionsTags.CONTEXT}><${actionsTags.CONTEXT}>file2.ts</${actionsTags.CONTEXT}><${actionsTags.ANSWER}>
|
||||
|
||||
**If you need NO context:**
|
||||
- Respond with: <${actionsTags.ANSWER}>
|
||||
|
||||
**If you need more context:**
|
||||
- Use <TOOL*> tags to request it
|
||||
- Example: <TOOLFILE><name>path/to/file.ts</name></TOOLFILE><TOOLSEARCH><query>class Controller</query></TOOLSEARCH>
|
||||
|
||||
## RULES
|
||||
- Only include files/contexts from CURRENT CONTEXT in <${actionsTags.CONTEXT}> tags
|
||||
- Never include empty <${actionsTags.CONTEXT}></${actionsTags.CONTEXT}> tags
|
||||
- Check CURRENT CONTEXT before requesting - avoid duplicate searches
|
||||
- Use multiple <TOOL*> tags in parallel if needed
|
||||
- Response must be ONLY tags, nothing else
|
||||
|
||||
## INVALID OUTPUT (DO NOT DO THIS)
|
||||
- Empty context: <${actionsTags.CONTEXT}></${actionsTags.CONTEXT}>
|
||||
- Explanations: <${actionsTags.ANSWER}> your explanation here
|
||||
- Non-shared context: <${actionsTags.CONTEXT}>not-in-context.ts</${actionsTags.CONTEXT}>
|
||||
|
||||
<user_input>
|
||||
${userInput}
|
||||
</user_input>
|
||||
|
||||
Remember: Your response will be parsed by a bot. Only include the expected tags.`
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { extractFileContent } from "@/integrations/misc/extract-file-content"
|
||||
import { Logger } from "@/services/logging/Logger"
|
||||
import { regexSearchFiles } from "@/services/ripgrep"
|
||||
import { FileReadResult, SearchResult } from "@/shared/cline/subagent"
|
||||
import { TaskConfig } from "../task/tools/types/TaskConfig"
|
||||
import { resolveWorkspacePath } from "../workspace"
|
||||
|
||||
export type SubAgentToolResult = FileReadResult[] | SearchResult[]
|
||||
|
||||
export interface SubAgentToolDefinition {
|
||||
title: string
|
||||
tag: string
|
||||
instruction: string
|
||||
placeholder: string
|
||||
examples?: string[]
|
||||
execute: (inputs: string[], taskConfig: TaskConfig) => Promise<SubAgentToolResult>
|
||||
}
|
||||
|
||||
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) => {
|
||||
try {
|
||||
// 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
|
||||
const fileContent = await extractFileContent(absolutePath, supportsImages)
|
||||
|
||||
Logger.info(`Read file content for "${filePath}" successfully.`)
|
||||
|
||||
return {
|
||||
path: filePath,
|
||||
content: fileContent.text,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`File read failed for "${filePath}": ${error instanceof Error ? error.message : String(error)}`)
|
||||
return {
|
||||
path: filePath,
|
||||
content: `Error reading file: ${error instanceof Error ? error.message : String(error)}`,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return await Promise.all(fileReadPromises)
|
||||
},
|
||||
}
|
||||
|
||||
const TOOLSEARCH: SubAgentToolDefinition = {
|
||||
title: "TOOLSEARCH",
|
||||
tag: "query",
|
||||
instruction:
|
||||
"Perform regex pattern searches across the codebase. Supports multiple parallel searches by including multiple query tags. All searches will execute simultaneously for faster results",
|
||||
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) => {
|
||||
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 {
|
||||
query,
|
||||
workspaceResults,
|
||||
resultCount,
|
||||
success: true,
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(`Search failed in ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return {
|
||||
query,
|
||||
workspaceResults: "",
|
||||
resultCount: 0,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const searchPromises = queries.map(async (query) => {
|
||||
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 {
|
||||
query,
|
||||
workspaceResults: "",
|
||||
resultCount: 0,
|
||||
success: false,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return await Promise.all(searchPromises)
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools configuration for SearchAgent.
|
||||
*/
|
||||
export const SEARCH_AGENT_TOOLS: SubAgentToolDefinition[] = [TOOLFILE, TOOLSEARCH]
|
||||
@@ -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))) || []
|
||||
}
|
||||
@@ -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 "./search_subagent"
|
||||
export * from "./use_mcp_tool"
|
||||
export * from "./web_fetch"
|
||||
export * from "./web_search"
|
||||
|
||||
@@ -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 { search_agents_variants } from "./search_subagent"
|
||||
import { use_mcp_tool_variants } from "./use_mcp_tool"
|
||||
import { web_fetch_variants } from "./web_fetch"
|
||||
import { web_search_variants } from "./web_search"
|
||||
@@ -45,6 +46,7 @@ export function registerClineToolSets(): void {
|
||||
...plan_mode_respond_variants,
|
||||
...read_file_variants,
|
||||
...replace_in_file_variants,
|
||||
...search_agents_variants,
|
||||
...search_files_variants,
|
||||
...use_mcp_tool_variants,
|
||||
...web_fetch_variants,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ModelFamily } from "@/shared/prompts"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ClineToolSpec } from "../spec"
|
||||
import { TASK_PROGRESS_PARAMETER } from "../types"
|
||||
|
||||
const id = ClineDefaultTool.SEARCH_AGENT
|
||||
|
||||
const NATIVE_NEXT_GEN: ClineToolSpec = {
|
||||
variant: ModelFamily.NATIVE_NEXT_GEN,
|
||||
id,
|
||||
name: id,
|
||||
description:
|
||||
"Search context using natural language input to find relevant context across different sources (codebase, files, etc.). The provided input should be a full descriptive phrase or question that'd allow the search agent to understand what you are looking for to formulate an effective search strategy that returns relevant context as results.",
|
||||
parameters: [
|
||||
{
|
||||
name: "input",
|
||||
required: true,
|
||||
instruction: `A detailed, complete natural language description or question about what you are trying to find, like 'What authentication providers are used in the codebase?' or 'The file that defined DBController symbol.' for example. IMPORTANT: Combining individual search terms or keywords like 'authentication auth providers login oauth' or 'database, config, connections' are not valid inputs and must be avoided.`,
|
||||
},
|
||||
TASK_PROGRESS_PARAMETER,
|
||||
],
|
||||
}
|
||||
|
||||
export const search_agents_variants = [NATIVE_NEXT_GEN]
|
||||
@@ -48,7 +48,7 @@ export const config = createVariant(ModelFamily.NATIVE_NEXT_GEN)
|
||||
ClineDefaultTool.FILE_READ,
|
||||
ClineDefaultTool.FILE_NEW,
|
||||
ClineDefaultTool.FILE_EDIT,
|
||||
ClineDefaultTool.SEARCH,
|
||||
ClineDefaultTool.SEARCH_AGENT,
|
||||
ClineDefaultTool.LIST_FILES,
|
||||
ClineDefaultTool.LIST_CODE_DEF,
|
||||
ClineDefaultTool.BROWSER,
|
||||
|
||||
@@ -74,15 +74,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 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
|
||||
|
||||
|
||||
@@ -75,4 +75,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.`
|
||||
|
||||
@@ -37,6 +37,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 { SearchSubAgentHandler } from "./tools/handlers/SearchSubAgentHandler"
|
||||
import { SummarizeTaskHandler } from "./tools/handlers/SummarizeTaskHandler"
|
||||
import { UseMcpToolHandler } from "./tools/handlers/UseMcpToolHandler"
|
||||
import { WebFetchToolHandler } from "./tools/handlers/WebFetchToolHandler"
|
||||
@@ -216,8 +217,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 SearchSubAgentHandler())
|
||||
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())
|
||||
|
||||
@@ -93,6 +93,7 @@ import {
|
||||
} from "@/shared/messages"
|
||||
import { ShowMessageType } from "@/shared/proto/index.host"
|
||||
import { isClineCliInstalled, isCliSubagentContext } from "@/utils/cli-detector"
|
||||
import { ClineAgent } from "../agents/ClineAgent"
|
||||
import { ensureLocalClineDirExists } from "../context/instructions/user-instructions/rule-helpers"
|
||||
import { refreshWorkflowToggles } from "../context/instructions/user-instructions/workflows"
|
||||
import { Controller } from "../controller"
|
||||
@@ -2430,6 +2431,10 @@ export class Task {
|
||||
console.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,
|
||||
@@ -2528,6 +2533,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
|
||||
@@ -2602,6 +2608,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),
|
||||
@@ -2702,6 +2713,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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseWorkspaceInlinePath } from "@/core/workspace/utils/parseWorkspaceI
|
||||
import { WorkspacePathAdapter } from "@/core/workspace/WorkspacePathAdapter"
|
||||
import { resolveWorkspacePath } from "@/core/workspace/WorkspaceResolver"
|
||||
import { telemetryService } from "@/services/telemetry"
|
||||
import { SearchResult } from "@/shared/cline/subagent"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
@@ -77,7 +78,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
absolutePath: string,
|
||||
workspaceName: string | undefined,
|
||||
workspaceRoot: string | undefined,
|
||||
regex: string,
|
||||
query: string,
|
||||
filePattern: string | undefined,
|
||||
) {
|
||||
try {
|
||||
@@ -87,7 +88,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
const workspaceResults = await regexSearchFiles(
|
||||
basePathForRelative,
|
||||
absolutePath,
|
||||
regex,
|
||||
query,
|
||||
filePattern,
|
||||
config.services.clineIgnoreController,
|
||||
)
|
||||
@@ -98,6 +99,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
const resultCount = resultMatch ? parseInt(resultMatch[1], 10) : 0
|
||||
|
||||
return {
|
||||
query,
|
||||
workspaceName,
|
||||
workspaceResults,
|
||||
resultCount,
|
||||
@@ -107,6 +109,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
// If search fails in one workspace, return error info
|
||||
console.error(`Search failed in ${absolutePath}:`, error)
|
||||
return {
|
||||
query,
|
||||
workspaceName,
|
||||
workspaceResults: "",
|
||||
resultCount: 0,
|
||||
@@ -120,12 +123,7 @@ export class SearchFilesToolHandler implements IFullyManagedTool {
|
||||
*/
|
||||
private formatSearchResults(
|
||||
config: TaskConfig,
|
||||
searchResults: Array<{
|
||||
workspaceName?: string
|
||||
workspaceResults: string
|
||||
resultCount: number
|
||||
success: boolean
|
||||
}>,
|
||||
searchResults: Array<SearchResult>,
|
||||
searchPaths: Array<{ absolutePath: string; workspaceName?: string }>,
|
||||
): string {
|
||||
const allResults: string[] = []
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ToolUse } from "@core/assistant-message"
|
||||
import { getReadablePath, isLocatedInWorkspace } from "@utils/path"
|
||||
import { SearchAgent } from "@/core/agents/SearchAgent"
|
||||
import { ClineSayTool } from "@/shared/ExtensionMessage"
|
||||
import { ClineDefaultTool } from "@/shared/tools"
|
||||
import type { ToolResponse } from "../../index"
|
||||
import type { IFullyManagedTool } from "../ToolExecutorCoordinator"
|
||||
import type { TaskConfig } from "../types/TaskConfig"
|
||||
import type { StronglyTypedUIHelpers } from "../types/UIHelpers"
|
||||
|
||||
export class SearchSubAgentHandler implements IFullyManagedTool {
|
||||
readonly name = ClineDefaultTool.SEARCH_AGENT
|
||||
|
||||
getDescription(block: ToolUse): string {
|
||||
return `[${block.name}]`
|
||||
}
|
||||
|
||||
private async buildToolMessage(config: TaskConfig, block: ToolUse, content: string, parsedPath: string): Promise<string> {
|
||||
const sharedProps: ClineSayTool = {
|
||||
tool: block.params.input ? "searchAgent" : "searchFiles",
|
||||
path: getReadablePath(config.cwd, block.params.path),
|
||||
content,
|
||||
regex: block.params.regex,
|
||||
filePattern: block.params.input || block.params.file_pattern,
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(parsedPath),
|
||||
}
|
||||
|
||||
return JSON.stringify(sharedProps)
|
||||
}
|
||||
|
||||
private async buildPartialToolMessage(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<string> {
|
||||
const config = uiHelpers.getConfig()
|
||||
const sharedProps: ClineSayTool = {
|
||||
tool: block.params.input ? "searchAgent" : "searchFiles",
|
||||
path: getReadablePath(config.cwd, uiHelpers.removeClosingTag(block, "path", block.params.path)),
|
||||
content: "",
|
||||
regex: uiHelpers.removeClosingTag(block, "regex", block.params.regex),
|
||||
filePattern: block.params.input || uiHelpers.removeClosingTag(block, "file_pattern", block.params.file_pattern),
|
||||
operationIsLocatedInWorkspace: await isLocatedInWorkspace(block.params.path),
|
||||
}
|
||||
|
||||
return JSON.stringify(sharedProps)
|
||||
}
|
||||
|
||||
async handlePartialBlock(block: ToolUse, uiHelpers: StronglyTypedUIHelpers): Promise<void> {
|
||||
const partialMessage = await this.buildPartialToolMessage(block, uiHelpers)
|
||||
const isAutoApprove = await uiHelpers.shouldAutoApproveToolWithPath(block.name, block.params.path)
|
||||
|
||||
if (isAutoApprove) {
|
||||
await uiHelpers.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await uiHelpers.say("tool", partialMessage, undefined, undefined, block.partial)
|
||||
} else {
|
||||
await uiHelpers.removeLastPartialMessageIfExistsWithType("say", "tool")
|
||||
await uiHelpers.ask("tool", partialMessage, block.partial).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async execute(config: TaskConfig, block: ToolUse): Promise<ToolResponse> {
|
||||
const searchInput: string | undefined = block.params.input
|
||||
|
||||
if (!searchInput) {
|
||||
throw new Error("Search input is required for SearchSubAgentHandler.")
|
||||
}
|
||||
|
||||
return await this.performNaturalLanguageSearch(config, block, searchInput)
|
||||
}
|
||||
|
||||
private async performNaturalLanguageSearch(config: TaskConfig, block: ToolUse, searchInput: string): Promise<ToolResponse> {
|
||||
try {
|
||||
const agent = new SearchAgent(config, 3, async (update) => {
|
||||
// Build a partial message showing the progress
|
||||
const progress = `[${update.iteration + 1}/${update.maxIterations}]`
|
||||
let statusText = progress
|
||||
|
||||
if (update.message) {
|
||||
statusText += ` - ${update.message}`
|
||||
}
|
||||
|
||||
if (update.actions) {
|
||||
const toolCallCount = update.actions.toolCalls.length
|
||||
const contextFileCount = update.actions.contextFiles.length
|
||||
|
||||
if (update.actions.isReadyToAnswer) {
|
||||
statusText += ` - Ready to answer`
|
||||
if (contextFileCount > 0) {
|
||||
statusText += ` with ${contextFileCount} file${contextFileCount > 1 ? "s" : ""}`
|
||||
}
|
||||
} else if (toolCallCount > 0) {
|
||||
statusText += ` - Executing ${toolCallCount} tool call${toolCallCount > 1 ? "s" : ""}`
|
||||
}
|
||||
}
|
||||
|
||||
const partialMessage = await this.buildToolMessage(config, block, statusText, block.params.path || "")
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", partialMessage, undefined, undefined, true)
|
||||
})
|
||||
|
||||
const searchResults = await agent.execute(searchInput)
|
||||
const formattedResults = Array.isArray(searchResults)
|
||||
? searchResults?.map((r) => (r.type === "text" ? r.text : ""))?.join("\n\n")
|
||||
: searchResults
|
||||
const completeMessage = await this.buildToolMessage(config, block, formattedResults, block.params.path || "")
|
||||
await config.callbacks.removeLastPartialMessageIfExistsWithType("ask", "tool")
|
||||
await config.callbacks.say("tool", completeMessage, undefined, undefined, false)
|
||||
|
||||
return searchResults
|
||||
} catch (error) {
|
||||
console.error("Natural language search error:", error)
|
||||
return `Natural language search error: ${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import * as childProcess from "child_process"
|
||||
import * as path from "path"
|
||||
import * as readline from "readline"
|
||||
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.
|
||||
@@ -55,8 +56,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) => {
|
||||
@@ -69,31 +72,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})` : ""}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -104,12 +177,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 })
|
||||
}
|
||||
|
||||
@@ -191,6 +191,7 @@ export interface ClineSayTool {
|
||||
| "listFilesTopLevel"
|
||||
| "listFilesRecursive"
|
||||
| "listCodeDefinitionNames"
|
||||
| "searchAgent"
|
||||
| "searchFiles"
|
||||
| "webFetch"
|
||||
| "webSearch"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { ApiHandler } from "@/core/api"
|
||||
import { ClineStorageMessage } from "@/shared/messages"
|
||||
|
||||
export interface AgentContext {
|
||||
filePaths: Set<string>
|
||||
searchResults: Map<string, SearchResult>
|
||||
fileContents: Map<string, string>
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
query: string
|
||||
workspaceName?: string
|
||||
workspaceResults: string
|
||||
resultCount: number
|
||||
success: boolean
|
||||
}
|
||||
|
||||
export interface FileReadResult {
|
||||
path: string
|
||||
content: 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[]
|
||||
/** Context files the agent wants to use in the final answer */
|
||||
contextFiles: string[]
|
||||
/** Whether the agent is ready to provide a final answer */
|
||||
isReadyToAnswer: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Progress update sent during agent iteration
|
||||
*/
|
||||
export interface AgentIterationUpdate {
|
||||
/** Current iteration number (0-indexed) */
|
||||
iteration: number
|
||||
/** Maximum iterations allowed */
|
||||
maxIterations: number
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
/** Callback for iteration progress updates */
|
||||
onIterationUpdate: (update: AgentIterationUpdate) => void | Promise<void>
|
||||
/** System Prompt for the agent */
|
||||
systemPrompt?: string
|
||||
/** Starting messages for the agent */
|
||||
messages?: ClineStorageMessage[]
|
||||
/** API Request Params */
|
||||
apiParams?: Record<string, unknown>
|
||||
/** Optional API client to use instead of the default ClineHandler */
|
||||
client?: ApiHandler
|
||||
/** 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
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export enum ClineDefaultTool {
|
||||
FILE_READ = "read_file",
|
||||
FILE_NEW = "write_to_file",
|
||||
SEARCH = "search_files",
|
||||
SEARCH_AGENT = "search_subagent",
|
||||
LIST_FILES = "list_files",
|
||||
LIST_CODE_DEF = "list_code_definition_names",
|
||||
BROWSER = "browser_action",
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { BooleanRequest, Int64Request, StringRequest } from "@shared/proto/cline/common"
|
||||
import { VSCodeBadge, VSCodeProgressRing } from "@vscode/webview-ui-toolkit/react"
|
||||
import deepEqual from "fast-deep-equal"
|
||||
import { FoldVerticalIcon } from "lucide-react"
|
||||
import { FoldVerticalIcon, Loader2Icon, ScanSearchIcon } from "lucide-react"
|
||||
import React, { MouseEvent, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSize } from "react-use"
|
||||
import styled from "styled-components"
|
||||
@@ -37,6 +37,7 @@ import { cn } from "@/lib/utils"
|
||||
import { FileServiceClient, TaskServiceClient, UiServiceClient } from "@/services/grpc-client"
|
||||
import { findMatchingResourceOrTemplate, getMcpServerDisplayName } from "@/utils/mcp"
|
||||
import CodeAccordian, { cleanPathPrefix } from "../common/CodeAccordian"
|
||||
import { Button } from "../ui/button"
|
||||
import { DiffEditRow } from "./DiffEditRow"
|
||||
import { ErrorBlockTitle } from "./ErrorBlockTitle"
|
||||
import ErrorRow from "./ErrorRow"
|
||||
@@ -780,6 +781,32 @@ export const ChatRowContent = memo(
|
||||
/>
|
||||
</>
|
||||
)
|
||||
case "searchAgent":
|
||||
return (
|
||||
<>
|
||||
<div style={headerStyle}>
|
||||
{message.partial ? (
|
||||
<Loader2Icon className="size-2 animate-spin" />
|
||||
) : (
|
||||
<ScanSearchIcon className="size-2" />
|
||||
)}
|
||||
{tool.operationIsLocatedInWorkspace === false &&
|
||||
toolIcon("sign-out", "yellow", -90, "This is outside of your workspace")}
|
||||
<span className="font-bold">Cline wants to ask search agent:</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"
|
||||
onClick={handleToggle}
|
||||
variant="ghost">
|
||||
{/* Search Agent Query */}
|
||||
<span className="w-full break-words whitespace-normal text-left">{tool.filePattern} </span>
|
||||
<div className="w-full flex flex-col gap-1 text-left select-text pt-1">
|
||||
{(message.partial || isExpanded) &&
|
||||
tool.content?.split("\n")?.map((line) => <div className="w-full">{line}</div>)}
|
||||
</div>
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
case "searchFiles":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -344,9 +344,7 @@ const HistoryView = ({ onDone }: HistoryViewProps) => {
|
||||
Workspace
|
||||
</span>
|
||||
</VSCodeRadio>
|
||||
<VSCodeRadio
|
||||
checked={showFavoritesOnly}
|
||||
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}>
|
||||
<VSCodeRadio checked={showFavoritesOnly} onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}>
|
||||
<span className="flex items-center gap-[3px]">
|
||||
<span className="codicon codicon-star-full text-(--vscode-button-background)" />
|
||||
Favorites
|
||||
|
||||
Reference in New Issue
Block a user