@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,148 @@
1
+ import type { AiProvider } from '../provider/types.ts';
2
+ import type { ContextProvider } from '../context/assemble.ts';
3
+ import type { ToolRegistry, ToolSchema } from '../tools/registry.ts';
4
+ import { validateArgs } from '../tools/validate.ts';
5
+
6
+ /**
7
+ * What an agent is, written down.
8
+ *
9
+ * Describes the *agent* rather than its connection: the tools and context are the
10
+ * agent's, and `providerId` is null when nothing is attached. An agent with no provider
11
+ * is still a fully described agent — it runs on its floor — and a manifest that
12
+ * required a connection would have nothing to say about the case this package was
13
+ * built to make ordinary.
14
+ */
15
+ export interface AiManifest {
16
+ readonly tools: readonly { id: string; description: string; schema: ToolSchema }[];
17
+ readonly context: readonly { id: string; title: string; schema: ToolSchema }[];
18
+ readonly providerId: string | null;
19
+ /** Effects a `@deterministic` function may still have, for a reader of a trace. */
20
+ readonly determinismBoundary: readonly string[];
21
+ /** Surfaces this engine refuses in writing, so a reader learns the shape of the hole. */
22
+ readonly refused: readonly { id: string; waitsOn: string }[];
23
+ }
24
+
25
+ const DETERMINISM_BOUNDARY: readonly string[] = [
26
+ 'pure',
27
+ 'clock.read',
28
+ 'scene.read',
29
+ 'scene.write',
30
+ 'ecs.read',
31
+ 'ecs.write',
32
+ 'physics.read',
33
+ 'physics.write',
34
+ ];
35
+
36
+ /**
37
+ * The two bridges Track O refuses, named so a coding assistant learns them.
38
+ *
39
+ * A manifest that listed only what works teaches a reader to ask for the rest, and the
40
+ * answer arrives as a failure rather than as a sentence. `docs/CAPABILITIES.md` carries
41
+ * a sentinel for each, so the day either is built is the day this list is wrong and the
42
+ * suite says so.
43
+ */
44
+ /*
45
+ * **Empty since 2026-09-05, and the emptiness is the point rather than an oversight.**
46
+ *
47
+ * Both entries that were here — the navigation bridge and the network authority — were refused for
48
+ * reasons that stopped being true on 2026-09-03, and neither this list nor `CAPABILITIES.md` had
49
+ * re-read them. `navigationBridge` and `AuthoritativeAgent` are built, so a refusal naming either
50
+ * would be this file telling a model a capability is absent while a consumer registers it.
51
+ *
52
+ * An empty list is a supported state and not a broken one: `describeAgent` simply says nothing is
53
+ * refused. What must not happen is an entry outliving the thing it waited on, which is what the
54
+ * gate above catches and what both of these did for two days.
55
+ */
56
+ const REFUSED: readonly { id: string; waitsOn: string }[] = [];
57
+
58
+ export function describeAgent<W>(
59
+ tools: ToolRegistry<W>,
60
+ context: readonly ContextProvider<unknown>[],
61
+ provider: AiProvider | null,
62
+ ): AiManifest {
63
+ return {
64
+ tools: tools.ids().map((id) => {
65
+ const tool = tools.get(id);
66
+ return {
67
+ id,
68
+ description: tool?.description ?? '',
69
+ schema: tool?.schema ?? { kind: 'object', fields: {} },
70
+ };
71
+ }),
72
+ context: context.map((entry) => {
73
+ const described = entry.describe();
74
+ return { id: entry.id, title: described.title, schema: described.schema };
75
+ }),
76
+ providerId: provider?.id ?? null,
77
+ determinismBoundary: DETERMINISM_BOUNDARY,
78
+ refused: REFUSED,
79
+ };
80
+ }
81
+
82
+ export type StructuredResult =
83
+ { readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly reason: string };
84
+
85
+ /**
86
+ * Ask for output shaped like a schema, or refuse before spending anything.
87
+ *
88
+ * A provider declaring no structured output is refused **before** the request. Sending
89
+ * it anyway and hoping the text parses is the silent-downgrade failure wearing a
90
+ * different hat: it works often enough to ship and fails on the input nobody tested.
91
+ */
92
+ export function requireStructuredOutput(
93
+ provider: AiProvider | null,
94
+ schema: ToolSchema,
95
+ value: unknown,
96
+ ): StructuredResult {
97
+ if (provider === null) {
98
+ return { ok: false, reason: 'no provider is attached, so nothing can be asked for' };
99
+ }
100
+ if (!provider.capabilities.structuredOutput) {
101
+ return {
102
+ ok: false,
103
+ reason: `provider "${provider.id}" declares no structured output — asking anyway and parsing the text is a downgrade, not a fallback`,
104
+ };
105
+ }
106
+
107
+ const validation = validateArgs(schema, value);
108
+ if (validation.ok) return { ok: true, value: validation.value };
109
+ return { ok: false, reason: `${validation.path}: ${validation.reason}` };
110
+ }
111
+
112
+ export interface DevelopmentManifest extends AiManifest {
113
+ /** What this package will not do, in the words a reader needs to stop asking. */
114
+ readonly refusals: readonly { id: string; waitsOn: string }[];
115
+ /** The properties everything else here exists to make cheap. */
116
+ readonly guarantees: readonly string[];
117
+ }
118
+
119
+ /**
120
+ * The manifest a coding assistant reads.
121
+ *
122
+ * Carries what the runtime manifest carries **plus the refusals**, because a
123
+ * description listing only what works teaches a reader to ask for the rest — and the
124
+ * answer arrives as a failure rather than as a sentence. The parent design's §43 asks
125
+ * for exactly this, and gives that reason.
126
+ *
127
+ * The refusal ids are the sentinel names in `docs/CAPABILITIES.md`, and a test asserts
128
+ * the two agree. Two descriptions of one thing will drift, and this is the pair that
129
+ * would.
130
+ */
131
+ export function describeForDevelopment<W>(
132
+ tools: ToolRegistry<W>,
133
+ context: readonly ContextProvider<unknown>[],
134
+ provider: AiProvider | null,
135
+ ): DevelopmentManifest {
136
+ const manifest = describeAgent(tools, context, provider);
137
+ return {
138
+ ...manifest,
139
+ refusals: REFUSED,
140
+ guarantees: [
141
+ 'an agent is never without a current intent, whether or not a provider is attached',
142
+ 'exactly one provider request is in flight per agent',
143
+ 'a buffered intent is revalidated at the drain and discarded, never deferred',
144
+ 'budget exhaustion degrades to the policy floor and throws nothing',
145
+ 'accepted commands and preemptions are recorded, so a run replays exactly',
146
+ ],
147
+ };
148
+ }
@@ -0,0 +1,112 @@
1
+ import {
2
+ entityGeneration,
3
+ entityIndex,
4
+ packEntity,
5
+ type Entity,
6
+ type World,
7
+ } from '@driftengine/entities';
8
+ import type { ComponentType } from '@driftengine/entities';
9
+ import type { ContextProvider } from '../context/assemble.ts';
10
+ import type { ToolDefinition, ToolSchema } from '../tools/registry.ts';
11
+
12
+ /**
13
+ * Context and tools over an entity world.
14
+ *
15
+ * This is the half of AI-6 that links. The other half — a bridge that moves something
16
+ * along a path — is refused in writing, because nothing pathfinds and `drift/navigation`
17
+ * waits on no track at all. A seam with no implementation behind it is what R1 withdrew.
18
+ */
19
+
20
+ /** A stable, printable name for an entity, and the only form a model ever sees. */
21
+ export function entityRef(entity: Entity): string {
22
+ return `e${entityIndex(entity)}.${entityGeneration(entity)}`;
23
+ }
24
+
25
+ /**
26
+ * Read a reference back, or `null` when it is not one.
27
+ *
28
+ * A model inventing an identifier is ordinary rather than exceptional — the parent
29
+ * design's identity constraints say so in words — so this returns rather than throws,
30
+ * and the caller's guard turns it into a refusal.
31
+ */
32
+ export function parseEntityRef(ref: string, world: World): Entity | null {
33
+ const match = /^e(\d+)\.(\d+)$/.exec(ref);
34
+ if (match === null) return null;
35
+
36
+ /* Rebuilt and asked, rather than searched for. `alive` compares the generation, so a
37
+ handle whose slot has been reused answers false — which is the whole reason a
38
+ reference carries one. */
39
+ const entity = packEntity(Number(match[1]), Number(match[2]));
40
+ return world.alive(entity) ? entity : null;
41
+ }
42
+
43
+ export interface EntityContextOptions {
44
+ readonly id: string;
45
+ readonly title: string;
46
+ readonly priority: number;
47
+ readonly maxItems?: number;
48
+ readonly components: readonly ComponentType[];
49
+ }
50
+
51
+ /**
52
+ * A context provider that samples a query into a list of references.
53
+ *
54
+ * **This allocates, and that is correct here.** A reference is a string and a sample is
55
+ * a list, so neither can be free. What matters is where it runs: context is assembled
56
+ * once per *request*, not once per tick, and a request already costs a network round
57
+ * trip. The floor is the per-tick path and it is the one with the allocation floor.
58
+ *
59
+ * What wrapping the cursor must not do is make *iteration* allocate — that property is
60
+ * `@driftengine/entities`' and is floored in its own suite — so the loop reads the
61
+ * cursor directly rather than materialising it first.
62
+ */
63
+ export function entityContext(
64
+ world: World,
65
+ options: EntityContextOptions,
66
+ ): ContextProvider<readonly string[]> {
67
+ const schema: ToolSchema = { kind: 'array', of: { kind: 'string' } };
68
+ const limit = options.maxItems ?? Number.POSITIVE_INFINITY;
69
+ const [a, b, c, d] = options.components;
70
+
71
+ return {
72
+ id: options.id,
73
+ priority: options.priority,
74
+ maxItems: options.maxItems,
75
+ describe: () => ({ title: options.title, schema }),
76
+ sample: (): readonly string[] => {
77
+ const out: string[] = [];
78
+ if (a === undefined) return out;
79
+ for (const entity of world.query(a, b, c, d)) {
80
+ if (out.length >= limit) break;
81
+ out.push(entityRef(entity));
82
+ }
83
+ return out;
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * A tool addressing an entity, whose guard checks the **generation** and not only the index.
90
+ *
91
+ * The staleness case the buffer makes likely rather than rare: an entity is destroyed
92
+ * and its slot is reused, so an index that still resolves resolves to something else
93
+ * entirely. A guard checking only the index would let a plan made about one thing run
94
+ * against another, which is worse than the plan failing.
95
+ */
96
+ export function entityTool<R>(
97
+ world: World,
98
+ id: string,
99
+ description: string,
100
+ execute: (entity: Entity, world: World) => R,
101
+ ): ToolDefinition<{ target: string }, R | null, World> {
102
+ return {
103
+ id,
104
+ description,
105
+ schema: { kind: 'object', fields: { target: { kind: 'string' } } },
106
+ admits: (args) => parseEntityRef(args.target, world) !== null,
107
+ execute: (args, w) => {
108
+ const entity = parseEntityRef(args.target, world);
109
+ return entity === null ? null : execute(entity, w);
110
+ },
111
+ };
112
+ }
package/src/index.ts ADDED
@@ -0,0 +1,94 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * Drift AI — provider-neutral intelligence sessions.
4
+ *
5
+ * The abstraction is not "an LLM-controlled character". It is typed intelligence
6
+ * sessions over consumer-defined capabilities, which is what makes it an engine
7
+ * package rather than a game feature: the same runtime serves a game exposing
8
+ * movement and dialogue tools, a site exposing camera and material tools, and an
9
+ * editor exposing selection and transform tools.
10
+ *
11
+ * The one property worth stating at the barrel: **an agent is never without a
12
+ * purpose**. A deterministic policy floor runs inside the simulation whether or
13
+ * not a provider exists, and the request for the next intent is issued while the
14
+ * current one is still executing. A provider that is slow, absent, or over budget
15
+ * costs quality, never motion.
16
+ */
17
+
18
+ export { Budget } from './budget/budget.ts';
19
+ export type { BudgetLimits } from './budget/budget.ts';
20
+ export { createAiProvider } from './provider/create.ts';
21
+ export { hasKnownExtent, UNKNOWN_EXTENT, validateIntent } from './policy/types.ts';
22
+ export type {
23
+ AgentPolicy,
24
+ Intent,
25
+ IntentCheck,
26
+ PolicyContext,
27
+ PolicyOption,
28
+ } from './policy/types.ts';
29
+ export { UtilityPolicy } from './policy/utility.ts';
30
+ export {
31
+ describeAgent,
32
+ describeForDevelopment,
33
+ requireStructuredOutput,
34
+ } from './describe/manifest.ts';
35
+ export type { AiManifest, DevelopmentManifest, StructuredResult } from './describe/manifest.ts';
36
+ export { entityContext, entityRef, entityTool, parseEntityRef } from './entities/context.ts';
37
+ export type { EntityContextOptions } from './entities/context.ts';
38
+ export { createRealtimeSession } from './realtime/session.ts';
39
+ export type { RealtimeOptions, RealtimeResult } from './realtime/session.ts';
40
+ export { createLocalProvider } from './adapters/local.ts';
41
+ export type { LocalProviderConfig } from './adapters/local.ts';
42
+ export { createProxyProvider } from './adapters/proxy.ts';
43
+ export type { ProxyProviderConfig } from './adapters/proxy.ts';
44
+ export { assembleContext } from './context/assemble.ts';
45
+ export type { AssembledContext, ContextProvider, ContextSection } from './context/assemble.ts';
46
+ export { continuationPreamble } from './context/continuation.ts';
47
+ export { applyCommand } from './command/apply.ts';
48
+ export type { ApplyOutcome } from './command/apply.ts';
49
+ export { CommandLog } from './command/log.ts';
50
+ export type { AiCommand, AiPreemption, LogEntry } from './command/log.ts';
51
+ export { ReplaySession } from './session/replay.ts';
52
+ export type { ReplaySource } from './session/replay.ts';
53
+ export { AgentSession } from './session/agent.ts';
54
+ export type { AgentSessionOptions, Observation, WhileBusy } from './session/agent.ts';
55
+ export { nextState } from './session/states.ts';
56
+ export type { AgentState, AgentTransition } from './session/states.ts';
57
+ export { admitToolCall, RateWindows } from './tools/policy.ts';
58
+ export type { Admission, ExecutionPolicy } from './tools/policy.ts';
59
+ export { ToolRegistry } from './tools/registry.ts';
60
+ export type { ToolDefinition, ToolSchema } from './tools/registry.ts';
61
+
62
+ /* The two bridges. Both are factories over a consumer's adapter, because the engine owns the graph
63
+ and the replication and the consumer owns what an agent is. */
64
+ export type {
65
+ NavigateArgs,
66
+ NavigateResult,
67
+ NavigationAdapter,
68
+ NavigationBridgeOptions,
69
+ } from './bridges/navigation.ts';
70
+ export { navigationBridge, reachableBy } from './bridges/navigation.ts';
71
+ export type { AgentRole, AuthoritativeAgentOptions, DecisionChannel } from './bridges/authority.ts';
72
+ export {
73
+ AI_NETWORK_AUTHORITY,
74
+ AuthoritativeAgent,
75
+ loopbackDecisionChannel,
76
+ } from './bridges/authority.ts';
77
+ export { validateArgs } from './tools/validate.ts';
78
+ export type { ValidationResult } from './tools/validate.ts';
79
+ export { LatencyEstimator } from './provider/latency.ts';
80
+ export { chargeUsage, createUsage, noteAbort, notePreemption } from './session/usage.ts';
81
+ export type { AiUsage } from './session/usage.ts';
82
+ export { DeterministicProvider } from './testing/deterministic.ts';
83
+ export type { DeterministicScript } from './testing/deterministic.ts';
84
+ export type {
85
+ AiEvent,
86
+ AiProvider,
87
+ AiProviderCapabilities,
88
+ AiProviderConfig,
89
+ AiProviderResult,
90
+ AiRequest,
91
+ AiSession,
92
+ AiSessionOptions,
93
+ AssembledContextLike,
94
+ } from './provider/types.ts';
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What an agent is doing, and the seam that decides it when nothing else can.
3
+ */
4
+
5
+ /** An intent whose extent is unknown. The watermark reads it as "ask immediately". */
6
+ export const UNKNOWN_EXTENT = -1;
7
+
8
+ export interface Intent {
9
+ readonly id: string;
10
+ readonly priority: number;
11
+ readonly toolIds: readonly string[];
12
+ readonly args: readonly unknown[];
13
+ /**
14
+ * How long this is expected to take, in milliseconds, or `UNKNOWN_EXTENT`.
15
+ *
16
+ * The continuation watermark subtracts the provider's measured p90 from this to
17
+ * decide when to ask what comes next. An unknown extent asks immediately, which is
18
+ * the behaviour a design that waited would have had and is correct for a one-shot.
19
+ */
20
+ readonly expectedExtentMs: number;
21
+ readonly source: 'floor' | 'model';
22
+ }
23
+
24
+ export interface PolicyContext {
25
+ readonly tick: number;
26
+ readonly agentId: string;
27
+ /** Milliseconds the current intent has been running. */
28
+ readonly elapsedMs: number;
29
+ }
30
+
31
+ export interface PolicyOption {
32
+ /**
33
+ * Pre-built and reused, never constructed per tick.
34
+ *
35
+ * The floor claims it allocates nothing per tick, and selecting among N existing
36
+ * objects makes that true by construction rather than by care.
37
+ */
38
+ readonly intent: Intent;
39
+ score(context: PolicyContext): number;
40
+ }
41
+
42
+ export interface AgentPolicy {
43
+ select(context: PolicyContext): Intent;
44
+ }
45
+
46
+ export function hasKnownExtent(intent: Intent): boolean {
47
+ return intent.expectedExtentMs > UNKNOWN_EXTENT;
48
+ }
49
+
50
+ export type IntentCheck = { readonly ok: true } | { readonly ok: false; readonly reason: string };
51
+
52
+ /**
53
+ * Whether an intent is internally coherent.
54
+ *
55
+ * Only the pairing of tools to arguments, because that is the one an intent can get
56
+ * wrong on its own. Whether the tools *exist* is the registry's question and whether
57
+ * the call is *permitted* is the policy's; asking all three here would put three
58
+ * failures behind one message.
59
+ */
60
+ export function validateIntent(intent: Intent): IntentCheck {
61
+ if (intent.toolIds.length !== intent.args.length) {
62
+ return {
63
+ ok: false,
64
+ reason:
65
+ `intent "${intent.id}" names ${intent.toolIds.length} tools but carries ` +
66
+ `${intent.args.length} argument sets`,
67
+ };
68
+ }
69
+ return { ok: true };
70
+ }
@@ -0,0 +1,53 @@
1
+ import type { AgentPolicy, Intent, PolicyContext, PolicyOption } from './types.ts';
2
+
3
+ /**
4
+ * The default floor: score every option, take the highest.
5
+ *
6
+ * Small on purpose. The seam is the deliverable and this is a convenience that has to
7
+ * earn its place by being used. *What it costs:* every consumer links a scorer it may
8
+ * not want. *What would make it wrong:* if consumers universally replace it, it is
9
+ * dead weight and belongs behind its own export rather than in the barrel.
10
+ *
11
+ * **Allocates nothing per tick.** `select` returns one of the options' pre-built
12
+ * intents — the same object identity every time that option wins. That is what makes
13
+ * the claim true by construction, and `utility.alloc.test.ts` is what keeps it true.
14
+ */
15
+ export class UtilityPolicy implements AgentPolicy {
16
+ private readonly options: readonly PolicyOption[];
17
+
18
+ constructor(options: readonly PolicyOption[]) {
19
+ if (options.length === 0) {
20
+ /*
21
+ * At construction rather than at `select`. A floor with nothing to do is a
22
+ * configuration error, and discovering it inside a fixed step is discovering it
23
+ * at the worst possible moment — the one where there is nothing to fall back to.
24
+ */
25
+ throw new Error(
26
+ 'a UtilityPolicy needs at least one option — a floor with nothing to do is not a floor',
27
+ );
28
+ }
29
+ this.options = options;
30
+ }
31
+
32
+ select(context: PolicyContext): Intent {
33
+ let best = 0;
34
+ let bestScore = Number.NEGATIVE_INFINITY;
35
+
36
+ for (let i = 0; i < this.options.length; i++) {
37
+ const option = this.options[i];
38
+ if (option === undefined) continue;
39
+ const score = option.score(context);
40
+ /* Strictly greater, so a tie goes to the earlier declaration. Declaration order
41
+ is something a consumer controls and can read off the file; any other
42
+ tie-break is a rule they would have to be told. */
43
+ if (score > bestScore) {
44
+ bestScore = score;
45
+ best = i;
46
+ }
47
+ }
48
+
49
+ const chosen = this.options[best];
50
+ if (chosen === undefined) throw new Error('unreachable: options is non-empty');
51
+ return chosen.intent;
52
+ }
53
+ }
@@ -0,0 +1,70 @@
1
+ import type {
2
+ AiProvider,
3
+ AiProviderCapabilities,
4
+ AiProviderConfig,
5
+ AiProviderResult,
6
+ } from './types.ts';
7
+
8
+ /**
9
+ * Accept a provider only if it declares what was asked of it, and say so either way.
10
+ *
11
+ * Renderer creation already works this way: `createRenderer` reports which backend it
12
+ * took and why, and `probeDevice` refuses in the device's own words. A provider is the
13
+ * same shape of decision with a bigger bill attached — a request to a provider that
14
+ * cannot do the thing costs latency and tokens before it fails, where a refusal here
15
+ * costs a string comparison.
16
+ *
17
+ * **There is no fallback path.** A downgrade from a local model to a paid remote one
18
+ * changes where a user's data goes, and a package that does it quietly makes every
19
+ * privacy notice written against it wrong. A caller wanting a second choice asks for
20
+ * it explicitly, having read the first refusal.
21
+ */
22
+ export async function createAiProvider(
23
+ config: AiProviderConfig,
24
+ required: Partial<AiProviderCapabilities>,
25
+ ): Promise<AiProviderResult> {
26
+ const provider = config.provider;
27
+ if (provider === undefined) {
28
+ return {
29
+ provider: null,
30
+ reason: `no provider was supplied for kind "${config.kind}" — build one with its own factory and pass it in`,
31
+ };
32
+ }
33
+
34
+ const missing = unsupported(provider, required);
35
+ if (missing.length > 0) {
36
+ return {
37
+ provider: null,
38
+ reason:
39
+ `provider "${provider.id}" is unsupported for this request: ` +
40
+ `it declares no ${missing.join(', no ')}`,
41
+ };
42
+ }
43
+
44
+ return {
45
+ provider,
46
+ reason: `provider "${provider.id}" ready, declaring ${declared(provider).join(', ')}`,
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Only the capabilities that were asked for *and* are absent.
52
+ *
53
+ * Naming a satisfied requirement in a refusal sends a reader looking for a fault in
54
+ * the half that worked, which is how a message costs more time than no message.
55
+ */
56
+ function unsupported(provider: AiProvider, required: Partial<AiProviderCapabilities>): string[] {
57
+ const missing: string[] = [];
58
+ for (const key of Object.keys(required) as (keyof AiProviderCapabilities)[]) {
59
+ if (required[key] === true && provider.capabilities[key] !== true) missing.push(key);
60
+ }
61
+ return missing;
62
+ }
63
+
64
+ function declared(provider: AiProvider): string[] {
65
+ const names: string[] = [];
66
+ for (const key of Object.keys(provider.capabilities) as (keyof AiProviderCapabilities)[]) {
67
+ if (provider.capabilities[key]) names.push(key);
68
+ }
69
+ return names.length > 0 ? names : ['nothing'];
70
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * A p90 that revises as it measures, and says so before it knows.
3
+ *
4
+ * The continuation watermark issues the next request when the current intent's
5
+ * remaining extent falls to this number. Measuring it rather than configuring it is
6
+ * what removes the tuning constant that would be wrong on every device, and wrong
7
+ * again the moment a consumer switched providers.
8
+ *
9
+ * **p90 rather than p50 or p99.** At p50 half the continuations land late and the
10
+ * agent visibly drops to its floor mid-behaviour. At p99 the lead is long enough that
11
+ * the context goes stale for the tail. *What would make this wrong:* a provider whose
12
+ * latency is bimodal — a cache hit at 40ms and a miss at 3s — has a p90 that describes
13
+ * neither, and the answer there is a per-request estimate from the request's own
14
+ * shape, not a different percentile.
15
+ */
16
+ export class LatencyEstimator {
17
+ private readonly window: Float64Array;
18
+ private readonly scratch: Float64Array;
19
+ private readonly minSamples: number;
20
+
21
+ private cursor = 0;
22
+ private filled = 0;
23
+
24
+ constructor(capacity = 64, minSamples = 8) {
25
+ const slots = Math.max(1, capacity | 0);
26
+ this.window = new Float64Array(slots);
27
+ /* Sorted into a buffer owned once rather than allocated per read. The watermark
28
+ asks for this on the tick an intent starts, which is a per-intent path. */
29
+ this.scratch = new Float64Array(slots);
30
+ this.minSamples = Math.max(1, minSamples | 0);
31
+ }
32
+
33
+ get samples(): number {
34
+ return this.filled;
35
+ }
36
+
37
+ /** `-1` until `minSamples` are in, which the watermark reads as "issue immediately". */
38
+ get p90(): number {
39
+ if (this.filled < this.minSamples) return -1;
40
+
41
+ for (let i = 0; i < this.filled; i++) {
42
+ const value = this.window[i];
43
+ this.scratch[i] = value === undefined ? 0 : value;
44
+ }
45
+ const view = this.scratch.subarray(0, this.filled);
46
+ view.sort();
47
+
48
+ const index = Math.min(this.filled - 1, Math.ceil(this.filled * 0.9) - 1);
49
+ return view[Math.max(0, index)] ?? -1;
50
+ }
51
+
52
+ record(ms: number): void {
53
+ this.window[this.cursor] = ms;
54
+ this.cursor = (this.cursor + 1) % this.window.length;
55
+ if (this.filled < this.window.length) this.filled++;
56
+ }
57
+ }