@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.
- package/README.md +53 -17
- package/dist/acp-client.d.ts +10 -4
- package/dist/acp-client.d.ts.map +1 -1
- package/dist/acp-client.js +11 -8
- package/dist/agent/acp-agent.d.ts +84 -26
- package/dist/agent/acp-agent.d.ts.map +1 -1
- package/dist/agent/acp-agent.js +458 -170
- package/dist/agent/errors.d.ts +12 -2
- package/dist/agent/errors.d.ts.map +1 -1
- package/dist/agent/errors.js +38 -9
- package/dist/agent/fork.d.ts +7 -5
- package/dist/agent/fork.d.ts.map +1 -1
- package/dist/agent/fork.js +7 -5
- package/dist/agent/messages.d.ts +42 -0
- package/dist/agent/messages.d.ts.map +1 -0
- package/dist/agent/messages.js +225 -0
- package/dist/agent/probe.d.ts +1 -1
- package/dist/agent/probe.d.ts.map +1 -1
- package/dist/agent/probe.js +5 -1
- package/dist/agent/queue.js +2 -2
- package/dist/agent/routing.d.ts +19 -1
- package/dist/agent/routing.d.ts.map +1 -1
- package/dist/agent/routing.js +41 -4
- package/dist/agent/stream.d.ts +21 -0
- package/dist/agent/stream.d.ts.map +1 -0
- package/dist/agent/stream.js +93 -0
- package/dist/agent/structured.d.ts +7 -3
- package/dist/agent/structured.d.ts.map +1 -1
- package/dist/agent/structured.js +24 -14
- package/dist/agent/tool-host.d.ts +34 -0
- package/dist/agent/tool-host.d.ts.map +1 -0
- package/dist/agent/tool-host.js +138 -0
- package/dist/agent/tools.d.ts +26 -0
- package/dist/agent/tools.d.ts.map +1 -0
- package/dist/agent/tools.js +62 -0
- package/dist/agent/turn.d.ts +6 -3
- package/dist/agent/turn.d.ts.map +1 -1
- package/dist/agent/turn.js +15 -67
- package/dist/agent/types.d.ts +174 -25
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/backend.d.ts +4 -3
- package/dist/backend.d.ts.map +1 -1
- package/dist/backends/codex.d.ts +2 -1
- package/dist/backends/codex.d.ts.map +1 -1
- package/dist/backends/codex.js +3 -2
- package/dist/config-catalog.d.ts +4 -0
- package/dist/config-catalog.d.ts.map +1 -1
- package/dist/config-catalog.js +1 -0
- package/dist/index.d.ts +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/local-mcp-host.d.ts +30 -0
- package/dist/local-mcp-host.d.ts.map +1 -0
- package/dist/local-mcp-host.js +151 -0
- package/dist/protocol-coverage.d.ts +8 -6
- package/dist/protocol-coverage.d.ts.map +1 -1
- package/dist/protocol-coverage.js +3 -2
- package/dist/registry.d.ts +3 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/runner.d.ts +5 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +7 -4
- package/dist/structured-output.d.ts +18 -0
- package/dist/structured-output.d.ts.map +1 -1
- package/dist/structured-output.js +19 -1
- package/dist/structured-tool.d.ts +7 -9
- package/dist/structured-tool.d.ts.map +1 -1
- package/dist/structured-tool.js +14 -102
- package/dist/traits.d.ts +51 -0
- package/dist/traits.d.ts.map +1 -0
- package/dist/traits.js +88 -0
- package/package.json +4 -4
package/dist/agent/acp-agent.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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 {
|
|
15
|
-
import {
|
|
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
|
|
94
|
-
* routes the backend synchronously but spawns nothing; the first
|
|
95
|
-
* `ready()` or an implicit `prompt()`) opens the session. `state`
|
|
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,
|
|
144
|
-
* spawns nothing. This is the ONLY public constructor signature — seeded agents (forks,
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
|
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"
|
|
201
|
+
if (name !== "session_update")
|
|
174
202
|
return;
|
|
175
203
|
const { update } = event;
|
|
176
|
-
this.#
|
|
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
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
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(
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
options.
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
(
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
424
|
-
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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 (
|
|
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` —
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
557
|
-
|
|
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
|
-
*
|
|
571
|
-
* already-dead process; rethrows
|
|
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.
|
|
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
|
-
|
|
641
|
-
|
|
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.#
|
|
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.#
|
|
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
|
|
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
|
|
735
|
-
* accumulator is empty (a `session/load`
|
|
736
|
-
|
|
737
|
-
|
|
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 =
|
|
1019
|
+
this.#historySeed = seed.historySeed;
|
|
1020
|
+
this.#textSeed = seed.textSeed ?? "";
|
|
1021
|
+
this.#messagesSeed = seed.messagesSeed ?? [];
|
|
741
1022
|
}
|
|
742
|
-
|
|
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
|
-
|
|
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
|
|
822
|
-
// holds keep-alive sockets to the
|
|
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.
|