@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/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 shared per-pull wrap, as used by `runAs` for the principal: this had its own
22
- // object-literal copy, which replaced the iterator wholesale and so dropped a class-based tool
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
+ }