@intx/inference 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 (97) hide show
  1. package/LICENSE +176 -0
  2. package/dist/actions.d.ts +16 -0
  3. package/dist/actions.js +200 -0
  4. package/dist/adapter.d.ts +38 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +68 -0
  7. package/dist/assembly.js +132 -0
  8. package/dist/audit-collector.d.ts +10 -0
  9. package/dist/audit-collector.js +139 -0
  10. package/dist/auth.d.ts +24 -0
  11. package/{src/auth.ts → dist/auth.js} +13 -19
  12. package/dist/authz-extension.d.ts +32 -0
  13. package/dist/authz-extension.js +100 -0
  14. package/dist/correlation.d.ts +25 -0
  15. package/dist/correlation.js +32 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +199 -0
  18. package/dist/director.d.ts +6 -0
  19. package/dist/director.js +56 -0
  20. package/dist/errors.d.ts +18 -0
  21. package/dist/errors.js +83 -0
  22. package/dist/gates.d.ts +27 -0
  23. package/dist/gates.js +80 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1319 -0
  26. package/dist/index.d.ts +37 -0
  27. package/dist/index.js +21 -0
  28. package/dist/manifest.d.ts +31 -0
  29. package/dist/manifest.js +44 -0
  30. package/dist/providers/anthropic.d.ts +33 -0
  31. package/dist/providers/anthropic.js +670 -0
  32. package/dist/providers/google-genai-files.d.ts +48 -0
  33. package/dist/providers/google-genai-files.js +205 -0
  34. package/dist/providers/google-genai.d.ts +3 -0
  35. package/dist/providers/google-genai.js +1196 -0
  36. package/dist/providers/index.d.ts +38 -0
  37. package/dist/providers/index.js +56 -0
  38. package/dist/providers/openai.d.ts +3 -0
  39. package/dist/providers/openai.js +609 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +920 -0
  42. package/dist/retry-policy.d.ts +31 -0
  43. package/{src/retry-policy.ts → dist/retry-policy.js} +41 -53
  44. package/dist/sse.d.ts +1 -0
  45. package/dist/sse.js +63 -0
  46. package/dist/state.d.ts +23 -0
  47. package/dist/state.js +100 -0
  48. package/dist/tool-name.d.ts +6 -0
  49. package/dist/tool-name.js +110 -0
  50. package/dist/transform.d.ts +11 -0
  51. package/dist/transform.js +117 -0
  52. package/dist/transforms/index.d.ts +2 -0
  53. package/dist/transforms/index.js +1 -0
  54. package/dist/transforms/size-cap.d.ts +12 -0
  55. package/dist/transforms/size-cap.js +80 -0
  56. package/dist/turns.d.ts +21 -0
  57. package/dist/turns.js +135 -0
  58. package/package.json +21 -6
  59. package/src/actions.ts +0 -245
  60. package/src/adapter.ts +0 -57
  61. package/src/assembly.test.ts +0 -728
  62. package/src/assembly.ts +0 -250
  63. package/src/audit-collector.test.ts +0 -332
  64. package/src/audit-collector.ts +0 -172
  65. package/src/auth.test.ts +0 -117
  66. package/src/authz-extension.test.ts +0 -269
  67. package/src/authz-extension.ts +0 -145
  68. package/src/correlation.ts +0 -61
  69. package/src/default-director.test.ts +0 -314
  70. package/src/default-director.ts +0 -344
  71. package/src/director.ts +0 -87
  72. package/src/errors.test.ts +0 -133
  73. package/src/errors.ts +0 -115
  74. package/src/gates.ts +0 -128
  75. package/src/harness.test.ts +0 -655
  76. package/src/harness.ts +0 -1571
  77. package/src/index.ts +0 -76
  78. package/src/providers/anthropic.test.ts +0 -771
  79. package/src/providers/anthropic.ts +0 -810
  80. package/src/providers/google-genai-files.ts +0 -289
  81. package/src/providers/google-genai.ts +0 -1518
  82. package/src/providers/openai.ts +0 -719
  83. package/src/providers/registry.ts +0 -33
  84. package/src/reactor.test.ts +0 -3660
  85. package/src/reactor.ts +0 -1058
  86. package/src/scheduler.test.ts +0 -41
  87. package/src/sse.test.ts +0 -133
  88. package/src/sse.ts +0 -76
  89. package/src/state.ts +0 -135
  90. package/src/transform.test.ts +0 -207
  91. package/src/transform.ts +0 -159
  92. package/src/transforms/index.ts +0 -2
  93. package/src/transforms/size-cap.test.ts +0 -172
  94. package/src/transforms/size-cap.ts +0 -110
  95. package/src/turns.ts +0 -54
  96. package/tsconfig.json +0 -4
  97. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,920 @@
