@automatalabs/acp-agents 0.8.0 → 0.9.1
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/README.md +42 -3
- package/dist/acp-client.d.ts +25 -6
- package/dist/acp-client.d.ts.map +1 -1
- package/dist/acp-client.js +82 -10
- package/dist/events.d.ts +16 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +10 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/interactive.d.ts +114 -0
- package/dist/interactive.d.ts.map +1 -0
- package/dist/interactive.js +139 -0
- package/dist/permissions.d.ts +9 -0
- package/dist/permissions.d.ts.map +1 -1
- package/dist/pool.d.ts +3 -1
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +1 -0
- package/dist/prompt.d.ts +21 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +75 -0
- package/dist/runner.d.ts +39 -7
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +162 -84
- package/package.json +1 -1
package/dist/runner.js
CHANGED
|
@@ -16,36 +16,56 @@
|
|
|
16
16
|
//
|
|
17
17
|
// Timeout and abort are the ENGINE's job: we honor opts.signal (wired to ACP session/cancel)
|
|
18
18
|
// and re-throw on abort, but never implement our own timeout.
|
|
19
|
+
import { isAbsolute } from "node:path";
|
|
19
20
|
import { WorkflowError, WorkflowErrorCode, } from "@automatalabs/shared-types";
|
|
21
|
+
import { PooledConnection } from "./acp-client.js";
|
|
20
22
|
import { AcpAgentPool } from "./pool.js";
|
|
21
23
|
import { TypedEventEmitter, } from "./events.js";
|
|
24
|
+
import { InteractiveSession } from "./interactive.js";
|
|
22
25
|
import { ClaudeBackend } from "./backends/claude.js";
|
|
23
26
|
import { CodexBackend } from "./backends/codex.js";
|
|
24
27
|
import { CustomAcpBackend } from "./backends/custom.js";
|
|
25
28
|
import { registryWithRunBackends, resolveBackendRegistry, } from "./registry.js";
|
|
26
29
|
import { mapThrownError } from "./errors-map.js";
|
|
27
|
-
import { toJsonSchema } from "./schema-strict.js";
|
|
28
30
|
import { resolveStructuredOutput } from "./structured-output.js";
|
|
31
|
+
import { buildRunPrompt, mergeTurnMeta, promptWithImages, validatePromptImages, } from "./prompt.js";
|
|
29
32
|
export class AcpAgentRunner {
|
|
30
33
|
pool;
|
|
31
34
|
/** The resolved custom-backend registry (env + option, validated at construction). */
|
|
32
35
|
backends;
|
|
33
|
-
/** Typed bus carrying every ACP event from every pooled session. Beyond the
|
|
34
|
-
* (additive observability) — subscribing never affects a run and never enters
|
|
36
|
+
/** Typed bus carrying every ACP event from every pooled or interactive session. Beyond the
|
|
37
|
+
* AgentRunner seam (additive observability) — subscribing never affects a run and never enters
|
|
38
|
+
* the resume hash. */
|
|
35
39
|
events = new TypedEventEmitter();
|
|
36
40
|
emitEvent = (name, event) => this.events.emit(name, event);
|
|
41
|
+
/** Client-side handlers and the runner-wide permission resolver are initialize/session wiring,
|
|
42
|
+
* so dedicated interactive connections must receive the SAME deps the pool receives. */
|
|
43
|
+
clientHandlers;
|
|
44
|
+
permissionResolver;
|
|
45
|
+
/** Held-open interactive sessions own dedicated ACP processes outside the pool. The runner
|
|
46
|
+
* tracks their connections so dispose() can release them and the process-exit hook can
|
|
47
|
+
* synchronously kill any dedicated children if the host exits without release(). */
|
|
48
|
+
interactiveSessions = new Map();
|
|
49
|
+
onProcessExit = () => this.killAllSync();
|
|
50
|
+
exitHookInstalled = false;
|
|
51
|
+
disposed = false;
|
|
37
52
|
constructor(options = {}) {
|
|
38
|
-
this.
|
|
53
|
+
this.clientHandlers = options.clientHandlers;
|
|
54
|
+
this.permissionResolver = options.onPermissionRequest;
|
|
55
|
+
this.pool = new AcpAgentPool(options, {
|
|
56
|
+
onEvent: this.emitEvent,
|
|
57
|
+
permissionResolver: options.onPermissionRequest,
|
|
58
|
+
});
|
|
39
59
|
this.backends = resolveBackendRegistry(options.backends);
|
|
40
60
|
}
|
|
41
61
|
/**
|
|
42
62
|
* Listen in on the live ACP stream. `name` is an ACP `sessionUpdate` discriminant
|
|
43
63
|
* ("agent_message_chunk", "tool_call", "usage_update", …) or one of the cross-cutting events
|
|
44
|
-
* ("session_update" catch-all, "
|
|
45
|
-
* "session_close", "backend_error"). The listener is typed to the event.
|
|
46
|
-
* thunk. A pooled runner multiplexes many concurrent runs, so each
|
|
47
|
-
* `{ sessionId, backendId, label?, runId? }` for filtering. Listeners are
|
|
48
|
-
* a throwing listener is isolated and never affects the run.
|
|
64
|
+
* ("session_update" catch-all, "permission_pending", "permission_request", "raw_message",
|
|
65
|
+
* "session_open", "session_close", "backend_error"). The listener is typed to the event.
|
|
66
|
+
* Returns an unsubscribe thunk. A pooled runner multiplexes many concurrent runs, so each
|
|
67
|
+
* event carries `{ sessionId, backendId, label?, runId? }` for filtering. Listeners are
|
|
68
|
+
* best-effort observers: a throwing listener is isolated and never affects the run.
|
|
49
69
|
*/
|
|
50
70
|
on(name, listener) {
|
|
51
71
|
return this.events.on(name, listener);
|
|
@@ -63,6 +83,75 @@ export class AcpAgentRunner {
|
|
|
63
83
|
listenerCount(name) {
|
|
64
84
|
return this.events.listenerCount(name);
|
|
65
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Open a held ACP session for multi-turn callers. Unlike run(), this does NOT acquire a pool
|
|
88
|
+
* slot: it spawns one dedicated backend process, opens one ACP session on it, and hands the
|
|
89
|
+
* caller an InteractiveSession that must be released. The dedicated process means a long-lived
|
|
90
|
+
* chat/debug loop never starves one-shot run() calls on the same backend (the default pool size
|
|
91
|
+
* is one).
|
|
92
|
+
*/
|
|
93
|
+
async openSession(opts) {
|
|
94
|
+
if (this.disposed)
|
|
95
|
+
throw new Error("ACP agent runner is disposed");
|
|
96
|
+
validateInteractiveCwd(opts.cwd, opts.label);
|
|
97
|
+
opts.signal?.throwIfAborted();
|
|
98
|
+
const prepared = this.prepareSession(opts, {
|
|
99
|
+
cwd: opts.cwd,
|
|
100
|
+
schema: undefined,
|
|
101
|
+
registry: this.backends,
|
|
102
|
+
permissionResolver: opts.onPermissionRequest,
|
|
103
|
+
retainSessionLog: false,
|
|
104
|
+
});
|
|
105
|
+
this.installExitHook();
|
|
106
|
+
let interactive;
|
|
107
|
+
const connection = PooledConnection.create(prepared.backend, {
|
|
108
|
+
onDead: () => {
|
|
109
|
+
// Dedicated connections are not stored in pool arrays, so there is nothing to evict.
|
|
110
|
+
// Once the public wrapper exists, process death releases it through the normal path:
|
|
111
|
+
// subscriptions are removed, session_close is emitted, and future prompt() calls fail
|
|
112
|
+
// with the clean released-session error. Before then, openSession's catch tears down.
|
|
113
|
+
void interactive?.release();
|
|
114
|
+
},
|
|
115
|
+
onEvent: this.emitEvent,
|
|
116
|
+
permissionResolver: this.permissionResolver,
|
|
117
|
+
clientHandlers: this.clientHandlers,
|
|
118
|
+
});
|
|
119
|
+
let session;
|
|
120
|
+
try {
|
|
121
|
+
session = await connection.openSession(prepared.sessionOptions);
|
|
122
|
+
opts.signal?.throwIfAborted();
|
|
123
|
+
await applyModelSelection(session, prepared.modelSpec, opts);
|
|
124
|
+
opts.signal?.throwIfAborted();
|
|
125
|
+
if (this.disposed)
|
|
126
|
+
throw new Error("ACP agent runner is disposed");
|
|
127
|
+
interactive = new InteractiveSession({
|
|
128
|
+
session,
|
|
129
|
+
connection,
|
|
130
|
+
backend: prepared.backend,
|
|
131
|
+
subscribe: (name, listener) => this.events.on(name, listener),
|
|
132
|
+
onRelease: (self) => this.interactiveSessions.delete(self),
|
|
133
|
+
signal: opts.signal,
|
|
134
|
+
label: opts.label,
|
|
135
|
+
});
|
|
136
|
+
this.interactiveSessions.set(interactive, connection);
|
|
137
|
+
return interactive;
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
try {
|
|
141
|
+
await session?.release();
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// best-effort: openSession failed, so teardown must never mask the real error.
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await connection.dispose();
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
// best-effort: same as pool teardown.
|
|
151
|
+
}
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
66
155
|
async run(prompt, options = {}) {
|
|
67
156
|
const opts = options;
|
|
68
157
|
const schema = opts.schema;
|
|
@@ -79,39 +168,26 @@ export class AcpAgentRunner {
|
|
|
79
168
|
agentLabel: opts.label,
|
|
80
169
|
});
|
|
81
170
|
}
|
|
82
|
-
const backend = selectBackend(opts, registry);
|
|
83
|
-
const policy = { allow: opts.toolNames, deny: opts.disallowedToolNames };
|
|
84
171
|
const cwd = opts.cwd ?? process.cwd();
|
|
85
172
|
validatePromptImages(opts.images, opts.label);
|
|
86
|
-
const
|
|
173
|
+
const prepared = this.prepareSession(opts, {
|
|
87
174
|
cwd,
|
|
88
175
|
schema,
|
|
89
|
-
|
|
176
|
+
registry,
|
|
90
177
|
signal: opts.signal,
|
|
91
|
-
mcpServers: opts.mcpServers,
|
|
92
|
-
// Generic session-scoped _meta passthrough (RunOptions.meta) — merged UNDER the
|
|
93
|
-
// backend-computed keys and the runId stamp in openSession. Additive; never hashed.
|
|
94
|
-
meta: opts.meta,
|
|
95
|
-
// Engine correlation id -> session/new _meta (META_KEYS.runId). Additive; never hashed.
|
|
96
|
-
runId: opts.runId,
|
|
97
|
-
// Stamped onto emitted ACP events as context (never sent on the wire).
|
|
98
|
-
label: opts.label,
|
|
99
|
-
// CODEX-ONLY session instruction overrides -> session/new _meta bare keys. Additive; never
|
|
100
|
-
// hashed. The Claude backend ignores them.
|
|
101
|
-
baseInstructions: opts.baseInstructions,
|
|
102
|
-
developerInstructions: opts.developerInstructions,
|
|
103
178
|
});
|
|
179
|
+
const session = await this.pool.acquire(prepared.backend, prepared.sessionOptions);
|
|
104
180
|
try {
|
|
105
181
|
opts.signal?.throwIfAborted();
|
|
106
182
|
// For a CUSTOM backend chosen by its registered name, the name itself is routing, not a
|
|
107
183
|
// model id: "browser" selects nothing; "browser/foo" selects "foo". Built-ins get the
|
|
108
184
|
// full spec unchanged (their catalogs match provider-prefixed and bare ids).
|
|
109
|
-
await applyModelSelection(session,
|
|
110
|
-
const text =
|
|
185
|
+
await applyModelSelection(session, prepared.modelSpec, opts);
|
|
186
|
+
const text = buildRunPrompt(prompt, opts, schema, prepared.backend);
|
|
111
187
|
const initialPrompt = opts.images && opts.images.length > 0 ? promptWithImages(text, opts.images) : text;
|
|
112
188
|
// Generic turn-scoped _meta passthrough merged UNDER the backend-computed keys (e.g. the
|
|
113
189
|
// outputSchema forward when a schema is set) — user meta never clobbers the schema channel.
|
|
114
|
-
const promptMeta = mergeTurnMeta(opts.promptMeta, backend.promptMeta(schema));
|
|
190
|
+
const promptMeta = mergeTurnMeta(opts.promptMeta, prepared.backend.promptMeta(schema));
|
|
115
191
|
const response = await session.prompt(initialPrompt, promptMeta);
|
|
116
192
|
opts.signal?.throwIfAborted();
|
|
117
193
|
// Inspect the turn's stop reason BEFORE the text/schema path: a refusal or truncation
|
|
@@ -127,7 +203,7 @@ export class AcpAgentRunner {
|
|
|
127
203
|
assertNormalStopReason(repromptResponse.stopReason, opts.label);
|
|
128
204
|
},
|
|
129
205
|
lastText: () => session.currentTurnText(),
|
|
130
|
-
tryNative: () => backend.nativeStructured(session),
|
|
206
|
+
tryNative: () => prepared.backend.nativeStructured(session),
|
|
131
207
|
};
|
|
132
208
|
const result = await resolveStructuredOutput(structuredSession, schema, {
|
|
133
209
|
maxSchemaRetries: opts.maxSchemaRetries,
|
|
@@ -177,9 +253,60 @@ export class AcpAgentRunner {
|
|
|
177
253
|
/** Tear down the whole pool (close every long-lived process). Call when the run ends / the
|
|
178
254
|
* runner is disposed. Beyond the AgentRunner seam (additive) — never enters the resume hash. */
|
|
179
255
|
async dispose() {
|
|
256
|
+
this.disposed = true;
|
|
257
|
+
this.removeExitHook();
|
|
258
|
+
const sessions = [...this.interactiveSessions.keys()];
|
|
259
|
+
await Promise.all(sessions.map((session) => session.release()));
|
|
180
260
|
await this.pool.dispose();
|
|
181
261
|
this.events.removeAllListeners();
|
|
182
262
|
}
|
|
263
|
+
/** Build the backend choice, model-selection spec, tool policy, and session/new options in one
|
|
264
|
+
* place for run() and openSession() so new AcpSessionOptions fields cannot drift by path. */
|
|
265
|
+
prepareSession(opts, config) {
|
|
266
|
+
const backend = selectBackend(opts, config.registry);
|
|
267
|
+
const policy = { allow: opts.toolNames, deny: opts.disallowedToolNames };
|
|
268
|
+
return {
|
|
269
|
+
backend,
|
|
270
|
+
modelSpec: innerModelSpec(opts.model ?? opts.tier, backend),
|
|
271
|
+
sessionOptions: {
|
|
272
|
+
cwd: config.cwd,
|
|
273
|
+
schema: config.schema,
|
|
274
|
+
policy,
|
|
275
|
+
permissionResolver: config.permissionResolver,
|
|
276
|
+
signal: config.signal,
|
|
277
|
+
mcpServers: opts.mcpServers,
|
|
278
|
+
// Generic session-scoped _meta passthrough (RunOptions.meta) — merged UNDER the
|
|
279
|
+
// backend-computed keys and the runId stamp in openSession. Additive; never hashed.
|
|
280
|
+
meta: opts.meta,
|
|
281
|
+
// Engine correlation id -> session/new _meta (META_KEYS.runId). Additive; never hashed.
|
|
282
|
+
runId: opts.runId,
|
|
283
|
+
// Stamped onto emitted ACP events as context (never sent on the wire).
|
|
284
|
+
label: opts.label,
|
|
285
|
+
// CODEX-ONLY session instruction overrides -> session/new _meta bare keys. Additive;
|
|
286
|
+
// never hashed. The Claude backend ignores them.
|
|
287
|
+
baseInstructions: opts.baseInstructions,
|
|
288
|
+
developerInstructions: opts.developerInstructions,
|
|
289
|
+
retainSessionLog: config.retainSessionLog,
|
|
290
|
+
},
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
installExitHook() {
|
|
294
|
+
if (this.exitHookInstalled)
|
|
295
|
+
return;
|
|
296
|
+
this.exitHookInstalled = true;
|
|
297
|
+
process.once("exit", this.onProcessExit);
|
|
298
|
+
}
|
|
299
|
+
removeExitHook() {
|
|
300
|
+
if (!this.exitHookInstalled)
|
|
301
|
+
return;
|
|
302
|
+
this.exitHookInstalled = false;
|
|
303
|
+
process.removeListener("exit", this.onProcessExit);
|
|
304
|
+
}
|
|
305
|
+
/** Synchronous best-effort child kill for the process-exit hook (no async work is possible). */
|
|
306
|
+
killAllSync() {
|
|
307
|
+
for (const connection of this.interactiveSessions.values())
|
|
308
|
+
connection.killNow();
|
|
309
|
+
}
|
|
183
310
|
}
|
|
184
311
|
/** Factory the mcp-server composition root calls to inject the runner into the engine. The pool
|
|
185
312
|
* size and the custom-backend registry are runner-level options — NOT RunOptions fields, so they
|
|
@@ -234,56 +361,6 @@ async function applyModelSelection(session, spec, opts) {
|
|
|
234
361
|
for (const fallback of modifierFallbacks ?? [])
|
|
235
362
|
opts.onModelFallback?.(fallback);
|
|
236
363
|
}
|
|
237
|
-
function buildPrompt(prompt, opts, schema, backend) {
|
|
238
|
-
const parts = [];
|
|
239
|
-
if (opts.instructions)
|
|
240
|
-
parts.push(opts.instructions);
|
|
241
|
-
if (opts.label)
|
|
242
|
-
parts.push(`Task label: ${opts.label}`);
|
|
243
|
-
parts.push(prompt);
|
|
244
|
-
if (schema) {
|
|
245
|
-
const contract = [
|
|
246
|
-
"Final output contract:",
|
|
247
|
-
"- Your FINAL message MUST be a single JSON object that conforms to the required output schema.",
|
|
248
|
-
"- Output ONLY that JSON object — no prose, no explanation, and no markdown code fences.",
|
|
249
|
-
"- If you need to inspect files or run commands first, do so, then emit the JSON object as your final message.",
|
|
250
|
-
];
|
|
251
|
-
if (backend.embedSchemaInPrompt) {
|
|
252
|
-
// The agent behind a custom backend may ignore the `_meta.outputSchema` forward, so the
|
|
253
|
-
// schema must be STATED, not just wired — otherwise the model invents its own keys and
|
|
254
|
-
// the repair ladder can never converge. Built-ins skip this: their native constraint
|
|
255
|
-
// channel is authoritative.
|
|
256
|
-
contract.push(`- The required output schema (JSON Schema):\n${JSON.stringify(toJsonSchema(schema))}`);
|
|
257
|
-
}
|
|
258
|
-
parts.push(contract.join("\n"));
|
|
259
|
-
}
|
|
260
|
-
return parts.join("\n\n");
|
|
261
|
-
}
|
|
262
|
-
function validatePromptImages(images, label) {
|
|
263
|
-
if (!images || images.length === 0)
|
|
264
|
-
return;
|
|
265
|
-
for (let i = 0; i < images.length; i += 1) {
|
|
266
|
-
const image = images[i];
|
|
267
|
-
if (typeof image?.data !== "string" || image.data.trim() === "") {
|
|
268
|
-
throw new WorkflowError(`images[${i}].data must be a non-empty string`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: label });
|
|
269
|
-
}
|
|
270
|
-
if (typeof image.mimeType !== "string" || image.mimeType.trim() === "") {
|
|
271
|
-
throw new WorkflowError(`images[${i}].mimeType must be a non-empty string`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: label });
|
|
272
|
-
}
|
|
273
|
-
if (image.uri !== undefined && (typeof image.uri !== "string" || image.uri.trim() === "")) {
|
|
274
|
-
throw new WorkflowError(`images[${i}].uri must be a non-empty string when present`, WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: label });
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
function promptWithImages(text, images) {
|
|
279
|
-
const blocks = [{ type: "text", text }];
|
|
280
|
-
for (const image of images) {
|
|
281
|
-
blocks.push(image.uri === undefined
|
|
282
|
-
? { type: "image", data: image.data, mimeType: image.mimeType }
|
|
283
|
-
: { type: "image", data: image.data, mimeType: image.mimeType, uri: image.uri });
|
|
284
|
-
}
|
|
285
|
-
return blocks;
|
|
286
|
-
}
|
|
287
364
|
/** Pick the backend by model/tier. Cross-provider routing = which ACP server to spawn.
|
|
288
365
|
* Registered CUSTOM names resolve FIRST (exact name, or `name/<inner-model>` prefix) so a
|
|
289
366
|
* registry entry is never shadowed by the built-in heuristics; then the claude/codex
|
|
@@ -322,11 +399,12 @@ function innerModelSpec(spec, backend) {
|
|
|
322
399
|
return spec.slice(backend.id.length + 1) || undefined;
|
|
323
400
|
return spec;
|
|
324
401
|
}
|
|
325
|
-
/**
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
402
|
+
/** Interactive sessions are public and long-lived, so fail before spawning a dedicated process
|
|
403
|
+
* when the required worktree root is absent or not absolute. */
|
|
404
|
+
function validateInteractiveCwd(cwd, label) {
|
|
405
|
+
if (typeof cwd !== "string" || cwd.trim() === "" || !isAbsolute(cwd)) {
|
|
406
|
+
throw new WorkflowError("openSession requires cwd to be a non-empty absolute path", WorkflowErrorCode.SCRIPT_VALIDATION_ERROR, { recoverable: false, agentLabel: label });
|
|
407
|
+
}
|
|
330
408
|
}
|
|
331
409
|
function backendIdForSpec(spec) {
|
|
332
410
|
if (!spec)
|