@matatbread/matbot-core 0.3.4 → 0.3.6
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 +6 -1
- package/src/runner.ts +94 -7
- package/src/session-runner.ts +117 -22
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.3.
|
|
7
|
+
"version": "0.3.6",
|
|
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.6"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/registry.ts
CHANGED
|
@@ -175,9 +175,14 @@ export async function instantiateProvider(services: MatbotRuntime, config: Provi
|
|
|
175
175
|
// already). The stored profile is NEVER rewritten: it stays exactly as the source wrote it (yaml path,
|
|
176
176
|
// npm name, or a storage-backend manifest entry), so `provider list` reports the source truth rather than
|
|
177
177
|
// a mix of specifiers and package names that depends on which profiles happen to have been used.
|
|
178
|
+
// The specifier→name scan only matches the *exact* string a profile was loaded with, so two profiles
|
|
179
|
+
// naming one adapter by different specifiers (a yaml path and the package name) miss each other; the
|
|
180
|
+
// host resolver closes that gap by deriving the canonical name the registry is actually keyed by —
|
|
181
|
+
// without it the second specifier force-loads and dies on "already registered".
|
|
178
182
|
const name = tryResolveProviderFactory(config.module) !== undefined
|
|
179
183
|
? config.module
|
|
180
|
-
: getPluginNameForSpecifier(config.module)
|
|
184
|
+
: getPluginNameForSpecifier(config.module)
|
|
185
|
+
?? await services.resolver?.identify(config.module).catch(() => undefined);
|
|
181
186
|
if (name !== undefined) {
|
|
182
187
|
const factory = tryResolveProviderFactory(name);
|
|
183
188
|
if (factory !== undefined) return factory(config);
|
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,6 +2,7 @@ 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';
|
|
@@ -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
|
|
@@ -230,6 +259,10 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
230
259
|
continue;
|
|
231
260
|
}
|
|
232
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
|
+
|
|
233
266
|
const ac = new AbortController();
|
|
234
267
|
s.ac = ac;
|
|
235
268
|
// Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
|
|
@@ -276,7 +309,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
276
309
|
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
277
310
|
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
278
311
|
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
279
|
-
...(
|
|
312
|
+
...(inject !== undefined ? { injectedEphemeral: inject } : {}),
|
|
280
313
|
})) {
|
|
281
314
|
emit(s, ev);
|
|
282
315
|
}
|
|
@@ -289,7 +322,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
289
322
|
if (!ac.signal.aborted && hooks) {
|
|
290
323
|
const committed = await deps.store.get(id);
|
|
291
324
|
if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
|
|
292
|
-
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: [] };
|
|
293
326
|
await contextSwitch(head.principal, async () => {
|
|
294
327
|
followup = await hooks.runFollowup({
|
|
295
328
|
session: committed,
|
|
@@ -348,7 +381,12 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
348
381
|
if (followup.retract) {
|
|
349
382
|
const lastUserIdx = committed.messages.findLastIndex(m => m.role === 'user');
|
|
350
383
|
const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
|
|
351
|
-
|
|
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));
|
|
352
390
|
const retractionMsg: Message = {
|
|
353
391
|
id: crypto.randomUUID(),
|
|
354
392
|
role: 'marker',
|
|
@@ -357,7 +395,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
357
395
|
// `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
|
|
358
396
|
// context fed to the redo — neither is otherwise persisted, so the pair fully traces
|
|
359
397
|
// the swap for a post-mortem (and a frontend can render the strike-through + the cause).
|
|
360
|
-
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 } }],
|
|
361
399
|
};
|
|
362
400
|
// Any followup markers ride along as a trailing marker message (not folded into the
|
|
363
401
|
// retraction marker) so each creator's trace stays its own block.
|
|
@@ -365,6 +403,9 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
365
403
|
? [{ id: crypto.randomUUID(), role: 'marker', createdAt: new Date().toISOString(), traceId: head.traceId, content: followup.markers }]
|
|
366
404
|
: [];
|
|
367
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 });
|
|
368
409
|
notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
|
|
369
410
|
if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
370
411
|
|
|
@@ -377,7 +418,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
377
418
|
principal: head.principal,
|
|
378
419
|
concatQueue: false,
|
|
379
420
|
resubmitDepth: head.resubmitDepth + 1,
|
|
380
|
-
redo: { ephemeral: followup.retract.context },
|
|
421
|
+
redo: { ephemeral: followup.retract.context ?? [] },
|
|
381
422
|
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
382
423
|
});
|
|
383
424
|
}
|
|
@@ -391,6 +432,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
391
432
|
}
|
|
392
433
|
} finally {
|
|
393
434
|
s.running = false;
|
|
435
|
+
s.runningTraceId = undefined;
|
|
394
436
|
s.replay = [];
|
|
395
437
|
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
396
438
|
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
@@ -416,22 +458,75 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
416
458
|
}
|
|
417
459
|
s = stateFor(opts.sessionId);
|
|
418
460
|
traceId = opts.traceId ?? crypto.randomUUID();
|
|
419
|
-
const
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
+
}
|
|
435
530
|
void pump(opts.sessionId, s);
|
|
436
531
|
}
|
|
437
532
|
|