@matatbread/matbot-core 0.4.19 → 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 +15 -8
- package/src/config/loader.ts +0 -18
- package/src/index.ts +3 -3
- package/src/registry.ts +64 -8
- package/src/runner.ts +5 -1
- package/src/security/vault.ts +6 -14
- 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
|
@@ -69,8 +69,6 @@ export interface AssembleOptions {
|
|
|
69
69
|
providers: ProviderRegistryImpl;
|
|
70
70
|
gate: HostGate;
|
|
71
71
|
defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>> | undefined;
|
|
72
|
-
/** Registry values present from boot, which an unregister reverts to rather than deleting. */
|
|
73
|
-
seed?: Partial<MatbotServices>;
|
|
74
72
|
builtinTools?: Tool[];
|
|
75
73
|
/** Resolve one `${NAME}` reference; the browser prompts for a missing secret. Defaults to the vault. */
|
|
76
74
|
resolveSecret?(ref: string, vault: Vault): Promise<string>;
|
|
@@ -183,6 +181,9 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
|
183
181
|
// registers its own (forwarding to its parent) over it — and unloading that reverts to refusing, which
|
|
184
182
|
// is the honest answer for a job with no channel. Late-bound, as `services` is built below. It writes
|
|
185
183
|
// through the shared runner, the one writer of the sessions in `services.sessions`.
|
|
184
|
+
// Not a host-supplied set: an embedder with a service of its own registers it on the returned
|
|
185
|
+
// `services` before `loaded()`, which is the same moment and one mechanism rather than two. A `seed`
|
|
186
|
+
// option existed for the node `FunctionRunner`, and went with it when that became a plugin.
|
|
186
187
|
const seed: Partial<MatbotServices> = {
|
|
187
188
|
MediaStore: fileStore,
|
|
188
189
|
SessionAppender: createSessionAppender({
|
|
@@ -191,15 +192,21 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
|
191
192
|
notifier: () => services.Notifier,
|
|
192
193
|
isSubAgent: () => services.isSubAgent(),
|
|
193
194
|
}),
|
|
194
|
-
...opts.seed,
|
|
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/config/loader.ts
CHANGED
|
@@ -21,12 +21,6 @@ export interface MatbotConfig {
|
|
|
21
21
|
* config rather than data. A stored value always wins; `delete` reverts to the default here.
|
|
22
22
|
*/
|
|
23
23
|
defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
|
|
24
|
-
/**
|
|
25
|
-
* Longest a `tool_function` body may run synchronously — without awaiting — before it is stopped
|
|
26
|
-
* (`function_timeout_ms`). Absent ⇒ the host's default. `0` ⇒ no limit at all, for testing: bodies run
|
|
27
|
-
* directly, and a loop that never awaits freezes the process.
|
|
28
|
-
*/
|
|
29
|
-
functionTimeoutMs?: number;
|
|
30
24
|
}
|
|
31
25
|
|
|
32
26
|
function asString(v: YamlValue | undefined, label: string): string {
|
|
@@ -144,17 +138,6 @@ export function parseConfig(
|
|
|
144
138
|
}
|
|
145
139
|
}
|
|
146
140
|
|
|
147
|
-
const functionTimeoutRaw = doc['function_timeout_ms'];
|
|
148
|
-
let functionTimeoutMs: number | undefined;
|
|
149
|
-
if (functionTimeoutRaw !== undefined && functionTimeoutRaw !== null) {
|
|
150
|
-
functionTimeoutMs = asNumber(functionTimeoutRaw, 'function_timeout_ms');
|
|
151
|
-
// Refused rather than clamped, like maxRounds: a negative or fractional limit is a typo, and `0` already
|
|
152
|
-
// means something of its own.
|
|
153
|
-
if (!Number.isInteger(functionTimeoutMs) || functionTimeoutMs < 0) {
|
|
154
|
-
throw new Error(`Config: "function_timeout_ms" must be a non-negative integer (milliseconds; 0 removes the limit), got ${functionTimeoutMs}`);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
|
|
158
141
|
const prompt = typeof doc['prompt'] === 'string' ? doc['prompt'] : undefined;
|
|
159
142
|
const ephemeral = doc['ephemeral'] === true ? true : undefined;
|
|
160
143
|
const defaultProvider = typeof doc['default_provider'] === 'string' ? doc['default_provider'] : undefined;
|
|
@@ -168,7 +151,6 @@ export function parseConfig(
|
|
|
168
151
|
...(defaultProvider !== undefined ? { defaultProvider } : {}),
|
|
169
152
|
...(principal !== undefined ? { principal } : {}),
|
|
170
153
|
...(defaultSettings.size > 0 ? { defaultSettings } : {}),
|
|
171
|
-
...(functionTimeoutMs !== undefined ? { functionTimeoutMs } : {}),
|
|
172
154
|
};
|
|
173
155
|
}
|
|
174
156
|
|
package/src/index.ts
CHANGED
|
@@ -18,12 +18,12 @@ export type {
|
|
|
18
18
|
MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, ReadOnlyError,
|
|
19
19
|
MediaRejectedError, InvalidSecretNameError, MatbotErrorKind,
|
|
20
20
|
} from '@matatbread/matbot-plugin-api';
|
|
21
|
-
export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE_KEY_RULE } from '@matatbread/matbot-plugin-api';
|
|
21
|
+
export { applyResolve, applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE_KEY_RULE } from '@matatbread/matbot-plugin-api';
|
|
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';
|
|
26
|
-
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, SessionAppendKind, FUNCTION_TIMEOUT } from '@matatbread/matbot-plugin-api';
|
|
25
|
+
export { applyProviderPatch, movedCredentialKeys, patchedFields, checkProviderModel, modelListed, closestIds } from '@matatbread/matbot-plugin-api';
|
|
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.
|
|
29
29
|
export { CONFIRM_YES, CONFIRM_NO } from '@matatbread/matbot-plugin-api';
|
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)
|
|
@@ -60,7 +61,7 @@ async function resolveToolCollision(
|
|
|
60
61
|
): Promise<boolean> {
|
|
61
62
|
const owner = existingOwner !== undefined ? `"${existingOwner}"` : 'a built-in';
|
|
62
63
|
const label = `Tool \`"${toolName}"\` is already registered by **${owner}**. Overwrite it with the one from **"${incomingOwner}"**?`;
|
|
63
|
-
const
|
|
64
|
+
const decision = await (services.PermissionGate ?? askPermissionGate).decide({
|
|
64
65
|
gate: 'tools.overwrite',
|
|
65
66
|
subject: toolName,
|
|
66
67
|
// The incoming OWNER rides in the label rather than in a field of its own: a single `subject`
|
|
@@ -73,9 +74,10 @@ async function resolveToolCollision(
|
|
|
73
74
|
// changed hands" is exactly the report this line answers. A policy that overwrites after ASKING
|
|
74
75
|
// needs no warning — the user just saw the question.
|
|
75
76
|
if (prompt === undefined) {
|
|
76
|
-
console.warn(`[matbot] ${label} — non-interactive
|
|
77
|
+
console.warn(`[matbot] ${label} — non-interactive (${decision.source}), `
|
|
78
|
+
+ `${decision.allowed ? 'overwriting' : 'keeping the existing tool'}.`);
|
|
77
79
|
}
|
|
78
|
-
return allowed;
|
|
80
|
+
return decision.allowed;
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
// ── Version check ─────────────────────────────────────────────────────────────
|
|
@@ -121,6 +123,8 @@ export function registerPlugin(plugin: MatbotPlugin): void {
|
|
|
121
123
|
|
|
122
124
|
// ── Resolution ────────────────────────────────────────────────────────────────
|
|
123
125
|
|
|
126
|
+
const adapterLoads = new Map<string, Promise<MatbotPlugin>>();
|
|
127
|
+
|
|
124
128
|
export function tryResolveProviderFactory(module: string): ProviderAdapterFactory | undefined {
|
|
125
129
|
return state.providers.get(module);
|
|
126
130
|
}
|
|
@@ -155,9 +159,18 @@ export async function instantiateProvider(services: MatbotRuntime, config: Provi
|
|
|
155
159
|
|
|
156
160
|
// Not loaded yet — force-load the adapter module on demand (a runtime-contributed profile, or a disabled
|
|
157
161
|
// pre-scan). Warn and return null rather than throw, so one unusable profile can't abort a turn. The next
|
|
158
|
-
// 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
|
+
}
|
|
159
172
|
try {
|
|
160
|
-
const plugin = await
|
|
173
|
+
const plugin = await loading;
|
|
161
174
|
if (plugin.provider === undefined) {
|
|
162
175
|
console.warn(`[matbot] provider "${config.name}": module "${config.module}" loaded but registered no adapter.`);
|
|
163
176
|
return null;
|
|
@@ -214,10 +227,46 @@ export function getRegisteredServiceKeys(pluginName: string): readonly string[]
|
|
|
214
227
|
* register() that would normally record the key. Recording it makes the boot-opened backend unload-equal
|
|
215
228
|
* to a runtime register(): unloadPlugin() then calls unregister() for it, reverting to the host base.
|
|
216
229
|
*/
|
|
217
|
-
export function recordServiceKey(pluginName: string, key: string): void {
|
|
230
|
+
export function recordServiceKey(pluginName: string, key: string, value?: unknown): void {
|
|
218
231
|
const keys = state.serviceKeys.get(pluginName) ?? [];
|
|
219
232
|
if (!keys.includes(key)) keys.push(key);
|
|
220
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));
|
|
221
270
|
}
|
|
222
271
|
|
|
223
272
|
/** Plugins that registered at least one hook in setup(). */
|
|
@@ -352,6 +401,7 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
352
401
|
resolve: (name: string) => services.tools.resolve(name),
|
|
353
402
|
list: () => services.tools.list(),
|
|
354
403
|
removeByPlugin:(name: string) => services.tools.removeByPlugin(name),
|
|
404
|
+
validate: async (name: string, parameters: unknown) => services.tools.validate?.(name, parameters),
|
|
355
405
|
},
|
|
356
406
|
hooks: {
|
|
357
407
|
register(hook: Hook) {
|
|
@@ -375,6 +425,12 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
375
425
|
async register(key, svc) {
|
|
376
426
|
await services.register(key, svc);
|
|
377
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));
|
|
378
434
|
},
|
|
379
435
|
registerFrontend(info) {
|
|
380
436
|
state.frontendPlugins.set(plugin.name, info);
|
|
@@ -404,7 +460,7 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
|
|
|
404
460
|
state.lifecycles.delete(pluginName);
|
|
405
461
|
|
|
406
462
|
for (const key of state.serviceKeys.get(pluginName) ?? []) {
|
|
407
|
-
services
|
|
463
|
+
releaseServiceLayer(services, pluginName, key);
|
|
408
464
|
}
|
|
409
465
|
state.serviceKeys.delete(pluginName);
|
|
410
466
|
state.hookPlugins.delete(pluginName);
|
package/src/runner.ts
CHANGED
|
@@ -192,6 +192,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
192
192
|
async createSecret() { throw new Error('No vault configured'); },
|
|
193
193
|
async writeSecret() { throw new Error('No vault configured'); },
|
|
194
194
|
hasKey() { return false; },
|
|
195
|
+
async readSecret() { return undefined; },
|
|
195
196
|
async resolve(ref: string) { return ref; },
|
|
196
197
|
scrub(text: string) { return text; },
|
|
197
198
|
};
|
|
@@ -449,6 +450,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
449
450
|
case 'thinking':
|
|
450
451
|
yield { type: 'thinking', delta: ev.delta, traceId };
|
|
451
452
|
break;
|
|
453
|
+
case 'progress':
|
|
454
|
+
yield { type: 'progress', pct: ev.pct, ...(ev.message !== undefined ? { message: ev.message } : {}), traceId };
|
|
455
|
+
break;
|
|
452
456
|
case 'thinking-block':
|
|
453
457
|
assistantParts.push({ type: 'thinking', thinking: ev.thinking, signature: ev.signature });
|
|
454
458
|
break;
|
|
@@ -668,7 +672,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
668
672
|
// default, so a gate handed it could never tell "no human is reachable" from "a human answered
|
|
669
673
|
// with the default" — and would get the fallback back one layer too late to reason about.
|
|
670
674
|
// `ToolContext.prompt` keeps the stand-in exactly as before; only the gate sees the truth.
|
|
671
|
-
...bindGate(opts.permissionGate,
|
|
675
|
+
...bindGate(opts.permissionGate, tool, opts.prompt, opts.toolRegistry ?? { resolve: n => tools.get(n) ?? null }),
|
|
672
676
|
...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
|
|
673
677
|
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
674
678
|
...(opts.files !== undefined ? { files: opts.files } : {}),
|
package/src/security/vault.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { Vault } from '@matatbread/matbot-plugin-api';
|
|
2
|
-
import {
|
|
2
|
+
import { applyResolve, applyCreateSecret, assertStorableKey, unreferenceableKey } from '@matatbread/matbot-plugin-api';
|
|
3
3
|
|
|
4
|
-
const REF_RE = /\$\{([^}]+)\}/g;
|
|
5
4
|
|
|
6
5
|
/**
|
|
7
6
|
* Default vault implementation: a single flat namespace of secrets keyed by name.
|
|
@@ -58,19 +57,12 @@ export class VaultImpl implements Vault {
|
|
|
58
57
|
return undefined;
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
async
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const result = ref.replace(REF_RE, (_, name: string) => {
|
|
65
|
-
const value = this.store.get(name);
|
|
66
|
-
if (value === undefined) { errors.push(name); return ''; }
|
|
67
|
-
return value;
|
|
68
|
-
});
|
|
60
|
+
async readSecret(name: string): Promise<string | undefined> {
|
|
61
|
+
return this.store.get(name);
|
|
62
|
+
}
|
|
69
63
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
return result;
|
|
64
|
+
resolve(ref: string): Promise<string> {
|
|
65
|
+
return applyResolve(this, ref);
|
|
74
66
|
}
|
|
75
67
|
|
|
76
68
|
scrub(text: string): string {
|
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 {
|