@mono-agent/web 0.20.14 → 0.21.1
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/README.md +388 -70
- package/dist/contracts.d.ts +364 -8
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js +2 -0
- package/dist/contracts.js.map +1 -1
- package/dist/cron-reply-context.d.ts +27 -0
- package/dist/cron-reply-context.d.ts.map +1 -0
- package/dist/cron-reply-context.js +241 -0
- package/dist/cron-reply-context.js.map +1 -0
- package/dist/discovery.d.ts +1 -0
- package/dist/discovery.d.ts.map +1 -1
- package/dist/discovery.js +8 -6
- package/dist/discovery.js.map +1 -1
- package/dist/effort-ladder.d.ts +14 -0
- package/dist/effort-ladder.d.ts.map +1 -1
- package/dist/effort-ladder.js +41 -0
- package/dist/effort-ladder.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/long-lived-fetch.d.ts +5 -0
- package/dist/long-lived-fetch.d.ts.map +1 -0
- package/dist/long-lived-fetch.js +22 -0
- package/dist/long-lived-fetch.js.map +1 -0
- package/dist/monitor-reply.d.ts +10 -0
- package/dist/monitor-reply.d.ts.map +1 -0
- package/dist/monitor-reply.js +67 -0
- package/dist/monitor-reply.js.map +1 -0
- package/dist/notification-client.d.ts.map +1 -1
- package/dist/notification-client.js +5 -4
- package/dist/notification-client.js.map +1 -1
- package/dist/operator-client.d.ts +25 -1
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +167 -8
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts +42 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +627 -49
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +201 -8
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +1425 -136
- package/dist/service.js.map +1 -1
- package/dist/store-migrations.d.ts +23 -0
- package/dist/store-migrations.d.ts.map +1 -0
- package/dist/store-migrations.js +212 -0
- package/dist/store-migrations.js.map +1 -0
- package/dist/store.d.ts +294 -17
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1637 -263
- package/dist/store.js.map +1 -1
- package/package.json +8 -5
- package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
- package/webapp/dist/assets/index-0v07DD8D.css +1 -0
- package/webapp/dist/assets/index-DJXY5RPz.js +155 -0
- package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
- package/webapp/dist/badge-96.png +0 -0
- package/webapp/dist/index.html +26 -6
- package/webapp/dist/manifest.webmanifest +1 -1
- package/webapp/dist/notification-sw.js +3 -1
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
- package/webapp/dist/assets/index-C4a2Dv1W.js +0 -155
- package/webapp/dist/assets/index-mhMBLGB0.css +0 -1
package/dist/service.js
CHANGED
|
@@ -1,29 +1,298 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
|
|
3
|
+
import { AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES, AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE, AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
|
|
4
4
|
import { EFFORT_LEVELS } from "@mono-agent/config";
|
|
5
5
|
import { WEB_API_VERSION, WEB_MAX_CONCURRENT_UPLOADS, WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, WEB_MAX_FILES_PER_TURN, WEB_MAX_STAGED_UPLOAD_BYTES, WEB_MAX_STAGED_UPLOADS, WEB_MAX_QUEUED_ATTACHMENT_TURNS, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_STAGED_UPLOAD_TTL_MS, } from "./contracts.js";
|
|
6
6
|
import { discoverOperatorAgents, } from "./discovery.js";
|
|
7
7
|
import { conversationTitleFromFrame } from "./conversation-title.js";
|
|
8
|
-
import {
|
|
8
|
+
import { parseCronReplyContext } from "./cron-reply-context.js";
|
|
9
|
+
import { advertisedEffortLevels, effectiveModelForAgent, effortLevelsForModel, inheritedEffortForModel, } from "./effort-ladder.js";
|
|
9
10
|
import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
|
|
10
11
|
import { OperatorClient } from "./operator-client.js";
|
|
11
12
|
import { generateWebPushIdentity, normalizeWebPushEndpoint, resolveWebPushSubject, validateWebPushEndpoint, validateWebPushKeys, WebPushDispatcher, WEB_PUSH_SERVICE_WORKER_VERSION, } from "./push.js";
|
|
12
13
|
import { acquireWebStateLease, prepareWebStatePaths } from "./state-paths.js";
|
|
13
|
-
import { cronChannelReadOnlyError, toWebAttachment, WebStore, notificationPushLogicalKey, } from "./store.js";
|
|
14
|
+
import { cronChannelReadOnlyError, toWebAttachment, WebStore, WEB_THREAD_PAGE_DEFAULT, notificationPushLogicalKey, } from "./store.js";
|
|
14
15
|
const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
|
|
15
16
|
const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
16
17
|
const INFO_TIMEOUT_MS = 2_500;
|
|
17
18
|
const ASK_DISCOVERY_TIMEOUT_MS = 120_000;
|
|
18
19
|
/** Bounded per-agent catalog-admitted model refs; beyond it, oldest go first. */
|
|
19
20
|
const MODEL_CATALOG_CACHE_CAP = 2_048;
|
|
21
|
+
/** Matches the browser picker: enough for today's 200-row provider ceiling,
|
|
22
|
+
* with slack for an older or independently implemented operator. */
|
|
23
|
+
const MODEL_CATALOG_RESTORE_PAGE_SIZE = 100;
|
|
24
|
+
const MODEL_CATALOG_RESTORE_PAGE_LIMIT = 5;
|
|
20
25
|
const REPLY_ACCESS_TTL_MS = 10 * 60 * 1_000;
|
|
26
|
+
/**
|
|
27
|
+
* How coarsely a reply capability's expiry is quantised.
|
|
28
|
+
*
|
|
29
|
+
* The expiry is part of the signed URL, so an expiry read off the wall clock
|
|
30
|
+
* made every projection of the same message a DIFFERENT transcript: the
|
|
31
|
+
* conversation's ETag moved once a second for the life of a running turn and no
|
|
32
|
+
* console could ever be answered with a 304, however little had changed. Rounded
|
|
33
|
+
* DOWN to the bucket the mint falls in, the URL is stable for the bucket and a
|
|
34
|
+
* key's life is between five and ten minutes -- shortened, never extended, so
|
|
35
|
+
* the validator's ceiling is untouched.
|
|
36
|
+
*/
|
|
37
|
+
const REPLY_ACCESS_BUCKET_MS = 5 * 60 * 1_000;
|
|
21
38
|
/**
|
|
22
39
|
* Raster types the console keeps its own copy of. `image/svg+xml` is absent on
|
|
23
40
|
* purpose: it is active content, and both the inline gate in the browser and
|
|
24
41
|
* `setReplyDownloadHeaders` already refuse to treat it as an image.
|
|
25
42
|
*/
|
|
26
43
|
const REPLY_IMAGE_MEDIA_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
44
|
+
/**
|
|
45
|
+
* How much of a tool call's `result`/`args` a browser read carries.
|
|
46
|
+
*
|
|
47
|
+
* Measured on a real fleet transcript: a 4-message tool-heavy conversation is
|
|
48
|
+
* 231 KB, of which 211 KB is `tool-call` result bodies (single `Exec`/`ReadSkill`
|
|
49
|
+
* results run 14-21 KB). The rows are collapsed, so what the operator sees of
|
|
50
|
+
* one is its first screenful; the rest is a request away.
|
|
51
|
+
*/
|
|
52
|
+
const TOOL_PAYLOAD_PREVIEW_CHARS = 4_096;
|
|
53
|
+
/**
|
|
54
|
+
* Telemetry whose `data` the console actually reads.
|
|
55
|
+
*
|
|
56
|
+
* `webapp/src/runtime.tsx` renders only `runtime_telemetry` of kind
|
|
57
|
+
* `context_compaction`/`assistant_message_boundary`/`context_usage` plus
|
|
58
|
+
* `cron_run`; `webapp/src/usage.ts` sums any part labelled `context_usage`,
|
|
59
|
+
* `context_compaction`, or containing `usage`/`cost`. Everything else --
|
|
60
|
+
* `provider_status`, `memory_recalled`, `status`, `run_config`, `cache_hit`,
|
|
61
|
+
* `capabilities_resolved`, ... -- is carried across the wire and dropped, which
|
|
62
|
+
* is ~10 KB a conversation.
|
|
63
|
+
*/
|
|
64
|
+
const TELEMETRY_DATA_ALLOWLIST = new Set([
|
|
65
|
+
"usage_update",
|
|
66
|
+
"cron_run",
|
|
67
|
+
"context_usage",
|
|
68
|
+
"context_compaction",
|
|
69
|
+
"assistant_message_boundary",
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* Whether a tool name IS AskUser, however the agent qualified it.
|
|
73
|
+
*
|
|
74
|
+
* An MCP server serves it as `mcp__<server>__ask_user`, a forwarding runtime as
|
|
75
|
+
* `some.namespace:AskUser`, and separators vary. Two places have to agree on
|
|
76
|
+
* this -- the frame observer that arms the interaction poller, and the shaper
|
|
77
|
+
* that must leave the card's question and answer alone -- so they read the same
|
|
78
|
+
* rule rather than two spellings of it. An exact `=== "AskUser"` here silently
|
|
79
|
+
* shaped the card's payload for every run that routes the tool through a server.
|
|
80
|
+
*/
|
|
81
|
+
function isAskUserToolName(toolName) {
|
|
82
|
+
return toolNameLeaf(toolName).toLowerCase().replace(/[^a-z0-9]+/gu, "") === "askuser";
|
|
83
|
+
}
|
|
84
|
+
/** A `runtime_telemetry` event's variant, which the store stores inside `data`. */
|
|
85
|
+
function telemetryKind(data) {
|
|
86
|
+
if (data === null || typeof data !== "object" || Array.isArray(data))
|
|
87
|
+
return undefined;
|
|
88
|
+
const kind = data.kind;
|
|
89
|
+
return typeof kind === "string" && kind.length > 0 ? kind : undefined;
|
|
90
|
+
}
|
|
91
|
+
/** The allowlist, or the `usage`/`cost` substring rule `usage.ts` applies. */
|
|
92
|
+
function readsTelemetryLabel(label) {
|
|
93
|
+
if (label === undefined)
|
|
94
|
+
return false;
|
|
95
|
+
const normalized = label.toLowerCase();
|
|
96
|
+
return TELEMETRY_DATA_ALLOWLIST.has(normalized)
|
|
97
|
+
|| normalized.includes("usage")
|
|
98
|
+
|| normalized.includes("cost");
|
|
99
|
+
}
|
|
100
|
+
function keepsTelemetryData(part) {
|
|
101
|
+
// `usage.ts` matches on the event name AND on a nested `kind`, so both are
|
|
102
|
+
// measured against the same rule -- a `runtime_telemetry{kind:"token_usage"}`
|
|
103
|
+
// counts towards the run's tokens exactly like a bare `usage_update`.
|
|
104
|
+
return readsTelemetryLabel(part.event) || readsTelemetryLabel(telemetryKind(part.data));
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Keep the part, drop the payload the console never reads.
|
|
108
|
+
*
|
|
109
|
+
* Removing it outright would renumber every later part, and both the client's
|
|
110
|
+
* part conversion and the index-based transcript reads that follow this change
|
|
111
|
+
* depend on positions being stable. `kind` is surfaced whenever the event has
|
|
112
|
+
* one -- kept or stripped -- so its presence is never a back-channel for "the
|
|
113
|
+
* payload was dropped".
|
|
114
|
+
*/
|
|
115
|
+
function shapeTelemetryPart(part) {
|
|
116
|
+
const kind = telemetryKind(part.data);
|
|
117
|
+
const keepsData = part.data === undefined || keepsTelemetryData(part);
|
|
118
|
+
if (keepsData && (kind === undefined || part.kind === kind))
|
|
119
|
+
return part;
|
|
120
|
+
return {
|
|
121
|
+
type: "telemetry",
|
|
122
|
+
event: part.event,
|
|
123
|
+
...(kind === undefined ? {} : { kind }),
|
|
124
|
+
...(keepsData ? { data: part.data } : {}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* What a truncated payload was cut from, so the console can prove a body it is
|
|
129
|
+
* holding is still that payload.
|
|
130
|
+
*
|
|
131
|
+
* The preview says how long the whole body is and what its first characters
|
|
132
|
+
* are, and a repaired body used to be restored on those two facts alone -- so a
|
|
133
|
+
* rewritten result of the same length with the same head put the OLD body back
|
|
134
|
+
* under the NEW preview. The digest closes that: a console restores only what
|
|
135
|
+
* the server names as the same content, and drops the repair otherwise.
|
|
136
|
+
*
|
|
137
|
+
* Over the SERIALIZED text, which is what both sides already have: a string
|
|
138
|
+
* payload as itself, anything else as its JSON. Nothing here is a secret and
|
|
139
|
+
* nothing is keyed -- it is an identity, not a signature.
|
|
140
|
+
*/
|
|
141
|
+
function payloadDigest(text) {
|
|
142
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The head of an oversized payload, or `undefined` when it is small enough (or
|
|
146
|
+
* cannot be serialized, in which case it is left exactly as stored).
|
|
147
|
+
*/
|
|
148
|
+
function payloadPreview(value) {
|
|
149
|
+
if (value === undefined)
|
|
150
|
+
return undefined;
|
|
151
|
+
const text = typeof value === "string" ? value : jsonTextOf(value);
|
|
152
|
+
if (text === undefined || text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
|
|
153
|
+
return undefined;
|
|
154
|
+
return {
|
|
155
|
+
preview: text.slice(0, TOOL_PAYLOAD_PREVIEW_CHARS),
|
|
156
|
+
length: text.length,
|
|
157
|
+
digest: payloadDigest(text),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
/** The same name the shaper gives a payload, for a whole one. See {@link payloadDigest}. */
|
|
161
|
+
function wholePayloadDigest(value) {
|
|
162
|
+
if (value === undefined)
|
|
163
|
+
return undefined;
|
|
164
|
+
const text = typeof value === "string" ? value : jsonTextOf(value);
|
|
165
|
+
return text === undefined ? undefined : payloadDigest(text);
|
|
166
|
+
}
|
|
167
|
+
/** `undefined` for anything JSON cannot express, which is then left as stored. */
|
|
168
|
+
function jsonTextOf(value) {
|
|
169
|
+
try {
|
|
170
|
+
return JSON.stringify(value);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function shapeToolCall(call) {
|
|
177
|
+
// AskUser's arguments and answer ARE the card the console renders, and they
|
|
178
|
+
// are bounded at the emitter. `structuredResult` is never shaped here: MCP
|
|
179
|
+
// and canonical host outcomes are already bounded and remain opaque.
|
|
180
|
+
if (isAskUserToolName(call.toolName))
|
|
181
|
+
return call;
|
|
182
|
+
const args = shapedArgs(call.args);
|
|
183
|
+
const result = payloadPreview(call.result);
|
|
184
|
+
if (args === undefined && result === undefined)
|
|
185
|
+
return call;
|
|
186
|
+
return {
|
|
187
|
+
...call,
|
|
188
|
+
...(args === undefined
|
|
189
|
+
? {}
|
|
190
|
+
: { args: args.preview, argsTruncated: true, argsBytes: args.length, argsDigest: args.digest }),
|
|
191
|
+
...(result === undefined
|
|
192
|
+
? {}
|
|
193
|
+
: { result: result.preview, resultTruncated: true, resultBytes: result.length, resultDigest: result.digest }),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
function shapeToolCallPart(part) {
|
|
197
|
+
return { ...shapeToolCall(part), type: "tool-call" };
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* A tool call's arguments, cut to fit -- serialized ONCE.
|
|
201
|
+
*
|
|
202
|
+
* The size question and the two answers to it all read the same JSON text: the
|
|
203
|
+
* object shaper used to serialize to decide whether it had work, and the
|
|
204
|
+
* whole-value fallback then serialized the same value again to answer the same
|
|
205
|
+
* question. Every under-budget object args paid for both, on every transcript
|
|
206
|
+
* read and now on every streamed delta.
|
|
207
|
+
*/
|
|
208
|
+
function shapedArgs(args) {
|
|
209
|
+
if (args === undefined)
|
|
210
|
+
return undefined;
|
|
211
|
+
const text = typeof args === "string" ? args : jsonTextOf(args);
|
|
212
|
+
// Under budget, or nothing JSON can express: left exactly as stored.
|
|
213
|
+
if (text === undefined || text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
|
|
214
|
+
return undefined;
|
|
215
|
+
const shaped = shapedArgsObject(args, text)
|
|
216
|
+
?? { preview: text.slice(0, TOOL_PAYLOAD_PREVIEW_CHARS), length: text.length };
|
|
217
|
+
return { ...shaped, digest: payloadDigest(text) };
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* An oversized ARGUMENTS OBJECT with its string leaves cut back to fit, rather
|
|
221
|
+
* than replaced by the head of its JSON text.
|
|
222
|
+
*
|
|
223
|
+
* Arguments are not opaque the way a result is: the console reads named keys out
|
|
224
|
+
* of them -- `command`, `file_path`, a delegation's `prompt` -- for the row
|
|
225
|
+
* summary and the Task note, and the head of a JSON text is none of those. It is
|
|
226
|
+
* also what an operator scans, and a mid-object slice reads as garbage. Nothing
|
|
227
|
+
* is added or removed, so the object keeps its shape and every key keeps its
|
|
228
|
+
* place, and the longest string pays first.
|
|
229
|
+
*
|
|
230
|
+
* Termination is the bounded pass count, NOT an assumption that every slice
|
|
231
|
+
* shrinks the serialization: cutting between a surrogate pair leaves a lone
|
|
232
|
+
* surrogate that `JSON.stringify` writes as `\udXXX`, which can make one pass
|
|
233
|
+
* longer than the last. Anything still over budget after eight passes falls
|
|
234
|
+
* back to the whole-value head.
|
|
235
|
+
*/
|
|
236
|
+
function shapedArgsObject(args, original) {
|
|
237
|
+
if (args === null || typeof args !== "object" || Array.isArray(args))
|
|
238
|
+
return undefined;
|
|
239
|
+
let shaped = { ...args };
|
|
240
|
+
for (let pass = 0; pass < 8; pass += 1) {
|
|
241
|
+
const text = jsonTextOf(shaped);
|
|
242
|
+
if (text === undefined)
|
|
243
|
+
return undefined;
|
|
244
|
+
if (text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
|
|
245
|
+
return { preview: shaped, length: original.length };
|
|
246
|
+
const longest = Object.entries(shaped)
|
|
247
|
+
.filter((entry) => typeof entry[1] === "string" && entry[1].length > 1)
|
|
248
|
+
.sort(([, left], [, right]) => right.length - left.length)[0];
|
|
249
|
+
if (longest === undefined)
|
|
250
|
+
return undefined;
|
|
251
|
+
const [key, value] = longest;
|
|
252
|
+
const keep = Math.max(1, value.length - (text.length - TOOL_PAYLOAD_PREVIEW_CHARS));
|
|
253
|
+
shaped = { ...shaped, [key]: value.slice(0, keep) };
|
|
254
|
+
}
|
|
255
|
+
// Too many oversized leaves to fit: fall back to the whole-value head rather
|
|
256
|
+
// than serve an object that is still over budget.
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
function shapeSubagentPart(part) {
|
|
260
|
+
const args = shapedArgs(part.args);
|
|
261
|
+
const result = payloadPreview(part.result);
|
|
262
|
+
return {
|
|
263
|
+
...part,
|
|
264
|
+
...(args === undefined
|
|
265
|
+
? {}
|
|
266
|
+
: { args: args.preview, argsTruncated: true, argsBytes: args.length, argsDigest: args.digest }),
|
|
267
|
+
...(result === undefined
|
|
268
|
+
? {}
|
|
269
|
+
: { result: result.preview, resultTruncated: true, resultBytes: result.length, resultDigest: result.digest }),
|
|
270
|
+
calls: part.calls.map(shapeToolCall),
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* ONE part's own payloads, named the way a preview of them would be.
|
|
275
|
+
*
|
|
276
|
+
* The repair read serves what is stored; without these the console has nothing
|
|
277
|
+
* to compare a later preview against and, failing closed, would drop every
|
|
278
|
+
* repair it made.
|
|
279
|
+
*
|
|
280
|
+
* Only the part handed to it -- a delegation's `calls` are NOT walked. They do
|
|
281
|
+
* not need to be: a repair is addressed by tool-call id, and a request naming a
|
|
282
|
+
* child is answered by synthesizing that child as the tool-call part it would
|
|
283
|
+
* have been, which comes back through here as a part in its own right.
|
|
284
|
+
*/
|
|
285
|
+
function nameWholePayloads(part) {
|
|
286
|
+
const args = wholePayloadDigest(part.args);
|
|
287
|
+
const result = wholePayloadDigest(part.result);
|
|
288
|
+
if (args === undefined && result === undefined)
|
|
289
|
+
return part;
|
|
290
|
+
return {
|
|
291
|
+
...part,
|
|
292
|
+
...(args === undefined ? {} : { argsDigest: args }),
|
|
293
|
+
...(result === undefined ? {} : { resultDigest: result }),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
27
296
|
function formatQuotedTurn(quote, text) {
|
|
28
297
|
const blockquote = quote
|
|
29
298
|
.trim()
|
|
@@ -32,6 +301,11 @@ function formatQuotedTurn(quote, text) {
|
|
|
32
301
|
.join("\n");
|
|
33
302
|
return `Quoted context:\n${blockquote}\n\n${text}`;
|
|
34
303
|
}
|
|
304
|
+
function assertTurnTextWithinLimit(operatorText) {
|
|
305
|
+
if (operatorText.length <= WEB_MAX_TURN_TEXT_CHARACTERS)
|
|
306
|
+
return;
|
|
307
|
+
throw new WebConsoleError("turn_text_too_large", `The message and quote may contain at most ${WEB_MAX_TURN_TEXT_CHARACTERS} characters after formatting.`, 413);
|
|
308
|
+
}
|
|
35
309
|
function assertMonitorWakeAddress(input) {
|
|
36
310
|
const originConversation = input.monitor.origin.conversationId.split("#", 1)[0];
|
|
37
311
|
const expectedDeliveryKey = `monitor:${input.monitor.monitorId}:${String(input.monitor.counters.seq)}`;
|
|
@@ -67,6 +341,7 @@ export class WebService {
|
|
|
67
341
|
drainingLiveInputThreads = new Set();
|
|
68
342
|
activeUploads = new Map();
|
|
69
343
|
activeNotifications = new Map();
|
|
344
|
+
activeCronReplies = new Map();
|
|
70
345
|
/** One serialization lane shared by queued user input and every host wake kind. */
|
|
71
346
|
hostWakeTails = new Map();
|
|
72
347
|
hostWakeReservations = new Map();
|
|
@@ -79,6 +354,13 @@ export class WebService {
|
|
|
79
354
|
replyAccessKey;
|
|
80
355
|
askWatches = new Map();
|
|
81
356
|
connections = new Map();
|
|
357
|
+
/**
|
|
358
|
+
* Source id -> the capability signature projected for it on the last
|
|
359
|
+
* discovery pass. No projected capability is on the summary or in the store,
|
|
360
|
+
* so this is the only record of them that survives a poll -- and the only way
|
|
361
|
+
* `refreshAgentsOnce` can tell that provider authentication itself moved.
|
|
362
|
+
*/
|
|
363
|
+
projectedCapabilities = new Map();
|
|
82
364
|
/** Bounded catalog-admitted model refs per agent, seeded from `modelOptions`
|
|
83
365
|
* and appended to by every proxied `/v1/models` page. Admission is `has`,
|
|
84
366
|
* metadata is `get`. Map preserves insertion order, so evicting the oldest
|
|
@@ -155,8 +437,23 @@ export class WebService {
|
|
|
155
437
|
throw error;
|
|
156
438
|
}
|
|
157
439
|
}
|
|
158
|
-
async bootstrap() {
|
|
440
|
+
async bootstrap(scope = {}) {
|
|
159
441
|
const currentThreadId = this.store.currentThreadId();
|
|
442
|
+
const currentThread = currentThreadId === undefined ? undefined : this.store.getThread(currentThreadId);
|
|
443
|
+
const discoveredCurrentThreadId = currentThread !== undefined
|
|
444
|
+
&& this.store.getAgent(currentThread.sourceId) !== undefined
|
|
445
|
+
? currentThreadId
|
|
446
|
+
: undefined;
|
|
447
|
+
const agents = this.store.listAgents().map((agent) => this.decorateProjectedCapabilities(agent));
|
|
448
|
+
const threadsSourceId = this.bootstrapSourceId(scope.sourceId, currentThread, agents);
|
|
449
|
+
const archived = scope.archived ?? false;
|
|
450
|
+
const page = threadsSourceId === null
|
|
451
|
+
? { threads: [] }
|
|
452
|
+
: this.store.listThreadsPage({
|
|
453
|
+
sourceId: threadsSourceId,
|
|
454
|
+
archived,
|
|
455
|
+
limit: scope.limit ?? WEB_THREAD_PAGE_DEFAULT,
|
|
456
|
+
});
|
|
160
457
|
return {
|
|
161
458
|
version: WEB_API_VERSION,
|
|
162
459
|
push: {
|
|
@@ -164,9 +461,11 @@ export class WebService {
|
|
|
164
461
|
keyFingerprint: this.pushIdentity.fingerprint,
|
|
165
462
|
serviceWorkerVersion: WEB_PUSH_SERVICE_WORKER_VERSION,
|
|
166
463
|
},
|
|
167
|
-
agents
|
|
168
|
-
threads:
|
|
169
|
-
|
|
464
|
+
agents,
|
|
465
|
+
threads: page.threads,
|
|
466
|
+
threadsSourceId,
|
|
467
|
+
threadsNextCursor: page.nextCursor ?? null,
|
|
468
|
+
...(discoveredCurrentThreadId === undefined ? {} : { currentThreadId: discoveredCurrentThreadId }),
|
|
170
469
|
limits: {
|
|
171
470
|
maxFileBytes: DEFAULT_AGENT_ATTACHMENT_MAX_BYTES,
|
|
172
471
|
maxFilesPerTurn: WEB_MAX_FILES_PER_TURN,
|
|
@@ -175,16 +474,83 @@ export class WebService {
|
|
|
175
474
|
},
|
|
176
475
|
};
|
|
177
476
|
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
477
|
+
/**
|
|
478
|
+
* The one bucket a bootstrap answers with.
|
|
479
|
+
*
|
|
480
|
+
* An absent or unknown `sourceId` is the ordinary case, not an error: the
|
|
481
|
+
* first request a fresh console makes has no selection to name, and a
|
|
482
|
+
* console whose stored agent has since gone away must still get a console.
|
|
483
|
+
* The chain is the one the browser resolves its own selection with -- the
|
|
484
|
+
* agent of the conversation the console was last in, else the first agent
|
|
485
|
+
* that can answer, else the first agent at all.
|
|
486
|
+
*/
|
|
487
|
+
bootstrapSourceId(requested, currentThread, agents) {
|
|
488
|
+
if (requested !== undefined && agents.some((agent) => agent.sourceId === requested))
|
|
489
|
+
return requested;
|
|
490
|
+
const current = currentThread === undefined
|
|
491
|
+
? undefined
|
|
492
|
+
: agents.find((agent) => agent.sourceId === currentThread.sourceId);
|
|
493
|
+
return current?.sourceId
|
|
494
|
+
?? agents.find((agent) => agent.status !== "offline")?.sourceId
|
|
495
|
+
?? agents[0]?.sourceId
|
|
496
|
+
?? null;
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The only place provider authentication is projected onto an agent summary.
|
|
500
|
+
*
|
|
501
|
+
* It is derived from the live operator connection's `/v1/info` response.
|
|
502
|
+
* `supportsProviderAuth` has an `agents` column, but the stored presentation
|
|
503
|
+
* bit is never authorization to call an agent. Keeping the field off discovery
|
|
504
|
+
* summaries prevents every heartbeat from looking like a fleet change.
|
|
505
|
+
*
|
|
506
|
+
* Provider authentication also requires a live connection deliberately:
|
|
507
|
+
* `requireProviderAuthConnection` refuses without one, so advertising it to a
|
|
508
|
+
* browser that cannot use it would expose a button whose route only 409s.
|
|
509
|
+
*
|
|
510
|
+
* `refreshAgentsOnce` tracks this projection explicitly so real transitions
|
|
511
|
+
* still emit `agents.changed` without treating heartbeats as changes.
|
|
512
|
+
*/
|
|
513
|
+
decorateProjectedCapabilities(agent) {
|
|
514
|
+
const connection = this.connections.get(agent.sourceId);
|
|
515
|
+
const providerAuth = connection?.info.supportsProviderAuth === true;
|
|
516
|
+
const providerAuthChecks = connection?.info.supportsProviderAuthChecks === true;
|
|
517
|
+
if (!providerAuth)
|
|
518
|
+
return agent;
|
|
519
|
+
return {
|
|
520
|
+
...agent,
|
|
521
|
+
supportsProviderAuth: true,
|
|
522
|
+
...(providerAuthChecks ? { supportsProviderAuthChecks: true } : {}),
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Compared pass to pass, this says provider authentication moved when
|
|
527
|
+
* nothing on the stored summary did.
|
|
528
|
+
*/
|
|
529
|
+
projectedCapabilitySignature(agent) {
|
|
530
|
+
const projected = this.decorateProjectedCapabilities(agent);
|
|
531
|
+
return [
|
|
532
|
+
projected.supportsProviderAuth === true ? "providerAuth" : "",
|
|
533
|
+
projected.supportsProviderAuthChecks === true ? "providerAuthChecks" : "",
|
|
534
|
+
].join("|");
|
|
535
|
+
}
|
|
536
|
+
createThread(sourceId, input = {}) {
|
|
537
|
+
const agent = this.store.getAgent(sourceId);
|
|
538
|
+
if (agent === undefined) {
|
|
539
|
+
throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
|
|
540
|
+
}
|
|
541
|
+
const inherited = agent.runSettings.override;
|
|
542
|
+
const model = input.model === undefined ? inherited?.model : input.model ?? undefined;
|
|
543
|
+
const effort = input.effort === undefined ? inherited?.effort : input.effort ?? undefined;
|
|
544
|
+
this.validateModelAndEffort(sourceId, agent, model, effort, input.model === undefined && inherited?.model !== undefined);
|
|
545
|
+
const thread = this.store.createThread(sourceId, input);
|
|
546
|
+
this.emitThread("threads.changed", { thread });
|
|
181
547
|
return thread;
|
|
182
548
|
}
|
|
183
|
-
thread(id) {
|
|
549
|
+
thread(id, options = {}) {
|
|
184
550
|
const detail = this.store.getThreadDetail(id);
|
|
185
551
|
if (detail === undefined)
|
|
186
552
|
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
187
|
-
return this.decorateThreadDetail(detail);
|
|
553
|
+
return this.decorateThreadDetail(detail, options);
|
|
188
554
|
}
|
|
189
555
|
threadsPage(input) {
|
|
190
556
|
return this.store.listThreadsPage(input);
|
|
@@ -197,8 +563,56 @@ export class WebService {
|
|
|
197
563
|
return this.store.searchThreads(input);
|
|
198
564
|
}
|
|
199
565
|
messagePage(threadId, input) {
|
|
200
|
-
|
|
201
|
-
|
|
566
|
+
// The store pages; the shape is a view concern and has no business reaching it.
|
|
567
|
+
const { full, ...query } = input;
|
|
568
|
+
const page = this.store.listMessagesPage(threadId, query);
|
|
569
|
+
const shape = full === undefined ? {} : { full };
|
|
570
|
+
return { ...page, messages: this.shapeMessages(page.messages, shape) };
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* ONE message, at the version it currently holds.
|
|
574
|
+
*
|
|
575
|
+
* The recovery a streamed transcript needs: a console whose delta no longer
|
|
576
|
+
* chains onto the `seq` it is holding re-reads the one message rather than
|
|
577
|
+
* the conversation around it. Addressed by (conversation, message) for the
|
|
578
|
+
* same reason the tool-call read is -- a message id is not a capability, and
|
|
579
|
+
* a lookup that took it alone would serve any caller any conversation.
|
|
580
|
+
*/
|
|
581
|
+
message(threadId, messageId, options = {}) {
|
|
582
|
+
const thread = this.store.getThread(threadId);
|
|
583
|
+
const message = this.store.getMessage(messageId);
|
|
584
|
+
if (thread === undefined || message === undefined || message.threadId !== thread.id) {
|
|
585
|
+
throw new WebConsoleError("message_not_found", "The message is unavailable.", 404);
|
|
586
|
+
}
|
|
587
|
+
return this.shapeMessage(message, options);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* The untruncated payloads of ONE tool call, for a transcript that was served
|
|
591
|
+
* a preview of it.
|
|
592
|
+
*
|
|
593
|
+
* Addressed by (conversation, message, tool call) rather than by tool-call id
|
|
594
|
+
* alone: the id is not a capability, and a lookup that took it on its own
|
|
595
|
+
* would hand any caller any conversation's transcript.
|
|
596
|
+
*/
|
|
597
|
+
toolCallPart(threadId, messageId, toolCallId) {
|
|
598
|
+
const thread = this.store.getThread(threadId);
|
|
599
|
+
const message = this.store.getMessage(messageId);
|
|
600
|
+
if (thread === undefined || message === undefined || message.threadId !== thread.id) {
|
|
601
|
+
throw new WebConsoleError("tool_call_not_found", "The tool call is unavailable.", 404);
|
|
602
|
+
}
|
|
603
|
+
const owned = message.parts.find((part) => (part.type === "tool-call" || part.type === "subagent") && part.toolCallId === toolCallId);
|
|
604
|
+
if (owned !== undefined)
|
|
605
|
+
return nameWholePayloads(owned);
|
|
606
|
+
for (const part of message.parts) {
|
|
607
|
+
if (part.type !== "subagent")
|
|
608
|
+
continue;
|
|
609
|
+
const call = part.calls.find((candidate) => candidate.toolCallId === toolCallId);
|
|
610
|
+
// A subagent's child owns no part of its own, so it answers as the
|
|
611
|
+
// tool-call part it would have been outside the delegation.
|
|
612
|
+
if (call !== undefined)
|
|
613
|
+
return nameWholePayloads({ type: "tool-call", ...call });
|
|
614
|
+
}
|
|
615
|
+
throw new WebConsoleError("tool_call_not_found", "The tool call is unavailable.", 404);
|
|
202
616
|
}
|
|
203
617
|
/**
|
|
204
618
|
* Re-mint a browser capability from the authoritative durable message. The
|
|
@@ -211,7 +625,7 @@ export class WebService {
|
|
|
211
625
|
return this.decorateReplyPart(message, part);
|
|
212
626
|
}
|
|
213
627
|
async replyAttachment(threadId, messageId, partId, expires, token, signal) {
|
|
214
|
-
const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
|
|
628
|
+
const { thread, part, remainingSeconds } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
|
|
215
629
|
const connection = this.connections.get(thread.sourceId);
|
|
216
630
|
if (connection === undefined || connection.info.replyAttachments?.version !== 1) {
|
|
217
631
|
throw new WebConsoleError("reply_attachment_unavailable", "The attachment source is offline or incompatible.", 409);
|
|
@@ -227,7 +641,7 @@ export class WebService {
|
|
|
227
641
|
...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
|
|
228
642
|
};
|
|
229
643
|
const response = await connection.client.replyArtifact(this.conversationIdForThread(thread.id), attachment, signal);
|
|
230
|
-
return { part, response };
|
|
644
|
+
return { part, response, remainingSeconds };
|
|
231
645
|
}
|
|
232
646
|
async mcpAppResource(threadId, messageId, partId, expires, token, signal) {
|
|
233
647
|
const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "mcp_app", expires, token);
|
|
@@ -366,36 +780,58 @@ export class WebService {
|
|
|
366
780
|
const result = this.store.patchThreadIfRunConfigUnset(id, patch);
|
|
367
781
|
if (!result.applied)
|
|
368
782
|
return result.thread;
|
|
369
|
-
this.
|
|
370
|
-
this.
|
|
783
|
+
this.emitThread("thread.changed", { thread: result.thread });
|
|
784
|
+
this.emitThread("threads.changed", { thread: result.thread });
|
|
371
785
|
return result.thread;
|
|
372
786
|
}
|
|
373
787
|
const thread = this.store.patchThread(id, patch);
|
|
374
|
-
this.
|
|
375
|
-
this.
|
|
788
|
+
this.emitThread("thread.changed", { thread });
|
|
789
|
+
this.emitThread("threads.changed", { thread });
|
|
376
790
|
return thread;
|
|
377
791
|
}
|
|
378
|
-
async deleteThread(id) {
|
|
792
|
+
async deleteThread(id, options = {}) {
|
|
379
793
|
const resolved = this.store.getThread(id)?.id;
|
|
380
794
|
if (resolved === undefined)
|
|
381
795
|
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
382
796
|
if (this.activeTurns.has(resolved)) {
|
|
383
797
|
throw new WebConsoleError("turn_active", "Cancel the active turn before deleting this conversation.", 409);
|
|
384
798
|
}
|
|
385
|
-
const result = await this.store.deleteArchivedThread(resolved);
|
|
799
|
+
const result = await this.store.deleteArchivedThread(resolved, options);
|
|
386
800
|
if (result.orphanedFiles > 0) {
|
|
387
801
|
this.options.logger?.warn?.("Deleted a web conversation with attachment files deferred to orphan cleanup.", {
|
|
388
802
|
threadId: resolved,
|
|
389
803
|
count: result.orphanedFiles,
|
|
390
804
|
});
|
|
391
805
|
}
|
|
392
|
-
this.
|
|
393
|
-
this.
|
|
806
|
+
this.emitThread("thread.changed", { threadId: resolved, removed: true });
|
|
807
|
+
this.emitThread("threads.changed", { threadId: resolved, removed: true });
|
|
394
808
|
}
|
|
395
809
|
patchAgent(sourceId, patch) {
|
|
396
810
|
const agent = this.store.setAgentPinned(sourceId, patch.pinned);
|
|
811
|
+
// A pin says exactly what it changed. The payload-less form means "discovery
|
|
812
|
+
// saw something move", which costs every open console a bootstrap plus its
|
|
813
|
+
// skills and cron -- for one boolean the pinning tab already applied from
|
|
814
|
+
// this call's own response.
|
|
815
|
+
const payload = { sourceId: agent.sourceId, pinned: agent.pinned === true };
|
|
816
|
+
this.emit("agents.changed", undefined, payload);
|
|
817
|
+
return this.decorateProjectedCapabilities(agent);
|
|
818
|
+
}
|
|
819
|
+
setAgentRunDefaults(sourceId, input) {
|
|
820
|
+
const agent = this.store.getAgent(sourceId);
|
|
821
|
+
if (agent === undefined)
|
|
822
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
823
|
+
if (this.connections.get(sourceId) === undefined || agent.status === "offline") {
|
|
824
|
+
throw new WebConsoleError("agent_offline", "This agent is offline. Reconnect it before saving new defaults.", 409);
|
|
825
|
+
}
|
|
826
|
+
this.validateModelAndEffort(sourceId, agent, input.model ?? undefined, input.effort ?? undefined, true);
|
|
827
|
+
const updated = this.store.setAgentRunOverride(sourceId, input);
|
|
397
828
|
this.emit("agents.changed");
|
|
398
|
-
return
|
|
829
|
+
return this.decorateProjectedCapabilities(updated);
|
|
830
|
+
}
|
|
831
|
+
clearAgentRunDefaults(sourceId) {
|
|
832
|
+
const agent = this.store.clearAgentRunOverride(sourceId);
|
|
833
|
+
this.emit("agents.changed");
|
|
834
|
+
return this.decorateProjectedCapabilities(agent);
|
|
399
835
|
}
|
|
400
836
|
agentSkills(sourceId) {
|
|
401
837
|
const agent = this.store.getAgent(sourceId);
|
|
@@ -440,7 +876,7 @@ export class WebService {
|
|
|
440
876
|
const stored = this.store.storedCronRuns(sourceId, jobId, input.limit);
|
|
441
877
|
return stored.messages === undefined
|
|
442
878
|
? stored
|
|
443
|
-
: { ...stored, messages: stored.messages.map((message) => this.
|
|
879
|
+
: { ...stored, messages: stored.messages.map((message) => this.shapeMessage(message)) };
|
|
444
880
|
}
|
|
445
881
|
const page = await connection.client.cronRuns(jobId, {
|
|
446
882
|
limit: input.limit,
|
|
@@ -449,25 +885,138 @@ export class WebService {
|
|
|
449
885
|
});
|
|
450
886
|
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, page.runs);
|
|
451
887
|
if (reconciled.changed) {
|
|
452
|
-
|
|
453
|
-
this.
|
|
454
|
-
this.emit("threads.changed", threadId);
|
|
888
|
+
this.announceReconciledMessages(reconciled);
|
|
889
|
+
this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
|
|
455
890
|
}
|
|
456
|
-
return { ...page, messages: reconciled.messages.map((message) => this.
|
|
891
|
+
return { ...page, messages: reconciled.messages.map((message) => this.shapeMessage(message)) };
|
|
457
892
|
}
|
|
458
893
|
async cronRun(sourceId, jobId, runId) {
|
|
459
894
|
const connection = this.requireCronConnection(sourceId, false);
|
|
460
895
|
const run = await connection.client.cronRun(jobId, runId, AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
461
896
|
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [run]);
|
|
462
897
|
const message = reconciled.messages[0];
|
|
898
|
+
if (reconciled.changed) {
|
|
899
|
+
this.announceReconciledMessages(reconciled);
|
|
900
|
+
this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
|
|
901
|
+
}
|
|
463
902
|
if (message === undefined) {
|
|
903
|
+
if (reconciled.suppressedRunIds?.includes(runId)) {
|
|
904
|
+
throw new WebConsoleError("cron_run_not_visible", "This cron run has no visible message.", 404);
|
|
905
|
+
}
|
|
464
906
|
throw new WebConsoleError("invalid_operator_cron", "Cron detail did not reconcile a message.", 502);
|
|
465
907
|
}
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
908
|
+
return this.shapeMessage(message);
|
|
909
|
+
}
|
|
910
|
+
createCronReplyThread(sourceId, jobId, runId, input) {
|
|
911
|
+
const running = this.activeCronReplies.get(input.operationId);
|
|
912
|
+
if (running !== undefined) {
|
|
913
|
+
if (running.sourceId !== sourceId || running.jobId !== jobId || running.runId !== runId) {
|
|
914
|
+
return Promise.reject(new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation id was used for another run.", 409));
|
|
915
|
+
}
|
|
916
|
+
return running.promise;
|
|
917
|
+
}
|
|
918
|
+
const operation = this.createCronReplyThreadOnce(sourceId, jobId, runId, input)
|
|
919
|
+
.then((receipt) => ({ ...receipt, messages: this.shapeMessages(receipt.messages) }));
|
|
920
|
+
this.activeCronReplies.set(input.operationId, { sourceId, jobId, runId, promise: operation });
|
|
921
|
+
const release = () => {
|
|
922
|
+
if (this.activeCronReplies.get(input.operationId)?.promise === operation) {
|
|
923
|
+
this.activeCronReplies.delete(input.operationId);
|
|
924
|
+
}
|
|
925
|
+
};
|
|
926
|
+
void operation.then(release, release);
|
|
927
|
+
return operation;
|
|
928
|
+
}
|
|
929
|
+
async createCronReplyThreadOnce(sourceId, jobId, runId, input) {
|
|
930
|
+
let state = this.store.cronReplyOperation(input.operationId);
|
|
931
|
+
if (state !== undefined) {
|
|
932
|
+
this.assertCronReplyStateIdentity(state, sourceId, jobId, runId);
|
|
933
|
+
const terminal = this.cronReplyTerminalResult(state);
|
|
934
|
+
if (terminal !== undefined)
|
|
935
|
+
return terminal;
|
|
936
|
+
}
|
|
937
|
+
const candidate = state === undefined
|
|
938
|
+
? this.store.captureCronReplySnapshot(sourceId, jobId, runId, input.snapshotKind)
|
|
939
|
+
: undefined;
|
|
940
|
+
const connection = this.connections.get(sourceId);
|
|
941
|
+
if (connection === undefined) {
|
|
942
|
+
throw new WebConsoleError("cron_reply_agent_offline", "This agent is offline; Reply was not started.", 503);
|
|
943
|
+
}
|
|
944
|
+
if (connection.info.contextImport?.version !== 1
|
|
945
|
+
|| connection.info.contextImport.maxTextBytes < AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES) {
|
|
946
|
+
throw new WebConsoleError("cron_reply_unsupported", "This agent does not support canonical cron-result Reply.", 503);
|
|
947
|
+
}
|
|
948
|
+
if (state === undefined) {
|
|
949
|
+
state = this.store.reserveCronReplyOperation(input.operationId, candidate);
|
|
950
|
+
this.assertCronReplyStateIdentity(state, sourceId, jobId, runId);
|
|
951
|
+
const terminal = this.cronReplyTerminalResult(state);
|
|
952
|
+
if (terminal !== undefined)
|
|
953
|
+
return terminal;
|
|
954
|
+
if (state.kind === "pending" && state.operation.operationId !== input.operationId) {
|
|
955
|
+
throw new WebConsoleError("cron_reply_pending", "A Reply for this cron result is already pending. Retry it explicitly.", 409, { operationId: state.operation.operationId });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
if (state.kind !== "reserved" && state.kind !== "pending") {
|
|
959
|
+
throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation cannot continue.", 409);
|
|
960
|
+
}
|
|
961
|
+
const reservation = state.operation;
|
|
962
|
+
if (reservation.snapshotText === undefined) {
|
|
963
|
+
throw new WebConsoleError("storage_corrupt", "Pending cron reply lost its immutable snapshot.", 500);
|
|
964
|
+
}
|
|
965
|
+
let canonicalStatus;
|
|
966
|
+
try {
|
|
967
|
+
canonicalStatus = await connection.client.recordContextImport(reservation.conversationId, reservation.snapshotText, reservation.idempotencyKey);
|
|
968
|
+
}
|
|
969
|
+
catch (error) {
|
|
970
|
+
if (error instanceof WebConsoleError
|
|
971
|
+
&& (error.code === "context_import_conflict"
|
|
972
|
+
|| error.code === "context_import_failed"
|
|
973
|
+
|| error.code === "context_import_unsupported")) {
|
|
974
|
+
const failed = this.store.failCronReplyOperation(input.operationId, typeof error.details?.reason === "string" ? error.details.reason : error.code);
|
|
975
|
+
const wonRace = this.cronReplyTerminalResult(failed);
|
|
976
|
+
if (wonRace !== undefined)
|
|
977
|
+
return wonRace;
|
|
978
|
+
throw new WebConsoleError(error.code === "context_import_conflict"
|
|
979
|
+
? "cron_reply_conflict"
|
|
980
|
+
: error.code === "context_import_unsupported"
|
|
981
|
+
? "cron_reply_unsupported"
|
|
982
|
+
: "cron_reply_failed", error.message, error.code === "context_import_conflict" ? 409 : error.code === "context_import_unsupported" ? 503 : 502);
|
|
983
|
+
}
|
|
984
|
+
throw new WebConsoleError("cron_reply_outcome_unknown", "Cron Reply may have reached the agent. Retry explicitly to resolve it.", 504, { operationId: input.operationId });
|
|
985
|
+
}
|
|
986
|
+
const completed = this.store.completeCronReplyOperation(input.operationId, canonicalStatus);
|
|
987
|
+
const receipt = this.cronReplyTerminalResult(completed);
|
|
988
|
+
if (receipt === undefined) {
|
|
989
|
+
throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation did not complete.", 409);
|
|
990
|
+
}
|
|
991
|
+
if (!receipt.duplicate) {
|
|
992
|
+
for (const message of receipt.messages) {
|
|
993
|
+
if (this.isCronReplyProvenanceMessage(message))
|
|
994
|
+
continue;
|
|
995
|
+
this.emit("message.changed", receipt.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
|
|
996
|
+
}
|
|
997
|
+
this.emitThread("threads.changed", { thread: receipt.thread });
|
|
998
|
+
this.emitThread("thread.changed", { thread: receipt.thread });
|
|
469
999
|
}
|
|
470
|
-
return
|
|
1000
|
+
return receipt;
|
|
1001
|
+
}
|
|
1002
|
+
assertCronReplyStateIdentity(state, sourceId, jobId, runId) {
|
|
1003
|
+
const identity = state.kind === "completed" ? state.receipt : state.operation;
|
|
1004
|
+
if (identity.sourceId !== sourceId || identity.jobId !== jobId || identity.runId !== runId) {
|
|
1005
|
+
throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation id was used for another run.", 409);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
cronReplyTerminalResult(state) {
|
|
1009
|
+
if (state.kind === "completed")
|
|
1010
|
+
return state.receipt;
|
|
1011
|
+
if (state.kind === "tombstoned") {
|
|
1012
|
+
throw new WebConsoleError("cron_reply_gone", "This imported conversation was deleted.", 410);
|
|
1013
|
+
}
|
|
1014
|
+
if (state.kind === "failed") {
|
|
1015
|
+
throw new WebConsoleError("cron_reply_failed", "This cron Reply failed definitively; start a new Reply to try again.", 409, {
|
|
1016
|
+
...(state.operation.failureReason === undefined ? {} : { reason: state.operation.failureReason }),
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
return undefined;
|
|
471
1020
|
}
|
|
472
1021
|
async agentModels(sourceId, input) {
|
|
473
1022
|
const agent = this.store.getAgent(sourceId);
|
|
@@ -496,20 +1045,33 @@ export class WebService {
|
|
|
496
1045
|
// the reply -- fetched from a process that is gone -- was written straight
|
|
497
1046
|
// into the freshly reconciled map, where `source: "page"` overwrites
|
|
498
1047
|
// unconditionally. Generation 1's ladder then judged generation 2's turns.
|
|
499
|
-
this.
|
|
500
|
-
const record = { source: "page", efforts: advertisedEffortLevels(model) };
|
|
501
|
-
// The wire carries provider-local ids while every selection surface
|
|
502
|
-
// speaks the canonical `<provider>:<model>` reference. Admit both, or a
|
|
503
|
-
// turn is judged against metadata the page did advertise but under a
|
|
504
|
-
// name nothing ever asks for.
|
|
505
|
-
const reference = model.provider ? `${model.provider}:${model.id}` : model.id;
|
|
506
|
-
const entries = [[model.id, record]];
|
|
507
|
-
if (reference !== model.id)
|
|
508
|
-
entries.push([reference, record]);
|
|
509
|
-
return entries;
|
|
510
|
-
}));
|
|
1048
|
+
this.admitModelPage(sourceId, generation, page);
|
|
511
1049
|
return page;
|
|
512
1050
|
}
|
|
1051
|
+
async providerAuthStatus(sourceId) {
|
|
1052
|
+
return await this.providerAuthCall(sourceId, async (connection) => await connection.client.providerAuthStatus(AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1053
|
+
}
|
|
1054
|
+
async startProviderAuth(sourceId, input) {
|
|
1055
|
+
return await this.providerAuthCall(sourceId, async (connection) => await connection.client.startProviderAuth(input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1056
|
+
}
|
|
1057
|
+
async providerAuthSession(sourceId, sessionId) {
|
|
1058
|
+
return await this.providerAuthCall(sourceId, async (connection) => await connection.client.providerAuthSession(sessionId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1059
|
+
}
|
|
1060
|
+
async submitProviderAuth(sourceId, sessionId, input) {
|
|
1061
|
+
return await this.providerAuthCall(sourceId, async (connection) => await connection.client.submitProviderAuth(sessionId, input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1062
|
+
}
|
|
1063
|
+
async cancelProviderAuth(sourceId, sessionId) {
|
|
1064
|
+
await this.providerAuthCall(sourceId, async (connection) => await connection.client.cancelProviderAuth(sessionId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1065
|
+
}
|
|
1066
|
+
async startProviderAuthCheck(sourceId, input) {
|
|
1067
|
+
return await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.startProviderAuthCheck(input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1068
|
+
}
|
|
1069
|
+
async providerAuthCheck(sourceId, checkId) {
|
|
1070
|
+
return await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.providerAuthCheck(checkId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1071
|
+
}
|
|
1072
|
+
async cancelProviderAuthCheck(sourceId, checkId) {
|
|
1073
|
+
await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.cancelProviderAuthCheck(checkId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
|
|
1074
|
+
}
|
|
513
1075
|
async cronConfigView(sourceId) {
|
|
514
1076
|
const connection = this.requireCronConnection(sourceId, false);
|
|
515
1077
|
return await connection.client.cronConfigView(AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
@@ -520,9 +1082,8 @@ export class WebService {
|
|
|
520
1082
|
if (result.kind === "completed") {
|
|
521
1083
|
const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [result.value.run]);
|
|
522
1084
|
if (reconciled.changed) {
|
|
523
|
-
|
|
524
|
-
this.
|
|
525
|
-
this.emit("threads.changed", threadId);
|
|
1085
|
+
this.announceReconciledMessages(reconciled);
|
|
1086
|
+
this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
|
|
526
1087
|
}
|
|
527
1088
|
}
|
|
528
1089
|
return result;
|
|
@@ -540,8 +1101,7 @@ export class WebService {
|
|
|
540
1101
|
throw new WebConsoleError("invalid_operator_cron", "Updated cron job disappeared.", 502);
|
|
541
1102
|
if (synced.changed) {
|
|
542
1103
|
this.emit("cron.changed", job.threadId, { sourceId, jobId });
|
|
543
|
-
this.
|
|
544
|
-
this.emit("threads.changed", job.threadId);
|
|
1104
|
+
this.emitStoredThread(job.threadId, ["thread.changed", "threads.changed"]);
|
|
545
1105
|
}
|
|
546
1106
|
return { ...result, value: { job } };
|
|
547
1107
|
}
|
|
@@ -549,8 +1109,13 @@ export class WebService {
|
|
|
549
1109
|
if (this.stopped) {
|
|
550
1110
|
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
551
1111
|
}
|
|
552
|
-
|
|
1112
|
+
// Monitor wakes are addressed to a retained thread, so they must reach the
|
|
1113
|
+
// wake-specific retry/abandon path even after discovery has removed the
|
|
1114
|
+
// source from the picker. New source-scoped deliveries still refresh before
|
|
1115
|
+
// the store decides whether the agent exists.
|
|
1116
|
+
if (input.triggerKind !== "monitor" && this.store.getAgent(input.sourceId) === undefined) {
|
|
553
1117
|
await this.refreshAgents();
|
|
1118
|
+
}
|
|
554
1119
|
if (this.stopped) {
|
|
555
1120
|
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
556
1121
|
}
|
|
@@ -575,7 +1140,9 @@ export class WebService {
|
|
|
575
1140
|
return { thread, duplicate: result.duplicate, delivery: result.receipt };
|
|
576
1141
|
}
|
|
577
1142
|
if (input.triggerKind === "job") {
|
|
578
|
-
|
|
1143
|
+
// Destructured off: the card's message id is how this service addresses
|
|
1144
|
+
// the invalidation, and it is not part of the delivery result on the wire.
|
|
1145
|
+
const { messageId, ...completed } = this.store.upsertProcessJobCard({
|
|
579
1146
|
sourceId: input.sourceId,
|
|
580
1147
|
threadId: input.threadId,
|
|
581
1148
|
deliveryKey: input.deliveryKey,
|
|
@@ -583,12 +1150,17 @@ export class WebService {
|
|
|
583
1150
|
...(input.text === undefined ? {} : { responseText: input.text }),
|
|
584
1151
|
...(input.parts === undefined ? {} : { replyParts: input.parts }),
|
|
585
1152
|
});
|
|
586
|
-
|
|
1153
|
+
// Addressed, not searched. Scanning a page of the conversation meant a job
|
|
1154
|
+
// that finished behind thirty later messages emitted no invalidation at
|
|
1155
|
+
// all, and its card sat at "running" until something else forced a read.
|
|
1156
|
+
const message = this.store.getMessage(messageId);
|
|
587
1157
|
if (!completed.duplicate && message !== undefined) {
|
|
588
1158
|
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
589
1159
|
}
|
|
590
|
-
|
|
591
|
-
|
|
1160
|
+
// Read back rather than trusted: the shared notification result leaves the
|
|
1161
|
+
// conversation optional, and an event that carries `undefined` is exactly
|
|
1162
|
+
// the bare event this normalisation exists to remove.
|
|
1163
|
+
this.emitStoredThread(input.threadId, ["threads.changed", "thread.changed"]);
|
|
592
1164
|
if (input.wakePrompt === undefined)
|
|
593
1165
|
return completed;
|
|
594
1166
|
if (this.connections.get(input.sourceId) === undefined)
|
|
@@ -635,35 +1207,148 @@ export class WebService {
|
|
|
635
1207
|
async startTurn(threadId, input) {
|
|
636
1208
|
const text = input.text ?? "";
|
|
637
1209
|
const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
|
|
638
|
-
|
|
639
|
-
throw new WebConsoleError("turn_text_too_large", `The message and quote may contain at most ${WEB_MAX_TURN_TEXT_CHARACTERS} characters after formatting.`, 413);
|
|
640
|
-
}
|
|
1210
|
+
assertTurnTextWithinLimit(operatorText);
|
|
641
1211
|
const attachmentIds = input.attachmentIds ?? [];
|
|
642
|
-
const
|
|
643
|
-
|
|
644
|
-
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
1212
|
+
const selection = this.resolveTurnSelection(threadId, input.model, input.effort);
|
|
1213
|
+
const { thread, agent, model, effort, requestedModel, requestedEffort } = selection;
|
|
645
1214
|
threadId = thread.id;
|
|
646
|
-
const agent = this.store.getAgent(thread.sourceId);
|
|
647
1215
|
const connection = this.connections.get(thread.sourceId);
|
|
648
1216
|
if (thread.trigger?.kind === "cron")
|
|
649
1217
|
throw cronChannelReadOnlyError();
|
|
650
|
-
if (
|
|
1218
|
+
if (connection === undefined || !thread.canSend) {
|
|
651
1219
|
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
652
1220
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1221
|
+
const started = this.store.beginTurn({
|
|
1222
|
+
threadId,
|
|
1223
|
+
text,
|
|
1224
|
+
attachmentIds,
|
|
1225
|
+
...(input.quote === undefined ? {} : { quote: input.quote }),
|
|
1226
|
+
...(model === undefined ? {} : { model }),
|
|
1227
|
+
...(effort === undefined ? {} : { effort }),
|
|
1228
|
+
...(requestedModel === undefined ? {} : { requestedModel }),
|
|
1229
|
+
...(requestedEffort === undefined ? {} : { requestedEffort }),
|
|
1230
|
+
});
|
|
662
1231
|
this.launchTurn(started, connection.client, operatorText);
|
|
1232
|
+
// The operator's own row, inserted by `beginTurn` and announced by nothing
|
|
1233
|
+
// else. A console that did not issue this turn holds neither it nor the
|
|
1234
|
+
// assistant row the deltas are about to describe, and it no longer answers
|
|
1235
|
+
// a conversation summary by re-reading the transcript.
|
|
1236
|
+
this.emit("message.changed", threadId, {
|
|
1237
|
+
messageId: started.userMessageId,
|
|
1238
|
+
updatedAt: started.thread.updatedAt,
|
|
1239
|
+
});
|
|
663
1240
|
this.emit("turn.changed", threadId, { turn: started.thread.runState });
|
|
664
|
-
this.
|
|
1241
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
665
1242
|
return { thread: started.thread, turn: started.thread.runState };
|
|
666
1243
|
}
|
|
1244
|
+
submit(threadId, input) {
|
|
1245
|
+
if (this.stopped)
|
|
1246
|
+
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
1247
|
+
const thread = this.store.getThread(threadId);
|
|
1248
|
+
if (thread === undefined)
|
|
1249
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
1250
|
+
threadId = thread.id;
|
|
1251
|
+
const text = input.text ?? "";
|
|
1252
|
+
const attachmentIds = input.attachmentIds ?? [];
|
|
1253
|
+
const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
|
|
1254
|
+
const payloadSha256 = createHash("sha256").update(JSON.stringify({
|
|
1255
|
+
text,
|
|
1256
|
+
quote: input.quote ?? null,
|
|
1257
|
+
attachmentIds,
|
|
1258
|
+
model: input.model ?? null,
|
|
1259
|
+
effort: input.effort ?? null,
|
|
1260
|
+
})).digest("hex");
|
|
1261
|
+
const existing = this.store.webSubmission(threadId, input.submissionId);
|
|
1262
|
+
if (existing !== undefined) {
|
|
1263
|
+
if (existing.payloadSha256 !== payloadSha256) {
|
|
1264
|
+
throw new WebConsoleError("submission_conflict", "Submission id was already used for different content.", 409);
|
|
1265
|
+
}
|
|
1266
|
+
return this.submissionReceipt(existing);
|
|
1267
|
+
}
|
|
1268
|
+
assertTurnTextWithinLimit(operatorText);
|
|
1269
|
+
if (thread.trigger?.kind === "cron")
|
|
1270
|
+
throw cronChannelReadOnlyError();
|
|
1271
|
+
const connection = this.connections.get(thread.sourceId);
|
|
1272
|
+
if (connection === undefined || !thread.canSend) {
|
|
1273
|
+
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
1274
|
+
}
|
|
1275
|
+
let started;
|
|
1276
|
+
let reserved;
|
|
1277
|
+
const activeTurnId = this.store.activeTurn(threadId)?.id;
|
|
1278
|
+
const activeTarget = activeTurnId === undefined ? undefined : this.activeTurns.get(threadId);
|
|
1279
|
+
const ownsActiveTarget = activeTarget?.turnId === activeTurnId;
|
|
1280
|
+
const claimed = this.store.claimWebSubmission({
|
|
1281
|
+
threadId,
|
|
1282
|
+
submissionId: input.submissionId,
|
|
1283
|
+
payloadSha256,
|
|
1284
|
+
create: () => {
|
|
1285
|
+
if (activeTurnId !== undefined && attachmentIds.length > 0) {
|
|
1286
|
+
return { outcome: "rejected", reason: "active_attachments_unsupported" };
|
|
1287
|
+
}
|
|
1288
|
+
if (activeTurnId !== undefined) {
|
|
1289
|
+
reserved = this.store.reserveLiveInput(threadId, text, input.quote, operatorText);
|
|
1290
|
+
if (!connection.info.supportsLiveInputTargeting || !ownsActiveTarget) {
|
|
1291
|
+
const reason = connection.info.supportsLiveInputTargeting
|
|
1292
|
+
? "closed_before_dispatch"
|
|
1293
|
+
: "unsupported_targeting";
|
|
1294
|
+
this.store.queueLiveInput(reserved.input.id, reason);
|
|
1295
|
+
return {
|
|
1296
|
+
outcome: "live-input",
|
|
1297
|
+
reason,
|
|
1298
|
+
messageId: reserved.message.id,
|
|
1299
|
+
inputId: reserved.input.id,
|
|
1300
|
+
turnId: activeTurnId,
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
outcome: "live-input",
|
|
1305
|
+
messageId: reserved.message.id,
|
|
1306
|
+
inputId: reserved.input.id,
|
|
1307
|
+
turnId: activeTurnId,
|
|
1308
|
+
};
|
|
1309
|
+
}
|
|
1310
|
+
const { model, effort, requestedModel, requestedEffort } = this.resolveTurnSelection(threadId, input.model, input.effort);
|
|
1311
|
+
started = this.store.beginTurn({
|
|
1312
|
+
threadId,
|
|
1313
|
+
text,
|
|
1314
|
+
attachmentIds,
|
|
1315
|
+
...(input.quote === undefined ? {} : { quote: input.quote }),
|
|
1316
|
+
...(model === undefined ? {} : { model }),
|
|
1317
|
+
...(effort === undefined ? {} : { effort }),
|
|
1318
|
+
...(requestedModel === undefined ? {} : { requestedModel }),
|
|
1319
|
+
...(requestedEffort === undefined ? {} : { requestedEffort }),
|
|
1320
|
+
});
|
|
1321
|
+
return {
|
|
1322
|
+
outcome: "turn",
|
|
1323
|
+
messageId: started.userMessageId,
|
|
1324
|
+
turnId: started.turnId,
|
|
1325
|
+
};
|
|
1326
|
+
},
|
|
1327
|
+
});
|
|
1328
|
+
if (claimed.created && started !== undefined) {
|
|
1329
|
+
this.launchTurn(started, connection.client, operatorText);
|
|
1330
|
+
this.emit("message.changed", threadId, { messageId: started.userMessageId, updatedAt: started.thread.updatedAt });
|
|
1331
|
+
this.emit("turn.changed", threadId, { turn: started.thread.runState });
|
|
1332
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
1333
|
+
}
|
|
1334
|
+
else if (claimed.created && reserved !== undefined) {
|
|
1335
|
+
this.emit("message.changed", threadId, { messageId: reserved.message.id, updatedAt: reserved.message.updatedAt });
|
|
1336
|
+
this.emitThread("threads.changed", { thread: reserved.thread });
|
|
1337
|
+
if (claimed.submission.reason !== undefined || activeTarget === undefined) {
|
|
1338
|
+
void this.drainQueuedLiveInputs(threadId);
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
void this.dispatchTargetedSubmission(claimed.submission, activeTarget);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return this.submissionReceipt(claimed.submission);
|
|
1345
|
+
}
|
|
1346
|
+
submission(threadId, submissionId) {
|
|
1347
|
+
const stored = this.store.webSubmission(threadId, submissionId);
|
|
1348
|
+
if (stored === undefined)
|
|
1349
|
+
throw new WebConsoleError("submission_not_found", "Submission not found.", 404);
|
|
1350
|
+
return this.submissionReceipt(stored);
|
|
1351
|
+
}
|
|
667
1352
|
submitLiveInput(threadId, text) {
|
|
668
1353
|
if (this.stopped) {
|
|
669
1354
|
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
@@ -676,13 +1361,21 @@ export class WebService {
|
|
|
676
1361
|
const active = this.activeTurns.get(threadId);
|
|
677
1362
|
const reserved = this.store.reserveLiveInput(threadId, text);
|
|
678
1363
|
this.emit("message.changed", threadId, { messageId: reserved.message.id, updatedAt: reserved.message.updatedAt });
|
|
679
|
-
this.
|
|
1364
|
+
this.emitThread("threads.changed", { thread: reserved.thread });
|
|
680
1365
|
if (!reserved.offered || active === undefined || connection === undefined || !connection.info.supportsLiveInput) {
|
|
681
1366
|
const queued = reserved.offered ? this.store.queueLiveInput(reserved.input.id) ?? reserved.message : reserved.message;
|
|
682
1367
|
this.emit("message.changed", threadId, { messageId: queued.id, updatedAt: queued.updatedAt });
|
|
683
1368
|
void this.drainQueuedLiveInputs(threadId);
|
|
684
1369
|
return { message: queued, disposition: "queued" };
|
|
685
1370
|
}
|
|
1371
|
+
if (!this.store.markLiveInputDispatchStarted(reserved.input.id, active.turnId)) {
|
|
1372
|
+
// A concurrent terminal transition won before the request boundary. Do
|
|
1373
|
+
// not dispatch and do not recreate a fallback from stale in-memory state.
|
|
1374
|
+
return {
|
|
1375
|
+
message: this.store.getMessage(reserved.message.id) ?? reserved.message,
|
|
1376
|
+
disposition: "pending",
|
|
1377
|
+
};
|
|
1378
|
+
}
|
|
686
1379
|
const controller = new AbortController();
|
|
687
1380
|
const completion = this.deliverLiveInput(reserved.input.id, threadId, active.client, controller, {
|
|
688
1381
|
conversationId: `web:${threadId}`,
|
|
@@ -805,6 +1498,17 @@ export class WebService {
|
|
|
805
1498
|
readyEvent() {
|
|
806
1499
|
return this.createEvent("ready", undefined, { version: WEB_API_VERSION });
|
|
807
1500
|
}
|
|
1501
|
+
/**
|
|
1502
|
+
* The id a conversation is known by NOW, following whatever superseded it.
|
|
1503
|
+
*
|
|
1504
|
+
* Every read resolves redirects on its way through the store; the event
|
|
1505
|
+
* stream matches its subscription by string equality against the id events
|
|
1506
|
+
* carry, so it has to ask for the same resolution explicitly. An id nothing
|
|
1507
|
+
* superseded resolves to itself, so this is safe for any string.
|
|
1508
|
+
*/
|
|
1509
|
+
resolveThreadId(id) {
|
|
1510
|
+
return this.store.resolveThreadId(id);
|
|
1511
|
+
}
|
|
808
1512
|
refreshAgents() {
|
|
809
1513
|
if (this.stopped)
|
|
810
1514
|
return Promise.resolve();
|
|
@@ -847,7 +1551,10 @@ export class WebService {
|
|
|
847
1551
|
turn.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
|
|
848
1552
|
}
|
|
849
1553
|
for (const [id, input] of activeLiveInputs) {
|
|
850
|
-
|
|
1554
|
+
// Dispatch has already crossed the durable marker boundary. Seal it as
|
|
1555
|
+
// uncertain before abort so a crash at any later shutdown cut-point can
|
|
1556
|
+
// never recover it into the automatic next-turn queue.
|
|
1557
|
+
this.store.markLiveInputUncertain(id);
|
|
851
1558
|
input.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
|
|
852
1559
|
}
|
|
853
1560
|
await Promise.allSettled(active.map((turn) => turn.completion));
|
|
@@ -864,10 +1571,9 @@ export class WebService {
|
|
|
864
1571
|
this.store.close();
|
|
865
1572
|
await this.lease.release();
|
|
866
1573
|
}
|
|
867
|
-
async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey) {
|
|
1574
|
+
async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey, onAdmitted) {
|
|
868
1575
|
const coalescer = new StreamFrameCoalescer(async (frames) => {
|
|
869
|
-
|
|
870
|
-
this.emit("message.changed", started.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
|
|
1576
|
+
this.emitMessageWrite(started.thread.id, this.store.applyStreamFrames(started.turnId, frames));
|
|
871
1577
|
}, (error) => controller.abort(error));
|
|
872
1578
|
let releaseAttachmentBudget;
|
|
873
1579
|
try {
|
|
@@ -900,17 +1606,21 @@ export class WebService {
|
|
|
900
1606
|
this.observeConversationTitleFrame(started.thread.id, started.turnId, frame);
|
|
901
1607
|
coalescer.push(frame);
|
|
902
1608
|
},
|
|
1609
|
+
...(onAdmitted === undefined ? {} : { onAdmitted }),
|
|
903
1610
|
});
|
|
904
1611
|
await coalescer.flush();
|
|
905
1612
|
const silentMonitorWake = hostWakeDeliveryKey?.startsWith("monitor:") === true
|
|
906
1613
|
&& (response.finalText === undefined || response.finalText.length === 0)
|
|
907
1614
|
&& (response.parts === undefined || response.parts.length === 0);
|
|
908
|
-
const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts, {
|
|
1615
|
+
const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts, {
|
|
1616
|
+
suppressResponsePush: silentMonitorWake,
|
|
1617
|
+
...(hostWakeDeliveryKey === undefined ? {} : { monitorWakeDeliveryKey: hostWakeDeliveryKey }),
|
|
1618
|
+
});
|
|
1619
|
+
this.emitMessageWrite(started.thread.id, detail.write);
|
|
909
1620
|
this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
|
|
910
|
-
this.
|
|
911
|
-
this.
|
|
912
|
-
|
|
913
|
-
this.announcePushEvent(`turn:${started.turnId}:terminal`);
|
|
1621
|
+
this.emitThread("thread.changed", { thread: detail.thread });
|
|
1622
|
+
this.emitThread("threads.changed", { thread: detail.thread });
|
|
1623
|
+
this.announcePushEvent(`turn:${started.turnId}:terminal`);
|
|
914
1624
|
// Detached: the turn is already finished and reported, and keeping a copy
|
|
915
1625
|
// must neither delay nor fail it. The agent is still connected here, which
|
|
916
1626
|
// is when a fetch is most likely to succeed.
|
|
@@ -933,9 +1643,10 @@ export class WebService {
|
|
|
933
1643
|
...(code === undefined ? {} : { code }),
|
|
934
1644
|
cancelled,
|
|
935
1645
|
});
|
|
1646
|
+
this.emitMessageWrite(started.thread.id, detail.write);
|
|
936
1647
|
this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
|
|
937
|
-
this.
|
|
938
|
-
this.
|
|
1648
|
+
this.emitThread("thread.changed", { thread: detail.thread });
|
|
1649
|
+
this.emitThread("threads.changed", { thread: detail.thread });
|
|
939
1650
|
this.announcePushEvent(`turn:${started.turnId}:terminal`);
|
|
940
1651
|
}
|
|
941
1652
|
finally {
|
|
@@ -946,7 +1657,12 @@ export class WebService {
|
|
|
946
1657
|
launchTurn(started, client, operatorText, hostWakeDeliveryKey) {
|
|
947
1658
|
const threadId = started.thread.id;
|
|
948
1659
|
const controller = new AbortController();
|
|
949
|
-
|
|
1660
|
+
let resolveAdmitted;
|
|
1661
|
+
const admitted = new Promise((resolve) => { resolveAdmitted = resolve; });
|
|
1662
|
+
const completion = this.runTurn(started, client, controller, operatorText, hostWakeDeliveryKey, () => { resolveAdmitted(true); }).finally(() => {
|
|
1663
|
+
// Inert once admission already resolved; the turn settled without the
|
|
1664
|
+
// operator ever returning a stream when it did not.
|
|
1665
|
+
resolveAdmitted(false);
|
|
950
1666
|
const active = this.activeTurns.get(threadId);
|
|
951
1667
|
if (active?.turnId === started.turnId)
|
|
952
1668
|
this.activeTurns.delete(threadId);
|
|
@@ -954,8 +1670,64 @@ export class WebService {
|
|
|
954
1670
|
void this.drainQueuedLiveInputs(threadId);
|
|
955
1671
|
}
|
|
956
1672
|
});
|
|
957
|
-
this.activeTurns.set(threadId, {
|
|
958
|
-
|
|
1673
|
+
this.activeTurns.set(threadId, {
|
|
1674
|
+
turnId: started.turnId,
|
|
1675
|
+
controller,
|
|
1676
|
+
client,
|
|
1677
|
+
completion,
|
|
1678
|
+
admitted,
|
|
1679
|
+
resolveAdmitted,
|
|
1680
|
+
});
|
|
1681
|
+
return { completion, admitted };
|
|
1682
|
+
}
|
|
1683
|
+
submissionReceipt(submission) {
|
|
1684
|
+
const message = submission.messageId === undefined ? undefined : this.store.getMessage(submission.messageId);
|
|
1685
|
+
const thread = this.store.getThread(submission.threadId);
|
|
1686
|
+
return {
|
|
1687
|
+
submissionId: submission.submissionId,
|
|
1688
|
+
threadId: submission.threadId,
|
|
1689
|
+
outcome: submission.outcome,
|
|
1690
|
+
...(submission.reason === undefined ? {} : { reason: submission.reason }),
|
|
1691
|
+
...(submission.messageId === undefined ? {} : { messageId: submission.messageId }),
|
|
1692
|
+
...(submission.turnId === undefined ? {} : { turnId: submission.turnId }),
|
|
1693
|
+
...(message === undefined ? {} : { message }),
|
|
1694
|
+
...(submission.turnId === undefined || thread?.runState.id !== submission.turnId
|
|
1695
|
+
? {}
|
|
1696
|
+
: { turn: thread.runState }),
|
|
1697
|
+
...(submission.outcome !== "live-input"
|
|
1698
|
+
? {}
|
|
1699
|
+
: message?.liveInputStatus === "queued"
|
|
1700
|
+
? { disposition: "queued" }
|
|
1701
|
+
: message?.liveInputStatus === "pending"
|
|
1702
|
+
? { disposition: "pending" }
|
|
1703
|
+
: {}),
|
|
1704
|
+
};
|
|
1705
|
+
}
|
|
1706
|
+
async dispatchTargetedSubmission(submission, active) {
|
|
1707
|
+
if (submission.inputId === undefined || submission.messageId === undefined || submission.turnId === undefined)
|
|
1708
|
+
return;
|
|
1709
|
+
await active.admitted;
|
|
1710
|
+
if (this.activeTurns.get(submission.threadId) !== active
|
|
1711
|
+
|| this.store.activeTurn(submission.threadId)?.id !== submission.turnId) {
|
|
1712
|
+
const queued = this.store.queueLiveInput(submission.inputId, "closed_before_dispatch");
|
|
1713
|
+
if (queued !== undefined) {
|
|
1714
|
+
this.emit("message.changed", submission.threadId, { messageId: queued.id, updatedAt: queued.updatedAt });
|
|
1715
|
+
void this.drainQueuedLiveInputs(submission.threadId);
|
|
1716
|
+
}
|
|
1717
|
+
return;
|
|
1718
|
+
}
|
|
1719
|
+
const input = this.store.storedLiveInput(submission.inputId);
|
|
1720
|
+
if (input === undefined || !this.store.markLiveInputDispatchStarted(submission.inputId, submission.turnId))
|
|
1721
|
+
return;
|
|
1722
|
+
const controller = new AbortController();
|
|
1723
|
+
const completion = this.deliverLiveInput(submission.inputId, submission.threadId, active.client, controller, {
|
|
1724
|
+
conversationId: `web:${submission.threadId}`,
|
|
1725
|
+
id: submission.inputId,
|
|
1726
|
+
text: input.text,
|
|
1727
|
+
receivedAt: input.createdAt,
|
|
1728
|
+
targetTurnId: submission.turnId,
|
|
1729
|
+
}).finally(() => this.activeLiveInputs.delete(submission.inputId));
|
|
1730
|
+
this.activeLiveInputs.set(submission.inputId, { threadId: submission.threadId, controller, completion });
|
|
959
1731
|
}
|
|
960
1732
|
async deliverLiveInput(id, threadId, client, controller, input) {
|
|
961
1733
|
let queued = false;
|
|
@@ -968,16 +1740,22 @@ export class WebService {
|
|
|
968
1740
|
else if (result.status === "discarded") {
|
|
969
1741
|
changedMessage = this.store.cancelLiveInput(id);
|
|
970
1742
|
}
|
|
971
|
-
else {
|
|
972
|
-
changedMessage = this.store.queueLiveInput(id);
|
|
1743
|
+
else if (result.status === "requeue") {
|
|
1744
|
+
changedMessage = this.store.queueLiveInput(id, `mailbox_${result.reason}`);
|
|
1745
|
+
queued = changedMessage !== undefined;
|
|
1746
|
+
}
|
|
1747
|
+
else if (result.status === "unavailable") {
|
|
1748
|
+
changedMessage = this.store.queueLiveInput(id, `operator_${result.reason}`);
|
|
973
1749
|
queued = changedMessage !== undefined;
|
|
974
1750
|
}
|
|
1751
|
+
else {
|
|
1752
|
+
changedMessage = this.store.markLiveInputUncertain(id);
|
|
1753
|
+
}
|
|
975
1754
|
}
|
|
976
1755
|
catch (error) {
|
|
977
|
-
changedMessage = this.store.
|
|
978
|
-
queued = changedMessage !== undefined;
|
|
1756
|
+
changedMessage = this.store.markLiveInputUncertain(id);
|
|
979
1757
|
if (!controller.signal.aborted) {
|
|
980
|
-
this.options.logger?.debug?.("Web live-input delivery
|
|
1758
|
+
this.options.logger?.debug?.("Web live-input delivery outcome is uncertain; automatic fallback is suppressed.", {
|
|
981
1759
|
threadId,
|
|
982
1760
|
error: errorMessage(error),
|
|
983
1761
|
});
|
|
@@ -989,7 +1767,7 @@ export class WebService {
|
|
|
989
1767
|
updatedAt: changedMessage.updatedAt,
|
|
990
1768
|
});
|
|
991
1769
|
}
|
|
992
|
-
this.
|
|
1770
|
+
this.emitStoredThread(threadId, ["threads.changed"]);
|
|
993
1771
|
if (queued && !this.stopped)
|
|
994
1772
|
await this.drainQueuedLiveInputs(threadId);
|
|
995
1773
|
}
|
|
@@ -1011,12 +1789,20 @@ export class WebService {
|
|
|
1011
1789
|
if (started === undefined)
|
|
1012
1790
|
return;
|
|
1013
1791
|
this.launchTurn(started, connection.client, started.text);
|
|
1792
|
+
// BOTH rows. `promoteNextQueuedLiveInput` rewrites the queued operator
|
|
1793
|
+
// message (its live-input status becomes "applied") as well as opening
|
|
1794
|
+
// the assistant row, and a console that heard only about the second was
|
|
1795
|
+
// left showing a steer that still reads "queued".
|
|
1796
|
+
this.emit("message.changed", threadId, {
|
|
1797
|
+
messageId: started.userMessageId,
|
|
1798
|
+
updatedAt: started.thread.updatedAt,
|
|
1799
|
+
});
|
|
1014
1800
|
this.emit("message.changed", threadId, {
|
|
1015
1801
|
messageId: started.assistantMessageId,
|
|
1016
1802
|
updatedAt: started.thread.updatedAt,
|
|
1017
1803
|
});
|
|
1018
1804
|
this.emit("turn.changed", threadId, { turn: started.thread.runState });
|
|
1019
|
-
this.
|
|
1805
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
1020
1806
|
}
|
|
1021
1807
|
finally {
|
|
1022
1808
|
this.drainingLiveInputThreads.delete(threadId);
|
|
@@ -1059,6 +1845,7 @@ export class WebService {
|
|
|
1059
1845
|
const active = this.activeTurns.get(input.threadId);
|
|
1060
1846
|
if (active !== undefined && connection.info.supportsLiveInput) {
|
|
1061
1847
|
try {
|
|
1848
|
+
this.store.associateProcessJobWakeTurn(input.deliveryKey, active.turnId);
|
|
1062
1849
|
const settlement = await active.client.liveInput({
|
|
1063
1850
|
conversationId: `web:${input.threadId}`,
|
|
1064
1851
|
id: input.deliveryKey,
|
|
@@ -1068,16 +1855,33 @@ export class WebService {
|
|
|
1068
1855
|
signal: AbortSignal.timeout(10 * 60 * 1_000),
|
|
1069
1856
|
});
|
|
1070
1857
|
if (settlement.status === "applied") {
|
|
1071
|
-
this.store.completeProcessJobWake({
|
|
1858
|
+
const message = this.store.completeProcessJobWake({
|
|
1072
1859
|
sourceId: input.sourceId,
|
|
1073
1860
|
jobId: input.processJob.jobId,
|
|
1074
1861
|
deliveryKey: input.deliveryKey,
|
|
1075
1862
|
disposition: "steered",
|
|
1863
|
+
turnId: active.turnId,
|
|
1076
1864
|
});
|
|
1865
|
+
if (message !== undefined) {
|
|
1866
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
1867
|
+
}
|
|
1077
1868
|
return { delivered: true, disposition: "steered" };
|
|
1078
1869
|
}
|
|
1870
|
+
if (settlement.status !== "requeue" && settlement.status !== "unavailable") {
|
|
1871
|
+
this.store.releaseProcessJobWakeTurn(input.deliveryKey, active.turnId);
|
|
1872
|
+
return {
|
|
1873
|
+
delivered: false,
|
|
1874
|
+
code: "process_job_wake_ambiguous",
|
|
1875
|
+
retryable: false,
|
|
1876
|
+
ambiguous: true,
|
|
1877
|
+
};
|
|
1878
|
+
}
|
|
1879
|
+
this.store.associateProcessJobWakeTurn(input.deliveryKey, active.turnId, false);
|
|
1079
1880
|
}
|
|
1080
1881
|
catch (error) {
|
|
1882
|
+
// Receipt uncertainty forbids replay, but is not authority to silence
|
|
1883
|
+
// the ordinary answer from the active turn indefinitely.
|
|
1884
|
+
this.store.releaseProcessJobWakeTurn(input.deliveryKey, active.turnId);
|
|
1081
1885
|
this.options.logger?.warn?.("Web process-job steering outcome is unknown; automatic fallback is suppressed.", {
|
|
1082
1886
|
threadId: input.threadId,
|
|
1083
1887
|
error: errorMessage(error),
|
|
@@ -1090,8 +1894,41 @@ export class WebService {
|
|
|
1090
1894
|
};
|
|
1091
1895
|
}
|
|
1092
1896
|
}
|
|
1093
|
-
if (active !== undefined)
|
|
1094
|
-
|
|
1897
|
+
if (active !== undefined) {
|
|
1898
|
+
try {
|
|
1899
|
+
await active.completion;
|
|
1900
|
+
}
|
|
1901
|
+
catch {
|
|
1902
|
+
// No live-input request was sent, or the active run explicitly said
|
|
1903
|
+
// requeue/unavailable before this wait. This wake therefore has not
|
|
1904
|
+
// crossed an operator boundary and is safe to release only when the
|
|
1905
|
+
// store proves that it deleted the exact accepted reservation.
|
|
1906
|
+
let abandoned = false;
|
|
1907
|
+
try {
|
|
1908
|
+
abandoned = this.store.abandonProcessJobWake({
|
|
1909
|
+
sourceId: input.sourceId,
|
|
1910
|
+
jobId: input.processJob.jobId,
|
|
1911
|
+
deliveryKey: input.deliveryKey,
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
catch {
|
|
1915
|
+
// The accepted claim may remain. Preserve ambiguity and no-replay.
|
|
1916
|
+
}
|
|
1917
|
+
if (!abandoned) {
|
|
1918
|
+
return {
|
|
1919
|
+
delivered: false,
|
|
1920
|
+
code: "process_job_wake_ambiguous",
|
|
1921
|
+
retryable: false,
|
|
1922
|
+
ambiguous: true,
|
|
1923
|
+
};
|
|
1924
|
+
}
|
|
1925
|
+
return {
|
|
1926
|
+
delivered: false,
|
|
1927
|
+
code: "process_job_wake_failed",
|
|
1928
|
+
retryable: false,
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1095
1932
|
if (this.stopped) {
|
|
1096
1933
|
this.store.abandonProcessJobWake({
|
|
1097
1934
|
sourceId: input.sourceId,
|
|
@@ -1111,9 +1948,19 @@ export class WebService {
|
|
|
1111
1948
|
}
|
|
1112
1949
|
let started;
|
|
1113
1950
|
try {
|
|
1951
|
+
const selection = this.resolveTurnSelection(input.threadId);
|
|
1114
1952
|
started = this.store.beginAssistantTurn({
|
|
1115
1953
|
threadId: input.threadId,
|
|
1116
1954
|
prompt: input.wakePrompt,
|
|
1955
|
+
processJobWake: {
|
|
1956
|
+
jobId: input.processJob.jobId,
|
|
1957
|
+
deliveryKey: input.deliveryKey,
|
|
1958
|
+
disposition: "follow_up",
|
|
1959
|
+
},
|
|
1960
|
+
...(selection.model === undefined ? {} : { model: selection.model }),
|
|
1961
|
+
...(selection.effort === undefined ? {} : { effort: selection.effort }),
|
|
1962
|
+
...(selection.requestedModel === undefined ? {} : { requestedModel: selection.requestedModel }),
|
|
1963
|
+
...(selection.requestedEffort === undefined ? {} : { requestedEffort: selection.requestedEffort }),
|
|
1117
1964
|
});
|
|
1118
1965
|
}
|
|
1119
1966
|
catch (error) {
|
|
@@ -1128,29 +1975,59 @@ export class WebService {
|
|
|
1128
1975
|
retryable: false,
|
|
1129
1976
|
};
|
|
1130
1977
|
}
|
|
1131
|
-
const completion = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
|
|
1978
|
+
const { completion, admitted } = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
|
|
1979
|
+
// Receipt ownership moves to the durable turn below. The turn remains
|
|
1980
|
+
// owned by `activeTurns`, but its completion is no longer part of the
|
|
1981
|
+
// notification request and an unexpected terminal-store failure must not
|
|
1982
|
+
// become an unhandled rejection after the receipt has returned. Attached
|
|
1983
|
+
// before the admission wait below, so a rejection in that window cannot
|
|
1984
|
+
// escape either.
|
|
1985
|
+
void completion.catch((error) => {
|
|
1986
|
+
try {
|
|
1987
|
+
this.options.logger?.error?.("Web process-job follow-up turn settlement failed after admission.", {
|
|
1988
|
+
threadId: input.threadId,
|
|
1989
|
+
turnId: started.turnId,
|
|
1990
|
+
errorCode: errorCode(error) ?? "unknown",
|
|
1991
|
+
});
|
|
1992
|
+
}
|
|
1993
|
+
catch {
|
|
1994
|
+
// A caller-provided logger cannot be allowed to re-detach the failure.
|
|
1995
|
+
}
|
|
1996
|
+
});
|
|
1997
|
+
// Announced before the wait: this row exists either way, and a turn that
|
|
1998
|
+
// never reaches the operator still has to be visible as the failure it is.
|
|
1132
1999
|
this.emit("message.changed", input.threadId, {
|
|
1133
2000
|
messageId: started.assistantMessageId,
|
|
1134
2001
|
updatedAt: started.thread.updatedAt,
|
|
1135
2002
|
});
|
|
1136
2003
|
this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
|
|
1137
|
-
this.
|
|
1138
|
-
|
|
1139
|
-
|
|
2004
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
2005
|
+
// The wake's host-owned capability — chain depth and remaining background
|
|
2006
|
+
// starts — is bound to this exact request by the agent when it accepts the
|
|
2007
|
+
// turn. Receipting earlier ends the wake route on the agent side and
|
|
2008
|
+
// strips that capability from the very turn the wake raised, so the
|
|
2009
|
+
// follow-up can no longer start the next job. This waits for admission
|
|
2010
|
+
// only; the model turn itself stays detached above.
|
|
2011
|
+
if (!await admitted) {
|
|
2012
|
+
// The request may still have reached the agent, so the accepted claim
|
|
2013
|
+
// stays put: no abandon, no replay, and any retry fails closed.
|
|
1140
2014
|
return {
|
|
1141
2015
|
delivered: false,
|
|
1142
|
-
code: "
|
|
2016
|
+
code: "process_job_wake_ambiguous",
|
|
1143
2017
|
retryable: false,
|
|
1144
2018
|
ambiguous: true,
|
|
1145
2019
|
};
|
|
1146
2020
|
}
|
|
1147
|
-
this.store.completeProcessJobWake({
|
|
2021
|
+
const message = this.store.completeProcessJobWake({
|
|
1148
2022
|
sourceId: input.sourceId,
|
|
1149
2023
|
jobId: input.processJob.jobId,
|
|
1150
2024
|
deliveryKey: input.deliveryKey,
|
|
1151
2025
|
disposition: "follow_up",
|
|
1152
2026
|
turnId: started.turnId,
|
|
1153
2027
|
});
|
|
2028
|
+
if (message !== undefined) {
|
|
2029
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
2030
|
+
}
|
|
1154
2031
|
return { delivered: true, disposition: "follow_up" };
|
|
1155
2032
|
});
|
|
1156
2033
|
const tail = delivery.then(() => undefined, () => undefined);
|
|
@@ -1240,6 +2117,7 @@ export class WebService {
|
|
|
1240
2117
|
&& connection.info.supportsLiveInput
|
|
1241
2118
|
&& input.wakePrompt.length <= AGENT_LIVE_INPUT_MAX_CHARACTERS) {
|
|
1242
2119
|
try {
|
|
2120
|
+
this.store.setMonitorWakeSteeringTurn(input.sourceId, input.deliveryKey, active.turnId, true);
|
|
1243
2121
|
const settlement = await active.client.liveInput({
|
|
1244
2122
|
conversationId: `web:${input.threadId}`,
|
|
1245
2123
|
id: input.deliveryKey,
|
|
@@ -1261,6 +2139,15 @@ export class WebService {
|
|
|
1261
2139
|
}
|
|
1262
2140
|
return { delivered: true, disposition: "steered" };
|
|
1263
2141
|
}
|
|
2142
|
+
if (settlement.status !== "requeue" && settlement.status !== "unavailable") {
|
|
2143
|
+
return {
|
|
2144
|
+
delivered: false,
|
|
2145
|
+
code: "monitor_wake_ambiguous",
|
|
2146
|
+
retryable: false,
|
|
2147
|
+
ambiguous: true,
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
this.store.setMonitorWakeSteeringTurn(input.sourceId, input.deliveryKey, active.turnId, false);
|
|
1264
2151
|
}
|
|
1265
2152
|
catch (error) {
|
|
1266
2153
|
this.options.logger?.warn?.("Web Monitor steering outcome is unknown; automatic fallback is suppressed.", {
|
|
@@ -1289,10 +2176,15 @@ export class WebService {
|
|
|
1289
2176
|
}
|
|
1290
2177
|
let started;
|
|
1291
2178
|
try {
|
|
2179
|
+
const selection = this.resolveTurnSelection(input.threadId);
|
|
1292
2180
|
started = this.store.beginAssistantTurn({
|
|
1293
2181
|
threadId: input.threadId,
|
|
1294
2182
|
prompt: input.wakePrompt,
|
|
1295
2183
|
storedPrompt: "[Monitor wake]",
|
|
2184
|
+
...(selection.model === undefined ? {} : { model: selection.model }),
|
|
2185
|
+
...(selection.effort === undefined ? {} : { effort: selection.effort }),
|
|
2186
|
+
...(selection.requestedModel === undefined ? {} : { requestedModel: selection.requestedModel }),
|
|
2187
|
+
...(selection.requestedEffort === undefined ? {} : { requestedEffort: selection.requestedEffort }),
|
|
1296
2188
|
});
|
|
1297
2189
|
}
|
|
1298
2190
|
catch (error) {
|
|
@@ -1303,13 +2195,13 @@ export class WebService {
|
|
|
1303
2195
|
retryable: false,
|
|
1304
2196
|
};
|
|
1305
2197
|
}
|
|
1306
|
-
const completion = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
|
|
2198
|
+
const { completion } = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
|
|
1307
2199
|
this.emit("message.changed", input.threadId, {
|
|
1308
2200
|
messageId: started.assistantMessageId,
|
|
1309
2201
|
updatedAt: started.thread.updatedAt,
|
|
1310
2202
|
});
|
|
1311
2203
|
this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
|
|
1312
|
-
this.
|
|
2204
|
+
this.emitThread("threads.changed", { thread: started.thread });
|
|
1313
2205
|
await completion;
|
|
1314
2206
|
if (this.store.turnStatus(started.turnId) !== "complete") {
|
|
1315
2207
|
return {
|
|
@@ -1376,8 +2268,19 @@ export class WebService {
|
|
|
1376
2268
|
return completed;
|
|
1377
2269
|
throw new WebConsoleError("storage_corrupt", "A new notification completed without a conversation.", 500);
|
|
1378
2270
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
2271
|
+
// Before the summaries, so a console applies the transcript move it can act
|
|
2272
|
+
// on rather than being told only that the conversation's revision moved.
|
|
2273
|
+
if (!completed.duplicate && completed.messageId !== undefined) {
|
|
2274
|
+
const written = this.store.getMessage(completed.messageId);
|
|
2275
|
+
if (written !== undefined) {
|
|
2276
|
+
this.emit("message.changed", completed.thread.id, {
|
|
2277
|
+
messageId: written.id,
|
|
2278
|
+
updatedAt: written.updatedAt,
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
}
|
|
2282
|
+
this.emitThread("threads.changed", { thread: completed.thread });
|
|
2283
|
+
this.emitThread("thread.changed", { thread: completed.thread });
|
|
1381
2284
|
if (!completed.duplicate) {
|
|
1382
2285
|
this.announcePushEvent(notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey));
|
|
1383
2286
|
}
|
|
@@ -1408,8 +2311,16 @@ export class WebService {
|
|
|
1408
2311
|
}
|
|
1409
2312
|
catch (error) {
|
|
1410
2313
|
this.options.logger?.warn?.("Web agent discovery failed.", { error: errorMessage(error) });
|
|
1411
|
-
const changed = this.store.
|
|
2314
|
+
const changed = this.store.markDiscoveredAgentsOffline();
|
|
1412
2315
|
this.connections = new Map();
|
|
2316
|
+
// The live connection that backs the projection is gone, so provider
|
|
2317
|
+
// authentication is unavailable now.
|
|
2318
|
+
// Clearing it here is what makes the recovery a transition worth
|
|
2319
|
+
// announcing rather than a no-op against a stale map. It is belt and
|
|
2320
|
+
// braces today: this projected capability implies a live connection, which
|
|
2321
|
+
// implies a row that was not offline, so `markDiscoveredAgentsOffline`
|
|
2322
|
+
// returns true and the failure is announced anyway.
|
|
2323
|
+
this.projectedCapabilities = new Map();
|
|
1413
2324
|
if (changed)
|
|
1414
2325
|
this.emit("agents.changed");
|
|
1415
2326
|
return;
|
|
@@ -1431,12 +2342,14 @@ export class WebService {
|
|
|
1431
2342
|
baseUrl: agent.baseUrl,
|
|
1432
2343
|
...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
|
|
1433
2344
|
...(agent.processJobsBearer === undefined ? {} : { processJobsBearer: agent.processJobsBearer }),
|
|
2345
|
+
...(agent.monitorsBearer === undefined ? {} : { monitorsBearer: agent.monitorsBearer }),
|
|
1434
2346
|
...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }),
|
|
1435
2347
|
});
|
|
1436
2348
|
try {
|
|
1437
2349
|
const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
|
|
1438
|
-
nextConnections.set(agent.source.sourceId, { client, info });
|
|
2350
|
+
nextConnections.set(agent.source.sourceId, { client, info, generation });
|
|
1439
2351
|
this.seedModelCatalogFromOptions(agent.source.sourceId, generation, info.modelOptions);
|
|
2352
|
+
await this.restorePersistedModelAdmission(client, agent.source.sourceId, generation, info.providers, signal);
|
|
1440
2353
|
const efforts = collectEfforts(info);
|
|
1441
2354
|
return {
|
|
1442
2355
|
sourceId: agent.source.sourceId,
|
|
@@ -1451,6 +2364,7 @@ export class WebService {
|
|
|
1451
2364
|
...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
|
|
1452
2365
|
...(efforts.length === 0 ? {} : { efforts }),
|
|
1453
2366
|
...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
|
|
2367
|
+
runSettings: configRunSettings(info.model, info.effort),
|
|
1454
2368
|
...(info.providers === undefined ? {} : { providers: info.providers }),
|
|
1455
2369
|
...(info.cron === undefined ? {} : { cron: info.cron }),
|
|
1456
2370
|
...(info.supportsAskById ? { supportsAskById: true } : {}),
|
|
@@ -1467,6 +2381,16 @@ export class WebService {
|
|
|
1467
2381
|
}));
|
|
1468
2382
|
this.connections = nextConnections;
|
|
1469
2383
|
const agentsChanged = this.store.replaceAgents(summaries);
|
|
2384
|
+
// Usable provider authentication comes from the live connection, so when it
|
|
2385
|
+
// turns on or off nothing on the discovery summary moves and `replaceAgents`
|
|
2386
|
+
// is right to say so. An operator can start or stop advertising
|
|
2387
|
+
// `capabilities.providerAuth` across a restart; an open console would
|
|
2388
|
+
// otherwise keep the action hidden until an unrelated change, or keep
|
|
2389
|
+
// offering one whose route now 409s. Read here after assigning the live
|
|
2390
|
+
// connections and before the cron refresh awaits on the network.
|
|
2391
|
+
const projected = new Map(summaries.map((summary) => [summary.sourceId, this.projectedCapabilitySignature(summary)]));
|
|
2392
|
+
const capabilityChanged = projected.size !== this.projectedCapabilities.size
|
|
2393
|
+
|| [...projected].some(([sourceId, signature]) => this.projectedCapabilities.get(sourceId) !== signature);
|
|
1470
2394
|
const cronChangedSources = new Set();
|
|
1471
2395
|
await Promise.all([...nextConnections.entries()].map(async ([sourceId, connection]) => {
|
|
1472
2396
|
if (connection.info.cron?.read !== true)
|
|
@@ -1484,7 +2408,13 @@ export class WebService {
|
|
|
1484
2408
|
});
|
|
1485
2409
|
}
|
|
1486
2410
|
}));
|
|
1487
|
-
|
|
2411
|
+
// Adopted only once the announcement is about to go out. Assigning it
|
|
2412
|
+
// before the cron refresh would let a throw in that window consume the
|
|
2413
|
+
// transition: the next pass would compare against a map that already
|
|
2414
|
+
// carried the new signatures, find nothing changed, and the flip would be
|
|
2415
|
+
// lost rather than deferred.
|
|
2416
|
+
this.projectedCapabilities = projected;
|
|
2417
|
+
if (agentsChanged || capabilityChanged)
|
|
1488
2418
|
this.emit("agents.changed");
|
|
1489
2419
|
for (const sourceId of cronChangedSources)
|
|
1490
2420
|
this.emit("cron.changed", undefined, { sourceId });
|
|
@@ -1494,6 +2424,34 @@ export class WebService {
|
|
|
1494
2424
|
void this.drainQueuedLiveInputs(threadId);
|
|
1495
2425
|
}
|
|
1496
2426
|
}
|
|
2427
|
+
requireProviderAuthConnection(sourceId) {
|
|
2428
|
+
const agent = this.store.getAgent(sourceId);
|
|
2429
|
+
if (agent === undefined)
|
|
2430
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
2431
|
+
const connection = this.connections.get(sourceId);
|
|
2432
|
+
if (connection === undefined)
|
|
2433
|
+
throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
|
|
2434
|
+
if (connection.info.supportsProviderAuth !== true) {
|
|
2435
|
+
throw new WebConsoleError("provider_auth_unavailable", "This agent does not expose provider authentication.", 409);
|
|
2436
|
+
}
|
|
2437
|
+
return connection;
|
|
2438
|
+
}
|
|
2439
|
+
async providerAuthCall(sourceId, operation) {
|
|
2440
|
+
const connection = this.requireProviderAuthConnection(sourceId);
|
|
2441
|
+
const result = await operation(connection);
|
|
2442
|
+
if (this.connections.get(sourceId)?.generation !== connection.generation) {
|
|
2443
|
+
throw new WebConsoleError("agent_generation_changed", "The agent restarted during provider authentication. Refresh status and retry.", 409);
|
|
2444
|
+
}
|
|
2445
|
+
return result;
|
|
2446
|
+
}
|
|
2447
|
+
async providerAuthCheckCall(sourceId, operation) {
|
|
2448
|
+
return await this.providerAuthCall(sourceId, async (connection) => {
|
|
2449
|
+
if (connection.info.supportsProviderAuthChecks !== true) {
|
|
2450
|
+
throw new WebConsoleError("provider_auth_unavailable", "This agent does not expose provider checks.", 409);
|
|
2451
|
+
}
|
|
2452
|
+
return await operation(connection);
|
|
2453
|
+
});
|
|
2454
|
+
}
|
|
1497
2455
|
startTimers() {
|
|
1498
2456
|
const discoveryInterval = this.options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
|
|
1499
2457
|
const purgeInterval = this.options.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
|
|
@@ -1532,6 +2490,117 @@ export class WebService {
|
|
|
1532
2490
|
this.options.logger?.info?.("Purged orphaned web uploads.", { count, partialCount, unreferencedCount });
|
|
1533
2491
|
}
|
|
1534
2492
|
}
|
|
2493
|
+
/**
|
|
2494
|
+
* A listing event that names a conversation AND describes it.
|
|
2495
|
+
*
|
|
2496
|
+
* The id comes off the payload, so the two can never disagree: an event that
|
|
2497
|
+
* named one conversation while carrying another would have every console
|
|
2498
|
+
* apply the wrong row.
|
|
2499
|
+
*/
|
|
2500
|
+
emitThread(type, payload) {
|
|
2501
|
+
this.emit(type, "thread" in payload ? payload.thread.id : payload.threadId, payload);
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Name every row a cron reconciliation actually wrote.
|
|
2505
|
+
*
|
|
2506
|
+
* A reconciliation moves a transcript and has no delta to describe it, so
|
|
2507
|
+
* each written row reaches a console as the invalidation it answers with one
|
|
2508
|
+
* message read. Rows the poll left alone are not announced: an unchanged page
|
|
2509
|
+
* must cost a console nothing.
|
|
2510
|
+
*/
|
|
2511
|
+
announceReconciledMessages(reconciled) {
|
|
2512
|
+
const written = new Set(reconciled.writtenMessageIds);
|
|
2513
|
+
for (const message of reconciled.messages) {
|
|
2514
|
+
if (!written.has(message.id))
|
|
2515
|
+
continue;
|
|
2516
|
+
this.emit("message.changed", message.threadId, {
|
|
2517
|
+
messageId: message.id,
|
|
2518
|
+
updatedAt: message.updatedAt,
|
|
2519
|
+
});
|
|
2520
|
+
}
|
|
2521
|
+
}
|
|
2522
|
+
/**
|
|
2523
|
+
* The same as {@link emitThread}, for a write whose caller kept no snapshot of
|
|
2524
|
+
* what it produced -- a cron reconcile, a live-input hand-off -- so the store
|
|
2525
|
+
* is the only place the fresh summary can come from.
|
|
2526
|
+
*
|
|
2527
|
+
* A conversation that is no longer there leaves nothing to describe, and the
|
|
2528
|
+
* bulk form is the only honest scope left for a listing that did change.
|
|
2529
|
+
*/
|
|
2530
|
+
emitStoredThread(threadId, types) {
|
|
2531
|
+
const thread = threadId === undefined ? undefined : this.store.getThread(threadId);
|
|
2532
|
+
if (thread === undefined) {
|
|
2533
|
+
this.emit("threads.changed");
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
for (const type of types)
|
|
2537
|
+
this.emitThread(type, { thread });
|
|
2538
|
+
}
|
|
2539
|
+
/**
|
|
2540
|
+
* Put one persisted assistant-message write on the wire as content.
|
|
2541
|
+
*
|
|
2542
|
+
* A streaming answer is rewritten every {@link STREAM_FLUSH_INTERVAL_MS}
|
|
2543
|
+
* milliseconds. Announcing each one as an invalidation made every connected
|
|
2544
|
+
* console re-read the whole conversation to find the few characters that had
|
|
2545
|
+
* arrived, so these two paths -- the stream coalescer and the write that
|
|
2546
|
+
* settles a turn -- say what changed instead.
|
|
2547
|
+
*
|
|
2548
|
+
* Three rules keep that honest:
|
|
2549
|
+
* - A write that did not happen says nothing. `applyStreamFrames` and the
|
|
2550
|
+
* finish both answer a settled turn with no delta at all, and a version
|
|
2551
|
+
* nobody wrote is not a version to announce.
|
|
2552
|
+
* - An empty `ops` list is still announced. A status-only finish moves the
|
|
2553
|
+
* sequence number without changing a part, and a console that never heard
|
|
2554
|
+
* about it would reject the next delta as a gap.
|
|
2555
|
+
* - The parts inside a `set` are shaped exactly as a read would serve them,
|
|
2556
|
+
* and a delta that would cost more than re-reading the message declines to
|
|
2557
|
+
* the invalidation hint every other writer emits. A splice-driven finish
|
|
2558
|
+
* re-sets every part it shifted, which is bigger than the message itself.
|
|
2559
|
+
* That hint carries no sequence number, so the NEXT delta will not chain
|
|
2560
|
+
* onto the last one a console applied -- which is exactly the mismatch that
|
|
2561
|
+
* sends it to the message read.
|
|
2562
|
+
*/
|
|
2563
|
+
emitMessageWrite(threadId, write) {
|
|
2564
|
+
if (write?.attributionChanged === true) {
|
|
2565
|
+
const thread = this.store.getThread(threadId);
|
|
2566
|
+
if (thread !== undefined)
|
|
2567
|
+
this.emit("turn.changed", threadId, { turn: thread.runState });
|
|
2568
|
+
}
|
|
2569
|
+
if (write === undefined || write.delta === undefined)
|
|
2570
|
+
return;
|
|
2571
|
+
const { message, delta } = write;
|
|
2572
|
+
// An invariant, not a case: all three callers pass the assistant row of a
|
|
2573
|
+
// turn. `mapMessage` filters quote and live-input telemetry off user rows,
|
|
2574
|
+
// so a delta diffed against one would describe parts no reader ever holds,
|
|
2575
|
+
// and quietly downgrading here would hide the day that stops being true.
|
|
2576
|
+
if (message.role !== "assistant") {
|
|
2577
|
+
throw new TypeError(`A ${message.role} message reached the delta path.`);
|
|
2578
|
+
}
|
|
2579
|
+
const shaped = {
|
|
2580
|
+
...delta,
|
|
2581
|
+
// Set only once a turn, by the write that ends it. Without it a
|
|
2582
|
+
// subscribed console still bought one whole-conversation read per turn
|
|
2583
|
+
// finish, purely to draw the Activity header's window.
|
|
2584
|
+
...(message.finishedAt === undefined ? {} : { finishedAt: message.finishedAt }),
|
|
2585
|
+
ops: delta.ops.map((op) => (op.op === "set" ? { ...op, part: this.shapePart(message, op.part, {}) } : op)),
|
|
2586
|
+
};
|
|
2587
|
+
// Only a write that REWRITES parts can outweigh the message it describes: an
|
|
2588
|
+
// `append` carries strictly less than the part it grew, and the message
|
|
2589
|
+
// carries that whole part plus its own envelope. Worth the check, because
|
|
2590
|
+
// the comparison shapes the entire message -- reply capabilities re-minted
|
|
2591
|
+
// and all -- and the streaming path runs this every 50 ms.
|
|
2592
|
+
if (shaped.ops.some((op) => op.op !== "append")
|
|
2593
|
+
&& JSON.stringify(shaped.ops).length > JSON.stringify(this.shapeMessage(message)).length) {
|
|
2594
|
+
const declined = {
|
|
2595
|
+
messageId: message.id,
|
|
2596
|
+
updatedAt: message.updatedAt,
|
|
2597
|
+
deltaDeclined: true,
|
|
2598
|
+
};
|
|
2599
|
+
this.emit("message.changed", threadId, declined);
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
this.emit("message.delta", threadId, shaped);
|
|
2603
|
+
}
|
|
1535
2604
|
emit(type, threadId, payload) {
|
|
1536
2605
|
if (this.stopped)
|
|
1537
2606
|
return;
|
|
@@ -1559,7 +2628,7 @@ export class WebService {
|
|
|
1559
2628
|
}
|
|
1560
2629
|
observeAskUserFrame(threadId, turnId, frame) {
|
|
1561
2630
|
if (this.stopped || frame.kind !== "event" || frame.event.type !== "tool_call_started"
|
|
1562
|
-
||
|
|
2631
|
+
|| !isAskUserToolName(frame.event.name))
|
|
1563
2632
|
return;
|
|
1564
2633
|
const key = `${threadId}\0${turnId}`;
|
|
1565
2634
|
if (this.askWatches.has(key))
|
|
@@ -1582,8 +2651,8 @@ export class WebService {
|
|
|
1582
2651
|
const thread = this.store.applyAgentTitle(threadId, title);
|
|
1583
2652
|
if (thread === undefined)
|
|
1584
2653
|
return;
|
|
1585
|
-
this.
|
|
1586
|
-
this.
|
|
2654
|
+
this.emitThread("thread.changed", { thread });
|
|
2655
|
+
this.emitThread("threads.changed", { thread });
|
|
1587
2656
|
}
|
|
1588
2657
|
catch (error) {
|
|
1589
2658
|
this.options.logger?.warn?.("Agent conversation-title update failed; the turn is continuing.", {
|
|
@@ -1641,7 +2710,10 @@ export class WebService {
|
|
|
1641
2710
|
sourceId: thread.sourceId,
|
|
1642
2711
|
title: `${agent?.label ?? "mono-agent"} needs input`,
|
|
1643
2712
|
body: `${question.header}: ${question.question}`,
|
|
1644
|
-
|
|
2713
|
+
// Push delivery remains bounded even when the underlying AskUser wait is
|
|
2714
|
+
// unbounded. Expiring this notification does not expire the interaction.
|
|
2715
|
+
expiresAt: snapshot.expiresAt
|
|
2716
|
+
?? new Date(this.currentDate().getTime() + ASK_PUSH_DELIVERY_TTL_MS).toISOString(),
|
|
1645
2717
|
});
|
|
1646
2718
|
if (event !== undefined)
|
|
1647
2719
|
this.announcePushEvent(event.logicalKey);
|
|
@@ -1663,7 +2735,8 @@ export class WebService {
|
|
|
1663
2735
|
: await connection.client.pendingAsk(this.store.cronConversationIdForThread(event.threadId) ?? `web:${event.threadId}`, boundedSignal);
|
|
1664
2736
|
return snapshot?.interactionId === interactionId
|
|
1665
2737
|
&& snapshot.status === "pending"
|
|
1666
|
-
&&
|
|
2738
|
+
&& (snapshot.expiresAt === null
|
|
2739
|
+
|| new Date(snapshot.expiresAt).getTime() > this.currentDate().getTime())
|
|
1667
2740
|
? "current"
|
|
1668
2741
|
: "stale";
|
|
1669
2742
|
}
|
|
@@ -1741,6 +2814,105 @@ export class WebService {
|
|
|
1741
2814
|
// only records admission; recording a ladder here would shadow it.
|
|
1742
2815
|
this.admitCatalogRefs(sourceId, generation, Object.keys(modelOptions).map((key) => [key, { source: "shortlist", efforts: undefined }]));
|
|
1743
2816
|
}
|
|
2817
|
+
/**
|
|
2818
|
+
* A catalog-only web default outlives this process, while the admission cache
|
|
2819
|
+
* deliberately does not. Revalidate that one persisted ref against the live
|
|
2820
|
+
* generation during discovery so first-use thread creation never depends on
|
|
2821
|
+
* a browser having opened the model picker. An exact match is mandatory: a
|
|
2822
|
+
* retired ref remains unadmitted even if the search returns close names.
|
|
2823
|
+
*/
|
|
2824
|
+
async restorePersistedModelAdmission(client, sourceId, generation, providers, signal) {
|
|
2825
|
+
const model = this.store.getAgent(sourceId)?.runSettings.override?.model;
|
|
2826
|
+
if (model === undefined || this.modelCatalogCache.get(sourceId)?.models.has(model) === true)
|
|
2827
|
+
return;
|
|
2828
|
+
const separator = model.indexOf(":");
|
|
2829
|
+
// The runtime wire makes only the first colon structural: provider ids may
|
|
2830
|
+
// not contain one, while opaque model ids commonly do. Keeping that exact
|
|
2831
|
+
// split also prevents a malformed colon-bearing provider from colliding
|
|
2832
|
+
// with a legitimate provider plus colon-bearing model id.
|
|
2833
|
+
const target = separator > 0 && separator < model.length - 1
|
|
2834
|
+
? {
|
|
2835
|
+
kind: "canonical",
|
|
2836
|
+
provider: model.slice(0, separator),
|
|
2837
|
+
modelId: model.slice(separator + 1),
|
|
2838
|
+
}
|
|
2839
|
+
: { kind: "bare", modelId: model };
|
|
2840
|
+
const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]);
|
|
2841
|
+
try {
|
|
2842
|
+
if (target.kind === "canonical") {
|
|
2843
|
+
await this.restorePersistedModelFromProvider(client, sourceId, generation, target.provider, target, boundedSignal);
|
|
2844
|
+
return;
|
|
2845
|
+
}
|
|
2846
|
+
// Bare ids have no provider address. Keep the cheap global search, but
|
|
2847
|
+
// accept only an exact id; if its 100-row fuzzy cap hides the target,
|
|
2848
|
+
// traverse the bounded provider window advertised in `/v1/info`.
|
|
2849
|
+
const search = await client.models({
|
|
2850
|
+
q: target.modelId,
|
|
2851
|
+
limit: MODEL_CATALOG_RESTORE_PAGE_SIZE,
|
|
2852
|
+
signal: boundedSignal,
|
|
2853
|
+
});
|
|
2854
|
+
if (this.admitModelPage(sourceId, generation, search, target))
|
|
2855
|
+
return;
|
|
2856
|
+
for (const provider of new Set((providers ?? []).map((entry) => entry.id))) {
|
|
2857
|
+
if (await this.restorePersistedModelFromProvider(client, sourceId, generation, provider, target, boundedSignal))
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
catch (error) {
|
|
2862
|
+
this.options.logger?.debug?.("Persisted web model default could not be revalidated.", {
|
|
2863
|
+
sourceId,
|
|
2864
|
+
error: errorMessage(error),
|
|
2865
|
+
});
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
async restorePersistedModelFromProvider(client, sourceId, generation, provider, target, signal) {
|
|
2869
|
+
let cursor;
|
|
2870
|
+
const seenCursors = new Set();
|
|
2871
|
+
for (let pageIndex = 0; pageIndex < MODEL_CATALOG_RESTORE_PAGE_LIMIT; pageIndex += 1) {
|
|
2872
|
+
if (this.modelCatalogCache.get(sourceId)?.generation !== generation)
|
|
2873
|
+
return false;
|
|
2874
|
+
const page = await client.models({
|
|
2875
|
+
provider,
|
|
2876
|
+
...(cursor === undefined ? {} : { cursor }),
|
|
2877
|
+
limit: MODEL_CATALOG_RESTORE_PAGE_SIZE,
|
|
2878
|
+
signal,
|
|
2879
|
+
});
|
|
2880
|
+
if (this.modelCatalogCache.get(sourceId)?.generation !== generation)
|
|
2881
|
+
return false;
|
|
2882
|
+
if (this.admitModelPage(sourceId, generation, page, target))
|
|
2883
|
+
return true;
|
|
2884
|
+
const nextCursor = page.truncated ? page.nextCursor : undefined;
|
|
2885
|
+
if (nextCursor === undefined || nextCursor.length === 0 || seenCursors.has(nextCursor))
|
|
2886
|
+
return false;
|
|
2887
|
+
seenCursors.add(nextCursor);
|
|
2888
|
+
cursor = nextCursor;
|
|
2889
|
+
}
|
|
2890
|
+
return false;
|
|
2891
|
+
}
|
|
2892
|
+
admitModelPage(sourceId, generation, page, exactTarget) {
|
|
2893
|
+
const refs = page.models.flatMap((model) => {
|
|
2894
|
+
const record = {
|
|
2895
|
+
source: "page",
|
|
2896
|
+
efforts: advertisedEffortLevels(model),
|
|
2897
|
+
advertisement: model,
|
|
2898
|
+
};
|
|
2899
|
+
// The wire carries provider-local ids while every selection surface
|
|
2900
|
+
// speaks the canonical `<provider>:<model>` reference. Admit both, or a
|
|
2901
|
+
// turn is judged against metadata the page did advertise but under a
|
|
2902
|
+
// name nothing ever asks for.
|
|
2903
|
+
const reference = model.provider ? `${model.provider}:${model.id}` : model.id;
|
|
2904
|
+
if (exactTarget?.kind === "canonical"
|
|
2905
|
+
&& (model.provider !== exactTarget.provider || model.id !== exactTarget.modelId))
|
|
2906
|
+
return [];
|
|
2907
|
+
if (exactTarget?.kind === "bare" && model.id !== exactTarget.modelId)
|
|
2908
|
+
return [];
|
|
2909
|
+
const entries = [[model.id, record]];
|
|
2910
|
+
if (reference !== model.id)
|
|
2911
|
+
entries.push([reference, record]);
|
|
2912
|
+
return entries;
|
|
2913
|
+
});
|
|
2914
|
+
return refs.length > 0 && this.admitCatalogRefs(sourceId, generation, refs);
|
|
2915
|
+
}
|
|
1744
2916
|
admitModelRef(entries, ref, record) {
|
|
1745
2917
|
const known = entries.get(ref);
|
|
1746
2918
|
if (known !== undefined) {
|
|
@@ -1760,8 +2932,36 @@ export class WebService {
|
|
|
1760
2932
|
entries.delete(oldest);
|
|
1761
2933
|
}
|
|
1762
2934
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
2935
|
+
/** Resolve dispatch and durable attribution from the same fresh thread snapshot. */
|
|
2936
|
+
resolveTurnSelection(threadId, explicitModel, explicitEffort) {
|
|
2937
|
+
const thread = this.store.getThread(threadId);
|
|
2938
|
+
if (thread === undefined)
|
|
2939
|
+
throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
|
|
2940
|
+
const agent = this.store.getAgent(thread.sourceId);
|
|
2941
|
+
if (agent === undefined) {
|
|
2942
|
+
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
2943
|
+
}
|
|
2944
|
+
const model = explicitModel ?? thread.runModel ?? undefined;
|
|
2945
|
+
const effort = explicitEffort ?? thread.runEffort ?? undefined;
|
|
2946
|
+
this.validateModelAndEffort(thread.sourceId, agent, model, effort);
|
|
2947
|
+
const requestedModel = effectiveModelForAgent(agent, model);
|
|
2948
|
+
const cached = requestedModel === undefined
|
|
2949
|
+
? undefined
|
|
2950
|
+
: this.modelCatalogCache.get(thread.sourceId)?.models.get(requestedModel);
|
|
2951
|
+
const requestedEffort = effort ?? inheritedEffortForModel(agent, requestedModel, cached?.advertisement, agent.defaultEffort);
|
|
2952
|
+
return {
|
|
2953
|
+
thread,
|
|
2954
|
+
agent,
|
|
2955
|
+
...(model === undefined ? {} : { model }),
|
|
2956
|
+
...(effort === undefined ? {} : { effort }),
|
|
2957
|
+
...(requestedModel === undefined ? {} : { requestedModel }),
|
|
2958
|
+
...(requestedEffort === undefined ? {} : { requestedEffort }),
|
|
2959
|
+
};
|
|
2960
|
+
}
|
|
2961
|
+
validateModelAndEffort(sourceId, agent, model, effort, strictModel = false) {
|
|
2962
|
+
if (model !== undefined && !(strictModel
|
|
2963
|
+
? this.modelStrictlyAdmitted(sourceId, agent, model)
|
|
2964
|
+
: this.modelAdmitted(sourceId, agent, model))) {
|
|
1765
2965
|
throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
|
|
1766
2966
|
}
|
|
1767
2967
|
// Both ends resolve a blank selection to the same route -- the browser fell
|
|
@@ -1782,17 +2982,19 @@ export class WebService {
|
|
|
1782
2982
|
}
|
|
1783
2983
|
}
|
|
1784
2984
|
modelAdmitted(sourceId, agent, model) {
|
|
2985
|
+
if (this.modelStrictlyAdmitted(sourceId, agent, model))
|
|
2986
|
+
return true;
|
|
2987
|
+
// Tier 3: syntactic `<provider>:<model>` floor. Existing per-conversation
|
|
2988
|
+
// selections retain this compatibility path; durable web defaults do not.
|
|
2989
|
+
return modelPassesSyntacticFloor(model);
|
|
2990
|
+
}
|
|
2991
|
+
modelStrictlyAdmitted(sourceId, agent, model) {
|
|
1785
2992
|
// Tier 1: the configured-route shortlist — unchanged, always allowed.
|
|
1786
2993
|
if (agent.models === undefined ? model === agent.defaultModel : agent.models.includes(model))
|
|
1787
2994
|
return true;
|
|
1788
2995
|
// Tier 2: a model reached only through the catalog cache.
|
|
1789
2996
|
const cached = this.modelCatalogCache.get(sourceId);
|
|
1790
|
-
|
|
1791
|
-
return true;
|
|
1792
|
-
// Tier 3: syntactic `<provider>:<model>` floor. The web package has no pi-ai
|
|
1793
|
-
// access, so a well-formed ref passes here and the agent itself is the real
|
|
1794
|
-
// gate at turn time.
|
|
1795
|
-
return modelPassesSyntacticFloor(model);
|
|
2997
|
+
return cached !== undefined && cached.models.has(model);
|
|
1796
2998
|
}
|
|
1797
2999
|
authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
|
|
1798
3000
|
const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
|
|
@@ -1804,7 +3006,11 @@ export class WebService {
|
|
|
1804
3006
|
if (access === "expired") {
|
|
1805
3007
|
throw new WebConsoleError("reply_access_expired", "Reply access expired. Refresh this reply part and try again.", 410);
|
|
1806
3008
|
}
|
|
1807
|
-
|
|
3009
|
+
// Answered here rather than at the route, because the SERVICE owns the
|
|
3010
|
+
// clock this expiry was signed against -- a route reading `Date.now()`
|
|
3011
|
+
// against an injected clock reports a body that may already be cold.
|
|
3012
|
+
const remainingMs = Number(expires) * 1_000 - this.currentDate().getTime();
|
|
3013
|
+
return { thread, part, remainingSeconds: Math.max(0, Math.floor(remainingMs / 1_000)) };
|
|
1808
3014
|
}
|
|
1809
3015
|
requireReplyPart(threadId, messageId, partId, type) {
|
|
1810
3016
|
const thread = this.store.getThread(threadId);
|
|
@@ -1825,20 +3031,79 @@ export class WebService {
|
|
|
1825
3031
|
throw new WebConsoleError("reply_part_expired", "The reply part has expired.", 410);
|
|
1826
3032
|
}
|
|
1827
3033
|
}
|
|
1828
|
-
decorateThreadDetail(detail) {
|
|
3034
|
+
decorateThreadDetail(detail, options = {}) {
|
|
1829
3035
|
// Backfill: messages that predate this feature, and any turn whose own
|
|
1830
3036
|
// attempt failed or was interrupted. Idempotent and guarded, so repeated
|
|
1831
3037
|
// reads of the same thread fetch each image at most once.
|
|
1832
3038
|
void this.persistReplyImages(detail.thread.id, detail.messages);
|
|
1833
|
-
return { ...detail, messages: detail.messages
|
|
3039
|
+
return { ...detail, messages: this.shapeMessages(detail.messages, options) };
|
|
3040
|
+
}
|
|
3041
|
+
/**
|
|
3042
|
+
* Fold the host's provenance row into the imported-context card.
|
|
3043
|
+
*
|
|
3044
|
+
* The exact host-owned string is enough to identify this row: no model turn
|
|
3045
|
+
* can own it, and the store writes it only for canonical context imports.
|
|
3046
|
+
* Filtering independently of its neighbour is intentional, because the two
|
|
3047
|
+
* stored rows can straddle a message-page boundary. `?full=1` remains the raw
|
|
3048
|
+
* transcript escape hatch and therefore returns both stored rows unchanged.
|
|
3049
|
+
*/
|
|
3050
|
+
shapeMessages(messages, options = {}) {
|
|
3051
|
+
const visible = options.full === true
|
|
3052
|
+
? messages
|
|
3053
|
+
: messages.filter((message) => !this.isCronReplyProvenanceMessage(message));
|
|
3054
|
+
return visible.map((message) => this.shapeMessage(message, options));
|
|
3055
|
+
}
|
|
3056
|
+
isCronReplyProvenanceMessage(message) {
|
|
3057
|
+
return message.role === "system"
|
|
3058
|
+
&& message.turnId === undefined
|
|
3059
|
+
&& message.parts.length === 1
|
|
3060
|
+
&& message.parts[0]?.type === "text"
|
|
3061
|
+
&& message.parts[0].text === AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE;
|
|
3062
|
+
}
|
|
3063
|
+
/**
|
|
3064
|
+
* The one boundary every browser-facing message crosses: it mints the
|
|
3065
|
+
* short-lived reply capabilities, and it puts the transcript on a diet.
|
|
3066
|
+
*
|
|
3067
|
+
* Shaping lives HERE and never in the store. The store's parts feed the
|
|
3068
|
+
* sidebar preview, the streamed-text split, and the transcript deltas that
|
|
3069
|
+
* follow this change, all of which need the payloads whole and the indexes
|
|
3070
|
+
* exactly as recorded.
|
|
3071
|
+
*/
|
|
3072
|
+
shapeMessage(message, options = {}) {
|
|
3073
|
+
if (options.full !== true && this.isCronReplyProvenanceMessage(message)) {
|
|
3074
|
+
// Individual-message recovery cannot omit its addressed row. An empty
|
|
3075
|
+
// projection keeps the raw provenance out of the UI; list reads remove
|
|
3076
|
+
// the row altogether through `shapeMessages`.
|
|
3077
|
+
return { ...message, parts: [] };
|
|
3078
|
+
}
|
|
3079
|
+
return { ...message, parts: message.parts.map((part) => this.shapePart(message, part, options)) };
|
|
1834
3080
|
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
3081
|
+
/**
|
|
3082
|
+
* One part, shaped exactly as a read of its message would serve it.
|
|
3083
|
+
*
|
|
3084
|
+
* A streamed delta puts individual parts on the wire, and they cross the same
|
|
3085
|
+
* boundary the transcript does. Reading the rules from one place is what keeps
|
|
3086
|
+
* a `set` op and a re-read of the same message from disagreeing about what a
|
|
3087
|
+
* tool result contains.
|
|
3088
|
+
*/
|
|
3089
|
+
shapePart(message, part, options) {
|
|
3090
|
+
if (part.type === "attachment" || part.type === "mcp_app")
|
|
1839
3091
|
return this.decorateReplyPart(message, part);
|
|
1840
|
-
|
|
1841
|
-
|
|
3092
|
+
if (options.full === true)
|
|
3093
|
+
return part;
|
|
3094
|
+
if (message.role === "assistant"
|
|
3095
|
+
&& message.turnId === undefined
|
|
3096
|
+
&& message.parts.length === 1
|
|
3097
|
+
&& part.type === "text") {
|
|
3098
|
+
return parseCronReplyContext(part.text) ?? part;
|
|
3099
|
+
}
|
|
3100
|
+
if (part.type === "telemetry")
|
|
3101
|
+
return shapeTelemetryPart(part);
|
|
3102
|
+
if (part.type === "tool-call")
|
|
3103
|
+
return shapeToolCallPart(part);
|
|
3104
|
+
if (part.type === "subagent")
|
|
3105
|
+
return shapeSubagentPart(part);
|
|
3106
|
+
return part;
|
|
1842
3107
|
}
|
|
1843
3108
|
/**
|
|
1844
3109
|
* Keeps the console's own copy of an image the agent published.
|
|
@@ -1953,7 +3218,10 @@ export class WebService {
|
|
|
1953
3218
|
const retentionDeadline = part.expiresAt === undefined
|
|
1954
3219
|
? Number.POSITIVE_INFINITY
|
|
1955
3220
|
: Date.parse(part.expiresAt);
|
|
1956
|
-
|
|
3221
|
+
// From the bucket, not from this instant. A retention deadline that binds is
|
|
3222
|
+
// already a fixed value and is left exactly as it is.
|
|
3223
|
+
const bucketStart = Math.floor(now / REPLY_ACCESS_BUCKET_MS) * REPLY_ACCESS_BUCKET_MS;
|
|
3224
|
+
const expiresAt = Math.min(bucketStart + REPLY_ACCESS_TTL_MS, retentionDeadline);
|
|
1957
3225
|
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
1958
3226
|
return part.type === "attachment" ? { ...part, ...storedUrl } : part;
|
|
1959
3227
|
}
|
|
@@ -2045,10 +3313,15 @@ function abortableDelay(delayMs, signal) {
|
|
|
2045
3313
|
});
|
|
2046
3314
|
}
|
|
2047
3315
|
function isFuturePendingAsk(snapshot, now) {
|
|
3316
|
+
if (snapshot.status !== "pending")
|
|
3317
|
+
return false;
|
|
3318
|
+
if (snapshot.expiresAt === null)
|
|
3319
|
+
return true;
|
|
2048
3320
|
const expiresAt = new Date(snapshot.expiresAt).getTime();
|
|
2049
|
-
return
|
|
3321
|
+
return Number.isFinite(expiresAt) && expiresAt > now.getTime();
|
|
2050
3322
|
}
|
|
2051
3323
|
const STREAM_FLUSH_INTERVAL_MS = 50;
|
|
3324
|
+
const ASK_PUSH_DELIVERY_TTL_MS = 24 * 60 * 60 * 1_000;
|
|
2052
3325
|
class WebTurnCancellation extends Error {
|
|
2053
3326
|
kind;
|
|
2054
3327
|
constructor(kind, message) {
|
|
@@ -2226,9 +3499,25 @@ function offlineSummary(agent, generation) {
|
|
|
2226
3499
|
pinned: false,
|
|
2227
3500
|
health: agent.source.health,
|
|
2228
3501
|
supportsAttachments: false,
|
|
3502
|
+
runSettings: configRunSettings(),
|
|
2229
3503
|
updatedAt: agent.source.updatedAt,
|
|
2230
3504
|
};
|
|
2231
3505
|
}
|
|
3506
|
+
function configRunSettings(model, effort) {
|
|
3507
|
+
return {
|
|
3508
|
+
config: {
|
|
3509
|
+
...(model === undefined ? {} : { model }),
|
|
3510
|
+
...(effort === undefined ? {} : { effort }),
|
|
3511
|
+
},
|
|
3512
|
+
override: null,
|
|
3513
|
+
effective: {
|
|
3514
|
+
...(model === undefined ? {} : { model }),
|
|
3515
|
+
modelSource: "config",
|
|
3516
|
+
...(effort === undefined ? {} : { effort }),
|
|
3517
|
+
effortSource: "config",
|
|
3518
|
+
},
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
2232
3521
|
function collectEfforts(info) {
|
|
2233
3522
|
// Older operator schemas do not advertise per-model metadata. Match the TUI
|
|
2234
3523
|
// picker in that case: cloud/unknown models use the canonical global effort
|