@intx/inference 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 (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 +40 -0
  5. package/dist/adapter.js +31 -0
  6. package/dist/assembly.d.ts +75 -0
  7. package/dist/assembly.js +133 -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 +46 -0
  13. package/dist/authz-extension.js +184 -0
  14. package/dist/correlation.d.ts +26 -0
  15. package/dist/correlation.js +39 -0
  16. package/dist/default-director.d.ts +111 -0
  17. package/dist/default-director.js +228 -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 +28 -0
  23. package/dist/gates.js +103 -0
  24. package/dist/harness.d.ts +147 -0
  25. package/dist/harness.js +1407 -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 +37 -0
  31. package/dist/providers/anthropic.js +917 -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 +5 -0
  35. package/dist/providers/google-genai.js +1205 -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 +9 -0
  39. package/dist/providers/openai.js +903 -0
  40. package/dist/reactor.d.ts +50 -0
  41. package/dist/reactor.js +1233 -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 +132 -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 +22 -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,1233 @@
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 { ApprovalDecision, signalKindToGateType } from "@intx/types";
17
+ import { type } from "arktype";
18
+ import { runInference } from "./harness.js";
19
+ import { createCapabilities } from "./director.js";
20
+ import { createGateManager } from "./gates.js";
21
+ import { createCorrelationRegistry } from "./correlation.js";
22
+ import { createStateManager } from "./state.js";
23
+ import { validateActions } from "./actions.js";
24
+ import { createToolResultTurn, createInboundTurn, assertWellFormedToolSequence, } from "./turns.js";
25
+ const logger = getLogger(["interchange", "reactor"]);
26
+ // Sentinel returned by a per-call tool run when a before-tool extension parked
27
+ // the call on a gate. Distinct from every ToolResult so a suspended call is
28
+ // excluded from the tool-result history append and from tool.done continuation.
29
+ const SUSPENDED = Symbol("suspended");
30
+ // Exhaustiveness guard for the resume-dispatch switch. A newly added
31
+ // SignalKind or approval outcome that is not classified fails to type-check
32
+ // here, so the switch cannot silently drop an unhandled case.
33
+ function assertNever(x) {
34
+ throw new Error(`Unhandled resume case: ${JSON.stringify(x)}`);
35
+ }
36
+ function buildHarnessOpts(turns, source, options, signal, nextSeq, deps) {
37
+ if (options !== undefined) {
38
+ return {
39
+ turns,
40
+ source,
41
+ inferenceOptions: options,
42
+ signal,
43
+ nextSeq,
44
+ deps,
45
+ };
46
+ }
47
+ return { turns, source, signal, nextSeq, deps };
48
+ }
49
+ const DEFAULT_GATE_TIMEOUT_MS = 3_600_000;
50
+ const DEFAULT_SHUTDOWN_TIMEOUT_MS = 30_000;
51
+ /**
52
+ * Creates a reactor instance bound to the given configuration.
53
+ * Call `start()` to begin the event loop.
54
+ */
55
+ export function createReactor(config) {
56
+ 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,
57
+ // Resolve the optional failover hooks once here, at the reactor's
58
+ // construction edge. A reactor with no source list fails over to
59
+ // nothing and resets to a no-op, so the inference loop below runs the
60
+ // single active source exactly as before.
61
+ failOverToNextSource = () => false, resetToPreferredSource = () => {
62
+ /* single-source: nothing to reset */
63
+ }, } = config;
64
+ // Monotonic sequence counter, scoped to this session.
65
+ let seq = 0;
66
+ function nextSeq() {
67
+ return ++seq;
68
+ }
69
+ function emit(event) {
70
+ onEvent(event);
71
+ }
72
+ // Inbound event queue. Events are pushed here and drained by the loop.
73
+ const queue = [];
74
+ let queueResolve = null;
75
+ // A tool cycle spans from the moment the reactor dispatches an inference or
76
+ // a tool batch until the director has consumed every completion event that
77
+ // operation produces. While a cycle is in flight, admitting a new inbound
78
+ // message — and the inference it triggers — ahead of the outstanding
79
+ // completion events corrupts the prompt: an assistant tool_call turn must be
80
+ // immediately followed by its tool results, and a new inference would
81
+ // instead interleave fresh turns and re-infer against a half-finished batch,
82
+ // which providers reject.
83
+ //
84
+ // pendingContinuations is the authoritative count of dispatched operations
85
+ // whose completion events have not yet been consumed. Every cycle event is
86
+ // counted as it is enqueued and uncounted as it is dequeued, so the count
87
+ // always equals the number of cycle events waiting in the queue. While it is
88
+ // positive, dequeueNext drains cycle events ahead of inbound mail; at zero
89
+ // the cycle is quiescent and processing reverts to FIFO.
90
+ //
91
+ // An earlier design inferred "mid-cycle" from history shape — whether the
92
+ // last turn was an assistant tool_call turn. That underreports in-flight
93
+ // work: a finished tool batch appends its tool-result turn to history before
94
+ // its tool.done events are consumed, flipping the last turn away from the
95
+ // assistant tool_call turn while completion events are still queued, which
96
+ // let inbound mail start an overlapping inference.
97
+ const CYCLE_EVENT_TYPES = new Set([
98
+ "inference.done",
99
+ "inference.error",
100
+ "tool.done",
101
+ ]);
102
+ let pendingContinuations = 0;
103
+ function enqueue(event) {
104
+ if (CYCLE_EVENT_TYPES.has(event.type)) {
105
+ pendingContinuations += 1;
106
+ }
107
+ queue.push(event);
108
+ if (queueResolve !== null) {
109
+ const resolve = queueResolve;
110
+ queueResolve = null;
111
+ resolve();
112
+ }
113
+ }
114
+ async function waitForEvent() {
115
+ if (queue.length > 0)
116
+ return;
117
+ await new Promise((resolve) => {
118
+ queueResolve = resolve;
119
+ });
120
+ }
121
+ function dequeueNext() {
122
+ if (queue.length === 0)
123
+ return undefined;
124
+ // Always process abort immediately.
125
+ const abortIdx = queue.findIndex((e) => e.type === "abort");
126
+ if (abortIdx !== -1) {
127
+ return queue.splice(abortIdx, 1)[0];
128
+ }
129
+ // Mid-cycle: drain inference-cycle events before anything else so the
130
+ // outstanding inference or tool batch completes before new mail can start
131
+ // an overlapping inference.
132
+ if (pendingContinuations > 0) {
133
+ const idx = queue.findIndex((e) => CYCLE_EVENT_TYPES.has(e.type));
134
+ if (idx !== -1) {
135
+ return queue.splice(idx, 1)[0];
136
+ }
137
+ }
138
+ return queue.shift();
139
+ }
140
+ const gates = createGateManager();
141
+ const correlations = createCorrelationRegistry();
142
+ const capabilities = createCapabilities();
143
+ let stateManager = null;
144
+ let running = false;
145
+ let done = false;
146
+ let shutdownStarted = false;
147
+ // Correlation state is empty until context loading and gate rehydration
148
+ // finish. Hold early deliveries so a resumed approval cannot be mistaken
149
+ // for a new conversation message during that startup window.
150
+ let startupDeliveries = [];
151
+ // Per-message run-bracket state. Set when the loop dequeues a
152
+ // message.received and begins per-message work; cleared at the
153
+ // terminal point (wait/reply/done) or at a reactor-fatal abandon.
154
+ // `messageRunId` is reactor-minted per dequeue via crypto.randomUUID
155
+ // so a crash-and-replay that re-delivers the same messageId still
156
+ // produces unambiguous start/end pairs downstream.
157
+ let currentMessageRunId = null;
158
+ let currentMessageId = null;
159
+ function openMessageRun(messageId) {
160
+ currentMessageRunId = crypto.randomUUID();
161
+ currentMessageId = messageId;
162
+ emit({
163
+ type: "message.run.started",
164
+ seq: nextSeq(),
165
+ data: {
166
+ messageId,
167
+ messageRunId: currentMessageRunId,
168
+ receivedAt: Date.now(),
169
+ },
170
+ });
171
+ }
172
+ function closeMessageRun(status, error) {
173
+ if (currentMessageRunId === null || currentMessageId === null)
174
+ return;
175
+ const data = {
176
+ messageRunId: currentMessageRunId,
177
+ messageId: currentMessageId,
178
+ status,
179
+ };
180
+ if (error !== undefined)
181
+ data.error = error;
182
+ emit({ type: "message.run.ended", seq: nextSeq(), data });
183
+ currentMessageRunId = null;
184
+ currentMessageId = null;
185
+ }
186
+ // Per-cycle accumulator of TransformRecord entries produced by every
187
+ // transform invocation (tool result, context, compactor). Flushed via
188
+ // contextStore.writeManifest at cycle boundaries.
189
+ let manifestBuffer = [];
190
+ // Tracks how the current cycle should be summarized in the commit message.
191
+ let cycleInferred = false;
192
+ let cycleToolCallsExecuted = 0;
193
+ let cycleCompactorName = null;
194
+ // A suspension registers a gate and may persist a pending operation. That is
195
+ // a durable state change even when the cycle ran no inference and completed
196
+ // no tool call, so it must force the cycle commit.
197
+ let cycleSuspended = false;
198
+ // Director-supplied checkpoint message override; consumed exactly once.
199
+ let pendingMessage = null;
200
+ // AbortController for in-flight inference/tool operations.
201
+ let operationController = new AbortController();
202
+ function abortOperations() {
203
+ operationController.abort();
204
+ operationController = new AbortController();
205
+ }
206
+ // Track in-flight inference and tool promises for shutdown cleanup.
207
+ const inFlight = new Set();
208
+ function track(p) {
209
+ inFlight.add(p);
210
+ p.then(() => inFlight.delete(p), () => inFlight.delete(p));
211
+ return p;
212
+ }
213
+ // -------------------------------------------------------------------------
214
+ // Correlation helper
215
+ // -------------------------------------------------------------------------
216
+ // Guard against concurrent tryCorrelate calls for the same correlationId.
217
+ // deliver() is fire-and-forget async, so two rapid delivers can interleave
218
+ // across an await boundary in the validator, causing double-correlation.
219
+ const correlatingIds = new Set();
220
+ // Decide how a correlated approval-kind pending operation resumes, granting
221
+ // any one-shot bypass synchronously so no delivery can interleave between the
222
+ // grant and the re-dispatch enqueued by the caller. An operation that carries
223
+ // a `suspendedCall` is an ask-flow suspension: the approver's decision routes
224
+ // it down the re-dispatch rail. An operation without one is an async-tool
225
+ // pending marker, which resumes on the normal gate-cleared rail.
226
+ //
227
+ // The nested switch is total: the outer `assertNever(op.kind)` rejects a
228
+ // future SignalKind at compile time, and the inner `assertNever` rejects a
229
+ // future decision outcome. A malformed decision body fails loud at the parse
230
+ // boundary before the switch.
231
+ function resumePendingOperation(op, message) {
232
+ if (op.suspendedCall === undefined) {
233
+ return { mode: "gate-cleared" };
234
+ }
235
+ const suspendedCall = op.suspendedCall;
236
+ if (message.content === undefined) {
237
+ throw new Error(`Correlated approval decision for ${op.correlationId} has no body to parse`);
238
+ }
239
+ let raw;
240
+ try {
241
+ raw = JSON.parse(message.content);
242
+ }
243
+ catch (cause) {
244
+ throw new Error(`Correlated approval decision for ${op.correlationId} is not valid JSON`, { cause });
245
+ }
246
+ const decision = ApprovalDecision(raw);
247
+ if (decision instanceof type.errors) {
248
+ throw new Error(`Correlated approval decision for ${op.correlationId} is malformed: ${decision.summary}`);
249
+ }
250
+ switch (op.kind) {
251
+ case "approval":
252
+ switch (decision.outcome) {
253
+ case "approved":
254
+ // Authorize the exact parked call to run once, then re-dispatch it.
255
+ // Grant on every before-tool extension: only the authz extension
256
+ // responds, but referencing it directly would re-couple the reactor
257
+ // to authz and break a deployment that runs without it.
258
+ for (const ext of beforeToolExtensions) {
259
+ ext.grantOneShot?.(suspendedCall.id);
260
+ }
261
+ return { mode: "redispatch", calls: [suspendedCall] };
262
+ case "rejected": {
263
+ // The approver denied the call. Answer the parked call with a
264
+ // synthetic error result rather than re-running it — no one-shot
265
+ // bypass is granted, so the tool never executes. The approver's
266
+ // reason, when present, is surfaced to the model verbatim.
267
+ const content = "denied by approver" +
268
+ (decision.message !== undefined ? `: ${decision.message}` : "");
269
+ return {
270
+ mode: "error_result",
271
+ result: { callId: suspendedCall.id, content, isError: true },
272
+ };
273
+ }
274
+ default:
275
+ return assertNever(decision.outcome);
276
+ }
277
+ default:
278
+ return assertNever(op.kind);
279
+ }
280
+ }
281
+ async function tryCorrelate(message) {
282
+ const correlationId = message.headers.interchangeCorrelationId;
283
+ if (correlationId === undefined)
284
+ return false;
285
+ if (correlatingIds.has(correlationId))
286
+ return false;
287
+ const pending = correlations.lookup(correlationId);
288
+ if (pending === undefined)
289
+ return false;
290
+ correlatingIds.add(correlationId);
291
+ if (correlationValidator !== undefined) {
292
+ let valid;
293
+ try {
294
+ valid = await correlationValidator.validate(pending, message);
295
+ }
296
+ catch (cause) {
297
+ logger.warn `Correlation validator threw for ${correlationId}: ${cause}`;
298
+ correlatingIds.delete(correlationId);
299
+ return false;
300
+ }
301
+ if (!valid) {
302
+ correlatingIds.delete(correlationId);
303
+ return false;
304
+ }
305
+ }
306
+ // Capture the operation before removal so the resume dispatch can read its
307
+ // kind and suspended call. Removal happens only after the dispatch is
308
+ // decided, all inside this correlatingIds-guarded critical section so a
309
+ // double-deliver early-returns rather than double-dispatching.
310
+ const op = pending;
311
+ let dispatch;
312
+ try {
313
+ dispatch = resumePendingOperation(op, message);
314
+ }
315
+ catch (cause) {
316
+ correlatingIds.delete(correlationId);
317
+ throw cause;
318
+ }
319
+ const gate = gates.findByCorrelationId(correlationId);
320
+ switch (dispatch.mode) {
321
+ case "redispatch": {
322
+ // Clear the gate WITHOUT enqueuing gate.cleared: the re-dispatched call
323
+ // is the resumption, so a gate.cleared-driven re-infer would double the
324
+ // continuation. The re-dispatch's own tool.done drives the re-infer.
325
+ if (gate !== undefined) {
326
+ gates.clearSilently(gate.gateId);
327
+ if (stateManager !== null) {
328
+ stateManager.setGatesSnapshot(gates.snapshot());
329
+ }
330
+ }
331
+ correlations.remove(correlationId);
332
+ if (stateManager !== null) {
333
+ stateManager.removePendingOperation(correlationId);
334
+ }
335
+ // The grant is already recorded (synchronously, in
336
+ // resumePendingOperation) with no await since; enqueue the re-dispatch
337
+ // so it runs on the loop with normal event ordering. The director seeds
338
+ // its outstanding-result count off this event before the call's
339
+ // tool.done arrives.
340
+ enqueue({ type: "resume.execute_tools", calls: dispatch.calls });
341
+ break;
342
+ }
343
+ case "error_result": {
344
+ // The approver denied the call. Clear the gate SILENTLY (like the
345
+ // approved redispatch) so it cannot also trip onGateCleared and enqueue
346
+ // a second continuation. The synthetic error result answers the parked
347
+ // call; the director appends it and re-infers once.
348
+ if (gate !== undefined) {
349
+ gates.clearSilently(gate.gateId);
350
+ if (stateManager !== null) {
351
+ stateManager.setGatesSnapshot(gates.snapshot());
352
+ }
353
+ }
354
+ correlations.remove(correlationId);
355
+ if (stateManager !== null) {
356
+ stateManager.removePendingOperation(correlationId);
357
+ }
358
+ enqueue({ type: "resume.tool_result", result: dispatch.result });
359
+ break;
360
+ }
361
+ case "gate-cleared": {
362
+ // Async-tool resumption: clear the gate normally so the director
363
+ // re-infers, and append the correlated response to history so the model
364
+ // sees the content it was waiting on.
365
+ if (gate !== undefined) {
366
+ gates.clear(gate.gateId);
367
+ }
368
+ correlations.remove(correlationId);
369
+ if (stateManager !== null) {
370
+ stateManager.removePendingOperation(correlationId);
371
+ const msg = createInboundTurn(message);
372
+ if (msg !== null) {
373
+ stateManager.appendTurn(msg);
374
+ }
375
+ }
376
+ break;
377
+ }
378
+ }
379
+ emit({
380
+ type: "message.correlated",
381
+ seq: nextSeq(),
382
+ data: { message, correlationId },
383
+ });
384
+ return true;
385
+ }
386
+ // -------------------------------------------------------------------------
387
+ // Action execution
388
+ // -------------------------------------------------------------------------
389
+ let pendingPacingDelayMs = 0;
390
+ function buildStrategyContext(trigger) {
391
+ if (stateManager === null) {
392
+ throw new Error("State manager not initialized");
393
+ }
394
+ return { state: stateManager.snapshot(), trigger };
395
+ }
396
+ async function persistBlobs(blobs) {
397
+ if (blobs === undefined)
398
+ return;
399
+ for (const blob of blobs) {
400
+ await contextStore.writeBlob(blob.key, blob.bytes, blob.contentType);
401
+ }
402
+ }
403
+ async function executeInfer(options) {
404
+ if (stateManager === null)
405
+ return;
406
+ const signal = operationController.signal;
407
+ // Proactive pacing: if the previous inference response indicated we are
408
+ // at the rate limit, wait before sending the next request.
409
+ if (pendingPacingDelayMs > 0 && !signal.aborted) {
410
+ const delayMs = pendingPacingDelayMs;
411
+ pendingPacingDelayMs = 0;
412
+ logger.info `Pacing: waiting ${String(delayMs)}ms before next inference request`;
413
+ await new Promise((resolve) => {
414
+ const timer = setTimeout(resolve, delayMs);
415
+ const onAbort = () => {
416
+ clearTimeout(timer);
417
+ resolve();
418
+ };
419
+ signal.addEventListener("abort", onAbort, { once: true });
420
+ });
421
+ if (signal.aborted)
422
+ return;
423
+ }
424
+ // Run the context transform chain to produce the materialized prompt.
425
+ let prompt = stateManager.getTurns();
426
+ for (const transform of contextTransforms) {
427
+ const ctx = buildStrategyContext("pre-inference");
428
+ const result = await transform.apply(prompt, ctx);
429
+ prompt = result.output;
430
+ manifestBuffer.push(result.record);
431
+ await persistBlobs(result.blobs);
432
+ }
433
+ // Tripwire: a malformed tool sequence is invalid in a coherent tool
434
+ // conversation and would otherwise surface as an opaque provider rejection.
435
+ // Catch it here, before the prompt is persisted or sent, so the corruption
436
+ // fails loud as an internal error at the assembly boundary. Throwing routes
437
+ // through the reactor's fatal-error path.
438
+ assertWellFormedToolSequence(prompt);
439
+ try {
440
+ await contextStore.writePrompt(prompt);
441
+ }
442
+ catch (cause) {
443
+ logger.error `writePrompt failed: ${cause}`;
444
+ emitError(`writePrompt failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
445
+ }
446
+ const p = (async () => {
447
+ // Each cycle starts at the most-preferred source; a failover in a
448
+ // prior cycle must not leave the agent permanently demoted.
449
+ resetToPreferredSource();
450
+ for (;;) {
451
+ const harnessOpts = buildHarnessOpts(prompt, config.source, options, signal, nextSeq, deps);
452
+ let lastDone;
453
+ let lastError;
454
+ for await (const event of inferenceRunner(harnessOpts)) {
455
+ emit(event);
456
+ if (event.type === "inference.done")
457
+ lastDone = event;
458
+ else if (event.type === "inference.error")
459
+ lastError = event;
460
+ }
461
+ if (lastDone !== undefined) {
462
+ if (stateManager !== null) {
463
+ stateManager.appendTurn(lastDone.data.turn);
464
+ stateManager.accumUsage(lastDone.data.usage);
465
+ stateManager.setLastCycleUsage(lastDone.data.usage);
466
+ stateManager.setLastCycleSource(lastDone.data.source);
467
+ }
468
+ cycleInferred = true;
469
+ try {
470
+ await contextStore.writeResponse(lastDone.data.turn);
471
+ }
472
+ catch (cause) {
473
+ logger.error `writeResponse failed: ${cause}`;
474
+ emitError(`writeResponse failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
475
+ }
476
+ if (lastDone.data.pacingDelayMs !== undefined) {
477
+ pendingPacingDelayMs = lastDone.data.pacingDelayMs;
478
+ }
479
+ const u = lastDone.data.usage;
480
+ 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` : ""}`;
481
+ enqueue({
482
+ type: "inference.done",
483
+ turn: lastDone.data.turn,
484
+ usage: lastDone.data.usage,
485
+ source: lastDone.data.source,
486
+ });
487
+ return;
488
+ }
489
+ if (lastError === undefined) {
490
+ emitError("Inference runner returned without a terminal event", true);
491
+ enqueue({
492
+ type: "inference.error",
493
+ error: {
494
+ category: "fatal",
495
+ message: "Inference runner returned without a terminal event",
496
+ },
497
+ partial: { text: "" },
498
+ });
499
+ return;
500
+ }
501
+ const err = lastError.data.error;
502
+ const partial = lastError.data.partial;
503
+ // Source-invariant failures: no source can serve this call, so
504
+ // abort the whole cycle rather than waste failover attempts.
505
+ if (err.category === "context_overflow" ||
506
+ err.category === "fatal" ||
507
+ err.category === "aborted") {
508
+ enqueue({ type: "inference.error", error: err, partial });
509
+ return;
510
+ }
511
+ // Any remaining error (quota, credential, protocol mismatch,
512
+ // retryable, timeout) is source-specific. The harness wrapper owns
513
+ // mechanical retry and has already exhausted it against this source
514
+ // by the time the reactor sees the error, including honoring a
515
+ // provider Retry-After for quota, so re-running the same source
516
+ // would only retry-compound. Fail over to the next source instead.
517
+ // A pacing delay the leaving source asked for must not gate the
518
+ // next source.
519
+ pendingPacingDelayMs = 0;
520
+ if (failOverToNextSource()) {
521
+ logger.warn `Failing over to next inference source after ${err.category}`;
522
+ continue;
523
+ }
524
+ // No further source to fail over to: surface the last error.
525
+ enqueue({ type: "inference.error", error: err, partial });
526
+ return;
527
+ }
528
+ })();
529
+ void track(p);
530
+ await p;
531
+ }
532
+ async function executeTools(calls, parallel, addToHistory = true) {
533
+ if (stateManager === null)
534
+ return;
535
+ const state = stateManager;
536
+ const signal = operationController.signal;
537
+ const runOne = async (call) => {
538
+ // Run before-tool extensions. The first non-allow decision terminates
539
+ // the chain: `block` answers the call with an error result, `suspend`
540
+ // parks it (no result, no tool.done).
541
+ for (const ext of beforeToolExtensions) {
542
+ let decision;
543
+ try {
544
+ decision = await ext.beforeTool(call, state.snapshot(), signal);
545
+ }
546
+ catch (cause) {
547
+ const msg = cause instanceof Error ? cause.message : String(cause);
548
+ emitError(`BeforeToolExtension threw for ${call.name}: ${msg}`, false);
549
+ decision = { type: "block", reason: msg };
550
+ }
551
+ if (decision.type === "suspend") {
552
+ // Park the call: register the gate, persist the pending operation,
553
+ // snapshot, and commit. The call is neither run nor answered — no
554
+ // tool.start, no tool.done, no tool-result turn. The gate clears
555
+ // when the correlated external decision is delivered.
556
+ await suspendOnGate({
557
+ gateType: decision.gate.type,
558
+ gateId: decision.gate.gateId,
559
+ timeoutMs: Math.max(1, decision.gate.timeoutAt - Date.now()),
560
+ correlationId: decision.gate.correlationId,
561
+ pendingOp: decision.pendingOp,
562
+ });
563
+ return SUSPENDED;
564
+ }
565
+ if (decision.type === "block") {
566
+ const blocked = {
567
+ callId: call.id,
568
+ content: decision.reason,
569
+ isError: true,
570
+ };
571
+ emit({
572
+ type: "tool.done",
573
+ seq: nextSeq(),
574
+ data: { result: blocked },
575
+ });
576
+ return blocked;
577
+ }
578
+ }
579
+ emit({ type: "tool.start", seq: nextSeq(), data: { call } });
580
+ const rawResult = await toolRunner.run(call, signal);
581
+ emit({ type: "tool.done", seq: nextSeq(), data: { result: rawResult } });
582
+ if (rawResult.pendingMarker !== undefined && stateManager !== null) {
583
+ const marker = rawResult.pendingMarker;
584
+ const gateId = `pending-${marker.correlationId}`;
585
+ const op = {
586
+ correlationId: marker.correlationId,
587
+ // Placeholder: async markers should carry their own SignalKind. The
588
+ // resume switch keys on suspendedCall presence (absent here) as the
589
+ // interim discriminator instead of on kind.
590
+ kind: "approval",
591
+ registeredAt: Date.now(),
592
+ gateId,
593
+ ...(marker.expectedFrom !== undefined
594
+ ? { expectedFrom: marker.expectedFrom }
595
+ : {}),
596
+ };
597
+ correlations.register(op);
598
+ stateManager.addPendingOperation(op);
599
+ }
600
+ // Apply the tool-result transform chain. Each transform's output is fed
601
+ // into the next; emitted blobs are persisted immediately so downstream
602
+ // transforms can rely on the spill being available.
603
+ let current = rawResult;
604
+ for (const transform of toolResultTransforms) {
605
+ const ctx = buildStrategyContext("tool-result-ingest");
606
+ const tr = await transform.apply({ call, result: current }, ctx);
607
+ manifestBuffer.push(tr.record);
608
+ await persistBlobs(tr.blobs);
609
+ current = tr.output;
610
+ }
611
+ return current;
612
+ };
613
+ let outcomes;
614
+ if (parallel) {
615
+ const p = Promise.all(calls.map((c) => runOne(c)));
616
+ void track(p);
617
+ outcomes = await p;
618
+ }
619
+ else {
620
+ outcomes = [];
621
+ for (const call of calls) {
622
+ const p = runOne(call);
623
+ void track(p);
624
+ outcomes.push(await p);
625
+ }
626
+ }
627
+ // Suspended calls are parked, not answered: they contribute no tool
628
+ // result to history and no tool.done continuation event.
629
+ const results = outcomes.filter((o) => o !== SUSPENDED);
630
+ cycleToolCallsExecuted += results.length;
631
+ if (addToHistory && stateManager !== null && results.length > 0) {
632
+ stateManager.appendTurn(createToolResultTurn(results));
633
+ }
634
+ for (const result of results) {
635
+ enqueue({ type: "tool.done", result });
636
+ }
637
+ }
638
+ async function executeCompact(compactorName, reason) {
639
+ if (stateManager === null)
640
+ return;
641
+ const compactor = compactors[compactorName];
642
+ if (compactor === undefined) {
643
+ throw new Error(`executeCompact: no compactor registered for name ${JSON.stringify(compactorName)}`);
644
+ }
645
+ const ctx = {
646
+ state: stateManager.snapshot(),
647
+ trigger: `director:${reason}`,
648
+ };
649
+ const result = await compactor.apply(stateManager.getTurns(), ctx);
650
+ stateManager.replaceTurns(result.output);
651
+ await contextStore.writeTurns(result.output);
652
+ await persistBlobs(result.blobs);
653
+ manifestBuffer.push(result.record);
654
+ cycleCompactorName = compactor.name;
655
+ logger.info `Compaction by ${compactor.name} reduced history (reason: ${reason})`;
656
+ }
657
+ // -------------------------------------------------------------------------
658
+ // Cycle boundary commit
659
+ // -------------------------------------------------------------------------
660
+ function buildCycleMessage() {
661
+ if (pendingMessage !== null) {
662
+ const msg = pendingMessage;
663
+ pendingMessage = null;
664
+ return msg;
665
+ }
666
+ if (cycleCompactorName !== null) {
667
+ return `Cycle: compaction by ${cycleCompactorName}`;
668
+ }
669
+ const parts = [];
670
+ if (cycleInferred)
671
+ parts.push("inferred");
672
+ if (cycleToolCallsExecuted > 0) {
673
+ const noun = cycleToolCallsExecuted === 1 ? "tool call" : "tool calls";
674
+ parts.push(`${String(cycleToolCallsExecuted)} ${noun}`);
675
+ }
676
+ if (parts.length === 0)
677
+ return "Cycle: no-op";
678
+ return `Cycle: ${parts.join(" + ")}`;
679
+ }
680
+ function resetCycleAccumulators() {
681
+ manifestBuffer = [];
682
+ cycleInferred = false;
683
+ cycleToolCallsExecuted = 0;
684
+ cycleCompactorName = null;
685
+ cycleSuspended = false;
686
+ }
687
+ async function commitCycle() {
688
+ if (stateManager === null)
689
+ return;
690
+ // Only commit when the cycle did real work or the director set an
691
+ // override message. An empty cycle (no inference, no tools, no compact,
692
+ // no override) commits nothing.
693
+ const hasWork = cycleInferred ||
694
+ cycleToolCallsExecuted > 0 ||
695
+ cycleCompactorName !== null ||
696
+ cycleSuspended;
697
+ const hasOverride = pendingMessage !== null;
698
+ if (!hasWork && !hasOverride) {
699
+ resetCycleAccumulators();
700
+ return;
701
+ }
702
+ const message = buildCycleMessage();
703
+ try {
704
+ await contextStore.writeTurns(stateManager.getTurns());
705
+ await contextStore.writeManifest(manifestBuffer);
706
+ await writeMetadata();
707
+ const commit = await contextStore.commit({ message });
708
+ lastCheckpointHash = commit.hash;
709
+ }
710
+ catch (cause) {
711
+ logger.error `Cycle commit failed: ${cause}`;
712
+ emitError(`Cycle commit failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
713
+ resetCycleAccumulators();
714
+ return;
715
+ }
716
+ resetCycleAccumulators();
717
+ if (afterCheckpoint !== undefined) {
718
+ try {
719
+ await afterCheckpoint();
720
+ }
721
+ catch (cause) {
722
+ logger.error `afterCheckpoint failed: ${cause}`;
723
+ emitError(`afterCheckpoint failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
724
+ }
725
+ }
726
+ }
727
+ async function writeMetadata() {
728
+ if (stateManager === null)
729
+ return;
730
+ await contextStore.writeMetadata({
731
+ pendingOperations: stateManager.getPendingOperations(),
732
+ tokenUsage: stateManager.getTokenUsage(),
733
+ });
734
+ }
735
+ let suspendingGate = null;
736
+ // Callback the gate manager invokes when a gate resolves, times out, or is
737
+ // shut down. Refreshes the snapshot and drives the loop's next step.
738
+ //
739
+ // A parked ask-flow approval that TIMES OUT ends without running its tool:
740
+ // it must be answered with a synthetic error result rather than left as a
741
+ // dangling tool_use. That path enqueues `resume.tool_result` INSTEAD OF
742
+ // `reactor.gate.cleared` — the two are mutually exclusive, because enqueuing
743
+ // both would drive two re-inferences for one timeout. Every other case (an
744
+ // async-marker pending op with no suspendedCall, no pending op at all, a
745
+ // `resolved`/`shutdown` reason, or a shutting-down reactor) keeps today's
746
+ // behavior: enqueue `reactor.gate.cleared` and let the director re-infer.
747
+ //
748
+ // A delivered `resolved` never reaches here on the ask rail — the redispatch
749
+ // and reject paths clear the gate silently (no onCleared) — so the timeout
750
+ // branch is gated on `reason === "timeout"` and shutdown stays on the plain
751
+ // path: a shutting-down reactor must not manufacture tool results.
752
+ function onGateCleared(gateId, reason) {
753
+ // A clear that fires while this gate's suspend is still committing must not
754
+ // take effect before `reactor.gate.blocked` is emitted. Record it and let
755
+ // suspendOnGate replay the full handler once the block is announced.
756
+ if (suspendingGate !== null &&
757
+ suspendingGate.gateId === gateId &&
758
+ suspendingGate.deferredClear === null) {
759
+ suspendingGate.deferredClear = { reason };
760
+ return;
761
+ }
762
+ if (stateManager !== null) {
763
+ stateManager.setGatesSnapshot(gates.snapshot());
764
+ }
765
+ if (reason === "timeout") {
766
+ const op = correlations.findByGateId(gateId);
767
+ if (op !== undefined && op.suspendedCall !== undefined) {
768
+ correlations.remove(op.correlationId);
769
+ if (stateManager !== null) {
770
+ stateManager.removePendingOperation(op.correlationId);
771
+ }
772
+ enqueue({
773
+ type: "resume.tool_result",
774
+ result: {
775
+ callId: op.suspendedCall.id,
776
+ content: "approval timed out",
777
+ isError: true,
778
+ },
779
+ });
780
+ return;
781
+ }
782
+ }
783
+ emit({
784
+ type: "reactor.gate.cleared",
785
+ seq: nextSeq(),
786
+ data: { gateId, reason },
787
+ });
788
+ enqueue({ type: "reactor.gate.cleared", gateId, reason });
789
+ }
790
+ // Parks the reactor on a gate. Shared by the director's `suspend` action and
791
+ // the before-tool `suspend` decision so both paths register the gate,
792
+ // durably persist any pending operation, snapshot the active gates, and
793
+ // commit before returning to the loop — a suspended reactor's state must be
794
+ // durable across restart. When `pendingOp` is supplied its correlation is
795
+ // registered and it is persisted; the director path has already persisted
796
+ // its pending operation (via the tool's pending marker), so it passes none.
797
+ async function suspendOnGate(args) {
798
+ const { gateType, gateId, timeoutMs, correlationId, pendingOp } = args;
799
+ if (pendingOp !== undefined) {
800
+ correlations.register(pendingOp);
801
+ if (stateManager !== null) {
802
+ stateManager.addPendingOperation(pendingOp);
803
+ }
804
+ }
805
+ // Track this suspend as in flight so a clear racing the commit below is
806
+ // deferred until `reactor.gate.blocked` has been emitted.
807
+ const inFlightSuspend = { gateId, deferredClear: null };
808
+ suspendingGate = inFlightSuspend;
809
+ // Register the gate. onGateCleared enqueues the cleared event so the loop
810
+ // processes it normally without blocking here.
811
+ void gates.register(gateId, gateType, timeoutMs, correlationId, onGateCleared);
812
+ if (stateManager !== null) {
813
+ stateManager.setGatesSnapshot(gates.snapshot());
814
+ }
815
+ // Registering the gate (and any pending operation) is a durable state
816
+ // change that must be committed even if this cycle did no other work.
817
+ cycleSuspended = true;
818
+ // Commit before the loop continues so the suspended state is durable
819
+ // across restart.
820
+ await commitCycle();
821
+ // Emit `reactor.gate.blocked` only AFTER the commit. This event resolves
822
+ // the `send()` awaiter as "suspended", and a downstream consumer (the warm
823
+ // agent's run-boundary durability mirror) reads the pending operation back
824
+ // out of the just-committed context store the instant `send()` settles.
825
+ // Emitting before the commit would resolve `send()` first, letting that
826
+ // mirror read a store that has not yet persisted the pending op -- it would
827
+ // durably mirror an empty pending-operation set and lose the approval
828
+ // snapshot, so a parked correlation could not be re-registered after a hub
829
+ // reconnect. This upholds persist-before-settle: the durable commit the
830
+ // header promises before returning to the loop lands before the suspension
831
+ // settles.
832
+ emit({
833
+ type: "reactor.gate.blocked",
834
+ seq: nextSeq(),
835
+ data: {
836
+ reason: gateType,
837
+ gateId,
838
+ ...(correlationId !== undefined ? { correlationId } : {}),
839
+ ...(pendingOp?.approvalSnapshot !== undefined
840
+ ? { approvalSnapshot: pendingOp.approvalSnapshot }
841
+ : {}),
842
+ },
843
+ });
844
+ // The suspension is announced. If the gate cleared while the commit was in
845
+ // flight, its handler was deferred to keep it after `blocked`; replay it
846
+ // now, in order.
847
+ suspendingGate = null;
848
+ if (inFlightSuspend.deferredClear !== null) {
849
+ onGateCleared(gateId, inFlightSuspend.deferredClear.reason);
850
+ }
851
+ }
852
+ // Re-registers a live gate and correlation for each pending operation loaded
853
+ // from the context store on restart. The remaining timeout is computed from
854
+ // the persisted absolute deadline (`timeoutAt`) against the current clock, so
855
+ // the deadline is preserved across the restart rather than restarted; a
856
+ // deadline already in the past clamps to 1ms so the gate fires on the next
857
+ // tick. An operation persisted without a `timeoutAt` (hold-indefinitely) has
858
+ // no deadline to preserve; the gate manager cannot express an indefinite
859
+ // hold, so it is armed with the session-level `gateTimeout` — the same
860
+ // effective timeout the director-suspend fallback uses — rather than a
861
+ // silent zero. This does not run through `suspendOnGate`: rehydration must
862
+ // not re-emit `reactor.gate.blocked` (the suspension already happened before
863
+ // the restart) and must not commit (nothing changed).
864
+ function rehydrateGates(ops) {
865
+ for (const op of ops) {
866
+ const timeoutMs = op.timeoutAt !== undefined
867
+ ? Math.max(1, op.timeoutAt - Date.now())
868
+ : gateTimeout;
869
+ correlations.register(op);
870
+ void gates.register(op.gateId, signalKindToGateType(op.kind), timeoutMs, op.correlationId, onGateCleared);
871
+ }
872
+ }
873
+ // -------------------------------------------------------------------------
874
+ // Main loop
875
+ // -------------------------------------------------------------------------
876
+ async function loop() {
877
+ if (stateManager === null) {
878
+ throw new Error("State manager not initialized before loop");
879
+ }
880
+ while (!done) {
881
+ await waitForEvent();
882
+ if (done)
883
+ break;
884
+ const event = dequeueNext();
885
+ if (event === undefined)
886
+ continue;
887
+ // A dequeued cycle event is one fewer in-flight continuation. Pairs with
888
+ // the increment in enqueue(); both key off CYCLE_EVENT_TYPES so they
889
+ // cannot drift.
890
+ if (CYCLE_EVENT_TYPES.has(event.type)) {
891
+ pendingContinuations -= 1;
892
+ }
893
+ // Handle abort events: initiate shutdown regardless of director.
894
+ if (event.type === "abort") {
895
+ if (!shutdownStarted) {
896
+ done = true;
897
+ await initiateShutdown();
898
+ }
899
+ break;
900
+ }
901
+ // Append inbound messages to conversation history so the provider sees them.
902
+ // Each dequeued message.received opens a fresh per-message run bracket.
903
+ // If a prior bracket is still open (defensive — should not occur given
904
+ // the dequeue priority that drains cycle events before new messages),
905
+ // close it as completed first so the new bracket starts cleanly.
906
+ if (event.type === "message.received") {
907
+ if (stateManager !== null) {
908
+ const msg = createInboundTurn(event.message);
909
+ if (msg !== null) {
910
+ stateManager.appendTurn(msg);
911
+ }
912
+ }
913
+ if (currentMessageRunId !== null) {
914
+ closeMessageRun("completed");
915
+ }
916
+ openMessageRun(event.message.headers.messageId);
917
+ }
918
+ // A parked approval that ended without running its tool (rejected or
919
+ // timed out) carries a synthetic error result answering the parked call.
920
+ // Land it in history before the director decides so the tool_result turn
921
+ // closes the dangling tool_use and the re-inference the director returns
922
+ // sees a well-formed sequence. No tool ran, so no tool.done and no
923
+ // counter change accompany it.
924
+ if (event.type === "resume.tool_result") {
925
+ if (stateManager !== null) {
926
+ stateManager.appendTurn(createToolResultTurn([event.result]));
927
+ }
928
+ }
929
+ let actions;
930
+ try {
931
+ actions = await director.decide(event, stateManager.snapshot(), capabilities);
932
+ }
933
+ catch (cause) {
934
+ const msg = cause instanceof Error ? cause.message : String(cause);
935
+ logger.error `Director threw during decide: ${cause}`;
936
+ emitError(`Director exception: ${msg}`, true);
937
+ closeMessageRun("failed", {
938
+ message: `Director exception: ${msg}`,
939
+ kind: "reactor_fatal",
940
+ });
941
+ done = true;
942
+ await initiateShutdown();
943
+ break;
944
+ }
945
+ const validation = validateActions(actions);
946
+ if (!validation.ok) {
947
+ emitError(`Invalid action set: ${validation.error}`, true);
948
+ closeMessageRun("failed", {
949
+ message: `Invalid action set: ${validation.error}`,
950
+ kind: "reactor_fatal",
951
+ });
952
+ done = true;
953
+ await initiateShutdown();
954
+ break;
955
+ }
956
+ const normalized = validation.normalized;
957
+ // Checkpoint sets the next cycle's commit message.
958
+ const checkpointAction = normalized.find((a) => a.type === "checkpoint");
959
+ if (checkpointAction !== undefined) {
960
+ pendingMessage = checkpointAction.message;
961
+ }
962
+ // Emit custom events (validated type namespace).
963
+ for (const action of normalized) {
964
+ if (action.type === "emit") {
965
+ const reserved = ["inference.", "tool.", "reactor.", "fork."];
966
+ const blocked = reserved.some((p) => action.eventType.startsWith(p));
967
+ if (blocked) {
968
+ emitError(`Director tried to emit reserved event type: ${action.eventType}`, false);
969
+ continue;
970
+ }
971
+ emit({ type: action.eventType, seq: nextSeq(), data: action.data });
972
+ }
973
+ }
974
+ // Fork is excluded in this build.
975
+ for (const action of normalized) {
976
+ if (action.type === "fork") {
977
+ emitError("Fork action is not supported in this build", false);
978
+ }
979
+ }
980
+ // Handle done.
981
+ if (normalized.some((a) => a.type === "done")) {
982
+ // Flush the cycle (in case the director paired done with checkpoint
983
+ // or other work) before shutting down.
984
+ await commitCycle();
985
+ closeMessageRun("completed");
986
+ done = true;
987
+ await initiateShutdown();
988
+ break;
989
+ }
990
+ // Handle wait: commit the cycle (if work happened) and return to the
991
+ // event loop without shutting down. Wait is a per-message terminal:
992
+ // the reactor has nothing more to do for the message and is returning
993
+ // to idle.
994
+ if (normalized.some((a) => a.type === "wait")) {
995
+ await commitCycle();
996
+ closeMessageRun("completed");
997
+ continue;
998
+ }
999
+ // Handle suspend: register gate and continue the loop (don't block).
1000
+ const suspendAction = normalized.find((a) => a.type === "suspend");
1001
+ if (suspendAction !== undefined && suspendAction.type === "suspend") {
1002
+ const { gate } = suspendAction;
1003
+ await suspendOnGate({
1004
+ gateType: gate.type,
1005
+ gateId: gate.gateId,
1006
+ timeoutMs: gate.timeoutMs > 0 ? gate.timeoutMs : gateTimeout,
1007
+ correlationId: gate.correlationId,
1008
+ pendingOp: undefined,
1009
+ });
1010
+ continue;
1011
+ }
1012
+ // Handle reply — emit the content for the harness/supervisor to send.
1013
+ const replyAction = normalized.find((a) => a.type === "reply");
1014
+ if (replyAction !== undefined && replyAction.type === "reply") {
1015
+ // Flush any pending cycle work before signaling the reply so the
1016
+ // emitted checkpointHash matches the visible state.
1017
+ await commitCycle();
1018
+ emit({
1019
+ type: "connector.reply",
1020
+ seq: nextSeq(),
1021
+ data: {
1022
+ content: replyAction.content,
1023
+ ...(lastCheckpointHash !== undefined
1024
+ ? { checkpointHash: lastCheckpointHash }
1025
+ : {}),
1026
+ },
1027
+ });
1028
+ // Reply is a per-message terminal point: close the bracket so the
1029
+ // next inbound message opens a fresh run.
1030
+ closeMessageRun("completed");
1031
+ // After replying, wait for the next inbound message.
1032
+ continue;
1033
+ }
1034
+ // Handle compact (its own cycle; runs before any infer can be requested
1035
+ // in the same director invocation — validation forbids that pairing).
1036
+ const compactAction = normalized.find((a) => a.type === "compact");
1037
+ if (compactAction !== undefined && compactAction.type === "compact") {
1038
+ try {
1039
+ await executeCompact(compactAction.compactor, compactAction.reason);
1040
+ }
1041
+ catch (cause) {
1042
+ const msg = cause instanceof Error ? cause.message : String(cause);
1043
+ logger.error `Compaction failed: ${cause}`;
1044
+ emitError(`Compaction failed: ${msg}`, true);
1045
+ closeMessageRun("failed", {
1046
+ message: `Compaction failed: ${msg}`,
1047
+ kind: "reactor_fatal",
1048
+ });
1049
+ done = true;
1050
+ await initiateShutdown();
1051
+ break;
1052
+ }
1053
+ await commitCycle();
1054
+ continue;
1055
+ }
1056
+ // Handle infer.
1057
+ const inferAction = normalized.find((a) => a.type === "infer");
1058
+ if (inferAction !== undefined && inferAction.type === "infer") {
1059
+ await executeInfer(inferAction.options);
1060
+ continue;
1061
+ }
1062
+ // Handle execute_tools.
1063
+ const toolsAction = normalized.find((a) => a.type === "execute_tools");
1064
+ if (toolsAction !== undefined && toolsAction.type === "execute_tools") {
1065
+ const parallel = toolsAction.parallel !== false;
1066
+ const addToHistory = toolsAction.addToHistory !== false;
1067
+ await executeTools(toolsAction.calls, parallel, addToHistory);
1068
+ continue;
1069
+ }
1070
+ // No infer/tools/reply/suspend/wait/compact action — if a checkpoint
1071
+ // override was set on its own (or alongside emit/fork), the next event
1072
+ // will pick it up. Nothing to flush here.
1073
+ }
1074
+ }
1075
+ function emitError(message, fatal) {
1076
+ emit({
1077
+ type: "reactor.error",
1078
+ seq: nextSeq(),
1079
+ data: { error: message, fatal },
1080
+ });
1081
+ }
1082
+ let lastCheckpointHash;
1083
+ async function initiateShutdown() {
1084
+ if (shutdownStarted)
1085
+ return;
1086
+ shutdownStarted = true;
1087
+ abortOperations();
1088
+ gates.shutdown();
1089
+ if (stateManager !== null) {
1090
+ stateManager.setGatesSnapshot([]);
1091
+ }
1092
+ if (inFlight.size > 0) {
1093
+ const deadline = new Promise((resolve) => setTimeout(resolve, shutdownTimeoutMs));
1094
+ await Promise.race([Promise.allSettled([...inFlight]), deadline]);
1095
+ }
1096
+ if (onShutdown !== undefined) {
1097
+ try {
1098
+ await onShutdown();
1099
+ }
1100
+ catch (cause) {
1101
+ logger.error `onShutdown failed: ${cause}`;
1102
+ emitError(`onShutdown failed: ${cause instanceof Error ? cause.message : String(cause)}`, false);
1103
+ }
1104
+ }
1105
+ emit({
1106
+ type: "reactor.done",
1107
+ seq: nextSeq(),
1108
+ data: {},
1109
+ });
1110
+ }
1111
+ // -------------------------------------------------------------------------
1112
+ // Public API
1113
+ // -------------------------------------------------------------------------
1114
+ function start() {
1115
+ if (running) {
1116
+ throw new Error("Reactor is already running");
1117
+ }
1118
+ running = true;
1119
+ void (async () => {
1120
+ let initialTurns;
1121
+ let initialOps;
1122
+ let initialUsage;
1123
+ try {
1124
+ const loaded = await contextStore.load();
1125
+ initialTurns = loaded.turns;
1126
+ initialOps = loaded.pendingOperations;
1127
+ initialUsage = loaded.tokenUsage;
1128
+ }
1129
+ catch (cause) {
1130
+ done = true;
1131
+ startupDeliveries = null;
1132
+ logger.error `Context store load failed: ${cause}`;
1133
+ emitError(`Context store load failed: ${cause instanceof Error ? cause.message : String(cause)}`, true);
1134
+ emit({ type: "reactor.done", seq: nextSeq(), data: {} });
1135
+ return;
1136
+ }
1137
+ stateManager = createStateManager(sessionId, initialTurns, initialOps, initialUsage);
1138
+ try {
1139
+ // Re-arm gates for operations that were suspended before the restart.
1140
+ // The state manager holds the loaded pending operations, but a gate is
1141
+ // in-memory and does not survive a restart; without this a reloaded
1142
+ // suspended agent is wedged (no live gate to clear, no correlation to
1143
+ // match). Each op re-registers its correlation and a live gate keyed on
1144
+ // the op's own gateId and correlationId, so a delivered signal clears
1145
+ // it exactly as the original suspension would have.
1146
+ //
1147
+ // Rehydration runs inside this try/catch because the pending operations
1148
+ // come from the context store — an untrusted external boundary — and
1149
+ // correlation/gate registration throws synchronously on a duplicate
1150
+ // correlationId or gateId. A throw must surface as reactor.error plus
1151
+ // reactor.done (matching the load-failure path), not brick the reactor
1152
+ // as a silent unhandled rejection.
1153
+ rehydrateGates(initialOps);
1154
+ stateManager.setGatesSnapshot(gates.snapshot());
1155
+ emit({ type: "reactor.start", seq: nextSeq(), data: {} });
1156
+ const bufferedDeliveries = startupDeliveries;
1157
+ startupDeliveries = null;
1158
+ if (bufferedDeliveries !== null) {
1159
+ for (const message of bufferedDeliveries) {
1160
+ processDelivery(message);
1161
+ }
1162
+ }
1163
+ await loop();
1164
+ }
1165
+ catch (cause) {
1166
+ const msg = cause instanceof Error ? cause.message : String(cause);
1167
+ done = true;
1168
+ startupDeliveries = null;
1169
+ logger.error `Reactor loop threw unexpectedly: ${cause}`;
1170
+ emitError(`Internal reactor error: ${msg}`, true);
1171
+ closeMessageRun("failed", {
1172
+ message: `Internal reactor error: ${msg}`,
1173
+ kind: "reactor_fatal",
1174
+ });
1175
+ if (!shutdownStarted) {
1176
+ await initiateShutdown();
1177
+ }
1178
+ }
1179
+ })();
1180
+ }
1181
+ function processDelivery(message) {
1182
+ void (async () => {
1183
+ let correlated;
1184
+ try {
1185
+ correlated = await tryCorrelate(message);
1186
+ }
1187
+ catch (cause) {
1188
+ // A correlation-path invariant failed (e.g. a malformed approval
1189
+ // decision). Surface it as a fatal reactor error rather than a silent
1190
+ // unhandled rejection, and stop the run — the resume cannot proceed on
1191
+ // a decision the reactor cannot trust.
1192
+ const msg = cause instanceof Error ? cause.message : String(cause);
1193
+ logger.error `Correlation dispatch failed: ${cause}`;
1194
+ emitError(`Correlation dispatch failed: ${msg}`, true);
1195
+ closeMessageRun("failed", {
1196
+ message: `Correlation dispatch failed: ${msg}`,
1197
+ kind: "reactor_fatal",
1198
+ });
1199
+ done = true;
1200
+ if (!shutdownStarted) {
1201
+ await initiateShutdown();
1202
+ }
1203
+ return;
1204
+ }
1205
+ if (!correlated) {
1206
+ emit({
1207
+ type: "message.received",
1208
+ seq: nextSeq(),
1209
+ data: { message },
1210
+ });
1211
+ enqueue({ type: "message.received", message });
1212
+ }
1213
+ })();
1214
+ }
1215
+ function deliver(message) {
1216
+ if (done)
1217
+ return;
1218
+ if (startupDeliveries !== null) {
1219
+ startupDeliveries.push(message);
1220
+ return;
1221
+ }
1222
+ processDelivery(message);
1223
+ }
1224
+ function abort(reason) {
1225
+ // The loop cannot dequeue the abort event while it is awaiting an active
1226
+ // inference or tool batch. Signal that operation immediately so it can
1227
+ // settle and return control to the loop, where the queued abort retains
1228
+ // its priority over every other event.
1229
+ operationController.abort();
1230
+ enqueue({ type: "abort", reason });
1231
+ }
1232
+ return { start, deliver, abort };
1233
+ }