@corenel/tools-web 0.8.0 → 0.10.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 +106 -10
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +459 -36
- package/dist/runtime.js.map +1 -1
- package/dist/runtime.testHost.d.ts.map +1 -1
- package/dist/runtime.testHost.js +4 -2
- package/dist/runtime.testHost.js.map +1 -1
- package/dist/speech/route.d.ts +25 -0
- package/dist/speech/route.d.ts.map +1 -0
- package/dist/speech/route.js +125 -0
- package/dist/speech/route.js.map +1 -0
- package/dist/speech/sidecarEngine.d.ts +11 -0
- package/dist/speech/sidecarEngine.d.ts.map +1 -0
- package/dist/speech/sidecarEngine.js +81 -0
- package/dist/speech/sidecarEngine.js.map +1 -0
- package/dist/subagentBus.d.ts +11 -0
- package/dist/subagentBus.d.ts.map +1 -0
- package/dist/subagentBus.js +31 -0
- package/dist/subagentBus.js.map +1 -0
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createWorkerAgent } from './workerClient';
|
|
2
2
|
import { nodeUsageOf } from './nodeUsage';
|
|
3
|
+
import { publishSubagentEvent, publishSubagentParentEnded } from './subagentBus';
|
|
3
4
|
import { foldLive } from './liveFold';
|
|
4
5
|
import { createRunWatchdog } from './runWatchdog';
|
|
5
6
|
import { listDocs, deleteDocsForSession } from './contextDocs';
|
|
@@ -11,15 +12,16 @@ import { sessionSystemPrompt } from './sessionSystemPrompt';
|
|
|
11
12
|
import { sessionSystemConfig, freezeSessionSystemConfig, clearSessionSystemConfig } from './sessionSystemConfig';
|
|
12
13
|
import { stateDirName, sessionsRoot, isCheckpointTool, unpricedCalleeRefusal } from '@corenel/protocol';
|
|
13
14
|
import { listSystemPrompts, resolveSystemPrompt, readSystemPromptSource } from '@corenel/harness/prompts/systemPrompts';
|
|
14
|
-
import { turnsFromSessionMessages } from '@corenel/harness/conversation';
|
|
15
|
+
import { turnsFromSessionMessages, foldPoint, isFoldedTurn } from '@corenel/harness/conversation';
|
|
15
16
|
import { allTools } from '@corenel/harness/tools/builtins';
|
|
16
17
|
import { applyTodoWrite, addActivity, resolveActivity } from '@corenel/harness/todos';
|
|
17
18
|
import { getPolicy, STANDARD_POLICY } from '@corenel/harness/guardrail';
|
|
18
19
|
import { composeAgentSystem, TOOLS_MARKER } from '@corenel/harness/prompts';
|
|
19
20
|
import { getSystemParts } from '@corenel/harness/systemParts';
|
|
20
|
-
import { CHECKPOINT_MARKER } from '@corenel/harness/core/compactHistory';
|
|
21
|
+
import { CHECKPOINT_MARKER, estimateTokens, COMPACT_AT_FILL } from '@corenel/harness/core/compactHistory';
|
|
21
22
|
import { priceUsageWith, pricedForUsd } from '@corenel/harness/core/pricing';
|
|
22
23
|
import { enabledSkillCatalog } from '@corenel/harness/skills';
|
|
24
|
+
import { alog } from '@corenel/harness/log';
|
|
23
25
|
// The agent's model is the user's picker selection (getModel) — a real
|
|
24
26
|
// catalog entry, so pricing/context come from host.modelInfo, not hardcoded figures.
|
|
25
27
|
const WATCHDOG_MS = 45000;
|
|
@@ -34,7 +36,8 @@ function emptyInternal() {
|
|
|
34
36
|
pendingPerm: null, pendingAsk: null, approvalSuggestion: null, loaded: false, suggestions: [], recovery: null, abort: null, toolMs: {}, allowed: new Set(),
|
|
35
37
|
pendingCommand: null, runId: null,
|
|
36
38
|
recEvents: [], injectedIds: [], lastUsage: { promptTokens: 0, completionTokens: 0 },
|
|
37
|
-
offloadTick: 0, policyNotes: [], degradedModel: null,
|
|
39
|
+
offloadTick: 0, policyNotes: [], degradedModel: null, model: null, modelNotice: null, persistedLen: null,
|
|
40
|
+
saveBlocked: null, loadMissed: false, mergeTick: 0,
|
|
38
41
|
};
|
|
39
42
|
}
|
|
40
43
|
/** Build the user message — a plain string, or multimodal parts when images are attached. */
|
|
@@ -62,6 +65,35 @@ function userImages(m) {
|
|
|
62
65
|
return [];
|
|
63
66
|
return m.content.filter((p) => p.type === 'image_url').map((p) => ('image_url' in p ? p.image_url.url : '')).filter(Boolean);
|
|
64
67
|
}
|
|
68
|
+
/** What a send hands the run: the raw included transcript, or -- once a run
|
|
69
|
+
* has folded -- the newest LIVE seam's folded view plus the included turns
|
|
70
|
+
* after the user turn that produced it. Turns at or before that turn are
|
|
71
|
+
* already in the view; sending them raw again re-folded the whole history
|
|
72
|
+
* every send. A view whose user turn is gone is skipped for the one before it,
|
|
73
|
+
* and with no live view at all the transcript goes raw (the next run re-folds). */
|
|
74
|
+
function outgoingMessages(turns) {
|
|
75
|
+
const fp = foldPoint(turns);
|
|
76
|
+
if (fp)
|
|
77
|
+
return [...fp.view, ...turns.slice(fp.throughIndex + 1).filter((t) => t.included).flatMap((t) => t.messages)];
|
|
78
|
+
return turns.filter((t) => t.included).flatMap((t) => t.messages);
|
|
79
|
+
}
|
|
80
|
+
/** Stamp a run's folded view on its newest seam, and keep exactly ONE older
|
|
81
|
+
* view: each is a full copy of the window and persist rewrites every turn on
|
|
82
|
+
* every send, so views left on every seam grow without bound in a chat that
|
|
83
|
+
* never ends. The one kept is what `retry` of the folding turn falls back to
|
|
84
|
+
* (retry removes the newest seam with its user turn). */
|
|
85
|
+
function stampFoldedView(turns, seamId, view, through) {
|
|
86
|
+
const older = turns.filter((t) => t.foldedView && t.id !== seamId).map((t) => t.id);
|
|
87
|
+
const keep = older[older.length - 1];
|
|
88
|
+
return turns.map((t) => {
|
|
89
|
+
if (t.id === seamId)
|
|
90
|
+
return { ...t, foldedView: view, foldedThrough: through };
|
|
91
|
+
if (!t.foldedView || t.id === keep)
|
|
92
|
+
return t;
|
|
93
|
+
const { foldedView: _view, foldedThrough: _through, ...rest } = t;
|
|
94
|
+
return rest;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
65
97
|
/** Merge a form's freshly-emitted envelope into a per-run override, against the
|
|
66
98
|
* currently saved default. The form only emits the keys it still wants set — a
|
|
67
99
|
* cleared field (e.g. "No limit") is simply ABSENT from `emitted`, not `undefined`.
|
|
@@ -127,6 +159,14 @@ export class AgentRuntime {
|
|
|
127
159
|
writable: true,
|
|
128
160
|
value: getTemperature
|
|
129
161
|
});
|
|
162
|
+
/** Stops the catalog listener the constructor installed. Only dispose()
|
|
163
|
+
* calls it: reset() keeps a long-lived runtime listening. */
|
|
164
|
+
Object.defineProperty(this, "offModels", {
|
|
165
|
+
enumerable: true,
|
|
166
|
+
configurable: true,
|
|
167
|
+
writable: true,
|
|
168
|
+
value: null
|
|
169
|
+
});
|
|
130
170
|
Object.defineProperty(this, "map", {
|
|
131
171
|
enumerable: true,
|
|
132
172
|
configurable: true,
|
|
@@ -157,9 +197,9 @@ export class AgentRuntime {
|
|
|
157
197
|
writable: true,
|
|
158
198
|
value: null
|
|
159
199
|
});
|
|
160
|
-
/** Reuse the most-recent empty session if there is one, else
|
|
161
|
-
*
|
|
162
|
-
*
|
|
200
|
+
/** Reuse the most-recent empty saved session if there is one, else open a
|
|
201
|
+
* DRAFT (newDraft): opening the app, or landing after a delete, saves
|
|
202
|
+
* nothing until the user first sends. Concurrent calls are deduped (React
|
|
163
203
|
* StrictMode double-invokes the mount effect) so the page never opens two. */
|
|
164
204
|
Object.defineProperty(this, "initInFlight", {
|
|
165
205
|
enumerable: true,
|
|
@@ -167,6 +207,52 @@ export class AgentRuntime {
|
|
|
167
207
|
writable: true,
|
|
168
208
|
value: null
|
|
169
209
|
});
|
|
210
|
+
/* DRAFT CHATS. A draft is a chat id held only here: loaded, empty, in no
|
|
211
|
+
* store and so in no chat list. Its first write (the first send, or a note,
|
|
212
|
+
* a kept console command, /memory) creates it in the store under the SAME
|
|
213
|
+
* id (materialize), so nothing that already holds the id has to move. A
|
|
214
|
+
* model picked on a draft stays in memory and is saved with it. Its system
|
|
215
|
+
* config is frozen then too, so abandoned drafts leave nothing behind. */
|
|
216
|
+
Object.defineProperty(this, "drafts", {
|
|
217
|
+
enumerable: true,
|
|
218
|
+
configurable: true,
|
|
219
|
+
writable: true,
|
|
220
|
+
value: new Set()
|
|
221
|
+
});
|
|
222
|
+
Object.defineProperty(this, "materializing", {
|
|
223
|
+
enumerable: true,
|
|
224
|
+
configurable: true,
|
|
225
|
+
writable: true,
|
|
226
|
+
value: new Map()
|
|
227
|
+
});
|
|
228
|
+
/* The chat's estimated size in tokens, keyed by the turns array: turns are
|
|
229
|
+
* replaced, never mutated, so the same array means the same size. The picker
|
|
230
|
+
* asks canHold for every row on every render; this keeps that one flatten
|
|
231
|
+
* per transcript, not one per row. */
|
|
232
|
+
Object.defineProperty(this, "fill", {
|
|
233
|
+
enumerable: true,
|
|
234
|
+
configurable: true,
|
|
235
|
+
writable: true,
|
|
236
|
+
value: new WeakMap()
|
|
237
|
+
});
|
|
238
|
+
/* ---- run ---- */
|
|
239
|
+
/* One store write at a time per session: every read-modify-write of the
|
|
240
|
+
* stored chat (persist, the first-send retitle) goes through here. Each
|
|
241
|
+
* re-reads the session first; two overlapping would let one read the other's
|
|
242
|
+
* write before persistedLen caught up and take it for another writer, or
|
|
243
|
+
* write back a list the other had just shortened (a retitle restoring a
|
|
244
|
+
* cleared chat, R37). */
|
|
245
|
+
Object.defineProperty(this, "writing", {
|
|
246
|
+
enumerable: true,
|
|
247
|
+
configurable: true,
|
|
248
|
+
writable: true,
|
|
249
|
+
value: new Map()
|
|
250
|
+
});
|
|
251
|
+
// Availability and the fallback are read live, so every open chat's
|
|
252
|
+
// notice must re-render when either moves.
|
|
253
|
+
if (host.perChatModel)
|
|
254
|
+
this.offModels = host.onModelsChange?.(() => { for (const id of this.subs.keys())
|
|
255
|
+
this.notify(id); }) ?? null;
|
|
170
256
|
}
|
|
171
257
|
ws() {
|
|
172
258
|
if (!this._ws)
|
|
@@ -244,7 +330,13 @@ export class AgentRuntime {
|
|
|
244
330
|
return () => { set.delete(cb); };
|
|
245
331
|
}
|
|
246
332
|
subscribeGlobal(cb) { this.globalSubs.add(cb); return () => { this.globalSubs.delete(cb); }; }
|
|
247
|
-
getState(id) {
|
|
333
|
+
getState(id) {
|
|
334
|
+
const s = this.map.get(id);
|
|
335
|
+
if (!s)
|
|
336
|
+
return emptyInternal();
|
|
337
|
+
s.modelNotice = this.modelNotice(id, s);
|
|
338
|
+
return s;
|
|
339
|
+
}
|
|
248
340
|
status(id) {
|
|
249
341
|
const s = this.map.get(id);
|
|
250
342
|
if (!s)
|
|
@@ -271,8 +363,31 @@ export class AgentRuntime {
|
|
|
271
363
|
const s = this.ensure(id);
|
|
272
364
|
if (s.loaded)
|
|
273
365
|
return;
|
|
274
|
-
|
|
275
|
-
|
|
366
|
+
/* A read that THROWS is a miss, not an error for the caller: send awaits
|
|
367
|
+
* this, and a rejected load would drop the send outright while the next
|
|
368
|
+
* attempt fails the same way. As a miss it goes down the path that is
|
|
369
|
+
* covered: the exchange runs, saveBlocked says it is not kept, and Retry
|
|
370
|
+
* (retryLoad) reads again. */
|
|
371
|
+
let ses;
|
|
372
|
+
try {
|
|
373
|
+
ses = await this.host.sessions.get(id);
|
|
374
|
+
}
|
|
375
|
+
catch {
|
|
376
|
+
ses = null;
|
|
377
|
+
}
|
|
378
|
+
// A concurrent load (the focus effect beside a send) landed first, and its
|
|
379
|
+
// merge already holds everything; merging again would double the chat.
|
|
380
|
+
if (s.loaded)
|
|
381
|
+
return;
|
|
382
|
+
/* MERGE, never replace: anything this page added before the stored chat
|
|
383
|
+
* arrived -- a /model notice, say -- comes after it rather than vanishing.
|
|
384
|
+
* A miss leaves those turns alone too. */
|
|
385
|
+
const pre = s.turns;
|
|
386
|
+
if (ses) {
|
|
387
|
+
s.turns = [...turnsFromSessionMessages(ses.messages), ...pre];
|
|
388
|
+
s.persistedLen = ses.messages.length;
|
|
389
|
+
}
|
|
390
|
+
s.model = ses?.model ?? null;
|
|
276
391
|
/* ONLY ON A HIT. A miss used to be cached exactly like a hit, and that made
|
|
277
392
|
* it permanent: the session could arrive a moment later and this would
|
|
278
393
|
* never look again, because the guard above returns on `loaded`.
|
|
@@ -290,6 +405,11 @@ export class AgentRuntime {
|
|
|
290
405
|
* -- not per render -- so a genuinely absent session does not spin. */
|
|
291
406
|
if (ses)
|
|
292
407
|
s.loaded = true;
|
|
408
|
+
s.loadMissed = !ses;
|
|
409
|
+
// Loaded with nothing of this page's to save: nothing is unsaved any more.
|
|
410
|
+
// With something, the persist below clears the flag once it lands.
|
|
411
|
+
if (ses && !pre.length)
|
|
412
|
+
s.saveBlocked = null;
|
|
293
413
|
/* MIGRATION. A conversation from before the snapshot existed has none, and
|
|
294
414
|
* would otherwise keep following the live Settings default forever. Opening
|
|
295
415
|
* it freezes it at what it would have used at this moment, so nothing
|
|
@@ -298,6 +418,37 @@ export class AgentRuntime {
|
|
|
298
418
|
if (!sessionSystemConfig(id))
|
|
299
419
|
this.freezeSystemConfig(id);
|
|
300
420
|
this.notify(id);
|
|
421
|
+
// What was added before the load could not be saved then (see persist).
|
|
422
|
+
if (ses && pre.length)
|
|
423
|
+
void this.persist(id);
|
|
424
|
+
}
|
|
425
|
+
/** Try the stored chat again after a load failed (the unsaved-chat notice's
|
|
426
|
+
* Retry): load it -- merging what this page added meanwhile after it, as
|
|
427
|
+
* `load` always does -- then save the merged list. True once it is saved;
|
|
428
|
+
* false when the load missed again (or threw), and `saveBlocked` stays. */
|
|
429
|
+
async retryLoad(id) {
|
|
430
|
+
await this.load(id);
|
|
431
|
+
const s = this.map.get(id);
|
|
432
|
+
if (!s?.loaded)
|
|
433
|
+
return false;
|
|
434
|
+
// persistNow swallows its own failures; this catch keeps the promise a
|
|
435
|
+
// Retry button awaits from ever rejecting.
|
|
436
|
+
await this.persist(id).catch(() => { });
|
|
437
|
+
/* GLOBAL, because the session list is. While the load kept missing, the
|
|
438
|
+
* list the page shows (and the header's title, read from it) could drop
|
|
439
|
+
* this chat; it is back now, and only a global change makes the page
|
|
440
|
+
* re-read the list. Without it the recovered chat stayed missing from the
|
|
441
|
+
* list, titled "New chat", until a reload. */
|
|
442
|
+
this.notifyGlobal();
|
|
443
|
+
return s.saveBlocked === null;
|
|
444
|
+
}
|
|
445
|
+
/** Raise the unsaved-chat flag when `id` is still not loaded after a load
|
|
446
|
+
* was tried, so the page says its new messages are not being kept. */
|
|
447
|
+
flagIfUnsaved(id) {
|
|
448
|
+
const s = this.map.get(id);
|
|
449
|
+
if (s && !s.loaded && s.loadMissed && s.turns.length && s.saveBlocked !== 'load-failed') {
|
|
450
|
+
this.patch(id, { saveBlocked: 'load-failed' });
|
|
451
|
+
}
|
|
301
452
|
}
|
|
302
453
|
async openInitial() {
|
|
303
454
|
if (this.initInFlight)
|
|
@@ -310,10 +461,14 @@ export class AgentRuntime {
|
|
|
310
461
|
// the traces, to be pruned with them.
|
|
311
462
|
const empty = list.find((s) => !s.parent && (!s.messages || s.messages.length === 0));
|
|
312
463
|
if (empty) {
|
|
313
|
-
this.ensure(empty.id)
|
|
464
|
+
const s = this.ensure(empty.id);
|
|
465
|
+
s.loaded = true;
|
|
466
|
+
s.persistedLen = 0;
|
|
467
|
+
// A model picked on this empty chat before the reload is still its model.
|
|
468
|
+
s.model = empty.model ?? null;
|
|
314
469
|
return empty.id;
|
|
315
470
|
}
|
|
316
|
-
return this.
|
|
471
|
+
return this.newDraft();
|
|
317
472
|
})();
|
|
318
473
|
try {
|
|
319
474
|
return await this.initInFlight;
|
|
@@ -322,6 +477,37 @@ export class AgentRuntime {
|
|
|
322
477
|
this.initInFlight = null;
|
|
323
478
|
}
|
|
324
479
|
}
|
|
480
|
+
/** A new chat that is saved only when it is first used. */
|
|
481
|
+
async newDraft() {
|
|
482
|
+
this.ws();
|
|
483
|
+
const id = `ses_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
484
|
+
const s = this.ensure(id);
|
|
485
|
+
s.loaded = true;
|
|
486
|
+
s.persistedLen = 0;
|
|
487
|
+
this.drafts.add(id);
|
|
488
|
+
return id;
|
|
489
|
+
}
|
|
490
|
+
/** Whether `id` is a draft that has not been saved yet. */
|
|
491
|
+
isDraft(id) { return this.drafts.has(id); }
|
|
492
|
+
/** Save a draft under its own id; a no-op for any other chat. Concurrent
|
|
493
|
+
* callers share one create. A failure leaves it a draft, so the next write
|
|
494
|
+
* tries again. */
|
|
495
|
+
materialize(id) {
|
|
496
|
+
if (!this.drafts.has(id))
|
|
497
|
+
return Promise.resolve();
|
|
498
|
+
const inFlight = this.materializing.get(id);
|
|
499
|
+
if (inFlight)
|
|
500
|
+
return inFlight;
|
|
501
|
+
const run = (async () => {
|
|
502
|
+
await this.host.sessions.create('New chat', this.surface, { id });
|
|
503
|
+
this.drafts.delete(id);
|
|
504
|
+
this.freezeSystemConfig(id);
|
|
505
|
+
this.notifyGlobal();
|
|
506
|
+
})();
|
|
507
|
+
this.materializing.set(id, run);
|
|
508
|
+
run.finally(() => this.materializing.delete(id)).catch(() => { });
|
|
509
|
+
return run;
|
|
510
|
+
}
|
|
325
511
|
async newSession() {
|
|
326
512
|
this.ws();
|
|
327
513
|
/* A new chat starts with no tasks because the task store is KEYED BY
|
|
@@ -334,6 +520,7 @@ export class AgentRuntime {
|
|
|
334
520
|
const ses = await this.host.sessions.create('New chat', this.surface);
|
|
335
521
|
const s = this.ensure(ses.id);
|
|
336
522
|
s.loaded = true;
|
|
523
|
+
s.persistedLen = 0;
|
|
337
524
|
// The system prompt is the head of the cached prefix, so this conversation's
|
|
338
525
|
// config is decided HERE and read from the snapshot after -- see
|
|
339
526
|
// sessionSystemConfig. Settings decides what the next one starts with.
|
|
@@ -346,12 +533,86 @@ export class AgentRuntime {
|
|
|
346
533
|
this.map.get(id)?.abort?.abort();
|
|
347
534
|
this.map.delete(id);
|
|
348
535
|
this.subs.delete(id);
|
|
536
|
+
this.drafts.delete(id);
|
|
349
537
|
// The frozen system config goes with the conversation it described.
|
|
350
538
|
clearSessionSystemConfig(id);
|
|
351
539
|
await this.host.sessions.delete(id).catch(() => { });
|
|
352
540
|
deleteDocsForSession(id);
|
|
353
541
|
this.notifyGlobal();
|
|
354
542
|
}
|
|
543
|
+
/* ---- model per chat ---- */
|
|
544
|
+
available(model) {
|
|
545
|
+
return this.host.isModelAvailable ? this.host.isModelAvailable(model) : true;
|
|
546
|
+
}
|
|
547
|
+
/** Is the chat's STORED model still usable? Trusted until the catalog has
|
|
548
|
+
* been consulted: before that, "not in the catalog" means "not looked yet",
|
|
549
|
+
* and acting on it would announce a fallback and fold for nothing. */
|
|
550
|
+
storedAvailable(model) {
|
|
551
|
+
if (this.host.modelsSettled && !this.host.modelsSettled())
|
|
552
|
+
return true;
|
|
553
|
+
return this.available(model);
|
|
554
|
+
}
|
|
555
|
+
/** The stored model when it is gone and the chat would run on a fallback. */
|
|
556
|
+
lostModel(s) {
|
|
557
|
+
if (!this.host.perChatModel || !s.model)
|
|
558
|
+
return null;
|
|
559
|
+
return this.storedAvailable(s.model) ? null : s.model;
|
|
560
|
+
}
|
|
561
|
+
modelNotice(id, s) {
|
|
562
|
+
const lost = this.lostModel(s);
|
|
563
|
+
if (!lost)
|
|
564
|
+
return null;
|
|
565
|
+
const fallback = this.getModel();
|
|
566
|
+
return `This chat used ${lost}, which is not available. It will run on ${fallback}`
|
|
567
|
+
+ (this.canHold(id, fallback) ? '.' : ', and older history will be folded to fit.');
|
|
568
|
+
}
|
|
569
|
+
/** The model this chat runs on: its own if still available, else the
|
|
570
|
+
* last-used pick. Without `perChatModel`, always the last-used pick. */
|
|
571
|
+
chatModel(id) {
|
|
572
|
+
const s = this.map.get(id);
|
|
573
|
+
if (!this.host.perChatModel || !s?.model || this.lostModel(s))
|
|
574
|
+
return this.getModel();
|
|
575
|
+
return s.model;
|
|
576
|
+
}
|
|
577
|
+
fillTokens(turns) {
|
|
578
|
+
let n = this.fill.get(turns);
|
|
579
|
+
if (n === undefined) {
|
|
580
|
+
n = estimateTokens(outgoingMessages(turns));
|
|
581
|
+
this.fill.set(turns, n);
|
|
582
|
+
}
|
|
583
|
+
return n;
|
|
584
|
+
}
|
|
585
|
+
/** Can `model`'s window hold this chat as it stands (below the fold line)?
|
|
586
|
+
* Measured over what a send would actually hand the run -- the folded view
|
|
587
|
+
* plus the turns after it -- not the raw transcript, which overstates a chat
|
|
588
|
+
* that has already folded. */
|
|
589
|
+
canHold(id, model) {
|
|
590
|
+
const s = this.map.get(id);
|
|
591
|
+
if (!s)
|
|
592
|
+
return true;
|
|
593
|
+
const window = this.host.modelInfo(model).contextWindow ?? 128000;
|
|
594
|
+
if (window <= 0)
|
|
595
|
+
return true;
|
|
596
|
+
return this.fillTokens(s.turns) / window < COMPACT_AT_FILL;
|
|
597
|
+
}
|
|
598
|
+
/** Switch this chat (only) to `model`; null when it switched. Refused, with
|
|
599
|
+
* nothing changed: before the chat has loaded (there is nothing to measure,
|
|
600
|
+
* and persisting would write an empty transcript over the stored one); when
|
|
601
|
+
* the model is not available (it would silently run on the fallback); and
|
|
602
|
+
* when it cannot hold the chat (switching never folds, spec 3.5). */
|
|
603
|
+
setChatModel(id, model) {
|
|
604
|
+
if (!this.map.get(id)?.loaded)
|
|
605
|
+
return 'not-loaded';
|
|
606
|
+
if (!this.available(model))
|
|
607
|
+
return 'unavailable';
|
|
608
|
+
if (!this.canHold(id, model))
|
|
609
|
+
return 'too-large';
|
|
610
|
+
this.patch(id, { model });
|
|
611
|
+
// A draft keeps the pick in memory; picking a model is not using the chat.
|
|
612
|
+
if (!this.drafts.has(id))
|
|
613
|
+
void this.persist(id);
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
355
616
|
/* ---- view mutators ---- */
|
|
356
617
|
setInput(id, v) { this.patch(id, { input: v }); }
|
|
357
618
|
setMode(id, m) { this.patch(id, { mode: m }); }
|
|
@@ -367,12 +628,15 @@ export class AgentRuntime {
|
|
|
367
628
|
void this.persist(id);
|
|
368
629
|
}
|
|
369
630
|
/** Append a local assistant-style notice turn (e.g. a /model confirmation) — not
|
|
370
|
-
* sent to the model, just shown in the transcript. */
|
|
631
|
+
* sent to the model, just shown in the transcript. Returns the turn's id. */
|
|
371
632
|
note(id, text, extra) {
|
|
372
633
|
const s = this.ensure(id);
|
|
373
|
-
|
|
634
|
+
// The random suffix: two notes in one millisecond (a fold beside a budget
|
|
635
|
+
// warning) must not share an id, or a later update by id lands on both.
|
|
636
|
+
const turn = { id: `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, included: false, messages: [{ role: 'assistant', content: text }], ...extra };
|
|
374
637
|
this.patch(id, { turns: [...s.turns, turn] });
|
|
375
638
|
void this.persist(id);
|
|
639
|
+
return turn.id;
|
|
376
640
|
}
|
|
377
641
|
/* The compaction seam. PERMANENT in the transcript, per the `checkpoint`
|
|
378
642
|
* event's contract: compaction is lossy, and a person who cannot see that it
|
|
@@ -392,7 +656,7 @@ export class AgentRuntime {
|
|
|
392
656
|
* The body rides along so the seam can be READ. It was dropped here before
|
|
393
657
|
* -- the event carries it, and this took only the count -- which left the
|
|
394
658
|
* 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);
|
|
659
|
+
return this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
|
|
396
660
|
}
|
|
397
661
|
/** Show a message in the transcript as YOUR turn, without adding it to what
|
|
398
662
|
* gets sent. Used for injected messages: the real copy is already in the
|
|
@@ -408,8 +672,13 @@ export class AgentRuntime {
|
|
|
408
672
|
setAttachments(id, fn) {
|
|
409
673
|
this.patch(id, { attachments: fn(this.ensure(id).attachments) });
|
|
410
674
|
}
|
|
675
|
+
/** Flip whether turn `turnId` is sent. A FOLDED turn (see isFoldedTurn) is
|
|
676
|
+
* refused -- no state change, no persist: the folded view stands for it, so
|
|
677
|
+
* including or dropping it would change nothing the next request carries. */
|
|
411
678
|
toggleInclude(id, turnId) {
|
|
412
679
|
const s = this.ensure(id);
|
|
680
|
+
if (isFoldedTurn(s.turns, s.turns.findIndex((t) => t.id === turnId)))
|
|
681
|
+
return;
|
|
413
682
|
this.patch(id, { turns: s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t)) });
|
|
414
683
|
void this.persist(id);
|
|
415
684
|
}
|
|
@@ -548,16 +817,103 @@ export class AgentRuntime {
|
|
|
548
817
|
this.getWorker().inject(s.runId, messages, hard);
|
|
549
818
|
return true;
|
|
550
819
|
}
|
|
551
|
-
|
|
552
|
-
|
|
820
|
+
queueWrite(id, write) {
|
|
821
|
+
// Nothing queued: start now, at the same tick as before this queue existed.
|
|
822
|
+
// A failed write must not stop the ones queued behind it.
|
|
823
|
+
const prev = this.writing.get(id);
|
|
824
|
+
const next = prev ? prev.catch(() => { }).then(write) : write();
|
|
825
|
+
this.writing.set(id, next);
|
|
826
|
+
next.finally(() => { if (this.writing.get(id) === next)
|
|
827
|
+
this.writing.delete(id); }).catch(() => { });
|
|
828
|
+
return next;
|
|
829
|
+
}
|
|
830
|
+
persist(id) {
|
|
831
|
+
return this.queueWrite(id, async () => {
|
|
832
|
+
// The first write to a draft saves it first; a failed save skips this
|
|
833
|
+
// write, and the draft stays a draft for the next one.
|
|
834
|
+
if (this.drafts.has(id)) {
|
|
835
|
+
try {
|
|
836
|
+
await this.materialize(id);
|
|
837
|
+
}
|
|
838
|
+
catch {
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
await this.persistNow(id);
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
async persistNow(id) {
|
|
553
846
|
const s = this.map.get(id);
|
|
554
|
-
|
|
847
|
+
/* Never before the stored transcript has loaded (ruling R34). Until then
|
|
848
|
+
* `turns` holds only what this page added -- a /model notice, say -- and
|
|
849
|
+
* saving it would write that over the whole stored chat. Such notes stay
|
|
850
|
+
* in memory -- and once a load has actually failed, say so. */
|
|
851
|
+
if (!s?.loaded) {
|
|
852
|
+
this.flagIfUnsaved(id);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
// A read that throws is a save that did not happen: persistedLen and
|
|
856
|
+
// saveBlocked stay as they are, and the next write tries again.
|
|
857
|
+
let ses;
|
|
858
|
+
try {
|
|
859
|
+
ses = await this.host.sessions.get(id);
|
|
860
|
+
}
|
|
861
|
+
catch {
|
|
555
862
|
return;
|
|
556
|
-
|
|
863
|
+
}
|
|
557
864
|
if (!ses)
|
|
558
865
|
return;
|
|
559
|
-
|
|
560
|
-
|
|
866
|
+
/* Shorten the stored chat (retry, /clear) only when it is the length this
|
|
867
|
+
* page last saw (ruling R36). Otherwise another writer appended since, and
|
|
868
|
+
* this page's shorter list would delete that writer's newer turns. */
|
|
869
|
+
const known = ses.messages.length === s.persistedLen;
|
|
870
|
+
let turns = s.turns;
|
|
871
|
+
if (!known) {
|
|
872
|
+
/* Another writer changed it since. Saving this page's list as it stands
|
|
873
|
+
* would write it BY POSITION -- a stale [q0] that sent q3 put q3 at index
|
|
874
|
+
* 1, over the other tab's q1 -- so merge by turn id instead, over what
|
|
875
|
+
* was just read: stored order first, this page's version of any turn
|
|
876
|
+
* both hold (last writer wins per turn), then this page's turns the
|
|
877
|
+
* store does not have, in this page's order. A flat (daemon) chat's
|
|
878
|
+
* turns get the same `stored-<i>` ids here as at load, so a turn this
|
|
879
|
+
* page loaded from it matches and is not added twice.
|
|
880
|
+
*
|
|
881
|
+
* The page then shows the merged chat. Nothing it holds is dropped, and
|
|
882
|
+
* the store loses nothing: the merge holds every stored turn, so the
|
|
883
|
+
* save may rewrite freely (allowTruncate below), which a flat store's
|
|
884
|
+
* conversion to turns needs. */
|
|
885
|
+
const stored = turnsFromSessionMessages(ses.messages);
|
|
886
|
+
const mine = new Map(s.turns.map((t) => [t.id, t]));
|
|
887
|
+
const storedIds = new Set(stored.map((t) => t.id));
|
|
888
|
+
/* Only the FIRST stored turn with an id takes this page's version. A
|
|
889
|
+
* second one with the same id is another turn that collided (ids are
|
|
890
|
+
* meant to be unique; turnsFromSessionMessages keeps its synthetic ones
|
|
891
|
+
* so), and replacing it too would write one turn twice and lose the
|
|
892
|
+
* other on this rewrite. */
|
|
893
|
+
const used = new Set();
|
|
894
|
+
const merged = stored.map((t) => {
|
|
895
|
+
const own = mine.get(t.id);
|
|
896
|
+
if (!own || used.has(t.id))
|
|
897
|
+
return t;
|
|
898
|
+
used.add(t.id);
|
|
899
|
+
return own;
|
|
900
|
+
});
|
|
901
|
+
turns = [...merged, ...s.turns.filter((t) => !storedIds.has(t.id))];
|
|
902
|
+
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)`);
|
|
903
|
+
this.patch(id, { turns, mergeTick: s.mergeTick + 1 });
|
|
904
|
+
}
|
|
905
|
+
ses.messages = turns;
|
|
906
|
+
if (s.model)
|
|
907
|
+
ses.model = s.model;
|
|
908
|
+
try {
|
|
909
|
+
// Known: this page's list IS the chat, and may shorten it (R36). A
|
|
910
|
+
// merge: holds every stored turn, so rewriting cannot lose one.
|
|
911
|
+
await this.host.sessions.save(ses, { allowTruncate: true });
|
|
912
|
+
s.persistedLen = turns.length;
|
|
913
|
+
if (s.saveBlocked)
|
|
914
|
+
this.patch(id, { saveBlocked: null });
|
|
915
|
+
}
|
|
916
|
+
catch { /* a failed save leaves persistedLen as it was */ }
|
|
561
917
|
}
|
|
562
918
|
/** Filename of the system prompt this surface will run with next — what the
|
|
563
919
|
* context drawer's active-prompt chip shows. Mirrors composeSystem's own
|
|
@@ -684,7 +1040,21 @@ export class AgentRuntime {
|
|
|
684
1040
|
const s = this.ensure(id);
|
|
685
1041
|
if ((!text && images.length === 0) || s.running)
|
|
686
1042
|
return;
|
|
687
|
-
|
|
1043
|
+
/* The stored chat first (ruling R35). Nothing stops the composer before it
|
|
1044
|
+
* lands, and a send built on the empty pre-load list would run with no
|
|
1045
|
+
* history, retitle the chat as if it were new, and then be wiped by the
|
|
1046
|
+
* load or never saved. Also retries a load that missed. */
|
|
1047
|
+
if (!s.loaded) {
|
|
1048
|
+
await this.load(id);
|
|
1049
|
+
// Another send started while this one waited (ruling R37): treat this
|
|
1050
|
+
// one like a send during a run -- nothing happens, the draft stays.
|
|
1051
|
+
if (s.running)
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
// Only a LOADED empty chat is new. One whose load missed again is empty
|
|
1055
|
+
// because it was never read, and retitling it would write this message's
|
|
1056
|
+
// text over the stored chat's title while every other write is held back.
|
|
1057
|
+
const isFirst = s.loaded && s.turns.length === 0;
|
|
688
1058
|
const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [buildUserMessage(text, images)] };
|
|
689
1059
|
const nextTurns = [...s.turns, userTurn];
|
|
690
1060
|
s.toolMs = {};
|
|
@@ -704,6 +1074,10 @@ export class AgentRuntime {
|
|
|
704
1074
|
turns: nextTurns, error: '', live: { text: '', tools: [] }, running: true, input: '', attachments: [], suggestions: [],
|
|
705
1075
|
policyNotes: [], degradedModel: null, injectedIds: [], lastStopReason: null,
|
|
706
1076
|
}, true);
|
|
1077
|
+
// Still not loaded after that retry: this exchange runs and shows, but will
|
|
1078
|
+
// not be saved. Flagged once the turn exists, so the notice is up for the
|
|
1079
|
+
// whole run rather than only after it.
|
|
1080
|
+
this.flagIfUnsaved(id);
|
|
707
1081
|
// Feature guard — the runtime is the chokepoint for every /agent run (send
|
|
708
1082
|
// and retry), so a quota/plan guard registered later covers this surface too.
|
|
709
1083
|
const gate = await this.host.guard('llm-execution');
|
|
@@ -722,23 +1096,34 @@ export class AgentRuntime {
|
|
|
722
1096
|
return;
|
|
723
1097
|
}
|
|
724
1098
|
const startedAt = Date.now();
|
|
1099
|
+
// A draft becomes a saved chat now, after the guard allowed the send, so a
|
|
1100
|
+
// refused send leaves nothing behind. A failed save does not stop the run:
|
|
1101
|
+
// its first write after it tries again.
|
|
1102
|
+
if (this.drafts.has(id))
|
|
1103
|
+
await this.materialize(id).catch(() => { });
|
|
725
1104
|
// Title an untitled session from its first message, so the switcher is readable.
|
|
726
1105
|
// A hand-renamed session is locked, so its chosen title is left alone.
|
|
727
1106
|
if (isFirst && text) {
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
ses
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
1107
|
+
await this.queueWrite(id, async () => {
|
|
1108
|
+
const ses = await this.host.sessions.get(id);
|
|
1109
|
+
if (ses && !ses.titleLocked) {
|
|
1110
|
+
ses.title = text.slice(0, 48);
|
|
1111
|
+
await this.host.sessions.save(ses).catch(() => { });
|
|
1112
|
+
this.notifyGlobal();
|
|
1113
|
+
}
|
|
1114
|
+
}).catch(() => { });
|
|
734
1115
|
}
|
|
735
|
-
const outgoing = nextTurns
|
|
1116
|
+
const outgoing = outgoingMessages(nextTurns);
|
|
736
1117
|
/* Transcript-only turns noteUser() added for messages injected mid-run.
|
|
737
1118
|
* They exist so the pivot is visible WHILE the run is in flight; once the
|
|
738
1119
|
* run returns, its own copy of each injected message arrives in `created`
|
|
739
1120
|
* and folds into the user turn, so keeping these would render the text a
|
|
740
1121
|
* second time below the final reply, where it reads as unanswered. */
|
|
741
1122
|
const injectedTurnIds = [];
|
|
1123
|
+
/* The seams THIS run wrote. The last one carries the run's folded view
|
|
1124
|
+
* once the run returns, so the next send starts from the fold instead of
|
|
1125
|
+
* re-folding the raw transcript. */
|
|
1126
|
+
const seamIds = [];
|
|
742
1127
|
let verification;
|
|
743
1128
|
let auditIncomplete;
|
|
744
1129
|
const ac = new AbortController();
|
|
@@ -779,7 +1164,14 @@ export class AgentRuntime {
|
|
|
779
1164
|
this.composeSystem(id, opts.persona, opts.partsOverride),
|
|
780
1165
|
enabledSkillCatalog((name) => !skillDeny.includes(name)).catch(() => []),
|
|
781
1166
|
]);
|
|
782
|
-
const model = this.
|
|
1167
|
+
const model = this.chatModel(id);
|
|
1168
|
+
/* Decided now, from what is true now: the chat's own model is gone and
|
|
1169
|
+
* the one it falls back to cannot hold it. A model picked since load, or
|
|
1170
|
+
* a catalog that has caught up, changes the answer. */
|
|
1171
|
+
const forceFold = !!this.lostModel(s) && !this.canHold(id, model);
|
|
1172
|
+
// The chat now runs on `model`; storing it is what clears the notice.
|
|
1173
|
+
if (this.host.perChatModel && s.model !== model)
|
|
1174
|
+
this.patch(id, { model });
|
|
783
1175
|
// Live model for onBreach pricing — reassigned when a degrade breach
|
|
784
1176
|
// switches the run onto a cheaper model (see onBreach below).
|
|
785
1177
|
let activeModel = model;
|
|
@@ -846,6 +1238,7 @@ export class AgentRuntime {
|
|
|
846
1238
|
: (getPolicy(opts.policyName) ?? STANDARD_POLICY),
|
|
847
1239
|
...(this.host.sessionsRoot ? { sessionsRoot: this.host.sessionsRoot() } : {}),
|
|
848
1240
|
compression: { mode: this.host.compression(), contextWindow },
|
|
1241
|
+
...(forceFold ? { forceFold: true } : {}),
|
|
849
1242
|
signal: ac.signal,
|
|
850
1243
|
sessionId: id,
|
|
851
1244
|
toolNames,
|
|
@@ -888,6 +1281,7 @@ export class AgentRuntime {
|
|
|
888
1281
|
// fallback exactly as before.
|
|
889
1282
|
editorFile: opts.editorFile,
|
|
890
1283
|
onNodeEvent: (e) => {
|
|
1284
|
+
publishSubagentEvent({ sessionId: id, surface: this.surface, event: e });
|
|
891
1285
|
const c = nodeUsageOf(e);
|
|
892
1286
|
if (c) {
|
|
893
1287
|
calleeSpend.inTok += c.usage.promptTokens;
|
|
@@ -997,7 +1391,7 @@ export class AgentRuntime {
|
|
|
997
1391
|
return;
|
|
998
1392
|
}
|
|
999
1393
|
if (e.type === 'checkpoint') {
|
|
1000
|
-
this.noteCheckpoint(id, e.collapsed, e.checkpoint);
|
|
1394
|
+
seamIds.push(this.noteCheckpoint(id, e.collapsed, e.checkpoint));
|
|
1001
1395
|
return;
|
|
1002
1396
|
}
|
|
1003
1397
|
if (e.type === 'tools-loaded') {
|
|
@@ -1085,11 +1479,13 @@ export class AgentRuntime {
|
|
|
1085
1479
|
...(res.calls ? { calls: res.calls } : {}),
|
|
1086
1480
|
}
|
|
1087
1481
|
: undefined;
|
|
1482
|
+
const lastSeam = seamIds[seamIds.length - 1];
|
|
1483
|
+
const stitched = s.turns
|
|
1484
|
+
.filter((t) => !injectedTurnIds.includes(t.id))
|
|
1485
|
+
.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t));
|
|
1088
1486
|
this.patch(id, {
|
|
1089
1487
|
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)),
|
|
1488
|
+
turns: lastSeam ? stampFoldedView(stitched, lastSeam, res.messages.filter((m) => m.role !== 'system'), userTurn.id) : stitched,
|
|
1093
1489
|
});
|
|
1094
1490
|
void this.persist(id);
|
|
1095
1491
|
if (stats) {
|
|
@@ -1132,6 +1528,8 @@ export class AgentRuntime {
|
|
|
1132
1528
|
s.abort = null;
|
|
1133
1529
|
s.runId = null;
|
|
1134
1530
|
this.patch(id, { running: false, live: null, recovery: null }, true);
|
|
1531
|
+
// Any callee still marked running under this session will never report.
|
|
1532
|
+
publishSubagentParentEnded(id);
|
|
1135
1533
|
// Upsert the session recording with everything captured so far (this turn's
|
|
1136
1534
|
// events appended to prior turns), under one stable id so the whole session
|
|
1137
1535
|
// replays as a single stream. Skipped until something replayable happened.
|
|
@@ -1158,12 +1556,21 @@ export class AgentRuntime {
|
|
|
1158
1556
|
if (!userText || s.running)
|
|
1159
1557
|
return;
|
|
1160
1558
|
this.ws();
|
|
1559
|
+
// As in send: never build on a transcript that has not loaded, and never
|
|
1560
|
+
// start a second turn that began while the load was pending.
|
|
1561
|
+
if (!s.loaded) {
|
|
1562
|
+
await this.load(id);
|
|
1563
|
+
if (s.running)
|
|
1564
|
+
return;
|
|
1565
|
+
}
|
|
1161
1566
|
const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [{ role: 'user', content: userText }] };
|
|
1162
1567
|
const ac = new AbortController();
|
|
1163
1568
|
s.abort = ac;
|
|
1164
1569
|
// Optimistic, for the same reason as `send` above: the guard is a network
|
|
1165
1570
|
// call, and a host-driven turn that waits on it looks like nothing happened.
|
|
1166
1571
|
this.patch(id, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true, suggestions: [] }, true);
|
|
1572
|
+
// As in send: a load that missed again leaves this turn unsaved; say so now.
|
|
1573
|
+
this.flagIfUnsaved(id);
|
|
1167
1574
|
const gate = await this.host.guard('llm-execution');
|
|
1168
1575
|
if (!gate.allowed) {
|
|
1169
1576
|
s.abort = null;
|
|
@@ -1192,13 +1599,16 @@ export class AgentRuntime {
|
|
|
1192
1599
|
* with the full content — the context drawer's per-item Restore, transplanted
|
|
1193
1600
|
* from the harness/conversation.ts singleton (same marker-matching and
|
|
1194
1601
|
* 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
|
|
1602
|
+
* (state untouched, no persist) when the turn or the matching stub is absent,
|
|
1603
|
+
* or when the turn is FOLDED (see isFoldedTurn): the folded view stands for
|
|
1604
|
+
* it, so content restored into it would never be sent. */
|
|
1196
1605
|
restoreToolResult(id, turnId, blobId, content) {
|
|
1197
1606
|
const marker = `recall_result({ id: "${blobId}" })`;
|
|
1198
1607
|
const s = this.ensure(id);
|
|
1199
|
-
const
|
|
1200
|
-
if (
|
|
1608
|
+
const at = s.turns.findIndex((t) => t.id === turnId);
|
|
1609
|
+
if (at < 0 || isFoldedTurn(s.turns, at))
|
|
1201
1610
|
return false;
|
|
1611
|
+
const turn = s.turns[at];
|
|
1202
1612
|
const idx = turn.messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
|
|
1203
1613
|
if (idx < 0)
|
|
1204
1614
|
return false;
|
|
@@ -1232,8 +1642,13 @@ export class AgentRuntime {
|
|
|
1232
1642
|
/** Drop all in-memory state and abort every run — called on a scope change so
|
|
1233
1643
|
* one identity's sessions and runs never carry into another's. */
|
|
1234
1644
|
reset() {
|
|
1645
|
+
const runningIds = [...this.map.entries()].filter(([, s]) => s.abort).map(([sid]) => sid);
|
|
1235
1646
|
for (const s of this.map.values())
|
|
1236
1647
|
s.abort?.abort();
|
|
1648
|
+
// The worker is disposed below, which never settles an in-flight run, so
|
|
1649
|
+
// its `finally` (and the parent-ended signal there) never runs: say it here.
|
|
1650
|
+
for (const sid of runningIds)
|
|
1651
|
+
publishSubagentParentEnded(sid);
|
|
1237
1652
|
const ids = [...this.subs.keys()];
|
|
1238
1653
|
this.map.clear();
|
|
1239
1654
|
this.worker?.dispose();
|
|
@@ -1243,5 +1658,13 @@ export class AgentRuntime {
|
|
|
1243
1658
|
this.notify(id);
|
|
1244
1659
|
this.notifyGlobal();
|
|
1245
1660
|
}
|
|
1661
|
+
/** End this runtime for good: reset, and stop listening to the catalog. For
|
|
1662
|
+
* a runtime that is recreated rather than reused (a project's, rebuilt on
|
|
1663
|
+
* every open); a long-lived one is reset() instead and keeps listening. */
|
|
1664
|
+
dispose() {
|
|
1665
|
+
this.reset();
|
|
1666
|
+
this.offModels?.();
|
|
1667
|
+
this.offModels = null;
|
|
1668
|
+
}
|
|
1246
1669
|
}
|
|
1247
1670
|
//# sourceMappingURL=runtime.js.map
|