@corenel/tools-web 0.8.0 → 0.9.0
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/dist/core/protocol.d.ts +3 -0
- package/dist/core/protocol.d.ts.map +1 -1
- package/dist/runtime.d.ts +82 -6
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +361 -32
- package/dist/runtime.js.map +1 -1
- package/dist/workerClient.d.ts +2 -0
- package/dist/workerClient.d.ts.map +1 -1
- package/dist/workerClient.js +1 -1
- package/dist/workerClient.js.map +1 -1
- package/dist/workerHandler.d.ts.map +1 -1
- package/dist/workerHandler.js +9 -4
- package/dist/workerHandler.js.map +1 -1
- package/package.json +3 -3
package/dist/runtime.js
CHANGED
|
@@ -11,15 +11,16 @@ import { sessionSystemPrompt } from './sessionSystemPrompt';
|
|
|
11
11
|
import { sessionSystemConfig, freezeSessionSystemConfig, clearSessionSystemConfig } from './sessionSystemConfig';
|
|
12
12
|
import { stateDirName, sessionsRoot, isCheckpointTool, unpricedCalleeRefusal } from '@corenel/protocol';
|
|
13
13
|
import { listSystemPrompts, resolveSystemPrompt, readSystemPromptSource } from '@corenel/harness/prompts/systemPrompts';
|
|
14
|
-
import { turnsFromSessionMessages } from '@corenel/harness/conversation';
|
|
14
|
+
import { turnsFromSessionMessages, foldPoint, isFoldedTurn } from '@corenel/harness/conversation';
|
|
15
15
|
import { allTools } from '@corenel/harness/tools/builtins';
|
|
16
16
|
import { applyTodoWrite, addActivity, resolveActivity } from '@corenel/harness/todos';
|
|
17
17
|
import { getPolicy, STANDARD_POLICY } from '@corenel/harness/guardrail';
|
|
18
18
|
import { composeAgentSystem, TOOLS_MARKER } from '@corenel/harness/prompts';
|
|
19
19
|
import { getSystemParts } from '@corenel/harness/systemParts';
|
|
20
|
-
import { CHECKPOINT_MARKER } from '@corenel/harness/core/compactHistory';
|
|
20
|
+
import { CHECKPOINT_MARKER, estimateTokens, COMPACT_AT_FILL } from '@corenel/harness/core/compactHistory';
|
|
21
21
|
import { priceUsageWith, pricedForUsd } from '@corenel/harness/core/pricing';
|
|
22
22
|
import { enabledSkillCatalog } from '@corenel/harness/skills';
|
|
23
|
+
import { alog } from '@corenel/harness/log';
|
|
23
24
|
// The agent's model is the user's picker selection (getModel) — a real
|
|
24
25
|
// catalog entry, so pricing/context come from host.modelInfo, not hardcoded figures.
|
|
25
26
|
const WATCHDOG_MS = 45000;
|
|
@@ -34,7 +35,8 @@ function emptyInternal() {
|
|
|
34
35
|
pendingPerm: null, pendingAsk: null, approvalSuggestion: null, loaded: false, suggestions: [], recovery: null, abort: null, toolMs: {}, allowed: new Set(),
|
|
35
36
|
pendingCommand: null, runId: null,
|
|
36
37
|
recEvents: [], injectedIds: [], lastUsage: { promptTokens: 0, completionTokens: 0 },
|
|
37
|
-
offloadTick: 0, policyNotes: [], degradedModel: null,
|
|
38
|
+
offloadTick: 0, policyNotes: [], degradedModel: null, model: null, modelNotice: null, persistedLen: null,
|
|
39
|
+
saveBlocked: null, loadMissed: false, mergeTick: 0,
|
|
38
40
|
};
|
|
39
41
|
}
|
|
40
42
|
/** Build the user message — a plain string, or multimodal parts when images are attached. */
|
|
@@ -62,6 +64,35 @@ function userImages(m) {
|
|
|
62
64
|
return [];
|
|
63
65
|
return m.content.filter((p) => p.type === 'image_url').map((p) => ('image_url' in p ? p.image_url.url : '')).filter(Boolean);
|
|
64
66
|
}
|
|
67
|
+
/** What a send hands the run: the raw included transcript, or -- once a run
|
|
68
|
+
* has folded -- the newest LIVE seam's folded view plus the included turns
|
|
69
|
+
* after the user turn that produced it. Turns at or before that turn are
|
|
70
|
+
* already in the view; sending them raw again re-folded the whole history
|
|
71
|
+
* every send. A view whose user turn is gone is skipped for the one before it,
|
|
72
|
+
* and with no live view at all the transcript goes raw (the next run re-folds). */
|
|
73
|
+
function outgoingMessages(turns) {
|
|
74
|
+
const fp = foldPoint(turns);
|
|
75
|
+
if (fp)
|
|
76
|
+
return [...fp.view, ...turns.slice(fp.throughIndex + 1).filter((t) => t.included).flatMap((t) => t.messages)];
|
|
77
|
+
return turns.filter((t) => t.included).flatMap((t) => t.messages);
|
|
78
|
+
}
|
|
79
|
+
/** Stamp a run's folded view on its newest seam, and keep exactly ONE older
|
|
80
|
+
* view: each is a full copy of the window and persist rewrites every turn on
|
|
81
|
+
* every send, so views left on every seam grow without bound in a chat that
|
|
82
|
+
* never ends. The one kept is what `retry` of the folding turn falls back to
|
|
83
|
+
* (retry removes the newest seam with its user turn). */
|
|
84
|
+
function stampFoldedView(turns, seamId, view, through) {
|
|
85
|
+
const older = turns.filter((t) => t.foldedView && t.id !== seamId).map((t) => t.id);
|
|
86
|
+
const keep = older[older.length - 1];
|
|
87
|
+
return turns.map((t) => {
|
|
88
|
+
if (t.id === seamId)
|
|
89
|
+
return { ...t, foldedView: view, foldedThrough: through };
|
|
90
|
+
if (!t.foldedView || t.id === keep)
|
|
91
|
+
return t;
|
|
92
|
+
const { foldedView: _view, foldedThrough: _through, ...rest } = t;
|
|
93
|
+
return rest;
|
|
94
|
+
});
|
|
95
|
+
}
|
|
65
96
|
/** Merge a form's freshly-emitted envelope into a per-run override, against the
|
|
66
97
|
* currently saved default. The form only emits the keys it still wants set — a
|
|
67
98
|
* cleared field (e.g. "No limit") is simply ABSENT from `emitted`, not `undefined`.
|
|
@@ -167,6 +198,34 @@ export class AgentRuntime {
|
|
|
167
198
|
writable: true,
|
|
168
199
|
value: null
|
|
169
200
|
});
|
|
201
|
+
/* The chat's estimated size in tokens, keyed by the turns array: turns are
|
|
202
|
+
* replaced, never mutated, so the same array means the same size. The picker
|
|
203
|
+
* asks canHold for every row on every render; this keeps that one flatten
|
|
204
|
+
* per transcript, not one per row. */
|
|
205
|
+
Object.defineProperty(this, "fill", {
|
|
206
|
+
enumerable: true,
|
|
207
|
+
configurable: true,
|
|
208
|
+
writable: true,
|
|
209
|
+
value: new WeakMap()
|
|
210
|
+
});
|
|
211
|
+
/* ---- run ---- */
|
|
212
|
+
/* One store write at a time per session: every read-modify-write of the
|
|
213
|
+
* stored chat (persist, the first-send retitle) goes through here. Each
|
|
214
|
+
* re-reads the session first; two overlapping would let one read the other's
|
|
215
|
+
* write before persistedLen caught up and take it for another writer, or
|
|
216
|
+
* write back a list the other had just shortened (a retitle restoring a
|
|
217
|
+
* cleared chat, R37). */
|
|
218
|
+
Object.defineProperty(this, "writing", {
|
|
219
|
+
enumerable: true,
|
|
220
|
+
configurable: true,
|
|
221
|
+
writable: true,
|
|
222
|
+
value: new Map()
|
|
223
|
+
});
|
|
224
|
+
// Availability and the fallback are read live, so every open chat's
|
|
225
|
+
// notice must re-render when either moves.
|
|
226
|
+
if (host.perChatModel)
|
|
227
|
+
host.onModelsChange?.(() => { for (const id of this.subs.keys())
|
|
228
|
+
this.notify(id); });
|
|
170
229
|
}
|
|
171
230
|
ws() {
|
|
172
231
|
if (!this._ws)
|
|
@@ -244,7 +303,13 @@ export class AgentRuntime {
|
|
|
244
303
|
return () => { set.delete(cb); };
|
|
245
304
|
}
|
|
246
305
|
subscribeGlobal(cb) { this.globalSubs.add(cb); return () => { this.globalSubs.delete(cb); }; }
|
|
247
|
-
getState(id) {
|
|
306
|
+
getState(id) {
|
|
307
|
+
const s = this.map.get(id);
|
|
308
|
+
if (!s)
|
|
309
|
+
return emptyInternal();
|
|
310
|
+
s.modelNotice = this.modelNotice(id, s);
|
|
311
|
+
return s;
|
|
312
|
+
}
|
|
248
313
|
status(id) {
|
|
249
314
|
const s = this.map.get(id);
|
|
250
315
|
if (!s)
|
|
@@ -271,8 +336,31 @@ export class AgentRuntime {
|
|
|
271
336
|
const s = this.ensure(id);
|
|
272
337
|
if (s.loaded)
|
|
273
338
|
return;
|
|
274
|
-
|
|
275
|
-
|
|
339
|
+
/* A read that THROWS is a miss, not an error for the caller: send awaits
|
|
340
|
+
* this, and a rejected load would drop the send outright while the next
|
|
341
|
+
* attempt fails the same way. As a miss it goes down the path that is
|
|
342
|
+
* covered: the exchange runs, saveBlocked says it is not kept, and Retry
|
|
343
|
+
* (retryLoad) reads again. */
|
|
344
|
+
let ses;
|
|
345
|
+
try {
|
|
346
|
+
ses = await this.host.sessions.get(id);
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
ses = null;
|
|
350
|
+
}
|
|
351
|
+
// A concurrent load (the focus effect beside a send) landed first, and its
|
|
352
|
+
// merge already holds everything; merging again would double the chat.
|
|
353
|
+
if (s.loaded)
|
|
354
|
+
return;
|
|
355
|
+
/* MERGE, never replace: anything this page added before the stored chat
|
|
356
|
+
* arrived -- a /model notice, say -- comes after it rather than vanishing.
|
|
357
|
+
* A miss leaves those turns alone too. */
|
|
358
|
+
const pre = s.turns;
|
|
359
|
+
if (ses) {
|
|
360
|
+
s.turns = [...turnsFromSessionMessages(ses.messages), ...pre];
|
|
361
|
+
s.persistedLen = ses.messages.length;
|
|
362
|
+
}
|
|
363
|
+
s.model = ses?.model ?? null;
|
|
276
364
|
/* ONLY ON A HIT. A miss used to be cached exactly like a hit, and that made
|
|
277
365
|
* it permanent: the session could arrive a moment later and this would
|
|
278
366
|
* never look again, because the guard above returns on `loaded`.
|
|
@@ -290,6 +378,11 @@ export class AgentRuntime {
|
|
|
290
378
|
* -- not per render -- so a genuinely absent session does not spin. */
|
|
291
379
|
if (ses)
|
|
292
380
|
s.loaded = true;
|
|
381
|
+
s.loadMissed = !ses;
|
|
382
|
+
// Loaded with nothing of this page's to save: nothing is unsaved any more.
|
|
383
|
+
// With something, the persist below clears the flag once it lands.
|
|
384
|
+
if (ses && !pre.length)
|
|
385
|
+
s.saveBlocked = null;
|
|
293
386
|
/* MIGRATION. A conversation from before the snapshot existed has none, and
|
|
294
387
|
* would otherwise keep following the live Settings default forever. Opening
|
|
295
388
|
* it freezes it at what it would have used at this moment, so nothing
|
|
@@ -298,6 +391,37 @@ export class AgentRuntime {
|
|
|
298
391
|
if (!sessionSystemConfig(id))
|
|
299
392
|
this.freezeSystemConfig(id);
|
|
300
393
|
this.notify(id);
|
|
394
|
+
// What was added before the load could not be saved then (see persist).
|
|
395
|
+
if (ses && pre.length)
|
|
396
|
+
void this.persist(id);
|
|
397
|
+
}
|
|
398
|
+
/** Try the stored chat again after a load failed (the unsaved-chat notice's
|
|
399
|
+
* Retry): load it -- merging what this page added meanwhile after it, as
|
|
400
|
+
* `load` always does -- then save the merged list. True once it is saved;
|
|
401
|
+
* false when the load missed again (or threw), and `saveBlocked` stays. */
|
|
402
|
+
async retryLoad(id) {
|
|
403
|
+
await this.load(id);
|
|
404
|
+
const s = this.map.get(id);
|
|
405
|
+
if (!s?.loaded)
|
|
406
|
+
return false;
|
|
407
|
+
// persistNow swallows its own failures; this catch keeps the promise a
|
|
408
|
+
// Retry button awaits from ever rejecting.
|
|
409
|
+
await this.persist(id).catch(() => { });
|
|
410
|
+
/* GLOBAL, because the session list is. While the load kept missing, the
|
|
411
|
+
* list the page shows (and the header's title, read from it) could drop
|
|
412
|
+
* this chat; it is back now, and only a global change makes the page
|
|
413
|
+
* re-read the list. Without it the recovered chat stayed missing from the
|
|
414
|
+
* list, titled "New chat", until a reload. */
|
|
415
|
+
this.notifyGlobal();
|
|
416
|
+
return s.saveBlocked === null;
|
|
417
|
+
}
|
|
418
|
+
/** Raise the unsaved-chat flag when `id` is still not loaded after a load
|
|
419
|
+
* was tried, so the page says its new messages are not being kept. */
|
|
420
|
+
flagIfUnsaved(id) {
|
|
421
|
+
const s = this.map.get(id);
|
|
422
|
+
if (s && !s.loaded && s.loadMissed && s.turns.length && s.saveBlocked !== 'load-failed') {
|
|
423
|
+
this.patch(id, { saveBlocked: 'load-failed' });
|
|
424
|
+
}
|
|
301
425
|
}
|
|
302
426
|
async openInitial() {
|
|
303
427
|
if (this.initInFlight)
|
|
@@ -310,7 +434,11 @@ export class AgentRuntime {
|
|
|
310
434
|
// the traces, to be pruned with them.
|
|
311
435
|
const empty = list.find((s) => !s.parent && (!s.messages || s.messages.length === 0));
|
|
312
436
|
if (empty) {
|
|
313
|
-
this.ensure(empty.id)
|
|
437
|
+
const s = this.ensure(empty.id);
|
|
438
|
+
s.loaded = true;
|
|
439
|
+
s.persistedLen = 0;
|
|
440
|
+
// A model picked on this empty chat before the reload is still its model.
|
|
441
|
+
s.model = empty.model ?? null;
|
|
314
442
|
return empty.id;
|
|
315
443
|
}
|
|
316
444
|
return this.newSession();
|
|
@@ -334,6 +462,7 @@ export class AgentRuntime {
|
|
|
334
462
|
const ses = await this.host.sessions.create('New chat', this.surface);
|
|
335
463
|
const s = this.ensure(ses.id);
|
|
336
464
|
s.loaded = true;
|
|
465
|
+
s.persistedLen = 0;
|
|
337
466
|
// The system prompt is the head of the cached prefix, so this conversation's
|
|
338
467
|
// config is decided HERE and read from the snapshot after -- see
|
|
339
468
|
// sessionSystemConfig. Settings decides what the next one starts with.
|
|
@@ -352,6 +481,77 @@ export class AgentRuntime {
|
|
|
352
481
|
deleteDocsForSession(id);
|
|
353
482
|
this.notifyGlobal();
|
|
354
483
|
}
|
|
484
|
+
/* ---- model per chat ---- */
|
|
485
|
+
available(model) {
|
|
486
|
+
return this.host.isModelAvailable ? this.host.isModelAvailable(model) : true;
|
|
487
|
+
}
|
|
488
|
+
/** Is the chat's STORED model still usable? Trusted until the catalog has
|
|
489
|
+
* been consulted: before that, "not in the catalog" means "not looked yet",
|
|
490
|
+
* and acting on it would announce a fallback and fold for nothing. */
|
|
491
|
+
storedAvailable(model) {
|
|
492
|
+
if (this.host.modelsSettled && !this.host.modelsSettled())
|
|
493
|
+
return true;
|
|
494
|
+
return this.available(model);
|
|
495
|
+
}
|
|
496
|
+
/** The stored model when it is gone and the chat would run on a fallback. */
|
|
497
|
+
lostModel(s) {
|
|
498
|
+
if (!this.host.perChatModel || !s.model)
|
|
499
|
+
return null;
|
|
500
|
+
return this.storedAvailable(s.model) ? null : s.model;
|
|
501
|
+
}
|
|
502
|
+
modelNotice(id, s) {
|
|
503
|
+
const lost = this.lostModel(s);
|
|
504
|
+
if (!lost)
|
|
505
|
+
return null;
|
|
506
|
+
const fallback = this.getModel();
|
|
507
|
+
return `This chat used ${lost}, which is not available. It will run on ${fallback}`
|
|
508
|
+
+ (this.canHold(id, fallback) ? '.' : ', and older history will be folded to fit.');
|
|
509
|
+
}
|
|
510
|
+
/** The model this chat runs on: its own if still available, else the
|
|
511
|
+
* last-used pick. Without `perChatModel`, always the last-used pick. */
|
|
512
|
+
chatModel(id) {
|
|
513
|
+
const s = this.map.get(id);
|
|
514
|
+
if (!this.host.perChatModel || !s?.model || this.lostModel(s))
|
|
515
|
+
return this.getModel();
|
|
516
|
+
return s.model;
|
|
517
|
+
}
|
|
518
|
+
fillTokens(turns) {
|
|
519
|
+
let n = this.fill.get(turns);
|
|
520
|
+
if (n === undefined) {
|
|
521
|
+
n = estimateTokens(outgoingMessages(turns));
|
|
522
|
+
this.fill.set(turns, n);
|
|
523
|
+
}
|
|
524
|
+
return n;
|
|
525
|
+
}
|
|
526
|
+
/** Can `model`'s window hold this chat as it stands (below the fold line)?
|
|
527
|
+
* Measured over what a send would actually hand the run -- the folded view
|
|
528
|
+
* plus the turns after it -- not the raw transcript, which overstates a chat
|
|
529
|
+
* that has already folded. */
|
|
530
|
+
canHold(id, model) {
|
|
531
|
+
const s = this.map.get(id);
|
|
532
|
+
if (!s)
|
|
533
|
+
return true;
|
|
534
|
+
const window = this.host.modelInfo(model).contextWindow ?? 128000;
|
|
535
|
+
if (window <= 0)
|
|
536
|
+
return true;
|
|
537
|
+
return this.fillTokens(s.turns) / window < COMPACT_AT_FILL;
|
|
538
|
+
}
|
|
539
|
+
/** Switch this chat (only) to `model`; null when it switched. Refused, with
|
|
540
|
+
* nothing changed: before the chat has loaded (there is nothing to measure,
|
|
541
|
+
* and persisting would write an empty transcript over the stored one); when
|
|
542
|
+
* the model is not available (it would silently run on the fallback); and
|
|
543
|
+
* when it cannot hold the chat (switching never folds, spec 3.5). */
|
|
544
|
+
setChatModel(id, model) {
|
|
545
|
+
if (!this.map.get(id)?.loaded)
|
|
546
|
+
return 'not-loaded';
|
|
547
|
+
if (!this.available(model))
|
|
548
|
+
return 'unavailable';
|
|
549
|
+
if (!this.canHold(id, model))
|
|
550
|
+
return 'too-large';
|
|
551
|
+
this.patch(id, { model });
|
|
552
|
+
void this.persist(id);
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
355
555
|
/* ---- view mutators ---- */
|
|
356
556
|
setInput(id, v) { this.patch(id, { input: v }); }
|
|
357
557
|
setMode(id, m) { this.patch(id, { mode: m }); }
|
|
@@ -367,12 +567,15 @@ export class AgentRuntime {
|
|
|
367
567
|
void this.persist(id);
|
|
368
568
|
}
|
|
369
569
|
/** Append a local assistant-style notice turn (e.g. a /model confirmation) — not
|
|
370
|
-
* sent to the model, just shown in the transcript. */
|
|
570
|
+
* sent to the model, just shown in the transcript. Returns the turn's id. */
|
|
371
571
|
note(id, text, extra) {
|
|
372
572
|
const s = this.ensure(id);
|
|
373
|
-
|
|
573
|
+
// The random suffix: two notes in one millisecond (a fold beside a budget
|
|
574
|
+
// warning) must not share an id, or a later update by id lands on both.
|
|
575
|
+
const turn = { id: `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, included: false, messages: [{ role: 'assistant', content: text }], ...extra };
|
|
374
576
|
this.patch(id, { turns: [...s.turns, turn] });
|
|
375
577
|
void this.persist(id);
|
|
578
|
+
return turn.id;
|
|
376
579
|
}
|
|
377
580
|
/* The compaction seam. PERMANENT in the transcript, per the `checkpoint`
|
|
378
581
|
* event's contract: compaction is lossy, and a person who cannot see that it
|
|
@@ -392,7 +595,7 @@ export class AgentRuntime {
|
|
|
392
595
|
* The body rides along so the seam can be READ. It was dropped here before
|
|
393
596
|
* -- the event carries it, and this took only the count -- which left the
|
|
394
597
|
* one question a seam raises with no answer anywhere in the UI. */
|
|
395
|
-
this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
|
|
598
|
+
return this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
|
|
396
599
|
}
|
|
397
600
|
/** Show a message in the transcript as YOUR turn, without adding it to what
|
|
398
601
|
* gets sent. Used for injected messages: the real copy is already in the
|
|
@@ -408,8 +611,13 @@ export class AgentRuntime {
|
|
|
408
611
|
setAttachments(id, fn) {
|
|
409
612
|
this.patch(id, { attachments: fn(this.ensure(id).attachments) });
|
|
410
613
|
}
|
|
614
|
+
/** Flip whether turn `turnId` is sent. A FOLDED turn (see isFoldedTurn) is
|
|
615
|
+
* refused -- no state change, no persist: the folded view stands for it, so
|
|
616
|
+
* including or dropping it would change nothing the next request carries. */
|
|
411
617
|
toggleInclude(id, turnId) {
|
|
412
618
|
const s = this.ensure(id);
|
|
619
|
+
if (isFoldedTurn(s.turns, s.turns.findIndex((t) => t.id === turnId)))
|
|
620
|
+
return;
|
|
413
621
|
this.patch(id, { turns: s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t)) });
|
|
414
622
|
void this.persist(id);
|
|
415
623
|
}
|
|
@@ -548,16 +756,91 @@ export class AgentRuntime {
|
|
|
548
756
|
this.getWorker().inject(s.runId, messages, hard);
|
|
549
757
|
return true;
|
|
550
758
|
}
|
|
551
|
-
|
|
552
|
-
|
|
759
|
+
queueWrite(id, write) {
|
|
760
|
+
// Nothing queued: start now, at the same tick as before this queue existed.
|
|
761
|
+
// A failed write must not stop the ones queued behind it.
|
|
762
|
+
const prev = this.writing.get(id);
|
|
763
|
+
const next = prev ? prev.catch(() => { }).then(write) : write();
|
|
764
|
+
this.writing.set(id, next);
|
|
765
|
+
next.finally(() => { if (this.writing.get(id) === next)
|
|
766
|
+
this.writing.delete(id); }).catch(() => { });
|
|
767
|
+
return next;
|
|
768
|
+
}
|
|
769
|
+
persist(id) {
|
|
770
|
+
return this.queueWrite(id, () => this.persistNow(id));
|
|
771
|
+
}
|
|
772
|
+
async persistNow(id) {
|
|
553
773
|
const s = this.map.get(id);
|
|
554
|
-
|
|
774
|
+
/* Never before the stored transcript has loaded (ruling R34). Until then
|
|
775
|
+
* `turns` holds only what this page added -- a /model notice, say -- and
|
|
776
|
+
* saving it would write that over the whole stored chat. Such notes stay
|
|
777
|
+
* in memory -- and once a load has actually failed, say so. */
|
|
778
|
+
if (!s?.loaded) {
|
|
779
|
+
this.flagIfUnsaved(id);
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
// A read that throws is a save that did not happen: persistedLen and
|
|
783
|
+
// saveBlocked stay as they are, and the next write tries again.
|
|
784
|
+
let ses;
|
|
785
|
+
try {
|
|
786
|
+
ses = await this.host.sessions.get(id);
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
555
789
|
return;
|
|
556
|
-
|
|
790
|
+
}
|
|
557
791
|
if (!ses)
|
|
558
792
|
return;
|
|
559
|
-
|
|
560
|
-
|
|
793
|
+
/* Shorten the stored chat (retry, /clear) only when it is the length this
|
|
794
|
+
* page last saw (ruling R36). Otherwise another writer appended since, and
|
|
795
|
+
* this page's shorter list would delete that writer's newer turns. */
|
|
796
|
+
const known = ses.messages.length === s.persistedLen;
|
|
797
|
+
let turns = s.turns;
|
|
798
|
+
if (!known) {
|
|
799
|
+
/* Another writer changed it since. Saving this page's list as it stands
|
|
800
|
+
* would write it BY POSITION -- a stale [q0] that sent q3 put q3 at index
|
|
801
|
+
* 1, over the other tab's q1 -- so merge by turn id instead, over what
|
|
802
|
+
* was just read: stored order first, this page's version of any turn
|
|
803
|
+
* both hold (last writer wins per turn), then this page's turns the
|
|
804
|
+
* store does not have, in this page's order. A flat (daemon) chat's
|
|
805
|
+
* turns get the same `stored-<i>` ids here as at load, so a turn this
|
|
806
|
+
* page loaded from it matches and is not added twice.
|
|
807
|
+
*
|
|
808
|
+
* The page then shows the merged chat. Nothing it holds is dropped, and
|
|
809
|
+
* the store loses nothing: the merge holds every stored turn, so the
|
|
810
|
+
* save may rewrite freely (allowTruncate below), which a flat store's
|
|
811
|
+
* conversion to turns needs. */
|
|
812
|
+
const stored = turnsFromSessionMessages(ses.messages);
|
|
813
|
+
const mine = new Map(s.turns.map((t) => [t.id, t]));
|
|
814
|
+
const storedIds = new Set(stored.map((t) => t.id));
|
|
815
|
+
/* Only the FIRST stored turn with an id takes this page's version. A
|
|
816
|
+
* second one with the same id is another turn that collided (ids are
|
|
817
|
+
* meant to be unique; turnsFromSessionMessages keeps its synthetic ones
|
|
818
|
+
* so), and replacing it too would write one turn twice and lose the
|
|
819
|
+
* other on this rewrite. */
|
|
820
|
+
const used = new Set();
|
|
821
|
+
const merged = stored.map((t) => {
|
|
822
|
+
const own = mine.get(t.id);
|
|
823
|
+
if (!own || used.has(t.id))
|
|
824
|
+
return t;
|
|
825
|
+
used.add(t.id);
|
|
826
|
+
return own;
|
|
827
|
+
});
|
|
828
|
+
turns = [...merged, ...s.turns.filter((t) => !storedIds.has(t.id))];
|
|
829
|
+
alog(`session ${id}: stored has ${ses.messages.length} messages, this page last saw ${s.persistedLen}; another writer changed it, so this save merges both by turn (${turns.length} turns)`);
|
|
830
|
+
this.patch(id, { turns, mergeTick: s.mergeTick + 1 });
|
|
831
|
+
}
|
|
832
|
+
ses.messages = turns;
|
|
833
|
+
if (s.model)
|
|
834
|
+
ses.model = s.model;
|
|
835
|
+
try {
|
|
836
|
+
// Known: this page's list IS the chat, and may shorten it (R36). A
|
|
837
|
+
// merge: holds every stored turn, so rewriting cannot lose one.
|
|
838
|
+
await this.host.sessions.save(ses, { allowTruncate: true });
|
|
839
|
+
s.persistedLen = turns.length;
|
|
840
|
+
if (s.saveBlocked)
|
|
841
|
+
this.patch(id, { saveBlocked: null });
|
|
842
|
+
}
|
|
843
|
+
catch { /* a failed save leaves persistedLen as it was */ }
|
|
561
844
|
}
|
|
562
845
|
/** Filename of the system prompt this surface will run with next — what the
|
|
563
846
|
* context drawer's active-prompt chip shows. Mirrors composeSystem's own
|
|
@@ -684,7 +967,21 @@ export class AgentRuntime {
|
|
|
684
967
|
const s = this.ensure(id);
|
|
685
968
|
if ((!text && images.length === 0) || s.running)
|
|
686
969
|
return;
|
|
687
|
-
|
|
970
|
+
/* The stored chat first (ruling R35). Nothing stops the composer before it
|
|
971
|
+
* lands, and a send built on the empty pre-load list would run with no
|
|
972
|
+
* history, retitle the chat as if it were new, and then be wiped by the
|
|
973
|
+
* load or never saved. Also retries a load that missed. */
|
|
974
|
+
if (!s.loaded) {
|
|
975
|
+
await this.load(id);
|
|
976
|
+
// Another send started while this one waited (ruling R37): treat this
|
|
977
|
+
// one like a send during a run -- nothing happens, the draft stays.
|
|
978
|
+
if (s.running)
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
// Only a LOADED empty chat is new. One whose load missed again is empty
|
|
982
|
+
// because it was never read, and retitling it would write this message's
|
|
983
|
+
// text over the stored chat's title while every other write is held back.
|
|
984
|
+
const isFirst = s.loaded && s.turns.length === 0;
|
|
688
985
|
const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [buildUserMessage(text, images)] };
|
|
689
986
|
const nextTurns = [...s.turns, userTurn];
|
|
690
987
|
s.toolMs = {};
|
|
@@ -704,6 +1001,10 @@ export class AgentRuntime {
|
|
|
704
1001
|
turns: nextTurns, error: '', live: { text: '', tools: [] }, running: true, input: '', attachments: [], suggestions: [],
|
|
705
1002
|
policyNotes: [], degradedModel: null, injectedIds: [], lastStopReason: null,
|
|
706
1003
|
}, true);
|
|
1004
|
+
// Still not loaded after that retry: this exchange runs and shows, but will
|
|
1005
|
+
// not be saved. Flagged once the turn exists, so the notice is up for the
|
|
1006
|
+
// whole run rather than only after it.
|
|
1007
|
+
this.flagIfUnsaved(id);
|
|
707
1008
|
// Feature guard — the runtime is the chokepoint for every /agent run (send
|
|
708
1009
|
// and retry), so a quota/plan guard registered later covers this surface too.
|
|
709
1010
|
const gate = await this.host.guard('llm-execution');
|
|
@@ -725,20 +1026,26 @@ export class AgentRuntime {
|
|
|
725
1026
|
// Title an untitled session from its first message, so the switcher is readable.
|
|
726
1027
|
// A hand-renamed session is locked, so its chosen title is left alone.
|
|
727
1028
|
if (isFirst && text) {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
ses
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1029
|
+
await this.queueWrite(id, async () => {
|
|
1030
|
+
const ses = await this.host.sessions.get(id);
|
|
1031
|
+
if (ses && !ses.titleLocked) {
|
|
1032
|
+
ses.title = text.slice(0, 48);
|
|
1033
|
+
await this.host.sessions.save(ses).catch(() => { });
|
|
1034
|
+
this.notifyGlobal();
|
|
1035
|
+
}
|
|
1036
|
+
}).catch(() => { });
|
|
734
1037
|
}
|
|
735
|
-
const outgoing = nextTurns
|
|
1038
|
+
const outgoing = outgoingMessages(nextTurns);
|
|
736
1039
|
/* Transcript-only turns noteUser() added for messages injected mid-run.
|
|
737
1040
|
* They exist so the pivot is visible WHILE the run is in flight; once the
|
|
738
1041
|
* run returns, its own copy of each injected message arrives in `created`
|
|
739
1042
|
* and folds into the user turn, so keeping these would render the text a
|
|
740
1043
|
* second time below the final reply, where it reads as unanswered. */
|
|
741
1044
|
const injectedTurnIds = [];
|
|
1045
|
+
/* The seams THIS run wrote. The last one carries the run's folded view
|
|
1046
|
+
* once the run returns, so the next send starts from the fold instead of
|
|
1047
|
+
* re-folding the raw transcript. */
|
|
1048
|
+
const seamIds = [];
|
|
742
1049
|
let verification;
|
|
743
1050
|
let auditIncomplete;
|
|
744
1051
|
const ac = new AbortController();
|
|
@@ -779,7 +1086,14 @@ export class AgentRuntime {
|
|
|
779
1086
|
this.composeSystem(id, opts.persona, opts.partsOverride),
|
|
780
1087
|
enabledSkillCatalog((name) => !skillDeny.includes(name)).catch(() => []),
|
|
781
1088
|
]);
|
|
782
|
-
const model = this.
|
|
1089
|
+
const model = this.chatModel(id);
|
|
1090
|
+
/* Decided now, from what is true now: the chat's own model is gone and
|
|
1091
|
+
* the one it falls back to cannot hold it. A model picked since load, or
|
|
1092
|
+
* a catalog that has caught up, changes the answer. */
|
|
1093
|
+
const forceFold = !!this.lostModel(s) && !this.canHold(id, model);
|
|
1094
|
+
// The chat now runs on `model`; storing it is what clears the notice.
|
|
1095
|
+
if (this.host.perChatModel && s.model !== model)
|
|
1096
|
+
this.patch(id, { model });
|
|
783
1097
|
// Live model for onBreach pricing — reassigned when a degrade breach
|
|
784
1098
|
// switches the run onto a cheaper model (see onBreach below).
|
|
785
1099
|
let activeModel = model;
|
|
@@ -846,6 +1160,7 @@ export class AgentRuntime {
|
|
|
846
1160
|
: (getPolicy(opts.policyName) ?? STANDARD_POLICY),
|
|
847
1161
|
...(this.host.sessionsRoot ? { sessionsRoot: this.host.sessionsRoot() } : {}),
|
|
848
1162
|
compression: { mode: this.host.compression(), contextWindow },
|
|
1163
|
+
...(forceFold ? { forceFold: true } : {}),
|
|
849
1164
|
signal: ac.signal,
|
|
850
1165
|
sessionId: id,
|
|
851
1166
|
toolNames,
|
|
@@ -997,7 +1312,7 @@ export class AgentRuntime {
|
|
|
997
1312
|
return;
|
|
998
1313
|
}
|
|
999
1314
|
if (e.type === 'checkpoint') {
|
|
1000
|
-
this.noteCheckpoint(id, e.collapsed, e.checkpoint);
|
|
1315
|
+
seamIds.push(this.noteCheckpoint(id, e.collapsed, e.checkpoint));
|
|
1001
1316
|
return;
|
|
1002
1317
|
}
|
|
1003
1318
|
if (e.type === 'tools-loaded') {
|
|
@@ -1085,11 +1400,13 @@ export class AgentRuntime {
|
|
|
1085
1400
|
...(res.calls ? { calls: res.calls } : {}),
|
|
1086
1401
|
}
|
|
1087
1402
|
: undefined;
|
|
1403
|
+
const lastSeam = seamIds[seamIds.length - 1];
|
|
1404
|
+
const stitched = s.turns
|
|
1405
|
+
.filter((t) => !injectedTurnIds.includes(t.id))
|
|
1406
|
+
.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t));
|
|
1088
1407
|
this.patch(id, {
|
|
1089
1408
|
lastStopReason: res.stopReason,
|
|
1090
|
-
turns:
|
|
1091
|
-
.filter((t) => !injectedTurnIds.includes(t.id))
|
|
1092
|
-
.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t)),
|
|
1409
|
+
turns: lastSeam ? stampFoldedView(stitched, lastSeam, res.messages.filter((m) => m.role !== 'system'), userTurn.id) : stitched,
|
|
1093
1410
|
});
|
|
1094
1411
|
void this.persist(id);
|
|
1095
1412
|
if (stats) {
|
|
@@ -1158,12 +1475,21 @@ export class AgentRuntime {
|
|
|
1158
1475
|
if (!userText || s.running)
|
|
1159
1476
|
return;
|
|
1160
1477
|
this.ws();
|
|
1478
|
+
// As in send: never build on a transcript that has not loaded, and never
|
|
1479
|
+
// start a second turn that began while the load was pending.
|
|
1480
|
+
if (!s.loaded) {
|
|
1481
|
+
await this.load(id);
|
|
1482
|
+
if (s.running)
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1161
1485
|
const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [{ role: 'user', content: userText }] };
|
|
1162
1486
|
const ac = new AbortController();
|
|
1163
1487
|
s.abort = ac;
|
|
1164
1488
|
// Optimistic, for the same reason as `send` above: the guard is a network
|
|
1165
1489
|
// call, and a host-driven turn that waits on it looks like nothing happened.
|
|
1166
1490
|
this.patch(id, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true, suggestions: [] }, true);
|
|
1491
|
+
// As in send: a load that missed again leaves this turn unsaved; say so now.
|
|
1492
|
+
this.flagIfUnsaved(id);
|
|
1167
1493
|
const gate = await this.host.guard('llm-execution');
|
|
1168
1494
|
if (!gate.allowed) {
|
|
1169
1495
|
s.abort = null;
|
|
@@ -1192,13 +1518,16 @@ export class AgentRuntime {
|
|
|
1192
1518
|
* with the full content — the context drawer's per-item Restore, transplanted
|
|
1193
1519
|
* from the harness/conversation.ts singleton (same marker-matching and
|
|
1194
1520
|
* identity-swap semantics; see that module for the rationale). Returns false
|
|
1195
|
-
* (state untouched, no persist) when the turn or the matching stub is absent
|
|
1521
|
+
* (state untouched, no persist) when the turn or the matching stub is absent,
|
|
1522
|
+
* or when the turn is FOLDED (see isFoldedTurn): the folded view stands for
|
|
1523
|
+
* it, so content restored into it would never be sent. */
|
|
1196
1524
|
restoreToolResult(id, turnId, blobId, content) {
|
|
1197
1525
|
const marker = `recall_result({ id: "${blobId}" })`;
|
|
1198
1526
|
const s = this.ensure(id);
|
|
1199
|
-
const
|
|
1200
|
-
if (
|
|
1527
|
+
const at = s.turns.findIndex((t) => t.id === turnId);
|
|
1528
|
+
if (at < 0 || isFoldedTurn(s.turns, at))
|
|
1201
1529
|
return false;
|
|
1530
|
+
const turn = s.turns[at];
|
|
1202
1531
|
const idx = turn.messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
|
|
1203
1532
|
if (idx < 0)
|
|
1204
1533
|
return false;
|