@matatbread/matbot-core 0.4.16 → 0.4.18
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 +399 -0
- package/src/ephemeral-turn.ts +67 -0
- package/src/index.ts +4 -1
- package/src/media.ts +3 -2
- package/src/registry.ts +31 -18
- package/src/runner.ts +28 -4
- package/src/session-append.ts +77 -0
- package/src/session-runner.ts +583 -88
- package/src/session.ts +7 -8
- package/src/settings.ts +1 -1
- package/src/storage-base/index.ts +1 -0
- package/src/storage-base/memory-store.ts +34 -0
- package/src/system-context.ts +6 -3
- package/src/usage.ts +6 -3
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.18",
|
|
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.18"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/about.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Tool, ToolExecutor, ToolContract, NoParams, ToolResultOf, ToolContext, MatbotMachine, SystemContextPart,
|
|
3
3
|
} from '@matatbread/matbot-plugin-api';
|
|
4
|
+
import { joinSystemContext } from './system-context.js';
|
|
4
5
|
|
|
5
6
|
declare module '@matatbread/matbot-plugin-api' {
|
|
6
7
|
interface ToolContracts {
|
|
@@ -36,9 +37,7 @@ export function createAboutMatbotTool(version: string, services: MatbotMachine):
|
|
|
36
37
|
const executor: ToolExecutor<ToolResultOf<'about_matbot'>> = {
|
|
37
38
|
async *execute(_input: unknown, ctx: ToolContext) {
|
|
38
39
|
const systemContext = await services.systemContext.parts({ session: ctx.session, signal: ctx.signal });
|
|
39
|
-
|
|
40
|
-
// sent prompt cannot differ in their separators.
|
|
41
|
-
const systemPrompt = systemContext.length > 0 ? systemContext.map(p => p.text).join('\n\n') : null;
|
|
40
|
+
const systemPrompt = joinSystemContext(systemContext);
|
|
42
41
|
yield { type: 'result', value: { version, about: ABOUT, currentProvider: ctx.provider, systemPrompt, systemContext } };
|
|
43
42
|
},
|
|
44
43
|
};
|
package/src/assemble.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
MatbotMachine, MatbotServices, MatbotPlugin, Store, Session, FileStore, StorageBackend, Vault, Notifier,
|
|
3
|
+
KnowledgeIndex, PermissionGate, PluginSettings, ProviderConfig, ProviderAdapter, SessionRunner, SessionAppender, Tool, Usage,
|
|
4
|
+
} from '@matatbread/matbot-plugin-api';
|
|
5
|
+
import {
|
|
6
|
+
forwardingProxy, makeSwappable, createMountTable, scheduleAtEdge, createNotifier, recordUsage, singleTurnRequest,
|
|
7
|
+
HookRegistry,
|
|
8
|
+
} from '@matatbread/matbot-plugin-api/host';
|
|
9
|
+
import type { SwapFn } from '@matatbread/matbot-plugin-api/host';
|
|
10
|
+
import { notifyingStore, createMessage } from '@matatbread/matbot-plugin-api';
|
|
11
|
+
import { mediumGuard } from './storage-base/medium-guard.js';
|
|
12
|
+
import { MemoryStore } from './storage-base/memory-store.js';
|
|
13
|
+
import { unifyServices } from './plugin.js';
|
|
14
|
+
import { instantiateProvider, recordServiceKey, getPluginNameForSpecifier, getRegisteredPlugins } from './registry.js';
|
|
15
|
+
import { installSettingsDefaults, installSettingsNotifier, makePluginSettings, settingsDefaultNamespaces } from './settings.js';
|
|
16
|
+
import type { SettingsDoc } from './settings.js';
|
|
17
|
+
import { ToolRegistryImpl } from './tool-registry.js';
|
|
18
|
+
import type { ToolInputValidator } from './tool-registry.js';
|
|
19
|
+
import type { ProviderRegistryImpl } from './provider-registry.js';
|
|
20
|
+
import { SystemContextRegistryImpl } from './system-context.js';
|
|
21
|
+
import { LookupKnowledgeIndex } from './knowledge/index.js';
|
|
22
|
+
import { createSessionRunner } from './session-runner.js';
|
|
23
|
+
import { createSessionAppender } from './session-append.js';
|
|
24
|
+
import { createSingleTurnTool } from './single-turn.js';
|
|
25
|
+
import { createAboutMatbotTool } from './about.js';
|
|
26
|
+
import { addUsage } from './usage.js';
|
|
27
|
+
|
|
28
|
+
/** A storage backend a configured plugin's manifest supplied at boot, and the config entry it came from. */
|
|
29
|
+
export interface PreScannedStorage {
|
|
30
|
+
backend: StorageBackend;
|
|
31
|
+
spec: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Open the first configured plugin's manifest `storageBackend`, before any store exists. Import failures
|
|
36
|
+
* are left for the loader to report. Node caches the imported modules, so the later load is free.
|
|
37
|
+
*/
|
|
38
|
+
export async function preScanStorage(
|
|
39
|
+
specs: readonly { spec: string; importSpec: string }[],
|
|
40
|
+
dir: string,
|
|
41
|
+
): Promise<PreScannedStorage | undefined> {
|
|
42
|
+
for (const { spec, importSpec } of specs) {
|
|
43
|
+
try {
|
|
44
|
+
const mod = await import(/* @vite-ignore */ importSpec) as Record<string, unknown>;
|
|
45
|
+
const plug = (mod['plugin'] ?? (mod['default'] as Record<string, unknown> | undefined)?.['plugin']) as MatbotPlugin | undefined;
|
|
46
|
+
if (plug?.storageBackend !== undefined) return { backend: await plug.storageBackend.open(dir), spec };
|
|
47
|
+
} catch { /* the loader surfaces it */ }
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The host's default permission policy, and the tools that inspect it (`@matatbread/matbot-default-gate`). */
|
|
53
|
+
export interface HostGate {
|
|
54
|
+
namespace: string;
|
|
55
|
+
create(settings: PluginSettings): PermissionGate;
|
|
56
|
+
tools(settings: PluginSettings): Tool[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The machine members only a host can supply. */
|
|
60
|
+
export type HostMembers =
|
|
61
|
+
Pick<MatbotMachine, 'loadPlugin' | 'unloadPlugin' | 'resolver' | 'isSubAgent' | 'TypeScriptStripper' | 'workdir' | 'configPath'>;
|
|
62
|
+
|
|
63
|
+
export interface AssembleOptions {
|
|
64
|
+
/** The host's own storage: the revert target when a plugin's StorageBackend is unregistered. */
|
|
65
|
+
bootBackend: StorageBackend;
|
|
66
|
+
preScanned?: PreScannedStorage | undefined;
|
|
67
|
+
/** The boot vault, behind a swappable proxy like every other swap-member. */
|
|
68
|
+
vault: Vault;
|
|
69
|
+
providers: ProviderRegistryImpl;
|
|
70
|
+
gate: HostGate;
|
|
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
|
+
builtinTools?: Tool[];
|
|
75
|
+
/** Resolve one `${NAME}` reference; the browser prompts for a missing secret. Defaults to the vault. */
|
|
76
|
+
resolveSecret?(ref: string, vault: Vault): Promise<string>;
|
|
77
|
+
/** Reported by `about_matbot`. */
|
|
78
|
+
version: string;
|
|
79
|
+
/** Called with a late-bound handle, since these members call back into the machine being built. */
|
|
80
|
+
host(machine: () => MatbotMachine): HostMembers;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface AssembledMachine {
|
|
84
|
+
services: MatbotMachine;
|
|
85
|
+
/** A session runner over any store — the shared one is `services.run`. */
|
|
86
|
+
makeRunner(store: Store<Session>, opts?: { appender?: SessionAppender }): SessionRunner;
|
|
87
|
+
/** Call once the configured plugins have loaded. */
|
|
88
|
+
loaded(): void;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const NEVER_ABORT = new AbortController().signal;
|
|
92
|
+
type AnyStore = Store<{ id: string; version: string }>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Stand a machine up: the swappable services and their boot defaults, the stores, the registries, the
|
|
96
|
+
* deferred storage swap and mount table, the session runner and the core tools. The one expression of
|
|
97
|
+
* the boot graph, so a step added here reaches every host. What differs by platform — carriers, where
|
|
98
|
+
* storage and secrets live, how a plugin is acquired — is the host's to pass in.
|
|
99
|
+
*
|
|
100
|
+
* The host installs its principal and usage carriers first: nothing here reads an identity, but the
|
|
101
|
+
* runner and every plugin will.
|
|
102
|
+
*/
|
|
103
|
+
export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
104
|
+
const { bootBackend, preScanned, providers } = opts;
|
|
105
|
+
installSettingsDefaults(opts.defaultSettings);
|
|
106
|
+
|
|
107
|
+
// Swap-members sit behind capture-safe proxies, so a captured reference — including a destructure
|
|
108
|
+
// like `const { KnowledgeIndex, StorageBackend } = services` — follows a register()-driven swap.
|
|
109
|
+
let activeVault = opts.vault;
|
|
110
|
+
const vault = forwardingProxy<Vault>(() => activeVault);
|
|
111
|
+
let activeNotifier: Notifier = createNotifier('core');
|
|
112
|
+
const notifier = forwardingProxy<Notifier>(() => activeNotifier);
|
|
113
|
+
// Settings writes announce on the same bus. The proxy, not the impl, so a registered distributed
|
|
114
|
+
// Notifier relays them too.
|
|
115
|
+
installSettingsNotifier(notifier);
|
|
116
|
+
let knowledgeImpl: KnowledgeIndex = new LookupKnowledgeIndex();
|
|
117
|
+
const knowledge = forwardingProxy<KnowledgeIndex>(() => knowledgeImpl);
|
|
118
|
+
|
|
119
|
+
// ── Storage ───────────────────────────────────────────────────────────────────────────────────────
|
|
120
|
+
let activeBackend: StorageBackend = preScanned?.backend ?? bootBackend;
|
|
121
|
+
// Held only to say so, once, when a plugin displaces it — see swapStorage.
|
|
122
|
+
let preScanBackend = preScanned?.backend;
|
|
123
|
+
const backend = forwardingProxy<StorageBackend>(() => activeBackend);
|
|
124
|
+
|
|
125
|
+
const storeProxies = new Map<string, [AnyStore, SwapFn<AnyStore>]>();
|
|
126
|
+
// Bumped by every swap, and read by the medium guard. A document handed out under one generation
|
|
127
|
+
// cannot be written back under another: nothing is migrated between backends, so that write would
|
|
128
|
+
// move it, silently, into a backend that never issued it.
|
|
129
|
+
let storageGeneration = 0;
|
|
130
|
+
const guarded = new Map<string, AnyStore>();
|
|
131
|
+
const createStore = <T extends { id: string; version: string }>(namespace: string): Store<T> => {
|
|
132
|
+
let entry = storeProxies.get(namespace);
|
|
133
|
+
if (entry === undefined) {
|
|
134
|
+
entry = makeSwappable<AnyStore>(activeBackend.createStore(namespace));
|
|
135
|
+
storeProxies.set(namespace, entry);
|
|
136
|
+
}
|
|
137
|
+
// Guard OUTSIDE the swap proxy: one stable object per namespace across every swap, so a captured
|
|
138
|
+
// store reference keeps working and the guard sees each call whichever backend is current.
|
|
139
|
+
let store = guarded.get(namespace);
|
|
140
|
+
if (store === undefined) {
|
|
141
|
+
store = mediumGuard<{ id: string; version: string }>(entry[0], () => storageGeneration, namespace);
|
|
142
|
+
guarded.set(namespace, store);
|
|
143
|
+
}
|
|
144
|
+
return store as Store<T>;
|
|
145
|
+
};
|
|
146
|
+
const [fileStore, swapFiles] = makeSwappable<FileStore>(activeBackend.fileStore);
|
|
147
|
+
// Every session write announces itself: the namespace has many writers (the turn pump, session_action,
|
|
148
|
+
// session_edit, a session minted over HTTP), and one wrapper covers them all, including the next.
|
|
149
|
+
const sessions = notifyingStore(createStore<Session>('sessions'), notifier, 'sessions', 'session');
|
|
150
|
+
|
|
151
|
+
// Re-point every store proxy and the file proxy at `next`. Synchronous, so readers see `next` at once;
|
|
152
|
+
// the displaced backend is closed in the background, because a slow or throwing close() (node:sqlite's
|
|
153
|
+
// rejecting on a still-open statement) must never gate the swap or suppress its mount notification.
|
|
154
|
+
// Driven only from the quiescent edge, never mid-turn.
|
|
155
|
+
const swapStorage = (next: StorageBackend): boolean => {
|
|
156
|
+
const removed = activeBackend;
|
|
157
|
+
if (removed === next) return false;
|
|
158
|
+
// Only ONE backend is ever active, and a plugin registering one displaces whatever the boot pre-scan
|
|
159
|
+
// opened — silently, since the swap is supported and the loser leaves nothing but an orphaned file.
|
|
160
|
+
// Said once, naming the plugin, at the moment it stops being true.
|
|
161
|
+
if (removed === preScanBackend && next !== bootBackend) {
|
|
162
|
+
preScanBackend = undefined;
|
|
163
|
+
console.warn(
|
|
164
|
+
`[matbot] storage: the backend opened at startup by "${preScanned?.spec}" has been replaced by another ` +
|
|
165
|
+
`plugin's storage backend. Only one is ever active and nothing is migrated between them, so anything ` +
|
|
166
|
+
`it already wrote is no longer read. Configure just one storage plugin to remove this ambiguity.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
activeBackend = next;
|
|
170
|
+
storageGeneration++;
|
|
171
|
+
for (const [ns, [, swap]] of storeProxies) swap(next.createStore(ns));
|
|
172
|
+
swapFiles(next.fileStore);
|
|
173
|
+
void Promise.resolve(removed.close?.()).catch(e => console.error('[matbot] closing displaced StorageBackend:', e));
|
|
174
|
+
return true;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// ── Registries ────────────────────────────────────────────────────────────────────────────────────
|
|
178
|
+
// The host's file area doubles as the media store, so attachments work with no plugin. Seeded into the
|
|
179
|
+
// registry rather than spelled on the base object, because `unifyServices` resolves an own property
|
|
180
|
+
// first — a member spelled there is one `register()` could never reach.
|
|
181
|
+
//
|
|
182
|
+
// The session appender is seeded the same way, in every process: it refuses in a background job, which
|
|
183
|
+
// registers its own (forwarding to its parent) over it — and unloading that reverts to refusing, which
|
|
184
|
+
// is the honest answer for a job with no channel. Late-bound, as `services` is built below. It writes
|
|
185
|
+
// through the shared runner, the one writer of the sessions in `services.sessions`.
|
|
186
|
+
const seed: Partial<MatbotServices> = {
|
|
187
|
+
MediaStore: fileStore,
|
|
188
|
+
SessionAppender: createSessionAppender({
|
|
189
|
+
sessions: () => services.sessions,
|
|
190
|
+
run: () => services.run,
|
|
191
|
+
notifier: () => services.Notifier,
|
|
192
|
+
isSubAgent: () => services.isSubAgent(),
|
|
193
|
+
}),
|
|
194
|
+
...opts.seed,
|
|
195
|
+
};
|
|
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);
|
|
202
|
+
const hooks = new HookRegistry();
|
|
203
|
+
const systemContext = new SystemContextRegistryImpl();
|
|
204
|
+
|
|
205
|
+
// The installation's permission policy. Deliberately NOT behind a forwardingProxy: the documented way
|
|
206
|
+
// to replace a policy is to capture the one displaced and delegate to it, and a capture-safe proxy
|
|
207
|
+
// would make that capture resolve to the capturing gate itself — which then calls itself until the
|
|
208
|
+
// stack overflows. A getter keeps the member read late-bound; every consumer resolves it per call.
|
|
209
|
+
const gateSettings = makePluginSettings(createStore<SettingsDoc>('settings'), opts.gate.namespace);
|
|
210
|
+
let activeGate = opts.gate.create(gateSettings);
|
|
211
|
+
|
|
212
|
+
const boot = { vault: activeVault, knowledge: knowledgeImpl, notifier: activeNotifier, gate: activeGate };
|
|
213
|
+
|
|
214
|
+
// ── The quiescent edge ────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
// A StorageBackend swap waits for it, since swapping the system of record under a running turn would
|
|
216
|
+
// split a compare-and-swap across two backends. The mount table batches mount notifications to it:
|
|
217
|
+
// register/unregister mark a key dirty, and the edge multicasts each key's net transition, so a
|
|
218
|
+
// reload within one turn collapses to a single remount.
|
|
219
|
+
const mountTable = createMountTable(() => services);
|
|
220
|
+
// A last-write-wins slot read at fire time, so three registers before an edge install one backend. One
|
|
221
|
+
// callback also orders the swap ahead of the mount flush, so the remount it marks lands in that edge.
|
|
222
|
+
let pendingSwap: StorageBackend | undefined;
|
|
223
|
+
const scheduleEdge = scheduleAtEdge(() => {
|
|
224
|
+
if (pendingSwap !== undefined) {
|
|
225
|
+
const next = pendingSwap;
|
|
226
|
+
pendingSwap = undefined;
|
|
227
|
+
if (swapStorage(next)) mountTable.markDirty('StorageBackend');
|
|
228
|
+
}
|
|
229
|
+
mountTable.flush();
|
|
230
|
+
});
|
|
231
|
+
const stageSwap = (next: StorageBackend): void => { pendingSwap = next; scheduleEdge(); };
|
|
232
|
+
const swapKnowledge = (next: KnowledgeIndex): void => {
|
|
233
|
+
const prev = knowledgeImpl;
|
|
234
|
+
if (prev === next) return;
|
|
235
|
+
knowledgeImpl = next;
|
|
236
|
+
if (prev.entries !== undefined) for (const e of prev.entries()) void next.index(e);
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const resolveSecret = (ref: string): Promise<string> => opts.resolveSecret?.(ref, vault) ?? vault.resolve(ref);
|
|
240
|
+
const resolveConfig = async (cfg: ProviderConfig): Promise<ProviderConfig> => {
|
|
241
|
+
let credentials: Record<string, string> | undefined;
|
|
242
|
+
if (cfg.credentials !== undefined) {
|
|
243
|
+
credentials = {};
|
|
244
|
+
for (const [k, v] of Object.entries(cfg.credentials)) credentials[k] = await resolveSecret(v);
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
...cfg,
|
|
248
|
+
...(credentials !== undefined ? { credentials } : {}),
|
|
249
|
+
...(cfg.endpoint !== undefined ? { endpoint: await resolveSecret(cfg.endpoint) } : {}),
|
|
250
|
+
};
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
let runner: SessionRunner | undefined;
|
|
254
|
+
|
|
255
|
+
const baseServices: MatbotMachine = {
|
|
256
|
+
// Plugins always receive the plugin-scoped override built in setupPlugin; the base is never called.
|
|
257
|
+
settings(): PluginSettings {
|
|
258
|
+
throw new Error('settings() is only available within a plugin scope (use the services passed to setup()).');
|
|
259
|
+
},
|
|
260
|
+
createStore,
|
|
261
|
+
get(key) { return serviceRegistry.get(key as string) as never; },
|
|
262
|
+
// StorageBackend is staged for the quiescent edge, which marks its mount dirty once the swap lands;
|
|
263
|
+
// the other swap-keys repoint at once and mark dirty for the edge to multicast.
|
|
264
|
+
async register(key, value) {
|
|
265
|
+
if (key === 'StorageBackend') stageSwap(value as StorageBackend);
|
|
266
|
+
else if (key === 'KnowledgeIndex') swapKnowledge(value as KnowledgeIndex);
|
|
267
|
+
else if (key === 'Vault') activeVault = value as Vault;
|
|
268
|
+
else if (key === 'Notifier') activeNotifier = value as Notifier;
|
|
269
|
+
else if (key === 'PermissionGate') activeGate = value as PermissionGate;
|
|
270
|
+
else serviceRegistry.set(key as string, value);
|
|
271
|
+
if (key !== 'StorageBackend') { mountTable.markDirty(key); scheduleEdge(); }
|
|
272
|
+
},
|
|
273
|
+
// Symmetric with register: a swap-key or seeded key reverts to its boot value rather than dangling on
|
|
274
|
+
// the unloaded plugin's impl — unloading a plugin that put media on S3 leaves attachments working on
|
|
275
|
+
// disk, not off until a restart. Anything else is deleted.
|
|
276
|
+
unregister(key: string) {
|
|
277
|
+
if (key === 'StorageBackend') stageSwap(bootBackend);
|
|
278
|
+
else if (key === 'KnowledgeIndex') knowledgeImpl = boot.knowledge;
|
|
279
|
+
else if (key === 'Vault') activeVault = boot.vault;
|
|
280
|
+
else if (key === 'Notifier') activeNotifier = boot.notifier;
|
|
281
|
+
else if (key === 'PermissionGate') activeGate = boot.gate;
|
|
282
|
+
else if (key in seed) serviceRegistry.set(key, seed[key as keyof MatbotServices]);
|
|
283
|
+
else serviceRegistry.delete(key);
|
|
284
|
+
if (key !== 'StorageBackend') { mountTable.markDirty(key as keyof MatbotServices); scheduleEdge(); }
|
|
285
|
+
},
|
|
286
|
+
registerFrontend() { /* bound per plugin in setupPlugin's scope */ },
|
|
287
|
+
|
|
288
|
+
async complete(req) {
|
|
289
|
+
const raw = providers.get(req.provider);
|
|
290
|
+
if (raw === undefined) throw new Error(`complete(): unknown provider "${req.provider}". Available: ${[...providers.keys()].join(', ')}`);
|
|
291
|
+
const resolved = await resolveConfig({
|
|
292
|
+
...raw,
|
|
293
|
+
// Per-call overrides shallow-merged over the config's own parameters (request wins).
|
|
294
|
+
...(req.parameters !== undefined ? { parameters: { ...raw.parameters, ...req.parameters } } : {}),
|
|
295
|
+
});
|
|
296
|
+
const adapter = await instantiateProvider(services, resolved);
|
|
297
|
+
if (adapter === null) throw new Error(`complete(): provider "${req.provider}" has no loadable adapter (module "${resolved.module}").`);
|
|
298
|
+
const messages = req.system !== undefined
|
|
299
|
+
? [createMessage({ role: 'system', content: [{ type: 'text', text: req.system }], traceId: crypto.randomUUID() }), ...req.messages]
|
|
300
|
+
: req.messages;
|
|
301
|
+
let text = '';
|
|
302
|
+
// Folded as the runner folds a turn's usage, because an adapter may report one call in several
|
|
303
|
+
// parts: anthropic sends input + cache counts on `message_start` and output on `message_delta`.
|
|
304
|
+
let usage: Usage = { inputTokens: 0, outputTokens: 0 };
|
|
305
|
+
const startedAt = Date.now();
|
|
306
|
+
for await (const ev of adapter.complete(messages, resolved, [], req.signal ?? NEVER_ABORT)) {
|
|
307
|
+
if (ev.type === 'text-delta') text += ev.delta;
|
|
308
|
+
if (ev.type === 'usage') usage = addUsage(usage, ev);
|
|
309
|
+
}
|
|
310
|
+
// Into the ambient usage sink, so a tool running this completion has its spend attributed to the
|
|
311
|
+
// site in force. Measured here because this is where the call is issued.
|
|
312
|
+
recordUsage(req.provider, usage, { startedAt: new Date(startedAt).toISOString(), durationMs: Date.now() - startedAt });
|
|
313
|
+
return { text, usage };
|
|
314
|
+
},
|
|
315
|
+
async singleTurn(req) {
|
|
316
|
+
return this.complete(singleTurnRequest(req));
|
|
317
|
+
},
|
|
318
|
+
|
|
319
|
+
...opts.host(() => services),
|
|
320
|
+
providers,
|
|
321
|
+
mounted: mountTable.mounted,
|
|
322
|
+
sessions,
|
|
323
|
+
files: fileStore,
|
|
324
|
+
get run() { return runner; },
|
|
325
|
+
ephemeral(opts) {
|
|
326
|
+
const store = new MemoryStore<Session>();
|
|
327
|
+
return { sessions: store, run: makeRunner(store, opts) };
|
|
328
|
+
},
|
|
329
|
+
hooks,
|
|
330
|
+
tools,
|
|
331
|
+
systemContext,
|
|
332
|
+
StorageBackend: backend,
|
|
333
|
+
Vault: vault,
|
|
334
|
+
Notifier: notifier,
|
|
335
|
+
get PermissionGate() { return activeGate; },
|
|
336
|
+
get KnowledgeIndex() { return knowledge; },
|
|
337
|
+
};
|
|
338
|
+
const services: MatbotMachine = unifyServices(baseServices);
|
|
339
|
+
|
|
340
|
+
// Resolved per turn, so it sees live `provider` edits. instantiateProvider force-loads an adapter that
|
|
341
|
+
// is not registered yet, and yields null rather than throwing when it cannot.
|
|
342
|
+
const resolveProvider = async (name: string): Promise<{ adapter: ProviderAdapter; config: ProviderConfig } | null> => {
|
|
343
|
+
const cfg = providers.get(name);
|
|
344
|
+
if (cfg === undefined) return null;
|
|
345
|
+
const config = await resolveConfig(cfg);
|
|
346
|
+
const adapter = await instantiateProvider(services, config);
|
|
347
|
+
return adapter === null ? null : { adapter, config };
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Services a plugin registers after boot are resolved live, per turn.
|
|
351
|
+
const makeRunner = (store: Store<Session>, extra?: { appender?: SessionAppender }): SessionRunner => createSessionRunner({
|
|
352
|
+
store,
|
|
353
|
+
...(extra?.appender !== undefined ? { appender: extra.appender } : {}),
|
|
354
|
+
resolveProvider,
|
|
355
|
+
tools,
|
|
356
|
+
hooks,
|
|
357
|
+
systemContext,
|
|
358
|
+
vault,
|
|
359
|
+
files: fileStore,
|
|
360
|
+
toolTypeIndex: () => services.ToolTypeIndex,
|
|
361
|
+
toolPresenter: () => services.ToolPresenter,
|
|
362
|
+
steeringPolicy: () => services.SteeringPolicy,
|
|
363
|
+
mediaStore: () => services.MediaStore,
|
|
364
|
+
permissionGate: () => services.PermissionGate,
|
|
365
|
+
...(services.workdir !== undefined ? { workdir: services.workdir } : {}),
|
|
366
|
+
...(services.configPath !== undefined ? { configPath: services.configPath } : {}),
|
|
367
|
+
loadPlugin: services.loadPlugin.bind(services),
|
|
368
|
+
unloadPlugin: services.unloadPlugin.bind(services),
|
|
369
|
+
});
|
|
370
|
+
runner = makeRunner(sessions);
|
|
371
|
+
|
|
372
|
+
// Seeded rather than carried by configured plugins: `gate_action` because a minimal install's first act
|
|
373
|
+
// (adding a plugin or provider) is gated, so inspecting an answer cannot depend on a config line.
|
|
374
|
+
for (const tool of opts.gate.tools(gateSettings)) tools.register(tool);
|
|
375
|
+
tools.register(createSingleTurnTool(services));
|
|
376
|
+
// The harness is not a plugin (no `plugin list` row), so its version gets a tool of its own.
|
|
377
|
+
tools.register(createAboutMatbotTool(opts.version, services));
|
|
378
|
+
|
|
379
|
+
const loaded = (): void => {
|
|
380
|
+
// A defaults key is a plugin NAME — loader-derived, not necessarily what `plugins:` spells. One
|
|
381
|
+
// naming nothing loaded is the feature's one silent failure. The gate's namespace is seeded by the
|
|
382
|
+
// host rather than a plugin, and reserved dunder namespaces are not plugins; both are exempt.
|
|
383
|
+
const names = new Set(getRegisteredPlugins().map(p => p.name));
|
|
384
|
+
for (const ns of settingsDefaultNamespaces()) {
|
|
385
|
+
if (names.has(ns) || ns === opts.gate.namespace || (ns.startsWith('__') && ns.endsWith('__'))) continue;
|
|
386
|
+
console.warn(
|
|
387
|
+
`[matbot] default settings name "${ns}", which is not a loaded plugin — they apply only once a plugin ` +
|
|
388
|
+
`of that name loads. Key them by the plugin's package name (\`plugin list\` reports them).`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
// The pre-scan opened a manifest backend before the loader knew the plugin's name, bypassing the
|
|
392
|
+
// scoped register() that attributes a service key. Attributing it now makes unloading that plugin
|
|
393
|
+
// revert storage to the host base, exactly as for a runtime register().
|
|
394
|
+
const name = preScanned !== undefined ? getPluginNameForSpecifier(preScanned.spec) : undefined;
|
|
395
|
+
if (name !== undefined) recordServiceKey(name, 'StorageBackend');
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
return { services, makeRunner, loaded };
|
|
399
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { EphemeralRun, PipelineEvent, Principal, PromptFn, Session, UserContent } from '@matatbread/matbot-plugin-api';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* One turn on an ephemeral run, from a fresh session to its committed transcript: what a background job
|
|
5
|
+
* and a skill demonstration each do, and each did for itself.
|
|
6
|
+
*
|
|
7
|
+
* Yields the turn's own events, up to and including its terminal, and returns the transcript.
|
|
8
|
+
*
|
|
9
|
+
* Three details are why this is shared rather than rewritten:
|
|
10
|
+
* - `signal` is wired to the run's `abort`. On `open` it only ends this view of the session, while the
|
|
11
|
+
* turn runs on the runner's own controller. Without the wiring, a cancelled job or compile left its
|
|
12
|
+
* turn calling tools after its caller had gone.
|
|
13
|
+
* - A caller that stops reading before the terminal aborts the turn for the same reason.
|
|
14
|
+
* - So does reaching the terminal, which is NOT the end of the run: `followup` is post-commit, so a hook
|
|
15
|
+
* that resubmits or retracts enqueues a further turn on this runner AFTER the terminal this returns on.
|
|
16
|
+
* Left armed, it ran on unobserved — calling tools, and appending through the run's appender — past the
|
|
17
|
+
* transcript its caller was handed and past a cancel that no longer reached it. One turn means one.
|
|
18
|
+
* - An `error` terminal carries no session, so the transcript is then recovered from the run's store.
|
|
19
|
+
*
|
|
20
|
+
* `sessionId` is for a caller that names the session before the turn starts, to log or check it.
|
|
21
|
+
*/
|
|
22
|
+
export async function* runEphemeralTurn(
|
|
23
|
+
ephemeral: EphemeralRun,
|
|
24
|
+
opts: {
|
|
25
|
+
content: UserContent[];
|
|
26
|
+
provider: string;
|
|
27
|
+
principal: Principal;
|
|
28
|
+
signal: AbortSignal;
|
|
29
|
+
prompt?: PromptFn;
|
|
30
|
+
sessionId?: string;
|
|
31
|
+
},
|
|
32
|
+
): AsyncGenerator<PipelineEvent, Session | undefined> {
|
|
33
|
+
const now = new Date().toISOString();
|
|
34
|
+
const id = opts.sessionId ?? crypto.randomUUID();
|
|
35
|
+
await ephemeral.sessions.set(id, { id, version: crypto.randomUUID(), status: 'active', messages: [], createdAt: now, updatedAt: now });
|
|
36
|
+
|
|
37
|
+
const view = await ephemeral.run.open({
|
|
38
|
+
sessionId: id, signal: opts.signal, provider: opts.provider, principal: opts.principal, content: opts.content,
|
|
39
|
+
...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
|
|
40
|
+
});
|
|
41
|
+
const stop = (): void => { ephemeral.run.abort(id); };
|
|
42
|
+
opts.signal.addEventListener('abort', stop, { once: true });
|
|
43
|
+
if (opts.signal.aborted) stop();
|
|
44
|
+
|
|
45
|
+
let final: Session | undefined;
|
|
46
|
+
try {
|
|
47
|
+
for await (const ev of view.events) {
|
|
48
|
+
// To `idle` — the run quiescing — not to this turn's terminal. `followup` is post-commit, so a hook
|
|
49
|
+
// that resubmits or retracts enqueues a FURTHER turn on this runner after the terminal, and stopping
|
|
50
|
+
// at the terminal left it running with nobody watching: appending through the run's appender, calling
|
|
51
|
+
// tools, past the transcript the caller was handed and past a cancel that no longer reached it.
|
|
52
|
+
// Aborting at the terminal does not close that either — the pop happens before the hook enqueues, so
|
|
53
|
+
// the resubmission is queued after the drain and runs anyway. Waiting is what makes the run's work
|
|
54
|
+
// the caller's: bounded by MAX_RESUBMIT_DEPTH, cancellable throughout, and counted.
|
|
55
|
+
if (ev.type === 'idle') break;
|
|
56
|
+
if (!('traceId' in ev) || ev.traceId !== view.traceId) continue;
|
|
57
|
+
if (ev.type === 'done' || ev.type === 'aborted') final = ev.session;
|
|
58
|
+
yield ev;
|
|
59
|
+
}
|
|
60
|
+
} finally {
|
|
61
|
+
opts.signal.removeEventListener('abort', stop);
|
|
62
|
+
stop();
|
|
63
|
+
}
|
|
64
|
+
// The store, not the terminal's session: a followup turn commits after it, and the transcript is meant to
|
|
65
|
+
// be everything the run did. `final` covers a store that has gone from under it.
|
|
66
|
+
return (await ephemeral.sessions.get(id)) ?? final;
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE
|
|
|
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
25
|
export { applyProviderPatch, patchedFields } from '@matatbread/matbot-plugin-api';
|
|
26
|
-
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, FUNCTION_TIMEOUT } from '@matatbread/matbot-plugin-api';
|
|
26
|
+
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, SessionAppendKind, FUNCTION_TIMEOUT } 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';
|
|
@@ -57,6 +57,8 @@ export * from './usage.js';
|
|
|
57
57
|
export * from './hooks.js';
|
|
58
58
|
export * from './runner.js';
|
|
59
59
|
export * from './session-runner.js';
|
|
60
|
+
export * from './session-append.js';
|
|
61
|
+
export * from './ephemeral-turn.js';
|
|
60
62
|
export * from './plugin.js';
|
|
61
63
|
export * from './registry.js';
|
|
62
64
|
export * from './settings.js';
|
|
@@ -70,3 +72,4 @@ export * from './about.js';
|
|
|
70
72
|
export * from './config/index.js';
|
|
71
73
|
export * from './security/index.js';
|
|
72
74
|
export * from './knowledge/index.js';
|
|
75
|
+
export * from './assemble.js';
|
package/src/media.ts
CHANGED
|
@@ -54,8 +54,9 @@ const INLINE_ARMS = new Set(['image', 'document', 'audio']);
|
|
|
54
54
|
* an arm that will fail: by the time the 400 arrives the `file-ref` is in persisted history, where it
|
|
55
55
|
* resolves into every subsequent outgoing copy and fails the session for good.
|
|
56
56
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
57
|
+
* `workspace`'s `showArm` deliberately admits more (any `image/*`): its media is wire-only and dies with
|
|
58
|
+
* the turn, so a type the endpoint rejects costs one turn there, where here it costs the session. Do not
|
|
59
|
+
* unify the two.
|
|
59
60
|
*/
|
|
60
61
|
const DECODABLE_IMAGE: ReadonlySet<string> = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
61
62
|
|
package/src/registry.ts
CHANGED
|
@@ -14,7 +14,6 @@ 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>(),
|
|
@@ -370,11 +369,12 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
370
369
|
build: (ctx) => services.systemContext.build(ctx),
|
|
371
370
|
parts: (ctx) => services.systemContext.parts(ctx),
|
|
372
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.
|
|
373
375
|
async register(key, svc) {
|
|
374
|
-
const keys = state.serviceKeys.get(plugin.name) ?? [];
|
|
375
|
-
keys.push(key as string);
|
|
376
|
-
state.serviceKeys.set(plugin.name, keys);
|
|
377
376
|
await services.register(key, svc);
|
|
377
|
+
recordServiceKey(plugin.name, key as string);
|
|
378
378
|
},
|
|
379
379
|
registerFrontend(info) {
|
|
380
380
|
state.frontendPlugins.set(plugin.name, info);
|
|
@@ -416,33 +416,46 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
|
|
|
416
416
|
|
|
417
417
|
state.plugins.splice(idx, 1);
|
|
418
418
|
services.Notifier.notify({ kind: RegistryChangeKind, source: 'plugins', registry: 'plugins', name: pluginName, operation: 'removed' });
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
419
|
+
await boundedTeardown(plugin);
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
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> {
|
|
422
431
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
423
432
|
try {
|
|
424
433
|
await Promise.race([
|
|
425
434
|
plugin.teardown?.(),
|
|
426
435
|
new Promise<void>((_, reject) => {
|
|
427
|
-
timer = setTimeout(() => reject(new Error(`Teardown timeout for plugin ${
|
|
436
|
+
timer = setTimeout(() => reject(new Error(`Teardown timeout for plugin ${plugin.name}`)), TEARDOWN_TIMEOUT_MS);
|
|
428
437
|
}),
|
|
429
438
|
]);
|
|
430
439
|
} finally {
|
|
431
440
|
if (timer !== undefined) clearTimeout(timer);
|
|
432
441
|
}
|
|
433
|
-
return true;
|
|
434
442
|
}
|
|
435
443
|
|
|
436
|
-
/**
|
|
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
|
|