@automatalabs/acp-agents 1.2.7 → 1.3.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 +67 -3
- package/dist/acp-client.d.ts +21 -1
- package/dist/acp-client.d.ts.map +1 -1
- package/dist/acp-client.js +29 -5
- package/dist/agent/acp-agent.d.ts +141 -0
- package/dist/agent/acp-agent.d.ts.map +1 -0
- package/dist/agent/acp-agent.js +832 -0
- package/dist/agent/errors.d.ts +17 -0
- package/dist/agent/errors.d.ts.map +1 -0
- package/dist/agent/errors.js +37 -0
- package/dist/agent/events.d.ts +26 -0
- package/dist/agent/events.d.ts.map +1 -0
- package/dist/agent/events.js +165 -0
- package/dist/agent/fork.d.ts +27 -0
- package/dist/agent/fork.d.ts.map +1 -0
- package/dist/agent/fork.js +28 -0
- package/dist/agent/probe.d.ts +14 -0
- package/dist/agent/probe.d.ts.map +1 -0
- package/dist/agent/probe.js +87 -0
- package/dist/agent/process-registry.d.ts +9 -0
- package/dist/agent/process-registry.d.ts.map +1 -0
- package/dist/agent/process-registry.js +28 -0
- package/dist/agent/queue.d.ts +19 -0
- package/dist/agent/queue.d.ts.map +1 -0
- package/dist/agent/queue.js +90 -0
- package/dist/agent/routing.d.ts +27 -0
- package/dist/agent/routing.d.ts.map +1 -0
- package/dist/agent/routing.js +87 -0
- package/dist/agent/structured.d.ts +47 -0
- package/dist/agent/structured.d.ts.map +1 -0
- package/dist/agent/structured.js +90 -0
- package/dist/agent/turn.d.ts +65 -0
- package/dist/agent/turn.d.ts.map +1 -0
- package/dist/agent/turn.js +187 -0
- package/dist/agent/types.d.ts +221 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +9 -0
- package/dist/backend.d.ts +6 -0
- package/dist/backend.d.ts.map +1 -1
- package/dist/backends/claude.d.ts +1 -0
- package/dist/backends/claude.d.ts.map +1 -1
- package/dist/backends/claude.js +5 -0
- package/dist/config-catalog.d.ts +173 -0
- package/dist/config-catalog.d.ts.map +1 -0
- package/dist/config-catalog.js +408 -0
- package/dist/index.d.ts +10 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -1
- package/dist/protocol-coverage.d.ts +56 -0
- package/dist/protocol-coverage.d.ts.map +1 -1
- package/dist/protocol-coverage.js +52 -0
- package/dist/registry.d.ts +12 -0
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +22 -0
- package/dist/routing.d.ts +14 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +53 -0
- package/dist/runner.d.ts.map +1 -1
- package/dist/runner.js +3 -64
- package/dist/session-ref.d.ts +8 -0
- package/dist/session-ref.d.ts.map +1 -0
- package/dist/session-ref.js +21 -0
- package/dist/structured-tool.d.ts +4 -0
- package/dist/structured-tool.d.ts.map +1 -1
- package/dist/structured-tool.js +5 -0
- package/package.json +3 -3
|
@@ -0,0 +1,832 @@
|
|
|
1
|
+
import { CANCEL_NOT_HONORED_GRACE_MS, PooledConnection, isChildCleanupError, } from "../acp-client.js";
|
|
2
|
+
import { validateClientHandlers } from "../client-handlers.js";
|
|
3
|
+
import { appendPromptImages, buildRunPrompt, mergeTurnMeta, validatePromptImages } from "../prompt.js";
|
|
4
|
+
import { assertNoModelConfigOption, resolveModelRoute } from "../routing.js";
|
|
5
|
+
import { sessionRefFor } from "../session-ref.js";
|
|
6
|
+
import { StructuredOutputToolHost } from "../structured-tool.js";
|
|
7
|
+
import { agentClosedError, agentTurnError, agentValidationError, mapAgentError } from "./errors.js";
|
|
8
|
+
import { AgentEventBus } from "./events.js";
|
|
9
|
+
import { acquireForkedSession, forkTraitFor } from "./fork.js";
|
|
10
|
+
import { probeCatalog } from "./probe.js";
|
|
11
|
+
import { releaseOnExit, retainOnExit } from "./process-registry.js";
|
|
12
|
+
import { SerialQueue } from "./queue.js";
|
|
13
|
+
import { freshBackendFor, resolveAgentRegistry, resolveAgentRoute, resolveRefRoute, validateAgentCwd, } from "./routing.js";
|
|
14
|
+
import { assertPerTurnSchemaAllowed, planStructured } from "./structured.js";
|
|
15
|
+
import { TurnCollector, buildTurn } from "./turn.js";
|
|
16
|
+
import { ZERO_USAGE, } from "./types.js";
|
|
17
|
+
/** Handed to the constructor by `AcpAgent.#seeded` only. Set and consumed SYNCHRONOUSLY (the
|
|
18
|
+
* constructor has no await), so two constructions can never interleave. Module-private: nothing
|
|
19
|
+
* outside this file can reach it, so the public constructor signature never grows a parameter
|
|
20
|
+
* through which a caller could skip the ref/poolKey checks of the statics. */
|
|
21
|
+
let constructionSeed;
|
|
22
|
+
let cancelGraceMs = CANCEL_NOT_HONORED_GRACE_MS;
|
|
23
|
+
/** Package-internal test seam for the ignored-cancel escalation grace. Not barrel-exported. */
|
|
24
|
+
export function setCancelGraceForTests(ms) {
|
|
25
|
+
const previous = cancelGraceMs;
|
|
26
|
+
cancelGraceMs = ms;
|
|
27
|
+
return () => {
|
|
28
|
+
cancelGraceMs = previous;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const noop = () => undefined;
|
|
32
|
+
/** Resolve true when `op` settles before `ms`, false when the grace wins; the timer never keeps
|
|
33
|
+
* the process alive and is cleared either way. */
|
|
34
|
+
function resolvesWithin(op, ms) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const timer = setTimeout(() => resolve(false), ms);
|
|
37
|
+
timer.unref?.();
|
|
38
|
+
void op.then(() => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
resolve(true);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function isRecord(value) {
|
|
45
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
/** Layer the backend's vendor-stream `_meta` UNDER the caller's `meta`, merging one level deep
|
|
48
|
+
* for keys both carry as objects (`sessionRequestMeta` layers shallowly, so a caller's
|
|
49
|
+
* `claudeCode: { custom }` must not erase the stream flag and vice versa). */
|
|
50
|
+
function layerRawMeta(rawMeta, userMeta) {
|
|
51
|
+
if (!rawMeta)
|
|
52
|
+
return userMeta;
|
|
53
|
+
if (!userMeta)
|
|
54
|
+
return rawMeta;
|
|
55
|
+
const merged = { ...rawMeta, ...userMeta };
|
|
56
|
+
for (const [key, rawValue] of Object.entries(rawMeta)) {
|
|
57
|
+
const userValue = userMeta[key];
|
|
58
|
+
if (isRecord(rawValue) && isRecord(userValue))
|
|
59
|
+
merged[key] = { ...rawValue, ...userValue };
|
|
60
|
+
}
|
|
61
|
+
return merged;
|
|
62
|
+
}
|
|
63
|
+
/** Client-side guard: every authored option id must be in the advertised catalog (values are
|
|
64
|
+
* still validated by the agent). */
|
|
65
|
+
function assertKnownConfigOptionIds(configOptions, advertised, backendId, label) {
|
|
66
|
+
if (!configOptions)
|
|
67
|
+
return;
|
|
68
|
+
const ids = advertised.map((option) => option.id);
|
|
69
|
+
for (const id of Object.keys(configOptions)) {
|
|
70
|
+
if (ids.includes(id))
|
|
71
|
+
continue;
|
|
72
|
+
throw agentValidationError(`config option "${id}" is not advertised by ${backendId}; advertised: ${ids.length > 0 ? ids.join(", ") : "(none)"}`, label);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function resolveNewSeed(options) {
|
|
76
|
+
const registry = resolveAgentRegistry(options.backends, options.label);
|
|
77
|
+
const route = resolveAgentRoute(options, registry);
|
|
78
|
+
return { kind: "new", registry, backend: route.backend, modelSpec: route.modelSpec };
|
|
79
|
+
}
|
|
80
|
+
function assertSessionRef(ref, label, method) {
|
|
81
|
+
if (!isRecord(ref) || typeof ref.sessionId !== "string" || ref.sessionId.trim() === "") {
|
|
82
|
+
throw agentValidationError(`${method} requires a session ref with a non-empty sessionId`, label);
|
|
83
|
+
}
|
|
84
|
+
if (typeof ref.backendId !== "string" || ref.backendId.trim() === "") {
|
|
85
|
+
throw agentValidationError(`${method} requires a session ref with a non-empty backendId`, label);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* One ACP agent session on its own dedicated backend process.
|
|
90
|
+
*
|
|
91
|
+
* Lazy: the constructor validates (cwd, `configOptions`, the registry, `clientHandlers`) and
|
|
92
|
+
* routes the backend synchronously but spawns nothing; the first queued operation (an explicit
|
|
93
|
+
* `ready()` or an implicit `prompt()`) opens the session. `state` walks
|
|
94
|
+
* `idle → opening → ready ⇄ busy → closed`.
|
|
95
|
+
*/
|
|
96
|
+
export class AcpAgent {
|
|
97
|
+
/** The session's absolute working directory (sent on session/new|fork|resume|load). */
|
|
98
|
+
cwd;
|
|
99
|
+
/** The human label stamped on event contexts and error `agentLabel`; never on the wire. */
|
|
100
|
+
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
|
+
#options;
|
|
107
|
+
#seed;
|
|
108
|
+
#registry;
|
|
109
|
+
#backend;
|
|
110
|
+
#modelSpec;
|
|
111
|
+
#schema;
|
|
112
|
+
#retainHistory;
|
|
113
|
+
#raw;
|
|
114
|
+
#signal;
|
|
115
|
+
#bus = new AgentEventBus();
|
|
116
|
+
#queue = new SerialQueue();
|
|
117
|
+
#replay = [];
|
|
118
|
+
#openPromise;
|
|
119
|
+
#closed = false;
|
|
120
|
+
#closedDetail;
|
|
121
|
+
#connection;
|
|
122
|
+
#handle;
|
|
123
|
+
#plan;
|
|
124
|
+
#structuredHost;
|
|
125
|
+
#sessionId;
|
|
126
|
+
#sessionRef;
|
|
127
|
+
#sessionUsage = ZERO_USAGE;
|
|
128
|
+
#historySeed = [];
|
|
129
|
+
#textSeed = "";
|
|
130
|
+
#collectingReplay = false;
|
|
131
|
+
#activeTurn;
|
|
132
|
+
#closePromise;
|
|
133
|
+
/** The `keep` the first `close()` asked for; a constructor-signal abort that drains that queued
|
|
134
|
+
* close() tears down with it, never with a keep of its own. */
|
|
135
|
+
#closeKeep;
|
|
136
|
+
#teardownPromise;
|
|
137
|
+
#teardownStarted = false;
|
|
138
|
+
#forkCount = 0;
|
|
139
|
+
#removeAbort;
|
|
140
|
+
/**
|
|
141
|
+
* Lazy: validates cwd/configOptions/registry/clientHandlers synchronously, routes the backend,
|
|
142
|
+
* spawns nothing. This is the ONLY public constructor signature — seeded agents (forks, cold
|
|
143
|
+
* reopen) are built by the statics through a module-private factory.
|
|
144
|
+
*/
|
|
145
|
+
constructor(options) {
|
|
146
|
+
const label = options.label;
|
|
147
|
+
validateAgentCwd(options.cwd, label, "AcpAgent");
|
|
148
|
+
assertNoModelConfigOption(options.configOptions, label);
|
|
149
|
+
try {
|
|
150
|
+
validateClientHandlers(options.clientHandlers);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
throw agentValidationError(error instanceof Error ? error.message : String(error), label);
|
|
154
|
+
}
|
|
155
|
+
const seed = constructionSeed ?? resolveNewSeed(options);
|
|
156
|
+
this.#options = { ...options };
|
|
157
|
+
this.#seed = seed;
|
|
158
|
+
this.#registry = seed.registry;
|
|
159
|
+
this.#backend = seed.backend;
|
|
160
|
+
this.#modelSpec = seed.modelSpec;
|
|
161
|
+
this.cwd = options.cwd;
|
|
162
|
+
this.label = label;
|
|
163
|
+
this.model = seed.modelSpec === undefined ? undefined : `${seed.backend.id}/${seed.modelSpec}`;
|
|
164
|
+
this.#schema = options.schema;
|
|
165
|
+
this.#retainHistory = options.retainHistory ?? true;
|
|
166
|
+
this.#raw = options.raw ?? true;
|
|
167
|
+
this.#signal = options.signal;
|
|
168
|
+
// Verbatim session/update records received before the session was ready (a load's replay, a
|
|
169
|
+
// fork's pre-response replay) — adopted from the acquisition buffer, observable as `replay`.
|
|
170
|
+
this.#bus.tap((name, event) => {
|
|
171
|
+
if (name !== "session_update" || !this.#collectingReplay)
|
|
172
|
+
return;
|
|
173
|
+
const { update } = event;
|
|
174
|
+
this.#replay.push({ update: structuredClone(update), receivedAt: Date.now() });
|
|
175
|
+
});
|
|
176
|
+
if (options.signal) {
|
|
177
|
+
const signal = options.signal;
|
|
178
|
+
if (signal.aborted) {
|
|
179
|
+
this.#closed = true;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
const onAbort = () => this.#onAbort();
|
|
183
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
184
|
+
this.#removeAbort = () => signal.removeEventListener("abort", onAbort);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** Statics and `fork()` build agents through this; the public signature never grows a second
|
|
189
|
+
* parameter, so a caller cannot hand-roll a seed that skips the ref/poolKey checks. */
|
|
190
|
+
static #seeded(options, seed) {
|
|
191
|
+
constructionSeed = seed;
|
|
192
|
+
try {
|
|
193
|
+
return new AcpAgent(options);
|
|
194
|
+
}
|
|
195
|
+
finally {
|
|
196
|
+
constructionSeed = undefined;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
static async #opened(agent) {
|
|
200
|
+
try {
|
|
201
|
+
await agent.ready();
|
|
202
|
+
return agent;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
await agent.close().catch(noop);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
/** `new AcpAgent(options)` + `ready()`; on failure the agent is closed and the mapped error rethrown. */
|
|
210
|
+
static async open(options) {
|
|
211
|
+
return AcpAgent.#opened(new AcpAgent(options));
|
|
212
|
+
}
|
|
213
|
+
/** No-prompt catalog discovery: the same `HarnessConfigReport` the MCP `action:"config"` is
|
|
214
|
+
* projected from, plus the per-harness `models` view. Never throws for a per-harness failure
|
|
215
|
+
* (`probed: false`); one disposed process per target. */
|
|
216
|
+
static probe(options = {}) {
|
|
217
|
+
return probeCatalog(options);
|
|
218
|
+
}
|
|
219
|
+
/** `session/resume` of `ref.sessionId` on a fresh dedicated process of `ref.backendId`
|
|
220
|
+
* (routed by name — never the default backend — and pool-key checked). `cwd` defaults to
|
|
221
|
+
* `ref.cwd`; `model` must stay on the ref's backend. */
|
|
222
|
+
static resume(ref, options = {}) {
|
|
223
|
+
return AcpAgent.#reopen("resume", ref, options);
|
|
224
|
+
}
|
|
225
|
+
/** `session/load`: the agent replays the transcript before the response; it lands in
|
|
226
|
+
* `history`/`text`/`replay` (the statics return after the fact, so the replay is observable
|
|
227
|
+
* only there, not through `on()`). */
|
|
228
|
+
static load(ref, options = {}) {
|
|
229
|
+
return AcpAgent.#reopen("load", ref, options);
|
|
230
|
+
}
|
|
231
|
+
/** Cold fork of a recorded session: the trait-driven choreography without a history seed
|
|
232
|
+
* (`history` starts empty on id-only backends unless the reattach fell back to `session/load`).
|
|
233
|
+
* To seed the fork with the transcript: `const src = await AcpAgent.load(ref); await src.fork()`. */
|
|
234
|
+
static fork(ref, options = {}) {
|
|
235
|
+
return AcpAgent.#reopen("fork", ref, options);
|
|
236
|
+
}
|
|
237
|
+
static async #reopen(kind, ref, options) {
|
|
238
|
+
const label = options.label;
|
|
239
|
+
const method = `AcpAgent.${kind}`;
|
|
240
|
+
assertSessionRef(ref, label, method);
|
|
241
|
+
const cwd = options.cwd ?? ref.cwd;
|
|
242
|
+
validateAgentCwd(cwd, label, method);
|
|
243
|
+
const registry = resolveAgentRegistry(options.backends, label);
|
|
244
|
+
const route = resolveRefRoute(ref, options.model, registry, label);
|
|
245
|
+
const base = { registry, backend: route.backend, modelSpec: route.modelSpec };
|
|
246
|
+
let seed;
|
|
247
|
+
if (kind === "fork") {
|
|
248
|
+
const trait = forkTraitFor(route.backend, registry);
|
|
249
|
+
if (trait.cwd === "source-only" && cwd !== ref.cwd) {
|
|
250
|
+
throw agentValidationError(`fork on ${route.backend.id} must keep the source cwd (${ref.cwd})`, label);
|
|
251
|
+
}
|
|
252
|
+
seed = { kind, sourceSessionId: ref.sessionId, ...base };
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
seed = { kind, sessionId: ref.sessionId, ...base };
|
|
256
|
+
}
|
|
257
|
+
return AcpAgent.#opened(AcpAgent.#seeded({ ...options, cwd }, seed));
|
|
258
|
+
}
|
|
259
|
+
// ── Getters (all readable after close; they return retained values) ──
|
|
260
|
+
/** The resolved backend id (built-in id or registered custom name). */
|
|
261
|
+
get backendId() {
|
|
262
|
+
return this.#backend.id;
|
|
263
|
+
}
|
|
264
|
+
/** `idle` → `opening` → `ready` ⇄ `busy` → `closed` (set the instant `close()` is called,
|
|
265
|
+
* the constructor signal aborts, or the process dies). */
|
|
266
|
+
get state() {
|
|
267
|
+
if (this.#closed)
|
|
268
|
+
return "closed";
|
|
269
|
+
if (this.#handle === undefined)
|
|
270
|
+
return this.#openPromise ? "opening" : "idle";
|
|
271
|
+
return this.#queue.running ? "busy" : "ready";
|
|
272
|
+
}
|
|
273
|
+
/** The ACP session id once open; retained after close. */
|
|
274
|
+
get sessionId() {
|
|
275
|
+
return this.#sessionId;
|
|
276
|
+
}
|
|
277
|
+
/** The re-attach handle computed at open (drives `AcpAgent.resume/load/fork`); retained after close. */
|
|
278
|
+
get sessionRef() {
|
|
279
|
+
return this.#sessionRef;
|
|
280
|
+
}
|
|
281
|
+
/** Capabilities negotiated on this agent's dedicated connection. */
|
|
282
|
+
get capabilities() {
|
|
283
|
+
return this.#connection?.capabilities;
|
|
284
|
+
}
|
|
285
|
+
/** The latest echoed session config-option catalog (verbatim ACP wire shapes). */
|
|
286
|
+
get configOptions() {
|
|
287
|
+
return this.#handle?.advertisedConfigOptions ?? [];
|
|
288
|
+
}
|
|
289
|
+
/** The agent-advertised mode catalog plus the current mode, when supported. */
|
|
290
|
+
get modes() {
|
|
291
|
+
return this.#handle?.modes;
|
|
292
|
+
}
|
|
293
|
+
/** `[...seed, ...session history]` (copies on read). The seed is the parent's snapshot for a
|
|
294
|
+
* live fork; a `session/load` replay lands in the session history itself. */
|
|
295
|
+
get history() {
|
|
296
|
+
return [
|
|
297
|
+
...this.#historySeed.map((entry) => ({ ...entry })),
|
|
298
|
+
...(this.#handle?.history ?? []).map((entry) => ({ ...entry })),
|
|
299
|
+
];
|
|
300
|
+
}
|
|
301
|
+
/** Verbatim session/update records received before the session was ready (a fork's
|
|
302
|
+
* pre-response replay, a load's replay), adopted from the acquisition buffer. */
|
|
303
|
+
get replay() {
|
|
304
|
+
return this.#replay;
|
|
305
|
+
}
|
|
306
|
+
/** The retained assistant text — the parent's seed (live fork) and this session's messages —
|
|
307
|
+
* folded exactly like `turn.text`: chunks of one message concatenate, distinct messages join
|
|
308
|
+
* with "\n\n". */
|
|
309
|
+
get text() {
|
|
310
|
+
return [this.#textSeed, this.#handle?.foldedText() ?? ""].filter((part) => part !== "").join("\n\n");
|
|
311
|
+
}
|
|
312
|
+
/** Running per-field sum of every turn this agent ran; `ZERO_USAGE` before the first turn. */
|
|
313
|
+
get usage() {
|
|
314
|
+
return this.#sessionUsage;
|
|
315
|
+
}
|
|
316
|
+
/** The session-level structured-output contract, if any. */
|
|
317
|
+
get schema() {
|
|
318
|
+
return this.#schema;
|
|
319
|
+
}
|
|
320
|
+
// ── Events (per agent: only this agent's session id; forks get their own emitter) ──
|
|
321
|
+
/** Subscribe. `session_open` is sticky: a listener registered after the session opened receives
|
|
322
|
+
* it once (next microtask); a listener that saw it live never sees it twice. Returns the
|
|
323
|
+
* unsubscribe thunk; after close it is a no-op. */
|
|
324
|
+
on(name, listener) {
|
|
325
|
+
return this.#bus.on(name, listener);
|
|
326
|
+
}
|
|
327
|
+
once(name, listener) {
|
|
328
|
+
return this.#bus.once(name, listener);
|
|
329
|
+
}
|
|
330
|
+
off(name, listener) {
|
|
331
|
+
this.#bus.off(name, listener);
|
|
332
|
+
}
|
|
333
|
+
// ── Lifecycle ──
|
|
334
|
+
/** Spawn + initialize + session/new|resume|load|fork (idempotent; memoized). The implicit
|
|
335
|
+
* open of the first `prompt()` shares the same promise. */
|
|
336
|
+
ready() {
|
|
337
|
+
return this.#enqueue(async () => {
|
|
338
|
+
await this.#ensureOpen();
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* One prompt turn, FIFO behind every earlier queued operation. Resolves an `AcpAgentTurn` for
|
|
343
|
+
* EVERY `PromptResponse` the wire returned (no `stopReason` is thrown on — refusal, max_tokens,
|
|
344
|
+
* cancelled included). Rejects only on a wire rejection (mapped like the runner), validation,
|
|
345
|
+
* abort (`signal.reason` untouched), a closed agent, or a typed session failure (the mapped
|
|
346
|
+
* `WorkflowError` carrying the complete turn as `error.turn`; see `isAcpAgentTurnError`).
|
|
347
|
+
* `configOptions`/`mode` are applied before the turn and stick for the session. To stop a
|
|
348
|
+
* specific turn use `options.signal`: it rejects while queued or before the turn reached the
|
|
349
|
+
* wire (nothing is sent) and sends one `session/cancel` once in flight — `cancel()` reaches only
|
|
350
|
+
* a turn already on the wire.
|
|
351
|
+
*/
|
|
352
|
+
prompt(content, options = {}) {
|
|
353
|
+
return this.#enqueue(async () => {
|
|
354
|
+
await this.#ensureOpen();
|
|
355
|
+
options.signal?.throwIfAborted();
|
|
356
|
+
this.#signal?.throwIfAborted();
|
|
357
|
+
const handle = this.#handle;
|
|
358
|
+
const plan = this.#plan;
|
|
359
|
+
const backend = this.#backend;
|
|
360
|
+
const label = this.label;
|
|
361
|
+
validatePromptImages(options.images, label);
|
|
362
|
+
assertPerTurnSchemaAllowed(backend, options.schema, label);
|
|
363
|
+
assertNoModelConfigOption(options.configOptions, label);
|
|
364
|
+
assertKnownConfigOptionIds(options.configOptions, handle.advertisedConfigOptions, this.backendId, label);
|
|
365
|
+
try {
|
|
366
|
+
if (options.configOptions) {
|
|
367
|
+
await handle.setConfigOptions(options.configOptions);
|
|
368
|
+
options.signal?.throwIfAborted();
|
|
369
|
+
this.#signal?.throwIfAborted();
|
|
370
|
+
}
|
|
371
|
+
if (options.mode !== undefined) {
|
|
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);
|
|
400
|
+
};
|
|
401
|
+
callSignal?.addEventListener("abort", onCallAbort, { once: true });
|
|
402
|
+
let response;
|
|
403
|
+
let failure;
|
|
404
|
+
try {
|
|
405
|
+
({ response, failure } = await outcome);
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (active.aborted)
|
|
409
|
+
throw active.abortReason;
|
|
410
|
+
if (this.#signal?.aborted)
|
|
411
|
+
throw this.#signal.reason;
|
|
412
|
+
throw mapAgentError(error, this.#errorContext());
|
|
413
|
+
}
|
|
414
|
+
finally {
|
|
415
|
+
collector.stop();
|
|
416
|
+
callSignal?.removeEventListener("abort", onCallAbort);
|
|
417
|
+
if (this.#activeTurn === active)
|
|
418
|
+
this.#activeTurn = undefined;
|
|
419
|
+
}
|
|
420
|
+
// An abort observed in flight rejects with the reason even when the agent answered
|
|
421
|
+
// `stopReason: "cancelled"` — abort is never a resolved turn.
|
|
422
|
+
if (active.aborted)
|
|
423
|
+
throw active.abortReason;
|
|
424
|
+
if (this.#signal?.aborted)
|
|
425
|
+
throw this.#signal.reason;
|
|
426
|
+
const turn = buildTurn({
|
|
427
|
+
response,
|
|
428
|
+
collector,
|
|
429
|
+
handle,
|
|
430
|
+
backend,
|
|
431
|
+
schema: turnSchema,
|
|
432
|
+
captured: plan.registration?.takeCaptured(),
|
|
433
|
+
sessionBefore: this.#sessionUsage,
|
|
434
|
+
});
|
|
435
|
+
// A walled turn still counts the tokens it burned.
|
|
436
|
+
this.#sessionUsage = turn.usage.session;
|
|
437
|
+
if (failure)
|
|
438
|
+
throw agentTurnError(failure, turn, this.#errorContext());
|
|
439
|
+
return turn;
|
|
440
|
+
}, options.signal);
|
|
441
|
+
}
|
|
442
|
+
/** Inject content into the turn in flight (`_session/steering`). Overlaps the FIFO; requires a
|
|
443
|
+
* `prompt()` in flight (SCRIPT_VALIDATION_ERROR otherwise). The complete raw response is returned. */
|
|
444
|
+
async steer(content, options = {}) {
|
|
445
|
+
this.#signal?.throwIfAborted();
|
|
446
|
+
if (this.#closed)
|
|
447
|
+
throw this.#closedError();
|
|
448
|
+
const handle = this.#handle;
|
|
449
|
+
if (!this.#activeTurn || !handle) {
|
|
450
|
+
throw agentValidationError("AcpAgent.steer() requires a prompt() in flight", this.label);
|
|
451
|
+
}
|
|
452
|
+
validatePromptImages(options.images, this.label);
|
|
453
|
+
try {
|
|
454
|
+
return await handle.steer(appendPromptImages(content, options.images), options.meta);
|
|
455
|
+
}
|
|
456
|
+
catch (error) {
|
|
457
|
+
throw mapAgentError(error, this.#errorContext(), this.#signal);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** ONE `session/cancel` for the turn whose `session/prompt` is on the wire (no-op otherwise).
|
|
461
|
+
* Resolves at the notify boundary; the in-flight `prompt()` then resolves with
|
|
462
|
+
* `stopReason: "cancelled"` when the agent honors it. A turn that ignores the cancel for the
|
|
463
|
+
* grace period ends in process disposal WITHOUT a wire `session/close` (the session stays
|
|
464
|
+
* re-openable through `sessionRef`); the turn then rejects and the agent is closed. Queued
|
|
465
|
+
* 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` — a
|
|
467
|
+
* `cancel()` in that window is a no-op the turn never sees. A per-call `signal` covers every
|
|
468
|
+
* window (rejects with the reason before anything is sent; `session/cancel` once in flight). */
|
|
469
|
+
cancel() {
|
|
470
|
+
return this.#cancelTurn();
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Fork this agent onto a NEW dedicated process (queued: it runs only when no turn is in flight,
|
|
474
|
+
* so the parent's persisted transcript is complete). Inherits every constructor option except
|
|
475
|
+
* `label` (suffixed `/fork-<n>`) and `signal`; `overrides` may change anything but the backend
|
|
476
|
+
* (`backends`, `authStore`, `providerStore`, `clientHandlers` are typed out; a `model` override
|
|
477
|
+
* must route to the same backend; a `cwd` override is rejected on `source-only` backends). The
|
|
478
|
+
* child's `history`/`text` are seeded from the parent's snapshot on backends whose fork response
|
|
479
|
+
* has no replay. The parent keeps going, unaffected; closing either side never affects the other.
|
|
480
|
+
*/
|
|
481
|
+
fork(overrides = {}) {
|
|
482
|
+
return this.#enqueue(async () => {
|
|
483
|
+
await this.#ensureOpen();
|
|
484
|
+
this.#signal?.throwIfAborted();
|
|
485
|
+
const handle = this.#handle;
|
|
486
|
+
const trait = forkTraitFor(this.#backend, this.#registry);
|
|
487
|
+
const n = (this.#forkCount += 1);
|
|
488
|
+
const label = overrides.label ?? (this.label ? `${this.label}/fork-${n}` : `fork-${n}`);
|
|
489
|
+
const cwd = overrides.cwd ?? this.cwd;
|
|
490
|
+
// An override set to `undefined` means "not overridden" (`fork({ schema: maybeSchema })` with
|
|
491
|
+
// an undefined variable type-checks): drop such keys so the spread cannot erase the parent's
|
|
492
|
+
// value.
|
|
493
|
+
const defined = Object.fromEntries(Object.entries(overrides).filter(([, value]) => value !== undefined));
|
|
494
|
+
const merged = {
|
|
495
|
+
...this.#options,
|
|
496
|
+
...defined,
|
|
497
|
+
cwd,
|
|
498
|
+
label,
|
|
499
|
+
signal: overrides.signal,
|
|
500
|
+
backends: this.#options.backends,
|
|
501
|
+
authStore: this.#options.authStore,
|
|
502
|
+
providerStore: this.#options.providerStore,
|
|
503
|
+
clientHandlers: this.#options.clientHandlers,
|
|
504
|
+
};
|
|
505
|
+
validateAgentCwd(cwd, this.label, "AcpAgent.fork");
|
|
506
|
+
if (trait.cwd === "source-only" && cwd !== this.cwd) {
|
|
507
|
+
throw agentValidationError(`fork on ${this.backendId} must keep the source cwd (${this.cwd})`, this.label);
|
|
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);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
assertNoModelConfigOption(merged.configOptions, label);
|
|
518
|
+
const child = AcpAgent.#seeded(merged, {
|
|
519
|
+
kind: "fork",
|
|
520
|
+
sourceSessionId: handle.sessionId,
|
|
521
|
+
registry: this.#registry,
|
|
522
|
+
backend: route?.backend ?? freshBackendFor(this.#backend, this.#registry),
|
|
523
|
+
modelSpec: route ? route.modelSpec : this.#modelSpec,
|
|
524
|
+
historySeed: this.history.map((entry) => ({ ...entry })),
|
|
525
|
+
textSeed: this.text,
|
|
526
|
+
});
|
|
527
|
+
return AcpAgent.#opened(child);
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
/** `session/set_mode` (queued; strict — an unadvertised id is a SCRIPT_VALIDATION_ERROR). */
|
|
531
|
+
setMode(modeId) {
|
|
532
|
+
return this.#enqueue(async () => {
|
|
533
|
+
await this.#ensureOpen();
|
|
534
|
+
this.#signal?.throwIfAborted();
|
|
535
|
+
try {
|
|
536
|
+
await this.#handle.setMode(modeId);
|
|
537
|
+
}
|
|
538
|
+
catch (error) {
|
|
539
|
+
throw mapAgentError(error, this.#errorContext(), this.#signal);
|
|
540
|
+
}
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/** `session/set_config_option` per id in ascending order (queued; sticky; `"model"` reserved;
|
|
544
|
+
* unknown ids rejected against the advertised catalog). */
|
|
545
|
+
setConfigOptions(options) {
|
|
546
|
+
return this.#enqueue(async () => {
|
|
547
|
+
await this.#ensureOpen();
|
|
548
|
+
this.#signal?.throwIfAborted();
|
|
549
|
+
const handle = this.#handle;
|
|
550
|
+
assertNoModelConfigOption(options, this.label);
|
|
551
|
+
assertKnownConfigOptionIds(options, handle.advertisedConfigOptions, this.backendId, this.label);
|
|
552
|
+
try {
|
|
553
|
+
await handle.setConfigOptions(options);
|
|
554
|
+
}
|
|
555
|
+
catch (error) {
|
|
556
|
+
throw mapAgentError(error, this.#errorContext(), this.#signal);
|
|
557
|
+
}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Close: `state` becomes `closed` immediately (no new work is admitted), the teardown waits
|
|
562
|
+
* behind queued work, releases the session (`keep: true` skips the wire `session/close` so the
|
|
563
|
+
* agent-persisted session stays re-openable via `sessionRef`), disposes the dedicated process,
|
|
564
|
+
* and releases the structured-output tool. Idempotent (same promise); never throws for an
|
|
565
|
+
* already-dead process; rethrows only a `child_cleanup_error` (mapped, non-recoverable).
|
|
566
|
+
*/
|
|
567
|
+
close(options = {}) {
|
|
568
|
+
this.#closeKeep ??= options.keep === true;
|
|
569
|
+
this.#closePromise ??= this.#closeOwned(this.#closeKeep);
|
|
570
|
+
return this.#closePromise;
|
|
571
|
+
}
|
|
572
|
+
/** `await using agent = …` — equivalent to `close()`. */
|
|
573
|
+
async [Symbol.asyncDispose]() {
|
|
574
|
+
await this.close();
|
|
575
|
+
}
|
|
576
|
+
// ── Internals ──
|
|
577
|
+
#enqueue(op, signal) {
|
|
578
|
+
if (this.#signal?.aborted)
|
|
579
|
+
return Promise.reject(this.#signal.reason);
|
|
580
|
+
if (this.#closed)
|
|
581
|
+
return Promise.reject(this.#closedError());
|
|
582
|
+
return this.#queue.run(op, signal);
|
|
583
|
+
}
|
|
584
|
+
#closedError(detail) {
|
|
585
|
+
return agentClosedError(this.label, this.backendId, detail ?? this.#closedDetail);
|
|
586
|
+
}
|
|
587
|
+
#errorContext() {
|
|
588
|
+
return {
|
|
589
|
+
label: this.label,
|
|
590
|
+
backendId: this.backendId,
|
|
591
|
+
backend: this.#backend,
|
|
592
|
+
providerErrorMetadata: this.#handle?.providerErrorMetadata,
|
|
593
|
+
authMethods: this.#connection?.capabilities?.authMethods,
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
#ensureOpen() {
|
|
597
|
+
this.#openPromise ??= this.#open();
|
|
598
|
+
return this.#openPromise;
|
|
599
|
+
}
|
|
600
|
+
#connectionDeps() {
|
|
601
|
+
const options = this.#options;
|
|
602
|
+
return {
|
|
603
|
+
onDead: () => this.#onDead(),
|
|
604
|
+
onEvent: this.#bus.sink,
|
|
605
|
+
// Session-scoped resolvers ride AcpSessionOptions; the connection-wide ones stay undefined.
|
|
606
|
+
advertiseElicitation: Boolean(options.onElicitation),
|
|
607
|
+
authStore: options.authStore,
|
|
608
|
+
providerStore: options.providerStore,
|
|
609
|
+
clientHandlers: options.clientHandlers,
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
#layeredMeta() {
|
|
613
|
+
const options = this.#options;
|
|
614
|
+
return this.#raw ? layerRawMeta(this.#backend.rawMessagesMeta?.(), options.meta) : options.meta;
|
|
615
|
+
}
|
|
616
|
+
#sessionOptions(plan) {
|
|
617
|
+
const options = this.#options;
|
|
618
|
+
return {
|
|
619
|
+
cwd: this.cwd,
|
|
620
|
+
schema: this.#schema,
|
|
621
|
+
policy: options.tools ?? {},
|
|
622
|
+
permissionResolver: options.onPermissionRequest,
|
|
623
|
+
enforceToolPolicyBeforePermissionResolver: false,
|
|
624
|
+
elicitationResolver: options.onElicitation,
|
|
625
|
+
// The agent owns abort (it sends the cancel and the escalation itself); never the handle.
|
|
626
|
+
signal: undefined,
|
|
627
|
+
mcpServers: plan.mcpServers,
|
|
628
|
+
meta: this.#layeredMeta(),
|
|
629
|
+
label: this.label,
|
|
630
|
+
baseInstructions: options.instructions?.base,
|
|
631
|
+
developerInstructions: options.instructions?.developer,
|
|
632
|
+
retainSessionLog: this.#retainHistory,
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
#planStructured(connection) {
|
|
636
|
+
return planStructured({
|
|
637
|
+
schema: this.#schema,
|
|
638
|
+
backend: this.#backend,
|
|
639
|
+
mcpServers: this.#options.mcpServers,
|
|
640
|
+
host: () => (this.#structuredHost ??= new StructuredOutputToolHost()),
|
|
641
|
+
}, connection);
|
|
642
|
+
}
|
|
643
|
+
async #open() {
|
|
644
|
+
let handle;
|
|
645
|
+
let plan;
|
|
646
|
+
try {
|
|
647
|
+
// Inside the try: `create` spawns synchronously and can throw before any wire traffic
|
|
648
|
+
// (spawn argument validation, missing stdio pipes, a backend's `spawnConfig()` side
|
|
649
|
+
// effects); such a failure must close the agent and map like every other open failure.
|
|
650
|
+
const connection = PooledConnection.create(this.#backend, this.#connectionDeps());
|
|
651
|
+
this.#connection = connection;
|
|
652
|
+
retainOnExit(connection);
|
|
653
|
+
this.#bus.beginAcquisition();
|
|
654
|
+
this.#collectingReplay = true;
|
|
655
|
+
const seed = this.#seed;
|
|
656
|
+
let replayed = false;
|
|
657
|
+
if (seed.kind === "new") {
|
|
658
|
+
// `prepare` runs after initialize, so the injection decision sees the capabilities.
|
|
659
|
+
handle = await connection.openPreparedSession(async (ready) => {
|
|
660
|
+
plan = await this.#planStructured(ready);
|
|
661
|
+
return this.#sessionOptions(plan);
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
else {
|
|
665
|
+
// The cheapest "await initialize": the injection decision needs the capabilities.
|
|
666
|
+
await connection.authMethods();
|
|
667
|
+
plan = await this.#planStructured(connection);
|
|
668
|
+
const opts = this.#sessionOptions(plan);
|
|
669
|
+
if (seed.kind === "fork") {
|
|
670
|
+
const trait = forkTraitFor(this.#backend, this.#registry);
|
|
671
|
+
const acquired = await acquireForkedSession(connection, seed.sourceSessionId, opts, trait);
|
|
672
|
+
handle = acquired.handle;
|
|
673
|
+
replayed = acquired.method === "load";
|
|
674
|
+
}
|
|
675
|
+
else if (seed.kind === "resume") {
|
|
676
|
+
handle = await connection.resumeSession(seed.sessionId, opts);
|
|
677
|
+
}
|
|
678
|
+
else {
|
|
679
|
+
handle = await connection.loadSession(seed.sessionId, opts);
|
|
680
|
+
replayed = true;
|
|
681
|
+
}
|
|
682
|
+
// The replay is complete at the load response; mark synchronously, before any later
|
|
683
|
+
// wire message can be applied.
|
|
684
|
+
if (replayed)
|
|
685
|
+
handle.markLoadBoundary();
|
|
686
|
+
}
|
|
687
|
+
this.#handle = handle;
|
|
688
|
+
this.#plan = plan;
|
|
689
|
+
this.#sessionId = handle.sessionId;
|
|
690
|
+
this.#bus.endAcquisition(handle.sessionId);
|
|
691
|
+
this.#collectingReplay = false;
|
|
692
|
+
this.#signal?.throwIfAborted();
|
|
693
|
+
await this.#applyPostOpen(handle);
|
|
694
|
+
if (seed.kind === "fork")
|
|
695
|
+
this.#seedHistory(seed.historySeed, seed.textSeed, handle);
|
|
696
|
+
this.#sessionRef = sessionRefFor(handle, this.#backend, this.cwd);
|
|
697
|
+
this.#sessionUsage = ZERO_USAGE;
|
|
698
|
+
}
|
|
699
|
+
catch (error) {
|
|
700
|
+
this.#collectingReplay = false;
|
|
701
|
+
this.#bus.abortAcquisition();
|
|
702
|
+
this.#closed = true;
|
|
703
|
+
this.#handle ??= handle;
|
|
704
|
+
this.#plan ??= plan;
|
|
705
|
+
plan?.registration?.release();
|
|
706
|
+
// Cleanup failure (child_cleanup_error) wins, exactly like the runner's interactive open.
|
|
707
|
+
await this.#teardown(false);
|
|
708
|
+
throw this.#signal?.aborted ? this.#signal.reason : mapAgentError(error, this.#errorContext());
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/** Re-apply model selection, config options and the mode on the LIVE handle (fork/resume/load
|
|
712
|
+
* responses replace the catalog). The mode rule is the runner's, verbatim. */
|
|
713
|
+
async #applyPostOpen(handle) {
|
|
714
|
+
const opts = this.#options;
|
|
715
|
+
const backend = this.#backend;
|
|
716
|
+
if (this.#modelSpec !== undefined)
|
|
717
|
+
await handle.selectModel(this.#modelSpec);
|
|
718
|
+
this.#signal?.throwIfAborted();
|
|
719
|
+
assertKnownConfigOptionIds(opts.configOptions, handle.advertisedConfigOptions, this.backendId, this.label);
|
|
720
|
+
await handle.setConfigOptions(opts.configOptions);
|
|
721
|
+
this.#signal?.throwIfAborted();
|
|
722
|
+
const effectiveMode = opts.mode ?? backend.defaultModeId;
|
|
723
|
+
if (effectiveMode &&
|
|
724
|
+
(opts.mode !== undefined || handle.modes?.availableModes.some((mode) => mode.id === effectiveMode))) {
|
|
725
|
+
await handle.setMode(effectiveMode);
|
|
726
|
+
}
|
|
727
|
+
this.#signal?.throwIfAborted();
|
|
728
|
+
}
|
|
729
|
+
/** Seed a live fork's history/text from the parent's snapshot — only when the child's own
|
|
730
|
+
* accumulator is empty (a `session/load` fallback already replayed the transcript). */
|
|
731
|
+
#seedHistory(seed, text, handle) {
|
|
732
|
+
if (!seed || handle.history.length > 0)
|
|
733
|
+
return;
|
|
734
|
+
this.#historySeed = seed;
|
|
735
|
+
this.#textSeed = text ?? "";
|
|
736
|
+
}
|
|
737
|
+
#cancelTurn() {
|
|
738
|
+
const active = this.#activeTurn;
|
|
739
|
+
const connection = this.#connection;
|
|
740
|
+
const sessionId = this.#sessionId;
|
|
741
|
+
if (!active || !connection || sessionId === undefined)
|
|
742
|
+
return Promise.resolve();
|
|
743
|
+
// Settles pending permissions/elicitations + ONE session/cancel notify.
|
|
744
|
+
active.cancelRequested ??= connection.cancelSession(sessionId);
|
|
745
|
+
active.escalation ??= active.cancelRequested.then(async () => {
|
|
746
|
+
if (await resolvesWithin(active.ended, cancelGraceMs))
|
|
747
|
+
return;
|
|
748
|
+
// Ignored: kill the process; NO wire session/close, so `keep` semantics survive.
|
|
749
|
+
await connection.dispose();
|
|
750
|
+
});
|
|
751
|
+
void active.escalation.catch(noop);
|
|
752
|
+
return active.cancelRequested;
|
|
753
|
+
}
|
|
754
|
+
#onAbort() {
|
|
755
|
+
const reason = this.#signal?.reason;
|
|
756
|
+
this.#closed = true;
|
|
757
|
+
this.#queue.drain(reason);
|
|
758
|
+
void this.#cancelTurn().catch(noop);
|
|
759
|
+
// An open/fork/reattach in flight: dispose the process so the raced wire call rejects.
|
|
760
|
+
if (this.#handle === undefined && this.#connection)
|
|
761
|
+
void this.#connection.dispose().catch(noop);
|
|
762
|
+
// Not queued: tear down once the in-flight op settled (queued ones were just drained). A
|
|
763
|
+
// close() that was queued behind that op keeps the `keep` it asked for.
|
|
764
|
+
void this.#queue.whenIdle().then(() => this.#teardown(this.#closeKeep ?? false)).catch(noop);
|
|
765
|
+
}
|
|
766
|
+
#onDead() {
|
|
767
|
+
// Our own dispose (close / abort / open failure) — the teardown already owns the connection.
|
|
768
|
+
if (this.#teardownStarted)
|
|
769
|
+
return;
|
|
770
|
+
if (!this.#closed)
|
|
771
|
+
this.#closedDetail = "process exited";
|
|
772
|
+
this.#closed = true;
|
|
773
|
+
this.#queue.drain(this.#closedError("process exited before the queued operation ran"));
|
|
774
|
+
void this.#teardown(true).catch(noop);
|
|
775
|
+
}
|
|
776
|
+
async #closeOwned(keep) {
|
|
777
|
+
this.#closed = true;
|
|
778
|
+
let started = false;
|
|
779
|
+
try {
|
|
780
|
+
await this.#queue.run(() => {
|
|
781
|
+
started = true;
|
|
782
|
+
return this.#teardown(keep);
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
// The teardown itself failed: only a genuine child_cleanup_error (mapped) gets here.
|
|
787
|
+
if (started)
|
|
788
|
+
throw error;
|
|
789
|
+
// The queued entry was drained (constructor abort / process death) while an op was still
|
|
790
|
+
// running: wait for that op to settle, then run the memoized teardown — never under a turn
|
|
791
|
+
// that is still on the wire (the abort's own cancel + grace must play out first).
|
|
792
|
+
await this.#queue.whenIdle();
|
|
793
|
+
await this.#teardown(keep);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
#teardown(keep) {
|
|
797
|
+
this.#teardownPromise ??= this.#teardownOwned(keep);
|
|
798
|
+
return this.#teardownPromise;
|
|
799
|
+
}
|
|
800
|
+
async #teardownOwned(keep) {
|
|
801
|
+
this.#teardownStarted = true;
|
|
802
|
+
const handle = this.#handle;
|
|
803
|
+
const connection = this.#connection;
|
|
804
|
+
const plan = this.#plan;
|
|
805
|
+
const host = this.#structuredHost;
|
|
806
|
+
let cleanupError;
|
|
807
|
+
try {
|
|
808
|
+
if (handle)
|
|
809
|
+
await handle.release({ keepOpen: keep });
|
|
810
|
+
}
|
|
811
|
+
catch (error) {
|
|
812
|
+
if (isChildCleanupError(error))
|
|
813
|
+
cleanupError = error;
|
|
814
|
+
}
|
|
815
|
+
plan?.registration?.release();
|
|
816
|
+
// The process BEFORE the tool host (the runner's order: pool, then tools): the agent process
|
|
817
|
+
// holds keep-alive sockets to the host's HTTP server, and `server.close()` waits for idle
|
|
818
|
+
// sockets to time out (seconds) unless the peer is gone first.
|
|
819
|
+
if (connection) {
|
|
820
|
+
await connection.dispose().catch(noop);
|
|
821
|
+
releaseOnExit(connection);
|
|
822
|
+
}
|
|
823
|
+
if (host)
|
|
824
|
+
await host.dispose().catch(noop);
|
|
825
|
+
this.#removeAbort?.();
|
|
826
|
+
this.#removeAbort = undefined;
|
|
827
|
+
// Last, so the agent's own `session_close` (emitted by the release above) was delivered.
|
|
828
|
+
this.#bus.close();
|
|
829
|
+
if (cleanupError)
|
|
830
|
+
throw mapAgentError(cleanupError, this.#errorContext());
|
|
831
|
+
}
|
|
832
|
+
}
|