feat: add assistant proposal worker runtime

This commit is contained in:
zenord
2026-08-18 18:48:47 +08:00
parent 947f19e3cd
commit b4121c9fd6
32 changed files with 2827 additions and 986 deletions
+66 -13
View File
@@ -4,10 +4,14 @@ import * as acp from "@agentclientprotocol/sdk";
import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } from "@agentclientprotocol/sdk";
import type { PermissionPolicy } from "../config.js";
export type SafeActivityCategory = "read" | "write" | "execute" | "search" | "delegate" | "other";
export interface AcpClientOptions {
initializeTimeoutMs: number;
policy: PermissionPolicy;
onSessionActivity?: () => void;
onSessionActivity?: (category?: SafeActivityCategory) => void;
forbidToolActivity?: boolean;
onToolViolation?: () => void;
}
export class AcpClient {
@@ -15,11 +19,16 @@ export class AcpClient {
private capabilities: AgentCapabilities = {};
private activeSessionId?: string;
private collecting = false;
private toolViolation = false;
private violationReject?: (error: Error) => void;
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))
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
if (options.forbidToolActivity) this.recordToolViolation();
return 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>,
@@ -40,8 +49,13 @@ export class AcpClient {
}
async newSession(cwd: string): Promise<string> {
const response = await this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] });
const response = await withTimeout(
this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] }),
this.options.initializeTimeoutMs,
"ACP session/new timed out"
);
this.activeSessionId = response.sessionId;
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
return response.sessionId;
}
@@ -49,16 +63,21 @@ export class AcpClient {
this.collecting = false;
this.chunks = [];
this.activeSessionId = sessionId;
const request = <T>(promise: Promise<T>, operation: string): Promise<T> => withTimeout(
promise,
this.options.initializeTimeoutMs,
`ACP session/${operation} timed out`
);
try {
if (this.capabilities.sessionCapabilities?.resume) {
try {
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] });
await request(this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] }), "resume");
} catch (error) {
if (!this.capabilities.loadSession) throw error;
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
await request(this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), "load");
}
} else if (this.capabilities.loadSession) {
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
await request(this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), "load");
} else {
throw new Error("ACP backend cannot resume or load sessions");
}
@@ -71,19 +90,32 @@ export class AcpClient {
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> {
if (!this.activeSessionId) throw new Error("ACP session is not active");
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
this.chunks = [];
this.collecting = true;
const violation = new Promise<never>((_resolve, reject) => { this.violationReject = reject; });
try {
await this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId,
prompt: [{ type: "text", text }]
}, cancellationSignal ? { cancellationSignal } : undefined);
await Promise.race([
this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId,
prompt: [{ type: "text", text }]
}, cancellationSignal ? { cancellationSignal } : undefined),
violation
]);
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
return this.chunks.join("").trim();
} finally {
this.violationReject = undefined;
this.collecting = false;
}
}
async settleIsolation(): Promise<void> {
if (!this.options.forbidToolActivity) return;
await new Promise((resolve) => setTimeout(resolve, 50));
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
}
async cancel(): Promise<void> {
if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId });
}
@@ -97,12 +129,33 @@ export class AcpClient {
close(error?: unknown): void { this.connection.close(error); }
private handleUpdate(notification: SessionNotification): void {
if (notification.sessionId !== this.activeSessionId) return;
this.options.onSessionActivity?.();
if (!this.collecting) return;
if (this.activeSessionId && notification.sessionId !== this.activeSessionId) return;
const update = notification.update;
const category = activityCategory(update);
if (category && this.options.forbidToolActivity) this.recordToolViolation();
this.options.onSessionActivity?.(category);
if (!this.collecting || notification.sessionId !== this.activeSessionId) return;
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text);
}
private recordToolViolation(): void {
if (this.toolViolation) return;
this.toolViolation = true;
this.violationReject?.(new Error("Assistant session attempted forbidden tool activity"));
this.options.onToolViolation?.();
}
}
function activityCategory(update: SessionNotification["update"]): SafeActivityCategory | undefined {
if (!String(update.sessionUpdate).startsWith("tool_call")) return undefined;
const record = update as unknown as Record<string, unknown>;
const raw = JSON.stringify({ kind: record.kind, name: record.name, title: record.title }).toLowerCase();
if (/read|view|fetch/.test(raw)) return "read";
if (/grep|glob|search|find/.test(raw)) return "search";
if (/write|edit|patch/.test(raw)) return "write";
if (/bash|terminal|execute|command/.test(raw)) return "execute";
if (/agent|delegate|subagent/.test(raw)) return "delegate";
return "other";
}
export function decidePermission(request: RequestPermissionRequest, policy: PermissionPolicy): RequestPermissionResponse {