@automatalabs/acp-agents 2.0.0 → 3.0.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.
Files changed (72) hide show
  1. package/README.md +53 -17
  2. package/dist/acp-client.d.ts +10 -4
  3. package/dist/acp-client.d.ts.map +1 -1
  4. package/dist/acp-client.js +11 -8
  5. package/dist/agent/acp-agent.d.ts +84 -26
  6. package/dist/agent/acp-agent.d.ts.map +1 -1
  7. package/dist/agent/acp-agent.js +458 -170
  8. package/dist/agent/errors.d.ts +12 -2
  9. package/dist/agent/errors.d.ts.map +1 -1
  10. package/dist/agent/errors.js +38 -9
  11. package/dist/agent/fork.d.ts +7 -5
  12. package/dist/agent/fork.d.ts.map +1 -1
  13. package/dist/agent/fork.js +7 -5
  14. package/dist/agent/messages.d.ts +42 -0
  15. package/dist/agent/messages.d.ts.map +1 -0
  16. package/dist/agent/messages.js +225 -0
  17. package/dist/agent/probe.d.ts +1 -1
  18. package/dist/agent/probe.d.ts.map +1 -1
  19. package/dist/agent/probe.js +5 -1
  20. package/dist/agent/queue.js +2 -2
  21. package/dist/agent/routing.d.ts +19 -1
  22. package/dist/agent/routing.d.ts.map +1 -1
  23. package/dist/agent/routing.js +41 -4
  24. package/dist/agent/stream.d.ts +21 -0
  25. package/dist/agent/stream.d.ts.map +1 -0
  26. package/dist/agent/stream.js +93 -0
  27. package/dist/agent/structured.d.ts +7 -3
  28. package/dist/agent/structured.d.ts.map +1 -1
  29. package/dist/agent/structured.js +24 -14
  30. package/dist/agent/tool-host.d.ts +34 -0
  31. package/dist/agent/tool-host.d.ts.map +1 -0
  32. package/dist/agent/tool-host.js +138 -0
  33. package/dist/agent/tools.d.ts +26 -0
  34. package/dist/agent/tools.d.ts.map +1 -0
  35. package/dist/agent/tools.js +62 -0
  36. package/dist/agent/turn.d.ts +6 -3
  37. package/dist/agent/turn.d.ts.map +1 -1
  38. package/dist/agent/turn.js +15 -67
  39. package/dist/agent/types.d.ts +174 -25
  40. package/dist/agent/types.d.ts.map +1 -1
  41. package/dist/backend.d.ts +4 -3
  42. package/dist/backend.d.ts.map +1 -1
  43. package/dist/backends/codex.d.ts +2 -1
  44. package/dist/backends/codex.d.ts.map +1 -1
  45. package/dist/backends/codex.js +3 -2
  46. package/dist/config-catalog.d.ts +4 -0
  47. package/dist/config-catalog.d.ts.map +1 -1
  48. package/dist/config-catalog.js +1 -0
  49. package/dist/index.d.ts +5 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +6 -0
  52. package/dist/local-mcp-host.d.ts +30 -0
  53. package/dist/local-mcp-host.d.ts.map +1 -0
  54. package/dist/local-mcp-host.js +151 -0
  55. package/dist/protocol-coverage.d.ts +8 -6
  56. package/dist/protocol-coverage.d.ts.map +1 -1
  57. package/dist/protocol-coverage.js +3 -2
  58. package/dist/registry.d.ts +3 -3
  59. package/dist/registry.d.ts.map +1 -1
  60. package/dist/runner.d.ts +5 -0
  61. package/dist/runner.d.ts.map +1 -1
  62. package/dist/runner.js +7 -4
  63. package/dist/structured-output.d.ts +18 -0
  64. package/dist/structured-output.d.ts.map +1 -1
  65. package/dist/structured-output.js +19 -1
  66. package/dist/structured-tool.d.ts +7 -9
  67. package/dist/structured-tool.d.ts.map +1 -1
  68. package/dist/structured-tool.js +14 -102
  69. package/dist/traits.d.ts +51 -0
  70. package/dist/traits.d.ts.map +1 -0
  71. package/dist/traits.js +88 -0
  72. package/package.json +4 -4
@@ -1,18 +1,24 @@
1
1
  import { CANCEL_NOT_HONORED_GRACE_MS, PooledConnection, isChildCleanupError, } from "../acp-client.js";
2
2
  import { validateClientHandlers } from "../client-handlers.js";
3
3
  import { appendPromptImages, buildRunPrompt, mergeTurnMeta, validatePromptImages } from "../prompt.js";
4
- import { assertNoModelConfigOption, resolveModelRoute } from "../routing.js";
4
+ import { assertNoModelConfigOption } from "../routing.js";
5
5
  import { sessionRefFor } from "../session-ref.js";
6
+ import { repairPromptText } from "../structured-output.js";
6
7
  import { StructuredOutputToolHost } from "../structured-tool.js";
7
8
  import { assertSystemPromptSupported } from "../system-prompt.js";
8
- import { agentClosedError, agentTurnError, agentValidationError, mapAgentError } from "./errors.js";
9
+ import { describeBackendTraits } from "../traits.js";
10
+ import { agentClosedError, agentTurnError, agentValidationError, mapAgentError, validateArguments, } from "./errors.js";
9
11
  import { AgentEventBus } from "./events.js";
10
12
  import { acquireForkedSession, forkTraitFor } from "./fork.js";
13
+ import { MessageFolder, copyMessage } from "./messages.js";
11
14
  import { probeCatalog } from "./probe.js";
12
15
  import { releaseOnExit, retainOnExit } from "./process-registry.js";
13
16
  import { SerialQueue } from "./queue.js";
14
- import { freshBackendFor, resolveAgentRegistry, resolveAgentRoute, resolveRefRoute, validateAgentCwd, } from "./routing.js";
15
- import { assertPerTurnSchemaAllowed, planStructured } from "./structured.js";
17
+ import { TurnStream } from "./stream.js";
18
+ import { freshBackendFor, resolveAgentRegistry, resolveAgentRoute, resolveModelSwitch, resolveRefRoute, resolveSameBackendModel, validateAgentCwd, } from "./routing.js";
19
+ import { assertPerTurnSchemaAllowed, planStructured, validateSchemaRetries } from "./structured.js";
20
+ import { AgentToolHost } from "./tool-host.js";
21
+ import { planTools, validateToolDefinitions } from "./tools.js";
16
22
  import { TurnCollector, buildTurn } from "./turn.js";
17
23
  import { ZERO_USAGE, } from "./types.js";
