@matatbread/matbot-core 0.4.15 → 0.4.17

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/src/registry.ts CHANGED
@@ -3,7 +3,7 @@ import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
3
3
  import { scopedNotifier, askPermissionGate } from '@matatbread/matbot-plugin-api/host';
4
4
  import type {
5
5
  MatbotPlugin, MatbotMachine, MatbotRuntime, Mounted,
6
- ProviderAdapterFactory, StoreFactory,
6
+ ProviderAdapterFactory,
7
7
  } from './plugin.js';
8
8
  import { PLUGIN_API_VERSION, unifyServices } from './plugin.js';
9
9
  import { makePluginSettings } from './settings.js';
@@ -14,11 +14,9 @@ import type { SettingsDoc } from './settings.js';
14
14
  // `FailedPlugin` now lives in plugin-api: `plugin list` reports it, so it is part of that tool's
15
15
  // contract, and the contract has to be declarable from a package this one can reach.
16
16
 
17
- // Mutable arrays/maps held in a single object to make _resetRegistry() simple.
18
17
  const state = {
19
18
  plugins: [] as MatbotPlugin[],
20
19
  providers: new Map<string, ProviderAdapterFactory>(),
21
- storage: new Map<string, StoreFactory>(),
22
20
  toolRegistry: undefined as ToolRegistry | undefined,
23
21
  frontendPlugins: new Map<string, FrontendInfo>(), // pluginName → info, written by services.registerFrontend()
24
22
  serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
@@ -114,24 +112,11 @@ export function registerPlugin(plugin: MatbotPlugin): void {
114
112
  throw new Error(`Provider "${plugin.name}" is already registered.`);
115
113
  }
116
114
 
117
- for (const type of Object.keys(plugin.storage ?? {})) {
118
- if (state.storage.has(type)) {
119
- const owner = state.plugins.find(p => p.storage?.[type] !== undefined)?.name ?? '?';
120
- throw new Error(
121
- `Storage type "${type}" is already registered by "${owner}". ` +
122
- `"${plugin.name}" cannot register it again.`,
123
- );
124
- }
125
- }
126
-
127
115
  state.plugins.push(plugin);
128
116
 
129
117
  if (plugin.provider !== undefined) {
130
118
  state.providers.set(plugin.name, plugin.provider);
131
119
  }
132
- for (const [type, factory] of Object.entries(plugin.storage ?? {})) {
133
- state.storage.set(type, factory);
134
- }
135
120
  }
136
121
 
137
122
  // ── Resolution ────────────────────────────────────────────────────────────────
@@ -313,6 +298,9 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
313
298
  // own settings — there is no way to name another's.
314
299
  const ownSettings = makePluginSettings(services.createStore<SettingsDoc>('settings'), plugin.name);
315
300
 
301
+ // Plugin-scoped notifier, likewise built once. See the `Notifier` member below.
302
+ const ownNotifier = scopedNotifier(services.Notifier, plugin.name);
303
+
316
304
  // Per-plugin `mounted`: a thin adapter over the host mount table that delivers *this plugin's* scoped
317
305
  // machine (and scoped onUnmount) to handlers. The stable `scoped` object reads through the host's
318
306
  // re-pointing proxies/registry, so `scoped[key]` is the host's live service by the time a transition
@@ -353,9 +341,11 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
353
341
  // being a deliberate read of the concrete gate at that moment.
354
342
  get PermissionGate() { return services.PermissionGate; },
355
343
  // Everything this plugin publishes is attributed to it by default — the notification analogue of
356
- // stamping `pluginName` on its tools. Reads through the host's swap proxy, so a registered
357
- // distributed Notifier takes effect for a plugin that captured this in setup().
358
- get Notifier() { return scopedNotifier(services.Notifier, plugin.name); },
344
+ // stamping `pluginName` on its tools. Wrapped ONCE per plugin (below), not per property read: this
345
+ // was a getter, so every `services.Notifier.notify(...)` minted a fresh three-method object. Swap
346
+ // safety is unaffected, because what is wrapped is the host's capture-safe proxy — both hosts put
347
+ // that stable object on the machine — so a registered distributed Notifier still takes effect.
348
+ Notifier: ownNotifier,
359
349
  tools: {
360
350
  register: registerTool,
361
351
  remove: (name: string) => services.tools.remove(name),
@@ -379,11 +369,12 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
379
369
  build: (ctx) => services.systemContext.build(ctx),
380
370
  parts: (ctx) => services.systemContext.parts(ctx),
381
371
  },
372
+ // Attributed only once the registration has succeeded: a key recorded for a register() that threw
373
+ // would make unload unregister a service this plugin never held — for a swap-member, reverting
374
+ // someone else's.
382
375
  async register(key, svc) {
383
- const keys = state.serviceKeys.get(plugin.name) ?? [];
384
- keys.push(key as string);
385
- state.serviceKeys.set(plugin.name, keys);
386
376
  await services.register(key, svc);
377
+ recordServiceKey(plugin.name, key as string);
387
378
  },
