Zed integration (#4266)

Co-authored-by: Agus Zubiaga <agus@zed.dev>
Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
Co-authored-by: mkorwel <matt.korwel@gmail.com>
This commit is contained in:
Conrad Irwin 2025-07-17 16:25:23 -06:00 committed by GitHub
parent 12401898f1
commit 761ffc6338
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1287 additions and 19 deletions

464
packages/cli/src/acp/acp.ts Normal file
View File

@ -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>;
}

View File

@ -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}`);
}
}
}

View File

@ -54,6 +54,7 @@ export interface CliArgs {
telemetryOtlpEndpoint: string | undefined; telemetryOtlpEndpoint: string | undefined;
telemetryLogPrompts: boolean | undefined; telemetryLogPrompts: boolean | undefined;
allowedMcpServerNames: string[] | undefined; allowedMcpServerNames: string[] | undefined;
experimentalAcp: boolean | undefined;
extensions: string[] | undefined; extensions: string[] | undefined;
listExtensions: boolean | undefined; listExtensions: boolean | undefined;
ideMode: boolean | undefined; ideMode: boolean | undefined;
@ -162,6 +163,10 @@ export async function parseArguments(): Promise<CliArgs> {
description: 'Enables checkpointing of file edits', description: 'Enables checkpointing of file edits',
default: false, default: false,
}) })
.option('experimental-acp', {
type: 'boolean',
description: 'Starts the agent in ACP mode',
})
.option('allowed-mcp-server-names', { .option('allowed-mcp-server-names', {
type: 'array', type: 'array',
string: true, string: true,
@ -396,6 +401,7 @@ export async function loadCliConfig(
model: argv.model!, model: argv.model!,
extensionContextFilePaths, extensionContextFilePaths,
maxSessionTurns: settings.maxSessionTurns ?? -1, maxSessionTurns: settings.maxSessionTurns ?? -1,
experimentalAcp: argv.experimentalAcp || false,
listExtensions: argv.listExtensions || false, listExtensions: argv.listExtensions || false,
activeExtensions: activeExtensions.map((e) => ({ activeExtensions: activeExtensions.map((e) => ({
name: e.config.name, name: e.config.name,

View File

@ -84,6 +84,7 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
await new Promise((resolve) => child.on('close', resolve)); await new Promise((resolve) => child.on('close', resolve));
process.exit(0); process.exit(0);
} }
import { runAcpPeer } from './acp/acpPeer.js';
export async function main() { export async function main() {
const workspaceRoot = process.cwd(); const workspaceRoot = process.cwd();
@ -189,6 +190,10 @@ export async function main() {
await getOauthClient(settings.merged.selectedAuthType, config); await getOauthClient(settings.merged.selectedAuthType, config);
} }
if (config.getExperimentalAcp()) {
return runAcpPeer(config, settings);
}
let input = config.getQuestion(); let input = config.getQuestion();
const startupWarnings = [ const startupWarnings = [
...(await getStartupWarnings()), ...(await getStartupWarnings()),

View File

@ -152,6 +152,8 @@ describe('<ToolMessage />', () => {
const diffResult = { const diffResult = {
fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new', fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new',
fileName: 'file.txt', fileName: 'file.txt',
originalContent: 'old',
newContent: 'new',
}; };
const { lastFrame } = renderWithContext( const { lastFrame } = renderWithContext(
<ToolMessage {...baseProps} resultDisplay={diffResult} />, <ToolMessage {...baseProps} resultDisplay={diffResult} />,

View File

@ -23,7 +23,8 @@ import {
ToolCallResponseInfo, ToolCallResponseInfo,
ToolCall, // Import from core ToolCall, // Import from core
Status as ToolCallStatusType, Status as ToolCallStatusType,
ApprovalMode, // Import from core ApprovalMode,
Icon,
} from '@google/gemini-cli-core'; } from '@google/gemini-cli-core';
import { import {
HistoryItemWithoutId, HistoryItemWithoutId,
@ -56,6 +57,8 @@ const mockTool: Tool = {
name: 'mockTool', name: 'mockTool',
displayName: 'Mock Tool', displayName: 'Mock Tool',
description: 'A mock tool for testing', description: 'A mock tool for testing',
icon: Icon.Hammer,
toolLocations: vi.fn(),
isOutputMarkdown: false, isOutputMarkdown: false,
canUpdateOutput: false, canUpdateOutput: false,
schema: {}, schema: {},
@ -85,6 +88,8 @@ const mockToolRequiresConfirmation: Tool = {
onConfirm: mockOnUserConfirmForToolConfirmation, onConfirm: mockOnUserConfirmForToolConfirmation,
fileName: 'mockToolRequiresConfirmation.ts', fileName: 'mockToolRequiresConfirmation.ts',
fileDiff: 'Mock tool requires confirmation', fileDiff: 'Mock tool requires confirmation',
originalContent: 'Original content',
newContent: 'New content',
}), }),
), ),
}; };
@ -807,6 +812,8 @@ describe('mapToDisplay', () => {
isOutputMarkdown: false, isOutputMarkdown: false,
canUpdateOutput: false, canUpdateOutput: false,
schema: {}, schema: {},
icon: Icon.Hammer,
toolLocations: vi.fn(),
validateToolParams: vi.fn(), validateToolParams: vi.fn(),
execute: vi.fn(), execute: vi.fn(),
shouldConfirmExecute: vi.fn(), shouldConfirmExecute: vi.fn(),
@ -885,6 +892,8 @@ describe('mapToDisplay', () => {
toolDisplayName: 'Test Tool Display', toolDisplayName: 'Test Tool Display',
fileName: 'test.ts', fileName: 'test.ts',
fileDiff: 'Test diff', fileDiff: 'Test diff',
originalContent: 'Original content',
newContent: 'New content',
} as ToolCallConfirmationDetails, } as ToolCallConfirmationDetails,
}, },
expectedStatus: ToolCallStatus.Confirming, expectedStatus: ToolCallStatus.Confirming,

View File

@ -145,6 +145,7 @@ export interface ConfigParameters {
model: string; model: string;
extensionContextFilePaths?: string[]; extensionContextFilePaths?: string[];
maxSessionTurns?: number; maxSessionTurns?: number;
experimentalAcp?: boolean;
listExtensions?: boolean; listExtensions?: boolean;
activeExtensions?: ActiveExtension[]; activeExtensions?: ActiveExtension[];
noBrowser?: boolean; noBrowser?: boolean;
@ -199,6 +200,7 @@ export class Config {
private readonly summarizeToolOutput: private readonly summarizeToolOutput:
| Record<string, SummarizeToolOutputSettings> | Record<string, SummarizeToolOutputSettings>
| undefined; | undefined;
private readonly experimentalAcp: boolean = false;
constructor(params: ConfigParameters) { constructor(params: ConfigParameters) {
this.sessionId = params.sessionId; this.sessionId = params.sessionId;
@ -241,6 +243,7 @@ export class Config {
this.model = params.model; this.model = params.model;
this.extensionContextFilePaths = params.extensionContextFilePaths ?? []; this.extensionContextFilePaths = params.extensionContextFilePaths ?? [];
this.maxSessionTurns = params.maxSessionTurns ?? -1; this.maxSessionTurns = params.maxSessionTurns ?? -1;
this.experimentalAcp = params.experimentalAcp ?? false;
this.listExtensions = params.listExtensions ?? false; this.listExtensions = params.listExtensions ?? false;
this._activeExtensions = params.activeExtensions ?? []; this._activeExtensions = params.activeExtensions ?? [];
this.noBrowser = params.noBrowser ?? false; this.noBrowser = params.noBrowser ?? false;
@ -494,6 +497,10 @@ export class Config {
return this.extensionContextFilePaths; return this.extensionContextFilePaths;
} }
getExperimentalAcp(): boolean {
return this.experimentalAcp;
}
getListExtensions(): boolean { getListExtensions(): boolean {
return this.listExtensions; return this.listExtensions;
} }

View File

@ -221,7 +221,7 @@ export class GeminiClient {
return initialParts; return initialParts;
} }
private async startChat(extraHistory?: Content[]): Promise<GeminiChat> { async startChat(extraHistory?: Content[]): Promise<GeminiChat> {
const envParts = await this.getEnvironment(); const envParts = await this.getEnvironment();
const toolRegistry = await this.config.getToolRegistry(); const toolRegistry = await this.config.getToolRegistry();
const toolDeclarations = toolRegistry.getFunctionDeclarations(); const toolDeclarations = toolRegistry.getFunctionDeclarations();

View File

@ -19,6 +19,7 @@ import {
ToolConfirmationPayload, ToolConfirmationPayload,
ToolResult, ToolResult,
Config, Config,
Icon,
} from '../index.js'; } from '../index.js';
import { Part, PartListUnion } from '@google/genai'; import { Part, PartListUnion } from '@google/genai';
@ -29,7 +30,7 @@ class MockTool extends BaseTool<Record<string, unknown>, ToolResult> {
executeFn = vi.fn(); executeFn = vi.fn();
constructor(name = 'mockTool') { constructor(name = 'mockTool') {
super(name, name, 'A mock tool', {}); super(name, name, 'A mock tool', Icon.Hammer, {});
} }
async shouldConfirmExecute( async shouldConfirmExecute(
@ -91,6 +92,8 @@ class MockModifiableTool
title: 'Confirm Mock Tool', title: 'Confirm Mock Tool',
fileName: 'test.txt', fileName: 'test.txt',
fileDiff: 'diff', fileDiff: 'diff',
originalContent: 'originalContent',
newContent: 'newContent',
onConfirm: async () => {}, onConfirm: async () => {},
}; };
} }

View File

@ -13,6 +13,7 @@ import {
Tool, Tool,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
Config, Config,
Icon,
} from '../index.js'; } from '../index.js';
import { Part, Type } from '@google/genai'; import { Part, Type } from '@google/genai';
@ -32,6 +33,7 @@ describe('executeToolCall', () => {
name: 'testTool', name: 'testTool',
displayName: 'Test Tool', displayName: 'Test Tool',
description: 'A tool for testing', description: 'A tool for testing',
icon: Icon.Hammer,
schema: { schema: {
name: 'testTool', name: 'testTool',
description: 'A tool for testing', description: 'A tool for testing',
@ -51,6 +53,7 @@ describe('executeToolCall', () => {
isOutputMarkdown: false, isOutputMarkdown: false,
canUpdateOutput: false, canUpdateOutput: false,
getDescription: vi.fn(), getDescription: vi.fn(),
toolLocations: vi.fn(() => []),
}; };
mockToolRegistry = { mockToolRegistry = {

View File

@ -33,6 +33,8 @@ export * from './utils/memoryDiscovery.js';
export * from './utils/gitIgnoreParser.js'; export * from './utils/gitIgnoreParser.js';
export * from './utils/editor.js'; export * from './utils/editor.js';
export * from './utils/quotaErrorDetection.js'; export * from './utils/quotaErrorDetection.js';
export * from './utils/fileUtils.js';
export * from './utils/retry.js';
// Export services // Export services
export * from './services/fileDiscoveryService.js'; export * from './services/fileDiscoveryService.js';

View File

@ -9,9 +9,11 @@ import * as path from 'path';
import * as Diff from 'diff'; import * as Diff from 'diff';
import { import {
BaseTool, BaseTool,
Icon,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolEditConfirmationDetails, ToolEditConfirmationDetails,
ToolLocation,
ToolResult, ToolResult,
ToolResultDisplay, ToolResultDisplay,
} from './tools.js'; } 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. 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. **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.`, **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: { properties: {
file_path: { file_path: {
@ -141,6 +144,15 @@ Expectation for required parameters:
return null; 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( private _applyReplacement(
currentContent: string | null, currentContent: string | null,
oldString: string, oldString: string,
@ -306,6 +318,8 @@ Expectation for required parameters:
title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`, title: `Confirm Edit: ${shortenPath(makeRelative(params.file_path, this.config.getTargetDir()))}`,
fileName, fileName,
fileDiff, fileDiff,
originalContent: editData.currentContent,
newContent: editData.newContent,
onConfirm: async (outcome: ToolConfirmationOutcome) => { onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) { if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); this.config.setApprovalMode(ApprovalMode.AUTO_EDIT);
@ -394,7 +408,12 @@ Expectation for required parameters:
'Proposed', 'Proposed',
DEFAULT_DIFF_OPTIONS, DEFAULT_DIFF_OPTIONS,
); );
displayResult = { fileDiff, fileName }; displayResult = {
fileDiff,
fileName,
originalContent: editData.currentContent,
newContent: editData.newContent,
};
} }
const llmSuccessMessageParts = [ const llmSuccessMessageParts = [

View File

@ -8,7 +8,7 @@ import fs from 'fs';
import path from 'path'; import path from 'path';
import { glob } from 'glob'; import { glob } from 'glob';
import { SchemaValidator } from '../utils/schemaValidator.js'; 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 { Type } from '@google/genai';
import { shortenPath, makeRelative } from '../utils/paths.js'; import { shortenPath, makeRelative } from '../utils/paths.js';
import { isWithinRoot } from '../utils/fileUtils.js'; import { isWithinRoot } from '../utils/fileUtils.js';
@ -86,6 +86,7 @@ export class GlobTool extends BaseTool<GlobToolParams, ToolResult> {
GlobTool.Name, GlobTool.Name,
'FindFiles', '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.', '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: { properties: {
pattern: { pattern: {

View File

@ -10,7 +10,7 @@ import path from 'path';
import { EOL } from 'os'; import { EOL } from 'os';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { globStream } from 'glob'; import { globStream } from 'glob';
import { BaseTool, ToolResult } from './tools.js'; import { BaseTool, Icon, ToolResult } from './tools.js';
import { Type } from '@google/genai'; import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
@ -62,6 +62,7 @@ export class GrepTool extends BaseTool<GrepToolParams, ToolResult> {
GrepTool.Name, GrepTool.Name,
'SearchText', '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.', '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: { properties: {
pattern: { pattern: {

View File

@ -6,7 +6,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { BaseTool, ToolResult } from './tools.js'; import { BaseTool, Icon, ToolResult } from './tools.js';
import { Type } from '@google/genai'; import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.js'; import { makeRelative, shortenPath } from '../utils/paths.js';
@ -74,6 +74,7 @@ export class LSTool extends BaseTool<LSToolParams, ToolResult> {
LSTool.Name, LSTool.Name,
'ReadFolder', 'ReadFolder',
'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.', 'Lists the names of files and subdirectories directly within a specified directory path. Can optionally ignore entries matching provided glob patterns.',
Icon.Folder,
{ {
properties: { properties: {
path: { path: {

View File

@ -10,6 +10,7 @@ import {
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolMcpConfirmationDetails, ToolMcpConfirmationDetails,
Icon,
} from './tools.js'; } from './tools.js';
import { import {
CallableTool, CallableTool,
@ -38,6 +39,7 @@ export class DiscoveredMCPTool extends BaseTool<ToolParams, ToolResult> {
name, name,
`${serverToolName} (${serverName} MCP Server)`, `${serverToolName} (${serverName} MCP Server)`,
description, description,
Icon.Hammer,
{ type: Type.OBJECT }, // this is a dummy Schema for MCP, will be not be used to construct the FunctionDeclaration { type: Type.OBJECT }, // this is a dummy Schema for MCP, will be not be used to construct the FunctionDeclaration
true, // isOutputMarkdown true, // isOutputMarkdown
false, // canUpdateOutput false, // canUpdateOutput

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { FunctionDeclaration, Type } from '@google/genai';
import * as fs from 'fs/promises'; import * as fs from 'fs/promises';
import * as path from 'path'; import * as path from 'path';
@ -105,6 +105,7 @@ export class MemoryTool extends BaseTool<SaveMemoryParams, ToolResult> {
MemoryTool.Name, MemoryTool.Name,
'Save Memory', 'Save Memory',
memoryToolDescription, memoryToolDescription,
Icon.LightBulb,
memoryToolSchemaData.parameters as Record<string, unknown>, memoryToolSchemaData.parameters as Record<string, unknown>,
); );
} }

View File

@ -7,7 +7,7 @@
import path from 'path'; import path from 'path';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
import { makeRelative, shortenPath } from '../utils/paths.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 { Type } from '@google/genai';
import { import {
isWithinRoot, isWithinRoot,
@ -51,6 +51,7 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
ReadFileTool.Name, ReadFileTool.Name,
'ReadFile', '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.', '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: { properties: {
absolute_path: { absolute_path: {
@ -118,6 +119,10 @@ export class ReadFileTool extends BaseTool<ReadFileToolParams, ToolResult> {
return shortenPath(relativePath); return shortenPath(relativePath);
} }
toolLocations(params: ReadFileToolParams): ToolLocation[] {
return [{ path: params.absolute_path, line: params.offset }];
}
async execute( async execute(
params: ReadFileToolParams, params: ReadFileToolParams,
_signal: AbortSignal, _signal: AbortSignal,

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * 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 { SchemaValidator } from '../utils/schemaValidator.js';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
import * as path from 'path'; 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". - 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.`, 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, parameterSchema,
); );
this.geminiIgnorePatterns = config this.geminiIgnorePatterns = config

View File

@ -15,6 +15,7 @@ import {
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolExecuteConfirmationDetails, ToolExecuteConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
Icon,
} from './tools.js'; } from './tools.js';
import { Type } from '@google/genai'; import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; 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. Signal: Signal number or \`(none)\` if no signal was received.
Background PIDs: List of background processes started or \`(none)\`. Background PIDs: List of background processes started or \`(none)\`.
Process Group PGID: Process group started or \`(none)\``, Process Group PGID: Process group started or \`(none)\``,
Icon.Terminal,
{ {
type: Type.OBJECT, type: Type.OBJECT,
properties: { properties: {

View File

@ -14,14 +14,14 @@ import {
afterEach, afterEach,
Mocked, Mocked,
} from 'vitest'; } from 'vitest';
import { Config, ConfigParameters, ApprovalMode } from '../config/config.js';
import { import {
ToolRegistry, ToolRegistry,
DiscoveredTool, DiscoveredTool,
sanitizeParameters, sanitizeParameters,
} from './tool-registry.js'; } from './tool-registry.js';
import { DiscoveredMCPTool } from './mcp-tool.js'; import { DiscoveredMCPTool } from './mcp-tool.js';
import { Config, ConfigParameters, ApprovalMode } from '../config/config.js'; import { BaseTool, Icon, ToolResult } from './tools.js';
import { BaseTool, ToolResult } from './tools.js';
import { import {
FunctionDeclaration, FunctionDeclaration,
CallableTool, CallableTool,
@ -109,7 +109,7 @@ class MockTool extends BaseTool<{ param: string }, ToolResult> {
displayName = 'A mock tool', displayName = 'A mock tool',
description = 'A mock tool description', description = 'A mock tool description',
) { ) {
super(name, displayName, description, { super(name, displayName, description, Icon.Hammer, {
type: Type.OBJECT, type: Type.OBJECT,
properties: { properties: {
param: { type: Type.STRING }, param: { type: Type.STRING },

View File

@ -5,7 +5,7 @@
*/ */
import { FunctionDeclaration, Schema, Type } from '@google/genai'; 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 { Config } from '../config/config.js';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { StringDecoder } from 'node:string_decoder'; import { StringDecoder } from 'node:string_decoder';
@ -44,6 +44,7 @@ Signal: Signal number or \`(none)\` if no signal was received.
name, name,
name, name,
description, description,
Icon.Hammer,
parameterSchema, parameterSchema,
false, // isOutputMarkdown false, // isOutputMarkdown
false, // canUpdateOutput false, // canUpdateOutput

View File

@ -28,6 +28,11 @@ export interface Tool<
*/ */
description: string; description: string;
/**
* The icon to display when interacting via ACP
*/
icon: Icon;
/** /**
* Function declaration schema from @google/genai * Function declaration schema from @google/genai
*/ */
@ -60,6 +65,13 @@ export interface Tool<
*/ */
getDescription(params: TParams): string; 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 * Determines if the tool should prompt for confirmation before execution
* @param params Parameters for the tool execution * @param params Parameters for the tool execution
@ -103,6 +115,7 @@ export abstract class BaseTool<
readonly name: string, readonly name: string,
readonly displayName: string, readonly displayName: string,
readonly description: string, readonly description: string,
readonly icon: Icon,
readonly parameterSchema: Schema, readonly parameterSchema: Schema,
readonly isOutputMarkdown: boolean = true, readonly isOutputMarkdown: boolean = true,
readonly canUpdateOutput: boolean = false, readonly canUpdateOutput: boolean = false,
@ -158,6 +171,18 @@ export abstract class BaseTool<
return Promise.resolve(false); 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 * Abstract method to execute the tool with the given parameters
* Must be implemented by derived classes * Must be implemented by derived classes
@ -199,6 +224,8 @@ export type ToolResultDisplay = string | FileDiff;
export interface FileDiff { export interface FileDiff {
fileDiff: string; fileDiff: string;
fileName: string; fileName: string;
originalContent: string | null;
newContent: string;
} }
export interface ToolEditConfirmationDetails { export interface ToolEditConfirmationDetails {
@ -210,6 +237,8 @@ export interface ToolEditConfirmationDetails {
) => Promise<void>; ) => Promise<void>;
fileName: string; fileName: string;
fileDiff: string; fileDiff: string;
originalContent: string | null;
newContent: string;
isModifying?: boolean; isModifying?: boolean;
} }
@ -258,3 +287,21 @@ export enum ToolConfirmationOutcome {
ModifyWithEditor = 'modify_with_editor', ModifyWithEditor = 'modify_with_editor',
Cancel = 'cancel', 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;
}

View File

@ -10,6 +10,7 @@ import {
ToolResult, ToolResult,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
Icon,
} from './tools.js'; } from './tools.js';
import { Type } from '@google/genai'; import { Type } from '@google/genai';
import { getErrorMessage } from '../utils/errors.js'; import { getErrorMessage } from '../utils/errors.js';
@ -70,6 +71,7 @@ export class WebFetchTool extends BaseTool<WebFetchToolParams, ToolResult> {
WebFetchTool.Name, WebFetchTool.Name,
'WebFetch', '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.", "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: { properties: {
prompt: { prompt: {

View File

@ -5,7 +5,7 @@
*/ */
import { GroundingMetadata } from '@google/genai'; import { GroundingMetadata } from '@google/genai';
import { BaseTool, ToolResult } from './tools.js'; import { BaseTool, Icon, ToolResult } from './tools.js';
import { Type } from '@google/genai'; import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
@ -69,6 +69,7 @@ export class WebSearchTool extends BaseTool<
WebSearchTool.Name, WebSearchTool.Name,
'GoogleSearch', '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.', '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, type: Type.OBJECT,
properties: { properties: {

View File

@ -15,6 +15,7 @@ import {
ToolEditConfirmationDetails, ToolEditConfirmationDetails,
ToolConfirmationOutcome, ToolConfirmationOutcome,
ToolCallConfirmationDetails, ToolCallConfirmationDetails,
Icon,
} from './tools.js'; } from './tools.js';
import { Type } from '@google/genai'; import { Type } from '@google/genai';
import { SchemaValidator } from '../utils/schemaValidator.js'; import { SchemaValidator } from '../utils/schemaValidator.js';
@ -75,6 +76,7 @@ export class WriteFileTool
`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.`, The user has the ability to modify \`content\`. If modified, this will be stated in the response.`,
Icon.Pencil,
{ {
properties: { properties: {
file_path: { file_path: {
@ -184,6 +186,8 @@ export class WriteFileTool
title: `Confirm Write: ${shortenPath(relativePath)}`, title: `Confirm Write: ${shortenPath(relativePath)}`,
fileName, fileName,
fileDiff, fileDiff,
originalContent,
newContent: correctedContent,
onConfirm: async (outcome: ToolConfirmationOutcome) => { onConfirm: async (outcome: ToolConfirmationOutcome) => {
if (outcome === ToolConfirmationOutcome.ProceedAlways) { if (outcome === ToolConfirmationOutcome.ProceedAlways) {
this.config.setApprovalMode(ApprovalMode.AUTO_EDIT); 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 lines = fileContent.split('\n').length;
const mimetype = getSpecificMimeType(params.file_path); const mimetype = getSpecificMimeType(params.file_path);

View File

@ -216,7 +216,7 @@ export async function retryWithBackoff<T>(
* @param error The error object. * @param error The error object.
* @returns The HTTP status code, or undefined if not found. * @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 (typeof error === 'object' && error !== null) {
if ('status' in error && typeof error.status === 'number') { if ('status' in error && typeof error.status === 'number') {
return error.status; return error.status;