mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-20 08:47:44 +00:00
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:
464
packages/cli/src/acp/acp.ts
Normal file
464
packages/cli/src/acp/acp.ts
Normal 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>;
|
||||
}
|
||||
674
packages/cli/src/acp/acpPeer.ts
Normal file
674
packages/cli/src/acp/acpPeer.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export interface CliArgs {
|
||||
telemetryOtlpEndpoint: string | undefined;
|
||||
telemetryLogPrompts: boolean | undefined;
|
||||
allowedMcpServerNames: string[] | undefined;
|
||||
experimentalAcp: boolean | undefined;
|
||||
extensions: string[] | undefined;
|
||||
listExtensions: boolean | undefined;
|
||||
ideMode: boolean | undefined;
|
||||
@@ -162,6 +163,10 @@ export async function parseArguments(): Promise<CliArgs> {
|
||||
description: 'Enables checkpointing of file edits',
|
||||
default: false,
|
||||
})
|
||||
.option('experimental-acp', {
|
||||
type: 'boolean',
|
||||
description: 'Starts the agent in ACP mode',
|
||||
})
|
||||
.option('allowed-mcp-server-names', {
|
||||
type: 'array',
|
||||
string: true,
|
||||
@@ -396,6 +401,7 @@ export async function loadCliConfig(
|
||||
model: argv.model!,
|
||||
extensionContextFilePaths,
|
||||
maxSessionTurns: settings.maxSessionTurns ?? -1,
|
||||
experimentalAcp: argv.experimentalAcp || false,
|
||||
listExtensions: argv.listExtensions || false,
|
||||
activeExtensions: activeExtensions.map((e) => ({
|
||||
name: e.config.name,
|
||||
|
||||
@@ -84,6 +84,7 @@ async function relaunchWithAdditionalArgs(additionalArgs: string[]) {
|
||||
await new Promise((resolve) => child.on('close', resolve));
|
||||
process.exit(0);
|
||||
}
|
||||
import { runAcpPeer } from './acp/acpPeer.js';
|
||||
|
||||
export async function main() {
|
||||
const workspaceRoot = process.cwd();
|
||||
@@ -189,6 +190,10 @@ export async function main() {
|
||||
await getOauthClient(settings.merged.selectedAuthType, config);
|
||||
}
|
||||
|
||||
if (config.getExperimentalAcp()) {
|
||||
return runAcpPeer(config, settings);
|
||||
}
|
||||
|
||||
let input = config.getQuestion();
|
||||
const startupWarnings = [
|
||||
...(await getStartupWarnings()),
|
||||
|
||||
@@ -152,6 +152,8 @@ describe('<ToolMessage />', () => {
|
||||
const diffResult = {
|
||||
fileDiff: '--- a/file.txt\n+++ b/file.txt\n@@ -1 +1 @@\n-old\n+new',
|
||||
fileName: 'file.txt',
|
||||
originalContent: 'old',
|
||||
newContent: 'new',
|
||||
};
|
||||
const { lastFrame } = renderWithContext(
|
||||
<ToolMessage {...baseProps} resultDisplay={diffResult} />,
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
ToolCallResponseInfo,
|
||||
ToolCall, // Import from core
|
||||
Status as ToolCallStatusType,
|
||||
ApprovalMode, // Import from core
|
||||
ApprovalMode,
|
||||
Icon,
|
||||
} from '@google/gemini-cli-core';
|
||||
import {
|
||||
HistoryItemWithoutId,
|
||||
@@ -56,6 +57,8 @@ const mockTool: Tool = {
|
||||
name: 'mockTool',
|
||||
displayName: 'Mock Tool',
|
||||
description: 'A mock tool for testing',
|
||||
icon: Icon.Hammer,
|
||||
toolLocations: vi.fn(),
|
||||
isOutputMarkdown: false,
|
||||
canUpdateOutput: false,
|
||||
schema: {},
|
||||
@@ -85,6 +88,8 @@ const mockToolRequiresConfirmation: Tool = {
|
||||
onConfirm: mockOnUserConfirmForToolConfirmation,
|
||||
fileName: 'mockToolRequiresConfirmation.ts',
|
||||
fileDiff: 'Mock tool requires confirmation',
|
||||
originalContent: 'Original content',
|
||||
newContent: 'New content',
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -807,6 +812,8 @@ describe('mapToDisplay', () => {
|
||||
isOutputMarkdown: false,
|
||||
canUpdateOutput: false,
|
||||
schema: {},
|
||||
icon: Icon.Hammer,
|
||||
toolLocations: vi.fn(),
|
||||
validateToolParams: vi.fn(),
|
||||
execute: vi.fn(),
|
||||
shouldConfirmExecute: vi.fn(),
|
||||
@@ -885,6 +892,8 @@ describe('mapToDisplay', () => {
|
||||
toolDisplayName: 'Test Tool Display',
|
||||
fileName: 'test.ts',
|
||||
fileDiff: 'Test diff',
|
||||
originalContent: 'Original content',
|
||||
newContent: 'New content',
|
||||
} as ToolCallConfirmationDetails,
|
||||
},
|
||||
expectedStatus: ToolCallStatus.Confirming,
|
||||
|
||||
Reference in New Issue
Block a user