@matatbread/matbot-core 0.4.10 → 0.4.12
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/about.ts +2 -2
- package/src/index.ts +3 -2
- package/src/security/vault.ts +11 -3
- package/src/session-runner.ts +2 -2
- package/src/settings.ts +46 -2
- package/src/tool-registry.ts +130 -4
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.12",
|
|
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.12"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/about.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine, SystemContextPart,
|
|
2
|
+
Tool, ToolExecutor, ToolContract, NoParams, ToolResultOf, ToolContext, MatbotMachine, SystemContextPart,
|
|
3
3
|
} from '@matatbread/matbot-plugin-api';
|
|
4
4
|
|
|
5
5
|
declare module '@matatbread/matbot-plugin-api' {
|
|
@@ -13,7 +13,7 @@ declare module '@matatbread/matbot-plugin-api' {
|
|
|
13
13
|
systemPrompt: string | null;
|
|
14
14
|
/** The same text kept apart and attributed, so a contribution can be traced to its plugin. */
|
|
15
15
|
systemContext: SystemContextPart[];
|
|
16
|
-
},
|
|
16
|
+
}, NoParams>;
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
package/src/index.ts
CHANGED
|
@@ -11,13 +11,14 @@ export {
|
|
|
11
11
|
promptCancelledError, isPromptCancelledError,
|
|
12
12
|
readOnlyError, isReadOnlyError,
|
|
13
13
|
mediaRejectedError, isMediaRejectedError,
|
|
14
|
+
invalidSecretNameError, isInvalidSecretNameError,
|
|
14
15
|
StoreQueryError,
|
|
15
16
|
} from '@matatbread/matbot-plugin-api';
|
|
16
17
|
export type {
|
|
17
18
|
MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, ReadOnlyError,
|
|
18
|
-
MediaRejectedError, MatbotErrorKind,
|
|
19
|
+
MediaRejectedError, InvalidSecretNameError, MatbotErrorKind,
|
|
19
20
|
} from '@matatbread/matbot-plugin-api';
|
|
20
|
-
export { applyCreateSecret }
|
|
21
|
+
export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE_KEY_RULE } from '@matatbread/matbot-plugin-api';
|
|
21
22
|
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
22
23
|
// Ambient security principal: the interface is a type (carried by `export type *`); these are the values.
|
|
23
24
|
export { currentPrincipal, tryCurrentPrincipal, runAs, lastActivityAt } from '@matatbread/matbot-plugin-api';
|
package/src/security/vault.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Vault } from '@matatbread/matbot-plugin-api';
|
|
2
|
-
import { missingSecretError, applyCreateSecret } from '@matatbread/matbot-plugin-api';
|
|
2
|
+
import { missingSecretError, applyCreateSecret, assertStorableKey, unreferenceableKey } from '@matatbread/matbot-plugin-api';
|
|
3
3
|
|
|
4
4
|
const REF_RE = /\$\{([^}]+)\}/g;
|
|
5
5
|
|
|
@@ -37,8 +37,16 @@ export class VaultImpl implements Vault {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
async writeSecret(name: string, value: string): Promise<void> {
|
|
40
|
-
if (value === '') this.store.delete(name);
|
|
41
|
-
|
|
40
|
+
if (value === '') { this.store.delete(name); return; }
|
|
41
|
+
// `this`, not the base policy directly, so a subclass narrowing the rule (EnvFileVault) is
|
|
42
|
+
// enforced through the inherited write rather than having to re-implement it.
|
|
43
|
+
assertStorableKey(this, name);
|
|
44
|
+
this.store.set(name, value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A plain map stores anything that can be referenced; subclasses narrowing this call `super`. */
|
|
48
|
+
unstorableKey(name: string): string | undefined {
|
|
49
|
+
return unreferenceableKey(name);
|
|
42
50
|
}
|
|
43
51
|
|
|
44
52
|
hasKey(name: string): boolean {
|
package/src/session-runner.ts
CHANGED
|
@@ -376,8 +376,8 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
376
376
|
// Establish the submitter's principal for the whole turn here, not inside runSession:
|
|
377
377
|
// pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
|
|
378
378
|
// turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
|
|
379
|
-
// access they trigger) reads it via currentPrincipal(). The consumption
|
|
380
|
-
// the
|
|
379
|
+
// access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
|
|
380
|
+
// because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
|
|
381
381
|
// runAs, not a context switch of its own: the pump holds the machine across the whole queue
|
|
382
382
|
// (see above), so a turn declares only its owner. Each item carries its own submitter.
|
|
383
383
|
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
package/src/settings.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { PluginSettings, Store } from '@matatbread/matbot-plugin-api';
|
|
1
|
+
import type { Notifier, PluginSettings, Store } from '@matatbread/matbot-plugin-api';
|
|
2
|
+
import { notifyingStore } from '@matatbread/matbot-plugin-api';
|
|
2
3
|
|
|
3
4
|
export interface SettingsDoc {
|
|
4
5
|
id: string;
|
|
@@ -45,6 +46,38 @@ export function settingsDefaultNamespaces(): readonly string[] {
|
|
|
45
46
|
return [...installDefaults.keys()];
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/** The routing namespace settings changes are announced under — the store namespace they live in. A
|
|
50
|
+
* consumer filters `kind === ItemChangeKind && namespace === SETTINGS_NAMESPACE && id === slug(mine)`. */
|
|
51
|
+
export const SETTINGS_NAMESPACE = 'settings';
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The bus settings writes announce on, installed by the host at boot alongside {@link installSettingsDefaults}.
|
|
55
|
+
*
|
|
56
|
+
* Module state consulted inside {@link makePluginSettings} for the same reason the defaults are, and it
|
|
57
|
+
* is the same call site that proves it: `plugins/browser` builds its own settings facade over a concrete
|
|
58
|
+
* backend, and a notifier passed as a parameter is one that facade silently omits — the symptom being a
|
|
59
|
+
* setting that quietly stops being observable, which nothing distinguishes from one that never changed.
|
|
60
|
+
*
|
|
61
|
+
* Hosts pass their capture-safe `Notifier` proxy, so a later `register('Notifier', …)` swap carries
|
|
62
|
+
* settings traffic with everything else. Absent (a test, an embedder that installed nothing) a write
|
|
63
|
+
* publishes nothing — settings still work, they are just unobservable.
|
|
64
|
+
*/
|
|
65
|
+
let settingsNotifier: Notifier | undefined;
|
|
66
|
+
|
|
67
|
+
/** Host boot assembly: announce settings writes on this bus. Call before any plugin loads. */
|
|
68
|
+
export function installSettingsNotifier(notifier: Notifier | undefined): void {
|
|
69
|
+
settingsNotifier = notifier;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read per publish, not captured at facade construction — a facade built before the host installs the
|
|
73
|
+
* bus (core's own reserved-namespace settings, a test) would otherwise be permanently silent. Only
|
|
74
|
+
* `notify` is ever reached: `notifyingStore` publishes and never subscribes. */
|
|
75
|
+
const lateBoundNotifier: Notifier = {
|
|
76
|
+
notify: n => settingsNotifier?.notify(n),
|
|
77
|
+
subscribe: async function *(signal, filter) { yield* settingsNotifier?.subscribe(signal, filter) ?? []; },
|
|
78
|
+
consume: (handler, signal, filter) => settingsNotifier?.consume(handler, signal, filter),
|
|
79
|
+
};
|
|
80
|
+
|
|
48
81
|
/**
|
|
49
82
|
* Build a PluginSettings facade over the shared settings store, scoped to one document id.
|
|
50
83
|
*
|
|
@@ -60,8 +93,19 @@ export function settingsDefaultNamespaces(): readonly string[] {
|
|
|
60
93
|
* key's default. Seeding on write would make a later config edit work or not work depending on
|
|
61
94
|
* unrelated write history, which is unexplainable to whoever edits the yaml.
|
|
62
95
|
*/
|
|
63
|
-
export function makePluginSettings(
|
|
96
|
+
export function makePluginSettings(rawStore: Store<SettingsDoc>, namespace: string): PluginSettings {
|
|
64
97
|
const id = slugSettingsNamespace(namespace);
|
|
98
|
+
// Wrapped here, not at the call sites, for the reason installDefaults is consulted here: the writers
|
|
99
|
+
// are `set` and `delete` below and nowhere else, so one wrapper covers every plugin's settings and
|
|
100
|
+
// cannot be forgotten by a host building its own facade.
|
|
101
|
+
//
|
|
102
|
+
// The announcement carries the UNSLUGGED namespace as `key`, because `id` is the slug — a shape the
|
|
103
|
+
// filesystem store's id rule imposed, which another backend could impose differently. A consumer
|
|
104
|
+
// asking "are these my settings?" compares `key` against its own `services.self.name` and never
|
|
105
|
+
// reproduces `slugSettingsNamespace`, which would keep compiling after that rule changed and simply
|
|
106
|
+
// stop matching. It also carries the ambient principal, an override being per-principal: a change to
|
|
107
|
+
// someone else's settings is not a change to yours.
|
|
108
|
+
const store = notifyingStore(rawStore, lateBoundNotifier, SETTINGS_NAMESPACE, 'settings', () => namespace);
|
|
65
109
|
|
|
66
110
|
const getDoc = async (): Promise<SettingsDoc | null> => {
|
|
67
111
|
const raw = await store.get(id);
|
package/src/tool-registry.ts
CHANGED
|
@@ -1,5 +1,123 @@
|
|
|
1
1
|
import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
2
|
-
import type { Tool, ToolRegistry, Notifier } from './types.js';
|
|
2
|
+
import type { Tool, ToolRegistry, Notifier, ToolContext, ToolEvent } from './types.js';
|
|
3
|
+
|
|
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".
|
|
10
|
+
*/
|
|
11
|
+
export interface ToolInputValidator {
|
|
12
|
+
validateToolCall(tool: string, parameters: unknown): Promise<ToolInputError[] | undefined>;
|
|
13
|
+
}
|
|
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
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Cap on the rendered value. Long enough to identify what was sent, short enough that a base64
|
|
32
|
+
* attachment or a large document cannot turn one rejected field into the bulk of a turn's context. */
|
|
33
|
+
const VALUE_CHARS = 160;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `.x: expected string, actual value \`{"x":8}\``
|
|
37
|
+
*
|
|
38
|
+
* The value is wrapped in its own field name, so what is shown is a fragment the caller can compare
|
|
39
|
+
* against what it sent rather than a bare literal it has to locate. An array element or the root has no
|
|
40
|
+
* name to wrap it in, so those render the value alone — the path already says which one it was.
|
|
41
|
+
*/
|
|
42
|
+
function formatError(e: ToolInputError): string {
|
|
43
|
+
if (e.value === undefined) return `${e.path}: ${e.message}`;
|
|
44
|
+
const named = /\.([A-Za-z_$][\w$]*)$/.exec(e.path);
|
|
45
|
+
let shown: string;
|
|
46
|
+
try {
|
|
47
|
+
// Validation now runs on internal calls too, so this is not always JSON.parse output: a caller can
|
|
48
|
+
// reach here with a bigint or a cycle, and a throw while REPORTING an error would replace the
|
|
49
|
+
// diagnosis with a stack trace.
|
|
50
|
+
shown = JSON.stringify(named ? { [named[1]!]: e.value } : e.value) ?? String(e.value);
|
|
51
|
+
} catch {
|
|
52
|
+
return `${e.path}: ${e.message}`;
|
|
53
|
+
}
|
|
54
|
+
if (shown.length > VALUE_CHARS) shown = `${shown.slice(0, VALUE_CHARS)}…`;
|
|
55
|
+
return `${e.path}: ${e.message}, actual value \`${shown}\``;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
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.
|
|
63
|
+
*
|
|
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.
|
|
67
|
+
*/
|
|
68
|
+
declare module '@matatbread/matbot-plugin-api' {
|
|
69
|
+
interface MatbotServices {
|
|
70
|
+
ToolCallValidator?: ToolInputValidator;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The `code` on the `error` event a rejected call yields. 422 rather than 400 because the body parsed
|
|
76
|
+
* fine and its *shape* is wrong, and — the reason a number is safe here at all — a process exit code
|
|
77
|
+
* cannot exceed 255, so this can never be mistaken for one (`bash` puts its exit status in the same
|
|
78
|
+
* field). A transport may map a 4xx code onto its own status; nothing is obliged to.
|
|
79
|
+
*/
|
|
80
|
+
export const TOOL_INPUT_INVALID = 422;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Validation belongs at the executor, not in a `toolcall` hook, because the hook is a RUNNER channel:
|
|
84
|
+
* 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.
|
|
88
|
+
*
|
|
89
|
+
* Wrapped at REGISTRATION rather than at `resolve`, so there is exactly one wrapper per tool and
|
|
90
|
+
* `resolve(x) === resolve(x)` still holds.
|
|
91
|
+
*
|
|
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
|
|
95
|
+
* present is one generator frame plus a structural check (measured in the tens of nanoseconds against
|
|
96
|
+
* tool work measured in milliseconds); the deliberate trade is that an INTERNAL call is validated too,
|
|
97
|
+
* though a statically-typed call site should already be sound. That is not pure waste: `invokeTool`
|
|
98
|
+
* with a dynamic name, a trigger's `invoke.params` (typed `object`) and a compiled skill's payload are
|
|
99
|
+
* all internal callers whose input no compiler has checked.
|
|
100
|
+
*/
|
|
101
|
+
function validatingTool(tool: Tool, lookup: () => ToolInputValidator | undefined): Tool {
|
|
102
|
+
const inner = tool.executor;
|
|
103
|
+
return {
|
|
104
|
+
...tool,
|
|
105
|
+
executor: {
|
|
106
|
+
execute(input: unknown, ctx: ToolContext): AsyncIterable<ToolEvent> {
|
|
107
|
+
return (async function* () {
|
|
108
|
+
const errs = await lookup()?.validateToolCall(tool.name, input);
|
|
109
|
+
if (errs !== undefined && errs.length > 0) {
|
|
110
|
+
const detail = errs.map(formatError).join('; ');
|
|
111
|
+
yield { type: 'error', message: `Invalid input for tool "${tool.name}": ${detail}`, code: TOOL_INPUT_INVALID };
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// `yield*` delegates `return`/`throw` as well as values, so an abort still reaches the tool.
|
|
115
|
+
yield* inner.execute(input, ctx);
|
|
116
|
+
})();
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
3
121
|
|
|
4
122
|
export class ToolRegistryImpl implements ToolRegistry {
|
|
5
123
|
private readonly tools = new Map<string, Tool>();
|
|
@@ -7,12 +125,20 @@ export class ToolRegistryImpl implements ToolRegistry {
|
|
|
7
125
|
// proxy, not a resolved impl, so a later register('Notifier', …) takes effect here too. Optional: a
|
|
8
126
|
// registry built without one (tests) simply announces nothing.
|
|
9
127
|
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;
|
|
10
131
|
|
|
11
|
-
constructor(initial?: Iterable<Tool>, notifier?: Notifier) {
|
|
12
|
-
|
|
132
|
+
constructor(initial?: Iterable<Tool>, notifier?: Notifier, validator?: () => ToolInputValidator | undefined) {
|
|
133
|
+
this.validator = validator;
|
|
134
|
+
if (initial !== undefined) for (const tool of initial) this.tools.set(tool.name, this.guard(tool));
|
|
13
135
|
this.notifier = notifier;
|
|
14
136
|
}
|
|
15
137
|
|
|
138
|
+
private guard(tool: Tool): Tool {
|
|
139
|
+
return this.validator === undefined ? tool : validatingTool(tool, this.validator);
|
|
140
|
+
}
|
|
141
|
+
|
|
16
142
|
private announce(name: string, operation: 'added' | 'removed', pluginName?: string): void {
|
|
17
143
|
this.notifier?.notify({
|
|
18
144
|
kind: RegistryChangeKind, source: 'tools', registry: 'tools', name, operation,
|
|
@@ -21,7 +147,7 @@ export class ToolRegistryImpl implements ToolRegistry {
|
|
|
21
147
|
}
|
|
22
148
|
|
|
23
149
|
register(tool: Tool): void {
|
|
24
|
-
this.tools.set(tool.name, tool);
|
|
150
|
+
this.tools.set(tool.name, this.guard(tool));
|
|
25
151
|
this.announce(tool.name, 'added', tool.pluginName);
|
|
26
152
|
}
|
|
27
153
|
|