@mono-agent/web 0.20.11 → 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 +75 -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 +100 -12
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +531 -65
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +97 -6
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +501 -33
- package/dist/store.js.map +1 -1
- package/package.json +5 -7
- package/webapp/dist/assets/index-C4a2Dv1W.js +155 -0
- package/webapp/dist/assets/index-mhMBLGB0.css +1 -0
- package/webapp/dist/index.html +2 -2
- package/webapp/dist/sw.js +1 -1
- package/webapp/dist/assets/index-BT463dRM.css +0 -1
- package/webapp/dist/assets/index-Co-qDQPq.js +0 -155
package/dist/service.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
2
|
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
-
import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
|
|
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,6 +15,8 @@ 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;
|
|
18
21
|
/**
|
|
19
22
|
* Raster types the console keeps its own copy of. `image/svg+xml` is absent on
|
|
@@ -29,6 +32,31 @@ function formatQuotedTurn(quote, text) {
|
|
|
29
32
|
.join("\n");
|
|
30
33
|
return `Quoted context:\n${blockquote}\n\n${text}`;
|
|
31
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
|
+
}
|
|
32
60
|
export class WebService {
|
|
33
61
|
store;
|
|
34
62
|
options;
|
|
@@ -39,9 +67,10 @@ export class WebService {
|
|
|
39
67
|
drainingLiveInputThreads = new Set();
|
|
40
68
|
activeUploads = new Map();
|
|
41
69
|
activeNotifications = new Map();
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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();
|
|
45
74
|
allowlist = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST);
|
|
46
75
|
attachmentTurnBudget;
|
|
47
76
|
pushIdentity;
|
|
@@ -50,6 +79,13 @@ export class WebService {
|
|
|
50
79
|
replyAccessKey;
|
|
51
80
|
askWatches = new Map();
|
|
52
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();
|
|
53
89
|
/** Parts whose durable copy is being fetched, so concurrent reads fetch once. */
|
|
54
90
|
persistingReplyImages = new Set();
|
|
55
91
|
discoveryTimer;
|
|
@@ -317,6 +353,23 @@ export class WebService {
|
|
|
317
353
|
return result;
|
|
318
354
|
}
|
|
319
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
|
+
}
|
|
320
373
|
const thread = this.store.patchThread(id, patch);
|
|
321
374
|
this.emit("thread.changed", thread.id, { thread });
|
|
322
375
|
this.emit("threads.changed", thread.id);
|
|
@@ -341,7 +394,7 @@ export class WebService {
|
|
|
341
394
|
}
|
|
342
395
|
patchAgent(sourceId, patch) {
|
|
343
396
|
const agent = this.store.setAgentPinned(sourceId, patch.pinned);
|
|
344
|
-
this.emit("agents.changed"
|
|
397
|
+
this.emit("agents.changed");
|
|
345
398
|
return agent;
|
|
346
399
|
}
|
|
347
400
|
agentSkills(sourceId) {
|
|
@@ -416,6 +469,47 @@ export class WebService {
|
|
|
416
469
|
}
|
|
417
470
|
return this.decorateMessage(message);
|
|
418
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
|
+
}
|
|
419
513
|
async cronConfigView(sourceId) {
|
|
420
514
|
const connection = this.requireCronConnection(sourceId, false);
|
|
421
515
|
return await connection.client.cronConfigView(AbortSignal.timeout(INFO_TIMEOUT_MS));
|
|
@@ -457,6 +551,29 @@ export class WebService {
|
|
|
457
551
|
}
|
|
458
552
|
if (this.store.getAgent(input.sourceId) === undefined)
|
|
459
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
|
+
}
|
|
460
577
|
if (input.triggerKind === "job") {
|
|
461
578
|
const completed = this.store.upsertProcessJobCard({
|
|
462
579
|
sourceId: input.sourceId,
|
|
@@ -476,6 +593,9 @@ export class WebService {
|
|
|
476
593
|
return completed;
|
|
477
594
|
if (this.connections.get(input.sourceId) === undefined)
|
|
478
595
|
await this.refreshAgents();
|
|
596
|
+
if (this.stopped) {
|
|
597
|
+
throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
|
|
598
|
+
}
|
|
479
599
|
const delivery = await this.deliverProcessJobWake(input);
|
|
480
600
|
return { ...completed, delivery };
|
|
481
601
|
}
|
|
@@ -530,8 +650,15 @@ export class WebService {
|
|
|
530
650
|
if (agent === undefined || connection === undefined || !thread.canSend) {
|
|
531
651
|
throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
|
|
532
652
|
}
|
|
533
|
-
|
|
534
|
-
|
|
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 }) });
|
|
535
662
|
this.launchTurn(started, connection.client, operatorText);
|
|
536
663
|
this.emit("turn.changed", threadId, { turn: started.thread.runState });
|
|
537
664
|
this.emit("threads.changed", threadId);
|
|
@@ -709,7 +836,7 @@ export class WebService {
|
|
|
709
836
|
const active = [...this.activeTurns.values()];
|
|
710
837
|
const activeLiveInputs = [...this.activeLiveInputs.entries()];
|
|
711
838
|
const activeNotifications = [...this.activeNotifications.values()];
|
|
712
|
-
const
|
|
839
|
+
const activeHostWakes = [...this.activeHostWakes.values()];
|
|
713
840
|
const trackedIds = new Set(active.map((turn) => turn.turnId));
|
|
714
841
|
for (const turnId of this.store.listActiveTurnIds()) {
|
|
715
842
|
if (!trackedIds.has(turnId))
|
|
@@ -725,7 +852,7 @@ export class WebService {
|
|
|
725
852
|
}
|
|
726
853
|
await Promise.allSettled(active.map((turn) => turn.completion));
|
|
727
854
|
await Promise.allSettled(activeLiveInputs.map(([, input]) => input.completion));
|
|
728
|
-
await Promise.allSettled(
|
|
855
|
+
await Promise.allSettled(activeHostWakes);
|
|
729
856
|
await Promise.allSettled(activeNotifications);
|
|
730
857
|
await Promise.allSettled(askWatches.map((watch) => watch.promise));
|
|
731
858
|
await this.pushDispatcher.stopAndDrain(5_000);
|
|
@@ -737,7 +864,7 @@ export class WebService {
|
|
|
737
864
|
this.store.close();
|
|
738
865
|
await this.lease.release();
|
|
739
866
|
}
|
|
740
|
-
async runTurn(started, client, controller, operatorText,
|
|
867
|
+
async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey) {
|
|
741
868
|
const coalescer = new StreamFrameCoalescer(async (frames) => {
|
|
742
869
|
const message = this.store.applyStreamFrames(started.turnId, frames);
|
|
743
870
|
this.emit("message.changed", started.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
|
|
@@ -761,13 +888,13 @@ export class WebService {
|
|
|
761
888
|
threadId: started.thread.id,
|
|
762
889
|
turnId: started.turnId,
|
|
763
890
|
...modelMetadata,
|
|
764
|
-
...(
|
|
891
|
+
...(hostWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
|
|
765
892
|
? { conversationTitle: { schema: 1, writable: true } }
|
|
766
893
|
: {}),
|
|
767
894
|
},
|
|
768
895
|
tui: modelMetadata,
|
|
769
896
|
},
|
|
770
|
-
...(
|
|
897
|
+
...(hostWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey: hostWakeDeliveryKey }),
|
|
771
898
|
onFrame: (frame) => {
|
|
772
899
|
this.observeAskUserFrame(started.thread.id, started.turnId, frame);
|
|
773
900
|
this.observeConversationTitleFrame(started.thread.id, started.turnId, frame);
|
|
@@ -775,11 +902,15 @@ export class WebService {
|
|
|
775
902
|
},
|
|
776
903
|
});
|
|
777
904
|
await coalescer.flush();
|
|
778
|
-
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 });
|
|
779
909
|
this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
|
|
780
910
|
this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
|
|
781
911
|
this.emit("threads.changed", started.thread.id);
|
|
782
|
-
|
|
912
|
+
if (!silentMonitorWake)
|
|
913
|
+
this.announcePushEvent(`turn:${started.turnId}:terminal`);
|
|
783
914
|
// Detached: the turn is already finished and reported, and keeping a copy
|
|
784
915
|
// must neither delay nor fail it. The agent is still connected here, which
|
|
785
916
|
// is when a fetch is most likely to succeed.
|
|
@@ -812,14 +943,14 @@ export class WebService {
|
|
|
812
943
|
coalescer.close();
|
|
813
944
|
}
|
|
814
945
|
}
|
|
815
|
-
launchTurn(started, client, operatorText,
|
|
946
|
+
launchTurn(started, client, operatorText, hostWakeDeliveryKey) {
|
|
816
947
|
const threadId = started.thread.id;
|
|
817
948
|
const controller = new AbortController();
|
|
818
|
-
const completion = this.runTurn(started, client, controller, operatorText,
|
|
949
|
+
const completion = this.runTurn(started, client, controller, operatorText, hostWakeDeliveryKey).finally(() => {
|
|
819
950
|
const active = this.activeTurns.get(threadId);
|
|
820
951
|
if (active?.turnId === started.turnId)
|
|
821
952
|
this.activeTurns.delete(threadId);
|
|
822
|
-
if (!this.stopped && !this.
|
|
953
|
+
if (!this.stopped && !this.hostWakeReservations.has(threadId)) {
|
|
823
954
|
void this.drainQueuedLiveInputs(threadId);
|
|
824
955
|
}
|
|
825
956
|
});
|
|
@@ -865,7 +996,7 @@ export class WebService {
|
|
|
865
996
|
async drainQueuedLiveInputs(threadId) {
|
|
866
997
|
if (this.stopped
|
|
867
998
|
|| this.activeTurns.has(threadId)
|
|
868
|
-
|| this.
|
|
999
|
+
|| this.hostWakeReservations.has(threadId)
|
|
869
1000
|
|| this.drainingLiveInputThreads.has(threadId))
|
|
870
1001
|
return;
|
|
871
1002
|
this.drainingLiveInputThreads.add(threadId);
|
|
@@ -892,8 +1023,8 @@ export class WebService {
|
|
|
892
1023
|
}
|
|
893
1024
|
}
|
|
894
1025
|
async deliverProcessJobWake(input) {
|
|
895
|
-
const activeKey = `${input.sourceId}\0${input.
|
|
896
|
-
const existing = this.
|
|
1026
|
+
const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
|
|
1027
|
+
const existing = this.activeHostWakes.get(activeKey);
|
|
897
1028
|
if (existing !== undefined)
|
|
898
1029
|
return await existing;
|
|
899
1030
|
const reservation = this.store.reserveProcessJobWake({
|
|
@@ -913,8 +1044,8 @@ export class WebService {
|
|
|
913
1044
|
ambiguous: true,
|
|
914
1045
|
};
|
|
915
1046
|
}
|
|
916
|
-
this.
|
|
917
|
-
const previous = this.
|
|
1047
|
+
this.retainHostWakeReservation(input.threadId);
|
|
1048
|
+
const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
|
|
918
1049
|
const delivery = previous.catch(() => undefined).then(async () => {
|
|
919
1050
|
const connection = this.connections.get(input.sourceId);
|
|
920
1051
|
if (connection === undefined) {
|
|
@@ -1023,30 +1154,206 @@ export class WebService {
|
|
|
1023
1154
|
return { delivered: true, disposition: "follow_up" };
|
|
1024
1155
|
});
|
|
1025
1156
|
const tail = delivery.then(() => undefined, () => undefined);
|
|
1026
|
-
this.
|
|
1027
|
-
this.
|
|
1157
|
+
this.hostWakeTails.set(input.threadId, tail);
|
|
1158
|
+
this.activeHostWakes.set(activeKey, delivery);
|
|
1028
1159
|
try {
|
|
1029
1160
|
return await delivery;
|
|
1030
1161
|
}
|
|
1031
1162
|
finally {
|
|
1032
|
-
if (this.
|
|
1033
|
-
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
|
+
}
|
|
1034
1278
|
}
|
|
1035
|
-
if (
|
|
1036
|
-
|
|
1279
|
+
if (active !== undefined)
|
|
1280
|
+
await active.completion;
|
|
1281
|
+
if (this.stopped) {
|
|
1282
|
+
abandon();
|
|
1283
|
+
return { delivered: false, code: "destination_channel_unavailable", retryable: true };
|
|
1037
1284
|
}
|
|
1038
|
-
this.
|
|
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
|
+
};
|
|
1321
|
+
}
|
|
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 });
|
|
1331
|
+
}
|
|
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);
|
|
1039
1346
|
}
|
|
1040
1347
|
}
|
|
1041
|
-
|
|
1042
|
-
this.
|
|
1348
|
+
retainHostWakeReservation(threadId) {
|
|
1349
|
+
this.hostWakeReservations.set(threadId, (this.hostWakeReservations.get(threadId) ?? 0) + 1);
|
|
1043
1350
|
}
|
|
1044
|
-
|
|
1045
|
-
const remaining = (this.
|
|
1351
|
+
releaseHostWakeReservation(threadId) {
|
|
1352
|
+
const remaining = (this.hostWakeReservations.get(threadId) ?? 1) - 1;
|
|
1046
1353
|
if (remaining > 0)
|
|
1047
|
-
this.
|
|
1354
|
+
this.hostWakeReservations.set(threadId, remaining);
|
|
1048
1355
|
else
|
|
1049
|
-
this.
|
|
1356
|
+
this.hostWakeReservations.delete(threadId);
|
|
1050
1357
|
if (!this.stopped)
|
|
1051
1358
|
void this.drainQueuedLiveInputs(threadId);
|
|
1052
1359
|
}
|
|
@@ -1104,13 +1411,22 @@ export class WebService {
|
|
|
1104
1411
|
const changed = this.store.replaceAgents([]);
|
|
1105
1412
|
this.connections = new Map();
|
|
1106
1413
|
if (changed)
|
|
1107
|
-
this.emit("agents.changed"
|
|
1414
|
+
this.emit("agents.changed");
|
|
1108
1415
|
return;
|
|
1109
1416
|
}
|
|
1110
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);
|
|
1111
1426
|
const summaries = await Promise.all(discovered.map(async (agent) => {
|
|
1427
|
+
const generation = generations.get(agent.source.sourceId);
|
|
1112
1428
|
if (agent.baseUrl === undefined)
|
|
1113
|
-
return offlineSummary(agent);
|
|
1429
|
+
return offlineSummary(agent, generation);
|
|
1114
1430
|
const client = new OperatorClient({
|
|
1115
1431
|
baseUrl: agent.baseUrl,
|
|
1116
1432
|
...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
|
|
@@ -1120,9 +1436,11 @@ export class WebService {
|
|
|
1120
1436
|
try {
|
|
1121
1437
|
const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
|
|
1122
1438
|
nextConnections.set(agent.source.sourceId, { client, info });
|
|
1439
|
+
this.seedModelCatalogFromOptions(agent.source.sourceId, generation, info.modelOptions);
|
|
1123
1440
|
const efforts = collectEfforts(info);
|
|
1124
1441
|
return {
|
|
1125
1442
|
sourceId: agent.source.sourceId,
|
|
1443
|
+
generation,
|
|
1126
1444
|
label: info.label ?? agent.source.label,
|
|
1127
1445
|
status: agent.source.health === "running" ? "online" : "degraded",
|
|
1128
1446
|
pinned: false,
|
|
@@ -1133,6 +1451,7 @@ export class WebService {
|
|
|
1133
1451
|
...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
|
|
1134
1452
|
...(efforts.length === 0 ? {} : { efforts }),
|
|
1135
1453
|
...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
|
|
1454
|
+
...(info.providers === undefined ? {} : { providers: info.providers }),
|
|
1136
1455
|
...(info.cron === undefined ? {} : { cron: info.cron }),
|
|
1137
1456
|
...(info.supportsAskById ? { supportsAskById: true } : {}),
|
|
1138
1457
|
updatedAt: agent.source.updatedAt,
|
|
@@ -1143,7 +1462,7 @@ export class WebService {
|
|
|
1143
1462
|
sourceId: agent.source.sourceId,
|
|
1144
1463
|
error: errorMessage(error),
|
|
1145
1464
|
});
|
|
1146
|
-
return offlineSummary(agent);
|
|
1465
|
+
return offlineSummary(agent, generation);
|
|
1147
1466
|
}
|
|
1148
1467
|
}));
|
|
1149
1468
|
this.connections = nextConnections;
|
|
@@ -1166,7 +1485,7 @@ export class WebService {
|
|
|
1166
1485
|
}
|
|
1167
1486
|
}));
|
|
1168
1487
|
if (agentsChanged)
|
|
1169
|
-
this.emit("agents.changed"
|
|
1488
|
+
this.emit("agents.changed");
|
|
1170
1489
|
for (const sourceId of cronChangedSources)
|
|
1171
1490
|
this.emit("cron.changed", undefined, { sourceId });
|
|
1172
1491
|
if (cronChangedSources.size > 0)
|
|
@@ -1365,6 +1684,116 @@ export class WebService {
|
|
|
1365
1684
|
}
|
|
1366
1685
|
return connection;
|
|
1367
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
|
+
}
|
|
1368
1797
|
authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
|
|
1369
1798
|
const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
|
|
1370
1799
|
if (access === "invalid") {
|
|
@@ -1745,9 +2174,53 @@ export class WeightedTurnBudget {
|
|
|
1745
2174
|
}
|
|
1746
2175
|
}
|
|
1747
2176
|
}
|
|
1748
|
-
|
|
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) {
|
|
1749
2221
|
return {
|
|
1750
2222
|
sourceId: agent.source.sourceId,
|
|
2223
|
+
generation,
|
|
1751
2224
|
label: agent.source.label,
|
|
1752
2225
|
status: "offline",
|
|
1753
2226
|
pinned: false,
|
|
@@ -1763,32 +2236,25 @@ function collectEfforts(info) {
|
|
|
1763
2236
|
if (info.modelOptions === undefined)
|
|
1764
2237
|
return EFFORT_LEVELS;
|
|
1765
2238
|
const models = info.models ?? (info.model === undefined ? [] : [info.model]);
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
if (model !== undefined
|
|
1770
|
-
&& (agent.models === undefined ? model !== agent.defaultModel : !agent.models.includes(model))) {
|
|
1771
|
-
throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
|
|
1772
|
-
}
|
|
1773
|
-
const effectiveModel = model ?? agent.defaultModel;
|
|
1774
|
-
const option = effectiveModel === undefined ? undefined : agent.modelOptions?.[effectiveModel];
|
|
1775
|
-
const allowedEfforts = agent.modelOptions === undefined
|
|
1776
|
-
? agent.efforts
|
|
1777
|
-
: effortLevelsForOption(option);
|
|
1778
|
-
if (effort !== undefined && (allowedEfforts === undefined || !allowedEfforts.includes(effort))) {
|
|
1779
|
-
throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
|
|
1780
|
-
}
|
|
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)))];
|
|
1781
2242
|
}
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
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;
|
|
1792
2258
|
}
|
|
1793
2259
|
function normalizeFilename(value) {
|
|
1794
2260
|
const withoutPath = value.replace(/\\/gu, "/").split("/").at(-1)?.trim() ?? "";
|