@driftengine/ai 3.61.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 (82) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +9 -0
  3. package/README.md +103 -0
  4. package/dist/adapters/local.d.ts +29 -0
  5. package/dist/adapters/local.js +24 -0
  6. package/dist/adapters/proxy.d.ts +28 -0
  7. package/dist/adapters/proxy.js +138 -0
  8. package/dist/bridges/authority.d.ts +153 -0
  9. package/dist/bridges/authority.js +179 -0
  10. package/dist/bridges/navigation.d.ts +100 -0
  11. package/dist/bridges/navigation.js +139 -0
  12. package/dist/budget/budget.d.ts +34 -0
  13. package/dist/budget/budget.js +57 -0
  14. package/dist/command/apply.d.ts +24 -0
  15. package/dist/command/apply.js +40 -0
  16. package/dist/command/log.d.ts +55 -0
  17. package/dist/command/log.js +50 -0
  18. package/dist/context/assemble.d.ts +48 -0
  19. package/dist/context/assemble.js +55 -0
  20. package/dist/context/continuation.d.ts +14 -0
  21. package/dist/context/continuation.js +36 -0
  22. package/dist/describe/manifest.d.ts +70 -0
  23. package/dist/describe/manifest.js +99 -0
  24. package/dist/entities/context.d.ts +52 -0
  25. package/dist/entities/context.js +83 -0
  26. package/dist/index.d.ts +61 -0
  27. package/dist/index.js +40 -0
  28. package/dist/policy/types.d.ts +55 -0
  29. package/dist/policy/types.js +26 -0
  30. package/dist/policy/utility.d.ts +18 -0
  31. package/dist/policy/utility.js +47 -0
  32. package/dist/provider/create.d.ts +16 -0
  33. package/dist/provider/create.js +57 -0
  34. package/dist/provider/latency.d.ts +27 -0
  35. package/dist/provider/latency.js +52 -0
  36. package/dist/provider/types.d.ts +90 -0
  37. package/dist/provider/types.js +8 -0
  38. package/dist/realtime/session.d.ts +35 -0
  39. package/dist/realtime/session.js +34 -0
  40. package/dist/session/agent.d.ts +217 -0
  41. package/dist/session/agent.js +506 -0
  42. package/dist/session/replay.d.ts +32 -0
  43. package/dist/session/replay.js +81 -0
  44. package/dist/session/states.d.ts +28 -0
  45. package/dist/session/states.js +33 -0
  46. package/dist/session/usage.d.ts +43 -0
  47. package/dist/session/usage.js +38 -0
  48. package/dist/testing/deterministic.d.ts +65 -0
  49. package/dist/testing/deterministic.js +150 -0
  50. package/dist/tools/policy.d.ts +47 -0
  51. package/dist/tools/policy.js +84 -0
  52. package/dist/tools/registry.d.ts +69 -0
  53. package/dist/tools/registry.js +75 -0
  54. package/dist/tools/validate.d.ts +24 -0
  55. package/dist/tools/validate.js +80 -0
  56. package/package.json +59 -0
  57. package/src/adapters/local.ts +64 -0
  58. package/src/adapters/proxy.ts +187 -0
  59. package/src/bridges/authority.ts +244 -0
  60. package/src/bridges/navigation.ts +207 -0
  61. package/src/budget/budget.ts +73 -0
  62. package/src/command/apply.ts +52 -0
  63. package/src/command/log.ts +81 -0
  64. package/src/context/assemble.ts +104 -0
  65. package/src/context/continuation.ts +39 -0
  66. package/src/describe/manifest.ts +148 -0
  67. package/src/entities/context.ts +112 -0
  68. package/src/index.ts +94 -0
  69. package/src/policy/types.ts +70 -0
  70. package/src/policy/utility.ts +53 -0
  71. package/src/provider/create.ts +70 -0
  72. package/src/provider/latency.ts +57 -0
  73. package/src/provider/types.ts +96 -0
  74. package/src/realtime/session.ts +63 -0
  75. package/src/session/agent.ts +622 -0
  76. package/src/session/replay.ts +96 -0
  77. package/src/session/states.ts +63 -0
  78. package/src/session/usage.ts +66 -0
  79. package/src/testing/deterministic.ts +204 -0
  80. package/src/tools/policy.ts +114 -0
  81. package/src/tools/registry.ts +122 -0
  82. package/src/tools/validate.ts +92 -0
