@driftengine/ai 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +103 -0
- package/dist/adapters/local.d.ts +29 -0
- package/dist/adapters/local.js +24 -0
- package/dist/adapters/proxy.d.ts +28 -0
- package/dist/adapters/proxy.js +138 -0
- package/dist/bridges/authority.d.ts +153 -0
- package/dist/bridges/authority.js +179 -0
- package/dist/bridges/navigation.d.ts +100 -0
- package/dist/bridges/navigation.js +139 -0
- package/dist/budget/budget.d.ts +34 -0
- package/dist/budget/budget.js +57 -0
- package/dist/command/apply.d.ts +24 -0
- package/dist/command/apply.js +40 -0
- package/dist/command/log.d.ts +55 -0
- package/dist/command/log.js +50 -0
- package/dist/context/assemble.d.ts +48 -0
- package/dist/context/assemble.js +55 -0
- package/dist/context/continuation.d.ts +14 -0
- package/dist/context/continuation.js +36 -0
- package/dist/describe/manifest.d.ts +70 -0
- package/dist/describe/manifest.js +99 -0
- package/dist/entities/context.d.ts +52 -0
- package/dist/entities/context.js +83 -0
- package/dist/index.d.ts +61 -0
- package/dist/index.js +40 -0
- package/dist/policy/types.d.ts +55 -0
- package/dist/policy/types.js +26 -0
- package/dist/policy/utility.d.ts +18 -0
- package/dist/policy/utility.js +47 -0
- package/dist/provider/create.d.ts +16 -0
- package/dist/provider/create.js +57 -0
- package/dist/provider/latency.d.ts +27 -0
- package/dist/provider/latency.js +52 -0
- package/dist/provider/types.d.ts +90 -0
- package/dist/provider/types.js +8 -0
- package/dist/realtime/session.d.ts +35 -0
- package/dist/realtime/session.js +34 -0
- package/dist/session/agent.d.ts +217 -0
- package/dist/session/agent.js +506 -0
- package/dist/session/replay.d.ts +32 -0
- package/dist/session/replay.js +81 -0
- package/dist/session/states.d.ts +28 -0
- package/dist/session/states.js +33 -0
- package/dist/session/usage.d.ts +43 -0
- package/dist/session/usage.js +38 -0
- package/dist/testing/deterministic.d.ts +65 -0
- package/dist/testing/deterministic.js +150 -0
- package/dist/tools/policy.d.ts +47 -0
- package/dist/tools/policy.js +84 -0
- package/dist/tools/registry.d.ts +69 -0
- package/dist/tools/registry.js +75 -0
- package/dist/tools/validate.d.ts +24 -0
- package/dist/tools/validate.js +80 -0
- package/package.json +59 -0
- package/src/adapters/local.ts +64 -0
- package/src/adapters/proxy.ts +187 -0
- package/src/bridges/authority.ts +244 -0
- package/src/bridges/navigation.ts +207 -0
- package/src/budget/budget.ts +73 -0
- package/src/command/apply.ts +52 -0
- package/src/command/log.ts +81 -0
- package/src/context/assemble.ts +104 -0
- package/src/context/continuation.ts +39 -0
- package/src/describe/manifest.ts +148 -0
- package/src/entities/context.ts +112 -0
- package/src/index.ts +94 -0
- package/src/policy/types.ts +70 -0
- package/src/policy/utility.ts +53 -0
- package/src/provider/create.ts +70 -0
- package/src/provider/latency.ts +57 -0
- package/src/provider/types.ts +96 -0
- package/src/realtime/session.ts +63 -0
- package/src/session/agent.ts +622 -0
- package/src/session/replay.ts +96 -0
- package/src/session/states.ts +63 -0
- package/src/session/usage.ts +66 -0
- package/src/testing/deterministic.ts +204 -0
- package/src/tools/policy.ts +114 -0
- package/src/tools/registry.ts +122 -0
- package/src/tools/validate.ts +92 -0
|
@@ -0,0 +1,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
|
+
}
|