@matatbread/matbot-core 0.4.20 → 0.4.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.20",
7
+ "version": "0.4.21",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.4.20"
38
+ "@matatbread/matbot-plugin-api": "^0.4.21"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
package/src/assemble.ts CHANGED
@@ -194,12 +194,19 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
194
194
  }),
195
195
  };
196
196
  const serviceRegistry = new Map<string, unknown>(Object.entries(seed));
197
- // The validator lookup is late-bound and read per call: one is registered by a plugin long after the
198
- // builtins are seeded, and may be unloaded again. It wraps the executor, so every door — the runner,
199
- // `POST /tools/:name`, `invokeTool` — is covered by one wrapper rather than a hook on the model's path.
200
- const tools = new ToolRegistryImpl(opts.builtinTools, notifier,
201
- () => serviceRegistry.get('ToolCallValidator') as ToolInputValidator | undefined);
197
+ // Validation wraps the executor, so every door — the runner, `POST /tools/:name`, `invokeTool` — is
198
+ // covered by one wrapper. It asks the `validate` hooks per call: validators arrive long after the
199
+ // builtins are seeded, and may be unloaded again.
202
200
  const hooks = new HookRegistry();
201
+ // The `ToolCallValidator` service is its predecessor, still honoured: consulted as one more hook, so a
202
+ // validator registered there is combined with the rest by the same rule. Host-owned (no `pluginName`),
203
+ // so no plugin's unload removes it; the key is read per call.
204
+ hooks.register({
205
+ on: 'validate',
206
+ handler: ctx => (serviceRegistry.get('ToolCallValidator') as ToolInputValidator | undefined)?.validateToolCall(ctx.tool, ctx.parameters),
207
+ });
208
+ const tools = new ToolRegistryImpl(opts.builtinTools, notifier,
209
+ (tool, parameters, signal) => hooks.runValidate({ tool, parameters, signal }));
203
210
  const systemContext = new SystemContextRegistryImpl();
204
211
 
205
212
  // The installation's permission policy. Deliberately NOT behind a forwardingProxy: the documented way
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ export { applyResolve, applyCreateSecret, assertStorableKey, unreferenceableKey,
22
22
  // The `provider update` patch policy, alongside the vault's: written once in plugin-api so the hosts
23
23
  // that apply one cannot differ on what a patch MEANS, and re-exported here so an app needs no direct
24
24
  // plugin-api dependency to get it.
25
- export { applyProviderPatch, patchedFields } from '@matatbread/matbot-plugin-api';
25
+ export { applyProviderPatch, movedCredentialKeys, patchedFields, checkProviderModel, modelListed, closestIds } from '@matatbread/matbot-plugin-api';
26
26
  export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, SessionAppendKind, FUNCTION_TIMEOUT, permitted, refused, refusalMessage } from '@matatbread/matbot-plugin-api';
27
27
  // The canonical `type: 'confirm'` answers. Same reason as the patch policy above: an app implementing a
28
28
  // PromptFn (or a test standing one in) has to speak them, and must not hard-code the rendered label.
