@intx/agent 0.1.2 → 0.2.2

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