@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,96 @@
1
+ import type { AgentPolicy, Intent, PolicyContext } from '../policy/types.ts';
2
+ import type { LogEntry } from '../command/log.ts';
3
+
4
+ export interface ReplaySource {
5
+ at(tick: number, out: LogEntry[]): number;
6
+ }
7
+
8
+ /**
9
+ * Replay accepted commands at their original boundaries. Calls no provider, ever.
10
+ *
11
+ * **A buffered agent replays exactly**, and the floor is why. Model decisions are
12
+ * replayed from the log; floor decisions are *recomputed*, because the floor is
13
+ * deterministic and runs inside the simulation. So the log carries only what could not
14
+ * be derived, and a thousand-tick recording is a handful of entries rather than a
15
+ * thousand.
16
+ *
17
+ * The design that waited could not promise this. There, the *timing* of a response was
18
+ * itself part of the behaviour — an agent stood still for however long the provider
19
+ * took — and timing is the one thing a live provider will not reproduce.
20
+ */
21
+ export class ReplaySession {
22
+ private readonly source: ReplaySource;
23
+ private readonly policy: AgentPolicy;
24
+ private readonly scratch: LogEntry[] = [];
25
+ private readonly context = { tick: 0, agentId: '', elapsedMs: 0 };
26
+
27
+ private currentIntent: Intent | null = null;
28
+ private startedAtMs = 0;
29
+ private replayed = 0;
30
+
31
+ constructor(source: ReplaySource, policy: AgentPolicy, agentId: string) {
32
+ this.source = source;
33
+ this.policy = policy;
34
+ this.context.agentId = agentId;
35
+ }
36
+
37
+ get current(): Intent | null {
38
+ return this.currentIntent;
39
+ }
40
+
41
+ /** Model intents taken from the log rather than recomputed. */
42
+ get replayedIntents(): number {
43
+ return this.replayed;
44
+ }
45
+
46
+ tick(tickNumber: number, nowMs: number): Intent {
47
+ this.context.tick = tickNumber;
48
+ this.context.elapsedMs = nowMs - this.startedAtMs;
49
+
50
+ const count = this.source.at(tickNumber, this.scratch);
51
+ const toolIds: string[] = [];
52
+ const args: unknown[] = [];
53
+ let issuedAt = tickNumber;
54
+ let preempted = false;
55
+
56
+ for (let i = 0; i < count; i++) {
57
+ const entry = this.scratch[i];
58
+ if (entry === undefined) continue;
59
+ if (entry.kind === 'preemption') {
60
+ preempted = true;
61
+ continue;
62
+ }
63
+ toolIds.push(entry.toolId);
64
+ args.push(entry.args);
65
+ issuedAt = entry.issuedAtTick;
66
+ }
67
+
68
+ if (preempted) {
69
+ /* The intent was cut short by external input. Force the floor to choose again
70
+ on this tick, exactly as the recording did. */
71
+ this.currentIntent = null;
72
+ }
73
+
74
+ if (toolIds.length > 0) {
75
+ this.replayed++;
76
+ this.currentIntent = {
77
+ id: `model:${issuedAt}`,
78
+ priority: 50,
79
+ toolIds,
80
+ args,
81
+ expectedExtentMs: -1,
82
+ source: 'model',
83
+ };
84
+ this.startedAtMs = nowMs;
85
+ return this.currentIntent;
86
+ }
87
+
88
+ const current = this.currentIntent;
89
+ if (current !== null && current.expectedExtentMs < 0) return current;
90
+ if (current !== null && nowMs - this.startedAtMs < current.expectedExtentMs) return current;
91
+
92
+ this.currentIntent = this.policy.select(this.context as PolicyContext);
93
+ this.startedAtMs = nowMs;
94
+ return this.currentIntent;
95
+ }
96
+ }
@@ -0,0 +1,63 @@
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
+
27
+ export type AgentTransition =
28
+ 'floorSupplied' | 'continuationIssued' | 'responseLanded' | 'intentCompleted' | 'preempted';
29
+
30
+ const TABLE: Readonly<Record<AgentState, Partial<Record<AgentTransition, AgentState>>>> = {
31
+ idle: {
32
+ floorSupplied: 'running',
33
+ preempted: 'idle',
34
+ },
35
+ running: {
36
+ continuationIssued: 'ahead',
37
+ /* Nothing is buffered and no request is out, so the next intent has to come from
38
+ the floor — which is what `idle` means here: not "doing nothing", but "owing
39
+ the floor a decision on the next tick". */
40
+ intentCompleted: 'idle',
41
+ preempted: 'idle',
42
+ },
43
+ ahead: {
44
+ responseLanded: 'ready',
45
+ /*
46
+ * To `idle`, not to `ready`. The response has not landed, so the floor supplies
47
+ * the next intent — and the request is still outstanding against an intent that
48
+ * is now over. The session abandons it rather than buffering an answer to a
49
+ * question nobody is asking any more.
50
+ */
51
+ intentCompleted: 'idle',
52
+ preempted: 'idle',
53
+ },
54
+ ready: {
55
+ intentCompleted: 'running',
56
+ preempted: 'idle',
57
+ },
58
+ };
59
+
60
+ /** The next state, or `null` when the transition is illegal from here. */
61
+ export function nextState(state: AgentState, transition: AgentTransition): AgentState | null {
62
+ return TABLE[state][transition] ?? null;
63
+ }
@@ -0,0 +1,66 @@
1
+ import type { AiEvent } from '../provider/types.ts';
2
+
3
+ /**
4
+ * What an agent has spent.
5
+ *
6
+ * Mutated in place. A session charges per event, and allocating a usage object per
7
+ * event would put an allocation on the path a streaming response takes hundreds of
8
+ * times per request.
9
+ */
10
+ export interface AiUsage {
11
+ inputTokens: number;
12
+ outputTokens: number;
13
+ requests: number;
14
+ /**
15
+ * Requests aborted because a higher-priority observation arrived.
16
+ *
17
+ * The token price of responsiveness, and the number a consumer tunes its priority
18
+ * thresholds against. Separate from `abortedRequests` because disposal and hot
19
+ * reload also abort, and no threshold a consumer can set changes those.
20
+ */
21
+ preemptedRequests: number;
22
+ abortedRequests: number;
23
+ costMicros: number;
24
+ latencyMsP90: number;
25
+ }
26
+
27
+ export function createUsage(): AiUsage {
28
+ return {
29
+ inputTokens: 0,
30
+ outputTokens: 0,
31
+ requests: 0,
32
+ preemptedRequests: 0,
33
+ abortedRequests: 0,
34
+ costMicros: 0,
35
+ latencyMsP90: 0,
36
+ };
37
+ }
38
+
39
+ /** Tokens only. Who aborted, and why, is something the session knows and an event does not. */
40
+ export function chargeUsage(usage: AiUsage, event: AiEvent): void {
41
+ if (event.kind !== 'usage') return;
42
+ usage.inputTokens += event.inputTokens;
43
+ usage.outputTokens += event.outputTokens;
44
+ }
45
+
46
+ /**
47
+ * Record an abort, whatever caused it.
48
+ *
49
+ * Counted where the session aborts rather than where the `done` event arrives. Reading
50
+ * it off the event double-counts every abort the session also has a reason for, and an
51
+ * aborted request produces a `done` *and* a decision — one abort, two places that
52
+ * could increment.
53
+ */
54
+ export function noteAbort(usage: AiUsage): void {
55
+ usage.abortedRequests++;
56
+ }
57
+
58
+ /**
59
+ * Record that the abort being taken is a preemption.
60
+ *
61
+ * Only the narrow counter. `noteAbort` moves the broad one, so a preemption increments
62
+ * both by going through both — and neither function has to know what the other did.
63
+ */
64
+ export function notePreemption(usage: AiUsage): void {
65
+ usage.preemptedRequests++;
66
+ }
@@ -0,0 +1,204 @@
1
+ import type {
2
+ AiEvent,
3
+ AiProvider,
4
+ AiProviderCapabilities,
5
+ AiRequest,
6
+ AiSession,
7
+ AiSessionOptions,
8
+ } from '../provider/types.ts';
9
+
10
+ /**
11
+ * A provider that answers after a stated number of ticks, or never.
12
+ *
13
+ * **This is not a mock capability provider.** R1 withdrew mocks standing in for engine
14
+ * capabilities that did not exist, on the reasoning that a mock is a second
15
+ * implementation of a contract with no first implementation to check it against. This
16
+ * stands in for a *provider* — a thing that is genuinely remote, genuinely slow and
17
+ * genuinely variable — and it exists so that timing can be asserted rather than
18
+ * observed.
19
+ *
20
+ * **Nothing here runs on a real timer.** A response is due at an absolute tick, and a
21
+ * tick only happens when a caller says so. Every timing property Track O claims is
22
+ * measured against this, and on a real clock each of those measurements would be a
23
+ * race that passes on a fast machine.
24
+ *
25
+ * *What it costs:* a test must remember to call `advance`, and one that forgets sees
26
+ * an agent that never gets an answer. *What would make it wrong:* if a provider adapter
27
+ * ever needed wall-clock behaviour to be exercised — a retry backoff, say — this could
28
+ * not exercise it, and that adapter would need its own harness rather than a change here.
29
+ */
30
+ export interface DeterministicScript {
31
+ /** Ticks between the request and its first event. `'never'` answers nothing, ever. */
32
+ readonly latencyTicks: number | 'never';
33
+ readonly events: readonly AiEvent[];
34
+ }
35
+
36
+ interface Pending {
37
+ dueTick: number;
38
+ events: readonly AiEvent[];
39
+ settled: boolean;
40
+ outcome: 'due' | 'aborted' | null;
41
+ message: string;
42
+ wake: (() => void) | null;
43
+ }
44
+
45
+ const CAPABILITIES: AiProviderCapabilities = {
46
+ text: true,
47
+ streamingText: true,
48
+ structuredOutput: true,
49
+ toolCalling: true,
50
+ realtimeAudio: false,
51
+ imageInput: false,
52
+ local: true,
53
+ };
54
+
55
+ export class DeterministicProvider implements AiProvider {
56
+ readonly id = 'deterministic';
57
+ readonly capabilities = CAPABILITIES;
58
+
59
+ /*
60
+ * Reused with a count rather than emptied. `array.length = 0` makes V8 re-grow the
61
+ * backing store, which is a per-iteration allocation in loops this provider is
62
+ * deliberately driven through ten thousand times.
63
+ */
64
+ private readonly pending: Pending[] = [];
65
+ private pendingCount = 0;
66
+
67
+ private readonly script: (requestIndex: number) => DeterministicScript;
68
+ private latencyOverride: number | 'never' | null = null;
69
+
70
+ private now = 0;
71
+ private requests = 0;
72
+ private aborted = 0;
73
+ private live = 0;
74
+ private peak = 0;
75
+
76
+ constructor(script: DeterministicScript | ((requestIndex: number) => DeterministicScript)) {
77
+ this.script = typeof script === 'function' ? script : () => script;
78
+ }
79
+
80
+ get requestCount(): number {
81
+ return this.requests;
82
+ }
83
+
84
+ get abortedCount(): number {
85
+ return this.aborted;
86
+ }
87
+
88
+ /** A high-water mark, never a current count. */
89
+ get peakConcurrency(): number {
90
+ return this.peak;
91
+ }
92
+
93
+ /** Applies to requests made after this call, never to one already in flight. */
94
+ setLatencyTicks(ticks: number | 'never'): void {
95
+ this.latencyOverride = ticks;
96
+ }
97
+
98
+ /** Drives the fake clock. Nothing resolves without it. */
99
+ advance(tick: number): void {
100
+ this.now = tick;
101
+ for (let i = 0; i < this.pendingCount; i++) {
102
+ const entry = this.pending[i];
103
+ if (entry === undefined || entry.settled) continue;
104
+ if (entry.dueTick > tick) continue;
105
+ entry.settled = true;
106
+ entry.outcome = 'due';
107
+ this.live--;
108
+ entry.wake?.();
109
+ }
110
+ }
111
+
112
+ createSession(_options: AiSessionOptions): AiSession {
113
+ const owned: Pending[] = [];
114
+
115
+ return {
116
+ run: (request: AiRequest): AsyncIterable<AiEvent> => {
117
+ const entry = this.begin(request);
118
+ owned.push(entry);
119
+ return this.drain(entry);
120
+ },
121
+ abort: (reason?: string): void => {
122
+ for (const entry of owned) this.settleAborted(entry, reason ?? 'aborted');
123
+ },
124
+ };
125
+ }
126
+
127
+ /**
128
+ * Registered eagerly, before the generator's body runs.
129
+ *
130
+ * An async generator does not execute until its first `next()`, so building the
131
+ * entry inside `drain` would leave `peakConcurrency` reading zero for a request that
132
+ * had been made — the exact number the one-in-flight tests are asserting against.
133
+ *
134
+ * It is released when the request *settles*, not when the generator is drained. A
135
+ * request that has been answered or aborted is no longer outstanding whether or not
136
+ * its consumer has read it yet, and several fixed steps can run in one frame with no
137
+ * microtask between them — which made an aborted request go on counting.
138
+ */
139
+ private begin(request: AiRequest): Pending {
140
+ const script = this.script(this.requests);
141
+ this.requests++;
142
+
143
+ const latency = this.latencyOverride ?? script.latencyTicks;
144
+ const entry: Pending = {
145
+ dueTick: latency === 'never' ? Number.POSITIVE_INFINITY : this.now + latency,
146
+ events: script.events,
147
+ settled: false,
148
+ outcome: null,
149
+ message: '',
150
+ wake: null,
151
+ };
152
+
153
+ if (this.pendingCount < this.pending.length) this.pending[this.pendingCount] = entry;
154
+ else this.pending.push(entry);
155
+ this.pendingCount++;
156
+
157
+ this.live++;
158
+ if (this.live > this.peak) this.peak = this.live;
159
+
160
+ if (request.signal.aborted) this.settleAborted(entry, 'signal');
161
+ else request.signal.addEventListener('abort', () => this.settleAborted(entry, 'signal'));
162
+
163
+ return entry;
164
+ }
165
+
166
+ private settleAborted(entry: Pending, message: string): void {
167
+ if (entry.settled) return;
168
+ entry.settled = true;
169
+ entry.outcome = 'aborted';
170
+ entry.message = message;
171
+ this.aborted++;
172
+ this.live--;
173
+ entry.wake?.();
174
+ }
175
+
176
+ private async *drain(entry: Pending): AsyncIterable<AiEvent> {
177
+ try {
178
+ if (!entry.settled) {
179
+ await new Promise<void>((resolve) => {
180
+ entry.wake = resolve;
181
+ });
182
+ }
183
+
184
+ if (entry.outcome === 'aborted') {
185
+ yield { kind: 'done', reason: 'aborted', message: entry.message };
186
+ return;
187
+ }
188
+
189
+ for (const event of entry.events) yield event;
190
+ } finally {
191
+ this.forget(entry);
192
+ }
193
+ }
194
+
195
+ private forget(entry: Pending): void {
196
+ for (let i = 0; i < this.pendingCount; i++) {
197
+ if (this.pending[i] !== entry) continue;
198
+ const last = this.pending[this.pendingCount - 1];
199
+ if (last !== undefined) this.pending[i] = last;
200
+ this.pendingCount--;
201
+ return;
202
+ }
203
+ }
204
+ }
@@ -0,0 +1,114 @@
1
+ import type { Budget } from '../budget/budget.ts';
2
+ import type { ToolRegistry } from './registry.ts';
3
+ import { validateArgs } from './validate.ts';
4
+
5
+ export interface ExecutionPolicy {
6
+ /** Tool ids this agent may call at all. Absent means every registered tool. */
7
+ readonly allow?: readonly string[];
8
+ /** Calls per rate class, per second. */
9
+ readonly rateLimits?: Readonly<Record<string, number>>;
10
+ /** Tools a human has to say yes to. Refused here rather than queued. */
11
+ readonly requireApproval?: readonly string[];
12
+ }
13
+
14
+ export type Admission = { readonly ok: true } | { readonly ok: false; readonly reason: string };
15
+
16
+ const OK: Admission = { ok: true };
17
+
18
+ /**
19
+ * Rate state, owned by whoever owns the agent.
20
+ *
21
+ * **Not module-level.** A shared map keyed by class name would make two unrelated
22
+ * agents share one limit, so a busy one would silence a quiet one — and worse, a test
23
+ * would carry state into the next test. The session owns one of these; the limit is
24
+ * per agent, which is the only scope a consumer can reason about.
25
+ *
26
+ * Timestamps live in a reused array with a count, because `array.length = 0` re-grows
27
+ * V8's backing store and this is touched on every accepted call.
28
+ */
29
+ export class RateWindows {
30
+ private readonly byClass = new Map<string, { at: number[]; count: number }>();
31
+
32
+ /** Records the call and reports whether it fits. */
33
+ admit(rateClass: string, limit: number, nowMs: number): boolean {
34
+ let window = this.byClass.get(rateClass);
35
+ if (window === undefined) {
36
+ window = { at: [], count: 0 };
37
+ this.byClass.set(rateClass, window);
38
+ }
39
+
40
+ let kept = 0;
41
+ for (let i = 0; i < window.count; i++) {
42
+ const stamp = window.at[i];
43
+ if (stamp !== undefined && nowMs - stamp < 1000) window.at[kept++] = stamp;
44
+ }
45
+ window.count = kept;
46
+
47
+ if (window.count >= limit) return false;
48
+
49
+ if (window.count < window.at.length) window.at[window.count] = nowMs;
50
+ else window.at.push(nowMs);
51
+ window.count++;
52
+ return true;
53
+ }
54
+
55
+ clear(): void {
56
+ this.byClass.clear();
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Whether a proposed tool call may be accepted.
62
+ *
63
+ * **The order is fixed and the first failure wins.** A call that is both unknown and
64
+ * over budget always reports that it is unknown, because a consumer chasing a message
65
+ * that changes between runs finds nothing wrong with either check. Order: exists,
66
+ * permitted, needs approval, arguments valid, within rate, within budget — cheapest
67
+ * and most specific first, so the message names the thing a reader can act on.
68
+ *
69
+ * This is the *acceptance* check. It does not run the tool's own `admits` guard: that
70
+ * runs at the tick boundary where the call is applied, because a snapshot is never
71
+ * authority and a buffered intent widens the gap between the two moments.
72
+ */
73
+ export function admitToolCall<W>(
74
+ registry: ToolRegistry<W>,
75
+ policy: ExecutionPolicy,
76
+ budget: Budget,
77
+ toolId: string,
78
+ args: unknown,
79
+ nowMs: number,
80
+ rates: RateWindows = new RateWindows(),
81
+ ): Admission {
82
+ const tool = registry.get(toolId);
83
+ if (tool === undefined) {
84
+ return { ok: false, reason: `unknown tool "${toolId}" — it is not registered` };
85
+ }
86
+
87
+ if (policy.allow !== undefined && !policy.allow.includes(toolId)) {
88
+ return { ok: false, reason: `tool "${toolId}" is not permitted by this agent's policy` };
89
+ }
90
+
91
+ if (policy.requireApproval?.includes(toolId) === true) {
92
+ return { ok: false, reason: `tool "${toolId}" requires approval, which was not given` };
93
+ }
94
+
95
+ const validation = validateArgs(tool.schema, args);
96
+ if (!validation.ok) {
97
+ return { ok: false, reason: `${validation.path}: ${validation.reason}` };
98
+ }
99
+
100
+ const rateClass = tool.rateClass;
101
+ const limit = rateClass === undefined ? undefined : policy.rateLimits?.[rateClass];
102
+ if (rateClass !== undefined && limit !== undefined) {
103
+ if (!rates.admit(rateClass, limit, nowMs)) {
104
+ return {
105
+ ok: false,
106
+ reason: `rate class "${rateClass}" is over its limit of ${limit} per second`,
107
+ };
108
+ }
109
+ }
110
+
111
+ if (budget.exhausted) return { ok: false, reason: budget.reason };
112
+
113
+ return OK;
114
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Tools an agent may call, and the guards that decide whether a proposal is still true.
3
+ *
4
+ * A tool schema is generated from the same contracts the language type-checks against,
5
+ * so it cannot drift from the implementation. That is what makes this different from
6
+ * writing a prompt that describes some functions.
7
+ */
8
+
9
+ /**
10
+ * What a model may be asked to produce.
11
+ *
12
+ * Schema-expressible types only. Anything a schema cannot describe cannot be asked
13
+ * for, validated, or recorded in a trace, and a tool taking one would be a tool whose
14
+ * arguments are checked by hope.
15
+ */
16
+ export type ToolSchema =
17
+ | { readonly kind: 'string' }
18
+ | { readonly kind: 'number' }
19
+ | { readonly kind: 'boolean' }
20
+ | { readonly kind: 'enum'; readonly values: readonly string[] }
21
+ | { readonly kind: 'array'; readonly of: ToolSchema }
22
+ | { readonly kind: 'object'; readonly fields: Readonly<Record<string, ToolSchema>> };
23
+
24
+ const EXPRESSIBLE = new Set(['string', 'number', 'boolean', 'enum', 'array', 'object']);
25
+
26
+ /**
27
+ * `W` is the consumer's world view, and this package never constrains it.
28
+ *
29
+ * An engine package that knew what a world contained would be an engine package that
30
+ * knew what game it was in. A guard asking `world.exists(id)` is the consumer's
31
+ * sentence, written against the consumer's world.
32
+ */
33
+ export interface ToolDefinition<A, R, W> {
34
+ /** Stable and versioned — `inspect@1`. Never renumbered once shipped. */
35
+ readonly id: string;
36
+ readonly description: string;
37
+ readonly schema: ToolSchema;
38
+ readonly rateClass?: string;
39
+ readonly idempotent?: boolean;
40
+ /**
41
+ * Whether this call is still true of the world.
42
+ *
43
+ * Declared once, at registration, by the tool. The model never writes one: a
44
+ * model-authored precondition on a model-authored action is the model marking its
45
+ * own homework.
46
+ */
47
+ admits(args: A, world: W): boolean;
48
+ execute(args: A, world: W): R;
49
+ }
50
+
51
+ export class ToolRegistry<W> {
52
+ private readonly byId = new Map<string, ToolDefinition<never, never, W>>();
53
+ private readonly order: string[] = [];
54
+
55
+ register<A, R>(tool: ToolDefinition<A, R, W>): void {
56
+ if (this.byId.has(tool.id)) {
57
+ throw new Error(`a tool is already registered as "${tool.id}"`);
58
+ }
59
+ if (!/@\d+$/.test(tool.id)) {
60
+ throw new Error(
61
+ `tool id "${tool.id}" carries no version — ids are versioned as "name@1" so a ` +
62
+ `recorded trace stays readable after the tool's shape changes`,
63
+ );
64
+ }
65
+
66
+ const bad = inexpressible(tool.schema, '');
67
+ if (bad !== null) {
68
+ throw new Error(`tool "${tool.id}" has a schema field a schema cannot express: ${bad}`);
69
+ }
70
+
71
+ this.byId.set(tool.id, tool as unknown as ToolDefinition<never, never, W>);
72
+ this.order.push(tool.id);
73
+ }
74
+
75
+ /** Registration order, so a prompt's tool list is stable and a trace stays comparable. */
76
+ ids(): readonly string[] {
77
+ return this.order;
78
+ }
79
+
80
+ get(id: string): ToolDefinition<never, never, W> | undefined {
81
+ return this.byId.get(id);
82
+ }
83
+
84
+ /**
85
+ * Whether every tool an intent names still admits its arguments.
86
+ *
87
+ * A plain loop rather than a `map` and an `every`: this runs at the moment a
88
+ * buffered intent drains, which is inside a fixed step.
89
+ */
90
+ admits(toolIds: readonly string[], args: readonly unknown[], world: W): boolean {
91
+ for (let i = 0; i < toolIds.length; i++) {
92
+ const id = toolIds[i];
93
+ if (id === undefined) return false;
94
+ const tool = this.byId.get(id);
95
+ if (tool === undefined) return false;
96
+ try {
97
+ if (!tool.admits(args[i] as never, world)) return false;
98
+ } catch {
99
+ /* A consumer bug in a guard must not become an exception inside a tick. It
100
+ becomes a refusal, the intent is discarded, and the policy floor covers. */
101
+ return false;
102
+ }
103
+ }
104
+ return true;
105
+ }
106
+ }
107
+
108
+ /** The dotted path of the first field no schema can express, or `null`. */
109
+ function inexpressible(schema: ToolSchema, path: string): string | null {
110
+ if (!EXPRESSIBLE.has(schema.kind)) return path === '' ? schema.kind : path;
111
+
112
+ if (schema.kind === 'array') return inexpressible(schema.of, path === '' ? 'of' : `${path}.of`);
113
+
114
+ if (schema.kind === 'object') {
115
+ for (const [name, field] of Object.entries(schema.fields)) {
116
+ const found = inexpressible(field, path === '' ? name : `${path}.${name}`);
117
+ if (found !== null) return found;
118
+ }
119
+ }
120
+
121
+ return null;
122
+ }