@matatbread/matbot-core 0.4.17 → 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/assemble.ts +25 -4
- package/src/ephemeral-turn.ts +67 -0
- package/src/index.ts +3 -1
- package/src/runner.ts +26 -1
- package/src/session-append.ts +77 -0
- package/src/session-runner.ts +583 -88
- package/src/session.ts +7 -8
- package/src/storage-base/index.ts +1 -0
- package/src/storage-base/memory-store.ts +34 -0
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/assemble.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
MatbotMachine, MatbotServices, MatbotPlugin, Store, Session, FileStore, StorageBackend, Vault, Notifier,
|
|
3
|
-
KnowledgeIndex, PermissionGate, PluginSettings, ProviderConfig, ProviderAdapter, SessionRunner, Tool, Usage,
|
|
3
|
+
KnowledgeIndex, PermissionGate, PluginSettings, ProviderConfig, ProviderAdapter, SessionRunner, SessionAppender, Tool, Usage,
|
|
4
4
|
} from '@matatbread/matbot-plugin-api';
|
|
5
5
|
import {
|
|
6
6
|
forwardingProxy, makeSwappable, createMountTable, scheduleAtEdge, createNotifier, recordUsage, singleTurnRequest,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
import type { SwapFn } from '@matatbread/matbot-plugin-api/host';
|
|
10
10
|
import { notifyingStore, createMessage } from '@matatbread/matbot-plugin-api';
|
|
11
11
|
import { mediumGuard } from './storage-base/medium-guard.js';
|
|
12
|
+
import { MemoryStore } from './storage-base/memory-store.js';
|
|
12
13
|
import { unifyServices } from './plugin.js';
|
|
13
14
|
import { instantiateProvider, recordServiceKey, getPluginNameForSpecifier, getRegisteredPlugins } from './registry.js';
|
|
14
15
|
import { installSettingsDefaults, installSettingsNotifier, makePluginSettings, settingsDefaultNamespaces } from './settings.js';
|
|
@@ -19,6 +20,7 @@ import type { ProviderRegistryImpl } from './provider-registry.js';
|
|
|
19
20
|
import { SystemContextRegistryImpl } from './system-context.js';
|
|
20
21
|
import { LookupKnowledgeIndex } from './knowledge/index.js';
|
|
21
22
|
import { createSessionRunner } from './session-runner.js';
|
|
23
|
+
import { createSessionAppender } from './session-append.js';
|
|
22
24
|
import { createSingleTurnTool } from './single-turn.js';
|
|
23
25
|
import { createAboutMatbotTool } from './about.js';
|
|
24
26
|
import { addUsage } from './usage.js';
|
|
@@ -81,7 +83,7 @@ export interface AssembleOptions {
|
|
|
81
83
|
export interface AssembledMachine {
|
|
82
84
|
services: MatbotMachine;
|
|
83
85
|
/** A session runner over any store — the shared one is `services.run`. */
|
|
84
|
-
makeRunner(store: Store<Session
|
|
86
|
+
makeRunner(store: Store<Session>, opts?: { appender?: SessionAppender }): SessionRunner;
|
|
85
87
|
/** Call once the configured plugins have loaded. */
|
|
86
88
|
loaded(): void;
|
|
87
89
|
}
|
|
@@ -176,7 +178,21 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
|
176
178
|
// The host's file area doubles as the media store, so attachments work with no plugin. Seeded into the
|
|
177
179
|
// registry rather than spelled on the base object, because `unifyServices` resolves an own property
|
|
178
180
|
// first — a member spelled there is one `register()` could never reach.
|
|
179
|
-
|
|
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
|
+
};
|
|
180
196
|
const serviceRegistry = new Map<string, unknown>(Object.entries(seed));
|
|
181
197
|
// The validator lookup is late-bound and read per call: one is registered by a plugin long after the
|
|
182
198
|
// builtins are seeded, and may be unloaded again. It wraps the executor, so every door — the runner,
|
|
@@ -306,6 +322,10 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
|
306
322
|
sessions,
|
|
307
323
|
files: fileStore,
|
|
308
324
|
get run() { return runner; },
|
|
325
|
+
ephemeral(opts) {
|
|
326
|
+
const store = new MemoryStore<Session>();
|
|
327
|
+
return { sessions: store, run: makeRunner(store, opts) };
|
|
328
|
+
},
|
|
309
329
|
hooks,
|
|
310
330
|
tools,
|
|
311
331
|
systemContext,
|
|
@@ -328,8 +348,9 @@ export function assembleMachine(opts: AssembleOptions): AssembledMachine {
|
|
|
328
348
|
};
|
|
329
349
|
|
|
330
350
|
// Services a plugin registers after boot are resolved live, per turn.
|
|
331
|
-
const makeRunner = (store: Store<Session
|
|
351
|
+
const makeRunner = (store: Store<Session>, extra?: { appender?: SessionAppender }): SessionRunner => createSessionRunner({
|
|
332
352
|
store,
|
|
353
|
+
...(extra?.appender !== undefined ? { appender: extra.appender } : {}),
|
|
333
354
|
resolveProvider,
|
|
334
355
|
tools,
|
|
335
356
|
hooks,
|
|
@@ -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';
|
package/src/runner.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
PermissionGate,
|
|
6
6
|
} from './types.js';
|
|
7
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
8
|
-
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
8
|
+
import type { ToolPresenter, SessionAppender } from '@matatbread/matbot-plugin-api';
|
|
9
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';
|
|
@@ -115,6 +115,9 @@ export interface RunSessionOpts {
|
|
|
115
115
|
workdir?: string;
|
|
116
116
|
configPath?: string;
|
|
117
117
|
files?: FileStore;
|
|
118
|
+
/** Handed to tools as `ToolContext.appender`: set only by a runner whose sessions the machine's own
|
|
119
|
+
* appender cannot reach (an ephemeral one). */
|
|
120
|
+
appender?: SessionAppender;
|
|
118
121
|
/** Where user-attached session media lives. Resolved once per turn into the outgoing copy, newest-first
|
|
119
122
|
* and inside {@link MEDIA_RESIDENCY_BYTES}; absent ⇒ every `file-ref` stays a ref and the converters
|
|
120
123
|
* degrade it. Never read for anything else — the runner does not otherwise touch a file. */
|
|
@@ -133,6 +136,13 @@ export interface RunSessionOpts {
|
|
|
133
136
|
* re-run of the originating user turn. Empty/absent for an ordinary turn.
|
|
134
137
|
*/
|
|
135
138
|
injectedEphemeral?: MessageContent[];
|
|
139
|
+
/**
|
|
140
|
+
* Consulted at the top of every round — the one point in a turn where the history is protocol-whole:
|
|
141
|
+
* every `tool_use` so far is answered, and no provider call is in flight. Returns the session with
|
|
142
|
+
* messages from outside the turn placed into it (the pump's parallel replies), and the traceIds to
|
|
143
|
+
* announce as `merged`; `undefined` ⇒ nothing arrived. Where they go is the caller's decision.
|
|
144
|
+
*/
|
|
145
|
+
interject?: (session: Session) => { session: Session; merged: string[] } | undefined;
|
|
136
146
|
loadPlugin: (specifier: string, prompt?: PromptFn, refresh?: boolean) => Promise<MatbotPlugin>;
|
|
137
147
|
unloadPlugin: (specifier: string) => Promise<boolean>;
|
|
138
148
|
}
|
|
@@ -165,7 +175,15 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
165
175
|
// Nothing is written mid-turn, so "commit" here means the whole turn or none of it.
|
|
166
176
|
// Closes over the reassigned `session` deliberately, so no exit can commit a stale copy.
|
|
167
177
|
async function* end(terminal: TurnEvent): AsyncIterable<TurnEvent> {
|
|
178
|
+
// One last pull, because this is the funnel every exit passes through. A parallel reply that settled
|
|
179
|
+
// since the previous round boundary may already be in the store (the session runner places one as it
|
|
180
|
+
// settles, so a turn parked in a long tool call does not hide it) — and the write below is the whole
|
|
181
|
+
// document, so a reply this turn never took would be erased by it. Reachable: a turn aborted or
|
|
182
|
+
// thrown during that same tool call never reaches another round top.
|
|
183
|
+
const late = opts.interject?.(session);
|
|
184
|
+
if (late !== undefined) session = late.session;
|
|
168
185
|
await store.set(session.id, session);
|
|
186
|
+
if (late !== undefined) for (const from of late.merged) yield { type: 'merged', traceId: from, into: traceId };
|
|
169
187
|
yield terminal;
|
|
170
188
|
}
|
|
171
189
|
|
|
@@ -296,6 +314,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
296
314
|
}
|
|
297
315
|
round += 1;
|
|
298
316
|
|
|
317
|
+
const interjected = opts.interject?.(session);
|
|
318
|
+
if (interjected !== undefined) {
|
|
319
|
+
session = interjected.session;
|
|
320
|
+
for (const from of interjected.merged) yield { type: 'merged', traceId: from, into: traceId };
|
|
321
|
+
}
|
|
322
|
+
|
|
299
323
|
// A raced screen verdict that already fired (settled during setup, or while a previous tool round
|
|
300
324
|
// ran): fold its correction in before generating, so this call is informed directly rather than
|
|
301
325
|
// discarded. This is the no-waste path — the verdict landed before we spent any tokens.
|
|
@@ -602,6 +626,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
602
626
|
...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
|
|
603
627
|
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
604
628
|
...(opts.files !== undefined ? { files: opts.files } : {}),
|
|
629
|
+
...(opts.appender !== undefined ? { appender: opts.appender } : {}),
|
|
605
630
|
};
|
|
606
631
|
|
|
607
632
|
// Iterated by hand rather than with `for await`, so the read can be bounded once the turn is
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AppendMessage, AppendResult, Notifier, Session, SessionAppender, SessionRunner, Store } from './types.js';
|
|
2
|
+
import { tryCurrentPrincipal, isReadOnlyError, SessionAppendKind } from '@matatbread/matbot-plugin-api';
|
|
3
|
+
import { appendMessage, createMessage } from './session.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The host's {@link SessionAppender}: an append is checked now, and written by the session's runner once no
|
|
7
|
+
* turn holds the session (`SessionRunner.write`).
|
|
8
|
+
*
|
|
9
|
+
* Checked now so that a session that does not exist is reported to a caller who can still act on it. When
|
|
10
|
+
* no turn holds the session the write runs at once and the append settles once it has, so a write that
|
|
11
|
+
* cannot land (the session went, or it is shared in read-only) is reported too. When a turn holds it, the
|
|
12
|
+
* append settles on acceptance: the caller may be that very turn, which the write is waiting for.
|
|
13
|
+
*
|
|
14
|
+
* Seeded in every process, and refuses in a background job — whose store is the parent's medium with none
|
|
15
|
+
* of the parent's turns behind it — until the job registers one that forwards to its parent.
|
|
16
|
+
*/
|
|
17
|
+
export function createSessionAppender(deps: {
|
|
18
|
+
sessions: () => Store<Session> | undefined;
|
|
19
|
+
run: () => SessionRunner | undefined;
|
|
20
|
+
notifier: () => Notifier;
|
|
21
|
+
isSubAgent: () => boolean;
|
|
22
|
+
}): SessionAppender {
|
|
23
|
+
return {
|
|
24
|
+
async append(sessionId, messages): Promise<AppendResult> {
|
|
25
|
+
if (deps.isSubAgent()) {
|
|
26
|
+
throw new Error('This background job has no way to reach its parent\'s sessions, so it cannot append to one.');
|
|
27
|
+
}
|
|
28
|
+
if (sessionId === undefined) throw new Error('No session was named to append to.');
|
|
29
|
+
if (messages.length === 0) throw new Error('Nothing to append.');
|
|
30
|
+
const store = deps.sessions();
|
|
31
|
+
if (store === undefined) throw new Error('No session store is available.');
|
|
32
|
+
const run = deps.run();
|
|
33
|
+
if (run === undefined) throw new Error('No session runner is available to write the session.');
|
|
34
|
+
if (await store.get(sessionId) === null) throw new Error(`Session "${sessionId}" not found.`);
|
|
35
|
+
|
|
36
|
+
const principal = tryCurrentPrincipal();
|
|
37
|
+
const traceId = crypto.randomUUID();
|
|
38
|
+
const built = messages.map((m: AppendMessage) => createMessage({
|
|
39
|
+
role: m.role, content: m.content, traceId,
|
|
40
|
+
...(m.metadata !== undefined ? { metadata: m.metadata } : {}),
|
|
41
|
+
}));
|
|
42
|
+
const messageIds = built.map(m => m.id);
|
|
43
|
+
|
|
44
|
+
let landed = false;
|
|
45
|
+
let dropped: string | undefined;
|
|
46
|
+
const write = run.write(sessionId, async () => {
|
|
47
|
+
const current = await store.get(sessionId);
|
|
48
|
+
if (current === null) {
|
|
49
|
+
dropped = `Session "${sessionId}" no longer exists.`;
|
|
50
|
+
console.error(`[matbot] append to session "${sessionId}" dropped: the session no longer exists.`);
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
if (!(await store.cas(sessionId, current.version, built.reduce(appendMessage, current))).ok) return false;
|
|
55
|
+
} catch (e) {
|
|
56
|
+
// Readable, so accepted; not writable — a session shared in read-only.
|
|
57
|
+
if (!isReadOnlyError(e)) throw e;
|
|
58
|
+
dropped = e.message;
|
|
59
|
+
console.error(`[matbot] append to session "${sessionId}" dropped: ${e.message}`);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
deps.notifier().notify({
|
|
63
|
+
kind: SessionAppendKind, source: 'append', sessionId, messageIds,
|
|
64
|
+
...(principal !== undefined ? { principal } : {}),
|
|
65
|
+
});
|
|
66
|
+
landed = true;
|
|
67
|
+
return true;
|
|
68
|
+
}, `[matbot] append to session "${sessionId}" lost to repeated concurrent writes.`);
|
|
69
|
+
|
|
70
|
+
if (!write.deferred) {
|
|
71
|
+
await write.done;
|
|
72
|
+
if (!landed) throw new Error(`The append was not written: ${dropped ?? 'it failed, or lost to repeated concurrent writes (see the log).'}`);
|
|
73
|
+
}
|
|
74
|
+
return { sessionId, messageIds, ...(write.deferred ? { deferred: true as const } : {}) };
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|