@automatalabs/acp-agents 0.1.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 +202 -0
- package/dist/acp-client.d.ts +164 -0
- package/dist/acp-client.d.ts.map +1 -0
- package/dist/acp-client.js +611 -0
- package/dist/backend.d.ts +28 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +4 -0
- package/dist/backends/claude.d.ts +10 -0
- package/dist/backends/claude.d.ts.map +1 -0
- package/dist/backends/claude.js +48 -0
- package/dist/backends/codex.d.ts +10 -0
- package/dist/backends/codex.d.ts.map +1 -0
- package/dist/backends/codex.js +62 -0
- package/dist/errors-map.d.ts +5 -0
- package/dist/errors-map.d.ts.map +1 -0
- package/dist/errors-map.js +47 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/permissions.d.ts +10 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +86 -0
- package/dist/pool.d.ts +35 -0
- package/dist/pool.d.ts.map +1 -0
- package/dist/pool.js +103 -0
- package/dist/runner.d.ts +22 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +211 -0
- package/dist/schema-strict.d.ts +11 -0
- package/dist/schema-strict.d.ts.map +1 -0
- package/dist/schema-strict.js +189 -0
- package/dist/structured-output.d.ts +37 -0
- package/dist/structured-output.d.ts.map +1 -0
- package/dist/structured-output.js +88 -0
- package/dist/usage.d.ts +21 -0
- package/dist/usage.d.ts.map +1 -0
- package/dist/usage.js +52 -0
- package/package.json +33 -0
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
// The ACP transport, POOL-managed. A backend's ACP server (claude-agent-acp / patched
|
|
2
|
+
// codex-acp) is spawned ONCE as a long-lived child process and its held ACP client connection
|
|
3
|
+
// is REUSED across many agent() calls. The PROCESS lifecycle is pool-managed (PooledConnection);
|
|
4
|
+
// the SESSION lifecycle stays per-agent (SessionHandle):
|
|
5
|
+
// PooledConnection.start: spawn + initialize (ONCE; benign clientInfo so Codex config options
|
|
6
|
+
// stay enabled). NO cwd here — cwd is per-SESSION.
|
|
7
|
+
// openSession -> session/new { cwd } (per-session cwd PRESERVES worktree isolation)
|
|
8
|
+
// -> session/set_config_option (model selection)
|
|
9
|
+
// -> session/prompt (+ drain session/update)
|
|
10
|
+
// -> session/cancel (on opts.signal)
|
|
11
|
+
// -> session/close (release the session; the PROCESS stays pooled)
|
|
12
|
+
//
|
|
13
|
+
// One connection multiplexes MANY concurrent sessions (the engine limiter caps concurrency, and
|
|
14
|
+
// a pinned server runs prompts on different sessions concurrently). The single ACP Client handler
|
|
15
|
+
// (MultiplexClient) therefore ROUTES every notification/permission request to the right
|
|
16
|
+
// per-session accumulator (SessionState) by `sessionId`.
|
|
17
|
+
//
|
|
18
|
+
// Draining: ACP delivers a prompt turn as session/update notifications followed by the
|
|
19
|
+
// session/prompt response, in wire order on one stream. Our Client handlers are synchronous
|
|
20
|
+
// (they only push into the routed session's arrays), so by the time `rpc.prompt(...)` resolves,
|
|
21
|
+
// every update for THAT session's turn has already been folded into its accumulator — even while
|
|
22
|
+
// other sessions' updates interleave on the same wire.
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { Readable, Writable } from "node:stream";
|
|
25
|
+
import { ClientSideConnection, ndJsonStream, PROTOCOL_VERSION, } from "@agentclientprotocol/sdk";
|
|
26
|
+
import { META_KEYS } from "@automatalabs/shared-types";
|
|
27
|
+
import { decidePermission } from "./permissions.js";
|
|
28
|
+
import { UsageAccumulator } from "./usage.js";
|
|
29
|
+
/** A benign client identity. NOT JetBrains/IntelliJ 2026.1 — that exact identity makes
|
|
30
|
+
* codex-acp disable session config options (our model/effort routing channel). */
|
|
31
|
+
const CLIENT_INFO = {
|
|
32
|
+
name: "agentprism-workflows",
|
|
33
|
+
title: "AgentPrism Workflows",
|
|
34
|
+
version: "0.1.0",
|
|
35
|
+
};
|
|
36
|
+
const CLAUDE_RAW_MESSAGE_METHOD = "_claude/sdkMessage";
|
|
37
|
+
/** Bound the best-effort session/close round-trip so a slow agent can't hang run()'s finally. */
|
|
38
|
+
const CLOSE_SESSION_TIMEOUT_MS = 5_000;
|
|
39
|
+
/** Bound the graceful SIGTERM shutdown before escalating to SIGKILL. */
|
|
40
|
+
const DISPOSE_SIGKILL_GRACE_MS = 2_000;
|
|
41
|
+
/** Per-session accumulator: assistant text, tool history, usage, the Claude raw structured_output,
|
|
42
|
+
* and the tool policy used to auto-answer permission requests for THIS session. */
|
|
43
|
+
class SessionState {
|
|
44
|
+
policy;
|
|
45
|
+
textChunks = [];
|
|
46
|
+
history = [];
|
|
47
|
+
usage = new UsageAccumulator();
|
|
48
|
+
rawResultSuccess;
|
|
49
|
+
turnStartIndex = 0;
|
|
50
|
+
constructor(policy) {
|
|
51
|
+
this.policy = policy;
|
|
52
|
+
}
|
|
53
|
+
/** Mark the start of a new turn so currentTurnText()/structured_output read only this turn. */
|
|
54
|
+
beginTurn() {
|
|
55
|
+
this.turnStartIndex = this.textChunks.length;
|
|
56
|
+
this.rawResultSuccess = undefined;
|
|
57
|
+
}
|
|
58
|
+
currentTurnText() {
|
|
59
|
+
return this.textChunks.slice(this.turnStartIndex).join("");
|
|
60
|
+
}
|
|
61
|
+
applyUpdate(update) {
|
|
62
|
+
switch (update.sessionUpdate) {
|
|
63
|
+
case "agent_message_chunk": {
|
|
64
|
+
if (update.content.type === "text") {
|
|
65
|
+
this.textChunks.push(update.content.text);
|
|
66
|
+
this.history.push({
|
|
67
|
+
role: "assistant",
|
|
68
|
+
kind: "text",
|
|
69
|
+
text: update.content.text,
|
|
70
|
+
timestamp: Date.now(),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
case "tool_call": {
|
|
76
|
+
this.history.push({
|
|
77
|
+
role: "tool",
|
|
78
|
+
kind: "toolCall",
|
|
79
|
+
text: update.title,
|
|
80
|
+
toolName: toolNameFromMeta(update._meta) ?? update.kind,
|
|
81
|
+
timestamp: Date.now(),
|
|
82
|
+
});
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
case "usage_update": {
|
|
86
|
+
this.usage.recordCost(update.cost);
|
|
87
|
+
// Also feed the context token counts so AgentUsage.total is non-zero for backends
|
|
88
|
+
// that report tokens via usage_update but never via PromptResponse.usage.
|
|
89
|
+
this.usage.recordContextTokens(update.used, update.size);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
default:
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
applyRawMessage(message) {
|
|
97
|
+
if (message && message.type === "result" && message.subtype === "success") {
|
|
98
|
+
this.rawResultSuccess = message;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** The single ACP Client handler for one pooled connection. It ROUTES every notification and
|
|
103
|
+
* permission request to the per-session SessionState by `sessionId`, so one process can serve
|
|
104
|
+
* many concurrent sessions without their streams crossing. */
|
|
105
|
+
class MultiplexClient {
|
|
106
|
+
sessions = new Map();
|
|
107
|
+
register(sessionId, state) {
|
|
108
|
+
this.sessions.set(sessionId, state);
|
|
109
|
+
}
|
|
110
|
+
unregister(sessionId) {
|
|
111
|
+
this.sessions.delete(sessionId);
|
|
112
|
+
}
|
|
113
|
+
requestPermission(params) {
|
|
114
|
+
const state = this.sessions.get(params.sessionId);
|
|
115
|
+
// Unknown/closed session: refuse rather than silently allow a tool we can't attribute.
|
|
116
|
+
if (!state)
|
|
117
|
+
return { outcome: { outcome: "cancelled" } };
|
|
118
|
+
return decidePermission(params, state.policy);
|
|
119
|
+
}
|
|
120
|
+
sessionUpdate(params) {
|
|
121
|
+
this.sessions.get(params.sessionId)?.applyUpdate(params.update);
|
|
122
|
+
}
|
|
123
|
+
extNotification(method, params) {
|
|
124
|
+
if (method !== CLAUDE_RAW_MESSAGE_METHOD)
|
|
125
|
+
return;
|
|
126
|
+
// claude-agent-acp stamps the owning sessionId on every raw _claude/sdkMessage; route by it
|
|
127
|
+
// so structured_output lands in the right session under concurrency.
|
|
128
|
+
const sessionId = typeof params.sessionId === "string" ? params.sessionId : undefined;
|
|
129
|
+
if (!sessionId)
|
|
130
|
+
return;
|
|
131
|
+
const message = params.message;
|
|
132
|
+
this.sessions.get(sessionId)?.applyRawMessage(message);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Merge the engine runId correlation stamp into a backend's session/new `_meta`. Returns the
|
|
136
|
+
* meta unchanged when no runId is given (so a backend that sends no `_meta` keeps sending none). */
|
|
137
|
+
function stampRunId(meta, runId) {
|
|
138
|
+
if (!runId)
|
|
139
|
+
return meta;
|
|
140
|
+
return { ...(meta ?? {}), [META_KEYS.runId]: runId };
|
|
141
|
+
}
|
|
142
|
+
function toolNameFromMeta(meta) {
|
|
143
|
+
if (!meta || typeof meta !== "object")
|
|
144
|
+
return undefined;
|
|
145
|
+
for (const value of Object.values(meta)) {
|
|
146
|
+
if (value && typeof value === "object") {
|
|
147
|
+
const toolName = value.toolName;
|
|
148
|
+
if (typeof toolName === "string")
|
|
149
|
+
return toolName;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* One long-lived ACP server subprocess + its held ACP client connection. Initialized ONCE and
|
|
156
|
+
* reused across agent() calls; it multiplexes many concurrent sessions. The process is NOT killed
|
|
157
|
+
* between sessions — only dispose() (pool teardown) or a crash ends it.
|
|
158
|
+
*/
|
|
159
|
+
export class PooledConnection {
|
|
160
|
+
backendId;
|
|
161
|
+
/** The held ACP connection; SessionHandles drive their session/* calls through it. */
|
|
162
|
+
rpc;
|
|
163
|
+
backend;
|
|
164
|
+
child;
|
|
165
|
+
client = new MultiplexClient();
|
|
166
|
+
onDead;
|
|
167
|
+
/** Resolves once `initialize` completed (or rejects if the process died first). */
|
|
168
|
+
ready;
|
|
169
|
+
/** Resolves when the process dies; `race()` turns it into a thrown, descriptive error. */
|
|
170
|
+
whenDead;
|
|
171
|
+
resolveDead;
|
|
172
|
+
deathError;
|
|
173
|
+
supportsClose = false;
|
|
174
|
+
_alive = true;
|
|
175
|
+
_activeSessions = 0;
|
|
176
|
+
stderrTail = "";
|
|
177
|
+
constructor(backend, deps) {
|
|
178
|
+
this.backend = backend;
|
|
179
|
+
this.backendId = backend.id;
|
|
180
|
+
this.onDead = deps.onDead;
|
|
181
|
+
const { command, args, env } = backend.spawnConfig();
|
|
182
|
+
// NOTE: deliberately NO `cwd` here. cwd is per-SESSION (session/new), so one pooled process
|
|
183
|
+
// serves runs in different worktrees without losing isolation.
|
|
184
|
+
const child = spawn(command, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
|
185
|
+
this.child = child;
|
|
186
|
+
if (!child.stdin || !child.stdout) {
|
|
187
|
+
throw new Error(`Failed to spawn ACP agent (${backend.id}): missing stdio pipes`);
|
|
188
|
+
}
|
|
189
|
+
child.stderr?.on("data", (chunk) => {
|
|
190
|
+
this.stderrTail = (this.stderrTail + chunk.toString()).slice(-4000);
|
|
191
|
+
});
|
|
192
|
+
// Swallow stdio pipe errors (EPIPE/ECONNRESET when the child dies mid-write) so they don't
|
|
193
|
+
// bubble up as an "Unhandled 'error' event" and crash the host. Process death is handled via
|
|
194
|
+
// the 'exit'/'error' events on `child` below.
|
|
195
|
+
child.stdin.on("error", () => { });
|
|
196
|
+
child.stdout.on("error", () => { });
|
|
197
|
+
this.whenDead = new Promise((resolve) => {
|
|
198
|
+
this.resolveDead = resolve;
|
|
199
|
+
});
|
|
200
|
+
const stream = ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout));
|
|
201
|
+
this.rpc = new ClientSideConnection(() => this.client, stream);
|
|
202
|
+
// Death detection. The connection's `signal` aborts the INSTANT the underlying stream closes
|
|
203
|
+
// (process crash or our own dispose) — in the SAME close() that rejects pending requests — so
|
|
204
|
+
// it is the earliest, DETERMINISTIC death signal: a connection is marked dead and evicted
|
|
205
|
+
// before its in-flight prompt's rejection even propagates, so a concurrent acquire can never
|
|
206
|
+
// hand out a connection whose process has already died. The child 'exit'/'error' events are a
|
|
207
|
+
// belt-and-suspenders backstop (and carry the exit code for a clearer message).
|
|
208
|
+
this.rpc.signal.addEventListener("abort", () => this.die(new Error(`ACP agent (${this.backendId}) connection closed${this.stderrSuffix()}`)), { once: true });
|
|
209
|
+
child.once("error", (err) => this.die(err));
|
|
210
|
+
child.once("exit", (code, sig) => {
|
|
211
|
+
this.die(new Error(`ACP agent (${this.backendId}) process exited (code=${code}, signal=${sig})${this.stderrSuffix()}`));
|
|
212
|
+
});
|
|
213
|
+
this.ready = this.initialize();
|
|
214
|
+
// The connection may be created and discarded (process dies) before anyone awaits `ready`.
|
|
215
|
+
this.ready.catch(() => { });
|
|
216
|
+
}
|
|
217
|
+
/** Spawn the backend and kick off the single `initialize`. Returns immediately; callers await
|
|
218
|
+
* readiness implicitly via openSession(). */
|
|
219
|
+
static create(backend, deps) {
|
|
220
|
+
return new PooledConnection(backend, deps);
|
|
221
|
+
}
|
|
222
|
+
get alive() {
|
|
223
|
+
return this._alive;
|
|
224
|
+
}
|
|
225
|
+
get activeSessions() {
|
|
226
|
+
return this._activeSessions;
|
|
227
|
+
}
|
|
228
|
+
/** Mark this connection dead exactly once, then ask the pool to evict it. Idempotent. */
|
|
229
|
+
die(error) {
|
|
230
|
+
if (!this._alive)
|
|
231
|
+
return;
|
|
232
|
+
this._alive = false;
|
|
233
|
+
this.deathError = error;
|
|
234
|
+
this.resolveDead();
|
|
235
|
+
this.onDead(this);
|
|
236
|
+
}
|
|
237
|
+
stderrSuffix() {
|
|
238
|
+
const tail = this.stderrTail.trim();
|
|
239
|
+
return tail ? `\n${tail}` : "";
|
|
240
|
+
}
|
|
241
|
+
/** Race a wire call against process death so a crash surfaces a clear error instead of hanging
|
|
242
|
+
* on a JSON-RPC response that will never come. */
|
|
243
|
+
async race(op) {
|
|
244
|
+
if (!this._alive)
|
|
245
|
+
throw this.deathError ?? new Error(`ACP agent (${this.backendId}) connection closed`);
|
|
246
|
+
const dead = this.whenDead.then(() => {
|
|
247
|
+
throw this.deathError ?? new Error(`ACP agent (${this.backendId}) connection closed`);
|
|
248
|
+
});
|
|
249
|
+
dead.catch(() => { });
|
|
250
|
+
return Promise.race([op, dead]);
|
|
251
|
+
}
|
|
252
|
+
async initialize() {
|
|
253
|
+
const response = await this.race(this.rpc.initialize({
|
|
254
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
255
|
+
clientCapabilities: {},
|
|
256
|
+
clientInfo: { ...CLIENT_INFO },
|
|
257
|
+
}));
|
|
258
|
+
this.supportsClose = Boolean(response.agentCapabilities?.sessionCapabilities?.close);
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Open a new per-agent session on this pooled connection: session/new { cwd }, register its
|
|
262
|
+
* accumulator for routing, and return a SessionHandle. `activeSessions` is reserved
|
|
263
|
+
* synchronously (before the first await) so the pool's load accounting is race-free.
|
|
264
|
+
*/
|
|
265
|
+
async openSession(opts) {
|
|
266
|
+
this._activeSessions += 1;
|
|
267
|
+
try {
|
|
268
|
+
await this.ready;
|
|
269
|
+
const state = new SessionState(opts.policy);
|
|
270
|
+
// The backend's vendor `_meta` (Claude schema channel; undefined for Codex) plus the
|
|
271
|
+
// optional engine runId correlation stamp. When neither is present, no `_meta` is sent.
|
|
272
|
+
const meta = stampRunId(this.backend.sessionMeta(opts.schema), opts.runId);
|
|
273
|
+
const request = {
|
|
274
|
+
cwd: opts.cwd,
|
|
275
|
+
// Client-provided MCP servers (additive run input), else the default empty list.
|
|
276
|
+
mcpServers: opts.mcpServers ?? [],
|
|
277
|
+
...(meta ? { _meta: meta } : {}),
|
|
278
|
+
};
|
|
279
|
+
const response = await this.race(this.rpc.newSession(request));
|
|
280
|
+
this.client.register(response.sessionId, state);
|
|
281
|
+
return new SessionHandle(this, response.sessionId, state, response.configOptions ?? [], opts);
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
this._activeSessions -= 1;
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** Best-effort ACP cancel for one session (wired to opts.signal). The PROCESS stays pooled. */
|
|
289
|
+
async cancelSession(sessionId) {
|
|
290
|
+
if (!this._alive)
|
|
291
|
+
return;
|
|
292
|
+
try {
|
|
293
|
+
await this.rpc.cancel({ sessionId });
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
// best-effort: the session settles as "cancelled" regardless.
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Release a session: stop routing it, free the load slot, and best-effort session/close on the
|
|
301
|
+
* wire (capability-gated, bounded, never fatal). The PROCESS is NOT killed — it returns to the
|
|
302
|
+
* pool for the next agent() call.
|
|
303
|
+
*/
|
|
304
|
+
async releaseSession(sessionId) {
|
|
305
|
+
this.client.unregister(sessionId);
|
|
306
|
+
if (this._activeSessions > 0)
|
|
307
|
+
this._activeSessions -= 1;
|
|
308
|
+
if (!this.supportsClose || !this._alive)
|
|
309
|
+
return;
|
|
310
|
+
try {
|
|
311
|
+
await this.race(withTimeout(this.rpc.closeSession({ sessionId }), CLOSE_SESSION_TIMEOUT_MS));
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// best-effort: the session is already untracked; the process stays pooled.
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Synchronous best-effort kill for a process-exit hook (no time to await a graceful close). */
|
|
318
|
+
killNow() {
|
|
319
|
+
if (!this._alive)
|
|
320
|
+
return;
|
|
321
|
+
try {
|
|
322
|
+
this.child.kill("SIGKILL");
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
// ignore
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
/** Close the process (pool teardown): end stdin, SIGTERM, escalate to SIGKILL, await exit. */
|
|
329
|
+
async dispose() {
|
|
330
|
+
if (!this._alive)
|
|
331
|
+
return;
|
|
332
|
+
const exited = new Promise((resolve) => {
|
|
333
|
+
this.child.once("exit", () => resolve());
|
|
334
|
+
});
|
|
335
|
+
try {
|
|
336
|
+
this.child.stdin?.end();
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// ignore
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
this.child.kill("SIGTERM");
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
// ignore
|
|
346
|
+
}
|
|
347
|
+
const sigkill = setTimeout(() => {
|
|
348
|
+
try {
|
|
349
|
+
this.child.kill("SIGKILL");
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// ignore
|
|
353
|
+
}
|
|
354
|
+
}, DISPOSE_SIGKILL_GRACE_MS);
|
|
355
|
+
sigkill.unref?.();
|
|
356
|
+
try {
|
|
357
|
+
await exited;
|
|
358
|
+
}
|
|
359
|
+
finally {
|
|
360
|
+
clearTimeout(sigkill);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* One agent() run's ACP session on a pooled connection. Owns the per-session cwd/schema/policy,
|
|
366
|
+
* the model-selection state, and the abort wiring. On release() it lets go of the session
|
|
367
|
+
* WITHOUT killing the pooled process. Implements StructuredSource for the backend's native read.
|
|
368
|
+
*/
|
|
369
|
+
export class SessionHandle {
|
|
370
|
+
pooled;
|
|
371
|
+
sessionId;
|
|
372
|
+
state;
|
|
373
|
+
opts;
|
|
374
|
+
configOptions;
|
|
375
|
+
removeAbort;
|
|
376
|
+
released = false;
|
|
377
|
+
constructor(pooled, sessionId, state, configOptions, opts) {
|
|
378
|
+
this.pooled = pooled;
|
|
379
|
+
this.sessionId = sessionId;
|
|
380
|
+
this.state = state;
|
|
381
|
+
this.opts = opts;
|
|
382
|
+
this.configOptions = configOptions;
|
|
383
|
+
if (opts.signal) {
|
|
384
|
+
const signal = opts.signal;
|
|
385
|
+
const onAbort = () => {
|
|
386
|
+
void this.cancel();
|
|
387
|
+
};
|
|
388
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
389
|
+
this.removeAbort = () => signal.removeEventListener("abort", onAbort);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/** Per-session usage accumulator (read by the runner on BOTH success and error paths). */
|
|
393
|
+
get usage() {
|
|
394
|
+
return this.state.usage;
|
|
395
|
+
}
|
|
396
|
+
/** Diagnostic message/tool history accumulated across this session's run. */
|
|
397
|
+
get history() {
|
|
398
|
+
return this.state.history;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Select the model for this session from the agent-advertised config options (§5.4).
|
|
402
|
+
* Returns `matched:false` (the caller fires onModelFallback) when the catalog has no value
|
|
403
|
+
* matching the spec, leaving the session default in place.
|
|
404
|
+
*
|
|
405
|
+
* Beyond the `model` select, this also drives the sibling config options the catalog may
|
|
406
|
+
* advertise (codex-acp), decoded from the `model[effort]` spec encoding:
|
|
407
|
+
* - `reasoning_effort` (id "reasoning_effort" / category "thought_level"): set to the
|
|
408
|
+
* bracketed effort token, e.g. `gpt-5.1-codex[high]` -> "high".
|
|
409
|
+
* - Fast mode (id "fast-mode" / category "fast-mode"): turned on when the bracket carries
|
|
410
|
+
* a `fast` token.
|
|
411
|
+
* Each is best-effort and advertise-gated: when the catalog does not expose the option, or
|
|
412
|
+
* the requested value is not among its choices, the modifier is NOT applied — but, unlike
|
|
413
|
+
* before, that silent no-op is now SURFACED. `modifierFallbacks` lists a descriptor for every
|
|
414
|
+
* requested effort/Fast value that could not be applied, so the caller can fire the same
|
|
415
|
+
* onModelFallback channel model selection uses (incorrect tiering becomes observable). It
|
|
416
|
+
* stays best-effort: an unmet modifier is reported, never thrown.
|
|
417
|
+
*/
|
|
418
|
+
async selectModel(spec) {
|
|
419
|
+
const option = this.configOptions.find(isModelSelectOption);
|
|
420
|
+
if (!option)
|
|
421
|
+
return { matched: false };
|
|
422
|
+
const values = flattenSelectOptions(option.options);
|
|
423
|
+
const target = matchModelValue(values, spec);
|
|
424
|
+
if (!target)
|
|
425
|
+
return { matched: false };
|
|
426
|
+
if (option.currentValue !== target.value) {
|
|
427
|
+
await this.applyConfigOption(option.id, target.value);
|
|
428
|
+
}
|
|
429
|
+
const modifierFallbacks = await this.applyModelModifiers(spec, target.value);
|
|
430
|
+
return { matched: true, resolved: target.value, modifierFallbacks };
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Drive reasoning_effort + Fast-mode from the `model[effort]` spec bracket, when advertised.
|
|
434
|
+
* Returns a descriptor for each requested modifier that could NOT be applied because the
|
|
435
|
+
* catalog does not advertise the option or the requested value — the symmetric signal to
|
|
436
|
+
* model fallback, so the no-op is observable rather than silent. When the resolved model id
|
|
437
|
+
* already ENCODES the bracket (e.g. a `gpt-5-codex[high]` catalog value), the effort is
|
|
438
|
+
* carried by the model select itself, so it is treated as satisfied (no fallback).
|
|
439
|
+
*/
|
|
440
|
+
async applyModelModifiers(spec, modelValue) {
|
|
441
|
+
const fallbacks = [];
|
|
442
|
+
const tokens = bracketTokens(spec);
|
|
443
|
+
if (tokens.length === 0)
|
|
444
|
+
return fallbacks;
|
|
445
|
+
// The model id already carries the bracket (e.g. "gpt-5-codex[high]") -> effort is applied
|
|
446
|
+
// via the model select; the separate effort/Fast options are not the channel here.
|
|
447
|
+
const effortAbsorbedByModel = modelValue.includes("[");
|
|
448
|
+
const fastRequested = tokens.some((t) => t.toLowerCase() === "fast");
|
|
449
|
+
const effortTokens = tokens.filter((t) => t.toLowerCase() !== "fast");
|
|
450
|
+
// reasoning_effort: set to the bracket token that matches one of its advertised values.
|
|
451
|
+
if (effortTokens.length > 0 && !effortAbsorbedByModel) {
|
|
452
|
+
const effortOption = this.configOptions.find(isReasoningEffortOption);
|
|
453
|
+
const match = effortOption
|
|
454
|
+
? matchToken(flattenSelectOptions(effortOption.options), effortTokens)
|
|
455
|
+
: undefined;
|
|
456
|
+
if (effortOption && match) {
|
|
457
|
+
if (effortOption.currentValue !== match.value) {
|
|
458
|
+
await this.applyConfigOption(effortOption.id, match.value);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
// No reasoning_effort option, or none of its choices match the requested effort.
|
|
463
|
+
fallbacks.push(`${spec}: reasoning_effort "${effortTokens.join(",")}" not advertised`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// Fast mode: a `fast` token turns the advertised toggle on.
|
|
467
|
+
if (fastRequested && !effortAbsorbedByModel) {
|
|
468
|
+
const fastOption = this.configOptions.find(isFastModeOption);
|
|
469
|
+
const onValue = fastOption ? fastModeOnValue(flattenSelectOptions(fastOption.options)) : undefined;
|
|
470
|
+
if (fastOption && onValue) {
|
|
471
|
+
if (fastOption.currentValue !== onValue) {
|
|
472
|
+
await this.applyConfigOption(fastOption.id, onValue);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
else {
|
|
476
|
+
// No Fast-mode option, or it advertises no "on" value.
|
|
477
|
+
fallbacks.push(`${spec}: Fast mode not advertised`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return fallbacks;
|
|
481
|
+
}
|
|
482
|
+
/** Set one session config option via the wire method and adopt the echoed catalog. */
|
|
483
|
+
async applyConfigOption(configId, value) {
|
|
484
|
+
const response = await this.pooled.race(this.pooled.rpc.setSessionConfigOption({ sessionId: this.sessionId, configId, value }));
|
|
485
|
+
this.configOptions = response.configOptions;
|
|
486
|
+
}
|
|
487
|
+
/** Send a prompt turn and drain it; returns the final PromptResponse. */
|
|
488
|
+
async prompt(text, promptMeta) {
|
|
489
|
+
this.opts.signal?.throwIfAborted();
|
|
490
|
+
this.state.beginTurn();
|
|
491
|
+
const prompt = [{ type: "text", text }];
|
|
492
|
+
const request = {
|
|
493
|
+
sessionId: this.sessionId,
|
|
494
|
+
prompt,
|
|
495
|
+
...(promptMeta ? { _meta: promptMeta } : {}),
|
|
496
|
+
};
|
|
497
|
+
const response = await this.pooled.race(this.pooled.rpc.prompt(request));
|
|
498
|
+
this.state.usage.recordPromptUsage(response.usage);
|
|
499
|
+
return response;
|
|
500
|
+
}
|
|
501
|
+
/** StructuredSource — the latest turn's assistant text. */
|
|
502
|
+
currentTurnText() {
|
|
503
|
+
return this.state.currentTurnText();
|
|
504
|
+
}
|
|
505
|
+
/** StructuredSource — Claude's raw structured_output for the latest turn, if any. */
|
|
506
|
+
rawStructuredOutput() {
|
|
507
|
+
return this.state.rawResultSuccess?.structured_output;
|
|
508
|
+
}
|
|
509
|
+
/** Best-effort ACP cancel (wired to opts.signal). The agent settles the turn as "cancelled". */
|
|
510
|
+
async cancel() {
|
|
511
|
+
await this.pooled.cancelSession(this.sessionId);
|
|
512
|
+
}
|
|
513
|
+
/** Let go of this session WITHOUT killing the pooled process; idempotent. */
|
|
514
|
+
async release() {
|
|
515
|
+
if (this.released)
|
|
516
|
+
return;
|
|
517
|
+
this.released = true;
|
|
518
|
+
this.removeAbort?.();
|
|
519
|
+
this.removeAbort = undefined;
|
|
520
|
+
await this.pooled.releaseSession(this.sessionId);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** Resolve `op`, but reject after `ms` so a stuck best-effort wire call can't hang a caller. */
|
|
524
|
+
function withTimeout(op, ms) {
|
|
525
|
+
return new Promise((resolve, reject) => {
|
|
526
|
+
const timer = setTimeout(() => reject(new Error(`ACP request timed out after ${ms}ms`)), ms);
|
|
527
|
+
timer.unref?.();
|
|
528
|
+
op.then((value) => {
|
|
529
|
+
clearTimeout(timer);
|
|
530
|
+
resolve(value);
|
|
531
|
+
}, (error) => {
|
|
532
|
+
clearTimeout(timer);
|
|
533
|
+
reject(error);
|
|
534
|
+
});
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
function isModelSelectOption(option) {
|
|
538
|
+
return option.type === "select" && (option.category === "model" || option.id === "model");
|
|
539
|
+
}
|
|
540
|
+
function isReasoningEffortOption(option) {
|
|
541
|
+
return option.type === "select" && (option.id === "reasoning_effort" || option.category === "thought_level");
|
|
542
|
+
}
|
|
543
|
+
function isFastModeOption(option) {
|
|
544
|
+
return option.type === "select" && (option.id === "fast-mode" || option.category === "fast-mode");
|
|
545
|
+
}
|
|
546
|
+
/** Split the trailing `[...]` of a `model[effort]` spec into its comma/space/plus-separated
|
|
547
|
+
* tokens (e.g. `gpt-5.1-codex[high]` -> ["high"], `gpt-5-codex[high fast]` -> ["high","fast"]). */
|
|
548
|
+
function bracketTokens(spec) {
|
|
549
|
+
const match = spec.match(/\[([^\]]+)\]\s*$/);
|
|
550
|
+
if (!match)
|
|
551
|
+
return [];
|
|
552
|
+
return match[1]
|
|
553
|
+
.split(/[\s,+]+/)
|
|
554
|
+
.map((token) => token.trim())
|
|
555
|
+
.filter(Boolean);
|
|
556
|
+
}
|
|
557
|
+
/** First advertised value whose id matches any of the given tokens (case-insensitive). */
|
|
558
|
+
function matchToken(values, tokens) {
|
|
559
|
+
const wanted = new Set(tokens.map((token) => token.toLowerCase()));
|
|
560
|
+
return values.find((value) => wanted.has(value.value.toLowerCase()));
|
|
561
|
+
}
|
|
562
|
+
/** The "on" value of a Fast-mode select (codex-acp advertises value "on"; tolerate name too). */
|
|
563
|
+
function fastModeOnValue(values) {
|
|
564
|
+
const on = values.find((value) => value.value.toLowerCase() === "on" || value.name.toLowerCase() === "on");
|
|
565
|
+
return on?.value;
|
|
566
|
+
}
|
|
567
|
+
function flattenSelectOptions(options) {
|
|
568
|
+
const out = [];
|
|
569
|
+
for (const entry of options) {
|
|
570
|
+
if ("options" in entry)
|
|
571
|
+
out.push(...entry.options);
|
|
572
|
+
else
|
|
573
|
+
out.push(entry);
|
|
574
|
+
}
|
|
575
|
+
return out;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Best-effort match of a model spec (`provider/modelId`, a bare `modelId`, or a tier word)
|
|
579
|
+
* against the agent's catalog. Tries, in priority order: exact spec, exact id-after-slash,
|
|
580
|
+
* the bare base id (with the `[effort]` bracket stripped, so `gpt-5.1-codex[high]` matches a
|
|
581
|
+
* bare `gpt-5.1-codex` model value while the bracket separately drives reasoning_effort), the
|
|
582
|
+
* Codex `base[effort]` encoding, exact option name, then substring fallbacks. The effort
|
|
583
|
+
* bracket itself is applied via applyModelModifiers, not folded into the model select.
|
|
584
|
+
*/
|
|
585
|
+
function matchModelValue(values, spec) {
|
|
586
|
+
const afterSlash = spec.includes("/") ? spec.slice(spec.indexOf("/") + 1) : spec;
|
|
587
|
+
const fullLower = spec.toLowerCase();
|
|
588
|
+
const idLower = afterSlash.toLowerCase();
|
|
589
|
+
const baseLower = stripEffortBracket(afterSlash).toLowerCase();
|
|
590
|
+
const tests = [
|
|
591
|
+
(value) => value.value.toLowerCase() === fullLower,
|
|
592
|
+
(value) => value.value.toLowerCase() === idLower,
|
|
593
|
+
(value) => value.value.toLowerCase() === baseLower,
|
|
594
|
+
(value) => value.value.toLowerCase().startsWith(`${baseLower}[`),
|
|
595
|
+
(value) => value.name.toLowerCase() === idLower,
|
|
596
|
+
(value) => value.name.toLowerCase() === baseLower,
|
|
597
|
+
(value) => value.value.toLowerCase().includes(baseLower),
|
|
598
|
+
(value) => value.name.toLowerCase().includes(baseLower),
|
|
599
|
+
];
|
|
600
|
+
for (const test of tests) {
|
|
601
|
+
const found = values.find(test);
|
|
602
|
+
if (found)
|
|
603
|
+
return found;
|
|
604
|
+
}
|
|
605
|
+
return undefined;
|
|
606
|
+
}
|
|
607
|
+
/** Drop a trailing `[effort]` bracket from a model id, leaving the base model id. */
|
|
608
|
+
function stripEffortBracket(spec) {
|
|
609
|
+
const open = spec.indexOf("[");
|
|
610
|
+
return open >= 0 ? spec.slice(0, open) : spec;
|
|
611
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
2
|
+
export type BackendId = "claude" | "codex";
|
|
3
|
+
export interface SpawnConfig {
|
|
4
|
+
command: string;
|
|
5
|
+
args: string[];
|
|
6
|
+
env: NodeJS.ProcessEnv;
|
|
7
|
+
}
|
|
8
|
+
/** The slice of an active session a Backend reads to extract the native structured result. */
|
|
9
|
+
export interface StructuredSource {
|
|
10
|
+
/** The latest turn's accumulated assistant text. */
|
|
11
|
+
currentTurnText(): string;
|
|
12
|
+
/** Claude only: `structured_output` from the latest `type:"result", subtype:"success"` raw message. */
|
|
13
|
+
rawStructuredOutput(): unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface Backend {
|
|
16
|
+
readonly id: BackendId;
|
|
17
|
+
/** How to launch this backend's ACP server over stdio. */
|
|
18
|
+
spawnConfig(): SpawnConfig;
|
|
19
|
+
/** `_meta` for session/new (undefined when this backend carries the schema elsewhere). */
|
|
20
|
+
sessionMeta(schema: TSchema | undefined): Record<string, unknown> | undefined;
|
|
21
|
+
/** `_meta` for session/prompt (undefined when this backend carries the schema at session/new). */
|
|
22
|
+
promptMeta(schema: TSchema | undefined): Record<string, unknown> | undefined;
|
|
23
|
+
/** Read this backend's native structured result for the latest turn (unvalidated), or undefined. */
|
|
24
|
+
nativeStructured(source: StructuredSource): unknown;
|
|
25
|
+
}
|
|
26
|
+
/** Split a whitespace-separated env override (e.g. AGENTPRISM_CLAUDE_ACP_ARGS) into argv. */
|
|
27
|
+
export declare function splitArgs(value: string | undefined): string[];
|
|
28
|
+
//# sourceMappingURL=backend.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"backend.d.ts","sourceRoot":"","sources":["../src/backend.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAEvC,MAAM,MAAM,SAAS,GAAG,QAAQ,GAAG,OAAO,CAAC;AAE3C,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,GAAG,EAAE,MAAM,CAAC,UAAU,CAAC;CACxB;AAED,8FAA8F;AAC9F,MAAM,WAAW,gBAAgB;IAC/B,oDAAoD;IACpD,eAAe,IAAI,MAAM,CAAC;IAC1B,uGAAuG;IACvG,mBAAmB,IAAI,OAAO,CAAC;CAChC;AAED,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,EAAE,EAAE,SAAS,CAAC;IACvB,0DAA0D;IAC1D,WAAW,IAAI,WAAW,CAAC;IAC3B,0FAA0F;IAC1F,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAC9E,kGAAkG;IAClG,UAAU,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAC;IAC7E,oGAAoG;IACpG,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC;CACrD;AAED,6FAA6F;AAC7F,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,EAAE,CAE7D"}
|
package/dist/backend.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
2
|
+
import type { Backend, SpawnConfig, StructuredSource } from "../backend.js";
|
|
3
|
+
export declare class ClaudeBackend implements Backend {
|
|
4
|
+
readonly id: "claude";
|
|
5
|
+
spawnConfig(): SpawnConfig;
|
|
6
|
+
sessionMeta(schema: TSchema | undefined): Record<string, unknown> | undefined;
|
|
7
|
+
promptMeta(): Record<string, unknown> | undefined;
|
|
8
|
+
nativeStructured(source: StructuredSource): unknown;
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=claude.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/backends/claude.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAC;AAM5E,qBAAa,aAAc,YAAW,OAAO;IAC3C,QAAQ,CAAC,EAAE,EAAG,QAAQ,CAAU;IAEhC,WAAW,IAAI,WAAW;IAgB1B,WAAW,CAAC,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS;IAY7E,UAAU,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS;IAKjD,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO;CAGpD"}
|