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.
Files changed (71) hide show
  1. package/LICENSE +55 -0
  2. package/README.md +18 -0
  3. package/dist/app-server/client.d.ts +80 -0
  4. package/dist/app-server/client.js +187 -0
  5. package/dist/app-server/codec.d.ts +52 -0
  6. package/dist/app-server/codec.js +252 -0
  7. package/dist/app-server/index.d.ts +22 -0
  8. package/dist/app-server/index.js +22 -0
  9. package/dist/app-server/main.d.ts +3 -0
  10. package/dist/app-server/main.js +85 -0
  11. package/dist/app-server/protocol.d.ts +217 -0
  12. package/dist/app-server/protocol.js +100 -0
  13. package/dist/app-server/server.d.ts +51 -0
  14. package/dist/app-server/server.js +274 -0
  15. package/dist/core.d.ts +12 -0
  16. package/dist/core.js +12 -0
  17. package/dist/cron/clock.d.ts +11 -0
  18. package/dist/cron/clock.js +19 -0
  19. package/dist/cron/cron-expr.d.ts +15 -0
  20. package/dist/cron/cron-expr.js +267 -0
  21. package/dist/cron/extension.d.ts +25 -0
  22. package/dist/cron/extension.js +141 -0
  23. package/dist/cron/index.d.ts +20 -0
  24. package/dist/cron/index.js +11 -0
  25. package/dist/cron/jitter.d.ts +18 -0
  26. package/dist/cron/jitter.js +46 -0
  27. package/dist/cron/manager.d.ts +55 -0
  28. package/dist/cron/manager.js +123 -0
  29. package/dist/cron/persist.d.ts +10 -0
  30. package/dist/cron/persist.js +22 -0
  31. package/dist/cron/scheduler.d.ts +25 -0
  32. package/dist/cron/scheduler.js +192 -0
  33. package/dist/cron/session-store.d.ts +14 -0
  34. package/dist/cron/session-store.js +50 -0
  35. package/dist/cron/time-format.d.ts +2 -0
  36. package/dist/cron/time-format.js +12 -0
  37. package/dist/cron/tools.d.ts +7 -0
  38. package/dist/cron/tools.js +161 -0
  39. package/dist/cron/types.d.ts +9 -0
  40. package/dist/cron/types.js +2 -0
  41. package/dist/extensions/index.d.ts +23 -0
  42. package/dist/extensions/index.js +41 -0
  43. package/dist/extensions/loader.d.ts +121 -0
  44. package/dist/extensions/loader.js +285 -0
  45. package/dist/extensions/manager.d.ts +103 -0
  46. package/dist/extensions/manager.js +190 -0
  47. package/dist/extensions/runtime.d.ts +188 -0
  48. package/dist/extensions/runtime.js +1127 -0
  49. package/dist/extensions/services.d.ts +85 -0
  50. package/dist/extensions/services.js +265 -0
  51. package/dist/extensions/types.d.ts +594 -0
  52. package/dist/extensions/types.js +2 -0
  53. package/dist/harness.d.ts +746 -0
  54. package/dist/harness.js +1483 -0
  55. package/dist/index.d.ts +24 -0
  56. package/dist/index.js +29 -0
  57. package/dist/internal.d.ts +9 -0
  58. package/dist/internal.js +9 -0
  59. package/dist/llm.d.ts +2 -0
  60. package/dist/llm.js +2 -0
  61. package/dist/local.d.ts +24 -0
  62. package/dist/local.js +58 -0
  63. package/dist/mcp.d.ts +2 -0
  64. package/dist/mcp.js +2 -0
  65. package/dist/protocol.d.ts +2 -0
  66. package/dist/protocol.js +2 -0
  67. package/dist/tools.d.ts +2 -0
  68. package/dist/tools.js +2 -0
  69. package/dist/tracing.d.ts +2 -0
  70. package/dist/tracing.js +2 -0
  71. 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