@intx/agent 0.1.2 → 0.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.
Files changed (60) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +80 -5
  3. package/dist/agent.d.ts +116 -0
  4. package/dist/agent.js +682 -0
  5. package/dist/canonicalize.d.ts +15 -0
  6. package/dist/canonicalize.js +160 -0
  7. package/dist/default-director.d.ts +24 -0
  8. package/dist/default-director.js +45 -0
  9. package/dist/definition.d.ts +139 -0
  10. package/dist/definition.js +40 -0
  11. package/dist/director-registry.d.ts +47 -0
  12. package/dist/director-registry.js +87 -0
  13. package/dist/director-types.d.ts +80 -0
  14. package/dist/director-types.js +13 -0
  15. package/dist/director.d.ts +70 -0
  16. package/dist/director.js +131 -0
  17. package/dist/env-validation.d.ts +59 -0
  18. package/dist/env-validation.js +180 -0
  19. package/dist/env.d.ts +160 -0
  20. package/dist/env.js +53 -0
  21. package/dist/index.d.ts +16 -0
  22. package/dist/index.js +23 -0
  23. package/dist/internal-fixtures/mail.d.ts +39 -0
  24. package/dist/internal-fixtures/mail.js +86 -0
  25. package/dist/internal-fixtures/planner.d.ts +19 -0
  26. package/dist/internal-fixtures/planner.js +49 -0
  27. package/dist/lock.d.ts +16 -0
  28. package/dist/lock.js +47 -0
  29. package/dist/namespace.d.ts +12 -0
  30. package/dist/namespace.js +39 -0
  31. package/dist/send-queue.d.ts +25 -0
  32. package/dist/send-queue.js +147 -0
  33. package/dist/source.d.ts +43 -0
  34. package/dist/source.js +118 -0
  35. package/dist/stream.d.ts +16 -0
  36. package/dist/stream.js +115 -0
  37. package/dist/testing/audit-noop.d.ts +7 -0
  38. package/dist/testing/audit-noop.js +25 -0
  39. package/dist/testing/authorize-allow.d.ts +8 -0
  40. package/dist/testing/authorize-allow.js +19 -0
  41. package/dist/testing/index.d.ts +2 -0
  42. package/dist/testing/index.js +17 -0
  43. package/dist/tool.d.ts +238 -0
  44. package/dist/tool.js +244 -0
  45. package/package.json +26 -7
  46. package/src/agent.test.ts +0 -46
  47. package/src/agent.ts +0 -494
  48. package/src/index.ts +0 -38
  49. package/src/lock.test.ts +0 -93
  50. package/src/lock.ts +0 -57
  51. package/src/send-queue.test.ts +0 -207
  52. package/src/send-queue.ts +0 -200
  53. package/src/source.test.ts +0 -171
  54. package/src/source.ts +0 -93
  55. package/src/stream.test.ts +0 -167
  56. package/src/stream.ts +0 -142
  57. package/src/tool.test.ts +0 -217
  58. package/src/tool.ts +0 -148
  59. package/tsconfig.json +0 -4
  60. package/tsconfig.tsbuildinfo +0 -1
