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