18
24
  /** Handed to the constructor by `AcpAgent.#seeded` only. Set and consumed SYNCHRONOUSLY (the
@@ -90,27 +96,29 @@ function assertSessionRef(ref, label, method) {
90
96
  /**
91
97
  * One ACP agent session on its own dedicated backend process.
92
98
  *
93
- * Lazy: the constructor validates (cwd, `configOptions`, the registry, `clientHandlers`) and
94
- * routes the backend synchronously but spawns nothing; the first queued operation (an explicit
95
- * `ready()` or an implicit `prompt()`) opens the session. `state` walks
96
- * `idle → opening → ready ⇄ busy → closed`.
99
+ * Lazy: the constructor validates (cwd, `configOptions`, the registry, `clientHandlers`, the
100
+ * function-tool definitions, `schemaRetries`) and routes the backend synchronously but spawns nothing; the first
101
+ * queued operation (an explicit `ready()` or an implicit `prompt()`) opens the session. `state`
102
+ * walks `idle → opening → ready ⇄ busy → closed`.
97
103
  */
98
104
  export class AcpAgent {
99
105
  /** The session's absolute working directory (sent on session/new|fork|resume|load). */
100
106
  cwd;
101
107
  /** The human label stamped on event contexts and error `agentLabel`; never on the wire. */
102
108
  label;
103
- /** The model this agent selects at open, as a routing spec that leads back to the same backend
104
- * (`<backendId>/<model id>`, e.g. `"claude/opus[1m]"`), or `undefined` when no model was
105
- * selected (the backend's default). Inherited by forks. An `AgentSessionRef` carries no model,
106
- * so a cold reopen keeps it only when told: `AcpAgent.resume(agent.sessionRef!, { model: agent.model })`. */
107
- model;
108
109
  #options;
109
110
  #seed;
110
111
  #registry;
111
112
  #backend;
113
+ /** The verbatim model id this agent is on (what open sends and a switch replaces); `undefined`
114
+ * = no selection. Read by open's post-open apply and by `fork()` for the child's seed. */
112
115
  #modelSpec;
116
+ /** `#modelSpec` in its routed spec form (`<backendId>/<id>`) — what the `model` getter returns. */
117
+ #model;
113
118
  #schema;
119
+ /** The default repair budget (`schemaRetries`, validated in the constructor); a per-turn value wins. */
120
+ #schemaRetries;
121
+ #tools;
114
122
  #retainHistory;
115
123
  #raw;
116
124
  #signal;
@@ -124,11 +132,22 @@ export class AcpAgent {
124
132
  #handle;
125
133
  #plan;
126
134
  #structuredHost;
135
+ /** The `agent_tools` host, created lazily by the first open that injects it; disposed on close. */
136
+ #toolHost;
137
+ /** The collector of the turn on the wire — what a function tool's `toolCallId` is correlated against. */
138
+ #activeCollector;
127
139
  #sessionId;
128
140
  #sessionRef;
129
141
  #sessionUsage = ZERO_USAGE;
130
142
  #historySeed = [];
131
143
  #textSeed = "";
144
+ #messagesSeed = [];
145
+ /** The retained transcript as messages — the same updates the handle's accumulator folds into
146
+ * `history`/`text`, folded per message. Live once the session is registered; a `load` replay
147
+ * is folded from `#replay` at that point (the accumulator saw it too); a fork's pre-response
148
+ * replay is not (the accumulator never saw it — it lands in `replay` only). */
149
+ #transcript = new MessageFolder();
150
+ #transcriptLive = false;
132
151
  #collectingReplay = false;
133
152
  #activeTurn;
134
153
  #closePromise;
@@ -140,40 +159,54 @@ export class AcpAgent {
140
159
  #forkCount = 0;
141
160
  #removeAbort;
142
161
  /**
143
- * Lazy: validates cwd/configOptions/registry/clientHandlers synchronously, routes the backend,
144
- * spawns nothing. This is the ONLY public constructor signature — seeded agents (forks, cold
145
- * reopen) are built by the statics through a module-private factory.
162
+ * Lazy: validates cwd/configOptions/registry/clientHandlers/tools/schemaRetries synchronously,
163
+ * routes the backend, spawns nothing. This is the ONLY public constructor signature — seeded agents (forks,
164
+ * cold reopen) are built by the statics through a module-private factory.
146
165
  */
147
166
  constructor(options) {
148
167
  const label = options.label;
149
- validateAgentCwd(options.cwd, label, "AcpAgent");
150
- assertNoModelConfigOption(options.configOptions, label);
151
- try {
152
- validateClientHandlers(options.clientHandlers);
153
- }
154
- catch (error) {
155
- throw agentValidationError(error instanceof Error ? error.message : String(error), label);
156
- }
157
- const seed = constructionSeed ?? resolveNewSeed(options);
168
+ // Every pre-spawn guard speaks INVALID_ARGUMENT: the SDK's own throw it directly, a shared
169
+ // validator's SCRIPT_VALIDATION_ERROR is re-coded here at the boundary.
170
+ const { seed, tools, schemaRetries } = validateArguments(() => {
171
+ validateAgentCwd(options.cwd, label, "AcpAgent");
172
+ assertNoModelConfigOption(options.configOptions, label);
173
+ try {
174
+ validateClientHandlers(options.clientHandlers);
175
+ }
176
+ catch (error) {
177
+ throw agentValidationError(error instanceof Error ? error.message : String(error), label);
178
+ }
179
+ const tools = validateToolDefinitions(options.tools, label);
180
+ const schemaRetries = validateSchemaRetries(options.schemaRetries, label, "AcpAgent");
181
+ return { seed: constructionSeed ?? resolveNewSeed(options), tools, schemaRetries };
182
+ });
158
183
  this.#options = { ...options };
184
+ this.#tools = tools;
185
+ this.#schemaRetries = schemaRetries;
159
186
  this.#seed = seed;
160
187
  this.#registry = seed.registry;
161
188
  this.#backend = seed.backend;
162
189
  this.#modelSpec = seed.modelSpec;
163
190
  this.cwd = options.cwd;
164
191
  this.label = label;
165
- this.model = seed.modelSpec === undefined ? undefined : `${seed.backend.id}/${seed.modelSpec}`;
192
+ this.#model = seed.modelSpec === undefined ? undefined : `${seed.backend.id}/${seed.modelSpec}`;
166
193
  this.#schema = options.schema;
167
194
  this.#retainHistory = options.retainHistory ?? true;
168
195
  this.#raw = options.raw ?? true;
169
196
  this.#signal = options.signal;
170
197
  // Verbatim session/update records received before the session was ready (a load's replay, a
171
198
  // fork's pre-response replay) — adopted from the acquisition buffer, observable as `replay`.
199
+ // Once the session is registered, every update is folded into the message transcript.
172
200
  this.#bus.tap((name, event) => {
173
- if (name !== "session_update" || !this.#collectingReplay)
201
+ if (name !== "session_update")
174
202
  return;
175
203
  const { update } = event;
176
- this.#replay.push({ update: structuredClone(update), receivedAt: Date.now() });
204
+ if (this.#collectingReplay) {
205
+ this.#replay.push({ update: structuredClone(update), receivedAt: Date.now() });
206
+ }
207
+ else if (this.#transcriptLive) {
208
+ this.#transcript.apply(structuredClone(update), Date.now());
209
+ }
177
210
  });
178
211
  if (options.signal) {
179
212
  const signal = options.signal;
@@ -218,6 +251,17 @@ export class AcpAgent {
218
251
  static probe(options = {}) {
219
252
  return probeCatalog(options);
220
253
  }
254
+ /** The table-based traits of the backend `spec` routes to — exactly the constructor's routing
255
+ * (`backends` merged over `AGENTPRISM_BACKENDS`, a registered name wins, an unrouted spec goes
256
+ * to the default backend) — without spawning anything. The instance getter refines the same
257
+ * shape with the live initialize advertisements once the agent is open. */
258
+ static traits(spec, options = {}) {
259
+ return validateArguments(() => {
260
+ const registry = resolveAgentRegistry(options.backends);
261
+ const route = resolveAgentRoute({ model: spec }, registry);
262
+ return describeBackendTraits(route.backend, registry);
263
+ });
264
+ }
221
265
  /** `session/resume` of `ref.sessionId` on a fresh dedicated process of `ref.backendId`
222
266
  * (routed by name — never the default backend — and pool-key checked). `cwd` defaults to
223
267
  * `ref.cwd`; `model` must stay on the ref's backend. */
@@ -230,33 +274,34 @@ export class AcpAgent {
230
274
  static load(ref, options = {}) {
231
275
  return AcpAgent.#reopen("load", ref, options);
232
276
  }
233
- /** Cold fork of a recorded session: the trait-driven choreography without a history seed
234
- * (`history` starts empty on id-only backends unless the reattach fell back to `session/load`).
235
- * To seed the fork with the transcript: `const src = await AcpAgent.load(ref); await src.fork()`. */
277
+ /** Cold fork of a recorded session: the trait-driven choreography with no parent to seed from,
278
+ * so on an id-only backend the reattach PREFERS `session/load` — the agent replays the forked
279
+ * transcript and it lands in the child's `history`/`text`/`messages`/`replay` — and takes
280
+ * `session/resume` (an empty transcript) only when load is not advertised. On a live backend the
281
+ * fork handle is the session and the child's transcript is whatever the agent streams. */
236
282
  static fork(ref, options = {}) {
237
283
  return AcpAgent.#reopen("fork", ref, options);
238
284
  }
239
285
  static async #reopen(kind, ref, options) {
240
286
  const label = options.label;
241
287
  const method = `AcpAgent.${kind}`;
242
- assertSessionRef(ref, label, method);
243
- const cwd = options.cwd ?? ref.cwd;
244
- validateAgentCwd(cwd, label, method);
245
- const registry = resolveAgentRegistry(options.backends, label);
246
- const route = resolveRefRoute(ref, options.model, registry, label);
247
- assertSystemPromptSupported(route.backend, options.systemPrompt, label);
248
- const base = { registry, backend: route.backend, modelSpec: route.modelSpec };
249
- let seed;
250
- if (kind === "fork") {
251
- const trait = forkTraitFor(route.backend, registry);
252
- if (trait.cwd === "source-only" && cwd !== ref.cwd) {
253
- throw agentValidationError(`fork on ${route.backend.id} must keep the source cwd (${ref.cwd})`, label);
288
+ const { cwd, seed } = validateArguments(() => {
289
+ assertSessionRef(ref, label, method);
290
+ const cwd = options.cwd ?? ref.cwd;
291
+ validateAgentCwd(cwd, label, method);
292
+ const registry = resolveAgentRegistry(options.backends, label);
293
+ const route = resolveRefRoute(ref, options.model, registry, label);
294
+ assertSystemPromptSupported(route.backend, options.systemPrompt, label);
295
+ const base = { registry, backend: route.backend, modelSpec: route.modelSpec };
296
+ if (kind === "fork") {
297
+ const trait = forkTraitFor(route.backend, registry);
298
+ if (trait.cwd === "source-only" && cwd !== ref.cwd) {
299
+ throw agentValidationError(`fork on ${route.backend.id} must keep the source cwd (${ref.cwd})`, label);
300
+ }
301
+ return { cwd, seed: { kind, sourceSessionId: ref.sessionId, reattach: "load", ...base } };
254
302
  }
255
- seed = { kind, sourceSessionId: ref.sessionId, ...base };
256
- }
257
- else {
258
- seed = { kind, sessionId: ref.sessionId, ...base };
259
- }
303
+ return { cwd, seed: { kind, sessionId: ref.sessionId, ...base } };
304
+ });
260
305
  return AcpAgent.#opened(AcpAgent.#seeded({ ...options, cwd }, seed));
261
306
  }
262
307
  // ── Getters (all readable after close; they return retained values) ──
@@ -264,6 +309,15 @@ export class AcpAgent {
264
309
  get backendId() {
265
310
  return this.#backend.id;
266
311
  }
312
+ /** The model this agent is on, as a routing spec that leads back to the same backend
313
+ * (`<backendId>/<model id>`, e.g. `"claude/opus[1m]"`): the constructor's `model` until a
314
+ * `setModel()` or a per-turn `model` applied, then the switched one; `undefined` when nothing
315
+ * was ever selected (the backend's default). Inherited by forks taken after the switch. An
316
+ * `AgentSessionRef` carries no model, so a cold reopen keeps it only when told:
317
+ * `AcpAgent.resume(agent.sessionRef!, { model: agent.model })`. */
318
+ get model() {
319
+ return this.#model;
320
+ }
267
321
  /** `idle` → `opening` → `ready` ⇄ `busy` → `closed` (set the instant `close()` is called,
268
322
  * the constructor signal aborts, or the process dies). */
269
323
  get state() {
@@ -285,6 +339,12 @@ export class AcpAgent {
285
339
  get capabilities() {
286
340
  return this.#connection?.capabilities;
287
341
  }
342
+ /** This backend's traits (`describeBackendTraits`): the tables before open, refined by the live
343
+ * initialize advertisements once the connection is up (retained after close). A fresh frozen
344
+ * object per read. */
345
+ get traits() {
346
+ return describeBackendTraits(this.#backend, this.#registry, this.#connection?.capabilities);
347
+ }
288
348
  /** The latest echoed session config-option catalog (verbatim ACP wire shapes). */
289
349
  get configOptions() {
290
350
  return this.#handle?.advertisedConfigOptions ?? [];
@@ -312,6 +372,13 @@ export class AcpAgent {
312
372
  get text() {
313
373
  return [this.#textSeed, this.#handle?.foldedText() ?? ""].filter((part) => part !== "").join("\n\n");
314
374
  }
375
+ /** The retained transcript as messages (`AcpAgentMessage`, copies on read): `[...seed, ...this
376
+ * session's messages]` — the same retained log `history`/`text` describe, folded per message
377
+ * with the turn fold's boundary, a `load` replay included, seeded from the parent's snapshot on
378
+ * a live fork, and holding only the latest turn under `retainHistory: false`. */
379
+ get messages() {
380
+ return [...this.#messagesSeed.map(copyMessage), ...this.#transcript.snapshot()];
381
+ }
315
382
  /** Running per-field sum of every turn this agent ran; `ZERO_USAGE` before the first turn. */
316
383
  get usage() {
317
384
  return this.#sessionUsage;
@@ -347,103 +414,224 @@ export class AcpAgent {
347
414
  * cancelled included). Rejects only on a wire rejection (mapped like the runner), validation,
348
415
  * abort (`signal.reason` untouched), a closed agent, or a typed session failure (the mapped
349
416
  * `WorkflowError` carrying the complete turn as `error.turn`; see `isAcpAgentTurnError`).
350
- * `configOptions`/`mode` are applied before the turn and stick for the session. To stop a
351
- * specific turn use `options.signal`: it rejects while queued or before the turn reached the
352
- * wire (nothing is sent) and sends one `session/cancel` once in flight — `cancel()` reaches only
353
- * a turn already on the wire.
417
+ * `model`/`configOptions`/`mode` are applied before the turn, in that order, and stick for the
418
+ * session. With a schema active and a `schemaRetries` budget > 0 (constructor or per turn), an
419
+ * `end_turn` that left `structured` absent is followed by up to that many repair turns inside
420
+ * this same operation; the resolved turn is the final attempt's with `structuredAttempts`. To
421
+ * stop a specific turn use `options.signal`: it rejects while queued or before the turn reached
422
+ * the wire (nothing is sent) and sends one `session/cancel` once in flight — `cancel()` reaches
423
+ * only a turn already on the wire (a repair turn included).
354
424
  */
355
425
  prompt(content, options = {}) {
356
- return this.#enqueue(async () => {
357
- await this.#ensureOpen();
358
- options.signal?.throwIfAborted();
359
- this.#signal?.throwIfAborted();
360
- const handle = this.#handle;
361
- const plan = this.#plan;
362
- const backend = this.#backend;
363
- const label = this.label;
426
+ return this.#enqueue(() => this.#promptTurn(content, options, "prompt"), options.signal);
427
+ }
428
+ /**
429
+ * The same turn as `prompt()`, observed as an async iterable of this turn's events: every bus
430
+ * event emitted while the turn's operation runs — from the instant it is dequeued (a lazy
431
+ * open's `session_open`, a per-turn `mode`'s `current_mode_update` included) until the wire
432
+ * settles — each tagged `{ type: <event name>, ...payload }` (an update once, under its kind;
433
+ * never the `session_update` catch-all; a concurrent `steer()`'s `steering` event and the
434
+ * permission / elicitation / raw_message events included), then the terminal
435
+ * `{ type: "turn", turn }` with exactly the `AcpAgentTurn` `prompt()` would have resolved.
436
+ * The turn is queued (FIFO) by this call, exactly like `prompt()`; events are buffered without
437
+ * dropping until consumed. When the turn rejects, the iterator yields the buffered events, then
438
+ * throws that same error. Leaving early — `break`, `return()`, `throw()` — aborts the turn's
439
+ * per-call signal: a turn still queued or not yet on the wire is dropped with nothing sent, a
440
+ * turn in flight gets the one `session/cancel` (and the agent's escalation), and the call
441
+ * resolves only once the turn settled, so no turn keeps running unobserved.
442
+ */
443
+ stream(content, options = {}) {
444
+ // The consumer's exit is not an error: the turn rejects with this marker, which never leaves
445
+ // the stream (a caller's own `options.signal` reason does — the iterator throws it).
446
+ const left = new Error("AcpAgent.stream(): the consumer stopped iterating");
447
+ const controller = new AbortController();
448
+ const outer = options.signal;
449
+ const forward = () => controller.abort(outer?.reason);
450
+ if (outer?.aborted)
451
+ controller.abort(outer.reason);
452
+ else
453
+ outer?.addEventListener("abort", forward, { once: true });
454
+ const stream = new TurnStream(() => {
455
+ controller.abort(left);
456
+ return settled.then(noop, noop);
457
+ });
458
+ // The tap is attached INSIDE the queued operation, the instant this turn is dequeued: from
459
+ // outside the queue it would observe the previous turn's events too.
460
+ const settled = this.#enqueue(async () => {
461
+ const untap = this.#bus.tap((name, event) => {
462
+ if (name === "session_update")
463
+ return;
464
+ stream.push({ type: name, ...event });
465
+ });
466
+ try {
467
+ const turn = await this.#promptTurn(content, { ...options, signal: controller.signal }, "stream");
468
+ stream.push({ type: "turn", turn });
469
+ return turn;
470
+ }
471
+ finally {
472
+ untap();
473
+ }
474
+ }, controller.signal);
475
+ void settled.then(() => stream.end(), (error) => {
476
+ if (error === left)
477
+ stream.end();
478
+ else
479
+ stream.fail(error);
480
+ });
481
+ void settled.then(noop, noop).finally(() => outer?.removeEventListener("abort", forward));
482
+ return stream;
483
+ }
484
+ /** The body of one turn — runs inside the FIFO (`prompt()` queues it directly, `stream()` under
485
+ * its tap). `options.signal` is the per-call signal; `stream()` hands in its own combined one.
486
+ * `entry` is the public method the caller used, so a refused per-turn option names it. */
487
+ async #promptTurn(content, options, entry) {
488
+ await this.#ensureOpen();
489
+ options.signal?.throwIfAborted();
490
+ this.#signal?.throwIfAborted();
491
+ const handle = this.#handle;
492
+ const plan = this.#plan;
493
+ const backend = this.#backend;
494
+ const label = this.label;
495
+ // Every per-turn option is validated up front — a rejected turn sends nothing, not even the
496
+ // options that would have passed.
497
+ const { modelSwitch, schemaRetries } = validateArguments(() => {
364
498
  validatePromptImages(options.images, label);
365
499
  assertPerTurnSchemaAllowed(backend, options.schema, label);
366
500
  assertNoModelConfigOption(options.configOptions, label);
367
501
  assertKnownConfigOptionIds(options.configOptions, handle.advertisedConfigOptions, this.backendId, label);
368
- try {
369
- if (options.configOptions) {
370
- await handle.setConfigOptions(options.configOptions);
371
- options.signal?.throwIfAborted();
372
- this.#signal?.throwIfAborted();
373
- }
374
- if (options.mode !== undefined) {
375
- await handle.setMode(options.mode);
376
- options.signal?.throwIfAborted();
377
- this.#signal?.throwIfAborted();
378
- }
379
- }
380
- catch (error) {
381
- throw mapAgentError(error, this.#errorContext(), options.signal?.aborted ? options.signal : this.#signal);
382
- }
383
- const turnSchema = options.schema ?? this.#schema;
384
- // Same request shaping as the runner: a generic backend whose agent may ignore the `_meta`
385
- // forward gets the contract stated in-band; backend turn meta wins only direct collisions.
386
- const shaped = typeof content === "string" && turnSchema !== undefined && backend.embedSchemaInPrompt
387
- ? buildRunPrompt(content, {}, turnSchema, backend, plan.toolActive)
388
- : content;
389
- const turnContent = appendPromptImages(shaped, options.images);
390
- const promptMeta = mergeTurnMeta(options.meta, backend.promptMeta(turnSchema));
391
- // SYNCHRONOUSLY before the wire call: the collector's tap sees every update of the turn.
392
- const collector = new TurnCollector(this.#bus, handle, { retainHistory: this.#retainHistory });
393
- // A capture left by a turn that rejected (wire error/abort) must not leak into this turn.
394
- plan.registration?.takeCaptured();
395
- const outcome = handle.promptOutcome(turnContent, promptMeta);
396
- const active = { ended: outcome.then(noop, noop), aborted: false };
397
- this.#activeTurn = active;
398
- const callSignal = options.signal;
399
- const onCallAbort = () => {
400
- active.aborted = true;
401
- active.abortReason = callSignal?.reason;
402
- void this.#cancelTurn().catch(noop);
502
+ return {
503
+ modelSwitch: options.model === undefined
504
+ ? undefined
505
+ : resolveModelSwitch(options.model, backend, this.#registry, label, `AcpAgent.${entry}({ model })`),
506
+ schemaRetries: options.schemaRetries === undefined
507
+ ? this.#schemaRetries
508
+ : validateSchemaRetries(options.schemaRetries, label, `AcpAgent.${entry}({ schemaRetries })`),
403
509
  };
404
- callSignal?.addEventListener("abort", onCallAbort, { once: true });
405
- let response;
406
- let failure;
407
- try {
408
- ({ response, failure } = await outcome);
510
+ });
511
+ try {
512
+ // The open's order, verbatim: model, config options, mode.
513
+ if (modelSwitch) {
514
+ await this.#applyModelSwitch(handle, modelSwitch);
515
+ options.signal?.throwIfAborted();
516
+ this.#signal?.throwIfAborted();
409
517
  }
410
- catch (error) {
411
- if (active.aborted)
412
- throw active.abortReason;
413
- if (this.#signal?.aborted)
414
- throw this.#signal.reason;
415
- throw mapAgentError(error, this.#errorContext());
518
+ if (options.configOptions) {
519
+ await handle.setConfigOptions(options.configOptions);
520
+ options.signal?.throwIfAborted();
521
+ this.#signal?.throwIfAborted();
416
522
  }
417
- finally {
418
- collector.stop();
419
- callSignal?.removeEventListener("abort", onCallAbort);
420
- if (this.#activeTurn === active)
421
- this.#activeTurn = undefined;
523
+ if (options.mode !== undefined) {
524
+ await handle.setMode(options.mode);
525
+ options.signal?.throwIfAborted();
526
+ this.#signal?.throwIfAborted();
422
527
  }
423
- // An abort observed in flight rejects with the reason even when the agent answered
424
- // `stopReason: "cancelled"` — abort is never a resolved turn.
528
+ }
529
+ catch (error) {
530
+ throw mapAgentError(error, this.#errorContext(), options.signal?.aborted ? options.signal : this.#signal);
531
+ }
532
+ const turnSchema = options.schema ?? this.#schema;
533
+ // Same request shaping as the runner: a generic backend whose agent may ignore the `_meta`
534
+ // forward gets the contract stated in-band; backend turn meta wins only direct collisions.
535
+ const shaped = typeof content === "string" && turnSchema !== undefined && backend.embedSchemaInPrompt
536
+ ? buildRunPrompt(content, {}, turnSchema, backend, plan.toolActive)
537
+ : content;
538
+ const turnContent = appendPromptImages(shaped, options.images);
539
+ const promptMeta = mergeTurnMeta(options.meta, backend.promptMeta(turnSchema));
540
+ let attempt = await this.#runTurn(turnContent, promptMeta, turnSchema, options.signal);
541
+ if (turnSchema === undefined) {
542
+ if (attempt.failure)
543
+ throw agentTurnError(attempt.failure, attempt.turn, this.#errorContext());
544
+ return attempt.turn;
545
+ }
546
+ // The opt-in repair ladder (`schemaRetries`, default 0): re-prompt the SAME session with the
547
+ // runner's repair prompt — the StructuredOutput-tool variant when the tool is active, else
548
+ // the JSON one, plus the attempt's validation failure — and the same turn `_meta`, so the
549
+ // native channel stays authoritative (Codex's per-turn `outputSchema` rides the repair too).
550
+ // Text only, like the runner's repair turns: no images, no re-embedded contract. Only an
551
+ // `end_turn` miss is repaired — a cancelled, refused, or truncated attempt (or a walled one)
552
+ // ends the ladder where it stands; a per-call or agent abort between attempts rejects.
553
+ let attempts = 1;
554
+ while (attempts <= schemaRetries &&
555
+ attempt.failure === undefined &&
556
+ attempt.turn.structured === undefined &&
557
+ attempt.turn.stopReason === "end_turn") {
558
+ options.signal?.throwIfAborted();
559
+ this.#signal?.throwIfAborted();
560
+ const repair = repairPromptText({ toolActive: plan.toolActive, reason: attempt.turn.structuredError });
561
+ attempt = await this.#runTurn(repair, promptMeta, turnSchema, options.signal);
562
+ attempts += 1;
563
+ }
564
+ const turn = { ...attempt.turn, structuredAttempts: attempts };
565
+ if (attempt.failure)
566
+ throw agentTurnError(attempt.failure, turn, this.#errorContext());
567
+ return turn;
568
+ }
569
+ /** ONE `session/prompt` on the wire and its fold: the collector's tap is attached synchronously
570
+ * before the call, the turn is the agent's active turn for `cancel()`/`steer()`/tool
571
+ * correlation while it flies, an abort observed in flight rejects with the reason (never a
572
+ * resolved `cancelled` turn), and the turn's tokens are added to the session sum whether or
573
+ * not it was walled. `#promptTurn` runs it once, plus once per repair. */
574
+ async #runTurn(content, promptMeta, schema, callSignal) {
575
+ const handle = this.#handle;
576
+ const plan = this.#plan;
577
+ // SYNCHRONOUSLY before the wire call: the collector's tap sees every update of the turn, and
578
+ // the transcript marks the turn boundary exactly where the handle's accumulator does
579
+ // (`SessionState.beginTurn()` inside `promptOutcome`) — nothing can interleave.
580
+ const collector = new TurnCollector(this.#bus, handle, { retainHistory: this.#retainHistory });
581
+ this.#transcript.beginTurn(this.#retainHistory);
582
+ // A capture left by a turn that rejected (wire error/abort) must not leak into this turn.
583
+ plan.registration?.takeCaptured();
584
+ const outcome = handle.promptOutcome(content, promptMeta);
585
+ const active = { ended: outcome.then(noop, noop), aborted: false };
586
+ this.#activeTurn = active;
587
+ this.#activeCollector = collector;
588
+ const onCallAbort = () => {
589
+ active.aborted = true;
590
+ active.abortReason = callSignal?.reason;
591
+ void this.#cancelTurn(callSignal?.reason).catch(noop);
592
+ };
593
+ callSignal?.addEventListener("abort", onCallAbort, { once: true });
594
+ let response;
595
+ let failure;
596
+ try {
597
+ ({ response, failure } = await outcome);
598
+ }
599
+ catch (error) {
425
600
  if (active.aborted)
426
601
  throw active.abortReason;
427
602
  if (this.#signal?.aborted)
428
603
  throw this.#signal.reason;
429
- const turn = buildTurn({
430
- response,
431
- collector,
432
- handle,
433
- backend,
434
- schema: turnSchema,
435
- captured: plan.registration?.takeCaptured(),
436
- sessionBefore: this.#sessionUsage,
437
- });
438
- // A walled turn still counts the tokens it burned.
439
- this.#sessionUsage = turn.usage.session;
440
- if (failure)
441
- throw agentTurnError(failure, turn, this.#errorContext());
442
- return turn;
443
- }, options.signal);
604
+ throw mapAgentError(error, this.#errorContext());
605
+ }
606
+ finally {
607
+ collector.stop();
608
+ callSignal?.removeEventListener("abort", onCallAbort);
609
+ if (this.#activeTurn === active)
610
+ this.#activeTurn = undefined;
611
+ if (this.#activeCollector === collector)
612
+ this.#activeCollector = undefined;
613
+ }
614
+ // An abort observed in flight rejects with the reason even when the agent answered
615
+ // `stopReason: "cancelled"` — abort is never a resolved turn.
616
+ if (active.aborted)
617
+ throw active.abortReason;
618
+ if (this.#signal?.aborted)
619
+ throw this.#signal.reason;
620
+ const turn = buildTurn({
621
+ response,
622
+ collector,
623
+ handle,
624
+ backend: this.#backend,
625
+ schema,
626
+ captured: plan.registration?.takeCaptured(),
627
+ sessionBefore: this.#sessionUsage,
628
+ });
629
+ // A walled turn still counts the tokens it burned.
630
+ this.#sessionUsage = turn.usage.session;
631
+ return failure ? { turn, failure } : { turn };
444
632
  }
445
633
  /** Inject content into the turn in flight (`_session/steering`). Overlaps the FIFO; requires a
446
- * `prompt()` in flight (SCRIPT_VALIDATION_ERROR otherwise). The complete raw response is returned. */
634
+ * `prompt()` in flight (INVALID_ARGUMENT otherwise). The complete raw response is returned. */
447
635
  async steer(content, options = {}) {
448
636
  this.#signal?.throwIfAborted();
449
637
  if (this.#closed)
@@ -452,7 +640,7 @@ export class AcpAgent {
452
640
  if (!this.#activeTurn || !handle) {
453
641
  throw agentValidationError("AcpAgent.steer() requires a prompt() in flight", this.label);
454
642
  }
455
- validatePromptImages(options.images, this.label);
643
+ validateArguments(() => validatePromptImages(options.images, this.label));
456
644
  try {
457
645
  return await handle.steer(appendPromptImages(content, options.images), options.meta);
458
646
  }
@@ -466,11 +654,11 @@ export class AcpAgent {
466
654
  * grace period ends in process disposal WITHOUT a wire `session/close` (the session stays
467
655
  * re-openable through `sessionRef`); the turn then rejects and the agent is closed. Queued
468
656
  * turns are untouched, and so is a turn that has started (`state === "busy"`) but has not
469
- * reached the wire yet — the lazy first open, or its per-turn `configOptions`/`mode` — a
470
- * `cancel()` in that window is a no-op the turn never sees. A per-call `signal` covers every
657
+ * reached the wire yet — the lazy first open, or its per-turn `model`/`configOptions`/`mode` —
658
+ * a `cancel()` in that window is a no-op the turn never sees. A per-call `signal` covers every
471
659
  * window (rejects with the reason before anything is sent; `session/cancel` once in flight). */
472
660
  cancel() {
473
- return this.#cancelTurn();
661
+ return this.#cancelTurn(new Error("AcpAgent.cancel(): the turn was cancelled"));
474
662
  }
475
663
  /**
476
664
  * Fork this agent onto a NEW dedicated process (queued: it runs only when no turn is in flight,
@@ -505,35 +693,73 @@ export class AcpAgent {
505
693
  providerStore: this.#options.providerStore,
506
694
  clientHandlers: this.#options.clientHandlers,
507
695
  };
508
- validateAgentCwd(cwd, this.label, "AcpAgent.fork");
509
- if (trait.cwd === "source-only" && cwd !== this.cwd) {
510
- throw agentValidationError(`fork on ${this.backendId} must keep the source cwd (${this.cwd})`, this.label);
511
- }
512
- let route;
513
- if (overrides.model !== undefined) {
514
- route = resolveModelRoute(overrides.model, this.#registry);
515
- const samePool = (route.backend.poolKey ?? route.backend.id) === (this.#backend.poolKey ?? this.backendId);
516
- if (route.backend.id !== this.backendId || !samePool) {
517
- throw agentValidationError(`fork model "${overrides.model}" must stay on backend "${this.backendId}"`, this.label);
696
+ const route = validateArguments(() => {
697
+ validateAgentCwd(cwd, this.label, "AcpAgent.fork");
698
+ if (trait.cwd === "source-only" && cwd !== this.cwd) {
699
+ throw agentValidationError(`fork on ${this.backendId} must keep the source cwd (${this.cwd})`, this.label);
518
700
  }
519
- }
520
- assertNoModelConfigOption(merged.configOptions, label);
521
- // The backend is fixed by the parent, so an inherited value already passed; an override
522
- // is validated here, before the child's process spawns.
523
- assertSystemPromptSupported(this.#backend, merged.systemPrompt, label);
701
+ let route;
702
+ if (overrides.model !== undefined) {
703
+ route = resolveSameBackendModel(overrides.model, this.#backend, this.#registry, this.label, "AcpAgent.fork()");
704
+ }
705
+ assertNoModelConfigOption(merged.configOptions, label);
706
+ // The backend is fixed by the parent, so an inherited value already passed; an override
707
+ // is validated here, before the child's process spawns.
708
+ assertSystemPromptSupported(this.#backend, merged.systemPrompt, label);
709
+ return route;
710
+ });
524
711
  const child = AcpAgent.#seeded(merged, {
525
712
  kind: "fork",
526
713
  sourceSessionId: handle.sessionId,
714
+ // The parent's snapshot seeds the child: reattach without a replay (load is the fallback).
715
+ reattach: "resume",
527
716
  registry: this.#registry,
528
717
  backend: route?.backend ?? freshBackendFor(this.#backend, this.#registry),
718
+ // The model the parent is on NOW (a `setModel` / per-turn switch included), not its
719
+ // constructor option — `merged.model` is never read for a seeded child.
529
720
  modelSpec: route ? route.modelSpec : this.#modelSpec,
530
721
  historySeed: this.history.map((entry) => ({ ...entry })),
531
722
  textSeed: this.text,
723
+ messagesSeed: [...this.messages],
532
724
  });
533
725
  return AcpAgent.#opened(child);
534
726
  });
535
727
  }
536
- /** `session/set_mode` (queued; strict — an unadvertised id is a SCRIPT_VALIDATION_ERROR). */
728
+ /**
729
+ * Switch the session's model (queued, FIFO like `setMode`; sticky). `spec` is resolved with the
730
+ * fork rule — the runner's routing grammar, and it must land on this agent's backend and poolKey
731
+ * (`"<backendId>/<model id>"`; an unrouted spec goes to the default backend and passes only when
732
+ * that is this one) — otherwise INVALID_ARGUMENT naming both backends. The route is checked
733
+ * BEFORE the operation is queued, so a refused spec sends nothing and, on an agent that has not
734
+ * opened yet, spawns nothing (the agent stays `idle`). A backend-only spec is INVALID_ARGUMENT
735
+ * too: there is no wire form for "unselect". Applied exactly like open's selection —
736
+ * `session/set_config_option { configId: "model" }` with the routed remainder verbatim; no
737
+ * aliases, coercion, catalog matching, or fallback; the agent's catalog and validation are
738
+ * authoritative and a wire rejection maps through the normal error path with `model` unchanged.
739
+ * On success `model` becomes the routed spec, so later forks and a cold reopen passing
740
+ * `agent.model` back inherit the switch. `"model"` stays reserved in `configOptions`; this is
741
+ * the one way to move it after open.
742
+ */
743
+ setModel(spec) {
744
+ let modelSwitch;
745
+ try {
746
+ modelSwitch = validateArguments(() => resolveModelSwitch(spec, this.#backend, this.#registry, this.label, "AcpAgent.setModel()"));
747
+ }
748
+ catch (error) {
749
+ return Promise.reject(error);
750
+ }
751
+ return this.#enqueue(async () => {
752
+ await this.#ensureOpen();
753
+ this.#signal?.throwIfAborted();
754
+ try {
755
+ await this.#applyModelSwitch(this.#handle, modelSwitch);
756
+ }
757
+ catch (error) {
758
+ throw mapAgentError(error, this.#errorContext(), this.#signal);
759
+ }
760
+ });
761
+ }
762
+ /** `session/set_mode` (queued; strict — an unadvertised id is INVALID_ARGUMENT). */
537
763
  setMode(modeId) {
538
764
  return this.#enqueue(async () => {
539
765
  await this.#ensureOpen();
@@ -553,8 +779,10 @@ export class AcpAgent {
553
779
  await this.#ensureOpen();
554
780
  this.#signal?.throwIfAborted();
555
781
  const handle = this.#handle;
556
- assertNoModelConfigOption(options, this.label);
557
- assertKnownConfigOptionIds(options, handle.advertisedConfigOptions, this.backendId, this.label);
782
+ validateArguments(() => {
783
+ assertNoModelConfigOption(options, this.label);
784
+ assertKnownConfigOptionIds(options, handle.advertisedConfigOptions, this.backendId, this.label);
785
+ });
558
786
  try {
559
787
  await handle.setConfigOptions(options);
560
788
  }
@@ -567,8 +795,9 @@ export class AcpAgent {
567
795
  * Close: `state` becomes `closed` immediately (no new work is admitted), the teardown waits
568
796
  * behind queued work, releases the session (`keep: true` skips the wire `session/close` so the
569
797
  * agent-persisted session stays re-openable via `sessionRef`), disposes the dedicated process,
570
- * and releases the structured-output tool. Idempotent (same promise); never throws for an
571
- * already-dead process; rethrows only a `child_cleanup_error` (mapped, non-recoverable).
798
+ * releases the structured-output tool, and closes the function-tool host (aborting any running
799
+ * `execute`). Idempotent (same promise); never throws for an already-dead process; rethrows
800
+ * only a `child_cleanup_error` (mapped, non-recoverable).
572
801
  */
573
802
  close(options = {}) {
574
803
  this.#closeKeep ??= options.keep === true;
@@ -624,7 +853,7 @@ export class AcpAgent {
624
853
  return {
625
854
  cwd: this.cwd,
626
855
  schema: this.#schema,
627
- policy: options.tools ?? {},
856
+ policy: options.permissions ?? {},
628
857
  permissionResolver: options.onPermissionRequest,
629
858
  enforceToolPolicyBeforePermissionResolver: false,
630
859
  elicitationResolver: options.onElicitation,
@@ -637,13 +866,46 @@ export class AcpAgent {
637
866
  retainSessionLog: this.#retainHistory,
638
867
  };
639
868
  }
640
- #planStructured(connection) {
641
- return planStructured({
869
+ /** After initialize (the capabilities are known): the structured-output injection, then the
870
+ * function-tool injection appended to the same `mcpServers` — or the INVALID_ARGUMENT refusal
871
+ * when the agent does not advertise HTTP MCP for the tools it was given. */
872
+ async #planSession(connection) {
873
+ const structured = await planStructured({
642
874
  schema: this.#schema,
643
875
  backend: this.#backend,
644
876
  mcpServers: this.#options.mcpServers,
645
877
  host: () => (this.#structuredHost ??= new StructuredOutputToolHost()),
646
878
  }, connection);
879
+ const mcpServers = await planTools({
880
+ tools: this.#tools,
881
+ backendId: this.backendId,
882
+ label: this.label,
883
+ mcpServers: structured.mcpServers,
884
+ host: () => (this.#toolHost ??= new AgentToolHost(this.#tools, () => this.#toolContext())),
885
+ }, connection);
886
+ return { ...structured, mcpServers };
887
+ }
888
+ #toolContext() {
889
+ return {
890
+ sessionId: this.#sessionId,
891
+ backendId: this.backendId,
892
+ label: this.label,
893
+ resolveToolCallId: (toolName) => this.#resolveToolCallId(toolName),
894
+ };
895
+ }
896
+ /** Best-effort: the latest unsettled `tool_call` of the turn in flight whose standard `name`
897
+ * (or `title`) is the tool's name or ends in `__<name>` (pi's `mcp__agent_tools__<name>`). */
898
+ #resolveToolCallId(toolName) {
899
+ const calls = this.#activeCollector?.toolCalls ?? [];
900
+ for (let index = calls.length - 1; index >= 0; index -= 1) {
901
+ const call = calls[index];
902
+ if (call.status === "completed" || call.status === "failed")
903
+ continue;
904
+ const names = [call.name, call.title].filter((value) => typeof value === "string");
905
+ if (names.some((name) => name === toolName || name.endsWith(`__${toolName}`)))
906
+ return call.toolCallId;
907
+ }
908
+ return undefined;
647
909
  }
648
910
  async #open() {
649
911
  let handle;
@@ -662,18 +924,18 @@ export class AcpAgent {
662
924
  if (seed.kind === "new") {
663
925
  // `prepare` runs after initialize, so the injection decision sees the capabilities.
664
926
  handle = await connection.openPreparedSession(async (ready) => {
665
- plan = await this.#planStructured(ready);
927
+ plan = await this.#planSession(ready);
666
928
  return this.#sessionOptions(plan);
667
929
  });
668
930
  }
669
931
  else {
670
932
  // The cheapest "await initialize": the injection decision needs the capabilities.
671
933
  await connection.authMethods();
672
- plan = await this.#planStructured(connection);
934
+ plan = await this.#planSession(connection);
673
935
  const opts = this.#sessionOptions(plan);
674
936
  if (seed.kind === "fork") {
675
937
  const trait = forkTraitFor(this.#backend, this.#registry);
676
- const acquired = await acquireForkedSession(connection, seed.sourceSessionId, opts, trait);
938
+ const acquired = await acquireForkedSession(connection, seed.sourceSessionId, opts, trait, seed.reattach);
677
939
  handle = acquired.handle;
678
940
  replayed = acquired.method === "load";
679
941
  }
@@ -694,10 +956,19 @@ export class AcpAgent {
694
956
  this.#sessionId = handle.sessionId;
695
957
  this.#bus.endAcquisition(handle.sessionId);
696
958
  this.#collectingReplay = false;
959
+ // The transcript follows the handle's accumulator: a replay the registered session applied
960
+ // (`session/load`, the id-only fork's load fallback) is folded from the adopted records; a
961
+ // pre-response fork replay was never applied there and stays in `replay` alone. Live from
962
+ // here on — synchronously, so no later update can slip between the two.
963
+ if (replayed) {
964
+ for (const { update, receivedAt } of this.#replay)
965
+ this.#transcript.apply(structuredClone(update), receivedAt);
966
+ }
967
+ this.#transcriptLive = true;
697
968
  this.#signal?.throwIfAborted();
698
969
  await this.#applyPostOpen(handle);
699
970
  if (seed.kind === "fork")
700
- this.#seedHistory(seed.historySeed, seed.textSeed, handle);
971
+ this.#seedHistory(seed, handle);
701
972
  this.#sessionRef = sessionRefFor(handle, this.#backend, this.cwd);
702
973
  this.#sessionUsage = ZERO_USAGE;
703
974
  }
@@ -713,6 +984,14 @@ export class AcpAgent {
713
984
  throw this.#signal?.aborted ? this.#signal.reason : mapAgentError(error, this.#errorContext());
714
985
  }
715
986
  }
987
+ /** The one mechanism behind every model selection after open (`setModel`, a per-turn `model`):
988
+ * `SessionHandle.selectModel` — the same call open makes — then `model` moves to the routed
989
+ * spec. Nothing moves when the wire rejects. */
990
+ async #applyModelSwitch(handle, next) {
991
+ await handle.selectModel(next.modelSpec);
992
+ this.#modelSpec = next.modelSpec;
993
+ this.#model = next.model;
994
+ }
716
995
  /** Re-apply model selection, config options and the mode on the LIVE handle (fork/resume/load
717
996
  * responses replace the catalog). The mode rule is the runner's, verbatim. */
718
997
  async #applyPostOpen(handle) {
@@ -731,20 +1010,25 @@ export class AcpAgent {
731
1010
  }
732
1011
  this.#signal?.throwIfAborted();
733
1012
  }
734
- /** Seed a live fork's history/text from the parent's snapshot — only when the child's own
735
- * accumulator is empty (a `session/load` fallback already replayed the transcript). */
736
- #seedHistory(seed, text, handle) {
737
- if (!seed || handle.history.length > 0)
1013
+ /** Seed a live fork's history/text/messages from the parent's snapshot — only when the child's
1014
+ * own accumulator is empty (a `session/load` reattach already replayed the transcript). The
1015
+ * cold static carries no seed: its transcript is the load replay, or empty after a resume. */
1016
+ #seedHistory(seed, handle) {
1017
+ if (!seed.historySeed || handle.history.length > 0)
738
1018
  return;
739
- this.#historySeed = seed;
740
- this.#textSeed = text ?? "";
1019
+ this.#historySeed = seed.historySeed;
1020
+ this.#textSeed = seed.textSeed ?? "";
1021
+ this.#messagesSeed = seed.messagesSeed ?? [];
741
1022
  }
742
- #cancelTurn() {
1023
+ /** `reason` is what a running function tool's `ctx.signal` aborts with. */
1024
+ #cancelTurn(reason) {
743
1025
  const active = this.#activeTurn;
744
1026
  const connection = this.#connection;
745
1027
  const sessionId = this.#sessionId;
746
1028
  if (!active || !connection || sessionId === undefined)
747
1029
  return Promise.resolve();
1030
+ // A function tool running for this turn stops with it.
1031
+ this.#toolHost?.abortInFlight(reason);
748
1032
  // Settles pending permissions/elicitations + ONE session/cancel notify.
749
1033
  active.cancelRequested ??= connection.cancelSession(sessionId);
750
1034
  active.escalation ??= active.cancelRequested.then(async () => {
@@ -760,7 +1044,8 @@ export class AcpAgent {
760
1044
  const reason = this.#signal?.reason;
761
1045
  this.#closed = true;
762
1046
  this.#queue.drain(reason);
763
- void this.#cancelTurn().catch(noop);
1047
+ this.#toolHost?.abortInFlight(reason);
1048
+ void this.#cancelTurn(reason).catch(noop);
764
1049
  // An open/fork/reattach in flight: dispose the process so the raced wire call rejects.
765
1050
  if (this.#handle === undefined && this.#connection)
766
1051
  void this.#connection.dispose().catch(noop);
@@ -808,6 +1093,7 @@ export class AcpAgent {
808
1093
  const connection = this.#connection;
809
1094
  const plan = this.#plan;
810
1095
  const host = this.#structuredHost;
1096
+ const toolHost = this.#toolHost;
811
1097
  let cleanupError;
812
1098
  try {
813
1099
  if (handle)
@@ -818,8 +1104,8 @@ export class AcpAgent {
818
1104
  cleanupError = error;
819
1105
  }
820
1106
  plan?.registration?.release();
821
- // The process BEFORE the tool host (the runner's order: pool, then tools): the agent process
822
- // holds keep-alive sockets to the host's HTTP server, and `server.close()` waits for idle
1107
+ // The process BEFORE the tool hosts (the runner's order: pool, then tools): the agent process
1108
+ // holds keep-alive sockets to the hosts' HTTP servers, and `server.close()` waits for idle
823
1109
  // sockets to time out (seconds) unless the peer is gone first.
824
1110
  if (connection) {
825
1111
  await connection.dispose().catch(noop);
@@ -827,6 +1113,8 @@ export class AcpAgent {
827
1113
  }
828
1114
  if (host)
829
1115
  await host.dispose().catch(noop);
1116
+ if (toolHost)
1117
+ await toolHost.dispose().catch(noop);
830
1118
  this.#removeAbort?.();
831
1119
  this.#removeAbort = undefined;
832
1120
  // Last, so the agent's own `session_close` (emitted by the release above) was delivered.