@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,207 @@
1
+ /**
2
+ * An agent that can be asked to go somewhere, and a guard that knows whether it still can.
3
+ *
4
+ * **This is the first tool in this package whose guard says something.** A buffered intent is a
5
+ * proposal authored against one snapshot and executed later, and until now the strongest `admits`
6
+ * a consumer could write was "the entity still exists". Navigation can do better: a bridge that
7
+ * dropped, a door that closed, a region that streamed out all make a destination unreachable
8
+ * between the moment a model chose it and the moment the agent acts on it. `navigate@1` runs the
9
+ * search in its guard and the intent is discarded, with the policy floor covering — which is what
10
+ * the package already promises for every other kind of stale proposal.
11
+ *
12
+ * **What the engine owns here is the graph and the search; what it does not own is the agent.**
13
+ * `tools/registry.ts` says a guard is "the consumer's sentence, written against the consumer's
14
+ * world", and that stays true: the three functions of `NavigationAdapter` are the consumer's, and
15
+ * everything above them is this file's. A tool that knew which entity was which, or how an agent
16
+ * moves, would be a particular game's engine.
17
+ *
18
+ * ## What it costs
19
+ *
20
+ * **Three searches per navigation intent that is accepted and applied**, and the third was a
21
+ * correction: this header said two until the demo was wired and the real path counted. `take` runs
22
+ * the guard when the buffered intent drains, `applyCommand` runs it **again** on the way in — which
23
+ * `apply.ts` argues for at length, because those are two different moments and a snapshot is never
24
+ * authority — and `execute` runs a third.
25
+ *
26
+ * Caching a route between any two of them would key it on nothing stable, since the world changing
27
+ * between them is the entire reason the guard exists. A guard that trusted the previous answer
28
+ * would be the guard `apply.ts` refuses to be.
29
+ *
30
+ * `nearestNavNode` is a linear scan over `nodeCount`, and both endpoints need one, so a navigation
31
+ * intent is `O(nodes)` six times plus three A*. At the sizes these graphs are built at that is tens
32
+ * of microseconds against an intent that happens on the order of once a second per agent. It is
33
+ * written down here so a consumer profiling a thousand agents knows where to look rather than
34
+ * discovering it.
35
+ */
36
+
37
+ import { NavSearch, nearestNavNode } from '@driftengine/core';
38
+ import type { NavGraph, NavPath } from '@driftengine/core';
39
+ import type { ToolDefinition } from '../tools/registry.ts';
40
+
41
+ /** What the consumer answers, because only the consumer knows what an agent is. */
42
+ export interface NavigationAdapter<W> {
43
+ /** The graph this agent navigates. A world may have several. Null means it cannot navigate. */
44
+ graphOf(world: W, agentId: string): NavGraph | null;
45
+ /** Where the agent is now, written into `out` as x, y, z. False when the agent is gone. */
46
+ positionOf(world: W, agentId: string, out: Float32Array): boolean;
47
+ /**
48
+ * The route object this agent follows.
49
+ *
50
+ * The consumer's and not the bridge's, deliberately: they already own its lifetime, its capacity
51
+ * and its steering options, and a second one owned here would be a second answer to where the
52
+ * agent is going.
53
+ */
54
+ pathOf(world: W, agentId: string): NavPath | null;
55
+ }
56
+
57
+ export interface NavigationBridgeOptions {
58
+ /**
59
+ * How far a destination may be from the nearest graph node and still count as that node.
60
+ *
61
+ * Infinite by default, which snaps to the nearest node however far away it is. A consumer with a
62
+ * sparse graph over a large world wants a real number here: without one, "go to the roof" routes
63
+ * to the nearest node on the ground and the agent walks confidently to the wrong place.
64
+ */
65
+ readonly snapDistance?: number;
66
+ /** Longest route the bridge will hold, in nodes. */
67
+ readonly maxNodes?: number;
68
+ }
69
+
70
+ export interface NavigateArgs {
71
+ readonly agentId: string;
72
+ readonly x: number;
73
+ readonly y: number;
74
+ readonly z: number;
75
+ }
76
+
77
+ export interface NavigateResult {
78
+ readonly found: boolean;
79
+ /** Nodes in the route, or 0. */
80
+ readonly nodes: number;
81
+ /**
82
+ * Route length in metres, or 0.
83
+ *
84
+ * Here because `Intent.expectedExtentMs` drives the continuation watermark, and a navigation
85
+ * intent is the one case where the engine knows the extent better than the policy that proposed
86
+ * it: a caller with a speed can turn this into a time. Ignoring it costs nothing.
87
+ */
88
+ readonly lengthM: number;
89
+ }
90
+
91
+ const NO_ROUTE: NavigateResult = { found: false, nodes: 0, lengthM: 0 };
92
+
93
+ /**
94
+ * Build the navigation tools for a consumer's world.
95
+ *
96
+ * Named `navigationBridge` deliberately: `docs/CAPABILITIES.md` carries a sentinel matching this
97
+ * symbol, so the day this file exists the documentation gate fails and the row that calls this
98
+ * capability absent has to be rewritten. A bridge named something else would have landed the
99
+ * capability and left the guard quiet, which is worse than the stale prose it replaces.
100
+ */
101
+ export function navigationBridge<W>(
102
+ adapter: NavigationAdapter<W>,
103
+ options: NavigationBridgeOptions = {},
104
+ ): readonly ToolDefinition<NavigateArgs, NavigateResult, W>[] {
105
+ const snap = options.snapDistance ?? Infinity;
106
+ const maxNodes = Math.max(2, options.maxNodes ?? 512);
107
+
108
+ /*
109
+ * One scratch route and one search per graph, reused across every call.
110
+ *
111
+ * `NavSearch` allocates five arrays sized by the graph, so building one per intent would be an
112
+ * allocation proportional to the world on a path the fixed step reaches. Keyed by graph because a
113
+ * world may have several and a search is bound to the one it was built for.
114
+ */
115
+ const route = new Uint32Array(maxNodes);
116
+ const here = new Float32Array(3);
117
+ const searches = new WeakMap<NavGraph, NavSearch>();
118
+
119
+ const searchFor = (graph: NavGraph): NavSearch => {
120
+ let search = searches.get(graph);
121
+ if (search === undefined) {
122
+ search = new NavSearch(graph);
123
+ searches.set(graph, search);
124
+ }
125
+ return search;
126
+ };
127
+
128
+ /**
129
+ * The route this request would produce, or 0 nodes.
130
+ *
131
+ * Shared by the guard and the action so they cannot disagree about what "reachable" means. They
132
+ * each call it once, which is the two searches the header prices.
133
+ */
134
+ const solve = (world: W, args: NavigateArgs): { graph: NavGraph; count: number } | null => {
135
+ const graph = adapter.graphOf(world, args.agentId);
136
+ if (graph === null) return null;
137
+ if (!adapter.positionOf(world, args.agentId, here)) return null;
138
+
139
+ const from = nearestNavNode(graph, here[0] ?? 0, here[1] ?? 0, here[2] ?? 0, snap);
140
+ if (from < 0) return null;
141
+ const to = nearestNavNode(graph, args.x, args.y, args.z, snap);
142
+ if (to < 0) return null;
143
+
144
+ return { graph, count: searchFor(graph).find(from, to, route) };
145
+ };
146
+
147
+ const navigate: ToolDefinition<NavigateArgs, NavigateResult, W> = {
148
+ id: 'navigate@1',
149
+ description:
150
+ 'Move an agent to a world position along the navigation graph. Fails when no route exists.',
151
+ schema: {
152
+ kind: 'object',
153
+ fields: {
154
+ agentId: { kind: 'string' },
155
+ x: { kind: 'number' },
156
+ y: { kind: 'number' },
157
+ z: { kind: 'number' },
158
+ },
159
+ },
160
+ /*
161
+ * **Not idempotent, and the distinction is not pedantic.** Calling it twice re-runs the search
162
+ * against wherever the agent has moved to, which is a different route from the same arguments.
163
+ */
164
+ idempotent: false,
165
+ rateClass: 'navigation',
166
+
167
+ admits(args: NavigateArgs, world: W): boolean {
168
+ const solved = solve(world, args);
169
+ return solved !== null && solved.count > 1;
170
+ },
171
+
172
+ execute(args: NavigateArgs, world: W): NavigateResult {
173
+ const path = adapter.pathOf(world, args.agentId);
174
+ if (path === null) return NO_ROUTE;
175
+ const solved = solve(world, args);
176
+ if (solved === null || solved.count < 2) {
177
+ /*
178
+ * Cleared rather than left alone. A failed navigate that leaves the previous route in place
179
+ * is an agent that keeps walking to somewhere nobody asked for any more, which reads as the
180
+ * tool having worked.
181
+ */
182
+ path.clear();
183
+ return NO_ROUTE;
184
+ }
185
+ path.set(route, solved.count);
186
+ return { found: true, nodes: solved.count, lengthM: path.lengthM };
187
+ },
188
+ };
189
+
190
+ return [navigate];
191
+ }
192
+
193
+ /**
194
+ * Whether a route exists for this agent right now, without writing one.
195
+ *
196
+ * The guard, as a question. `navigate@1` answers it as a side effect of doing the thing, so a
197
+ * caller wanting to *ask* had to route and then undo. `drift/ai.reachable` is this, and so is any
198
+ * consumer's own policy that wants to score a destination before proposing it.
199
+ */
200
+ export function reachableBy<W>(
201
+ tools: readonly ToolDefinition<NavigateArgs, NavigateResult, W>[],
202
+ args: NavigateArgs,
203
+ world: W,
204
+ ): boolean {
205
+ const navigate = tools.find((tool) => tool.id === 'navigate@1');
206
+ return navigate !== undefined && navigate.admits(args, world);
207
+ }
@@ -0,0 +1,73 @@
1
+ import type { AiUsage } from '../session/usage.ts';
2
+
3
+ export interface BudgetLimits {
4
+ readonly inputTokens?: number;
5
+ readonly outputTokens?: number;
6
+ readonly requests?: number;
7
+ readonly costMicros?: number;
8
+ readonly wallMs?: number;
9
+ }
10
+
11
+ const COUNTED = ['inputTokens', 'outputTokens', 'requests', 'costMicros'] as const;
12
+
13
+ /**
14
+ * A ceiling on what an agent may spend, enforced here rather than asked for in a prompt.
15
+ *
16
+ * **Exhaustion degrades.** `charge` past a limit returns normally and latches
17
+ * `exhausted`; the session stops issuing continuations and the policy floor keeps
18
+ * producing intents. Throwing would put an exception inside a fixed step, which is
19
+ * worse than the overspend it prevents.
20
+ *
21
+ * *What it costs:* an agent that goes over is quietly less capable, and a consumer
22
+ * that never reads `reason` will not know why. *What would make it wrong:* if a
23
+ * consumer needs to know at the moment it happens rather than by polling, this needs
24
+ * a callback — deliberately not added until something asks, because a callback firing
25
+ * inside a fixed step has the same hazard the throw did.
26
+ */
27
+ export class Budget {
28
+ private readonly limits: BudgetLimits;
29
+ private latched = false;
30
+ private latchedReason = '';
31
+ private startedAtMs = -1;
32
+
33
+ constructor(limits: BudgetLimits) {
34
+ this.limits = limits;
35
+ }
36
+
37
+ get exhausted(): boolean {
38
+ return this.latched;
39
+ }
40
+
41
+ /** The sentence a consumer reports. Empty while not exhausted. */
42
+ get reason(): string {
43
+ return this.latchedReason;
44
+ }
45
+
46
+ charge(usage: Readonly<AiUsage>, nowMs: number): void {
47
+ if (this.startedAtMs < 0) this.startedAtMs = nowMs;
48
+ if (this.latched) return;
49
+
50
+ for (const key of COUNTED) {
51
+ const limit = this.limits[key];
52
+ if (limit !== undefined && usage[key] > limit) {
53
+ this.latch(key, limit, usage[key]);
54
+ return;
55
+ }
56
+ }
57
+
58
+ const wall = this.limits.wallMs;
59
+ if (wall !== undefined && nowMs - this.startedAtMs > wall) {
60
+ this.latch('wallMs', wall, nowMs - this.startedAtMs);
61
+ }
62
+ }
63
+
64
+ /*
65
+ * Latched rather than recomputed. A budget derived from current usage comes back to
66
+ * life the moment a consumer resets a counter, and the agent resumes spending
67
+ * against a limit somebody has already hit.
68
+ */
69
+ private latch(name: string, limit: number, actual: number): void {
70
+ this.latched = true;
71
+ this.latchedReason = `budget exhausted: ${name} limit ${limit}, reached ${actual}`;
72
+ }
73
+ }
@@ -0,0 +1,52 @@
1
+ import type { ToolRegistry } from '../tools/registry.ts';
2
+ import type { AiCommand } from './log.ts';
3
+
4
+ export type ApplyOutcome =
5
+ { readonly ok: true; readonly result: unknown } | { readonly ok: false; readonly reason: string };
6
+
7
+ /**
8
+ * Run an accepted command at a tick boundary, revalidating on the way in.
9
+ *
10
+ * The tool's own guard ran once already, when the buffered intent drained. It runs
11
+ * **again** here, because those are two different moments and a snapshot is never
12
+ * authority — the buffer widens the gap on purpose, and admission proves the plan was
13
+ * true when it was taken up rather than that it is true now.
14
+ *
15
+ * Not the execution policy, which ran at acceptance. Running that twice would charge
16
+ * the rate limit twice for one call.
17
+ *
18
+ * **Returns on every path.** A caller inside `fixedUpdate` never needs a `try`, and a
19
+ * consumer's tool throwing must not become an exception halfway through a tick.
20
+ */
21
+ export function applyCommand<W>(
22
+ registry: ToolRegistry<W>,
23
+ world: W,
24
+ command: AiCommand,
25
+ _tick: number,
26
+ ): ApplyOutcome {
27
+ const tool = registry.get(command.toolId);
28
+ if (tool === undefined) {
29
+ return { ok: false, reason: `unknown tool "${command.toolId}" — it is not registered` };
30
+ }
31
+
32
+ let admitted: boolean;
33
+ try {
34
+ admitted = tool.admits(command.args as never, world);
35
+ } catch {
36
+ return { ok: false, reason: `the guard for "${command.toolId}" threw, so the call is refused` };
37
+ }
38
+
39
+ if (!admitted) {
40
+ return {
41
+ ok: false,
42
+ reason: `"${command.toolId}" no longer admits: the world changed between acceptance and application`,
43
+ };
44
+ }
45
+
46
+ try {
47
+ return { ok: true, result: tool.execute(command.args as never, world) };
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ return { ok: false, reason: `"${command.toolId}" failed while running: ${message}` };
51
+ }
52
+ }
@@ -0,0 +1,81 @@
1
+ export interface AiCommand {
2
+ readonly kind: 'command';
3
+ readonly toolId: string;
4
+ readonly args: unknown;
5
+ readonly agentId: string;
6
+ /** The tick the model was asked. */
7
+ readonly issuedAtTick: number;
8
+ /** The tick the command crossed into the simulation. */
9
+ readonly acceptedAtTick: number;
10
+ }
11
+
12
+ /**
13
+ * An observation that ended the current intent early.
14
+ *
15
+ * Recorded for the same reason a command is: it cannot be recomputed. The floor is
16
+ * deterministic and replays by rerunning, but an observation is *external input* —
17
+ * the same class of thing as a keypress — and nothing in the simulation derives it.
18
+ * Without this, a replay reruns the floor past the moment it was interrupted and
19
+ * diverges from there on.
20
+ */
21
+ export interface AiPreemption {
22
+ readonly kind: 'preemption';
23
+ readonly agentId: string;
24
+ readonly acceptedAtTick: number;
25
+ }
26
+
27
+ export type LogEntry = AiCommand | AiPreemption;
28
+
29
+ /**
30
+ * What was accepted, and when — beside `TickTrace` rather than inside it.
31
+ *
32
+ * `TickTrace` is `Float32Array` channels plus a `Uint8Array` of flags, sampled per
33
+ * tick. A command carries a string id and structured arguments, and boxing those into
34
+ * float channels is not a design, it is a defeat. This borrows the ring-buffer
35
+ * discipline and the tick indexing and shares nothing else, which answers the question
36
+ * §33 of the parent design left open for AI-2.
37
+ *
38
+ * *What it costs:* two ring buffers where a reader might expect one place to look.
39
+ * *What would make it wrong:* if a consumer needed one ordered stream of both, this
40
+ * would need a merge read rather than a merged store — the storage shapes have no
41
+ * common representation worth finding.
42
+ */
43
+ export class CommandLog {
44
+ private readonly entries: (LogEntry | undefined)[];
45
+ private cursor = 0;
46
+ private filled = 0;
47
+
48
+ constructor(capacity = 256) {
49
+ this.entries = new Array<LogEntry | undefined>(Math.max(1, capacity | 0));
50
+ }
51
+
52
+ /** How many commands are still retained, not how many were ever recorded. */
53
+ get length(): number {
54
+ return this.filled;
55
+ }
56
+
57
+ record(entry: LogEntry): void {
58
+ this.entries[this.cursor] = entry;
59
+ this.cursor = (this.cursor + 1) % this.entries.length;
60
+ if (this.filled < this.entries.length) this.filled++;
61
+ }
62
+
63
+ /**
64
+ * Commands accepted at exactly this tick, in acceptance order, written into `out`.
65
+ *
66
+ * Returns the count written. Reading a tick allocates nothing, because a replay
67
+ * reads every tick and a per-tick array would put an allocation on that path.
68
+ */
69
+ at(tick: number, out: LogEntry[]): number {
70
+ let written = 0;
71
+ const size = this.entries.length;
72
+ const start = this.filled < size ? 0 : this.cursor;
73
+
74
+ for (let i = 0; i < this.filled; i++) {
75
+ const entry = this.entries[(start + i) % size];
76
+ if (entry === undefined || entry.acceptedAtTick !== tick) continue;
77
+ out[written++] = entry;
78
+ }
79
+ return written;
80
+ }
81
+ }
@@ -0,0 +1,104 @@
1
+ import type { ToolSchema } from '../tools/registry.ts';
2
+
3
+ /**
4
+ * A read-only snapshot the model is told about.
5
+ *
6
+ * Context and tools are separate surfaces and the separation improves behaviour:
7
+ * nearby objects are context, inspecting one in detail is a tool, moving is a tool.
8
+ */
9
+ export interface ContextProvider<T> {
10
+ /** Stable and versioned, like a tool id: `project.visible@5`. */
11
+ readonly id: string;
12
+ readonly priority: number;
13
+ readonly maxItems?: number;
14
+ describe(): { readonly title: string; readonly schema: ToolSchema };
15
+ sample(subject: string): T;
16
+ }
17
+
18
+ export interface ContextSection {
19
+ readonly id: string;
20
+ readonly title: string;
21
+ readonly items: unknown;
22
+ readonly estimatedTokens: number;
23
+ /** True when `maxItems` cut the sample, so a reader can tell short from truncated. */
24
+ readonly truncated: boolean;
25
+ }
26
+
27
+ export interface AssembledContext {
28
+ readonly capturedAtTick: number;
29
+ readonly sections: readonly ContextSection[];
30
+ readonly estimatedTokens: number;
31
+ /**
32
+ * Section ids left out, and why they are named rather than merely absent.
33
+ *
34
+ * A budget that silently truncates reads as coverage. A model told nothing about
35
+ * nearby objects behaves as though there are none, and a consumer reading a trace
36
+ * cannot tell that from a world that was empty.
37
+ */
38
+ readonly dropped: readonly string[];
39
+ }
40
+
41
+ /**
42
+ * Never send the scene graph.
43
+ *
44
+ * Sections are taken in descending priority until the budget is spent, and what did
45
+ * not fit is named. The estimate is deliberately crude — a provider-specific tokenizer
46
+ * may sharpen it, but requiring one would make the core abstraction depend on whichever
47
+ * provider happened to be configured.
48
+ */
49
+ export function assembleContext(
50
+ providers: readonly ContextProvider<unknown>[],
51
+ subject: string,
52
+ tick: number,
53
+ tokenBudget: number,
54
+ ): AssembledContext {
55
+ const ordered = [...providers].sort((a, b) => b.priority - a.priority);
56
+ const sections: ContextSection[] = [];
57
+ const dropped: string[] = [];
58
+ let spent = 0;
59
+
60
+ for (const provider of ordered) {
61
+ let items: unknown;
62
+ let truncated = false;
63
+ try {
64
+ items = provider.sample(subject);
65
+ } catch {
66
+ /* One bad context provider must not silence an agent. It is named in `dropped`,
67
+ which is the difference between a section that failed and one that was empty. */
68
+ dropped.push(provider.id);
69
+ continue;
70
+ }
71
+
72
+ const limit = provider.maxItems;
73
+ if (limit !== undefined && Array.isArray(items) && items.length > limit) {
74
+ items = items.slice(0, limit);
75
+ truncated = true;
76
+ }
77
+
78
+ const cost = estimateTokens(items);
79
+ if (spent + cost > tokenBudget) {
80
+ dropped.push(provider.id);
81
+ continue;
82
+ }
83
+
84
+ spent += cost;
85
+ sections.push({
86
+ id: provider.id,
87
+ title: provider.describe().title,
88
+ items,
89
+ estimatedTokens: cost,
90
+ truncated,
91
+ });
92
+ }
93
+
94
+ return { capturedAtTick: tick, sections, estimatedTokens: spent, dropped };
95
+ }
96
+
97
+ /** Four characters to a token, which is close enough to budget with and cheap to compute. */
98
+ function estimateTokens(value: unknown): number {
99
+ try {
100
+ return Math.ceil(JSON.stringify(value ?? null).length / 4);
101
+ } catch {
102
+ return 0;
103
+ }
104
+ }
@@ -0,0 +1,39 @@
1
+ import { hasKnownExtent } from '../policy/types.ts';
2
+ import type { Intent } from '../policy/types.ts';
3
+
4
+ /**
5
+ * Tell the model when its answer will be used.
6
+ *
7
+ * Three lines. Without them the model answers as though acting immediately, proposes
8
+ * what is right now and wrong in a second and a half, and the lookahead becomes lag
9
+ * that is still paid for. The whole value of asking early is lost to not saying so.
10
+ *
11
+ * *What it costs:* three lines of every continuation's context budget. *What would
12
+ * make it wrong:* a provider that ignores framing of this kind entirely, at which
13
+ * point the lead should shrink toward zero rather than the preamble being dropped —
14
+ * the fix for a model that cannot reason about the future is to stop asking it to.
15
+ */
16
+ export function continuationPreamble(current: Intent, remainingMs: number): string {
17
+ const doing = describeIntent(current);
18
+ const when = hasKnownExtent(current)
19
+ ? `~${(Math.max(0, remainingMs) / 1000).toFixed(1)}s after this snapshot`
20
+ : 'unknown — this action has no predictable duration';
21
+
22
+ return [
23
+ `You are currently: ${doing}`,
24
+ `Expected to finish: ${when}`,
25
+ `Your answer applies: when that finishes, not now`,
26
+ ].join('\n');
27
+ }
28
+
29
+ /**
30
+ * What the agent is doing, in the model's terms.
31
+ *
32
+ * A floor intent is described exactly like a model intent. Telling the model the
33
+ * engine improvised would invite it to treat the current behaviour as provisional,
34
+ * and the floor's choice is as real as any other — it is what the agent is doing.
35
+ */
36
+ function describeIntent(intent: Intent): string {
37
+ if (intent.toolIds.length === 0) return `${intent.id} (no tool calls)`;
38
+ return `${intent.id} (${intent.toolIds.join(', ')})`;
39
+ }