feat: add assistant proposal worker runtime
This commit is contained in:
+66
-13
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user