operon-agents 0.1.0-alpha.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 +55 -0
- package/README.md +18 -0
- package/dist/app-server/client.d.ts +80 -0
- package/dist/app-server/client.js +187 -0
- package/dist/app-server/codec.d.ts +52 -0
- package/dist/app-server/codec.js +252 -0
- package/dist/app-server/index.d.ts +22 -0
- package/dist/app-server/index.js +22 -0
- package/dist/app-server/main.d.ts +3 -0
- package/dist/app-server/main.js +85 -0
- package/dist/app-server/protocol.d.ts +217 -0
- package/dist/app-server/protocol.js +100 -0
- package/dist/app-server/server.d.ts +51 -0
- package/dist/app-server/server.js +274 -0
- package/dist/core.d.ts +12 -0
- package/dist/core.js +12 -0
- package/dist/cron/clock.d.ts +11 -0
- package/dist/cron/clock.js +19 -0
- package/dist/cron/cron-expr.d.ts +15 -0
- package/dist/cron/cron-expr.js +267 -0
- package/dist/cron/extension.d.ts +25 -0
- package/dist/cron/extension.js +141 -0
- package/dist/cron/index.d.ts +20 -0
- package/dist/cron/index.js +11 -0
- package/dist/cron/jitter.d.ts +18 -0
- package/dist/cron/jitter.js +46 -0
- package/dist/cron/manager.d.ts +55 -0
- package/dist/cron/manager.js +123 -0
- package/dist/cron/persist.d.ts +10 -0
- package/dist/cron/persist.js +22 -0
- package/dist/cron/scheduler.d.ts +25 -0
- package/dist/cron/scheduler.js +192 -0
- package/dist/cron/session-store.d.ts +14 -0
- package/dist/cron/session-store.js +50 -0
- package/dist/cron/time-format.d.ts +2 -0
- package/dist/cron/time-format.js +12 -0
- package/dist/cron/tools.d.ts +7 -0
- package/dist/cron/tools.js +161 -0
- package/dist/cron/types.d.ts +9 -0
- package/dist/cron/types.js +2 -0
- package/dist/extensions/index.d.ts +23 -0
- package/dist/extensions/index.js +41 -0
- package/dist/extensions/loader.d.ts +121 -0
- package/dist/extensions/loader.js +285 -0
- package/dist/extensions/manager.d.ts +103 -0
- package/dist/extensions/manager.js +190 -0
- package/dist/extensions/runtime.d.ts +188 -0
- package/dist/extensions/runtime.js +1127 -0
- package/dist/extensions/services.d.ts +85 -0
- package/dist/extensions/services.js +265 -0
- package/dist/extensions/types.d.ts +594 -0
- package/dist/extensions/types.js +2 -0
- package/dist/harness.d.ts +746 -0
- package/dist/harness.js +1483 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +29 -0
- package/dist/internal.d.ts +9 -0
- package/dist/internal.js +9 -0
- package/dist/llm.d.ts +2 -0
- package/dist/llm.js +2 -0
- package/dist/local.d.ts +24 -0
- package/dist/local.js +58 -0
- package/dist/mcp.d.ts +2 -0
- package/dist/mcp.js +2 -0
- package/dist/protocol.d.ts +2 -0
- package/dist/protocol.js +2 -0
- package/dist/tools.d.ts +2 -0
- package/dist/tools.js +2 -0
- package/dist/tracing.d.ts +2 -0
- package/dist/tracing.js +2 -0
- package/package.json +78 -0
|
@@ -0,0 +1,1127 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { ServiceUnavailableError, isProbeProperty } from "./services.js";
|
|
3
|
+
import { tagToolSource } from "operon-agents-core";
|
|
4
|
+
/** Decision points block the loop, so they get room to do real work (network, subprocess). */
|
|
5
|
+
const DECISION_TIMEOUT_MS = 30_000;
|
|
6
|
+
/** Observers must not become latency: a slow one is dropped, not waited on. */
|
|
7
|
+
const OBSERVE_TIMEOUT_MS = 1_000;
|
|
8
|
+
const OBSERVE_EVENTS = new Set([
|
|
9
|
+
"session.start",
|
|
10
|
+
"session.end",
|
|
11
|
+
// Runs after the HTTP response arrives but BEFORE its body is consumed — a slow handler
|
|
12
|
+
// here is latency on every streamed token, so it gets the short budget.
|
|
13
|
+
"provider.response",
|
|
14
|
+
]);
|
|
15
|
+
export class ExtensionRuntime {
|
|
16
|
+
definitions;
|
|
17
|
+
handlers = new Map();
|
|
18
|
+
tools = new Map();
|
|
19
|
+
/** Stable reference: `extensionsCapability` hands this array to the assembler, and `setup`
|
|
20
|
+
* (which runs at openSession, before any run assembles) fills it in place. */
|
|
21
|
+
injectors = [];
|
|
22
|
+
/** Slash commands by normalized name — surfaced to the session's registry via the
|
|
23
|
+
* `sessionCommands()` duck protocol. Two extensions claiming one name fail closed. */
|
|
24
|
+
extCommands = new Map();
|
|
25
|
+
/** Host-facing control surfaces published via `api.expose`, by extension id. */
|
|
26
|
+
exposedHandles = new Map();
|
|
27
|
+
/** Records read path: open-time snapshot buckets (built lazily off the session's shared
|
|
28
|
+
* memoized log read — zero extra traversal) + everything recorded this session. */
|
|
29
|
+
recordSnapshot;
|
|
30
|
+
recordWrites = new Map();
|
|
31
|
+
/** Per-extension teardown: setup's cleanup + every registration, undone in reverse. */
|
|
32
|
+
scopes = new Map();
|
|
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();
|
|
43
|
+
session;
|
|
44
|
+
run;
|
|
45
|
+
/** The conversation shard the in-flight decision point belongs to; backs `actions.record`. */
|
|
46
|
+
activeContext;
|
|
47
|
+
/** Snapshot of the last assembled registry, refreshed once per turn by `filterTools`. */
|
|
48
|
+
allToolNames = [];
|
|
49
|
+
/** `null` = unrestricted. Set by `actions.setActiveTools`; applied at the next assembly. */
|
|
50
|
+
activeToolNames = null;
|
|
51
|
+
reportingWarning = false;
|
|
52
|
+
/** Harness reach. Absent when the capability is built standalone (bare Runner / tests). */
|
|
53
|
+
host;
|
|
54
|
+
/** This session's per-session extension arguments, by extension id. A value of `false` means
|
|
55
|
+
* "skip this extension for this session"; any other value is passed to `setup` as `params`. */
|
|
56
|
+
params;
|
|
57
|
+
constructor(definitions, host, params = {}) {
|
|
58
|
+
this.host = host;
|
|
59
|
+
this.params = params;
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
for (const definition of definitions) {
|
|
62
|
+
if (!definition.id.trim())
|
|
63
|
+
throw new Error("extension id must not be empty");
|
|
64
|
+
// Slug only, colons forbidden: records and state scope by the "extension:<id>:" prefix,
|
|
65
|
+
// so an id containing ":" would make one extension's bucket swallow another's.
|
|
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);
|
|
72
|
+
}
|
|
73
|
+
// A session that opted an extension out (`params[id] === false`) never carries it: not in
|
|
74
|
+
// the definition list, never set up, never reported as attached.
|
|
75
|
+
this.definitions = definitions.filter((definition) => params[definition.id] !== false);
|
|
76
|
+
}
|
|
77
|
+
// ==========================================================================
|
|
78
|
+
// Lifecycle
|
|
79
|
+
// ==========================================================================
|
|
80
|
+
async open(ctx, reason = "open") {
|
|
81
|
+
this.session = ctx;
|
|
82
|
+
for (const definition of this.definitions) {
|
|
83
|
+
await this.setupExtension(definition);
|
|
84
|
+
}
|
|
85
|
+
for (const registered of [...this.handlersFor("session.start")]) {
|
|
86
|
+
await this.invoke("session.start", registered, { ...this.sessionContext(registered.extensionId), reason });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
attachRun(ctx) {
|
|
90
|
+
this.run = ctx;
|
|
91
|
+
}
|
|
92
|
+
async detachRun() {
|
|
93
|
+
this.run = undefined;
|
|
94
|
+
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
|
+
}
|
|
98
|
+
async close(reason = "close") {
|
|
99
|
+
if (this.session) {
|
|
100
|
+
for (const registered of [...this.handlersFor("session.end")]) {
|
|
101
|
+
await this.invoke("session.end", registered, { ...this.sessionContext(registered.extensionId), reason });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
for (const [id, scope] of [...this.scopes].reverse()) {
|
|
105
|
+
try {
|
|
106
|
+
await scope();
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
await this.warn(id, `teardown failed: ${messageOf(error)}`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
this.scopes.clear();
|
|
113
|
+
this.live.clear();
|
|
114
|
+
this.extCommands.clear();
|
|
115
|
+
this.exposedHandles.clear();
|
|
116
|
+
this.recordSnapshot = undefined;
|
|
117
|
+
this.recordWrites.clear();
|
|
118
|
+
for (const entry of this.pending.splice(0))
|
|
119
|
+
entry.reject(new Error("session closed"));
|
|
120
|
+
this.injectors.length = 0;
|
|
121
|
+
this.session = undefined;
|
|
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);
|
|
152
|
+
}
|
|
153
|
+
/** Remove an extension: its `session.end` fires, then its whole scope is torn down. */
|
|
154
|
+
detach(id) {
|
|
155
|
+
return this.submit({ kind: "detach", id });
|
|
156
|
+
}
|
|
157
|
+
submit(change) {
|
|
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. */
|
|
246
|
+
async setupExtension(definition) {
|
|
247
|
+
const params = this.params[definition.id];
|
|
248
|
+
// A `create`-bearing definition reaches a session only after the harness ran its `create`
|
|
249
|
+
// and registered the result under the id. Not registered ⇒ it was handed to a session
|
|
250
|
+
// directly instead of being registered — a programming error, so it throws rather than
|
|
251
|
+
// being skipped.
|
|
252
|
+
if (definition.create !== undefined && this.host?.services?.has(definition.id) !== true) {
|
|
253
|
+
throw new Error(`extension "${definition.id}" has a create half but no service "${definition.id}" is registered — its create never ran. Register it in createHarness({ extensions }) or load it from extensionDir; a definition with create cannot be handed to a session directly`);
|
|
254
|
+
}
|
|
255
|
+
const shared = definition.create !== undefined ? this.serviceHandle(definition.id, definition.id) : undefined;
|
|
256
|
+
const registrations = [];
|
|
257
|
+
this.live.add(definition.id);
|
|
258
|
+
try {
|
|
259
|
+
// `uses`: checked at registration, re-checked here (a provider may have unloaded since)
|
|
260
|
+
// and handed in resolved — an extension never looks a service up by name.
|
|
261
|
+
const services = {};
|
|
262
|
+
for (const name of definition.uses ?? []) {
|
|
263
|
+
if (this.host?.services?.has(name) !== true)
|
|
264
|
+
throw new Error(`uses "${name}": no such service is registered`);
|
|
265
|
+
services[name] = this.serviceHandle(definition.id, name);
|
|
266
|
+
}
|
|
267
|
+
const teardown = await definition.setup(this.apiFor(definition, registrations), { shared, params, services });
|
|
268
|
+
this.scopes.set(definition.id, async () => {
|
|
269
|
+
if (teardown)
|
|
270
|
+
await teardown();
|
|
271
|
+
for (const dispose of [...registrations].reverse())
|
|
272
|
+
dispose();
|
|
273
|
+
});
|
|
274
|
+
return true;
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
this.live.delete(definition.id);
|
|
278
|
+
for (const dispose of [...registrations].reverse())
|
|
279
|
+
dispose();
|
|
280
|
+
await this.warn(definition.id, `setup failed; extension skipped: ${messageOf(error)}`);
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Durable audit trail: replay needs "tool X only exists after record N" to reconstruct the
|
|
286
|
+
* capability timeline. Rides the `custom` record type — audit-only, ignored by reducers.
|
|
287
|
+
*/
|
|
288
|
+
async logChange(kind, extensionId) {
|
|
289
|
+
const store = this.session?.store;
|
|
290
|
+
if (!store)
|
|
291
|
+
return;
|
|
292
|
+
try {
|
|
293
|
+
await store.appendRecord({ type: "custom", name: `extensions.${kind}`, data: { extensionId } });
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
await this.warn(extensionId, `failed to journal extension ${kind}: ${messageOf(error)}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
listTools() {
|
|
300
|
+
return [...this.tools.values()].map((entry) => entry.tool);
|
|
301
|
+
}
|
|
302
|
+
listInjectors() {
|
|
303
|
+
return this.injectors;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The capability's `ToolFilter`. Snapshots the full registry (so `getAllTools` has something
|
|
307
|
+
* to report) and applies the active-tool restriction, if any. Identity-returns when nothing
|
|
308
|
+
* is restricted, which lets the caller skip rebuilding the deferred-name set.
|
|
309
|
+
*/
|
|
310
|
+
filterTools = (tools) => {
|
|
311
|
+
this.allToolNames = tools.map((tool) => tool.schema.name);
|
|
312
|
+
const allow = this.activeToolNames;
|
|
313
|
+
if (allow === null)
|
|
314
|
+
return tools;
|
|
315
|
+
return tools.filter((tool) => allow.has(tool.schema.name));
|
|
316
|
+
};
|
|
317
|
+
// ==========================================================================
|
|
318
|
+
// Dispatch — one method per loop-hook decision point
|
|
319
|
+
// ==========================================================================
|
|
320
|
+
/**
|
|
321
|
+
* Run-tier: the caller's input, before guardrails and before anything is journaled.
|
|
322
|
+
* There is no active conversation yet, so this borrows the session context's shape.
|
|
323
|
+
*/
|
|
324
|
+
async beforeRun(origin, input) {
|
|
325
|
+
let current = input;
|
|
326
|
+
let changed = false;
|
|
327
|
+
for (const registered of this.handlersFor("run.start")) {
|
|
328
|
+
const result = await this.invoke("run.start", registered, {
|
|
329
|
+
...this.sessionContext(registered.extensionId),
|
|
330
|
+
address: origin.address,
|
|
331
|
+
signal: origin.signal,
|
|
332
|
+
agent: origin.agent,
|
|
333
|
+
input: current,
|
|
334
|
+
});
|
|
335
|
+
// First claim wins; the runner stops consulting hooks once a run is answered.
|
|
336
|
+
if (result?.handled !== undefined)
|
|
337
|
+
return { handled: result.handled };
|
|
338
|
+
if (result?.input !== undefined) {
|
|
339
|
+
current = result.input;
|
|
340
|
+
changed = true;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return changed ? { input: current } : undefined;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* The capability's compaction gate. Not engine-driven: the compaction capability calls this
|
|
347
|
+
* through core's `CapabilityGates`, which is why there is no matching `LoopHooks` slot.
|
|
348
|
+
*
|
|
349
|
+
* There is no run-scoped address here (compaction is a session-level operation on one shard),
|
|
350
|
+
* so handlers get the session context shape plus the shard being compacted.
|
|
351
|
+
*/
|
|
352
|
+
compactionGate = async (ctx) => {
|
|
353
|
+
const handlers = this.handlersFor("compaction.before");
|
|
354
|
+
if (handlers.length === 0)
|
|
355
|
+
return undefined;
|
|
356
|
+
let replacement;
|
|
357
|
+
for (const registered of handlers) {
|
|
358
|
+
const result = await this.invoke("compaction.before", registered, {
|
|
359
|
+
...this.sessionContext(registered.extensionId),
|
|
360
|
+
address: this.activeContext?.address ?? "main",
|
|
361
|
+
signal: ctx.signal,
|
|
362
|
+
reason: ctx.reason,
|
|
363
|
+
messages: ctx.messages,
|
|
364
|
+
compactCount: ctx.compactCount,
|
|
365
|
+
});
|
|
366
|
+
if (result?.cancel === true)
|
|
367
|
+
return { cancel: true };
|
|
368
|
+
if (result?.replacement !== undefined && replacement === undefined)
|
|
369
|
+
replacement = result.replacement;
|
|
370
|
+
}
|
|
371
|
+
return replacement ? { replacement } : undefined;
|
|
372
|
+
};
|
|
373
|
+
async beforeStep(origin, context, system) {
|
|
374
|
+
this.activeContext = context;
|
|
375
|
+
let currentSystem = system;
|
|
376
|
+
let changed = false;
|
|
377
|
+
for (const registered of this.handlersFor("step.start")) {
|
|
378
|
+
const result = await this.invoke("step.start", registered, {
|
|
379
|
+
...this.eventContext(registered.extensionId, origin),
|
|
380
|
+
turnId: origin.turnId,
|
|
381
|
+
stepNumber: origin.stepNumber,
|
|
382
|
+
model: origin.model,
|
|
383
|
+
context,
|
|
384
|
+
system: currentSystem,
|
|
385
|
+
});
|
|
386
|
+
if (result?.block === true)
|
|
387
|
+
return result;
|
|
388
|
+
if (result?.system !== undefined) {
|
|
389
|
+
currentSystem = result.system;
|
|
390
|
+
changed = true;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return changed ? { system: currentSystem } : undefined;
|
|
394
|
+
}
|
|
395
|
+
async afterStep(origin, context, usage, stopReason) {
|
|
396
|
+
this.activeContext = context;
|
|
397
|
+
let stopTurn = false;
|
|
398
|
+
for (const registered of this.handlersFor("step.end")) {
|
|
399
|
+
const result = await this.invoke("step.end", registered, {
|
|
400
|
+
...this.eventContext(registered.extensionId, origin),
|
|
401
|
+
turnId: origin.turnId,
|
|
402
|
+
stepNumber: origin.stepNumber,
|
|
403
|
+
model: origin.model,
|
|
404
|
+
usage,
|
|
405
|
+
stopReason,
|
|
406
|
+
context,
|
|
407
|
+
});
|
|
408
|
+
if (result?.stopTurn === true)
|
|
409
|
+
stopTurn = true;
|
|
410
|
+
}
|
|
411
|
+
return stopTurn ? { stopTurn: true } : undefined;
|
|
412
|
+
}
|
|
413
|
+
async runSettled(origin, usage, stopReason) {
|
|
414
|
+
for (const registered of this.handlersFor("run.settled")) {
|
|
415
|
+
const result = await this.invoke("run.settled", registered, {
|
|
416
|
+
...this.eventContext(registered.extensionId, origin),
|
|
417
|
+
turnId: origin.turnId,
|
|
418
|
+
stepNumber: origin.stepNumber,
|
|
419
|
+
model: origin.model,
|
|
420
|
+
usage,
|
|
421
|
+
stopReason,
|
|
422
|
+
});
|
|
423
|
+
if (result?.continue === true)
|
|
424
|
+
return { continue: true };
|
|
425
|
+
}
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
async beforeModelRequest(origin, request, context) {
|
|
429
|
+
this.activeContext = context;
|
|
430
|
+
let current = request;
|
|
431
|
+
let changed = false;
|
|
432
|
+
for (const registered of this.handlersFor("model.request")) {
|
|
433
|
+
const result = await this.invoke("model.request", registered, {
|
|
434
|
+
...this.eventContext(registered.extensionId, origin),
|
|
435
|
+
turnId: origin.turnId,
|
|
436
|
+
stepNumber: origin.stepNumber,
|
|
437
|
+
model: origin.model,
|
|
438
|
+
request: current,
|
|
439
|
+
context,
|
|
440
|
+
});
|
|
441
|
+
if (!result)
|
|
442
|
+
continue;
|
|
443
|
+
if (result.block === true)
|
|
444
|
+
return result;
|
|
445
|
+
if (result.request !== undefined) {
|
|
446
|
+
current = result.request;
|
|
447
|
+
changed = true;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
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
|
+
return changed ? { request: current } : undefined;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Attach `transformHeaders` / `onPayload` / `onResponse` when anything is listening. Returns
|
|
462
|
+
* `undefined` when nothing is, so a request with no provider-tier extensions is left byte-identical.
|
|
463
|
+
*
|
|
464
|
+
* These fire once per HTTP ATTEMPT, so a retry re-runs them — the contract tells handlers to be
|
|
465
|
+
* idempotent, and the runtime does not try to dedupe on their behalf.
|
|
466
|
+
*/
|
|
467
|
+
withProviderHooks(request, origin) {
|
|
468
|
+
const headerHandlers = this.handlersFor("provider.headers");
|
|
469
|
+
const payloadHandlers = this.handlersFor("provider.payload");
|
|
470
|
+
const responseHandlers = this.handlersFor("provider.response");
|
|
471
|
+
if (headerHandlers.length === 0 && payloadHandlers.length === 0 && responseHandlers.length === 0) {
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
const base = { turnId: origin.turnId, stepNumber: origin.stepNumber };
|
|
475
|
+
const providerOptions = { ...request.providerOptions };
|
|
476
|
+
if (headerHandlers.length > 0) {
|
|
477
|
+
providerOptions.transformHeaders = async (headers) => {
|
|
478
|
+
let current = headers;
|
|
479
|
+
for (const registered of headerHandlers) {
|
|
480
|
+
const result = await this.invoke("provider.headers", registered, {
|
|
481
|
+
...this.eventContext(registered.extensionId, origin),
|
|
482
|
+
...base,
|
|
483
|
+
headers: current,
|
|
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
|
+
});
|
|
515
|
+
}
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
return { ...request, providerOptions };
|
|
519
|
+
}
|
|
520
|
+
async afterModelResponse(origin, request, response, context) {
|
|
521
|
+
this.activeContext = context;
|
|
522
|
+
let current = response;
|
|
523
|
+
let changed = false;
|
|
524
|
+
for (const registered of this.handlersFor("model.response")) {
|
|
525
|
+
const result = await this.invoke("model.response", registered, {
|
|
526
|
+
...this.eventContext(registered.extensionId, origin),
|
|
527
|
+
turnId: origin.turnId,
|
|
528
|
+
stepNumber: origin.stepNumber,
|
|
529
|
+
model: origin.model,
|
|
530
|
+
request,
|
|
531
|
+
response: current,
|
|
532
|
+
context,
|
|
533
|
+
});
|
|
534
|
+
if (result !== undefined) {
|
|
535
|
+
current = result;
|
|
536
|
+
changed = true;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
return changed ? current : undefined;
|
|
540
|
+
}
|
|
541
|
+
async beforeToolCall(origin, toolName, toolCallId, tool, args) {
|
|
542
|
+
let currentArgs = args;
|
|
543
|
+
let changed = false;
|
|
544
|
+
for (const registered of this.handlersFor("tool.call")) {
|
|
545
|
+
const result = await this.invoke("tool.call", registered, {
|
|
546
|
+
...this.eventContext(registered.extensionId, origin),
|
|
547
|
+
turnId: origin.turnId,
|
|
548
|
+
stepNumber: origin.stepNumber,
|
|
549
|
+
toolName,
|
|
550
|
+
toolCallId,
|
|
551
|
+
tool,
|
|
552
|
+
args: currentArgs,
|
|
553
|
+
});
|
|
554
|
+
if (!result)
|
|
555
|
+
continue;
|
|
556
|
+
if (result.block === true || result.syntheticResult !== undefined)
|
|
557
|
+
return result;
|
|
558
|
+
// `terminate` without `block` is meaningless — the call runs, and a running tool
|
|
559
|
+
// signals turn termination through its own result.
|
|
560
|
+
if (result.updatedArgs !== undefined) {
|
|
561
|
+
currentArgs = result.updatedArgs;
|
|
562
|
+
changed = true;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return changed ? { updatedArgs: currentArgs } : undefined;
|
|
566
|
+
}
|
|
567
|
+
async authorizeToolCall(origin, toolName, toolCallId, tool, args, plan) {
|
|
568
|
+
for (const registered of this.handlersFor("tool.authorize")) {
|
|
569
|
+
const result = await this.invoke("tool.authorize", registered, {
|
|
570
|
+
...this.eventContext(registered.extensionId, origin),
|
|
571
|
+
turnId: origin.turnId,
|
|
572
|
+
stepNumber: origin.stepNumber,
|
|
573
|
+
toolName,
|
|
574
|
+
toolCallId,
|
|
575
|
+
tool,
|
|
576
|
+
args,
|
|
577
|
+
plan,
|
|
578
|
+
});
|
|
579
|
+
if (result?.block === true || result?.interrupt !== undefined || result?.syntheticResult !== undefined) {
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
async afterToolResult(origin, toolName, toolCallId, tool, args, result) {
|
|
586
|
+
let current = result;
|
|
587
|
+
let changed = false;
|
|
588
|
+
for (const registered of this.handlersFor("tool.result")) {
|
|
589
|
+
const next = await this.invoke("tool.result", registered, {
|
|
590
|
+
...this.eventContext(registered.extensionId, origin),
|
|
591
|
+
turnId: origin.turnId,
|
|
592
|
+
stepNumber: origin.stepNumber,
|
|
593
|
+
toolName,
|
|
594
|
+
toolCallId,
|
|
595
|
+
tool,
|
|
596
|
+
args,
|
|
597
|
+
result: current,
|
|
598
|
+
});
|
|
599
|
+
if (next !== undefined) {
|
|
600
|
+
current = next;
|
|
601
|
+
changed = true;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return changed ? current : undefined;
|
|
605
|
+
}
|
|
606
|
+
// ==========================================================================
|
|
607
|
+
// Registration
|
|
608
|
+
// ==========================================================================
|
|
609
|
+
apiFor(definition, registrations) {
|
|
610
|
+
return {
|
|
611
|
+
on: (event, handler) => {
|
|
612
|
+
if (!this.live.has(definition.id)) {
|
|
613
|
+
void this.warn(definition.id, `on("${event}") ignored: extension is detached.`);
|
|
614
|
+
return () => undefined;
|
|
615
|
+
}
|
|
616
|
+
const entry = {
|
|
617
|
+
extensionId: definition.id,
|
|
618
|
+
timeoutMs: definition.timeoutMs,
|
|
619
|
+
handler: handler,
|
|
620
|
+
};
|
|
621
|
+
const list = this.handlers.get(event) ?? [];
|
|
622
|
+
list.push(entry);
|
|
623
|
+
this.handlers.set(event, list);
|
|
624
|
+
const dispose = () => {
|
|
625
|
+
const index = list.indexOf(entry);
|
|
626
|
+
if (index >= 0)
|
|
627
|
+
list.splice(index, 1);
|
|
628
|
+
};
|
|
629
|
+
registrations.push(dispose);
|
|
630
|
+
return dispose;
|
|
631
|
+
},
|
|
632
|
+
onEvent: (listener) => {
|
|
633
|
+
if (!this.live.has(definition.id)) {
|
|
634
|
+
void this.warn(definition.id, "onEvent() ignored: extension is detached.");
|
|
635
|
+
return () => undefined;
|
|
636
|
+
}
|
|
637
|
+
// `setup` runs inside `open`, so the session context is already in place.
|
|
638
|
+
const sink = this.session?.events;
|
|
639
|
+
if (sink === undefined)
|
|
640
|
+
return () => undefined;
|
|
641
|
+
const dispose = sink.subscribe((event) => {
|
|
642
|
+
try {
|
|
643
|
+
listener(event);
|
|
644
|
+
}
|
|
645
|
+
catch (error) {
|
|
646
|
+
// Observation must never disrupt the run that produced the event.
|
|
647
|
+
void this.warn(definition.id, `onEvent listener failed: ${messageOf(error)}`);
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
registrations.push(dispose);
|
|
651
|
+
return dispose;
|
|
652
|
+
},
|
|
653
|
+
registerTool: (toolOrSpec) => {
|
|
654
|
+
// A plain spec (file extensions — no framework imports, so no `tool()` helper) is
|
|
655
|
+
// expanded here; a real Tool passes through untouched.
|
|
656
|
+
const tool = "schema" in toolOrSpec ? toolOrSpec : toolFromSpec(toolOrSpec);
|
|
657
|
+
if (!this.live.has(definition.id)) {
|
|
658
|
+
void this.warn(definition.id, `registerTool("${tool.schema.name}") ignored: extension is detached.`);
|
|
659
|
+
return () => undefined;
|
|
660
|
+
}
|
|
661
|
+
const name = tool.schema.name;
|
|
662
|
+
const existing = this.tools.get(name);
|
|
663
|
+
if (existing) {
|
|
664
|
+
throw new Error(`extension tool "${name}" from "${definition.id}" collides with "${existing.extensionId}"`);
|
|
665
|
+
}
|
|
666
|
+
this.tools.set(name, { extensionId: definition.id, tool });
|
|
667
|
+
// Core's toolset assembly reads this tag to fail a name collision closed, naming us.
|
|
668
|
+
tagToolSource(tool, definition.id);
|
|
669
|
+
const dispose = () => {
|
|
670
|
+
if (this.tools.get(name)?.tool === tool)
|
|
671
|
+
this.tools.delete(name);
|
|
672
|
+
};
|
|
673
|
+
registrations.push(dispose);
|
|
674
|
+
return dispose;
|
|
675
|
+
},
|
|
676
|
+
registerInjector: (injector) => {
|
|
677
|
+
if (!this.live.has(definition.id)) {
|
|
678
|
+
void this.warn(definition.id, `registerInjector("${injector.id}") ignored: extension is detached.`);
|
|
679
|
+
return () => undefined;
|
|
680
|
+
}
|
|
681
|
+
this.injectors.push(injector);
|
|
682
|
+
const dispose = () => {
|
|
683
|
+
const index = this.injectors.indexOf(injector);
|
|
684
|
+
if (index >= 0)
|
|
685
|
+
this.injectors.splice(index, 1);
|
|
686
|
+
};
|
|
687
|
+
registrations.push(dispose);
|
|
688
|
+
return dispose;
|
|
689
|
+
},
|
|
690
|
+
registerCommand: (command) => {
|
|
691
|
+
if (!this.live.has(definition.id)) {
|
|
692
|
+
void this.warn(definition.id, `registerCommand("/${command.name}") ignored: extension is detached.`);
|
|
693
|
+
return () => undefined;
|
|
694
|
+
}
|
|
695
|
+
const key = command.name.trim().toLowerCase();
|
|
696
|
+
const existing = this.extCommands.get(key);
|
|
697
|
+
if (existing) {
|
|
698
|
+
throw new Error(`extension command "/${key}" from "${definition.id}" collides with "${existing.extensionId}"`);
|
|
699
|
+
}
|
|
700
|
+
const headless = {
|
|
701
|
+
name: command.name,
|
|
702
|
+
...(command.aliases !== undefined ? { aliases: command.aliases } : {}),
|
|
703
|
+
description: command.description,
|
|
704
|
+
run: async (_ctx, args) => command.run(args),
|
|
705
|
+
};
|
|
706
|
+
this.extCommands.set(key, { extensionId: definition.id, command: headless });
|
|
707
|
+
const dispose = () => {
|
|
708
|
+
if (this.extCommands.get(key)?.command === headless)
|
|
709
|
+
this.extCommands.delete(key);
|
|
710
|
+
};
|
|
711
|
+
registrations.push(dispose);
|
|
712
|
+
return dispose;
|
|
713
|
+
},
|
|
714
|
+
emitEvent: (name, data) => {
|
|
715
|
+
if (!this.live.has(definition.id)) {
|
|
716
|
+
void this.warn(definition.id, `emitEvent("${name}") ignored: extension is detached.`);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const session = this.session;
|
|
720
|
+
if (!session) {
|
|
721
|
+
void this.warn(definition.id, `emitEvent("${name}") ignored: no open session.`);
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
void session.events.emit({
|
|
725
|
+
address: "main",
|
|
726
|
+
sessionId: session.sessionId,
|
|
727
|
+
type: "extension",
|
|
728
|
+
extensionId: definition.id,
|
|
729
|
+
name,
|
|
730
|
+
...(data !== undefined ? { data } : {}),
|
|
731
|
+
});
|
|
732
|
+
},
|
|
733
|
+
expose: (handle) => {
|
|
734
|
+
if (!this.live.has(definition.id)) {
|
|
735
|
+
void this.warn(definition.id, "expose() ignored: extension is detached.");
|
|
736
|
+
return () => undefined;
|
|
737
|
+
}
|
|
738
|
+
this.exposedHandles.set(definition.id, handle);
|
|
739
|
+
const dispose = () => {
|
|
740
|
+
if (this.exposedHandles.get(definition.id) === handle)
|
|
741
|
+
this.exposedHandles.delete(definition.id);
|
|
742
|
+
};
|
|
743
|
+
registrations.push(dispose);
|
|
744
|
+
return dispose;
|
|
745
|
+
},
|
|
746
|
+
records: async () => {
|
|
747
|
+
const snapshot = (await this.ensureRecordSnapshot()).get(definition.id) ?? [];
|
|
748
|
+
const writes = this.recordWrites.get(definition.id) ?? [];
|
|
749
|
+
return [...snapshot, ...writes];
|
|
750
|
+
},
|
|
751
|
+
state: {
|
|
752
|
+
get: (key) => this.stateFor(definition.id, this.session?.store).get(key),
|
|
753
|
+
set: (key, value) => this.stateFor(definition.id, this.session?.store).set(key, value),
|
|
754
|
+
delete: (key) => this.stateFor(definition.id, this.session?.store).delete(key),
|
|
755
|
+
},
|
|
756
|
+
actions: this.actionsFor(definition.id),
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* A handle to service `name` collared to `extensionId`'s liveness — the `actions` revocation
|
|
761
|
+
* collar mirrored: liveness is checked at CALL time, including for METHODS stashed off the
|
|
762
|
+
* handle before detach (the returned function re-checks on every invocation), so nothing
|
|
763
|
+
* handed out while live keeps operating afterwards. The inner registry handle decides the
|
|
764
|
+
* probe/shape questions (probes read undefined, field access throws); only functions get
|
|
765
|
+
* the wrapper.
|
|
766
|
+
*/
|
|
767
|
+
serviceHandle(extensionId, name) {
|
|
768
|
+
const services = this.host?.services;
|
|
769
|
+
if (services === undefined)
|
|
770
|
+
throw new Error(`service "${name}": this host exposes no services`);
|
|
771
|
+
const runtime = this;
|
|
772
|
+
const dead = () => (..._args) => {
|
|
773
|
+
void runtime.warn(extensionId, `service "${name}" call ignored: extension is detached.`);
|
|
774
|
+
throw new ServiceUnavailableError(name, "missing");
|
|
775
|
+
};
|
|
776
|
+
return new Proxy(Object.create(null), {
|
|
777
|
+
get(_target, prop) {
|
|
778
|
+
if (isProbeProperty(prop))
|
|
779
|
+
return undefined;
|
|
780
|
+
if (!runtime.live.has(extensionId))
|
|
781
|
+
return dead();
|
|
782
|
+
const inner = services.handle(name)[prop];
|
|
783
|
+
if (typeof inner !== "function")
|
|
784
|
+
return inner;
|
|
785
|
+
return (...args) => {
|
|
786
|
+
if (!runtime.live.has(extensionId))
|
|
787
|
+
return dead()();
|
|
788
|
+
return inner(...args);
|
|
789
|
+
};
|
|
790
|
+
},
|
|
791
|
+
// Forward `in` so consumers can probe registration ("route" in handle) through the
|
|
792
|
+
// collar; a detached extension's probe reads false like a dead handle's.
|
|
793
|
+
has: (_target, prop) => {
|
|
794
|
+
if (!runtime.live.has(extensionId))
|
|
795
|
+
return false;
|
|
796
|
+
return Reflect.has(services.handle(name), prop);
|
|
797
|
+
},
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
// ==========================================================================
|
|
801
|
+
// Actions
|
|
802
|
+
// ==========================================================================
|
|
803
|
+
/** viaHost-backed actions promise session handles; when revoked they must reject, not fake. */
|
|
804
|
+
static PROMISE_ACTIONS = new Set([
|
|
805
|
+
"newSession",
|
|
806
|
+
"fork",
|
|
807
|
+
"openSession",
|
|
808
|
+
"listSessions",
|
|
809
|
+
"waitForIdle",
|
|
810
|
+
]);
|
|
811
|
+
/**
|
|
812
|
+
* Revocation collar: a detached extension's stashed `actions` must not keep operating the
|
|
813
|
+
* session. Liveness is checked at CALL time (not build time), so closures handed out before
|
|
814
|
+
* the detach die with it. Reads (`hasActiveRun`) pass through — a stale read is harmless,
|
|
815
|
+
* a stale steer/abort is not.
|
|
816
|
+
*/
|
|
817
|
+
revocable(extensionId, actions) {
|
|
818
|
+
const runtime = this;
|
|
819
|
+
return new Proxy(actions, {
|
|
820
|
+
get(target, prop, receiver) {
|
|
821
|
+
const value = Reflect.get(target, prop, receiver);
|
|
822
|
+
if (typeof value !== "function" || runtime.live.has(extensionId))
|
|
823
|
+
return value;
|
|
824
|
+
return (..._args) => {
|
|
825
|
+
void runtime.warn(extensionId, `${String(prop)}() ignored: extension is detached.`);
|
|
826
|
+
if (ExtensionRuntime.PROMISE_ACTIONS.has(String(prop))) {
|
|
827
|
+
return Promise.reject(new Error(`${String(prop)}() unavailable: extension "${extensionId}" is detached`));
|
|
828
|
+
}
|
|
829
|
+
if (prop === "isIdle")
|
|
830
|
+
return true;
|
|
831
|
+
if (prop === "getAllTools" || prop === "getActiveTools")
|
|
832
|
+
return [];
|
|
833
|
+
return undefined;
|
|
834
|
+
};
|
|
835
|
+
},
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
actionsFor(extensionId) {
|
|
839
|
+
const runtime = this;
|
|
840
|
+
return this.revocable(extensionId, {
|
|
841
|
+
steer: (content, options) => runtime.enqueue(extensionId, content, "steering", options?.metadata),
|
|
842
|
+
followUp: (content, options) => runtime.enqueue(extensionId, content, "follow_up", options?.metadata),
|
|
843
|
+
record: (name, data) => {
|
|
844
|
+
const context = runtime.activeContext;
|
|
845
|
+
if (!context) {
|
|
846
|
+
void runtime.warn(extensionId, `record("${name}") ignored: no active conversation.`);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
context.record({ type: "custom", name: `extension:${extensionId}:${name}`, data });
|
|
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 } : {}) });
|
|
856
|
+
},
|
|
857
|
+
abort: (reason) => {
|
|
858
|
+
const controls = runtime.controls();
|
|
859
|
+
if (!controls) {
|
|
860
|
+
void runtime.warn(extensionId, "abort() ignored: session controls unavailable.");
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
controls.abort(reason ?? `aborted by extension "${extensionId}"`);
|
|
864
|
+
},
|
|
865
|
+
compact: (options) => {
|
|
866
|
+
const controls = runtime.controls();
|
|
867
|
+
if (!controls) {
|
|
868
|
+
void runtime.warn(extensionId, "compact() ignored: session controls unavailable.");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
void controls
|
|
872
|
+
.compact(options?.instruction !== undefined ? { instruction: options.instruction } : {})
|
|
873
|
+
.catch((error) => runtime.warn(extensionId, `compact() failed: ${messageOf(error)}`));
|
|
874
|
+
},
|
|
875
|
+
getContextUsage: () => runtime.controls()?.getContextBreakdown(),
|
|
876
|
+
setModel: (model) => {
|
|
877
|
+
const controls = runtime.controls();
|
|
878
|
+
if (!controls) {
|
|
879
|
+
void runtime.warn(extensionId, "setModel() ignored: session controls unavailable.");
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
try {
|
|
883
|
+
controls.setModel(model);
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
void runtime.warn(extensionId, `setModel() failed: ${messageOf(error)}`);
|
|
887
|
+
}
|
|
888
|
+
},
|
|
889
|
+
setThinkingLevel: (level) => {
|
|
890
|
+
const controls = runtime.controls();
|
|
891
|
+
if (!controls) {
|
|
892
|
+
void runtime.warn(extensionId, "setThinkingLevel() ignored: session controls unavailable.");
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
try {
|
|
896
|
+
controls.setThinking(level);
|
|
897
|
+
}
|
|
898
|
+
catch (error) {
|
|
899
|
+
void runtime.warn(extensionId, `setThinkingLevel() failed: ${messageOf(error)}`);
|
|
900
|
+
}
|
|
901
|
+
},
|
|
902
|
+
getAllTools: () => runtime.allToolNames,
|
|
903
|
+
getActiveTools: () => {
|
|
904
|
+
const allow = runtime.activeToolNames;
|
|
905
|
+
return allow === null ? runtime.allToolNames : runtime.allToolNames.filter((name) => allow.has(name));
|
|
906
|
+
},
|
|
907
|
+
setActiveTools: (names) => {
|
|
908
|
+
runtime.activeToolNames = names === null ? null : new Set(names);
|
|
909
|
+
},
|
|
910
|
+
get hasActiveRun() {
|
|
911
|
+
return runtime.run !== undefined;
|
|
912
|
+
},
|
|
913
|
+
// ── Harness reach ──
|
|
914
|
+
newSession: (options) => runtime.viaHost(extensionId, "newSession", (host) => host.newSession(options)),
|
|
915
|
+
fork: (options) => runtime.viaHost(extensionId, "fork", (host) => host.fork(options)),
|
|
916
|
+
openSession: (id) => runtime.viaHost(extensionId, "openSession", (host) => host.openSession(id)),
|
|
917
|
+
listSessions: () => runtime.viaHost(extensionId, "listSessions", (host) => host.listSessions()),
|
|
918
|
+
registerProvider: (provider) => {
|
|
919
|
+
const host = runtime.host;
|
|
920
|
+
if (!host) {
|
|
921
|
+
void runtime.warn(extensionId, "registerProvider() ignored: no harness host.");
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
host.registerProvider(provider);
|
|
925
|
+
},
|
|
926
|
+
unregisterProvider: (id) => {
|
|
927
|
+
const host = runtime.host;
|
|
928
|
+
if (!host) {
|
|
929
|
+
void runtime.warn(extensionId, "unregisterProvider() ignored: no harness host.");
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
host.unregisterProvider(id);
|
|
933
|
+
},
|
|
934
|
+
// No host ⇒ report the session as idle rather than claiming a run we cannot see.
|
|
935
|
+
isIdle: () => runtime.host?.isIdle() ?? runtime.run === undefined,
|
|
936
|
+
waitForIdle: () => runtime.host?.waitForIdle() ?? Promise.resolve(),
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Extension-originated prompts ride the `external` steer origin — they ARE an outside source
|
|
941
|
+
* as far as the transcript is concerned, and reusing it keeps the persisted `PromptOrigin`
|
|
942
|
+
* schema untouched. `source` names the extension so a fold can attribute the message.
|
|
943
|
+
*/
|
|
944
|
+
enqueue(extensionId, content, channel, metadata) {
|
|
945
|
+
const bus = this.run?.steer ?? this.session?.steer;
|
|
946
|
+
if (!bus) {
|
|
947
|
+
void this.warn(extensionId, `${channel === "steering" ? "steer" : "followUp"}() ignored: no steer bus.`);
|
|
948
|
+
return undefined;
|
|
949
|
+
}
|
|
950
|
+
return bus.steer(content, {
|
|
951
|
+
kind: "extension",
|
|
952
|
+
extensionId,
|
|
953
|
+
...(metadata !== undefined ? { metadata } : {}),
|
|
954
|
+
channel,
|
|
955
|
+
});
|
|
956
|
+
}
|
|
957
|
+
controls() {
|
|
958
|
+
return this.run?.controls ?? this.session?.controls;
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* Host-backed action wrapper. Rejects (rather than silently resolving to something fake) when
|
|
962
|
+
* no host is present — these return values are session handles a caller would go on to use, so
|
|
963
|
+
* a no-op would just move the failure somewhere less obvious.
|
|
964
|
+
*/
|
|
965
|
+
async viaHost(extensionId, action, body) {
|
|
966
|
+
const host = this.host;
|
|
967
|
+
if (!host) {
|
|
968
|
+
await this.warn(extensionId, `${action}() unavailable: this extension runtime has no harness host.`);
|
|
969
|
+
throw new Error(`${action}() requires a harness host`);
|
|
970
|
+
}
|
|
971
|
+
return body(host);
|
|
972
|
+
}
|
|
973
|
+
// ==========================================================================
|
|
974
|
+
// Context assembly + invocation
|
|
975
|
+
// ==========================================================================
|
|
976
|
+
sessionContext(extensionId) {
|
|
977
|
+
const session = this.requireSession();
|
|
978
|
+
return {
|
|
979
|
+
extensionId,
|
|
980
|
+
sessionId: session.sessionId,
|
|
981
|
+
signal: session.signal,
|
|
982
|
+
machine: session.machine,
|
|
983
|
+
store: session.store,
|
|
984
|
+
state: this.stateFor(extensionId, session.store),
|
|
985
|
+
actions: this.actionsFor(extensionId),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
eventContext(extensionId, origin) {
|
|
989
|
+
const session = this.requireSession();
|
|
990
|
+
return {
|
|
991
|
+
extensionId,
|
|
992
|
+
sessionId: session.sessionId,
|
|
993
|
+
address: origin.address ?? "main",
|
|
994
|
+
signal: origin.signal,
|
|
995
|
+
machine: this.run?.machine ?? session.machine,
|
|
996
|
+
store: session.store,
|
|
997
|
+
state: this.stateFor(extensionId, session.store),
|
|
998
|
+
actions: this.actionsFor(extensionId),
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
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
|
+
handlersFor(name) {
|
|
1055
|
+
return (this.handlers.get(name) ?? []);
|
|
1056
|
+
}
|
|
1057
|
+
async invoke(name, registered, event) {
|
|
1058
|
+
const timeoutMs = registered.timeoutMs
|
|
1059
|
+
?? (OBSERVE_EVENTS.has(name) ? OBSERVE_TIMEOUT_MS : DECISION_TIMEOUT_MS);
|
|
1060
|
+
try {
|
|
1061
|
+
return (await withTimeout(Promise.resolve(registered.handler(event)), timeoutMs));
|
|
1062
|
+
}
|
|
1063
|
+
catch (error) {
|
|
1064
|
+
await this.warn(registered.extensionId, `${name} handler failed: ${messageOf(error)}`);
|
|
1065
|
+
return undefined;
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
async warn(extensionId, message) {
|
|
1069
|
+
const session = this.session;
|
|
1070
|
+
if (!session || this.reportingWarning)
|
|
1071
|
+
return;
|
|
1072
|
+
this.reportingWarning = true;
|
|
1073
|
+
try {
|
|
1074
|
+
await session.events.emit({
|
|
1075
|
+
type: "warning",
|
|
1076
|
+
message: `[extension ${extensionId}] ${message}`,
|
|
1077
|
+
address: "main",
|
|
1078
|
+
sessionId: session.sessionId,
|
|
1079
|
+
});
|
|
1080
|
+
}
|
|
1081
|
+
finally {
|
|
1082
|
+
this.reportingWarning = false;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
requireSession() {
|
|
1086
|
+
if (!this.session)
|
|
1087
|
+
throw new Error("extension runtime is not attached to a session");
|
|
1088
|
+
return this.session;
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
function messageOf(error) {
|
|
1092
|
+
return error instanceof Error ? error.message : String(error);
|
|
1093
|
+
}
|
|
1094
|
+
/** Expand a dependency-free {@link ExtensionToolSpec} into a Tool. Args are NOT validated —
|
|
1095
|
+
* the spec form has no validator by design; `execute` owns its own input checking. */
|
|
1096
|
+
function toolFromSpec(spec) {
|
|
1097
|
+
const parameters = spec.parameters ?? { type: "object", properties: {} };
|
|
1098
|
+
return {
|
|
1099
|
+
schema: { name: spec.name, description: spec.description, parameters },
|
|
1100
|
+
resolve(rawArgs) {
|
|
1101
|
+
return {
|
|
1102
|
+
approvalRule: spec.approvalRule ?? spec.name,
|
|
1103
|
+
run: async (ctx) => normalizeSpecReturn(await spec.execute(rawArgs, ctx)),
|
|
1104
|
+
};
|
|
1105
|
+
},
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
function normalizeSpecReturn(value) {
|
|
1109
|
+
if (typeof value === "string")
|
|
1110
|
+
return { content: [{ type: "text", text: value }] };
|
|
1111
|
+
if (Array.isArray(value))
|
|
1112
|
+
return { content: value };
|
|
1113
|
+
return value;
|
|
1114
|
+
}
|
|
1115
|
+
function withTimeout(promise, timeoutMs) {
|
|
1116
|
+
return new Promise((resolve, reject) => {
|
|
1117
|
+
const timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
1118
|
+
promise.then((value) => {
|
|
1119
|
+
clearTimeout(timer);
|
|
1120
|
+
resolve(value);
|
|
1121
|
+
}, (error) => {
|
|
1122
|
+
clearTimeout(timer);
|
|
1123
|
+
reject(error);
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
//# sourceMappingURL=runtime.js.map
|