@zvada/agent-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +71 -0
- package/package.json +87 -0
- package/src/client/client.ts +589 -0
- package/src/client/index.ts +18 -0
- package/src/client/transports.ts +84 -0
- package/src/core/agents/acp/acp-agent.ts +322 -0
- package/src/core/agents/acp/adapter.ts +260 -0
- package/src/core/agents/acp/client.ts +212 -0
- package/src/core/agents/acp/known-agents.ts +29 -0
- package/src/core/agents/acp/mappings.ts +136 -0
- package/src/core/agents/base.ts +145 -0
- package/src/core/agents/claude-code/adapter.ts +451 -0
- package/src/core/agents/claude-code/claude-agent.ts +235 -0
- package/src/core/agents/claude-code/generator-session.ts +344 -0
- package/src/core/agents/claude-code/options.ts +161 -0
- package/src/core/agents/claude-code/session-manager.ts +159 -0
- package/src/core/agents/codex-app-server/adapter.ts +214 -0
- package/src/core/agents/codex-app-server/client.ts +221 -0
- package/src/core/agents/codex-app-server/codex-app-server-agent.ts +385 -0
- package/src/core/agents/codex-items.ts +122 -0
- package/src/core/agents/codex-sdk/adapter.ts +204 -0
- package/src/core/agents/codex-sdk/codex-sdk-agent.ts +236 -0
- package/src/core/agents/config-fingerprint.ts +19 -0
- package/src/core/agents/error-classifier.ts +68 -0
- package/src/core/agents/registry.ts +40 -0
- package/src/core/agents/session-store.ts +72 -0
- package/src/core/agents/tool-meta.ts +68 -0
- package/src/core/agents/types.ts +54 -0
- package/src/core/index.ts +114 -0
- package/src/core/presets.ts +78 -0
- package/src/core/provision/extract.ts +31 -0
- package/src/core/provision/index.ts +10 -0
- package/src/core/provision/npm.ts +114 -0
- package/src/core/provision/pins.ts +51 -0
- package/src/core/provision/platform.ts +73 -0
- package/src/core/provision/provisioner.ts +478 -0
- package/src/core/proxy/anthropic-proxy.ts +69 -0
- package/src/core/proxy/api-key-store.ts +34 -0
- package/src/core/proxy/index.ts +7 -0
- package/src/core/runtime/agent-runtime.ts +363 -0
- package/src/core/runtime/event-processor.ts +218 -0
- package/src/core/runtime/event-sink.ts +37 -0
- package/src/core/utils/errors.ts +41 -0
- package/src/index.ts +4 -0
- package/src/protocol/async-queue.ts +68 -0
- package/src/protocol/config.ts +100 -0
- package/src/protocol/factories.ts +125 -0
- package/src/protocol/harness.ts +50 -0
- package/src/protocol/ids.ts +53 -0
- package/src/protocol/index.ts +16 -0
- package/src/protocol/lifecycle.ts +309 -0
- package/src/protocol/models.ts +45 -0
- package/src/protocol/part-input.ts +58 -0
- package/src/protocol/parts.ts +60 -0
- package/src/protocol/thinking.ts +32 -0
- package/src/protocol/tokens.ts +39 -0
- package/src/protocol/tool-state.ts +89 -0
- package/src/protocol/wire.ts +313 -0
- package/src/server/acp/binding.ts +163 -0
- package/src/server/acp/translate.ts +160 -0
- package/src/server/agent-server.ts +357 -0
- package/src/server/bin.ts +174 -0
- package/src/server/index.ts +24 -0
- package/src/server/install.ts +51 -0
- package/src/server/session-log.ts +66 -0
- package/src/server/transports.ts +149 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { type ChildProcess, spawn } from "node:child_process";
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import type {
|
|
4
|
+
AgentNotificationMethod,
|
|
5
|
+
AgentNotificationParamsByMethod,
|
|
6
|
+
AgentRequestMethod,
|
|
7
|
+
AgentRequestParamsByMethod,
|
|
8
|
+
AgentRequestResponsesByMethod,
|
|
9
|
+
ClientConnection,
|
|
10
|
+
RequestPermissionRequest,
|
|
11
|
+
RequestPermissionResponse,
|
|
12
|
+
SessionNotification,
|
|
13
|
+
} from "@agentclientprotocol/sdk";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* How to start one ACP agent subprocess. Operator-level only — the wire never
|
|
17
|
+
* carries commands (same rule as `resolveCliPath`): an attacker who controls
|
|
18
|
+
* the launch command controls code execution on the host.
|
|
19
|
+
*/
|
|
20
|
+
export interface AcpLaunch {
|
|
21
|
+
command: string;
|
|
22
|
+
args?: string[];
|
|
23
|
+
/** Extra env layered onto the subprocess (over process.env). */
|
|
24
|
+
env?: Record<string, string>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Parse an operator-supplied launch spec (`"npx -y pi-acp"`) into an AcpLaunch.
|
|
29
|
+
* Naive whitespace split — no shell quoting; env vars come from the caller.
|
|
30
|
+
* The canonical parser for `--acp-agent` flags / `$AGENT_SERVER_ACP_AGENT`.
|
|
31
|
+
*/
|
|
32
|
+
export function parseAcpLaunch(spec: string | undefined): AcpLaunch | undefined {
|
|
33
|
+
const [command, ...args] = spec?.trim().split(/\s+/).filter(Boolean) ?? [];
|
|
34
|
+
if (!command) return undefined;
|
|
35
|
+
return { command, ...(args.length && { args }) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type SessionUpdateHandler = (notification: SessionNotification) => void;
|
|
39
|
+
|
|
40
|
+
/** Answers agent→client `session/request_permission`. Absent handler → cancelled. */
|
|
41
|
+
export type AcpPermissionHandler = (
|
|
42
|
+
request: RequestPermissionRequest,
|
|
43
|
+
) => Promise<RequestPermissionResponse>;
|
|
44
|
+
|
|
45
|
+
export interface AcpClientOptions {
|
|
46
|
+
launch: AcpLaunch;
|
|
47
|
+
cwd: string;
|
|
48
|
+
/** Per-session env on top of the launch env (RunConfig.env). */
|
|
49
|
+
env?: Record<string, string>;
|
|
50
|
+
clientName?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One ACP agent subprocess + its SDK connection. The engine talks to any
|
|
55
|
+
* ACP v1 agent through this: JSON-RPC framing, routing, and schema types come
|
|
56
|
+
* from `@agentclientprotocol/sdk` (dynamic-imported, optional peer dep);
|
|
57
|
+
* this wrapper owns the child process, close fan-out, and the mutable
|
|
58
|
+
* per-turn handlers (the SDK app's handlers are fixed at connect time, so
|
|
59
|
+
* they dispatch through this class's current fields — mirroring the
|
|
60
|
+
* codex-app-server client's `setRequestHandler` pattern).
|
|
61
|
+
*/
|
|
62
|
+
export class AcpClient {
|
|
63
|
+
private proc?: ChildProcess;
|
|
64
|
+
private connection?: ClientConnection;
|
|
65
|
+
private readonly updateHandlers = new Set<SessionUpdateHandler>();
|
|
66
|
+
private readonly closeHandlers = new Set<(error?: Error) => void>();
|
|
67
|
+
private permissionHandler?: AcpPermissionHandler;
|
|
68
|
+
private exited = false;
|
|
69
|
+
|
|
70
|
+
private constructor(private readonly opts: AcpClientOptions) {}
|
|
71
|
+
|
|
72
|
+
/** Spawn the agent and connect. Fails if `@agentclientprotocol/sdk` is absent. */
|
|
73
|
+
static async create(opts: AcpClientOptions): Promise<AcpClient> {
|
|
74
|
+
const client = new AcpClient(opts);
|
|
75
|
+
await client.start();
|
|
76
|
+
return client;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
onSessionUpdate(handler: SessionUpdateHandler): () => void {
|
|
80
|
+
this.updateHandlers.add(handler);
|
|
81
|
+
return () => this.updateHandlers.delete(handler);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Install the answerer for permission round-trips (set once per session;
|
|
85
|
+
* per-turn context lives inside the handler's own closure). */
|
|
86
|
+
setPermissionHandler(handler: AcpPermissionHandler | undefined): void {
|
|
87
|
+
this.permissionHandler = handler;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
onClose(handler: (error?: Error) => void): () => void {
|
|
91
|
+
this.closeHandlers.add(handler);
|
|
92
|
+
return () => this.closeHandlers.delete(handler);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
get closed(): boolean {
|
|
96
|
+
return this.exited;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Send a request to the agent (`initialize`, `session/new`, `session/prompt`, …).
|
|
101
|
+
* `timeoutMs` bounds the wait for handshake-style requests — a spawned-but-wedged
|
|
102
|
+
* agent must not stall the turn forever. The SDK's `cancellationSignal` is
|
|
103
|
+
* cooperative (the peer still decides when to respond), so the local race is
|
|
104
|
+
* what actually enforces the deadline. Turn-long requests (`session/prompt`)
|
|
105
|
+
* pass no timeout.
|
|
106
|
+
*/
|
|
107
|
+
async request<M extends AgentRequestMethod>(
|
|
108
|
+
method: M,
|
|
109
|
+
params: AgentRequestParamsByMethod[M],
|
|
110
|
+
timeoutMs?: number,
|
|
111
|
+
): Promise<AgentRequestResponsesByMethod[M]> {
|
|
112
|
+
if (!this.connection || this.exited) throw new Error("acp agent not running");
|
|
113
|
+
const agent = this.connection.agent;
|
|
114
|
+
if (!timeoutMs) return agent.request(method, params);
|
|
115
|
+
const abort = new AbortController();
|
|
116
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
117
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
118
|
+
timer = setTimeout(() => {
|
|
119
|
+
abort.abort();
|
|
120
|
+
reject(new Error(`acp agent '${method}' timed out after ${timeoutMs}ms`));
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
timer.unref?.();
|
|
123
|
+
});
|
|
124
|
+
try {
|
|
125
|
+
return await Promise.race([
|
|
126
|
+
agent.request(method, params, { cancellationSignal: abort.signal }),
|
|
127
|
+
deadline,
|
|
128
|
+
]);
|
|
129
|
+
} finally {
|
|
130
|
+
if (timer) clearTimeout(timer);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Send a notification to the agent (`session/cancel`). */
|
|
135
|
+
async notify<M extends AgentNotificationMethod>(
|
|
136
|
+
method: M,
|
|
137
|
+
params: AgentNotificationParamsByMethod[M],
|
|
138
|
+
): Promise<void> {
|
|
139
|
+
if (!this.connection || this.exited) return;
|
|
140
|
+
await this.connection.agent.notify(method, params);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
close(): void {
|
|
144
|
+
const proc = this.proc;
|
|
145
|
+
const wasOpen = !this.exited;
|
|
146
|
+
this.exited = true;
|
|
147
|
+
this.proc = undefined;
|
|
148
|
+
this.connection?.close();
|
|
149
|
+
this.connection = undefined;
|
|
150
|
+
if (proc && !proc.killed) proc.kill("SIGTERM");
|
|
151
|
+
if (wasOpen) this.notifyClosed();
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async start(): Promise<void> {
|
|
155
|
+
const acp = await import("@agentclientprotocol/sdk");
|
|
156
|
+
const { launch } = this.opts;
|
|
157
|
+
const proc = spawn(launch.command, launch.args ?? [], {
|
|
158
|
+
cwd: this.opts.cwd,
|
|
159
|
+
env: { ...process.env, ...launch.env, ...this.opts.env },
|
|
160
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
161
|
+
});
|
|
162
|
+
this.proc = proc;
|
|
163
|
+
if (!proc.stdin || !proc.stdout) {
|
|
164
|
+
proc.kill("SIGTERM");
|
|
165
|
+
throw new Error("acp agent subprocess has no stdio");
|
|
166
|
+
}
|
|
167
|
+
// Drain stderr so the agent can't block on a full pipe; its logs are not ours.
|
|
168
|
+
proc.stderr?.on("data", () => {});
|
|
169
|
+
proc.on("error", (err) => this.onExit(err));
|
|
170
|
+
proc.on("exit", (code, signal) =>
|
|
171
|
+
this.onExit(new Error(`acp agent exited (code=${code} signal=${signal})`)),
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
const stream = acp.ndJsonStream(
|
|
175
|
+
Writable.toWeb(proc.stdin),
|
|
176
|
+
// Kept cast: embedders compile these sources under their own tsconfig,
|
|
177
|
+
// and older lib.dom types Readable.toWeb as ReadableStream<any>.
|
|
178
|
+
Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>,
|
|
179
|
+
);
|
|
180
|
+
this.connection = acp
|
|
181
|
+
.client({ name: this.opts.clientName ?? "agent-server" })
|
|
182
|
+
.onRequest("session/request_permission", async (ctx): Promise<RequestPermissionResponse> => {
|
|
183
|
+
const handler = this.permissionHandler;
|
|
184
|
+
if (!handler) return { outcome: { outcome: "cancelled" } };
|
|
185
|
+
return handler(ctx.params);
|
|
186
|
+
})
|
|
187
|
+
.onNotification("session/update", (ctx) => {
|
|
188
|
+
for (const handler of this.updateHandlers) handler(ctx.params);
|
|
189
|
+
})
|
|
190
|
+
.connect(stream);
|
|
191
|
+
// A closed connection without a process exit (stream error) still ends the
|
|
192
|
+
// client; a rejected `closed` must not become an unhandled rejection.
|
|
193
|
+
this.connection.closed.then(
|
|
194
|
+
() => this.onExit(new Error("acp connection closed")),
|
|
195
|
+
(err: unknown) =>
|
|
196
|
+
this.onExit(err instanceof Error ? err : new Error(`acp connection failed: ${err}`)),
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private onExit(error: Error): void {
|
|
201
|
+
if (this.exited) return;
|
|
202
|
+
this.exited = true;
|
|
203
|
+
this.connection?.close(error);
|
|
204
|
+
this.connection = undefined;
|
|
205
|
+
this.notifyClosed(error);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private notifyClosed(error?: Error): void {
|
|
209
|
+
for (const handler of this.closeHandlers) handler(error);
|
|
210
|
+
this.closeHandlers.clear();
|
|
211
|
+
}
|
|
212
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type AcpLaunch, parseAcpLaunch } from "./client.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Advisory catalog of well-known ACP agents — UX sugar, not wire vocabulary
|
|
5
|
+
* (like MODEL_CATALOG). On the wire the harness is always `acp`; these names
|
|
6
|
+
* let entry points offer "pi" or "gemini" as first-class choices and expand
|
|
7
|
+
* them to launch commands at the operator edge. Anything not listed here runs
|
|
8
|
+
* via an explicit launch command — any ACP-compatible agent works.
|
|
9
|
+
*/
|
|
10
|
+
export const KNOWN_ACP_AGENTS: Record<string, { label: string; launch: AcpLaunch }> = {
|
|
11
|
+
pi: {
|
|
12
|
+
label: "pi.dev (Pi coding agent, via the pi-acp adapter)",
|
|
13
|
+
launch: { command: "npx", args: ["-y", "pi-acp"] },
|
|
14
|
+
},
|
|
15
|
+
gemini: {
|
|
16
|
+
label: "Gemini CLI (native ACP mode)",
|
|
17
|
+
launch: { command: "gemini", args: ["--acp"] },
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve an operator spec that is either a known agent name (`"pi"`) or a
|
|
23
|
+
* raw launch command (`"npx -y pi-acp"`). The canonical resolver behind
|
|
24
|
+
* `--acp-agent` and `$AGENT_SERVER_ACP_AGENT`.
|
|
25
|
+
*/
|
|
26
|
+
export function resolveAcpLaunch(spec: string | undefined): AcpLaunch | undefined {
|
|
27
|
+
if (!spec) return undefined;
|
|
28
|
+
return KNOWN_ACP_AGENTS[spec.trim()]?.launch ?? parseAcpLaunch(spec);
|
|
29
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContentBlock,
|
|
3
|
+
McpServer,
|
|
4
|
+
PermissionOption,
|
|
5
|
+
RequestPermissionRequest,
|
|
6
|
+
RequestPermissionResponse,
|
|
7
|
+
ToolCallLocation,
|
|
8
|
+
} from "@agentclientprotocol/sdk";
|
|
9
|
+
import type {
|
|
10
|
+
AgentInput,
|
|
11
|
+
McpServerConfig,
|
|
12
|
+
PermissionMode,
|
|
13
|
+
PermissionToolCall,
|
|
14
|
+
ToolKind,
|
|
15
|
+
ToolLocation,
|
|
16
|
+
} from "../../../protocol/index.ts";
|
|
17
|
+
import { TOOL_KINDS } from "../../../protocol/index.ts";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The single bridge between ACP's vocabulary and the engine's. Everything here
|
|
21
|
+
* is a pure conversion shared by the agent (outbound requests, permission
|
|
22
|
+
* round-trips) and the adapter (inbound session updates) — the harness
|
|
23
|
+
* seat's single bridge, so its two halves cannot drift. (The ACP *server*
|
|
24
|
+
* binding in packages/server/src/acp/ is the inverse direction, not a copy.)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** ACP's ToolKind strings are ours verbatim (DESIGN D2); narrow with a membership check. */
|
|
28
|
+
export function toolKindOf(kind: string | null | undefined): ToolKind | undefined {
|
|
29
|
+
return kind && (TOOL_KINDS as readonly string[]).includes(kind) ? (kind as ToolKind) : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** ACP `ToolCallLocation[]` → engine `ToolLocation[]` (drop null lines, empty → absent). */
|
|
33
|
+
export function toToolLocations(
|
|
34
|
+
locations: ToolCallLocation[] | null | undefined,
|
|
35
|
+
): ToolLocation[] | undefined {
|
|
36
|
+
if (!locations?.length) return undefined;
|
|
37
|
+
return locations.map((l) => ({ path: l.path, ...(l.line != null && { line: l.line }) }));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Engine MCP config map → ACP `McpServer[]` (names come from the record keys). */
|
|
41
|
+
export function toAcpMcpServers(
|
|
42
|
+
mcpServers: Record<string, McpServerConfig> | undefined,
|
|
43
|
+
): McpServer[] {
|
|
44
|
+
if (!mcpServers) return [];
|
|
45
|
+
return Object.entries(mcpServers).map(([name, config]): McpServer => {
|
|
46
|
+
if (config.type === "stdio") {
|
|
47
|
+
return {
|
|
48
|
+
name,
|
|
49
|
+
command: config.command,
|
|
50
|
+
args: config.args ?? [],
|
|
51
|
+
env: Object.entries(config.env ?? {}).map(([n, value]) => ({ name: n, value })),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
type: config.type,
|
|
56
|
+
name,
|
|
57
|
+
url: config.url,
|
|
58
|
+
headers: Object.entries(config.headers ?? {}).map(([n, value]) => ({ name: n, value })),
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Engine turn input → ACP prompt content blocks (image blocks gated on agent capability). */
|
|
64
|
+
export function toPromptBlocks(input: AgentInput, imagesSupported: boolean): ContentBlock[] {
|
|
65
|
+
if (typeof input === "string") return [{ type: "text", text: input }];
|
|
66
|
+
const blocks: ContentBlock[] = [];
|
|
67
|
+
for (const part of input) {
|
|
68
|
+
if (part.type === "text") {
|
|
69
|
+
blocks.push({ type: "text", text: part.text });
|
|
70
|
+
} else if (part.type === "image" && imagesSupported) {
|
|
71
|
+
if (part.data) {
|
|
72
|
+
blocks.push({ type: "image", data: part.data, mimeType: part.mediaType });
|
|
73
|
+
} else if (part.url) {
|
|
74
|
+
blocks.push({ type: "resource_link", uri: part.url, name: part.url });
|
|
75
|
+
}
|
|
76
|
+
} else if (part.type === "file" && part.url) {
|
|
77
|
+
blocks.push({ type: "resource_link", uri: part.url, name: part.url });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return blocks.length ? blocks : [{ type: "text", text: "" }];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** ACP permission request → the engine's PermissionToolCall (SDK-validated, no casts). */
|
|
84
|
+
export function permissionToolCall(request: RequestPermissionRequest): PermissionToolCall {
|
|
85
|
+
const toolCall = request.toolCall;
|
|
86
|
+
const kind = toolKindOf(toolCall.kind);
|
|
87
|
+
const locations = toToolLocations(toolCall.locations);
|
|
88
|
+
return {
|
|
89
|
+
...(toolCall.toolCallId && { toolCallId: toolCall.toolCallId }),
|
|
90
|
+
toolName: toolCall.name ?? toolCall.title ?? toolCall.kind ?? "tool",
|
|
91
|
+
...(kind && { kind }),
|
|
92
|
+
...(toolCall.title && { title: toolCall.title }),
|
|
93
|
+
...(locations && { locations }),
|
|
94
|
+
...(isRecord(toolCall.rawInput) && { rawInput: toolCall.rawInput }),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export const CANCELLED_OUTCOME: RequestPermissionResponse = {
|
|
99
|
+
outcome: { outcome: "cancelled" },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Pick the option matching the decision: allow → first allow_once/allow_always,
|
|
104
|
+
* deny → first reject_once/reject_always; no matching option → cancelled.
|
|
105
|
+
*/
|
|
106
|
+
export function chooseOutcome(
|
|
107
|
+
options: PermissionOption[],
|
|
108
|
+
allow: boolean,
|
|
109
|
+
): RequestPermissionResponse {
|
|
110
|
+
const kinds = allow ? ["allow_once", "allow_always"] : ["reject_once", "reject_always"];
|
|
111
|
+
for (const kind of kinds) {
|
|
112
|
+
const match = options.find((o) => o.kind === kind);
|
|
113
|
+
if (match) return { outcome: { outcome: "selected", optionId: match.optionId } };
|
|
114
|
+
}
|
|
115
|
+
return CANCELLED_OUTCOME;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Decide an ACP permission request without the engine broker: mirrors the
|
|
120
|
+
* codex posture where only `default` mode asks — every other mode auto-decides.
|
|
121
|
+
*/
|
|
122
|
+
export function autoDecide(
|
|
123
|
+
request: RequestPermissionRequest,
|
|
124
|
+
mode: PermissionMode | undefined,
|
|
125
|
+
): RequestPermissionResponse {
|
|
126
|
+
const kind = request.toolCall.kind;
|
|
127
|
+
const allowed =
|
|
128
|
+
mode === "bypassPermissions" ||
|
|
129
|
+
(mode === "acceptEdits" && (kind === "edit" || kind === "read"));
|
|
130
|
+
return chooseOutcome(request.options, allowed);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The SDK types `rawInput`/`rawOutput` as `unknown`; the engine wants a record. */
|
|
134
|
+
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
135
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
136
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AgentCapabilities,
|
|
3
|
+
AgentHarness,
|
|
4
|
+
AgentInput,
|
|
5
|
+
McpServerConfig,
|
|
6
|
+
PermissionMode,
|
|
7
|
+
PermissionToolCall,
|
|
8
|
+
ThinkingLevel,
|
|
9
|
+
} from "../../protocol/index.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What the engine tells a harness after brokering a permission round-trip.
|
|
13
|
+
* The harness maps this onto its native approval vocabulary (Claude
|
|
14
|
+
* `allow`/`deny`, codex `accept`/`decline`/`cancel`).
|
|
15
|
+
*/
|
|
16
|
+
export type PermissionDecision =
|
|
17
|
+
| { decision: "allow" }
|
|
18
|
+
| { decision: "deny"; reason?: string }
|
|
19
|
+
/** The turn is being cancelled — abort rather than merely skip the tool. */
|
|
20
|
+
| { decision: "cancel" };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Provided by the engine; called by a harness when its backend blocks on an
|
|
24
|
+
* approval. The engine emits `permission.requested`, waits for
|
|
25
|
+
* `respondPermission` (or cancellation), and returns the decision. `signal`
|
|
26
|
+
* lets the harness abort the wait when its own backend gives up.
|
|
27
|
+
*/
|
|
28
|
+
export type PermissionRequestHandler = (
|
|
29
|
+
toolCall: PermissionToolCall,
|
|
30
|
+
opts?: { signal?: AbortSignal },
|
|
31
|
+
) => Promise<PermissionDecision>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Everything a harness needs to run one turn. The engine builds this from a
|
|
35
|
+
* RunRequest. `sessionId` is our stable logical id (the session-manager key);
|
|
36
|
+
* `resumeSessionId` is the harness-native id to continue (from a prior
|
|
37
|
+
* `session.created`). A harness reports its discovered native id via
|
|
38
|
+
* `onNativeSession` so the engine can surface it for future resumes.
|
|
39
|
+
*/
|
|
40
|
+
export interface AgentExecuteOptions {
|
|
41
|
+
sessionId: string;
|
|
42
|
+
turnId: string;
|
|
43
|
+
cwd: string;
|
|
44
|
+
additionalDirectories?: string[];
|
|
45
|
+
model?: string;
|
|
46
|
+
thinkingLevel?: ThinkingLevel;
|
|
47
|
+
permissionMode?: PermissionMode;
|
|
48
|
+
maxTurns?: number;
|
|
49
|
+
systemPromptAppend?: string;
|
|
50
|
+
resumeSessionId?: string;
|
|
51
|
+
resumeSessionAt?: string;
|
|
52
|
+
mcpServers?: Record<string, McpServerConfig>;
|
|
53
|
+
env?: Record<string, string>;
|
|
54
|
+
apiKey?: string;
|
|
55
|
+
/** Disable all built-in tools for this turn (text-only). */
|
|
56
|
+
disableTools?: boolean;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
/**
|
|
59
|
+
* Called once with the harness-native session/thread id (for resume).
|
|
60
|
+
* Pass the harness's honest `resumed` judgment unconditionally — the
|
|
61
|
+
* runtime surfaces it only on turns that requested a resume (false =
|
|
62
|
+
* fresh-session fallback).
|
|
63
|
+
*/
|
|
64
|
+
onNativeSession?: (nativeSessionId: string, info?: { resumed?: boolean }) => void;
|
|
65
|
+
/** Engine-brokered approval round-trip (see PermissionRequestHandler). */
|
|
66
|
+
onPermissionRequest?: PermissionRequestHandler;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Raw, harness-specific event. Adapters interpret it; the engine never does. */
|
|
70
|
+
export type RawAgentEvent = unknown;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A harness: a thin wrapper over one agent backend (Claude Code, Codex SDK,
|
|
74
|
+
* Codex app-server). `execute` yields raw backend events; normalization is the
|
|
75
|
+
* adapter's job. Harnesses own session reuse and cancellation.
|
|
76
|
+
*/
|
|
77
|
+
export interface Agent {
|
|
78
|
+
readonly harness: AgentHarness;
|
|
79
|
+
readonly capabilities: AgentCapabilities;
|
|
80
|
+
execute(input: AgentInput, options: AgentExecuteOptions): AsyncIterableIterator<RawAgentEvent>;
|
|
81
|
+
/** Cancel the in-flight turn for a logical session. */
|
|
82
|
+
cancel(sessionId: string): Promise<void>;
|
|
83
|
+
/** Release all harness-owned state for one idle logical session. */
|
|
84
|
+
release?(sessionId: string): Promise<void>;
|
|
85
|
+
/** Tear down every live session (process shutdown). */
|
|
86
|
+
terminateAll(): Promise<void>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export abstract class BaseAgent implements Agent {
|
|
90
|
+
abstract readonly harness: AgentHarness;
|
|
91
|
+
abstract readonly capabilities: AgentCapabilities;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* In-flight turn controllers per logical sessionId. A set (not a single
|
|
95
|
+
* controller) so overlapping turns on one session can't clobber each other's
|
|
96
|
+
* cancellation — `cancel(sessionId)` aborts all of them, and `endTurn`
|
|
97
|
+
* removes only the specific controller it created.
|
|
98
|
+
*/
|
|
99
|
+
protected readonly inflight = new Map<string, Set<AbortController>>();
|
|
100
|
+
|
|
101
|
+
abstract execute(
|
|
102
|
+
input: AgentInput,
|
|
103
|
+
options: AgentExecuteOptions,
|
|
104
|
+
): AsyncIterableIterator<RawAgentEvent>;
|
|
105
|
+
|
|
106
|
+
async cancel(sessionId: string): Promise<void> {
|
|
107
|
+
for (const controller of this.inflight.get(sessionId) ?? []) controller.abort();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async release(sessionId: string): Promise<void> {
|
|
111
|
+
await this.cancel(sessionId);
|
|
112
|
+
this.inflight.delete(sessionId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async terminateAll(): Promise<void> {
|
|
116
|
+
for (const set of this.inflight.values()) {
|
|
117
|
+
for (const controller of set) controller.abort();
|
|
118
|
+
}
|
|
119
|
+
this.inflight.clear();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Create a per-turn controller bound to `sessionId`, optionally chained to an external signal. */
|
|
123
|
+
protected trackTurn(sessionId: string, external?: AbortSignal): AbortController {
|
|
124
|
+
const controller = new AbortController();
|
|
125
|
+
let set = this.inflight.get(sessionId);
|
|
126
|
+
if (!set) {
|
|
127
|
+
set = new Set();
|
|
128
|
+
this.inflight.set(sessionId, set);
|
|
129
|
+
}
|
|
130
|
+
set.add(controller);
|
|
131
|
+
if (external) {
|
|
132
|
+
if (external.aborted) controller.abort();
|
|
133
|
+
else external.addEventListener("abort", () => controller.abort(), { once: true });
|
|
134
|
+
}
|
|
135
|
+
return controller;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
protected endTurn(sessionId: string, controller?: AbortController): void {
|
|
139
|
+
const set = this.inflight.get(sessionId);
|
|
140
|
+
if (!set) return;
|
|
141
|
+
if (controller) set.delete(controller);
|
|
142
|
+
else set.clear();
|
|
143
|
+
if (set.size === 0) this.inflight.delete(sessionId);
|
|
144
|
+
}
|
|
145
|
+
}
|