@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/package.json +2 -2
- package/src/about.ts +2 -3
- package/src/assemble.ts +378 -0
- package/src/config/loader.ts +15 -0
- package/src/config/yaml.ts +38 -2
- package/src/index.ts +3 -1
- package/src/knowledge/lookup-knowledge-index.ts +3 -2
- package/src/loader.ts +36 -19
- package/src/media.ts +9 -3
- package/src/registry.ts +49 -36
- package/src/runner.ts +31 -28
- package/src/session-runner.ts +276 -277
- package/src/session.ts +5 -18
- package/src/settings.ts +18 -2
- package/src/system-context.ts +6 -3
- package/src/usage.ts +6 -3
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,
|
|
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.
|
|
357
|
-
//
|
|
358
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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,
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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:
|
|
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:
|
|
442
|
+
yield* end({ type: 'error', error: errorText(e), traceId });
|
|
440
443
|
return;
|
|
441
444
|
}
|
|
442
445
|
}
|