@corenel/tools-web 0.11.4 → 0.12.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/composerPicks.d.ts +30 -0
- package/dist/composerPicks.d.ts.map +1 -0
- package/dist/composerPicks.js +123 -0
- package/dist/composerPicks.js.map +1 -0
- package/dist/core/protocol.d.ts +36 -1
- package/dist/core/protocol.d.ts.map +1 -1
- package/dist/injectionCheck.d.ts +44 -0
- package/dist/injectionCheck.d.ts.map +1 -0
- package/dist/injectionCheck.js +73 -0
- package/dist/injectionCheck.js.map +1 -0
- package/dist/liveFold.d.ts +4 -0
- package/dist/liveFold.d.ts.map +1 -1
- package/dist/liveFold.js +15 -0
- package/dist/liveFold.js.map +1 -1
- package/dist/mutationHook.d.ts +32 -0
- package/dist/mutationHook.d.ts.map +1 -0
- package/dist/mutationHook.js +108 -0
- package/dist/mutationHook.js.map +1 -0
- package/dist/runtime.d.ts +145 -41
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +1045 -975
- package/dist/runtime.js.map +1 -1
- package/dist/sessionTools.d.ts +5 -3
- package/dist/sessionTools.d.ts.map +1 -1
- package/dist/sessionTools.js +12 -7
- package/dist/sessionTools.js.map +1 -1
- package/dist/systemOneGate/gate.d.ts +45 -0
- package/dist/systemOneGate/gate.d.ts.map +1 -0
- package/dist/systemOneGate/gate.js +380 -0
- package/dist/systemOneGate/gate.js.map +1 -0
- package/dist/systemOneGate/requests.d.ts +67 -0
- package/dist/systemOneGate/requests.d.ts.map +1 -0
- package/dist/systemOneGate/requests.js +201 -0
- package/dist/systemOneGate/requests.js.map +1 -0
- package/dist/workerClient.d.ts +39 -1
- package/dist/workerClient.d.ts.map +1 -1
- package/dist/workerClient.js +96 -7
- package/dist/workerClient.js.map +1 -1
- package/dist/workerHandler.d.ts.map +1 -1
- package/dist/workerHandler.js +118 -20
- package/dist/workerHandler.js.map +1 -1
- package/dist/workspaceBlock.d.ts +5 -0
- package/dist/workspaceBlock.d.ts.map +1 -0
- package/dist/workspaceBlock.js +18 -0
- package/dist/workspaceBlock.js.map +1 -0
- package/package.json +4 -4
package/dist/runtime.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createWorkerAgent } from './workerClient';
|
|
2
2
|
import { nodeUsageOf } from './nodeUsage';
|
|
3
3
|
import { publishSubagentEvent, publishSubagentParentEnded } from './subagentBus';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { createChat } from '@corenel/harness/chat/engine';
|
|
5
|
+
import { userText, userImages, outgoingMessages } from '@corenel/harness/chat/messages';
|
|
6
6
|
import { listDocs, deleteDocsForSession } from './contextDocs';
|
|
7
7
|
import { loadedToolNames, setLoadedTools } from './sessionLoadedTools';
|
|
8
8
|
/* Read-only now: a conversation from before the snapshot may carry an explicit
|
|
@@ -10,89 +10,82 @@ import { loadedToolNames, setLoadedTools } from './sessionLoadedTools';
|
|
|
10
10
|
* chat's prompt is decided when it starts. */
|
|
11
11
|
import { sessionSystemPrompt } from './sessionSystemPrompt';
|
|
12
12
|
import { sessionSystemConfig, freezeSessionSystemConfig, clearSessionSystemConfig } from './sessionSystemConfig';
|
|
13
|
-
import { stateDirName, sessionsRoot, isCheckpointTool, unpricedCalleeRefusal } from '@corenel/protocol';
|
|
13
|
+
import { stateDirName, sessionsRoot, isCheckpointTool, unpricedCalleeRefusal, HOOK_ASK_TOOL } from '@corenel/protocol';
|
|
14
14
|
import { listSystemPrompts, resolveSystemPrompt, readSystemPromptSource } from '@corenel/harness/prompts/systemPrompts';
|
|
15
|
-
import {
|
|
15
|
+
import { workspaceBlock } from './workspaceBlock';
|
|
16
16
|
import { allTools } from '@corenel/harness/tools/builtins';
|
|
17
17
|
import { applyTodoWrite, addActivity, resolveActivity } from '@corenel/harness/todos';
|
|
18
18
|
import { getPolicy, STANDARD_POLICY } from '@corenel/harness/guardrail';
|
|
19
19
|
import { composeAgentSystem, TOOLS_MARKER } from '@corenel/harness/prompts';
|
|
20
20
|
import { getSystemParts } from '@corenel/harness/systemParts';
|
|
21
|
-
import {
|
|
21
|
+
import { estimateTokens, COMPACT_AT_FILL } from '@corenel/harness/core/compactHistory';
|
|
22
22
|
import { priceUsageWith, pricedForUsd } from '@corenel/harness/core/pricing';
|
|
23
23
|
import { enabledSkillCatalog } from '@corenel/harness/skills';
|
|
24
|
-
import { alog } from '@corenel/harness/log';
|
|
25
|
-
// The agent's model is the user's picker selection (getModel) — a real
|
|
26
|
-
// catalog entry, so pricing/context come from host.modelInfo, not hardcoded figures.
|
|
27
|
-
const WATCHDOG_MS = 45000;
|
|
28
|
-
/** A session allow is keyed by WHO asked and the tool, so allowing a callee's
|
|
29
|
-
* write never carries to another callee, or to the caller. */
|
|
30
|
-
function allowKey(agent, tool) {
|
|
31
|
-
return JSON.stringify([agent ?? '', tool]);
|
|
32
|
-
}
|
|
33
24
|
function emptyInternal() {
|
|
34
25
|
return {
|
|
35
26
|
turns: [], live: null, running: false, error: '', mode: 'auto', input: '', attachments: [],
|
|
36
|
-
pendingPerm: null, pendingAsk: null, approvalSuggestion: null, loaded: false, suggestions: [], recovery: null,
|
|
37
|
-
pendingCommand: null,
|
|
27
|
+
pendingPerm: null, pendingAsk: null, approvalSuggestion: null, loaded: false, suggestions: [], recovery: null,
|
|
28
|
+
pendingCommand: null,
|
|
38
29
|
recEvents: [], injectedIds: [], lastUsage: { promptTokens: 0, completionTokens: 0 },
|
|
39
|
-
offloadTick: 0, policyNotes: [], degradedModel: null, model: null, modelNotice: null,
|
|
40
|
-
saveBlocked: null,
|
|
30
|
+
offloadTick: 0, policyNotes: [], degradedModel: null, model: null, modelNotice: null,
|
|
31
|
+
saveBlocked: null, mergeTick: 0,
|
|
41
32
|
};
|
|
42
33
|
}
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
34
|
+
/** Whether `c` is a whole stored session (a save's read hands back the store's
|
|
35
|
+
* own record) rather than a draft's stand-in. */
|
|
36
|
+
function isSession(c) {
|
|
37
|
+
const p = c;
|
|
38
|
+
return typeof p.createdAt === 'number' && typeof p.updatedAt === 'number';
|
|
39
|
+
}
|
|
40
|
+
/* The engine's frozen system prompt (StoredConversation.system, R4) is a daemon
|
|
41
|
+
* store field. The browser never keeps it: it is left out of every save and
|
|
42
|
+
* every read, so a reload composes the system afresh. */
|
|
43
|
+
function withoutSystem(c) {
|
|
44
|
+
if (!('system' in c))
|
|
45
|
+
return c;
|
|
46
|
+
const copy = { ...c };
|
|
47
|
+
Reflect.deleteProperty(copy, 'system');
|
|
48
|
+
return copy;
|
|
53
49
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
if (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
50
|
+
/** The ask detail back from the engine, which carries it as unknown: the
|
|
51
|
+
* worker built it as a PermDetail (already clipped), so only its fields pass. */
|
|
52
|
+
function permInput(x) {
|
|
53
|
+
if (x === null || typeof x !== 'object')
|
|
54
|
+
return undefined;
|
|
55
|
+
const o = x;
|
|
56
|
+
const out = {};
|
|
57
|
+
for (const k of ['command', 'path', 'cwd', 'root']) {
|
|
58
|
+
const v = o[k];
|
|
59
|
+
if (typeof v === 'string')
|
|
60
|
+
out[k] = v;
|
|
61
|
+
}
|
|
62
|
+
return Object.keys(out).length ? out : undefined;
|
|
62
63
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
64
|
+
/** A System One ask's detail back from the engine, which carries it as
|
|
65
|
+
* unknown: the tool, the reasons and an optional result, nothing else. */
|
|
66
|
+
function hookInput(x) {
|
|
67
|
+
if (x === null || typeof x !== 'object')
|
|
68
|
+
return undefined;
|
|
69
|
+
const o = x;
|
|
70
|
+
if (typeof o.tool !== 'string' || !Array.isArray(o.reasons))
|
|
71
|
+
return undefined;
|
|
72
|
+
const reasons = o.reasons.filter((r) => typeof r === 'string');
|
|
73
|
+
return { tool: o.tool, reasons, ...(typeof o.result === 'string' ? { result: o.result } : {}) };
|
|
67
74
|
}
|
|
68
|
-
/**
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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);
|
|
75
|
+
/** The event as recordings and host hooks see it: a tool-hooks event without
|
|
76
|
+
* its blocked result. */
|
|
77
|
+
function withoutBlockedText(e) {
|
|
78
|
+
if (e.type !== 'tool-hooks' || e.blockedText === undefined)
|
|
79
|
+
return e;
|
|
80
|
+
return { type: 'tool-hooks', id: e.id, mark: e.mark };
|
|
79
81
|
}
|
|
80
|
-
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
});
|
|
82
|
+
/** The engine's permission answer in the worker's terms: a session allow says
|
|
83
|
+
* so, a deny with a note carries it, and every other answer stays the bare
|
|
84
|
+
* boolean it always was. */
|
|
85
|
+
function toPermissionAnswer(a) {
|
|
86
|
+
if (a.by === 'session')
|
|
87
|
+
return { allow: a.allow, by: 'session' };
|
|
88
|
+
return !a.allow && a.note ? { allow: a.allow, by: 'human', note: a.note } : a.allow;
|
|
96
89
|
}
|
|
97
90
|
/** Merge a form's freshly-emitted envelope into a per-run override, against the
|
|
98
91
|
* currently saved default. The form only emits the keys it still wants set — a
|
|
@@ -185,6 +178,30 @@ export class AgentRuntime {
|
|
|
185
178
|
writable: true,
|
|
186
179
|
value: new Set()
|
|
187
180
|
});
|
|
181
|
+
Object.defineProperty(this, "nextPermId", {
|
|
182
|
+
enumerable: true,
|
|
183
|
+
configurable: true,
|
|
184
|
+
writable: true,
|
|
185
|
+
value: 1
|
|
186
|
+
});
|
|
187
|
+
/** Asks raised while another was on screen, per session, oldest first: each
|
|
188
|
+
* becomes the shown card (pendingPerm) when the one before it is answered,
|
|
189
|
+
* so two parallel asks are two cards and an answer reaches only its own. */
|
|
190
|
+
Object.defineProperty(this, "permQueues", {
|
|
191
|
+
enumerable: true,
|
|
192
|
+
configurable: true,
|
|
193
|
+
writable: true,
|
|
194
|
+
value: new Map()
|
|
195
|
+
});
|
|
196
|
+
/** Tool results a System One check or hook blocked, per session and tool
|
|
197
|
+
* call id. Memory only, for the life of the page: never saved, recorded or
|
|
198
|
+
* sent to a model. */
|
|
199
|
+
Object.defineProperty(this, "blocked", {
|
|
200
|
+
enumerable: true,
|
|
201
|
+
configurable: true,
|
|
202
|
+
writable: true,
|
|
203
|
+
value: new Map()
|
|
204
|
+
});
|
|
188
205
|
Object.defineProperty(this, "_ws", {
|
|
189
206
|
enumerable: true,
|
|
190
207
|
configurable: true,
|
|
@@ -197,6 +214,48 @@ export class AgentRuntime {
|
|
|
197
214
|
writable: true,
|
|
198
215
|
value: null
|
|
199
216
|
});
|
|
217
|
+
/* ---- the conversation engine ---------------------------------------------
|
|
218
|
+
*
|
|
219
|
+
* Turns, the live fold, the run's lifecycle (guard, watchdog, stitch), the
|
|
220
|
+
* write queue and the merge-by-turn-id save are the harness chat engine's.
|
|
221
|
+
* This runtime is its browser host (chatHost): it supplies the worker loop,
|
|
222
|
+
* the session store, the composer, the approval cards and everything else
|
|
223
|
+
* only a browser has. */
|
|
224
|
+
Object.defineProperty(this, "chat", {
|
|
225
|
+
enumerable: true,
|
|
226
|
+
configurable: true,
|
|
227
|
+
writable: true,
|
|
228
|
+
value: createChat()
|
|
229
|
+
});
|
|
230
|
+
Object.defineProperty(this, "chatHost", {
|
|
231
|
+
enumerable: true,
|
|
232
|
+
configurable: true,
|
|
233
|
+
writable: true,
|
|
234
|
+
value: void 0
|
|
235
|
+
});
|
|
236
|
+
/** Forgotten in the engine (deleteSession, reset), so every engine mutator is
|
|
237
|
+
* a no-op for them until a load or a turn opens them again. open() reopens
|
|
238
|
+
* one before this side mutates it. */
|
|
239
|
+
Object.defineProperty(this, "forgotten", {
|
|
240
|
+
enumerable: true,
|
|
241
|
+
configurable: true,
|
|
242
|
+
writable: true,
|
|
243
|
+
value: new Set()
|
|
244
|
+
});
|
|
245
|
+
/** Sends and retries waiting for the engine to start their turn, oldest first. */
|
|
246
|
+
Object.defineProperty(this, "pending", {
|
|
247
|
+
enumerable: true,
|
|
248
|
+
configurable: true,
|
|
249
|
+
writable: true,
|
|
250
|
+
value: new Map()
|
|
251
|
+
});
|
|
252
|
+
/** The turn the engine is running per session, from its guard to turnComplete. */
|
|
253
|
+
Object.defineProperty(this, "active", {
|
|
254
|
+
enumerable: true,
|
|
255
|
+
configurable: true,
|
|
256
|
+
writable: true,
|
|
257
|
+
value: new Map()
|
|
258
|
+
});
|
|
200
259
|
/** Reuse the most-recent empty saved session if there is one, else open a
|
|
201
260
|
* DRAFT (newDraft): opening the app, or landing after a delete, saves
|
|
202
261
|
* nothing until the user first sends. Concurrent calls are deduped (React
|
|
@@ -210,9 +269,10 @@ export class AgentRuntime {
|
|
|
210
269
|
/* DRAFT CHATS. A draft is a chat id held only here: loaded, empty, in no
|
|
211
270
|
* store and so in no chat list. Its first write (the first send, or a note,
|
|
212
271
|
* a kept console command, /memory) creates it in the store under the SAME
|
|
213
|
-
* id (materialize
|
|
214
|
-
*
|
|
215
|
-
* config is frozen then too, so
|
|
272
|
+
* id (materialize, from the host's writeConversation), so nothing that
|
|
273
|
+
* already holds the id has to move. A model picked on a draft stays in
|
|
274
|
+
* memory and is saved with it. Its system config is frozen then too, so
|
|
275
|
+
* abandoned drafts leave nothing behind. */
|
|
216
276
|
Object.defineProperty(this, "drafts", {
|
|
217
277
|
enumerable: true,
|
|
218
278
|
configurable: true,
|
|
@@ -235,19 +295,7 @@ export class AgentRuntime {
|
|
|
235
295
|
writable: true,
|
|
236
296
|
value: new WeakMap()
|
|
237
297
|
});
|
|
238
|
-
|
|
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
|
-
});
|
|
298
|
+
this.chatHost = this.buildChatHost();
|
|
251
299
|
// Availability and the fallback are read live, so every open chat's
|
|
252
300
|
// notice must re-render when either moves.
|
|
253
301
|
if (host.perChatModel)
|
|
@@ -274,6 +322,7 @@ export class AgentRuntime {
|
|
|
274
322
|
getMemoryProvider: () => this.host.memory?.() ?? null,
|
|
275
323
|
getCalleeMemoryProvider: (agent) => this.host.calleeMemory?.(agent) ?? null,
|
|
276
324
|
...this.host.workerDeps,
|
|
325
|
+
...(this.host.mutations ? { mutations: this.host.mutations } : {}),
|
|
277
326
|
});
|
|
278
327
|
return this.worker;
|
|
279
328
|
}
|
|
@@ -313,6 +362,40 @@ export class AgentRuntime {
|
|
|
313
362
|
}
|
|
314
363
|
return s;
|
|
315
364
|
}
|
|
365
|
+
/** Copy the engine's half of the session onto this side's, so the object a
|
|
366
|
+
* view holds reads as one SessionState. Only for a session this side holds:
|
|
367
|
+
* reading the engine creates its entry. */
|
|
368
|
+
sync(id, s) {
|
|
369
|
+
const c = this.chat.state(id);
|
|
370
|
+
s.turns = c.turns;
|
|
371
|
+
s.live = c.live;
|
|
372
|
+
s.running = c.running;
|
|
373
|
+
s.error = c.error;
|
|
374
|
+
s.loaded = c.loaded;
|
|
375
|
+
s.pendingCommand = c.pendingCommand;
|
|
376
|
+
s.lastStopReason = c.lastStopReason;
|
|
377
|
+
s.injectedIds = c.injectedIds;
|
|
378
|
+
s.saveBlocked = c.saveBlocked;
|
|
379
|
+
s.mergeTick = c.mergeTick;
|
|
380
|
+
return s;
|
|
381
|
+
}
|
|
382
|
+
/** The engine's turns for a session this side holds, else none. */
|
|
383
|
+
turnsOf(id) {
|
|
384
|
+
return this.map.has(id) ? this.chat.state(id).turns : [];
|
|
385
|
+
}
|
|
386
|
+
/** Reopen a session the engine was told to forget, before this side changes
|
|
387
|
+
* it: the load's first step reopens it at once, and the read it starts
|
|
388
|
+
* merges what is stored under anything added meanwhile. */
|
|
389
|
+
open(id) {
|
|
390
|
+
if (this.forgotten.delete(id))
|
|
391
|
+
void this.chat.load(id, this.chatHost);
|
|
392
|
+
}
|
|
393
|
+
/** A chat known to be empty in the store (a draft, a new chat, an empty saved
|
|
394
|
+
* one) needs no read: the engine opens it loaded with nothing stored. */
|
|
395
|
+
markLoadedEmpty(id) {
|
|
396
|
+
this.forgotten.delete(id);
|
|
397
|
+
this.chat.seedEmpty(id, this.chatHost);
|
|
398
|
+
}
|
|
316
399
|
notify(id) { this.subs.get(id)?.forEach((fn) => fn()); }
|
|
317
400
|
notifyGlobal() { this.globalSubs.forEach((fn) => fn()); }
|
|
318
401
|
/** Mutate a session and notify its view; `global` also pokes the running-badge subscribers. */
|
|
@@ -336,10 +419,29 @@ export class AgentRuntime {
|
|
|
336
419
|
return () => { set.delete(cb); };
|
|
337
420
|
}
|
|
338
421
|
subscribeGlobal(cb) { this.globalSubs.add(cb); return () => { this.globalSubs.delete(cb); }; }
|
|
422
|
+
/** Every session blocked on a tool permission, for a host that shows approvals
|
|
423
|
+
* outside the chat that asked. The budget pseudo-permission is not a tool ask. */
|
|
424
|
+
pendingPerms() {
|
|
425
|
+
const out = [];
|
|
426
|
+
for (const [sessionId, s] of this.map) {
|
|
427
|
+
const p = s.pendingPerm;
|
|
428
|
+
if (!p || p.tool === 'budget')
|
|
429
|
+
continue;
|
|
430
|
+
out.push({
|
|
431
|
+
permId: p.id, sessionId, tool: p.tool, summary: p.summary,
|
|
432
|
+
...(p.agent ? { agent: p.agent } : {}), ...(p.caller ? { caller: p.caller } : {}),
|
|
433
|
+
...(p.input ? { input: p.input } : {}),
|
|
434
|
+
// A System One ask is answered once, for its own tool call: never a session allow.
|
|
435
|
+
checkpoint: isCheckpointTool(p.tool) || p.tool === HOOK_ASK_TOOL, askedAt: p.askedAt,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
return out;
|
|
439
|
+
}
|
|
339
440
|
getState(id) {
|
|
340
441
|
const s = this.map.get(id);
|
|
341
442
|
if (!s)
|
|
342
443
|
return emptyInternal();
|
|
444
|
+
this.sync(id, s);
|
|
343
445
|
s.modelNotice = this.modelNotice(id, s);
|
|
344
446
|
return s;
|
|
345
447
|
}
|
|
@@ -347,6 +449,7 @@ export class AgentRuntime {
|
|
|
347
449
|
const s = this.map.get(id);
|
|
348
450
|
if (!s)
|
|
349
451
|
return 'idle';
|
|
452
|
+
this.sync(id, s);
|
|
350
453
|
if (s.pendingPerm || s.pendingAsk)
|
|
351
454
|
return 'waiting';
|
|
352
455
|
// A running command is a running session. Reporting 'idle' while a command
|
|
@@ -361,61 +464,28 @@ export class AgentRuntime {
|
|
|
361
464
|
* workspace()/fileService() before its mount effect (CraftPage direct load)
|
|
362
465
|
* otherwise hits the default null resolver and the store throws
|
|
363
466
|
* "No file workspace is connected". */
|
|
364
|
-
/** Lazily load a session's turns from the store the first time it's focused.
|
|
467
|
+
/** Lazily load a session's turns from the store the first time it's focused.
|
|
468
|
+
* The engine merges what is stored before anything this page added, keeps a
|
|
469
|
+
* miss unloaded (so the next ask reads again) and treats a read that throws
|
|
470
|
+
* as a miss; onLoaded does this side's part of the read. */
|
|
365
471
|
async load(id) {
|
|
366
472
|
if (!id)
|
|
367
473
|
return;
|
|
368
474
|
this.ws();
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
ses = await this.host.sessions.get(id);
|
|
380
|
-
}
|
|
381
|
-
catch {
|
|
382
|
-
ses = null;
|
|
383
|
-
}
|
|
384
|
-
// A concurrent load (the focus effect beside a send) landed first, and its
|
|
385
|
-
// merge already holds everything; merging again would double the chat.
|
|
386
|
-
if (s.loaded)
|
|
475
|
+
this.ensure(id);
|
|
476
|
+
this.forgotten.delete(id);
|
|
477
|
+
await this.chat.load(id, this.chatHost);
|
|
478
|
+
}
|
|
479
|
+
/** What reading the stored chat for a LOAD means on this side, hit or miss:
|
|
480
|
+
* the chat's stored model, and the system-config snapshot of a chat from
|
|
481
|
+
* before the snapshot existed. A concurrent load that landed first already
|
|
482
|
+
* did both, and merging again is the engine's to refuse. */
|
|
483
|
+
onLoaded(id, ses) {
|
|
484
|
+
if (this.chat.state(id).loaded)
|
|
387
485
|
return;
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
const pre = s.turns;
|
|
392
|
-
if (ses) {
|
|
393
|
-
s.turns = [...turnsFromSessionMessages(ses.messages), ...pre];
|
|
394
|
-
s.persistedLen = ses.messages.length;
|
|
395
|
-
}
|
|
396
|
-
s.model = ses?.model ?? null;
|
|
397
|
-
/* ONLY ON A HIT. A miss used to be cached exactly like a hit, and that made
|
|
398
|
-
* it permanent: the session could arrive a moment later and this would
|
|
399
|
-
* never look again, because the guard above returns on `loaded`.
|
|
400
|
-
*
|
|
401
|
-
* It arrives a moment later more often than it sounds. A thread MOVED into
|
|
402
|
-
* a project (the handover offer) is read by the project's runtime, and if
|
|
403
|
-
* anything asked for it before the move landed, the runtime cached "no such
|
|
404
|
-
* session, zero turns" and showed an empty transcript for a conversation
|
|
405
|
-
* that was sitting on disk. Reported as "it brought the chat over but was
|
|
406
|
-
* empty"; switching away and back appeared to fix it only because that went
|
|
407
|
-
* through a different runtime.
|
|
408
|
-
*
|
|
409
|
-
* Leaving it unloaded costs one more read the next time something asks,
|
|
410
|
-
* and `load` is called from an effect keyed on the session and the runtime
|
|
411
|
-
* -- not per render -- so a genuinely absent session does not spin. */
|
|
412
|
-
if (ses)
|
|
413
|
-
s.loaded = true;
|
|
414
|
-
s.loadMissed = !ses;
|
|
415
|
-
// Loaded with nothing of this page's to save: nothing is unsaved any more.
|
|
416
|
-
// With something, the persist below clears the flag once it lands.
|
|
417
|
-
if (ses && !pre.length)
|
|
418
|
-
s.saveBlocked = null;
|
|
486
|
+
const s = this.map.get(id);
|
|
487
|
+
if (s)
|
|
488
|
+
s.model = ses?.model ?? null;
|
|
419
489
|
/* MIGRATION. A conversation from before the snapshot existed has none, and
|
|
420
490
|
* would otherwise keep following the live Settings default forever. Opening
|
|
421
491
|
* it freezes it at what it would have used at this moment, so nothing
|
|
@@ -423,38 +493,18 @@ export class AgentRuntime {
|
|
|
423
493
|
* freeze would re-decide a live chat at today's defaults. */
|
|
424
494
|
if (!sessionSystemConfig(id))
|
|
425
495
|
this.freezeSystemConfig(id);
|
|
426
|
-
this.notify(id);
|
|
427
|
-
// What was added before the load could not be saved then (see persist).
|
|
428
|
-
if (ses && pre.length)
|
|
429
|
-
void this.persist(id);
|
|
430
496
|
}
|
|
431
497
|
/** Try the stored chat again after a load failed (the unsaved-chat notice's
|
|
432
498
|
* Retry): load it -- merging what this page added meanwhile after it, as
|
|
433
499
|
* `load` always does -- then save the merged list. True once it is saved;
|
|
434
|
-
* false when the load missed again (or threw), and `saveBlocked` stays.
|
|
500
|
+
* false when the load missed again (or threw), and `saveBlocked` stays. The
|
|
501
|
+
* engine says so globally, because the session list may have dropped this
|
|
502
|
+
* chat while its load kept missing. */
|
|
435
503
|
async retryLoad(id) {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
// persistNow swallows its own failures; this catch keeps the promise a
|
|
441
|
-
// Retry button awaits from ever rejecting.
|
|
442
|
-
await this.persist(id).catch(() => { });
|
|
443
|
-
/* GLOBAL, because the session list is. While the load kept missing, the
|
|
444
|
-
* list the page shows (and the header's title, read from it) could drop
|
|
445
|
-
* this chat; it is back now, and only a global change makes the page
|
|
446
|
-
* re-read the list. Without it the recovered chat stayed missing from the
|
|
447
|
-
* list, titled "New chat", until a reload. */
|
|
448
|
-
this.notifyGlobal();
|
|
449
|
-
return s.saveBlocked === null;
|
|
450
|
-
}
|
|
451
|
-
/** Raise the unsaved-chat flag when `id` is still not loaded after a load
|
|
452
|
-
* was tried, so the page says its new messages are not being kept. */
|
|
453
|
-
flagIfUnsaved(id) {
|
|
454
|
-
const s = this.map.get(id);
|
|
455
|
-
if (s && !s.loaded && s.loadMissed && s.turns.length && s.saveBlocked !== 'load-failed') {
|
|
456
|
-
this.patch(id, { saveBlocked: 'load-failed' });
|
|
457
|
-
}
|
|
504
|
+
this.ws();
|
|
505
|
+
this.ensure(id);
|
|
506
|
+
this.forgotten.delete(id);
|
|
507
|
+
return this.chat.retryLoad(id, this.chatHost);
|
|
458
508
|
}
|
|
459
509
|
async openInitial() {
|
|
460
510
|
if (this.initInFlight)
|
|
@@ -476,15 +526,16 @@ export class AgentRuntime {
|
|
|
476
526
|
// second one, for two callers asking moments apart.
|
|
477
527
|
if (!empty) {
|
|
478
528
|
for (const d of this.drafts) {
|
|
479
|
-
|
|
480
|
-
|
|
529
|
+
if (!this.map.has(d))
|
|
530
|
+
continue;
|
|
531
|
+
const st = this.chat.state(d);
|
|
532
|
+
if (st.turns.length === 0 && !st.running)
|
|
481
533
|
return d;
|
|
482
534
|
}
|
|
483
535
|
}
|
|
484
536
|
if (empty) {
|
|
485
537
|
const s = this.ensure(empty.id);
|
|
486
|
-
|
|
487
|
-
s.persistedLen = 0;
|
|
538
|
+
this.markLoadedEmpty(empty.id);
|
|
488
539
|
// A model picked on this empty chat before the reload is still its model.
|
|
489
540
|
s.model = empty.model ?? null;
|
|
490
541
|
return empty.id;
|
|
@@ -504,15 +555,14 @@ export class AgentRuntime {
|
|
|
504
555
|
const id = store?.pendingChat();
|
|
505
556
|
if (!store || !id)
|
|
506
557
|
return null;
|
|
507
|
-
if (list.some((s) => s.id === id) ||
|
|
558
|
+
if (list.some((s) => s.id === id) || this.turnsOf(id).length > 0) {
|
|
508
559
|
store.setPendingChat(null);
|
|
509
560
|
return null;
|
|
510
561
|
}
|
|
511
562
|
if (!(this.map.get(id)?.input ?? store.get(id)))
|
|
512
563
|
return null;
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
s.persistedLen = 0;
|
|
564
|
+
this.ensure(id);
|
|
565
|
+
this.markLoadedEmpty(id);
|
|
516
566
|
this.drafts.add(id);
|
|
517
567
|
return id;
|
|
518
568
|
}
|
|
@@ -528,31 +578,31 @@ export class AgentRuntime {
|
|
|
528
578
|
}
|
|
529
579
|
createDraft() {
|
|
530
580
|
const id = `ses_${Date.now().toString(36)}${Math.floor(Math.random() * 1e6).toString(36)}`;
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
s.persistedLen = 0;
|
|
581
|
+
this.ensure(id);
|
|
582
|
+
this.markLoadedEmpty(id);
|
|
534
583
|
this.drafts.add(id);
|
|
535
584
|
this.host.composerDrafts?.setPendingChat(id);
|
|
536
585
|
return id;
|
|
537
586
|
}
|
|
538
587
|
/** Whether `id` is a draft that has not been saved yet. */
|
|
539
588
|
isDraft(id) { return this.drafts.has(id); }
|
|
540
|
-
/** Save a draft under its own id
|
|
541
|
-
* callers share one create. A failure leaves it a
|
|
542
|
-
* tries again. */
|
|
589
|
+
/** Save a draft under its own id, resolving the created session; null for
|
|
590
|
+
* any other chat. Concurrent callers share one create. A failure leaves it a
|
|
591
|
+
* draft, so the next write tries again. */
|
|
543
592
|
materialize(id) {
|
|
544
593
|
if (!this.drafts.has(id))
|
|
545
|
-
return Promise.resolve();
|
|
594
|
+
return Promise.resolve(null);
|
|
546
595
|
const inFlight = this.materializing.get(id);
|
|
547
596
|
if (inFlight)
|
|
548
597
|
return inFlight;
|
|
549
598
|
const run = (async () => {
|
|
550
|
-
await this.host.sessions.create('New chat', this.surface, { id });
|
|
599
|
+
const created = await this.host.sessions.create('New chat', this.surface, { id });
|
|
551
600
|
this.drafts.delete(id);
|
|
552
601
|
if (this.host.composerDrafts?.pendingChat() === id)
|
|
553
602
|
this.host.composerDrafts.setPendingChat(null);
|
|
554
603
|
this.freezeSystemConfig(id);
|
|
555
604
|
this.notifyGlobal();
|
|
605
|
+
return created;
|
|
556
606
|
})();
|
|
557
607
|
this.materializing.set(id, run);
|
|
558
608
|
run.finally(() => this.materializing.delete(id)).catch(() => { });
|
|
@@ -568,9 +618,8 @@ export class AgentRuntime {
|
|
|
568
618
|
* shared list that this narrow fix could not reach. Keying the store is
|
|
569
619
|
* the repair that comment asked for. */
|
|
570
620
|
const ses = await this.host.sessions.create('New chat', this.surface);
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
s.persistedLen = 0;
|
|
621
|
+
this.ensure(ses.id);
|
|
622
|
+
this.markLoadedEmpty(ses.id);
|
|
574
623
|
// The system prompt is the head of the cached prefix, so this conversation's
|
|
575
624
|
// config is decided HERE and read from the snapshot after -- see
|
|
576
625
|
// sessionSystemConfig. Settings decides what the next one starts with.
|
|
@@ -580,8 +629,12 @@ export class AgentRuntime {
|
|
|
580
629
|
}
|
|
581
630
|
async deleteSession(id) {
|
|
582
631
|
this.ws();
|
|
583
|
-
|
|
632
|
+
// Aborts its run, and nothing the run writes late lands anywhere.
|
|
633
|
+
this.denyAllPerms(id);
|
|
634
|
+
this.chat.forget(id);
|
|
635
|
+
this.forgotten.add(id);
|
|
584
636
|
this.map.delete(id);
|
|
637
|
+
this.blocked.delete(id);
|
|
585
638
|
this.subs.delete(id);
|
|
586
639
|
this.drafts.delete(id);
|
|
587
640
|
this.host.composerDrafts?.set(id, '');
|
|
@@ -640,13 +693,12 @@ export class AgentRuntime {
|
|
|
640
693
|
* plus the turns after it -- not the raw transcript, which overstates a chat
|
|
641
694
|
* that has already folded. */
|
|
642
695
|
canHold(id, model) {
|
|
643
|
-
|
|
644
|
-
if (!s)
|
|
696
|
+
if (!this.map.has(id))
|
|
645
697
|
return true;
|
|
646
698
|
const window = this.host.modelInfo(model).contextWindow ?? 128000;
|
|
647
699
|
if (window <= 0)
|
|
648
700
|
return true;
|
|
649
|
-
return this.fillTokens(
|
|
701
|
+
return this.fillTokens(this.turnsOf(id)) / window < COMPACT_AT_FILL;
|
|
650
702
|
}
|
|
651
703
|
/** Switch this chat (only) to `model`; null when it switched. Refused, with
|
|
652
704
|
* nothing changed: before the chat has loaded (there is nothing to measure,
|
|
@@ -654,16 +706,17 @@ export class AgentRuntime {
|
|
|
654
706
|
* the model is not available (it would silently run on the fallback); and
|
|
655
707
|
* when it cannot hold the chat (switching never folds, spec 3.5). */
|
|
656
708
|
setChatModel(id, model) {
|
|
657
|
-
if (!this.map.
|
|
709
|
+
if (!this.map.has(id) || !this.chat.state(id).loaded)
|
|
658
710
|
return 'not-loaded';
|
|
659
711
|
if (!this.available(model))
|
|
660
712
|
return 'unavailable';
|
|
661
713
|
if (!this.canHold(id, model))
|
|
662
714
|
return 'too-large';
|
|
663
715
|
this.patch(id, { model });
|
|
664
|
-
|
|
716
|
+
/* A draft keeps the pick in memory; picking a model is not using the chat.
|
|
717
|
+
* A saved chat is saved now, carrying the pick (writeConversation stamps it). */
|
|
665
718
|
if (!this.drafts.has(id))
|
|
666
|
-
void this.
|
|
719
|
+
void this.chat.save(id, this.chatHost);
|
|
667
720
|
return null;
|
|
668
721
|
}
|
|
669
722
|
/* ---- view mutators ---- */
|
|
@@ -673,23 +726,23 @@ export class AgentRuntime {
|
|
|
673
726
|
* starts a fresh recording stream for what follows. */
|
|
674
727
|
clear(id) {
|
|
675
728
|
const s = this.ensure(id);
|
|
729
|
+
this.open(id);
|
|
676
730
|
s.recId = undefined;
|
|
677
731
|
s.recPrompt = undefined;
|
|
678
732
|
s.recImages = undefined;
|
|
679
733
|
s.recEvents = [];
|
|
680
|
-
|
|
681
|
-
|
|
734
|
+
s.suggestions = [];
|
|
735
|
+
s.approvalSuggestion = null;
|
|
736
|
+
this.blocked.delete(id);
|
|
737
|
+
// The engine empties the transcript, notifies globally and saves.
|
|
738
|
+
this.chat.clear(id, this.chatHost);
|
|
682
739
|
}
|
|
683
740
|
/** Append a local assistant-style notice turn (e.g. a /model confirmation) — not
|
|
684
741
|
* sent to the model, just shown in the transcript. Returns the turn's id. */
|
|
685
742
|
note(id, text, extra) {
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
const turn = { id: `n${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, included: false, messages: [{ role: 'assistant', content: text }], ...extra };
|
|
690
|
-
this.patch(id, { turns: [...s.turns, turn] });
|
|
691
|
-
void this.persist(id);
|
|
692
|
-
return turn.id;
|
|
743
|
+
this.ensure(id);
|
|
744
|
+
this.open(id);
|
|
745
|
+
return this.chat.note(id, this.chatHost, text, extra);
|
|
693
746
|
}
|
|
694
747
|
/* The compaction seam. PERMANENT in the transcript, per the `checkpoint`
|
|
695
748
|
* event's contract: compaction is lossy, and a person who cannot see that it
|
|
@@ -698,29 +751,21 @@ export class AgentRuntime {
|
|
|
698
751
|
*
|
|
699
752
|
* Recorded as an unsent note. The collapse already happened inside the run's
|
|
700
753
|
* own message array, so a seam that counted as conversation would describe
|
|
701
|
-
* the compaction back to the model on every later turn.
|
|
754
|
+
* the compaction back to the model on every later turn. The body rides along
|
|
755
|
+
* so the seam can be READ. */
|
|
702
756
|
noteCheckpoint(id, collapsed, body) {
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
* marker the harness writes, and two copies of one string is how they end
|
|
707
|
-
* up looking for different things.
|
|
708
|
-
*
|
|
709
|
-
* The body rides along so the seam can be READ. It was dropped here before
|
|
710
|
-
* -- the event carries it, and this took only the count -- which left the
|
|
711
|
-
* one question a seam raises with no answer anywhere in the UI. */
|
|
712
|
-
return this.note(id, `${CHECKPOINT_MARKER}${n} collapsed — earlier context is summarized from here`, body ? { checkpoint: body } : undefined);
|
|
757
|
+
this.ensure(id);
|
|
758
|
+
this.open(id);
|
|
759
|
+
return this.chat.noteCheckpoint(id, this.chatHost, collapsed, body);
|
|
713
760
|
}
|
|
714
761
|
/** Show a message in the transcript as YOUR turn, without adding it to what
|
|
715
762
|
* gets sent. Used for injected messages: the real copy is already in the
|
|
716
763
|
* run's own message list (the loop appended it), so an included turn here
|
|
717
764
|
* would send it twice. */
|
|
718
765
|
noteUser(id, text) {
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
this.
|
|
722
|
-
void this.persist(id);
|
|
723
|
-
return turn.id;
|
|
766
|
+
this.ensure(id);
|
|
767
|
+
this.open(id);
|
|
768
|
+
return this.chat.noteUser(id, this.chatHost, text);
|
|
724
769
|
}
|
|
725
770
|
setAttachments(id, fn) {
|
|
726
771
|
this.patch(id, { attachments: fn(this.ensure(id).attachments) });
|
|
@@ -729,11 +774,9 @@ export class AgentRuntime {
|
|
|
729
774
|
* refused -- no state change, no persist: the folded view stands for it, so
|
|
730
775
|
* including or dropping it would change nothing the next request carries. */
|
|
731
776
|
toggleInclude(id, turnId) {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
this.patch(id, { turns: s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t)) });
|
|
736
|
-
void this.persist(id);
|
|
777
|
+
this.ensure(id);
|
|
778
|
+
this.open(id);
|
|
779
|
+
this.chat.toggleInclude(id, this.chatHost, turnId);
|
|
737
780
|
}
|
|
738
781
|
/**
|
|
739
782
|
* Ask the user to approve something the HOST is about to do, through the same
|
|
@@ -745,19 +788,21 @@ export class AgentRuntime {
|
|
|
745
788
|
* Two different approval dialogs for the same act is how people learn to click
|
|
746
789
|
* the one that appears more often without reading it.
|
|
747
790
|
*
|
|
791
|
+
* The session allows are the engine's, so an always-allow given here and one
|
|
792
|
+
* given to the run answer each other.
|
|
793
|
+
*
|
|
748
794
|
* Resolves false if the session is gone, so a caller can never read a dropped
|
|
749
795
|
* prompt as consent.
|
|
750
796
|
*/
|
|
751
797
|
requestPermission(id, tool, summary) {
|
|
752
|
-
|
|
753
|
-
if (!s)
|
|
798
|
+
if (!this.map.has(id))
|
|
754
799
|
return Promise.resolve(false);
|
|
755
|
-
if (
|
|
800
|
+
if (this.chat.isAllowed(id, undefined, tool))
|
|
756
801
|
return Promise.resolve(true);
|
|
757
802
|
return new Promise((resolve) => {
|
|
758
803
|
// 'you': this path exists for something the USER typed (a `>pd` command),
|
|
759
804
|
// never for the run loop, which raises its own asks through onPermission.
|
|
760
|
-
this.
|
|
805
|
+
this.raisePerm(id, { id: this.nextPermId++, tool, summary, requester: 'you', askedAt: Date.now(), resolve });
|
|
761
806
|
});
|
|
762
807
|
}
|
|
763
808
|
/**
|
|
@@ -771,7 +816,7 @@ export class AgentRuntime {
|
|
|
771
816
|
* already has.
|
|
772
817
|
*
|
|
773
818
|
* IT IS RECORDED OUT OF CONTEXT. Persistence and context-inclusion are
|
|
774
|
-
* separate switches here -
|
|
819
|
+
* separate switches here - every turn is saved, while only `included`
|
|
775
820
|
* turns are sent to the model - and a command should default to written-down
|
|
776
821
|
* but not re-sent. A transcript message is the most expensive kind of
|
|
777
822
|
* context: it is paid again on every subsequent turn for the rest of the
|
|
@@ -783,59 +828,94 @@ export class AgentRuntime {
|
|
|
783
828
|
*/
|
|
784
829
|
/** Mark a console command as running. Paired with recordCommand, which clears
|
|
785
830
|
* it - so the indicator cannot outlive the command even if it fails, since the
|
|
786
|
-
* failure is itself recorded. */
|
|
831
|
+
* failure is itself recorded. Global: the session list shows it running. */
|
|
787
832
|
beginCommand(id, command, startedAt) {
|
|
788
|
-
this.
|
|
833
|
+
this.ensure(id);
|
|
834
|
+
this.open(id);
|
|
835
|
+
this.chat.beginCommand(id, command, startedAt);
|
|
836
|
+
this.notify(id);
|
|
837
|
+
this.notifyGlobal();
|
|
789
838
|
}
|
|
790
839
|
/** Give up on a command that will never settle (the surface unmounted, the
|
|
791
840
|
* session was closed). Clears the indicator WITHOUT recording a result,
|
|
792
841
|
* because there is no honest result to record. */
|
|
793
842
|
abandonCommand(id) {
|
|
794
|
-
if (!this.map.
|
|
843
|
+
if (!this.map.has(id) || !this.chat.state(id).pendingCommand)
|
|
795
844
|
return;
|
|
796
|
-
this.
|
|
845
|
+
this.chat.abandonCommand(id);
|
|
846
|
+
this.notify(id);
|
|
847
|
+
this.notifyGlobal();
|
|
797
848
|
}
|
|
798
849
|
recordCommand(id, command, output, ok) {
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
id: callId,
|
|
811
|
-
type: 'function',
|
|
812
|
-
// `ok` rides along in the arguments deliberately. On a synthetic call
|
|
813
|
-
// it is part of the record, and once kept it tells the model the
|
|
814
|
-
// command failed - which it would otherwise have to infer from
|
|
815
|
-
// whatever the tool happened to print.
|
|
816
|
-
function: { name: 'console_command', arguments: JSON.stringify({ command, ok }) },
|
|
817
|
-
}],
|
|
818
|
-
},
|
|
819
|
-
{
|
|
820
|
-
role: 'tool',
|
|
821
|
-
tool_call_id: callId,
|
|
822
|
-
content: output || (ok ? '(no output)' : '(failed, no output)'),
|
|
823
|
-
},
|
|
824
|
-
],
|
|
825
|
-
};
|
|
826
|
-
this.patch(id, { turns: [...s.turns, turn], pendingCommand: null });
|
|
827
|
-
void this.persist(id);
|
|
850
|
+
this.ensure(id);
|
|
851
|
+
this.open(id);
|
|
852
|
+
this.chat.recordCommand(id, this.chatHost, command, output, ok);
|
|
853
|
+
}
|
|
854
|
+
/** Answer a permission only if it is still the request the caller was shown.
|
|
855
|
+
* Returns false, and changes nothing, when it was answered or replaced. */
|
|
856
|
+
resolvePermIf(sessionId, permId, allow, always = false, note) {
|
|
857
|
+
if (this.map.get(sessionId)?.pendingPerm?.id !== permId)
|
|
858
|
+
return false;
|
|
859
|
+
this.resolvePerm(sessionId, allow, always, note);
|
|
860
|
+
return true;
|
|
828
861
|
}
|
|
829
862
|
/** `note` is the person's reason for a deny (the plan sheet's Deny with a
|
|
830
|
-
* note); it reaches the model after the denial text. Ignored with allow.
|
|
863
|
+
* note); it reaches the model after the denial text. Ignored with allow.
|
|
864
|
+
* An always-allow is the engine's session allow (it never holds a checkpoint). */
|
|
831
865
|
resolvePerm(id, allow, always = false, note) {
|
|
832
866
|
const s = this.map.get(id);
|
|
833
867
|
if (!s?.pendingPerm)
|
|
834
868
|
return;
|
|
835
|
-
if (allow && always
|
|
836
|
-
|
|
869
|
+
if (allow && always)
|
|
870
|
+
this.chat.allow(id, s.pendingPerm.agent, s.pendingPerm.tool);
|
|
837
871
|
s.pendingPerm.resolve(allow, allow ? undefined : note);
|
|
838
|
-
|
|
872
|
+
// The next queued ask, if any, is the card now.
|
|
873
|
+
this.patch(id, { pendingPerm: this.permQueues.get(id)?.shift() ?? null }, true);
|
|
874
|
+
}
|
|
875
|
+
/** Show an ask, or queue it behind the one on screen. */
|
|
876
|
+
raisePerm(id, perm) {
|
|
877
|
+
if (!this.ensure(id).pendingPerm) {
|
|
878
|
+
this.patch(id, { pendingPerm: perm }, true);
|
|
879
|
+
return;
|
|
880
|
+
}
|
|
881
|
+
let q = this.permQueues.get(id);
|
|
882
|
+
if (!q) {
|
|
883
|
+
q = [];
|
|
884
|
+
this.permQueues.set(id, q);
|
|
885
|
+
}
|
|
886
|
+
q.push(perm);
|
|
887
|
+
this.notifyGlobal();
|
|
888
|
+
}
|
|
889
|
+
/** Answer the shown ask and every queued one with a deny, and forget them:
|
|
890
|
+
* nothing is left waiting on a card that can no longer be shown. */
|
|
891
|
+
denyAllPerms(id) {
|
|
892
|
+
const s = this.map.get(id);
|
|
893
|
+
const shown = s?.pendingPerm ?? null;
|
|
894
|
+
if (s)
|
|
895
|
+
s.pendingPerm = null;
|
|
896
|
+
shown?.resolve(false);
|
|
897
|
+
for (const p of this.permQueues.get(id)?.splice(0) ?? [])
|
|
898
|
+
p.resolve(false);
|
|
899
|
+
this.permQueues.delete(id);
|
|
900
|
+
}
|
|
901
|
+
/** Answer `permId` with a deny wherever it waits: on screen (the next one
|
|
902
|
+
* then shows) or in the queue (it is dropped). */
|
|
903
|
+
denyPerm(id, permId) {
|
|
904
|
+
if (this.map.get(id)?.pendingPerm?.id === permId) {
|
|
905
|
+
this.resolvePerm(id, false);
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
const q = this.permQueues.get(id);
|
|
909
|
+
const i = q ? q.findIndex((p) => p.id === permId) : -1;
|
|
910
|
+
if (!q || i < 0)
|
|
911
|
+
return;
|
|
912
|
+
const [perm] = q.splice(i, 1);
|
|
913
|
+
perm.resolve(false);
|
|
914
|
+
}
|
|
915
|
+
/** A tool result a System One check or hook blocked in this page, for the
|
|
916
|
+
* tool card's "Show the result"; undefined once the page has reloaded. */
|
|
917
|
+
blockedResult(sessionId, callId) {
|
|
918
|
+
return this.blocked.get(sessionId)?.get(callId);
|
|
839
919
|
}
|
|
840
920
|
resolveAsk(id, answers) {
|
|
841
921
|
const s = this.map.get(id);
|
|
@@ -858,115 +938,13 @@ export class AgentRuntime {
|
|
|
858
938
|
this.note(id, `Could not save that approval: ${String(e?.message || e)}`);
|
|
859
939
|
});
|
|
860
940
|
}
|
|
861
|
-
stop(id) { this.
|
|
941
|
+
stop(id) { this.chat.stop(id); }
|
|
862
942
|
/** Push user messages into the run already in flight on this session.
|
|
863
943
|
* `hard` cuts the current model stream so they land immediately instead of at
|
|
864
944
|
* the next turn boundary. Returns false when nothing is running -- the caller
|
|
865
945
|
* keeps the message queued rather than losing it. */
|
|
866
946
|
inject(id, messages, hard) {
|
|
867
|
-
|
|
868
|
-
if (!s || !s.running || s.runId == null)
|
|
869
|
-
return false;
|
|
870
|
-
this.getWorker().inject(s.runId, messages, hard);
|
|
871
|
-
return true;
|
|
872
|
-
}
|
|
873
|
-
queueWrite(id, write) {
|
|
874
|
-
// Nothing queued: start now, at the same tick as before this queue existed.
|
|
875
|
-
// A failed write must not stop the ones queued behind it.
|
|
876
|
-
const prev = this.writing.get(id);
|
|
877
|
-
const next = prev ? prev.catch(() => { }).then(write) : write();
|
|
878
|
-
this.writing.set(id, next);
|
|
879
|
-
next.finally(() => { if (this.writing.get(id) === next)
|
|
880
|
-
this.writing.delete(id); }).catch(() => { });
|
|
881
|
-
return next;
|
|
882
|
-
}
|
|
883
|
-
persist(id) {
|
|
884
|
-
return this.queueWrite(id, async () => {
|
|
885
|
-
// The first write to a draft saves it first; a failed save skips this
|
|
886
|
-
// write, and the draft stays a draft for the next one.
|
|
887
|
-
if (this.drafts.has(id)) {
|
|
888
|
-
try {
|
|
889
|
-
await this.materialize(id);
|
|
890
|
-
}
|
|
891
|
-
catch {
|
|
892
|
-
return;
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
await this.persistNow(id);
|
|
896
|
-
});
|
|
897
|
-
}
|
|
898
|
-
async persistNow(id) {
|
|
899
|
-
const s = this.map.get(id);
|
|
900
|
-
/* Never before the stored transcript has loaded (ruling R34). Until then
|
|
901
|
-
* `turns` holds only what this page added -- a /model notice, say -- and
|
|
902
|
-
* saving it would write that over the whole stored chat. Such notes stay
|
|
903
|
-
* in memory -- and once a load has actually failed, say so. */
|
|
904
|
-
if (!s?.loaded) {
|
|
905
|
-
this.flagIfUnsaved(id);
|
|
906
|
-
return;
|
|
907
|
-
}
|
|
908
|
-
// A read that throws is a save that did not happen: persistedLen and
|
|
909
|
-
// saveBlocked stay as they are, and the next write tries again.
|
|
910
|
-
let ses;
|
|
911
|
-
try {
|
|
912
|
-
ses = await this.host.sessions.get(id);
|
|
913
|
-
}
|
|
914
|
-
catch {
|
|
915
|
-
return;
|
|
916
|
-
}
|
|
917
|
-
if (!ses)
|
|
918
|
-
return;
|
|
919
|
-
/* Shorten the stored chat (retry, /clear) only when it is the length this
|
|
920
|
-
* page last saw (ruling R36). Otherwise another writer appended since, and
|
|
921
|
-
* this page's shorter list would delete that writer's newer turns. */
|
|
922
|
-
const known = ses.messages.length === s.persistedLen;
|
|
923
|
-
let turns = s.turns;
|
|
924
|
-
if (!known) {
|
|
925
|
-
/* Another writer changed it since. Saving this page's list as it stands
|
|
926
|
-
* would write it BY POSITION -- a stale [q0] that sent q3 put q3 at index
|
|
927
|
-
* 1, over the other tab's q1 -- so merge by turn id instead, over what
|
|
928
|
-
* was just read: stored order first, this page's version of any turn
|
|
929
|
-
* both hold (last writer wins per turn), then this page's turns the
|
|
930
|
-
* store does not have, in this page's order. A flat (daemon) chat's
|
|
931
|
-
* turns get the same `stored-<i>` ids here as at load, so a turn this
|
|
932
|
-
* page loaded from it matches and is not added twice.
|
|
933
|
-
*
|
|
934
|
-
* The page then shows the merged chat. Nothing it holds is dropped, and
|
|
935
|
-
* the store loses nothing: the merge holds every stored turn, so the
|
|
936
|
-
* save may rewrite freely (allowTruncate below), which a flat store's
|
|
937
|
-
* conversion to turns needs. */
|
|
938
|
-
const stored = turnsFromSessionMessages(ses.messages);
|
|
939
|
-
const mine = new Map(s.turns.map((t) => [t.id, t]));
|
|
940
|
-
const storedIds = new Set(stored.map((t) => t.id));
|
|
941
|
-
/* Only the FIRST stored turn with an id takes this page's version. A
|
|
942
|
-
* second one with the same id is another turn that collided (ids are
|
|
943
|
-
* meant to be unique; turnsFromSessionMessages keeps its synthetic ones
|
|
944
|
-
* so), and replacing it too would write one turn twice and lose the
|
|
945
|
-
* other on this rewrite. */
|
|
946
|
-
const used = new Set();
|
|
947
|
-
const merged = stored.map((t) => {
|
|
948
|
-
const own = mine.get(t.id);
|
|
949
|
-
if (!own || used.has(t.id))
|
|
950
|
-
return t;
|
|
951
|
-
used.add(t.id);
|
|
952
|
-
return own;
|
|
953
|
-
});
|
|
954
|
-
turns = [...merged, ...s.turns.filter((t) => !storedIds.has(t.id))];
|
|
955
|
-
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)`);
|
|
956
|
-
this.patch(id, { turns, mergeTick: s.mergeTick + 1 });
|
|
957
|
-
}
|
|
958
|
-
ses.messages = turns;
|
|
959
|
-
if (s.model)
|
|
960
|
-
ses.model = s.model;
|
|
961
|
-
try {
|
|
962
|
-
// Known: this page's list IS the chat, and may shorten it (R36). A
|
|
963
|
-
// merge: holds every stored turn, so rewriting cannot lose one.
|
|
964
|
-
await this.host.sessions.save(ses, { allowTruncate: true });
|
|
965
|
-
s.persistedLen = turns.length;
|
|
966
|
-
if (s.saveBlocked)
|
|
967
|
-
this.patch(id, { saveBlocked: null });
|
|
968
|
-
}
|
|
969
|
-
catch { /* a failed save leaves persistedLen as it was */ }
|
|
947
|
+
return this.chat.inject(id, this.chatHost, messages, hard);
|
|
970
948
|
}
|
|
971
949
|
/** Filename of the system prompt this surface will run with next — what the
|
|
972
950
|
* context drawer's active-prompt chip shows. Mirrors composeSystem's own
|
|
@@ -1036,21 +1014,7 @@ export class AgentRuntime {
|
|
|
1036
1014
|
const s = this.ensure(id);
|
|
1037
1015
|
// systemExtra (e.g. Craft's document-creation directive) leads the context.
|
|
1038
1016
|
const blocks = this.systemExtra ? [this.systemExtra] : [];
|
|
1039
|
-
|
|
1040
|
-
const src = svc.kind === 'directory' ? `local folder "${svc.label}"`
|
|
1041
|
-
: svc.kind === 'sidecar' ? `connected sidecar machine "${svc.label}"`
|
|
1042
|
-
: `virtual in-browser workspace "${svc.label}"`;
|
|
1043
|
-
const flags = `${svc.isLocalDisk ? "on the user's real disk" : 'in-browser'}, ${svc.writable ? 'writable' : 'read-only'}`;
|
|
1044
|
-
blocks.push(`--- Workspace ---\n` +
|
|
1045
|
-
`Connected source: ${src} (${flags}).\n` +
|
|
1046
|
-
`All file paths are relative to this workspace root (POSIX, no leading slash, e.g. "prompts/base.prmd"). ` +
|
|
1047
|
-
`There is no separate working directory or shell — inspect the workspace with list_files / read_file / ` +
|
|
1048
|
-
`stat_file / search_files, and change it with write_file / create_file / delete_file / rename_file. ` +
|
|
1049
|
-
`The browser does not expose the folder's absolute path, so report locations relative to the root.`);
|
|
1050
|
-
}
|
|
1051
|
-
else {
|
|
1052
|
-
blocks.push(`--- Workspace ---\nNo file workspace is connected, so the file tools are unavailable. Say so rather than guessing about files.`);
|
|
1053
|
-
}
|
|
1017
|
+
blocks.push(workspaceBlock(svc));
|
|
1054
1018
|
// Host-supplied extra system-context blocks (project instructions, project
|
|
1055
1019
|
// memory, editor buffer …), resolved per send against the active FileService.
|
|
1056
1020
|
for (const p of this.host.contextProviders ?? []) {
|
|
@@ -1082,526 +1046,120 @@ export class AgentRuntime {
|
|
|
1082
1046
|
systemPath,
|
|
1083
1047
|
systemText,
|
|
1084
1048
|
contextText: blocks.join('\n\n'),
|
|
1085
|
-
|
|
1049
|
+
// A user's own prompt that still prints {{ tools }} gets the sent tools there.
|
|
1050
|
+
toolsText: TOOLS_MARKER,
|
|
1086
1051
|
params: this.host.systemParams?.(),
|
|
1087
1052
|
memoryIndex: this.host.memoryIndex,
|
|
1088
1053
|
});
|
|
1089
1054
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1055
|
+
/* ---- run ---- */
|
|
1056
|
+
/* R4 on this host. The engine freezes a conversation's system after its first
|
|
1057
|
+
* turn; this side drops it before every send and retry, so each turn composes
|
|
1058
|
+
* afresh, as it always has. Nearly every input of composeSystem is something
|
|
1059
|
+
* the person changes between turns, and most of them never pass through this
|
|
1060
|
+
* runtime: the persona and a parts override arrive in SendOpts, the mode is
|
|
1061
|
+
* setMode, and the editor buffer, the current Craft document, the
|
|
1062
|
+
* destination, project instructions, attached documents, the workspace and
|
|
1063
|
+
* the chosen system prompt are read live from the host and its stores. A
|
|
1064
|
+
* reset per user action would miss every one of those it cannot see.
|
|
1065
|
+
* `stored: false`: this host never stores a system (withoutSystem), so the
|
|
1066
|
+
* engine has nothing to remove and does not read the store to look. */
|
|
1067
|
+
recomposeSystem(id) {
|
|
1068
|
+
this.chat.resetSystem(id, { stored: false });
|
|
1069
|
+
}
|
|
1070
|
+
/** A send or retry's context, queued for the engine's guard to start. */
|
|
1071
|
+
queueTurn(id, opts, text, images) {
|
|
1072
|
+
const t = {
|
|
1073
|
+
opts, text, images, ui: this.ensure(id), priorInput: '', priorAttachments: [], allowed: false, userTurnId: '',
|
|
1074
|
+
startedAt: Date.now(), recBuf: [], turnStart: 0, model: '', priced: null,
|
|
1075
|
+
calleeSpend: { inTok: 0, outTok: 0, cost: 0 }, control: null,
|
|
1076
|
+
};
|
|
1077
|
+
const q = this.pending.get(id);
|
|
1078
|
+
if (q)
|
|
1079
|
+
q.push(t);
|
|
1080
|
+
else
|
|
1081
|
+
this.pending.set(id, [t]);
|
|
1082
|
+
return t;
|
|
1083
|
+
}
|
|
1084
|
+
/** A turn that never reached the guard (refused before it started) leaves the queue. */
|
|
1085
|
+
dropTurn(id, t) {
|
|
1086
|
+
const q = this.pending.get(id);
|
|
1087
|
+
if (q) {
|
|
1088
|
+
const at = q.indexOf(t);
|
|
1089
|
+
if (at >= 0)
|
|
1090
|
+
q.splice(at, 1);
|
|
1091
|
+
if (!q.length)
|
|
1092
|
+
this.pending.delete(id);
|
|
1106
1093
|
}
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1094
|
+
if (this.active.get(id) === t)
|
|
1095
|
+
this.active.delete(id);
|
|
1096
|
+
}
|
|
1097
|
+
/** The turn the engine is running on `id`; it always started one before asking. */
|
|
1098
|
+
turnFor(id) {
|
|
1099
|
+
const t = this.active.get(id);
|
|
1100
|
+
if (!t)
|
|
1101
|
+
throw new Error(`no turn in flight for ${id}`);
|
|
1102
|
+
return t;
|
|
1103
|
+
}
|
|
1104
|
+
/* OPTIMISTIC, and the ordering here is the whole point.
|
|
1105
|
+
*
|
|
1106
|
+
* The feature guard used to run FIRST, and on the web host it is a network
|
|
1107
|
+
* call (src/guards/apiGuard.ts fetches /entitlements). For that entire round
|
|
1108
|
+
* trip the composer still held the text, no turn existed and `running` was
|
|
1109
|
+
* false -- pressing send looked like it had done nothing, for seconds.
|
|
1110
|
+
* Reported from use.
|
|
1111
|
+
*
|
|
1112
|
+
* The engine shows the turn and reads as running before it asks the guard;
|
|
1113
|
+
* the guard (chatHost.guard) empties the composer in the same tick. The old
|
|
1114
|
+
* order existed so that "a denial loses nothing", and that is kept too: a
|
|
1115
|
+
* denial puts the turn (the engine) and the draft (here) back. */
|
|
1116
|
+
startTurn(id) {
|
|
1117
|
+
const q = this.pending.get(id);
|
|
1118
|
+
if (!q)
|
|
1119
|
+
return null;
|
|
1120
|
+
/* Which waiting send this is: the engine has just put its user turn last.
|
|
1121
|
+
* Two sends that both waited on the load race for it, and the one the
|
|
1122
|
+
* engine refused leaves the queue when its send returns. */
|
|
1123
|
+
const turns = this.chat.state(id).turns;
|
|
1124
|
+
const last = turns[turns.length - 1]?.messages[0];
|
|
1125
|
+
const text = last ? userText(last) : '';
|
|
1126
|
+
const images = last ? userImages(last) : [];
|
|
1127
|
+
const at = q.findIndex((x) => x.text === text && x.images.length === images.length && x.images.every((u, i) => u === images[i]));
|
|
1128
|
+
const [t] = q.splice(at >= 0 ? at : 0, 1);
|
|
1129
|
+
if (!q.length)
|
|
1130
|
+
this.pending.delete(id);
|
|
1131
|
+
if (!t)
|
|
1132
|
+
return null;
|
|
1133
|
+
t.userTurnId = turns[turns.length - 1]?.id ?? '';
|
|
1134
|
+
this.active.set(id, t);
|
|
1135
|
+
const s = t.ui;
|
|
1136
|
+
t.priorInput = s.input;
|
|
1137
|
+
t.priorAttachments = s.attachments;
|
|
1126
1138
|
/* The box empties only when what it held is what is being sent. A retry,
|
|
1127
1139
|
* a follow-up chip or a host's own send carries other text, and wiping
|
|
1128
1140
|
* what the user was typing meanwhile would lose it (the composer clears
|
|
1129
1141
|
* its own send before calling here, so that path still ends empty). */
|
|
1130
|
-
const keptInput = priorInput.trim() && priorInput.trim() !== text.trim() ? priorInput : '';
|
|
1131
|
-
this.patch(id, {
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
if (!gate.allowed) {
|
|
1143
|
-
// Roll the optimism back: drop the turn, restore what was typed (and any
|
|
1144
|
-
// pasted images), and stop reading as running. A turn left behind here is
|
|
1145
|
-
// a message the user believes they sent and the agent never saw.
|
|
1146
|
-
this.patch(id, {
|
|
1147
|
-
turns: this.ensure(id).turns.filter((t) => t.id !== userTurn.id),
|
|
1148
|
-
input: priorInput || text,
|
|
1149
|
-
attachments: priorAttachments.length ? priorAttachments : images,
|
|
1150
|
-
running: false,
|
|
1151
|
-
live: null,
|
|
1152
|
-
error: gate.reason,
|
|
1153
|
-
}, true);
|
|
1142
|
+
const keptInput = t.priorInput.trim() && t.priorInput.trim() !== t.text.trim() ? t.priorInput : '';
|
|
1143
|
+
this.patch(id, { input: keptInput, attachments: [], suggestions: [], policyNotes: [], degradedModel: null }, true);
|
|
1144
|
+
return t;
|
|
1145
|
+
}
|
|
1146
|
+
/** Send a message and run the agent for `id`. Concurrent with other sessions:
|
|
1147
|
+
* each run is its own AbortController + watchdog and streams into its own
|
|
1148
|
+
* slice. The engine loads the stored chat first (ruling R35), refuses a send
|
|
1149
|
+
* while one runs (R37), and settles once every write the turn queued has. */
|
|
1150
|
+
async send(id, text, images, opts) {
|
|
1151
|
+
this.ensure(id);
|
|
1152
|
+
this.forgotten.delete(id);
|
|
1153
|
+
if ((!text && images.length === 0) || this.chat.state(id).running)
|
|
1154
1154
|
return;
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
// refused send leaves nothing behind. A failed save does not stop the run:
|
|
1159
|
-
// its first write after it tries again.
|
|
1160
|
-
if (this.drafts.has(id))
|
|
1161
|
-
await this.materialize(id).catch(() => { });
|
|
1162
|
-
// Title an untitled session from its first message, so the switcher is readable.
|
|
1163
|
-
// A hand-renamed session is locked, so its chosen title is left alone.
|
|
1164
|
-
if (isFirst && text) {
|
|
1165
|
-
await this.queueWrite(id, async () => {
|
|
1166
|
-
const ses = await this.host.sessions.get(id);
|
|
1167
|
-
if (ses && !ses.titleLocked) {
|
|
1168
|
-
ses.title = text.slice(0, 48);
|
|
1169
|
-
await this.host.sessions.save(ses).catch(() => { });
|
|
1170
|
-
this.notifyGlobal();
|
|
1171
|
-
}
|
|
1172
|
-
}).catch(() => { });
|
|
1173
|
-
}
|
|
1174
|
-
const outgoing = outgoingMessages(nextTurns);
|
|
1175
|
-
/* Transcript-only turns noteUser() added for messages injected mid-run.
|
|
1176
|
-
* They exist so the pivot is visible WHILE the run is in flight; once the
|
|
1177
|
-
* run returns, its own copy of each injected message arrives in `created`
|
|
1178
|
-
* and folds into the user turn, so keeping these would render the text a
|
|
1179
|
-
* second time below the final reply, where it reads as unanswered. */
|
|
1180
|
-
const injectedTurnIds = [];
|
|
1181
|
-
/* The seams THIS run wrote. The last one carries the run's folded view
|
|
1182
|
-
* once the run returns, so the next send starts from the fold instead of
|
|
1183
|
-
* re-folding the raw transcript. */
|
|
1184
|
-
const seamIds = [];
|
|
1185
|
-
let verification;
|
|
1186
|
-
let auditIncomplete;
|
|
1187
|
-
const ac = new AbortController();
|
|
1188
|
-
s.abort = ac;
|
|
1189
|
-
// This turn's events append to the session's accumulated stream (one recording
|
|
1190
|
-
// per session, not per turn) — see Internal.recEvents.
|
|
1191
|
-
const recBuf = s.recEvents;
|
|
1192
|
-
// Where THIS turn's events start — so `ok` reflects only this turn (a transient
|
|
1193
|
-
// error in an earlier turn must not mark a recovered session as failed forever).
|
|
1194
|
-
const turnStart = recBuf.length;
|
|
1195
|
-
// Mark a FOLLOW-UP user turn so the replay shows it as a bubble in place. The
|
|
1196
|
-
// first turn's prompt is the recording's `prompt` header, so only record once
|
|
1197
|
-
// the session already has replayable content (avoids duplicating it).
|
|
1198
|
-
if (recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
|
|
1199
|
-
recBuf.push({ at: Date.now(), ev: { type: 'user', content: text, images: images.length ? images.slice() : undefined } });
|
|
1200
|
-
}
|
|
1201
|
-
// Shared wedged-run watchdog (see runWatchdog.ts): soft window while
|
|
1202
|
-
// streaming, paused mid-tool with a hard cap so a never-settling tool
|
|
1203
|
-
// (half-open sidecar socket) can't hang the run forever.
|
|
1204
|
-
const dog = createRunWatchdog((reason) => {
|
|
1205
|
-
ac.abort();
|
|
1206
|
-
this.patch(id, {
|
|
1207
|
-
error: reason === 'tool-hard-cap'
|
|
1208
|
-
? 'A tool ran for over 10 minutes without completing — aborted. The connection to it may have dropped.'
|
|
1209
|
-
: 'No response from the gateway in 45s — aborted. Check that you are signed in and the backend is reachable.',
|
|
1210
|
-
});
|
|
1211
|
-
}, { softMs: WATCHDOG_MS });
|
|
1212
|
-
dog.bump();
|
|
1155
|
+
// The browser never freezes its system: composeSystem reads live inputs each turn.
|
|
1156
|
+
this.recomposeSystem(id);
|
|
1157
|
+
const t = this.queueTurn(id, opts, text, images);
|
|
1213
1158
|
try {
|
|
1214
|
-
|
|
1215
|
-
* files, and the loop owns both the index and `use_skill`. Resolved for
|
|
1216
|
-
* THIS session, so a skill switched off in one conversation is still
|
|
1217
|
-
* offered in every other. Read alongside compose rather than after it,
|
|
1218
|
-
* so it adds no latency ahead of the run. Never fatal: a read failure
|
|
1219
|
-
* costs skills, not the run. */
|
|
1220
|
-
const skillDeny = this.host.capabilities?.skillDeny?.(id) ?? [];
|
|
1221
|
-
const [system, skills] = await Promise.all([
|
|
1222
|
-
this.composeSystem(id, opts.persona, opts.partsOverride),
|
|
1223
|
-
enabledSkillCatalog((name) => !skillDeny.includes(name)).catch(() => []),
|
|
1224
|
-
]);
|
|
1225
|
-
const model = this.chatModel(id);
|
|
1226
|
-
/* Decided now, from what is true now: the chat's own model is gone and
|
|
1227
|
-
* the one it falls back to cannot hold it. A model picked since load, or
|
|
1228
|
-
* a catalog that has caught up, changes the answer. */
|
|
1229
|
-
const forceFold = !!this.lostModel(s) && !this.canHold(id, model);
|
|
1230
|
-
// The chat now runs on `model`; storing it is what clears the notice.
|
|
1231
|
-
if (this.host.perChatModel && s.model !== model)
|
|
1232
|
-
this.patch(id, { model });
|
|
1233
|
-
// Live model for onBreach pricing — reassigned when a degrade breach
|
|
1234
|
-
// switches the run onto a cheaper model (see onBreach below).
|
|
1235
|
-
let activeModel = model;
|
|
1236
|
-
const priced = this.host.modelInfo(model);
|
|
1237
|
-
const contextWindow = priced.contextWindow ?? 128000;
|
|
1238
|
-
// The surface's constraints default (Task 7), overridden per run when the
|
|
1239
|
-
// caller passes one (a masked-override envelope from `mergeOverride`) —
|
|
1240
|
-
// mirrors AgentPanel's `eff = { ...savedDefault, ...perRunOverride }`.
|
|
1241
|
-
// NOTE: this deliberately reads `this.surface`'s constraints, not a
|
|
1242
|
-
// hardcoded 'agent' — a behavior change for /craft vs. the pre-extraction
|
|
1243
|
-
// class (which always read 'agent'); 'craft' has no saved defaults so the
|
|
1244
|
-
// effective result is unchanged.
|
|
1245
|
-
const constraints = { ...this.host.constraints(this.surface), ...(opts.constraints ?? {}) };
|
|
1246
|
-
const hasBudgetLimits = constraints.maxUsd != null || constraints.maxTokens != null || constraints.maxMs != null;
|
|
1247
|
-
// Capability seam (Task 3): only send toolNames when the host's tools
|
|
1248
|
-
// getter is NOT the default allTools (reference inequality) — this keeps
|
|
1249
|
-
// the default path byte-identical, since every surface today passes
|
|
1250
|
-
// `tools: allTools` (or omits capabilities entirely).
|
|
1251
|
-
const capTools = this.host.capabilities?.tools;
|
|
1252
|
-
const toolNames = capTools && capTools !== allTools ? capTools().map((t) => t.name) : undefined;
|
|
1253
|
-
/* RESOLVED PER RUN, FOR THIS SESSION. The composer popout can switch a
|
|
1254
|
-
* source (an MCP server, a builtin namespace, a skill) off for ONE
|
|
1255
|
-
* conversation, and this is where that choice stops being a UI state and
|
|
1256
|
-
* becomes tools the worker will not offer. Passing the id rather than
|
|
1257
|
-
* reading an "active session" global keeps it correct while two surfaces
|
|
1258
|
-
* run at once. A host that ignores the argument -- crew does -- is
|
|
1259
|
-
* unaffected. */
|
|
1260
|
-
const toolDeny = this.host.capabilities?.toolDeny?.(id);
|
|
1261
|
-
// Resolved per run so an edit to a callee (or its canCall list) applies to
|
|
1262
|
-
// the next delegation without rebuilding the runtime. Never fatal: a read
|
|
1263
|
-
// failure costs delegation, not the run.
|
|
1264
|
-
const resolvedDelegates = await (this.host.capabilities?.delegates?.().catch(() => []) ?? Promise.resolve([]));
|
|
1265
|
-
/* Under a usd cap a callee's turns are priced against this run's meter,
|
|
1266
|
-
* so a callee whose model this host cannot price would spend without
|
|
1267
|
-
* moving it. Refused, with the daemon's words (executor.ts). */
|
|
1268
|
-
const crewDelegates = constraints.maxUsd != null
|
|
1269
|
-
? resolvedDelegates.map((d) => (d.model && !pricedForUsd(this.host.usdRates?.(d.model) ?? null)
|
|
1270
|
-
? { ...d, model: '', refusal: unpricedCalleeRefusal(d.name, d.model) }
|
|
1271
|
-
: d))
|
|
1272
|
-
: resolvedDelegates;
|
|
1273
|
-
const crewOrchestration = this.host.capabilities?.orchestration?.();
|
|
1274
|
-
const crewTraceRoot = this.host.capabilities?.traceRoot?.();
|
|
1275
|
-
const auditAgent = this.host.capabilities?.agentName?.();
|
|
1276
|
-
/* What this turn's sub-agents (delegated callees) spent, from their
|
|
1277
|
-
* node-event usage (review B-I6). Added to the turn's stats and the
|
|
1278
|
-
* usage ledger, as the daemon's run log does. Only a tally for display:
|
|
1279
|
-
* the budget gate meters the same events itself (workerClient), so
|
|
1280
|
-
* nothing here feeds it. */
|
|
1281
|
-
const calleeSpend = { inTok: 0, outTok: 0, cost: 0 };
|
|
1282
|
-
const res = await this.getWorker().run(outgoing, {
|
|
1283
|
-
model,
|
|
1284
|
-
temperature: this.getTemperature?.(),
|
|
1285
|
-
toolDeny,
|
|
1286
|
-
// What this conversation already found with tool_search, so turn 9
|
|
1287
|
-
// does not search again for what turn 2 loaded.
|
|
1288
|
-
loadedTools: loadedToolNames(id),
|
|
1289
|
-
skills,
|
|
1290
|
-
crewDelegates,
|
|
1291
|
-
crewOrchestration,
|
|
1292
|
-
...(crewTraceRoot ? { crewTraceRoot } : {}),
|
|
1293
|
-
system,
|
|
1294
|
-
policy: this.host.policy
|
|
1295
|
-
? this.host.policy(getPolicy(opts.policyName) ?? STANDARD_POLICY)
|
|
1296
|
-
: (getPolicy(opts.policyName) ?? STANDARD_POLICY),
|
|
1297
|
-
...(this.host.sessionsRoot ? { sessionsRoot: this.host.sessionsRoot() } : {}),
|
|
1298
|
-
compression: { mode: this.host.compression(), contextWindow },
|
|
1299
|
-
...(forceFold ? { forceFold: true } : {}),
|
|
1300
|
-
signal: ac.signal,
|
|
1301
|
-
sessionId: id,
|
|
1302
|
-
toolNames,
|
|
1303
|
-
// Record the worker's run id the instant it exists, so inject() (which
|
|
1304
|
-
// can arrive at any point during this run) has something live to address.
|
|
1305
|
-
onStart: (runId) => { s.runId = runId; },
|
|
1306
|
-
// The queue's only way to tell "landed" from "never made it": the
|
|
1307
|
-
// AgentEvent 'injected' the loop emits per message carries text, not the
|
|
1308
|
-
// caller's id, so a reverting caller (Task 7's queue) reads THIS to know
|
|
1309
|
-
// which of its still-pending ids it must NOT revert (reverting one that
|
|
1310
|
-
// already landed would re-send it as a fresh turn and duplicate it).
|
|
1311
|
-
onInjected: (ids) => { this.patch(id, { injectedIds: [...this.ensure(id).injectedIds, ...ids] }); },
|
|
1312
|
-
onPermission: (req) => new Promise((done) => {
|
|
1313
|
-
// A checkpoint is asked every time, whatever the session allowed.
|
|
1314
|
-
if (!isCheckpointTool(req.tool) && s.allowed.has(allowKey(req.agent, req.tool))) {
|
|
1315
|
-
done({ allow: true, by: 'session' });
|
|
1316
|
-
return;
|
|
1317
|
-
}
|
|
1318
|
-
// A callee's ask names who called it: this chat's own agent.
|
|
1319
|
-
const caller = req.agent ? (auditAgent ? { caller: auditAgent } : {}) : {};
|
|
1320
|
-
// A deny with a note carries it; every other answer stays the bare
|
|
1321
|
-
// boolean it always was.
|
|
1322
|
-
const resolve = (allow, note) => done(!allow && note ? { allow, by: 'human', note } : allow);
|
|
1323
|
-
this.patch(id, { pendingPerm: { ...req, ...caller, resolve } }, true);
|
|
1324
|
-
}),
|
|
1325
|
-
// Non-blocking: fires when the approvals bridge records a decision and
|
|
1326
|
-
// offers a standing rule. The context identifies which session/surface
|
|
1327
|
-
// recorded it — the runId is added by the bridge itself.
|
|
1328
|
-
approvalContext: { surface: this.surface, sessionId: id },
|
|
1329
|
-
// The audit log files this run under its session, and under its agent
|
|
1330
|
-
// when the host names one (persona is prompt text, never an identity).
|
|
1331
|
-
auditContext: { session: id, ...(auditAgent ? { agent: auditAgent } : {}) },
|
|
1332
|
-
onApprovalSuggestion: (sug) => this.patch(id, { approvalSuggestion: sug }, true),
|
|
1333
|
-
onAsk: (questions) => new Promise((resolve) => {
|
|
1334
|
-
dog.pause();
|
|
1335
|
-
this.patch(id, { pendingAsk: { questions, resolve: (a) => { resolve(a); dog.bump(); } } }, true);
|
|
1336
|
-
}),
|
|
1337
|
-
// Editor-buffer surfaces pass onEditReview/editorFile (Task 4); the
|
|
1338
|
-
// standalone page omits both, so propose_edit keeps its non-blocking
|
|
1339
|
-
// fallback exactly as before.
|
|
1340
|
-
editorFile: opts.editorFile,
|
|
1341
|
-
onNodeEvent: (e) => {
|
|
1342
|
-
publishSubagentEvent({ sessionId: id, surface: this.surface, event: e });
|
|
1343
|
-
const c = nodeUsageOf(e);
|
|
1344
|
-
if (c) {
|
|
1345
|
-
calleeSpend.inTok += c.usage.promptTokens;
|
|
1346
|
-
calleeSpend.outTok += c.usage.completionTokens;
|
|
1347
|
-
const m = c.model ?? model;
|
|
1348
|
-
// Priced only when this host really knows the model's rates; an
|
|
1349
|
-
// unknown one still counts its tokens (never an invented cost).
|
|
1350
|
-
if (pricedForUsd(this.host.usdRates?.(m) ?? null))
|
|
1351
|
-
calleeSpend.cost += this.host.price(c.usage, m);
|
|
1352
|
-
}
|
|
1353
|
-
opts.onNodeEvent?.(e);
|
|
1354
|
-
},
|
|
1355
|
-
onEditReview: opts.onEditReview
|
|
1356
|
-
? async (callId) => {
|
|
1357
|
-
// propose_edit blocks on the user's verdict — pause the watchdog
|
|
1358
|
-
// (like an open ask form), resume once the host resolves it.
|
|
1359
|
-
dog.pause();
|
|
1360
|
-
const r = await opts.onEditReview(callId, ac.signal);
|
|
1361
|
-
dog.bump();
|
|
1362
|
-
return r;
|
|
1363
|
-
}
|
|
1364
|
-
: undefined,
|
|
1365
|
-
/* start_project offering to bring the conversation with it. The run is
|
|
1366
|
-
* STILL WRITING, so this cannot be file work the tool does on its own:
|
|
1367
|
-
* only here is it known which session this run belongs to, and only
|
|
1368
|
-
* here can the store that is saving it be re-pointed. The host does
|
|
1369
|
-
* both, or the option is not offered at all. */
|
|
1370
|
-
onMoveSession: this.host.moveSession
|
|
1371
|
-
? async (projectRoot) => {
|
|
1372
|
-
dog.pause();
|
|
1373
|
-
const message = await this.host.moveSession(id, projectRoot);
|
|
1374
|
-
dog.bump();
|
|
1375
|
-
// Its chats moved surfaces: the list this session was in no longer
|
|
1376
|
-
// holds it, and the one it went to now does.
|
|
1377
|
-
this.notifyGlobal();
|
|
1378
|
-
return message;
|
|
1379
|
-
}
|
|
1380
|
-
: undefined,
|
|
1381
|
-
constraints: hasBudgetLimits ? constraints : undefined,
|
|
1382
|
-
price: (usage, priceModel) => this.host.price(usage, priceModel),
|
|
1383
|
-
onBreach: constraints.onBreach && this.host.breach
|
|
1384
|
-
? async (breach) => {
|
|
1385
|
-
// Tracks the model actually running (workerClient resets its breach
|
|
1386
|
-
// latch after a degrade, so a second in-run breach can fire on the
|
|
1387
|
-
// NEW model) — price the next breach against that, not the model
|
|
1388
|
-
// captured at run() time.
|
|
1389
|
-
const r = await this.host.breach.decide(constraints.onBreach, breach, {
|
|
1390
|
-
currentModel: activeModel,
|
|
1391
|
-
// Reuse the tool-permission confirm dialog (pendingPerm/resolvePerm) as
|
|
1392
|
-
// the yes/no "continue past budget?" prompt — same pattern as AgentPanel's
|
|
1393
|
-
// per-run budget control: a 'budget' pseudo-tool the overlay special-cases.
|
|
1394
|
-
ask: (m) => new Promise((resolve) => {
|
|
1395
|
-
dog.pause();
|
|
1396
|
-
this.patch(id, { pendingPerm: { tool: 'budget', summary: m, resolve: (b) => { resolve(b); dog.bump(); } } }, true);
|
|
1397
|
-
}),
|
|
1398
|
-
// Reuse the existing "append a local notice turn" affordance (used
|
|
1399
|
-
// today for /model confirmations) for a soft warn — ALSO appended to
|
|
1400
|
-
// policyNotes, unless the caller opted into policyNotes-only
|
|
1401
|
-
// rendering (see SendOpts.budgetNotices).
|
|
1402
|
-
warn: (m) => {
|
|
1403
|
-
if ((opts.budgetNotices ?? 'transcript') === 'transcript')
|
|
1404
|
-
this.note(id, m);
|
|
1405
|
-
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: m, action: 'warn' }] });
|
|
1406
|
-
},
|
|
1407
|
-
cheaperModel: this.host.breach.cheaperModel(() => s.lastUsage),
|
|
1408
|
-
});
|
|
1409
|
-
if (r.model) {
|
|
1410
|
-
activeModel = r.model;
|
|
1411
|
-
this.patch(id, { degradedModel: r.model });
|
|
1412
|
-
}
|
|
1413
|
-
return r;
|
|
1414
|
-
}
|
|
1415
|
-
: undefined,
|
|
1416
|
-
onEvent: (e) => {
|
|
1417
|
-
/* The task list is fed HERE, at the one choke point every surface's
|
|
1418
|
-
* runtime passes through - not by a host.
|
|
1419
|
-
*
|
|
1420
|
-
* It used to be wired in prompd's AgentPanel alone, so `todo_write`
|
|
1421
|
-
* worked there and nowhere else: on corenel the model dutifully kept a
|
|
1422
|
-
* plan across a multi-step job and not one surface could read it,
|
|
1423
|
-
* because nothing was listening. A tool whose entire purpose is to be
|
|
1424
|
-
* SEEN cannot depend on each host remembering to subscribe.
|
|
1425
|
-
*
|
|
1426
|
-
* Other tool calls bucket as activity under whichever task is active,
|
|
1427
|
-
* which is what gives an expanded task its detail. */
|
|
1428
|
-
if (e.type === 'tool-call') {
|
|
1429
|
-
if (e.name === 'todo_write')
|
|
1430
|
-
applyTodoWrite(id, e.args);
|
|
1431
|
-
else
|
|
1432
|
-
addActivity(id, e.id, e.name);
|
|
1433
|
-
}
|
|
1434
|
-
else if (e.type === 'tool-result') {
|
|
1435
|
-
resolveActivity(id, e.id, e.isError);
|
|
1436
|
-
}
|
|
1437
|
-
opts.onRunEvent?.(e);
|
|
1438
|
-
dog.onEvent(e);
|
|
1439
|
-
recBuf.push({ at: Date.now(), ev: e });
|
|
1440
|
-
// Heal persistence is centralized in the worker client (host workerDeps
|
|
1441
|
-
// onHeal) so it captures sub-agent/DAG-node heals too — not tapped here.
|
|
1442
|
-
// We just surface the live "Recovering…" status (null clears it on recovered).
|
|
1443
|
-
if (e.type === 'heal') {
|
|
1444
|
-
this.patch(id, { recovery: this.host.healNote?.(e.record) ?? null });
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
if (e.type === 'offload') {
|
|
1448
|
-
this.patch(id, { offloadTick: this.ensure(id).offloadTick + 1 });
|
|
1449
|
-
return;
|
|
1450
|
-
}
|
|
1451
|
-
if (e.type === 'checkpoint') {
|
|
1452
|
-
seamIds.push(this.noteCheckpoint(id, e.collapsed, e.checkpoint));
|
|
1453
|
-
return;
|
|
1454
|
-
}
|
|
1455
|
-
if (e.type === 'tools-loaded') {
|
|
1456
|
-
setLoadedTools(id, e.names);
|
|
1457
|
-
return;
|
|
1458
|
-
}
|
|
1459
|
-
if (e.type === 'policy-violation') {
|
|
1460
|
-
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: e.kind, message: e.message, action: e.action }] });
|
|
1461
|
-
return;
|
|
1462
|
-
}
|
|
1463
|
-
// Neither of these reads `live`, so they belong ABOVE its early
|
|
1464
|
-
// return -- below it they would silently stop firing the moment a
|
|
1465
|
-
// run has no live slice.
|
|
1466
|
-
if (e.type === 'turn-verified') {
|
|
1467
|
-
verification = e.verification;
|
|
1468
|
-
return;
|
|
1469
|
-
}
|
|
1470
|
-
if (e.type === 'audit-incomplete') {
|
|
1471
|
-
auditIncomplete = { lost: e.lost, ...(e.unconfirmed ? { unconfirmed: true } : {}) };
|
|
1472
|
-
return;
|
|
1473
|
-
}
|
|
1474
|
-
if (e.type === 'injected') {
|
|
1475
|
-
injectedTurnIds.push(this.noteUser(id, e.content));
|
|
1476
|
-
return;
|
|
1477
|
-
}
|
|
1478
|
-
if (e.type === 'turn-interrupted') {
|
|
1479
|
-
// The partial reply already streamed into `live` is never coming
|
|
1480
|
-
// back -- clear it so the discarded text does not read as a real
|
|
1481
|
-
// answer sitting above the injected message.
|
|
1482
|
-
this.patch(id, { live: foldLive(this.ensure(id).live ?? { text: '', tools: [] }, e) });
|
|
1483
|
-
return;
|
|
1484
|
-
}
|
|
1485
|
-
const live = s.live;
|
|
1486
|
-
if (!live)
|
|
1487
|
-
return;
|
|
1488
|
-
if (e.type === 'assistant-delta' || e.type === 'assistant' || e.type === 'tool-call' || e.type === 'tool-result') {
|
|
1489
|
-
if (e.type === 'tool-result' && typeof e.durationMs === 'number')
|
|
1490
|
-
s.toolMs[e.id] = e.durationMs;
|
|
1491
|
-
this.patch(id, e.type === 'assistant-delta' ? { live: foldLive(live, e), recovery: null } : { live: foldLive(live, e) });
|
|
1492
|
-
}
|
|
1493
|
-
else if (e.type === 'error')
|
|
1494
|
-
this.patch(id, { error: e.message });
|
|
1495
|
-
else if (e.type === 'usage')
|
|
1496
|
-
s.lastUsage = e.usage;
|
|
1497
|
-
// Synthetic event workerClient emits when onBreach returned {continue:false}
|
|
1498
|
-
// — surfaced as a transcript notice via the existing note() affordance,
|
|
1499
|
-
// AND appended to policyNotes (same warn/stop duality as above, gated
|
|
1500
|
-
// by SendOpts.budgetNotices).
|
|
1501
|
-
else if (e.type === 'budget-stop') {
|
|
1502
|
-
const msg = `Run stopped: budget reached (${this.host.breach.describe(e.breach)}).`;
|
|
1503
|
-
if ((opts.budgetNotices ?? 'transcript') === 'transcript')
|
|
1504
|
-
this.note(id, msg);
|
|
1505
|
-
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: msg, action: 'stop' }] });
|
|
1506
|
-
}
|
|
1507
|
-
},
|
|
1508
|
-
});
|
|
1509
|
-
// Stitch each tool call's measured duration onto its tool message (`_ms`).
|
|
1510
|
-
const ms = s.toolMs;
|
|
1511
|
-
/* FROM THE TAIL, by the count the run reports — never an absolute offset
|
|
1512
|
-
* into what we sent.
|
|
1513
|
-
*
|
|
1514
|
-
* This was `res.messages.slice(outgoing.length + 1)`, which assumed the
|
|
1515
|
-
* loop only ever APPENDS to the array it was handed. Rung-2 compaction
|
|
1516
|
-
* rewrites it in place and the rewrite is shorter than the input, so the
|
|
1517
|
-
* offset ran off the end, the slice came back empty, and the reply the
|
|
1518
|
-
* run had just produced was dropped: the transcript kept the user's
|
|
1519
|
-
* message and nothing else, as though the agent never answered. With
|
|
1520
|
-
* compaction on by default in the browser, that was every turn of a
|
|
1521
|
-
* conversation past 86% fill. */
|
|
1522
|
-
const created = res.messages.slice(res.messages.length - res.appended).map((m) => {
|
|
1523
|
-
const tid = m.tool_call_id;
|
|
1524
|
-
return m.role === 'tool' && tid && ms[tid] != null ? { ...m, _ms: ms[tid] } : m;
|
|
1525
|
-
});
|
|
1526
|
-
const u = res.usage ?? { promptTokens: 0, completionTokens: 0 };
|
|
1527
|
-
const stats = u.promptTokens || u.completionTokens || calleeSpend.inTok || calleeSpend.outTok
|
|
1528
|
-
? {
|
|
1529
|
-
// This agent's own turns plus every callee's.
|
|
1530
|
-
inTok: u.promptTokens + calleeSpend.inTok,
|
|
1531
|
-
outTok: u.completionTokens + calleeSpend.outTok,
|
|
1532
|
-
// Cache-aware: the hand-rolled in*price it replaced billed cached tokens at the full rate.
|
|
1533
|
-
cost: (priceUsageWith(u, { inPrice: priced.inPrice, outPrice: priced.outPrice }) ?? 0) + calleeSpend.cost,
|
|
1534
|
-
ms: Date.now() - startedAt,
|
|
1535
|
-
...(res.contextTokens ? { ctxTok: res.contextTokens } : {}),
|
|
1536
|
-
...(u.cachedTokens != null ? { cachedTok: u.cachedTokens } : {}),
|
|
1537
|
-
...(res.calls ? { calls: res.calls } : {}),
|
|
1538
|
-
}
|
|
1539
|
-
: undefined;
|
|
1540
|
-
const lastSeam = seamIds[seamIds.length - 1];
|
|
1541
|
-
const stitched = s.turns
|
|
1542
|
-
.filter((t) => !injectedTurnIds.includes(t.id))
|
|
1543
|
-
.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats, ...(verification ? { verification } : {}), ...(auditIncomplete ? { auditIncomplete } : {}) } : t));
|
|
1544
|
-
this.patch(id, {
|
|
1545
|
-
lastStopReason: res.stopReason,
|
|
1546
|
-
turns: lastSeam ? stampFoldedView(stitched, lastSeam, res.messages.filter((m) => m.role !== 'system'), userTurn.id) : stitched,
|
|
1547
|
-
});
|
|
1548
|
-
void this.persist(id);
|
|
1549
|
-
if (stats) {
|
|
1550
|
-
this.host.recordUsage({ surface: this.surface, provider: priced.provider, model, modelLabel: priced.label, inEst: null, inTok: stats.inTok, outTok: stats.outTok, cost: stats.cost });
|
|
1551
|
-
}
|
|
1552
|
-
// Best-effort follow-up chips for this turn — generated after the reply lands,
|
|
1553
|
-
// dropped if the user starts/aborts another run before it returns.
|
|
1554
|
-
if (this.host.suggestFollowups) {
|
|
1555
|
-
void this.host.suggestFollowups({ messages: res.messages, model, signal: ac.signal }).then((sug) => {
|
|
1556
|
-
if (sug.length && !ac.signal.aborted)
|
|
1557
|
-
this.patch(id, { suggestions: sug }, true);
|
|
1558
|
-
});
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
catch (e) {
|
|
1562
|
-
// A throw never returns res.messages, so the run's own copy of every
|
|
1563
|
-
// injected message dies with it and only the transcript-only turns
|
|
1564
|
-
// noteUser added are left. Promote those to real history: the queue
|
|
1565
|
-
// drops an entry the worker confirmed landed, so leaving them excluded
|
|
1566
|
-
// would silently delete the correction from what the next turn sends.
|
|
1567
|
-
const turns = this.ensure(id).turns;
|
|
1568
|
-
const patch = { error: String(e?.message || e) };
|
|
1569
|
-
if (injectedTurnIds.length) {
|
|
1570
|
-
patch.turns = turns.map((t) => (injectedTurnIds.includes(t.id) ? { ...t, included: true } : t));
|
|
1571
|
-
}
|
|
1572
|
-
this.patch(id, patch);
|
|
1573
|
-
void this.persist(id);
|
|
1159
|
+
await this.chat.run({ conversationId: id, input: { text, images } }, this.chatHost).done;
|
|
1574
1160
|
}
|
|
1575
1161
|
finally {
|
|
1576
|
-
|
|
1577
|
-
// or budget-breach "continue?") is still open must not leave its promise —
|
|
1578
|
-
// and the caller awaiting it — hanging forever. Treat an unanswered perm as
|
|
1579
|
-
// "no". resolvePerm() already nulls s.pendingPerm once the user answers, so
|
|
1580
|
-
// this is a no-op (idempotent) on the happy path.
|
|
1581
|
-
if (s.pendingPerm) {
|
|
1582
|
-
s.pendingPerm.resolve(false);
|
|
1583
|
-
this.patch(id, { pendingPerm: null }, true);
|
|
1584
|
-
}
|
|
1585
|
-
dog.clear();
|
|
1586
|
-
s.abort = null;
|
|
1587
|
-
s.runId = null;
|
|
1588
|
-
this.patch(id, { running: false, live: null, recovery: null }, true);
|
|
1589
|
-
// Any callee still marked running under this session will never report.
|
|
1590
|
-
publishSubagentParentEnded(id);
|
|
1591
|
-
// Upsert the session recording with everything captured so far (this turn's
|
|
1592
|
-
// events appended to prior turns), under one stable id so the whole session
|
|
1593
|
-
// replays as a single stream. Skipped until something replayable happened.
|
|
1594
|
-
if (this.host.recorder && recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
|
|
1595
|
-
if (!s.recId) {
|
|
1596
|
-
s.recId = this.host.recorder.newId();
|
|
1597
|
-
s.recPrompt = text;
|
|
1598
|
-
s.recImages = images.length ? images.slice() : undefined;
|
|
1599
|
-
}
|
|
1600
|
-
// `ok` is THIS turn's outcome (slice from turnStart); a snapshot copy of the
|
|
1601
|
-
// events so the store doesn't hold the live array that later turns mutate.
|
|
1602
|
-
const ok = !recBuf.slice(turnStart).some((r) => r.ev.type === 'error');
|
|
1603
|
-
this.host.recorder.save({ kind: 'agent', id: s.recId, title: (s.recPrompt || 'Agent run').slice(0, 60), at: Date.now(), ok, prompt: s.recPrompt || text, promptImages: s.recImages, events: recBuf.slice() });
|
|
1604
|
-
}
|
|
1162
|
+
this.dropTurn(id, t);
|
|
1605
1163
|
}
|
|
1606
1164
|
}
|
|
1607
1165
|
/** A host-driven turn: appends the user message, then awaits a host-supplied
|
|
@@ -1610,118 +1168,68 @@ export class AgentRuntime {
|
|
|
1610
1168
|
* tool calls — used by surfaces that render a live "thinking" placeholder for
|
|
1611
1169
|
* work the engine itself doesn't perform. */
|
|
1612
1170
|
async hostTurn(id, userText, produce) {
|
|
1613
|
-
|
|
1614
|
-
|
|
1171
|
+
this.ensure(id);
|
|
1172
|
+
this.forgotten.delete(id);
|
|
1173
|
+
if (!userText || this.chat.state(id).running)
|
|
1615
1174
|
return;
|
|
1616
1175
|
this.ws();
|
|
1617
|
-
|
|
1618
|
-
// start a second turn that began while the load was pending.
|
|
1619
|
-
if (!s.loaded) {
|
|
1620
|
-
await this.load(id);
|
|
1621
|
-
if (s.running)
|
|
1622
|
-
return;
|
|
1623
|
-
}
|
|
1624
|
-
// A loaded chat with no turns is new: titled from this turn, as send does.
|
|
1625
|
-
const isFirst = s.loaded && s.turns.length === 0;
|
|
1626
|
-
const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [{ role: 'user', content: userText }] };
|
|
1627
|
-
const ac = new AbortController();
|
|
1628
|
-
s.abort = ac;
|
|
1629
|
-
// Optimistic, for the same reason as `send` above: the guard is a network
|
|
1630
|
-
// call, and a host-driven turn that waits on it looks like nothing happened.
|
|
1631
|
-
this.patch(id, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true, suggestions: [] }, true);
|
|
1632
|
-
// As in send: a load that missed again leaves this turn unsaved; say so now.
|
|
1633
|
-
this.flagIfUnsaved(id);
|
|
1634
|
-
const gate = await this.host.guard('llm-execution');
|
|
1635
|
-
if (!gate.allowed) {
|
|
1636
|
-
s.abort = null;
|
|
1637
|
-
this.patch(id, {
|
|
1638
|
-
turns: this.ensure(id).turns.filter((t) => t.id !== userTurn.id),
|
|
1639
|
-
running: false,
|
|
1640
|
-
live: null,
|
|
1641
|
-
error: gate.reason,
|
|
1642
|
-
}, true);
|
|
1643
|
-
return;
|
|
1644
|
-
}
|
|
1645
|
-
try {
|
|
1646
|
-
const content = await produce(ac.signal);
|
|
1647
|
-
this.patch(id, { turns: this.ensure(id).turns.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], { role: 'assistant', content }] } : t)) });
|
|
1648
|
-
void this.persist(id);
|
|
1649
|
-
if (isFirst) {
|
|
1650
|
-
// Queued behind the save, which is what creates a draft.
|
|
1651
|
-
void this.queueWrite(id, async () => {
|
|
1652
|
-
const ses = await this.host.sessions.get(id);
|
|
1653
|
-
if (ses && !ses.titleLocked) {
|
|
1654
|
-
ses.title = userText.slice(0, 48);
|
|
1655
|
-
await this.host.sessions.save(ses).catch(() => { });
|
|
1656
|
-
this.notifyGlobal();
|
|
1657
|
-
}
|
|
1658
|
-
}).catch(() => { });
|
|
1659
|
-
}
|
|
1660
|
-
}
|
|
1661
|
-
catch (e) {
|
|
1662
|
-
this.patch(id, { error: String(e?.message || e) });
|
|
1663
|
-
}
|
|
1664
|
-
finally {
|
|
1665
|
-
s.abort = null;
|
|
1666
|
-
this.patch(id, { running: false, live: null }, true);
|
|
1667
|
-
}
|
|
1176
|
+
await this.chat.hostTurn(id, userText, produce, this.chatHost);
|
|
1668
1177
|
}
|
|
1669
1178
|
/** Replace the offload stub for `blobId` inside turn `turnId` (session `id`)
|
|
1670
|
-
* with the full content — the context drawer's per-item Restore
|
|
1671
|
-
*
|
|
1672
|
-
*
|
|
1673
|
-
*
|
|
1674
|
-
* or when the turn is FOLDED (see isFoldedTurn): the folded view stands for
|
|
1675
|
-
* it, so content restored into it would never be sent. */
|
|
1179
|
+
* with the full content — the context drawer's per-item Restore. Returns
|
|
1180
|
+
* false (state untouched, no persist) when the turn or the matching stub is
|
|
1181
|
+
* absent, or when the turn is FOLDED (see isFoldedTurn): the folded view
|
|
1182
|
+
* stands for it, so content restored into it would never be sent. */
|
|
1676
1183
|
restoreToolResult(id, turnId, blobId, content) {
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
if (at < 0 || isFoldedTurn(s.turns, at))
|
|
1681
|
-
return false;
|
|
1682
|
-
const turn = s.turns[at];
|
|
1683
|
-
const idx = turn.messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
|
|
1684
|
-
if (idx < 0)
|
|
1685
|
-
return false;
|
|
1686
|
-
const next = s.turns.map((t) => {
|
|
1687
|
-
if (t.id !== turnId)
|
|
1688
|
-
return t;
|
|
1689
|
-
const messages = t.messages.slice();
|
|
1690
|
-
messages[idx] = { ...messages[idx], content };
|
|
1691
|
-
return { ...t, messages };
|
|
1692
|
-
});
|
|
1693
|
-
this.patch(id, { turns: next });
|
|
1694
|
-
void this.persist(id);
|
|
1695
|
-
return true;
|
|
1184
|
+
this.ensure(id);
|
|
1185
|
+
this.open(id);
|
|
1186
|
+
return this.chat.restoreToolResult(id, this.chatHost, turnId, blobId, content);
|
|
1696
1187
|
}
|
|
1697
|
-
/** Re-run a prior user turn: drop it and everything after, then resend it.
|
|
1188
|
+
/** Re-run a prior user turn: drop it and everything after, then resend it. A
|
|
1189
|
+
* refused retry puts the dropped turns back (the engine). */
|
|
1698
1190
|
async retry(id, turnId, opts) {
|
|
1699
|
-
|
|
1700
|
-
|
|
1191
|
+
this.ensure(id);
|
|
1192
|
+
const state = this.chat.state(id);
|
|
1193
|
+
if (state.running)
|
|
1701
1194
|
return;
|
|
1702
|
-
const
|
|
1703
|
-
if (idx < 0)
|
|
1704
|
-
return;
|
|
1705
|
-
const first = s.turns[idx].messages[0];
|
|
1195
|
+
const first = state.turns.find((t) => t.id === turnId)?.messages[0];
|
|
1706
1196
|
const text = first ? userText(first) : '';
|
|
1707
1197
|
const images = first ? userImages(first) : [];
|
|
1708
1198
|
if (!text && images.length === 0)
|
|
1709
1199
|
return;
|
|
1710
|
-
|
|
1711
|
-
|
|
1200
|
+
// The browser never freezes its system: composeSystem reads live inputs each turn.
|
|
1201
|
+
this.recomposeSystem(id);
|
|
1202
|
+
const t = this.queueTurn(id, opts, text, images);
|
|
1203
|
+
try {
|
|
1204
|
+
await this.chat.retry(id, turnId, this.chatHost).done;
|
|
1205
|
+
}
|
|
1206
|
+
finally {
|
|
1207
|
+
this.dropTurn(id, t);
|
|
1208
|
+
}
|
|
1712
1209
|
}
|
|
1713
1210
|
/** Drop all in-memory state and abort every run — called on a scope change so
|
|
1714
1211
|
* one identity's sessions and runs never carry into another's. */
|
|
1715
1212
|
reset() {
|
|
1716
|
-
const
|
|
1717
|
-
|
|
1718
|
-
|
|
1213
|
+
const held = [...this.map.keys()];
|
|
1214
|
+
const runningIds = held.filter((sid) => this.chat.state(sid).running);
|
|
1215
|
+
// Nothing of the last identity's waits or blocked output outlives the switch.
|
|
1216
|
+
for (const sid of new Set([...held, ...this.permQueues.keys()]))
|
|
1217
|
+
this.denyAllPerms(sid);
|
|
1218
|
+
this.permQueues.clear();
|
|
1219
|
+
this.blocked.clear();
|
|
1220
|
+
// Forgotten, so a run's late writes land nowhere; reopened on next use.
|
|
1221
|
+
for (const sid of held) {
|
|
1222
|
+
this.chat.forget(sid);
|
|
1223
|
+
this.forgotten.add(sid);
|
|
1224
|
+
}
|
|
1719
1225
|
// The worker is disposed below, which never settles an in-flight run, so
|
|
1720
|
-
// its
|
|
1226
|
+
// its end (and the parent-ended signal there) never comes: say it here.
|
|
1721
1227
|
for (const sid of runningIds)
|
|
1722
1228
|
publishSubagentParentEnded(sid);
|
|
1723
1229
|
const ids = [...this.subs.keys()];
|
|
1724
1230
|
this.map.clear();
|
|
1231
|
+
this.pending.clear();
|
|
1232
|
+
this.active.clear();
|
|
1725
1233
|
this.worker?.dispose();
|
|
1726
1234
|
this.worker = null;
|
|
1727
1235
|
this._ws = null;
|
|
@@ -1737,5 +1245,567 @@ export class AgentRuntime {
|
|
|
1737
1245
|
this.offModels?.();
|
|
1738
1246
|
this.offModels = null;
|
|
1739
1247
|
}
|
|
1248
|
+
/* ---- the browser host of the chat engine ---- */
|
|
1249
|
+
buildChatHost() {
|
|
1250
|
+
return {
|
|
1251
|
+
/* Feature guard — the runtime is the chokepoint for every /agent run (send
|
|
1252
|
+
* and retry), so a quota/plan guard registered later covers this surface
|
|
1253
|
+
* too. Called once the engine shows the turn: this side's half of the
|
|
1254
|
+
* optimistic start (the composer) happens here, and so does its half of
|
|
1255
|
+
* a denial. */
|
|
1256
|
+
guard: ({ conversationId: id, kind }) => {
|
|
1257
|
+
/* An inject joins the run already going: it is checked as a send is
|
|
1258
|
+
* (llm-execution), and starts no turn, touches no composer and owes no
|
|
1259
|
+
* run end. The feature guards only read an entitlement, so it counts
|
|
1260
|
+
* nothing against a quota; usage is recorded once, at the run's end. */
|
|
1261
|
+
if (kind === 'inject')
|
|
1262
|
+
return this.host.guard('llm-execution');
|
|
1263
|
+
const t = id && (kind === 'send' || kind === 'retry') ? this.startTurn(id) : null;
|
|
1264
|
+
if (id && kind === 'host-turn')
|
|
1265
|
+
this.patch(id, { suggestions: [] }, true);
|
|
1266
|
+
/* The host's own promise where nothing waits on it, so a host turn's
|
|
1267
|
+
* produce starts as soon after the guard as it always did (a stop that
|
|
1268
|
+
* lands in between still reaches it). */
|
|
1269
|
+
if (!id || !t)
|
|
1270
|
+
return this.host.guard('llm-execution');
|
|
1271
|
+
// Put back what was typed (and any pasted images): a turn refused here
|
|
1272
|
+
// is a message the user would otherwise believe they sent.
|
|
1273
|
+
const putBack = () => {
|
|
1274
|
+
this.patch(id, { input: t.priorInput || t.text, attachments: t.priorAttachments.length ? t.priorAttachments : t.images });
|
|
1275
|
+
};
|
|
1276
|
+
return this.host.guard('llm-execution').then((gate) => {
|
|
1277
|
+
if (!gate.allowed)
|
|
1278
|
+
putBack();
|
|
1279
|
+
else {
|
|
1280
|
+
t.allowed = true;
|
|
1281
|
+
t.startedAt = Date.now();
|
|
1282
|
+
t.recBuf = t.ui.recEvents;
|
|
1283
|
+
t.turnStart = t.recBuf.length;
|
|
1284
|
+
/* A draft becomes a saved chat now, before the run, so it is in the
|
|
1285
|
+
* chat list while it runs; a refused send leaves nothing behind. A
|
|
1286
|
+
* text send's retitle would create it too, an image-only one never.
|
|
1287
|
+
* A failed save does not stop the run: its first write tries again. */
|
|
1288
|
+
if (this.drafts.has(id))
|
|
1289
|
+
return this.materialize(id).then(() => gate, () => gate);
|
|
1290
|
+
}
|
|
1291
|
+
return gate;
|
|
1292
|
+
}, (e) => { putBack(); throw e; });
|
|
1293
|
+
},
|
|
1294
|
+
// The browser always names its chat; a new one is a draft until first written.
|
|
1295
|
+
createConversation: () => this.newDraft(),
|
|
1296
|
+
/* A draft is in no store: it reads as an empty chat, so the engine's
|
|
1297
|
+
* first-turn retitle and its save find a record (writeConversation then
|
|
1298
|
+
* creates it). A LOAD's read (the chat is not loaded yet) is where this
|
|
1299
|
+
* side takes the stored model; a save's read-before-write is not. */
|
|
1300
|
+
loadConversation: (id) => {
|
|
1301
|
+
this.ws();
|
|
1302
|
+
if (this.drafts.has(id))
|
|
1303
|
+
return Promise.resolve({ id, title: 'New chat', messages: [] });
|
|
1304
|
+
const read = this.host.sessions.get(id);
|
|
1305
|
+
if (this.chat.state(id).loaded)
|
|
1306
|
+
return read.then((ses) => (ses ? withoutSystem(ses) : ses));
|
|
1307
|
+
return read.then((ses) => { this.onLoaded(id, ses); return ses ? withoutSystem(ses) : ses; }, (e) => { this.onLoaded(id, null); throw e; });
|
|
1308
|
+
},
|
|
1309
|
+
/* The first write to a draft saves it first; a failed save fails this
|
|
1310
|
+
* write, and the draft stays a draft for the next one. A save (not the
|
|
1311
|
+
* retitle, which writes the stored record back as read) carries the
|
|
1312
|
+
* chat's model. */
|
|
1313
|
+
writeConversation: (c, { allowTruncate }) => (this.drafts.has(c.id)
|
|
1314
|
+
? this.materialize(c.id).then((created) => this.writeSession(c, allowTruncate, created))
|
|
1315
|
+
: this.writeSession(c, allowTruncate, null)),
|
|
1316
|
+
runOptions: async ({ conversationId: id }) => this.runOptionsFor(id, this.turnFor(id)),
|
|
1317
|
+
system: ({ conversationId: id }) => {
|
|
1318
|
+
const t = this.turnFor(id);
|
|
1319
|
+
return this.composeSystem(id, t.opts.persona, t.opts.partsOverride);
|
|
1320
|
+
},
|
|
1321
|
+
runLoop: (messages, runOpts, control) => this.runWorker(messages, runOpts, control),
|
|
1322
|
+
injectInto: (runId, messages, hard) => {
|
|
1323
|
+
this.getWorker().inject(Number(runId), messages, hard);
|
|
1324
|
+
return true;
|
|
1325
|
+
},
|
|
1326
|
+
askPermission: (id, req) => new Promise((done) => {
|
|
1327
|
+
// A callee's ask names who called it: this chat's own agent.
|
|
1328
|
+
const auditAgent = this.host.capabilities?.agentName?.();
|
|
1329
|
+
const caller = req.agent ? (auditAgent ? { caller: auditAgent } : {}) : {};
|
|
1330
|
+
// A System One ask keeps its own detail (tool, reasons, result); a tool ask only the clipped PermDetail.
|
|
1331
|
+
const hook = req.tool === HOOK_ASK_TOOL ? hookInput(req.input) : undefined;
|
|
1332
|
+
const input = req.tool === HOOK_ASK_TOOL ? undefined : permInput(req.input);
|
|
1333
|
+
const permId = this.nextPermId++;
|
|
1334
|
+
// The run ended while the card was up or queued: no answer is a deny (Block).
|
|
1335
|
+
const onAbort = () => this.denyPerm(id, permId);
|
|
1336
|
+
// A deny with a note carries it, as the person's reason.
|
|
1337
|
+
const resolve = (allow, note) => {
|
|
1338
|
+
req.signal.removeEventListener('abort', onAbort);
|
|
1339
|
+
done(!allow && note ? { allow, by: 'human', note } : { allow });
|
|
1340
|
+
};
|
|
1341
|
+
if (req.signal.aborted) {
|
|
1342
|
+
resolve(false);
|
|
1343
|
+
return;
|
|
1344
|
+
}
|
|
1345
|
+
req.signal.addEventListener('abort', onAbort, { once: true });
|
|
1346
|
+
this.raisePerm(id, {
|
|
1347
|
+
tool: req.tool, summary: req.summary, ...(req.agent ? { agent: req.agent } : {}), ...(input ? { input } : {}),
|
|
1348
|
+
...(hook ? { hook } : {}), ...caller, id: permId, askedAt: Date.now(), resolve,
|
|
1349
|
+
});
|
|
1350
|
+
}),
|
|
1351
|
+
askUser: (id, questions, signal) => new Promise((done) => {
|
|
1352
|
+
// The run ended while the form was up: no answer is a decline.
|
|
1353
|
+
const onAbort = () => { if (this.map.get(id)?.pendingAsk === ask)
|
|
1354
|
+
this.resolveAsk(id, null); };
|
|
1355
|
+
const ask = { questions, resolve: (a) => { signal.removeEventListener('abort', onAbort); done(a); } };
|
|
1356
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
1357
|
+
this.patch(id, { pendingAsk: ask }, true);
|
|
1358
|
+
}),
|
|
1359
|
+
/* Unused in the browser: runWorker wires propose_edit through control.paused
|
|
1360
|
+
* with the run's own SendOpts, so the verdict stays typed, bound to the run
|
|
1361
|
+
* that asked, and a surface without onEditReview keeps the non-blocking
|
|
1362
|
+
* fallback. Present because every ChatHost method is required. */
|
|
1363
|
+
reviewEdit: (id, callId, signal) => this.reviewEditFor(this.active.get(id)?.opts, callId, signal),
|
|
1364
|
+
// Best-effort follow-up chips for this turn — generated after the reply lands,
|
|
1365
|
+
// dropped if the user starts/aborts another run before it returns.
|
|
1366
|
+
suggestFollowups: ({ conversationId: id, messages, signal }) => {
|
|
1367
|
+
if (!this.host.suggestFollowups)
|
|
1368
|
+
return;
|
|
1369
|
+
const model = this.active.get(id)?.model || this.chatModel(id);
|
|
1370
|
+
void this.host.suggestFollowups({ messages, model, signal }).then((sug) => {
|
|
1371
|
+
if (sug.length && !signal.aborted)
|
|
1372
|
+
this.patch(id, { suggestions: sug }, true);
|
|
1373
|
+
});
|
|
1374
|
+
},
|
|
1375
|
+
turnComplete: (result) => this.endTurn(result),
|
|
1376
|
+
stateChanged: (id, global) => {
|
|
1377
|
+
const s = this.map.get(id);
|
|
1378
|
+
if (s)
|
|
1379
|
+
this.sync(id, s);
|
|
1380
|
+
this.notify(id);
|
|
1381
|
+
if (global)
|
|
1382
|
+
this.notifyGlobal();
|
|
1383
|
+
},
|
|
1384
|
+
/* ---- run events ----
|
|
1385
|
+
*
|
|
1386
|
+
* The engine keeps the live slice, the error, the turn's tool timings and
|
|
1387
|
+
* injection checks, the seams, injected messages, verification and audit
|
|
1388
|
+
* gaps. What only this side keeps is below; every other event is an
|
|
1389
|
+
* explicit no-op. */
|
|
1390
|
+
onTurn: () => { },
|
|
1391
|
+
onAssistantDelta: (_e, { conversationId: id }) => {
|
|
1392
|
+
// Output resumed: the "Recovering…" note is over.
|
|
1393
|
+
if (this.map.get(id)?.recovery != null)
|
|
1394
|
+
this.patch(id, { recovery: null });
|
|
1395
|
+
},
|
|
1396
|
+
onAssistant: () => { },
|
|
1397
|
+
onInjected: () => { },
|
|
1398
|
+
onTurnInterrupted: () => { },
|
|
1399
|
+
/* The task list is fed HERE, at the one choke point every surface's
|
|
1400
|
+
* runtime passes through - not by a host.
|
|
1401
|
+
*
|
|
1402
|
+
* It used to be wired in prompd's AgentPanel alone, so `todo_write`
|
|
1403
|
+
* worked there and nowhere else: on corenel the model dutifully kept a
|
|
1404
|
+
* plan across a multi-step job and not one surface could read it,
|
|
1405
|
+
* because nothing was listening. A tool whose entire purpose is to be
|
|
1406
|
+
* SEEN cannot depend on each host remembering to subscribe.
|
|
1407
|
+
*
|
|
1408
|
+
* Other tool calls bucket as activity under whichever task is active,
|
|
1409
|
+
* which is what gives an expanded task its detail. */
|
|
1410
|
+
onToolCall: (e, { conversationId: id }) => {
|
|
1411
|
+
if (e.name === 'todo_write')
|
|
1412
|
+
applyTodoWrite(id, e.args);
|
|
1413
|
+
else
|
|
1414
|
+
addActivity(id, e.id, e.name);
|
|
1415
|
+
},
|
|
1416
|
+
onToolDelta: () => { },
|
|
1417
|
+
onToolResult: (e, { conversationId: id }) => {
|
|
1418
|
+
resolveActivity(id, e.id, e.isError);
|
|
1419
|
+
},
|
|
1420
|
+
onOffload: (_e, { conversationId: id }) => {
|
|
1421
|
+
this.patch(id, { offloadTick: this.ensure(id).offloadTick + 1 });
|
|
1422
|
+
},
|
|
1423
|
+
// The engine stitches the check onto its tool message (`_check`).
|
|
1424
|
+
onToolCheck: () => { },
|
|
1425
|
+
// The engine stitches the marks (`_hooks`); a blocked result stays here, in memory.
|
|
1426
|
+
onToolHooks: (e, { conversationId: id }) => {
|
|
1427
|
+
if (e.blockedText === undefined)
|
|
1428
|
+
return;
|
|
1429
|
+
let m = this.blocked.get(id);
|
|
1430
|
+
if (!m) {
|
|
1431
|
+
m = new Map();
|
|
1432
|
+
this.blocked.set(id, m);
|
|
1433
|
+
}
|
|
1434
|
+
m.set(e.id, e.blockedText);
|
|
1435
|
+
},
|
|
1436
|
+
// The engine writes the seam (noteCheckpoint) and stamps the folded view.
|
|
1437
|
+
onCheckpoint: () => { },
|
|
1438
|
+
onToolsLoaded: (e, { conversationId: id }) => {
|
|
1439
|
+
// What this conversation found with tool_search, for the next run's loadedTools.
|
|
1440
|
+
setLoadedTools(id, e.names);
|
|
1441
|
+
},
|
|
1442
|
+
onContextPressure: () => { },
|
|
1443
|
+
onPermission: () => { },
|
|
1444
|
+
onPermissionAudit: () => { },
|
|
1445
|
+
onPolicyViolation: (e, { conversationId: id }) => {
|
|
1446
|
+
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: e.kind, message: e.message, action: e.action }] });
|
|
1447
|
+
},
|
|
1448
|
+
onHeal: (e, { conversationId: id }) => {
|
|
1449
|
+
// Heal persistence is centralized in the worker client (host workerDeps
|
|
1450
|
+
// onHeal) so it captures sub-agent/DAG-node heals too — not tapped here.
|
|
1451
|
+
// We just surface the live "Recovering…" status (null clears it on recovered).
|
|
1452
|
+
this.patch(id, { recovery: this.host.healNote?.(e.record) ?? null });
|
|
1453
|
+
},
|
|
1454
|
+
onUsage: (e, { conversationId: id }) => {
|
|
1455
|
+
// Latest turn's usage, for a degrade breach's next-turn cost projection.
|
|
1456
|
+
const s = this.map.get(id);
|
|
1457
|
+
if (s)
|
|
1458
|
+
s.lastUsage = e.usage;
|
|
1459
|
+
},
|
|
1460
|
+
onTurnVerified: () => { },
|
|
1461
|
+
onDone: () => { },
|
|
1462
|
+
onArtifact: () => { },
|
|
1463
|
+
onError: () => { },
|
|
1464
|
+
/* Synthetic event workerClient emits when onBreach returned {continue:false}
|
|
1465
|
+
* — surfaced as a transcript notice via the existing note() affordance,
|
|
1466
|
+
* AND appended to policyNotes (same warn/stop duality as the breach warn,
|
|
1467
|
+
* gated by SendOpts.budgetNotices). */
|
|
1468
|
+
onBudgetStop: (e, { conversationId: id }) => {
|
|
1469
|
+
const msg = `Run stopped: budget reached (${this.host.breach.describe(e.breach)}).`;
|
|
1470
|
+
if ((this.active.get(id)?.opts.budgetNotices ?? 'transcript') === 'transcript')
|
|
1471
|
+
this.note(id, msg);
|
|
1472
|
+
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: msg, action: 'stop' }] });
|
|
1473
|
+
},
|
|
1474
|
+
onBudgetWarn: () => { },
|
|
1475
|
+
onAuditIncomplete: () => { },
|
|
1476
|
+
onMcpUnavailable: () => { },
|
|
1477
|
+
onAwaitingOwner: () => { },
|
|
1478
|
+
onDontsChecking: () => { },
|
|
1479
|
+
onOwnerDecided: () => { },
|
|
1480
|
+
};
|
|
1481
|
+
}
|
|
1482
|
+
/** Saves a conversation the engine hands over. A save's record is the
|
|
1483
|
+
* store's own session read just before (so every field it holds goes back);
|
|
1484
|
+
* a draft's stand-in takes the session its first write just created. */
|
|
1485
|
+
writeSession(c, allowTruncate, created) {
|
|
1486
|
+
const model = allowTruncate ? this.map.get(c.id)?.model : null;
|
|
1487
|
+
const write = (base) => {
|
|
1488
|
+
const ses = withoutSystem({ ...base, ...c, ...(model ? { model } : {}) });
|
|
1489
|
+
// A save may shorten the stored chat: the engine only asks when this
|
|
1490
|
+
// page's list IS the chat (R36) or holds every stored turn after a merge.
|
|
1491
|
+
return this.host.sessions.save(ses, allowTruncate ? { allowTruncate: true } : undefined);
|
|
1492
|
+
};
|
|
1493
|
+
if (created)
|
|
1494
|
+
return write(created);
|
|
1495
|
+
if (isSession(c))
|
|
1496
|
+
return write(c);
|
|
1497
|
+
return this.host.sessions.get(c.id).then((ses) => {
|
|
1498
|
+
if (!ses)
|
|
1499
|
+
throw new Error(`session ${c.id} is not stored`);
|
|
1500
|
+
return write(ses);
|
|
1501
|
+
});
|
|
1502
|
+
}
|
|
1503
|
+
/** propose_edit blocks on the user's verdict through the surface's own
|
|
1504
|
+
* onEditReview; with none, the review is unsupported and resolves null. */
|
|
1505
|
+
reviewEditFor(opts, callId, signal) {
|
|
1506
|
+
const review = opts?.onEditReview;
|
|
1507
|
+
return review ? review(callId, signal) : Promise.resolve(null);
|
|
1508
|
+
}
|
|
1509
|
+
/** What `send` hands the worker for one turn, beyond what the engine's
|
|
1510
|
+
* control supplies (the system prompt, the signal, the event stream, the
|
|
1511
|
+
* asks). Resolved per run, for THIS session. */
|
|
1512
|
+
async runOptionsFor(id, t) {
|
|
1513
|
+
const s = t.ui;
|
|
1514
|
+
const { opts } = t;
|
|
1515
|
+
// Mark a FOLLOW-UP user turn so the replay shows it as a bubble in place. The
|
|
1516
|
+
// first turn's prompt is the recording's `prompt` header, so only record once
|
|
1517
|
+
// the session already has replayable content (avoids duplicating it).
|
|
1518
|
+
if (t.recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
|
|
1519
|
+
t.recBuf.push({ at: Date.now(), ev: { type: 'user', content: t.text, images: t.images.length ? t.images.slice() : undefined } });
|
|
1520
|
+
}
|
|
1521
|
+
/* The skill catalog travels WITH the run: the worker cannot read skill
|
|
1522
|
+
* files, and the loop owns both the index and `use_skill`. Resolved for
|
|
1523
|
+
* THIS session, so a skill switched off in one conversation is still
|
|
1524
|
+
* offered in every other. Read while the engine composes the system
|
|
1525
|
+
* prompt, so it adds no latency ahead of the run. Never fatal: a read
|
|
1526
|
+
* failure costs skills, not the run. */
|
|
1527
|
+
const skillDeny = this.host.capabilities?.skillDeny?.(id) ?? [];
|
|
1528
|
+
const skills = await enabledSkillCatalog((name) => !skillDeny.includes(name)).catch(() => []);
|
|
1529
|
+
const model = this.chatModel(id);
|
|
1530
|
+
/* Decided now, from what is true now: the chat's own model is gone and
|
|
1531
|
+
* the one it falls back to cannot hold it. A model picked since load, or
|
|
1532
|
+
* a catalog that has caught up, changes the answer. */
|
|
1533
|
+
const forceFold = !!this.lostModel(s) && !this.canHold(id, model);
|
|
1534
|
+
// The chat now runs on `model`; storing it is what clears the notice.
|
|
1535
|
+
if (this.host.perChatModel && s.model !== model)
|
|
1536
|
+
this.patch(id, { model });
|
|
1537
|
+
// Live model for onBreach pricing — reassigned when a degrade breach
|
|
1538
|
+
// switches the run onto a cheaper model (see onBreach below).
|
|
1539
|
+
let activeModel = model;
|
|
1540
|
+
const priced = this.host.modelInfo(model);
|
|
1541
|
+
t.model = model;
|
|
1542
|
+
t.priced = priced;
|
|
1543
|
+
const contextWindow = priced.contextWindow ?? 128000;
|
|
1544
|
+
// The surface's constraints default (Task 7), overridden per run when the
|
|
1545
|
+
// caller passes one (a masked-override envelope from `mergeOverride`) —
|
|
1546
|
+
// mirrors AgentPanel's `eff = { ...savedDefault, ...perRunOverride }`.
|
|
1547
|
+
// NOTE: this deliberately reads `this.surface`'s constraints, not a
|
|
1548
|
+
// hardcoded 'agent' — a behavior change for /craft vs. the pre-extraction
|
|
1549
|
+
// class (which always read 'agent'); 'craft' has no saved defaults so the
|
|
1550
|
+
// effective result is unchanged.
|
|
1551
|
+
const constraints = { ...this.host.constraints(this.surface), ...(opts.constraints ?? {}) };
|
|
1552
|
+
const hasBudgetLimits = constraints.maxUsd != null || constraints.maxTokens != null || constraints.maxMs != null;
|
|
1553
|
+
// Capability seam (Task 3): only send toolNames when the host's tools
|
|
1554
|
+
// getter is NOT the default allTools (reference inequality) — this keeps
|
|
1555
|
+
// the default path byte-identical, since every surface today passes
|
|
1556
|
+
// `tools: allTools` (or omits capabilities entirely).
|
|
1557
|
+
const capTools = this.host.capabilities?.tools;
|
|
1558
|
+
const toolNames = capTools && capTools !== allTools ? capTools().map((x) => x.name) : undefined;
|
|
1559
|
+
/* RESOLVED PER RUN, FOR THIS SESSION. The composer popout can switch a
|
|
1560
|
+
* source (an MCP server, a builtin namespace, a skill) off for ONE
|
|
1561
|
+
* conversation, and this is where that choice stops being a UI state and
|
|
1562
|
+
* becomes tools the worker will not offer. Passing the id rather than
|
|
1563
|
+
* reading an "active session" global keeps it correct while two surfaces
|
|
1564
|
+
* run at once. A host that ignores the argument -- crew does -- is
|
|
1565
|
+
* unaffected. */
|
|
1566
|
+
const toolDeny = this.host.capabilities?.toolDeny?.(id);
|
|
1567
|
+
// Resolved per run so an edit to a callee (or its canCall list) applies to
|
|
1568
|
+
// the next delegation without rebuilding the runtime. Never fatal: a read
|
|
1569
|
+
// failure costs delegation, not the run.
|
|
1570
|
+
const resolvedDelegates = await (this.host.capabilities?.delegates?.().catch(() => []) ?? Promise.resolve([]));
|
|
1571
|
+
/* Under a usd cap a callee's turns are priced against this run's meter,
|
|
1572
|
+
* so a callee whose model this host cannot price would spend without
|
|
1573
|
+
* moving it. Refused, with the daemon's words (executor.ts). */
|
|
1574
|
+
const crewDelegates = constraints.maxUsd != null
|
|
1575
|
+
? resolvedDelegates.map((d) => (d.model && !pricedForUsd(this.host.usdRates?.(d.model) ?? null)
|
|
1576
|
+
? { ...d, model: '', refusal: unpricedCalleeRefusal(d.name, d.model) }
|
|
1577
|
+
: d))
|
|
1578
|
+
: resolvedDelegates;
|
|
1579
|
+
const crewOrchestration = this.host.capabilities?.orchestration?.();
|
|
1580
|
+
const crewTraceRoot = this.host.capabilities?.traceRoot?.();
|
|
1581
|
+
const auditAgent = this.host.capabilities?.agentName?.();
|
|
1582
|
+
/* What this turn's sub-agents (delegated callees) spent, from their
|
|
1583
|
+
* node-event usage (review B-I6). Added to the turn's stats and the
|
|
1584
|
+
* usage ledger, as the daemon's run log does. Only a tally for display:
|
|
1585
|
+
* the budget gate meters the same events itself (workerClient), so
|
|
1586
|
+
* nothing here feeds it. */
|
|
1587
|
+
const calleeSpend = t.calleeSpend;
|
|
1588
|
+
// A prompt this side raises holds the run's watchdog while a person decides.
|
|
1589
|
+
const paused = (ask) => (t.control ? t.control.paused(ask) : ask());
|
|
1590
|
+
return {
|
|
1591
|
+
model,
|
|
1592
|
+
temperature: this.getTemperature?.(),
|
|
1593
|
+
toolDeny,
|
|
1594
|
+
// What this conversation already found with tool_search, so turn 9
|
|
1595
|
+
// does not search again for what turn 2 loaded.
|
|
1596
|
+
loadedTools: loadedToolNames(id),
|
|
1597
|
+
skills,
|
|
1598
|
+
crewDelegates,
|
|
1599
|
+
crewOrchestration,
|
|
1600
|
+
...(crewTraceRoot ? { crewTraceRoot } : {}),
|
|
1601
|
+
policy: this.host.policy
|
|
1602
|
+
? this.host.policy(getPolicy(opts.policyName) ?? STANDARD_POLICY)
|
|
1603
|
+
: (getPolicy(opts.policyName) ?? STANDARD_POLICY),
|
|
1604
|
+
...(this.host.sessionsRoot ? { sessionsRoot: this.host.sessionsRoot() } : {}),
|
|
1605
|
+
compression: { mode: this.host.compression(), contextWindow },
|
|
1606
|
+
...(forceFold ? { forceFold: true } : {}),
|
|
1607
|
+
toolNames,
|
|
1608
|
+
// Non-blocking: fires when the approvals bridge records a decision and
|
|
1609
|
+
// offers a standing rule. The context identifies which session/surface
|
|
1610
|
+
// recorded it — the runId is added by the bridge itself.
|
|
1611
|
+
approvalContext: { surface: this.surface, sessionId: id },
|
|
1612
|
+
// The audit log files this run under its session, and under its agent
|
|
1613
|
+
// when the host names one (persona is prompt text, never an identity).
|
|
1614
|
+
auditContext: { session: id, ...(auditAgent ? { agent: auditAgent } : {}) },
|
|
1615
|
+
onApprovalSuggestion: (sug) => this.patch(id, { approvalSuggestion: sug }, true),
|
|
1616
|
+
// Editor-buffer surfaces pass onEditReview/editorFile (Task 4); the
|
|
1617
|
+
// standalone page omits both, so propose_edit keeps its non-blocking
|
|
1618
|
+
// fallback exactly as before.
|
|
1619
|
+
editorFile: opts.editorFile,
|
|
1620
|
+
onNodeEvent: (e) => {
|
|
1621
|
+
publishSubagentEvent({ sessionId: id, surface: this.surface, event: e });
|
|
1622
|
+
const c = nodeUsageOf(e);
|
|
1623
|
+
if (c) {
|
|
1624
|
+
calleeSpend.inTok += c.usage.promptTokens;
|
|
1625
|
+
calleeSpend.outTok += c.usage.completionTokens;
|
|
1626
|
+
const m = c.model ?? model;
|
|
1627
|
+
// Priced only when this host really knows the model's rates; an
|
|
1628
|
+
// unknown one still counts its tokens (never an invented cost).
|
|
1629
|
+
if (pricedForUsd(this.host.usdRates?.(m) ?? null))
|
|
1630
|
+
calleeSpend.cost += this.host.price(c.usage, m);
|
|
1631
|
+
}
|
|
1632
|
+
opts.onNodeEvent?.(e);
|
|
1633
|
+
},
|
|
1634
|
+
/* start_project offering to bring the conversation with it. The run is
|
|
1635
|
+
* STILL WRITING, so this cannot be file work the tool does on its own:
|
|
1636
|
+
* only here is it known which session this run belongs to, and only
|
|
1637
|
+
* here can the store that is saving it be re-pointed. The host does
|
|
1638
|
+
* both, or the option is not offered at all. */
|
|
1639
|
+
onMoveSession: this.host.moveSession
|
|
1640
|
+
? async (projectRoot) => {
|
|
1641
|
+
const message = await paused(() => this.host.moveSession(id, projectRoot));
|
|
1642
|
+
// Its chats moved surfaces: the list this session was in no longer
|
|
1643
|
+
// holds it, and the one it went to now does.
|
|
1644
|
+
this.notifyGlobal();
|
|
1645
|
+
return message;
|
|
1646
|
+
}
|
|
1647
|
+
: undefined,
|
|
1648
|
+
constraints: hasBudgetLimits ? constraints : undefined,
|
|
1649
|
+
price: (usage, priceModel) => this.host.price(usage, priceModel),
|
|
1650
|
+
onBreach: constraints.onBreach && this.host.breach
|
|
1651
|
+
? async (breach) => {
|
|
1652
|
+
// Tracks the model actually running (workerClient resets its breach
|
|
1653
|
+
// latch after a degrade, so a second in-run breach can fire on the
|
|
1654
|
+
// NEW model) — price the next breach against that, not the model
|
|
1655
|
+
// captured at run() time.
|
|
1656
|
+
const r = await this.host.breach.decide(constraints.onBreach, breach, {
|
|
1657
|
+
currentModel: activeModel,
|
|
1658
|
+
// Reuse the tool-permission confirm dialog (pendingPerm/resolvePerm) as
|
|
1659
|
+
// the yes/no "continue past budget?" prompt — same pattern as AgentPanel's
|
|
1660
|
+
// per-run budget control: a 'budget' pseudo-tool the overlay special-cases.
|
|
1661
|
+
ask: (m) => paused(() => new Promise((resolve) => {
|
|
1662
|
+
this.raisePerm(id, { id: this.nextPermId++, tool: 'budget', summary: m, askedAt: Date.now(), resolve });
|
|
1663
|
+
})),
|
|
1664
|
+
// Reuse the existing "append a local notice turn" affordance (used
|
|
1665
|
+
// today for /model confirmations) for a soft warn — ALSO appended to
|
|
1666
|
+
// policyNotes, unless the caller opted into policyNotes-only
|
|
1667
|
+
// rendering (see SendOpts.budgetNotices).
|
|
1668
|
+
warn: (m) => {
|
|
1669
|
+
if ((opts.budgetNotices ?? 'transcript') === 'transcript')
|
|
1670
|
+
this.note(id, m);
|
|
1671
|
+
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: m, action: 'warn' }] });
|
|
1672
|
+
},
|
|
1673
|
+
cheaperModel: this.host.breach.cheaperModel(() => s.lastUsage),
|
|
1674
|
+
});
|
|
1675
|
+
if (r.model) {
|
|
1676
|
+
activeModel = r.model;
|
|
1677
|
+
this.patch(id, { degradedModel: r.model });
|
|
1678
|
+
}
|
|
1679
|
+
return r;
|
|
1680
|
+
}
|
|
1681
|
+
: undefined,
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
/** One turn of the worker's agent loop, under the engine's control. */
|
|
1685
|
+
async runWorker(messages, runOpts, control) {
|
|
1686
|
+
const id = control.conversationId;
|
|
1687
|
+
const t = this.turnFor(id);
|
|
1688
|
+
t.control = control;
|
|
1689
|
+
const { opts } = t;
|
|
1690
|
+
const review = opts.onEditReview;
|
|
1691
|
+
const res = await this.getWorker().run(messages, {
|
|
1692
|
+
...runOpts,
|
|
1693
|
+
system: control.system,
|
|
1694
|
+
signal: control.signal,
|
|
1695
|
+
sessionId: id,
|
|
1696
|
+
// Record the worker's run id the instant it exists, so inject() (which
|
|
1697
|
+
// can arrive at any point during this run) has something live to address.
|
|
1698
|
+
onStart: (runId) => control.onStart(String(runId)),
|
|
1699
|
+
// The queue's only way to tell "landed" from "never made it": the
|
|
1700
|
+
// AgentEvent 'injected' the loop emits per message carries text, not the
|
|
1701
|
+
// caller's id, so a reverting caller reads THIS to know which of its
|
|
1702
|
+
// still-pending ids it must NOT revert (reverting one that already
|
|
1703
|
+
// landed would re-send it as a fresh turn and duplicate it).
|
|
1704
|
+
onInjected: (ids) => control.onInjected(ids),
|
|
1705
|
+
// The engine answers from the session allows, or asks (askPermission).
|
|
1706
|
+
onPermission: async (req) => toPermissionAnswer(await control.askPermission({
|
|
1707
|
+
tool: req.tool, summary: req.summary, ...(req.agent ? { agent: req.agent } : {}), ...(req.input ? { input: req.input } : {}),
|
|
1708
|
+
signal: control.signal,
|
|
1709
|
+
})),
|
|
1710
|
+
onAsk: (questions) => control.askUser(questions),
|
|
1711
|
+
// propose_edit blocks on the user's verdict — the watchdog holds (like an
|
|
1712
|
+
// open ask form) until the surface resolves it.
|
|
1713
|
+
onEditReview: review ? (callId) => control.paused(() => this.reviewEditFor(opts, callId, control.signal)) : undefined,
|
|
1714
|
+
onEvent: (e) => {
|
|
1715
|
+
// The engine first (the live slice, then this side's handlers, the task
|
|
1716
|
+
// list among them); a host hook that throws cannot cost it an event.
|
|
1717
|
+
control.onEvent(e);
|
|
1718
|
+
// A blocked result goes no further than this page's memory (onToolHooks above).
|
|
1719
|
+
const kept = withoutBlockedText(e);
|
|
1720
|
+
opts.onRunEvent?.(kept);
|
|
1721
|
+
t.recBuf.push({ at: Date.now(), ev: kept });
|
|
1722
|
+
},
|
|
1723
|
+
});
|
|
1724
|
+
const priced = t.priced ?? this.host.modelInfo(t.model);
|
|
1725
|
+
const spend = t.calleeSpend;
|
|
1726
|
+
const u = res.usage ?? { promptTokens: 0, completionTokens: 0 };
|
|
1727
|
+
const stats = u.promptTokens || u.completionTokens || spend.inTok || spend.outTok
|
|
1728
|
+
? {
|
|
1729
|
+
// This agent's own turns plus every callee's.
|
|
1730
|
+
inTok: u.promptTokens + spend.inTok,
|
|
1731
|
+
outTok: u.completionTokens + spend.outTok,
|
|
1732
|
+
// Cache-aware: the hand-rolled in*price it replaced billed cached tokens at the full rate.
|
|
1733
|
+
cost: (priceUsageWith(u, { inPrice: priced.inPrice, outPrice: priced.outPrice }) ?? 0) + spend.cost,
|
|
1734
|
+
ms: Date.now() - t.startedAt,
|
|
1735
|
+
...(res.contextTokens ? { ctxTok: res.contextTokens } : {}),
|
|
1736
|
+
...(u.cachedTokens != null ? { cachedTok: u.cachedTokens } : {}),
|
|
1737
|
+
...(res.calls ? { calls: res.calls } : {}),
|
|
1738
|
+
}
|
|
1739
|
+
: undefined;
|
|
1740
|
+
return {
|
|
1741
|
+
messages: res.messages,
|
|
1742
|
+
// FROM THE TAIL, by the count the run reports (stitchTurn): rung-2
|
|
1743
|
+
// compaction rewrites the array shorter than what was sent.
|
|
1744
|
+
appended: res.appended,
|
|
1745
|
+
stopReason: res.stopReason,
|
|
1746
|
+
...(res.usage ? { usage: res.usage } : {}),
|
|
1747
|
+
...(stats ? { stats } : {}),
|
|
1748
|
+
// The engine measures every tool's duration from its own events.
|
|
1749
|
+
toolMs: {},
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
/** What a run's end owes this side, once the engine has settled the turn.
|
|
1753
|
+
*
|
|
1754
|
+
* The engine calls this once for EVERY turn (R3): one refused before it
|
|
1755
|
+
* started (empty input, or a second send while a run is going), a refused
|
|
1756
|
+
* retry, one stopped before its loop, and a host turn. Only a send or retry
|
|
1757
|
+
* whose loop actually ran owes the run-end effects below (usage, the
|
|
1758
|
+
* auto-deny of an open permission card, the subagent parent-ended signal,
|
|
1759
|
+
* the recording). Anything else only notifies: a refused second send must
|
|
1760
|
+
* not deny or bill the run that IS going. */
|
|
1761
|
+
endTurn(result) {
|
|
1762
|
+
const id = result.conversationId;
|
|
1763
|
+
if (!id)
|
|
1764
|
+
return;
|
|
1765
|
+
const t = this.active.get(id);
|
|
1766
|
+
const mine = t !== undefined && t.userTurnId === result.userTurnId;
|
|
1767
|
+
if (t && mine)
|
|
1768
|
+
this.active.delete(id);
|
|
1769
|
+
if (!t || !mine || !t.allowed || !result.ran || result.kind === 'host-turn') {
|
|
1770
|
+
this.notify(id);
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
const s = t.ui;
|
|
1774
|
+
const { stats } = result;
|
|
1775
|
+
const priced = t.priced;
|
|
1776
|
+
if (stats && priced) {
|
|
1777
|
+
this.host.recordUsage({ surface: this.surface, provider: priced.provider, model: t.model, modelLabel: priced.label, inEst: null, inTok: stats.inTok, outTok: stats.outTok, cost: stats.cost });
|
|
1778
|
+
}
|
|
1779
|
+
// A run that ends (abort/watchdog/error) while a perm overlay (tool-permission
|
|
1780
|
+
// or budget-breach "continue?") is still open must not leave its promise —
|
|
1781
|
+
// and the caller awaiting it — hanging forever. Treat an unanswered perm as
|
|
1782
|
+
// "no". resolvePerm() already nulls s.pendingPerm once the user answers, so
|
|
1783
|
+
// this is a no-op (idempotent) on the happy path. Queued asks go the same way.
|
|
1784
|
+
if (s.pendingPerm) {
|
|
1785
|
+
s.pendingPerm.resolve(false);
|
|
1786
|
+
s.pendingPerm = null;
|
|
1787
|
+
}
|
|
1788
|
+
this.denyAllPerms(id);
|
|
1789
|
+
s.recovery = null;
|
|
1790
|
+
this.notify(id);
|
|
1791
|
+
this.notifyGlobal();
|
|
1792
|
+
// Any callee still marked running under this session will never report.
|
|
1793
|
+
publishSubagentParentEnded(id);
|
|
1794
|
+
// Upsert the session recording with everything captured so far (this turn's
|
|
1795
|
+
// events appended to prior turns), under one stable id so the whole session
|
|
1796
|
+
// replays as a single stream. Skipped until something replayable happened.
|
|
1797
|
+
const recBuf = t.recBuf;
|
|
1798
|
+
if (this.host.recorder && recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
|
|
1799
|
+
if (!s.recId) {
|
|
1800
|
+
s.recId = this.host.recorder.newId();
|
|
1801
|
+
s.recPrompt = t.text;
|
|
1802
|
+
s.recImages = t.images.length ? t.images.slice() : undefined;
|
|
1803
|
+
}
|
|
1804
|
+
// `ok` is THIS turn's outcome (slice from turnStart); a snapshot copy of the
|
|
1805
|
+
// events so the store doesn't hold the live array that later turns mutate.
|
|
1806
|
+
const ok = !recBuf.slice(t.turnStart).some((r) => r.ev.type === 'error');
|
|
1807
|
+
this.host.recorder.save({ kind: 'agent', id: s.recId, title: (s.recPrompt || 'Agent run').slice(0, 60), at: Date.now(), ok, prompt: s.recPrompt || t.text, promptImages: s.recImages, events: recBuf.slice() });
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1740
1810
|
}
|
|
1741
1811
|
//# sourceMappingURL=runtime.js.map
|