@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,153 @@
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
+ import type { AiCommand, CommandLog } from '../command/log.ts';
38
+ import type { Intent } from '../policy/types.ts';
39
+ import type { AgentSession } from '../session/agent.ts';
40
+ import type { ReplaySession } from '../session/replay.ts';
41
+ /**
42
+ * The sentinel's own name, exported so the documentation gate fires when this file arrives.
43
+ *
44
+ * `docs/CAPABILITIES.md` matches `AI_NETWORK_AUTHORITY|replicateDecision`, and a bridge named around
45
+ * the pattern would have landed the capability and left the guard quiet — leaving the document
46
+ * refusing in writing a thing that exists, which is worse than the stale prose it replaces.
47
+ */
48
+ export declare const AI_NETWORK_AUTHORITY = "ai-network-authority@1";
49
+ /**
50
+ * How a decision crosses. The consumer's, because packing and transport are theirs.
51
+ *
52
+ * A command is a tool id, structured arguments and two tick numbers. It is **not** a simulation
53
+ * input: `inputLog.ts` is fixed-width per participant per tick and refuses variable-length payloads
54
+ * by design, and an agent's decision is exactly the shape it refuses. So this rides whatever
55
+ * reliable channel the consumer already has.
56
+ */
57
+ export interface DecisionChannel {
58
+ /** Authority side. Called once per accepted command, never for a floor intent. */
59
+ replicateDecision(command: AiCommand): void;
60
+ /**
61
+ * Participant side. Hand over everything that has arrived since the last call.
62
+ *
63
+ * Returns how many were delivered. Draining rather than a callback registration so the consumer
64
+ * decides when decisions enter the simulation, which on a fixed step must be at a tick boundary.
65
+ */
66
+ drain(into: (command: AiCommand) => void): number;
67
+ }
68
+ export type AgentRole = 'authority' | 'participant';
69
+ export interface AuthoritativeAgentOptions {
70
+ /**
71
+ * Which side this is, asked every tick.
72
+ *
73
+ * A function and not a flag because authority can move, and a wrapper that cached the answer at
74
+ * construction would keep deciding after it stopped being allowed to.
75
+ */
76
+ role(): AgentRole;
77
+ channel: DecisionChannel;
78
+ /**
79
+ * The log both sides read.
80
+ *
81
+ * On the authority the session writes it and this publishes from it; on a participant the channel
82
+ * fills it and the replay reads it. One structure, because a second one would be a second answer
83
+ * to what the agent decided.
84
+ */
85
+ log: CommandLog;
86
+ }
87
+ /**
88
+ * An agent whose model decisions are taken in one place.
89
+ *
90
+ * Wraps the two sessions the package already has and picks between them. It adds no policy, no
91
+ * provider handling and no replay logic; all three exist and are reused.
92
+ */
93
+ export declare class AuthoritativeAgent {
94
+ private readonly session;
95
+ private readonly replay;
96
+ private readonly options;
97
+ private readonly scratch;
98
+ /** Ticks whose commands have been put on the wire. Nothing before this is published twice. */
99
+ private publishedThrough;
100
+ private lastTick;
101
+ private late;
102
+ private published;
103
+ private accepted;
104
+ constructor(session: AgentSession<unknown>, replay: ReplaySession, options: AuthoritativeAgentOptions);
105
+ /** Decisions this peer put on the wire. Zero on a participant, always. */
106
+ get publishedDecisions(): number;
107
+ /** Decisions taken off the wire. Zero on the authority, always. */
108
+ get acceptedDecisions(): number;
109
+ /**
110
+ * The oldest tick a decision arrived for after that tick had already run, or -1.
111
+ *
112
+ * A consumer holding a `RewindLoop` reads this, rewinds to it and replays. Cleared by
113
+ * `clearLate` once they have.
114
+ */
115
+ get earliestLateTick(): number;
116
+ clearLate(): void;
117
+ /**
118
+ * Advance one tick and return what the agent is doing.
119
+ *
120
+ * Arriving decisions are drained **before** the tick runs, so a command for this tick is in the
121
+ * log by the time the replay reads it. A command for an earlier tick is recorded anyway — the log
122
+ * is keyed by acceptance tick, so it lands where a later rewind will find it — and noted in
123
+ * `earliestLateTick`.
124
+ */
125
+ tick(tickNumber: number, nowMs: number): Intent;
126
+ private drain;
127
+ /**
128
+ * Put this tick's accepted commands on the wire.
129
+ *
130
+ * Read back out of the log rather than intercepted on the way in, so what crosses is exactly what
131
+ * a replay of this authority would see. A second path that built its own message could disagree
132
+ * with the log, and the disagreement would only show up as a participant that drifts.
133
+ */
134
+ private publish;
135
+ }
136
+ /**
137
+ * A channel with both ends in this process, for a test or a single-process host.
138
+ *
139
+ * **Not a mock.** R1 withdrew mocks standing in for engine capabilities that did not exist; this
140
+ * stands in for a *transport*, which is a thing `AGENTS.md` says is a caller's to supply, and it
141
+ * delivers real commands with a real ordering. `packages/network`'s seeded loopback is the same
142
+ * shape of object for the same reason.
143
+ *
144
+ * Delivery is deferred to the next `drain` rather than immediate, because a channel that delivered
145
+ * inside `replicateDecision` would let a decision reach a participant in the same tick it was taken,
146
+ * which no real link does and which would hide every late-arrival bug this bridge exists to notice.
147
+ */
148
+ export declare function loopbackDecisionChannel(): {
149
+ authority: DecisionChannel;
150
+ participant: DecisionChannel;
151
+ /** How many are waiting. A test asserting a decision has *not* arrived yet reads this. */
152
+ pending(): number;
153
+ };
@@ -0,0 +1,179 @@
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
+ * The sentinel's own name, exported so the documentation gate fires when this file arrives.
39
+ *
40
+ * `docs/CAPABILITIES.md` matches `AI_NETWORK_AUTHORITY|replicateDecision`, and a bridge named around
41
+ * the pattern would have landed the capability and left the guard quiet — leaving the document
42
+ * refusing in writing a thing that exists, which is worse than the stale prose it replaces.
43
+ */
44
+ export const AI_NETWORK_AUTHORITY = 'ai-network-authority@1';
45
+ /**
46
+ * An agent whose model decisions are taken in one place.
47
+ *
48
+ * Wraps the two sessions the package already has and picks between them. It adds no policy, no
49
+ * provider handling and no replay logic; all three exist and are reused.
50
+ */
51
+ export class AuthoritativeAgent {
52
+ session;
53
+ replay;
54
+ options;
55
+ scratch = [];
56
+ /** Ticks whose commands have been put on the wire. Nothing before this is published twice. */
57
+ publishedThrough = -1;
58
+ lastTick = -1;
59
+ late = -1;
60
+ published = 0;
61
+ accepted = 0;
62
+ constructor(session, replay, options) {
63
+ this.session = session;
64
+ this.replay = replay;
65
+ this.options = options;
66
+ }
67
+ /** Decisions this peer put on the wire. Zero on a participant, always. */
68
+ get publishedDecisions() {
69
+ return this.published;
70
+ }
71
+ /** Decisions taken off the wire. Zero on the authority, always. */
72
+ get acceptedDecisions() {
73
+ return this.accepted;
74
+ }
75
+ /**
76
+ * The oldest tick a decision arrived for after that tick had already run, or -1.
77
+ *
78
+ * A consumer holding a `RewindLoop` reads this, rewinds to it and replays. Cleared by
79
+ * `clearLate` once they have.
80
+ */
81
+ get earliestLateTick() {
82
+ return this.late;
83
+ }
84
+ clearLate() {
85
+ this.late = -1;
86
+ }
87
+ /**
88
+ * Advance one tick and return what the agent is doing.
89
+ *
90
+ * Arriving decisions are drained **before** the tick runs, so a command for this tick is in the
91
+ * log by the time the replay reads it. A command for an earlier tick is recorded anyway — the log
92
+ * is keyed by acceptance tick, so it lands where a later rewind will find it — and noted in
93
+ * `earliestLateTick`.
94
+ */
95
+ tick(tickNumber, nowMs) {
96
+ this.drain(tickNumber);
97
+ this.lastTick = tickNumber;
98
+ if (this.options.role() === 'participant') {
99
+ /* No provider is reachable from here. `ReplaySession` has none, which is the property that
100
+ makes "a participant never decides" true by construction rather than by care. */
101
+ return this.replay.tick(tickNumber, nowMs);
102
+ }
103
+ const intent = this.session.tick(tickNumber, nowMs);
104
+ this.publish(tickNumber);
105
+ return intent;
106
+ }
107
+ drain(tickNumber) {
108
+ this.options.channel.drain((command) => {
109
+ this.options.log.record(command);
110
+ this.accepted++;
111
+ if (command.acceptedAtTick < tickNumber && this.lastTick >= command.acceptedAtTick) {
112
+ if (this.late < 0 || command.acceptedAtTick < this.late)
113
+ this.late = command.acceptedAtTick;
114
+ }
115
+ });
116
+ }
117
+ /**
118
+ * Put this tick's accepted commands on the wire.
119
+ *
120
+ * Read back out of the log rather than intercepted on the way in, so what crosses is exactly what
121
+ * a replay of this authority would see. A second path that built its own message could disagree
122
+ * with the log, and the disagreement would only show up as a participant that drifts.
123
+ */
124
+ publish(tickNumber) {
125
+ if (tickNumber <= this.publishedThrough)
126
+ return;
127
+ const count = this.options.log.at(tickNumber, this.scratch);
128
+ for (let i = 0; i < count; i++) {
129
+ const entry = this.scratch[i];
130
+ if (entry === undefined || entry.kind !== 'command')
131
+ continue;
132
+ this.options.channel.replicateDecision(entry);
133
+ this.published++;
134
+ }
135
+ this.publishedThrough = tickNumber;
136
+ }
137
+ }
138
+ /**
139
+ * A channel with both ends in this process, for a test or a single-process host.
140
+ *
141
+ * **Not a mock.** R1 withdrew mocks standing in for engine capabilities that did not exist; this
142
+ * stands in for a *transport*, which is a thing `AGENTS.md` says is a caller's to supply, and it
143
+ * delivers real commands with a real ordering. `packages/network`'s seeded loopback is the same
144
+ * shape of object for the same reason.
145
+ *
146
+ * Delivery is deferred to the next `drain` rather than immediate, because a channel that delivered
147
+ * inside `replicateDecision` would let a decision reach a participant in the same tick it was taken,
148
+ * which no real link does and which would hide every late-arrival bug this bridge exists to notice.
149
+ */
150
+ export function loopbackDecisionChannel() {
151
+ const queue = [];
152
+ return {
153
+ authority: {
154
+ replicateDecision(command) {
155
+ queue.push(command);
156
+ },
157
+ drain() {
158
+ return 0;
159
+ },
160
+ },
161
+ participant: {
162
+ replicateDecision() {
163
+ /* A participant never publishes. Silent rather than throwing, because this is reached from
164
+ a fixed step and `AGENTS.md` forbids throwing in one; `AuthoritativeAgent` never calls
165
+ it, so a call arriving here is a consumer's own wiring and their assertion to make. */
166
+ },
167
+ drain(into) {
168
+ const count = queue.length;
169
+ for (let i = 0; i < count; i++)
170
+ into(queue[i]);
171
+ queue.length = 0;
172
+ return count;
173
+ },
174
+ },
175
+ pending() {
176
+ return queue.length;
177
+ },
178
+ };
179
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * An agent that can be asked to go somewhere, and a guard that knows whether it still can.
3
+ *
4
+ * **This is the first tool in this package whose guard says something.** A buffered intent is a
5
+ * proposal authored against one snapshot and executed later, and until now the strongest `admits`
6
+ * a consumer could write was "the entity still exists". Navigation can do better: a bridge that
7
+ * dropped, a door that closed, a region that streamed out all make a destination unreachable
8
+ * between the moment a model chose it and the moment the agent acts on it. `navigate@1` runs the
9
+ * search in its guard and the intent is discarded, with the policy floor covering — which is what
10
+ * the package already promises for every other kind of stale proposal.
11
+ *
12
+ * **What the engine owns here is the graph and the search; what it does not own is the agent.**
13
+ * `tools/registry.ts` says a guard is "the consumer's sentence, written against the consumer's
14
+ * world", and that stays true: the three functions of `NavigationAdapter` are the consumer's, and
15
+ * everything above them is this file's. A tool that knew which entity was which, or how an agent
16
+ * moves, would be a particular game's engine.
17
+ *
18
+ * ## What it costs
19
+ *
20
+ * **Three searches per navigation intent that is accepted and applied**, and the third was a
21
+ * correction: this header said two until the demo was wired and the real path counted. `take` runs
22
+ * the guard when the buffered intent drains, `applyCommand` runs it **again** on the way in — which
23
+ * `apply.ts` argues for at length, because those are two different moments and a snapshot is never
24
+ * authority — and `execute` runs a third.
25
+ *
26
+ * Caching a route between any two of them would key it on nothing stable, since the world changing
27
+ * between them is the entire reason the guard exists. A guard that trusted the previous answer
28
+ * would be the guard `apply.ts` refuses to be.
29
+ *
30
+ * `nearestNavNode` is a linear scan over `nodeCount`, and both endpoints need one, so a navigation
31
+ * intent is `O(nodes)` six times plus three A*. At the sizes these graphs are built at that is tens
32
+ * of microseconds against an intent that happens on the order of once a second per agent. It is
33
+ * written down here so a consumer profiling a thousand agents knows where to look rather than
34
+ * discovering it.
35
+ */
36
+ import type { NavGraph, NavPath } from '@driftengine/core';
37
+ import type { ToolDefinition } from '../tools/registry.ts';
38
+ /** What the consumer answers, because only the consumer knows what an agent is. */
39
+ export interface NavigationAdapter<W> {
40
+ /** The graph this agent navigates. A world may have several. Null means it cannot navigate. */
41
+ graphOf(world: W, agentId: string): NavGraph | null;
42
+ /** Where the agent is now, written into `out` as x, y, z. False when the agent is gone. */
43
+ positionOf(world: W, agentId: string, out: Float32Array): boolean;
44
+ /**
45
+ * The route object this agent follows.
46
+ *
47
+ * The consumer's and not the bridge's, deliberately: they already own its lifetime, its capacity
48
+ * and its steering options, and a second one owned here would be a second answer to where the
49
+ * agent is going.
50
+ */
51
+ pathOf(world: W, agentId: string): NavPath | null;
52
+ }
53
+ export interface NavigationBridgeOptions {
54
+ /**
55
+ * How far a destination may be from the nearest graph node and still count as that node.
56
+ *
57
+ * Infinite by default, which snaps to the nearest node however far away it is. A consumer with a
58
+ * sparse graph over a large world wants a real number here: without one, "go to the roof" routes
59
+ * to the nearest node on the ground and the agent walks confidently to the wrong place.
60
+ */
61
+ readonly snapDistance?: number;
62
+ /** Longest route the bridge will hold, in nodes. */
63
+ readonly maxNodes?: number;
64
+ }
65
+ export interface NavigateArgs {
66
+ readonly agentId: string;
67
+ readonly x: number;
68
+ readonly y: number;
69
+ readonly z: number;
70
+ }
71
+ export interface NavigateResult {
72
+ readonly found: boolean;
73
+ /** Nodes in the route, or 0. */
74
+ readonly nodes: number;
75
+ /**
76
+ * Route length in metres, or 0.
77
+ *
78
+ * Here because `Intent.expectedExtentMs` drives the continuation watermark, and a navigation
79
+ * intent is the one case where the engine knows the extent better than the policy that proposed
80
+ * it: a caller with a speed can turn this into a time. Ignoring it costs nothing.
81
+ */
82
+ readonly lengthM: number;
83
+ }
84
+ /**
85
+ * Build the navigation tools for a consumer's world.
86
+ *
87
+ * Named `navigationBridge` deliberately: `docs/CAPABILITIES.md` carries a sentinel matching this
88
+ * symbol, so the day this file exists the documentation gate fails and the row that calls this
89
+ * capability absent has to be rewritten. A bridge named something else would have landed the
90
+ * capability and left the guard quiet, which is worse than the stale prose it replaces.
91
+ */
92
+ export declare function navigationBridge<W>(adapter: NavigationAdapter<W>, options?: NavigationBridgeOptions): readonly ToolDefinition<NavigateArgs, NavigateResult, W>[];
93
+ /**
94
+ * Whether a route exists for this agent right now, without writing one.
95
+ *
96
+ * The guard, as a question. `navigate@1` answers it as a side effect of doing the thing, so a
97
+ * caller wanting to *ask* had to route and then undo. `drift/ai.reachable` is this, and so is any
98
+ * consumer's own policy that wants to score a destination before proposing it.
99
+ */
100
+ export declare function reachableBy<W>(tools: readonly ToolDefinition<NavigateArgs, NavigateResult, W>[], args: NavigateArgs, world: W): boolean;
@@ -0,0 +1,139 @@
1
+ /**
2
+ * An agent that can be asked to go somewhere, and a guard that knows whether it still can.
3
+ *
4
+ * **This is the first tool in this package whose guard says something.** A buffered intent is a
5
+ * proposal authored against one snapshot and executed later, and until now the strongest `admits`
6
+ * a consumer could write was "the entity still exists". Navigation can do better: a bridge that
7
+ * dropped, a door that closed, a region that streamed out all make a destination unreachable
8
+ * between the moment a model chose it and the moment the agent acts on it. `navigate@1` runs the
9
+ * search in its guard and the intent is discarded, with the policy floor covering — which is what
10
+ * the package already promises for every other kind of stale proposal.
11
+ *
12
+ * **What the engine owns here is the graph and the search; what it does not own is the agent.**
13
+ * `tools/registry.ts` says a guard is "the consumer's sentence, written against the consumer's
14
+ * world", and that stays true: the three functions of `NavigationAdapter` are the consumer's, and
15
+ * everything above them is this file's. A tool that knew which entity was which, or how an agent
16
+ * moves, would be a particular game's engine.
17
+ *
18
+ * ## What it costs
19
+ *
20
+ * **Three searches per navigation intent that is accepted and applied**, and the third was a
21
+ * correction: this header said two until the demo was wired and the real path counted. `take` runs
22
+ * the guard when the buffered intent drains, `applyCommand` runs it **again** on the way in — which
23
+ * `apply.ts` argues for at length, because those are two different moments and a snapshot is never
24
+ * authority — and `execute` runs a third.
25
+ *
26
+ * Caching a route between any two of them would key it on nothing stable, since the world changing
27
+ * between them is the entire reason the guard exists. A guard that trusted the previous answer
28
+ * would be the guard `apply.ts` refuses to be.
29
+ *
30
+ * `nearestNavNode` is a linear scan over `nodeCount`, and both endpoints need one, so a navigation
31
+ * intent is `O(nodes)` six times plus three A*. At the sizes these graphs are built at that is tens
32
+ * of microseconds against an intent that happens on the order of once a second per agent. It is
33
+ * written down here so a consumer profiling a thousand agents knows where to look rather than
34
+ * discovering it.
35
+ */
36
+ import { NavSearch, nearestNavNode } from '@driftengine/core';
37
+ const NO_ROUTE = { found: false, nodes: 0, lengthM: 0 };
38
+ /**
39
+ * Build the navigation tools for a consumer's world.
40
+ *
41
+ * Named `navigationBridge` deliberately: `docs/CAPABILITIES.md` carries a sentinel matching this
42
+ * symbol, so the day this file exists the documentation gate fails and the row that calls this
43
+ * capability absent has to be rewritten. A bridge named something else would have landed the
44
+ * capability and left the guard quiet, which is worse than the stale prose it replaces.
45
+ */
46
+ export function navigationBridge(adapter, options = {}) {
47
+ const snap = options.snapDistance ?? Infinity;
48
+ const maxNodes = Math.max(2, options.maxNodes ?? 512);
49
+ /*
50
+ * One scratch route and one search per graph, reused across every call.
51
+ *
52
+ * `NavSearch` allocates five arrays sized by the graph, so building one per intent would be an
53
+ * allocation proportional to the world on a path the fixed step reaches. Keyed by graph because a
54
+ * world may have several and a search is bound to the one it was built for.
55
+ */
56
+ const route = new Uint32Array(maxNodes);
57
+ const here = new Float32Array(3);
58
+ const searches = new WeakMap();
59
+ const searchFor = (graph) => {
60
+ let search = searches.get(graph);
61
+ if (search === undefined) {
62
+ search = new NavSearch(graph);
63
+ searches.set(graph, search);
64
+ }
65
+ return search;
66
+ };
67
+ /**
68
+ * The route this request would produce, or 0 nodes.
69
+ *
70
+ * Shared by the guard and the action so they cannot disagree about what "reachable" means. They
71
+ * each call it once, which is the two searches the header prices.
72
+ */
73
+ const solve = (world, args) => {
74
+ const graph = adapter.graphOf(world, args.agentId);
75
+ if (graph === null)
76
+ return null;
77
+ if (!adapter.positionOf(world, args.agentId, here))
78
+ return null;
79
+ const from = nearestNavNode(graph, here[0] ?? 0, here[1] ?? 0, here[2] ?? 0, snap);
80
+ if (from < 0)
81
+ return null;
82
+ const to = nearestNavNode(graph, args.x, args.y, args.z, snap);
83
+ if (to < 0)
84
+ return null;
85
+ return { graph, count: searchFor(graph).find(from, to, route) };
86
+ };
87
+ const navigate = {
88
+ id: 'navigate@1',
89
+ description: 'Move an agent to a world position along the navigation graph. Fails when no route exists.',
90
+ schema: {
91
+ kind: 'object',
92
+ fields: {
93
+ agentId: { kind: 'string' },
94
+ x: { kind: 'number' },
95
+ y: { kind: 'number' },
96
+ z: { kind: 'number' },
97
+ },
98
+ },
99
+ /*
100
+ * **Not idempotent, and the distinction is not pedantic.** Calling it twice re-runs the search
101
+ * against wherever the agent has moved to, which is a different route from the same arguments.
102
+ */
103
+ idempotent: false,
104
+ rateClass: 'navigation',
105
+ admits(args, world) {
106
+ const solved = solve(world, args);
107
+ return solved !== null && solved.count > 1;
108
+ },
109
+ execute(args, world) {
110
+ const path = adapter.pathOf(world, args.agentId);
111
+ if (path === null)
112
+ return NO_ROUTE;
113
+ const solved = solve(world, args);
114
+ if (solved === null || solved.count < 2) {
115
+ /*
116
+ * Cleared rather than left alone. A failed navigate that leaves the previous route in place
117
+ * is an agent that keeps walking to somewhere nobody asked for any more, which reads as the
118
+ * tool having worked.
119
+ */
120
+ path.clear();
121
+ return NO_ROUTE;
122
+ }
123
+ path.set(route, solved.count);
124
+ return { found: true, nodes: solved.count, lengthM: path.lengthM };
125
+ },
126
+ };
127
+ return [navigate];
128
+ }
129
+ /**
130
+ * Whether a route exists for this agent right now, without writing one.
131
+ *
132
+ * The guard, as a question. `navigate@1` answers it as a side effect of doing the thing, so a
133
+ * caller wanting to *ask* had to route and then undo. `drift/ai.reachable` is this, and so is any
134
+ * consumer's own policy that wants to score a destination before proposing it.
135
+ */
136
+ export function reachableBy(tools, args, world) {
137
+ const navigate = tools.find((tool) => tool.id === 'navigate@1');
138
+ return navigate !== undefined && navigate.admits(args, world);
139
+ }
@@ -0,0 +1,34 @@
1
+ import type { AiUsage } from '../session/usage.ts';
2
+ export interface BudgetLimits {
3
+ readonly inputTokens?: number;
4
+ readonly outputTokens?: number;
5
+ readonly requests?: number;
6
+ readonly costMicros?: number;
7
+ readonly wallMs?: number;
8
+ }
9
+ /**
10
+ * A ceiling on what an agent may spend, enforced here rather than asked for in a prompt.
11
+ *
12
+ * **Exhaustion degrades.** `charge` past a limit returns normally and latches
13
+ * `exhausted`; the session stops issuing continuations and the policy floor keeps
14
+ * producing intents. Throwing would put an exception inside a fixed step, which is
15
+ * worse than the overspend it prevents.
16
+ *
17
+ * *What it costs:* an agent that goes over is quietly less capable, and a consumer
18
+ * that never reads `reason` will not know why. *What would make it wrong:* if a
19
+ * consumer needs to know at the moment it happens rather than by polling, this needs
20
+ * a callback — deliberately not added until something asks, because a callback firing
21
+ * inside a fixed step has the same hazard the throw did.
22
+ */
23
+ export declare class Budget {
24
+ private readonly limits;
25
+ private latched;
26
+ private latchedReason;
27
+ private startedAtMs;
28
+ constructor(limits: BudgetLimits);
29
+ get exhausted(): boolean;
30
+ /** The sentence a consumer reports. Empty while not exhausted. */
31
+ get reason(): string;
32
+ charge(usage: Readonly<AiUsage>, nowMs: number): void;
33
+ private latch;
34
+ }
@@ -0,0 +1,57 @@
1
+ const COUNTED = ['inputTokens', 'outputTokens', 'requests', 'costMicros'];
2
+ /**
3
+ * A ceiling on what an agent may spend, enforced here rather than asked for in a prompt.
4
+ *
5
+ * **Exhaustion degrades.** `charge` past a limit returns normally and latches
6
+ * `exhausted`; the session stops issuing continuations and the policy floor keeps
7
+ * producing intents. Throwing would put an exception inside a fixed step, which is
8
+ * worse than the overspend it prevents.
9
+ *
10
+ * *What it costs:* an agent that goes over is quietly less capable, and a consumer
11
+ * that never reads `reason` will not know why. *What would make it wrong:* if a
12
+ * consumer needs to know at the moment it happens rather than by polling, this needs
13
+ * a callback — deliberately not added until something asks, because a callback firing
14
+ * inside a fixed step has the same hazard the throw did.
15
+ */
16
+ export class Budget {
17
+ limits;
18
+ latched = false;
19
+ latchedReason = '';
20
+ startedAtMs = -1;
21
+ constructor(limits) {
22
+ this.limits = limits;
23
+ }
24
+ get exhausted() {
25
+ return this.latched;
26
+ }
27
+ /** The sentence a consumer reports. Empty while not exhausted. */
28
+ get reason() {
29
+ return this.latchedReason;
30
+ }
31
+ charge(usage, nowMs) {
32
+ if (this.startedAtMs < 0)
33
+ this.startedAtMs = nowMs;
34
+ if (this.latched)
35
+ return;
36
+ for (const key of COUNTED) {
37
+ const limit = this.limits[key];
38
+ if (limit !== undefined && usage[key] > limit) {
39
+ this.latch(key, limit, usage[key]);
40
+ return;
41
+ }
42
+ }
43
+ const wall = this.limits.wallMs;
44
+ if (wall !== undefined && nowMs - this.startedAtMs > wall) {
45
+ this.latch('wallMs', wall, nowMs - this.startedAtMs);
46
+ }
47
+ }
48
+ /*
49
+ * Latched rather than recomputed. A budget derived from current usage comes back to
50
+ * life the moment a consumer resets a counter, and the agent resumes spending
51
+ * against a limit somebody has already hit.
52
+ */
53
+ latch(name, limit, actual) {
54
+ this.latched = true;
55
+ this.latchedReason = `budget exhausted: ${name} limit ${limit}, reached ${actual}`;
56
+ }
57
+ }