@matatbread/matbot-core 0.3.3 → 0.3.5
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/registry.ts +5 -4
- package/src/runner.ts +94 -7
- package/src/session-runner.ts +129 -24
- package/src/session.ts +2 -10
- package/src/storage-base/caching-backend.ts +166 -0
- package/src/storage-base/index.ts +1 -0
- package/src/tool-registry.ts +3 -2
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.
|
|
7
|
+
"version": "0.3.5",
|
|
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.3.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.3.5"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/registry.ts
CHANGED
|
@@ -39,8 +39,9 @@ const state = {
|
|
|
39
39
|
// not in `state` — it owns subscribers across the registry's lifetime, not per-plugin data.
|
|
40
40
|
const pluginEvents = createBroadcaster<PluginRegistryEvent>();
|
|
41
41
|
|
|
42
|
-
export function watchPlugins(signal?: AbortSignal): AsyncIterable<PluginRegistryEvent> {
|
|
43
|
-
|
|
42
|
+
export async function* watchPlugins(signal?: AbortSignal): AsyncIterable<PluginRegistryEvent> {
|
|
43
|
+
// Plugin CRUD is global — no origin — so unwrap the envelope to the bare event the panel expects.
|
|
44
|
+
for await (const { value } of pluginEvents.subscribe(signal)) yield value;
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
// Settings namespace + key under which the user's "overwrite all colliding tools" choice
|
|
@@ -306,13 +307,13 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
306
307
|
// fires. Forward-referenced via `scoped`, assigned below; consume() only runs after setup.
|
|
307
308
|
let scoped: MatbotMachine;
|
|
308
309
|
const scopedMounted: Mounted = {
|
|
309
|
-
|
|
310
|
+
observe(options, handler) {
|
|
310
311
|
// Forward to the host mount table but deliver *this plugin's* scoped machine — it reads through
|
|
311
312
|
// the same proxies/registry, so scoped[key] is the host's live service. onUnmount is scoped too.
|
|
312
313
|
const forwarded = options.onUnmount !== undefined
|
|
313
314
|
? { ...options, onUnmount: () => options.onUnmount!(scoped) }
|
|
314
315
|
: options;
|
|
315
|
-
services.mounted.
|
|
316
|
+
services.mounted.observe(forwarded, () => handler(scoped as never));
|
|
316
317
|
},
|
|
317
318
|
};
|
|
318
319
|
|
package/src/runner.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Session, MessageContent, Usage, ProviderMeta,
|
|
2
|
+
Session, MessageContent, Usage, ProviderMeta, DeferredCorrection,
|
|
3
3
|
PipelineEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
4
|
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
5
|
} from './types.js';
|
|
@@ -10,6 +10,15 @@ import { HookRegistry } from './hooks.js';
|
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
11
|
import { addUsage } from './usage.js';
|
|
12
12
|
|
|
13
|
+
// Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
|
|
14
|
+
// the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
|
|
15
|
+
// internal token that reads as a real failure. A steer's continuation turn is the one place a model
|
|
16
|
+
// reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
|
|
17
|
+
// deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
|
|
18
|
+
const INTERRUPTED_TOOL_RESULT = {
|
|
19
|
+
error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
13
22
|
export interface RunSessionOpts {
|
|
14
23
|
session: Session;
|
|
15
24
|
config: RunConfig;
|
|
@@ -111,8 +120,42 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
111
120
|
// drops non-result content from tool-role messages — a separate trailing message would break on
|
|
112
121
|
// one or the other).
|
|
113
122
|
// injectedEphemeral (a pump-supplied retract-redo's context) leads, then screen's own — both share
|
|
114
|
-
// the identical tail-fold path below.
|
|
115
|
-
|
|
123
|
+
// the identical tail-fold path below. Mutable: a raced screen verdict (below) folds its correction
|
|
124
|
+
// in here when it fires, and the loop re-runs with it.
|
|
125
|
+
let ephemeral = [...(opts.injectedEphemeral ?? []), ...screen.ephemeral];
|
|
126
|
+
|
|
127
|
+
// Raced screen verdicts (see DeferredScreen): polled — synchronously, never awaited — at each loop
|
|
128
|
+
// edge. `pollDeferred` claims any that have fired this poll (exactly once each) and merges their
|
|
129
|
+
// corrections; `applyCorrection` then folds them in and returns any durable blocks to emit live.
|
|
130
|
+
const deferred = screen.deferred;
|
|
131
|
+
const pollDeferred = (): DeferredCorrection | undefined => {
|
|
132
|
+
if (deferred.length === 0) return undefined;
|
|
133
|
+
const ephemeralC: MessageContent[] = [];
|
|
134
|
+
const durableC: MessageContent[] = [];
|
|
135
|
+
for (const d of deferred) {
|
|
136
|
+
const c = d.claim();
|
|
137
|
+
if (!c) continue;
|
|
138
|
+
if (c.ephemeral) ephemeralC.push(...c.ephemeral);
|
|
139
|
+
if (c.durable) durableC.push(...c.durable);
|
|
140
|
+
}
|
|
141
|
+
return (ephemeralC.length > 0 || durableC.length > 0) ? { ephemeral: ephemeralC, durable: durableC } : undefined;
|
|
142
|
+
};
|
|
143
|
+
// Apply a claimed correction: `ephemeral` leads the tail-fold for the re-run; `durable` is folded onto
|
|
144
|
+
// the turn's user message (persisted, re-run sees it in history) and RETURNED so the caller emits it
|
|
145
|
+
// live as a `robo-user` event — the durable twin, preserving a contextual fire's persistence even
|
|
146
|
+
// though the verdict landed mid-turn. Mutates `ephemeral`/`session` in place.
|
|
147
|
+
const applyCorrection = (c: DeferredCorrection): MessageContent[] => {
|
|
148
|
+
if (c.ephemeral && c.ephemeral.length > 0) ephemeral = [...c.ephemeral, ...ephemeral];
|
|
149
|
+
if (c.durable && c.durable.length > 0) {
|
|
150
|
+
const idx = session.messages.findLastIndex(m => m.role === 'user');
|
|
151
|
+
if (idx >= 0) {
|
|
152
|
+
session = { ...session, messages: session.messages.map((m, i) =>
|
|
153
|
+
i === idx ? { ...m, content: [...m.content, ...c.durable!] } : m) };
|
|
154
|
+
return c.durable;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return [];
|
|
158
|
+
};
|
|
116
159
|
|
|
117
160
|
// ── 2. System context (built once per submit, never persisted) ─────────────
|
|
118
161
|
|
|
@@ -138,10 +181,18 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
138
181
|
return;
|
|
139
182
|
}
|
|
140
183
|
|
|
184
|
+
// A raced screen verdict that already fired (settled during setup, or while a previous tool round
|
|
185
|
+
// ran): fold its correction in before generating, so this call is informed directly rather than
|
|
186
|
+
// discarded. This is the no-waste path — the verdict landed before we spent any tokens.
|
|
187
|
+
const early = pollDeferred();
|
|
188
|
+
if (early) { const dur = applyCorrection(early); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; }
|
|
189
|
+
|
|
141
190
|
const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta }> = [];
|
|
142
191
|
const assistantParts: MessageContent[] = [];
|
|
143
192
|
let textAcc = '';
|
|
144
193
|
let turnUsage: Usage | undefined;
|
|
194
|
+
// Set if a raced verdict fires mid-generation: discard this response and re-run the loop (below).
|
|
195
|
+
let restart = false;
|
|
145
196
|
|
|
146
197
|
// One provider call. The outgoing array (system context + history, with screen's ephemeral
|
|
147
198
|
// blocks appended onto the tail message) is assembled here and handed to `contribute` hooks for
|
|
@@ -164,8 +215,21 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
164
215
|
const advertised = opts.toolPresenter
|
|
165
216
|
? await opts.toolPresenter.present([...tools.values()], { session, provider: config.provider })
|
|
166
217
|
: [...tools.values()];
|
|
218
|
+
// Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
|
|
219
|
+
// to cancel the in-flight request and stop backend generation, without aborting the whole turn.
|
|
220
|
+
const callAc = new AbortController();
|
|
221
|
+
const callSignal = AbortSignal.any([signal, callAc.signal]);
|
|
167
222
|
try {
|
|
168
|
-
for await (const ev of provider.complete(outgoing, providerConfig, advertised,
|
|
223
|
+
for await (const ev of provider.complete(outgoing, providerConfig, advertised, callSignal)) {
|
|
224
|
+
// A raced verdict firing mid-generation changes the premise: discard this doomed response and
|
|
225
|
+
// restart the loop with the correction folded in. Polled BEFORE the event is emitted, so a
|
|
226
|
+
// classifier faster than time-to-first-token is caught before any token reaches the frontend.
|
|
227
|
+
const hit = pollDeferred();
|
|
228
|
+
if (hit) {
|
|
229
|
+
const dur = applyCorrection(hit);
|
|
230
|
+
if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId };
|
|
231
|
+
restart = true; callAc.abort('screen-restart'); break;
|
|
232
|
+
}
|
|
169
233
|
switch (ev.type) {
|
|
170
234
|
case 'text-delta':
|
|
171
235
|
textAcc += ev.delta;
|
|
@@ -202,7 +266,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
202
266
|
}
|
|
203
267
|
}
|
|
204
268
|
} catch (e: any) {
|
|
205
|
-
|
|
269
|
+
// A provider call cancelled by an in-situ restart (callAc, not the turn signal) is expected — fall
|
|
270
|
+
// through to the restart below rather than surfacing it as a turn abort or an error.
|
|
271
|
+
if (restart || (callAc.signal.aborted && !signal.aborted)) {
|
|
272
|
+
restart = true;
|
|
273
|
+
} else if (signal.aborted) {
|
|
206
274
|
// Save whatever the LLM streamed before the abort hit.
|
|
207
275
|
if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
|
|
208
276
|
if (assistantParts.length > 0) {
|
|
@@ -214,11 +282,22 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
214
282
|
await store.set(session.id, session);
|
|
215
283
|
yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
|
|
216
284
|
return;
|
|
285
|
+
} else {
|
|
286
|
+
yield { type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId };
|
|
287
|
+
return;
|
|
217
288
|
}
|
|
218
|
-
yield { type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId };
|
|
219
|
-
return;
|
|
220
289
|
}
|
|
221
290
|
|
|
291
|
+
// In-situ restart: discard this iteration's (uncommitted) response and re-run the loop with the
|
|
292
|
+
// raced correction now folded into `ephemeral`. No store write, no retraction marker — the clean
|
|
293
|
+
// counterpart to a post-commit `followup` retract.
|
|
294
|
+
if (restart) continue;
|
|
295
|
+
|
|
296
|
+
// Pre-commit poll: the verdict may have settled exactly as the stream ended. Discard and re-run
|
|
297
|
+
// rather than commit-then-retract, keeping the correction on the cheaper in-situ path.
|
|
298
|
+
const late = pollDeferred();
|
|
299
|
+
if (late) { const dur = applyCorrection(late); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; continue; }
|
|
300
|
+
|
|
222
301
|
// Build and store assistant message
|
|
223
302
|
if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
|
|
224
303
|
for (const tc of pendingCalls) {
|
|
@@ -318,6 +397,14 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
318
397
|
isError = true;
|
|
319
398
|
}
|
|
320
399
|
|
|
400
|
+
// A tool that errored under an aborted signal did so because it was cut off (a steer interrupt,
|
|
401
|
+
// a user cancel), not a genuine fault — reframe it before it is recorded, so the raw abort reason
|
|
402
|
+
// never reaches the model or the transcript. Covers both the throw and the yielded-`error` paths;
|
|
403
|
+
// a successful result is left untouched.
|
|
404
|
+
if (signal.aborted && isError) {
|
|
405
|
+
result = INTERRUPTED_TOOL_RESULT;
|
|
406
|
+
}
|
|
407
|
+
|
|
321
408
|
// toolresult — last chance to transform the result before it's recorded/yielded (hard redaction),
|
|
322
409
|
// or to observe it (auditing: args + result + timing). Owns the LLM-facing + persisted surfaces.
|
|
323
410
|
result = await hookReg.runToolResult({
|
package/src/session-runner.ts
CHANGED
|
@@ -2,12 +2,13 @@ import type {
|
|
|
2
2
|
Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
|
|
3
3
|
Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
|
|
4
4
|
ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
|
|
5
|
+
SteeringPolicy, SteeringDecision,
|
|
5
6
|
} from './types.js';
|
|
6
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
8
|
import type { HookRegistry } from './hooks.js';
|
|
8
9
|
import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
9
10
|
import { appendMessage, createMessage } from './session.js';
|
|
10
|
-
import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api';
|
|
11
|
+
import { contextSwitch, withUsageScope, isReadOnlyError } from '@matatbread/matbot-plugin-api';
|
|
11
12
|
import { runSession } from './runner.js';
|
|
12
13
|
|
|
13
14
|
export interface SessionRunnerDeps {
|
|
@@ -22,6 +23,10 @@ export interface SessionRunnerDeps {
|
|
|
22
23
|
// Resolved live (registers after boot): picks which tools are advertised to the model per provider
|
|
23
24
|
// call. Absent ⇒ the whole turn snapshot is advertised. Threaded into runSession per turn.
|
|
24
25
|
toolPresenter?: () => ToolPresenter | undefined;
|
|
26
|
+
// Resolved live (registers after boot): decides how a mid-turn submission under `mode: 'auto'` is
|
|
27
|
+
// disposed (queue vs interrupt) and supplies an interrupt's continuation nudge. Absent ⇒ the runner's
|
|
28
|
+
// own defaults (DEFAULT_STEERING_POLICY / DEFAULT_STEER_NUDGE).
|
|
29
|
+
steeringPolicy?: () => SteeringPolicy | undefined;
|
|
25
30
|
hooks?: HookRegistry;
|
|
26
31
|
systemContext?: SystemContextRegistry;
|
|
27
32
|
vault?: Vault;
|
|
@@ -50,11 +55,30 @@ interface QueuedItem {
|
|
|
50
55
|
// message already exists) and hands `ephemeral` (the trigger tool's output) to runSession, which
|
|
51
56
|
// tail-folds it for this run only. `content` is empty for such an item — there is no new bubble.
|
|
52
57
|
redo?: { ephemeral: MessageContent[] };
|
|
58
|
+
// Turn-scoped ephemeral context to fold onto this item's turn (tail-folded, never persisted) — used
|
|
59
|
+
// to carry an interrupt's "keep going, noting the above" nudge. Feeds runSession's injectedEphemeral,
|
|
60
|
+
// the same path `redo` uses; the two are mutually exclusive in practice (redo re-runs an existing
|
|
61
|
+
// turn with no new message, a steer introduces a new user message).
|
|
62
|
+
injectEphemeral?: MessageContent[];
|
|
53
63
|
prompt?: PromptFn;
|
|
54
64
|
}
|
|
55
65
|
|
|
56
66
|
const MAX_RESUBMIT_DEPTH = 8;
|
|
57
67
|
|
|
68
|
+
// Disposition for a `mode: 'auto'` mid-turn submission when no SteeringPolicy is registered (or it
|
|
69
|
+
// declares no `classify`). Interrupt-by-default: a message sent while the agent works stops the
|
|
70
|
+
// running turn (its committed partial work is preserved) and runs next. Flip to 'queue' to make the
|
|
71
|
+
// conservative "wait for the turn boundary" the default — this const is the single knob.
|
|
72
|
+
const DEFAULT_STEERING_POLICY: SteeringDecision = 'interrupt';
|
|
73
|
+
|
|
74
|
+
// The ephemeral nudge folded onto an interrupt's continuation when no SteeringPolicy supplies one. It
|
|
75
|
+
// biases the model to continue the interrupted task while accounting for the steer, rather than pivot
|
|
76
|
+
// wholesale — the dial between "I'll get to that in a minute" and a full reprioritisation.
|
|
77
|
+
const DEFAULT_STEER_NUDGE: MessageContent[] = [{
|
|
78
|
+
type: 'text',
|
|
79
|
+
text: 'The user sent the message above while you were working. Take it into account and keep going — continue the task you were on, incorporating this new input rather than discarding your progress.',
|
|
80
|
+
}];
|
|
81
|
+
|
|
58
82
|
// Marker creator for a retract-and-rerun: its `data.retracted` carries the popped (superseded) turn
|
|
59
83
|
// messages so a frontend can render them struck-through and a post-mortem can audit them. Core-owned
|
|
60
84
|
// because the pop is a pump operation, not a plugin's.
|
|
@@ -69,6 +93,10 @@ interface SessionState {
|
|
|
69
93
|
// submissions interleaved among them (see the batch-building loop in pump). Concat and queued mix freely.
|
|
70
94
|
queue: QueuedItem[];
|
|
71
95
|
running: boolean;
|
|
96
|
+
// traceId of the turn currently running (set at pump loop top, cleared when the turn ends). Lets an
|
|
97
|
+
// interrupt name the turn it is yielding, so a frontend correlates the `steer` event's
|
|
98
|
+
// `interruptedTraceId` with the `aborted` (reason 'steer') it is about to see.
|
|
99
|
+
runningTraceId: string | undefined;
|
|
72
100
|
ac: AbortController | undefined;
|
|
73
101
|
subscribers: Set<Sink>;
|
|
74
102
|
// Events of the *current* turn, replayed to a subscriber that joins mid-turn. Cleared at each
|
|
@@ -134,7 +162,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
134
162
|
const stateFor = (id: string): SessionState => {
|
|
135
163
|
let s = states.get(id);
|
|
136
164
|
if (s === undefined) {
|
|
137
|
-
s = { queue: [], running: false, ac: undefined, subscribers: new Set(), replay: [] };
|
|
165
|
+
s = { queue: [], running: false, runningTraceId: undefined, ac: undefined, subscribers: new Set(), replay: [] };
|
|
138
166
|
states.set(id, s);
|
|
139
167
|
}
|
|
140
168
|
return s;
|
|
@@ -176,6 +204,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
176
204
|
while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
|
|
177
205
|
}
|
|
178
206
|
const content = batch.flatMap(i => i.content);
|
|
207
|
+
s.runningTraceId = head.traceId;
|
|
179
208
|
s.replay = [];
|
|
180
209
|
// Seed replay with the running turn's user message as a single merged `queued`, mirroring the
|
|
181
210
|
// message persisted just below. notify() (in open()) reaches only subscribers that are live at
|
|
@@ -211,7 +240,17 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
211
240
|
// while queued. That is what stops a mid-turn submit from clobbering session state. A robo
|
|
212
241
|
// resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
|
|
213
242
|
session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
|
|
214
|
-
|
|
243
|
+
try {
|
|
244
|
+
await deps.store.set(session.id, session);
|
|
245
|
+
} catch (e) {
|
|
246
|
+
// A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
|
|
247
|
+
// from another profile's partition). That is a per-turn condition, not a host fault: surface
|
|
248
|
+
// it as a turn error and drop this submission rather than let it escape the detached pump and
|
|
249
|
+
// crash the process. Any other write failure stays fatal.
|
|
250
|
+
if (!isReadOnlyError(e)) throw e;
|
|
251
|
+
emit(s, { type: 'error', error: e.message, traceId: head.traceId });
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
215
254
|
}
|
|
216
255
|
|
|
217
256
|
const resolved = await deps.resolveProvider(head.provider);
|
|
@@ -220,6 +259,10 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
220
259
|
continue;
|
|
221
260
|
}
|
|
222
261
|
|
|
262
|
+
// A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
|
|
263
|
+
// two never coincide on one item, so either supplies runSession's injectedEphemeral.
|
|
264
|
+
const inject = head.redo?.ephemeral ?? head.injectEphemeral;
|
|
265
|
+
|
|
223
266
|
const ac = new AbortController();
|
|
224
267
|
s.ac = ac;
|
|
225
268
|
// Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
|
|
@@ -266,7 +309,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
266
309
|
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
267
310
|
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
268
311
|
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
269
|
-
...(
|
|
312
|
+
...(inject !== undefined ? { injectedEphemeral: inject } : {}),
|
|
270
313
|
})) {
|
|
271
314
|
emit(s, ev);
|
|
272
315
|
}
|
|
@@ -279,7 +322,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
279
322
|
if (!ac.signal.aborted && hooks) {
|
|
280
323
|
const committed = await deps.store.get(id);
|
|
281
324
|
if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
|
|
282
|
-
let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[] } } = { resubmits: [], markers: [] };
|
|
325
|
+
let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
|
|
283
326
|
await contextSwitch(head.principal, async () => {
|
|
284
327
|
followup = await hooks.runFollowup({
|
|
285
328
|
session: committed,
|
|
@@ -338,7 +381,12 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
338
381
|
if (followup.retract) {
|
|
339
382
|
const lastUserIdx = committed.messages.findLastIndex(m => m.role === 'user');
|
|
340
383
|
const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
|
|
341
|
-
|
|
384
|
+
// `durable` correction (a contextual fire that landed post-commit) folds onto the user
|
|
385
|
+
// message we keep — persisted, so the redo AND every later turn see it in history, the
|
|
386
|
+
// exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
|
|
387
|
+
const durable = followup.retract.durable ?? [];
|
|
388
|
+
const kept = (lastUserIdx >= 0 ? committed.messages.slice(0, lastUserIdx + 1) : committed.messages)
|
|
389
|
+
.map((m, i) => (durable.length > 0 && i === lastUserIdx ? { ...m, content: [...m.content, ...durable] } : m));
|
|
342
390
|
const retractionMsg: Message = {
|
|
343
391
|
id: crypto.randomUUID(),
|
|
344
392
|
role: 'marker',
|
|
@@ -347,7 +395,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
347
395
|
// `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
|
|
348
396
|
// context fed to the redo — neither is otherwise persisted, so the pair fully traces
|
|
349
397
|
// the swap for a post-mortem (and a frontend can render the strike-through + the cause).
|
|
350
|
-
content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context, traceId: head.traceId } }],
|
|
398
|
+
content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
|
|
351
399
|
};
|
|
352
400
|
// Any followup markers ride along as a trailing marker message (not folded into the
|
|
353
401
|
// retraction marker) so each creator's trace stays its own block.
|
|
@@ -355,6 +403,9 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
355
403
|
? [{ id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: head.traceId, content: followup.markers }]
|
|
356
404
|
: [];
|
|
357
405
|
await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
|
|
406
|
+
// Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
|
|
407
|
+
// marker, mirroring the screen-time durable path's ordering.
|
|
408
|
+
if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
|
|
358
409
|
notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
|
|
359
410
|
if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
360
411
|
|
|
@@ -367,7 +418,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
367
418
|
principal: head.principal,
|
|
368
419
|
concatQueue: false,
|
|
369
420
|
resubmitDepth: head.resubmitDepth + 1,
|
|
370
|
-
redo: { ephemeral: followup.retract.context },
|
|
421
|
+
redo: { ephemeral: followup.retract.context ?? [] },
|
|
371
422
|
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
372
423
|
});
|
|
373
424
|
}
|
|
@@ -381,6 +432,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
381
432
|
}
|
|
382
433
|
} finally {
|
|
383
434
|
s.running = false;
|
|
435
|
+
s.runningTraceId = undefined;
|
|
384
436
|
s.replay = [];
|
|
385
437
|
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
386
438
|
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
@@ -406,22 +458,75 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
406
458
|
}
|
|
407
459
|
s = stateFor(opts.sessionId);
|
|
408
460
|
traceId = opts.traceId ?? crypto.randomUUID();
|
|
409
|
-
const
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
461
|
+
const { content, provider, principal } = opts;
|
|
462
|
+
const mode = opts.mode ?? 'queue';
|
|
463
|
+
|
|
464
|
+
// Decide interrupt vs queue for a submission arriving mid-turn. Steering is only meaningful
|
|
465
|
+
// while a turn is running; with nothing running it enqueues and runs next regardless of mode.
|
|
466
|
+
// The decision — and, on interrupt, the abort+unshift — happen here synchronously against
|
|
467
|
+
// s.running (re-checked after any classify/nudge await) so an interrupt can never land on the
|
|
468
|
+
// wrong (a later) turn: the fatal race of deciding across a wire is closed by deciding in-runner.
|
|
469
|
+
let interrupt = false;
|
|
470
|
+
if (mode !== 'queue' && s.running) {
|
|
471
|
+
const policy = deps.steeringPolicy?.();
|
|
472
|
+
// Fetch the committed session once (history up to the running turn's start) only when a policy
|
|
473
|
+
// member actually needs it: classify under `auto`, and nudge on any interrupt. The common
|
|
474
|
+
// no-policy interrupt path (e.g. the web default) does no extra read.
|
|
475
|
+
const wantClassify = mode === 'auto' && policy?.classify !== undefined;
|
|
476
|
+
const wantNudge = policy?.nudge !== undefined;
|
|
477
|
+
const committed = (wantClassify || wantNudge) ? await deps.store.get(opts.sessionId) : null;
|
|
478
|
+
|
|
479
|
+
const decision: SteeringDecision = mode === 'interrupt'
|
|
480
|
+
? 'interrupt'
|
|
481
|
+
: policy?.classify !== undefined
|
|
482
|
+
? (committed ? await policy.classify({ session: committed, steer: content }) : DEFAULT_STEERING_POLICY)
|
|
483
|
+
: DEFAULT_STEERING_POLICY;
|
|
484
|
+
|
|
485
|
+
// Any await above may have outlived the running turn; only interrupt one still live.
|
|
486
|
+
if (decision === 'interrupt' && s.running) {
|
|
487
|
+
const nudge = policy?.nudge !== undefined && committed
|
|
488
|
+
? policy.nudge({ session: committed, steer: content })
|
|
489
|
+
: DEFAULT_STEER_NUDGE;
|
|
490
|
+
const interruptedTraceId = s.runningTraceId ?? '';
|
|
491
|
+
// Head of the queue (ahead of anything else already queued): the steer runs immediately
|
|
492
|
+
// after the interrupted turn commits its partial work. Own turn (never concat).
|
|
493
|
+
s.queue.unshift({
|
|
494
|
+
traceId,
|
|
495
|
+
rootTraceId: traceId,
|
|
496
|
+
content,
|
|
497
|
+
provider,
|
|
498
|
+
principal,
|
|
499
|
+
concatQueue: false,
|
|
500
|
+
resubmitDepth: 0,
|
|
501
|
+
injectEphemeral: nudge,
|
|
502
|
+
...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
|
|
503
|
+
});
|
|
504
|
+
// Announce the steer before aborting so a subscriber places the new bubble and reads the
|
|
505
|
+
// imminent `aborted` (reason 'steer') as a yield, not a dead-end.
|
|
506
|
+
notify(s, { type: 'steer', content, interruptedTraceId, traceId, rootTraceId: traceId });
|
|
507
|
+
s.ac?.abort('steer');
|
|
508
|
+
interrupt = true;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
if (!interrupt) {
|
|
513
|
+
const concatQueue = opts.concatQueue ?? false;
|
|
514
|
+
s.queue.push({
|
|
515
|
+
traceId,
|
|
516
|
+
rootTraceId: traceId,
|
|
517
|
+
content,
|
|
518
|
+
provider,
|
|
519
|
+
principal,
|
|
520
|
+
concatQueue,
|
|
521
|
+
resubmitDepth: 0,
|
|
522
|
+
...(opts.prompt !== undefined ? { prompt: opts.prompt } : {}),
|
|
523
|
+
});
|
|
524
|
+
// Announce the submission on the stream as part of the live delta, before its turn's events.
|
|
525
|
+
// If it runs immediately `queued` is 0 (no wait); otherwise it's how many are ahead. concatQueue
|
|
526
|
+
// tells a frontend whether this submission will merge into the running batch (so it can fold the
|
|
527
|
+
// bubble) or run as its own turn.
|
|
528
|
+
notify(s, { type: 'queued', content, queued: aheadOf(s, s.queue.length - 1), concatQueue, traceId, rootTraceId: traceId });
|
|
529
|
+
}
|
|
425
530
|
void pump(opts.sessionId, s);
|
|
426
531
|
}
|
|
427
532
|
|
package/src/session.ts
CHANGED
|
@@ -1,25 +1,17 @@
|
|
|
1
|
-
import type { Session, Message, MessageContent, MessageRole,
|
|
1
|
+
import type { Session, Message, MessageContent, MessageRole, Usage } from './types.js';
|
|
2
2
|
|
|
3
3
|
export interface CreateSessionOpts {
|
|
4
|
-
ownerPrincipal: Principal;
|
|
5
|
-
actorPrincipal?: Principal;
|
|
6
|
-
persona?: string;
|
|
7
4
|
title?: string;
|
|
8
5
|
contexts?: string[];
|
|
9
6
|
parentSessionId?: string;
|
|
10
7
|
branchPointMessageId?: string;
|
|
11
8
|
}
|
|
12
9
|
|
|
13
|
-
export function createSession(opts: CreateSessionOpts): Session {
|
|
10
|
+
export function createSession(opts: CreateSessionOpts = {}): Session {
|
|
14
11
|
const now = new Date().toISOString();
|
|
15
12
|
return {
|
|
16
13
|
id: crypto.randomUUID(),
|
|
17
14
|
version: crypto.randomUUID(),
|
|
18
|
-
ownerPrincipalId: opts.ownerPrincipal.id,
|
|
19
|
-
...(opts.actorPrincipal && opts.actorPrincipal.id !== opts.ownerPrincipal.id
|
|
20
|
-
? { actorPrincipalId: opts.actorPrincipal.id }
|
|
21
|
-
: {}),
|
|
22
|
-
...(opts.persona !== undefined ? { persona: opts.persona } : {}),
|
|
23
15
|
...(opts.title !== undefined ? { title: opts.title } : {}),
|
|
24
16
|
...(opts.parentSessionId !== undefined ? { parentSessionId: opts.parentSessionId } : {}),
|
|
25
17
|
...(opts.branchPointMessageId !== undefined ? { branchPointMessageId: opts.branchPointMessageId } : {}),
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Store, StorageBackend, FileStore, StoreQuery, QueryResult, CASResult,
|
|
3
|
+
} from '@matatbread/matbot-plugin-api';
|
|
4
|
+
import { executeQuery } from './query/execute.js';
|
|
5
|
+
|
|
6
|
+
export interface CachingOptions {
|
|
7
|
+
/**
|
|
8
|
+
* Bounded-staleness ceiling for FOREIGN writes — another process/tab/host mutating the same backing
|
|
9
|
+
* store behind our back. When set, a namespace's in-memory image is treated as stale this many ms
|
|
10
|
+
* after it was warmed and re-loaded on the next read. Own writes are always coherent regardless
|
|
11
|
+
* (write-through keeps the image in step), so a strictly single-writer deployment can leave this
|
|
12
|
+
* unset. Undefined ⇒ warm once and never expire on time.
|
|
13
|
+
*
|
|
14
|
+
* TTL is the *portable* invalidation tier — every backing store supports it. A backing store that
|
|
15
|
+
* also exposes a change feed (Node `fs.watch`, the Drive Changes API, Postgres `LISTEN/NOTIFY`)
|
|
16
|
+
* could drive sharper foreign-write invalidation by evicting `image` on a remote change event; that
|
|
17
|
+
* is a later, opt-in refinement layered on top of this and deliberately NOT required here.
|
|
18
|
+
*/
|
|
19
|
+
ttlMs?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Per-namespace cache counters, for answering "is the cache actually saving reads?" — see
|
|
23
|
+
* {@link CachingStorageBackend.stats}. `reads` is get()+query() calls; `hits` is the subset served
|
|
24
|
+
* from the warm image without touching the backing store; `loads` is full-namespace warms/refreshes
|
|
25
|
+
* (the expensive round-trips); `lastLoadMs` times the most recent one; `docs` is the current image
|
|
26
|
+
* size. A healthy cache shows `hits` climbing while `loads` stays flat (typically 1). */
|
|
27
|
+
export interface CacheNamespaceStats {
|
|
28
|
+
docs: number;
|
|
29
|
+
reads: number;
|
|
30
|
+
hits: number;
|
|
31
|
+
loads: number;
|
|
32
|
+
lastLoadMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Cache-aside, write-through caching Store. Reads serve from a full in-memory image of the namespace
|
|
37
|
+
* (`Map<id, T>`), warmed lazily by a single `backing.query({})`; every write goes to the backing store
|
|
38
|
+
* first (the system of record — CAS and durability stay there) and then updates the image on success.
|
|
39
|
+
*
|
|
40
|
+
* Coherence is honest: always coherent with THIS store's own writes; foreign writes only as sharp as
|
|
41
|
+
* the {@link CachingOptions.ttlMs} tier (never, if unset). That is the single-writer assumption made
|
|
42
|
+
* explicit at one layer — a backend that cannot assume it simply isn't wrapped, or wraps with a short
|
|
43
|
+
* TTL.
|
|
44
|
+
*/
|
|
45
|
+
class CachingStore<T extends { id: string; version: string }> implements Store<T> {
|
|
46
|
+
private image: Map<string, T> | undefined;
|
|
47
|
+
private warmedAt = 0;
|
|
48
|
+
private warming: Promise<Map<string, T>> | undefined;
|
|
49
|
+
|
|
50
|
+
private readonly backing: Store<T>;
|
|
51
|
+
private readonly ttlMs: number | undefined;
|
|
52
|
+
|
|
53
|
+
private reads = 0;
|
|
54
|
+
private hits = 0;
|
|
55
|
+
private loads = 0;
|
|
56
|
+
private lastLoadMs = 0;
|
|
57
|
+
|
|
58
|
+
constructor(backing: Store<T>, ttlMs: number | undefined) {
|
|
59
|
+
this.backing = backing;
|
|
60
|
+
this.ttlMs = ttlMs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get stats(): CacheNamespaceStats {
|
|
64
|
+
return { docs: this.image?.size ?? 0, reads: this.reads, hits: this.hits, loads: this.loads, lastLoadMs: this.lastLoadMs };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private fresh(): boolean {
|
|
68
|
+
if (this.image === undefined) return false;
|
|
69
|
+
if (this.ttlMs === undefined) return true;
|
|
70
|
+
return Date.now() - this.warmedAt < this.ttlMs;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Serve the whole-namespace image, (re)loading it when cold or TTL-stale. Concurrent callers share
|
|
74
|
+
// one in-flight load rather than each firing their own `query({})`.
|
|
75
|
+
private warm(): Promise<Map<string, T>> {
|
|
76
|
+
if (this.image !== undefined && this.fresh()) { this.hits++; return Promise.resolve(this.image); }
|
|
77
|
+
return (this.warming ??= this.load());
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async load(): Promise<Map<string, T>> {
|
|
81
|
+
const started = Date.now();
|
|
82
|
+
try {
|
|
83
|
+
const { items } = await this.backing.query({});
|
|
84
|
+
const map = new Map<string, T>(items.map(d => [d.id, d]));
|
|
85
|
+
this.image = map;
|
|
86
|
+
this.warmedAt = Date.now();
|
|
87
|
+
this.loads++;
|
|
88
|
+
this.lastLoadMs = Date.now() - started;
|
|
89
|
+
return map;
|
|
90
|
+
} finally {
|
|
91
|
+
this.warming = undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async get(id: string): Promise<T | null> {
|
|
96
|
+
this.reads++;
|
|
97
|
+
return (await this.warm()).get(id) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async query(q: StoreQuery): Promise<QueryResult<T>> {
|
|
101
|
+
this.reads++;
|
|
102
|
+
return executeQuery([...(await this.warm()).values()], q);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async set(id: string, value: T): Promise<void> {
|
|
106
|
+
await this.backing.set(id, value);
|
|
107
|
+
this.image?.set(id, value);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async cas(id: string, expected: string, next: T): Promise<CASResult<T>> {
|
|
111
|
+
const result = await this.backing.cas(id, expected, next);
|
|
112
|
+
if (this.image !== undefined) {
|
|
113
|
+
if (result.ok) this.image.set(id, next);
|
|
114
|
+
// Conflict: the backing store hands back the authoritative current doc — reconcile the image so a
|
|
115
|
+
// retry (and the caller inspecting `current`) sees truth, not our stale copy.
|
|
116
|
+
else if (result.current !== null) this.image.set(id, result.current);
|
|
117
|
+
else this.image.delete(id);
|
|
118
|
+
}
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async delete(id: string, expectedVersion?: string): Promise<boolean> {
|
|
123
|
+
const ok = await this.backing.delete(id, expectedVersion);
|
|
124
|
+
if (ok) this.image?.delete(id);
|
|
125
|
+
return ok;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Wraps any {@link StorageBackend} in a per-namespace read-through cache — see {@link CachingStore}.
|
|
131
|
+
* Compose it BELOW any principal-partitioning router (e.g. the profiles backend) so each partition
|
|
132
|
+
* gets its own cache and the cache never sees a blended, principal-blind view; a slow backend
|
|
133
|
+
* (browser Google Drive) wraps its own backend before registering. File blobs pass straight through
|
|
134
|
+
* uncached (large, and read far less often than documents).
|
|
135
|
+
*/
|
|
136
|
+
export class CachingStorageBackend implements StorageBackend {
|
|
137
|
+
readonly inner: StorageBackend;
|
|
138
|
+
private readonly ttlMs: number | undefined;
|
|
139
|
+
private readonly stores = new Map<string, CachingStore<{ id: string; version: string }>>();
|
|
140
|
+
|
|
141
|
+
constructor(inner: StorageBackend, options: CachingOptions = {}) {
|
|
142
|
+
this.inner = inner;
|
|
143
|
+
this.ttlMs = options.ttlMs;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
createStore<T extends { id: string; version: string }>(namespace: string): Store<T> {
|
|
147
|
+
let store = this.stores.get(namespace);
|
|
148
|
+
if (store === undefined) {
|
|
149
|
+
store = new CachingStore(this.inner.createStore(namespace), this.ttlMs);
|
|
150
|
+
this.stores.set(namespace, store);
|
|
151
|
+
}
|
|
152
|
+
return store as unknown as Store<T>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Per-namespace cache counters (only namespaces touched so far). A diagnostic surface — read it to
|
|
156
|
+
* confirm the cache is serving reads (`hits` up, `loads` ~1) rather than re-reading the backend. */
|
|
157
|
+
stats(): Record<string, CacheNamespaceStats> {
|
|
158
|
+
const out: Record<string, CacheNamespaceStats> = {};
|
|
159
|
+
for (const [ns, store] of this.stores) out[ns] = store.stats;
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
get fileStore(): FileStore { return this.inner.fileStore; }
|
|
164
|
+
|
|
165
|
+
close(): Promise<void> { return this.inner.close?.() ?? Promise.resolve(); }
|
|
166
|
+
}
|
package/src/tool-registry.ts
CHANGED
|
@@ -32,7 +32,8 @@ export class ToolRegistryImpl implements ToolRegistry {
|
|
|
32
32
|
return [...this.tools.values()];
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent> {
|
|
36
|
-
|
|
35
|
+
async *watch(signal?: AbortSignal): AsyncIterable<ToolRegistryEvent> {
|
|
36
|
+
// Tool CRUD is global — no origin — so unwrap the envelope to the bare event.
|
|
37
|
+
for await (const { value } of this.events.subscribe(signal)) yield value;
|
|
37
38
|
}
|
|
38
39
|
}
|