@automatalabs/acp-agents 1.3.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 +66 -21
- package/dist/acp-client.d.ts +15 -9
- package/dist/acp-client.d.ts.map +1 -1
- package/dist/acp-client.js +12 -10
- 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 +461 -168
- 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 +183 -29
- package/dist/agent/types.d.ts.map +1 -1
- package/dist/backend.d.ts +13 -5
- package/dist/backend.d.ts.map +1 -1
- package/dist/backends/claude.d.ts +4 -0
- package/dist/backends/claude.d.ts.map +1 -1
- package/dist/backends/claude.js +40 -17
- package/dist/backends/codex.d.ts +5 -0
- package/dist/backends/codex.d.ts.map +1 -1
- package/dist/backends/codex.js +15 -9
- package/dist/backends/custom.d.ts.map +1 -1
- package/dist/backends/custom.js +3 -2
- package/dist/backends/opencode.d.ts +4 -0
- package/dist/backends/opencode.d.ts.map +1 -1
- package/dist/backends/opencode.js +5 -1
- package/dist/backends/pi.d.ts +5 -2
- package/dist/backends/pi.d.ts.map +1 -1
- package/dist/backends/pi.js +15 -3
- 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 +8 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/interactive.d.ts +4 -5
- package/dist/interactive.d.ts.map +1 -1
- 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 +34 -6
- package/dist/protocol-coverage.d.ts.map +1 -1
- package/dist/protocol-coverage.js +26 -1
- 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 +13 -8
- 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/system-prompt.d.ts +13 -0
- package/dist/system-prompt.d.ts.map +1 -0
- package/dist/system-prompt.js +67 -0
- 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,17 +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
|
-
import {
|
|
8
|
+
import { assertSystemPromptSupported } from "../system-prompt.js";
|
|
9
|
+
import { describeBackendTraits } from "../traits.js";
|
|
10
|
+
import { agentClosedError, agentTurnError, agentValidationError, mapAgentError, validateArguments, } from "./errors.js";
|
|
8
11
|
import { AgentEventBus } from "./events.js";
|
|
9
12
|
import { acquireForkedSession, forkTraitFor } from "./fork.js";
|
|
13
|
+
import { MessageFolder, copyMessage } from "./messages.js";
|
|
10
14
|
import { probeCatalog } from "./probe.js";
|
|
11
15
|
import { releaseOnExit, retainOnExit } from "./process-registry.js";
|
|
12
16
|
import { SerialQueue } from "./queue.js";
|
|
13
|
-
import {
|
|
14
|
-
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";
|
|
15
22
|
import { TurnCollector, buildTurn } from "./turn.js";
|
|
16
23
|
import { ZERO_USAGE, } from "./types.js";
|
|
17
24
|
/** Handed to the constructor by `AcpAgent.#seeded` only. Set and consumed SYNCHRONOUSLY (the
|
|
@@ -75,6 +82,7 @@ function assertKnownConfigOptionIds(configOptions, advertised, backendId, label)
|
|
|
75
82
|
function resolveNewSeed(options) {
|
|
76
83
|
const registry = resolveAgentRegistry(options.backends, options.label);
|
|
77
84
|
const route = resolveAgentRoute(options, registry);
|
|
85
|
+
assertSystemPromptSupported(route.backend, options.systemPrompt, options.label);
|
|
78
86
|
return { kind: "new", registry, backend: route.backend, modelSpec: route.modelSpec };
|
|
79
87
|
}
|
|
80
88
|
function assertSessionRef(ref, label, method) {
|
|
@@ -88,27 +96,29 @@ function assertSessionRef(ref, label, method) {
|
|
|
88
96
|
/**
|
|
89
97
|
* One ACP agent session on its own dedicated backend process.
|
|
90
98
|
*
|
|
91
|
-
* Lazy: the constructor validates (cwd, `configOptions`, the registry, `clientHandlers
|
|
92
|
-
* routes the backend synchronously but spawns nothing; the first
|
|
93
|
-
* `ready()` or an implicit `prompt()`) opens the session. `state`
|
|
94
|
-
* `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`.
|
|
95
103
|
*/
|
|
96
104
|
export class AcpAgent {
|
|
97
105
|
/** The session's absolute working directory (sent on session/new|fork|resume|load). */
|
|
98
106
|
cwd;
|
|
99
107
|
/** The human label stamped on event contexts and error `agentLabel`; never on the wire. */
|
|
100
108
|
label;
|
|
101
|
-
/** The model this agent selects at open, as a routing spec that leads back to the same backend
|
|
102
|
-
* (`<backendId>/<model id>`, e.g. `"claude/opus[1m]"`), or `undefined` when no model was
|
|
103
|
-
* selected (the backend's default). Inherited by forks. An `AgentSessionRef` carries no model,
|
|
104
|
-
* so a cold reopen keeps it only when told: `AcpAgent.resume(agent.sessionRef!, { model: agent.model })`. */
|
|
105
|
-
model;
|
|
106
109
|
#options;
|
|
107
110
|
#seed;
|
|
108
111
|
#registry;
|
|
109
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. */
|
|
110
115
|
#modelSpec;
|
|
116
|
+
/** `#modelSpec` in its routed spec form (`<backendId>/<id>`) — what the `model` getter returns. */
|
|
117
|
+
#model;
|
|
111
118
|
#schema;
|
|
119
|
+
/** The default repair budget (`schemaRetries`, validated in the constructor); a per-turn value wins. */
|
|
120
|
+
#schemaRetries;
|
|
121
|
+
#tools;
|
|
112
122
|
#retainHistory;
|
|
113
123
|
#raw;
|
|
114
124
|
#signal;
|
|
@@ -122,11 +132,22 @@ export class AcpAgent {
|
|
|
122
132
|
#handle;
|
|
123
133
|
#plan;
|
|
124
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;
|
|
125
139
|
#sessionId;
|
|
126
140
|
#sessionRef;
|
|
127
141
|
#sessionUsage = ZERO_USAGE;
|
|
128
142
|
#historySeed = [];
|
|
129
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;
|
|
130
151
|
#collectingReplay = false;
|
|
131
152
|
#activeTurn;
|
|
132
153
|
#closePromise;
|
|
@@ -138,40 +159,54 @@ export class AcpAgent {
|
|
|
138
159
|
#forkCount = 0;
|
|
139
160
|
#removeAbort;
|
|
140
161
|
/**
|
|
141
|
-
* Lazy: validates cwd/configOptions/registry/clientHandlers synchronously,
|
|
142
|
-
* spawns nothing. This is the ONLY public constructor signature — seeded agents (forks,
|
|
143
|
-
* 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.
|
|
144
165
|
*/
|
|
145
166
|
constructor(options) {
|
|
146
167
|
const label = options.label;
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
+
});
|
|
156
183
|
this.#options = { ...options };
|
|
184
|
+
this.#tools = tools;
|
|
185
|
+
this.#schemaRetries = schemaRetries;
|
|
157
186
|
this.#seed = seed;
|
|
158
187
|
this.#registry = seed.registry;
|
|
159
188
|
this.#backend = seed.backend;
|
|
160
189
|
this.#modelSpec = seed.modelSpec;
|
|
161
190
|
this.cwd = options.cwd;
|
|
162
191
|
this.label = label;
|
|
163
|
-
this
|
|
192
|
+
this.#model = seed.modelSpec === undefined ? undefined : `${seed.backend.id}/${seed.modelSpec}`;
|
|
164
193
|
this.#schema = options.schema;
|
|
165
194
|
this.#retainHistory = options.retainHistory ?? true;
|
|
166
195
|
this.#raw = options.raw ?? true;
|
|
167
196
|
this.#signal = options.signal;
|
|
168
197
|
// Verbatim session/update records received before the session was ready (a load's replay, a
|
|
169
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.
|
|
170
200
|
this.#bus.tap((name, event) => {
|
|
171
|
-
if (name !== "session_update"
|
|
201
|
+
if (name !== "session_update")
|
|
172
202
|
return;
|
|
173
203
|
const { update } = event;
|
|
174
|
-
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
|
+
}
|
|
175
210
|
});
|
|
176
211
|
if (options.signal) {
|
|
177
212
|
const signal = options.signal;
|
|
@@ -216,6 +251,17 @@ export class AcpAgent {
|
|
|
216
251
|
static probe(options = {}) {
|
|
217
252
|
return probeCatalog(options);
|
|
218
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
|
+
}
|
|
219
265
|
/** `session/resume` of `ref.sessionId` on a fresh dedicated process of `ref.backendId`
|
|
220
266
|
* (routed by name — never the default backend — and pool-key checked). `cwd` defaults to
|
|
221
267
|
* `ref.cwd`; `model` must stay on the ref's backend. */
|
|
@@ -228,32 +274,34 @@ export class AcpAgent {
|
|
|
228
274
|
static load(ref, options = {}) {
|
|
229
275
|
return AcpAgent.#reopen("load", ref, options);
|
|
230
276
|
}
|
|
231
|
-
/** Cold fork of a recorded session: the trait-driven choreography
|
|
232
|
-
*
|
|
233
|
-
*
|
|
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. */
|
|
234
282
|
static fork(ref, options = {}) {
|
|
235
283
|
return AcpAgent.#reopen("fork", ref, options);
|
|
236
284
|
}
|
|
237
285
|
static async #reopen(kind, ref, options) {
|
|
238
286
|
const label = options.label;
|
|
239
287
|
const method = `AcpAgent.${kind}`;
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 } };
|
|
251
302
|
}
|
|
252
|
-
seed
|
|
253
|
-
}
|
|
254
|
-
else {
|
|
255
|
-
seed = { kind, sessionId: ref.sessionId, ...base };
|
|
256
|
-
}
|
|
303
|
+
return { cwd, seed: { kind, sessionId: ref.sessionId, ...base } };
|
|
304
|
+
});
|
|
257
305
|
return AcpAgent.#opened(AcpAgent.#seeded({ ...options, cwd }, seed));
|
|
258
306
|
}
|
|
259
307
|
// ── Getters (all readable after close; they return retained values) ──
|
|
@@ -261,6 +309,15 @@ export class AcpAgent {
|
|
|
261
309
|
get backendId() {
|
|
262
310
|
return this.#backend.id;
|
|
263
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
|
+
}
|
|
264
321
|
/** `idle` → `opening` → `ready` ⇄ `busy` → `closed` (set the instant `close()` is called,
|
|
265
322
|
* the constructor signal aborts, or the process dies). */
|
|
266
323
|
get state() {
|
|
@@ -282,6 +339,12 @@ export class AcpAgent {
|
|
|
282
339
|
get capabilities() {
|
|
283
340
|
return this.#connection?.capabilities;
|
|
284
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
|
+
}
|
|
285
348
|
/** The latest echoed session config-option catalog (verbatim ACP wire shapes). */
|
|
286
349
|
get configOptions() {
|
|
287
350
|
return this.#handle?.advertisedConfigOptions ?? [];
|
|
@@ -309,6 +372,13 @@ export class AcpAgent {
|
|
|
309
372
|
get text() {
|
|
310
373
|
return [this.#textSeed, this.#handle?.foldedText() ?? ""].filter((part) => part !== "").join("\n\n");
|
|
311
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
|
+
}
|
|
312
382
|
/** Running per-field sum of every turn this agent ran; `ZERO_USAGE` before the first turn. */
|
|
313
383
|
get usage() {
|
|
314
384
|
return this.#sessionUsage;
|
|
@@ -344,103 +414,224 @@ export class AcpAgent {
|
|
|
344
414
|
* cancelled included). Rejects only on a wire rejection (mapped like the runner), validation,
|
|
345
415
|
* abort (`signal.reason` untouched), a closed agent, or a typed session failure (the mapped
|
|
346
416
|
* `WorkflowError` carrying the complete turn as `error.turn`; see `isAcpAgentTurnError`).
|
|
347
|
-
* `configOptions`/`mode` are applied before the turn and stick for the
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
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).
|
|
351
424
|
*/
|
|
352
425
|
prompt(content, options = {}) {
|
|
353
|
-
return this.#enqueue(
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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(() => {
|
|
361
498
|
validatePromptImages(options.images, label);
|
|
362
499
|
assertPerTurnSchemaAllowed(backend, options.schema, label);
|
|
363
500
|
assertNoModelConfigOption(options.configOptions, label);
|
|
364
501
|
assertKnownConfigOptionIds(options.configOptions, handle.advertisedConfigOptions, this.backendId, label);
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
options.
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
await handle.setMode(options.mode);
|
|
373
|
-
options.signal?.throwIfAborted();
|
|
374
|
-
this.#signal?.throwIfAborted();
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
catch (error) {
|
|
378
|
-
throw mapAgentError(error, this.#errorContext(), options.signal?.aborted ? options.signal : this.#signal);
|
|
379
|
-
}
|
|
380
|
-
const turnSchema = options.schema ?? this.#schema;
|
|
381
|
-
// Same request shaping as the runner: a generic backend whose agent may ignore the `_meta`
|
|
382
|
-
// forward gets the contract stated in-band; backend turn meta wins only direct collisions.
|
|
383
|
-
const shaped = typeof content === "string" && turnSchema !== undefined && backend.embedSchemaInPrompt
|
|
384
|
-
? buildRunPrompt(content, {}, turnSchema, backend, plan.toolActive)
|
|
385
|
-
: content;
|
|
386
|
-
const turnContent = appendPromptImages(shaped, options.images);
|
|
387
|
-
const promptMeta = mergeTurnMeta(options.meta, backend.promptMeta(turnSchema));
|
|
388
|
-
// SYNCHRONOUSLY before the wire call: the collector's tap sees every update of the turn.
|
|
389
|
-
const collector = new TurnCollector(this.#bus, handle, { retainHistory: this.#retainHistory });
|
|
390
|
-
// A capture left by a turn that rejected (wire error/abort) must not leak into this turn.
|
|
391
|
-
plan.registration?.takeCaptured();
|
|
392
|
-
const outcome = handle.promptOutcome(turnContent, promptMeta);
|
|
393
|
-
const active = { ended: outcome.then(noop, noop), aborted: false };
|
|
394
|
-
this.#activeTurn = active;
|
|
395
|
-
const callSignal = options.signal;
|
|
396
|
-
const onCallAbort = () => {
|
|
397
|
-
active.aborted = true;
|
|
398
|
-
active.abortReason = callSignal?.reason;
|
|
399
|
-
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 })`),
|
|
400
509
|
};
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
(
|
|
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();
|
|
406
517
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
throw this.#signal.reason;
|
|
412
|
-
throw mapAgentError(error, this.#errorContext());
|
|
518
|
+
if (options.configOptions) {
|
|
519
|
+
await handle.setConfigOptions(options.configOptions);
|
|
520
|
+
options.signal?.throwIfAborted();
|
|
521
|
+
this.#signal?.throwIfAborted();
|
|
413
522
|
}
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
this.#activeTurn = undefined;
|
|
523
|
+
if (options.mode !== undefined) {
|
|
524
|
+
await handle.setMode(options.mode);
|
|
525
|
+
options.signal?.throwIfAborted();
|
|
526
|
+
this.#signal?.throwIfAborted();
|
|
419
527
|
}
|
|
420
|
-
|
|
421
|
-
|
|
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) {
|
|
422
600
|
if (active.aborted)
|
|
423
601
|
throw active.abortReason;
|
|
424
602
|
if (this.#signal?.aborted)
|
|
425
603
|
throw this.#signal.reason;
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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 };
|
|
441
632
|
}
|
|
442
633
|
/** Inject content into the turn in flight (`_session/steering`). Overlaps the FIFO; requires a
|
|
443
|
-
* `prompt()` in flight (
|
|
634
|
+
* `prompt()` in flight (INVALID_ARGUMENT otherwise). The complete raw response is returned. */
|
|
444
635
|
async steer(content, options = {}) {
|
|
445
636
|
this.#signal?.throwIfAborted();
|
|
446
637
|
if (this.#closed)
|
|
@@ -449,7 +640,7 @@ export class AcpAgent {
|
|
|
449
640
|
if (!this.#activeTurn || !handle) {
|
|
450
641
|
throw agentValidationError("AcpAgent.steer() requires a prompt() in flight", this.label);
|
|
451
642
|
}
|
|
452
|
-
validatePromptImages(options.images, this.label);
|
|
643
|
+
validateArguments(() => validatePromptImages(options.images, this.label));
|
|
453
644
|
try {
|
|
454
645
|
return await handle.steer(appendPromptImages(content, options.images), options.meta);
|
|
455
646
|
}
|
|
@@ -463,11 +654,11 @@ export class AcpAgent {
|
|
|
463
654
|
* grace period ends in process disposal WITHOUT a wire `session/close` (the session stays
|
|
464
655
|
* re-openable through `sessionRef`); the turn then rejects and the agent is closed. Queued
|
|
465
656
|
* turns are untouched, and so is a turn that has started (`state === "busy"`) but has not
|
|
466
|
-
* reached the wire yet — the lazy first open, or its per-turn `configOptions`/`mode` —
|
|
467
|
-
* `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
|
|
468
659
|
* window (rejects with the reason before anything is sent; `session/cancel` once in flight). */
|
|
469
660
|
cancel() {
|
|
470
|
-
return this.#cancelTurn();
|
|
661
|
+
return this.#cancelTurn(new Error("AcpAgent.cancel(): the turn was cancelled"));
|
|
471
662
|
}
|
|
472
663
|
/**
|
|
473
664
|
* Fork this agent onto a NEW dedicated process (queued: it runs only when no turn is in flight,
|
|
@@ -502,32 +693,73 @@ export class AcpAgent {
|
|
|
502
693
|
providerStore: this.#options.providerStore,
|
|
503
694
|
clientHandlers: this.#options.clientHandlers,
|
|
504
695
|
};
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
let route;
|
|
510
|
-
if (overrides.model !== undefined) {
|
|
511
|
-
route = resolveModelRoute(overrides.model, this.#registry);
|
|
512
|
-
const samePool = (route.backend.poolKey ?? route.backend.id) === (this.#backend.poolKey ?? this.backendId);
|
|
513
|
-
if (route.backend.id !== this.backendId || !samePool) {
|
|
514
|
-
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);
|
|
515
700
|
}
|
|
516
|
-
|
|
517
|
-
|
|
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
|
+
});
|
|
518
711
|
const child = AcpAgent.#seeded(merged, {
|
|
519
712
|
kind: "fork",
|
|
520
713
|
sourceSessionId: handle.sessionId,
|
|
714
|
+
// The parent's snapshot seeds the child: reattach without a replay (load is the fallback).
|
|
715
|
+
reattach: "resume",
|
|
521
716
|
registry: this.#registry,
|
|
522
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.
|
|
523
720
|
modelSpec: route ? route.modelSpec : this.#modelSpec,
|
|
524
721
|
historySeed: this.history.map((entry) => ({ ...entry })),
|
|
525
722
|
textSeed: this.text,
|
|
723
|
+
messagesSeed: [...this.messages],
|
|
526
724
|
});
|
|
527
725
|
return AcpAgent.#opened(child);
|
|
528
726
|
});
|
|
529
727
|
}
|
|
530
|
-
/**
|
|
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). */
|
|
531
763
|
setMode(modeId) {
|
|
532
764
|
return this.#enqueue(async () => {
|
|
533
765
|
await this.#ensureOpen();
|
|
@@ -547,8 +779,10 @@ export class AcpAgent {
|
|
|
547
779
|
await this.#ensureOpen();
|
|
548
780
|
this.#signal?.throwIfAborted();
|
|
549
781
|
const handle = this.#handle;
|
|
550
|
-
|
|
551
|
-
|
|
782
|
+
validateArguments(() => {
|
|
783
|
+
assertNoModelConfigOption(options, this.label);
|
|
784
|
+
assertKnownConfigOptionIds(options, handle.advertisedConfigOptions, this.backendId, this.label);
|
|
785
|
+
});
|
|
552
786
|
try {
|
|
553
787
|
await handle.setConfigOptions(options);
|
|
554
788
|
}
|
|
@@ -561,8 +795,9 @@ export class AcpAgent {
|
|
|
561
795
|
* Close: `state` becomes `closed` immediately (no new work is admitted), the teardown waits
|
|
562
796
|
* behind queued work, releases the session (`keep: true` skips the wire `session/close` so the
|
|
563
797
|
* agent-persisted session stays re-openable via `sessionRef`), disposes the dedicated process,
|
|
564
|
-
*
|
|
565
|
-
* 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).
|
|
566
801
|
*/
|
|
567
802
|
close(options = {}) {
|
|
568
803
|
this.#closeKeep ??= options.keep === true;
|
|
@@ -618,7 +853,7 @@ export class AcpAgent {
|
|
|
618
853
|
return {
|
|
619
854
|
cwd: this.cwd,
|
|
620
855
|
schema: this.#schema,
|
|
621
|
-
policy: options.
|
|
856
|
+
policy: options.permissions ?? {},
|
|
622
857
|
permissionResolver: options.onPermissionRequest,
|
|
623
858
|
enforceToolPolicyBeforePermissionResolver: false,
|
|
624
859
|
elicitationResolver: options.onElicitation,
|
|
@@ -627,18 +862,50 @@ export class AcpAgent {
|
|
|
627
862
|
mcpServers: plan.mcpServers,
|
|
628
863
|
meta: this.#layeredMeta(),
|
|
629
864
|
label: this.label,
|
|
630
|
-
|
|
631
|
-
developerInstructions: options.instructions?.developer,
|
|
865
|
+
systemPrompt: options.systemPrompt,
|
|
632
866
|
retainSessionLog: this.#retainHistory,
|
|
633
867
|
};
|
|
634
868
|
}
|
|
635
|
-
|
|
636
|
-
|
|
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({
|
|
637
874
|
schema: this.#schema,
|
|
638
875
|
backend: this.#backend,
|
|
639
876
|
mcpServers: this.#options.mcpServers,
|
|
640
877
|
host: () => (this.#structuredHost ??= new StructuredOutputToolHost()),
|
|
641
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;
|
|
642
909
|
}
|
|
643
910
|
async #open() {
|
|
644
911
|
let handle;
|
|
@@ -657,18 +924,18 @@ export class AcpAgent {
|
|
|
657
924
|
if (seed.kind === "new") {
|
|
658
925
|
// `prepare` runs after initialize, so the injection decision sees the capabilities.
|
|
659
926
|
handle = await connection.openPreparedSession(async (ready) => {
|
|
660
|
-
plan = await this.#
|
|
927
|
+
plan = await this.#planSession(ready);
|
|
661
928
|
return this.#sessionOptions(plan);
|
|
662
929
|
});
|
|
663
930
|
}
|
|
664
931
|
else {
|
|
665
932
|
// The cheapest "await initialize": the injection decision needs the capabilities.
|
|
666
933
|
await connection.authMethods();
|
|
667
|
-
plan = await this.#
|
|
934
|
+
plan = await this.#planSession(connection);
|
|
668
935
|
const opts = this.#sessionOptions(plan);
|
|
669
936
|
if (seed.kind === "fork") {
|
|
670
937
|
const trait = forkTraitFor(this.#backend, this.#registry);
|
|
671
|
-
const acquired = await acquireForkedSession(connection, seed.sourceSessionId, opts, trait);
|
|
938
|
+
const acquired = await acquireForkedSession(connection, seed.sourceSessionId, opts, trait, seed.reattach);
|
|
672
939
|
handle = acquired.handle;
|
|
673
940
|
replayed = acquired.method === "load";
|
|
674
941
|
}
|
|
@@ -689,10 +956,19 @@ export class AcpAgent {
|
|
|
689
956
|
this.#sessionId = handle.sessionId;
|
|
690
957
|
this.#bus.endAcquisition(handle.sessionId);
|
|
691
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;
|
|
692
968
|
this.#signal?.throwIfAborted();
|
|
693
969
|
await this.#applyPostOpen(handle);
|
|
694
970
|
if (seed.kind === "fork")
|
|
695
|
-
this.#seedHistory(seed
|
|
971
|
+
this.#seedHistory(seed, handle);
|
|
696
972
|
this.#sessionRef = sessionRefFor(handle, this.#backend, this.cwd);
|
|
697
973
|
this.#sessionUsage = ZERO_USAGE;
|
|
698
974
|
}
|
|
@@ -708,6 +984,14 @@ export class AcpAgent {
|
|
|
708
984
|
throw this.#signal?.aborted ? this.#signal.reason : mapAgentError(error, this.#errorContext());
|
|
709
985
|
}
|
|
710
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
|
+
}
|
|
711
995
|
/** Re-apply model selection, config options and the mode on the LIVE handle (fork/resume/load
|
|
712
996
|
* responses replace the catalog). The mode rule is the runner's, verbatim. */
|
|
713
997
|
async #applyPostOpen(handle) {
|
|
@@ -726,20 +1010,25 @@ export class AcpAgent {
|
|
|
726
1010
|
}
|
|
727
1011
|
this.#signal?.throwIfAborted();
|
|
728
1012
|
}
|
|
729
|
-
/** Seed a live fork's history/text from the parent's snapshot — only when the child's
|
|
730
|
-
* accumulator is empty (a `session/load`
|
|
731
|
-
|
|
732
|
-
|
|
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)
|
|
733
1018
|
return;
|
|
734
|
-
this.#historySeed = seed;
|
|
735
|
-
this.#textSeed =
|
|
1019
|
+
this.#historySeed = seed.historySeed;
|
|
1020
|
+
this.#textSeed = seed.textSeed ?? "";
|
|
1021
|
+
this.#messagesSeed = seed.messagesSeed ?? [];
|
|
736
1022
|
}
|
|
737
|
-
|
|
1023
|
+
/** `reason` is what a running function tool's `ctx.signal` aborts with. */
|
|
1024
|
+
#cancelTurn(reason) {
|
|
738
1025
|
const active = this.#activeTurn;
|
|
739
1026
|
const connection = this.#connection;
|
|
740
1027
|
const sessionId = this.#sessionId;
|
|
741
1028
|
if (!active || !connection || sessionId === undefined)
|
|
742
1029
|
return Promise.resolve();
|
|
1030
|
+
// A function tool running for this turn stops with it.
|
|
1031
|
+
this.#toolHost?.abortInFlight(reason);
|
|
743
1032
|
// Settles pending permissions/elicitations + ONE session/cancel notify.
|
|
744
1033
|
active.cancelRequested ??= connection.cancelSession(sessionId);
|
|
745
1034
|
active.escalation ??= active.cancelRequested.then(async () => {
|
|
@@ -755,7 +1044,8 @@ export class AcpAgent {
|
|
|
755
1044
|
const reason = this.#signal?.reason;
|
|
756
1045
|
this.#closed = true;
|
|
757
1046
|
this.#queue.drain(reason);
|
|
758
|
-
|
|
1047
|
+
this.#toolHost?.abortInFlight(reason);
|
|
1048
|
+
void this.#cancelTurn(reason).catch(noop);
|
|
759
1049
|
// An open/fork/reattach in flight: dispose the process so the raced wire call rejects.
|
|
760
1050
|
if (this.#handle === undefined && this.#connection)
|
|
761
1051
|
void this.#connection.dispose().catch(noop);
|
|
@@ -803,6 +1093,7 @@ export class AcpAgent {
|
|
|
803
1093
|
const connection = this.#connection;
|
|
804
1094
|
const plan = this.#plan;
|
|
805
1095
|
const host = this.#structuredHost;
|
|
1096
|
+
const toolHost = this.#toolHost;
|
|
806
1097
|
let cleanupError;
|
|
807
1098
|
try {
|
|
808
1099
|
if (handle)
|
|
@@ -813,8 +1104,8 @@ export class AcpAgent {
|
|
|
813
1104
|
cleanupError = error;
|
|
814
1105
|
}
|
|
815
1106
|
plan?.registration?.release();
|
|
816
|
-
// The process BEFORE the tool
|
|
817
|
-
// 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
|
|
818
1109
|
// sockets to time out (seconds) unless the peer is gone first.
|
|
819
1110
|
if (connection) {
|
|
820
1111
|
await connection.dispose().catch(noop);
|
|
@@ -822,6 +1113,8 @@ export class AcpAgent {
|
|
|
822
1113
|
}
|
|
823
1114
|
if (host)
|
|
824
1115
|
await host.dispose().catch(noop);
|
|
1116
|
+
if (toolHost)
|
|
1117
|
+
await toolHost.dispose().catch(noop);
|
|
825
1118
|
this.#removeAbort?.();
|
|
826
1119
|
this.#removeAbort = undefined;
|
|
827
1120
|
// Last, so the agent's own `session_close` (emitted by the release above) was delivered.
|