package/src/registry.ts CHANGED
@@ -20,7 +20,8 @@ const state = {
20
20
  toolRegistry: undefined as ToolRegistry | undefined,
21
21
  frontendPlugins: new Map<string, FrontendInfo>(), // pluginName → info, written by services.registerFrontend()
22
22
  serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
23
- hookPlugins: new Set<string>(), // plugins that registered at least one hook
23
+ serviceLayers: new Map<string, { owner: string; value: unknown }[]>(), // key → plugin registrations, oldest first
24
+ hookPlugins: new Set<string>(), // plugins that registered at least one hook
24
25
  systemContextPlugins: new Set<string>(), // plugins that registered a system-context contributor
25
26
  lifecycles: new Map<string, AbortController>(), // pluginName → "this plugin is loaded" signal, aborted on unload
26
27
  failedPlugins: [] as FailedPlugin[], // plugins the loader skipped, keyed by specifier (last failure wins)
@@ -122,6 +123,8 @@ export function registerPlugin(plugin: MatbotPlugin): void {
122
123
 
123
124
  // ── Resolution ────────────────────────────────────────────────────────────────
124
125
 
126
+ const adapterLoads = new Map<string, Promise<MatbotPlugin>>();
127
+
125
128
  export function tryResolveProviderFactory(module: string): ProviderAdapterFactory | undefined {
126
129
  return state.providers.get(module);
127
130
  }
@@ -156,9 +159,18 @@ export async function instantiateProvider(services: MatbotRuntime, config: Provi
156
159
 
157
160
  // Not loaded yet — force-load the adapter module on demand (a runtime-contributed profile, or a disabled
158
161
  // pre-scan). Warn and return null rather than throw, so one unusable profile can't abort a turn. The next
159
- // resolution hits the fast path above via specifier→name, so this loads at most once per adapter module.
162
+ // resolution hits the fast path above via specifier→name, so this loads at most once per adapter module —
163
+ // once SETTLED. Calls that arrive while the load is in flight share it: each starting its own lost to
164
+ // the first on "already registered", and boot is exactly when they arrive together (a skills reindex
165
+ // analyses every skill at once, all through one provider whose module `plugins:` does not list).
166
+ const loadKey = name ?? config.module;
167
+ let loading = adapterLoads.get(loadKey);
168
+ if (loading === undefined) {
169
+ loading = services.loadPlugin(config.module).finally(() => adapterLoads.delete(loadKey));
170
+ adapterLoads.set(loadKey, loading);
171
+ }
160
172
  try {
161
- const plugin = await services.loadPlugin(config.module);
173
+ const plugin = await loading;
162
174
  if (plugin.provider === undefined) {
163
175
  console.warn(`[matbot] provider "${config.name}": module "${config.module}" loaded but registered no adapter.`);
164
176
  return null;
@@ -215,10 +227,46 @@ export function getRegisteredServiceKeys(pluginName: string): readonly string[]
215
227
  * register() that would normally record the key. Recording it makes the boot-opened backend unload-equal
216
228
  * to a runtime register(): unloadPlugin() then calls unregister() for it, reverting to the host base.
217
229
  */
218
- export function recordServiceKey(pluginName: string, key: string): void {
230
+ export function recordServiceKey(pluginName: string, key: string, value?: unknown): void {
219
231
  const keys = state.serviceKeys.get(pluginName) ?? [];
220
232
  if (!keys.includes(key)) keys.push(key);
221
233
  state.serviceKeys.set(pluginName, keys);
234
+ // Out of band means it was in place before anything the loader registered, so it goes underneath.
235
+ const layers = state.serviceLayers.get(key) ?? [];
236
+ if (value !== undefined && !layers.some(l => l.owner === pluginName)) state.serviceLayers.set(key, [{ owner: pluginName, value }, ...layers]);
237
+ }
238
+
239
+ // A displaced StorageBackend is closed by the swap, so there is nothing beneath to reinstate.
240
+ const CLOSED_WHEN_DISPLACED = new Set(['StorageBackend']);
241
+
242
+ function pushServiceLayer(owner: string, key: string, value: unknown): void {
243
+ const layers = (state.serviceLayers.get(key) ?? []).filter(l => l.owner !== owner);
244
+ layers.push({ owner, value });
245
+ state.serviceLayers.set(key, layers);
246
+ }
247
+
248
+ /**
249
+ * Withdraw one plugin's registration of `key`. The registry holds one value per key, so this used to
250
+ * be a plain `unregister`, which removed whatever was CURRENT — not necessarily this plugin's. Where
251
+ * plugins stack on a key (a validator or gate that captures the one it displaces and delegates to it),
252
+ * unloading the one underneath took the one on top with it, and unloading the one on top reverted past
253
+ * the one it had displaced. Now: withdrawing the current registration reinstates the one it displaced,
254
+ * withdrawing a displaced one changes nothing visible, and only the last one out reverts to the host.
255
+ */
256
+ function releaseServiceLayer(services: MatbotMachine, owner: string, key: string): void {
257
+ const layers = state.serviceLayers.get(key) ?? [];
258
+ const idx = layers.findIndex(l => l.owner === owner);
259
+ if (idx === -1) { services.unregister(key); return; }
260
+ const wasCurrent = idx === layers.length - 1;
261
+ layers.splice(idx, 1);
262
+ if (wasCurrent && CLOSED_WHEN_DISPLACED.has(key)) layers.length = 0;
263
+ if (layers.length === 0) state.serviceLayers.delete(key);
264
+ if (!wasCurrent) return;
265
+ const beneath = layers[layers.length - 1];
266
+ if (beneath === undefined) { services.unregister(key); return; }
267
+ // The base register applies its value synchronously, before its returned promise settles.
268
+ void services.register(key as never, beneath.value as never).catch((e: unknown) =>
269
+ console.error(`[matbot] Could not reinstate "${key}" from "${beneath.owner}":`, e instanceof Error ? e.message : e));
222
270
  }
223
271
 
224
272
  /** Plugins that registered at least one hook in setup(). */
@@ -353,6 +401,7 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
353
401
  resolve: (name: string) => services.tools.resolve(name),
354
402
  list: () => services.tools.list(),
355
403
  removeByPlugin:(name: string) => services.tools.removeByPlugin(name),
404
+ validate: async (name: string, parameters: unknown) => services.tools.validate?.(name, parameters),
356
405
  },
357
406
  hooks: {
358
407
  register(hook: Hook) {
@@ -376,6 +425,12 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
376
425
  async register(key, svc) {
377
426
  await services.register(key, svc);
378
427
  recordServiceKey(plugin.name, key as string);
428
+ pushServiceLayer(plugin.name, key as string, svc);
429
+ },
430
+ unregister(key: string) {
431
+ releaseServiceLayer(services, plugin.name, key);
432
+ const keys = state.serviceKeys.get(plugin.name);
433
+ if (keys !== undefined) state.serviceKeys.set(plugin.name, keys.filter(k => k !== key));
379
434
  },
380
435
  registerFrontend(info) {
381
436
  state.frontendPlugins.set(plugin.name, info);
@@ -405,7 +460,7 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
405
460
  state.lifecycles.delete(pluginName);
406
461
 
407
462
  for (const key of state.serviceKeys.get(pluginName) ?? []) {
408
- services.unregister(key);
463
+ releaseServiceLayer(services, pluginName, key);
409
464
  }
410
465
  state.serviceKeys.delete(pluginName);
411
466
  state.hookPlugins.delete(pluginName);
@@ -1,32 +1,19 @@
1
1
  import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
2
- import type { Tool, ToolRegistry, Notifier, ToolContext, ToolEvent } from './types.js';
2
+ import type { Tool, ToolRegistry, Notifier, ToolContext, ToolEvent, ToolInputError } from './types.js';
3
3
 
4
4
  /**
5
- * Structural contract for a registered input validator — duck-typed, never imported, exactly as
6
- * `ProfileDirectory` is: core must not depend on whichever plugin implements it (today
7
- * `@matatbread/matbot-tool-types`, from a tool's `ToolContract` params type), and the shape is the
8
- * whole agreement. `undefined` means NO OPINION — no contract for this tool, or one that cannot be
9
- * honestly validated — and is never read as "valid".
5
+ * The shape of the `ToolCallValidator` service — the single-valued predecessor of the `validate` hook,
6
+ * still consulted as one more opinion beside the hooks so a validator written against it keeps working.
7
+ * `undefined` means NO OPINION and is never read as "valid".
8
+ *
9
+ * @deprecated Register a `validate` hook instead. Removed in 0.5.0, with `ToolCallValidator`.
10
10
  */
11
11
  export interface ToolInputValidator {
12
12
  validateToolCall(tool: string, parameters: unknown): Promise<ToolInputError[] | undefined>;
13
13
  }
14
14
 
15
- /**
16
- * One rejected field. `path` is dotted, as a caller would WRITE the access (`.items[0].name`) rather
17
- * than as JSON Pointer — the reader is either a model repairing its own call or a developer looking at
18
- * a 422, and both write dots. `.` alone is the whole value.
19
- *
20
- * `value` is what was actually supplied there, and is what makes the difference between a message a
21
- * model can act on and one it has to guess at. Optional because a validator may not have it, and
22
- * `undefined` specifically means ABSENT: JSON carries no undefined, so there is nothing to show for a
23
- * missing property beyond saying it is missing.
24
- */
25
- export interface ToolInputError {
26
- path: string;
27
- message: string;
28
- value?: unknown;
29
- }
15
+ /** Judges one call: every `validate` hook, and the `ToolCallValidator` service if one is registered. */
16
+ export type ToolCallJudge = (tool: string, parameters: unknown, signal: AbortSignal) => Promise<ToolInputError[] | undefined>;
30
17
 
31
18
  /** Cap on the rendered value. Long enough to identify what was sent, short enough that a base64
32
19
  * attachment or a large document cannot turn one rejected field into the bulk of a turn's context. */
@@ -56,17 +43,18 @@ function formatError(e: ToolInputError): string {
56
43
  }
57
44
 
58
45
  /**
59
- * Declared HERE, by the consumer, rather than by any of the plugins that implement it — `json-validation`
60
- * (against `inputSchema`) and `ts-validation` (against the `ToolContract` type) both register this key,
61
- * and two plugins declaring one key must declare it identically or the merge is a TS2717 in a file
62
- * neither owns. Core reads it, so core names it.
46
+ * Declared HERE, by the consumer, rather than by a plugin that implements it: two plugins declaring one
47
+ * key must declare it identically or the merge is a TS2717 in a file neither owns. Core reads it, so core
48
+ * names it.
63
49
  *
64
- * Exactly one is active, since the registry holds one value per key. A validator that loads over another
65
- * should capture the one it displaces and delegate to it when it has no opinion of its own — which is
66
- * how the typed and schema validators compose rather than shadow each other.
50
+ * Superseded by the `validate` hook, which matbot's own validators now use. A service key holds one value,
51
+ * so a second validator had to capture the first and delegate to it, which made the pair depend on load
52
+ * order and on each other. Still consulted, as one more opinion, so a validator registered here works.
67
53
  */
68
54
  declare module '@matatbread/matbot-plugin-api' {
69
55
  interface MatbotServices {
56
+ /** @deprecated Register a `validate` hook instead: several co-exist, where this key holds one. Still
57
+ * consulted, as one more opinion beside the hooks; removed in 0.5.0. */
70
58
  ToolCallValidator?: ToolInputValidator;
71
59
  }
72
60
  }
@@ -80,32 +68,32 @@ declare module '@matatbread/matbot-plugin-api' {
80
68
  export const TOOL_INPUT_INVALID = 422;
81
69
 
82
70
  /**
83
- * Validation belongs at the executor, not in a `toolcall` hook, because the hook is a RUNNER channel:
71
+ * Validation belongs at the executor, not in a `toolcall` hook, because that hook is a RUNNER channel:
84
72
  * it guards the model's path and nothing else. `POST /tools/:name` and `invokeTool` both call
85
- * `tool.executor.execute` directly and fire no hooks, so a hook leaves two doors open and invites the
86
- * per-door duplication that then drifts. Wrapping here is the one place every door already passes
87
- * through.
73
+ * `tool.executor.execute` directly and fire no runner hooks, so a runner hook leaves two doors open and
74
+ * invites the per-door duplication that then drifts. Wrapping here is the one place every door already
75
+ * passes through — which is why the `validate` hook channel is dispatched from here, not by the runner.
88
76
  *
89
77
  * Wrapped at REGISTRATION rather than at `resolve`, so there is exactly one wrapper per tool and
90
78
  * `resolve(x) === resolve(x)` still holds.
91
79
  *
92
- * The validator is looked up per CALL, not captured: it may be registered long after the tools are
93
- * (plugin order), and it may be unloaded. No validator ⇒ the call passes straight through, so core
94
- * mandates no validation — it only honours one that is registered. The cost when a validator IS
80
+ * Validators are consulted per CALL, not captured: they are registered long after the tools are, and
81
+ * may be unloaded. None with an opinion ⇒ the call passes straight through, so core mandates no
82
+ * validation — it only honours what is registered. The cost when a validator IS
95
83
  * present is one generator frame plus a structural check (measured in the tens of nanoseconds against
96
84
  * tool work measured in milliseconds); the deliberate trade is that an INTERNAL call is validated too,
97
85
  * though a statically-typed call site should already be sound. That is not pure waste: `invokeTool`
98
86
  * with a dynamic name, a trigger's `invoke.params` (typed `object`) and a compiled skill's payload are
99
87
  * all internal callers whose input no compiler has checked.
100
88
  */
101
- function validatingTool(tool: Tool, lookup: () => ToolInputValidator | undefined): Tool {
89
+ function validatingTool(tool: Tool, judge: ToolCallJudge): Tool {
102
90
  const inner = tool.executor;
103
91
  return {
104
92
  ...tool,
105
93
  executor: {
106
94
  execute(input: unknown, ctx: ToolContext): AsyncIterable<ToolEvent> {
107
95
  return (async function* () {
108
- const errs = await lookup()?.validateToolCall(tool.name, input);
96
+ const errs = await judge(tool.name, input, ctx.signal);
109
97
  if (errs !== undefined && errs.length > 0) {
110
98
  const detail = errs.map(formatError).join('; ');
111
99
  yield { type: 'error', message: `Invalid input for tool "${tool.name}": ${detail}`, code: TOOL_INPUT_INVALID };
@@ -125,18 +113,22 @@ export class ToolRegistryImpl implements ToolRegistry {
125
113
  // proxy, not a resolved impl, so a later register('Notifier', …) takes effect here too. Optional: a
126
114
  // registry built without one (tests) simply announces nothing.
127
115
  private readonly notifier: Notifier | undefined;
128
- // Host-injected, late-bound: a validator is typically registered by a plugin long after the builtin
129
- // tools are seeded, so this is read per call rather than resolved here.
130
- private readonly validator: (() => ToolInputValidator | undefined) | undefined;
116
+ // Host-injected, and consulted per call: validators are registered by plugins long after the builtin
117
+ // tools are seeded, and may be unloaded again.
118
+ private readonly judge: ToolCallJudge | undefined;
131
119
 
132
- constructor(initial?: Iterable<Tool>, notifier?: Notifier, validator?: () => ToolInputValidator | undefined) {
133
- this.validator = validator;
120
+ constructor(initial?: Iterable<Tool>, notifier?: Notifier, judge?: ToolCallJudge) {
121
+ this.judge = judge;
134
122
  if (initial !== undefined) for (const tool of initial) this.tools.set(tool.name, this.guard(tool));
135
123
  this.notifier = notifier;
136
124
  }
137
125
 
138
126
  private guard(tool: Tool): Tool {
139
- return this.validator === undefined ? tool : validatingTool(tool, this.validator);
127
+ return this.judge === undefined ? tool : validatingTool(tool, this.judge);
128
+ }
129
+
130
+ async validate(name: string, parameters: unknown): Promise<ToolInputError[] | undefined> {
131
+ return this.judge?.(name, parameters, new AbortController().signal);
140
132
  }
141
133
 
142
134
  private announce(name: string, operation: 'added' | 'removed', pluginName?: string): void {