@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 +2 -2
- package/src/assemble.ts +12 -5
- package/src/index.ts +1 -1
- package/src/registry.ts +60 -5
- package/src/tool-registry.ts +35 -43
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
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.
|
|
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
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
463
|
+
releaseServiceLayer(services, pluginName, key);
|
|
409
464
|
}
|
|
410
465
|
state.serviceKeys.delete(pluginName);
|
|
411
466
|
state.hookPlugins.delete(pluginName);
|
package/src/tool-registry.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
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
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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
|
|
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
|
|
86
|
-
* per-door duplication that then drifts. Wrapping here is the one place every door already
|
|
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
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
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,
|
|
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
|
|
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,
|
|
129
|
-
// tools are seeded,
|
|
130
|
-
private readonly
|
|
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,
|
|
133
|
-
this.
|
|
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.
|
|
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 {
|