@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,43 @@
1
+ import type { AiEvent } from '../provider/types.ts';
2
+ /**
3
+ * What an agent has spent.
4
+ *
5
+ * Mutated in place. A session charges per event, and allocating a usage object per
6
+ * event would put an allocation on the path a streaming response takes hundreds of
7
+ * times per request.
8
+ */
9
+ export interface AiUsage {
10
+ inputTokens: number;
11
+ outputTokens: number;
12
+ requests: number;
13
+ /**
14
+ * Requests aborted because a higher-priority observation arrived.
15
+ *
16
+ * The token price of responsiveness, and the number a consumer tunes its priority
17
+ * thresholds against. Separate from `abortedRequests` because disposal and hot
18
+ * reload also abort, and no threshold a consumer can set changes those.
19
+ */
20
+ preemptedRequests: number;
21
+ abortedRequests: number;
22
+ costMicros: number;
23
+ latencyMsP90: number;
24
+ }
25
+ export declare function createUsage(): AiUsage;
26
+ /** Tokens only. Who aborted, and why, is something the session knows and an event does not. */
27
+ export declare function chargeUsage(usage: AiUsage, event: AiEvent): void;
28
+ /**
29
+ * Record an abort, whatever caused it.
30
+ *
31
+ * Counted where the session aborts rather than where the `done` event arrives. Reading
32
+ * it off the event double-counts every abort the session also has a reason for, and an
33
+ * aborted request produces a `done` *and* a decision — one abort, two places that
34
+ * could increment.
35
+ */
36
+ export declare function noteAbort(usage: AiUsage): void;
37
+ /**
38
+ * Record that the abort being taken is a preemption.
39
+ *
40
+ * Only the narrow counter. `noteAbort` moves the broad one, so a preemption increments
41
+ * both by going through both — and neither function has to know what the other did.
42
+ */
43
+ export declare function notePreemption(usage: AiUsage): void;
@@ -0,0 +1,38 @@
1
+ export function createUsage() {
2
+ return {
3
+ inputTokens: 0,
4
+ outputTokens: 0,
5
+ requests: 0,
6
+ preemptedRequests: 0,
7
+ abortedRequests: 0,
8
+ costMicros: 0,
9
+ latencyMsP90: 0,
10
+ };
11
+ }
12
+ /** Tokens only. Who aborted, and why, is something the session knows and an event does not. */
13
+ export function chargeUsage(usage, event) {
14
+ if (event.kind !== 'usage')
15
+ return;
16
+ usage.inputTokens += event.inputTokens;
17
+ usage.outputTokens += event.outputTokens;
18
+ }
19
+ /**
20
+ * Record an abort, whatever caused it.
21
+ *
22
+ * Counted where the session aborts rather than where the `done` event arrives. Reading
23
+ * it off the event double-counts every abort the session also has a reason for, and an
24
+ * aborted request produces a `done` *and* a decision — one abort, two places that
25
+ * could increment.
26
+ */
27
+ export function noteAbort(usage) {
28
+ usage.abortedRequests++;
29
+ }
30
+ /**
31
+ * Record that the abort being taken is a preemption.
32
+ *
33
+ * Only the narrow counter. `noteAbort` moves the broad one, so a preemption increments
34
+ * both by going through both — and neither function has to know what the other did.
35
+ */
36
+ export function notePreemption(usage) {
37
+ usage.preemptedRequests++;
38
+ }
@@ -0,0 +1,65 @@
1
+ import type { AiEvent, AiProvider, AiProviderCapabilities, AiSession, AiSessionOptions } from '../provider/types.ts';
2
+ /**
3
+ * A provider that answers after a stated number of ticks, or never.
4
+ *
5
+ * **This is not a mock capability provider.** R1 withdrew mocks standing in for engine
6
+ * capabilities that did not exist, on the reasoning that a mock is a second
7
+ * implementation of a contract with no first implementation to check it against. This
8
+ * stands in for a *provider* — a thing that is genuinely remote, genuinely slow and
9
+ * genuinely variable — and it exists so that timing can be asserted rather than
10
+ * observed.
11
+ *
12
+ * **Nothing here runs on a real timer.** A response is due at an absolute tick, and a
13
+ * tick only happens when a caller says so. Every timing property Track O claims is
14
+ * measured against this, and on a real clock each of those measurements would be a
15
+ * race that passes on a fast machine.
16
+ *
17
+ * *What it costs:* a test must remember to call `advance`, and one that forgets sees
18
+ * an agent that never gets an answer. *What would make it wrong:* if a provider adapter
19
+ * ever needed wall-clock behaviour to be exercised — a retry backoff, say — this could
20
+ * not exercise it, and that adapter would need its own harness rather than a change here.
21
+ */
22
+ export interface DeterministicScript {
23
+ /** Ticks between the request and its first event. `'never'` answers nothing, ever. */
24
+ readonly latencyTicks: number | 'never';
25
+ readonly events: readonly AiEvent[];
26
+ }
27
+ export declare class DeterministicProvider implements AiProvider {
28
+ readonly id = "deterministic";
29
+ readonly capabilities: AiProviderCapabilities;
30
+ private readonly pending;
31
+ private pendingCount;
32
+ private readonly script;
33
+ private latencyOverride;
34
+ private now;
35
+ private requests;
36
+ private aborted;
37
+ private live;
38
+ private peak;
39
+ constructor(script: DeterministicScript | ((requestIndex: number) => DeterministicScript));
40
+ get requestCount(): number;
41
+ get abortedCount(): number;
42
+ /** A high-water mark, never a current count. */
43
+ get peakConcurrency(): number;
44
+ /** Applies to requests made after this call, never to one already in flight. */
45
+ setLatencyTicks(ticks: number | 'never'): void;
46
+ /** Drives the fake clock. Nothing resolves without it. */
47
+ advance(tick: number): void;
48
+ createSession(_options: AiSessionOptions): AiSession;
49
+ /**
50
+ * Registered eagerly, before the generator's body runs.
51
+ *
52
+ * An async generator does not execute until its first `next()`, so building the
53
+ * entry inside `drain` would leave `peakConcurrency` reading zero for a request that
54
+ * had been made — the exact number the one-in-flight tests are asserting against.
55
+ *
56
+ * It is released when the request *settles*, not when the generator is drained. A
57
+ * request that has been answered or aborted is no longer outstanding whether or not
58
+ * its consumer has read it yet, and several fixed steps can run in one frame with no
59
+ * microtask between them — which made an aborted request go on counting.
60
+ */
61
+ private begin;
62
+ private settleAborted;
63
+ private drain;
64
+ private forget;
65
+ }
@@ -0,0 +1,150 @@
1
+ const CAPABILITIES = {
2
+ text: true,
3
+ streamingText: true,
4
+ structuredOutput: true,
5
+ toolCalling: true,
6
+ realtimeAudio: false,
7
+ imageInput: false,
8
+ local: true,
9
+ };
10
+ export class DeterministicProvider {
11
+ id = 'deterministic';
12
+ capabilities = CAPABILITIES;
13
+ /*
14
+ * Reused with a count rather than emptied. `array.length = 0` makes V8 re-grow the
15
+ * backing store, which is a per-iteration allocation in loops this provider is
16
+ * deliberately driven through ten thousand times.
17
+ */
18
+ pending = [];
19
+ pendingCount = 0;
20
+ script;
21
+ latencyOverride = null;
22
+ now = 0;
23
+ requests = 0;
24
+ aborted = 0;
25
+ live = 0;
26
+ peak = 0;
27
+ constructor(script) {
28
+ this.script = typeof script === 'function' ? script : () => script;
29
+ }
30
+ get requestCount() {
31
+ return this.requests;
32
+ }
33
+ get abortedCount() {
34
+ return this.aborted;
35
+ }
36
+ /** A high-water mark, never a current count. */
37
+ get peakConcurrency() {
38
+ return this.peak;
39
+ }
40
+ /** Applies to requests made after this call, never to one already in flight. */
41
+ setLatencyTicks(ticks) {
42
+ this.latencyOverride = ticks;
43
+ }
44
+ /** Drives the fake clock. Nothing resolves without it. */
45
+ advance(tick) {
46
+ this.now = tick;
47
+ for (let i = 0; i < this.pendingCount; i++) {
48
+ const entry = this.pending[i];
49
+ if (entry === undefined || entry.settled)
50
+ continue;
51
+ if (entry.dueTick > tick)
52
+ continue;
53
+ entry.settled = true;
54
+ entry.outcome = 'due';
55
+ this.live--;
56
+ entry.wake?.();
57
+ }
58
+ }
59
+ createSession(_options) {
60
+ const owned = [];
61
+ return {
62
+ run: (request) => {
63
+ const entry = this.begin(request);
64
+ owned.push(entry);
65
+ return this.drain(entry);
66
+ },
67
+ abort: (reason) => {
68
+ for (const entry of owned)
69
+ this.settleAborted(entry, reason ?? 'aborted');
70
+ },
71
+ };
72
+ }
73
+ /**
74
+ * Registered eagerly, before the generator's body runs.
75
+ *
76
+ * An async generator does not execute until its first `next()`, so building the
77
+ * entry inside `drain` would leave `peakConcurrency` reading zero for a request that
78
+ * had been made — the exact number the one-in-flight tests are asserting against.
79
+ *
80
+ * It is released when the request *settles*, not when the generator is drained. A
81
+ * request that has been answered or aborted is no longer outstanding whether or not
82
+ * its consumer has read it yet, and several fixed steps can run in one frame with no
83
+ * microtask between them — which made an aborted request go on counting.
84
+ */
85
+ begin(request) {
86
+ const script = this.script(this.requests);
87
+ this.requests++;
88
+ const latency = this.latencyOverride ?? script.latencyTicks;
89
+ const entry = {
90
+ dueTick: latency === 'never' ? Number.POSITIVE_INFINITY : this.now + latency,
91
+ events: script.events,
92
+ settled: false,
93
+ outcome: null,
94
+ message: '',
95
+ wake: null,
96
+ };
97
+ if (this.pendingCount < this.pending.length)
98
+ this.pending[this.pendingCount] = entry;
99
+ else
100
+ this.pending.push(entry);
101
+ this.pendingCount++;
102
+ this.live++;
103
+ if (this.live > this.peak)
104
+ this.peak = this.live;
105
+ if (request.signal.aborted)
106
+ this.settleAborted(entry, 'signal');
107
+ else
108
+ request.signal.addEventListener('abort', () => this.settleAborted(entry, 'signal'));
109
+ return entry;
110
+ }
111
+ settleAborted(entry, message) {
112
+ if (entry.settled)
113
+ return;
114
+ entry.settled = true;
115
+ entry.outcome = 'aborted';
116
+ entry.message = message;
117
+ this.aborted++;
118
+ this.live--;
119
+ entry.wake?.();
120
+ }
121
+ async *drain(entry) {
122
+ try {
123
+ if (!entry.settled) {
124
+ await new Promise((resolve) => {
125
+ entry.wake = resolve;
126
+ });
127
+ }
128
+ if (entry.outcome === 'aborted') {
129
+ yield { kind: 'done', reason: 'aborted', message: entry.message };
130
+ return;
131
+ }
132
+ for (const event of entry.events)
133
+ yield event;
134
+ }
135
+ finally {
136
+ this.forget(entry);
137
+ }
138
+ }
139
+ forget(entry) {
140
+ for (let i = 0; i < this.pendingCount; i++) {
141
+ if (this.pending[i] !== entry)
142
+ continue;
143
+ const last = this.pending[this.pendingCount - 1];
144
+ if (last !== undefined)
145
+ this.pending[i] = last;
146
+ this.pendingCount--;
147
+ return;
148
+ }
149
+ }
150
+ }
@@ -0,0 +1,47 @@
1
+ import type { Budget } from '../budget/budget.ts';
2
+ import type { ToolRegistry } from './registry.ts';
3
+ export interface ExecutionPolicy {
4
+ /** Tool ids this agent may call at all. Absent means every registered tool. */
5
+ readonly allow?: readonly string[];
6
+ /** Calls per rate class, per second. */
7
+ readonly rateLimits?: Readonly<Record<string, number>>;
8
+ /** Tools a human has to say yes to. Refused here rather than queued. */
9
+ readonly requireApproval?: readonly string[];
10
+ }
11
+ export type Admission = {
12
+ readonly ok: true;
13
+ } | {
14
+ readonly ok: false;
15
+ readonly reason: string;
16
+ };
17
+ /**
18
+ * Rate state, owned by whoever owns the agent.
19
+ *
20
+ * **Not module-level.** A shared map keyed by class name would make two unrelated
21
+ * agents share one limit, so a busy one would silence a quiet one — and worse, a test
22
+ * would carry state into the next test. The session owns one of these; the limit is
23
+ * per agent, which is the only scope a consumer can reason about.
24
+ *
25
+ * Timestamps live in a reused array with a count, because `array.length = 0` re-grows
26
+ * V8's backing store and this is touched on every accepted call.
27
+ */
28
+ export declare class RateWindows {
29
+ private readonly byClass;
30
+ /** Records the call and reports whether it fits. */
31
+ admit(rateClass: string, limit: number, nowMs: number): boolean;
32
+ clear(): void;
33
+ }
34
+ /**
35
+ * Whether a proposed tool call may be accepted.
36
+ *
37
+ * **The order is fixed and the first failure wins.** A call that is both unknown and
38
+ * over budget always reports that it is unknown, because a consumer chasing a message
39
+ * that changes between runs finds nothing wrong with either check. Order: exists,
40
+ * permitted, needs approval, arguments valid, within rate, within budget — cheapest
41
+ * and most specific first, so the message names the thing a reader can act on.
42
+ *
43
+ * This is the *acceptance* check. It does not run the tool's own `admits` guard: that
44
+ * runs at the tick boundary where the call is applied, because a snapshot is never
45
+ * authority and a buffered intent widens the gap between the two moments.
46
+ */
47
+ export declare function admitToolCall<W>(registry: ToolRegistry<W>, policy: ExecutionPolicy, budget: Budget, toolId: string, args: unknown, nowMs: number, rates?: RateWindows): Admission;
@@ -0,0 +1,84 @@
1
+ import { validateArgs } from './validate.js';
2
+ const OK = { ok: true };
3
+ /**
4
+ * Rate state, owned by whoever owns the agent.
5
+ *
6
+ * **Not module-level.** A shared map keyed by class name would make two unrelated
7
+ * agents share one limit, so a busy one would silence a quiet one — and worse, a test
8
+ * would carry state into the next test. The session owns one of these; the limit is
9
+ * per agent, which is the only scope a consumer can reason about.
10
+ *
11
+ * Timestamps live in a reused array with a count, because `array.length = 0` re-grows
12
+ * V8's backing store and this is touched on every accepted call.
13
+ */
14
+ export class RateWindows {
15
+ byClass = new Map();
16
+ /** Records the call and reports whether it fits. */
17
+ admit(rateClass, limit, nowMs) {
18
+ let window = this.byClass.get(rateClass);
19
+ if (window === undefined) {
20
+ window = { at: [], count: 0 };
21
+ this.byClass.set(rateClass, window);
22
+ }
23
+ let kept = 0;
24
+ for (let i = 0; i < window.count; i++) {
25
+ const stamp = window.at[i];
26
+ if (stamp !== undefined && nowMs - stamp < 1000)
27
+ window.at[kept++] = stamp;
28
+ }
29
+ window.count = kept;
30
+ if (window.count >= limit)
31
+ return false;
32
+ if (window.count < window.at.length)
33
+ window.at[window.count] = nowMs;
34
+ else
35
+ window.at.push(nowMs);
36
+ window.count++;
37
+ return true;
38
+ }
39
+ clear() {
40
+ this.byClass.clear();
41
+ }
42
+ }
43
+ /**
44
+ * Whether a proposed tool call may be accepted.
45
+ *
46
+ * **The order is fixed and the first failure wins.** A call that is both unknown and
47
+ * over budget always reports that it is unknown, because a consumer chasing a message
48
+ * that changes between runs finds nothing wrong with either check. Order: exists,
49
+ * permitted, needs approval, arguments valid, within rate, within budget — cheapest
50
+ * and most specific first, so the message names the thing a reader can act on.
51
+ *
52
+ * This is the *acceptance* check. It does not run the tool's own `admits` guard: that
53
+ * runs at the tick boundary where the call is applied, because a snapshot is never
54
+ * authority and a buffered intent widens the gap between the two moments.
55
+ */
56
+ export function admitToolCall(registry, policy, budget, toolId, args, nowMs, rates = new RateWindows()) {
57
+ const tool = registry.get(toolId);
58
+ if (tool === undefined) {
59
+ return { ok: false, reason: `unknown tool "${toolId}" — it is not registered` };
60
+ }
61
+ if (policy.allow !== undefined && !policy.allow.includes(toolId)) {
62
+ return { ok: false, reason: `tool "${toolId}" is not permitted by this agent's policy` };
63
+ }
64
+ if (policy.requireApproval?.includes(toolId) === true) {
65
+ return { ok: false, reason: `tool "${toolId}" requires approval, which was not given` };
66
+ }
67
+ const validation = validateArgs(tool.schema, args);
68
+ if (!validation.ok) {
69
+ return { ok: false, reason: `${validation.path}: ${validation.reason}` };
70
+ }
71
+ const rateClass = tool.rateClass;
72
+ const limit = rateClass === undefined ? undefined : policy.rateLimits?.[rateClass];
73
+ if (rateClass !== undefined && limit !== undefined) {
74
+ if (!rates.admit(rateClass, limit, nowMs)) {
75
+ return {
76
+ ok: false,
77
+ reason: `rate class "${rateClass}" is over its limit of ${limit} per second`,
78
+ };
79
+ }
80
+ }
81
+ if (budget.exhausted)
82
+ return { ok: false, reason: budget.reason };
83
+ return OK;
84
+ }
@@ -0,0 +1,69 @@
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
+ * What a model may be asked to produce.
10
+ *
11
+ * Schema-expressible types only. Anything a schema cannot describe cannot be asked
12
+ * for, validated, or recorded in a trace, and a tool taking one would be a tool whose
13
+ * arguments are checked by hope.
14
+ */
15
+ export type ToolSchema = {
16
+ readonly kind: 'string';
17
+ } | {
18
+ readonly kind: 'number';
19
+ } | {
20
+ readonly kind: 'boolean';
21
+ } | {
22
+ readonly kind: 'enum';
23
+ readonly values: readonly string[];
24
+ } | {
25
+ readonly kind: 'array';
26
+ readonly of: ToolSchema;
27
+ } | {
28
+ readonly kind: 'object';
29
+ readonly fields: Readonly<Record<string, ToolSchema>>;
30
+ };
31
+ /**
32
+ * `W` is the consumer's world view, and this package never constrains it.
33
+ *
34
+ * An engine package that knew what a world contained would be an engine package that
35
+ * knew what game it was in. A guard asking `world.exists(id)` is the consumer's
36
+ * sentence, written against the consumer's world.
37
+ */
38
+ export interface ToolDefinition<A, R, W> {
39
+ /** Stable and versioned — `inspect@1`. Never renumbered once shipped. */
40
+ readonly id: string;
41
+ readonly description: string;
42
+ readonly schema: ToolSchema;
43
+ readonly rateClass?: string;
44
+ readonly idempotent?: boolean;
45
+ /**
46
+ * Whether this call is still true of the world.
47
+ *
48
+ * Declared once, at registration, by the tool. The model never writes one: a
49
+ * model-authored precondition on a model-authored action is the model marking its
50
+ * own homework.
51
+ */
52
+ admits(args: A, world: W): boolean;
53
+ execute(args: A, world: W): R;
54
+ }
55
+ export declare class ToolRegistry<W> {
56
+ private readonly byId;
57
+ private readonly order;
58
+ register<A, R>(tool: ToolDefinition<A, R, W>): void;
59
+ /** Registration order, so a prompt's tool list is stable and a trace stays comparable. */
60
+ ids(): readonly string[];
61
+ get(id: string): ToolDefinition<never, never, W> | undefined;
62
+ /**
63
+ * Whether every tool an intent names still admits its arguments.
64
+ *
65
+ * A plain loop rather than a `map` and an `every`: this runs at the moment a
66
+ * buffered intent drains, which is inside a fixed step.
67
+ */
68
+ admits(toolIds: readonly string[], args: readonly unknown[], world: W): boolean;
69
+ }
@@ -0,0 +1,75 @@
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
+ const EXPRESSIBLE = new Set(['string', 'number', 'boolean', 'enum', 'array', 'object']);
9
+ export class ToolRegistry {
10
+ byId = new Map();
11
+ order = [];
12
+ register(tool) {
13
+ if (this.byId.has(tool.id)) {
14
+ throw new Error(`a tool is already registered as "${tool.id}"`);
15
+ }
16
+ if (!/@\d+$/.test(tool.id)) {
17
+ throw new Error(`tool id "${tool.id}" carries no version — ids are versioned as "name@1" so a ` +
18
+ `recorded trace stays readable after the tool's shape changes`);
19
+ }
20
+ const bad = inexpressible(tool.schema, '');
21
+ if (bad !== null) {
22
+ throw new Error(`tool "${tool.id}" has a schema field a schema cannot express: ${bad}`);
23
+ }
24
+ this.byId.set(tool.id, tool);
25
+ this.order.push(tool.id);
26
+ }
27
+ /** Registration order, so a prompt's tool list is stable and a trace stays comparable. */
28
+ ids() {
29
+ return this.order;
30
+ }
31
+ get(id) {
32
+ return this.byId.get(id);
33
+ }
34
+ /**
35
+ * Whether every tool an intent names still admits its arguments.
36
+ *
37
+ * A plain loop rather than a `map` and an `every`: this runs at the moment a
38
+ * buffered intent drains, which is inside a fixed step.
39
+ */
40
+ admits(toolIds, args, world) {
41
+ for (let i = 0; i < toolIds.length; i++) {
42
+ const id = toolIds[i];
43
+ if (id === undefined)
44
+ return false;
45
+ const tool = this.byId.get(id);
46
+ if (tool === undefined)
47
+ return false;
48
+ try {
49
+ if (!tool.admits(args[i], world))
50
+ return false;
51
+ }
52
+ catch {
53
+ /* A consumer bug in a guard must not become an exception inside a tick. It
54
+ becomes a refusal, the intent is discarded, and the policy floor covers. */
55
+ return false;
56
+ }
57
+ }
58
+ return true;
59
+ }
60
+ }
61
+ /** The dotted path of the first field no schema can express, or `null`. */
62
+ function inexpressible(schema, path) {
63
+ if (!EXPRESSIBLE.has(schema.kind))
64
+ return path === '' ? schema.kind : path;
65
+ if (schema.kind === 'array')
66
+ return inexpressible(schema.of, path === '' ? 'of' : `${path}.of`);
67
+ if (schema.kind === 'object') {
68
+ for (const [name, field] of Object.entries(schema.fields)) {
69
+ const found = inexpressible(field, path === '' ? name : `${path}.${name}`);
70
+ if (found !== null)
71
+ return found;
72
+ }
73
+ }
74
+ return null;
75
+ }
@@ -0,0 +1,24 @@
1
+ import type { ToolSchema } from './registry.ts';
2
+ export type ValidationResult = {
3
+ readonly ok: true;
4
+ readonly value: unknown;
5
+ } | {
6
+ readonly ok: false;
7
+ readonly reason: string;
8
+ readonly path: string;
9
+ };
10
+ /**
11
+ * Check a model's arguments against a tool's schema.
12
+ *
13
+ * **Never throws.** A model producing bad arguments is ordinary rather than
14
+ * exceptional — it is the thing schemas exist for — and putting the ordinary case on
15
+ * the exception path means every call site needs a `try` it will eventually forget.
16
+ *
17
+ * **An unknown field is a failure, not something to drop.** A model inventing a field
18
+ * is a signal: it means the prompt and the schema disagree about the tool's shape, and
19
+ * silently discarding it is how that disagreement survives a release. *What it costs:*
20
+ * a provider that helpfully adds metadata to a tool call breaks until the schema names
21
+ * it. *What would make it wrong:* if a mainstream provider does that unavoidably, the
22
+ * answer is a declared passthrough field rather than a blanket tolerance.
23
+ */
24
+ export declare function validateArgs(schema: ToolSchema, args: unknown): ValidationResult;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Check a model's arguments against a tool's schema.
3
+ *
4
+ * **Never throws.** A model producing bad arguments is ordinary rather than
5
+ * exceptional — it is the thing schemas exist for — and putting the ordinary case on
6
+ * the exception path means every call site needs a `try` it will eventually forget.
7
+ *
8
+ * **An unknown field is a failure, not something to drop.** A model inventing a field
9
+ * is a signal: it means the prompt and the schema disagree about the tool's shape, and
10
+ * silently discarding it is how that disagreement survives a release. *What it costs:*
11
+ * a provider that helpfully adds metadata to a tool call breaks until the schema names
12
+ * it. *What would make it wrong:* if a mainstream provider does that unavoidably, the
13
+ * answer is a declared passthrough field rather than a blanket tolerance.
14
+ */
15
+ export function validateArgs(schema, args) {
16
+ const failure = check(schema, args, '');
17
+ return failure ?? { ok: true, value: args };
18
+ }
19
+ function fail(path, reason) {
20
+ return { ok: false, reason, path };
21
+ }
22
+ function check(schema, value, path) {
23
+ switch (schema.kind) {
24
+ case 'string':
25
+ case 'number':
26
+ case 'boolean': {
27
+ const actual = typeof value;
28
+ if (actual !== schema.kind) {
29
+ return fail(path, `expected ${schema.kind}, received ${describe(value)}`);
30
+ }
31
+ return null;
32
+ }
33
+ case 'enum': {
34
+ if (typeof value !== 'string' || !schema.values.includes(value)) {
35
+ return fail(path, `expected one of ${schema.values.join(', ')}, received ${describe(value)}`);
36
+ }
37
+ return null;
38
+ }
39
+ case 'array': {
40
+ if (!Array.isArray(value))
41
+ return fail(path, `expected array, received ${describe(value)}`);
42
+ for (let i = 0; i < value.length; i++) {
43
+ const failure = check(schema.of, value[i], `${path}[${i}]`);
44
+ if (failure !== null)
45
+ return failure;
46
+ }
47
+ return null;
48
+ }
49
+ case 'object': {
50
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
51
+ return fail(path, `expected object, received ${describe(value)}`);
52
+ }
53
+ const record = value;
54
+ for (const [name, field] of Object.entries(schema.fields)) {
55
+ const at = path === '' ? name : `${path}.${name}`;
56
+ if (!(name in record))
57
+ return fail(at, `missing required field`);
58
+ const failure = check(field, record[name], at);
59
+ if (failure !== null)
60
+ return failure;
61
+ }
62
+ for (const name of Object.keys(record)) {
63
+ if (name in schema.fields)
64
+ continue;
65
+ const at = path === '' ? name : `${path}.${name}`;
66
+ return fail(at, `unknown field, not in the schema`);
67
+ }
68
+ return null;
69
+ }
70
+ default:
71
+ return fail(path, `unrecognised schema kind`);
72
+ }
73
+ }
74
+ function describe(value) {
75
+ if (value === null)
76
+ return 'null';
77
+ if (Array.isArray(value))
78
+ return 'array';
79
+ return typeof value;
80
+ }