@corenel/tools-web 0.1.0 → 0.3.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/budgetGate.d.ts +21 -0
- package/dist/budgetGate.d.ts.map +1 -0
- package/dist/budgetGate.js +41 -0
- package/dist/budgetGate.js.map +1 -0
- package/dist/contextDocs.d.ts +54 -0
- package/dist/contextDocs.d.ts.map +1 -0
- package/dist/contextDocs.js +145 -0
- package/dist/contextDocs.js.map +1 -0
- package/dist/core/protocol.d.ts +58 -2
- package/dist/core/protocol.d.ts.map +1 -1
- package/dist/idb.d.ts +9 -0
- package/dist/idb.d.ts.map +1 -1
- package/dist/idb.js +15 -0
- package/dist/idb.js.map +1 -1
- package/dist/memory/local.d.ts.map +1 -1
- package/dist/memory/local.js.map +1 -1
- package/dist/prompts/history.d.ts.map +1 -1
- package/dist/prompts/history.js +11 -6
- package/dist/prompts/history.js.map +1 -1
- package/dist/runWatchdog.d.ts +20 -0
- package/dist/runWatchdog.d.ts.map +1 -0
- package/dist/runWatchdog.js +66 -0
- package/dist/runWatchdog.js.map +1 -0
- package/dist/runtime.d.ts +444 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +858 -0
- package/dist/runtime.js.map +1 -0
- package/dist/runtime.testHost.d.ts +9 -0
- package/dist/runtime.testHost.d.ts.map +1 -0
- package/dist/runtime.testHost.js +53 -0
- package/dist/runtime.testHost.js.map +1 -0
- package/dist/storageQuota.d.ts +25 -0
- package/dist/storageQuota.d.ts.map +1 -0
- package/dist/storageQuota.js +89 -0
- package/dist/storageQuota.js.map +1 -0
- package/dist/storageRegistry.d.ts +53 -0
- package/dist/storageRegistry.d.ts.map +1 -0
- package/dist/storageRegistry.js +40 -0
- package/dist/storageRegistry.js.map +1 -0
- package/dist/workerClient.d.ts +61 -3
- package/dist/workerClient.d.ts.map +1 -1
- package/dist/workerClient.js +72 -5
- package/dist/workerClient.js.map +1 -1
- package/dist/workerHandler.d.ts +15 -0
- package/dist/workerHandler.d.ts.map +1 -1
- package/dist/workerHandler.js +105 -62
- package/dist/workerHandler.js.map +1 -1
- package/package.json +19 -29
package/dist/runtime.js
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import { createWorkerAgent } from './workerClient';
|
|
2
|
+
import { createRunWatchdog } from './runWatchdog';
|
|
3
|
+
import { listDocs, deleteDocsForSession } from './contextDocs';
|
|
4
|
+
import { allTools } from '@corenel/harness/tools/builtins';
|
|
5
|
+
import { applyTodoWrite, addActivity, resolveActivity, clearTodos } from '@corenel/harness/todos';
|
|
6
|
+
import { getPolicy, STANDARD_POLICY } from '@corenel/harness/guardrail';
|
|
7
|
+
import { composeAgentSystem, TOOLS_MARKER } from '@corenel/harness/prompts';
|
|
8
|
+
import { getSystemParts } from '@corenel/harness/systemParts';
|
|
9
|
+
// The agent's model is the user's picker selection (getModel) — a real
|
|
10
|
+
// catalog entry, so pricing/context come from host.modelInfo, not hardcoded figures.
|
|
11
|
+
const WATCHDOG_MS = 45000;
|
|
12
|
+
function emptyInternal() {
|
|
13
|
+
return {
|
|
14
|
+
turns: [], live: null, running: false, error: '', mode: 'auto', input: '', attachments: [],
|
|
15
|
+
pendingPerm: null, pendingAsk: null, loaded: false, suggestions: [], recovery: null, abort: null, toolMs: {}, allowed: new Set(),
|
|
16
|
+
pendingCommand: null,
|
|
17
|
+
recEvents: [], lastUsage: { promptTokens: 0, completionTokens: 0 },
|
|
18
|
+
offloadTick: 0, policyNotes: [], degradedModel: null,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Build the user message — a plain string, or multimodal parts when images are attached. */
|
|
22
|
+
function buildUserMessage(text, images) {
|
|
23
|
+
if (!images.length)
|
|
24
|
+
return { role: 'user', content: text };
|
|
25
|
+
const parts = [];
|
|
26
|
+
if (text)
|
|
27
|
+
parts.push({ type: 'text', text });
|
|
28
|
+
for (const url of images)
|
|
29
|
+
parts.push({ type: 'image_url', image_url: { url } });
|
|
30
|
+
return { role: 'user', content: parts };
|
|
31
|
+
}
|
|
32
|
+
function userText(m) {
|
|
33
|
+
if (typeof m.content === 'string')
|
|
34
|
+
return m.content;
|
|
35
|
+
if (Array.isArray(m.content)) {
|
|
36
|
+
const t = m.content.find((p) => p.type === 'text');
|
|
37
|
+
return t && 'text' in t ? t.text : '';
|
|
38
|
+
}
|
|
39
|
+
return '';
|
|
40
|
+
}
|
|
41
|
+
function userImages(m) {
|
|
42
|
+
if (!Array.isArray(m.content))
|
|
43
|
+
return [];
|
|
44
|
+
return m.content.filter((p) => p.type === 'image_url').map((p) => ('image_url' in p ? p.image_url.url : '')).filter(Boolean);
|
|
45
|
+
}
|
|
46
|
+
/** Merge a form's freshly-emitted envelope into a per-run override, against the
|
|
47
|
+
* currently saved default. The form only emits the keys it still wants set — a
|
|
48
|
+
* cleared field (e.g. "No limit") is simply ABSENT from `emitted`, not `undefined`.
|
|
49
|
+
* A plain `{...savedDefault, ...emitted}` merge at read time would then silently
|
|
50
|
+
* fall back to the saved default for that field, so "No limit" could never
|
|
51
|
+
* actually clear a saved cap. Fix: for every key present in `savedDefault` but
|
|
52
|
+
* absent from `emitted`, materialize an explicit `undefined` in the returned
|
|
53
|
+
* override so the read-time spread masks it (`{...{maxUsd:2}, ...{maxUsd:undefined}}`
|
|
54
|
+
* -> `maxUsd: undefined`, which all `!= null` consumers treat as no limit). Keys the
|
|
55
|
+
* form already sends as `undefined` (its own internal clears) pass through unchanged. */
|
|
56
|
+
export function mergeOverride(savedDefault, emitted) {
|
|
57
|
+
const next = { ...emitted };
|
|
58
|
+
for (const key of Object.keys(savedDefault)) {
|
|
59
|
+
if (!(key in emitted))
|
|
60
|
+
next[key] = undefined;
|
|
61
|
+
}
|
|
62
|
+
return next;
|
|
63
|
+
}
|
|
64
|
+
export class AgentRuntime {
|
|
65
|
+
/** `surface` namespaces this runtime's sessions in the store (so /agent and /craft
|
|
66
|
+
* never list each other's chats). `systemExtra` is prepended to every run's system
|
|
67
|
+
* context — Craft uses it to carry the document-creation directive. */
|
|
68
|
+
constructor(host, surface = 'agent',
|
|
69
|
+
// Not marked optional (`?`) — a required parameter (getModel) follows it, and
|
|
70
|
+
// TS forbids an optional parameter before a required one. Callers (createAgentRuntime)
|
|
71
|
+
// always pass all four positionally, so this is a mechanical, not semantic, change.
|
|
72
|
+
systemExtra,
|
|
73
|
+
/** Resolves the model id for each run — the host factory always passes one
|
|
74
|
+
* (Corenel's agent model, or Craft's own surface getter). */
|
|
75
|
+
getModel,
|
|
76
|
+
/** Optional sampling temperature for each run. The HOST resolves this,
|
|
77
|
+
* because whether a temperature may be sent at all depends on the model
|
|
78
|
+
* family (reasoning models reject a non-default value) and model metadata
|
|
79
|
+
* lives host-side. Undefined leaves the provider default in place. */
|
|
80
|
+
getTemperature) {
|
|
81
|
+
Object.defineProperty(this, "host", {
|
|
82
|
+
enumerable: true,
|
|
83
|
+
configurable: true,
|
|
84
|
+
writable: true,
|
|
85
|
+
value: host
|
|
86
|
+
});
|
|
87
|
+
Object.defineProperty(this, "surface", {
|
|
88
|
+
enumerable: true,
|
|
89
|
+
configurable: true,
|
|
90
|
+
writable: true,
|
|
91
|
+
value: surface
|
|
92
|
+
});
|
|
93
|
+
Object.defineProperty(this, "systemExtra", {
|
|
94
|
+
enumerable: true,
|
|
95
|
+
configurable: true,
|
|
96
|
+
writable: true,
|
|
97
|
+
value: systemExtra
|
|
98
|
+
});
|
|
99
|
+
Object.defineProperty(this, "getModel", {
|
|
100
|
+
enumerable: true,
|
|
101
|
+
configurable: true,
|
|
102
|
+
writable: true,
|
|
103
|
+
value: getModel
|
|
104
|
+
});
|
|
105
|
+
Object.defineProperty(this, "getTemperature", {
|
|
106
|
+
enumerable: true,
|
|
107
|
+
configurable: true,
|
|
108
|
+
writable: true,
|
|
109
|
+
value: getTemperature
|
|
110
|
+
});
|
|
111
|
+
Object.defineProperty(this, "map", {
|
|
112
|
+
enumerable: true,
|
|
113
|
+
configurable: true,
|
|
114
|
+
writable: true,
|
|
115
|
+
value: new Map()
|
|
116
|
+
});
|
|
117
|
+
Object.defineProperty(this, "subs", {
|
|
118
|
+
enumerable: true,
|
|
119
|
+
configurable: true,
|
|
120
|
+
writable: true,
|
|
121
|
+
value: new Map()
|
|
122
|
+
});
|
|
123
|
+
Object.defineProperty(this, "globalSubs", {
|
|
124
|
+
enumerable: true,
|
|
125
|
+
configurable: true,
|
|
126
|
+
writable: true,
|
|
127
|
+
value: new Set()
|
|
128
|
+
});
|
|
129
|
+
Object.defineProperty(this, "_ws", {
|
|
130
|
+
enumerable: true,
|
|
131
|
+
configurable: true,
|
|
132
|
+
writable: true,
|
|
133
|
+
value: null
|
|
134
|
+
});
|
|
135
|
+
Object.defineProperty(this, "worker", {
|
|
136
|
+
enumerable: true,
|
|
137
|
+
configurable: true,
|
|
138
|
+
writable: true,
|
|
139
|
+
value: null
|
|
140
|
+
});
|
|
141
|
+
/** Reuse the most-recent empty session if there is one, else create a fresh one.
|
|
142
|
+
* Returns the id to focus on page open — keeps a single throwaway "New chat"
|
|
143
|
+
* around instead of breeding empties. Concurrent calls are deduped (React
|
|
144
|
+
* StrictMode double-invokes the mount effect) so the page never opens two. */
|
|
145
|
+
Object.defineProperty(this, "initInFlight", {
|
|
146
|
+
enumerable: true,
|
|
147
|
+
configurable: true,
|
|
148
|
+
writable: true,
|
|
149
|
+
value: null
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
ws() {
|
|
153
|
+
if (!this._ws)
|
|
154
|
+
this._ws = this.host.workspace();
|
|
155
|
+
// This surface is active — its workspace backs the session/recall stores.
|
|
156
|
+
// Gated on whenReady() so a fresh page load never lets the session store
|
|
157
|
+
// resolve the default root before the persisted source has been restored.
|
|
158
|
+
this.host.installFilesResolver(async () => {
|
|
159
|
+
const w = this._ws;
|
|
160
|
+
await w.whenReady();
|
|
161
|
+
return w.get();
|
|
162
|
+
});
|
|
163
|
+
return this._ws;
|
|
164
|
+
}
|
|
165
|
+
getWorker() {
|
|
166
|
+
if (!this.worker)
|
|
167
|
+
this.worker = createWorkerAgent({ getFileService: () => this.ws().get(), ...this.host.workerDeps });
|
|
168
|
+
return this.worker;
|
|
169
|
+
}
|
|
170
|
+
/** The active file workspace (the page shows its label as a context chip). */
|
|
171
|
+
fileService() { return this.ws().get(); }
|
|
172
|
+
workspace() { return this.ws(); }
|
|
173
|
+
/* ---- reactivity ---- */
|
|
174
|
+
ensure(id) {
|
|
175
|
+
let s = this.map.get(id);
|
|
176
|
+
if (!s) {
|
|
177
|
+
s = emptyInternal();
|
|
178
|
+
const dm = this.host.defaultMode?.();
|
|
179
|
+
if (dm)
|
|
180
|
+
s.mode = dm;
|
|
181
|
+
this.map.set(id, s);
|
|
182
|
+
}
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
notify(id) { this.subs.get(id)?.forEach((fn) => fn()); }
|
|
186
|
+
notifyGlobal() { this.globalSubs.forEach((fn) => fn()); }
|
|
187
|
+
/** Mutate a session and notify its view; `global` also pokes the running-badge subscribers. */
|
|
188
|
+
patch(id, p, global = false) {
|
|
189
|
+
Object.assign(this.ensure(id), p);
|
|
190
|
+
this.notify(id);
|
|
191
|
+
if (global)
|
|
192
|
+
this.notifyGlobal();
|
|
193
|
+
}
|
|
194
|
+
subscribe(id, cb) {
|
|
195
|
+
let set = this.subs.get(id);
|
|
196
|
+
if (!set) {
|
|
197
|
+
set = new Set();
|
|
198
|
+
this.subs.set(id, set);
|
|
199
|
+
}
|
|
200
|
+
set.add(cb);
|
|
201
|
+
return () => { set.delete(cb); };
|
|
202
|
+
}
|
|
203
|
+
subscribeGlobal(cb) { this.globalSubs.add(cb); return () => { this.globalSubs.delete(cb); }; }
|
|
204
|
+
getState(id) { return this.map.get(id) ?? emptyInternal(); }
|
|
205
|
+
status(id) {
|
|
206
|
+
const s = this.map.get(id);
|
|
207
|
+
if (!s)
|
|
208
|
+
return 'idle';
|
|
209
|
+
if (s.pendingPerm || s.pendingAsk)
|
|
210
|
+
return 'waiting';
|
|
211
|
+
// A running command is a running session. Reporting 'idle' while a command
|
|
212
|
+
// is in flight is what let a hung one look like a keystroke that did
|
|
213
|
+
// nothing, and it is the same claim the session list and the tab title make.
|
|
214
|
+
return s.running || s.pendingCommand ? 'running' : 'idle';
|
|
215
|
+
}
|
|
216
|
+
/* ---- session lifecycle ---- */
|
|
217
|
+
/** The session store reads through the module-global active-files resolver
|
|
218
|
+
* (stateFs), which only THIS runtime's ws() installs. Every session-store
|
|
219
|
+
* entry point must assert it first — a page that never touches
|
|
220
|
+
* workspace()/fileService() before its mount effect (CraftPage direct load)
|
|
221
|
+
* otherwise hits the default null resolver and the store throws
|
|
222
|
+
* "No file workspace is connected". */
|
|
223
|
+
/** Lazily load a session's turns from the store the first time it's focused. */
|
|
224
|
+
async load(id) {
|
|
225
|
+
if (!id)
|
|
226
|
+
return;
|
|
227
|
+
this.ws();
|
|
228
|
+
const s = this.ensure(id);
|
|
229
|
+
if (s.loaded)
|
|
230
|
+
return;
|
|
231
|
+
const ses = await this.host.sessions.get(id);
|
|
232
|
+
s.turns = ses?.messages || [];
|
|
233
|
+
s.loaded = true;
|
|
234
|
+
this.notify(id);
|
|
235
|
+
}
|
|
236
|
+
async openInitial() {
|
|
237
|
+
if (this.initInFlight)
|
|
238
|
+
return this.initInFlight;
|
|
239
|
+
this.ws();
|
|
240
|
+
this.initInFlight = (async () => {
|
|
241
|
+
const list = await this.host.sessions.list(this.surface);
|
|
242
|
+
const empty = list.find((s) => !s.messages || s.messages.length === 0);
|
|
243
|
+
if (empty) {
|
|
244
|
+
this.ensure(empty.id).loaded = true;
|
|
245
|
+
return empty.id;
|
|
246
|
+
}
|
|
247
|
+
return this.newSession();
|
|
248
|
+
})();
|
|
249
|
+
try {
|
|
250
|
+
return await this.initInFlight;
|
|
251
|
+
}
|
|
252
|
+
finally {
|
|
253
|
+
this.initInFlight = null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async newSession() {
|
|
257
|
+
this.ws();
|
|
258
|
+
/* A NEW CHAT STARTS WITH NO TASKS.
|
|
259
|
+
*
|
|
260
|
+
* The task list is one module-level store keyed by nothing, so it outlived
|
|
261
|
+
* both the run that made it and the session it was made in: a fresh chat
|
|
262
|
+
* opened showing another agent's half-finished plan, and the only way out
|
|
263
|
+
* was to clear it by hand. Reported from a real machine.
|
|
264
|
+
*
|
|
265
|
+
* This is the narrow fix — a NEW session, where inherited tasks are never
|
|
266
|
+
* right. Switching BETWEEN existing sessions still shows one shared list;
|
|
267
|
+
* making the store per-session is the real repair and a bigger change than
|
|
268
|
+
* this one. */
|
|
269
|
+
clearTodos();
|
|
270
|
+
const ses = await this.host.sessions.create('New chat', this.surface);
|
|
271
|
+
const s = this.ensure(ses.id);
|
|
272
|
+
s.loaded = true;
|
|
273
|
+
this.notifyGlobal();
|
|
274
|
+
return ses.id;
|
|
275
|
+
}
|
|
276
|
+
async deleteSession(id) {
|
|
277
|
+
this.ws();
|
|
278
|
+
this.map.get(id)?.abort?.abort();
|
|
279
|
+
this.map.delete(id);
|
|
280
|
+
this.subs.delete(id);
|
|
281
|
+
await this.host.sessions.delete(id).catch(() => { });
|
|
282
|
+
deleteDocsForSession(id);
|
|
283
|
+
this.notifyGlobal();
|
|
284
|
+
}
|
|
285
|
+
/* ---- view mutators ---- */
|
|
286
|
+
setInput(id, v) { this.patch(id, { input: v }); }
|
|
287
|
+
setMode(id, m) { this.patch(id, { mode: m }); }
|
|
288
|
+
/** Clear the conversation (keep the session) — empties the visible transcript and
|
|
289
|
+
* starts a fresh recording stream for what follows. */
|
|
290
|
+
clear(id) {
|
|
291
|
+
const s = this.ensure(id);
|
|
292
|
+
s.recId = undefined;
|
|
293
|
+
s.recPrompt = undefined;
|
|
294
|
+
s.recImages = undefined;
|
|
295
|
+
s.recEvents = [];
|
|
296
|
+
this.patch(id, { turns: [], error: '', suggestions: [] }, true);
|
|
297
|
+
void this.persist(id);
|
|
298
|
+
}
|
|
299
|
+
/** Append a local assistant-style notice turn (e.g. a /model confirmation) — not
|
|
300
|
+
* sent to the model, just shown in the transcript. */
|
|
301
|
+
note(id, text) {
|
|
302
|
+
const s = this.ensure(id);
|
|
303
|
+
const turn = { id: `n${Date.now().toString(36)}`, included: false, messages: [{ role: 'assistant', content: text }] };
|
|
304
|
+
this.patch(id, { turns: [...s.turns, turn] });
|
|
305
|
+
void this.persist(id);
|
|
306
|
+
}
|
|
307
|
+
setAttachments(id, fn) {
|
|
308
|
+
this.patch(id, { attachments: fn(this.ensure(id).attachments) });
|
|
309
|
+
}
|
|
310
|
+
toggleInclude(id, turnId) {
|
|
311
|
+
const s = this.ensure(id);
|
|
312
|
+
this.patch(id, { turns: s.turns.map((t) => (t.id === turnId ? { ...t, included: !t.included } : t)) });
|
|
313
|
+
void this.persist(id);
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Ask the user to approve something the HOST is about to do, through the same
|
|
317
|
+
* card a tool call raises.
|
|
318
|
+
*
|
|
319
|
+
* A console command typed into the chat is judged by the same policy as the
|
|
320
|
+
* agent's own tools (see terminal/commandSubject), and when that policy says
|
|
321
|
+
* `ask` it has to actually ask - through this, not a second prompt of its own.
|
|
322
|
+
* Two different approval dialogs for the same act is how people learn to click
|
|
323
|
+
* the one that appears more often without reading it.
|
|
324
|
+
*
|
|
325
|
+
* Resolves false if the session is gone, so a caller can never read a dropped
|
|
326
|
+
* prompt as consent.
|
|
327
|
+
*/
|
|
328
|
+
requestPermission(id, tool, summary) {
|
|
329
|
+
const s = this.map.get(id);
|
|
330
|
+
if (!s)
|
|
331
|
+
return Promise.resolve(false);
|
|
332
|
+
if (s.allowed.has(tool))
|
|
333
|
+
return Promise.resolve(true);
|
|
334
|
+
return new Promise((resolve) => {
|
|
335
|
+
// 'you': this path exists for something the USER typed (a `>pd` command),
|
|
336
|
+
// never for the run loop, which raises its own asks through onPermission.
|
|
337
|
+
this.patch(id, { pendingPerm: { tool, summary, requester: 'you', resolve } }, true);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Record a console command run from the chat as a synthetic tool call+result.
|
|
342
|
+
*
|
|
343
|
+
* The SHAPE is the point. Written as the message pair a real tool call
|
|
344
|
+
* produces, it gets four things with no new machinery: the agent sees it in a
|
|
345
|
+
* form it already understands, it persists to the session log (a
|
|
346
|
+
* transcript-only rendering would not), it replays in recordings, and the
|
|
347
|
+
* user can promote it into context with the include toggle every turn
|
|
348
|
+
* already has.
|
|
349
|
+
*
|
|
350
|
+
* IT IS RECORDED OUT OF CONTEXT. Persistence and context-inclusion are
|
|
351
|
+
* separate switches here - persist() writes every turn, while only `included`
|
|
352
|
+
* turns are sent to the model - and a command should default to written-down
|
|
353
|
+
* but not re-sent. A transcript message is the most expensive kind of
|
|
354
|
+
* context: it is paid again on every subsequent turn for the rest of the
|
|
355
|
+
* session, so a handful of exploratory `pd ls` calls quietly become a
|
|
356
|
+
* permanent tax. Defaulting IN would put the work of noticing on the user at
|
|
357
|
+
* exactly the moment they are thinking about something else, and the ones
|
|
358
|
+
* they forget are the ones that compound. Defaulting OUT makes forgetting
|
|
359
|
+
* free and makes Keep the deliberate act.
|
|
360
|
+
*/
|
|
361
|
+
/** Mark a console command as running. Paired with recordCommand, which clears
|
|
362
|
+
* it - so the indicator cannot outlive the command even if it fails, since the
|
|
363
|
+
* failure is itself recorded. */
|
|
364
|
+
beginCommand(id, command, startedAt) {
|
|
365
|
+
this.patch(id, { pendingCommand: { command, startedAt } }, true);
|
|
366
|
+
}
|
|
367
|
+
/** Give up on a command that will never settle (the surface unmounted, the
|
|
368
|
+
* session was closed). Clears the indicator WITHOUT recording a result,
|
|
369
|
+
* because there is no honest result to record. */
|
|
370
|
+
abandonCommand(id) {
|
|
371
|
+
if (!this.map.get(id)?.pendingCommand)
|
|
372
|
+
return;
|
|
373
|
+
this.patch(id, { pendingCommand: null }, true);
|
|
374
|
+
}
|
|
375
|
+
recordCommand(id, command, output, ok) {
|
|
376
|
+
const s = this.ensure(id);
|
|
377
|
+
const callId = `cmd_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
378
|
+
const turn = {
|
|
379
|
+
id: callId,
|
|
380
|
+
// Not included: kept on disk, out of the context window until Keep.
|
|
381
|
+
included: false,
|
|
382
|
+
messages: [
|
|
383
|
+
{
|
|
384
|
+
role: 'assistant',
|
|
385
|
+
content: null,
|
|
386
|
+
tool_calls: [{
|
|
387
|
+
id: callId,
|
|
388
|
+
type: 'function',
|
|
389
|
+
// `ok` rides along in the arguments deliberately. On a synthetic call
|
|
390
|
+
// it is part of the record, and once kept it tells the model the
|
|
391
|
+
// command failed - which it would otherwise have to infer from
|
|
392
|
+
// whatever the tool happened to print.
|
|
393
|
+
function: { name: 'console_command', arguments: JSON.stringify({ command, ok }) },
|
|
394
|
+
}],
|
|
395
|
+
},
|
|
396
|
+
{
|
|
397
|
+
role: 'tool',
|
|
398
|
+
tool_call_id: callId,
|
|
399
|
+
content: output || (ok ? '(no output)' : '(failed, no output)'),
|
|
400
|
+
},
|
|
401
|
+
],
|
|
402
|
+
};
|
|
403
|
+
this.patch(id, { turns: [...s.turns, turn], pendingCommand: null });
|
|
404
|
+
void this.persist(id);
|
|
405
|
+
}
|
|
406
|
+
resolvePerm(id, allow, always = false) {
|
|
407
|
+
const s = this.map.get(id);
|
|
408
|
+
if (!s?.pendingPerm)
|
|
409
|
+
return;
|
|
410
|
+
if (allow && always)
|
|
411
|
+
s.allowed.add(s.pendingPerm.tool);
|
|
412
|
+
s.pendingPerm.resolve(allow);
|
|
413
|
+
this.patch(id, { pendingPerm: null }, true);
|
|
414
|
+
}
|
|
415
|
+
resolveAsk(id, answers) {
|
|
416
|
+
const s = this.map.get(id);
|
|
417
|
+
if (!s?.pendingAsk)
|
|
418
|
+
return;
|
|
419
|
+
s.pendingAsk.resolve(answers);
|
|
420
|
+
this.patch(id, { pendingAsk: null }, true);
|
|
421
|
+
}
|
|
422
|
+
stop(id) { this.map.get(id)?.abort?.abort(); }
|
|
423
|
+
/* ---- run ---- */
|
|
424
|
+
async persist(id) {
|
|
425
|
+
const s = this.map.get(id);
|
|
426
|
+
if (!s)
|
|
427
|
+
return;
|
|
428
|
+
const ses = await this.host.sessions.get(id);
|
|
429
|
+
if (!ses)
|
|
430
|
+
return;
|
|
431
|
+
ses.messages = s.turns;
|
|
432
|
+
await this.host.sessions.save(ses).catch(() => { });
|
|
433
|
+
}
|
|
434
|
+
async composeSystem(id, persona, partsOverride) {
|
|
435
|
+
const svc = this.ws().get();
|
|
436
|
+
const s = this.ensure(id);
|
|
437
|
+
// systemExtra (e.g. Craft's document-creation directive) leads the context.
|
|
438
|
+
const blocks = this.systemExtra ? [this.systemExtra] : [];
|
|
439
|
+
if (svc) {
|
|
440
|
+
const src = svc.kind === 'directory' ? `local folder "${svc.label}"`
|
|
441
|
+
: svc.kind === 'sidecar' ? `connected sidecar machine "${svc.label}"`
|
|
442
|
+
: `virtual in-browser workspace "${svc.label}"`;
|
|
443
|
+
const flags = `${svc.isLocalDisk ? "on the user's real disk" : 'in-browser'}, ${svc.writable ? 'writable' : 'read-only'}`;
|
|
444
|
+
blocks.push(`--- Workspace ---\n` +
|
|
445
|
+
`Connected source: ${src} (${flags}).\n` +
|
|
446
|
+
`All file paths are relative to this workspace root (POSIX, no leading slash, e.g. "prompts/base.prmd"). ` +
|
|
447
|
+
`There is no separate working directory or shell — inspect the workspace with list_files / read_file / ` +
|
|
448
|
+
`stat_file / search_files, and change it with write_file / create_file / delete_file / rename_file. ` +
|
|
449
|
+
`The browser does not expose the folder's absolute path, so report locations relative to the root.`);
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
blocks.push(`--- Workspace ---\nNo file workspace is connected, so the file tools are unavailable. Say so rather than guessing about files.`);
|
|
453
|
+
}
|
|
454
|
+
// Host-supplied extra system-context blocks (project instructions, project
|
|
455
|
+
// memory, editor buffer …), resolved per send against the active FileService.
|
|
456
|
+
for (const p of this.host.contextProviders ?? []) {
|
|
457
|
+
const block = await p(svc);
|
|
458
|
+
if (block)
|
|
459
|
+
blocks.push(block);
|
|
460
|
+
}
|
|
461
|
+
// User-uploaded context documents (persistent chips): one stable block per
|
|
462
|
+
// doc, addedAt order — stable bytes/order keep provider prompt caches warm.
|
|
463
|
+
for (const d of listDocs(id)) {
|
|
464
|
+
blocks.push(`--- Attached document: ${d.name} ---\n${d.text}`);
|
|
465
|
+
}
|
|
466
|
+
const p = partsOverride ?? getSystemParts();
|
|
467
|
+
return composeAgentSystem({
|
|
468
|
+
persona: p.persona ? persona : '',
|
|
469
|
+
mode: s.mode,
|
|
470
|
+
includeMode: p.mode,
|
|
471
|
+
includeToolGuidance: p.toolGuidance,
|
|
472
|
+
systemPath: p.baseSystem ? undefined : this.host.systemPath?.(svc),
|
|
473
|
+
contextText: blocks.join('\n\n'),
|
|
474
|
+
toolsText: p.tools ? TOOLS_MARKER : '',
|
|
475
|
+
params: this.host.systemParams?.(),
|
|
476
|
+
memoryIndex: this.host.memoryIndex,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
/** Send a message and run the agent for `id`. Concurrent with other sessions:
|
|
480
|
+
* each run is its own AbortController + watchdog and streams into its own slice. */
|
|
481
|
+
async send(id, text, images, opts) {
|
|
482
|
+
const s = this.ensure(id);
|
|
483
|
+
if ((!text && images.length === 0) || s.running)
|
|
484
|
+
return;
|
|
485
|
+
// Feature guard — the runtime is the chokepoint for every /agent run (send
|
|
486
|
+
// and retry), so a quota/plan guard registered later covers this surface
|
|
487
|
+
// too. Checked before the turn is appended or the composer cleared, so a
|
|
488
|
+
// denial loses nothing; s.running is re-read after the await because
|
|
489
|
+
// another send may have claimed the session while the guard evaluated.
|
|
490
|
+
const gate = await this.host.guard('llm-execution');
|
|
491
|
+
if (!gate.allowed) {
|
|
492
|
+
this.patch(id, { error: gate.reason });
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (s.running)
|
|
496
|
+
return;
|
|
497
|
+
const isFirst = s.turns.length === 0;
|
|
498
|
+
const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [buildUserMessage(text, images)] };
|
|
499
|
+
const nextTurns = [...s.turns, userTurn];
|
|
500
|
+
s.toolMs = {};
|
|
501
|
+
this.patch(id, {
|
|
502
|
+
turns: nextTurns, error: '', live: { text: '', tools: [] }, running: true, input: '', attachments: [], suggestions: [],
|
|
503
|
+
policyNotes: [], degradedModel: null,
|
|
504
|
+
}, true);
|
|
505
|
+
const startedAt = Date.now();
|
|
506
|
+
// Title an untitled session from its first message, so the switcher is readable.
|
|
507
|
+
// A hand-renamed session is locked, so its chosen title is left alone.
|
|
508
|
+
if (isFirst && text) {
|
|
509
|
+
const ses = await this.host.sessions.get(id);
|
|
510
|
+
if (ses && !ses.titleLocked) {
|
|
511
|
+
ses.title = text.slice(0, 48);
|
|
512
|
+
await this.host.sessions.save(ses).catch(() => { });
|
|
513
|
+
this.notifyGlobal();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
const outgoing = nextTurns.filter((t) => t.included).flatMap((t) => t.messages);
|
|
517
|
+
const ac = new AbortController();
|
|
518
|
+
s.abort = ac;
|
|
519
|
+
// This turn's events append to the session's accumulated stream (one recording
|
|
520
|
+
// per session, not per turn) — see Internal.recEvents.
|
|
521
|
+
const recBuf = s.recEvents;
|
|
522
|
+
// Where THIS turn's events start — so `ok` reflects only this turn (a transient
|
|
523
|
+
// error in an earlier turn must not mark a recovered session as failed forever).
|
|
524
|
+
const turnStart = recBuf.length;
|
|
525
|
+
// Mark a FOLLOW-UP user turn so the replay shows it as a bubble in place. The
|
|
526
|
+
// first turn's prompt is the recording's `prompt` header, so only record once
|
|
527
|
+
// the session already has replayable content (avoids duplicating it).
|
|
528
|
+
if (recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
|
|
529
|
+
recBuf.push({ at: Date.now(), ev: { type: 'user', content: text, images: images.length ? images.slice() : undefined } });
|
|
530
|
+
}
|
|
531
|
+
// Shared wedged-run watchdog (see runWatchdog.ts): soft window while
|
|
532
|
+
// streaming, paused mid-tool with a hard cap so a never-settling tool
|
|
533
|
+
// (half-open sidecar socket) can't hang the run forever.
|
|
534
|
+
const dog = createRunWatchdog((reason) => {
|
|
535
|
+
ac.abort();
|
|
536
|
+
this.patch(id, {
|
|
537
|
+
error: reason === 'tool-hard-cap'
|
|
538
|
+
? 'A tool ran for over 10 minutes without completing — aborted. The connection to it may have dropped.'
|
|
539
|
+
: 'No response from the gateway in 45s — aborted. Check that you are signed in and the backend is reachable.',
|
|
540
|
+
});
|
|
541
|
+
}, { softMs: WATCHDOG_MS });
|
|
542
|
+
dog.bump();
|
|
543
|
+
try {
|
|
544
|
+
const system = await this.composeSystem(id, opts.persona, opts.partsOverride);
|
|
545
|
+
const model = this.getModel();
|
|
546
|
+
// Live model for onBreach pricing — reassigned when a degrade breach
|
|
547
|
+
// switches the run onto a cheaper model (see onBreach below).
|
|
548
|
+
let activeModel = model;
|
|
549
|
+
const priced = this.host.modelInfo(model);
|
|
550
|
+
const contextWindow = priced.contextWindow ?? 128000;
|
|
551
|
+
// The surface's constraints default (Task 7), overridden per run when the
|
|
552
|
+
// caller passes one (a masked-override envelope from `mergeOverride`) —
|
|
553
|
+
// mirrors AgentPanel's `eff = { ...savedDefault, ...perRunOverride }`.
|
|
554
|
+
// NOTE: this deliberately reads `this.surface`'s constraints, not a
|
|
555
|
+
// hardcoded 'agent' — a behavior change for /craft vs. the pre-extraction
|
|
556
|
+
// class (which always read 'agent'); 'craft' has no saved defaults so the
|
|
557
|
+
// effective result is unchanged.
|
|
558
|
+
const constraints = { ...this.host.constraints(this.surface), ...(opts.constraints ?? {}) };
|
|
559
|
+
const hasBudgetLimits = constraints.maxUsd != null || constraints.maxTokens != null || constraints.maxMs != null;
|
|
560
|
+
// Capability seam (Task 3): only send toolNames when the host's tools
|
|
561
|
+
// getter is NOT the default allTools (reference inequality) — this keeps
|
|
562
|
+
// the default path byte-identical, since every surface today passes
|
|
563
|
+
// `tools: allTools` (or omits capabilities entirely).
|
|
564
|
+
const capTools = this.host.capabilities?.tools;
|
|
565
|
+
const toolNames = capTools && capTools !== allTools ? capTools().map((t) => t.name) : undefined;
|
|
566
|
+
const toolDeny = this.host.capabilities?.toolDeny?.();
|
|
567
|
+
// Resolved per run so an edit to a callee (or its canCall list) applies to
|
|
568
|
+
// the next delegation without rebuilding the runtime. Never fatal: a read
|
|
569
|
+
// failure costs delegation, not the run.
|
|
570
|
+
const crewDelegates = await (this.host.capabilities?.delegates?.().catch(() => []) ?? Promise.resolve([]));
|
|
571
|
+
const res = await this.getWorker().run(outgoing, {
|
|
572
|
+
model,
|
|
573
|
+
temperature: this.getTemperature?.(),
|
|
574
|
+
toolDeny,
|
|
575
|
+
crewDelegates,
|
|
576
|
+
system,
|
|
577
|
+
policy: getPolicy(opts.policyName) ?? STANDARD_POLICY,
|
|
578
|
+
compression: { mode: this.host.compression(), contextWindow },
|
|
579
|
+
signal: ac.signal,
|
|
580
|
+
sessionId: id,
|
|
581
|
+
toolNames,
|
|
582
|
+
onPermission: (req) => new Promise((resolve) => {
|
|
583
|
+
if (s.allowed.has(req.tool)) {
|
|
584
|
+
resolve(true);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
this.patch(id, { pendingPerm: { ...req, resolve } }, true);
|
|
588
|
+
}),
|
|
589
|
+
onAsk: (questions) => new Promise((resolve) => {
|
|
590
|
+
dog.pause();
|
|
591
|
+
this.patch(id, { pendingAsk: { questions, resolve: (a) => { resolve(a); dog.bump(); } } }, true);
|
|
592
|
+
}),
|
|
593
|
+
// Editor-buffer surfaces pass onEditReview/editorFile (Task 4); the
|
|
594
|
+
// standalone page omits both, so propose_edit keeps its non-blocking
|
|
595
|
+
// fallback exactly as before.
|
|
596
|
+
editorFile: opts.editorFile,
|
|
597
|
+
onNodeEvent: opts.onNodeEvent,
|
|
598
|
+
onEditReview: opts.onEditReview
|
|
599
|
+
? async (callId) => {
|
|
600
|
+
// propose_edit blocks on the user's verdict — pause the watchdog
|
|
601
|
+
// (like an open ask form), resume once the host resolves it.
|
|
602
|
+
dog.pause();
|
|
603
|
+
const r = await opts.onEditReview(callId, ac.signal);
|
|
604
|
+
dog.bump();
|
|
605
|
+
return r;
|
|
606
|
+
}
|
|
607
|
+
: undefined,
|
|
608
|
+
constraints: hasBudgetLimits ? constraints : undefined,
|
|
609
|
+
price: (usage, priceModel) => this.host.price(usage, priceModel),
|
|
610
|
+
onBreach: constraints.onBreach && this.host.breach
|
|
611
|
+
? async (breach) => {
|
|
612
|
+
// Tracks the model actually running (workerClient resets its breach
|
|
613
|
+
// latch after a degrade, so a second in-run breach can fire on the
|
|
614
|
+
// NEW model) — price the next breach against that, not the model
|
|
615
|
+
// captured at run() time.
|
|
616
|
+
const r = await this.host.breach.decide(constraints.onBreach, breach, {
|
|
617
|
+
currentModel: activeModel,
|
|
618
|
+
// Reuse the tool-permission confirm dialog (pendingPerm/resolvePerm) as
|
|
619
|
+
// the yes/no "continue past budget?" prompt — same pattern as AgentPanel's
|
|
620
|
+
// per-run budget control: a 'budget' pseudo-tool the overlay special-cases.
|
|
621
|
+
ask: (m) => new Promise((resolve) => {
|
|
622
|
+
dog.pause();
|
|
623
|
+
this.patch(id, { pendingPerm: { tool: 'budget', summary: m, resolve: (b) => { resolve(b); dog.bump(); } } }, true);
|
|
624
|
+
}),
|
|
625
|
+
// Reuse the existing "append a local notice turn" affordance (used
|
|
626
|
+
// today for /model confirmations) for a soft warn — ALSO appended to
|
|
627
|
+
// policyNotes, unless the caller opted into policyNotes-only
|
|
628
|
+
// rendering (see SendOpts.budgetNotices).
|
|
629
|
+
warn: (m) => {
|
|
630
|
+
if ((opts.budgetNotices ?? 'transcript') === 'transcript')
|
|
631
|
+
this.note(id, m);
|
|
632
|
+
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: m, action: 'warn' }] });
|
|
633
|
+
},
|
|
634
|
+
cheaperModel: this.host.breach.cheaperModel(() => s.lastUsage),
|
|
635
|
+
});
|
|
636
|
+
if (r.model) {
|
|
637
|
+
activeModel = r.model;
|
|
638
|
+
this.patch(id, { degradedModel: r.model });
|
|
639
|
+
}
|
|
640
|
+
return r;
|
|
641
|
+
}
|
|
642
|
+
: undefined,
|
|
643
|
+
onEvent: (e) => {
|
|
644
|
+
/* The task list is fed HERE, at the one choke point every surface's
|
|
645
|
+
* runtime passes through - not by a host.
|
|
646
|
+
*
|
|
647
|
+
* It used to be wired in prompd's AgentPanel alone, so `todo_write`
|
|
648
|
+
* worked there and nowhere else: on corenel the model dutifully kept a
|
|
649
|
+
* plan across a multi-step job and not one surface could read it,
|
|
650
|
+
* because nothing was listening. A tool whose entire purpose is to be
|
|
651
|
+
* SEEN cannot depend on each host remembering to subscribe.
|
|
652
|
+
*
|
|
653
|
+
* Other tool calls bucket as activity under whichever task is active,
|
|
654
|
+
* which is what gives an expanded task its detail. */
|
|
655
|
+
if (e.type === 'tool-call') {
|
|
656
|
+
if (e.name === 'todo_write')
|
|
657
|
+
applyTodoWrite(e.args);
|
|
658
|
+
else
|
|
659
|
+
addActivity(e.id, e.name);
|
|
660
|
+
}
|
|
661
|
+
else if (e.type === 'tool-result') {
|
|
662
|
+
resolveActivity(e.id, e.isError);
|
|
663
|
+
}
|
|
664
|
+
opts.onRunEvent?.(e);
|
|
665
|
+
dog.onEvent(e);
|
|
666
|
+
recBuf.push({ at: Date.now(), ev: e });
|
|
667
|
+
// Heal persistence is centralized in the worker client (host workerDeps
|
|
668
|
+
// onHeal) so it captures sub-agent/DAG-node heals too — not tapped here.
|
|
669
|
+
// We just surface the live "Recovering…" status (null clears it on recovered).
|
|
670
|
+
if (e.type === 'heal') {
|
|
671
|
+
this.patch(id, { recovery: this.host.healNote?.(e.record) ?? null });
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
if (e.type === 'offload') {
|
|
675
|
+
this.patch(id, { offloadTick: this.ensure(id).offloadTick + 1 });
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (e.type === 'policy-violation') {
|
|
679
|
+
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: e.kind, message: e.message, action: e.action }] });
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const live = s.live;
|
|
683
|
+
if (!live)
|
|
684
|
+
return;
|
|
685
|
+
if (e.type === 'assistant-delta')
|
|
686
|
+
this.patch(id, { live: { ...live, text: live.text + e.delta }, recovery: null });
|
|
687
|
+
else if (e.type === 'assistant')
|
|
688
|
+
this.patch(id, { live: { ...live, text: '' } });
|
|
689
|
+
else if (e.type === 'tool-call')
|
|
690
|
+
this.patch(id, { live: { ...live, tools: [...live.tools, { id: e.id, name: e.name, args: e.args }] } });
|
|
691
|
+
else if (e.type === 'tool-result') {
|
|
692
|
+
if (typeof e.durationMs === 'number')
|
|
693
|
+
s.toolMs[e.id] = e.durationMs;
|
|
694
|
+
this.patch(id, { live: { ...live, tools: live.tools.map((t) => (t.id === e.id ? { ...t, result: e.result, isError: e.isError, done: true, ms: e.durationMs } : t)) } });
|
|
695
|
+
}
|
|
696
|
+
else if (e.type === 'error')
|
|
697
|
+
this.patch(id, { error: e.message });
|
|
698
|
+
else if (e.type === 'usage')
|
|
699
|
+
s.lastUsage = e.usage;
|
|
700
|
+
// Synthetic event workerClient emits when onBreach returned {continue:false}
|
|
701
|
+
// — surfaced as a transcript notice via the existing note() affordance,
|
|
702
|
+
// AND appended to policyNotes (same warn/stop duality as above, gated
|
|
703
|
+
// by SendOpts.budgetNotices).
|
|
704
|
+
else if (e.type === 'budget-stop') {
|
|
705
|
+
const msg = `Run stopped: budget reached (${this.host.breach.describe(e.breach)}).`;
|
|
706
|
+
if ((opts.budgetNotices ?? 'transcript') === 'transcript')
|
|
707
|
+
this.note(id, msg);
|
|
708
|
+
this.patch(id, { policyNotes: [...this.ensure(id).policyNotes, { kind: 'budget', message: msg, action: 'stop' }] });
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
// Stitch each tool call's measured duration onto its tool message (`_ms`).
|
|
713
|
+
const ms = s.toolMs;
|
|
714
|
+
const created = res.messages.slice(outgoing.length + 1).map((m) => {
|
|
715
|
+
const tid = m.tool_call_id;
|
|
716
|
+
return m.role === 'tool' && tid && ms[tid] != null ? { ...m, _ms: ms[tid] } : m;
|
|
717
|
+
});
|
|
718
|
+
const u = res.usage;
|
|
719
|
+
const stats = u && (u.promptTokens || u.completionTokens)
|
|
720
|
+
? { inTok: u.promptTokens, outTok: u.completionTokens, cost: (u.promptTokens / 1e6) * priced.inPrice + (u.completionTokens / 1e6) * priced.outPrice, ms: Date.now() - startedAt }
|
|
721
|
+
: undefined;
|
|
722
|
+
this.patch(id, { turns: s.turns.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], ...created], stats } : t)) });
|
|
723
|
+
void this.persist(id);
|
|
724
|
+
if (stats) {
|
|
725
|
+
this.host.recordUsage({ surface: this.surface, provider: priced.provider, model, modelLabel: priced.label, inEst: null, inTok: stats.inTok, outTok: stats.outTok, cost: stats.cost });
|
|
726
|
+
}
|
|
727
|
+
// Best-effort follow-up chips for this turn — generated after the reply lands,
|
|
728
|
+
// dropped if the user starts/aborts another run before it returns.
|
|
729
|
+
if (this.host.suggestFollowups) {
|
|
730
|
+
void this.host.suggestFollowups({ messages: res.messages, model, signal: ac.signal }).then((sug) => {
|
|
731
|
+
if (sug.length && !ac.signal.aborted)
|
|
732
|
+
this.patch(id, { suggestions: sug }, true);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
catch (e) {
|
|
737
|
+
this.patch(id, { error: String(e?.message || e) });
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
// A run that ends (abort/watchdog/error) while a perm overlay (tool-permission
|
|
741
|
+
// or budget-breach "continue?") is still open must not leave its promise —
|
|
742
|
+
// and the caller awaiting it — hanging forever. Treat an unanswered perm as
|
|
743
|
+
// "no". resolvePerm() already nulls s.pendingPerm once the user answers, so
|
|
744
|
+
// this is a no-op (idempotent) on the happy path.
|
|
745
|
+
if (s.pendingPerm) {
|
|
746
|
+
s.pendingPerm.resolve(false);
|
|
747
|
+
this.patch(id, { pendingPerm: null }, true);
|
|
748
|
+
}
|
|
749
|
+
dog.clear();
|
|
750
|
+
s.abort = null;
|
|
751
|
+
this.patch(id, { running: false, live: null, recovery: null }, true);
|
|
752
|
+
// Upsert the session recording with everything captured so far (this turn's
|
|
753
|
+
// events appended to prior turns), under one stable id so the whole session
|
|
754
|
+
// replays as a single stream. Skipped until something replayable happened.
|
|
755
|
+
if (this.host.recorder && recBuf.some((r) => r.ev.type === 'assistant' || r.ev.type === 'tool-call')) {
|
|
756
|
+
if (!s.recId) {
|
|
757
|
+
s.recId = this.host.recorder.newId();
|
|
758
|
+
s.recPrompt = text;
|
|
759
|
+
s.recImages = images.length ? images.slice() : undefined;
|
|
760
|
+
}
|
|
761
|
+
// `ok` is THIS turn's outcome (slice from turnStart); a snapshot copy of the
|
|
762
|
+
// events so the store doesn't hold the live array that later turns mutate.
|
|
763
|
+
const ok = !recBuf.slice(turnStart).some((r) => r.ev.type === 'error');
|
|
764
|
+
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() });
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
/** A host-driven turn: appends the user message, then awaits a host-supplied
|
|
769
|
+
* `produce` (e.g. the /strategy command's own completion) instead of running
|
|
770
|
+
* the worker agent. Same running/live/error/abort shape as `send`, minus
|
|
771
|
+
* tool calls — used by surfaces that render a live "thinking" placeholder for
|
|
772
|
+
* work the engine itself doesn't perform. */
|
|
773
|
+
async hostTurn(id, userText, produce) {
|
|
774
|
+
const s = this.ensure(id);
|
|
775
|
+
if (!userText || s.running)
|
|
776
|
+
return;
|
|
777
|
+
this.ws();
|
|
778
|
+
const gate = await this.host.guard('llm-execution');
|
|
779
|
+
if (!gate.allowed) {
|
|
780
|
+
this.patch(id, { error: gate.reason });
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (s.running)
|
|
784
|
+
return;
|
|
785
|
+
const userTurn = { id: `t${Date.now().toString(36)}`, included: true, messages: [{ role: 'user', content: userText }] };
|
|
786
|
+
const ac = new AbortController();
|
|
787
|
+
s.abort = ac;
|
|
788
|
+
this.patch(id, { turns: [...s.turns, userTurn], error: '', live: { text: '', tools: [] }, running: true, suggestions: [] }, true);
|
|
789
|
+
try {
|
|
790
|
+
const content = await produce(ac.signal);
|
|
791
|
+
this.patch(id, { turns: this.ensure(id).turns.map((t) => (t.id === userTurn.id ? { ...t, messages: [userTurn.messages[0], { role: 'assistant', content }] } : t)) });
|
|
792
|
+
void this.persist(id);
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
this.patch(id, { error: String(e?.message || e) });
|
|
796
|
+
}
|
|
797
|
+
finally {
|
|
798
|
+
s.abort = null;
|
|
799
|
+
this.patch(id, { running: false, live: null }, true);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/** Replace the offload stub for `blobId` inside turn `turnId` (session `id`)
|
|
803
|
+
* with the full content — the context drawer's per-item Restore, transplanted
|
|
804
|
+
* from the harness/conversation.ts singleton (same marker-matching and
|
|
805
|
+
* identity-swap semantics; see that module for the rationale). Returns false
|
|
806
|
+
* (state untouched, no persist) when the turn or the matching stub is absent. */
|
|
807
|
+
restoreToolResult(id, turnId, blobId, content) {
|
|
808
|
+
const marker = `recall_result({ id: "${blobId}" })`;
|
|
809
|
+
const s = this.ensure(id);
|
|
810
|
+
const turn = s.turns.find((t) => t.id === turnId);
|
|
811
|
+
if (!turn)
|
|
812
|
+
return false;
|
|
813
|
+
const idx = turn.messages.findIndex((m) => m.role === 'tool' && typeof m.content === 'string' && m.content.includes(marker));
|
|
814
|
+
if (idx < 0)
|
|
815
|
+
return false;
|
|
816
|
+
const next = s.turns.map((t) => {
|
|
817
|
+
if (t.id !== turnId)
|
|
818
|
+
return t;
|
|
819
|
+
const messages = t.messages.slice();
|
|
820
|
+
messages[idx] = { ...messages[idx], content };
|
|
821
|
+
return { ...t, messages };
|
|
822
|
+
});
|
|
823
|
+
this.patch(id, { turns: next });
|
|
824
|
+
void this.persist(id);
|
|
825
|
+
return true;
|
|
826
|
+
}
|
|
827
|
+
/** Re-run a prior user turn: drop it and everything after, then resend it. */
|
|
828
|
+
async retry(id, turnId, opts) {
|
|
829
|
+
const s = this.ensure(id);
|
|
830
|
+
if (s.running)
|
|
831
|
+
return;
|
|
832
|
+
const idx = s.turns.findIndex((t) => t.id === turnId);
|
|
833
|
+
if (idx < 0)
|
|
834
|
+
return;
|
|
835
|
+
const first = s.turns[idx].messages[0];
|
|
836
|
+
const text = first ? userText(first) : '';
|
|
837
|
+
const images = first ? userImages(first) : [];
|
|
838
|
+
if (!text && images.length === 0)
|
|
839
|
+
return;
|
|
840
|
+
this.patch(id, { turns: s.turns.slice(0, idx) });
|
|
841
|
+
await this.send(id, text, images, opts);
|
|
842
|
+
}
|
|
843
|
+
/** Drop all in-memory state and abort every run — called on a scope change so
|
|
844
|
+
* one identity's sessions and runs never carry into another's. */
|
|
845
|
+
reset() {
|
|
846
|
+
for (const s of this.map.values())
|
|
847
|
+
s.abort?.abort();
|
|
848
|
+
const ids = [...this.subs.keys()];
|
|
849
|
+
this.map.clear();
|
|
850
|
+
this.worker?.dispose();
|
|
851
|
+
this.worker = null;
|
|
852
|
+
this._ws = null;
|
|
853
|
+
for (const id of ids)
|
|
854
|
+
this.notify(id);
|
|
855
|
+
this.notifyGlobal();
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
//# sourceMappingURL=runtime.js.map
|