package/dist/agent.js ADDED
@@ -0,0 +1,682 @@
1
+ // In-process agent runtime.
2
+ //
3
+ // `createAgent(def, env)` is the single entry point. The `def` is the
4
+ // portable, hashable `AgentDefinition` (id, system prompt, tool
5
+ // factories, director ref, inference preferences, capabilities, tags).
6
+ // The `env` is the runtime environment supplying the active inference
7
+ // source, the context store, the working directory, the audit sink,
8
+ // the authorize callback, and the director registry. The agent
9
+ // instantiates against those: it locks the context directory, walks
10
+ // each tool factory to build its tool runner, resolves the director
11
+ // against the registry, and wires the result into the reactor
12
+ // assembly. The reactor is wrapped exactly once.
13
+ //
14
+ // Composition:
15
+ // - `send()` enqueues into a FIFO `SendQueue` capped at
16
+ // `env.sendQueueMax`. Per-send `AbortSignal` removes queued items or
17
+ // rejects in-flight callers while letting the reactor cycle finish
18
+ // in the background.
19
+ // - `stream()` returns a bounded `StreamConsumer` iterator; consumers
20
+ // buffer independently and noisy backpressure poisons only the
21
+ // affected iterator.
22
+ // - `close()` aborts the reactor, drains the send queue with
23
+ // `AgentClosedError`, terminates every active stream iterator, waits
24
+ // up to `env.closeTimeoutMs` for the reactor's shutdown sequence to
25
+ // complete (audit flush, in-flight commits), and finally releases
26
+ // the singleton-per-`workdir` lock so another agent can open the
27
+ // same directory.
28
+ //
29
+ // `setSource` covers the whole source: id/provider/baseURL/apiKey/model
30
+ // plus the model-bound `defaults` and `capabilities`. Credentials and
31
+ // model rotate together via the shared source object the reactor reads
32
+ // lazily at the start of each inference call. The director never names
33
+ // a model -- `capabilities.infer(options?)` does not take one -- so the
34
+ // active source's model is the single source of truth and rotations
35
+ // take effect on the next inference call without any wrapper.
36
+ //
37
+ // Tool factories are bundle-shaped: each declares `(env) => ToolBundle`
38
+ // via `defineTool`. The agent invokes each factory once at construction,
39
+ // collects the bundles' definitions, and dispatches calls to the
40
+ // owning bundle's `run`. Bundle lifetimes (and any `dispose` step) are
41
+ // the caller's responsibility -- the env is the agent's dependency
42
+ // contract; the caller owns the lifetime of what it puts in env.
43
+ import { createReactorAssembly, } from "@intx/inference";
44
+ import { createDefaultDependencies } from "@intx/inference/providers";
45
+ import { getLogger } from "@intx/log";
46
+ import { createInboundMessage } from "@intx/mime";
47
+ import { validateDirectorConfig } from "./director.js";
48
+ import { validateEnv } from "./env-validation.js";
49
+ import { acquireContextDirLock } from "./lock.js";
50
+ import { createSourceRegistry } from "./source.js";
51
+ import { createSendQueue } from "./send-queue.js";
52
+ import { createStreamConsumer } from "./stream.js";
53
+ import { DuplicateToolError } from "./tool.js";
54
+ const logger = getLogger(["interchange", "agent"]);
55
+ // Synthetic recipient/sender used when `agent.send(content)` is
56
+ // called with a plain string. `agent.send` is the in-process API for
57
+ // driving an agent without a transport; the synthesized message is
58
+ // never sent over the wire, so the addresses are just shape-fillers
59
+ // for the reactor's MIME-derived event shape. The `from` field is
60
+ // override-able via `SendOptions.from` because callers occasionally
61
+ // want to stamp a meaningful sender for audit purposes. The `to`
62
+ // field is fixed because no in-tree call path makes a routing or
63
+ // audit decision on it: harness-wrapped agents do not surface
64
+ // `agent.send` (the `Harness` shape exposes only deliver/setSource/
65
+ // stream/close/blobReader), and standalone agents have no addressing
66
+ // substrate to begin with. Callers that need an addressable inbound
67
+ // message build the `InboundMessage` themselves and pass it to
68
+ // `agent.send(message)` directly, bypassing this synthesis path.
69
+ const DEFAULT_SEND_FROM = "user@local";
70
+ const DEFAULT_SEND_TO = "agent@local";
71
+ const DEFAULT_SEND_QUEUE_MAX = 16;
72
+ const DEFAULT_STREAM_BUFFER_MAX = 1024;
73
+ const DEFAULT_CLOSE_TIMEOUT_MS = 5000;
74
+ /**
75
+ * A `reactor.gate.blocked` event settled the active send but carried no
76
+ * `correlationId`, so the resulting suspension has no handle a caller
77
+ * could resume against. The reactor omits `correlationId` for gates
78
+ * parked without a correlation (e.g. a director suspend with no
79
+ * correlated external decision), and `send()` cannot hand back an
80
+ * unresumable outcome -- it surfaces this instead.
81
+ */
82
+ export class GateSuspendedWithoutCorrelationError extends Error {
83
+ gateId;
84
+ constructor(gateId) {
85
+ super(`reactor suspended on gate ${gateId} without a correlationId; the send has no handle to resume against`);
86
+ this.name = "GateSuspendedWithoutCorrelationError";
87
+ this.gateId = gateId;
88
+ }
89
+ }
90
+ export class AgentClosedError extends Error {
91
+ constructor() {
92
+ super("agent is closed");
93
+ this.name = "AgentClosedError";
94
+ }
95
+ }
96
+ /**
97
+ * Walk each annotated tool factory, build the bundle, and produce a
98
+ * single `ToolRunner` that dispatches calls by tool name to the
99
+ * originating bundle. Throws on duplicate tool names across bundles.
100
+ *
101
+ * Bundle lifetimes (disposal) are the caller's responsibility per the
102
+ * `ToolBundle` contract once `createAgent` returns. While
103
+ * `createAgent` is still constructing -- whether the failure surfaces
104
+ * inside this function or later in `createAgent`'s body -- there is
105
+ * no caller to honor that contract, so the bundles list is exposed
106
+ * for the surrounding `try`/`finally` to dispose on failure.
107
+ */
108
+ function resolveTools(def, env) {
109
+ const byName = new Map();
110
+ const definitions = [];
111
+ // Track constructed bundles so we can dispose them on a later
112
+ // factory's failure. Once `resolveTools` returns successfully the
113
+ // caller (createAgent) is the lifetime owner per the `ToolBundle`
114
+ // contract; until then the only reference is in this function.
115
+ const constructed = [];
116
+ try {
117
+ for (const factory of def.toolFactories) {
118
+ const bundle = factory(env);
119
+ constructed.push(bundle);
120
+ for (const definition of bundle.definitions) {
121
+ if (byName.has(definition.name)) {
122
+ throw new DuplicateToolError(definition.name);
123
+ }
124
+ byName.set(definition.name, bundle);
125
+ definitions.push(definition);
126
+ }
127
+ }
128
+ }
129
+ catch (cause) {
130
+ // Dispose every bundle we did successfully construct before
131
+ // re-raising. Without this, factories that allocate resources at
132
+ // construction time (mail bundles holding an IMAP session, posix
133
+ // bundles spawning an LSP server, etc.) leak when a later
134
+ // factory throws or a duplicate-name collision aborts the walk.
135
+ for (const bundle of constructed) {
136
+ if (bundle.dispose === undefined)
137
+ continue;
138
+ try {
139
+ // Swallow disposer errors so the original construction
140
+ // failure remains the one the caller sees; a noisy disposer
141
+ // running during rollback would mask the real problem.
142
+ //
143
+ // `void bundle.dispose()` would not be enough on its own: it
144
+ // discards the returned promise but leaves any rejection in
145
+ // flight, which the surrounding synchronous try/catch cannot
146
+ // observe and the runtime surfaces as an unhandled promise
147
+ // rejection. We attach a no-op `.catch` to absorb async
148
+ // rejections and let the throw below propagate immediately
149
+ // (the caller's lock is still held; awaiting rollback would
150
+ // delay the construction failure for no benefit).
151
+ const result = bundle.dispose();
152
+ if (result instanceof Promise) {
153
+ result.catch(() => {
154
+ // Swallow per the comment above.
155
+ });
156
+ }
157
+ }
158
+ catch {
159
+ // Synchronous throws from a non-async dispose that throws
160
+ // before returning a promise. Same intent as the async path:
161
+ // never let rollback noise mask the original failure.
162
+ }
163
+ }
164
+ throw cause;
165
+ }
166
+ const runner = {
167
+ definitions: Object.freeze([...definitions]),
168
+ async run(call, signal) {
169
+ const bundle = byName.get(call.name);
170
+ if (bundle === undefined) {
171
+ return {
172
+ callId: call.id,
173
+ content: `unknown tool: ${call.name}`,
174
+ isError: true,
175
+ };
176
+ }
177
+ try {
178
+ return await bundle.run(call, signal);
179
+ }
180
+ catch (err) {
181
+ return {
182
+ callId: call.id,
183
+ content: err instanceof Error ? err.message : String(err),
184
+ isError: true,
185
+ };
186
+ }
187
+ },
188
+ };
189
+ return { definitions, runner, bundles: constructed };
190
+ }
191
+ function resolveDirector(def, env, toolDefinitions, compactorNames) {
192
+ const ref = def.director ?? env.directors.buildDefaultRef();
193
+ const factory = env.directors.resolve(ref);
194
+ // Re-validate ref.config against the factory's registered schema.
195
+ // `defineDirector.build(config)` validates at construction time, but
196
+ // `DirectorRef` is a public structural type -- nothing forces refs
197
+ // through `build`. A hand-constructed ref would otherwise reach the
198
+ // factory body with whatever shape the author wrote.
199
+ validateDirectorConfig(ref.config, factory.configSchema);
200
+ return factory(ref.config, env, {
201
+ systemPrompt: def.systemPrompt,
202
+ toolDefinitions,
203
+ compactorNames,
204
+ });
205
+ }
206
+ export async function createAgent(def, env) {
207
+ validateEnv(def, env);
208
+ const lock = acquireContextDirLock(env.workdir);
209
+ // The construction below acquires several resources before the
210
+ // returned Agent's `close()` becomes reachable. Anything that
211
+ // throws between here and the final return leaks the lock and any
212
+ // tool-bundle resources unless we explicitly release them. Track
213
+ // the success path with a flag, release the lock in `finally` when
214
+ // we never reached the return, and dispose every successfully
215
+ // constructed tool bundle so post-`resolveTools` failures
216
+ // (resolveDirector throw, createReactorAssembly throw,
217
+ // createSourceRegistry throw, reactor.start throw) don't leak the
218
+ // bundles `resolveTools` built. The intra-`resolveTools` rollback
219
+ // disposes bundles that were constructed before the throwing
220
+ // factory; this outer rollback covers the rest.
221
+ let succeeded = false;
222
+ let bundlesForRollback = [];
223
+ try {
224
+ const resolvedTools = resolveTools(def, env);
225
+ bundlesForRollback = resolvedTools.bundles;
226
+ const sourceRegistry = createSourceRegistry({
227
+ sources: env.sources,
228
+ defaultSource: env.defaultSource,
229
+ });
230
+ // Capture the registered names as a frozen snapshot at construction
231
+ // so the director receives a stable list it can iterate. The
232
+ // reactor assembly retains the live `env.compactors` reference for
233
+ // `caps.compact` lookups, so a deployer that mutates the registry
234
+ // after `createAgent` returns would diverge this snapshot from the
235
+ // reactor's resolution. Treat `env.compactors` as immutable
236
+ // post-construction.
237
+ const compactorNames = Object.freeze(Object.keys(env.compactors ?? {}));
238
+ const director = resolveDirector(def, env, resolvedTools.definitions, compactorNames);
239
+ const contextStore = env.storage;
240
+ const auditStore = env.audit;
241
+ const authorize = env.authorize;
242
+ const deps = env.deps ?? createDefaultDependencies();
243
+ const sessionId = env.sessionId ?? crypto.randomUUID();
244
+ const streamBufferMax = env.streamBufferMax ?? DEFAULT_STREAM_BUFFER_MAX;
245
+ const streamConsumers = new Set();
246
+ // Pre-start buffer for events emitted between `reactor.start()` and
247
+ // the first `stream()` consumer attaching. Without this buffer
248
+ // those events fan out into an empty consumer set and are dropped
249
+ // silently: `reactor.start()` runs synchronously inside
250
+ // `createAgent`, before the caller has a chance to register a
251
+ // consumer, so a `reactor.start` event (or any other event the
252
+ // reactor emits during its synchronous startup window) would be
253
+ // lost. We buffer up to `streamBufferMax` events; when the first
254
+ // consumer attaches, the buffer is drained into it and discarded.
255
+ // Subsequent consumers see only events emitted after their own
256
+ // registration, matching the existing per-consumer fan-out
257
+ // semantics. Overflow during the pre-start window drops the
258
+ // oldest events with a log warning rather than throwing: aborting
259
+ // `reactor.start()` mid-startup leaves the agent in a worse state
260
+ // than missing observability for the very earliest events, and a
261
+ // startup that emits more than `streamBufferMax` events before
262
+ // any consumer registers is a pathology the caller can observe
263
+ // via the warning.
264
+ let preStartBuffer = [];
265
+ let preStartBufferOverflows = 0;
266
+ let activeCycle = null;
267
+ // sendQueue is built after the reactor (since its `start` callback
268
+ // delivers into the reactor), but handleEvent -- which is wired
269
+ // into the reactor's assembly -- needs to see sendQueue. Assigned
270
+ // exactly once after the reactor exists and before
271
+ // reactor.start(); no event can reach handleEvent before the
272
+ // queue is wired.
273
+ //
274
+ // The cycle is irreducible at the type level: `handleEvent`
275
+ // reads `sendQueue` from closure; `sendQueue.start` calls
276
+ // `reactor.deliver`; `reactor` is constructed with
277
+ // `onEvent: handleEvent`. Three references, each pointing at
278
+ // the next. `const` requires its initializer at declaration time,
279
+ // which forces the cycle to break at one of these edges --
280
+ // every break either threads an extra parameter through
281
+ // handleEvent (which the reactor's `onEvent` shape does not
282
+ // accept), wraps sendQueue behind a `{ value: SendQueue }` cell
283
+ // (which makes every send-site check for undefined that the
284
+ // construction order already guarantees absent), or splits
285
+ // handleEvent into a factory that takes sendQueue as input
286
+ // (which moves the same forward-declaration problem one level
287
+ // up). The `let` here is the smallest expression of the cycle
288
+ // the language allows; the comment block above is what makes
289
+ // the "assigned before any reachable read" invariant explicit.
290
+ // eslint-disable-next-line prefer-const -- forward declaration; const cannot express this ordering
291
+ let sendQueue;
292
+ // shutdownComplete resolves from the assembly's onShutdown hook
293
+ // (composed after audit flush by the assembly) or, as a fallback, from
294
+ // handleEvent observing the reactor's terminal `reactor.done` event.
295
+ // close() awaits this (with a timeout) before releasing the
296
+ // workdir lock so a subsequent createAgent on the same directory
297
+ // sees a quiesced store.
298
+ //
299
+ // Use Promise.withResolvers so `resolveShutdown` is bound to the
300
+ // promise's resolve function at the point of declaration rather
301
+ // than after the Promise constructor's synchronous executor runs;
302
+ // the previous pattern needed a no-op seed for a TDZ window that
303
+ // the language already closes synchronously.
304
+ const { promise: shutdownComplete, resolve: resolveShutdown,
305
+ // eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- Promise.withResolvers<void>() is the conventional shape for a fire-and-forget settled-signal; matches Promise<void> used elsewhere on this assembly
306
+ } = Promise.withResolvers();
307
+ // Error accumulation. inference.error and reactor.error events
308
+ // observed at the assembly's onEvent boundary accumulate here and
309
+ // flush at the assembly's afterCheckpoint and onShutdown lifecycle
310
+ // hooks. Audit recording is always wired now: env.audit is required.
311
+ //
312
+ // Serialization through `flushInProgress` + `pendingFollowUp`: if
313
+ // a flush is already running, all concurrent callers ride a single
314
+ // shared follow-up promise that fires exactly once after the
315
+ // current flush settles. This prevents the multi-caller race
316
+ // where N concurrent chained continuations each observe
317
+ // `flushInProgress === undefined` in the same microtask drain and
318
+ // start parallel `commitErrors(batch)` invocations on the same
319
+ // prefix -- which would double-commit and incorrectly splice the
320
+ // accumulator. The shared follow-up clears itself before invoking
321
+ // the next flush, so a fourth caller arriving after the follow-up
322
+ // begins still observes a clean state and starts its own flush.
323
+ const accumulatedErrors = [];
324
+ let errorSeq = 0;
325
+ let flushInProgress;
326
+ let pendingFollowUp;
327
+ function flushErrors() {
328
+ if (flushInProgress !== undefined) {
329
+ // If another caller already arranged a follow-up flush after
330
+ // the current one settles, ride that. Otherwise arrange one
331
+ // and let every later concurrent caller share it. Run the
332
+ // follow-up on both fulfilment and rejection: if the in-flight
333
+ // flush failed, the accumulator still holds its records and
334
+ // the next attempt should retry rather than observe the prior
335
+ // failure.
336
+ if (pendingFollowUp !== undefined)
337
+ return pendingFollowUp;
338
+ pendingFollowUp = flushInProgress.then(() => {
339
+ pendingFollowUp = undefined;
340
+ return flushErrors();
341
+ }, () => {
342
+ pendingFollowUp = undefined;
343
+ return flushErrors();
344
+ });
345
+ return pendingFollowUp;
346
+ }
347
+ if (accumulatedErrors.length === 0)
348
+ return Promise.resolve();
349
+ const count = accumulatedErrors.length;
350
+ const batch = accumulatedErrors.slice(0, count);
351
+ // Splice only after a successful commit. A throwing audit store
352
+ // must not lose the batch -- the next flush hook (a later
353
+ // afterCheckpoint or the onShutdown drain) retries the same
354
+ // records. Note this means that on a permanent audit-store
355
+ // failure, the accumulator grows unbounded; the assembly's
356
+ // expectation is that commitErrors failures are transient.
357
+ flushInProgress = (async () => {
358
+ try {
359
+ await auditStore.commitErrors(batch);
360
+ accumulatedErrors.splice(0, count);
361
+ }
362
+ finally {
363
+ flushInProgress = undefined;
364
+ }
365
+ })();
366
+ return flushInProgress;
367
+ }
368
+ function buildSyntheticTurn(text) {
369
+ return {
370
+ role: "assistant",
371
+ content: [{ type: "text", text }],
372
+ model: sourceRegistry.active.model,
373
+ timestamp: Date.now(),
374
+ };
375
+ }
376
+ function handleEvent(event) {
377
+ if (event.type === "inference.error") {
378
+ accumulatedErrors.push({
379
+ source: "inference",
380
+ category: event.data.error.category,
381
+ message: event.data.error.message,
382
+ fatal: false,
383
+ timestamp: new Date().toISOString(),
384
+ sessionId,
385
+ seq: errorSeq++,
386
+ ...(event.data.error.statusCode !== undefined
387
+ ? { statusCode: event.data.error.statusCode }
388
+ : {}),
389
+ });
390
+ }
391
+ else if (event.type === "reactor.error") {
392
+ accumulatedErrors.push({
393
+ source: "reactor",
394
+ category: "reactor_error",
395
+ message: event.data.error,
396
+ fatal: event.data.fatal,
397
+ timestamp: new Date().toISOString(),
398
+ sessionId,
399
+ seq: errorSeq++,
400
+ });
401
+ }
402
+ if (activeCycle !== null && event.type === "inference.done") {
403
+ activeCycle.lastAssistantTurn = event.data.turn;
404
+ }
405
+ if (activeCycle !== null) {
406
+ if (event.type === "connector.reply") {
407
+ const turn = activeCycle.lastAssistantTurn ??
408
+ buildSyntheticTurn(event.data.content);
409
+ activeCycle = null;
410
+ sendQueue.resolveActive({
411
+ type: "reply",
412
+ reply: event.data.content,
413
+ turn,
414
+ });
415
+ }
416
+ else if (event.type === "reactor.gate.blocked") {
417
+ // The reactor parked on a gate before producing a reply. This
418
+ // is a terminal outcome for the active send: the cycle will not
419
+ // continue until the correlated external decision is delivered,
420
+ // and a parked cycle does not emit connector.reply or
421
+ // reactor.done, so leaving the send unsettled would hang the
422
+ // caller. Resolve with the suspended outcome so the caller can
423
+ // resume against the correlationId. A gate parked without a
424
+ // correlationId is unresumable -- surface it rather than hand
425
+ // back an outcome with no handle.
426
+ const { correlationId, approvalSnapshot } = event.data;
427
+ activeCycle = null;
428
+ if (correlationId === undefined) {
429
+ sendQueue.rejectActive(new GateSuspendedWithoutCorrelationError(event.data.gateId));
430
+ }
431
+ else {
432
+ sendQueue.resolveActive({
433
+ type: "suspended",
434
+ correlationId,
435
+ ...(approvalSnapshot !== undefined ? { approvalSnapshot } : {}),
436
+ });
437
+ }
438
+ }
439
+ else if (event.type === "reactor.error" && event.data.fatal) {
440
+ // Only fatal reactor errors terminate the active send. Non-fatal
441
+ // errors (e.g. transient write/commit failures the reactor is
442
+ // recovering from) are surfaced via stream() but must not
443
+ // resolve send() -- the cycle is still running and may yet
444
+ // produce connector.reply or a fatal error.
445
+ activeCycle = null;
446
+ sendQueue.rejectActive(new Error(`reactor error: ${event.data.error}`));
447
+ }
448
+ else if (event.type === "reactor.done") {
449
+ activeCycle = null;
450
+ sendQueue.rejectActive(new AgentClosedError());
451
+ }
452
+ }
453
+ // reactor.done is the reactor's terminal event. Resolve
454
+ // shutdownComplete here in addition to the onShutdown hook so close()
455
+ // does not hang for the full closeTimeoutMs on paths where the hook
456
+ // never fires (e.g. the reactor's context-store load fails during
457
+ // start, or the composed onShutdown wrapper throws during audit
458
+ // flush). resolveShutdown is idempotent.
459
+ if (event.type === "reactor.done") {
460
+ resolveShutdown();
461
+ }
462
+ // Pre-start window: if no consumer has attached yet, buffer the
463
+ // event so the first consumer to attach picks it up. The buffer
464
+ // is discarded after the first drain; later consumers see only
465
+ // events emitted after their own registration. Overflow drops
466
+ // the oldest event with a log warning -- raising here would
467
+ // abort reactor startup, which is worse than missing
468
+ // observability for the earliest events.
469
+ if (preStartBuffer !== undefined && streamConsumers.size === 0) {
470
+ if (preStartBuffer.length >= streamBufferMax) {
471
+ preStartBuffer.shift();
472
+ preStartBufferOverflows += 1;
473
+ }
474
+ preStartBuffer.push(event);
475
+ return;
476
+ }
477
+ // Iterate a snapshot so removing closed consumers mid-iteration is
478
+ // not just relying on Set's iteration tolerance.
479
+ for (const consumer of Array.from(streamConsumers)) {
480
+ consumer.push(event);
481
+ if (consumer.closed) {
482
+ streamConsumers.delete(consumer);
483
+ }
484
+ }
485
+ }
486
+ const { reactor, blobReader } = createReactorAssembly({
487
+ sessionId,
488
+ director,
489
+ source: sourceRegistry.active,
490
+ failOverToNextSource: () => sourceRegistry.failOverToNextSource(),
491
+ resetToPreferredSource: () => sourceRegistry.resetToPreferredSource(),
492
+ toolRunner: resolvedTools.runner,
493
+ contextStore,
494
+ onEvent: handleEvent,
495
+ auditStore,
496
+ authorize,
497
+ toolDefinitions: resolvedTools.definitions,
498
+ onShutdown: async () => {
499
+ try {
500
+ await flushErrors();
501
+ }
502
+ finally {
503
+ resolveShutdown();
504
+ }
505
+ },
506
+ afterCheckpoint: flushErrors,
507
+ ...(env.sizeCapMaxChars !== undefined
508
+ ? { sizeCapMaxChars: env.sizeCapMaxChars }
509
+ : {}),
510
+ deps,
511
+ ...(env.compactors !== undefined ? { compactors: env.compactors } : {}),
512
+ });
513
+ sendQueue = createSendQueue({
514
+ maxDepth: env.sendQueueMax ?? DEFAULT_SEND_QUEUE_MAX,
515
+ start: (message) => {
516
+ activeCycle = { lastAssistantTurn: undefined };
517
+ reactor.deliver(message);
518
+ },
519
+ });
520
+ reactor.start();
521
+ let closed = false;
522
+ function ensureOpen() {
523
+ if (closed)
524
+ throw new AgentClosedError();
525
+ }
526
+ function buildInboundMessage(content, opts) {
527
+ if (typeof content !== "string")
528
+ return content;
529
+ // Conversation messages use `content` (a string); the mail-builder
530
+ // rejects passing `payload` for conversation types.
531
+ return createInboundMessage({
532
+ from: opts?.from ?? DEFAULT_SEND_FROM,
533
+ to: DEFAULT_SEND_TO,
534
+ content,
535
+ interchangeType: "conversation.message",
536
+ });
537
+ }
538
+ function send(content, opts) {
539
+ // Closed-agent errors come back as rejections so callers can handle
540
+ // them with `.catch()` instead of having to defensively wrap every
541
+ // `agent.send(...)` in a try/catch. `SendQueueFullError` from
542
+ // `sendQueue.enqueue` is left as a synchronous throw -- it signals a
543
+ // programmer error (the caller exceeded the configured queue cap)
544
+ // and per the design must fail loud.
545
+ if (closed)
546
+ return Promise.reject(new AgentClosedError());
547
+ const message = buildInboundMessage(content, opts);
548
+ return sendQueue.enqueue(message, opts?.signal);
549
+ }
550
+ function stream() {
551
+ ensureOpen();
552
+ const consumer = createStreamConsumer(streamBufferMax);
553
+ // Drain the pre-start buffer into the first consumer that
554
+ // attaches so events emitted between reactor.start() and the
555
+ // first stream() call are not lost. The buffer is discarded
556
+ // after the first drain -- later consumers see only events
557
+ // emitted after their own registration, matching the per-
558
+ // consumer semantics every other code path expects.
559
+ if (preStartBuffer !== undefined) {
560
+ if (preStartBufferOverflows > 0) {
561
+ logger.warn `pre-start event buffer overflowed by ${preStartBufferOverflows} event(s) before the first stream() consumer attached; oldest events were dropped`;
562
+ }
563
+ for (const event of preStartBuffer)
564
+ consumer.push(event);
565
+ preStartBuffer = undefined;
566
+ }
567
+ streamConsumers.add(consumer);
568
+ return consumer.iterator();
569
+ }
570
+ function deliver(message) {
571
+ ensureOpen();
572
+ reactor.deliver(message);
573
+ }
574
+ function setSource(source) {
575
+ ensureOpen();
576
+ sourceRegistry.setSource(source);
577
+ }
578
+ function setSources(sources, defaultSource) {
579
+ ensureOpen();
580
+ sourceRegistry.setSources(sources, defaultSource);
581
+ }
582
+ async function history() {
583
+ const loaded = await contextStore.load();
584
+ return loaded.turns;
585
+ }
586
+ async function checkpoints(limit) {
587
+ return contextStore.log(limit);
588
+ }
589
+ async function readAt(hash) {
590
+ return contextStore.readAt(hash);
591
+ }
592
+ async function close() {
593
+ if (closed)
594
+ return;
595
+ closed = true;
596
+ reactor.abort("user_disconnect");
597
+ sendQueue.drain(new AgentClosedError());
598
+ activeCycle = null;
599
+ for (const consumer of streamConsumers)
600
+ consumer.close();
601
+ streamConsumers.clear();
602
+ // Surface any pre-start buffer state the caller never observed.
603
+ // The buffer drains into the first `stream()` consumer at
604
+ // attachment time and logs its overflow count then. If no
605
+ // consumer ever attached (e.g. a `send()`-only caller that
606
+ // never subscribed to the event stream), the buffer and its
607
+ // overflow counter would silently disappear here without an
608
+ // operator signal. Log the overflow once at close time so a
609
+ // startup pathology that dropped reactor.start-window events
610
+ // is at least observable in the logs.
611
+ if (preStartBuffer !== undefined && preStartBufferOverflows > 0) {
612
+ logger.warn `pre-start event buffer overflowed by ${preStartBufferOverflows} event(s) and no stream() consumer ever attached to drain it; oldest events were dropped`;
613
+ }
614
+ preStartBuffer = undefined;
615
+ // Wait for the reactor's shutdown sequence (audit flush, in-flight
616
+ // commits) before releasing the lock so a subsequent createAgent on
617
+ // the same workdir does not race with background writers against
618
+ // the same .git directory. The timeout is a backstop: if the
619
+ // reactor's shutdown is genuinely stuck (e.g. a parked test fetch
620
+ // that never resolves) we release the lock anyway rather than
621
+ // deadlock the caller. `closeTimeoutMs: 0` disables the wait.
622
+ const timeoutMs = env.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
623
+ if (timeoutMs > 0) {
624
+ let timer;
625
+ const timeout = new Promise((resolve) => {
626
+ timer = setTimeout(resolve, timeoutMs);
627
+ });
628
+ try {
629
+ await Promise.race([shutdownComplete, timeout]);
630
+ }
631
+ finally {
632
+ if (timer !== undefined)
633
+ clearTimeout(timer);
634
+ }
635
+ }
636
+ lock.release();
637
+ }
638
+ const agent = {
639
+ send,
640
+ stream,
641
+ deliver,
642
+ close,
643
+ setSource,
644
+ setSources,
645
+ history,
646
+ checkpoints,
647
+ readAt,
648
+ blobReader,
649
+ };
650
+ succeeded = true;
651
+ return agent;
652
+ }
653
+ finally {
654
+ if (!succeeded) {
655
+ // Dispose every successfully constructed bundle. Mirror the
656
+ // intra-`resolveTools` rollback shape: swallow async rejections
657
+ // via a `.catch` (a bare `void promise.dispose()` would leave
658
+ // the rejection in flight and surface as an unhandled rejection
659
+ // on the event loop), swallow synchronous throws with the
660
+ // surrounding try/catch, and let the throw the caller actually
661
+ // raised propagate immediately rather than awaiting cleanup.
662
+ for (const bundle of bundlesForRollback) {
663
+ if (bundle.dispose === undefined)
664
+ continue;
665
+ try {
666
+ const result = bundle.dispose();
667
+ if (result instanceof Promise) {
668
+ result.catch(() => {
669
+ // Swallow per the comment above.
670
+ });
671
+ }
672
+ }
673
+ catch {
674
+ // Synchronous throws from a non-async dispose that throws
675
+ // before returning a promise. Same intent as the async
676
+ // path: never let rollback noise mask the original failure.
677
+ }
678
+ }
679
+ lock.release();
680
+ }
681
+ }
682
+ }