@matatbread/matbot-core 0.1.0
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/LICENSE +201 -0
- package/README.md +23 -0
- package/package.json +39 -0
- package/src/config/index.ts +4 -0
- package/src/config/loader.ts +123 -0
- package/src/config/yaml.ts +127 -0
- package/src/hooks.ts +1 -0
- package/src/index.ts +32 -0
- package/src/knowledge/index.ts +1 -0
- package/src/knowledge/lookup-knowledge-index.ts +58 -0
- package/src/loader.ts +340 -0
- package/src/plugin.ts +1 -0
- package/src/providers-base/index.ts +1 -0
- package/src/providers-base/sse.ts +28 -0
- package/src/registry.ts +348 -0
- package/src/runner.ts +324 -0
- package/src/security/grants.ts +6 -0
- package/src/security/index.ts +2 -0
- package/src/security/vault.ts +76 -0
- package/src/session-runner.ts +455 -0
- package/src/session.ts +59 -0
- package/src/settings.ts +66 -0
- package/src/single-turn.ts +64 -0
- package/src/storage-base/index.ts +1 -0
- package/src/storage-base/query/access.ts +16 -0
- package/src/storage-base/query/compile.ts +42 -0
- package/src/storage-base/query/execute.ts +49 -0
- package/src/storage-base/query/index.ts +6 -0
- package/src/storage-base/query/paginate.ts +26 -0
- package/src/storage-base/query/sort.ts +29 -0
- package/src/storage-base/query/validate.ts +113 -0
- package/src/system-context.ts +26 -0
- package/src/tool-registry.ts +38 -0
- package/src/types.ts +13 -0
package/src/registry.ts
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, PluginRegistryEvent } from './types.js';
|
|
2
|
+
import { createBroadcaster } from '@matatbread/matbot-plugin-api';
|
|
3
|
+
import type {
|
|
4
|
+
MatbotPlugin, MatbotMachine, Mounted,
|
|
5
|
+
ProviderAdapterFactory, StoreFactory,
|
|
6
|
+
} from './plugin.js';
|
|
7
|
+
import { PLUGIN_API_VERSION, unifyServices } from './plugin.js';
|
|
8
|
+
import { HookRegistry } from './hooks.js';
|
|
9
|
+
import { makePluginSettings } from './settings.js';
|
|
10
|
+
import type { SettingsDoc } from './settings.js';
|
|
11
|
+
|
|
12
|
+
// ── Internal state ────────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
// Mutable arrays/maps held in a single object to make _resetRegistry() simple.
|
|
15
|
+
const state = {
|
|
16
|
+
plugins: [] as MatbotPlugin[],
|
|
17
|
+
providers: new Map<string, ProviderAdapterFactory>(),
|
|
18
|
+
storage: new Map<string, StoreFactory>(),
|
|
19
|
+
toolRegistry: undefined as ToolRegistry | undefined,
|
|
20
|
+
frontendPlugins: new Map<string, FrontendInfo>(), // pluginName → info, written by services.registerFrontend()
|
|
21
|
+
serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
|
|
22
|
+
hookPlugins: new Set<string>(), // plugins that registered at least one hook
|
|
23
|
+
systemContextPlugins: new Set<string>(), // plugins that registered a system-context contributor
|
|
24
|
+
overwriteAllTools: undefined as boolean | undefined, // persisted "overwrite on collision, this install" choice, loaded lazily
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// Read-only observation of plugin load/unload, for consumers that key off plugin presence (e.g. the
|
|
28
|
+
// web plugins panel refreshing live when a backend restores a plugin set out of band). Module-level,
|
|
29
|
+
// not in `state` — it owns subscribers across the registry's lifetime, not per-plugin data.
|
|
30
|
+
const pluginEvents = createBroadcaster<PluginRegistryEvent>();
|
|
31
|
+
|
|
32
|
+
export function watchPlugins(signal?: AbortSignal): AsyncIterable<PluginRegistryEvent> {
|
|
33
|
+
return pluginEvents.subscribe(signal);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Settings namespace + key under which the user's "overwrite all colliding tools" choice
|
|
37
|
+
// is persisted for the installation. The namespace doubles as a Store document id, so it must
|
|
38
|
+
// satisfy the storage id charset (/^[\w-]+$/) — hence underscores, not '@matbot/core'. The
|
|
39
|
+
// dunder marks it reserved (internal), so it won't collide with a real plugin's settings.
|
|
40
|
+
const CORE_SETTINGS_NS = '__matbot_core__';
|
|
41
|
+
const OVERWRITE_TOOLS_KEY = 'overwriteToolsOnCollision';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Decide whether an incoming tool registration may overwrite an existing tool of the
|
|
45
|
+
* same name owned by a different plugin. Returns true to overwrite, false to keep the
|
|
46
|
+
* existing one and drop the incoming registration.
|
|
47
|
+
*
|
|
48
|
+
* Resolution order: a persisted "overwrite all (this install)" choice short-circuits to
|
|
49
|
+
* true; otherwise the user is prompted [n / Y / all] with Y (overwrite) as the default.
|
|
50
|
+
* 'all' persists the choice. With no prompt available (non-interactive host) we overwrite —
|
|
51
|
+
* the default — preserving matbot's historical last-registration-wins behaviour.
|
|
52
|
+
*/
|
|
53
|
+
async function resolveToolCollision(
|
|
54
|
+
services: MatbotMachine,
|
|
55
|
+
toolName: string,
|
|
56
|
+
existingOwner: string | undefined,
|
|
57
|
+
incomingOwner: string,
|
|
58
|
+
prompt: PromptFn | undefined,
|
|
59
|
+
): Promise<boolean> {
|
|
60
|
+
const coreSettings = makePluginSettings(services.createStore<SettingsDoc>('settings'), CORE_SETTINGS_NS);
|
|
61
|
+
if (state.overwriteAllTools === undefined) {
|
|
62
|
+
state.overwriteAllTools = (await coreSettings.get<boolean>(OVERWRITE_TOOLS_KEY)) ?? false;
|
|
63
|
+
}
|
|
64
|
+
if (state.overwriteAllTools) return true;
|
|
65
|
+
|
|
66
|
+
const owner = existingOwner !== undefined ? `"${existingOwner}"` : 'a built-in';
|
|
67
|
+
const label = `Tool \`"${toolName}"\` is already registered by **${owner}**. Overwrite it with the one from **"${incomingOwner}"**?`;
|
|
68
|
+
|
|
69
|
+
if (prompt === undefined) {
|
|
70
|
+
console.warn(`[matbot] ${label} — non-interactive, overwriting (default).`);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const field: FormField = {
|
|
75
|
+
name: 'overwrite',
|
|
76
|
+
label,
|
|
77
|
+
type: 'select',
|
|
78
|
+
options: ['Keep existing', 'Overwrite', 'Always overwrite'],
|
|
79
|
+
default: 'Overwrite',
|
|
80
|
+
};
|
|
81
|
+
const answer = (await prompt(field)).trim().toLowerCase();
|
|
82
|
+
if (answer.startsWith('a')) { // "Always overwrite" — persist for the installation
|
|
83
|
+
state.overwriteAllTools = true;
|
|
84
|
+
await coreSettings.set(OVERWRITE_TOOLS_KEY, true);
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
return !answer.startsWith('k'); // "Keep existing" → false; "Overwrite"/default → true
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── Version check ─────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function checkApiVersion(plugin: MatbotPlugin): void {
|
|
93
|
+
const [rMajor = '0', rMinor = '0'] = PLUGIN_API_VERSION.split('.');
|
|
94
|
+
const [pMajor = '0', pMinor = '0'] = plugin.apiVersion.split('.');
|
|
95
|
+
|
|
96
|
+
if (pMajor !== rMajor) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Plugin "${plugin.name}" requires API ${plugin.apiVersion} (major ${pMajor}) ` +
|
|
99
|
+
`but runtime provides ${PLUGIN_API_VERSION}. ` +
|
|
100
|
+
`Update the plugin or the runtime.`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (Number(pMinor) > Number(rMinor)) {
|
|
104
|
+
console.warn(
|
|
105
|
+
`[matbot] Plugin "${plugin.name}" targets API ${plugin.apiVersion} ` +
|
|
106
|
+
`but runtime is ${PLUGIN_API_VERSION}. Some features may not be available.`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── Registration ──────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export function registerPlugin(plugin: MatbotPlugin): void {
|
|
114
|
+
checkApiVersion(plugin);
|
|
115
|
+
|
|
116
|
+
if (state.plugins.some(p => p.name === plugin.name)) {
|
|
117
|
+
throw new Error(`Plugin "${plugin.name}" is already registered.`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (plugin.provider !== undefined && state.providers.has(plugin.name)) {
|
|
121
|
+
throw new Error(`Provider "${plugin.name}" is already registered.`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const type of Object.keys(plugin.storage ?? {})) {
|
|
125
|
+
if (state.storage.has(type)) {
|
|
126
|
+
const owner = state.plugins.find(p => p.storage?.[type] !== undefined)?.name ?? '?';
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Storage type "${type}" is already registered by "${owner}". ` +
|
|
129
|
+
`"${plugin.name}" cannot register it again.`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
state.plugins.push(plugin);
|
|
135
|
+
|
|
136
|
+
if (plugin.provider !== undefined) {
|
|
137
|
+
state.providers.set(plugin.name, plugin.provider);
|
|
138
|
+
}
|
|
139
|
+
for (const [type, factory] of Object.entries(plugin.storage ?? {})) {
|
|
140
|
+
state.storage.set(type, factory);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
pluginEvents.emit({ type: 'loaded', name: plugin.name });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ── Resolution ────────────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
export function resolveProviderFactory(module: string): ProviderAdapterFactory {
|
|
149
|
+
const factory = state.providers.get(module);
|
|
150
|
+
if (factory === undefined) {
|
|
151
|
+
const available = [...state.providers.keys()].join(', ') || 'none';
|
|
152
|
+
throw new Error(
|
|
153
|
+
`No provider registered for module "${module}". ` +
|
|
154
|
+
`Available: ${available}. ` +
|
|
155
|
+
`Install and load the provider plugin.`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
return factory;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function getRegisteredTools(): readonly Tool[] {
|
|
162
|
+
return state.toolRegistry?.list() ?? [];
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function getRegisteredPlugins(): readonly MatbotPlugin[] {
|
|
166
|
+
return state.plugins;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function getRegisteredFrontendPlugins(): ReadonlyMap<string, FrontendInfo> {
|
|
170
|
+
return state.frontendPlugins;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** MatbotMachine keys a plugin registered at runtime via services.register() (e.g. 'KnowledgeIndex'). */
|
|
174
|
+
export function getRegisteredServiceKeys(pluginName: string): readonly string[] {
|
|
175
|
+
return state.serviceKeys.get(pluginName) ?? [];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Attribute a service key to a plugin out of band. The host uses this for a backend it opened at boot
|
|
180
|
+
* *before* the registry knew the plugin's name — a storageBackend manifest pre-scan bypasses the scoped
|
|
181
|
+
* register() that would normally record the key. Recording it makes the boot-opened backend unload-equal
|
|
182
|
+
* to a runtime register(): unloadPlugin() then calls unregister() for it, reverting to the host base.
|
|
183
|
+
*/
|
|
184
|
+
export function recordServiceKey(pluginName: string, key: string): void {
|
|
185
|
+
const keys = state.serviceKeys.get(pluginName) ?? [];
|
|
186
|
+
if (!keys.includes(key)) keys.push(key);
|
|
187
|
+
state.serviceKeys.set(pluginName, keys);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Plugins that registered at least one hook in setup(). */
|
|
191
|
+
export function getHookPlugins(): ReadonlySet<string> {
|
|
192
|
+
return state.hookPlugins;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Plugins that registered a system-context contributor in setup(). */
|
|
196
|
+
export function getSystemContextPlugins(): ReadonlySet<string> {
|
|
197
|
+
return state.systemContextPlugins;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Resolve a loaded plugin's name from the specifier used to load it. Each plugin carries its own
|
|
201
|
+
* specifier, so this is a scan of the plugin list — no side-map to keep in sync. */
|
|
202
|
+
export function getPluginNameForSpecifier(specifier: string): string | undefined {
|
|
203
|
+
return state.plugins.find(p => p.specifier === specifier)?.name;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Reverse of getPluginNameForSpecifier — finds the specifier used to load the named plugin. */
|
|
207
|
+
export function getSpecifierForPlugin(pluginName: string): string | undefined {
|
|
208
|
+
return state.plugins.find(p => p.name === pluginName)?.specifier;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Run setup() for a single plugin. Called by loadPlugins immediately after registration.
|
|
215
|
+
*
|
|
216
|
+
* `prompt`, when supplied by the host, makes tool-name collisions interactive: registering a
|
|
217
|
+
* tool whose name a *different* plugin already owns asks the user whether to overwrite. Absent
|
|
218
|
+
* (non-interactive host), collisions overwrite silently — the historical default.
|
|
219
|
+
*/
|
|
220
|
+
export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine, prompt?: PromptFn): Promise<void> {
|
|
221
|
+
state.toolRegistry ??= services.tools;
|
|
222
|
+
|
|
223
|
+
// Single choke point for every plugin tool registration (static `plugin.tools` and in-setup
|
|
224
|
+
// `services.tools.register`). Stamps ownership and resolves name collisions. The no-collision
|
|
225
|
+
// path runs synchronously (an async fn yields nothing before its first await), so fire-and-forget
|
|
226
|
+
// callers that don't await still get the tool registered in the same tick.
|
|
227
|
+
const registerTool = async (tool: Tool): Promise<void> => {
|
|
228
|
+
const stamped: Tool = { ...tool, pluginName: plugin.name };
|
|
229
|
+
const existing = services.tools.resolve(stamped.name);
|
|
230
|
+
if (existing !== null && existing.pluginName !== plugin.name) {
|
|
231
|
+
const overwrite = await resolveToolCollision(services, stamped.name, existing.pluginName, plugin.name, prompt);
|
|
232
|
+
if (!overwrite) return;
|
|
233
|
+
}
|
|
234
|
+
services.tools.register(stamped);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// Plugin-scoped settings: bound to this plugin's identity, built once. A plugin reaches only its
|
|
238
|
+
// own settings — there is no way to name another's.
|
|
239
|
+
const ownSettings = makePluginSettings(services.createStore<SettingsDoc>('settings'), plugin.name);
|
|
240
|
+
|
|
241
|
+
// Per-plugin `mounted`: a thin adapter over the host mount table that delivers *this plugin's* scoped
|
|
242
|
+
// machine (and scoped onUnmount) to handlers. The stable `scoped` object reads through the host's
|
|
243
|
+
// re-pointing proxies/registry, so `scoped[key]` is the host's live service by the time a transition
|
|
244
|
+
// fires. Forward-referenced via `scoped`, assigned below; consume() only runs after setup.
|
|
245
|
+
let scoped: MatbotMachine;
|
|
246
|
+
const scopedMounted: Mounted = {
|
|
247
|
+
consume(options, handler) {
|
|
248
|
+
// Forward to the host mount table but deliver *this plugin's* scoped machine — it reads through
|
|
249
|
+
// the same proxies/registry, so scoped[key] is the host's live service. onUnmount is scoped too.
|
|
250
|
+
const forwarded = options.onUnmount !== undefined
|
|
251
|
+
? { ...options, onUnmount: () => options.onUnmount!(scoped) }
|
|
252
|
+
: options;
|
|
253
|
+
services.mounted.consume(forwarded, () => handler(scoped as never));
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
scoped = unifyServices({
|
|
258
|
+
...services,
|
|
259
|
+
mounted: scopedMounted,
|
|
260
|
+
settings: () => ownSettings,
|
|
261
|
+
self: {
|
|
262
|
+
name: plugin.name,
|
|
263
|
+
specifier: plugin.specifier,
|
|
264
|
+
...(plugin.source !== undefined ? { source: plugin.source } : {}),
|
|
265
|
+
},
|
|
266
|
+
tools: {
|
|
267
|
+
register: registerTool,
|
|
268
|
+
remove: (name: string) => services.tools.remove(name),
|
|
269
|
+
resolve: (name: string) => services.tools.resolve(name),
|
|
270
|
+
list: () => services.tools.list(),
|
|
271
|
+
removeByPlugin:(name: string) => services.tools.removeByPlugin(name),
|
|
272
|
+
watch: (signal?: AbortSignal) => services.tools.watch(signal),
|
|
273
|
+
},
|
|
274
|
+
hooks: {
|
|
275
|
+
register(hook: Hook) {
|
|
276
|
+
state.hookPlugins.add(plugin.name);
|
|
277
|
+
services.hooks.register({ ...hook, pluginName: plugin.name } as Hook);
|
|
278
|
+
},
|
|
279
|
+
removeByPlugin: (name: string) => services.hooks.removeByPlugin(name),
|
|
280
|
+
} as unknown as HookRegistry,
|
|
281
|
+
systemContext: {
|
|
282
|
+
register(contributor) {
|
|
283
|
+
state.systemContextPlugins.add(plugin.name);
|
|
284
|
+
services.systemContext.register(contributor, plugin.name);
|
|
285
|
+
},
|
|
286
|
+
removeByPlugin: (name: string) => services.systemContext.removeByPlugin(name),
|
|
287
|
+
build: (ctx) => services.systemContext.build(ctx),
|
|
288
|
+
},
|
|
289
|
+
async register(key, svc) {
|
|
290
|
+
const keys = state.serviceKeys.get(plugin.name) ?? [];
|
|
291
|
+
keys.push(key as string);
|
|
292
|
+
state.serviceKeys.set(plugin.name, keys);
|
|
293
|
+
await services.register(key, svc);
|
|
294
|
+
},
|
|
295
|
+
registerFrontend(info) {
|
|
296
|
+
state.frontendPlugins.set(plugin.name, info);
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
for (const tool of plugin.tools ?? []) {
|
|
300
|
+
await registerTool(tool);
|
|
301
|
+
}
|
|
302
|
+
await plugin.setup?.(scoped);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Tear down and fully unload a single plugin, removing all its registered contributions. */
|
|
306
|
+
export async function unloadPlugin(pluginName: string, services: MatbotMachine): Promise<boolean> {
|
|
307
|
+
console.warn(`[matbot] Unloading plugin "${pluginName}"`);
|
|
308
|
+
const idx = state.plugins.findIndex(p => p.name === pluginName);
|
|
309
|
+
if (idx === -1) return false;
|
|
310
|
+
|
|
311
|
+
// Note: all synchronous cleanup (removing tools, hooks, services) is done before any asynchronous teardown() calls, to ensure a consistent state even if teardown() fails or hangs.
|
|
312
|
+
const plugin = state.plugins[idx]!;
|
|
313
|
+
|
|
314
|
+
services.tools.removeByPlugin(pluginName);
|
|
315
|
+
services.hooks.removeByPlugin(pluginName);
|
|
316
|
+
services.systemContext.removeByPlugin(pluginName);
|
|
317
|
+
|
|
318
|
+
for (const key of state.serviceKeys.get(pluginName) ?? []) {
|
|
319
|
+
services.unregister(key);
|
|
320
|
+
}
|
|
321
|
+
state.serviceKeys.delete(pluginName);
|
|
322
|
+
state.hookPlugins.delete(pluginName);
|
|
323
|
+
state.systemContextPlugins.delete(pluginName);
|
|
324
|
+
|
|
325
|
+
if (plugin.provider !== undefined) state.providers.delete(plugin.name);
|
|
326
|
+
for (const type of Object.keys(plugin.storage ?? {})) state.storage.delete(type);
|
|
327
|
+
|
|
328
|
+
state.frontendPlugins.delete(pluginName);
|
|
329
|
+
|
|
330
|
+
state.plugins.splice(idx, 1);
|
|
331
|
+
pluginEvents.emit({ type: 'unloaded', name: pluginName });
|
|
332
|
+
await Promise.race([
|
|
333
|
+
plugin.teardown?.(),
|
|
334
|
+
new Promise<void>((_, reject) => setTimeout(() => reject(new Error(`Teardown timeout for plugin ${pluginName}`)), 10000))
|
|
335
|
+
]);
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Run each plugin's teardown() in reverse-registration order. Errors are logged, not thrown. */
|
|
340
|
+
export async function teardownPlugins(): Promise<void> {
|
|
341
|
+
const results = await Promise.allSettled([...state.plugins].reverse().map(plugin => plugin.teardown?.()));
|
|
342
|
+
results.forEach((result, i) => {
|
|
343
|
+
if (result.status === 'rejected') {
|
|
344
|
+
console.error(`[matbot] teardown error in plugin "${state.plugins[i]?.name}":`, result.reason);
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Session, MessageContent,
|
|
3
|
+
PipelineEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
|
+
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
|
+
} from './types.js';
|
|
6
|
+
import type { MatbotPlugin } from './plugin.js';
|
|
7
|
+
import { HookRegistry } from './hooks.js';
|
|
8
|
+
import { appendMessage, createMessage } from './session.js';
|
|
9
|
+
|
|
10
|
+
export interface RunSessionOpts {
|
|
11
|
+
session: Session;
|
|
12
|
+
config: RunConfig;
|
|
13
|
+
provider: ProviderAdapter;
|
|
14
|
+
providerConfig: ProviderConfig;
|
|
15
|
+
tools?: ReadonlyMap<string, Tool>;
|
|
16
|
+
/**
|
|
17
|
+
* Live registry consulted to *resolve the executor* at call time. The `tools` map above is a
|
|
18
|
+
* turn-start snapshot — the stable menu advertised to the model for the whole turn — but a tool
|
|
19
|
+
* that mutates the registry mid-turn (`plugin reload`/`add`/`remove`) must take effect for any
|
|
20
|
+
* call later in the *same* turn, so execution resolves against this live registry rather than the
|
|
21
|
+
* snapshot. Present ⇒ authoritative (a tool removed mid-turn resolves to null ⇒ "Unknown tool",
|
|
22
|
+
* which is correct); absent ⇒ falls back to the snapshot (direct callers / tests that pass only
|
|
23
|
+
* `tools`).
|
|
24
|
+
*/
|
|
25
|
+
toolRegistry?: ToolRegistry;
|
|
26
|
+
store: Store<Session>;
|
|
27
|
+
hooks?: HookRegistry;
|
|
28
|
+
systemContext?: SystemContextRegistry;
|
|
29
|
+
signal: AbortSignal;
|
|
30
|
+
vault?: Vault;
|
|
31
|
+
workdir?: string;
|
|
32
|
+
configPath?: string;
|
|
33
|
+
files?: FileStore;
|
|
34
|
+
/** Supply a prompt implementation to allow tools to ask interactive questions. */
|
|
35
|
+
prompt?: PromptFn;
|
|
36
|
+
/**
|
|
37
|
+
* Turn-scoped context to inject ephemerally, exactly like a `screen` hook's `ephemeral` (tail-folded
|
|
38
|
+
* onto the freshest non-marker message, never persisted) — merged ahead of whatever `screen` adds.
|
|
39
|
+
* The pump supplies this for an agent-phase retract-redo: the trigger tool's output rides into the
|
|
40
|
+
* re-run of the originating user turn. Empty/absent for an ordinary turn.
|
|
41
|
+
*/
|
|
42
|
+
injectedEphemeral?: MessageContent[];
|
|
43
|
+
loadPlugin: (specifier: string, prompt?: PromptFn) => Promise<MatbotPlugin>;
|
|
44
|
+
unloadPlugin: (specifier: string) => Promise<boolean>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineEvent> {
|
|
48
|
+
const { config, provider, providerConfig, store, signal } = opts;
|
|
49
|
+
const tools = opts.tools ?? new Map<string, Tool>();
|
|
50
|
+
const hookReg = opts.hooks ?? new HookRegistry();
|
|
51
|
+
const promptFn: PromptFn = opts.prompt ?? (((p: string | FormField, def?: string): Promise<string> => {
|
|
52
|
+
const fallback = typeof p === 'string' ? def : p.default;
|
|
53
|
+
if (fallback !== undefined) return Promise.resolve(fallback);
|
|
54
|
+
const label = typeof p === 'string' ? p : p.label;
|
|
55
|
+
return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
|
|
56
|
+
}) as PromptFn);
|
|
57
|
+
const vault: Vault = opts.vault ?? {
|
|
58
|
+
async createSecret() { throw new Error('No vault configured'); },
|
|
59
|
+
async writeSecret() { throw new Error('No vault configured'); },
|
|
60
|
+
hasKey() { return false; },
|
|
61
|
+
async resolve(ref: string) { return ref; },
|
|
62
|
+
scrub(text: string) { return text; },
|
|
63
|
+
};
|
|
64
|
+
const traceId = config.traceId ?? crypto.randomUUID();
|
|
65
|
+
|
|
66
|
+
// ── 1. screen — once per turn: shape/abort the incoming submission ──────────
|
|
67
|
+
|
|
68
|
+
const screen = await hookReg.runScreen({ session: opts.session, config, signal, prompt: promptFn });
|
|
69
|
+
if (screen.abort) {
|
|
70
|
+
// Hook-failure (and any other screen-injected) markers carried live, even on abort, so a
|
|
71
|
+
// misconfigured hook surfaces this turn rather than only on a later reload.
|
|
72
|
+
if (screen.markers.length > 0) yield { type: 'marker', content: screen.markers, traceId };
|
|
73
|
+
await store.set(screen.session.id, screen.session);
|
|
74
|
+
yield { type: 'aborted', reason: screen.abort, session: screen.session, traceId };
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
// Durable context a screen hook folded onto the user message (e.g. a fired `contextual` trigger),
|
|
78
|
+
// carried live so a frontend draws it now rather than only on a later reload. It is already part of
|
|
79
|
+
// screen.session (origin: 'robo' blocks on the user turn) — this event is purely live delivery.
|
|
80
|
+
// Emitted BEFORE the markers so the live order matches a reload: the durable blocks belong to the
|
|
81
|
+
// user message (rendered with the turn), while screen markers were appended to the session AFTER it.
|
|
82
|
+
if (screen.durable.length > 0) {
|
|
83
|
+
yield { type: 'robo-user', content: screen.durable, traceId };
|
|
84
|
+
}
|
|
85
|
+
// Hook-failure (and any other screen-injected) markers, carried live so the UI shows them this turn
|
|
86
|
+
// rather than only on a later session reload. They are already persisted in screen.session.
|
|
87
|
+
if (screen.markers.length > 0) {
|
|
88
|
+
yield { type: 'marker', content: screen.markers, traceId };
|
|
89
|
+
}
|
|
90
|
+
let session = screen.session;
|
|
91
|
+
|
|
92
|
+
// Turn-scoped ephemeral context from screen — appended to the TAIL of the outgoing messages
|
|
93
|
+
// (onto the content of the freshest history message, preserving its role), never persisted.
|
|
94
|
+
// At the tail, not as a system prefix, because (a) a "do X now" directive needs the salience of
|
|
95
|
+
// being the last thing the model reads — a system-block prefix sits above the whole history and
|
|
96
|
+
// reads as stale once the turn has any momentum — and (b) per-turn content in the prefix would
|
|
97
|
+
// bust the cached system/history prefix every turn; appended at the tail it leaves the prefix
|
|
98
|
+
// byte-stable. Folding into the last message (rather than adding a trailing message) avoids
|
|
99
|
+
// role-alternation hazards and survives both adapters (Anthropic folds system→system=; OpenAI
|
|
100
|
+
// drops non-result content from tool-role messages — a separate trailing message would break on
|
|
101
|
+
// one or the other).
|
|
102
|
+
// injectedEphemeral (a pump-supplied retract-redo's context) leads, then screen's own — both share
|
|
103
|
+
// the identical tail-fold path below.
|
|
104
|
+
const ephemeral = [...(opts.injectedEphemeral ?? []), ...screen.ephemeral];
|
|
105
|
+
|
|
106
|
+
// ── 2. System context (built once per submit, never persisted) ─────────────
|
|
107
|
+
|
|
108
|
+
const systemText = opts.systemContext
|
|
109
|
+
? await opts.systemContext.build({ session, signal })
|
|
110
|
+
: null;
|
|
111
|
+
|
|
112
|
+
const systemMsg = systemText !== null
|
|
113
|
+
? [createMessage({ role: 'system', content: [{ type: 'text', text: systemText }], traceId: '' })]
|
|
114
|
+
: [];
|
|
115
|
+
|
|
116
|
+
if (systemText !== null) {
|
|
117
|
+
yield { type: 'system-context', text: systemText, traceId };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── 3. Agentic loop ────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
for (;;) {
|
|
123
|
+
// Respect an abort that arrived between turns (e.g. during tool execution).
|
|
124
|
+
if (signal.aborted) {
|
|
125
|
+
await store.set(session.id, session);
|
|
126
|
+
yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const pendingCalls: Array<{ id: string; name: string; input: unknown }> = [];
|
|
131
|
+
const assistantParts: MessageContent[] = [];
|
|
132
|
+
let textAcc = '';
|
|
133
|
+
|
|
134
|
+
// One provider call. The outgoing array (system context + history, with screen's ephemeral
|
|
135
|
+
// blocks appended onto the tail message) is assembled here and handed to `contribute` hooks for
|
|
136
|
+
// a final ephemeral transform — none of this is written back to the session.
|
|
137
|
+
// Fold onto the last NON-marker message: markers are elided from the wire, so the literal last
|
|
138
|
+
// message can be a marker (e.g. a retract-redo leaves the retraction marker as the tail) — folding
|
|
139
|
+
// there would drop the ephemeral with it. -1 ⇒ nothing to fold onto (no non-marker history).
|
|
140
|
+
const foldIdx = ephemeral.length > 0 ? session.messages.findLastIndex(m => m.role !== 'marker') : -1;
|
|
141
|
+
const history = foldIdx >= 0
|
|
142
|
+
? session.messages.map((m, i) =>
|
|
143
|
+
i === foldIdx ? { ...m, content: [...m.content, ...ephemeral] } : m)
|
|
144
|
+
: session.messages;
|
|
145
|
+
const outgoing = await hookReg.runContribute({
|
|
146
|
+
outgoing: [...systemMsg, ...history],
|
|
147
|
+
session, config, signal,
|
|
148
|
+
});
|
|
149
|
+
try {
|
|
150
|
+
for await (const ev of provider.complete(outgoing, providerConfig, [...tools.values()], signal)) {
|
|
151
|
+
switch (ev.type) {
|
|
152
|
+
case 'text-delta':
|
|
153
|
+
textAcc += ev.delta;
|
|
154
|
+
yield { type: 'text-delta', delta: ev.delta, traceId };
|
|
155
|
+
break;
|
|
156
|
+
case 'thinking':
|
|
157
|
+
yield { type: 'thinking', delta: ev.delta, traceId };
|
|
158
|
+
break;
|
|
159
|
+
case 'thinking-block':
|
|
160
|
+
assistantParts.push({ type: 'thinking', thinking: ev.thinking, signature: ev.signature });
|
|
161
|
+
break;
|
|
162
|
+
case 'redacted-thinking':
|
|
163
|
+
assistantParts.push({ type: 'redacted-thinking', data: ev.data });
|
|
164
|
+
break;
|
|
165
|
+
case 'unknown-block':
|
|
166
|
+
assistantParts.push({ type: 'unknown-content', blockType: ev.blockType, raw: ev.raw });
|
|
167
|
+
break;
|
|
168
|
+
case 'reasoning-block':
|
|
169
|
+
assistantParts.push({ type: 'reasoning', reasoning: ev.reasoning });
|
|
170
|
+
break;
|
|
171
|
+
case 'tool-call':
|
|
172
|
+
pendingCalls.push({ id: ev.id, name: ev.name, input: ev.input });
|
|
173
|
+
break;
|
|
174
|
+
case 'usage':
|
|
175
|
+
yield { type: 'usage', inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, traceId,
|
|
176
|
+
...(ev.costUsd !== undefined ? { costUsd: ev.costUsd } : {}),
|
|
177
|
+
...(ev.cacheReadTokens !== undefined ? { cacheReadTokens: ev.cacheReadTokens } : {}),
|
|
178
|
+
...(ev.cacheCreationTokens !== undefined ? { cacheCreationTokens: ev.cacheCreationTokens } : {}) };
|
|
179
|
+
break;
|
|
180
|
+
case 'done':
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (e: any) {
|
|
185
|
+
if (signal.aborted) {
|
|
186
|
+
// Save whatever the LLM streamed before the abort hit.
|
|
187
|
+
if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
|
|
188
|
+
if (assistantParts.length > 0) {
|
|
189
|
+
session = appendMessage(session, createMessage({
|
|
190
|
+
role: 'assistant', content: assistantParts, traceId, providerName: config.provider,
|
|
191
|
+
}));
|
|
192
|
+
}
|
|
193
|
+
await store.set(session.id, session);
|
|
194
|
+
yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
yield { type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId };
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Build and store assistant message
|
|
202
|
+
if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
|
|
203
|
+
for (const tc of pendingCalls) {
|
|
204
|
+
assistantParts.push({ type: 'tool-call', id: tc.id, name: tc.name, input: tc.input });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (assistantParts.length > 0) {
|
|
208
|
+
const assistantMsg = createMessage({
|
|
209
|
+
role: 'assistant',
|
|
210
|
+
content: assistantParts,
|
|
211
|
+
traceId,
|
|
212
|
+
providerName: config.provider,
|
|
213
|
+
});
|
|
214
|
+
session = appendMessage(session, assistantMsg);
|
|
215
|
+
} else {
|
|
216
|
+
// Diagnostic: the provider returned no text, no tool calls, no thinking — a genuinely empty
|
|
217
|
+
// completion. The turn will end with no assistant message, which reads as "the agent never
|
|
218
|
+
// replied". Logged here so a silent no-reply turn is traceable. (textAcc length is shown to
|
|
219
|
+
// tell a truly empty stream apart from one that was only whitespace.)
|
|
220
|
+
console.warn(`[runner] empty completion (no assistant content) on traceId ${traceId}; textAcc=${textAcc.length} chars, provider=${config.provider}`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// No tool calls → done
|
|
224
|
+
if (pendingCalls.length === 0) break;
|
|
225
|
+
|
|
226
|
+
// ── 3. Execute tool calls ────────────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
const toolResults: MessageContent[] = [];
|
|
229
|
+
const toolMarkers: MessageContent[] = [];
|
|
230
|
+
|
|
231
|
+
for (const tc of pendingCalls) {
|
|
232
|
+
yield { type: 'tool:start', callId: tc.id, name: tc.name, input: tc.input, traceId };
|
|
233
|
+
|
|
234
|
+
const tool = opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name);
|
|
235
|
+
if (!tool) {
|
|
236
|
+
const err = { error: `Unknown tool: ${tc.name}` };
|
|
237
|
+
toolResults.push({ type: 'tool-result', id: tc.id, result: err, isError: true });
|
|
238
|
+
yield { type: 'tool:end', callId: tc.id, result: err, isError: true, traceId };
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const decision = await hookReg.runToolCall({
|
|
243
|
+
session, config, signal,
|
|
244
|
+
toolCall: { id: tc.id, name: tc.name, input: tc.input },
|
|
245
|
+
tool,
|
|
246
|
+
});
|
|
247
|
+
if (decision.abort) {
|
|
248
|
+
yield { type: 'aborted', reason: decision.abort, session, traceId };
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
if (decision.rejectTool) {
|
|
252
|
+
const err = { error: decision.rejectTool.message };
|
|
253
|
+
toolResults.push({ type: 'tool-result', id: tc.id, result: err, isError: true });
|
|
254
|
+
yield { type: 'tool:end', callId: tc.id, result: err, isError: true, traceId };
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let result: unknown;
|
|
259
|
+
let isError = false;
|
|
260
|
+
const startedAt = Date.now();
|
|
261
|
+
|
|
262
|
+
const toolCtx: ToolContext = {
|
|
263
|
+
callId: tc.id, session, signal, vault,
|
|
264
|
+
provider: config.provider,
|
|
265
|
+
prompt: promptFn,
|
|
266
|
+
loadPlugin: (specifier: string) => opts.loadPlugin(specifier, promptFn),
|
|
267
|
+
unloadPlugin: opts.unloadPlugin,
|
|
268
|
+
...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
|
|
269
|
+
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
270
|
+
...(opts.files !== undefined ? { files: opts.files } : {}),
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
for await (const toolEv of tool.executor.execute(tc.input, toolCtx)) {
|
|
275
|
+
switch (toolEv.type) {
|
|
276
|
+
case 'stdout': yield { type: 'tool:stdout', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
|
|
277
|
+
case 'stderr': yield { type: 'tool:stderr', callId: tc.id, chunk: toolEv.chunk, traceId }; break;
|
|
278
|
+
case 'file': yield { type: 'file', handle: toolEv.handle, traceId }; break;
|
|
279
|
+
case 'result': result = toolEv.value; break;
|
|
280
|
+
case 'marker': toolMarkers.push({ type: 'marker', creator: toolEv.creator, data: toolEv.data }); break;
|
|
281
|
+
case 'progress': break;
|
|
282
|
+
case 'error': result = {
|
|
283
|
+
error: toolEv.message,
|
|
284
|
+
...(toolEv.stdout !== undefined ? { stdout: toolEv.stdout } : {}),
|
|
285
|
+
...(toolEv.stderr !== undefined ? { stderr: toolEv.stderr } : {}),
|
|
286
|
+
}; isError = true; break;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch (e) {
|
|
290
|
+
result = { error: String(e) };
|
|
291
|
+
isError = true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// toolresult — last chance to transform the result before it's recorded/yielded (hard redaction),
|
|
295
|
+
// or to observe it (auditing: args + result + timing). Owns the LLM-facing + persisted surfaces.
|
|
296
|
+
result = await hookReg.runToolResult({
|
|
297
|
+
session, config, signal,
|
|
298
|
+
toolCall: { id: tc.id, name: tc.name, input: tc.input },
|
|
299
|
+
tool, result, isError, durationMs: Date.now() - startedAt,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
toolResults.push({ type: 'tool-result', id: tc.id, result, isError });
|
|
303
|
+
yield { type: 'tool:end', callId: tc.id, result, isError, traceId };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Add tool results message, then loop for the next provider call.
|
|
307
|
+
const toolMsg = createMessage({ role: 'tool', content: toolResults, traceId });
|
|
308
|
+
session = appendMessage(session, toolMsg);
|
|
309
|
+
|
|
310
|
+
// Markers a tool emitted while running: persist as their own marker-role message (elided from
|
|
311
|
+
// provider submission) and carry live so a frontend can render them without a reload.
|
|
312
|
+
if (toolMarkers.length > 0) {
|
|
313
|
+
session = appendMessage(session, createMessage({ role: 'marker', content: toolMarkers, traceId }));
|
|
314
|
+
yield { type: 'marker', content: toolMarkers, traceId };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// ── 4. Persist and finish ──────────────────────────────────────────────────
|
|
319
|
+
// `react` fires post-commit, in pump (the queue owner) — not here.
|
|
320
|
+
|
|
321
|
+
await store.set(session.id, session);
|
|
322
|
+
|
|
323
|
+
yield { type: 'done', session, traceId };
|
|
324
|
+
}
|