@matatbread/matbot-core 0.4.15 → 0.4.17
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 +378 -0
- package/src/config/loader.ts +15 -0
- package/src/config/yaml.ts +38 -2
- package/src/index.ts +3 -1
- package/src/knowledge/lookup-knowledge-index.ts +3 -2
- package/src/loader.ts +36 -19
- package/src/media.ts +9 -3
- package/src/registry.ts +49 -36
- package/src/runner.ts +31 -28
- package/src/session-runner.ts +276 -277
- package/src/session.ts +5 -18
- package/src/settings.ts +18 -2
- package/src/system-context.ts +6 -3
- package/src/usage.ts +6 -3
package/src/session-runner.ts
CHANGED
|
@@ -165,10 +165,16 @@ function createSink(dispose: () => void): Sink {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
/**
|
|
169
|
+
* A tool's description with its wire contract appended — the text the model actually reads for every
|
|
170
|
+
* tool, every turn. Both blocks are fenced identically; the result block used to omit the newline after
|
|
171
|
+
* its label and before its fence, so the type opened inline against "TypeScript result:```" and the
|
|
172
|
+
* block never closed where a reader expects it.
|
|
173
|
+
*/
|
|
174
|
+
export function wireDescription(description: string, wc: { params: string; result: string } | undefined): string {
|
|
175
|
+
if (wc === undefined) return description;
|
|
176
|
+
const block = (label: string, type: string): string => `${label}:\n\`\`\`\n${type}\n\`\`\``;
|
|
177
|
+
return `${description}\n\n${block('TypeScript params', wc.params)}\n\n${block('TypeScript result', wc.result)}`;
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
@@ -280,295 +286,288 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
280
286
|
// site, and spelling it here made it something the next caller of `machineBusy` could omit without
|
|
281
287
|
// any symptom — so it moved inside the hold it guards.
|
|
282
288
|
return machineBusy(async () => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
289
|
+
try {
|
|
290
|
+
while (s.queue.length > 0) {
|
|
291
|
+
// Per-submission concat: the head always runs; if the head is a concat submission it absorbs
|
|
292
|
+
// the following submissions while they too are concat, stopping at the first non-concat (a turn
|
|
293
|
+
// boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
|
|
294
|
+
// while every queued/robo submission stays its own ordered turn — concat and queued mix freely.
|
|
295
|
+
const head = s.queue.shift()!;
|
|
296
|
+
const batch = [head];
|
|
297
|
+
if (head.concatQueue) {
|
|
298
|
+
while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
|
|
299
|
+
}
|
|
300
|
+
const content = batch.flatMap(i => i.content);
|
|
301
|
+
s.runningTraceId = head.traceId;
|
|
302
|
+
s.replay = [];
|
|
303
|
+
// Seed replay with the running turn's user message as a single merged `queued`, mirroring the
|
|
304
|
+
// message persisted just below. notify() (in open()) reaches only subscribers that are live at
|
|
305
|
+
// enqueue time; a GET /events/sessions/:id that connects after this synchronous preamble — the
|
|
306
|
+
// common case when the submit POST wins the race against the events stream — would otherwise
|
|
307
|
+
// find an empty queue and a cleared replay, and never render the user bubble. One merged event
|
|
308
|
+
// (not one per batch item) matches both stored history on reload and the live fold, so a late
|
|
309
|
+
// subscriber reconstructs exactly one bubble. A redo carries no new user message (and no
|
|
310
|
+
// content), so it seeds nothing — its retraction marker already went out at enqueue time.
|
|
311
|
+
if (head.redo === undefined) {
|
|
312
|
+
s.replay.push({ type: 'queued', content, queued: 0, concatQueue: false, traceId: head.traceId, rootTraceId: head.rootTraceId });
|
|
313
|
+
}
|
|
308
314
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
let session = await deps.store.get(id);
|
|
316
|
+
if (session === null) {
|
|
317
|
+
emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A redo re-runs the existing committed user turn — no title derivation, no new user message.
|
|
322
|
+
if (head.redo === undefined) {
|
|
323
|
+
if (!session.title && !session.messages.some(m => m.role === 'user')) {
|
|
324
|
+
// Falls back to the attachment names when the turn has no words of its own: an image-only
|
|
325
|
+
// first turn is a real submission, and got no title at all — which reads in the session
|
|
326
|
+
// list as a session that failed to start.
|
|
327
|
+
const text = content
|
|
328
|
+
.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
|
|
329
|
+
.map(c => c.text).join(' ').trim()
|
|
330
|
+
|| content
|
|
331
|
+
.filter((c): c is Extract<MessageContent, { type: 'file-ref' }> => c.type === 'file-ref')
|
|
332
|
+
.map(c => c.name).join(', ');
|
|
333
|
+
if (text) {
|
|
334
|
+
const words = text.split(/\s+/).slice(0, 8).join(' ');
|
|
335
|
+
session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
314
338
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
|
|
339
|
+
// Persist-at-turn-start: the user message only hits the store when its turn begins, never
|
|
340
|
+
// while queued. That is what stops a mid-turn submit from clobbering session state. A robo
|
|
341
|
+
// resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
|
|
342
|
+
session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
|
|
343
|
+
try {
|
|
344
|
+
await deps.store.set(session.id, session);
|
|
345
|
+
} catch (e) {
|
|
346
|
+
// A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
|
|
347
|
+
// from another profile's partition). That is a per-turn condition, not a host fault: surface
|
|
348
|
+
// it as a turn error and drop this submission rather than let it escape the detached pump and
|
|
349
|
+
// crash the process. Any other write failure stays fatal.
|
|
350
|
+
if (!isReadOnlyError(e)) throw e;
|
|
351
|
+
emit(s, { type: 'error', error: e.message, traceId: head.traceId });
|
|
352
|
+
continue;
|
|
330
353
|
}
|
|
331
354
|
}
|
|
332
355
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
|
|
337
|
-
try {
|
|
338
|
-
await deps.store.set(session.id, session);
|
|
339
|
-
} catch (e) {
|
|
340
|
-
// A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
|
|
341
|
-
// from another profile's partition). That is a per-turn condition, not a host fault: surface
|
|
342
|
-
// it as a turn error and drop this submission rather than let it escape the detached pump and
|
|
343
|
-
// crash the process. Any other write failure stays fatal.
|
|
344
|
-
if (!isReadOnlyError(e)) throw e;
|
|
345
|
-
emit(s, { type: 'error', error: e.message, traceId: head.traceId });
|
|
356
|
+
const resolved = await deps.resolveProvider(head.provider);
|
|
357
|
+
if (resolved === null) {
|
|
358
|
+
emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
|
|
346
359
|
continue;
|
|
347
360
|
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const resolved = await deps.resolveProvider(head.provider);
|
|
351
|
-
if (resolved === null) {
|
|
352
|
-
emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
361
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
try {
|
|
381
|
-
// Establish the submitter's principal for the whole turn here, not inside runSession:
|
|
382
|
-
// pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
|
|
383
|
-
// turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
|
|
384
|
-
// access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
|
|
385
|
-
// because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
|
|
386
|
-
// runAs, not a context switch of its own: the pump holds the machine across the whole queue
|
|
387
|
-
// (see above), so a turn declares only its owner. Each item carries its own submitter.
|
|
388
|
-
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
389
|
-
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
390
|
-
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
391
|
-
// in both cases a followup hook would judge history with no completed answer in it — and a
|
|
392
|
-
// `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
|
|
393
|
-
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
394
|
-
// signal-driven aborts and not the policy ones.
|
|
395
|
-
let terminal: PipelineEvent['type'] | undefined;
|
|
396
|
-
// One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
|
|
397
|
-
// definition and a detached classifier settles whenever it settles, so a scope that ended at
|
|
398
|
-
// the turn's commit would drop precisely the spend that is hardest to account for otherwise.
|
|
399
|
-
// The entries array is registered for flush before any work starts, so a late arrival lands
|
|
400
|
-
// in something already tracked and needs no coordination of its own.
|
|
401
|
-
await withUsageScope(async usage => {
|
|
402
|
-
s.pendingUsage.push(usage.entries);
|
|
403
|
-
await runAs(head.principal, async () => {
|
|
404
|
-
for await (const ev of runSession({
|
|
405
|
-
session,
|
|
406
|
-
config: { provider: head.provider, traceId: head.traceId },
|
|
407
|
-
provider: resolved.adapter,
|
|
408
|
-
providerConfig: resolved.config,
|
|
409
|
-
store: deps.store,
|
|
410
|
-
signal: ac.signal,
|
|
411
|
-
loadPlugin: deps.loadPlugin,
|
|
412
|
-
unloadPlugin: deps.unloadPlugin,
|
|
413
|
-
...(toolMap !== undefined ? { tools: toolMap } : {}),
|
|
414
|
-
...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
|
|
415
|
-
...(toolPresenter !== undefined ? { toolPresenter } : {}),
|
|
416
|
-
...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
|
|
417
|
-
...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
|
|
418
|
-
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
|
419
|
-
...(deps.files !== undefined ? { files: deps.files } : {}),
|
|
420
|
-
...(mediaStore !== undefined ? { mediaStore } : {}),
|
|
421
|
-
...(permissionGate !== undefined ? { permissionGate } : {}),
|
|
422
|
-
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
423
|
-
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
424
|
-
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
425
|
-
...(inject !== undefined ? { injectedEphemeral: inject } : {}),
|
|
426
|
-
})) {
|
|
427
|
-
if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
|
|
428
|
-
emit(s, ev);
|
|
429
|
-
}
|
|
430
|
-
});
|
|
362
|
+
// A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
|
|
363
|
+
// two never coincide on one item, so either supplies runSession's injectedEphemeral.
|
|
364
|
+
const inject = head.redo?.ephemeral ?? head.injectEphemeral;
|
|
365
|
+
|
|
366
|
+
const ac = new AbortController();
|
|
367
|
+
s.ac = ac;
|
|
368
|
+
// Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
|
|
369
|
+
// (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
|
|
370
|
+
// for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
|
|
371
|
+
// Derived here because `services`/ToolTypeIndex are live at dispatch. Tools with only a loose
|
|
372
|
+
// inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
|
|
373
|
+
const wire = await deps.toolTypeIndex?.()?.wireContracts();
|
|
374
|
+
const toolPresenter = deps.toolPresenter?.();
|
|
375
|
+
const mediaStore = deps.mediaStore?.();
|
|
376
|
+
const permissionGate = deps.permissionGate?.();
|
|
377
|
+
const toolMap = deps.tools !== undefined
|
|
378
|
+
? new Map<string, Tool>(deps.tools.list().map(t => {
|
|
379
|
+
const wc = wire?.[t.name];
|
|
380
|
+
return [t.name, wc !== undefined
|
|
381
|
+
? { ...t, description: wireDescription(t.description, wc) }
|
|
382
|
+
: t];
|
|
383
|
+
}))
|
|
384
|
+
: undefined;
|
|
431
385
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
386
|
+
try {
|
|
387
|
+
// Establish the submitter's principal for the whole turn here, not inside runSession:
|
|
388
|
+
// pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
|
|
389
|
+
// turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
|
|
390
|
+
// access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
|
|
391
|
+
// because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
|
|
392
|
+
// runAs, not a context switch of its own: the pump holds the machine across the whole queue
|
|
393
|
+
// (see above), so a turn declares only its owner. Each item carries its own submitter.
|
|
394
|
+
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
395
|
+
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
396
|
+
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
397
|
+
// in both cases a followup hook would judge history with no completed answer in it — and a
|
|
398
|
+
// `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
|
|
399
|
+
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
400
|
+
// signal-driven aborts and not the policy ones.
|
|
401
|
+
let terminal: PipelineEvent['type'] | undefined;
|
|
402
|
+
// One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
|
|
403
|
+
// definition and a detached classifier settles whenever it settles, so a scope that ended at
|
|
404
|
+
// the turn's commit would drop precisely the spend that is hardest to account for otherwise.
|
|
405
|
+
// The entries array is registered for flush before any work starts, so a late arrival lands
|
|
406
|
+
// in something already tracked and needs no coordination of its own.
|
|
407
|
+
await withUsageScope(async usage => {
|
|
408
|
+
s.pendingUsage.push(usage.entries);
|
|
441
409
|
await runAs(head.principal, async () => {
|
|
442
|
-
|
|
443
|
-
session
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
410
|
+
for await (const ev of runSession({
|
|
411
|
+
session,
|
|
412
|
+
config: { provider: head.provider, traceId: head.traceId },
|
|
413
|
+
provider: resolved.adapter,
|
|
414
|
+
providerConfig: resolved.config,
|
|
415
|
+
store: deps.store,
|
|
416
|
+
signal: ac.signal,
|
|
417
|
+
loadPlugin: deps.loadPlugin,
|
|
418
|
+
unloadPlugin: deps.unloadPlugin,
|
|
419
|
+
...(toolMap !== undefined ? { tools: toolMap } : {}),
|
|
420
|
+
...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
|
|
421
|
+
...(toolPresenter !== undefined ? { toolPresenter } : {}),
|
|
422
|
+
...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
|
|
423
|
+
...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
|
|
424
|
+
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
|
425
|
+
...(deps.files !== undefined ? { files: deps.files } : {}),
|
|
426
|
+
...(mediaStore !== undefined ? { mediaStore } : {}),
|
|
427
|
+
...(permissionGate !== undefined ? { permissionGate } : {}),
|
|
428
|
+
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
429
|
+
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
430
|
+
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
431
|
+
...(inject !== undefined ? { injectedEphemeral: inject } : {}),
|
|
432
|
+
})) {
|
|
433
|
+
if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
|
|
434
|
+
emit(s, ev);
|
|
435
|
+
}
|
|
449
436
|
});
|
|
450
|
-
const resubmits = followup.resubmits;
|
|
451
|
-
|
|
452
|
-
// Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
|
|
453
|
-
// what it did). Append-only to the just-committed session; the pump is the sole writer
|
|
454
|
-
// post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
|
|
455
|
-
// `queued` event) so a live draw matches a reload — the engine surfaces everything it
|
|
456
|
-
// persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
|
|
457
|
-
// the message tail, so these markers are folded into that single write instead (below)
|
|
458
|
-
// to keep ordering sane (a separate append here would be sliced into the popped region).
|
|
459
|
-
if (followup.markers.length > 0 && !followup.retract) {
|
|
460
|
-
await deps.store.set(id, {
|
|
461
|
-
...committed,
|
|
462
|
-
messages: [...committed.messages, {
|
|
463
|
-
id: crypto.randomUUID(),
|
|
464
|
-
role: 'marker',
|
|
465
|
-
createdAt: new Date().toISOString(),
|
|
466
|
-
traceId: head.traceId,
|
|
467
|
-
content: followup.markers,
|
|
468
|
-
}],
|
|
469
|
-
});
|
|
470
|
-
notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
471
|
-
}
|
|
472
|
-
// unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
|
|
473
|
-
// every block `origin: 'robo'` here — once — so it rides into both the live `queued`
|
|
474
|
-
// event and the persisted user message the pump builds from this item.
|
|
475
|
-
for (const raw of resubmits.reverse()) {
|
|
476
|
-
const rt = crypto.randomUUID();
|
|
477
|
-
// Every block, not just text: `origin` is authorship, orthogonal to what the block
|
|
478
|
-
// carries, and a resubmission that attached an image was silently presenting it as the
|
|
479
|
-
// user's own. The arms a stamp would be wrong on don't occur here — a hook returns
|
|
480
|
-
// content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
|
|
481
|
-
const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
|
|
482
|
-
s.queue.unshift({
|
|
483
|
-
traceId: rt,
|
|
484
|
-
rootTraceId: head.rootTraceId,
|
|
485
|
-
content,
|
|
486
|
-
provider: head.provider,
|
|
487
|
-
principal: head.principal,
|
|
488
|
-
concatQueue: false,
|
|
489
|
-
resubmitDepth: head.resubmitDepth + 1,
|
|
490
|
-
});
|
|
491
|
-
notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
|
|
492
|
-
}
|
|
493
437
|
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
438
|
+
// followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
|
|
439
|
+
// head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
|
|
440
|
+
// committed (see `terminal`), and under the submitter's principal because a reactor may itself
|
|
441
|
+
// call complete() (a classifier).
|
|
442
|
+
const hooks = deps.hooks;
|
|
443
|
+
if (terminal === 'done' && hooks) {
|
|
444
|
+
const committed = await deps.store.get(id);
|
|
445
|
+
if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
|
|
446
|
+
let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
|
|
447
|
+
await runAs(head.principal, async () => {
|
|
448
|
+
followup = await hooks.runFollowup({
|
|
449
|
+
session: committed,
|
|
450
|
+
resubmitDepth: head.resubmitDepth,
|
|
451
|
+
config: { provider: head.provider, traceId: head.traceId },
|
|
452
|
+
signal: ac.signal,
|
|
453
|
+
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
const resubmits = followup.resubmits;
|
|
457
|
+
|
|
458
|
+
// Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
|
|
459
|
+
// what it did). Append-only to the just-committed session; the pump is the sole writer
|
|
460
|
+
// post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
|
|
461
|
+
// `queued` event) so a live draw matches a reload — the engine surfaces everything it
|
|
462
|
+
// persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
|
|
463
|
+
// the message tail, so these markers are folded into that single write instead (below)
|
|
464
|
+
// to keep ordering sane (a separate append here would be sliced into the popped region).
|
|
465
|
+
if (followup.markers.length > 0 && !followup.retract) {
|
|
466
|
+
await deps.store.set(id, {
|
|
467
|
+
...committed,
|
|
468
|
+
messages: [...committed.messages,
|
|
469
|
+
createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })],
|
|
470
|
+
});
|
|
471
|
+
notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
472
|
+
}
|
|
473
|
+
// unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
|
|
474
|
+
// every block `origin: 'robo'` here — once — so it rides into both the live `queued`
|
|
475
|
+
// event and the persisted user message the pump builds from this item.
|
|
476
|
+
for (const raw of resubmits.reverse()) {
|
|
477
|
+
const rt = crypto.randomUUID();
|
|
478
|
+
// Every block, not just text: `origin` is authorship, orthogonal to what the block
|
|
479
|
+
// carries, and a resubmission that attached an image was silently presenting it as the
|
|
480
|
+
// user's own. The arms a stamp would be wrong on don't occur here — a hook returns
|
|
481
|
+
// content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
|
|
482
|
+
const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
|
|
483
|
+
s.queue.unshift({
|
|
484
|
+
traceId: rt,
|
|
485
|
+
rootTraceId: head.rootTraceId,
|
|
486
|
+
content,
|
|
487
|
+
provider: head.provider,
|
|
488
|
+
principal: head.principal,
|
|
489
|
+
concatQueue: false,
|
|
490
|
+
resubmitDepth: head.resubmitDepth + 1,
|
|
491
|
+
});
|
|
492
|
+
notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// retract-and-rerun: pop the just-committed turn back to (and excluding) the last user
|
|
496
|
+
// message, stash the popped messages in a durable retraction marker (LLM-elided like any
|
|
497
|
+
// marker — so the model never re-reads its superseded answer — but carried in `data` for
|
|
498
|
+
// a strike-through render and audit), then re-enqueue a redo of that same user turn at the
|
|
499
|
+
// head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
|
|
500
|
+
// sits at the very head (runs next) even if a resubmit was also queued above.
|
|
501
|
+
if (followup.retract) {
|
|
502
|
+
const lastUserIdx = lastUserIndex(committed);
|
|
503
|
+
const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
|
|
504
|
+
// `durable` correction (a contextual fire that landed post-commit) folds onto the user
|
|
505
|
+
// message we keep — persisted, so the redo AND every later turn see it in history, the
|
|
506
|
+
// exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
|
|
507
|
+
// The fold runs on the *truncated* session, whose last user message is the one we keep,
|
|
508
|
+
// so it goes through the same helper as the screen and raced-verdict folds.
|
|
509
|
+
const durable = followup.retract.durable ?? [];
|
|
510
|
+
const truncated = lastUserIdx >= 0
|
|
511
|
+
? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
|
|
512
|
+
: committed;
|
|
513
|
+
const kept = foldOntoUserTurn(truncated, durable).messages;
|
|
514
|
+
const retractionMsg: Message = createMessage({
|
|
515
|
+
role: 'marker',
|
|
516
|
+
traceId: head.traceId,
|
|
517
|
+
// `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
|
|
518
|
+
// context fed to the redo — neither is otherwise persisted, so the pair fully traces
|
|
519
|
+
// the swap for a post-mortem (and a frontend can render the strike-through + the cause).
|
|
520
|
+
content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
|
|
521
|
+
});
|
|
522
|
+
// Any followup markers ride along as a trailing marker message (not folded into the
|
|
523
|
+
// retraction marker) so each creator's trace stays its own block.
|
|
524
|
+
const trailing: Message[] = followup.markers.length > 0
|
|
525
|
+
? [createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })]
|
|
526
|
+
: [];
|
|
527
|
+
await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
|
|
528
|
+
// Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
|
|
529
|
+
// marker, mirroring the screen-time durable path's ordering.
|
|
530
|
+
if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
|
|
531
|
+
notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
|
|
532
|
+
if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
533
|
+
|
|
534
|
+
const rt = crypto.randomUUID();
|
|
535
|
+
s.queue.unshift({
|
|
536
|
+
traceId: rt,
|
|
537
|
+
rootTraceId: head.rootTraceId,
|
|
538
|
+
content: [],
|
|
539
|
+
provider: head.provider,
|
|
540
|
+
principal: head.principal,
|
|
541
|
+
concatQueue: false,
|
|
542
|
+
resubmitDepth: head.resubmitDepth + 1,
|
|
543
|
+
redo: { ephemeral: followup.retract.context ?? [] },
|
|
544
|
+
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
547
548
|
}
|
|
548
|
-
}
|
|
549
|
+
}, { traceId: head.traceId, rootTraceId: head.rootTraceId });
|
|
550
|
+
} catch (e) {
|
|
551
|
+
emit(s, { type: 'error', error: String(e), traceId: head.traceId });
|
|
552
|
+
} finally {
|
|
553
|
+
s.ac = undefined;
|
|
549
554
|
}
|
|
550
|
-
}, { traceId: head.traceId, rootTraceId: head.rootTraceId });
|
|
551
|
-
} catch (e) {
|
|
552
|
-
emit(s, { type: 'error', error: String(e), traceId: head.traceId });
|
|
553
|
-
} finally {
|
|
554
|
-
s.ac = undefined;
|
|
555
555
|
}
|
|
556
|
+
} finally {
|
|
557
|
+
s.running = false;
|
|
558
|
+
s.runningTraceId = undefined;
|
|
559
|
+
s.replay = [];
|
|
560
|
+
// Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
|
|
561
|
+
// is not a well-defined moment to total anything at: steers terminate and resume, a retract
|
|
562
|
+
// re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
|
|
563
|
+
// settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
|
|
564
|
+
await flushUsage(id, s);
|
|
565
|
+
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
566
|
+
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
567
|
+
// the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
|
|
568
|
+
notify(s, { type: 'idle', sessionId: id });
|
|
569
|
+
maybeCleanup(id, s);
|
|
556
570
|
}
|
|
557
|
-
} finally {
|
|
558
|
-
s.running = false;
|
|
559
|
-
s.runningTraceId = undefined;
|
|
560
|
-
s.replay = [];
|
|
561
|
-
// Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
|
|
562
|
-
// is not a well-defined moment to total anything at: steers terminate and resume, a retract
|
|
563
|
-
// re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
|
|
564
|
-
// settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
|
|
565
|
-
await flushUsage(id, s);
|
|
566
|
-
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
567
|
-
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
568
|
-
// the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
|
|
569
|
-
notify(s, { type: 'idle', sessionId: id });
|
|
570
|
-
maybeCleanup(id, s);
|
|
571
|
-
}
|
|
572
571
|
});
|
|
573
572
|
};
|
|
574
573
|
|