@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
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@driftengine/ai",
|
|
3
|
+
"version": "3.61.0",
|
|
4
|
+
"description": "Provider-neutral intelligence sessions, typed tools and context, and an agent loop that does not wait",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"drift-source": "./src/index.ts",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json",
|
|
16
|
+
"./*": "./*"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"!src/**/*.test.ts",
|
|
22
|
+
"!src/**/*.test.mjs",
|
|
23
|
+
"!src/**/__snapshots__",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"NOTICE"
|
|
27
|
+
],
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@driftengine/core": "3.61.0",
|
|
31
|
+
"@driftengine/entities": "3.61.0"
|
|
32
|
+
},
|
|
33
|
+
"author": "Drift Technologies",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/drftrun/driftengine.git",
|
|
37
|
+
"directory": "packages/ai"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/drftrun/driftengine#readme",
|
|
40
|
+
"bugs": "https://github.com/drftrun/driftengine/issues",
|
|
41
|
+
"keywords": [
|
|
42
|
+
"driftengine",
|
|
43
|
+
"3d",
|
|
44
|
+
"webgl",
|
|
45
|
+
"webgpu",
|
|
46
|
+
"typescript",
|
|
47
|
+
"agents",
|
|
48
|
+
"behaviour-tree",
|
|
49
|
+
"navmesh",
|
|
50
|
+
"pathfinding",
|
|
51
|
+
"llm"
|
|
52
|
+
],
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22.12.0"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AiProvider,
|
|
3
|
+
AiProviderCapabilities,
|
|
4
|
+
AiProviderResult,
|
|
5
|
+
AiSession,
|
|
6
|
+
AiSessionOptions,
|
|
7
|
+
} from '../provider/types.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A provider running on this device, accepted by running it.
|
|
11
|
+
*
|
|
12
|
+
* The engine's 2.7.0 rule for devices: `probeDevice` compiles what it is given and
|
|
13
|
+
* reads back a known pixel, because a device that reports support and then draws
|
|
14
|
+
* nothing is a device that lied. A local model is the same shape of question — whether
|
|
15
|
+
* it works here is a property of this machine, not of the configuration — so it is
|
|
16
|
+
* asked by doing rather than by reading a capability flag.
|
|
17
|
+
*
|
|
18
|
+
* **A failed probe never falls back to a remote provider.** That would move where a
|
|
19
|
+
* user's data goes, quietly, and make every privacy notice written against this wrong.
|
|
20
|
+
* The result carries no provider and a sentence saying why; choosing a second option
|
|
21
|
+
* is the caller's, having read the first refusal.
|
|
22
|
+
*/
|
|
23
|
+
export interface LocalProviderConfig {
|
|
24
|
+
readonly id: string;
|
|
25
|
+
readonly model: string;
|
|
26
|
+
readonly capabilities: Omit<AiProviderCapabilities, 'local'>;
|
|
27
|
+
/**
|
|
28
|
+
* Run the model once and report whether it worked.
|
|
29
|
+
*
|
|
30
|
+
* Resolving false, or throwing, are the same answer with different words. Both
|
|
31
|
+
* refuse; neither substitutes anything.
|
|
32
|
+
*/
|
|
33
|
+
probe(): Promise<boolean>;
|
|
34
|
+
createSession(options: AiSessionOptions): AiSession;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function createLocalProvider(config: LocalProviderConfig): Promise<AiProviderResult> {
|
|
38
|
+
let works: boolean;
|
|
39
|
+
let detail = '';
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
works = await config.probe();
|
|
43
|
+
} catch (error) {
|
|
44
|
+
works = false;
|
|
45
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!works) {
|
|
49
|
+
return {
|
|
50
|
+
provider: null,
|
|
51
|
+
reason:
|
|
52
|
+
`local provider "${config.id}" is unavailable on this device: the probe did not run` +
|
|
53
|
+
(detail === '' ? '' : ` — ${detail}`),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const provider: AiProvider = {
|
|
58
|
+
id: config.id,
|
|
59
|
+
capabilities: { ...config.capabilities, local: true },
|
|
60
|
+
createSession: (options) => config.createSession(options),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
return { provider, reason: `local provider "${config.id}" ready, probed on this device` };
|
|
64
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AiEvent,
|
|
3
|
+
AiProvider,
|
|
4
|
+
AiProviderCapabilities,
|
|
5
|
+
AiRequest,
|
|
6
|
+
AiSession,
|
|
7
|
+
AiSessionOptions,
|
|
8
|
+
} from '../provider/types.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A remote provider reached through the consumer's own endpoint.
|
|
12
|
+
*
|
|
13
|
+
* **No credential ever reaches this package.** The consumer runs a proxy holding its
|
|
14
|
+
* key, and this speaks to that. A test reads this module's own source and fails if a
|
|
15
|
+
* credential-shaped word appears in it, which is what keeps the rule true rather than
|
|
16
|
+
* remembered.
|
|
17
|
+
*
|
|
18
|
+
* *What it costs:* a consumer has to run something. *What would make it wrong:* a
|
|
19
|
+
* provider offering genuinely scoped, short-lived browser credentials, where the proxy
|
|
20
|
+
* buys nothing — and that is an adapter of its own rather than a loosening of this one.
|
|
21
|
+
*/
|
|
22
|
+
export interface ProxyProviderConfig {
|
|
23
|
+
readonly endpoint: string;
|
|
24
|
+
readonly model: string;
|
|
25
|
+
/** What the endpoint says it can do. Declared, because this cannot probe it. */
|
|
26
|
+
readonly capabilities: AiProviderCapabilities;
|
|
27
|
+
readonly fetch?: typeof globalThis.fetch;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface WireEvent {
|
|
31
|
+
readonly type?: string;
|
|
32
|
+
readonly text?: string;
|
|
33
|
+
readonly callId?: string;
|
|
34
|
+
readonly toolId?: string;
|
|
35
|
+
readonly args?: unknown;
|
|
36
|
+
readonly inputTokens?: number;
|
|
37
|
+
readonly outputTokens?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Returns a provider directly, where the local path returns a `Promise<AiProviderResult>`.
|
|
42
|
+
*
|
|
43
|
+
* A proxy has nothing to probe: its capabilities are whatever the consumer's endpoint
|
|
44
|
+
* declares, and finding out otherwise costs a network round trip nobody asked for.
|
|
45
|
+
* `createAiProvider` wraps either behind the capability check, so a caller sees one shape.
|
|
46
|
+
*/
|
|
47
|
+
export function createProxyProvider(config: ProxyProviderConfig): AiProvider {
|
|
48
|
+
const doFetch = config.fetch ?? globalThis.fetch;
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
id: `proxy:${config.model}`,
|
|
52
|
+
capabilities: config.capabilities,
|
|
53
|
+
createSession(_options: AiSessionOptions): AiSession {
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
abort(reason?: string): void {
|
|
58
|
+
controller.abort(reason ?? 'aborted');
|
|
59
|
+
},
|
|
60
|
+
run(request: AiRequest): AsyncIterable<AiEvent> {
|
|
61
|
+
return stream(doFetch, config, request, controller);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function* stream(
|
|
69
|
+
doFetch: typeof globalThis.fetch,
|
|
70
|
+
config: ProxyProviderConfig,
|
|
71
|
+
request: AiRequest,
|
|
72
|
+
controller: AbortController,
|
|
73
|
+
): AsyncIterable<AiEvent> {
|
|
74
|
+
/* Either the session's abort or the request's own cancels this. A session outlives
|
|
75
|
+
one request, so both are real and neither subsumes the other. */
|
|
76
|
+
const onAbort = (): void => {
|
|
77
|
+
controller.abort('signal');
|
|
78
|
+
};
|
|
79
|
+
request.signal.addEventListener('abort', onAbort);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const response = await doFetch(config.endpoint, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'content-type': 'application/json' },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
model: config.model,
|
|
87
|
+
preamble: request.preamble,
|
|
88
|
+
context: request.context,
|
|
89
|
+
toolIds: request.toolIds,
|
|
90
|
+
}),
|
|
91
|
+
signal: controller.signal,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
/* Never a throw and never silence. A caller inside a session loop reads a `done`
|
|
96
|
+
and reports it; an exception here would surface as an unhandled rejection in a
|
|
97
|
+
microtask nobody is awaiting. */
|
|
98
|
+
yield {
|
|
99
|
+
kind: 'done',
|
|
100
|
+
reason: 'error',
|
|
101
|
+
message: `proxy responded ${response.status} ${response.statusText}`,
|
|
102
|
+
};
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for await (const line of lines(response)) {
|
|
107
|
+
const event = parse(line);
|
|
108
|
+
if (event === null) continue;
|
|
109
|
+
yield event;
|
|
110
|
+
if (event.kind === 'done') return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
yield { kind: 'done', reason: 'complete' };
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const aborted = controller.signal.aborted;
|
|
116
|
+
yield {
|
|
117
|
+
kind: 'done',
|
|
118
|
+
reason: aborted ? 'aborted' : 'error',
|
|
119
|
+
message: aborted ? String(controller.signal.reason ?? 'aborted') : describe(error),
|
|
120
|
+
};
|
|
121
|
+
} finally {
|
|
122
|
+
request.signal.removeEventListener('abort', onAbort);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function* lines(response: Response): AsyncIterable<string> {
|
|
127
|
+
const body = response.body;
|
|
128
|
+
if (body === null) return;
|
|
129
|
+
|
|
130
|
+
const decoder = new TextDecoder();
|
|
131
|
+
const reader = body.getReader();
|
|
132
|
+
let buffer = '';
|
|
133
|
+
|
|
134
|
+
for (;;) {
|
|
135
|
+
const { done, value } = await reader.read();
|
|
136
|
+
if (done) break;
|
|
137
|
+
buffer += decoder.decode(value, { stream: true });
|
|
138
|
+
|
|
139
|
+
let newline = buffer.indexOf('\n');
|
|
140
|
+
while (newline >= 0) {
|
|
141
|
+
const line = buffer.slice(0, newline).trim();
|
|
142
|
+
buffer = buffer.slice(newline + 1);
|
|
143
|
+
if (line.length > 0) yield line;
|
|
144
|
+
newline = buffer.indexOf('\n');
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const rest = buffer.trim();
|
|
149
|
+
if (rest.length > 0) yield rest;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* One frame, or `null` if it cannot be read.
|
|
154
|
+
*
|
|
155
|
+
* A malformed frame is skipped rather than ending the stream. One bad chunk must not
|
|
156
|
+
* end an agent's session — the agent would drop to its floor for a byte.
|
|
157
|
+
*/
|
|
158
|
+
function parse(line: string): AiEvent | null {
|
|
159
|
+
let wire: WireEvent;
|
|
160
|
+
try {
|
|
161
|
+
wire = JSON.parse(line) as WireEvent;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
switch (wire.type) {
|
|
167
|
+
case 'text':
|
|
168
|
+
return typeof wire.text === 'string' ? { kind: 'text', text: wire.text } : null;
|
|
169
|
+
case 'toolCall':
|
|
170
|
+
if (typeof wire.callId !== 'string' || typeof wire.toolId !== 'string') return null;
|
|
171
|
+
return { kind: 'toolCall', callId: wire.callId, toolId: wire.toolId, args: wire.args };
|
|
172
|
+
case 'usage':
|
|
173
|
+
return {
|
|
174
|
+
kind: 'usage',
|
|
175
|
+
inputTokens: wire.inputTokens ?? 0,
|
|
176
|
+
outputTokens: wire.outputTokens ?? 0,
|
|
177
|
+
};
|
|
178
|
+
case 'done':
|
|
179
|
+
return { kind: 'done', reason: 'complete' };
|
|
180
|
+
default:
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function describe(error: unknown): string {
|
|
186
|
+
return error instanceof Error ? error.message : String(error);
|
|
187
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One machine decides what an agent does, and everybody else is replaying it.
|
|
3
|
+
*
|
|
4
|
+
* **A model is not a function of the simulation.** It is slow, variable and metered, and two peers
|
|
5
|
+
* running the same agent loop over the same world do not agree. So a decision is taken on the
|
|
6
|
+
* authority and reaches a participant as a fact it does not re-take.
|
|
7
|
+
*
|
|
8
|
+
* ## There is no second mechanism here, and that is the design
|
|
9
|
+
*
|
|
10
|
+
* `CommandLog` records what a model decided and when it crossed into the simulation;
|
|
11
|
+
* `ReplaySession` reads that log, recomputes floor intents and **calls no provider, ever**. The
|
|
12
|
+
* floor is deterministic and replays by rerunning, so the log carries only what cannot be derived.
|
|
13
|
+
*
|
|
14
|
+
* **A participant is therefore a replay whose log arrives over a network instead of from a
|
|
15
|
+
* recording**, and a rewind is a replay of the same log. Replicating an intent id on the input path
|
|
16
|
+
* instead would have solved a problem this package had already solved, and `inputLog.ts` refuses
|
|
17
|
+
* variable-length payloads in writing anyway.
|
|
18
|
+
*
|
|
19
|
+
* What is left for this file is the part that is genuinely about networking: which side decides,
|
|
20
|
+
* getting decisions onto the wire, and noticing when one arrives for a tick already run.
|
|
21
|
+
*
|
|
22
|
+
* ## A late decision is a rewind, and this file will not perform one
|
|
23
|
+
*
|
|
24
|
+
* A command can land after the participant has already stepped the tick it belongs to. That
|
|
25
|
+
* participant used its floor where the authority used a model, and the two worlds differ. The
|
|
26
|
+
* correction is Track J's rewind and it belongs to the consumer, who owns the `RewindLoop`, the
|
|
27
|
+
* snapshot and the decision about how far back is worth going. `earliestLateTick` reports the
|
|
28
|
+
* oldest such tick and this file does nothing else about it: performing a rewind from inside an
|
|
29
|
+
* agent would be an AI package deciding when a whole simulation goes backwards.
|
|
30
|
+
*
|
|
31
|
+
* ## What a script may not see
|
|
32
|
+
*
|
|
33
|
+
* Nothing here is bound to `drift/ai` beyond `deciding`. A capability handing a script the tools or
|
|
34
|
+
* arguments a model chose would let a `@deterministic` system branch on a provider's answer, and the
|
|
35
|
+
* replay would take the other branch.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import type { AiCommand, CommandLog, LogEntry } from '../command/log.ts';
|
|
39
|
+
import type { Intent } from '../policy/types.ts';
|
|
40
|
+
import type { AgentSession } from '../session/agent.ts';
|
|
41
|
+
import type { ReplaySession } from '../session/replay.ts';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The sentinel's own name, exported so the documentation gate fires when this file arrives.
|
|
45
|
+
*
|
|
46
|
+
* `docs/CAPABILITIES.md` matches `AI_NETWORK_AUTHORITY|replicateDecision`, and a bridge named around
|
|
47
|
+
* the pattern would have landed the capability and left the guard quiet — leaving the document
|
|
48
|
+
* refusing in writing a thing that exists, which is worse than the stale prose it replaces.
|
|
49
|
+
*/
|
|
50
|
+
export const AI_NETWORK_AUTHORITY = 'ai-network-authority@1';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* How a decision crosses. The consumer's, because packing and transport are theirs.
|
|
54
|
+
*
|
|
55
|
+
* A command is a tool id, structured arguments and two tick numbers. It is **not** a simulation
|
|
56
|
+
* input: `inputLog.ts` is fixed-width per participant per tick and refuses variable-length payloads
|
|
57
|
+
* by design, and an agent's decision is exactly the shape it refuses. So this rides whatever
|
|
58
|
+
* reliable channel the consumer already has.
|
|
59
|
+
*/
|
|
60
|
+
export interface DecisionChannel {
|
|
61
|
+
/** Authority side. Called once per accepted command, never for a floor intent. */
|
|
62
|
+
replicateDecision(command: AiCommand): void;
|
|
63
|
+
/**
|
|
64
|
+
* Participant side. Hand over everything that has arrived since the last call.
|
|
65
|
+
*
|
|
66
|
+
* Returns how many were delivered. Draining rather than a callback registration so the consumer
|
|
67
|
+
* decides when decisions enter the simulation, which on a fixed step must be at a tick boundary.
|
|
68
|
+
*/
|
|
69
|
+
drain(into: (command: AiCommand) => void): number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type AgentRole = 'authority' | 'participant';
|
|
73
|
+
|
|
74
|
+
export interface AuthoritativeAgentOptions {
|
|
75
|
+
/**
|
|
76
|
+
* Which side this is, asked every tick.
|
|
77
|
+
*
|
|
78
|
+
* A function and not a flag because authority can move, and a wrapper that cached the answer at
|
|
79
|
+
* construction would keep deciding after it stopped being allowed to.
|
|
80
|
+
*/
|
|
81
|
+
role(): AgentRole;
|
|
82
|
+
channel: DecisionChannel;
|
|
83
|
+
/**
|
|
84
|
+
* The log both sides read.
|
|
85
|
+
*
|
|
86
|
+
* On the authority the session writes it and this publishes from it; on a participant the channel
|
|
87
|
+
* fills it and the replay reads it. One structure, because a second one would be a second answer
|
|
88
|
+
* to what the agent decided.
|
|
89
|
+
*/
|
|
90
|
+
log: CommandLog;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* An agent whose model decisions are taken in one place.
|
|
95
|
+
*
|
|
96
|
+
* Wraps the two sessions the package already has and picks between them. It adds no policy, no
|
|
97
|
+
* provider handling and no replay logic; all three exist and are reused.
|
|
98
|
+
*/
|
|
99
|
+
export class AuthoritativeAgent {
|
|
100
|
+
private readonly session: AgentSession<unknown>;
|
|
101
|
+
private readonly replay: ReplaySession;
|
|
102
|
+
private readonly options: AuthoritativeAgentOptions;
|
|
103
|
+
private readonly scratch: LogEntry[] = [];
|
|
104
|
+
|
|
105
|
+
/** Ticks whose commands have been put on the wire. Nothing before this is published twice. */
|
|
106
|
+
private publishedThrough = -1;
|
|
107
|
+
private lastTick = -1;
|
|
108
|
+
private late = -1;
|
|
109
|
+
private published = 0;
|
|
110
|
+
private accepted = 0;
|
|
111
|
+
|
|
112
|
+
constructor(
|
|
113
|
+
session: AgentSession<unknown>,
|
|
114
|
+
replay: ReplaySession,
|
|
115
|
+
options: AuthoritativeAgentOptions,
|
|
116
|
+
) {
|
|
117
|
+
this.session = session;
|
|
118
|
+
this.replay = replay;
|
|
119
|
+
this.options = options;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Decisions this peer put on the wire. Zero on a participant, always. */
|
|
123
|
+
get publishedDecisions(): number {
|
|
124
|
+
return this.published;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Decisions taken off the wire. Zero on the authority, always. */
|
|
128
|
+
get acceptedDecisions(): number {
|
|
129
|
+
return this.accepted;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The oldest tick a decision arrived for after that tick had already run, or -1.
|
|
134
|
+
*
|
|
135
|
+
* A consumer holding a `RewindLoop` reads this, rewinds to it and replays. Cleared by
|
|
136
|
+
* `clearLate` once they have.
|
|
137
|
+
*/
|
|
138
|
+
get earliestLateTick(): number {
|
|
139
|
+
return this.late;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
clearLate(): void {
|
|
143
|
+
this.late = -1;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Advance one tick and return what the agent is doing.
|
|
148
|
+
*
|
|
149
|
+
* Arriving decisions are drained **before** the tick runs, so a command for this tick is in the
|
|
150
|
+
* log by the time the replay reads it. A command for an earlier tick is recorded anyway — the log
|
|
151
|
+
* is keyed by acceptance tick, so it lands where a later rewind will find it — and noted in
|
|
152
|
+
* `earliestLateTick`.
|
|
153
|
+
*/
|
|
154
|
+
tick(tickNumber: number, nowMs: number): Intent {
|
|
155
|
+
this.drain(tickNumber);
|
|
156
|
+
this.lastTick = tickNumber;
|
|
157
|
+
|
|
158
|
+
if (this.options.role() === 'participant') {
|
|
159
|
+
/* No provider is reachable from here. `ReplaySession` has none, which is the property that
|
|
160
|
+
makes "a participant never decides" true by construction rather than by care. */
|
|
161
|
+
return this.replay.tick(tickNumber, nowMs);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const intent = this.session.tick(tickNumber, nowMs);
|
|
165
|
+
this.publish(tickNumber);
|
|
166
|
+
return intent;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private drain(tickNumber: number): void {
|
|
170
|
+
this.options.channel.drain((command) => {
|
|
171
|
+
this.options.log.record(command);
|
|
172
|
+
this.accepted++;
|
|
173
|
+
if (command.acceptedAtTick < tickNumber && this.lastTick >= command.acceptedAtTick) {
|
|
174
|
+
if (this.late < 0 || command.acceptedAtTick < this.late) this.late = command.acceptedAtTick;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Put this tick's accepted commands on the wire.
|
|
181
|
+
*
|
|
182
|
+
* Read back out of the log rather than intercepted on the way in, so what crosses is exactly what
|
|
183
|
+
* a replay of this authority would see. A second path that built its own message could disagree
|
|
184
|
+
* with the log, and the disagreement would only show up as a participant that drifts.
|
|
185
|
+
*/
|
|
186
|
+
private publish(tickNumber: number): void {
|
|
187
|
+
if (tickNumber <= this.publishedThrough) return;
|
|
188
|
+
const count = this.options.log.at(tickNumber, this.scratch);
|
|
189
|
+
for (let i = 0; i < count; i++) {
|
|
190
|
+
const entry = this.scratch[i];
|
|
191
|
+
if (entry === undefined || entry.kind !== 'command') continue;
|
|
192
|
+
this.options.channel.replicateDecision(entry);
|
|
193
|
+
this.published++;
|
|
194
|
+
}
|
|
195
|
+
this.publishedThrough = tickNumber;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* A channel with both ends in this process, for a test or a single-process host.
|
|
201
|
+
*
|
|
202
|
+
* **Not a mock.** R1 withdrew mocks standing in for engine capabilities that did not exist; this
|
|
203
|
+
* stands in for a *transport*, which is a thing `AGENTS.md` says is a caller's to supply, and it
|
|
204
|
+
* delivers real commands with a real ordering. `packages/network`'s seeded loopback is the same
|
|
205
|
+
* shape of object for the same reason.
|
|
206
|
+
*
|
|
207
|
+
* Delivery is deferred to the next `drain` rather than immediate, because a channel that delivered
|
|
208
|
+
* inside `replicateDecision` would let a decision reach a participant in the same tick it was taken,
|
|
209
|
+
* which no real link does and which would hide every late-arrival bug this bridge exists to notice.
|
|
210
|
+
*/
|
|
211
|
+
export function loopbackDecisionChannel(): {
|
|
212
|
+
authority: DecisionChannel;
|
|
213
|
+
participant: DecisionChannel;
|
|
214
|
+
/** How many are waiting. A test asserting a decision has *not* arrived yet reads this. */
|
|
215
|
+
pending(): number;
|
|
216
|
+
} {
|
|
217
|
+
const queue: AiCommand[] = [];
|
|
218
|
+
return {
|
|
219
|
+
authority: {
|
|
220
|
+
replicateDecision(command: AiCommand): void {
|
|
221
|
+
queue.push(command);
|
|
222
|
+
},
|
|
223
|
+
drain(): number {
|
|
224
|
+
return 0;
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
participant: {
|
|
228
|
+
replicateDecision(): void {
|
|
229
|
+
/* A participant never publishes. Silent rather than throwing, because this is reached from
|
|
230
|
+
a fixed step and `AGENTS.md` forbids throwing in one; `AuthoritativeAgent` never calls
|
|
231
|
+
it, so a call arriving here is a consumer's own wiring and their assertion to make. */
|
|
232
|
+
},
|
|
233
|
+
drain(into: (command: AiCommand) => void): number {
|
|
234
|
+
const count = queue.length;
|
|
235
|
+
for (let i = 0; i < count; i++) into(queue[i] as AiCommand);
|
|
236
|
+
queue.length = 0;
|
|
237
|
+
return count;
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
pending(): number {
|
|
241
|
+
return queue.length;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|