@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +103 -0
- package/dist/adapters/local.d.ts +29 -0
- package/dist/adapters/local.js +24 -0
- package/dist/adapters/proxy.d.ts +28 -0
- package/dist/adapters/proxy.js +138 -0
- package/dist/bridges/authority.d.ts +153 -0
- package/dist/bridges/authority.js +179 -0
- package/dist/bridges/navigation.d.ts +100 -0
- package/dist/bridges/navigation.js +139 -0
- package/dist/budget/budget.d.ts +34 -0
- package/dist/budget/budget.js +57 -0
- package/dist/command/apply.d.ts +24 -0
- package/dist/command/apply.js +40 -0
- package/dist/command/log.d.ts +55 -0
- package/dist/command/log.js +50 -0
- package/dist/context/assemble.d.ts +48 -0
- package/dist/context/assemble.js +55 -0
- package/dist/context/continuation.d.ts +14 -0
- package/dist/context/continuation.js +36 -0
- package/dist/describe/manifest.d.ts +70 -0
- package/dist/describe/manifest.js +99 -0
- package/dist/entities/context.d.ts +52 -0
- package/dist/entities/context.js +83 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +40 -0
- package/dist/policy/types.d.ts +55 -0
- package/dist/policy/types.js +26 -0
- package/dist/policy/utility.d.ts +18 -0
- package/dist/policy/utility.js +47 -0
- package/dist/provider/create.d.ts +16 -0
- package/dist/provider/create.js +57 -0
- package/dist/provider/latency.d.ts +27 -0
- package/dist/provider/latency.js +52 -0
- package/dist/provider/types.d.ts +90 -0
- package/dist/provider/types.js +8 -0
- package/dist/realtime/session.d.ts +35 -0
- package/dist/realtime/session.js +34 -0
- package/dist/session/agent.d.ts +217 -0
- package/dist/session/agent.js +506 -0
- package/dist/session/replay.d.ts +32 -0
- package/dist/session/replay.js +81 -0
- package/dist/session/states.d.ts +28 -0
- package/dist/session/states.js +33 -0
- package/dist/session/usage.d.ts +43 -0
- package/dist/session/usage.js +38 -0
- package/dist/testing/deterministic.d.ts +65 -0
- package/dist/testing/deterministic.js +150 -0
- package/dist/tools/policy.d.ts +47 -0
- package/dist/tools/policy.js +84 -0
- package/dist/tools/registry.d.ts +69 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/validate.d.ts +24 -0
- package/dist/tools/validate.js +80 -0
- package/package.json +59 -0
- package/src/adapters/local.ts +64 -0
- package/src/adapters/proxy.ts +187 -0
- package/src/bridges/authority.ts +244 -0
- package/src/bridges/navigation.ts +207 -0
- package/src/budget/budget.ts +73 -0
- package/src/command/apply.ts +52 -0
- package/src/command/log.ts +81 -0
- package/src/context/assemble.ts +104 -0
- package/src/context/continuation.ts +39 -0
- package/src/describe/manifest.ts +148 -0
- package/src/entities/context.ts +112 -0
- package/src/index.ts +94 -0
- package/src/policy/types.ts +70 -0
- package/src/policy/utility.ts +53 -0
- package/src/provider/create.ts +70 -0
- package/src/provider/latency.ts +57 -0
- package/src/provider/types.ts +96 -0
- package/src/realtime/session.ts +63 -0
- package/src/session/agent.ts +622 -0
- package/src/session/replay.ts +96 -0
- package/src/session/states.ts +63 -0
- package/src/session/usage.ts +66 -0
- package/src/testing/deterministic.ts +204 -0
- package/src/tools/policy.ts +114 -0
- package/src/tools/registry.ts +122 -0
- 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
|
+
}
|