@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
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
+ }