@automatalabs/acp-agents 0.7.0 → 0.9.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/README.md +76 -6
- package/dist/acp-client.d.ts +36 -12
- package/dist/acp-client.d.ts.map +1 -1
- package/dist/acp-client.js +172 -31
- package/dist/backend.d.ts +7 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backends/codex.d.ts +4 -0
- package/dist/backends/codex.d.ts.map +1 -1
- package/dist/backends/codex.js +6 -1
- package/dist/backends/custom.d.ts +1 -0
- package/dist/backends/custom.d.ts.map +1 -1
- package/dist/backends/custom.js +3 -0
- package/dist/capabilities.d.ts +18 -7
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +80 -22
- package/dist/client-handlers.d.ts +27 -0
- package/dist/client-handlers.d.ts.map +1 -0
- package/dist/client-handlers.js +39 -0
- package/dist/events.d.ts +13 -2
- package/dist/events.d.ts.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -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 +7 -1
- package/dist/pool.d.ts.map +1 -1
- package/dist/pool.js +6 -0
- package/dist/prompt.d.ts +21 -0
- package/dist/prompt.d.ts.map +1 -0
- package/dist/prompt.js +75 -0
- package/dist/registry.d.ts +6 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +19 -0
- package/dist/runner.d.ts +41 -9
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +165 -60
- package/package.json +2 -2
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,38 +168,27 @@ 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);
|
|
173
|
+
const prepared = this.prepareSession(opts, {
|
|
86
174
|
cwd,
|
|
87
175
|
schema,
|
|
88
|
-
|
|
176
|
+
registry,
|
|
89
177
|
signal: opts.signal,
|
|
90
|
-
mcpServers: opts.mcpServers,
|
|
91
|
-
// Generic session-scoped _meta passthrough (RunOptions.meta) — merged UNDER the
|
|
92
|
-
// backend-computed keys and the runId stamp in openSession. Additive; never hashed.
|
|
93
|
-
meta: opts.meta,
|
|
94
|
-
// Engine correlation id -> session/new _meta (META_KEYS.runId). Additive; never hashed.
|
|
95
|
-
runId: opts.runId,
|
|
96
|
-
// Stamped onto emitted ACP events as context (never sent on the wire).
|
|
97
|
-
label: opts.label,
|
|
98
|
-
// CODEX-ONLY session instruction overrides -> session/new _meta bare keys. Additive; never
|
|
99
|
-
// hashed. The Claude backend ignores them.
|
|
100
|
-
baseInstructions: opts.baseInstructions,
|
|
101
|
-
developerInstructions: opts.developerInstructions,
|
|
102
178
|
});
|
|
179
|
+
const session = await this.pool.acquire(prepared.backend, prepared.sessionOptions);
|
|
103
180
|
try {
|
|
104
181
|
opts.signal?.throwIfAborted();
|
|
105
182
|
// For a CUSTOM backend chosen by its registered name, the name itself is routing, not a
|
|
106
183
|
// model id: "browser" selects nothing; "browser/foo" selects "foo". Built-ins get the
|
|
107
184
|
// full spec unchanged (their catalogs match provider-prefixed and bare ids).
|
|
108
|
-
await applyModelSelection(session,
|
|
109
|
-
const text =
|
|
185
|
+
await applyModelSelection(session, prepared.modelSpec, opts);
|
|
186
|
+
const text = buildRunPrompt(prompt, opts, schema, prepared.backend);
|
|
187
|
+
const initialPrompt = opts.images && opts.images.length > 0 ? promptWithImages(text, opts.images) : text;
|
|
110
188
|
// Generic turn-scoped _meta passthrough merged UNDER the backend-computed keys (e.g. the
|
|
111
189
|
// outputSchema forward when a schema is set) — user meta never clobbers the schema channel.
|
|
112
|
-
const promptMeta = mergeTurnMeta(opts.promptMeta, backend.promptMeta(schema));
|
|
113
|
-
const response = await session.prompt(
|
|
190
|
+
const promptMeta = mergeTurnMeta(opts.promptMeta, prepared.backend.promptMeta(schema));
|
|
191
|
+
const response = await session.prompt(initialPrompt, promptMeta);
|
|
114
192
|
opts.signal?.throwIfAborted();
|
|
115
193
|
// Inspect the turn's stop reason BEFORE the text/schema path: a refusal or truncation
|
|
116
194
|
// must surface distinctly here, never be misread as empty output or burned through the
|
|
@@ -125,7 +203,7 @@ export class AcpAgentRunner {
|
|
|
125
203
|
assertNormalStopReason(repromptResponse.stopReason, opts.label);
|
|
126
204
|
},
|
|
127
205
|
lastText: () => session.currentTurnText(),
|
|
128
|
-
tryNative: () => backend.nativeStructured(session),
|
|
206
|
+
tryNative: () => prepared.backend.nativeStructured(session),
|
|
129
207
|
};
|
|
130
208
|
const result = await resolveStructuredOutput(structuredSession, schema, {
|
|
131
209
|
maxSchemaRetries: opts.maxSchemaRetries,
|
|
@@ -175,9 +253,60 @@ export class AcpAgentRunner {
|
|
|
175
253
|
/** Tear down the whole pool (close every long-lived process). Call when the run ends / the
|
|
176
254
|
* runner is disposed. Beyond the AgentRunner seam (additive) — never enters the resume hash. */
|
|
177
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()));
|
|
178
260
|
await this.pool.dispose();
|
|
179
261
|
this.events.removeAllListeners();
|
|
180
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
|
+
}
|
|
181
310
|
}
|
|
182
311
|
/** Factory the mcp-server composition root calls to inject the runner into the engine. The pool
|
|
183
312
|
* size and the custom-backend registry are runner-level options — NOT RunOptions fields, so they
|
|
@@ -232,31 +361,6 @@ async function applyModelSelection(session, spec, opts) {
|
|
|
232
361
|
for (const fallback of modifierFallbacks ?? [])
|
|
233
362
|
opts.onModelFallback?.(fallback);
|
|
234
363
|
}
|
|
235
|
-
function buildPrompt(prompt, opts, schema, backend) {
|
|
236
|
-
const parts = [];
|
|
237
|
-
if (opts.instructions)
|
|
238
|
-
parts.push(opts.instructions);
|
|
239
|
-
if (opts.label)
|
|
240
|
-
parts.push(`Task label: ${opts.label}`);
|
|
241
|
-
parts.push(prompt);
|
|
242
|
-
if (schema) {
|
|
243
|
-
const contract = [
|
|
244
|
-
"Final output contract:",
|
|
245
|
-
"- Your FINAL message MUST be a single JSON object that conforms to the required output schema.",
|
|
246
|
-
"- Output ONLY that JSON object — no prose, no explanation, and no markdown code fences.",
|
|
247
|
-
"- If you need to inspect files or run commands first, do so, then emit the JSON object as your final message.",
|
|
248
|
-
];
|
|
249
|
-
if (backend.embedSchemaInPrompt) {
|
|
250
|
-
// The agent behind a custom backend may ignore the `_meta.outputSchema` forward, so the
|
|
251
|
-
// schema must be STATED, not just wired — otherwise the model invents its own keys and
|
|
252
|
-
// the repair ladder can never converge. Built-ins skip this: their native constraint
|
|
253
|
-
// channel is authoritative.
|
|
254
|
-
contract.push(`- The required output schema (JSON Schema):\n${JSON.stringify(toJsonSchema(schema))}`);
|
|
255
|
-
}
|
|
256
|
-
parts.push(contract.join("\n"));
|
|
257
|
-
}
|
|
258
|
-
return parts.join("\n\n");
|
|
259
|
-
}
|
|
260
364
|
/** Pick the backend by model/tier. Cross-provider routing = which ACP server to spawn.
|
|
261
365
|
* Registered CUSTOM names resolve FIRST (exact name, or `name/<inner-model>` prefix) so a
|
|
262
366
|
* registry entry is never shadowed by the built-in heuristics; then the claude/codex
|
|
@@ -295,11 +399,12 @@ function innerModelSpec(spec, backend) {
|
|
|
295
399
|
return spec.slice(backend.id.length + 1) || undefined;
|
|
296
400
|
return spec;
|
|
297
401
|
}
|
|
298
|
-
/**
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
+
}
|
|
303
408
|
}
|
|
304
409
|
function backendIdForSpec(spec) {
|
|
305
410
|
if (!spec)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@automatalabs/acp-agents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"@agentclientprotocol/claude-agent-acp": "0.55.0",
|
|
29
29
|
"@automatalabs/codex-acp": "1.3.0",
|
|
30
30
|
"typebox": "1.3.2",
|
|
31
|
-
"@automatalabs/shared-types": "0.
|
|
31
|
+
"@automatalabs/shared-types": "0.7.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -b",
|