@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/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';
|
|
@@ -18,9 +18,8 @@ import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
|
|
|
18
18
|
// context, losing any scope entered around the loop body. Scoping each `next()` instead means the
|
|
19
19
|
// tool's body — and anything it kicks off while running — always resumes under its own site.
|
|
20
20
|
//
|
|
21
|
-
// `scopeIterable` is the
|
|
22
|
-
//
|
|
23
|
-
// iterator's own members, prototype and `instanceof`.
|
|
21
|
+
// `scopeIterable` is the per-pull wrap `runAs` also uses for the principal. It wraps the iterator rather
|
|
22
|
+
// than replacing it, so a class-based tool iterator keeps its own members, prototype and `instanceof`.
|
|
24
23
|
const siteScoped = <T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> =>
|
|
25
24
|
scopeIterable(src, f => withUsageSite(site, f));
|
|
26
25
|
|
|
@@ -116,6 +115,9 @@ export interface RunSessionOpts {
|
|
|
116
115
|
workdir?: string;
|
|
117
116
|
configPath?: string;
|
|
118
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;
|
|
119
121
|
/** Where user-attached session media lives. Resolved once per turn into the outgoing copy, newest-first
|
|
120
122
|
* and inside {@link MEDIA_RESIDENCY_BYTES}; absent ⇒ every `file-ref` stays a ref and the converters
|
|
121
123
|
* degrade it. Never read for anything else — the runner does not otherwise touch a file. */
|
|
@@ -134,6 +136,13 @@ export interface RunSessionOpts {
|
|
|
134
136
|
* re-run of the originating user turn. Empty/absent for an ordinary turn.
|
|
135
137
|
*/
|
|
136
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;
|
|
137
146
|
loadPlugin: (specifier: string, prompt?: PromptFn, refresh?: boolean) => Promise<MatbotPlugin>;
|
|
138
147
|
unloadPlugin: (specifier: string) => Promise<boolean>;
|
|
139
148
|
}
|
|
@@ -166,7 +175,15 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
166
175
|
// Nothing is written mid-turn, so "commit" here means the whole turn or none of it.
|
|
167
176
|
// Closes over the reassigned `session` deliberately, so no exit can commit a stale copy.
|
|
168
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;
|
|
169
185
|
await store.set(session.id, session);
|
|
186
|
+
if (late !== undefined) for (const from of late.merged) yield { type: 'merged', traceId: from, into: traceId };
|
|
170
187
|
yield terminal;
|
|
171
188
|
}
|
|
172
189
|
|
|
@@ -297,6 +314,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
297
314
|
}
|
|
298
315
|
round += 1;
|
|
299
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
|
+
|
|
300
323
|
// A raced screen verdict that already fired (settled during setup, or while a previous tool round
|
|
301
324
|
// ran): fold its correction in before generating, so this call is informed directly rather than
|
|
302
325
|
// discarded. This is the no-waste path — the verdict landed before we spent any tokens.
|
|
@@ -603,6 +626,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
603
626
|
...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
|
|
604
627
|
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
605
628
|
...(opts.files !== undefined ? { files: opts.files } : {}),
|
|
629
|
+
...(opts.appender !== undefined ? { appender: opts.appender } : {}),
|
|
606
630
|
};
|
|
607
631
|
|
|
608
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
|
+
}
|