operon-agents 0.1.0-alpha.2 → 0.1.0-alpha.21
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/README.md +42 -0
- package/dist/app-server/client.js +8 -5
- package/dist/app-server/main.js +1 -1
- package/dist/app-server/params.d.ts +115 -0
- package/dist/app-server/params.js +81 -0
- package/dist/app-server/protocol.d.ts +10 -4
- package/dist/app-server/protocol.js +45 -37
- package/dist/app-server/server.d.ts +0 -1
- package/dist/app-server/server.js +46 -35
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/cron/cron-expr.js +1 -1
- package/dist/cron/extension.js +2 -2
- package/dist/extensions/index.d.ts +19 -5
- package/dist/extensions/index.js +66 -10
- package/dist/extensions/loader.d.ts +2 -2
- package/dist/extensions/loader.js +23 -15
- package/dist/extensions/manager.d.ts +15 -13
- package/dist/extensions/manager.js +28 -25
- package/dist/extensions/runtime.d.ts +69 -74
- package/dist/extensions/runtime.js +183 -381
- package/dist/extensions/types.d.ts +55 -45
- package/dist/harness.d.ts +339 -180
- package/dist/harness.js +673 -420
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/internal.d.ts +1 -0
- package/dist/internal.js +1 -0
- package/dist/local.d.ts +25 -8
- package/dist/local.js +103 -33
- package/dist/providers.d.ts +144 -0
- package/dist/providers.js +280 -0
- package/dist/telemetry-posthog.d.ts +88 -0
- package/dist/telemetry-posthog.js +142 -0
- package/dist/telemetry.d.ts +4 -0
- package/dist/telemetry.js +3 -0
- package/dist/workspace-mcp.d.ts +62 -0
- package/dist/workspace-mcp.js +222 -0
- package/package.json +10 -3
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
1
|
import { ServiceUnavailableError, isProbeProperty } from "./services.js";
|
|
3
|
-
import { tagToolSource } from "operon-agents-core";
|
|
2
|
+
import { appendAudit, tagToolSource } from "operon-agents-core";
|
|
4
3
|
/** Decision points block the loop, so they get room to do real work (network, subprocess). */
|
|
5
4
|
const DECISION_TIMEOUT_MS = 30_000;
|
|
6
5
|
/** Observers must not become latency: a slow one is dropped, not waited on. */
|
|
@@ -12,37 +11,35 @@ const OBSERVE_EVENTS = new Set([
|
|
|
12
11
|
// here is latency on every streamed token, so it gets the short budget.
|
|
13
12
|
"provider.response",
|
|
14
13
|
]);
|
|
14
|
+
/**
|
|
15
|
+
* One extension, running in one session. `extensionCapability` wraps it as that session's
|
|
16
|
+
* capability, so everything around the extension — assembly, hook isolation, attach and detach
|
|
17
|
+
* at a run boundary, its state and records — is the engine's, the same as for any capability.
|
|
18
|
+
* What is left here is the translation the extension API exists for: `api` registrations into
|
|
19
|
+
* capability parts, and the engine's decision points into the extension's event vocabulary.
|
|
20
|
+
*/
|
|
15
21
|
export class ExtensionRuntime {
|
|
16
|
-
|
|
22
|
+
definition;
|
|
17
23
|
handlers = new Map();
|
|
18
24
|
tools = new Map();
|
|
19
|
-
/** Stable reference: `
|
|
25
|
+
/** Stable reference: `extensionCapability` hands this array to the assembler, and `session`
|
|
20
26
|
* (which runs at openSession, before any run assembles) fills it in place. */
|
|
21
27
|
injectors = [];
|
|
22
|
-
/** Slash commands
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
/** Extensions whose contributions are currently honored. An id leaves on detach (and on
|
|
34
|
-
* setup failure), which turns every `actions`/`api` closure the extension still holds
|
|
35
|
-
* into a warn-and-noop — a detached extension must not keep steering the session. */
|
|
36
|
-
live = new Set();
|
|
37
|
-
/** Changes wait here until a quiet point: mid-run requests apply at the run's stop
|
|
38
|
-
* boundary, so a turn never sees its toolset change underneath it. */
|
|
39
|
-
pending = [];
|
|
40
|
-
/** Serializes flushes so concurrent attach/detach apply strictly in submission order. */
|
|
41
|
-
flushChain = Promise.resolve();
|
|
42
|
-
memoryState = new Map();
|
|
28
|
+
/** Slash commands, filled in place by `api.registerCommand` — the capability's `commands`. */
|
|
29
|
+
commands = [];
|
|
30
|
+
/** The host-facing control surface published via `api.expose`. */
|
|
31
|
+
exposed;
|
|
32
|
+
/** `session()`'s cleanup plus every registration, undone in reverse. */
|
|
33
|
+
teardown;
|
|
34
|
+
/** False until `session` has run, and again once the extension is closed: every `actions` /
|
|
35
|
+
* `api` closure it still holds turns into a warn-and-noop — a detached extension must not
|
|
36
|
+
* keep steering the session. */
|
|
37
|
+
live = false;
|
|
38
|
+
/** The session binding: the services the capability DECLARED, plus the session's id/signal. */
|
|
43
39
|
session;
|
|
44
40
|
run;
|
|
45
|
-
/** The conversation shard the in-flight decision point belongs to
|
|
41
|
+
/** The conversation shard the in-flight decision point belongs to — the address a
|
|
42
|
+
* `compaction.before` handler is told about. */
|
|
46
43
|
activeContext;
|
|
47
44
|
/** Snapshot of the last assembled registry, refreshed once per turn by `filterTools`. */
|
|
48
45
|
allToolNames = [];
|
|
@@ -51,210 +48,93 @@ export class ExtensionRuntime {
|
|
|
51
48
|
reportingWarning = false;
|
|
52
49
|
/** Harness reach. Absent when the capability is built standalone (bare Runner / tests). */
|
|
53
50
|
host;
|
|
54
|
-
/** This session's
|
|
55
|
-
* "skip this extension for this session"; any other value is passed to `setup` as `params`. */
|
|
51
|
+
/** This session's argument for the extension (`createSession({ params: { [id]: … } })`). */
|
|
56
52
|
params;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(definition.id)) {
|
|
67
|
-
throw new Error(`extension id "${definition.id}" must be a slug ([A-Za-z0-9_.-], no colons)`);
|
|
68
|
-
}
|
|
69
|
-
if (seen.has(definition.id))
|
|
70
|
-
throw new Error(`duplicate extension id "${definition.id}"`);
|
|
71
|
-
seen.add(definition.id);
|
|
53
|
+
/** What `session.start` reports: `attach` for an extension added to a live session. */
|
|
54
|
+
startReason;
|
|
55
|
+
constructor(definition, options = {}) {
|
|
56
|
+
if (!definition.id.trim())
|
|
57
|
+
throw new Error("extension id must not be empty");
|
|
58
|
+
// Slug only, colons forbidden: records and state scope by the "extension:<id>:" prefix,
|
|
59
|
+
// so an id containing ":" would make one extension's bucket swallow another's.
|
|
60
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(definition.id)) {
|
|
61
|
+
throw new Error(`extension id "${definition.id}" must be a slug ([A-Za-z0-9_.-], no colons)`);
|
|
72
62
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
this.
|
|
63
|
+
this.definition = definition;
|
|
64
|
+
this.host = options.host;
|
|
65
|
+
this.params = options.params;
|
|
66
|
+
this.startReason = options.startReason ?? "open";
|
|
67
|
+
}
|
|
68
|
+
get id() {
|
|
69
|
+
return this.definition.id;
|
|
76
70
|
}
|
|
77
71
|
// ==========================================================================
|
|
78
72
|
// Lifecycle
|
|
79
73
|
// ==========================================================================
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
74
|
+
/** Run `session` and fire `session.start`. Throws when `session` does — the capability is then
|
|
75
|
+
* absent from the session, with nothing it registered left behind. */
|
|
76
|
+
async open(services, ctx) {
|
|
77
|
+
this.session = { ...ctx, ...services };
|
|
78
|
+
if (!(await this.setupExtension(this.definition)))
|
|
79
|
+
throw new Error(`extension "${this.id}" session() failed`);
|
|
85
80
|
for (const registered of [...this.handlersFor("session.start")]) {
|
|
86
|
-
await this.invoke("session.start", registered, { ...this.sessionContext(
|
|
81
|
+
await this.invoke("session.start", registered, { ...this.sessionContext(this.id), reason: this.startReason });
|
|
87
82
|
}
|
|
83
|
+
if (this.startReason === "attach")
|
|
84
|
+
await this.logChange("attached");
|
|
88
85
|
}
|
|
89
86
|
attachRun(ctx) {
|
|
90
87
|
this.run = ctx;
|
|
91
88
|
}
|
|
92
|
-
|
|
89
|
+
detachRun() {
|
|
93
90
|
this.run = undefined;
|
|
94
91
|
this.activeContext = undefined;
|
|
95
|
-
// The run just ended — this is the quiet point mid-run attach/detach requests wait for.
|
|
96
|
-
await this.flushPending();
|
|
97
92
|
}
|
|
93
|
+
/** Fire `session.end` while the handlers are still registered, then unwind everything. */
|
|
98
94
|
async close(reason = "close") {
|
|
99
|
-
if (this.session) {
|
|
95
|
+
if (this.session && this.live) {
|
|
100
96
|
for (const registered of [...this.handlersFor("session.end")]) {
|
|
101
|
-
await this.invoke("session.end", registered, { ...this.sessionContext(
|
|
97
|
+
await this.invoke("session.end", registered, { ...this.sessionContext(this.id), reason });
|
|
102
98
|
}
|
|
103
99
|
}
|
|
104
|
-
|
|
100
|
+
this.live = false;
|
|
101
|
+
const teardown = this.teardown;
|
|
102
|
+
this.teardown = undefined;
|
|
103
|
+
if (teardown) {
|
|
105
104
|
try {
|
|
106
|
-
await
|
|
105
|
+
await teardown();
|
|
107
106
|
}
|
|
108
107
|
catch (error) {
|
|
109
|
-
await this.warn(id, `teardown failed: ${messageOf(error)}`);
|
|
108
|
+
await this.warn(this.id, `teardown failed: ${messageOf(error)}`);
|
|
110
109
|
}
|
|
111
110
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
this.
|
|
115
|
-
this.
|
|
116
|
-
this.recordSnapshot = undefined;
|
|
117
|
-
this.recordWrites.clear();
|
|
118
|
-
for (const entry of this.pending.splice(0))
|
|
119
|
-
entry.reject(new Error("session closed"));
|
|
111
|
+
if (reason === "detach")
|
|
112
|
+
await this.logChange("detached");
|
|
113
|
+
this.commands.length = 0;
|
|
114
|
+
this.exposed = undefined;
|
|
120
115
|
this.injectors.length = 0;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
// ==========================================================================
|
|
124
|
-
// Hot attach / detach — host API, applied at the next quiet point
|
|
125
|
-
// ==========================================================================
|
|
126
|
-
/**
|
|
127
|
-
* Add an extension to the live session. Resolves once its `setup` has run and its
|
|
128
|
-
* contributions are in place — with no run in flight that is immediately; mid-run it is
|
|
129
|
-
* the current run's stop boundary, so an in-flight turn never sees its registry change.
|
|
130
|
-
* Rejects on duplicate/empty id or when `setup` throws.
|
|
131
|
-
*/
|
|
132
|
-
attach(definition) {
|
|
133
|
-
return this.submit({ kind: "attach", definition });
|
|
134
|
-
}
|
|
135
|
-
/** Ids of currently attached extensions — the reshape coordinator's affected-set probe. */
|
|
136
|
-
attachedIds() {
|
|
137
|
-
return [...this.live];
|
|
138
|
-
}
|
|
139
|
-
/** Currently attached extensions with what each declared it `uses` — the unload consumer scan. */
|
|
140
|
-
attachedExtensions() {
|
|
141
|
-
return this.definitions.filter((definition) => this.live.has(definition.id)).map((definition) => ({ id: definition.id, uses: definition.uses ?? [] }));
|
|
142
|
-
}
|
|
143
|
-
/** The duck protocol the session's command registry consumes: dynamic commands contributed
|
|
144
|
-
* by attached extensions (`api.registerCommand`). */
|
|
145
|
-
sessionCommands() {
|
|
146
|
-
return [...this.extCommands.values()].map((entry) => entry.command);
|
|
147
|
-
}
|
|
148
|
-
/** The control surface `extensionId` published via `api.expose`; undefined when absent
|
|
149
|
-
* (never attached, detached, or nothing exposed). */
|
|
150
|
-
exposedHandle(extensionId) {
|
|
151
|
-
return this.exposedHandles.get(extensionId);
|
|
116
|
+
// The session binding stays: a closure the extension stashed must still be able to say, into
|
|
117
|
+
// the event stream, that it was ignored because the extension is gone.
|
|
152
118
|
}
|
|
153
|
-
/**
|
|
154
|
-
|
|
155
|
-
|
|
119
|
+
/** The control surface published via `api.expose`; undefined when nothing is (or it was
|
|
120
|
+
* disposed, or the extension is closed). */
|
|
121
|
+
exposedHandle() {
|
|
122
|
+
return this.exposed;
|
|
156
123
|
}
|
|
157
|
-
|
|
158
|
-
return new Promise((resolve, reject) => {
|
|
159
|
-
this.pending.push({ ...change, resolve, reject });
|
|
160
|
-
if (this.run === undefined)
|
|
161
|
-
void this.flushPending();
|
|
162
|
-
});
|
|
163
|
-
}
|
|
164
|
-
flushPending() {
|
|
165
|
-
const next = this.flushChain.then(() => this.drainPending());
|
|
166
|
-
this.flushChain = next.catch(() => undefined);
|
|
167
|
-
return next;
|
|
168
|
-
}
|
|
169
|
-
async drainPending() {
|
|
170
|
-
while (this.pending.length > 0) {
|
|
171
|
-
const entry = this.pending.shift();
|
|
172
|
-
if (!entry)
|
|
173
|
-
break;
|
|
174
|
-
try {
|
|
175
|
-
if (entry.kind === "attach")
|
|
176
|
-
await this.attachNow(entry.definition);
|
|
177
|
-
else
|
|
178
|
-
await this.detachNow(entry.id);
|
|
179
|
-
entry.resolve();
|
|
180
|
-
}
|
|
181
|
-
catch (error) {
|
|
182
|
-
entry.reject(error);
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
async attachNow(definition) {
|
|
187
|
-
if (!definition.id.trim())
|
|
188
|
-
throw new Error("extension id must not be empty");
|
|
189
|
-
// Slug only, colons forbidden: records and state scope by the "extension:<id>:" prefix,
|
|
190
|
-
// so an id containing ":" would make one extension's bucket swallow another's.
|
|
191
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(definition.id)) {
|
|
192
|
-
throw new Error(`extension id "${definition.id}" must be a slug ([A-Za-z0-9_.-], no colons)`);
|
|
193
|
-
}
|
|
194
|
-
if (this.params[definition.id] === false) {
|
|
195
|
-
throw new Error(`extension "${definition.id}" is opted out for this session (params["${definition.id}"] is false)`);
|
|
196
|
-
}
|
|
197
|
-
if (this.definitions.some((existing) => existing.id === definition.id)) {
|
|
198
|
-
throw new Error(`duplicate extension id "${definition.id}"`);
|
|
199
|
-
}
|
|
200
|
-
this.definitions.push(definition);
|
|
201
|
-
// Not open yet: `open()` will run setup with everything else.
|
|
202
|
-
if (!this.session)
|
|
203
|
-
return;
|
|
204
|
-
const ok = await this.setupExtension(definition);
|
|
205
|
-
if (!ok) {
|
|
206
|
-
const index = this.definitions.findIndex((existing) => existing.id === definition.id);
|
|
207
|
-
if (index >= 0)
|
|
208
|
-
this.definitions.splice(index, 1);
|
|
209
|
-
throw new Error(`extension "${definition.id}" setup failed`);
|
|
210
|
-
}
|
|
211
|
-
for (const registered of [...this.handlersFor("session.start")]) {
|
|
212
|
-
if (registered.extensionId !== definition.id)
|
|
213
|
-
continue;
|
|
214
|
-
await this.invoke("session.start", registered, { ...this.sessionContext(definition.id), reason: "attach" });
|
|
215
|
-
}
|
|
216
|
-
await this.logChange("attached", definition.id);
|
|
217
|
-
}
|
|
218
|
-
async detachNow(id) {
|
|
219
|
-
const index = this.definitions.findIndex((existing) => existing.id === id);
|
|
220
|
-
if (index < 0)
|
|
221
|
-
throw new Error(`unknown extension "${id}"`);
|
|
222
|
-
// Notify while its handlers are still registered.
|
|
223
|
-
if (this.session && this.live.has(id)) {
|
|
224
|
-
for (const registered of [...this.handlersFor("session.end")]) {
|
|
225
|
-
if (registered.extensionId !== id)
|
|
226
|
-
continue;
|
|
227
|
-
await this.invoke("session.end", registered, { ...this.sessionContext(id), reason: "detach" });
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
this.definitions.splice(index, 1);
|
|
231
|
-
this.live.delete(id);
|
|
232
|
-
const scope = this.scopes.get(id);
|
|
233
|
-
this.scopes.delete(id);
|
|
234
|
-
if (scope) {
|
|
235
|
-
try {
|
|
236
|
-
await scope();
|
|
237
|
-
}
|
|
238
|
-
catch (error) {
|
|
239
|
-
await this.warn(id, `teardown failed: ${messageOf(error)}`);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
if (this.session)
|
|
243
|
-
await this.logChange("detached", id);
|
|
244
|
-
}
|
|
245
|
-
/** Runs one extension's `setup` and files its scope. Failure ⇒ skipped, contributions undone. */
|
|
124
|
+
/** Runs one extension's `session` and files its scope. Failure ⇒ skipped, contributions undone. */
|
|
246
125
|
async setupExtension(definition) {
|
|
247
|
-
const params = this.params
|
|
248
|
-
// A
|
|
249
|
-
// and registered the result under the id. Not registered ⇒ it was handed
|
|
250
|
-
// directly instead of being registered — a programming error, so it throws
|
|
251
|
-
// being skipped.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
126
|
+
const params = this.params;
|
|
127
|
+
// A definition with a shared half reaches a session only after that half ran — the harness
|
|
128
|
+
// ran `harness` once and registered the result under the id. Not registered ⇒ it was handed
|
|
129
|
+
// to a session directly instead of being registered — a programming error, so it throws
|
|
130
|
+
// rather than being skipped.
|
|
131
|
+
const sharedHalf = definition.harness !== undefined;
|
|
132
|
+
if (sharedHalf && this.host?.services?.has(definition.id) !== true) {
|
|
133
|
+
throw new Error(`extension "${definition.id}" has a shared half but no service "${definition.id}" is reachable from this session — its harness half never ran. Register it in createHarness({ extensions }) or load it from extensionDir; a definition with a shared half cannot be handed to a session directly`);
|
|
134
|
+
}
|
|
135
|
+
const shared = sharedHalf ? this.serviceHandle(definition.id, definition.id) : undefined;
|
|
256
136
|
const registrations = [];
|
|
257
|
-
this.live
|
|
137
|
+
this.live = true;
|
|
258
138
|
try {
|
|
259
139
|
// `uses`: checked at registration, re-checked here (a provider may have unloaded since)
|
|
260
140
|
// and handed in resolved — an extension never looks a service up by name.
|
|
@@ -264,40 +144,41 @@ export class ExtensionRuntime {
|
|
|
264
144
|
throw new Error(`uses "${name}": no such service is registered`);
|
|
265
145
|
services[name] = this.serviceHandle(definition.id, name);
|
|
266
146
|
}
|
|
267
|
-
const teardown = await definition.
|
|
268
|
-
this.
|
|
147
|
+
const teardown = await definition.session(this.apiFor(definition, registrations), { shared, params, services });
|
|
148
|
+
this.teardown = async () => {
|
|
269
149
|
if (teardown)
|
|
270
150
|
await teardown();
|
|
271
151
|
for (const dispose of [...registrations].reverse())
|
|
272
152
|
dispose();
|
|
273
|
-
}
|
|
153
|
+
};
|
|
274
154
|
return true;
|
|
275
155
|
}
|
|
276
156
|
catch (error) {
|
|
277
|
-
this.live
|
|
157
|
+
this.live = false;
|
|
278
158
|
for (const dispose of [...registrations].reverse())
|
|
279
159
|
dispose();
|
|
280
|
-
await this.warn(definition.id, `
|
|
160
|
+
await this.warn(definition.id, `session() failed; extension skipped: ${messageOf(error)}`);
|
|
281
161
|
return false;
|
|
282
162
|
}
|
|
283
163
|
}
|
|
284
164
|
/**
|
|
285
|
-
* Durable audit trail
|
|
286
|
-
*
|
|
165
|
+
* Durable audit trail of when this extension joined or left the session — written to the
|
|
166
|
+
* session's audit shard, which nothing replays from.
|
|
287
167
|
*/
|
|
288
|
-
async logChange(kind
|
|
168
|
+
async logChange(kind) {
|
|
169
|
+
const extensionId = this.id;
|
|
289
170
|
const store = this.session?.store;
|
|
290
171
|
if (!store)
|
|
291
172
|
return;
|
|
292
173
|
try {
|
|
293
|
-
await store
|
|
174
|
+
await appendAudit(store, { type: "custom", name: `extensions.${kind}`, data: { extensionId } });
|
|
294
175
|
}
|
|
295
176
|
catch (error) {
|
|
296
177
|
await this.warn(extensionId, `failed to journal extension ${kind}: ${messageOf(error)}`);
|
|
297
178
|
}
|
|
298
179
|
}
|
|
299
180
|
listTools() {
|
|
300
|
-
return [...this.tools.values()]
|
|
181
|
+
return [...this.tools.values()];
|
|
301
182
|
}
|
|
302
183
|
listInjectors() {
|
|
303
184
|
return this.injectors;
|
|
@@ -447,75 +328,55 @@ export class ExtensionRuntime {
|
|
|
447
328
|
changed = true;
|
|
448
329
|
}
|
|
449
330
|
}
|
|
450
|
-
// Fold the provider-tier callbacks into the request pi-ai will actually send. They cannot be
|
|
451
|
-
// driven from here — pi-ai invokes them from inside its HTTP path — so the only way to reach
|
|
452
|
-
// them is to ride along on `providerOptions`.
|
|
453
|
-
const withProvider = this.withProviderHooks(current, origin);
|
|
454
|
-
if (withProvider !== undefined) {
|
|
455
|
-
current = withProvider;
|
|
456
|
-
changed = true;
|
|
457
|
-
}
|
|
458
331
|
return changed ? { request: current } : undefined;
|
|
459
332
|
}
|
|
460
333
|
/**
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
334
|
+
* The capability's provider hooks — `provider.headers` / `payload` / `response`. The engine
|
|
335
|
+
* composes them into the request with every other capability's, so two extensions rewriting
|
|
336
|
+
* headers both take effect instead of the second replacing the first. They fire once per HTTP
|
|
337
|
+
* ATTEMPT, so a retry re-runs them — the contract tells handlers to be idempotent, and the
|
|
338
|
+
* runtime does not try to dedupe on their behalf.
|
|
466
339
|
*/
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
if (result?.headers !== undefined)
|
|
486
|
-
current = result.headers;
|
|
487
|
-
}
|
|
488
|
-
return current;
|
|
489
|
-
};
|
|
490
|
-
}
|
|
491
|
-
if (payloadHandlers.length > 0) {
|
|
492
|
-
providerOptions.onPayload = async (payload) => {
|
|
493
|
-
let current = payload;
|
|
494
|
-
for (const registered of payloadHandlers) {
|
|
495
|
-
const result = await this.invoke("provider.payload", registered, {
|
|
496
|
-
...this.eventContext(registered.extensionId, origin),
|
|
497
|
-
...base,
|
|
498
|
-
payload: current,
|
|
499
|
-
});
|
|
500
|
-
if (result !== undefined && "payload" in result)
|
|
501
|
-
current = result.payload;
|
|
502
|
-
}
|
|
503
|
-
return current;
|
|
504
|
-
};
|
|
505
|
-
}
|
|
506
|
-
if (responseHandlers.length > 0) {
|
|
507
|
-
providerOptions.onResponse = async (response) => {
|
|
508
|
-
for (const registered of responseHandlers) {
|
|
509
|
-
await this.invoke("provider.response", registered, {
|
|
510
|
-
...this.eventContext(registered.extensionId, origin),
|
|
511
|
-
...base,
|
|
512
|
-
status: response.status,
|
|
513
|
-
headers: response.headers,
|
|
514
|
-
});
|
|
340
|
+
providerHooks = {
|
|
341
|
+
headers: async (headers, ctx) => {
|
|
342
|
+
let current = headers;
|
|
343
|
+
for (const registered of this.handlersFor("provider.headers")) {
|
|
344
|
+
const result = await this.invoke("provider.headers", registered, { ...this.providerContext(ctx), headers: current });
|
|
345
|
+
if (result?.headers !== undefined)
|
|
346
|
+
current = result.headers;
|
|
347
|
+
}
|
|
348
|
+
return current;
|
|
349
|
+
},
|
|
350
|
+
payload: async (payload, ctx) => {
|
|
351
|
+
let current = payload;
|
|
352
|
+
let changed = false;
|
|
353
|
+
for (const registered of this.handlersFor("provider.payload")) {
|
|
354
|
+
const result = await this.invoke("provider.payload", registered, { ...this.providerContext(ctx), payload: current });
|
|
355
|
+
if (result !== undefined && "payload" in result) {
|
|
356
|
+
current = result.payload;
|
|
357
|
+
changed = true;
|
|
515
358
|
}
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
|
|
359
|
+
}
|
|
360
|
+
return changed ? { payload: current } : undefined;
|
|
361
|
+
},
|
|
362
|
+
response: async (response, ctx) => {
|
|
363
|
+
for (const registered of this.handlersFor("provider.response")) {
|
|
364
|
+
await this.invoke("provider.response", registered, { ...this.providerContext(ctx), status: response.status, headers: response.headers });
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
/** Whether any provider-tier handler is registered — the capability omits `provider` otherwise,
|
|
369
|
+
* so a request with no provider-tier extension is left byte-identical. */
|
|
370
|
+
hasProviderHandlers() {
|
|
371
|
+
return this.handlersFor("provider.headers").length + this.handlersFor("provider.payload").length + this.handlersFor("provider.response").length > 0;
|
|
372
|
+
}
|
|
373
|
+
providerContext(ctx) {
|
|
374
|
+
const session = this.requireSession();
|
|
375
|
+
return {
|
|
376
|
+
...this.eventContext(this.id, { ...(ctx.address !== undefined ? { address: ctx.address } : {}), signal: session.signal }),
|
|
377
|
+
turnId: ctx.turnId,
|
|
378
|
+
stepNumber: ctx.stepNumber,
|
|
379
|
+
};
|
|
519
380
|
}
|
|
520
381
|
async afterModelResponse(origin, request, response, context) {
|
|
521
382
|
this.activeContext = context;
|
|
@@ -609,7 +470,7 @@ export class ExtensionRuntime {
|
|
|
609
470
|
apiFor(definition, registrations) {
|
|
610
471
|
return {
|
|
611
472
|
on: (event, handler) => {
|
|
612
|
-
if (!this.live
|
|
473
|
+
if (!this.live) {
|
|
613
474
|
void this.warn(definition.id, `on("${event}") ignored: extension is detached.`);
|
|
614
475
|
return () => undefined;
|
|
615
476
|
}
|
|
@@ -630,11 +491,11 @@ export class ExtensionRuntime {
|
|
|
630
491
|
return dispose;
|
|
631
492
|
},
|
|
632
493
|
onEvent: (listener) => {
|
|
633
|
-
if (!this.live
|
|
494
|
+
if (!this.live) {
|
|
634
495
|
void this.warn(definition.id, "onEvent() ignored: extension is detached.");
|
|
635
496
|
return () => undefined;
|
|
636
497
|
}
|
|
637
|
-
// `
|
|
498
|
+
// `session` runs inside `open`, so the session context is already in place.
|
|
638
499
|
const sink = this.session?.events;
|
|
639
500
|
if (sink === undefined)
|
|
640
501
|
return () => undefined;
|
|
@@ -654,27 +515,26 @@ export class ExtensionRuntime {
|
|
|
654
515
|
// A plain spec (file extensions — no framework imports, so no `tool()` helper) is
|
|
655
516
|
// expanded here; a real Tool passes through untouched.
|
|
656
517
|
const tool = "schema" in toolOrSpec ? toolOrSpec : toolFromSpec(toolOrSpec);
|
|
657
|
-
if (!this.live
|
|
518
|
+
if (!this.live) {
|
|
658
519
|
void this.warn(definition.id, `registerTool("${tool.schema.name}") ignored: extension is detached.`);
|
|
659
520
|
return () => undefined;
|
|
660
521
|
}
|
|
661
522
|
const name = tool.schema.name;
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
// Core's toolset assembly reads this tag to fail a name collision closed, naming us.
|
|
523
|
+
if (this.tools.has(name))
|
|
524
|
+
throw new Error(`extension "${definition.id}" registered tool "${name}" twice`);
|
|
525
|
+
this.tools.set(name, tool);
|
|
526
|
+
// Core's toolset assembly reads this tag to fail a collision with ANOTHER capability's
|
|
527
|
+
// tool closed, naming us.
|
|
668
528
|
tagToolSource(tool, definition.id);
|
|
669
529
|
const dispose = () => {
|
|
670
|
-
if (this.tools.get(name)
|
|
530
|
+
if (this.tools.get(name) === tool)
|
|
671
531
|
this.tools.delete(name);
|
|
672
532
|
};
|
|
673
533
|
registrations.push(dispose);
|
|
674
534
|
return dispose;
|
|
675
535
|
},
|
|
676
536
|
registerInjector: (injector) => {
|
|
677
|
-
if (!this.live
|
|
537
|
+
if (!this.live) {
|
|
678
538
|
void this.warn(definition.id, `registerInjector("${injector.id}") ignored: extension is detached.`);
|
|
679
539
|
return () => undefined;
|
|
680
540
|
}
|
|
@@ -688,14 +548,13 @@ export class ExtensionRuntime {
|
|
|
688
548
|
return dispose;
|
|
689
549
|
},
|
|
690
550
|
registerCommand: (command) => {
|
|
691
|
-
if (!this.live
|
|
551
|
+
if (!this.live) {
|
|
692
552
|
void this.warn(definition.id, `registerCommand("/${command.name}") ignored: extension is detached.`);
|
|
693
553
|
return () => undefined;
|
|
694
554
|
}
|
|
695
555
|
const key = command.name.trim().toLowerCase();
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
throw new Error(`extension command "/${key}" from "${definition.id}" collides with "${existing.extensionId}"`);
|
|
556
|
+
if (this.commands.some((existing) => existing.name.trim().toLowerCase() === key)) {
|
|
557
|
+
throw new Error(`extension "${definition.id}" registered command "/${key}" twice`);
|
|
699
558
|
}
|
|
700
559
|
const headless = {
|
|
701
560
|
name: command.name,
|
|
@@ -703,16 +562,17 @@ export class ExtensionRuntime {
|
|
|
703
562
|
description: command.description,
|
|
704
563
|
run: async (_ctx, args) => command.run(args),
|
|
705
564
|
};
|
|
706
|
-
this.
|
|
565
|
+
this.commands.push(headless);
|
|
707
566
|
const dispose = () => {
|
|
708
|
-
|
|
709
|
-
|
|
567
|
+
const index = this.commands.indexOf(headless);
|
|
568
|
+
if (index >= 0)
|
|
569
|
+
this.commands.splice(index, 1);
|
|
710
570
|
};
|
|
711
571
|
registrations.push(dispose);
|
|
712
572
|
return dispose;
|
|
713
573
|
},
|
|
714
574
|
emitEvent: (name, data) => {
|
|
715
|
-
if (!this.live
|
|
575
|
+
if (!this.live) {
|
|
716
576
|
void this.warn(definition.id, `emitEvent("${name}") ignored: extension is detached.`);
|
|
717
577
|
return;
|
|
718
578
|
}
|
|
@@ -731,27 +591,24 @@ export class ExtensionRuntime {
|
|
|
731
591
|
});
|
|
732
592
|
},
|
|
733
593
|
expose: (handle) => {
|
|
734
|
-
if (!this.live
|
|
594
|
+
if (!this.live) {
|
|
735
595
|
void this.warn(definition.id, "expose() ignored: extension is detached.");
|
|
736
596
|
return () => undefined;
|
|
737
597
|
}
|
|
738
|
-
this.
|
|
598
|
+
this.exposed = handle;
|
|
739
599
|
const dispose = () => {
|
|
740
|
-
if (this.
|
|
741
|
-
this.
|
|
600
|
+
if (this.exposed === handle)
|
|
601
|
+
this.exposed = undefined;
|
|
742
602
|
};
|
|
743
603
|
registrations.push(dispose);
|
|
744
604
|
return dispose;
|
|
745
605
|
},
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const writes = this.recordWrites.get(definition.id) ?? [];
|
|
749
|
-
return [...snapshot, ...writes];
|
|
750
|
-
},
|
|
606
|
+
// The engine keeps both, partitioned by this capability's name — the extension's id.
|
|
607
|
+
records: () => this.requireSession().records(),
|
|
751
608
|
state: {
|
|
752
|
-
get: (key) => this.
|
|
753
|
-
set: (key, value) => this.
|
|
754
|
-
delete: (key) => this.
|
|
609
|
+
get: (key) => this.requireSession().state.get(key),
|
|
610
|
+
set: (key, value) => this.requireSession().state.set(key, value),
|
|
611
|
+
delete: (key) => this.requireSession().state.delete(key),
|
|
755
612
|
},
|
|
756
613
|
actions: this.actionsFor(definition.id),
|
|
757
614
|
};
|
|
@@ -777,13 +634,13 @@ export class ExtensionRuntime {
|
|
|
777
634
|
get(_target, prop) {
|
|
778
635
|
if (isProbeProperty(prop))
|
|
779
636
|
return undefined;
|
|
780
|
-
if (!runtime.live
|
|
637
|
+
if (!runtime.live)
|
|
781
638
|
return dead();
|
|
782
639
|
const inner = services.handle(name)[prop];
|
|
783
640
|
if (typeof inner !== "function")
|
|
784
641
|
return inner;
|
|
785
642
|
return (...args) => {
|
|
786
|
-
if (!runtime.live
|
|
643
|
+
if (!runtime.live)
|
|
787
644
|
return dead()();
|
|
788
645
|
return inner(...args);
|
|
789
646
|
};
|
|
@@ -791,7 +648,7 @@ export class ExtensionRuntime {
|
|
|
791
648
|
// Forward `in` so consumers can probe registration ("route" in handle) through the
|
|
792
649
|
// collar; a detached extension's probe reads false like a dead handle's.
|
|
793
650
|
has: (_target, prop) => {
|
|
794
|
-
if (!runtime.live
|
|
651
|
+
if (!runtime.live)
|
|
795
652
|
return false;
|
|
796
653
|
return Reflect.has(services.handle(name), prop);
|
|
797
654
|
},
|
|
@@ -819,7 +676,7 @@ export class ExtensionRuntime {
|
|
|
819
676
|
return new Proxy(actions, {
|
|
820
677
|
get(target, prop, receiver) {
|
|
821
678
|
const value = Reflect.get(target, prop, receiver);
|
|
822
|
-
if (typeof value !== "function" || runtime.live
|
|
679
|
+
if (typeof value !== "function" || runtime.live)
|
|
823
680
|
return value;
|
|
824
681
|
return (..._args) => {
|
|
825
682
|
void runtime.warn(extensionId, `${String(prop)}() ignored: extension is detached.`);
|
|
@@ -840,19 +697,15 @@ export class ExtensionRuntime {
|
|
|
840
697
|
return this.revocable(extensionId, {
|
|
841
698
|
steer: (content, options) => runtime.enqueue(extensionId, content, "steering", options?.metadata),
|
|
842
699
|
followUp: (content, options) => runtime.enqueue(extensionId, content, "follow_up", options?.metadata),
|
|
700
|
+
// Written by the engine to the session's audit shard as `extension:<id>:<name>`, and seen
|
|
701
|
+
// by `records()` straight away.
|
|
843
702
|
record: (name, data) => {
|
|
844
|
-
const
|
|
845
|
-
if (!
|
|
846
|
-
void runtime.warn(extensionId, `record("${name}") ignored: no
|
|
703
|
+
const session = runtime.session;
|
|
704
|
+
if (!session) {
|
|
705
|
+
void runtime.warn(extensionId, `record("${name}") ignored: no open session.`);
|
|
847
706
|
return;
|
|
848
707
|
}
|
|
849
|
-
|
|
850
|
-
// Mirror into the in-memory bucket so `records()` sees this session's writes without
|
|
851
|
-
// re-reading the log (the open-time memoized read predates them).
|
|
852
|
-
const bucket = runtime.recordWrites.get(extensionId) ?? [];
|
|
853
|
-
if (!runtime.recordWrites.has(extensionId))
|
|
854
|
-
runtime.recordWrites.set(extensionId, bucket);
|
|
855
|
-
bucket.push({ name, ...(data !== undefined ? { data } : {}) });
|
|
708
|
+
session.record(name, data);
|
|
856
709
|
},
|
|
857
710
|
abort: (reason) => {
|
|
858
711
|
const controls = runtime.controls();
|
|
@@ -942,7 +795,7 @@ export class ExtensionRuntime {
|
|
|
942
795
|
* schema untouched. `source` names the extension so a fold can attribute the message.
|
|
943
796
|
*/
|
|
944
797
|
enqueue(extensionId, content, channel, metadata) {
|
|
945
|
-
const bus = this.
|
|
798
|
+
const bus = this.session?.steer;
|
|
946
799
|
if (!bus) {
|
|
947
800
|
void this.warn(extensionId, `${channel === "steering" ? "steer" : "followUp"}() ignored: no steer bus.`);
|
|
948
801
|
return undefined;
|
|
@@ -975,82 +828,31 @@ export class ExtensionRuntime {
|
|
|
975
828
|
// ==========================================================================
|
|
976
829
|
sessionContext(extensionId) {
|
|
977
830
|
const session = this.requireSession();
|
|
831
|
+
const store = session.store;
|
|
978
832
|
return {
|
|
979
833
|
extensionId,
|
|
980
834
|
sessionId: session.sessionId,
|
|
981
835
|
signal: session.signal,
|
|
982
|
-
|
|
983
|
-
store
|
|
984
|
-
state:
|
|
836
|
+
environment: session.environment,
|
|
837
|
+
store,
|
|
838
|
+
state: session.state,
|
|
985
839
|
actions: this.actionsFor(extensionId),
|
|
986
840
|
};
|
|
987
841
|
}
|
|
988
842
|
eventContext(extensionId, origin) {
|
|
989
843
|
const session = this.requireSession();
|
|
844
|
+
const store = session.store;
|
|
990
845
|
return {
|
|
991
846
|
extensionId,
|
|
992
847
|
sessionId: session.sessionId,
|
|
993
848
|
address: origin.address ?? "main",
|
|
994
849
|
signal: origin.signal,
|
|
995
|
-
|
|
996
|
-
store
|
|
997
|
-
state:
|
|
850
|
+
environment: session.environment,
|
|
851
|
+
store,
|
|
852
|
+
state: session.state,
|
|
998
853
|
actions: this.actionsFor(extensionId),
|
|
999
854
|
};
|
|
1000
855
|
}
|
|
1001
|
-
/** Open-time record buckets, built ONCE off the session's shared memoized log read — the
|
|
1002
|
-
* same single read goal/plan/todo fold from, so `records()` adds zero log traversals. One
|
|
1003
|
-
* filter pass buckets every extension's records; ids are colon-free slugs so the
|
|
1004
|
-
* "extension:<id>:" prefix parse is unambiguous. */
|
|
1005
|
-
async ensureRecordSnapshot() {
|
|
1006
|
-
if (this.recordSnapshot !== undefined)
|
|
1007
|
-
return this.recordSnapshot;
|
|
1008
|
-
const buckets = new Map();
|
|
1009
|
-
const log = this.session?.logRecords !== undefined ? await this.session.logRecords() : [];
|
|
1010
|
-
for (const record of log) {
|
|
1011
|
-
if (record.type !== "custom")
|
|
1012
|
-
continue;
|
|
1013
|
-
const full = record.name;
|
|
1014
|
-
if (typeof full !== "string" || !full.startsWith("extension:"))
|
|
1015
|
-
continue;
|
|
1016
|
-
const rest = full.slice("extension:".length);
|
|
1017
|
-
const sep = rest.indexOf(":");
|
|
1018
|
-
if (sep <= 0)
|
|
1019
|
-
continue;
|
|
1020
|
-
const id = rest.slice(0, sep);
|
|
1021
|
-
const data = record.data;
|
|
1022
|
-
const bucket = buckets.get(id) ?? [];
|
|
1023
|
-
if (!buckets.has(id))
|
|
1024
|
-
buckets.set(id, bucket);
|
|
1025
|
-
bucket.push({ name: rest.slice(sep + 1), ...(data !== undefined ? { data } : {}) });
|
|
1026
|
-
}
|
|
1027
|
-
this.recordSnapshot = buckets;
|
|
1028
|
-
return buckets;
|
|
1029
|
-
}
|
|
1030
|
-
stateFor(extensionId, store) {
|
|
1031
|
-
const prefix = `extension:${extensionId}:`;
|
|
1032
|
-
return {
|
|
1033
|
-
get: async (key) => {
|
|
1034
|
-
const full = prefix + key;
|
|
1035
|
-
const value = store ? await store.getState(full) : this.memoryState.get(full) ?? null;
|
|
1036
|
-
return value;
|
|
1037
|
-
},
|
|
1038
|
-
set: async (key, value) => {
|
|
1039
|
-
const full = prefix + key;
|
|
1040
|
-
if (store)
|
|
1041
|
-
await store.putState(full, value);
|
|
1042
|
-
else
|
|
1043
|
-
this.memoryState.set(full, value);
|
|
1044
|
-
},
|
|
1045
|
-
delete: async (key) => {
|
|
1046
|
-
const full = prefix + key;
|
|
1047
|
-
if (store)
|
|
1048
|
-
await store.deleteState(full);
|
|
1049
|
-
else
|
|
1050
|
-
this.memoryState.delete(full);
|
|
1051
|
-
},
|
|
1052
|
-
};
|
|
1053
|
-
}
|
|
1054
856
|
handlersFor(name) {
|
|
1055
857
|
return (this.handlers.get(name) ?? []);
|
|
1056
858
|
}
|