summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/cli/src/acp/acp.ts464
-rw-r--r--packages/cli/src/acp/acpPeer.ts674
-rw-r--r--packages/cli/src/config/config.ts6
-rw-r--r--packages/cli/src/gemini.tsx5
-rw-r--r--packages/cli/src/ui/components/messages/ToolMessage.test.tsx2
-rw-r--r--packages/cli/src/ui/hooks/useToolScheduler.test.ts11
-rw-r--r--packages/core/src/config/config.ts7
-rw-r--r--packages/core/src/core/client.ts2
-rw-r--r--packages/core/src/core/coreToolScheduler.test.ts5
-rw-r--r--packages/core/src/core/nonInteractiveToolExecutor.test.ts3
-rw-r--r--packages/core/src/index.ts2
-rw-r--r--packages/core/src/tools/edit.ts21
-rw-r--r--packages/core/src/tools/glob.ts3
-rw-r--r--packages/core/src/tools/grep.ts3
-rw-r--r--packages/core/src/tools/ls.ts3
-rw-r--r--packages/core/src/tools/mcp-tool.ts2
-rw-r--r--packages/core/src/tools/memoryTool.ts3
-rw-r--r--packages/core/src/tools/read-file.ts7
-rw-r--r--packages/core/src/tools/read-many-files.ts3
-rw-r--r--packages/core/src/tools/shell.ts2
-rw-r--r--packages/core/src/tools/tool-registry.test.ts6
-rw-r--r--packages/core/src/tools/tool-registry.ts3
-rw-r--r--packages/core/src/tools/tools.ts47
-rw-r--r--packages/core/src/tools/web-fetch.ts2
-rw-r--r--packages/core/src/tools/web-search.ts3
-rw-r--r--packages/core/src/tools/write-file.ts15
-rw-r--r--packages/core/src/utils/retry.ts2
27 files changed, 1287 insertions, 19 deletions
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<Agent>;
+
+ constructor(
+ agent: (client: Client) => Agent,
+ input: WritableStream<Uint8Array>,
+ output: ReadableStream<Uint8Array>,
+ ) {
+ this.#connection = new Connection(agent(this), input, output);
+ }
+
+ /**
+ * Streams part of an assistant response to the client
+ */
+ async streamAssistantMessageChunk(
+ params: StreamAssistantMessageChunkParams,
+ ): Promise<void> {
+ 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<RequestToolCallConfirmationResponse> {
+ 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<PushToolCallResponse> {
+ 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<void> {
+ 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<unknown>;
+
+type Result<T> =
+ | {
+ 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<D> {
+ #pendingResponses: Map<number, PendingResponse> = new Map();
+ #nextRequestId: number = 0;
+ #delegate: D;
+ #peerInput: WritableStream<Uint8Array>;
+ #writeQueue: Promise<void> = Promise.resolve();
+ #textEncoder: TextEncoder;
+
+ constructor(
+ delegate: D,
+ peerInput: WritableStream<Uint8Array>,
+ peerOutput: ReadableStream<Uint8Array>,
+ ) {
+ this.#peerInput = peerInput;
+ this.#textEncoder = new TextEncoder();
+
+ this.#delegate = delegate;
+ this.#receive(peerOutput);
+ }
+
+ async #receive(output: ReadableStream<Uint8Array>) {
+ 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<Result<unknown>> {
+ 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<Req, Resp>(method: string, params?: Req): Promise<Resp> {
+ 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<Resp>;
+ }
+
+ 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<T>(): Result<T> {
+ 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<void>;
+
+ requestToolCallConfirmation(
+ params: RequestToolCallConfirmationParams,
+ ): Promise<RequestToolCallConfirmationResponse>;
+
+ pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse>;
+
+ updateToolCall(params: UpdateToolCallParams): Promise<void>;
+}
+
+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<InitializeResponse>;
+
+ /**
+ * 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<void>;
+
+ /**
+ * 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<void>;
+
+ /**
+ * Cancels the current generation.
+ */
+ cancelSendMessage(): Promise<void>;
+}
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<Uint8Array>;
+
+ // 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<acp.InitializeResponse> {
+ 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<void> {
+ await clearCachedCredentialFile();
+ await this.config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE);
+ this.settings.setValue(
+ SettingScope.User,
+ 'selectedAuthType',
+ AuthType.LOGIN_WITH_GOOGLE,
+ );
+ }
+
+ async cancelSendMessage(): Promise<void> {
+ if (!this.pendingSend) {
+ throw new Error('Not currently generating');
+ }
+
+ this.pendingSend.abort();
+ delete this.pendingSend;
+ }
+
+ async sendUserMessage(params: acp.SendUserMessageParams): Promise<void> {
+ 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<PartListUnion> {
+ const callId = fc.id ?? `${fc.name}-${Date.now()}`;
+ const args = (fc.args ?? {}) as Record<string, unknown>;
+
+ 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<Part[]> {
+ 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<string, string>();
+ 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<CliArgs> {
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('<ToolMessage />', () => {
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(
<ToolMessage {...baseProps} resultDisplay={diffResult} />,
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<string, SummarizeToolOutputSettings>
| 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<GeminiChat> {
+ async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
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<Record<string, unknown>, 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<GlobToolParams, ToolResult> {
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<GrepToolParams, ToolResult> {
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<LSToolParams, ToolResult> {
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<ToolParams, ToolResult> {
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<SaveMemoryParams, ToolResult> {
MemoryTool.Name,
'Save Memory',
memoryToolDescription,
+ Icon.LightBulb,
memoryToolSchemaData.parameters as Record<string, unknown>,
);
}
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<ReadFileToolParams, ToolResult> {
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<ReadFileToolParams, ToolResult> {
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
@@ -29,6 +29,11 @@ export interface Tool<
description: string;
/**
+ * The icon to display when interacting via ACP
+ */
+ icon: Icon;
+
+ /**
* Function declaration schema from @google/genai
*/
schema: FunctionDeclaration;
@@ -61,6 +66,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
* @returns Whether execute should be confirmed.
@@ -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,
@@ -159,6 +172,18 @@ export abstract class BaseTool<
}
/**
+ * 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
* @param params Parameters for the tool execution
@@ -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<void>;
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<WebFetchToolParams, ToolResult> {
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<T>(
* @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;