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