1
+ // Agent reactor: the event-driven dispatch loop.
2
+ //
3
+ // The reactor processes one event at a time, asks the director for the next
4
+ // action, validates the action set, and executes. It manages the streaming
5
+ // harness for inference, dispatches tool calls, handles gates and correlation,
6
+ // and emits all session events with monotonic sequence numbers.
7
+ //
8
+ // Suspension semantics: when the director returns a suspend action, the reactor
9
+ // registers the gate and continues processing events. Inbound messages during
10
+ // suspension reach the director as message.received events (director decides:
11
+ // queue, fork, or ignore). When the gate clears, a reactor.gate.cleared event
12
+ // is enqueued and the director gets to decide next steps.
13
+ //
14
+ // (INFERENCE.md § Agent Reactor)
15
+ import { getLogger } from "@intx/log";
16
+ import { runInference } from "./harness.js";
17
+ import { createCapabilities } from "./director.js";
18
+ import { createGateManager } from "./gates.js";
19
+ import { createCorrelationRegistry } from "./correlation.js";
20
+ import { createStateManager } from "./state.js";
21
+ import { validateActions } from "./actions.js";
22
+ import { createToolResultTurn, createInboundTurn, assertWellFormedToolSequence, } from "./turns.js";
23
+ const logger = getLogger(["interchange", "reactor"]);
24
+ function buildHarnessOpts(turns, source, options, signal, nextSeq, deps) {
25
+ if (options !== undefined) {
26
+ return {
27
+ turns,
28
+ source,
29
+ inferenceOptions: options,
30
+ signal,
31
+ nextSeq,
32
+ deps,
33
+ };
34
+ }
35
+ return { turns, source, signal, nextSeq, deps };
36
+ }
37
+ const DEFAULT_GATE_TIMEOUT_MS = 3_600_000;
38
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
39
+ /**
40
+ * Creates a reactor instance bound to the given configuration.
41
+ * Call `start()` to begin the event loop.
42
+ */
43
+ export function createReactor(config) {
44
+ const { sessionId, director, toolRunner, contextStore, correlationValidator, onEvent, deps, inferenceRunner = runInference, beforeToolExtensions = [], toolResultTransforms = [], contextTransforms = [], compactors = {}, afterCheckpoint, onShutdown, gateTimeout = DEFAULT_GATE_TIMEOUT_MS, shutdownTimeoutMs = DEFAULT_SHUTDOWN_TIMEOUT_MS,
45
+ // Resolve the optional failover hooks once here, at the reactor's
46
+ // construction edge. A reactor with no source list fails over to
47
+ // nothing and resets to a no-op, so the inference loop below runs the
48
+ // single active source exactly as before.
49
+ failOverToNextSource = () => false, resetToPreferredSource = () => {
50
+ /* single-source: nothing to reset */
51
+ }, } = config;
52
+ // Monotonic sequence counter, scoped to this session.
53
+ let seq = 0;
54
+ function nextSeq() {
55
+ return ++seq;
56
+ }
57
+ function emit(event) {
58
+ onEvent(event);
59
+ }
60
+ // Inbound event queue. Events are pushed here and drained by the loop.
61
+ const queue = [];
62
+ let queueResolve = null;
63
+ // A tool cycle spans from the moment the reactor dispatches an inference or
64
+ // a tool batch until the director has consumed every completion event that
65
+ // operation produces. While a cycle is in flight, admitting a new inbound
66
+ // message — and the inference it triggers — ahead of the outstanding
67
+ // completion events corrupts the prompt: an assistant tool_call turn must be
68
+ // immediately followed by its tool results, and a new inference would
69
+ // instead interleave fresh turns and re-infer against a half-finished batch,
70
+ // which providers reject.
71
+ //
72
+ // pendingContinuations is the authoritative count of dispatched operations
73
+ // whose completion events have not yet been consumed. Every cycle event is
74
+ // counted as it is enqueued and uncounted as it is dequeued, so the count
75
+ // always equals the number of cycle events waiting in the queue. While it is
76
+ // positive, dequeueNext drains cycle events ahead of inbound mail; at zero
77
+ // the cycle is quiescent and processing reverts to FIFO.
78
+ //
79
+ // An earlier design inferred "mid-cycle" from history shape — whether the
80
+ // last turn was an assistant tool_call turn. That underreports in-flight
81
+ // work: a finished tool batch appends its tool-result turn to history before
82
+ // its tool.done events are consumed, flipping the last turn away from the
83
+ // assistant tool_call turn while completion events are still queued, which
84
+ // let inbound mail start an overlapping inference.
85
+ const CYCLE_EVENT_TYPES = new Set([
86
+ "inference.done",
87
+ "inference.error",
88
+ "tool.done",
89
+ ]);
90
+ let pendingContinuations = 0;
91
+ function enqueue(event) {
92
+ if (CYCLE_EVENT_TYPES.has(event.type)) {
93
+ pendingContinuations += 1;
94
+ }
95
+ queue.push(event);
96
+ if (queueResolve !== null) {
97
+ const resolve = queueResolve;
98
+ queueResolve = null;
99
+ resolve();
100
+ }
101
+ }
102
+ async function waitForEvent() {
103
+ if (queue.length > 0)
104
+ return;
105
+ await new Promise((resolve) => {
106
+ queueResolve = resolve;
107
+ });
108
+ }
109
+ function dequeueNext() {
110
+ if (queue.length === 0)
111
+ return undefined;
112
+ // Always process abort immediately.
113
+ const abortIdx = queue.findIndex((e) => e.type === "abort");
114
+ if (abortIdx !== -1) {
115
+ return queue.splice(abortIdx, 1)[0];
116
+ }
117
+ // Mid-cycle: drain inference-cycle events before anything else so the
118
+ // outstanding inference or tool batch completes before new mail can start
119
+ // an overlapping inference.
120
+ if (pendingContinuations > 0) {
121
+ const idx = queue.findIndex((e) => CYCLE_EVENT_TYPES.has(e.type));
122
+ if (idx !== -1) {
123
+ return queue.splice(idx, 1)[0];
124
+ }
125
+ }
126
+ return queue.shift();
127
+ }
128
+ const gates = createGateManager();
129
+ const correlations = createCorrelationRegistry();
130
+ const capabilities = createCapabilities();
131
+ let stateManager = null;
132
+ let running = false;
133
+ let done = false;
134
+ let shutdownStarted = false;
135
+ // Per-message run-bracket state. Set when the loop dequeues a
136
+ // message.received and begins per-message work; cleared at the
137
+ // terminal point (wait/reply/done) or at a reactor-fatal abandon.
138
+ // `messageRunId` is reactor-minted per dequeue via crypto.randomUUID
139
+ // so a crash-and-replay that re-delivers the same messageId still
140
+ // produces unambiguous start/end pairs downstream.
141
+ let currentMessageRunId = null;
142
+ let currentMessageId = null;
143
+ function openMessageRun(messageId) {
144
+ currentMessageRunId = crypto.randomUUID();
145
+ currentMessageId = messageId;
146
+ emit({
147
+ type: "message.run.started",
148
+ seq: nextSeq(),
149
+ data: {
150
+ messageId,
151
+ messageRunId: currentMessageRunId,
152
+ receivedAt: Date.now(),
153
+ },
154
+ });
155
+ }
156
+ function closeMessageRun(status, error) {
157
+ if (currentMessageRunId === null || currentMessageId === null)
158
+ return;
159
+ const data = {
160
+ messageRunId: currentMessageRunId,
161
+ messageId: currentMessageId,
162
+ status,
163
+ };
164
+ if (error !== undefined)
165
+ data.error = error;
166
+ emit({ type: "message.run.ended", seq: nextSeq(), data });
167
+ currentMessageRunId = null;
168
+ currentMessageId = null;
169
+ }
170
+ // Per-cycle accumulator of TransformRecord entries produced by every
171
+ // transform invocation (tool result, context, compactor). Flushed via
172
+ // contextStore.writeManifest at cycle boundaries.
173
+ let manifestBuffer = [];
174
+ // Tracks how the current cycle should be summarized in the commit message.
175
+ let cycleInferred = false;
176
+ let cycleToolCallsExecuted = 0;
177
+ let cycleCompactorName = null;
178
+ // Director-supplied checkpoint message override; consumed exactly once.
179
+ let pendingMessage = null;
180
+ // AbortController for in-flight inference/tool operations.
181
+ let operationController = new AbortController();
182
+ function abortOperations() {
183
+ operationController.abort();
184
+ operationController = new AbortController();
185
+ }
186
+ // Track in-flight inference and tool promises for shutdown cleanup.
187
+ const inFlight = new Set();
188
+ function track(p) {
189
+ inFlight.add(p);
190
+ p.then(() => inFlight.delete(p), () => inFlight.delete(p));
191
+ return p;
192
+ }
193
+ // -------------------------------------------------------------------------
194
+ // Correlation helper
195
+ // -------------------------------------------------------------------------
196
+ // Guard against concurrent tryCorrelate calls for the same correlationId.
197
+ // deliver() is fire-and-forget async, so two rapid delivers can interleave
198
+ // across an await boundary in the validator, causing double-correlation.
199
+ const correlatingIds = new Set();
200
+ async function tryCorrelate(message) {
201
+ const correlationId = message.headers.interchangeCorrelationId;
202
+ if (correlationId === undefined)
203
+ return false;
204
+ if (correlatingIds.has(correlationId))
205
+ return false;
206
+ const pending = correlations.lookup(correlationId);
207
+ if (pending === undefined)
208
+ return false;
209
+ correlatingIds.add(correlationId);
210
+ if (correlationValidator !== undefined) {
211
+ let valid;
212
+ try {
213
+ valid = await correlationValidator.validate(pending, message);
214
+ }
215
+ catch (cause) {
216
+ logger.warn `Correlation validator threw for ${correlationId}: ${cause}`;
217
+ correlatingIds.delete(correlationId);
218
+ return false;
219
+ }
220
+ if (!valid) {
221
+ correlatingIds.delete(correlationId);
222
+ return false;
223
+ }
224
+ }
225
+ // Clear the gate associated with this correlation, if any.
226
+ const gate = gates.findByCorrelationId(correlationId);
227
+ if (gate !== undefined) {
228
+ gates.clear(gate.gateId);
229
+ }
230
+ correlations.remove(correlationId);
231
+ if (stateManager !== null) {
232
+ stateManager.removePendingOperation(correlationId);
233
+ // Append the correlated message to conversation history so the model
234
+ // sees the response content when it re-infers after the gate clears.
235
+ const msg = createInboundTurn(message);
236
+ if (msg !== null) {
237
+ stateManager.appendTurn(msg);
238
+ }
239
+ }
240
+ emit({
241
+ type: "message.correlated",
242
+ seq: nextSeq(),
243
+ data: { message, correlationId },
244
+ });
245
+ return true;
246
+ }
247
+ // -------------------------------------------------------------------------
248
+ // Action execution
249
+ // -------------------------------------------------------------------------
250
+ let pendingPacingDelayMs = 0;
251
+ function buildStrategyContext(trigger) {
252
+ if (stateManager === null) {
253
+ throw new Error("State manager not initialized");
254
+ }
255
+ return { state: stateManager.snapshot(), trigger };
256
+ }
257
+ async function persistBlobs(blobs) {
258
+ if (blobs === undefined)
259
+ return;
260
+ for (const blob of blobs) {
261
+ await contextStore.writeBlob(blob.key, blob.bytes, blob.contentType);
262
+ }
263
+ }
264
+ async function executeInfer(options) {
265
+ if (stateManager === null)
266
+ return;
267
+ const signal = operationController.signal;
268
+ // Proactive pacing: if the previous inference response indicated we are
269
+ // at the rate limit, wait before sending the next request.
270
+ if (pendingPacingDelayMs > 0 && !signal.aborted) {
271
+ const delayMs = pendingPacingDelayMs;
272
+ pendingPacingDelayMs = 0;
273
+ logger.info `Pacing: waiting ${String(delayMs)}ms before next inference request`;
274
+ await new Promise((resolve) => {
275
+ const timer = setTimeout(resolve, delayMs);
276
+ const onAbort = () => {
277
+ clearTimeout(timer);
278
+ resolve();
279
+ };
280
+ signal.addEventListener("abort", onAbort, { once: true });
281
+ });
282
+ if (signal.aborted)
283
+ return;
284
+ }
285
+ // Run the context transform chain to produce the materialized prompt.
286
+ let prompt = stateManager.getTurns();
287
+ for (const transform of contextTransforms) {
288
+ const ctx = buildStrategyContext("pre-inference");
289
+ const result = await transform.apply(prompt, ctx);
290
+ prompt = result.output;
291
+ manifestBuffer.push(result.record);
292
+ await persistBlobs(result.blobs);
293
+ }
294
+ // Tripwire: a malformed tool sequence is invalid in a coherent tool
295
+ // conversation and would otherwise surface as an opaque provider rejection.
296
+ // Catch it here, before the prompt is persisted or sent, so the corruption
297
+ // fails loud as an internal error at the assembly boundary. Throwing routes
298
+ // through the reactor's fatal-error path.
299
+ assertWellFormedToolSequence(prompt);
300
+ try {
301
+ await contextStore.writePrompt(prompt);
302
+ }
303
+ catch (cause) {
304
+ logger.error `writePrompt failed: ${cause}`;
305
+ emitError(`writePrompt failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
306
+ }
307
+ const p = (async () => {
308
+ // Per-source attempt budget for transient errors (quota/retryable/
309
+ // timeout). Kept small because failover, not flogging one source, is
310
+ // the recovery path: the harness already does its own mechanical
311
+ // retry under each attempt, so this caps reactor-level same-source
312
+ // retries at one before moving to the next source.
313
+ const sameSourceAttempts = 2;
314
+ const defaultRetryMs = 60_000;
315
+ // Each cycle starts at the most-preferred source; a failover in a
316
+ // prior cycle must not leave the agent permanently demoted.
317
+ resetToPreferredSource();
318
+ let attempt = 0;
319
+ for (;;) {
320
+ const harnessOpts = buildHarnessOpts(prompt, config.source, options, signal, nextSeq, deps);
321
+ let lastDone;
322
+ let lastError;
323
+ for await (const event of inferenceRunner(harnessOpts)) {
324
+ emit(event);
325
+ if (event.type === "inference.done")
326
+ lastDone = event;
327
+ else if (event.type === "inference.error")
328
+ lastError = event;
329
+ }
330
+ if (lastDone !== undefined) {
331
+ if (stateManager !== null) {
332
+ stateManager.appendTurn(lastDone.data.turn);
333
+ stateManager.accumUsage(lastDone.data.usage);
334
+ stateManager.setLastCycleUsage(lastDone.data.usage);
335
+ stateManager.setLastCycleSource(lastDone.data.source);
336
+ }
337
+ cycleInferred = true;
338
+ try {
339
+ await contextStore.writeResponse(lastDone.data.turn);
340
+ }
341
+ catch (cause) {
342
+ logger.error `writeResponse failed: ${cause}`;
343
+ emitError(`writeResponse failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
344
+ }
345
+ if (lastDone.data.pacingDelayMs !== undefined) {
346
+ pendingPacingDelayMs = lastDone.data.pacingDelayMs;
347
+ }
348
+ const u = lastDone.data.usage;
349
+ logger.info `Inference usage: input=${String(u.input)} output=${String(u.output)} cacheRead=${String(u.cacheRead)} cacheWrite=${String(u.cacheWrite)}${lastDone.data.pacingDelayMs !== undefined ? ` pacing=${String(lastDone.data.pacingDelayMs)}ms` : ""}`;
350
+ enqueue({
351
+ type: "inference.done",
352
+ turn: lastDone.data.turn,
353
+ usage: lastDone.data.usage,
354
+ source: lastDone.data.source,
355
+ });
356
+ return;
357
+ }
358
+ if (lastError === undefined) {
359
+ emitError("Inference runner returned without a terminal event", true);
360
+ enqueue({
361
+ type: "inference.error",
362
+ error: {
363
+ category: "fatal",
364
+ message: "Inference runner returned without a terminal event",
365
+ },
366
+ partial: { text: "" },
367
+ });
368
+ return;
369
+ }
370
+ const err = lastError.data.error;
371
+ const partial = lastError.data.partial;
372
+ // Source-invariant failures: no source can serve this call, so
373
+ // abort the whole cycle rather than waste failover attempts.
374
+ if (err.category === "context_overflow" ||
375
+ err.category === "fatal" ||
376
+ err.category === "aborted") {
377
+ enqueue({ type: "inference.error", error: err, partial });
378
+ return;
379
+ }
380
+ // A rate limit is the one category worth waiting out on the same
381
+ // source: it clears with time, and the reactor's backoff is longer
382
+ // than the harness's own per-call retry. The harness has already
383
+ // exhausted its internal mechanical retries for retryable/timeout
384
+ // by the time the reactor sees them, so those fail over rather than
385
+ // re-running the same source (which would just retry-compound).
386
+ if (err.category === "quota_exhausted") {
387
+ attempt += 1;
388
+ if (attempt < sameSourceAttempts && !signal.aborted) {
389
+ const delayMs = err.retryAfterMs ?? defaultRetryMs;
390
+ logger.warn `Rate limited, retrying same source after ${String(delayMs)}ms`;
391
+ await new Promise((resolve) => {
392
+ const timer = setTimeout(resolve, delayMs);
393
+ const onAbort = () => {
394
+ clearTimeout(timer);
395
+ resolve();
396
+ };
397
+ signal.addEventListener("abort", onAbort, { once: true });
398
+ });
399
+ if (signal.aborted) {
400
+ enqueue({
401
+ type: "inference.error",
402
+ error: {
403
+ category: "aborted",
404
+ message: "inference aborted during rate limit backoff",
405
+ },
406
+ partial,
407
+ });
408
+ return;
409
+ }
410
+ continue;
411
+ }
412
+ }
413
+ // Same-source rate-limit budget exhausted, or a source-specific
414
+ // failure (credential, protocol mismatch, retryable, timeout): fail
415
+ // over to the next source. A pacing delay the leaving source asked
416
+ // for must not gate the next source.
417
+ pendingPacingDelayMs = 0;
418
+ if (failOverToNextSource()) {
419
+ logger.warn `Failing over to next inference source after ${err.category}`;
420
+ attempt = 0;
421
+ continue;
422
+ }
423
+ // No further source to fail over to: surface the last error.
424
+ enqueue({ type: "inference.error", error: err, partial });
425
+ return;
426
+ }
427
+ })();
428
+ void track(p);
429
+ await p;
430
+ }
431
+ async function executeTools(calls, parallel, addToHistory = true) {
432
+ if (stateManager === null)
433
+ return;
434
+ const state = stateManager;
435
+ const signal = operationController.signal;
436
+ const runOne = async (call) => {
437
+ // Run before-tool extensions. First block or throw terminates the chain.
438
+ for (const ext of beforeToolExtensions) {
439
+ let blockReason;
440
+ try {
441
+ blockReason = await ext.beforeTool(call, state.snapshot(), signal);
442
+ }
443
+ catch (cause) {
444
+ const msg = cause instanceof Error ? cause.message : String(cause);
445
+ emitError(`BeforeToolExtension threw for ${call.name}: ${msg}`, false);
446
+ blockReason = msg;
447
+ }
448
+ if (blockReason !== undefined) {
449
+ const blocked = {
450
+ callId: call.id,
451
+ content: blockReason,
452
+ isError: true,
453
+ };
454
+ emit({
455
+ type: "tool.done",
456
+ seq: nextSeq(),
457
+ data: { result: blocked },
458
+ });
459
+ return blocked;
460
+ }
461
+ }
462
+ emit({ type: "tool.start", seq: nextSeq(), data: { call } });
463
+ const rawResult = await toolRunner.run(call, signal);
464
+ emit({ type: "tool.done", seq: nextSeq(), data: { result: rawResult } });
465
+ if (rawResult.pendingMarker !== undefined && stateManager !== null) {
466
+ const marker = rawResult.pendingMarker;
467
+ const gateId = `pending-${marker.correlationId}`;
468
+ const op = {
469
+ correlationId: marker.correlationId,
470
+ registeredAt: Date.now(),
471
+ gateId,
472
+ ...(marker.expectedFrom !== undefined
473
+ ? { expectedFrom: marker.expectedFrom }
474
+ : {}),
475
+ };
476
+ correlations.register(op);
477
+ stateManager.addPendingOperation(op);
478
+ }
479
+ // Apply the tool-result transform chain. Each transform's output is fed
480
+ // into the next; emitted blobs are persisted immediately so downstream
481
+ // transforms can rely on the spill being available.
482
+ let current = rawResult;
483
+ for (const transform of toolResultTransforms) {
484
+ const ctx = buildStrategyContext("tool-result-ingest");
485
+ const tr = await transform.apply({ call, result: current }, ctx);
486
+ manifestBuffer.push(tr.record);
487
+ await persistBlobs(tr.blobs);
488
+ current = tr.output;
489
+ }
490
+ return current;
491
+ };
492
+ let results;
493
+ if (parallel) {
494
+ const p = Promise.all(calls.map((c) => runOne(c)));
495
+ void track(p);
496
+ results = await p;
497
+ }
498
+ else {
499
+ results = [];
500
+ for (const call of calls) {
501
+ const p = runOne(call);
502
+ void track(p);
503
+ results.push(await p);
504
+ }
505
+ }
506
+ cycleToolCallsExecuted += results.length;
507
+ if (addToHistory && stateManager !== null) {
508
+ stateManager.appendTurn(createToolResultTurn(results));
509
+ }
510
+ for (const result of results) {
511
+ enqueue({ type: "tool.done", result });
512
+ }
513
+ }
514
+ async function executeCompact(compactorName, reason) {
515
+ if (stateManager === null)
516
+ return;
517
+ const compactor = compactors[compactorName];
518
+ if (compactor === undefined) {
519
+ throw new Error(`executeCompact: no compactor registered for name ${JSON.stringify(compactorName)}`);
520
+ }
521
+ const ctx = {
522
+ state: stateManager.snapshot(),
523
+ trigger: `director:${reason}`,
524
+ };
525
+ const result = await compactor.apply(stateManager.getTurns(), ctx);
526
+ stateManager.replaceTurns(result.output);
527
+ await contextStore.writeTurns(result.output);
528
+ await persistBlobs(result.blobs);
529
+ manifestBuffer.push(result.record);
530
+ cycleCompactorName = compactor.name;
531
+ logger.info `Compaction by ${compactor.name} reduced history (reason: ${reason})`;
532
+ }
533
+ // -------------------------------------------------------------------------
534
+ // Cycle boundary commit
535
+ // -------------------------------------------------------------------------
536
+ function buildCycleMessage() {
537
+ if (pendingMessage !== null) {
538
+ const msg = pendingMessage;
539
+ pendingMessage = null;
540
+ return msg;
541
+ }
542
+ if (cycleCompactorName !== null) {
543
+ return `Cycle: compaction by ${cycleCompactorName}`;
544
+ }
545
+ const parts = [];
546
+ if (cycleInferred)
547
+ parts.push("inferred");
548
+ if (cycleToolCallsExecuted > 0) {
549
+ const noun = cycleToolCallsExecuted === 1 ? "tool call" : "tool calls";
550
+ parts.push(`${String(cycleToolCallsExecuted)} ${noun}`);
551
+ }
552
+ if (parts.length === 0)
553
+ return "Cycle: no-op";
554
+ return `Cycle: ${parts.join(" + ")}`;
555
+ }
556
+ function resetCycleAccumulators() {
557
+ manifestBuffer = [];
558
+ cycleInferred = false;
559
+ cycleToolCallsExecuted = 0;
560
+ cycleCompactorName = null;
561
+ }
562
+ async function commitCycle() {
563
+ if (stateManager === null)
564
+ return;
565
+ // Only commit when the cycle did real work or the director set an
566
+ // override message. An empty cycle (no inference, no tools, no compact,
567
+ // no override) commits nothing.
568
+ const hasWork = cycleInferred ||
569
+ cycleToolCallsExecuted > 0 ||
570
+ cycleCompactorName !== null;
571
+ const hasOverride = pendingMessage !== null;
572
+ if (!hasWork && !hasOverride) {
573
+ resetCycleAccumulators();
574
+ return;
575
+ }
576
+ const message = buildCycleMessage();
577
+ try {
578
+ await contextStore.writeTurns(stateManager.getTurns());
579
+ await contextStore.writeManifest(manifestBuffer);
580
+ await writeMetadata();
581
+ const commit = await contextStore.commit({ message });
582
+ lastCheckpointHash = commit.hash;
583
+ }
584
+ catch (cause) {
585
+ logger.error `Cycle commit failed: ${cause}`;
586
+ emitError(`Cycle commit failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
587
+ resetCycleAccumulators();
588
+ return;
589
+ }
590
+ resetCycleAccumulators();
591
+ if (afterCheckpoint !== undefined) {
592
+ try {
593
+ await afterCheckpoint();
594
+ }
595
+ catch (cause) {
596
+ logger.error `afterCheckpoint failed: ${cause}`;
597
+ emitError(`afterCheckpoint failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
598
+ }
599
+ }
600
+ }
601
+ async function writeMetadata() {
602
+ if (stateManager === null)
603
+ return;
604
+ await contextStore.writeMetadata({
605
+ pendingOperations: stateManager.getPendingOperations(),
606
+ tokenUsage: stateManager.getTokenUsage(),
607
+ });
608
+ }
609
+ // -------------------------------------------------------------------------
610
+ // Main loop
611
+ // -------------------------------------------------------------------------
612
+ async function loop() {
613
+ if (stateManager === null) {
614
+ throw new Error("State manager not initialized before loop");
615
+ }
616
+ while (!done) {
617
+ await waitForEvent();
618
+ if (done)
619
+ break;
620
+ const event = dequeueNext();
621
+ if (event === undefined)
622
+ continue;
623
+ // A dequeued cycle event is one fewer in-flight continuation. Pairs with
624
+ // the increment in enqueue(); both key off CYCLE_EVENT_TYPES so they
625
+ // cannot drift.
626
+ if (CYCLE_EVENT_TYPES.has(event.type)) {
627
+ pendingContinuations -= 1;
628
+ }
629
+ // Handle abort events: initiate shutdown regardless of director.
630
+ if (event.type === "abort") {
631
+ if (!shutdownStarted) {
632
+ done = true;
633
+ await initiateShutdown();
634
+ }
635
+ break;
636
+ }
637
+ // Append inbound messages to conversation history so the provider sees them.
638
+ // Each dequeued message.received opens a fresh per-message run bracket.
639
+ // If a prior bracket is still open (defensive — should not occur given
640
+ // the dequeue priority that drains cycle events before new messages),
641
+ // close it as completed first so the new bracket starts cleanly.
642
+ if (event.type === "message.received") {
643
+ if (stateManager !== null) {
644
+ const msg = createInboundTurn(event.message);
645
+ if (msg !== null) {
646
+ stateManager.appendTurn(msg);
647
+ }
648
+ }
649
+ if (currentMessageRunId !== null) {
650
+ closeMessageRun("completed");
651
+ }
652
+ openMessageRun(event.message.headers.messageId);
653
+ }
654
+ let actions;
655
+ try {
656
+ actions = await director.decide(event, stateManager.snapshot(), capabilities);
657
+ }
658
+ catch (cause) {
659
+ const msg = cause instanceof Error ? cause.message : String(cause);
660
+ logger.error `Director threw during decide: ${cause}`;
661
+ emitError(`Director exception: ${msg}`, true);
662
+ closeMessageRun("failed", {
663
+ message: `Director exception: ${msg}`,
664
+ kind: "reactor_fatal",
665
+ });
666
+ done = true;
667
+ await initiateShutdown();
668
+ break;
669
+ }
670
+ const validation = validateActions(actions);
671
+ if (!validation.ok) {
672
+ emitError(`Invalid action set: ${validation.error}`, true);
673
+ closeMessageRun("failed", {
674
+ message: `Invalid action set: ${validation.error}`,
675
+ kind: "reactor_fatal",
676
+ });
677
+ done = true;
678
+ await initiateShutdown();
679
+ break;
680
+ }
681
+ const normalized = validation.normalized;
682
+ // Checkpoint sets the next cycle's commit message.
683
+ const checkpointAction = normalized.find((a) => a.type === "checkpoint");
684
+ if (checkpointAction !== undefined) {
685
+ pendingMessage = checkpointAction.message;
686
+ }
687
+ // Emit custom events (validated type namespace).
688
+ for (const action of normalized) {
689
+ if (action.type === "emit") {
690
+ const reserved = ["inference.", "tool.", "reactor.", "fork."];
691
+ const blocked = reserved.some((p) => action.eventType.startsWith(p));
692
+ if (blocked) {
693
+ emitError(`Director tried to emit reserved event type: ${action.eventType}`, false);
694
+ continue;
695
+ }
696
+ emit({ type: action.eventType, seq: nextSeq(), data: action.data });
697
+ }
698
+ }
699
+ // Fork is excluded in this build.
700
+ for (const action of normalized) {
701
+ if (action.type === "fork") {
702
+ emitError("Fork action is not supported in this build", false);
703
+ }
704
+ }
705
+ // Handle done.
706
+ if (normalized.some((a) => a.type === "done")) {
707
+ // Flush the cycle (in case the director paired done with checkpoint
708
+ // or other work) before shutting down.
709
+ await commitCycle();
710
+ closeMessageRun("completed");
711
+ done = true;
712
+ await initiateShutdown();
713
+ break;
714
+ }
715
+ // Handle wait: commit the cycle (if work happened) and return to the
716
+ // event loop without shutting down. Wait is a per-message terminal:
717
+ // the reactor has nothing more to do for the message and is returning
718
+ // to idle.
719
+ if (normalized.some((a) => a.type === "wait")) {
720
+ await commitCycle();
721
+ closeMessageRun("completed");
722
+ continue;
723
+ }
724
+ // Handle suspend: register gate and continue the loop (don't block).
725
+ const suspendAction = normalized.find((a) => a.type === "suspend");
726
+ if (suspendAction !== undefined && suspendAction.type === "suspend") {
727
+ const { gate } = suspendAction;
728
+ const effectiveTimeout = gate.timeoutMs > 0 ? gate.timeoutMs : gateTimeout;
729
+ emit({
730
+ type: "reactor.gate.blocked",
731
+ seq: nextSeq(),
732
+ data: { reason: gate.type, gateId: gate.gateId },
733
+ });
734
+ if (stateManager !== null) {
735
+ stateManager.setGatesSnapshot(gates.snapshot());
736
+ }
737
+ // Register the gate. The onCleared callback enqueues the cleared event
738
+ // so the loop processes it normally without blocking here.
739
+ void gates.register(gate.gateId, gate.type, effectiveTimeout, gate.correlationId, (gateId, reason) => {
740
+ if (stateManager !== null) {
741
+ stateManager.setGatesSnapshot(gates.snapshot());
742
+ }
743
+ emit({
744
+ type: "reactor.gate.cleared",
745
+ seq: nextSeq(),
746
+ data: { gateId, reason },
747
+ });
748
+ enqueue({ type: "reactor.gate.cleared", gateId, reason });
749
+ });
750
+ if (stateManager !== null) {
751
+ stateManager.setGatesSnapshot(gates.snapshot());
752
+ }
753
+ // Commit before the loop continues so the suspended-state turns are
754
+ // durable across restart.
755
+ await commitCycle();
756
+ continue;
757
+ }
758
+ // Handle reply — emit the content for the harness/supervisor to send.
759
+ const replyAction = normalized.find((a) => a.type === "reply");
760
+ if (replyAction !== undefined && replyAction.type === "reply") {
761
+ // Flush any pending cycle work before signaling the reply so the
762
+ // emitted checkpointHash matches the visible state.
763
+ await commitCycle();
764
+ emit({
765
+ type: "connector.reply",
766
+ seq: nextSeq(),
767
+ data: {
768
+ content: replyAction.content,
769
+ ...(lastCheckpointHash !== undefined
770
+ ? { checkpointHash: lastCheckpointHash }
771
+ : {}),
772
+ },
773
+ });
774
+ // Reply is a per-message terminal point: close the bracket so the
775
+ // next inbound message opens a fresh run.
776
+ closeMessageRun("completed");
777
+ // After replying, wait for the next inbound message.
778
+ continue;
779
+ }
780
+ // Handle compact (its own cycle; runs before any infer can be requested
781
+ // in the same director invocation — validation forbids that pairing).
782
+ const compactAction = normalized.find((a) => a.type === "compact");
783
+ if (compactAction !== undefined && compactAction.type === "compact") {
784
+ try {
785
+ await executeCompact(compactAction.compactor, compactAction.reason);
786
+ }
787
+ catch (cause) {
788
+ const msg = cause instanceof Error ? cause.message : String(cause);
789
+ logger.error `Compaction failed: ${cause}`;
790
+ emitError(`Compaction failed: ${msg}`, true);
791
+ closeMessageRun("failed", {
792
+ message: `Compaction failed: ${msg}`,
793
+ kind: "reactor_fatal",
794
+ });
795
+ done = true;
796
+ await initiateShutdown();
797
+ break;
798
+ }
799
+ await commitCycle();
800
+ continue;
801
+ }
802
+ // Handle infer.
803
+ const inferAction = normalized.find((a) => a.type === "infer");
804
+ if (inferAction !== undefined && inferAction.type === "infer") {
805
+ await executeInfer(inferAction.options);
806
+ continue;
807
+ }
808
+ // Handle execute_tools.
809
+ const toolsAction = normalized.find((a) => a.type === "execute_tools");
810
+ if (toolsAction !== undefined && toolsAction.type === "execute_tools") {
811
+ const parallel = toolsAction.parallel !== false;
812
+ const addToHistory = toolsAction.addToHistory !== false;
813
+ await executeTools(toolsAction.calls, parallel, addToHistory);
814
+ continue;
815
+ }
816
+ // No infer/tools/reply/suspend/wait/compact action — if a checkpoint
817
+ // override was set on its own (or alongside emit/fork), the next event
818
+ // will pick it up. Nothing to flush here.
819
+ }
820
+ }
821
+ function emitError(message, fatal) {
822
+ emit({
823
+ type: "reactor.error",
824
+ seq: nextSeq(),
825
+ data: { error: message, fatal },
826
+ });
827
+ }
828
+ let lastCheckpointHash;
829
+ async function initiateShutdown() {
830
+ if (shutdownStarted)
831
+ return;
832
+ shutdownStarted = true;
833
+ abortOperations();
834
+ gates.shutdown();
835
+ if (stateManager !== null) {
836
+ stateManager.setGatesSnapshot([]);
837
+ }
838
+ if (inFlight.size > 0) {
839
+ const deadline = new Promise((resolve) => setTimeout(resolve, shutdownTimeoutMs));
840
+ await Promise.race([Promise.allSettled([...inFlight]), deadline]);
841
+ }
842
+ if (onShutdown !== undefined) {
843
+ try {
844
+ await onShutdown();
845
+ }
846
+ catch (cause) {
847
+ logger.error `onShutdown failed: ${cause}`;
848
+ emitError(`onShutdown failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
849
+ }
850
+ }
851
+ emit({
852
+ type: "reactor.done",
853
+ seq: nextSeq(),
854
+ data: {},
855
+ });
856
+ }
857
+ // -------------------------------------------------------------------------
858
+ // Public API
859
+ // -------------------------------------------------------------------------
860
+ function start() {
861
+ if (running) {
862
+ throw new Error("Reactor is already running");
863
+ }
864
+ running = true;
865
+ void (async () => {
866
+ let initialTurns;
867
+ let initialOps;
868
+ let initialUsage;
869
+ try {
870
+ const loaded = await contextStore.load();
871
+ initialTurns = loaded.turns;
872
+ initialOps = loaded.pendingOperations;
873
+ initialUsage = loaded.tokenUsage;
874
+ }
875
+ catch (cause) {
876
+ logger.error `Context store load failed: ${cause}`;
877
+ emitError(`Context store load failed: ${cause instanceof Error ? cause.message : String(cause)}`, true);
878
+ emit({ type: "reactor.done", seq: nextSeq(), data: {} });
879
+ return;
880
+ }
881
+ stateManager = createStateManager(sessionId, initialTurns, initialOps, initialUsage);
882
+ stateManager.setGatesSnapshot(gates.snapshot());
883
+ emit({ type: "reactor.start", seq: nextSeq(), data: {} });
884
+ try {
885
+ await loop();
886
+ }
887
+ catch (cause) {
888
+ const msg = cause instanceof Error ? cause.message : String(cause);
889
+ logger.error `Reactor loop threw unexpectedly: ${cause}`;
890
+ emitError(`Internal reactor error: ${msg}`, true);
891
+ closeMessageRun("failed", {
892
+ message: `Internal reactor error: ${msg}`,
893
+ kind: "reactor_fatal",
894
+ });
895
+ if (!shutdownStarted) {
896
+ await initiateShutdown();
897
+ }
898
+ }
899
+ })();
900
+ }
901
+ function deliver(message) {
902
+ if (done)
903
+ return;
904
+ void (async () => {
905
+ const correlated = await tryCorrelate(message);
906
+ if (!correlated) {
907
+ emit({
908
+ type: "message.received",
909
+ seq: nextSeq(),
910
+ data: { message },
911
+ });
912
+ enqueue({ type: "message.received", message });
913
+ }
914
+ })();
915
+ }
916
+ function abort(reason) {
917
+ enqueue({ type: "abort", reason });
918
+ }
919
+ return { start, deliver, abort };
920
+ }