@matatbread/matbot-plugin-api 0.4.14 → 0.4.16

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.14",
7
+ "version": "0.4.16",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -104,15 +104,13 @@ export type Quiescer = (unregister: () => void) => void | Promise<void>;
104
104
  * onContextQuiesce(() => { if (nothingToDo) return; … });
105
105
  * ```
106
106
  *
107
- * **This or {@link scheduleAtEdge}?** They answer different questions, and the difference is what happens
108
- * when you ask twice.
107
+ * **This or {@link scheduleAtEdge}?** The difference is what happens when you ask twice.
109
108
  *
110
109
  * This is a *subscription*: each call adds a callback, and every callback added runs. Ask twice and the work
111
110
  * happens twice — correct when each call carries its own work (two deferred session edits are two edits, and
112
- * both must land). `scheduleAtEdge` is a *dirty flag*: you create one scheduler up front, and however many
113
- * times you poke it before an edge, the work runs once. Correct when the calls describe a **slot** rather
114
- * than a queue — three `register('StorageBackend')` calls mean one backend to install, not three to install
115
- * in turn, and the work reads the slot at fire time so the last writer wins.
111
+ * both must land). `scheduleAtEdge` is a *dirty flag*: one scheduler, poked any number of times before an
112
+ * edge, runs once and reads a slot at fire time. Correct when the calls describe a **slot** rather than a
113
+ * queue — three `register('StorageBackend')` calls mean one backend to install, not three in turn.
116
114
  *
117
115
  * Picking wrongly is not subtle in either direction: a subscription where you wanted a flag re-applies stale
118
116
  * intermediate values, and a flag where you wanted a subscription silently drops work.
@@ -125,36 +123,24 @@ export type Quiescer = (unregister: () => void) => void | Promise<void>;
125
123
  *
126
124
  * - Flushers are **invoked in registration order**, in one synchronous sweep; one that goes async is
127
125
  * started in that order and then left to settle alongside the rest.
128
- * - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing
129
- * yields at all, which is what lets the host stage a mutation and land it with a bare
130
- * `flushIfQuiescent()` — in effect before the call returns.
126
+ * - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing yields at
127
+ * all, so a host can stage a mutation and have it in effect before the staging call returns.
131
128
  * - **No second sweep begins until the current one has completely settled**, and a mutation staged
132
129
  * meanwhile is turned away rather than applied.
133
130
  * - A throwing flusher is isolated, and so is a rejecting one — neither may deny the others their
134
131
  * completion, nor escape into the operation whose release happened to reach the edge.
132
+ * - **Exclusivity, for a flusher's whole extent**, because {@link machineBusy} bars entry while a flush is
133
+ * settling. Flushers still overlap *each other*, deliberately: sequencing them would remove one source of
134
+ * concurrent mutation while leaving every other in place, at the price of every flusher waiting on the
135
+ * slowest. Keeping a flusher synchronous is still worth it where the work allows.
135
136
  *
136
- * - **Exclusivity, for a flusher's whole extent.** A synchronous flusher has always had it for free —
137
- * the runtime is single-threaded — and that remains the argument for keeping one synchronous whenever
138
- * the work allows. An asynchronous one now has it too, because {@link machineBusy} bars entry while a
139
- * flush is settling. Flushers still overlap *each other*, deliberately: sequencing them would remove
140
- * one source of concurrent mutation while leaving every other in place, at the price of every flusher
141
- * waiting on the slowest.
142
- *
143
- * Exclusivity is a recent acquisition and the reasoning that preceded it is worth keeping, because it
144
- * says what the barrier is really for. This edge used to be a counter, not a gate: `depth` was 0 while
145
- * a flush settled, so an HTTP endpoint could accept a request and run a whole tool call inside a single
146
- * `await`, and the conclusion drawn was that contention over a service is the *service's* to resolve —
147
- * a `Store` answers it with compare-and-swap — and that the sweep's job was only to make contention
148
- * rare rather than to pretend the machine stops.
149
- *
150
- * That is still true, and `mediumGuard` is the shipped form of it: a write whose read came from another
151
- * `StorageBackend` fails on the version stamp, so coherence does not depend on this module at all.
152
- * Which is precisely what makes the barrier safe to add. It is not the correctness mechanism; it is the
153
- * **liveness** one. A counter cannot force `depth` to reach 0, so under continuously overlapping holds —
154
- * several sessions on a busy server, each pump holding across its own queue — the edge simply never
155
- * arrives and staged work waits for an idle moment that never comes. A deferred session edit that never
156
- * lands, and a staged backend swap that never applies, are both failures with no symptom. Barring entry
157
- * is the only way to make the drain reachable, and exclusivity falls out of it for free.
137
+ * **The barrier is for liveness, not coherence.** Coherence does not depend on this module at all:
138
+ * `mediumGuard` fails a write whose read came from another `StorageBackend` on the version stamp, and
139
+ * contention over a service is that service's to resolve (a `Store` answers it with compare-and-swap). What
140
+ * a counter *cannot* do is force `depth` to reach 0 — under continuously overlapping holds (several sessions
141
+ * on a busy server, each pump holding across its own queue) the edge never arrives, and a deferred session
142
+ * edit that never lands and a staged swap that never applies are both failures with no symptom. Barring
143
+ * entry is the only way to make the drain reachable.
158
144
  *
159
145
  * The keys that repoint *immediately* — `Vault`, `KnowledgeIndex`, `Notifier`, anything a plugin
160
146
  * registers — carry no protection here or anywhere: they change under a running turn too, by the
@@ -165,16 +151,13 @@ export function onContextQuiesce(flush: Quiescer): () => void {
165
151
  const unregister = (): void => { cs.quiescers.delete(flush); };
166
152
  cs.quiescers.add(flush);
167
153
 
168
- // **Registering IS announcing.** A stager should not have to also remember to ask for an edge: the one
169
- // plugin doing this in-tree registered a one-shot and never called `flushIfQuiescent`, so the barrier
170
- // never engaged on its behalf and its work depended on someone else happening to release. Two calls that
171
- // must be paired, where omitting the second is silent, is the shape this module exists to avoid.
154
+ // **Registering IS announcing**, because two calls that must be paired, where omitting the second is
155
+ // silent, is the shape this module exists to avoid: a stager that registered and forgot to ask for an
156
+ // edge left the barrier disengaged, and its work then depended on someone else happening to release.
172
157
  //
173
158
  // The barrier is raised synchronously (it invokes nothing) so it engages at once, while the edge ATTEMPT
174
159
  // waits for a microtask — a callback must not run before the statement registering it has finished, or a
175
160
  // one-shot reading anything initialised on that same line, `unregister` included, would see it missing.
176
- // Nothing depends on registration landing synchronously; the host's inline-landing guarantee is attached
177
- // to an explicit `flushIfQuiescent()`, which still has it.
178
161
  if (cs.depth !== 0 || cs.flushing) cs.wanted = true;
179
162
  void Promise.resolve().then(() => { if (cs.quiescers.has(flush)) flushIfQuiescent(); });
180
163
 
@@ -183,13 +166,8 @@ export function onContextQuiesce(flush: Quiescer): () => void {
183
166
 
184
167
  /**
185
168
  * Announce registered work and land it if the edge is already here — **private**, and the second half of
186
- * {@link onContextQuiesce}.
187
- *
188
- * It was public, and announcing was then a call a stager had to remember to pair with its registration. The
189
- * one plugin registering a one-shot never made it, so the barrier never engaged for the very work whose
190
- * starvation motivated it. Registering announces now, which is the same guarantee with nothing to forget —
191
- * and once that was true, nothing outside this module had any reason to call this. Exposing "force an edge"
192
- * only offered a way to reason about firing that a caller should not have to have.
169
+ * {@link onContextQuiesce}. Private because registering announces, so nothing outside this module has any
170
+ * reason to force an edge; offering one only invites reasoning about firing that a caller should not need.
193
171
  *
194
172
  * It stays separate from the opportunistic sweep {@link machineBusy} performs at its own edges: only a
195
173
  * registration knows work exists, and a sweep that raised `wanted` merely because it found the machine busy
@@ -277,10 +255,9 @@ function sweep(): Promise<void> | undefined {
277
255
  * caller through here — which is all the mount contract has ever promised: eventual and ordered,
278
256
  * never timed.
279
257
  *
280
- * **Largely redundant since the barrier.** {@link machineBusy} now waits for staged work before it takes
281
- * the hold, which is this guarantee delivered where it cannot be forgotten — so the pump, the caller this
282
- * was written for, no longer calls it. It remains for an operation that needs deferred work complete and
283
- * does *not* want to hold the machine.
258
+ * **Largely redundant since the barrier**, which delivers this guarantee inside {@link machineBusy} where
259
+ * it cannot be forgotten. It remains for an operation that needs deferred work complete and does *not*
260
+ * want to hold the machine.
284
261
  */
285
262
  export async function quiesced(): Promise<void> {
286
263
  await sweep();
@@ -369,23 +346,21 @@ async function admit(): Promise<void> {
369
346
  * off-limits to further ones until `fn` is done. When `fn` is async the hold stays up until its promise
370
347
  * settles, so the count tracks the real operation rather than the synchronous call.
371
348
  *
372
- * **Always a promise, because entry can wait** ({@link admit}) — that is the barrier, and it is in here
373
- * rather than at the call site on purpose. An explicit "wait, then hold" pair reads better and puts the
374
- * cost in view, but it makes the waiting *optional*, and a second caller who omits it gets no error and
375
- * no symptom: staged work simply stops landing. That is the failure this whole module exists to prevent,
376
- * so it is not one to leave to a caller's memory — the same reasoning that makes the principal ambient
377
- * rather than threaded, and binds a mount interest to its plugin's load extent.
349
+ * **Always a promise, because entry can wait** ({@link admit}) — the barrier is in here rather than at the
350
+ * call site on purpose. An explicit "wait, then hold" pair reads better and puts the cost in view, but it
351
+ * makes the waiting *optional*, and a caller who omits it gets no error and no symptom: staged work simply
352
+ * stops landing. Same reasoning that makes the principal ambient rather than threaded, and binds a mount
353
+ * interest to its plugin's load extent.
378
354
  *
379
355
  * When nothing is staged, `fn` still runs **synchronously** within the call, before the returned promise
380
356
  * is handed back: the barrier adds a hold, never a scheduling gap, so nothing can slip between the check
381
357
  * and the hold. A synchronous throw becomes a rejection, so the two paths report failure identically.
382
358
  *
383
359
  * The hold is released on **every** exit — a throw, a rejected promise, a bare `return` from anywhere
384
- * inside — because the release is bound to the call, not written at the exits. That is the whole reason
385
- * this is a wrapper and not a `begin()`/`end()` pair: a caller with a dozen early returns (the pump has
386
- * several per queue item) cannot leave the counter stuck, and a stuck counter is unrecoverable — every
387
- * later flush would silently no-op forever, and the symptom would be a deferred mutation that simply
388
- * never happens.
360
+ * inside — because the release is bound to the call, not written at the exits. That is why this is a
361
+ * wrapper and not a `begin()`/`end()` pair: a caller with a dozen early returns (the pump has several per
362
+ * queue item) cannot leave the counter stuck, and a stuck counter is unrecoverable — every later flush
363
+ * would silently no-op forever, with no symptom but a deferred mutation that never happens.
389
364
  */
390
365
  export function machineBusy<T>(fn: () => T | Promise<T>): Promise<T> {
391
366
  if (!clear()) return admit().then(() => held(fn));
package/src/hooks.ts CHANGED
@@ -2,7 +2,7 @@ import type {
2
2
  Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen,
3
3
  ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
4
4
  } from './types.js';
5
- import { foldOntoUserTurn } from './session.js';
5
+ import { foldOntoUserTurn, createMessage } from './session.js';
6
6
  import { withUsageSite } from './usage-context.js';
7
7
 
8
8
  const HOOK_ERROR_CREATOR = 'matbot-hooks';
@@ -27,6 +27,14 @@ export class HookRegistry implements HookRegistrar {
27
27
  this.hooks.set(hook.on, list);
28
28
  }
29
29
 
30
+ // The hooks registered on one channel, narrowed to that channel's arm. The map is keyed BY `on`, so
31
+ // its members are already the right arm; each run* method used to re-test `if (hook.on !== 'screen')
32
+ // continue` purely to tell TypeScript so — five guards that could never fire. Narrowing at the lookup
33
+ // says it once, where it is true by construction.
34
+ private on<P extends HookPoint>(point: P): Extract<Hook, { on: P }>[] {
35
+ return (this.hooks.get(point) ?? []) as Extract<Hook, { on: P }>[];
36
+ }
37
+
30
38
  removeByPlugin(pluginName: string): void {
31
39
  for (const [point, list] of this.hooks) {
32
40
  this.hooks.set(point, list.filter(h => h.pluginName !== pluginName));
@@ -79,12 +87,10 @@ export class HookRegistry implements HookRegistrar {
79
87
  // fallback path, not the only one).
80
88
  private drainFailureMarkers(session: Session): { session: Session; markers: MessageContent[] } {
81
89
  if (this.pendingFailureMarkers.length === 0) return { session, markers: [] };
82
- const messages: Message[] = this.pendingFailureMarkers.map(f => ({
83
- id: crypto.randomUUID(),
84
- role: 'marker',
85
- createdAt: new Date().toISOString(),
86
- traceId: '',
87
- content: [{
90
+ const messages: Message[] = this.pendingFailureMarkers.map(f => createMessage({
91
+ role: 'marker',
92
+ traceId: '',
93
+ content: [{
88
94
  type: 'marker',
89
95
  creator: HOOK_ERROR_CREATOR,
90
96
  data: { channel: f.channel, ...(f.pluginName !== undefined ? { pluginName: f.pluginName } : {}), message: f.message },
@@ -110,9 +116,8 @@ export class HookRegistry implements HookRegistrar {
110
116
  const handlerMarkers: MessageContent[] = [];
111
117
  const appendMarkers = (blocks: MessageContent[]): void => {
112
118
  handlerMarkers.push(...blocks);
113
- session = { ...session, messages: [...session.messages, {
114
- id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: '', content: blocks,
115
- }] };
119
+ session = { ...session, messages: [...session.messages,
120
+ createMessage({ role: 'marker', traceId: '', content: blocks })] };
116
121
  };
117
122
  // Handler-returned durable context: folded onto the running turn's user message (the last one
118
123
  // with role 'user'), so it persists into history AND rides every subsequent provider call — the
@@ -127,8 +132,7 @@ export class HookRegistry implements HookRegistrar {
127
132
  durable.push(...blocks);
128
133
  session = folded;
129
134
  };
130
- for (const hook of this.hooks.get('screen') ?? []) {
131
- if (hook.on !== 'screen') continue;
135
+ for (const hook of this.on('screen')) {
132
136
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
133
137
  if (!r) continue;
134
138
  if (r.session) session = r.session;
@@ -149,8 +153,7 @@ export class HookRegistry implements HookRegistrar {
149
153
  // session is never touched.
150
154
  async runContribute(ctx: Omit<ContributeContext, 'removeHook'>): Promise<Message[]> {
151
155
  let outgoing = ctx.outgoing as Message[];
152
- for (const hook of this.hooks.get('contribute') ?? []) {
153
- if (hook.on !== 'contribute') continue;
156
+ for (const hook of this.on('contribute')) {
154
157
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
155
158
  if (r) outgoing = r;
156
159
  }
@@ -159,8 +162,7 @@ export class HookRegistry implements HookRegistrar {
159
162
 
160
163
  // toolcall stops at the first hook that rejects or aborts; the rest don't run.
161
164
  async runToolCall(ctx: Omit<ToolCallContext, 'removeHook'>): Promise<ToolCallResult> {
162
- for (const hook of this.hooks.get('toolcall') ?? []) {
163
- if (hook.on !== 'toolcall') continue;
165
+ for (const hook of this.on('toolcall')) {
164
166
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
165
167
  if (r && (r.rejectTool || r.abort)) return r;
166
168
  }
@@ -171,8 +173,7 @@ export class HookRegistry implements HookRegistrar {
171
173
  // a hook that returns nothing just observes (auditing). Returns the final result.
172
174
  async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
173
175
  let result = ctx.result;
174
- for (const hook of this.hooks.get('toolresult') ?? []) {
175
- if (hook.on !== 'toolresult') continue;
176
+ for (const hook of this.on('toolresult')) {
176
177
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
177
178
  if (r) result = r.result;
178
179
  }
@@ -188,17 +189,20 @@ export class HookRegistry implements HookRegistrar {
188
189
  const markers: MessageContent[] = [];
189
190
  const retractCtx: MessageContent[] = [];
190
191
  const retractDur: MessageContent[] = [];
191
- for (const hook of this.hooks.get('followup') ?? []) {
192
- if (hook.on !== 'followup') continue;
192
+ // Whether a hook ASKED to retract, not how much it sent with the request. Deriving it from the
193
+ // payload size dropped a `retractAndRerun: {}` — type-legal, both fields being optional — so a hook
194
+ // asking to pop and re-run with nothing added was answered by doing nothing at all, silently.
195
+ let retracting = false;
196
+ for (const hook of this.on('followup')) {
193
197
  const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
194
198
  if (r?.resubmit) resubmits.push(r.resubmit.content);
195
199
  if (r?.retractAndRerun) {
200
+ retracting = true;
196
201
  if (r.retractAndRerun.context) retractCtx.push(...r.retractAndRerun.context);
197
202
  if (r.retractAndRerun.durable) retractDur.push(...r.retractAndRerun.durable);
198
203
  }
199
204
  if (r?.markers) markers.push(...r.markers);
200
205
  }
201
- const retracting = retractCtx.length > 0 || retractDur.length > 0;
202
206
  return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
203
207
  }
204
208
  }
@@ -50,8 +50,15 @@ export type SwapFn<T extends object> = (next: T) => void;
50
50
  * A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
51
51
  * reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
52
52
  * is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
53
- * ownKeys + getOwnPropertyDescriptor keep object spread faithful. Methods bind to the current impl,
54
- * not the proxy. A nullish current (an optional service with nothing registered yet) reads as empty.
53
+ * ownKeys + getOwnPropertyDescriptor keep object spread faithful. A nullish current (an optional service
54
+ * with nothing registered yet) reads as empty.
55
+ *
56
+ * **Method identity is not stable**, and callers do need to know: a `get` binds to the impl current at
57
+ * that moment, so each read produces a NEW function and `store.get !== store.get`. That is what makes a
58
+ * captured method follow a swap instead of calling into the displaced impl — the point of the whole
59
+ * proxy — but it means anything keyed on a method's identity silently never matches: a `Set` of
60
+ * callbacks it is asked to remove one from, a memo keyed by function, an `===` comparison against a
61
+ * previously-read method. Capture the SERVICE and call through it; do not capture its methods.
55
62
  */
56
63
  export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
57
64
  return new Proxy({} as T, {
package/src/host.ts CHANGED
@@ -33,6 +33,10 @@ export * from './usage-context.js';
33
33
  // The machine half of a context switch: quiescent-edge flushers and the deferred-swap application point.
34
34
  export * from './context-switch.js';
35
35
 
36
+ // Re-entering an ambient scope on every pull of an async iterator — what both carriers need to cover a
37
+ // tool executor's deferred body. Host-side: a plugin gets this behaviour for free from `runAs`.
38
+ export * from './scoped-iterator.js';
39
+
36
40
  // The boot `PermissionGate` a host installs as the base of the swap-member: ask through whatever
37
41
  // channel is in scope, else answer the request's own `fallback`. A plugin supplying a *policy* is the
38
42
  // author-facing half and needs only the `PermissionGate` type, which the root exports.
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  export type * from './types.js';
12
- export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted } from './types.js';
12
+ export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted, FUNCTION_TIMEOUT } from './types.js';
13
13
 
14
14
  export * from './base64.js';
15
15
  export * from './bytes.js';
@@ -68,7 +68,8 @@ export type InvokeToolOptions =
68
68
  * calling tool's `ctx` to forward them all (see {@link InvokeToolOptions}); everything else (vault,
69
69
  * plugin (un)loading, workdir/configPath/files) is filled from `machine`.
70
70
  *
71
- * Throws synchronously if no tool is registered under `name`. When no `prompt` is supplied the tool
71
+ * Throws synchronously if no tool is registered under `name`, or if `opts.signal` has already aborted.
72
+ * When no `prompt` is supplied the tool
72
73
  * runs non-interactively: any attempt to prompt rejects, which a tool surfaces as a normal error
73
74
  * event. Pair with {@link toolText} to collapse the stream to its result string.
74
75
  */
@@ -80,6 +81,9 @@ export function invokeTool<K extends string, const P>(
80
81
  ): AsyncIterable<ToolEvent<ToolResultFor<K, P>>> {
81
82
  const tool = machine.tools.resolve(name);
82
83
  if (tool === null) throw new Error(`Tool "${name}" is not registered`);
84
+ // A call whose turn was cancelled never starts. Otherwise a composed body looping over `await tool.x()`
85
+ // goes on calling tools after the abort, each callee deciding for itself whether to honour the signal.
86
+ if (opts.signal.aborted) throw new Error(`Tool "${name}" was not started: the call was cancelled.`);
83
87
 
84
88
  const prompt = opts.prompt ?? rejectingPrompt;
85
89
  const ctx: ToolContext = {
@@ -104,6 +108,21 @@ export function invokeTool<K extends string, const P>(
104
108
  return tool.executor.execute(params, ctx) as AsyncIterable<ToolEvent<ToolResultFor<K, P>>>;
105
109
  }
106
110
 
111
+ /**
112
+ * The one drain: consume a tool event stream, keeping the last `result` and throwing on the first
113
+ * `error`. Whether a stream that yielded NO result is a failure is the caller's question, and the only
114
+ * thing the three public drains ever disagreed on — so it is the only thing they pass in.
115
+ */
116
+ async function drain<R>(events: AsyncIterable<ToolEvent<R>>): Promise<{ value: R | undefined; hadResult: boolean }> {
117
+ let value: R | undefined;
118
+ let hadResult = false;
119
+ for await (const ev of events) {
120
+ if (ev.type === 'result') { value = ev.value; hadResult = true; }
121
+ else if (ev.type === 'error') { throw new Error(ev.message); }
122
+ }
123
+ return { value, hadResult };
124
+ }
125
+
107
126
  /**
108
127
  * Drain a tool event stream (e.g. {@link invokeTool}'s return) to its raw `result` *value*, typed:
109
128
  * paired with `invokeTool(machine, name, …)` it returns whatever `ToolContracts[name]` declares (or
@@ -112,14 +131,9 @@ export function invokeTool<K extends string, const P>(
112
131
  * Stops and throws on the first `error` event, or if the tool finished without yielding a `result`.
113
132
  */
114
133
  export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R> {
115
- let value!: R;
116
- let hadResult = false;
117
- for await (const ev of events) {
118
- if (ev.type === 'result') { value = ev.value; hadResult = true; }
119
- else if (ev.type === 'error') { throw new Error(ev.message); }
120
- }
134
+ const { value, hadResult } = await drain(events);
121
135
  if (!hadResult) throw new Error('Tool produced no result');
122
- return value;
136
+ return value as R;
123
137
  }
124
138
 
125
139
  /**
@@ -129,13 +143,7 @@ export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promis
129
143
  * (the shape `skill_action` and other prose tools return) by its `content`, anything else as JSON.
130
144
  */
131
145
  export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string> {
132
- let result: unknown;
133
- let hadResult = false;
134
- for await (const ev of events) {
135
- if (ev.type === 'result') { result = ev.value; hadResult = true; }
136
- else if (ev.type === 'error') { throw new Error(ev.message); }
137
- }
138
- if (!hadResult) throw new Error('Tool produced no result');
146
+ const result = await toolResult(events);
139
147
 
140
148
  if (typeof result === 'string') return result;
141
149
  if (result !== null && typeof result === 'object' && typeof (result as { content?: unknown }).content === 'string') {
@@ -144,9 +152,28 @@ export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string
144
152
  return JSON.stringify(result, null, 2);
145
153
  }
146
154
 
147
- const rejectingPrompt: PromptFn = (((p: string | FormField): Promise<string> => {
148
- const label = typeof p === 'string' ? p : p.label;
149
- return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
155
+ const cannotPrompt = (p: string | FormField): Promise<string> =>
156
+ Promise.reject(new Error(`Non-interactive context: cannot prompt for "${typeof p === 'string' ? p : p.label}"`));
157
+
158
+ /**
159
+ * The stand-in for "there is nobody to ask": every request rejects, naming the field, which a tool's
160
+ * surrounding try/catch turns into an ordinary error event.
161
+ *
162
+ * Deliberately NOT what a gate is handed — see {@link bindGate}. A gate must be able to tell "nobody is
163
+ * here" (`ask === undefined`) from "a human answered", and a stand-in that answers makes that
164
+ * undecidable.
165
+ */
166
+ export const rejectingPrompt: PromptFn = cannotPrompt as PromptFn;
167
+
168
+ /**
169
+ * The stand-in that answers with a field's OWN default where it has one, and rejects like
170
+ * {@link rejectingPrompt} where it does not — what `ToolContext.prompt` is when the host supplied no
171
+ * channel. The two stand-ins shared one message written out twice, in two packages; what actually
172
+ * separates them is this one line, so it is the only thing that differs now.
173
+ */
174
+ export const defaultingPrompt: PromptFn = (((p: string | FormField, def?: string): Promise<string> => {
175
+ const fallback = typeof p === 'string' ? def : p.default;
176
+ return fallback !== undefined ? Promise.resolve(fallback) : cannotPrompt(p);
150
177
  }) as PromptFn);
151
178
 
152
179
  /** Compact a value to one trace line. */
@@ -157,6 +184,25 @@ function compactTrace(v: unknown): string {
157
184
  return s.length > 240 ? `${s.slice(0, 240)}…` : s;
158
185
  }
159
186
 
187
+ /**
188
+ * The {@link ToolProxy}'s drain. Unlike {@link toolResult}, a stream that ends with no `result` resolves
189
+ * to `undefined` instead of throwing — a tool whose work is a side effect yields nothing BY DESIGN
190
+ * (`function-tools` omits the event when a body returns nothing, and the triggers dispatcher fires only
191
+ * on a yielded result), and this proxy is the surface such a tool is called through. Throwing here made
192
+ * the one surface meant to be silent the one that shouted.
193
+ *
194
+ * A tool that declares a DATA result and yields nothing is still an error — but it is caught where the
195
+ * types are, not re-derived from a contract string on every call: the checker compiles a body against its
196
+ * declared return type, and `strict` rejects one that can fall through (TS2355 with no return at all,
197
+ * TS2366 when only some paths do). A body declaring `T | undefined` is deliberately NOT rejected —
198
+ * `undefined` IS its contract, and is exactly what this drain hands back. Two paths reach here unchecked
199
+ * and resolve to `undefined` rather than throwing: an explicit `noTypeCheck`, and the browser, where there
200
+ * is no `ToolTypeIndex`. Both are opted into.
201
+ */
202
+ async function drainProxyResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R | undefined> {
203
+ return (await drain(events)).value;
204
+ }
205
+
160
206
  /**
161
207
  * A factory for a context-overridden {@link ToolProxy}: `toolInContext({ provider }).some_tool(params)`
162
208
  * runs `some_tool` with the given fields merged over the box's bound context (omitted fields inherited).
@@ -191,7 +237,7 @@ export function makeToolBox(
191
237
  if (typeof prop !== 'string' || prop === 'then') return undefined;
192
238
  return async (params?: unknown): Promise<unknown> => {
193
239
  opts?.onEvent?.({ type: 'stdout', chunk: `→ ${prop}(${compactTrace(params)})\n` });
194
- const value = await toolResult(invokeTool(machine, prop, params ?? {}, call));
240
+ const value = await drainProxyResult(invokeTool(machine, prop, params ?? {}, call));
195
241
  opts?.onEvent?.({ type: 'stdout', chunk: `← ${prop}: ${compactTrace(value)}\n` });
196
242
  return value;
197
243
  };
package/src/plugin.ts CHANGED
@@ -2,23 +2,21 @@ import type {
2
2
  FileStore, MediaStore, Principal, Vault, Message, ModelParameters,
3
3
  ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
4
4
  Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage, HookRegistrar,
5
- TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent, PermissionGate,
5
+ TypeScriptStripper, FunctionRunner, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent, PermissionGate,
6
6
  } from './types.js';
7
7
  import type { Notifications, Notifier } from './notify.js';
8
8
 
9
9
  /**
10
- * The plugin API contract version: this package's `major.minor`, and nothing else. It sat at `'0.1'`
11
- * while the package shipped 0.3.x, so it conveyed no information and could not be reasoned about at a
12
- * version boundary — the point at which it is the only thing every third-party plugin hardcodes.
10
+ * The plugin API contract version: this package's `major.minor`, and nothing else — it must track the
11
+ * package, being the only thing every third-party plugin hardcodes.
13
12
  *
14
13
  * The gate (`checkApiVersion`, in core's registry) reads it as: **major must match exactly** — a
15
14
  * mismatch is a hard load failure — and a plugin declaring a *newer minor* than the runtime warns and
16
15
  * loads, since it may reach for something absent. Declare it as `apiVersion: PLUGIN_API_VERSION` and it
17
16
  * stays right for free; that is what every plugin in this repo does.
18
17
  *
19
- * Bumping 0.1 → 0.4 breaks nothing: at 0.x the major is `'0'` either way, and an older declared minor
20
- * does not warn. It matters at 1.0.0, where a plugin still declaring `'0.x'` will fail the major check
21
- * loudly instead of loading against a contract it was never built for.
18
+ * It matters at 1.0.0, where a plugin still declaring `'0.x'` fails the major check loudly instead of
19
+ * loading against a contract it was never built for.
22
20
  */
23
21
  export const PLUGIN_API_VERSION = '0.4';
24
22
 
@@ -191,6 +189,10 @@ export interface MatbotServices {
191
189
  * `file-ref` degrades to the converters' `[Attached file: x]` note. A text-only deployment is
192
190
  * unaffected either way. See {@link MediaStore}. */
193
191
  readonly MediaStore?: MediaStore | undefined;
192
+ /** Optional bound on how long model-authored code may run without awaiting. The node host registers
193
+ * one; absent ⇒ such code runs unbounded, as it must where synchronous code cannot be interrupted.
194
+ * See {@link FunctionRunner}. */
195
+ readonly FunctionRunner?: FunctionRunner | undefined;
194
196
  }
195
197
 
196
198
  /** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
@@ -243,26 +245,8 @@ export interface MatbotRuntime {
243
245
  */
244
246
  createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
245
247
 
246
- /**
247
- * Mount-table notifications: react to a registry service (re)mounting or being unloaded. A plugin
248
- * needs this iff its setup() reads another service's *current state* to build cached/derived state —
249
- * skills/triggers cache the StorageBackend's documents; cognition seeds from the SkillManager. A pure
250
- * map (no setup data; data arrives later as a tool call or hook) needs nothing — resolve the service
251
- * per-invocation through its proxy/member instead.
252
- *
253
- * // cache the backend's documents; rebuild on every swap (own initial load was in setup())
254
- * services.mounted.observe({ key: 'StorageBackend', signal }, () => this.load());
255
- *
256
- * // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
257
- * services.mounted.observe({ key: 'SkillManager', replay: true, signal }, m => seed(m));
258
- *
259
- * Contract guarantees only *eventual, ordered* delivery of each key's net presence transition — it
260
- * says nothing about timing: a mount may fire synchronously-ish or batch to a later quiescent edge, so
261
- * never assume a register() is observed inline or at a turn boundary. A reload (unregister+register
262
- * before the edge) collapses to a single remount; an unregister not replaced by the edge is a
263
- * committed unload, delivered to `onUnmount` (drop your captured ref there to let the gone plugin's
264
- * working set be collected). Handlers may re-fire on later remounts, so they must be idempotent.
265
- */
248
+ /** Mount-table notifications: react to a registry service (re)mounting or being unloaded. See
249
+ * {@link Mounted} for when a plugin needs this, the two idioms, and what the contract guarantees. */
266
250
  readonly mounted: Mounted;
267
251
 
268
252
  /**
@@ -354,9 +338,29 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
354
338
  readonly onUnmount?: (machine: MatbotMachine) => void | Promise<void>;
355
339
  }
356
340
 
357
- /** The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}. `observe` (not `consume`)
358
- * so the verb `consume` means exactly one thing repo-wide — the detached stream drain on
359
- * {@link Subscribable}; this keyed, replay/onUnmount, edge-batched delivery is its own paradigm. */
341
+ /**
342
+ * The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}.
343
+ *
344
+ * A plugin needs this iff its `setup()` reads another service's *current state* to build cached/derived
345
+ * state — skills/triggers cache the StorageBackend's documents; cognition seeds from the SkillManager. A
346
+ * pure map (no setup data; data arrives later as a tool call or hook) needs nothing, and resolves the
347
+ * service per-invocation through its proxy/member instead.
348
+ *
349
+ * // cache the backend's documents; rebuild on every swap (own initial load was in setup())
350
+ * services.mounted.observe({ key: 'StorageBackend' }, () => this.load());
351
+ *
352
+ * // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
353
+ * services.mounted.observe({ key: 'SkillManager', replay: true }, m => seed(m));
354
+ *
355
+ * The contract guarantees only *eventual, ordered* delivery of each key's net presence transition, and
356
+ * says nothing about timing — never assume a `register()` is observed inline or at a turn boundary. A
357
+ * reload (unregister+register before the edge) collapses to a single remount; an unregister not replaced
358
+ * by the edge is a committed unload, delivered to `onUnmount`. Handlers re-fire on a later remount, so
359
+ * they must be idempotent.
360
+ *
361
+ * `observe`, not `consume`, so the verb `consume` means exactly one thing repo-wide — the detached
362
+ * stream drain on {@link Subscribable}; this keyed, edge-batched delivery is its own paradigm.
363
+ */
360
364
  export interface Mounted {
361
365
  observe<K extends keyof MatbotServices>(
362
366
  options: MountConsumeOptions<K>,
@@ -370,11 +374,6 @@ export interface Mounted {
370
374
 
371
375
  export type ProviderAdapterFactory = (config: ProviderConfig) => ProviderAdapter;
372
376
 
373
- export type StoreFactory = (
374
- kind: string,
375
- options: Record<string, unknown>,
376
- ) => Store<{ id: string; version: string }>;
377
-
378
377
  // ── Plugin manifest ───────────────────────────────────────────────────────────
379
378
 
380
379
  export interface PluginManifest {
@@ -449,12 +448,11 @@ export interface VisibilityQuery {
449
448
  * notification is visible to that connection's principal. Absent ⇒ no partitioning, and every connection
450
449
  * sees everything.
451
450
  *
452
- * **The predicate is consulted for EVERY kind, and decides for itself which it filters.** It used to be
453
- * reached only for an `ItemChange` carrying a principal, because the caller gated on that — which left
454
- * every plugin-defined kind fanned out unfiltered with no hook capable of stopping it, and made `kind` a
455
- * constant not worth passing. Moving that judgement here is the point: routing a partitioned namespace and
456
- * addressing a notification to a recipient are two different policies, and only the implementation knows
457
- * which of its kinds want which. An implementation that has nothing to say about a kind returns `true`.
451
+ * **The predicate is consulted for EVERY kind, and decides for itself which it filters** — so a
452
+ * plugin-defined kind cannot be fanned out unfiltered with no hook capable of stopping it. Routing a
453
+ * partitioned namespace and addressing a notification to a recipient are two different policies, and only
454
+ * the implementation knows which of its kinds want which. One with nothing to say about a kind returns
455
+ * `true`.
458
456
  */
459
457
  export interface WatchVisibility {
460
458
  /**
@@ -484,9 +482,8 @@ export interface WatchVisibility {
484
482
  * **The token is a partition, never a principal.** The two are not interchangeable: one principal may
485
483
  * hold several profiles, and a profile may alias a namespace onto a third profile's partition, so the
486
484
  * partition serving a principal's files is not derivable from the principal id. Minting the token from
487
- * the identity in force — the bug this replaces — attached an addressable partition to files that were
488
- * in the shared base area, and would have pointed at an empty partition the moment one was created
489
- * under that name.
485
+ * the identity in force instead attaches an addressable partition to files that are in the shared base
486
+ * area, and points at an empty partition the moment one is created under that name.
490
487
  *
491
488
  * `current` and `enter` are one round trip and must stay each other's inverse: whatever `current`
492
489
  * returns, `enter` puts a reader back into the area those bytes were written to. Both live here rather
@@ -514,7 +511,6 @@ export interface MatbotPluginSpec {
514
511
  readonly apiVersion: string;
515
512
  readonly manifest?: PluginManifest;
516
513
  readonly provider?: ProviderAdapterFactory;
517
- readonly storage?: Record<string, StoreFactory>;
518
514
  readonly tools?: readonly Tool[];
519
515
  /**
520
516
  * When present, the runtime calls open(dotData) before creating the services object and uses the
@@ -1,4 +1,5 @@
1
1
  import type { Principal } from './types.js';
2
+ import { scopeIterator } from './scoped-iterator.js';
2
3
 
3
4
  /**
4
5
  * Ambient carrier for the security {@link Principal} — the single mechanism by which any layer
@@ -115,32 +116,14 @@ function isAsyncIterator(value: unknown): value is AsyncIterator<unknown> & Asyn
115
116
  && typeof (value as AsyncIterator<unknown>).next === 'function';
116
117
  }
117
118
 
119
+ // The per-pull re-entry itself is `scopeIterator` (./scoped-iterator.ts) — the usage carrier needs the
120
+ // identical wrap for its call site, and had its own, less faithful copy.
118
121
  function scopedIterator<T>(
119
122
  carrier: PrincipalCarrier,
120
123
  principal: Principal,
121
124
  source: AsyncIterator<T> & AsyncIterable<T>,
122
125
  ): AsyncIterableIterator<T> {
123
- const inScope = <R>(f: () => R): R => carrier.run(principal, f);
124
- // A Proxy rather than a fresh object literal: the value crossing back out must still BE what it was —
125
- // a custom iterator's own methods, its prototype, `instanceof` — so only the pull points are replaced.
126
- // Anything else is forwarded bound to the target, which a class-based iterator holding internal state
127
- // (or an object with internal slots) requires.
128
- const pulls: Record<PropertyKey, unknown> = {
129
- [Symbol.asyncIterator]: () => proxy,
130
- next: (...args: [] | [undefined]) => inScope(() => source.next(...args)),
131
- ...(source.return !== undefined ? { return: (value?: unknown) => inScope(() => source.return!(value)) } : {}),
132
- ...(source.throw !== undefined ? { throw: (error?: unknown) => inScope(() => source.throw!(error)) } : {}),
133
- };
134
- const proxy = new Proxy(source, {
135
- // `hasOwn`, not `in`: the latter reaches Object.prototype, which would serve `toString`/`constructor`
136
- // off the lookup object instead of the iterator being wrapped.
137
- get(target, prop) {
138
- if (Object.hasOwn(pulls, prop)) return pulls[prop];
139
- const value = Reflect.get(target, prop, target);
140
- return typeof value === 'function' ? value.bind(target) : value;
141
- },
142
- }) as AsyncIterableIterator<T>;
143
- return proxy;
126
+ return scopeIterator(source, f => carrier.run(principal, f));
144
127
  }
145
128
 
146
129
  /** Imperatively establish `principal` for the current flow (entry points only). */
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Re-enter an ambient scope on every pull of an async iterator.
3
+ *
4
+ * The problem is the same wherever it appears: an async generator's body does not begin until the first
5
+ * pull, so an iterator returned out of a scope carries its whole extent OUT of it — the scope covered
6
+ * the construction, and the work then runs in whatever flow happens to pull it. The tool ABI
7
+ * (`executor.execute()`) returns exactly that shape, so both of matbot's ambient carriers need this, and
8
+ * both had written it out: `runAs` for the principal, and the runner for the usage call site.
9
+ *
10
+ * The two copies did not agree. This one is the careful version: a **Proxy**, so the value crossing back
11
+ * out still IS what it was — a class-based iterator keeps its own members, its prototype and
12
+ * `instanceof` — with only the pull points replaced. The object-literal copy silently dropped all of
13
+ * that, which held only for as long as every tool was a plain async generator.
14
+ *
15
+ * Whether re-entering per pull is *sound* is the caller's question, not this helper's, and the answer
16
+ * differs: an identity is a re-entrant label, so `runAs` re-establishes it freely, while a hold or a
17
+ * roll-up settles when its `fn` does and must not be re-acquired per pull. See `machineBusy` and
18
+ * `withUsageScope`, which deliberately do not use this.
19
+ */
20
+
21
+ /** Run `f` inside the caller's ambient scope. The shape both `carrier.run(p, f)` and `withUsageSite(s, f)` take. */
22
+ export type ScopeFn = <R>(f: () => R) => R;
23
+
24
+ export function scopeIterator<T>(source: AsyncIterator<T>, inScope: ScopeFn): AsyncIterableIterator<T> {
25
+ const pulls: Record<PropertyKey, unknown> = {
26
+ [Symbol.asyncIterator]: () => proxy,
27
+ next: (...args: [] | [undefined]) => inScope(() => source.next(...args)),
28
+ ...(source.return !== undefined ? { return: (value?: unknown) => inScope(() => source.return!(value)) } : {}),
29
+ ...(source.throw !== undefined ? { throw: (error?: unknown) => inScope(() => source.throw!(error)) } : {}),
30
+ };
31
+ const proxy: AsyncIterableIterator<T> = new Proxy(source, {
32
+ // `hasOwn`, not `in`: the latter reaches Object.prototype, which would serve `toString`/`constructor`
33
+ // off the lookup object instead of the iterator being wrapped.
34
+ get(target, prop) {
35
+ if (Object.hasOwn(pulls, prop)) return pulls[prop];
36
+ const value = Reflect.get(target, prop, target);
37
+ return typeof value === 'function' ? value.bind(target) : value;
38
+ },
39
+ }) as AsyncIterableIterator<T>;
40
+ return proxy;
41
+ }
42
+
43
+ /**
44
+ * The iterable form: every iterator this hands out is scoped per pull. For a caller holding an
45
+ * `AsyncIterable` that is not itself iterator-shaped — legal, and what a tool returning a bare
46
+ * `{ [Symbol.asyncIterator]() {…} }` gives the runner.
47
+ */
48
+ export function scopeIterable<T>(source: AsyncIterable<T>, inScope: ScopeFn): AsyncIterable<T> {
49
+ return { [Symbol.asyncIterator]: () => scopeIterator(source[Symbol.asyncIterator](), inScope) };
50
+ }
package/src/session.ts CHANGED
@@ -1,4 +1,31 @@
1
- import type { MessageContent, Session } from './types.js';
1
+ import type { Message, MessageContent, MessageRole, Session } from './types.js';
2
+
3
+ /**
4
+ * A `Message` with the fields every one carries minted for it — a fresh id, `createdAt` now — and the
5
+ * optional ones spread conditionally.
6
+ *
7
+ * Lives here rather than in core because a message is not core's to define: the hook registry builds
8
+ * marker messages in *this* package, and had its own copy of these five lines because core is
9
+ * downstream of it. There were five such copies in two packages, and every one of them is a place a
10
+ * message could quietly acquire a different shape.
11
+ */
12
+ export function createMessage(opts: {
13
+ role: MessageRole;
14
+ content: MessageContent[];
15
+ traceId: string;
16
+ providerName?: string;
17
+ metadata?: Record<string, unknown>;
18
+ }): Message {
19
+ return {
20
+ id: crypto.randomUUID(),
21
+ role: opts.role,
22
+ content: opts.content,
23
+ createdAt: new Date().toISOString(),
24
+ traceId: opts.traceId,
25
+ ...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
26
+ ...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
27
+ };
28
+ }
2
29
 
3
30
  /**
4
31
  * A session's `updatedAt` invariant: the timestamp of its last message, or its own `createdAt` when it
@@ -195,7 +195,8 @@ export interface FollowupResult {
195
195
  * `context` is folded EPHEMERALLY onto the redo (for-this-answer-only). `durable` is instead folded
196
196
  * onto the re-run's user message — persisted, visible (mark it `origin: 'robo'`), carried live as a
197
197
  * `robo-user` event — so a `contextual`-kind correction updates the conversation durably even when it
198
- * lands post-commit. At least one of the two is present.
198
+ * lands post-commit. Normally at least one is supplied; the RETRACT is driven by the presence of this
199
+ * object, so `{}` means "pop and re-run with nothing added" rather than being quietly ignored.
199
200
  */
200
201
  retractAndRerun?: { context?: MessageContent[]; durable?: MessageContent[] };
201
202
  /**
@@ -21,6 +21,7 @@ export interface KnowledgeIndex {
21
21
  * index never inspects an entry's opaque `source`. The party that indexed an entry owns retracting
22
22
  * it — e.g. a skill manager retracts a hidden/deleted skill rather than the index policing tenants. */
23
23
  remove(id: string): Promise<void>;
24
+ /** Rejects with the signal's reason if it is already aborted; an abort during the search may reject too. */
24
25
  search(terms: Array<{ term: string; context?: string }>, signal: AbortSignal): Promise<KnowledgeEntry[]>;
25
26
  /** Enumerate all indexed entries. When present, register('KnowledgeIndex', …) drains these into the incoming backend. */
26
27
  entries?(): Iterable<KnowledgeEntry>;
@@ -17,6 +17,26 @@ export interface TypeScriptStripper {
17
17
  strip(source: string): string | Promise<string>;
18
18
  }
19
19
 
20
+ /**
21
+ * Optional, host-supplied: compiles already-stripped JavaScript into an async function whose SYNCHRONOUS
22
+ * execution is bounded. Model-authored code (a `tool_function` body) runs on the one event loop every
23
+ * session, frontend and schedule shares, so a loop that never awaits freezes all of them — and nothing
24
+ * in-process can stop it, an abort signal included, since stopping needs the loop to yield. Only the
25
+ * stretches between awaits are bounded: a body waiting on a slow tool call is not doing work.
26
+ *
27
+ * Absent ⇒ a consumer compiles with `new AsyncFunction` and runs unbounded, which is all a platform with
28
+ * no way to interrupt synchronous code (the browser) can do. An embedder may register its own.
29
+ */
30
+ export interface FunctionRunner {
31
+ /** Compile `body` as the body of an async function taking `params`. Throws on a syntax error. A call
32
+ * stopped at the limit rejects with an error whose `code` is {@link FUNCTION_TIMEOUT}. */
33
+ compile(params: readonly string[], body: string): (...args: unknown[]) => Promise<unknown>;
34
+ }
35
+
36
+ /** The `code` on the error a {@link FunctionRunner} rejects with when it stops a run at its limit, so a
37
+ * consumer can tell a stopped runaway from an ordinary failure without matching message text. */
38
+ export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
39
+
20
40
  /**
21
41
  * One finding from {@link ToolTypeIndex.check} — the record, not a rendering of it.
22
42
  *