388
379
  registerFrontend(info) {
389
380
  state.frontendPlugins.set(plugin.name, info);
@@ -420,29 +411,51 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
420
411
  state.systemContextPlugins.delete(pluginName);
421
412
 
422
413
  if (plugin.provider !== undefined) state.providers.delete(plugin.name);
423
- for (const type of Object.keys(plugin.storage ?? {})) state.storage.delete(type);
424
414
 
425
415
  state.frontendPlugins.delete(pluginName);
426
416
 
427
417
  state.plugins.splice(idx, 1);
428
418
  services.Notifier.notify({ kind: RegistryChangeKind, source: 'plugins', registry: 'plugins', name: pluginName, operation: 'removed' });
429
- await Promise.race([
430
- plugin.teardown?.(),
431
- new Promise<void>((_, reject) => setTimeout(() => reject(new Error(`Teardown timeout for plugin ${pluginName}`)), 10000))
432
- ]);
419
+ await boundedTeardown(plugin);
433
420
  return true;
434
421
  }
435
422
 
436
- /** Run each plugin's teardown() in reverse-registration order. Errors are logged, not thrown. */
423
+ /** How long one plugin's teardown() may take before it is abandoned. */
424
+ const TEARDOWN_TIMEOUT_MS = 10_000;
425
+
426
+ // The one teardown both paths use, so a runtime unload and process exit cannot drift apart on the budget.
427
+ // The timer is cleared however the race settles: left dangling it outlived every SUCCESSFUL teardown,
428
+ // holding the event loop open for the rest of its budget — so `plugin unload` followed by exit sat there
429
+ // with nothing to wait for.
430
+ async function boundedTeardown(plugin: MatbotPlugin): Promise<void> {
431
+ let timer: ReturnType<typeof setTimeout> | undefined;
432
+ try {
433
+ await Promise.race([
434
+ plugin.teardown?.(),
435
+ new Promise<void>((_, reject) => {
436
+ timer = setTimeout(() => reject(new Error(`Teardown timeout for plugin ${plugin.name}`)), TEARDOWN_TIMEOUT_MS);
437
+ }),
438
+ ]);
439
+ } finally {
440
+ if (timer !== undefined) clearTimeout(timer);
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Run each plugin's teardown() in reverse-registration order, one at a time, each within the same budget
446
+ * as {@link unloadPlugin}. Errors and timeouts are logged, not thrown, and do not stop the rest.
447
+ *
448
+ * Sequential and reversed because a plugin loaded later may depend on a service an earlier one provides,
449
+ * and must be able to flush through it before it closes. Bounded because a sequential loop is otherwise
450
+ * held hostage by one teardown that never settles — and this runs on the way to process exit.
451
+ */
437
452
  export async function teardownPlugins(): Promise<void> {
438
- // Reverse once and keep it: indexing the *unreversed* array to name a result reported the wrong
439
- // plugin for every failure but the middle one.
440
- const ordered = [...state.plugins].reverse();
441
- const results = await Promise.allSettled(ordered.map(plugin => plugin.teardown?.()));
442
- results.forEach((result, i) => {
443
- if (result.status === 'rejected') {
444
- console.error(`[matbot] teardown error in plugin "${ordered[i]?.name}":`, result.reason);
453
+ for (const plugin of [...state.plugins].reverse()) {
454
+ try {
455
+ await boundedTeardown(plugin);
456
+ } catch (err) {
457
+ console.error(`[matbot] teardown error in plugin "${plugin.name}":`, err);
445
458
  }
446
- });
459
+ }
447
460
  }
448
461
 
package/src/runner.ts CHANGED
@@ -1,15 +1,15 @@
1
1
  import type {
2
2
  Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
3
3
  TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
4
- Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn, FormField,
4
+ Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn,
5
5
  PermissionGate,
6
6
  } from './types.js';
7
7
  import type { MatbotPlugin } from './plugin.js';
8
8
  import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
9
- import { recordSpan, recordUsage, withUsageSite } from '@matatbread/matbot-plugin-api/host';
9
+ import { recordSpan, recordUsage, withUsageSite, scopeIterable } from '@matatbread/matbot-plugin-api/host';
10
10
  import { HookRegistry } from './hooks.js';
11
11
  import { appendMessage, createMessage } from './session.js';
12
- import { foldOntoUserTurn, bindPluginOps, bindGate } from '@matatbread/matbot-plugin-api';
12
+ import { foldOntoUserTurn, bindPluginOps, bindGate, defaultingPrompt } from '@matatbread/matbot-plugin-api';
13
13
  import { addUsage } from './usage.js';
14
14
  import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
15
15
 
@@ -17,24 +17,12 @@ import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
17
17
  // runner is itself an async generator, so it suspends at every `yield` and resumes under the consumer's
18
18
  // context, losing any scope entered around the loop body. Scoping each `next()` instead means the
19
19
  // tool's body — and anything it kicks off while running — always resumes under its own site.
20
- function siteScoped<T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> {
21
- return {
22
- [Symbol.asyncIterator]: () => {
23
- const it = src[Symbol.asyncIterator]();
24
- return {
25
- next: () => withUsageSite(site, () => it.next()),
26
- ...(it.return ? { return: (v?: never) => withUsageSite(site, () => it.return!(v)) } : {}),
27
- ...(it.throw ? { throw: (e?: unknown) => withUsageSite(site, () => it.throw!(e)) } : {}),
28
- };
29
- },
30
- };
31
- }
20
+ //
21
+ // `scopeIterable` is the per-pull wrap `runAs` also uses for the principal. It wraps the iterator rather
22
+ // than replacing it, so a class-based tool iterator keeps its own members, prototype and `instanceof`.
23
+ const siteScoped = <T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> =>
24
+ scopeIterable(src, f => withUsageSite(site, f));
32
25
 
33
- // Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
34
- // the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
35
- // internal token that reads as a real failure. A steer's continuation turn is the one place a model
36
- // reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
37
- // deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
38
26
  // Creator of the marker recording that a provider cut a response short. Marker-role, so it is durable
39
27
  // and visible to a reader while elided from every submission: the model's own text is already truncated
40
28
  // in the transcript, and a block telling it so invites narrating the cut-off rather than continuing.
@@ -45,6 +33,19 @@ const TRUNCATION_CREATOR = 'matbot-truncation';
45
33
  const abortReason = (signal: AbortSignal): string =>
46
34
  typeof signal.reason === 'string' ? signal.reason : 'user-abort';
47
35
 
36
+ /**
37
+ * A thrown value as a turn-terminal message, with its `cause` appended when it has one.
38
+ *
39
+ * A `throw` carries anything, so this narrows before reaching for `cause`: `'cause' in e` throws
40
+ * TypeError on a primitive, which made the catch handler ITSELF throw for a provider that rejected
41
+ * with a string — escaping the generator and skipping the `end()` commit that the branch calling this
42
+ * exists to perform. The one place in this package that took `any` rather than narrow.
43
+ */
44
+ const errorText = (e: unknown): string => {
45
+ const cause = typeof e === 'object' && e !== null && 'cause' in e ? (e as { cause?: unknown }).cause : undefined;
46
+ return cause ? `${String(e)} (${String(cause)})` : String(e);
47
+ };
48
+
48
49
  /** How long a tool executor may keep the turn alive AFTER the turn was aborted, before the runner stops
49
50
  * reading it. An abort must abort: any executor can fail to end — a child process whose stdout was
50
51
  * inherited by an orphan, a generator awaiting something that never settles, a bridged remote that went
@@ -73,6 +74,11 @@ function abandonDeadline(signal: AbortSignal, ms: number): { wait: Promise<typeo
73
74
  };
74
75
  }
75
76
 
77
+ // Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
78
+ // the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
79
+ // internal token that reads as a real failure. A steer's continuation turn is the one place a model
80
+ // reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
81
+ // deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
76
82
  const INTERRUPTED_TOOL_RESULT = {
77
83
  error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
78
84
  } as const;
@@ -137,12 +143,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
137
143
  const { config, provider, providerConfig, store, signal } = opts;
138
144
  const tools = opts.tools ?? new Map<string, Tool>();
139
145
  const hookReg = opts.hooks ?? new HookRegistry();
140
- const promptFn: PromptFn = opts.prompt ?? (((p: string | FormField, def?: string): Promise<string> => {
141
- const fallback = typeof p === 'string' ? def : p.default;
142
- if (fallback !== undefined) return Promise.resolve(fallback);
143
- const label = typeof p === 'string' ? p : p.label;
144
- return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
145
- }) as PromptFn);
146
+ // The stand-in answers with a field's own default where it has one — which is precisely why the gate
147
+ // below is handed `opts.prompt` raw instead (see bindGate).
148
+ const promptFn: PromptFn = opts.prompt ?? defaultingPrompt;
146
149
  const vault: Vault = opts.vault ?? {
147
150
  async createSecret() { throw new Error('No vault configured'); },
148
151
  async writeSecret() { throw new Error('No vault configured'); },
@@ -408,7 +411,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
408
411
  break;
409
412
  }
410
413
  }
411
- } catch (e: any) {
414
+ } catch (e: unknown) {
412
415
  // A provider call cancelled by an in-situ restart (callAc, not the turn signal) is expected — fall
413
416
  // through to the restart below rather than surfacing it as a turn abort or an error.
414
417
  if (restart || (callAc.signal.aborted && !signal.aborted)) {
@@ -436,7 +439,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
436
439
  // carries no session (a failure is not a transcript), so this yields the terminal itself rather
437
440
  // than the session — but it commits first, exactly like every other exit.
438
441
  bookRound();
439
- yield* end({ type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId });
442
+ yield* end({ type: 'error', error: errorText(e), traceId });
440
443
  return;
441
444
  }
442
445
  }