diff options
Diffstat (limited to 'packages/cli/src/acp/acp.ts')
| -rw-r--r-- | packages/cli/src/acp/acp.ts | 464 |
1 files changed, 464 insertions, 0 deletions
diff --git a/packages/cli/src/acp/acp.ts b/packages/cli/src/acp/acp.ts new file mode 100644 index 00000000..1fbdf7a8 --- /dev/null +++ b/packages/cli/src/acp/acp.ts @@ -0,0 +1,464 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* ACP defines a schema for a simple (experimental) JSON-RPC protocol that allows GUI applications to interact with agents. */ + +import { Icon } from '@google/gemini-cli-core'; +import { WritableStream, ReadableStream } from 'node:stream/web'; + +export class ClientConnection implements Client { + #connection: Connection<Agent>; + + constructor( + agent: (client: Client) => Agent, + input: WritableStream<Uint8Array>, + output: ReadableStream<Uint8Array>, + ) { + this.#connection = new Connection(agent(this), input, output); + } + + /** + * Streams part of an assistant response to the client + */ + async streamAssistantMessageChunk( + params: StreamAssistantMessageChunkParams, + ): Promise<void> { + await this.#connection.sendRequest('streamAssistantMessageChunk', params); + } + + /** + * Request confirmation before running a tool + * + * When allowed, the client returns a [`ToolCallId`] which can be used + * to update the tool call's `status` and `content` as it runs. + */ + requestToolCallConfirmation( + params: RequestToolCallConfirmationParams, + ): Promise<RequestToolCallConfirmationResponse> { + return this.#connection.sendRequest('requestToolCallConfirmation', params); + } + + /** + * pushToolCall allows the agent to start a tool call + * when it does not need to request permission to do so. + * + * The returned id can be used to update the UI for the tool + * call as needed. + */ + pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse> { + return this.#connection.sendRequest('pushToolCall', params); + } + + /** + * updateToolCall allows the agent to update the content and status of the tool call. + * + * The new content replaces what is currently displayed in the UI. + * + * The [`ToolCallId`] is included in the response of + * `pushToolCall` or `requestToolCallConfirmation` respectively. + */ + async updateToolCall(params: UpdateToolCallParams): Promise<void> { + await this.#connection.sendRequest('updateToolCall', params); + } +} + +type AnyMessage = AnyRequest | AnyResponse; + +type AnyRequest = { + id: number; + method: string; + params?: unknown; +}; + +type AnyResponse = { jsonrpc: '2.0'; id: number } & Result<unknown>; + +type Result<T> = + | { + result: T; + } + | { + error: ErrorResponse; + }; + +type ErrorResponse = { + code: number; + message: string; + data?: { details?: string }; +}; + +type PendingResponse = { + resolve: (response: unknown) => void; + reject: (error: ErrorResponse) => void; +}; + +class Connection<D> { + #pendingResponses: Map<number, PendingResponse> = new Map(); + #nextRequestId: number = 0; + #delegate: D; + #peerInput: WritableStream<Uint8Array>; + #writeQueue: Promise<void> = Promise.resolve(); + #textEncoder: TextEncoder; + + constructor( + delegate: D, + peerInput: WritableStream<Uint8Array>, + peerOutput: ReadableStream<Uint8Array>, + ) { + this.#peerInput = peerInput; + this.#textEncoder = new TextEncoder(); + + this.#delegate = delegate; + this.#receive(peerOutput); + } + + async #receive(output: ReadableStream<Uint8Array>) { + let content = ''; + const decoder = new TextDecoder(); + for await (const chunk of output) { + content += decoder.decode(chunk, { stream: true }); + const lines = content.split('\n'); + content = lines.pop() || ''; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine) { + const message = JSON.parse(trimmedLine); + this.#processMessage(message); + } + } + } + } + + async #processMessage(message: AnyMessage) { + if ('method' in message) { + const response = await this.#tryCallDelegateMethod( + message.method, + message.params, + ); + + await this.#sendMessage({ + jsonrpc: '2.0', + id: message.id, + ...response, + }); + } else { + this.#handleResponse(message); + } + } + + async #tryCallDelegateMethod( + method: string, + params?: unknown, + ): Promise<Result<unknown>> { + const methodName = method as keyof D; + if (typeof this.#delegate[methodName] !== 'function') { + return RequestError.methodNotFound(method).toResult(); + } + + try { + const result = await this.#delegate[methodName](params); + return { result: result ?? null }; + } catch (error: unknown) { + if (error instanceof RequestError) { + return error.toResult(); + } + + let details; + + if (error instanceof Error) { + details = error.message; + } else if ( + typeof error === 'object' && + error != null && + 'message' in error && + typeof error.message === 'string' + ) { + details = error.message; + } + + return RequestError.internalError(details).toResult(); + } + } + + #handleResponse(response: AnyResponse) { + const pendingResponse = this.#pendingResponses.get(response.id); + if (pendingResponse) { + if ('result' in response) { + pendingResponse.resolve(response.result); + } else if ('error' in response) { + pendingResponse.reject(response.error); + } + this.#pendingResponses.delete(response.id); + } + } + + async sendRequest<Req, Resp>(method: string, params?: Req): Promise<Resp> { + const id = this.#nextRequestId++; + const responsePromise = new Promise((resolve, reject) => { + this.#pendingResponses.set(id, { resolve, reject }); + }); + await this.#sendMessage({ jsonrpc: '2.0', id, method, params }); + return responsePromise as Promise<Resp>; + } + + async #sendMessage(json: AnyMessage) { + const content = JSON.stringify(json) + '\n'; + this.#writeQueue = this.#writeQueue + .then(async () => { + const writer = this.#peerInput.getWriter(); + try { + await writer.write(this.#textEncoder.encode(content)); + } finally { + writer.releaseLock(); + } + }) + .catch((error) => { + // Continue processing writes on error + console.error('ACP write error:', error); + }); + return this.#writeQueue; + } +} + +export class RequestError extends Error { + data?: { details?: string }; + + constructor( + public code: number, + message: string, + details?: string, + ) { + super(message); + this.name = 'RequestError'; + if (details) { + this.data = { details }; + } + } + + static parseError(details?: string): RequestError { + return new RequestError(-32700, 'Parse error', details); + } + + static invalidRequest(details?: string): RequestError { + return new RequestError(-32600, 'Invalid request', details); + } + + static methodNotFound(details?: string): RequestError { + return new RequestError(-32601, 'Method not found', details); + } + + static invalidParams(details?: string): RequestError { + return new RequestError(-32602, 'Invalid params', details); + } + + static internalError(details?: string): RequestError { + return new RequestError(-32603, 'Internal error', details); + } + + toResult<T>(): Result<T> { + return { + error: { + code: this.code, + message: this.message, + data: this.data, + }, + }; + } +} + +// Protocol types + +export const LATEST_PROTOCOL_VERSION = '0.0.9'; + +export type AssistantMessageChunk = + | { + text: string; + } + | { + thought: string; + }; + +export type ToolCallConfirmation = + | { + description?: string | null; + type: 'edit'; + } + | { + description?: string | null; + type: 'execute'; + command: string; + rootCommand: string; + } + | { + description?: string | null; + type: 'mcp'; + serverName: string; + toolDisplayName: string; + toolName: string; + } + | { + description?: string | null; + type: 'fetch'; + urls: string[]; + } + | { + description: string; + type: 'other'; + }; + +export type ToolCallContent = + | { + type: 'markdown'; + markdown: string; + } + | { + type: 'diff'; + newText: string; + oldText: string | null; + path: string; + }; + +export type ToolCallStatus = 'running' | 'finished' | 'error'; + +export type ToolCallId = number; + +export type ToolCallConfirmationOutcome = + | 'allow' + | 'alwaysAllow' + | 'alwaysAllowMcpServer' + | 'alwaysAllowTool' + | 'reject' + | 'cancel'; + +/** + * A part in a user message + */ +export type UserMessageChunk = + | { + text: string; + } + | { + path: string; + }; + +export interface StreamAssistantMessageChunkParams { + chunk: AssistantMessageChunk; +} + +export interface RequestToolCallConfirmationParams { + confirmation: ToolCallConfirmation; + content?: ToolCallContent | null; + icon: Icon; + label: string; + locations?: ToolCallLocation[]; +} + +export interface ToolCallLocation { + line?: number | null; + path: string; +} + +export interface PushToolCallParams { + content?: ToolCallContent | null; + icon: Icon; + label: string; + locations?: ToolCallLocation[]; +} + +export interface UpdateToolCallParams { + content: ToolCallContent | null; + status: ToolCallStatus; + toolCallId: ToolCallId; +} + +export interface RequestToolCallConfirmationResponse { + id: ToolCallId; + outcome: ToolCallConfirmationOutcome; +} + +export interface PushToolCallResponse { + id: ToolCallId; +} + +export interface InitializeParams { + /** + * The version of the protocol that the client supports. + * This should be the latest version supported by the client. + */ + protocolVersion: string; +} + +export interface SendUserMessageParams { + chunks: UserMessageChunk[]; +} + +export interface InitializeResponse { + /** + * Indicates whether the agent is authenticated and + * ready to handle requests. + */ + isAuthenticated: boolean; + /** + * The version of the protocol that the agent supports. + * If the agent supports the requested version, it should respond with the same version. + * Otherwise, the agent should respond with the latest version it supports. + */ + protocolVersion: string; +} + +export interface Error { + code: number; + data?: unknown; + message: string; +} + +export interface Client { + streamAssistantMessageChunk( + params: StreamAssistantMessageChunkParams, + ): Promise<void>; + + requestToolCallConfirmation( + params: RequestToolCallConfirmationParams, + ): Promise<RequestToolCallConfirmationResponse>; + + pushToolCall(params: PushToolCallParams): Promise<PushToolCallResponse>; + + updateToolCall(params: UpdateToolCallParams): Promise<void>; +} + +export interface Agent { + /** + * Initializes the agent's state. It should be called before any other method, + * and no other methods should be called until it has completed. + * + * If the agent is not authenticated, then the client should prompt the user to authenticate, + * and then call the `authenticate` method. + * Otherwise the client can send other messages to the agent. + */ + initialize(params: InitializeParams): Promise<InitializeResponse>; + + /** + * Begins the authentication process. + * + * This method should only be called if `initialize` indicates the user isn't already authenticated. + * The Promise MUST not resolve until authentication is complete. + */ + authenticate(): Promise<void>; + + /** + * Allows the user to send a message to the agent. + * This method should complete after the agent is finished, during + * which time the agent may update the client by calling + * streamAssistantMessageChunk and other methods. + */ + sendUserMessage(params: SendUserMessageParams): Promise<void>; + + /** + * Cancels the current generation. + */ + cancelSendMessage(): Promise<void>; +} |
