@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/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 AgentRunner seam
34
- * (additive observability) — subscribing never affects a run and never enters the resume hash. */
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.pool = new AcpAgentPool(options, { onEvent: this.emitEvent });
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, "permission_request", "raw_message", "session_open",
45
- * "session_close", "backend_error"). The listener is typed to the event. Returns an unsubscribe
46
- * thunk. A pooled runner multiplexes many concurrent runs, so each event carries
47
- * `{ sessionId, backendId, label?, runId? }` for filtering. Listeners are best-effort observers:
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 session = await this.pool.acquire(backend, {
173
+ const prepared = this.prepareSession(opts, {
87
174
  cwd,
88
175
  schema,
89
- policy,
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, innerModelSpec(opts.model ?? opts.tier, backend), opts);
110
- const text = buildPrompt(prompt, opts, schema, backend);
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
- /** Merge the generic turn-scoped meta passthrough UNDER the backend-computed turn meta. */
326
- function mergeTurnMeta(user, backend) {
327
- if (!user)
328
- return backend;
329
- return { ...user, ...(backend ?? {}) };
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@automatalabs/acp-agents",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "license": "Apache-2.0",
5
5
  "repository": {
6
6
  "type": "git",