@@ -0,0 +1,506 @@
1
+ import { MessageQueue } from '@driftengine/core';
2
+ import { LatencyEstimator } from '../provider/latency.js';
3
+ import { hasKnownExtent, UNKNOWN_EXTENT } from '../policy/types.js';
4
+ import { chargeUsage, createUsage, noteAbort, notePreemption } from './usage.js';
5
+ import { continuationPreamble } from '../context/continuation.js';
6
+ import { nextState } from './states.js';
7
+ const WHILE_BUSY = ['coalesce', 'preempt', 'drop'];
8
+ /**
9
+ * One agent: a floor that always runs, and one slot holding what comes next.
10
+ *
11
+ * **The current slot is never empty.** When an intent finishes, the buffer drains into
12
+ * it; when the buffer is empty, the policy floor supplies. An agent with no provider
13
+ * configured behaves exactly as one whose provider is slow — less cleverly, and
14
+ * without ever standing still.
15
+ *
16
+ * `tick` is the whole surface a consumer needs inside its fixed step. It returns the
17
+ * intent to execute and never returns null.
18
+ */
19
+ export class AgentSession {
20
+ agentId;
21
+ policy;
22
+ provider;
23
+ budget;
24
+ model;
25
+ tools;
26
+ whileBusy;
27
+ observations;
28
+ log;
29
+ maxIntentMs;
30
+ interruptProvider;
31
+ world;
32
+ latency = new LatencyEstimator();
33
+ usageState = createUsage();
34
+ agentState = 'idle';
35
+ currentIntent = null;
36
+ bufferedIntent = null;
37
+ startedAtMs = 0;
38
+ disposedReason = '';
39
+ inFlight = null;
40
+ inFlightAt = 0;
41
+ /** When the pending response's request went out, or -1 when nothing has landed. */
42
+ landedAtMs = -1;
43
+ discarded = 0;
44
+ issuedAtTick = 0;
45
+ inFlightGeneration = 0;
46
+ /*
47
+ * One context object, mutated rather than rebuilt. The floor is called every tick
48
+ * for every agent, and a fresh context per call would put an allocation on the one
49
+ * path this package promises has none.
50
+ */
51
+ context = { tick: 0, agentId: '', elapsedMs: 0 };
52
+ constructor(options) {
53
+ this.agentId = options.agentId;
54
+ this.policy = options.policy;
55
+ this.provider = options.provider ?? null;
56
+ this.budget = options.budget ?? null;
57
+ this.model = options.model ?? 'default';
58
+ this.tools = options.tools ?? null;
59
+ this.world = options.world;
60
+ this.log = options.log ?? null;
61
+ this.maxIntentMs = options.maxIntentMs ?? Number.POSITIVE_INFINITY;
62
+ this.interruptProvider = options.interruptProvider ?? null;
63
+ this.context.agentId = options.agentId;
64
+ const whileBusy = options.whileBusy ?? 'coalesce';
65
+ if (!WHILE_BUSY.includes(whileBusy)) {
66
+ throw new Error(`whileBusy must be one of ${WHILE_BUSY.join(', ')} — there is only ever one ` +
67
+ `request in flight, so "queue" and "replace pending" name no distinct behaviour`);
68
+ }
69
+ this.whileBusy = whileBusy;
70
+ /* R7: backpressure is `MessageQueue`. Its dedupe window collapses a repeat, its
71
+ ceiling drops the least important rather than the oldest, and its priority
72
+ ordering is what a preemption compares against. Writing a second priority queue
73
+ with dedupe and a ceiling, in a package that depends on the one that has one, is
74
+ the trap that reversal was written against. */
75
+ this.observations = new MessageQueue({
76
+ maxPending: options.maxPendingObservations ?? 4,
77
+ dedupeWindowMs: options.dedupeWindowMs ?? 900,
78
+ });
79
+ }
80
+ /**
81
+ * Something happened that the agent may care about.
82
+ *
83
+ * Never acted on here — an observation taken mid-tick would mutate the simulation
84
+ * halfway through a step. It waits for the next `tick`.
85
+ */
86
+ observe(observation) {
87
+ if (this.disposedReason !== '')
88
+ return;
89
+ this.observations.push({ ...observation, durationMs: 0 });
90
+ }
91
+ get state() {
92
+ return this.agentState;
93
+ }
94
+ /** Never null once `tick` has run once. */
95
+ get current() {
96
+ return this.currentIntent;
97
+ }
98
+ get buffered() {
99
+ return this.bufferedIntent;
100
+ }
101
+ get usage() {
102
+ return this.usageState;
103
+ }
104
+ /**
105
+ * Advance one fixed step and return the intent to execute.
106
+ *
107
+ * Never returns null. That is the track's whole claim and it is asserted directly.
108
+ */
109
+ tick(tickNumber, nowMs) {
110
+ if (this.disposedReason !== '') {
111
+ throw new Error(`agent "${this.agentId}" was disposed: ${this.disposedReason}`);
112
+ }
113
+ /*
114
+ * The context is stamped before anything reads it. It was stamped after `take` in
115
+ * the first draft, so the floor scored every decision against the *previous* tick
116
+ * and a recorded command carried the previous tick as the one it was accepted on —
117
+ * which put every replayed model intent one step early.
118
+ */
119
+ this.context.tick = tickNumber;
120
+ this.context.elapsedMs = nowMs - this.startedAtMs;
121
+ this.recordLanding(nowMs);
122
+ this.budget?.charge(this.usageState, nowMs);
123
+ this.drainObservations(nowMs);
124
+ if (this.currentIntent === null)
125
+ this.take(nowMs);
126
+ else if (this.finished(nowMs))
127
+ this.finish(nowMs);
128
+ this.context.elapsedMs = nowMs - this.startedAtMs;
129
+ this.maybeContinue(nowMs);
130
+ const current = this.currentIntent;
131
+ if (current === null)
132
+ throw new Error('unreachable: take() always sets an intent');
133
+ return current;
134
+ }
135
+ /** The consumer says the current intent is over, ahead of its expected extent. */
136
+ complete(nowMs) {
137
+ if (this.currentIntent === null)
138
+ return;
139
+ this.finish(nowMs);
140
+ }
141
+ /**
142
+ * End the current intent and fill the slot again.
143
+ *
144
+ * **Aborts a request that is still in flight.** `ahead -> intentCompleted -> idle`
145
+ * abandons the question, and a request nobody will read is a request that should
146
+ * stop costing money — but more than that, leaving it open means the next intent
147
+ * issues a second one and two are outstanding at once, which is the guarantee the
148
+ * state machine is shaped to make impossible. The state machine cannot see the
149
+ * provider; this is where the two are kept in agreement.
150
+ */
151
+ finish(nowMs) {
152
+ if (this.agentState === 'ahead')
153
+ this.abortInFlight();
154
+ this.transition('intentCompleted');
155
+ this.take(nowMs);
156
+ }
157
+ dispose(reason = 'disposed') {
158
+ this.disposedReason = reason;
159
+ this.abortInFlight();
160
+ this.bufferedIntent = null;
161
+ }
162
+ /**
163
+ * Fill the current slot: from the buffer if something is there, from the floor if not.
164
+ *
165
+ * The order matters and only in one direction — a buffered intent is a model's
166
+ * answer to the question the floor would otherwise be answering, so it wins when it
167
+ * exists. There is no case where the floor should override a fresh proposal, because
168
+ * a proposal the floor should override is one the admission guards discard.
169
+ */
170
+ take(nowMs) {
171
+ const buffered = this.bufferedIntent;
172
+ if (buffered !== null) {
173
+ this.bufferedIntent = null;
174
+ if (this.admits(buffered)) {
175
+ this.currentIntent = buffered;
176
+ this.startedAtMs = nowMs;
177
+ this.record(buffered);
178
+ if (this.agentState === 'ready')
179
+ this.transition('intentCompleted');
180
+ return;
181
+ }
182
+ /* Discarded, never deferred, and never retried. It falls through to the floor
183
+ on this same tick, which is why a failed guard costs quality rather than
184
+ motion. */
185
+ if (this.agentState === 'ready')
186
+ this.transition('intentCompleted');
187
+ this.discarded++;
188
+ }
189
+ this.context.elapsedMs = nowMs - this.startedAtMs;
190
+ this.currentIntent = this.policy.select(this.context);
191
+ this.startedAtMs = nowMs;
192
+ this.transition('floorSupplied');
193
+ }
194
+ /**
195
+ * Whether a buffered intent is still true of the world.
196
+ *
197
+ * Composed from the guards the *tools* declared, never from anything the model
198
+ * wrote. With no registry configured every intent admits: a consumer that has not
199
+ * described its world cannot have its plans checked against it, and pretending
200
+ * otherwise would be a check that always passes wearing the shape of one that means
201
+ * something.
202
+ */
203
+ admits(intent) {
204
+ const tools = this.tools;
205
+ if (tools === null)
206
+ return true;
207
+ return tools.admits(intent.toolIds, intent.args, this.world);
208
+ }
209
+ /**
210
+ * Take the most important observation waiting, and act on it if it outranks now.
211
+ *
212
+ * At the tick boundary, never mid-tick. Nothing mutates the simulation halfway
213
+ * through a step, which is the boundary §33 of the parent design already runs.
214
+ */
215
+ drainObservations(nowMs) {
216
+ const observation = this.observations.update(nowMs);
217
+ if (observation === null)
218
+ return;
219
+ if (this.whileBusy === 'drop' && this.agentState === 'ahead')
220
+ return;
221
+ if (this.whileBusy !== 'preempt')
222
+ return;
223
+ const current = this.currentIntent;
224
+ /* Strictly greater. A tie is not an interruption — otherwise every routine
225
+ observation of the same importance would restart the behaviour it belongs to. */
226
+ if (current !== null && observation.priority <= current.priority)
227
+ return;
228
+ this.preempt(nowMs);
229
+ }
230
+ /**
231
+ * Throw away the current intent, the buffer and the request, and let the floor cover.
232
+ *
233
+ * *Cost, stated:* one in-flight request is discarded. That is the token price of
234
+ * responsiveness, bounded by the preemption rate, which the consumer sets through
235
+ * priorities — so `usage.preemptedRequests` reports it. A number nobody reports is a
236
+ * number nobody tunes.
237
+ */
238
+ preempt(nowMs) {
239
+ if (this.inFlight !== null) {
240
+ notePreemption(this.usageState);
241
+ this.abortInFlight();
242
+ }
243
+ this.bufferedIntent = null;
244
+ this.currentIntent = null;
245
+ /* Recorded, because an observation is external input and nothing in the simulation
246
+ derives it. A replay that reran the floor past this moment would diverge from
247
+ here on, and every later decision would be wrong for one reason. */
248
+ const mark = {
249
+ kind: 'preemption',
250
+ agentId: this.agentId,
251
+ acceptedAtTick: this.context.tick,
252
+ };
253
+ this.log?.record(mark);
254
+ this.transition('preempted');
255
+ this.take(nowMs);
256
+ /*
257
+ * The interrupt question is about *now*, so its answer goes into the current slot
258
+ * rather than the buffer. The floor is already running by the time it lands, which
259
+ * is what makes a slow answer harmless rather than a hole.
260
+ */
261
+ if (this.interruptProvider !== null)
262
+ this.askInterrupt(nowMs);
263
+ }
264
+ askInterrupt(nowMs) {
265
+ const provider = this.interruptProvider;
266
+ if (provider === null)
267
+ return;
268
+ if (this.budget?.exhausted === true)
269
+ return;
270
+ const session = provider.createSession({ model: this.model });
271
+ const generation = ++this.inFlightGeneration;
272
+ this.inFlight = session;
273
+ this.inFlightAt = nowMs;
274
+ this.issuedAtTick = this.context.tick;
275
+ this.usageState.requests++;
276
+ /*
277
+ * The interrupt *is* the request in flight. Without this the session would fall
278
+ * through to `maybeContinue` on the same tick, issue a continuation, bump the
279
+ * generation, and drop the interrupt's answer as stale — two requests out, and
280
+ * the faster one thrown away.
281
+ */
282
+ this.transition('continuationIssued');
283
+ void this.consumeInterrupt(session.run({
284
+ preamble: 'Something just happened. What should this agent do right now?',
285
+ context: { capturedAtTick: this.context.tick },
286
+ toolIds: this.tools?.ids() ?? [],
287
+ signal: new AbortController().signal,
288
+ }), generation);
289
+ }
290
+ async consumeInterrupt(events, generation) {
291
+ const toolIds = [];
292
+ const args = [];
293
+ let landed = false;
294
+ for await (const event of events) {
295
+ chargeUsage(this.usageState, event);
296
+ if (event.kind === 'toolCall') {
297
+ toolIds.push(event.toolId);
298
+ args.push(event.args);
299
+ }
300
+ if (event.kind === 'done') {
301
+ landed = event.reason !== 'aborted';
302
+ break;
303
+ }
304
+ }
305
+ if (generation !== this.inFlightGeneration)
306
+ return;
307
+ this.inFlight = null;
308
+ /* Out of `ahead` whatever the answer was. A refused or empty interrupt that left
309
+ the session there would mean no continuation could ever be issued again — the
310
+ agent would run on its floor for the rest of the session and nothing would say
311
+ why. */
312
+ this.transition('responseLanded');
313
+ this.transition('intentCompleted');
314
+ if (!landed || toolIds.length === 0)
315
+ return;
316
+ const intent = {
317
+ id: `interrupt:${this.issuedAtTick}`,
318
+ priority: 90,
319
+ toolIds,
320
+ args,
321
+ expectedExtentMs: UNKNOWN_EXTENT,
322
+ source: 'model',
323
+ };
324
+ if (!this.admits(intent))
325
+ return;
326
+ this.currentIntent = intent;
327
+ this.record(intent);
328
+ }
329
+ /**
330
+ * Write an admitted model intent into the command log.
331
+ *
332
+ * Only model intents. The floor is deterministic and recomputes identically on
333
+ * replay, so recording its decisions would store what can be derived — and a
334
+ * thousand-tick recording would be a thousand entries instead of a handful.
335
+ */
336
+ record(intent) {
337
+ const log = this.log;
338
+ if (log === null || intent.source !== 'model')
339
+ return;
340
+ for (let i = 0; i < intent.toolIds.length; i++) {
341
+ const toolId = intent.toolIds[i];
342
+ if (toolId === undefined)
343
+ continue;
344
+ const command = {
345
+ kind: 'command',
346
+ toolId,
347
+ args: intent.args[i],
348
+ agentId: this.agentId,
349
+ issuedAtTick: this.issuedAtTick,
350
+ acceptedAtTick: this.context.tick,
351
+ };
352
+ log.record(command);
353
+ }
354
+ }
355
+ /** True once a budget is exhausted: still moving, no longer asking. */
356
+ get degraded() {
357
+ return this.budget?.exhausted === true;
358
+ }
359
+ /** The budget's own sentence, or empty while nothing is exhausted. */
360
+ get degradedReason() {
361
+ if (this.budget?.exhausted !== true)
362
+ return '';
363
+ return `agent ${this.agentId}: ${this.budget.reason} — running on policy floor, 0 requests in flight`;
364
+ }
365
+ /** Buffered intents discarded because their guard had gone false. */
366
+ get discardedIntents() {
367
+ return this.discarded;
368
+ }
369
+ finished(nowMs) {
370
+ const current = this.currentIntent;
371
+ if (current === null)
372
+ return false;
373
+ const elapsed = nowMs - this.startedAtMs;
374
+ if (elapsed >= this.maxIntentMs)
375
+ return true;
376
+ if (!hasKnownExtent(current))
377
+ return false;
378
+ return elapsed >= current.expectedExtentMs;
379
+ }
380
+ /**
381
+ * Issue the continuation, if this is the tick to issue it on.
382
+ *
383
+ * The watermark itself is `leadMs`. This only decides whether the state machine
384
+ * has an edge available, which is where one-request-in-flight actually lives.
385
+ */
386
+ maybeContinue(nowMs) {
387
+ if (this.provider === null)
388
+ return;
389
+ if (this.budget?.exhausted === true)
390
+ return;
391
+ if (nextState(this.agentState, 'continuationIssued') === null)
392
+ return;
393
+ const current = this.currentIntent;
394
+ if (current === null)
395
+ return;
396
+ const lead = this.leadMs;
397
+ if (hasKnownExtent(current)) {
398
+ const remaining = current.expectedExtentMs - (nowMs - this.startedAtMs);
399
+ if (lead >= 0 && remaining > lead)
400
+ return;
401
+ }
402
+ this.issue(nowMs);
403
+ }
404
+ /** The lead the watermark uses: the provider's measured p90, or -1 before it knows. */
405
+ get leadMs() {
406
+ return this.latency.p90;
407
+ }
408
+ /**
409
+ * Charge the latency of a response, measured to the tick that observed it.
410
+ *
411
+ * A response lands in a microtask, where there is no simulation clock to read. So
412
+ * the landing is flagged and priced on the next `tick`, which measures the latency a
413
+ * fixed-step consumer *experiences* rather than the one a wall clock would report.
414
+ * Those differ by up to one step, and the one that matters for deciding when to ask
415
+ * again is this one.
416
+ *
417
+ * *What it costs:* a response landing just after a tick is charged nearly a whole
418
+ * step more than it took. *What would make it wrong:* a consumer stepping far more
419
+ * slowly than its provider answers, where a step's rounding would dominate the
420
+ * measurement — at which point the session needs a clock rather than a flag.
421
+ */
422
+ recordLanding(nowMs) {
423
+ if (this.landedAtMs < 0)
424
+ return;
425
+ this.latency.record(Math.max(0, nowMs - this.landedAtMs));
426
+ this.usageState.latencyMsP90 = Math.max(0, this.latency.p90);
427
+ this.landedAtMs = -1;
428
+ }
429
+ issue(nowMs) {
430
+ const provider = this.provider;
431
+ if (provider === null)
432
+ return;
433
+ const session = provider.createSession({ model: this.model });
434
+ const controller = new AbortController();
435
+ const generation = ++this.inFlightGeneration;
436
+ this.inFlight = session;
437
+ this.inFlightAt = nowMs;
438
+ this.issuedAtTick = this.context.tick;
439
+ this.usageState.requests++;
440
+ this.transition('continuationIssued');
441
+ /* A first request, from `idle`, has no "currently" to describe — the agent is
442
+ being asked what to do now. A continuation, from `running`, must say when its
443
+ answer will be used or the model answers about the wrong moment. */
444
+ const current = this.currentIntent;
445
+ const remaining = current === null ? 0 : current.expectedExtentMs - (nowMs - this.startedAtMs);
446
+ const preamble = current === null ? '' : continuationPreamble(current, remaining);
447
+ void this.consume(session.run({
448
+ preamble,
449
+ context: { capturedAtTick: this.context.tick },
450
+ toolIds: this.tools?.ids() ?? [],
451
+ signal: controller.signal,
452
+ }), generation);
453
+ }
454
+ async consume(events, generation) {
455
+ const toolIds = [];
456
+ const args = [];
457
+ let landed = false;
458
+ for await (const event of events) {
459
+ chargeUsage(this.usageState, event);
460
+ if (event.kind === 'toolCall') {
461
+ toolIds.push(event.toolId);
462
+ args.push(event.args);
463
+ }
464
+ if (event.kind === 'done') {
465
+ landed = event.reason !== 'aborted';
466
+ break;
467
+ }
468
+ }
469
+ /* A response for a request the session has moved past. Dropping it is the point:
470
+ `ahead -> intentCompleted -> idle` abandoned this question, and buffering the
471
+ answer would put a stale intent in the slot behind a fresh one. */
472
+ if (generation !== this.inFlightGeneration)
473
+ return;
474
+ this.inFlight = null;
475
+ if (!landed)
476
+ return;
477
+ /* Priced on the next tick, where there is a simulation clock to read. */
478
+ this.landedAtMs = this.inFlightAt;
479
+ /* Named by the tick the model was asked, not by a generation counter, because that
480
+ is the one identifier the command log preserves — so a replayed intent carries
481
+ the same name as the recorded one without the log having to store it. */
482
+ this.bufferedIntent = {
483
+ id: `model:${this.issuedAtTick}`,
484
+ priority: 50,
485
+ toolIds,
486
+ args,
487
+ expectedExtentMs: UNKNOWN_EXTENT,
488
+ source: 'model',
489
+ };
490
+ this.transition('responseLanded');
491
+ }
492
+ abortInFlight() {
493
+ const session = this.inFlight;
494
+ if (session === null)
495
+ return;
496
+ this.inFlightGeneration++;
497
+ this.inFlight = null;
498
+ noteAbort(this.usageState);
499
+ session.abort('aborted');
500
+ }
501
+ transition(name) {
502
+ const next = nextState(this.agentState, name);
503
+ if (next !== null)
504
+ this.agentState = next;
505
+ }
506
+ }
@@ -0,0 +1,32 @@
1
+ import type { AgentPolicy, Intent } from '../policy/types.ts';
2
+ import type { LogEntry } from '../command/log.ts';
3
+ export interface ReplaySource {
4
+ at(tick: number, out: LogEntry[]): number;
5
+ }
6
+ /**
7
+ * Replay accepted commands at their original boundaries. Calls no provider, ever.
8
+ *
9
+ * **A buffered agent replays exactly**, and the floor is why. Model decisions are
10
+ * replayed from the log; floor decisions are *recomputed*, because the floor is
11
+ * deterministic and runs inside the simulation. So the log carries only what could not
12
+ * be derived, and a thousand-tick recording is a handful of entries rather than a
13
+ * thousand.
14
+ *
15
+ * The design that waited could not promise this. There, the *timing* of a response was
16
+ * itself part of the behaviour — an agent stood still for however long the provider
17
+ * took — and timing is the one thing a live provider will not reproduce.
18
+ */
19
+ export declare class ReplaySession {
20
+ private readonly source;
21
+ private readonly policy;
22
+ private readonly scratch;
23
+ private readonly context;
24
+ private currentIntent;
25
+ private startedAtMs;
26
+ private replayed;
27
+ constructor(source: ReplaySource, policy: AgentPolicy, agentId: string);
28
+ get current(): Intent | null;
29
+ /** Model intents taken from the log rather than recomputed. */
30
+ get replayedIntents(): number;
31
+ tick(tickNumber: number, nowMs: number): Intent;
32
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Replay accepted commands at their original boundaries. Calls no provider, ever.
3
+ *
4
+ * **A buffered agent replays exactly**, and the floor is why. Model decisions are
5
+ * replayed from the log; floor decisions are *recomputed*, because the floor is
6
+ * deterministic and runs inside the simulation. So the log carries only what could not
7
+ * be derived, and a thousand-tick recording is a handful of entries rather than a
8
+ * thousand.
9
+ *
10
+ * The design that waited could not promise this. There, the *timing* of a response was
11
+ * itself part of the behaviour — an agent stood still for however long the provider
12
+ * took — and timing is the one thing a live provider will not reproduce.
13
+ */
14
+ export class ReplaySession {
15
+ source;
16
+ policy;
17
+ scratch = [];
18
+ context = { tick: 0, agentId: '', elapsedMs: 0 };
19
+ currentIntent = null;
20
+ startedAtMs = 0;
21
+ replayed = 0;
22
+ constructor(source, policy, agentId) {
23
+ this.source = source;
24
+ this.policy = policy;
25
+ this.context.agentId = agentId;
26
+ }
27
+ get current() {
28
+ return this.currentIntent;
29
+ }
30
+ /** Model intents taken from the log rather than recomputed. */
31
+ get replayedIntents() {
32
+ return this.replayed;
33
+ }
34
+ tick(tickNumber, nowMs) {
35
+ this.context.tick = tickNumber;
36
+ this.context.elapsedMs = nowMs - this.startedAtMs;
37
+ const count = this.source.at(tickNumber, this.scratch);
38
+ const toolIds = [];
39
+ const args = [];
40
+ let issuedAt = tickNumber;
41
+ let preempted = false;
42
+ for (let i = 0; i < count; i++) {
43
+ const entry = this.scratch[i];
44
+ if (entry === undefined)
45
+ continue;
46
+ if (entry.kind === 'preemption') {
47
+ preempted = true;
48
+ continue;
49
+ }
50
+ toolIds.push(entry.toolId);
51
+ args.push(entry.args);
52
+ issuedAt = entry.issuedAtTick;
53
+ }
54
+ if (preempted) {
55
+ /* The intent was cut short by external input. Force the floor to choose again
56
+ on this tick, exactly as the recording did. */
57
+ this.currentIntent = null;
58
+ }
59
+ if (toolIds.length > 0) {
60
+ this.replayed++;
61
+ this.currentIntent = {
62
+ id: `model:${issuedAt}`,
63
+ priority: 50,
64
+ toolIds,
65
+ args,
66
+ expectedExtentMs: -1,
67
+ source: 'model',
68
+ };
69
+ this.startedAtMs = nowMs;
70
+ return this.currentIntent;
71
+ }
72
+ const current = this.currentIntent;
73
+ if (current !== null && current.expectedExtentMs < 0)
74
+ return current;
75
+ if (current !== null && nowMs - this.startedAtMs < current.expectedExtentMs)
76
+ return current;
77
+ this.currentIntent = this.policy.select(this.context);
78
+ this.startedAtMs = nowMs;
79
+ return this.currentIntent;
80
+ }
81
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Four states, and a second request that has no edge to arrive on.
3
+ *
4
+ * ```text
5
+ * intent completes, buffer empty
6
+ * ┌───────────────────────────────────────┐
7
+ * ▼ │
8
+ * IDLE ── floor supplies ──▶ RUNNING ───────┤
9
+ * │ │
10
+ * remaining ≈ p90 latency │
11
+ * ▼ │
12
+ * AHEAD │
13
+ * │ │
14
+ * response lands │
15
+ * ▼ │
16
+ * READY ────────┘
17
+ * intent completes,
18
+ * buffer drains
19
+ * ```
20
+ *
21
+ * **`continuationIssued` is legal only from `running`.** That is the whole of the
22
+ * one-request-in-flight guarantee: a counter can be violated by any path that forgets
23
+ * to check it, where an edge that does not exist cannot be taken.
24
+ */
25
+ export type AgentState = 'idle' | 'running' | 'ahead' | 'ready';
26
+ export type AgentTransition = 'floorSupplied' | 'continuationIssued' | 'responseLanded' | 'intentCompleted' | 'preempted';
27
+ /** The next state, or `null` when the transition is illegal from here. */
28
+ export declare function nextState(state: AgentState, transition: AgentTransition): AgentState | null;
@@ -0,0 +1,33 @@
1
+ const TABLE = {
2
+ idle: {
3
+ floorSupplied: 'running',
4
+ preempted: 'idle',
5
+ },
6
+ running: {
7
+ continuationIssued: 'ahead',
8
+ /* Nothing is buffered and no request is out, so the next intent has to come from
9
+ the floor — which is what `idle` means here: not "doing nothing", but "owing
10
+ the floor a decision on the next tick". */
11
+ intentCompleted: 'idle',
12
+ preempted: 'idle',
13
+ },
14
+ ahead: {
15
+ responseLanded: 'ready',
16
+ /*
17
+ * To `idle`, not to `ready`. The response has not landed, so the floor supplies
18
+ * the next intent — and the request is still outstanding against an intent that
19
+ * is now over. The session abandons it rather than buffering an answer to a
20
+ * question nobody is asking any more.
21
+ */
22
+ intentCompleted: 'idle',
23
+ preempted: 'idle',
24
+ },
25
+ ready: {
26
+ intentCompleted: 'running',
27
+ preempted: 'idle',
28
+ },
29
+ };
30
+ /** The next state, or `null` when the transition is illegal from here. */
31
+ export function nextState(state, transition) {
32
+ return TABLE[state][transition] ?? null;
33
+ }