@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/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,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
- const session = await this.pool.acquire(backend, {
172
+ validatePromptImages(opts.images, opts.label);
173
+ const prepared = this.prepareSession(opts, {
86
174
  cwd,
87
175
  schema,
88
- policy,
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, innerModelSpec(opts.model ?? opts.tier, backend), opts);
109
- const text = buildPrompt(prompt, opts, schema, backend);
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(text, promptMeta);
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
- /** Merge the generic turn-scoped meta passthrough UNDER the backend-computed turn meta. */
299
- function mergeTurnMeta(user, backend) {
300
- if (!user)
301
- return backend;
302
- 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
+ }
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.7.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.6.0"
31
+ "@automatalabs/shared-types": "0.7.0"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -b",