Add ACP-backed role sessions
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import * as acp from "@agentclientprotocol/sdk";
|
||||
import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { RolePolicy } from "../config.js";
|
||||
|
||||
export interface AcpClientOptions {
|
||||
initializeTimeoutMs: number;
|
||||
policy: RolePolicy;
|
||||
}
|
||||
|
||||
export class AcpClient {
|
||||
private readonly connection: acp.ClientConnection;
|
||||
private capabilities: AgentCapabilities = {};
|
||||
private activeSessionId?: string;
|
||||
private collecting = false;
|
||||
private chunks: string[] = [];
|
||||
|
||||
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpClientOptions) {
|
||||
const app = acp.client({ name: "gori-agent" })
|
||||
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => decidePermission(params, options.policy))
|
||||
.onNotification(acp.methods.client.session.update, ({ params }) => this.handleUpdate(params));
|
||||
const stream = acp.ndJsonStream(
|
||||
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
|
||||
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>
|
||||
);
|
||||
this.connection = app.connect(stream);
|
||||
}
|
||||
|
||||
async initialize(): Promise<InitializeResponse> {
|
||||
const response = await withTimeout(this.connection.agent.request(acp.methods.agent.initialize, {
|
||||
protocolVersion: acp.PROTOCOL_VERSION,
|
||||
clientCapabilities: {},
|
||||
clientInfo: { name: "gori-agent", version: "0.1.0" }
|
||||
}), this.options.initializeTimeoutMs, "ACP initialize timed out");
|
||||
if (response.protocolVersion !== acp.PROTOCOL_VERSION) throw new Error(`Unsupported ACP protocol version ${response.protocolVersion}`);
|
||||
this.capabilities = response.agentCapabilities || {};
|
||||
return response;
|
||||
}
|
||||
|
||||
async newSession(cwd: string): Promise<string> {
|
||||
const response = await this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] });
|
||||
this.activeSessionId = response.sessionId;
|
||||
return response.sessionId;
|
||||
}
|
||||
|
||||
async resumeSession(sessionId: string, cwd: string): Promise<void> {
|
||||
this.collecting = false;
|
||||
this.chunks = [];
|
||||
if (this.capabilities.sessionCapabilities?.resume) {
|
||||
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] });
|
||||
} else if (this.capabilities.loadSession) {
|
||||
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
|
||||
} else {
|
||||
throw new Error("ACP backend cannot resume or load sessions");
|
||||
}
|
||||
this.activeSessionId = sessionId;
|
||||
this.chunks = [];
|
||||
}
|
||||
|
||||
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> {
|
||||
if (!this.activeSessionId) throw new Error("ACP session is not active");
|
||||
this.chunks = [];
|
||||
this.collecting = true;
|
||||
try {
|
||||
await this.connection.agent.request(acp.methods.agent.session.prompt, {
|
||||
sessionId: this.activeSessionId,
|
||||
prompt: [{ type: "text", text }]
|
||||
}, cancellationSignal ? { cancellationSignal } : undefined);
|
||||
return this.chunks.join("").trim();
|
||||
} finally {
|
||||
this.collecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async cancel(): Promise<void> {
|
||||
if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId });
|
||||
}
|
||||
|
||||
async closeSession(): Promise<void> {
|
||||
if (this.activeSessionId && this.capabilities.sessionCapabilities?.close) {
|
||||
await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.activeSessionId }).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
close(error?: unknown): void { this.connection.close(error); }
|
||||
|
||||
private handleUpdate(notification: SessionNotification): void {
|
||||
if (!this.collecting || notification.sessionId !== this.activeSessionId) return;
|
||||
const update = notification.update;
|
||||
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text);
|
||||
}
|
||||
}
|
||||
|
||||
export function decidePermission(request: RequestPermissionRequest, policy: RolePolicy): RequestPermissionResponse {
|
||||
if (policy.permissionMode === "deny") return reject(request);
|
||||
const allowOption = request.options.find((option) => option.kind === "allow_once") || request.options.find((option) => option.kind === "allow_always");
|
||||
if (!allowOption) return reject(request);
|
||||
if (policy.permissionMode === "auto") return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
|
||||
|
||||
const name = String(request.toolCall.name || request.toolCall.kind || "").toLowerCase();
|
||||
const title = String(request.toolCall.title || "").toLowerCase();
|
||||
const allowedTool = policy.allowedTools.some((tool) => name === tool.toLowerCase() || title.startsWith(tool.toLowerCase()));
|
||||
if (!allowedTool) return reject(request);
|
||||
if (name === "bash" || name === "terminal" || title.startsWith("bash") || title.startsWith("terminal")) {
|
||||
if (request.toolCall.rawInput === undefined || policy.allowedCommandPatterns.length === 0) return reject(request);
|
||||
const input = typeof request.toolCall.rawInput === "string" ? request.toolCall.rawInput : JSON.stringify(request.toolCall.rawInput);
|
||||
if (!policy.allowedCommandPatterns.some((pattern) => new RegExp(pattern).test(input))) return reject(request);
|
||||
}
|
||||
return { outcome: { outcome: "selected", optionId: allowOption.optionId } };
|
||||
}
|
||||
|
||||
function reject(request: RequestPermissionRequest): RequestPermissionResponse {
|
||||
const option = request.options.find((item) => item.kind === "reject_once") || request.options.find((item) => item.kind === "reject_always");
|
||||
return option ? { outcome: { outcome: "selected", optionId: option.optionId } } : { outcome: { outcome: "cancelled" } };
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
try {
|
||||
return await Promise.race([promise, new Promise<T>((_resolve, reject) => { timer = setTimeout(() => reject(new Error(message)), timeoutMs); })]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user