diff --git a/packages/cli/src/acp/acp.ts b/packages/cli/src/acp/acp.ts new file mode 100644 index 00000000..1fbdf7a8 --- /dev/null +++ b/packages/cli/src/acp/acp.ts @@ -0,0 +1,464 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ + +import { Icon } from '@google/gemini-cli-core'; +import { WritableStream, ReadableStream } from 'node:stream/web'; + +export class ClientConnection implements Client { + #connection: Connection; + + constructor( + agent: (client: Client) => Agent, + input: WritableStream, + output: ReadableStream, + ) { + this.#connection = new Connection(agent(this), input, output); + } + + /** + * Streams part of an assistant response to the client + */ + async streamAssistantMessageChunk( + params: StreamAssistantMessageChunkParams, + ): Promise { + await this.#connection.sendRequest('streamAssistantMessageChunk', params); + } + + /** + * Request confirmation before running a tool + * + * When allowed, the client returns a [`ToolCallId`] which can be used + * to update the tool call's `status` and `content` as it runs. + */ + requestToolCallConfirmation( + params: RequestToolCallConfirmationParams, + ): Promise { + return this.#connection.sendRequest('requestToolCallConfirmation', params); + } + + /** + * pushToolCall allows the agent to start a tool call + * when it does not need to request permission to do so. + * + * The returned id can be used to update the UI for the tool + * call as needed. + */ + pushToolCall(params: PushToolCallParams): Promise { + return this.#connection.sendRequest('pushToolCall', params); + } + + /** + * updateToolCall allows the agent to update the content and status of the tool call. + * + * The new content replaces what is currently displayed in the UI. + * + * The [`ToolCallId`] is included in the response of + * `pushToolCall` or `requestToolCallConfirmation` respectively. + */ + async updateToolCall(params: UpdateToolCallParams): Promise { + await this.#connection.sendRequest('updateToolCall', params); + } +} + +type AnyMessage = AnyRequest | AnyResponse; + +type AnyRequest = { + id: number; + method: string; + params?: unknown; +}; + +type AnyResponse = { jsonrpc: '2.0'; id: number } & Result; + +type Result = + | { + result: T; + } + | { + error: ErrorResponse; + }; + +type ErrorResponse = { + code: number; + message: string; + data?: { details?: string }; +}; + +type PendingResponse = { + resolve: (response: unknown) => void; + reject: (error: ErrorResponse) => void; +}; + +class Connection { + #pendingResponses: Map = new Map(); + #nextRequestId: number = 0; + #delegate: D; + #peerInput: WritableStream; + #writeQueue: Promise = Promise.resolve(); + #textEncoder: TextEncoder; + + constructor( + delegate: D, + peerInput: WritableStream, + peerOutput: ReadableStream, + ) { + this.#peerInput = peerInput; + this.#textEncoder = new TextEncoder(); + + this.#delegate = delegate; + this.#receive(peerOutput); + } + + async #receive(output: ReadableStream) { + let content = ''; + const decoder = new TextDecoder(); + for await (const chunk of output) { + content += decoder.decode(chunk, { stream: true }); + const lines = content.split('\n'); + content = lines.pop() || ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine) { + const message = JSON.parse(trimmedLine); + this.#processMessage(message); + } + } + } + } + + async #processMessage(message: AnyMessage) { + if ('method' in message) { + const response = await this.#tryCallDelegateMethod( + message.method, + message.params, + ); + + await this.#sendMessage({ + jsonrpc: '2.0', + id: message.id, + ...response, + }); + } else { + this.#handleResponse(message); + } + } + + async #tryCallDelegateMethod( + method: string, + params?: unknown, + ): Promise> { + const methodName = method as keyof D; + if (typeof this.#delegate[methodName] !== 'function') { + return RequestError.methodNotFound(method).toResult(); + } + + try { + const result = await this.#delegate[methodName](params); + return { result: result ?? null }; + } catch (error: unknown) { + if (error instanceof RequestError) { + return error.toResult(); + } + + let details; + + if (error instanceof Error) { + details = error.message; + } else if ( + typeof error === 'object' && + error != null && + 'message' in error && + typeof error.message === 'string' + ) { + details = error.message; + } + + return RequestError.internalError(details).toResult(); + } + } + + #handleResponse(response: AnyResponse) { + const pendingResponse = this.#pendingResponses.get(response.id); + if (pendingResponse) { + if ('result' in response) { + pendingResponse.resolve(response.result); + } else if ('error' in response) { + pendingResponse.reject(response.error); + } + this.#pendingResponses.delete(response.id); + } + } + + async sendRequest(method: string, params?: Req): Promise { + const id = this.#nextRequestId++; + const responsePromise = new Promise((resolve, reject) => { + this.#pendingResponses.set(id, { resolve, reject }); + }); + await this.#sendMessage({ jsonrpc: '2.0', id, method, params }); + return responsePromise as Promise; + } + + async #sendMessage(json: AnyMessage) { + const content = JSON.stringify(json) + '\n'; + this.#writeQueue = this.#writeQueue + .then(async () => { + const writer = this.#peerInput.getWriter(); + try { + await writer.write(this.#textEncoder.encode(content)); + } finally { + writer.releaseLock(); + } + }) + .catch((error) => { + // Continue processing writes on error + console.error('ACP write error:', error); + }); + return this.#writeQueue; + } +} + +export class RequestError extends Error { + data?: { details?: string }; + + constructor( + public code: number, + message: string, + details?: string, + ) { + super(message); + this.name = 'RequestError'; + if (details) { + this.data = { details }; + } + } + + static parseError(details?: string): RequestError { + return new RequestError(-32700, 'Parse error', details); + } + + static invalidRequest(details?: string): RequestError { + return new RequestError(-32600, 'Invalid request', details); + } + + static methodNotFound(details?: string): RequestError { + return new RequestError(-32601, 'Method not found', details); + } + + static invalidParams(details?: string): RequestError { + return new RequestError(-32602, 'Invalid params', details); + } + + static internalError(details?: string): RequestError { + return new RequestError(-32603, 'Internal error', details); + } + + toResult(): Result { + return { + error: { + code: this.code, + message: this.message, + data: this.data, + }, + }; + } +} + +// Protocol types + +export const LATEST_PROTOCOL_VERSION = '0.0.9'; + +export type AssistantMessageChunk = + | { + text: string; + } + | { + thought: string; + }; + +export type ToolCallConfirmation = + | { + description?: string | null; + type: 'edit'; + } + | { + description?: string | null; + type: 'execute'; + command: string; + rootCommand: string; + } + | { + description?: string | null; + type: 'mcp'; + serverName: string; + toolDisplayName: string; + toolName: string; + } + | { + description?: string | null; + type: 'fetch'; + urls: string[]; + } + | { + description: string; + type: 'other'; + }; + +export type ToolCallContent = + | { + type: 'markdown'; + markdown: string; + } + | { + type: 'diff'; + newText: string; + oldText: string | null; + path: string; + }; + +export type ToolCallStatus = 'running' | 'finished' | 'error'; + +export type ToolCallId = number; + +export type ToolCallConfirmationOutcome = + | 'allow' + | 'alwaysAllow' + | 'alwaysAllowMcpServer' + | 'alwaysAllowTool' + | 'reject' + | 'cancel'; + +/** + * A part in a user message + */ +export type UserMessageChunk = + | { + text: string; + } + | { + path: string; + }; + +export interface StreamAssistantMessageChunkParams { + chunk: AssistantMessageChunk; +} + +export interface RequestToolCallConfirmationParams { + confirmation: ToolCallConfirmation; + content?: ToolCallContent | null; + icon: Icon; + label: string; + locations?: ToolCallLocation[]; +} + +export interface ToolCallLocation { + line?: number | null; + path: string; +} + +export interface PushToolCallParams { + content?: ToolCallContent | null; + icon: Icon; + label: string; + locations?: ToolCallLocation[]; +} + +export interface UpdateToolCallParams { + content: ToolCallContent | null; + status: ToolCallStatus; + toolCallId: ToolCallId; +} + +export interface RequestToolCallConfirmationResponse { + id: ToolCallId; + outcome: ToolCallConfirmationOutcome; +} + +export interface PushToolCallResponse { + id: ToolCallId; +} + +export interface InitializeParams { + /** + * The version of the protocol that the client supports. + * This should be the latest version supported by the client. + */ + protocolVersion: string; +} + +export interface SendUserMessageParams { + chunks: UserMessageChunk[]; +} + +export interface InitializeResponse { + /** + * Indicates whether the agent is authenticated and + * ready to handle requests. + */ + isAuthenticated: boolean; + /** + * The version of the protocol that the agent supports. + * If the agent supports the requested version, it should respond with the same version. + * Otherwise, the agent should respond with the latest version it supports. + */ + protocolVersion: string; +} + +export interface Error { + code: number; + data?: unknown; + message: string; +} + +export interface Client { + streamAssistantMessageChunk( + params: StreamAssistantMessageChunkParams, + ): Promise; + + requestToolCallConfirmation( + params: RequestToolCallConfirmationParams, + ): Promise; + + pushToolCall(params: PushToolCallParams): Promise; + + updateToolCall(params: UpdateToolCallParams): Promise; +} + +export interface Agent { + /** + * Initializes the agent's state. It should be called before any other method, + * and no other methods should be called until it has completed. + * + * If the agent is not authenticated, then the client should prompt the user to authenticate, + * and then call the `authenticate` method. + * Otherwise the client can send other messages to the agent. + */ + initialize(params: InitializeParams): Promise; + + /** + * Begins the authentication process. + * + * This method should only be called if `initialize` indicates the user isn't already authenticated. + * The Promise MUST not resolve until authentication is complete. + */ + authenticate(): Promise; + + /** + * Allows the user to send a message to the agent. + * This method should complete after the agent is finished, during + * which time the agent may update the client by calling + * streamAssistantMessageChunk and other methods. + */ + sendUserMessage(params: SendUserMessageParams): Promise; + + /** + * Cancels the current generation. + */ + cancelSendMessage(): Promise; +} diff --git a/packages/cli/src/acp/acpPeer.ts b/packages/cli/src/acp/acpPeer.ts new file mode 100644 index 00000000..90952b7f --- /dev/null +++ b/packages/cli/src/acp/acpPeer.ts @@ -0,0 +1,674 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WritableStream, ReadableStream } from 'node:stream/web'; + +import { + AuthType, + Config, + GeminiChat, + ToolRegistry, + logToolCall, + ToolResult, + convertToFunctionResponse, + ToolCallConfirmationDetails, + ToolConfirmationOutcome, + clearCachedCredentialFile, + isNodeError, + getErrorMessage, + isWithinRoot, + getErrorStatus, +} from '@google/gemini-cli-core'; +import * as acp from './acp.js'; +import { Agent } from './acp.js'; +import { Readable, Writable } from 'node:stream'; +import { Content, Part, FunctionCall, PartListUnion } from '@google/genai'; +import { LoadedSettings, SettingScope } from '../config/settings.js'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +export async function runAcpPeer(config: Config, settings: LoadedSettings) { + const stdout = Writable.toWeb(process.stdout) as WritableStream; + const stdin = Readable.toWeb(process.stdin) as ReadableStream; + + // Stdout is used to send messages to the client, so console.log/console.info + // messages to stderr so that they don't interfere with ACP. + console.log = console.error; + console.info = console.error; + console.debug = console.error; + + new acp.ClientConnection( + (client: acp.Client) => new GeminiAgent(config, settings, client), + stdout, + stdin, + ); +} + +class GeminiAgent implements Agent { + chat?: GeminiChat; + pendingSend?: AbortController; + + constructor( + private config: Config, + private settings: LoadedSettings, + private client: acp.Client, + ) {} + + async initialize(_: acp.InitializeParams): Promise { + let isAuthenticated = false; + if (this.settings.merged.selectedAuthType) { + try { + await this.config.refreshAuth(this.settings.merged.selectedAuthType); + isAuthenticated = true; + } catch (error) { + console.error('Failed to refresh auth:', error); + } + } + return { protocolVersion: acp.LATEST_PROTOCOL_VERSION, isAuthenticated }; + } + + async authenticate(): Promise { + await clearCachedCredentialFile(); + await this.config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); + this.settings.setValue( + SettingScope.User, + 'selectedAuthType', + AuthType.LOGIN_WITH_GOOGLE, + ); + } + + async cancelSendMessage(): Promise { + if (!this.pendingSend) { + throw new Error('Not currently generating'); + } + + this.pendingSend.abort(); + delete this.pendingSend; + } + + async sendUserMessage(params: acp.SendUserMessageParams): Promise { + this.pendingSend?.abort(); + const pendingSend = new AbortController(); + this.pendingSend = pendingSend; + + if (!this.chat) { + const geminiClient = this.config.getGeminiClient(); + this.chat = await geminiClient.startChat(); + } + + const promptId = Math.random().toString(16).slice(2); + const chat = this.chat!; + const toolRegistry: ToolRegistry = await this.config.getToolRegistry(); + const parts = await this.#resolveUserMessage(params, pendingSend.signal); + + let nextMessage: Content | null = { role: 'user', parts }; + + while (nextMessage !== null) { + if (pendingSend.signal.aborted) { + chat.addHistory(nextMessage); + return; + } + + const functionCalls: FunctionCall[] = []; + + try { + const responseStream = await chat.sendMessageStream( + { + message: nextMessage?.parts ?? [], + config: { + abortSignal: pendingSend.signal, + tools: [ + { + functionDeclarations: toolRegistry.getFunctionDeclarations(), + }, + ], + }, + }, + promptId, + ); + nextMessage = null; + + for await (const resp of responseStream) { + if (pendingSend.signal.aborted) { + return; + } + + if (resp.candidates && resp.candidates.length > 0) { + const candidate = resp.candidates[0]; + for (const part of candidate.content?.parts ?? []) { + if (!part.text) { + continue; + } + + this.client.streamAssistantMessageChunk({ + chunk: part.thought + ? { thought: part.text } + : { text: part.text }, + }); + } + } + + if (resp.functionCalls) { + functionCalls.push(...resp.functionCalls); + } + } + } catch (error) { + if (getErrorStatus(error) === 429) { + throw new acp.RequestError( + 429, + 'Rate limit exceeded. Try again later.', + ); + } + + throw error; + } + + if (functionCalls.length > 0) { + const toolResponseParts: Part[] = []; + + for (const fc of functionCalls) { + const response = await this.#runTool( + pendingSend.signal, + promptId, + fc, + ); + + const parts = Array.isArray(response) ? response : [response]; + + for (const part of parts) { + if (typeof part === 'string') { + toolResponseParts.push({ text: part }); + } else if (part) { + toolResponseParts.push(part); + } + } + } + + nextMessage = { role: 'user', parts: toolResponseParts }; + } + } + } + + async #runTool( + abortSignal: AbortSignal, + promptId: string, + fc: FunctionCall, + ): Promise { + const callId = fc.id ?? `${fc.name}-${Date.now()}`; + const args = (fc.args ?? {}) as Record; + + const startTime = Date.now(); + + const errorResponse = (error: Error) => { + const durationMs = Date.now() - startTime; + logToolCall(this.config, { + 'event.name': 'tool_call', + 'event.timestamp': new Date().toISOString(), + prompt_id: promptId, + function_name: fc.name ?? '', + function_args: args, + duration_ms: durationMs, + success: false, + error: error.message, + }); + + return [ + { + functionResponse: { + id: callId, + name: fc.name ?? '', + response: { error: error.message }, + }, + }, + ]; + }; + + if (!fc.name) { + return errorResponse(new Error('Missing function name')); + } + + const toolRegistry: ToolRegistry = await this.config.getToolRegistry(); + const tool = toolRegistry.getTool(fc.name as string); + + if (!tool) { + return errorResponse( + new Error(`Tool "${fc.name}" not found in registry.`), + ); + } + + let toolCallId; + const confirmationDetails = await tool.shouldConfirmExecute( + args, + abortSignal, + ); + if (confirmationDetails) { + let content: acp.ToolCallContent | null = null; + if (confirmationDetails.type === 'edit') { + content = { + type: 'diff', + path: confirmationDetails.fileName, + oldText: confirmationDetails.originalContent, + newText: confirmationDetails.newContent, + }; + } + + const result = await this.client.requestToolCallConfirmation({ + label: tool.getDescription(args), + icon: tool.icon, + content, + confirmation: toAcpToolCallConfirmation(confirmationDetails), + locations: tool.toolLocations(args), + }); + + await confirmationDetails.onConfirm(toToolCallOutcome(result.outcome)); + switch (result.outcome) { + case 'reject': + return errorResponse( + new Error(`Tool "${fc.name}" not allowed to run by the user.`), + ); + + case 'cancel': + return errorResponse( + new Error(`Tool "${fc.name}" was canceled by the user.`), + ); + case 'allow': + case 'alwaysAllow': + case 'alwaysAllowMcpServer': + case 'alwaysAllowTool': + break; + default: { + const resultOutcome: never = result.outcome; + throw new Error(`Unexpected: ${resultOutcome}`); + } + } + + toolCallId = result.id; + } else { + const result = await this.client.pushToolCall({ + icon: tool.icon, + label: tool.getDescription(args), + locations: tool.toolLocations(args), + }); + + toolCallId = result.id; + } + + try { + const toolResult: ToolResult = await tool.execute(args, abortSignal); + const toolCallContent = toToolCallContent(toolResult); + + await this.client.updateToolCall({ + toolCallId, + status: 'finished', + content: toolCallContent, + }); + + const durationMs = Date.now() - startTime; + logToolCall(this.config, { + 'event.name': 'tool_call', + 'event.timestamp': new Date().toISOString(), + function_name: fc.name, + function_args: args, + duration_ms: durationMs, + success: true, + prompt_id: promptId, + }); + + return convertToFunctionResponse(fc.name, callId, toolResult.llmContent); + } catch (e) { + const error = e instanceof Error ? e : new Error(String(e)); + await this.client.updateToolCall({ + toolCallId, + status: 'error', + content: { type: 'markdown', markdown: error.message }, + }); + + return errorResponse(error); + } + } + + async #resolveUserMessage( + message: acp.SendUserMessageParams, + abortSignal: AbortSignal, + ): Promise { + const atPathCommandParts = message.chunks.filter((part) => 'path' in part); + + if (atPathCommandParts.length === 0) { + return message.chunks.map((chunk) => { + if ('text' in chunk) { + return { text: chunk.text }; + } else { + throw new Error('Unexpected chunk type'); + } + }); + } + + // Get centralized file discovery service + const fileDiscovery = this.config.getFileService(); + const respectGitIgnore = this.config.getFileFilteringRespectGitIgnore(); + + const pathSpecsToRead: string[] = []; + const atPathToResolvedSpecMap = new Map(); + const contentLabelsForDisplay: string[] = []; + const ignoredPaths: string[] = []; + + const toolRegistry = await this.config.getToolRegistry(); + const readManyFilesTool = toolRegistry.getTool('read_many_files'); + const globTool = toolRegistry.getTool('glob'); + + if (!readManyFilesTool) { + throw new Error('Error: read_many_files tool not found.'); + } + + for (const atPathPart of atPathCommandParts) { + const pathName = atPathPart.path; + + // Check if path should be ignored by git + if (fileDiscovery.shouldGitIgnoreFile(pathName)) { + ignoredPaths.push(pathName); + const reason = respectGitIgnore + ? 'git-ignored and will be skipped' + : 'ignored by custom patterns'; + console.warn(`Path ${pathName} is ${reason}.`); + continue; + } + + let currentPathSpec = pathName; + let resolvedSuccessfully = false; + + try { + const absolutePath = path.resolve(this.config.getTargetDir(), pathName); + if (isWithinRoot(absolutePath, this.config.getTargetDir())) { + const stats = await fs.stat(absolutePath); + if (stats.isDirectory()) { + currentPathSpec = pathName.endsWith('/') + ? `${pathName}**` + : `${pathName}/**`; + this.#debug( + `Path ${pathName} resolved to directory, using glob: ${currentPathSpec}`, + ); + } else { + this.#debug( + `Path ${pathName} resolved to file: ${currentPathSpec}`, + ); + } + resolvedSuccessfully = true; + } else { + this.#debug( + `Path ${pathName} is outside the project directory. Skipping.`, + ); + } + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + if (this.config.getEnableRecursiveFileSearch() && globTool) { + this.#debug( + `Path ${pathName} not found directly, attempting glob search.`, + ); + try { + const globResult = await globTool.execute( + { + pattern: `**/*${pathName}*`, + path: this.config.getTargetDir(), + }, + abortSignal, + ); + if ( + globResult.llmContent && + typeof globResult.llmContent === 'string' && + !globResult.llmContent.startsWith('No files found') && + !globResult.llmContent.startsWith('Error:') + ) { + const lines = globResult.llmContent.split('\n'); + if (lines.length > 1 && lines[1]) { + const firstMatchAbsolute = lines[1].trim(); + currentPathSpec = path.relative( + this.config.getTargetDir(), + firstMatchAbsolute, + ); + this.#debug( + `Glob search for ${pathName} found ${firstMatchAbsolute}, using relative path: ${currentPathSpec}`, + ); + resolvedSuccessfully = true; + } else { + this.#debug( + `Glob search for '**/*${pathName}*' did not return a usable path. Path ${pathName} will be skipped.`, + ); + } + } else { + this.#debug( + `Glob search for '**/*${pathName}*' found no files or an error. Path ${pathName} will be skipped.`, + ); + } + } catch (globError) { + console.error( + `Error during glob search for ${pathName}: ${getErrorMessage(globError)}`, + ); + } + } else { + this.#debug( + `Glob tool not found. Path ${pathName} will be skipped.`, + ); + } + } else { + console.error( + `Error stating path ${pathName}. Path ${pathName} will be skipped.`, + ); + } + } + + if (resolvedSuccessfully) { + pathSpecsToRead.push(currentPathSpec); + atPathToResolvedSpecMap.set(pathName, currentPathSpec); + contentLabelsForDisplay.push(pathName); + } + } + + // Construct the initial part of the query for the LLM + let initialQueryText = ''; + for (let i = 0; i < message.chunks.length; i++) { + const chunk = message.chunks[i]; + if ('text' in chunk) { + initialQueryText += chunk.text; + } else { + // type === 'atPath' + const resolvedSpec = atPathToResolvedSpecMap.get(chunk.path); + if ( + i > 0 && + initialQueryText.length > 0 && + !initialQueryText.endsWith(' ') && + resolvedSpec + ) { + // Add space if previous part was text and didn't end with space, or if previous was @path + const prevPart = message.chunks[i - 1]; + if ( + 'text' in prevPart || + ('path' in prevPart && atPathToResolvedSpecMap.has(prevPart.path)) + ) { + initialQueryText += ' '; + } + } + if (resolvedSpec) { + initialQueryText += `@${resolvedSpec}`; + } else { + // If not resolved for reading (e.g. lone @ or invalid path that was skipped), + // add the original @-string back, ensuring spacing if it's not the first element. + if ( + i > 0 && + initialQueryText.length > 0 && + !initialQueryText.endsWith(' ') && + !chunk.path.startsWith(' ') + ) { + initialQueryText += ' '; + } + initialQueryText += `@${chunk.path}`; + } + } + } + initialQueryText = initialQueryText.trim(); + + // Inform user about ignored paths + if (ignoredPaths.length > 0) { + const ignoreType = respectGitIgnore ? 'git-ignored' : 'custom-ignored'; + this.#debug( + `Ignored ${ignoredPaths.length} ${ignoreType} files: ${ignoredPaths.join(', ')}`, + ); + } + + // Fallback for lone "@" or completely invalid @-commands resulting in empty initialQueryText + if (pathSpecsToRead.length === 0) { + console.warn('No valid file paths found in @ commands to read.'); + return [{ text: initialQueryText }]; + } + + const processedQueryParts: Part[] = [{ text: initialQueryText }]; + + const toolArgs = { + paths: pathSpecsToRead, + respectGitIgnore, // Use configuration setting + }; + + const toolCall = await this.client.pushToolCall({ + icon: readManyFilesTool.icon, + label: readManyFilesTool.getDescription(toolArgs), + }); + try { + const result = await readManyFilesTool.execute(toolArgs, abortSignal); + const content = toToolCallContent(result) || { + type: 'markdown', + markdown: `Successfully read: ${contentLabelsForDisplay.join(', ')}`, + }; + await this.client.updateToolCall({ + toolCallId: toolCall.id, + status: 'finished', + content, + }); + + if (Array.isArray(result.llmContent)) { + const fileContentRegex = /^--- (.*?) ---\n\n([\s\S]*?)\n\n$/; + processedQueryParts.push({ + text: '\n--- Content from referenced files ---', + }); + for (const part of result.llmContent) { + if (typeof part === 'string') { + const match = fileContentRegex.exec(part); + if (match) { + const filePathSpecInContent = match[1]; // This is a resolved pathSpec + const fileActualContent = match[2].trim(); + processedQueryParts.push({ + text: `\nContent from @${filePathSpecInContent}:\n`, + }); + processedQueryParts.push({ text: fileActualContent }); + } else { + processedQueryParts.push({ text: part }); + } + } else { + // part is a Part object. + processedQueryParts.push(part); + } + } + processedQueryParts.push({ text: '\n--- End of content ---' }); + } else { + console.warn( + 'read_many_files tool returned no content or empty content.', + ); + } + + return processedQueryParts; + } catch (error: unknown) { + await this.client.updateToolCall({ + toolCallId: toolCall.id, + status: 'error', + content: { + type: 'markdown', + markdown: `Error reading files (${contentLabelsForDisplay.join(', ')}): ${getErrorMessage(error)}`, + }, + }); + throw error; + } + } + + #debug(msg: string) { + if (this.config.getDebugMode()) { + console.warn(msg); + } + } +} + +function toToolCallContent(toolResult: ToolResult): acp.ToolCallContent | null { + if (toolResult.returnDisplay) { + if (typeof toolResult.returnDisplay === 'string') { + return { + type: 'markdown', + markdown: toolResult.returnDisplay, + }; + } else { + return { + type: 'diff', + path: toolResult.returnDisplay.fileName, + oldText: toolResult.returnDisplay.originalContent, + newText: toolResult.returnDisplay.newContent, + }; + } + } else { + return null; + } +} + +function toAcpToolCallConfirmation( + confirmationDetails: ToolCallConfirmationDetails, +): acp.ToolCallConfirmation { + switch (confirmationDetails.type) { + case 'edit': + return { type: 'edit' }; + case 'exec': + return { + type: 'execute', + rootCommand: confirmationDetails.rootCommand, + command: confirmationDetails.command, + }; + case 'mcp': + return { + type: 'mcp', + serverName: confirmationDetails.serverName, + toolName: confirmationDetails.toolName, + toolDisplayName: confirmationDetails.toolDisplayName, + }; + case 'info': + return { + type: 'fetch', + urls: confirmationDetails.urls || [], + description: confirmationDetails.urls?.length + ? null + : confirmationDetails.prompt, + }; + default: { + const unreachable: never = confirmationDetails; + throw new Error(`Unexpected: ${unreachable}`); + } + } +} + +function toToolCallOutcome( + outcome: acp.ToolCallConfirmationOutcome, +): ToolConfirmationOutcome { + switch (outcome) { + case 'allow': + return ToolConfirmationOutcome.ProceedOnce; + case 'alwaysAllow': + return ToolConfirmationOutcome.ProceedAlways; + case 'alwaysAllowMcpServer': + return ToolConfirmationOutcome.ProceedAlwaysServer; + case 'alwaysAllowTool': + return ToolConfirmationOutcome.ProceedAlwaysTool; + case 'reject': + case 'cancel': + return ToolConfirmationOutcome.Cancel; + default: { + const unreachable: never = outcome; + throw new Error(`Unexpected: ${unreachable}`); + } + } +} diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index f76d6c60..543801f0 100644 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -54,6 +54,7 @@ export interface CliArgs { telemetryOtlpEndpoint: string | undefined; telemetryLogPrompts: boolean | undefined; allowedMcpServerNames: string[] | undefined; + experimentalAcp: boolean | undefined; extensions: string[] | undefined; listExtensions: boolean | undefined; ideMode: boolean | undefined; @@ -162,6 +163,10 @@ export async function parseArguments(): Promise { description: 'Enables checkpointing of file edits', default: false, }) + .option('experimental-acp', { + type: 'boolean', + description: 'Starts the agent in ACP mode', + }) .option('allowed-mcp-server-names', { type: 'array', string: true, @@ -396,6 +401,7 @@ export async function loadCliConfig( model: argv.model!, extensionContextFilePaths, maxSessionTurns: settings.maxSessionTurns ?? -1, + experimentalAcp: argv.experimentalAcp || false, listExtensions: argv.listExtensions || false, activeExtensions: activeExtensions.map((e) => ({ name: e.config.name, diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index f0d3f401..71e69952 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -84,6 +84,7 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) { await new Promise((resolve) => child.on('close', resolve)); process.exit(0); } +import { runAcpPeer } from './acp/acpPeer.js'; export async function main() { const workspaceRoot = process.cwd(); @@ -189,6 +190,10 @@ export async function main() { await getOauthClient(settings.merged.selectedAuthType, config); } + if (config.getExperimentalAcp()) { + return runAcpPeer(config, settings); + } + let input = config.getQuestion(); const startupWarnings = [ ...(await getStartupWarnings()), diff --git a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx index 7b9de92e..c9bed003 100644 --- a/packages/cli/src/ui/components/messages/ToolMessage.test.tsx +++ b/packages/cli/src/ui/components/messages/ToolMessage.test.tsx @@ -152,6 +152,8 @@ describe('', () => { const diffResult = { fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new', fileName: 'file.txt', + originalContent: 'old', + newContent: 'new', }; const { lastFrame } = renderWithContext( , diff --git a/packages/cli/src/ui/hooks/useToolScheduler.test.ts b/packages/cli/src/ui/hooks/useToolScheduler.test.ts index e9354ee9..81ea1f77 100644 --- a/packages/cli/src/ui/hooks/useToolScheduler.test.ts +++ b/packages/cli/src/ui/hooks/useToolScheduler.test.ts @@ -23,7 +23,8 @@ import { ToolCallResponseInfo, ToolCall, // Import from core Status as ToolCallStatusType, - ApprovalMode, // Import from core + ApprovalMode, + Icon, } from '@google/gemini-cli-core'; import { HistoryItemWithoutId, @@ -56,6 +57,8 @@ const mockTool: Tool = { name: 'mockTool', displayName: 'Mock Tool', description: 'A mock tool for testing', + icon: Icon.Hammer, + toolLocations: vi.fn(), isOutputMarkdown: false, canUpdateOutput: false, schema: {}, @@ -85,6 +88,8 @@ const mockToolRequiresConfirmation: Tool = { onConfirm: mockOnUserConfirmForToolConfirmation, fileName: 'mockToolRequiresConfirmation.ts', fileDiff: 'Mock tool requires confirmation', + originalContent: 'Original content', + newContent: 'New content', }), ), }; @@ -807,6 +812,8 @@ describe('mapToDisplay', () => { isOutputMarkdown: false, canUpdateOutput: false, schema: {}, + icon: Icon.Hammer, + toolLocations: vi.fn(), validateToolParams: vi.fn(), execute: vi.fn(), shouldConfirmExecute: vi.fn(), @@ -885,6 +892,8 @@ describe('mapToDisplay', () => { toolDisplayName: 'Test Tool Display', fileName: 'test.ts', fileDiff: 'Test diff', + originalContent: 'Original content', + newContent: 'New content', } as ToolCallConfirmationDetails, }, expectedStatus: ToolCallStatus.Confirming, diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 59f1e1ba..9d47fb08 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -145,6 +145,7 @@ export interface ConfigParameters { model: string; extensionContextFilePaths?: string[]; maxSessionTurns?: number; + experimentalAcp?: boolean; listExtensions?: boolean; activeExtensions?: ActiveExtension[]; noBrowser?: boolean; @@ -199,6 +200,7 @@ export class Config { private readonly summarizeToolOutput: | Record | undefined; + private readonly experimentalAcp: boolean = false; constructor(params: ConfigParameters) { this.sessionId = params.sessionId; @@ -241,6 +243,7 @@ export class Config { this.model = params.model; this.extensionContextFilePaths = params.extensionContextFilePaths ?? []; this.maxSessionTurns = params.maxSessionTurns ?? -1; + this.experimentalAcp = params.experimentalAcp ?? false; this.listExtensions = params.listExtensions ?? false; this._activeExtensions = params.activeExtensions ?? []; this.noBrowser = params.noBrowser ?? false; @@ -494,6 +497,10 @@ export class Config { return this.extensionContextFilePaths; } + getExperimentalAcp(): boolean { + return this.experimentalAcp; + } + getListExtensions(): boolean { return this.listExtensions; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 1cffd6a3..7bb8cea4 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -221,7 +221,7 @@ export class GeminiClient { return initialParts; } - private async startChat(extraHistory?: Content[]): Promise { + async startChat(extraHistory?: Content[]): Promise { const envParts = await this.getEnvironment(); const toolRegistry = await this.config.getToolRegistry(); const toolDeclarations = toolRegistry.getFunctionDeclarations(); diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 0b2c5124..94d4f7c1 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -19,6 +19,7 @@ import { ToolConfirmationPayload, ToolResult, Config, + Icon, } from '../index.js'; import { Part, PartListUnion } from '@google/genai'; @@ -29,7 +30,7 @@ class MockTool extends BaseTool, ToolResult> { executeFn = vi.fn(); constructor(name = 'mockTool') { - super(name, name, 'A mock tool', {}); + super(name, name, 'A mock tool', Icon.Hammer, {}); } async shouldConfirmExecute( @@ -91,6 +92,8 @@ class MockModifiableTool title: 'Confirm Mock Tool', fileName: 'test.txt', fileDiff: 'diff', + originalContent: 'originalContent', + newContent: 'newContent', onConfirm: async () => {}, }; } diff --git a/packages/core/src/core/nonInteractiveToolExecutor.test.ts b/packages/core/src/core/nonInteractiveToolExecutor.test.ts index 14b048b4..d52efb06 100644 --- a/packages/core/src/core/nonInteractiveToolExecutor.test.ts +++ b/packages/core/src/core/nonInteractiveToolExecutor.test.ts @@ -13,6 +13,7 @@ import { Tool, ToolCallConfirmationDetails, Config, + Icon, } from '../index.js'; import { Part, Type } from '@google/genai'; @@ -32,6 +33,7 @@ describe('executeToolCall', () => { name: 'testTool', displayName: 'Test Tool', description: 'A tool for testing', + icon: Icon.Hammer, schema: { name: 'testTool', description: 'A tool for testing', @@ -51,6 +53,7 @@ describe('executeToolCall', () => { isOutputMarkdown: false, canUpdateOutput: false, getDescription: vi.fn(), + toolLocations: vi.fn(() => []), }; mockToolRegistry = { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f1dc3e7..ffc06866 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,6 +33,8 @@ export * from './utils/memoryDiscovery.js'; export * from './utils/gitIgnoreParser.js'; export * from './utils/editor.js'; export * from './utils/quotaErrorDetection.js'; +export * from './utils/fileUtils.js'; +export * from './utils/retry.js'; // Export services export * from './services/fileDiscoveryService.js'; diff --git a/packages/core/src/tools/edit.ts b/packages/core/src/tools/edit.ts index 8d8753d4..ccba3d72 100644 --- a/packages/core/src/tools/edit.ts +++ b/packages/core/src/tools/edit.ts @@ -9,9 +9,11 @@ import * as path from 'path'; import * as Diff from 'diff'; import { BaseTool, + Icon, ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolEditConfirmationDetails, + ToolLocation, ToolResult, ToolResultDisplay, } from './tools.js'; @@ -89,6 +91,7 @@ Expectation for required parameters: 4. NEVER escape \`old_string\` or \`new_string\`, that would break the exact literal text requirement. **Important:** If ANY of the above are not satisfied, the tool will fail. CRITICAL for \`old_string\`: Must uniquely identify the single instance to change. Include at least 3 lines of context BEFORE and AFTER the target text, matching whitespace and indentation precisely. If this string matches multiple locations, or does not match exactly, the tool will fail. **Multiple replacements:** Set \`expected_replacements\` to the number of occurrences you want to replace. The tool will replace ALL occurrences that match \`old_string\` exactly. Ensure the number of replacements matches your expectation.`, + Icon.Pencil, { properties: { file_path: { @@ -141,6 +144,15 @@ Expectation for required parameters: return null; } + /** + * Determines any file locations affected by the tool execution + * @param params Parameters for the tool execution + * @returns A list of such paths + */ + toolLocations(params: EditToolParams): ToolLocation[] { + return [{ path: params.file_path }]; + } + private _applyReplacement( currentContent: string | null, oldString: string, @@ -306,6 +318,8 @@ Expectation for required parameters: title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`, fileName, fileDiff, + originalContent: editData.currentContent, + newContent: editData.newContent, onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); @@ -394,7 +408,12 @@ Expectation for required parameters: 'Proposed', DEFAULT_DIFF_OPTIONS, ); - displayResult = { fileDiff, fileName }; + displayResult = { + fileDiff, + fileName, + originalContent: editData.currentContent, + newContent: editData.newContent, + }; } const llmSuccessMessageParts = [ diff --git a/packages/core/src/tools/glob.ts b/packages/core/src/tools/glob.ts index 9381894e..417495fe 100644 --- a/packages/core/src/tools/glob.ts +++ b/packages/core/src/tools/glob.ts @@ -8,7 +8,7 @@ import fs from 'fs'; import path from 'path'; import { glob } from 'glob'; import { SchemaValidator } from '../utils/schemaValidator.js'; -import { BaseTool, ToolResult } from './tools.js'; +import { BaseTool, Icon, ToolResult } from './tools.js'; import { Type } from '@google/genai'; import { shortenPath, makeRelative } from '../utils/paths.js'; import { isWithinRoot } from '../utils/fileUtils.js'; @@ -86,6 +86,7 @@ export class GlobTool extends BaseTool { GlobTool.Name, 'FindFiles', 'Efficiently finds files matching specific glob patterns (e.g., `src/**/*.ts`, `**/*.md`), returning absolute paths sorted by modification time (newest first). Ideal for quickly locating files based on their name or path structure, especially in large codebases.', + Icon.FileSearch, { properties: { pattern: { diff --git a/packages/core/src/tools/grep.ts b/packages/core/src/tools/grep.ts index afe83050..177bd1aa 100644 --- a/packages/core/src/tools/grep.ts +++ b/packages/core/src/tools/grep.ts @@ -10,7 +10,7 @@ import path from 'path'; import { EOL } from 'os'; import { spawn } from 'child_process'; import { globStream } from 'glob'; -import { BaseTool, ToolResult } from './tools.js'; +import { BaseTool, Icon, ToolResult } from './tools.js'; import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; @@ -62,6 +62,7 @@ export class GrepTool extends BaseTool { GrepTool.Name, 'SearchText', 'Searches for a regular expression pattern within the content of files in a specified directory (or current working directory). Can filter files by a glob pattern. Returns the lines containing matches, along with their file paths and line numbers.', + Icon.Regex, { properties: { pattern: { diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index 9fb60072..fc4f06dd 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -6,7 +6,7 @@ import fs from 'fs'; import path from 'path'; -import { BaseTool, ToolResult } from './tools.js'; +import { BaseTool, Icon, ToolResult } from './tools.js'; import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; @@ -74,6 +74,7 @@ export class LSTool extends BaseTool { LSTool.Name, 'ReadFolder', 'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.', + Icon.Folder, { properties: { path: { diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 663ec6ee..aadc484a 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -10,6 +10,7 @@ import { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolMcpConfirmationDetails, + Icon, } from './tools.js'; import { CallableTool, @@ -38,6 +39,7 @@ export class DiscoveredMCPTool extends BaseTool { name, `${serverToolName} (${serverName} MCP Server)`, description, + Icon.Hammer, { type: Type.OBJECT }, // this is a dummy Schema for MCP, will be not be used to construct the FunctionDeclaration true, // isOutputMarkdown false, // canUpdateOutput diff --git a/packages/core/src/tools/memoryTool.ts b/packages/core/src/tools/memoryTool.ts index b4a671b0..f0f1e16b 100644 --- a/packages/core/src/tools/memoryTool.ts +++ b/packages/core/src/tools/memoryTool.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BaseTool, ToolResult } from './tools.js'; +import { BaseTool, Icon, ToolResult } from './tools.js'; import { FunctionDeclaration, Type } from '@google/genai'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -105,6 +105,7 @@ export class MemoryTool extends BaseTool { MemoryTool.Name, 'Save Memory', memoryToolDescription, + Icon.LightBulb, memoryToolSchemaData.parameters as Record, ); } diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index a2ff89c1..9ba80672 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -7,7 +7,7 @@ import path from 'path'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; -import { BaseTool, ToolResult } from './tools.js'; +import { BaseTool, Icon, ToolLocation, ToolResult } from './tools.js'; import { Type } from '@google/genai'; import { isWithinRoot, @@ -51,6 +51,7 @@ export class ReadFileTool extends BaseTool { ReadFileTool.Name, 'ReadFile', 'Reads and returns the content of a specified file from the local filesystem. Handles text, images (PNG, JPG, GIF, WEBP, SVG, BMP), and PDF files. For text files, it can read specific line ranges.', + Icon.FileSearch, { properties: { absolute_path: { @@ -118,6 +119,10 @@ export class ReadFileTool extends BaseTool { return shortenPath(relativePath); } + toolLocations(params: ReadFileToolParams): ToolLocation[] { + return [{ path: params.absolute_path, line: params.offset }]; + } + async execute( params: ReadFileToolParams, _signal: AbortSignal, diff --git a/packages/core/src/tools/read-many-files.ts b/packages/core/src/tools/read-many-files.ts index c43841b5..1c01ee9f 100644 --- a/packages/core/src/tools/read-many-files.ts +++ b/packages/core/src/tools/read-many-files.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BaseTool, ToolResult } from './tools.js'; +import { BaseTool, Icon, ToolResult } from './tools.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; import { getErrorMessage } from '../utils/errors.js'; import * as path from 'path'; @@ -196,6 +196,7 @@ This tool is useful when you need to understand or analyze a collection of files - When the user asks to "read all files in X directory" or "show me the content of all Y files". Use this tool when the user's query implies needing the content of several files simultaneously for context, analysis, or summarization. For text files, it uses default UTF-8 encoding and a '--- {filePath} ---' separator between file contents. Ensure paths are relative to the target directory. Glob patterns like 'src/**/*.js' are supported. Avoid using for single files if a more specific single-file reading tool is available, unless the user specifically requests to process a list containing just one file via this tool. Other binary files (not explicitly requested as image/PDF) are generally skipped. Default excludes apply to common non-text files (except for explicitly requested images/PDFs) and large dependency directories unless 'useDefaultExcludes' is false.`, + Icon.FileSearch, parameterSchema, ); this.geminiIgnorePatterns = config diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 3dc3d0a6..af514546 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -15,6 +15,7 @@ import { ToolCallConfirmationDetails, ToolExecuteConfirmationDetails, ToolConfirmationOutcome, + Icon, } from './tools.js'; import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; @@ -52,6 +53,7 @@ Exit Code: Exit code or \`(none)\` if terminated by signal. Signal: Signal number or \`(none)\` if no signal was received. Background PIDs: List of background processes started or \`(none)\`. Process Group PGID: Process group started or \`(none)\``, + Icon.Terminal, { type: Type.OBJECT, properties: { diff --git a/packages/core/src/tools/tool-registry.test.ts b/packages/core/src/tools/tool-registry.test.ts index 38b058ea..ec709a44 100644 --- a/packages/core/src/tools/tool-registry.test.ts +++ b/packages/core/src/tools/tool-registry.test.ts @@ -14,14 +14,14 @@ import { afterEach, Mocked, } from 'vitest'; +import { Config, ConfigParameters, ApprovalMode } from '../config/config.js'; import { ToolRegistry, DiscoveredTool, sanitizeParameters, } from './tool-registry.js'; import { DiscoveredMCPTool } from './mcp-tool.js'; -import { Config, ConfigParameters, ApprovalMode } from '../config/config.js'; -import { BaseTool, ToolResult } from './tools.js'; +import { BaseTool, Icon, ToolResult } from './tools.js'; import { FunctionDeclaration, CallableTool, @@ -109,7 +109,7 @@ class MockTool extends BaseTool<{ param: string }, ToolResult> { displayName = 'A mock tool', description = 'A mock tool description', ) { - super(name, displayName, description, { + super(name, displayName, description, Icon.Hammer, { type: Type.OBJECT, properties: { param: { type: Type.STRING }, diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index d2303fc9..e6a4121d 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -5,7 +5,7 @@ */ import { FunctionDeclaration, Schema, Type } from '@google/genai'; -import { Tool, ToolResult, BaseTool } from './tools.js'; +import { Tool, ToolResult, BaseTool, Icon } from './tools.js'; import { Config } from '../config/config.js'; import { spawn } from 'node:child_process'; import { StringDecoder } from 'node:string_decoder'; @@ -44,6 +44,7 @@ Signal: Signal number or \`(none)\` if no signal was received. name, name, description, + Icon.Hammer, parameterSchema, false, // isOutputMarkdown false, // canUpdateOutput diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 6f6d3f58..0d7b402a 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -28,6 +28,11 @@ export interface Tool< */ description: string; + /** + * The icon to display when interacting via ACP + */ + icon: Icon; + /** * Function declaration schema from @google/genai */ @@ -60,6 +65,13 @@ export interface Tool< */ getDescription(params: TParams): string; + /** + * Determines what file system paths the tool will affect + * @param params Parameters for the tool execution + * @returns A list of such paths + */ + toolLocations(params: TParams): ToolLocation[]; + /** * Determines if the tool should prompt for confirmation before execution * @param params Parameters for the tool execution @@ -103,6 +115,7 @@ export abstract class BaseTool< readonly name: string, readonly displayName: string, readonly description: string, + readonly icon: Icon, readonly parameterSchema: Schema, readonly isOutputMarkdown: boolean = true, readonly canUpdateOutput: boolean = false, @@ -158,6 +171,18 @@ export abstract class BaseTool< return Promise.resolve(false); } + /** + * Determines what file system paths the tool will affect + * @param params Parameters for the tool execution + * @returns A list of such paths + */ + toolLocations( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + params: TParams, + ): ToolLocation[] { + return []; + } + /** * Abstract method to execute the tool with the given parameters * Must be implemented by derived classes @@ -199,6 +224,8 @@ export type ToolResultDisplay = string | FileDiff; export interface FileDiff { fileDiff: string; fileName: string; + originalContent: string | null; + newContent: string; } export interface ToolEditConfirmationDetails { @@ -210,6 +237,8 @@ export interface ToolEditConfirmationDetails { ) => Promise; fileName: string; fileDiff: string; + originalContent: string | null; + newContent: string; isModifying?: boolean; } @@ -258,3 +287,21 @@ export enum ToolConfirmationOutcome { ModifyWithEditor = 'modify_with_editor', Cancel = 'cancel', } + +export enum Icon { + FileSearch = 'fileSearch', + Folder = 'folder', + Globe = 'globe', + Hammer = 'hammer', + LightBulb = 'lightBulb', + Pencil = 'pencil', + Regex = 'regex', + Terminal = 'terminal', +} + +export interface ToolLocation { + // Absolute path to the file + path: string; + // Which line (if known) + line?: number; +} diff --git a/packages/core/src/tools/web-fetch.ts b/packages/core/src/tools/web-fetch.ts index ee06880e..c96cae6c 100644 --- a/packages/core/src/tools/web-fetch.ts +++ b/packages/core/src/tools/web-fetch.ts @@ -10,6 +10,7 @@ import { ToolResult, ToolCallConfirmationDetails, ToolConfirmationOutcome, + Icon, } from './tools.js'; import { Type } from '@google/genai'; import { getErrorMessage } from '../utils/errors.js'; @@ -70,6 +71,7 @@ export class WebFetchTool extends BaseTool { WebFetchTool.Name, 'WebFetch', "Processes content from URL(s), including local and private network addresses (e.g., localhost), embedded in a prompt. Include up to 20 URLs and instructions (e.g., summarize, extract specific data) directly in the 'prompt' parameter.", + Icon.Globe, { properties: { prompt: { diff --git a/packages/core/src/tools/web-search.ts b/packages/core/src/tools/web-search.ts index 98be1f30..480cc7e7 100644 --- a/packages/core/src/tools/web-search.ts +++ b/packages/core/src/tools/web-search.ts @@ -5,7 +5,7 @@ */ import { GroundingMetadata } from '@google/genai'; -import { BaseTool, ToolResult } from './tools.js'; +import { BaseTool, Icon, ToolResult } from './tools.js'; import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; @@ -69,6 +69,7 @@ export class WebSearchTool extends BaseTool< WebSearchTool.Name, 'GoogleSearch', 'Performs a web search using Google Search (via the Gemini API) and returns the results. This tool is useful for finding information on the internet based on a query.', + Icon.Globe, { type: Type.OBJECT, properties: { diff --git a/packages/core/src/tools/write-file.ts b/packages/core/src/tools/write-file.ts index a3756c69..ae37ca8a 100644 --- a/packages/core/src/tools/write-file.ts +++ b/packages/core/src/tools/write-file.ts @@ -15,6 +15,7 @@ import { ToolEditConfirmationDetails, ToolConfirmationOutcome, ToolCallConfirmationDetails, + Icon, } from './tools.js'; import { Type } from '@google/genai'; import { SchemaValidator } from '../utils/schemaValidator.js'; @@ -72,9 +73,10 @@ export class WriteFileTool super( WriteFileTool.Name, 'WriteFile', - `Writes content to a specified file in the local filesystem. - + `Writes content to a specified file in the local filesystem. + The user has the ability to modify \`content\`. If modified, this will be stated in the response.`, + Icon.Pencil, { properties: { file_path: { @@ -184,6 +186,8 @@ export class WriteFileTool title: `Confirm Write: ${shortenPath(relativePath)}`, fileName, fileDiff, + originalContent, + newContent: correctedContent, onConfirm: async (outcome: ToolConfirmationOutcome) => { if (outcome === ToolConfirmationOutcome.ProceedAlways) { this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); @@ -269,7 +273,12 @@ export class WriteFileTool ); } - const displayResult: FileDiff = { fileDiff, fileName }; + const displayResult: FileDiff = { + fileDiff, + fileName, + originalContent: correctedContentResult.originalContent, + newContent: correctedContentResult.correctedContent, + }; const lines = fileContent.split('\n').length; const mimetype = getSpecificMimeType(params.file_path); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index e5d65751..bf4532bc 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -216,7 +216,7 @@ export async function retryWithBackoff( * @param error The error object. * @returns The HTTP status code, or undefined if not found. */ -function getErrorStatus(error: unknown): number | undefined { +export function getErrorStatus(error: unknown): number | undefined { if (typeof error === 'object' && error !== null) { if ('status' in error && typeof error.status === 'number') { return error.status;