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:
parent
12401898f1
commit
761ffc6338
|
@ -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>;
|
||||||
|
}
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
|
|
@ -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()),
|
||||||
|
|
|
@ -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} />,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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 () => {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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 },
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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';
|
||||||
|
@ -72,9 +73,10 @@ export class WriteFileTool
|
||||||
super(
|
super(
|
||||||
WriteFileTool.Name,
|
WriteFileTool.Name,
|
||||||
'WriteFile',
|
'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.`,
|
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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue