@mono-agent/web 0.20.10 → 0.20.14
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 +19 -8
- package/dist/contracts.d.ts +82 -2
- package/dist/contracts.d.ts.map +1 -1
- package/dist/contracts.js.map +1 -1
- package/dist/effort-ladder.d.ts +97 -0
- package/dist/effort-ladder.d.ts.map +1 -0
- package/dist/effort-ladder.js +114 -0
- package/dist/effort-ladder.js.map +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/notification-client.d.ts +11 -2
- package/dist/notification-client.d.ts.map +1 -1
- package/dist/notification-client.js +1 -1
- package/dist/notification-client.js.map +1 -1
- package/dist/notification-ingress.d.ts +8 -1
- package/dist/notification-ingress.d.ts.map +1 -1
- package/dist/notification-ingress.js +28 -6
- package/dist/notification-ingress.js.map +1 -1
- package/dist/operator-client.d.ts +10 -1
- package/dist/operator-client.d.ts.map +1 -1
- package/dist/operator-client.js +110 -2
- package/dist/operator-client.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +52 -3
- package/dist/server.js.map +1 -1
- package/dist/service.d.ts +117 -12
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +661 -69
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +124 -6
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +543 -34
- package/dist/store.js.map +1 -1
- package/package.json +5 -7
- package/webapp/dist/assets/{assistant-ui-CxqQJmH9.js → assistant-ui-BzN2E6n6.js} +1 -1
- package/webapp/dist/assets/index-C4a2Dv1W.js +155 -0
- package/webapp/dist/assets/index-mhMBLGB0.css +1 -0
- package/webapp/dist/assets/{markdown-8pwMczg6.js → markdown-Vq23xgh7.js} +1 -1
- package/webapp/dist/index.html +4 -4
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/assets/index-DG1ELH22.css +0 -1
- package/webapp/dist/assets/index-j6VTEsJ5.js +0 -155
package/dist/service.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
3
|
-
import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
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";
|
|
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 { advertisedEffortLevels, effectiveModelForAgent, effortLevelsForModel } from "./effort-ladder.js";
|
|
8
9
|
import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
|
|
9
10
|
import { OperatorClient } from "./operator-client.js";
|
|
10
11
|
import { generateWebPushIdentity, normalizeWebPushEndpoint, resolveWebPushSubject, validateWebPushEndpoint, validateWebPushKeys, WebPushDispatcher, WEB_PUSH_SERVICE_WORKER_VERSION, } from "./push.js";
|
|
@@ -14,7 +15,15 @@ const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
|
|
|
14
15
|
const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
|
|
15
16
|
const INFO_TIMEOUT_MS = 2_500;
|
|
16
17
|
const ASK_DISCOVERY_TIMEOUT_MS = 120_000;
|
|
18
|
+
/** Bounded per-agent catalog-admitted model refs; beyond it, oldest go first. */
|
|
19
|
+
const MODEL_CATALOG_CACHE_CAP = 2_048;
|
|
17
20
|
const REPLY_ACCESS_TTL_MS = 10 * 60 * 1_000;
|
|
21
|
+
/**
|
|
22
|
+
* Raster types the console keeps its own copy of. `image/svg+xml` is absent on
|
|
23
|
+
* purpose: it is active content, and both the inline gate in the browser and
|
|
24
|
+
* `setReplyDownloadHeaders` already refuse to treat it as an image.
|
|
25
|
+
*/
|
|
26
|
+
const REPLY_IMAGE_MEDIA_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
18
27
|
function formatQuotedTurn(quote, text) {
|
|
19
28
|
const blockquote = quote
|
|
20
29
|
.trim()
|
|
@@ -23,6 +32,31 @@ function formatQuotedTurn(quote, text) {
|
|
|
23
32
|
.join("\n");
|
|
24
33
|
return `Quoted context:\n${blockquote}\n\n${text}`;
|
|
25
34
|
}
|
|
35
|
+
function assertMonitorWakeAddress(input) {
|
|
36
|
+
const originConversation = input.monitor.origin.conversationId.split("#", 1)[0];
|
|
37
|
+
const expectedDeliveryKey = `monitor:${input.monitor.monitorId}:${String(input.monitor.counters.seq)}`;
|
|
38
|
+
if (input.monitor.origin.channel !== "web"
|
|
39
|
+
|| originConversation !== `web:${input.threadId}`
|
|
40
|
+
|| input.deliveryKey !== expectedDeliveryKey) {
|
|
41
|
+
throw new WebConsoleError("invalid_notification", "The Monitor wake origin or delivery key does not match its web destination.", 409);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function monitorWakePayloadSha256(monitor, wakePrompt) {
|
|
45
|
+
return createHash("sha256")
|
|
46
|
+
.update(canonicalJson(monitor))
|
|
47
|
+
.update("\0")
|
|
48
|
+
.update(wakePrompt)
|
|
49
|
+
.digest("hex");
|
|
50
|
+
}
|
|
51
|
+
function canonicalJson(value) {
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
|
|
54
|
+
if (typeof value === "object" && value !== null) {
|
|
55
|
+
const record = value;
|
|
56
|
+
return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
|
57
|
+
}
|
|
58
|
+
return JSON.stringify(value) ?? "null";
|
|
59
|
+
}
|
|
26
60
|
export class WebService {
|
|
27
61
|
store;
|
|
28
62
|
options;
|
|
@@ -33,9 +67,10 @@ export class WebService {
|
|
|
33
67
|
drainingLiveInputThreads = new Set();
|
|
34
68
|
activeUploads = new Map();
|
|
35
69
|
activeNotifications = new Map();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
/** One serialization lane shared by queued user input and every host wake kind. */
|
|
71
|
+
hostWakeTails = new Map();
|
|
72
|
+
hostWakeReservations = new Map();
|
|
73
|
+
activeHostWakes = new Map();
|
|
39
74
|
allowlist = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST);
|
|
40
75
|
attachmentTurnBudget;
|
|
41
76
|
pushIdentity;
|
|
@@ -44,6 +79,15 @@ export class WebService {
|
|
|
44
79
|
replyAccessKey;
|
|
45
80
|
askWatches = new Map();
|
|
46
81
|
connections = new Map();
|
|
82
|
+
/** Bounded catalog-admitted model refs per agent, seeded from `modelOptions`
|
|
83
|
+
* and appended to by every proxied `/v1/models` page. Admission is `has`,
|
|
84
|
+
* metadata is `get`. Map preserves insertion order, so evicting the oldest
|
|
85
|
+
* entry is deleting the head. Scoped to the agent GENERATION that filled it
|
|
86
|
+
* (see `reconcileModelCatalogCache`), because a source id outlives the
|
|
87
|
+
* process behind it. */
|
|
88
|
+
modelCatalogCache = new Map();
|
|
89
|
+
/** Parts whose durable copy is being fetched, so concurrent reads fetch once. */
|
|
90
|
+
persistingReplyImages = new Set();
|
|
47
91
|
discoveryTimer;
|
|
48
92
|
purgeTimer;
|
|
49
93
|
purgePromise;
|
|
@@ -309,6 +353,23 @@ export class WebService {
|
|
|
309
353
|
return result;
|
|
310
354
|
}
|
|
311
355
|
patchThread(id, patch) {
|
|
356
|
+
if (patch.ifRunConfigUnset === true) {
|
|
357
|
+
// Compare-and-set for the console's one-time adoption of a browser-local
|
|
358
|
+
// override. Whoever set an override first keeps it; the loser adopts what
|
|
359
|
+
// it reads back, and no event is emitted because nothing changed.
|
|
360
|
+
//
|
|
361
|
+
// The check and the write are one `BEGIN IMMEDIATE` inside the store, not
|
|
362
|
+
// two calls from here: this request handler is synchronous, so no other
|
|
363
|
+
// HTTP request can interleave, but the process lease is held on a
|
|
364
|
+
// separate database file and does not stop a second connection to the
|
|
365
|
+
// state DB from writing between a bare read and a bare write.
|
|
366
|
+
const result = this.store.patchThreadIfRunConfigUnset(id, patch);
|
|
367
|
+
if (!result.applied)
|
|
368
|
+
return result.thread;
|
|
369
|
+
this.emit("thread.changed", result.thread.id, { thread: result.thread });
|
|
370
|
+
this.emit("threads.changed", result.thread.id);
|
|
371
|
+
return result.thread;
|
|
372
|
+
}
|
|
312
373
|
const thread = this.store.patchThread(id, patch);
|
|
313
374
|
this.emit("thread.changed", thread.id, { thread });
|
|
314
375
|
this.emit("threads.changed", thread.id);
|
|
@@ -333,7 +394,7 @@ export class WebService {
|
|
|
333
394
|
}
|
|
334
395
|
patchAgent(sourceId, patch) {
|
|
335
396
|
const agent = this.store.setAgentPinned(sourceId, patch.pinned);
|
|
336
|
-
this.emit("agents.changed"
|
|
397
|
+
this.emit("agents.changed");
|
|
337
398
|
return agent;
|
|
338
399
|
}
|
|
339
400
|
agentSkills(sourceId) {
|
|
@@ -408,6 +469,47 @@ export class WebService {
|
|
|
408
469
|
}
|
|
409
470
|
return this.decorateMessage(message);
|
|
410
471
|
}
|
|
472
|
+
async agentModels(sourceId, input) {
|
|
473
|
+
const agent = this.store.getAgent(sourceId);
|
|
474
|
+
if (agent === undefined)
|
|
475
|
+
throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
|
|
476
|
+
const connection = this.connections.get(sourceId);
|
|
477
|
+
if (connection === undefined)
|
|
478
|
+
throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
|
|
479
|
+
const generation = this.modelCatalogCache.get(sourceId)?.generation;
|
|
480
|
+
const page = await connection.client.models({
|
|
481
|
+
...(input.provider === undefined ? {} : { provider: input.provider }),
|
|
482
|
+
...(input.q === undefined ? {} : { q: input.q }),
|
|
483
|
+
...(input.cursor === undefined ? {} : { cursor: input.cursor }),
|
|
484
|
+
limit: input.limit,
|
|
485
|
+
signal: AbortSignal.timeout(INFO_TIMEOUT_MS),
|
|
486
|
+
});
|
|
487
|
+
// Every proxied page widens the per-agent catalog cache, the only feed
|
|
488
|
+
// (besides `modelOptions` keys) that makes tier-2 model admission possible,
|
|
489
|
+
// and the only place the effort ladder a catalog model advertises is ever
|
|
490
|
+
// seen -- `modelOptions` never describes it, so dropping it here is what
|
|
491
|
+
// left effort validation with nothing to judge against.
|
|
492
|
+
//
|
|
493
|
+
// Admitted under the generation the request was ISSUED under, never under
|
|
494
|
+
// whatever is current when it answers. A discovery refresh can retire this
|
|
495
|
+
// agent's generation inside the await above, and keyed by source id alone
|
|
496
|
+
// the reply -- fetched from a process that is gone -- was written straight
|
|
497
|
+
// into the freshly reconciled map, where `source: "page"` overwrites
|
|
498
|
+
// unconditionally. Generation 1's ladder then judged generation 2's turns.
|
|
499
|
+
this.admitCatalogRefs(sourceId, generation, page.models.flatMap((model) => {
|
|
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
|
+
}));
|
|
511
|
+
return page;
|
|
512
|
+
}
|
|
411
513
|
async cronConfigView(sourceId) {
|
|
412
514
|
const connection = this.requireCronConnection(sourceId, false);
|
|
413
515
|
return await connection.client.cronConfigView(AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
@@ -449,6 +551,29 @@ export class WebService {
|
|
|
449
551
|
}
|
|
450
552
|
if (this.store.getAgent(input.sourceId) === undefined)
|
|
451
553
|
await this.refreshAgents();
|
|
554
|
+
if (this.stopped) {
|
|
555
|
+
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
556
|
+
}
|
|
557
|
+
if (input.triggerKind === "monitor") {
|
|
558
|
+
assertMonitorWakeAddress(input);
|
|
559
|
+
const thread = this.store.getThread(input.threadId);
|
|
560
|
+
if (thread === undefined || thread.sourceId !== input.sourceId) {
|
|
561
|
+
return {
|
|
562
|
+
duplicate: true,
|
|
563
|
+
tombstoned: true,
|
|
564
|
+
delivery: { delivered: false, code: "monitor_origin_mismatch", retryable: false },
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
if (thread.archivedAt !== null || thread.trigger !== undefined) {
|
|
568
|
+
return {
|
|
569
|
+
thread,
|
|
570
|
+
duplicate: false,
|
|
571
|
+
delivery: { delivered: false, code: "monitor_wake_failed", retryable: false },
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
const result = await this.deliverMonitorWake(input);
|
|
575
|
+
return { thread, duplicate: result.duplicate, delivery: result.receipt };
|
|
576
|
+
}
|
|
452
577
|
if (input.triggerKind === "job") {
|
|
453
578
|
const completed = this.store.upsertProcessJobCard({
|
|
454
579
|
sourceId: input.sourceId,
|
|
@@ -468,6 +593,9 @@ export class WebService {
|
|
|
468
593
|
return completed;
|
|
469
594
|
if (this.connections.get(input.sourceId) === undefined)
|
|
470
595
|
await this.refreshAgents();
|
|
596
|
+
if (this.stopped) {
|
|
597
|
+
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
598
|
+
}
|
|
471
599
|
const delivery = await this.deliverProcessJobWake(input);
|
|
472
600
|
return { ...completed, delivery };
|
|
473
601
|
}
|
|
@@ -522,8 +650,15 @@ export class WebService {
|
|
|
522
650
|
if (agent === undefined || connection === undefined || !thread.canSend) {
|
|
523
651
|
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
524
652
|
}
|
|
525
|
-
|
|
526
|
-
|
|
653
|
+
// The per-thread override is server state now, so it governs turns this
|
|
654
|
+
// server starts too -- process-job follow-ups and other assistant-owned
|
|
655
|
+
// wakes omit model/effort and would otherwise silently run on the agent
|
|
656
|
+
// default, ignoring the selection made in that very conversation. An
|
|
657
|
+
// explicit request value still wins.
|
|
658
|
+
const model = input.model ?? thread.runModel ?? undefined;
|
|
659
|
+
const effort = input.effort ?? thread.runEffort ?? undefined;
|
|
660
|
+
this.validateModelAndEffort(thread.sourceId, agent, model, effort);
|
|
661
|
+
const started = this.store.beginTurn({ threadId, text, attachmentIds, ...(input.quote === undefined ? {} : { quote: input.quote }), ...(model === undefined ? {} : { model }), ...(effort === undefined ? {} : { effort }) });
|
|
527
662
|
this.launchTurn(started, connection.client, operatorText);
|
|
528
663
|
this.emit("turn.changed", threadId, { turn: started.thread.runState });
|
|
529
664
|
this.emit("threads.changed", threadId);
|
|
@@ -701,7 +836,7 @@ export class WebService {
|
|
|
701
836
|
const active = [...this.activeTurns.values()];
|
|
702
837
|
const activeLiveInputs = [...this.activeLiveInputs.entries()];
|
|
703
838
|
const activeNotifications = [...this.activeNotifications.values()];
|
|
704
|
-
const
|
|
839
|
+
const activeHostWakes = [...this.activeHostWakes.values()];
|
|
705
840
|
const trackedIds = new Set(active.map((turn) => turn.turnId));
|
|
706
841
|
for (const turnId of this.store.listActiveTurnIds()) {
|
|
707
842
|
if (!trackedIds.has(turnId))
|
|
@@ -717,7 +852,7 @@ export class WebService {
|
|
|
717
852
|
}
|
|
718
853
|
await Promise.allSettled(active.map((turn) => turn.completion));
|
|
719
854
|
await Promise.allSettled(activeLiveInputs.map(([, input]) => input.completion));
|
|
720
|
-
await Promise.allSettled(
|
|
855
|
+
await Promise.allSettled(activeHostWakes);
|
|
721
856
|
await Promise.allSettled(activeNotifications);
|
|
722
857
|
await Promise.allSettled(askWatches.map((watch) => watch.promise));
|
|
723
858
|
await this.pushDispatcher.stopAndDrain(5_000);
|
|
@@ -729,7 +864,7 @@ export class WebService {
|
|
|
729
864
|
this.store.close();
|
|
730
865
|
await this.lease.release();
|
|
731
866
|
}
|
|
732
|
-
async runTurn(started, client, controller, operatorText,
|
|
867
|
+
async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey) {
|
|
733
868
|
const coalescer = new StreamFrameCoalescer(async (frames) => {
|
|
734
869
|
const message = this.store.applyStreamFrames(started.turnId, frames);
|
|
735
870
|
this.emit("message.changed", started.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
|
|
@@ -753,13 +888,13 @@ export class WebService {
|
|
|
753
888
|
threadId: started.thread.id,
|
|
754
889
|
turnId: started.turnId,
|
|
755
890
|
...modelMetadata,
|
|
756
|
-
...(
|
|
891
|
+
...(hostWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
|
|
757
892
|
? { conversationTitle: { schema: 1, writable: true } }
|
|
758
893
|
: {}),
|
|
759
894
|
},
|
|
760
895
|
tui: modelMetadata,
|
|
761
896
|
},
|
|
762
|
-
...(
|
|
897
|
+
...(hostWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey: hostWakeDeliveryKey }),
|
|
763
898
|
onFrame: (frame) => {
|
|
764
899
|
this.observeAskUserFrame(started.thread.id, started.turnId, frame);
|
|
765
900
|
this.observeConversationTitleFrame(started.thread.id, started.turnId, frame);
|
|
@@ -767,11 +902,19 @@ export class WebService {
|
|
|
767
902
|
},
|
|
768
903
|
});
|
|
769
904
|
await coalescer.flush();
|
|
770
|
-
const
|
|
905
|
+
const silentMonitorWake = hostWakeDeliveryKey?.startsWith("monitor:") === true
|
|
906
|
+
&& (response.finalText === undefined || response.finalText.length === 0)
|
|
907
|
+
&& (response.parts === undefined || response.parts.length === 0);
|
|
908
|
+
const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts, { suppressResponsePush: silentMonitorWake });
|
|
771
909
|
this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
|
|
772
910
|
this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
|
|
773
911
|
this.emit("threads.changed", started.thread.id);
|
|
774
|
-
|
|
912
|
+
if (!silentMonitorWake)
|
|
913
|
+
this.announcePushEvent(`turn:${started.turnId}:terminal`);
|
|
914
|
+
// Detached: the turn is already finished and reported, and keeping a copy
|
|
915
|
+
// must neither delay nor fail it. The agent is still connected here, which
|
|
916
|
+
// is when a fetch is most likely to succeed.
|
|
917
|
+
void this.persistReplyImages(started.thread.id, detail.messages);
|
|
775
918
|
}
|
|
776
919
|
catch (error) {
|
|
777
920
|
let failure = error;
|
|
@@ -800,14 +943,14 @@ export class WebService {
|
|
|
800
943
|
coalescer.close();
|
|
801
944
|
}
|
|
802
945
|
}
|
|
803
|
-
launchTurn(started, client, operatorText,
|
|
946
|
+
launchTurn(started, client, operatorText, hostWakeDeliveryKey) {
|
|
804
947
|
const threadId = started.thread.id;
|
|
805
948
|
const controller = new AbortController();
|
|
806
|
-
const completion = this.runTurn(started, client, controller, operatorText,
|
|
949
|
+
const completion = this.runTurn(started, client, controller, operatorText, hostWakeDeliveryKey).finally(() => {
|
|
807
950
|
const active = this.activeTurns.get(threadId);
|
|
808
951
|
if (active?.turnId === started.turnId)
|
|
809
952
|
this.activeTurns.delete(threadId);
|
|
810
|
-
if (!this.stopped && !this.
|
|
953
|
+
if (!this.stopped && !this.hostWakeReservations.has(threadId)) {
|
|
811
954
|
void this.drainQueuedLiveInputs(threadId);
|
|
812
955
|
}
|
|
813
956
|
});
|
|
@@ -853,7 +996,7 @@ export class WebService {
|
|
|
853
996
|
async drainQueuedLiveInputs(threadId) {
|
|
854
997
|
if (this.stopped
|
|
855
998
|
|| this.activeTurns.has(threadId)
|
|
856
|
-
|| this.
|
|
999
|
+
|| this.hostWakeReservations.has(threadId)
|
|
857
1000
|
|| this.drainingLiveInputThreads.has(threadId))
|
|
858
1001
|
return;
|
|
859
1002
|
this.drainingLiveInputThreads.add(threadId);
|
|
@@ -880,8 +1023,8 @@ export class WebService {
|
|
|
880
1023
|
}
|
|
881
1024
|
}
|
|
882
1025
|
async deliverProcessJobWake(input) {
|
|
883
|
-
const activeKey = `${input.sourceId}\0${input.
|
|
884
|
-
const existing = this.
|
|
1026
|
+
const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
|
|
1027
|
+
const existing = this.activeHostWakes.get(activeKey);
|
|
885
1028
|
if (existing !== undefined)
|
|
886
1029
|
return await existing;
|
|
887
1030
|
const reservation = this.store.reserveProcessJobWake({
|
|
@@ -901,8 +1044,8 @@ export class WebService {
|
|
|
901
1044
|
ambiguous: true,
|
|
902
1045
|
};
|
|
903
1046
|
}
|
|
904
|
-
this.
|
|
905
|
-
const previous = this.
|
|
1047
|
+
this.retainHostWakeReservation(input.threadId);
|
|
1048
|
+
const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
|
|
906
1049
|
const delivery = previous.catch(() => undefined).then(async () => {
|
|
907
1050
|
const connection = this.connections.get(input.sourceId);
|
|
908
1051
|
if (connection === undefined) {
|
|
@@ -1011,30 +1154,206 @@ export class WebService {
|
|
|
1011
1154
|
return { delivered: true, disposition: "follow_up" };
|
|
1012
1155
|
});
|
|
1013
1156
|
const tail = delivery.then(() => undefined, () => undefined);
|
|
1014
|
-
this.
|
|
1015
|
-
this.
|
|
1157
|
+
this.hostWakeTails.set(input.threadId, tail);
|
|
1158
|
+
this.activeHostWakes.set(activeKey, delivery);
|
|
1016
1159
|
try {
|
|
1017
1160
|
return await delivery;
|
|
1018
1161
|
}
|
|
1019
1162
|
finally {
|
|
1020
|
-
if (this.
|
|
1021
|
-
this.
|
|
1163
|
+
if (this.hostWakeTails.get(input.threadId) === tail) {
|
|
1164
|
+
this.hostWakeTails.delete(input.threadId);
|
|
1165
|
+
}
|
|
1166
|
+
if (this.activeHostWakes.get(activeKey) === delivery) {
|
|
1167
|
+
this.activeHostWakes.delete(activeKey);
|
|
1168
|
+
}
|
|
1169
|
+
this.releaseHostWakeReservation(input.threadId);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
async deliverMonitorWake(input) {
|
|
1173
|
+
const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
|
|
1174
|
+
const reservation = this.store.reserveMonitorWake({
|
|
1175
|
+
sourceId: input.sourceId,
|
|
1176
|
+
threadId: input.threadId,
|
|
1177
|
+
monitorId: input.monitor.monitorId,
|
|
1178
|
+
deliveryKey: input.deliveryKey,
|
|
1179
|
+
payloadSha256: monitorWakePayloadSha256(input.monitor, input.wakePrompt),
|
|
1180
|
+
monitor: input.monitor,
|
|
1181
|
+
});
|
|
1182
|
+
if (reservation.kind === "completed") {
|
|
1183
|
+
return {
|
|
1184
|
+
receipt: { delivered: true, disposition: reservation.disposition },
|
|
1185
|
+
duplicate: true,
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
if (reservation.kind === "uncertain") {
|
|
1189
|
+
const existing = this.activeHostWakes.get(activeKey);
|
|
1190
|
+
if (existing !== undefined)
|
|
1191
|
+
return { receipt: await existing, duplicate: true };
|
|
1192
|
+
return {
|
|
1193
|
+
receipt: {
|
|
1194
|
+
delivered: false,
|
|
1195
|
+
code: "monitor_wake_ambiguous",
|
|
1196
|
+
retryable: false,
|
|
1197
|
+
ambiguous: true,
|
|
1198
|
+
},
|
|
1199
|
+
duplicate: true,
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
this.retainHostWakeReservation(input.threadId);
|
|
1203
|
+
const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
|
|
1204
|
+
const delivery = previous.catch(() => undefined).then(async () => {
|
|
1205
|
+
const abandon = () => this.store.abandonMonitorWake({
|
|
1206
|
+
sourceId: input.sourceId,
|
|
1207
|
+
monitorId: input.monitor.monitorId,
|
|
1208
|
+
deliveryKey: input.deliveryKey,
|
|
1209
|
+
});
|
|
1210
|
+
let connection = this.connections.get(input.sourceId);
|
|
1211
|
+
if (connection === undefined) {
|
|
1212
|
+
try {
|
|
1213
|
+
await this.refreshAgents();
|
|
1214
|
+
}
|
|
1215
|
+
catch (error) {
|
|
1216
|
+
abandon();
|
|
1217
|
+
this.options.logger?.debug?.("Web Monitor destination refresh failed before delivery.", {
|
|
1218
|
+
threadId: input.threadId,
|
|
1219
|
+
monitorId: input.monitor.monitorId,
|
|
1220
|
+
error: errorMessage(error),
|
|
1221
|
+
});
|
|
1222
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
1223
|
+
}
|
|
1224
|
+
connection = this.connections.get(input.sourceId);
|
|
1225
|
+
}
|
|
1226
|
+
if (this.stopped || connection === undefined) {
|
|
1227
|
+
abandon();
|
|
1228
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
1229
|
+
}
|
|
1230
|
+
const destination = this.store.getThread(input.threadId);
|
|
1231
|
+
if (destination === undefined
|
|
1232
|
+
|| destination.sourceId !== input.sourceId
|
|
1233
|
+
|| destination.archivedAt !== null
|
|
1234
|
+
|| destination.trigger !== undefined) {
|
|
1235
|
+
abandon();
|
|
1236
|
+
return { delivered: false, code: "monitor_origin_mismatch", retryable: false };
|
|
1237
|
+
}
|
|
1238
|
+
const active = this.activeTurns.get(input.threadId);
|
|
1239
|
+
if (active !== undefined
|
|
1240
|
+
&& connection.info.supportsLiveInput
|
|
1241
|
+
&& input.wakePrompt.length <= AGENT_LIVE_INPUT_MAX_CHARACTERS) {
|
|
1242
|
+
try {
|
|
1243
|
+
const settlement = await active.client.liveInput({
|
|
1244
|
+
conversationId: `web:${input.threadId}`,
|
|
1245
|
+
id: input.deliveryKey,
|
|
1246
|
+
text: input.wakePrompt,
|
|
1247
|
+
receivedAt: new Date().toISOString(),
|
|
1248
|
+
deliveryKey: input.deliveryKey,
|
|
1249
|
+
signal: AbortSignal.timeout(10 * 60 * 1_000),
|
|
1250
|
+
});
|
|
1251
|
+
if (settlement.status === "applied") {
|
|
1252
|
+
const message = this.store.completeMonitorWake({
|
|
1253
|
+
sourceId: input.sourceId,
|
|
1254
|
+
monitorId: input.monitor.monitorId,
|
|
1255
|
+
deliveryKey: input.deliveryKey,
|
|
1256
|
+
disposition: "steered",
|
|
1257
|
+
turnId: active.turnId,
|
|
1258
|
+
});
|
|
1259
|
+
if (message !== undefined) {
|
|
1260
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
1261
|
+
}
|
|
1262
|
+
return { delivered: true, disposition: "steered" };
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
catch (error) {
|
|
1266
|
+
this.options.logger?.warn?.("Web Monitor steering outcome is unknown; automatic fallback is suppressed.", {
|
|
1267
|
+
threadId: input.threadId,
|
|
1268
|
+
monitorId: input.monitor.monitorId,
|
|
1269
|
+
error: errorMessage(error),
|
|
1270
|
+
});
|
|
1271
|
+
return {
|
|
1272
|
+
delivered: false,
|
|
1273
|
+
code: "monitor_wake_ambiguous",
|
|
1274
|
+
retryable: false,
|
|
1275
|
+
ambiguous: true,
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
if (active !== undefined)
|
|
1280
|
+
await active.completion;
|
|
1281
|
+
if (this.stopped) {
|
|
1282
|
+
abandon();
|
|
1283
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
1284
|
+
}
|
|
1285
|
+
const refreshedConnection = this.connections.get(input.sourceId);
|
|
1286
|
+
if (refreshedConnection === undefined) {
|
|
1287
|
+
abandon();
|
|
1288
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
1289
|
+
}
|
|
1290
|
+
let started;
|
|
1291
|
+
try {
|
|
1292
|
+
started = this.store.beginAssistantTurn({
|
|
1293
|
+
threadId: input.threadId,
|
|
1294
|
+
prompt: input.wakePrompt,
|
|
1295
|
+
storedPrompt: "[Monitor wake]",
|
|
1296
|
+
});
|
|
1297
|
+
}
|
|
1298
|
+
catch (error) {
|
|
1299
|
+
abandon();
|
|
1300
|
+
return {
|
|
1301
|
+
delivered: false,
|
|
1302
|
+
code: errorCode(error) ?? "monitor_wake_failed",
|
|
1303
|
+
retryable: false,
|
|
1304
|
+
};
|
|
1305
|
+
}
|
|
1306
|
+
const completion = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
|
|
1307
|
+
this.emit("message.changed", input.threadId, {
|
|
1308
|
+
messageId: started.assistantMessageId,
|
|
1309
|
+
updatedAt: started.thread.updatedAt,
|
|
1310
|
+
});
|
|
1311
|
+
this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
|
|
1312
|
+
this.emit("threads.changed", input.threadId);
|
|
1313
|
+
await completion;
|
|
1314
|
+
if (this.store.turnStatus(started.turnId) !== "complete") {
|
|
1315
|
+
return {
|
|
1316
|
+
delivered: false,
|
|
1317
|
+
code: "monitor_wake_failed",
|
|
1318
|
+
retryable: false,
|
|
1319
|
+
ambiguous: true,
|
|
1320
|
+
};
|
|
1022
1321
|
}
|
|
1023
|
-
|
|
1024
|
-
|
|
1322
|
+
const message = this.store.completeMonitorWake({
|
|
1323
|
+
sourceId: input.sourceId,
|
|
1324
|
+
monitorId: input.monitor.monitorId,
|
|
1325
|
+
deliveryKey: input.deliveryKey,
|
|
1326
|
+
disposition: "follow_up",
|
|
1327
|
+
turnId: started.turnId,
|
|
1328
|
+
});
|
|
1329
|
+
if (message !== undefined) {
|
|
1330
|
+
this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
|
|
1025
1331
|
}
|
|
1026
|
-
|
|
1332
|
+
return { delivered: true, disposition: "follow_up" };
|
|
1333
|
+
});
|
|
1334
|
+
const tail = delivery.then(() => undefined, () => undefined);
|
|
1335
|
+
this.hostWakeTails.set(input.threadId, tail);
|
|
1336
|
+
this.activeHostWakes.set(activeKey, delivery);
|
|
1337
|
+
try {
|
|
1338
|
+
return { receipt: await delivery, duplicate: false };
|
|
1339
|
+
}
|
|
1340
|
+
finally {
|
|
1341
|
+
if (this.hostWakeTails.get(input.threadId) === tail)
|
|
1342
|
+
this.hostWakeTails.delete(input.threadId);
|
|
1343
|
+
if (this.activeHostWakes.get(activeKey) === delivery)
|
|
1344
|
+
this.activeHostWakes.delete(activeKey);
|
|
1345
|
+
this.releaseHostWakeReservation(input.threadId);
|
|
1027
1346
|
}
|
|
1028
1347
|
}
|
|
1029
|
-
|
|
1030
|
-
this.
|
|
1348
|
+
retainHostWakeReservation(threadId) {
|
|
1349
|
+
this.hostWakeReservations.set(threadId, (this.hostWakeReservations.get(threadId) ?? 0) + 1);
|
|
1031
1350
|
}
|
|
1032
|
-
|
|
1033
|
-
const remaining = (this.
|
|
1351
|
+
releaseHostWakeReservation(threadId) {
|
|
1352
|
+
const remaining = (this.hostWakeReservations.get(threadId) ?? 1) - 1;
|
|
1034
1353
|
if (remaining > 0)
|
|
1035
|
-
this.
|
|
1354
|
+
this.hostWakeReservations.set(threadId, remaining);
|
|
1036
1355
|
else
|
|
1037
|
-
this.
|
|
1356
|
+
this.hostWakeReservations.delete(threadId);
|
|
1038
1357
|
if (!this.stopped)
|
|
1039
1358
|
void this.drainQueuedLiveInputs(threadId);
|
|
1040
1359
|
}
|
|
@@ -1092,13 +1411,22 @@ export class WebService {
|
|
|
1092
1411
|
const changed = this.store.replaceAgents([]);
|
|
1093
1412
|
this.connections = new Map();
|
|
1094
1413
|
if (changed)
|
|
1095
|
-
this.emit("agents.changed"
|
|
1414
|
+
this.emit("agents.changed");
|
|
1096
1415
|
return;
|
|
1097
1416
|
}
|
|
1098
1417
|
const nextConnections = new Map();
|
|
1418
|
+
// What the cache is allowed to survive: the same process, at the same
|
|
1419
|
+
// endpoint, since the same start. Anything else is a new generation whose
|
|
1420
|
+
// catalog the previous one cannot speak for.
|
|
1421
|
+
const generations = new Map(discovered.map((agent) => [
|
|
1422
|
+
agent.source.sourceId,
|
|
1423
|
+
agentGeneration(agent),
|
|
1424
|
+
]));
|
|
1425
|
+
this.reconcileModelCatalogCache(generations);
|
|
1099
1426
|
const summaries = await Promise.all(discovered.map(async (agent) => {
|
|
1427
|
+
const generation = generations.get(agent.source.sourceId);
|
|
1100
1428
|
if (agent.baseUrl === undefined)
|
|
1101
|
-
return offlineSummary(agent);
|
|
1429
|
+
return offlineSummary(agent, generation);
|
|
1102
1430
|
const client = new OperatorClient({
|
|
1103
1431
|
baseUrl: agent.baseUrl,
|
|
1104
1432
|
...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
|
|
@@ -1108,9 +1436,11 @@ export class WebService {
|
|
|
1108
1436
|
try {
|
|
1109
1437
|
const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
|
|
1110
1438
|
nextConnections.set(agent.source.sourceId, { client, info });
|
|
1439
|
+
this.seedModelCatalogFromOptions(agent.source.sourceId, generation, info.modelOptions);
|
|
1111
1440
|
const efforts = collectEfforts(info);
|
|
1112
1441
|
return {
|
|
1113
1442
|
sourceId: agent.source.sourceId,
|
|
1443
|
+
generation,
|
|
1114
1444
|
label: info.label ?? agent.source.label,
|
|
1115
1445
|
status: agent.source.health === "running" ? "online" : "degraded",
|
|
1116
1446
|
pinned: false,
|
|
@@ -1121,6 +1451,7 @@ export class WebService {
|
|
|
1121
1451
|
...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
|
|
1122
1452
|
...(efforts.length === 0 ? {} : { efforts }),
|
|
1123
1453
|
...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
|
|
1454
|
+
...(info.providers === undefined ? {} : { providers: info.providers }),
|
|
1124
1455
|
...(info.cron === undefined ? {} : { cron: info.cron }),
|
|
1125
1456
|
...(info.supportsAskById ? { supportsAskById: true } : {}),
|
|
1126
1457
|
updatedAt: agent.source.updatedAt,
|
|
@@ -1131,7 +1462,7 @@ export class WebService {
|
|
|
1131
1462
|
sourceId: agent.source.sourceId,
|
|
1132
1463
|
error: errorMessage(error),
|
|
1133
1464
|
});
|
|
1134
|
-
return offlineSummary(agent);
|
|
1465
|
+
return offlineSummary(agent, generation);
|
|
1135
1466
|
}
|
|
1136
1467
|
}));
|
|
1137
1468
|
this.connections = nextConnections;
|
|
@@ -1154,7 +1485,7 @@ export class WebService {
|
|
|
1154
1485
|
}
|
|
1155
1486
|
}));
|
|
1156
1487
|
if (agentsChanged)
|
|
1157
|
-
this.emit("agents.changed"
|
|
1488
|
+
this.emit("agents.changed");
|
|
1158
1489
|
for (const sourceId of cronChangedSources)
|
|
1159
1490
|
this.emit("cron.changed", undefined, { sourceId });
|
|
1160
1491
|
if (cronChangedSources.size > 0)
|
|
@@ -1353,6 +1684,116 @@ export class WebService {
|
|
|
1353
1684
|
}
|
|
1354
1685
|
return connection;
|
|
1355
1686
|
}
|
|
1687
|
+
/**
|
|
1688
|
+
* Tier-2 admission: the catalog cache, seeded from `modelOptions` keys and
|
|
1689
|
+
* appended to by every proxied `/v1/models` page.
|
|
1690
|
+
*
|
|
1691
|
+
* Both feeds cross an await between deciding what to admit and admitting it,
|
|
1692
|
+
* so both name the generation they read. A write whose generation is no
|
|
1693
|
+
* longer the one on file is dropped outright rather than filed under the
|
|
1694
|
+
* successor: it describes a process that has been replaced, and admitting it
|
|
1695
|
+
* is exactly the cross-generation contamination `reconcileModelCatalogCache`
|
|
1696
|
+
* exists to prevent. Nothing is created here either -- an entry exists for
|
|
1697
|
+
* every discovered source from the moment a refresh reconciles it, and a
|
|
1698
|
+
* source with no entry is one discovery has dropped.
|
|
1699
|
+
*
|
|
1700
|
+
* @returns whether the refs were admitted.
|
|
1701
|
+
*/
|
|
1702
|
+
admitCatalogRefs(sourceId, generation, refs) {
|
|
1703
|
+
if (generation === undefined)
|
|
1704
|
+
return false;
|
|
1705
|
+
const entry = this.modelCatalogCache.get(sourceId);
|
|
1706
|
+
if (entry === undefined || entry.generation !== generation)
|
|
1707
|
+
return false;
|
|
1708
|
+
for (const [ref, record] of refs)
|
|
1709
|
+
this.admitModelRef(entry.models, ref, record);
|
|
1710
|
+
return true;
|
|
1711
|
+
}
|
|
1712
|
+
/**
|
|
1713
|
+
* Bind the cache to the agent process that filled it, and to nothing else.
|
|
1714
|
+
* A source id outlives the process behind it: reconfigure an agent and
|
|
1715
|
+
* restart it and the next generation advertises a different catalog under
|
|
1716
|
+
* the same id. Keyed by id alone, generation 1's ladder judged generation
|
|
1717
|
+
* 2's turns and rejected grades the running agent accepts, with no way to
|
|
1718
|
+
* clear it short of restarting the console.
|
|
1719
|
+
*
|
|
1720
|
+
* The single place the cache is scoped, so there is one answer to "whose
|
|
1721
|
+
* catalog is this". Runs before the per-agent probes of a refresh, so a seed
|
|
1722
|
+
* or a proxied page during that refresh files under the current generation.
|
|
1723
|
+
* Sources discovery no longer reports are dropped outright, or a retired
|
|
1724
|
+
* agent's refs would accumulate for the life of the process.
|
|
1725
|
+
*/
|
|
1726
|
+
reconcileModelCatalogCache(generations) {
|
|
1727
|
+
for (const sourceId of [...this.modelCatalogCache.keys()]) {
|
|
1728
|
+
if (!generations.has(sourceId))
|
|
1729
|
+
this.modelCatalogCache.delete(sourceId);
|
|
1730
|
+
}
|
|
1731
|
+
for (const [sourceId, generation] of generations) {
|
|
1732
|
+
if (this.modelCatalogCache.get(sourceId)?.generation === generation)
|
|
1733
|
+
continue;
|
|
1734
|
+
this.modelCatalogCache.set(sourceId, { generation, models: new Map() });
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
seedModelCatalogFromOptions(sourceId, generation, modelOptions) {
|
|
1738
|
+
if (modelOptions === undefined)
|
|
1739
|
+
return;
|
|
1740
|
+
// `modelOptions` stays the authority for the refs it names, so the seed
|
|
1741
|
+
// only records admission; recording a ladder here would shadow it.
|
|
1742
|
+
this.admitCatalogRefs(sourceId, generation, Object.keys(modelOptions).map((key) => [key, { source: "shortlist", efforts: undefined }]));
|
|
1743
|
+
}
|
|
1744
|
+
admitModelRef(entries, ref, record) {
|
|
1745
|
+
const known = entries.get(ref);
|
|
1746
|
+
if (known !== undefined) {
|
|
1747
|
+
// A page is the live word on what it serves, so its metadata replaces
|
|
1748
|
+
// whatever is held -- including replacing a ladder with silence, which
|
|
1749
|
+
// is how a re-fetched catalog heals a model whose grades changed. The
|
|
1750
|
+
// shortlist seed only records admission and must never shadow a page.
|
|
1751
|
+
// Re-setting an existing key leaves its eviction position alone.
|
|
1752
|
+
if (record.source === "page")
|
|
1753
|
+
entries.set(ref, record);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
entries.set(ref, record);
|
|
1757
|
+
if (entries.size > MODEL_CATALOG_CACHE_CAP) {
|
|
1758
|
+
const oldest = entries.keys().next().value;
|
|
1759
|
+
if (oldest !== undefined)
|
|
1760
|
+
entries.delete(oldest);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
validateModelAndEffort(sourceId, agent, model, effort) {
|
|
1764
|
+
if (model !== undefined && !this.modelAdmitted(sourceId, agent, model)) {
|
|
1765
|
+
throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
|
|
1766
|
+
}
|
|
1767
|
+
// Both ends resolve a blank selection to the same route -- the browser fell
|
|
1768
|
+
// back to the first shortlist entry while this stopped at `defaultModel`,
|
|
1769
|
+
// so any `/v1/info` omitting `model` had the picker offering one ladder and
|
|
1770
|
+
// this rejecting from another.
|
|
1771
|
+
const effectiveModel = effectiveModelForAgent(agent, model);
|
|
1772
|
+
// `modelOptions` only ever covers the configured shortlist, so a model
|
|
1773
|
+
// reached through the provider catalog has no entry there. `effort-ladder`
|
|
1774
|
+
// holds the tiering, and the browser runs the exact same function, so the
|
|
1775
|
+
// picker cannot offer a grade this rejects or hide one it accepts.
|
|
1776
|
+
const cached = effectiveModel === undefined
|
|
1777
|
+
? undefined
|
|
1778
|
+
: this.modelCatalogCache.get(sourceId)?.models.get(effectiveModel);
|
|
1779
|
+
const allowedEfforts = effortLevelsForModel(agent, effectiveModel, cached?.efforts);
|
|
1780
|
+
if (effort !== undefined && !allowedEfforts.includes(effort)) {
|
|
1781
|
+
throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1784
|
+
modelAdmitted(sourceId, agent, model) {
|
|
1785
|
+
// Tier 1: the configured-route shortlist — unchanged, always allowed.
|
|
1786
|
+
if (agent.models === undefined ? model === agent.defaultModel : agent.models.includes(model))
|
|
1787
|
+
return true;
|
|
1788
|
+
// Tier 2: a model reached only through the catalog cache.
|
|
1789
|
+
const cached = this.modelCatalogCache.get(sourceId);
|
|
1790
|
+
if (cached !== undefined && cached.models.has(model))
|
|
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);
|
|
1796
|
+
}
|
|
1356
1797
|
authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
|
|
1357
1798
|
const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
|
|
1358
1799
|
if (access === "invalid") {
|
|
@@ -1385,6 +1826,10 @@ export class WebService {
|
|
|
1385
1826
|
}
|
|
1386
1827
|
}
|
|
1387
1828
|
decorateThreadDetail(detail) {
|
|
1829
|
+
// Backfill: messages that predate this feature, and any turn whose own
|
|
1830
|
+
// attempt failed or was interrupted. Idempotent and guarded, so repeated
|
|
1831
|
+
// reads of the same thread fetch each image at most once.
|
|
1832
|
+
void this.persistReplyImages(detail.thread.id, detail.messages);
|
|
1388
1833
|
return { ...detail, messages: detail.messages.map((message) => this.decorateMessage(message)) };
|
|
1389
1834
|
}
|
|
1390
1835
|
decorateMessage(message) {
|
|
@@ -1395,14 +1840,123 @@ export class WebService {
|
|
|
1395
1840
|
});
|
|
1396
1841
|
return { ...message, parts };
|
|
1397
1842
|
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Keeps the console's own copy of an image the agent published.
|
|
1845
|
+
*
|
|
1846
|
+
* Reply artifacts are proxied from the agent and never stored, so without this
|
|
1847
|
+
* a generated image dies at the agent's retention deadline and shows broken
|
|
1848
|
+
* whenever that agent is stopped. Raster types only: `image/svg+xml` is active
|
|
1849
|
+
* content, is refused inline by both the client and `setReplyDownloadHeaders`,
|
|
1850
|
+
* and is deliberately never persisted either.
|
|
1851
|
+
*
|
|
1852
|
+
* Entirely best-effort. Every failure leaves the part on its existing
|
|
1853
|
+
* capability path, because a stored copy is an optimisation and a turn must
|
|
1854
|
+
* never fail over one.
|
|
1855
|
+
*/
|
|
1856
|
+
async persistReplyImages(threadId, messages) {
|
|
1857
|
+
for (const message of messages) {
|
|
1858
|
+
for (const part of message.parts) {
|
|
1859
|
+
if (part.type !== "attachment")
|
|
1860
|
+
continue;
|
|
1861
|
+
if (!REPLY_IMAGE_MEDIA_TYPES.has(part.mediaType.toLowerCase()))
|
|
1862
|
+
continue;
|
|
1863
|
+
if (part.sizeBytes > DEFAULT_AGENT_ATTACHMENT_MAX_BYTES)
|
|
1864
|
+
continue;
|
|
1865
|
+
if (this.store.storedReplyAttachment(message.id, part.id) !== undefined)
|
|
1866
|
+
continue;
|
|
1867
|
+
const key = WebStore.replyAttachmentId(message.id, part.id);
|
|
1868
|
+
if (this.persistingReplyImages.has(key))
|
|
1869
|
+
continue;
|
|
1870
|
+
this.persistingReplyImages.add(key);
|
|
1871
|
+
try {
|
|
1872
|
+
await this.persistReplyImage(threadId, message, part);
|
|
1873
|
+
}
|
|
1874
|
+
catch (error) {
|
|
1875
|
+
this.options.logger?.debug?.("Web reply image was not persisted.", {
|
|
1876
|
+
threadId,
|
|
1877
|
+
messageId: message.id,
|
|
1878
|
+
partId: part.id,
|
|
1879
|
+
error: errorMessage(error),
|
|
1880
|
+
});
|
|
1881
|
+
}
|
|
1882
|
+
finally {
|
|
1883
|
+
this.persistingReplyImages.delete(key);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
async persistReplyImage(threadId, message, part) {
|
|
1889
|
+
const thread = this.store.getThread(threadId);
|
|
1890
|
+
if (thread === undefined)
|
|
1891
|
+
return;
|
|
1892
|
+
const connection = this.connections.get(thread.sourceId);
|
|
1893
|
+
if (connection === undefined || connection.info.replyAttachments?.version !== 1)
|
|
1894
|
+
return;
|
|
1895
|
+
const response = await connection.client.replyArtifact(this.conversationIdForThread(thread.id), {
|
|
1896
|
+
type: "attachment",
|
|
1897
|
+
id: part.id,
|
|
1898
|
+
reference: { scheme: "mono-agent-artifact", id: part.artifactId },
|
|
1899
|
+
name: part.name,
|
|
1900
|
+
mediaType: part.mediaType,
|
|
1901
|
+
sizeBytes: part.sizeBytes,
|
|
1902
|
+
integrityId: part.integrityId,
|
|
1903
|
+
...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
|
|
1904
|
+
});
|
|
1905
|
+
if (!response.ok || response.body === null)
|
|
1906
|
+
return;
|
|
1907
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
1908
|
+
// The same pair the browser checks before handing a download to the user. A
|
|
1909
|
+
// copy that fails either is not written at all, so a corrupt artifact can
|
|
1910
|
+
// never be served from a stable URL that outlives its source.
|
|
1911
|
+
if (bytes.byteLength !== part.sizeBytes)
|
|
1912
|
+
return;
|
|
1913
|
+
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
1914
|
+
if (digest !== part.integrityId.toLowerCase())
|
|
1915
|
+
return;
|
|
1916
|
+
const storageName = `${randomUUID()}.bin`;
|
|
1917
|
+
const destination = this.store.attachmentPath({ storageName });
|
|
1918
|
+
const staging = `${destination}.partial-${randomUUID()}`;
|
|
1919
|
+
await writeFile(staging, bytes, { mode: 0o600 });
|
|
1920
|
+
try {
|
|
1921
|
+
await rename(staging, destination);
|
|
1922
|
+
}
|
|
1923
|
+
catch (error) {
|
|
1924
|
+
await unlink(staging).catch(() => undefined);
|
|
1925
|
+
throw error;
|
|
1926
|
+
}
|
|
1927
|
+
try {
|
|
1928
|
+
this.store.recordReplyAttachment({
|
|
1929
|
+
threadId: thread.id,
|
|
1930
|
+
messageId: message.id,
|
|
1931
|
+
partId: part.id,
|
|
1932
|
+
name: part.name,
|
|
1933
|
+
contentType: part.mediaType,
|
|
1934
|
+
sizeBytes: bytes.byteLength,
|
|
1935
|
+
storageName,
|
|
1936
|
+
});
|
|
1937
|
+
}
|
|
1938
|
+
catch (error) {
|
|
1939
|
+
await unlink(destination).catch(() => undefined);
|
|
1940
|
+
throw error;
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1398
1943
|
decorateReplyPart(message, part) {
|
|
1944
|
+
// A durable copy is resolved before the retention gate below, because
|
|
1945
|
+
// outliving that deadline is the entire reason it was kept.
|
|
1946
|
+
const stored = part.type === "attachment"
|
|
1947
|
+
? this.store.storedReplyAttachment(message.id, part.id)
|
|
1948
|
+
: undefined;
|
|
1949
|
+
const storedUrl = stored === undefined
|
|
1950
|
+
? {}
|
|
1951
|
+
: { storedUrl: `/api/v1/uploads/${encodeURIComponent(stored.id)}/content` };
|
|
1399
1952
|
const now = this.currentDate().getTime();
|
|
1400
1953
|
const retentionDeadline = part.expiresAt === undefined
|
|
1401
1954
|
? Number.POSITIVE_INFINITY
|
|
1402
1955
|
: Date.parse(part.expiresAt);
|
|
1403
1956
|
const expiresAt = Math.min(now + REPLY_ACCESS_TTL_MS, retentionDeadline);
|
|
1404
|
-
if (!Number.isFinite(expiresAt) || expiresAt <= now)
|
|
1405
|
-
return part;
|
|
1957
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
1958
|
+
return part.type === "attachment" ? { ...part, ...storedUrl } : part;
|
|
1959
|
+
}
|
|
1406
1960
|
const expires = String(Math.floor(expiresAt / 1_000));
|
|
1407
1961
|
const token = this.replyAccessToken(message.threadId, message.id, part.type, part.id, expires);
|
|
1408
1962
|
const base = `/api/v1/threads/${encodeURIComponent(message.threadId)}`
|
|
@@ -1411,6 +1965,7 @@ export class WebService {
|
|
|
1411
1965
|
return part.type === "attachment"
|
|
1412
1966
|
? {
|
|
1413
1967
|
...part,
|
|
1968
|
+
...storedUrl,
|
|
1414
1969
|
contentUrl: `${base}/reply-attachments/${encodeURIComponent(part.id)}/content?${query}`,
|
|
1415
1970
|
}
|
|
1416
1971
|
: {
|
|
@@ -1619,9 +2174,53 @@ export class WeightedTurnBudget {
|
|
|
1619
2174
|
}
|
|
1620
2175
|
}
|
|
1621
2176
|
}
|
|
1622
|
-
|
|
2177
|
+
/**
|
|
2178
|
+
* The identity of the agent PROCESS behind a source id. `sourceId` is stable
|
|
2179
|
+
* across restarts by design, so it cannot scope anything the running process
|
|
2180
|
+
* told us: a reconfigured agent restarts at a new endpoint, with a new pid and
|
|
2181
|
+
* a new `startedAt`, and advertises a different catalog under the same id.
|
|
2182
|
+
* Deliberately excludes `updatedAt`, which every heartbeat moves.
|
|
2183
|
+
*
|
|
2184
|
+
* This is what the model catalog cache is scoped to -- and, since the browser
|
|
2185
|
+
* caches the same `/v1/models` pages and had nothing generation-shaped to
|
|
2186
|
+
* watch, what `WebAgentSummary.generation` carries to it.
|
|
2187
|
+
*
|
|
2188
|
+
* Hashed because it now goes on the wire: the raw form names the agent's
|
|
2189
|
+
* operator endpoint and pid, and the console has no reason to hand those to a
|
|
2190
|
+
* page. The token only has to be stable while one process lives and different
|
|
2191
|
+
* once it is replaced, which a digest of those three fields is.
|
|
2192
|
+
*
|
|
2193
|
+
* Length-prefixed rather than `|`-joined. A separator that can occur inside a
|
|
2194
|
+
* field is not a separator: two different accepted tuples whose parts happen to
|
|
2195
|
+
* contain the delimiter flatten to the same string and hash to the same token,
|
|
2196
|
+
* and two distinct processes sharing a generation is precisely the state the
|
|
2197
|
+
* token exists to make impossible. Nothing first-party produces such a tuple
|
|
2198
|
+
* today, which is why this is robustness rather than a live defect --- but a
|
|
2199
|
+
* digest whose only defence is what its inputs happen to look like is one
|
|
2200
|
+
* unrelated change away from being wrong.
|
|
2201
|
+
*
|
|
2202
|
+
* Hashed as UTF-16 code units for the same reason the prefix replaced the
|
|
2203
|
+
* delimiter. UTF-8 has no encoding for an unpaired surrogate, so a lone high
|
|
2204
|
+
* surrogate and a lone low surrogate both became the replacement character and
|
|
2205
|
+
* two different one-character fields -- identically length-prefixed -- hashed
|
|
2206
|
+
* alike. `utf16le` is a lossless transcription of exactly the code units the
|
|
2207
|
+
* length prefix counts, so what is hashed is what was measured.
|
|
2208
|
+
*/
|
|
2209
|
+
export function agentGeneration(agent) {
|
|
2210
|
+
const parts = [
|
|
2211
|
+
agent.baseUrl ?? "",
|
|
2212
|
+
String(agent.source.pid ?? ""),
|
|
2213
|
+
agent.source.startedAt,
|
|
2214
|
+
];
|
|
2215
|
+
return createHash("sha256")
|
|
2216
|
+
.update(parts.map((part) => `${String(part.length)}:${part}`).join(""), "utf16le")
|
|
2217
|
+
.digest("hex")
|
|
2218
|
+
.slice(0, 16);
|
|
2219
|
+
}
|
|
2220
|
+
function offlineSummary(agent, generation) {
|
|
1623
2221
|
return {
|
|
1624
2222
|
sourceId: agent.source.sourceId,
|
|
2223
|
+
generation,
|
|
1625
2224
|
label: agent.source.label,
|
|
1626
2225
|
status: "offline",
|
|
1627
2226
|
pinned: false,
|
|
@@ -1637,32 +2236,25 @@ function collectEfforts(info) {
|
|
|
1637
2236
|
if (info.modelOptions === undefined)
|
|
1638
2237
|
return EFFORT_LEVELS;
|
|
1639
2238
|
const models = info.models ?? (info.model === undefined ? [] : [info.model]);
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
if (model !== undefined
|
|
1644
|
-
&& (agent.models === undefined ? model !== agent.defaultModel : !agent.models.includes(model))) {
|
|
1645
|
-
throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
|
|
1646
|
-
}
|
|
1647
|
-
const effectiveModel = model ?? agent.defaultModel;
|
|
1648
|
-
const option = effectiveModel === undefined ? undefined : agent.modelOptions?.[effectiveModel];
|
|
1649
|
-
const allowedEfforts = agent.modelOptions === undefined
|
|
1650
|
-
? agent.efforts
|
|
1651
|
-
: effortLevelsForOption(option);
|
|
1652
|
-
if (effort !== undefined && (allowedEfforts === undefined || !allowedEfforts.includes(effort))) {
|
|
1653
|
-
throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
|
|
1654
|
-
}
|
|
2239
|
+
// Same rule as every other effort decision, so the union an agent advertises
|
|
2240
|
+
// cannot disagree with what a turn on one of those models may carry.
|
|
2241
|
+
return [...new Set(models.flatMap((model) => effortLevelsForModel(info, model, undefined)))];
|
|
1655
2242
|
}
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
2243
|
+
/**
|
|
2244
|
+
* Tier 3: the syntactic floor. `@mono-agent/web` may not import pi-ai, so this
|
|
2245
|
+
* cannot be authoritative -- the agent is. It exists to reject obvious garbage,
|
|
2246
|
+
* and it must not be looser than the runtime parser, or a reference the console
|
|
2247
|
+
* accepts is silently ignored and the turn runs on the default model instead.
|
|
2248
|
+
*/
|
|
2249
|
+
function modelPassesSyntacticFloor(model) {
|
|
2250
|
+
const separator = model.indexOf(":");
|
|
2251
|
+
if (separator <= 0 || separator >= model.length - 1)
|
|
2252
|
+
return false;
|
|
2253
|
+
const provider = model.slice(0, separator);
|
|
2254
|
+
const rest = model.slice(separator + 1);
|
|
2255
|
+
// Provider ids are lowercase kebab/alphanumeric; the model half may carry
|
|
2256
|
+
// further colons and slashes but must not be blank or padded.
|
|
2257
|
+
return /^[a-z0-9][a-z0-9-]*$/u.test(provider) && rest.trim() === rest && rest.trim().length > 0;
|
|
1666
2258
|
}
|
|
1667
2259
|
function normalizeFilename(value) {
|
|
1668
2260
|
const withoutPath = value.replace(/\\/gu, "/").split("/").at(-1)?.trim() ?? "";
|