ocuclaw 1.2.4 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -5
- package/dist/config/runtime-config.js +81 -3
- package/dist/domain/activity-status-adapter.js +138 -605
- package/dist/domain/activity-status-arbiter.js +109 -0
- package/dist/domain/activity-status-labels.js +906 -0
- package/dist/domain/code-span-regions.js +103 -0
- package/dist/domain/conversation-state.js +14 -1
- package/dist/domain/debug-store.js +38 -182
- package/dist/domain/glasses-ui-content-summary.js +62 -0
- package/dist/domain/glasses-ui-system-prompt.js +28 -0
- package/dist/domain/message-emoji-allowlist.js +16 -0
- package/dist/domain/message-emoji-filter.js +33 -55
- package/dist/domain/neural-emoji-reactor-system-prompt.js +43 -0
- package/dist/domain/neural-emoji-reactor-tag-config.js +56 -0
- package/dist/domain/neural-pace-modulator-system-prompt.js +32 -0
- package/dist/domain/neural-pace-modulator-tag-config.js +51 -0
- package/dist/domain/tagged-span-parser.js +121 -0
- package/dist/domain/tagged-span-strip.js +38 -0
- package/dist/even-ai/even-ai-endpoint.js +91 -0
- package/dist/even-ai/even-ai-run-waiter.js +14 -0
- package/dist/even-ai/even-ai-settings-store.js +14 -0
- package/dist/gateway/gateway-bridge.js +14 -2
- package/dist/gateway/gateway-timing-ledger.js +457 -0
- package/dist/gateway/openclaw-client.js +462 -38
- package/dist/index.js +28 -1
- package/dist/runtime/downstream-handler.js +754 -83
- package/dist/runtime/downstream-server.js +700 -534
- package/dist/runtime/ocuclaw-settings-store.js +74 -31
- package/dist/runtime/plugin-update-service.js +216 -0
- package/dist/runtime/protocol-adapter.js +9 -0
- package/dist/runtime/provider-usage-select.js +168 -0
- package/dist/runtime/relay-client-nudge-controller.js +553 -0
- package/dist/runtime/relay-core.js +1209 -204
- package/dist/runtime/relay-health-monitor.js +172 -0
- package/dist/runtime/relay-operation-registry.js +263 -0
- package/dist/runtime/relay-service.js +201 -1
- package/dist/runtime/relay-worker-approval-replay-cache.js +68 -0
- package/dist/runtime/relay-worker-entry.js +32 -0
- package/dist/runtime/relay-worker-health.js +272 -0
- package/dist/runtime/relay-worker-protocol.js +285 -0
- package/dist/runtime/relay-worker-queue.js +202 -0
- package/dist/runtime/relay-worker-supervisor.js +1081 -0
- package/dist/runtime/relay-worker-transport.js +1051 -0
- package/dist/runtime/session-context-service.js +189 -0
- package/dist/runtime/session-service.js +615 -24
- package/dist/runtime/upstream-runtime.js +1167 -60
- package/dist/tools/device-info-tool.js +242 -0
- package/dist/tools/glasses-ui-cron.js +427 -0
- package/dist/tools/glasses-ui-descriptors.js +261 -0
- package/dist/tools/glasses-ui-limits.js +21 -0
- package/dist/tools/glasses-ui-paint-floor.js +99 -0
- package/dist/tools/glasses-ui-recipes.js +746 -0
- package/dist/tools/glasses-ui-surfaces.js +278 -0
- package/dist/tools/glasses-ui-template.js +182 -0
- package/dist/tools/glasses-ui-tool.js +1147 -0
- package/dist/tools/session-title-tool.js +209 -0
- package/dist/version.js +2 -0
- package/openclaw.plugin.json +163 -15
- package/package.json +12 -4
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
buildRateLimitInfoFromSnapshot,
|
|
4
|
+
selectProviderUsageSnapshot,
|
|
5
|
+
} from "./provider-usage-select.js";
|
|
6
|
+
import { stripAllTaggedSpans } from "../domain/tagged-span-strip.js";
|
|
7
|
+
import { parseTaggedSpans } from "../domain/tagged-span-parser.js";
|
|
8
|
+
import { EMOJI_TAG_FAMILY_CONFIG } from "../domain/neural-emoji-reactor-tag-config.js";
|
|
9
|
+
import { PACE_TAG_FAMILY_CONFIG } from "../domain/neural-pace-modulator-tag-config.js";
|
|
10
|
+
import { createSessionContextService } from "./session-context-service.js";
|
|
11
|
+
|
|
1
12
|
function normalizeLogger(logger) {
|
|
2
13
|
if (!logger || typeof logger !== "object") {
|
|
3
14
|
return console;
|
|
@@ -13,6 +24,24 @@ function normalizeLogger(logger) {
|
|
|
13
24
|
|
|
14
25
|
const DEFAULT_MODEL_PROVIDER = "anthropic";
|
|
15
26
|
const DEFAULT_MODEL_ID = "claude-opus-4-6";
|
|
27
|
+
const POOL_OUTCOME_FRESHNESS_MS = 10 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
// Dropped from 150 ms to 33 ms after Session 3 memoization (StreamingBuffer.displayedText
|
|
30
|
+
// + MessageScreenPageSliceCache) reduced per-frame client-side cost. Session 1's earlier
|
|
31
|
+
// 33 ms run produced ~15 s of long-tasks per ~10 s of streaming and a 10.8 s probe gap;
|
|
32
|
+
// the Session-3 re-validation must confirm those signals stay near the 150 ms baseline.
|
|
33
|
+
export const STREAMING_REBROADCAST_THROTTLE_MS = 33;
|
|
34
|
+
|
|
35
|
+
function fullMessageText(content) {
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
if (Array.isArray(content)) {
|
|
38
|
+
return content
|
|
39
|
+
.filter((b) => b && b.type === "text" && typeof b.text === "string")
|
|
40
|
+
.map((b) => b.text)
|
|
41
|
+
.join("");
|
|
42
|
+
}
|
|
43
|
+
return "";
|
|
44
|
+
}
|
|
16
45
|
|
|
17
46
|
function modelRefKey(provider, model) {
|
|
18
47
|
return `${provider}/${model}`;
|
|
@@ -285,6 +314,8 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
285
314
|
const sessionService = opts.sessionService;
|
|
286
315
|
const handler = opts.handler;
|
|
287
316
|
const emitDebug = typeof opts.emitDebug === "function" ? opts.emitDebug : () => {};
|
|
317
|
+
const operationRegistry = opts.operationRegistry || null;
|
|
318
|
+
const now = typeof opts.now === "function" ? opts.now : () => Date.now();
|
|
288
319
|
const broadcastPages =
|
|
289
320
|
typeof opts.broadcastPages === "function" ? opts.broadcastPages : () => {};
|
|
290
321
|
const broadcastStatus =
|
|
@@ -293,6 +324,14 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
293
324
|
typeof opts.broadcastActivity === "function"
|
|
294
325
|
? opts.broadcastActivity
|
|
295
326
|
: (activity) => activity;
|
|
327
|
+
const broadcastProviderUsageSnapshot =
|
|
328
|
+
typeof opts.broadcastProviderUsageSnapshot === "function"
|
|
329
|
+
? opts.broadcastProviderUsageSnapshot
|
|
330
|
+
: () => {};
|
|
331
|
+
const getCurrentSessionModelConfigSnapshot =
|
|
332
|
+
typeof opts.getCurrentSessionModelConfigSnapshot === "function"
|
|
333
|
+
? opts.getCurrentSessionModelConfigSnapshot
|
|
334
|
+
: () => null;
|
|
296
335
|
const resetActivityStatusAdapter =
|
|
297
336
|
typeof opts.resetActivityStatusAdapter === "function"
|
|
298
337
|
? opts.resetActivityStatusAdapter
|
|
@@ -302,13 +341,162 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
302
341
|
const getVoiceRuntime =
|
|
303
342
|
typeof opts.getVoiceRuntime === "function" ? opts.getVoiceRuntime : () => null;
|
|
304
343
|
|
|
344
|
+
const gatewayUrl = typeof opts.gatewayUrl === "string" ? opts.gatewayUrl : null;
|
|
345
|
+
const gatewayToken = typeof opts.gatewayToken === "string" ? opts.gatewayToken : null;
|
|
346
|
+
// Cap on the encoded data URI length. Real-world OpenClaw avatars from the
|
|
347
|
+
// gateway can be >1 MB PNGs; the original 256 KB cap silently dropped them.
|
|
348
|
+
// 4 MB is generous for a one-shot identity broadcast over loopback/tailnet
|
|
349
|
+
// and is still tight enough to keep status frames from ballooning if a
|
|
350
|
+
// pathologically large image is configured.
|
|
351
|
+
const MAX_AVATAR_DATA_URI_BYTES = 4 * 1024 * 1024;
|
|
352
|
+
|
|
353
|
+
function gatewayHttpOriginFromWsUrl(wsUrl) {
|
|
354
|
+
if (typeof wsUrl !== "string") return null;
|
|
355
|
+
if (wsUrl.startsWith("wss://")) return "https://" + wsUrl.slice("wss://".length);
|
|
356
|
+
if (wsUrl.startsWith("ws://")) return "http://" + wsUrl.slice("ws://".length);
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
async function defaultFetchAgentAvatar(agentId, _source) {
|
|
361
|
+
if (!gatewayUrl || !gatewayToken || !agentId) return null;
|
|
362
|
+
const origin = gatewayHttpOriginFromWsUrl(gatewayUrl);
|
|
363
|
+
if (!origin) return null;
|
|
364
|
+
const url = `${origin}/avatar/${encodeURIComponent(agentId)}`;
|
|
365
|
+
const res = await fetch(url, {
|
|
366
|
+
headers: { Authorization: `Bearer ${gatewayToken}` },
|
|
367
|
+
});
|
|
368
|
+
if (!res.ok) return null;
|
|
369
|
+
const contentType = res.headers.get("content-type") || "";
|
|
370
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
371
|
+
return { contentType, body: buffer };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const fetchAgentAvatar =
|
|
375
|
+
typeof opts.fetchAgentAvatar === "function" ? opts.fetchAgentAvatar : defaultFetchAgentAvatar;
|
|
376
|
+
|
|
377
|
+
/** @type {Map<string, {dataUri: string, hash: string}>} */
|
|
378
|
+
const avatarCache = new Map();
|
|
379
|
+
/** @type {Map<string, Promise<{dataUri: string, hash: string}|null>>} */
|
|
380
|
+
const inFlightAvatarFetches = new Map();
|
|
381
|
+
/** @type {Map<string, string>} */ // hash → cacheKey
|
|
382
|
+
const avatarHashIndex = new Map();
|
|
383
|
+
|
|
384
|
+
async function resolveAgentAvatar(agentId, avatarSource) {
|
|
385
|
+
if (!agentId || typeof avatarSource !== "string" || !avatarSource) return null;
|
|
386
|
+
|
|
387
|
+
// For data: URIs, decode the base64 payload to compute a hash, but skip the
|
|
388
|
+
// gateway fetch entirely. Same dedupe semantics as remote sources.
|
|
389
|
+
if (avatarSource.startsWith("data:")) {
|
|
390
|
+
const cacheKey = `${agentId}|${avatarSource}`;
|
|
391
|
+
const cached = avatarCache.get(cacheKey);
|
|
392
|
+
if (cached) return cached;
|
|
393
|
+
const commaIndex = avatarSource.indexOf(",");
|
|
394
|
+
if (commaIndex < 0) return null;
|
|
395
|
+
if (avatarSource.length > MAX_AVATAR_DATA_URI_BYTES) {
|
|
396
|
+
emitDebug(
|
|
397
|
+
"relay.session",
|
|
398
|
+
"agent_avatar_resolve_dropped",
|
|
399
|
+
"warn",
|
|
400
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
401
|
+
() => ({
|
|
402
|
+
reason: "oversize",
|
|
403
|
+
agentId,
|
|
404
|
+
dataUriBytes: avatarSource.length,
|
|
405
|
+
capBytes: MAX_AVATAR_DATA_URI_BYTES,
|
|
406
|
+
}),
|
|
407
|
+
);
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
const base64 = avatarSource.slice(commaIndex + 1);
|
|
411
|
+
const buffer = Buffer.from(base64, "base64");
|
|
412
|
+
const hash = createHash("sha256").update(buffer).digest("hex");
|
|
413
|
+
const entry = { dataUri: avatarSource, hash };
|
|
414
|
+
avatarCache.set(cacheKey, entry);
|
|
415
|
+
avatarHashIndex.set(hash, cacheKey);
|
|
416
|
+
return entry;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const cacheKey = `${agentId}|${avatarSource}`;
|
|
420
|
+
if (avatarCache.has(cacheKey)) return avatarCache.get(cacheKey);
|
|
421
|
+
if (inFlightAvatarFetches.has(cacheKey)) return inFlightAvatarFetches.get(cacheKey);
|
|
422
|
+
|
|
423
|
+
const promise = (async () => {
|
|
424
|
+
try {
|
|
425
|
+
const result = await fetchAgentAvatar(agentId, avatarSource);
|
|
426
|
+
if (!result || !result.body) {
|
|
427
|
+
emitDebug(
|
|
428
|
+
"relay.session",
|
|
429
|
+
"agent_avatar_resolve_dropped",
|
|
430
|
+
"warn",
|
|
431
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
432
|
+
() => ({ reason: "empty_response", agentId }),
|
|
433
|
+
);
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
const contentType = String(result.contentType || "").toLowerCase();
|
|
437
|
+
if (!contentType.startsWith("image/")) {
|
|
438
|
+
emitDebug(
|
|
439
|
+
"relay.session",
|
|
440
|
+
"agent_avatar_resolve_dropped",
|
|
441
|
+
"warn",
|
|
442
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
443
|
+
() => ({ reason: "non_image_content_type", agentId, contentType }),
|
|
444
|
+
);
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
const buffer = Buffer.isBuffer(result.body) ? result.body : Buffer.from(result.body);
|
|
448
|
+
const base64 = buffer.toString("base64");
|
|
449
|
+
const dataUri = `data:${contentType};base64,${base64}`;
|
|
450
|
+
if (dataUri.length > MAX_AVATAR_DATA_URI_BYTES) {
|
|
451
|
+
emitDebug(
|
|
452
|
+
"relay.session",
|
|
453
|
+
"agent_avatar_resolve_dropped",
|
|
454
|
+
"warn",
|
|
455
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
456
|
+
() => ({
|
|
457
|
+
reason: "oversize",
|
|
458
|
+
agentId,
|
|
459
|
+
dataUriBytes: dataUri.length,
|
|
460
|
+
capBytes: MAX_AVATAR_DATA_URI_BYTES,
|
|
461
|
+
}),
|
|
462
|
+
);
|
|
463
|
+
return null;
|
|
464
|
+
}
|
|
465
|
+
const hash = createHash("sha256").update(buffer).digest("hex");
|
|
466
|
+
const entry = { dataUri, hash };
|
|
467
|
+
avatarCache.set(cacheKey, entry);
|
|
468
|
+
avatarHashIndex.set(hash, cacheKey);
|
|
469
|
+
return entry;
|
|
470
|
+
} catch (err) {
|
|
471
|
+
emitDebug(
|
|
472
|
+
"relay.session",
|
|
473
|
+
"agent_avatar_resolve_failed",
|
|
474
|
+
"warn",
|
|
475
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
476
|
+
() => ({ message: err && err.message ? err.message : String(err) }),
|
|
477
|
+
);
|
|
478
|
+
return null;
|
|
479
|
+
} finally {
|
|
480
|
+
inFlightAvatarFetches.delete(cacheKey);
|
|
481
|
+
}
|
|
482
|
+
})();
|
|
483
|
+
|
|
484
|
+
inFlightAvatarFetches.set(cacheKey, promise);
|
|
485
|
+
return promise;
|
|
486
|
+
}
|
|
487
|
+
|
|
305
488
|
const modelsCacheTtlMs =
|
|
306
489
|
Number.isFinite(opts.modelsCacheTtlMs) && opts.modelsCacheTtlMs > 0
|
|
307
490
|
? Math.floor(opts.modelsCacheTtlMs)
|
|
308
491
|
: 300000;
|
|
492
|
+
const providerUsageCacheTtlMs =
|
|
493
|
+
Number.isFinite(opts.providerUsageCacheTtlMs) && opts.providerUsageCacheTtlMs > 0
|
|
494
|
+
? Math.floor(opts.providerUsageCacheTtlMs)
|
|
495
|
+
: 60000;
|
|
309
496
|
|
|
310
497
|
let openclawConnected = false;
|
|
311
|
-
|
|
498
|
+
/** @type {{name: string|null, emoji: string|null, avatarDataUri: string|null, avatarHash: string|null}} */
|
|
499
|
+
let agentIdentity = { name: null, emoji: null, avatarDataUri: null, avatarHash: null };
|
|
312
500
|
/** @type {Array<{provider: string, id: string, name: string, contextWindow?: number, reasoning?: boolean}>|null} */
|
|
313
501
|
let cachedModelsCatalog = null;
|
|
314
502
|
let cachedModelsCatalogFetchedAt = 0;
|
|
@@ -321,14 +509,44 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
321
509
|
let cachedSkillsCatalogStale = true;
|
|
322
510
|
/** @type {Promise<{skills: Array, fetchedAtMs: number, stale: boolean}>|null} */
|
|
323
511
|
let inFlightSkillsCatalogFetch = null;
|
|
512
|
+
let cachedProviderUsageSummary = null;
|
|
513
|
+
let cachedProviderUsageFetchedAt = 0;
|
|
514
|
+
let cachedProviderUsageObservedAt = 0;
|
|
515
|
+
let cachedProviderUsageStale = true;
|
|
516
|
+
/** @type {Promise<object>|null} */
|
|
517
|
+
let inFlightProviderUsageFetch = null;
|
|
518
|
+
const providerOutcomeState = new Map();
|
|
519
|
+
const cachedAuthProfileCounts = new Map();
|
|
324
520
|
const upstreamRunPipeline = new Map();
|
|
325
521
|
let streamingThrottleTimer = null;
|
|
326
|
-
let
|
|
522
|
+
let pendingStreaming = null;
|
|
523
|
+
/** @type {{runId: string, sessionKey: string}|null} */
|
|
524
|
+
let activeTyping = null;
|
|
327
525
|
let bootstrapRefreshTimer = null;
|
|
328
526
|
let bootstrapRefreshNonce = 0;
|
|
329
527
|
|
|
330
528
|
function getAgentName() {
|
|
331
|
-
return
|
|
529
|
+
return agentIdentity.name;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function getAgentEmoji() {
|
|
533
|
+
return agentIdentity.emoji;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function getAgentAvatarDataUri() {
|
|
537
|
+
return agentIdentity.avatarDataUri;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function getAgentAvatarHash() {
|
|
541
|
+
return agentIdentity.avatarHash;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function getAgentAvatarDataUriByHash(hash) {
|
|
545
|
+
if (typeof hash !== "string" || !hash) return null;
|
|
546
|
+
const cacheKey = avatarHashIndex.get(hash);
|
|
547
|
+
if (!cacheKey) return null;
|
|
548
|
+
const entry = avatarCache.get(cacheKey);
|
|
549
|
+
return entry ? entry.dataUri : null;
|
|
332
550
|
}
|
|
333
551
|
|
|
334
552
|
function isConnected() {
|
|
@@ -374,6 +592,31 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
374
592
|
}),
|
|
375
593
|
);
|
|
376
594
|
});
|
|
595
|
+
refreshProviderUsage(true).then((snapshot) => {
|
|
596
|
+
emitDebug(
|
|
597
|
+
"relay.session",
|
|
598
|
+
"provider_usage_prefetched",
|
|
599
|
+
"info",
|
|
600
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
601
|
+
() => ({
|
|
602
|
+
hasProvider: !!(snapshot && snapshot.provider),
|
|
603
|
+
stale: !!(snapshot && snapshot.stale),
|
|
604
|
+
trigger,
|
|
605
|
+
}),
|
|
606
|
+
);
|
|
607
|
+
});
|
|
608
|
+
sessionService.getCurrentSessionModelConfig().then((config) => {
|
|
609
|
+
emitDebug(
|
|
610
|
+
"relay.session",
|
|
611
|
+
"session_model_config_prefetched",
|
|
612
|
+
"info",
|
|
613
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
614
|
+
() => ({
|
|
615
|
+
hasProvider: !!(config && config.modelProvider),
|
|
616
|
+
trigger,
|
|
617
|
+
}),
|
|
618
|
+
);
|
|
619
|
+
});
|
|
377
620
|
}
|
|
378
621
|
|
|
379
622
|
function applyConnectedStatus(connected, trigger, emitTransportEvent = true) {
|
|
@@ -381,10 +624,27 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
381
624
|
openclawConnected = !!connected;
|
|
382
625
|
sessionService.handleUpstreamStatusChange(openclawConnected);
|
|
383
626
|
if (!openclawConnected) {
|
|
627
|
+
clearTyping("upstream_disconnected");
|
|
384
628
|
inFlightModelsCatalogFetch = null;
|
|
385
629
|
inFlightSkillsCatalogFetch = null;
|
|
630
|
+
inFlightProviderUsageFetch = null;
|
|
386
631
|
cachedSkillsCatalogStale = true;
|
|
632
|
+
cachedProviderUsageStale = true;
|
|
387
633
|
resetActivityStatusAdapter();
|
|
634
|
+
// Drop the identity-tier overlays so the WebUI brand slot reverts to the
|
|
635
|
+
// OcuClaw mark while disconnected (spec 2026-04-27).
|
|
636
|
+
if (
|
|
637
|
+
agentIdentity.emoji != null ||
|
|
638
|
+
agentIdentity.avatarDataUri != null ||
|
|
639
|
+
agentIdentity.avatarHash != null
|
|
640
|
+
) {
|
|
641
|
+
agentIdentity = {
|
|
642
|
+
...agentIdentity,
|
|
643
|
+
emoji: null,
|
|
644
|
+
avatarDataUri: null,
|
|
645
|
+
avatarHash: null,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
388
648
|
} else if (!wasConnected) {
|
|
389
649
|
onConnectedStateEstablished(trigger);
|
|
390
650
|
}
|
|
@@ -403,6 +663,85 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
403
663
|
broadcastStatus();
|
|
404
664
|
}
|
|
405
665
|
|
|
666
|
+
function applyAgentIdentity(identity, source) {
|
|
667
|
+
const agentId =
|
|
668
|
+
identity && typeof identity.agentId === "string" && identity.agentId ? identity.agentId : null;
|
|
669
|
+
const avatarSource =
|
|
670
|
+
identity && typeof identity.avatar === "string" && identity.avatar ? identity.avatar : null;
|
|
671
|
+
// Data: URIs are pass-through per spec — set them on the initial broadcast.
|
|
672
|
+
// For data: URIs the hash is cheap and synchronous — compute it now so the
|
|
673
|
+
// first status broadcast carries both fields. For remote avatars the hash
|
|
674
|
+
// arrives via the resolveAgentAvatar(...).then(...) settle below, after the
|
|
675
|
+
// initial broadcast has gone out with hash:null.
|
|
676
|
+
const inlineAvatarDataUri =
|
|
677
|
+
avatarSource && avatarSource.startsWith("data:") ? avatarSource : null;
|
|
678
|
+
let inlineAvatarHash = null;
|
|
679
|
+
if (agentId && inlineAvatarDataUri) {
|
|
680
|
+
const cacheKey = `${agentId}|${inlineAvatarDataUri}`;
|
|
681
|
+
const cached = avatarCache.get(cacheKey);
|
|
682
|
+
if (cached) {
|
|
683
|
+
inlineAvatarHash = cached.hash;
|
|
684
|
+
} else if (inlineAvatarDataUri.length <= MAX_AVATAR_DATA_URI_BYTES) {
|
|
685
|
+
const commaIndex = inlineAvatarDataUri.indexOf(",");
|
|
686
|
+
if (commaIndex >= 0) {
|
|
687
|
+
const buffer = Buffer.from(inlineAvatarDataUri.slice(commaIndex + 1), "base64");
|
|
688
|
+
inlineAvatarHash = createHash("sha256").update(buffer).digest("hex");
|
|
689
|
+
const entry = { dataUri: inlineAvatarDataUri, hash: inlineAvatarHash };
|
|
690
|
+
avatarCache.set(cacheKey, entry);
|
|
691
|
+
avatarHashIndex.set(inlineAvatarHash, cacheKey);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
const next = {
|
|
696
|
+
name: identity && typeof identity.name === "string" && identity.name ? identity.name : null,
|
|
697
|
+
emoji: identity && typeof identity.emoji === "string" && identity.emoji ? identity.emoji : null,
|
|
698
|
+
avatarDataUri: inlineAvatarDataUri,
|
|
699
|
+
avatarHash: inlineAvatarHash,
|
|
700
|
+
};
|
|
701
|
+
agentIdentity = next;
|
|
702
|
+
conversationState.setAgentName(next.name || "Agent");
|
|
703
|
+
emitDebug(
|
|
704
|
+
"relay.session",
|
|
705
|
+
"agent_identity_applied",
|
|
706
|
+
"info",
|
|
707
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
708
|
+
() => ({
|
|
709
|
+
source,
|
|
710
|
+
hasName: !!next.name,
|
|
711
|
+
hasEmoji: !!next.emoji,
|
|
712
|
+
hasAvatarSource: !!avatarSource,
|
|
713
|
+
avatarInline: !!inlineAvatarDataUri,
|
|
714
|
+
}),
|
|
715
|
+
);
|
|
716
|
+
broadcastStatus();
|
|
717
|
+
|
|
718
|
+
if (!agentId || !avatarSource) return;
|
|
719
|
+
const generationName = next.name;
|
|
720
|
+
const generationEmoji = next.emoji;
|
|
721
|
+
resolveAgentAvatar(agentId, avatarSource).then((resolved) => {
|
|
722
|
+
// Drop if the identity changed underneath us.
|
|
723
|
+
if (
|
|
724
|
+
agentIdentity.name !== generationName ||
|
|
725
|
+
agentIdentity.emoji !== generationEmoji
|
|
726
|
+
) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (!resolved) return;
|
|
730
|
+
if (
|
|
731
|
+
agentIdentity.avatarDataUri === resolved.dataUri &&
|
|
732
|
+
agentIdentity.avatarHash === resolved.hash
|
|
733
|
+
) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
agentIdentity = {
|
|
737
|
+
...agentIdentity,
|
|
738
|
+
avatarDataUri: resolved.dataUri,
|
|
739
|
+
avatarHash: resolved.hash,
|
|
740
|
+
};
|
|
741
|
+
broadcastStatus();
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
406
745
|
async function refreshUpstreamBootstrap(trigger, attempt = 0) {
|
|
407
746
|
const refreshNonce = ++bootstrapRefreshNonce;
|
|
408
747
|
clearBootstrapRefreshTimer();
|
|
@@ -419,21 +758,7 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
419
758
|
if (statusOk || identityOk) {
|
|
420
759
|
applyConnectedStatus(true, `${trigger}_bootstrap`, !statusOk);
|
|
421
760
|
if (identityOk) {
|
|
422
|
-
|
|
423
|
-
agentName = identity && identity.name ? identity.name : null;
|
|
424
|
-
conversationState.setAgentName(agentName || "Agent");
|
|
425
|
-
emitDebug(
|
|
426
|
-
"relay.session",
|
|
427
|
-
"agent_identity_bootstrap",
|
|
428
|
-
"info",
|
|
429
|
-
{ sessionKey: sessionService.ensureSessionKey() },
|
|
430
|
-
() => ({
|
|
431
|
-
hasName: !!agentName,
|
|
432
|
-
trigger,
|
|
433
|
-
attempt,
|
|
434
|
-
}),
|
|
435
|
-
);
|
|
436
|
-
broadcastStatus();
|
|
761
|
+
applyAgentIdentity(identityResult.value, `${trigger}_bootstrap`);
|
|
437
762
|
}
|
|
438
763
|
return;
|
|
439
764
|
}
|
|
@@ -467,22 +792,98 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
467
792
|
}
|
|
468
793
|
|
|
469
794
|
function flushPendingStreamingText() {
|
|
470
|
-
if (!
|
|
795
|
+
if (!pendingStreaming) return;
|
|
796
|
+
const queuedStreaming = pendingStreaming;
|
|
797
|
+
pendingStreaming = null;
|
|
471
798
|
const server = getServer();
|
|
472
799
|
if (server) {
|
|
473
|
-
|
|
800
|
+
const runId = queuedStreaming.runId || null;
|
|
801
|
+
if (runId) {
|
|
802
|
+
stopTypingForRun(runId, "first_visible_assistant_progress");
|
|
803
|
+
emitDebug(
|
|
804
|
+
"openclaw.message",
|
|
805
|
+
"agent_first_chunk",
|
|
806
|
+
"info",
|
|
807
|
+
{ sessionKey: queuedStreaming.sessionKey || sessionService.ensureSessionKey() },
|
|
808
|
+
() => ({ runId }),
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
// Parse + markdown the latest cumulative text here, not per chunk: only
|
|
812
|
+
// the surviving (non-superseded) buffer reaches the parser.
|
|
813
|
+
const parsedSpans = parseTaggedSpans(queuedStreaming.rawText, [
|
|
814
|
+
EMOJI_TAG_FAMILY_CONFIG,
|
|
815
|
+
PACE_TAG_FAMILY_CONFIG,
|
|
816
|
+
]);
|
|
817
|
+
const { text, spansByFamily } = applyMarkdownWithSpans(
|
|
818
|
+
{
|
|
819
|
+
cleanText: parsedSpans.cleanText,
|
|
820
|
+
spansByFamily: parsedSpans.spansByFamily,
|
|
821
|
+
trailingPartialTag: parsedSpans.trailingPartialTag,
|
|
822
|
+
},
|
|
823
|
+
queuedStreaming.prefix,
|
|
824
|
+
conversationState,
|
|
825
|
+
);
|
|
826
|
+
const emojiSpans = spansByFamily.emoji || [];
|
|
827
|
+
const paceSpans = spansByFamily.pace || [];
|
|
828
|
+
server.broadcast(
|
|
829
|
+
handler.formatStreaming(text, emojiSpans, paceSpans),
|
|
830
|
+
);
|
|
831
|
+
const now = Date.now();
|
|
832
|
+
const runPipeline = runId ? upstreamRunPipeline.get(runId) : null;
|
|
833
|
+
const firstRelayBroadcast = runPipeline
|
|
834
|
+
? !runPipeline.firstRelayBroadcastAt
|
|
835
|
+
: queuedStreaming.flushReason === "first_immediate";
|
|
836
|
+
if (runPipeline && !runPipeline.firstRelayBroadcastAt) {
|
|
837
|
+
runPipeline.firstRelayBroadcastAt = now;
|
|
838
|
+
runPipeline.firstRelayBroadcastChars = text.length;
|
|
839
|
+
}
|
|
840
|
+
emitDebug(
|
|
841
|
+
"relay.protocol",
|
|
842
|
+
"streaming_rebroadcast",
|
|
843
|
+
"debug",
|
|
844
|
+
{
|
|
845
|
+
sessionKey: queuedStreaming.sessionKey || sessionService.ensureSessionKey(),
|
|
846
|
+
runId,
|
|
847
|
+
},
|
|
848
|
+
() => ({
|
|
849
|
+
reason: queuedStreaming.flushReason || "throttled_flush",
|
|
850
|
+
rebroadcastChars: text.length,
|
|
851
|
+
rawAssistantChars: queuedStreaming.rawAssistantChars,
|
|
852
|
+
assistantDeltaChars: queuedStreaming.assistantDeltaChars,
|
|
853
|
+
firstGatewayChunk:
|
|
854
|
+
typeof queuedStreaming.firstGatewayChunk === "boolean"
|
|
855
|
+
? queuedStreaming.firstGatewayChunk
|
|
856
|
+
: null,
|
|
857
|
+
firstRelayBroadcast,
|
|
858
|
+
gatewayReceivedAtMs: queuedStreaming.gatewayReceivedAtMs,
|
|
859
|
+
gatewayToRebroadcastMs:
|
|
860
|
+
Number.isFinite(queuedStreaming.gatewayReceivedAtMs)
|
|
861
|
+
? (now - queuedStreaming.gatewayReceivedAtMs)
|
|
862
|
+
: null,
|
|
863
|
+
sendToRebroadcastMs: runPipeline ? (now - runPipeline.sendStartedAt) : null,
|
|
864
|
+
ackToRebroadcastMs:
|
|
865
|
+
runPipeline && runPipeline.ackAt ? (now - runPipeline.ackAt) : null,
|
|
866
|
+
runStartToRebroadcastMs:
|
|
867
|
+
runPipeline && runPipeline.lifecycleStartAt
|
|
868
|
+
? (now - runPipeline.lifecycleStartAt)
|
|
869
|
+
: null,
|
|
870
|
+
firstStreamingToRebroadcastMs:
|
|
871
|
+
runPipeline && runPipeline.firstStreamingAt
|
|
872
|
+
? (now - runPipeline.firstStreamingAt)
|
|
873
|
+
: null,
|
|
874
|
+
}),
|
|
875
|
+
);
|
|
474
876
|
}
|
|
475
|
-
pendingStreamingText = null;
|
|
476
877
|
}
|
|
477
878
|
|
|
478
879
|
function modelCatalogSnapshot(nowMs) {
|
|
479
|
-
const
|
|
880
|
+
const currentNow = Number.isFinite(nowMs) ? nowMs : now();
|
|
480
881
|
const hasCache = Array.isArray(cachedModelsCatalog);
|
|
481
|
-
const ageMs = hasCache ?
|
|
882
|
+
const ageMs = hasCache ? currentNow - cachedModelsCatalogFetchedAt : Infinity;
|
|
482
883
|
const ttlExpired = ageMs >= modelsCacheTtlMs;
|
|
483
884
|
return {
|
|
484
885
|
models: hasCache ? cachedModelsCatalog : [],
|
|
485
|
-
fetchedAtMs: hasCache ? cachedModelsCatalogFetchedAt :
|
|
886
|
+
fetchedAtMs: hasCache ? cachedModelsCatalogFetchedAt : currentNow,
|
|
486
887
|
stale: !hasCache || cachedModelsCatalogStale || ttlExpired,
|
|
487
888
|
};
|
|
488
889
|
}
|
|
@@ -491,17 +892,17 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
491
892
|
cachedModelsCatalog = Array.isArray(models) ? models : [];
|
|
492
893
|
cachedModelsCatalogFetchedAt = Number.isFinite(fetchedAtMs)
|
|
493
894
|
? Math.floor(fetchedAtMs)
|
|
494
|
-
:
|
|
895
|
+
: now();
|
|
495
896
|
cachedModelsCatalogStale = !!stale;
|
|
496
897
|
return modelCatalogSnapshot(cachedModelsCatalogFetchedAt);
|
|
497
898
|
}
|
|
498
899
|
|
|
499
900
|
function skillsCatalogSnapshot(nowMs) {
|
|
500
|
-
const
|
|
901
|
+
const currentNow = Number.isFinite(nowMs) ? nowMs : now();
|
|
501
902
|
const hasCache = Array.isArray(cachedSkillsCatalog);
|
|
502
903
|
return {
|
|
503
904
|
skills: hasCache ? cachedSkillsCatalog : [],
|
|
504
|
-
fetchedAtMs: hasCache ? cachedSkillsCatalogFetchedAt :
|
|
905
|
+
fetchedAtMs: hasCache ? cachedSkillsCatalogFetchedAt : currentNow,
|
|
505
906
|
stale: !hasCache || cachedSkillsCatalogStale,
|
|
506
907
|
};
|
|
507
908
|
}
|
|
@@ -510,11 +911,196 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
510
911
|
cachedSkillsCatalog = Array.isArray(skills) ? skills : [];
|
|
511
912
|
cachedSkillsCatalogFetchedAt = Number.isFinite(fetchedAtMs)
|
|
512
913
|
? Math.floor(fetchedAtMs)
|
|
513
|
-
:
|
|
914
|
+
: now();
|
|
514
915
|
cachedSkillsCatalogStale = !!stale;
|
|
515
916
|
return skillsCatalogSnapshot(cachedSkillsCatalogFetchedAt);
|
|
516
917
|
}
|
|
517
918
|
|
|
919
|
+
function providerUsageCacheState(nowMs) {
|
|
920
|
+
const currentNow = Number.isFinite(nowMs) ? nowMs : now();
|
|
921
|
+
const hasCache =
|
|
922
|
+
cachedProviderUsageSummary &&
|
|
923
|
+
typeof cachedProviderUsageSummary === "object" &&
|
|
924
|
+
!Array.isArray(cachedProviderUsageSummary);
|
|
925
|
+
const ageMs = hasCache ? currentNow - cachedProviderUsageObservedAt : Infinity;
|
|
926
|
+
const ttlExpired = ageMs >= providerUsageCacheTtlMs;
|
|
927
|
+
return {
|
|
928
|
+
summary: hasCache ? cachedProviderUsageSummary : null,
|
|
929
|
+
fetchedAtMs: hasCache ? cachedProviderUsageFetchedAt : currentNow,
|
|
930
|
+
stale: !hasCache || cachedProviderUsageStale || ttlExpired,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
function activeProviderContext() {
|
|
935
|
+
const sessionKey = sessionService.ensureSessionKey();
|
|
936
|
+
const config = getCurrentSessionModelConfigSnapshot();
|
|
937
|
+
const configMatchesActiveSession =
|
|
938
|
+
!!config &&
|
|
939
|
+
(
|
|
940
|
+
typeof sessionService.isCurrentSession === "function"
|
|
941
|
+
? sessionService.isCurrentSession(config.sessionKey)
|
|
942
|
+
: config.sessionKey === sessionKey
|
|
943
|
+
);
|
|
944
|
+
return {
|
|
945
|
+
sessionKey,
|
|
946
|
+
provider: normalizeProviderId(
|
|
947
|
+
configMatchesActiveSession ? config && config.modelProvider : null,
|
|
948
|
+
),
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function emptyProviderUsageSnapshot(nowMs, stale = true) {
|
|
953
|
+
const { sessionKey, provider } = activeProviderContext();
|
|
954
|
+
const fallbackFetchedAtMs = Number.isFinite(nowMs) ? Math.floor(nowMs) : now();
|
|
955
|
+
return {
|
|
956
|
+
sessionKey: sessionKey || null,
|
|
957
|
+
provider: provider || null,
|
|
958
|
+
displayName: null,
|
|
959
|
+
limitingWindowKey: null,
|
|
960
|
+
windows: [],
|
|
961
|
+
fetchedAtMs: fallbackFetchedAtMs,
|
|
962
|
+
stale: !!stale,
|
|
963
|
+
poolStatus: computePoolStatus(provider, fallbackFetchedAtMs),
|
|
964
|
+
totalProfileCount: cachedAuthProfileCounts.has(provider)
|
|
965
|
+
? cachedAuthProfileCounts.get(provider)
|
|
966
|
+
: null,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function computePoolStatus(provider, nowMs) {
|
|
971
|
+
if (!provider) return "unknown";
|
|
972
|
+
const entry = providerOutcomeState.get(provider);
|
|
973
|
+
if (!entry) return "unknown";
|
|
974
|
+
const ageMs = nowMs - entry.lastOutcomeAtMs;
|
|
975
|
+
if (ageMs >= POOL_OUTCOME_FRESHNESS_MS) return "unknown";
|
|
976
|
+
return entry.lastOutcome;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
function projectProviderUsageSnapshot(nowMs) {
|
|
980
|
+
const currentNow = Number.isFinite(nowMs) ? nowMs : now();
|
|
981
|
+
const cacheState = providerUsageCacheState(currentNow);
|
|
982
|
+
if (!cacheState.summary) {
|
|
983
|
+
return emptyProviderUsageSnapshot(cacheState.fetchedAtMs);
|
|
984
|
+
}
|
|
985
|
+
const { sessionKey, provider } = activeProviderContext();
|
|
986
|
+
const projected = selectProviderUsageSnapshot(cacheState.summary, {
|
|
987
|
+
sessionKey,
|
|
988
|
+
provider,
|
|
989
|
+
stale: cacheState.stale,
|
|
990
|
+
});
|
|
991
|
+
if (!projected) {
|
|
992
|
+
return emptyProviderUsageSnapshot(cacheState.fetchedAtMs, cacheState.stale);
|
|
993
|
+
}
|
|
994
|
+
return {
|
|
995
|
+
...projected,
|
|
996
|
+
poolStatus: computePoolStatus(provider, currentNow),
|
|
997
|
+
totalProfileCount: cachedAuthProfileCounts.has(provider)
|
|
998
|
+
? cachedAuthProfileCounts.get(provider)
|
|
999
|
+
: null,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
async function fetchAuthProfileCounts() {
|
|
1004
|
+
const result = await gatewayBridge.request("models.authStatus", {});
|
|
1005
|
+
const next = new Map();
|
|
1006
|
+
const providers = Array.isArray(result && result.providers) ? result.providers : [];
|
|
1007
|
+
for (const entry of providers) {
|
|
1008
|
+
const provider = normalizeProviderId(entry && entry.provider);
|
|
1009
|
+
if (!provider) continue;
|
|
1010
|
+
const profiles = Array.isArray(entry && entry.profiles) ? entry.profiles : [];
|
|
1011
|
+
let count = 0;
|
|
1012
|
+
for (const profile of profiles) {
|
|
1013
|
+
const type = typeof profile?.type === "string" ? profile.type.trim().toLowerCase() : "";
|
|
1014
|
+
if (type === "oauth" || type === "token") count += 1;
|
|
1015
|
+
}
|
|
1016
|
+
next.set(provider, count);
|
|
1017
|
+
}
|
|
1018
|
+
return next;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
async function refreshProviderUsage(force) {
|
|
1022
|
+
const snapshot = projectProviderUsageSnapshot();
|
|
1023
|
+
if (!force && !snapshot.stale) {
|
|
1024
|
+
return snapshot;
|
|
1025
|
+
}
|
|
1026
|
+
if (inFlightProviderUsageFetch) {
|
|
1027
|
+
return inFlightProviderUsageFetch;
|
|
1028
|
+
}
|
|
1029
|
+
if (!openclawConnected) {
|
|
1030
|
+
return snapshot;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
inFlightProviderUsageFetch = (async () => {
|
|
1034
|
+
const [usageResult, authStatusResult] = await Promise.allSettled([
|
|
1035
|
+
gatewayBridge.request("usage.status", {}),
|
|
1036
|
+
fetchAuthProfileCounts(),
|
|
1037
|
+
]);
|
|
1038
|
+
|
|
1039
|
+
if (authStatusResult.status === "fulfilled") {
|
|
1040
|
+
cachedAuthProfileCounts.clear();
|
|
1041
|
+
for (const [provider, count] of authStatusResult.value) {
|
|
1042
|
+
cachedAuthProfileCounts.set(provider, count);
|
|
1043
|
+
}
|
|
1044
|
+
} else {
|
|
1045
|
+
// Intentionally do NOT clear cachedAuthProfileCounts on rejection.
|
|
1046
|
+
// Retain-on-failure preserves the caption count across transient blips;
|
|
1047
|
+
// the next successful fetch overwrites. See spec
|
|
1048
|
+
// 2026-05-07-rate-limit-pool-status-design.md (authStatus retain-on-failure).
|
|
1049
|
+
emitDebug(
|
|
1050
|
+
"relay.session",
|
|
1051
|
+
"models_auth_status_refresh_failed",
|
|
1052
|
+
"warn",
|
|
1053
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
1054
|
+
() => ({
|
|
1055
|
+
message: authStatusResult.reason && authStatusResult.reason.message
|
|
1056
|
+
? authStatusResult.reason.message
|
|
1057
|
+
: String(authStatusResult.reason),
|
|
1058
|
+
hadCount: cachedAuthProfileCounts.size > 0,
|
|
1059
|
+
}),
|
|
1060
|
+
);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
if (usageResult.status === "fulfilled") {
|
|
1064
|
+
const result = usageResult.value;
|
|
1065
|
+
cachedProviderUsageSummary =
|
|
1066
|
+
result && typeof result === "object" && !Array.isArray(result) ? result : {};
|
|
1067
|
+
cachedProviderUsageObservedAt = now();
|
|
1068
|
+
cachedProviderUsageFetchedAt =
|
|
1069
|
+
Number.isFinite(result && result.updatedAt)
|
|
1070
|
+
? Math.floor(result.updatedAt)
|
|
1071
|
+
: cachedProviderUsageObservedAt;
|
|
1072
|
+
cachedProviderUsageStale = false;
|
|
1073
|
+
const refreshedSnapshot = projectProviderUsageSnapshot(cachedProviderUsageObservedAt);
|
|
1074
|
+
broadcastProviderUsageSnapshot(refreshedSnapshot);
|
|
1075
|
+
return refreshedSnapshot;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const err = usageResult.reason;
|
|
1079
|
+
emitDebug(
|
|
1080
|
+
"relay.session",
|
|
1081
|
+
"provider_usage_refresh_failed",
|
|
1082
|
+
"warn",
|
|
1083
|
+
{ sessionKey: sessionService.ensureSessionKey() },
|
|
1084
|
+
() => ({
|
|
1085
|
+
message: err && err.message ? err.message : String(err),
|
|
1086
|
+
hadCache: !!providerUsageCacheState().summary,
|
|
1087
|
+
}),
|
|
1088
|
+
);
|
|
1089
|
+
cachedProviderUsageStale = true;
|
|
1090
|
+
if (!providerUsageCacheState().summary) {
|
|
1091
|
+
cachedProviderUsageObservedAt = now();
|
|
1092
|
+
cachedProviderUsageFetchedAt = cachedProviderUsageObservedAt;
|
|
1093
|
+
}
|
|
1094
|
+
const refreshedSnapshot = projectProviderUsageSnapshot();
|
|
1095
|
+
broadcastProviderUsageSnapshot(refreshedSnapshot);
|
|
1096
|
+
return refreshedSnapshot;
|
|
1097
|
+
})();
|
|
1098
|
+
|
|
1099
|
+
return inFlightProviderUsageFetch.finally(() => {
|
|
1100
|
+
inFlightProviderUsageFetch = null;
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
|
|
518
1104
|
async function refreshModelCatalog(force) {
|
|
519
1105
|
const snapshot = modelCatalogSnapshot();
|
|
520
1106
|
if (!force && !snapshot.stale) {
|
|
@@ -550,7 +1136,7 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
550
1136
|
cachedModelsCatalogStale = true;
|
|
551
1137
|
return modelCatalogSnapshot();
|
|
552
1138
|
}
|
|
553
|
-
return cacheModelCatalog([],
|
|
1139
|
+
return cacheModelCatalog([], now(), true);
|
|
554
1140
|
});
|
|
555
1141
|
|
|
556
1142
|
return inFlightModelsCatalogFetch.finally(() => {
|
|
@@ -591,7 +1177,7 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
591
1177
|
cachedSkillsCatalogStale = true;
|
|
592
1178
|
return skillsCatalogSnapshot();
|
|
593
1179
|
}
|
|
594
|
-
return cacheSkillsCatalog([],
|
|
1180
|
+
return cacheSkillsCatalog([], now(), true);
|
|
595
1181
|
});
|
|
596
1182
|
|
|
597
1183
|
return inFlightSkillsCatalogFetch.finally(() => {
|
|
@@ -609,6 +1195,10 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
609
1195
|
ackAt: entry.ackAt || Date.now(),
|
|
610
1196
|
lifecycleStartAt: null,
|
|
611
1197
|
firstStreamingAt: null,
|
|
1198
|
+
firstGatewayReceivedAt: null,
|
|
1199
|
+
firstGatewayChars: null,
|
|
1200
|
+
firstRelayBroadcastAt: null,
|
|
1201
|
+
firstRelayBroadcastChars: null,
|
|
612
1202
|
});
|
|
613
1203
|
}
|
|
614
1204
|
|
|
@@ -628,6 +1218,40 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
628
1218
|
return snapshot;
|
|
629
1219
|
}
|
|
630
1220
|
|
|
1221
|
+
async function getProviderUsageSnapshot() {
|
|
1222
|
+
const snapshot = projectProviderUsageSnapshot();
|
|
1223
|
+
if (snapshot.stale && openclawConnected) {
|
|
1224
|
+
return refreshProviderUsage(true);
|
|
1225
|
+
}
|
|
1226
|
+
return snapshot;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
async function handleCurrentSessionModelConfigChanged() {
|
|
1230
|
+
// The session-context window is keyed by the active model, which only
|
|
1231
|
+
// becomes known once the model config resolves. On connect that resolution
|
|
1232
|
+
// happens AFTER the initial connect-time context refresh has already run
|
|
1233
|
+
// with a null model key, so a cached/persisted window for a cold (pre-turn)
|
|
1234
|
+
// session would otherwise never be applied until the first turn warms the
|
|
1235
|
+
// gateway's window. Re-refresh now that the model key is known so the
|
|
1236
|
+
// cached window surfaces immediately; fire-and-forget so it doesn't block
|
|
1237
|
+
// the provider-usage rebroadcast. Also keeps context correct across model
|
|
1238
|
+
// switches.
|
|
1239
|
+
sessionContextService.refreshActiveSessionContext().catch(() => {});
|
|
1240
|
+
|
|
1241
|
+
const snapshot = projectProviderUsageSnapshot();
|
|
1242
|
+
if (snapshot.stale && openclawConnected) {
|
|
1243
|
+
return refreshProviderUsage(true);
|
|
1244
|
+
}
|
|
1245
|
+
broadcastProviderUsageSnapshot(snapshot);
|
|
1246
|
+
return snapshot;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
async function handleCurrentSessionModelConfigCleared() {
|
|
1250
|
+
const snapshot = emptyProviderUsageSnapshot(now(), false);
|
|
1251
|
+
broadcastProviderUsageSnapshot(snapshot);
|
|
1252
|
+
return snapshot;
|
|
1253
|
+
}
|
|
1254
|
+
|
|
631
1255
|
function handleSessionChanged(trigger) {
|
|
632
1256
|
if (!openclawConnected) {
|
|
633
1257
|
cachedSkillsCatalogStale = true;
|
|
@@ -638,6 +1262,76 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
638
1262
|
});
|
|
639
1263
|
}
|
|
640
1264
|
|
|
1265
|
+
function normalizeGatewayTimingEvent(raw) {
|
|
1266
|
+
const source = raw && typeof raw === "object" ? raw : {};
|
|
1267
|
+
const category =
|
|
1268
|
+
source.category === "openclaw.run" || source.category === "relay.protocol"
|
|
1269
|
+
? source.category
|
|
1270
|
+
: "relay.protocol";
|
|
1271
|
+
const event =
|
|
1272
|
+
typeof source.event === "string" && source.event.trim()
|
|
1273
|
+
? source.event.trim()
|
|
1274
|
+
: "gateway_timing";
|
|
1275
|
+
const severity =
|
|
1276
|
+
source.severity === "debug" ||
|
|
1277
|
+
source.severity === "info" ||
|
|
1278
|
+
source.severity === "warn" ||
|
|
1279
|
+
source.severity === "error"
|
|
1280
|
+
? source.severity
|
|
1281
|
+
: "debug";
|
|
1282
|
+
const context = source.context && typeof source.context === "object"
|
|
1283
|
+
? source.context
|
|
1284
|
+
: {};
|
|
1285
|
+
const data = source.data && typeof source.data === "object"
|
|
1286
|
+
? source.data
|
|
1287
|
+
: {};
|
|
1288
|
+
return {
|
|
1289
|
+
category,
|
|
1290
|
+
event,
|
|
1291
|
+
severity,
|
|
1292
|
+
context: {
|
|
1293
|
+
sessionKey:
|
|
1294
|
+
typeof context.sessionKey === "string" && context.sessionKey.trim()
|
|
1295
|
+
? context.sessionKey.trim()
|
|
1296
|
+
: sessionService.ensureSessionKey(),
|
|
1297
|
+
runId:
|
|
1298
|
+
typeof context.runId === "string" && context.runId.trim()
|
|
1299
|
+
? context.runId.trim()
|
|
1300
|
+
: null,
|
|
1301
|
+
},
|
|
1302
|
+
data,
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
// Session-context service: tracks contextTokens/contextWindow and broadcasts
|
|
1307
|
+
// snapshots to connected WebUI clients. session.switch.applied trigger is
|
|
1308
|
+
// deferred (see Task 4 plan notes — the switchSession path lives in
|
|
1309
|
+
// downstream-handler.ts and is out of scope for this task).
|
|
1310
|
+
let cachedRunActiveSessionKey = null;
|
|
1311
|
+
// contextWindow is NOT resolved on a fresh session (the gateway warms a
|
|
1312
|
+
// model's window only after the first turn). The service caches the observed
|
|
1313
|
+
// window per model and reuses it for cold/new-session reads, keyed by the
|
|
1314
|
+
// active session's resolved provider/model.
|
|
1315
|
+
const sessionContextService = createSessionContextService({
|
|
1316
|
+
gatewayBridge,
|
|
1317
|
+
stateDir: opts.stateDir,
|
|
1318
|
+
getActiveSessionKey: () => sessionService.ensureSessionKey() || null,
|
|
1319
|
+
getActiveModelKey: () => {
|
|
1320
|
+
const config = getCurrentSessionModelConfigSnapshot();
|
|
1321
|
+
if (!config) return null;
|
|
1322
|
+
const provider = normalizeProviderId(config.modelProvider);
|
|
1323
|
+
const model = typeof config.model === "string" ? config.model.trim() : "";
|
|
1324
|
+
if (!provider || !model) return null;
|
|
1325
|
+
return modelRefKey(provider, model);
|
|
1326
|
+
},
|
|
1327
|
+
getRunActive: () => !!cachedRunActiveSessionKey,
|
|
1328
|
+
nowMs: () => Date.now(),
|
|
1329
|
+
broadcast: (frame) => {
|
|
1330
|
+
const server = getServer();
|
|
1331
|
+
if (server) server.broadcast(JSON.stringify(frame));
|
|
1332
|
+
},
|
|
1333
|
+
});
|
|
1334
|
+
|
|
641
1335
|
gatewayBridge.on("history", (data) => {
|
|
642
1336
|
if (!sessionService.isCurrentSession(data.sessionKey)) return;
|
|
643
1337
|
emitDebug(
|
|
@@ -649,7 +1343,14 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
649
1343
|
messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
|
|
650
1344
|
}),
|
|
651
1345
|
);
|
|
652
|
-
|
|
1346
|
+
const sanitizedMessages = Array.isArray(data.messages)
|
|
1347
|
+
? data.messages.map((msg) =>
|
|
1348
|
+
msg && msg.role === "assistant"
|
|
1349
|
+
? { ...msg, content: sanitizeAssistantContentBlocks(msg.content) }
|
|
1350
|
+
: msg,
|
|
1351
|
+
)
|
|
1352
|
+
: data.messages;
|
|
1353
|
+
conversationState.hydrate(sanitizedMessages, agentIdentity.name);
|
|
653
1354
|
broadcastPages();
|
|
654
1355
|
});
|
|
655
1356
|
|
|
@@ -688,12 +1389,148 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
688
1389
|
);
|
|
689
1390
|
});
|
|
690
1391
|
|
|
1392
|
+
function sanitizeAssistantContentBlocks(content) {
|
|
1393
|
+
if (typeof content === "string") {
|
|
1394
|
+
return stripAllTaggedSpans(content);
|
|
1395
|
+
}
|
|
1396
|
+
if (!Array.isArray(content)) return content;
|
|
1397
|
+
return content.map((block) => {
|
|
1398
|
+
if (
|
|
1399
|
+
block &&
|
|
1400
|
+
typeof block === "object" &&
|
|
1401
|
+
block.type === "text" &&
|
|
1402
|
+
typeof block.text === "string"
|
|
1403
|
+
) {
|
|
1404
|
+
return { ...block, text: stripAllTaggedSpans(block.text) };
|
|
1405
|
+
}
|
|
1406
|
+
return block;
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// Unique sentinel characters used as span boundary markers through the markdown pass.
|
|
1411
|
+
// These are ASCII control characters that never appear in assistant text.
|
|
1412
|
+
const SPAN_START_MARK = "\x01";
|
|
1413
|
+
const SPAN_END_MARK = "\x02";
|
|
1414
|
+
|
|
1415
|
+
function applyMarkdownWithSpans(parsed, prefix, conversationState) {
|
|
1416
|
+
const { cleanText, spansByFamily } = parsed;
|
|
1417
|
+
const familyNames = Object.keys(spansByFamily);
|
|
1418
|
+
const totalSpans = familyNames.reduce(
|
|
1419
|
+
(sum, name) => sum + spansByFamily[name].length,
|
|
1420
|
+
0,
|
|
1421
|
+
);
|
|
1422
|
+
if (totalSpans === 0) {
|
|
1423
|
+
const { text } = conversationState._markdownToPlainText(cleanText, {
|
|
1424
|
+
stripReplyTags: true,
|
|
1425
|
+
});
|
|
1426
|
+
const empty = {};
|
|
1427
|
+
for (const name of familyNames) empty[name] = [];
|
|
1428
|
+
return { text: `${prefix}${text}`, spansByFamily: empty };
|
|
1429
|
+
}
|
|
1430
|
+
// Flatten all spans into one event list, tagged by family for routing back.
|
|
1431
|
+
// Each span contributes two boundary events. Sort by offset, start-before-end on tie.
|
|
1432
|
+
const events = [];
|
|
1433
|
+
for (const family of familyNames) {
|
|
1434
|
+
const spans = spansByFamily[family];
|
|
1435
|
+
for (let i = 0; i < spans.length; i++) {
|
|
1436
|
+
events.push({ offset: spans[i].start, family, spanIndex: i, isEnd: false });
|
|
1437
|
+
events.push({ offset: spans[i].end, family, spanIndex: i, isEnd: true });
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
events.sort((a, b) => a.offset - b.offset || (a.isEnd ? 1 : -1));
|
|
1441
|
+
|
|
1442
|
+
// Build marked text, inserting one boundary marker per event.
|
|
1443
|
+
let markedText = "";
|
|
1444
|
+
let cursor = 0;
|
|
1445
|
+
for (const ev of events) {
|
|
1446
|
+
markedText += cleanText.slice(cursor, ev.offset);
|
|
1447
|
+
cursor = ev.offset;
|
|
1448
|
+
markedText += ev.isEnd ? SPAN_END_MARK : SPAN_START_MARK;
|
|
1449
|
+
}
|
|
1450
|
+
markedText += cleanText.slice(cursor);
|
|
1451
|
+
|
|
1452
|
+
// Run the full markdown pass on the marked text.
|
|
1453
|
+
const { text: rawPost } = conversationState._markdownToPlainText(markedText, {
|
|
1454
|
+
stripReplyTags: true,
|
|
1455
|
+
});
|
|
1456
|
+
|
|
1457
|
+
// Strip markers from the post-markdown text and record their post-strip positions.
|
|
1458
|
+
// Each marker, in document order, corresponds to the i-th event from `events`.
|
|
1459
|
+
const eventPostPositions = [];
|
|
1460
|
+
let stripped = "";
|
|
1461
|
+
for (let j = 0; j < rawPost.length; j++) {
|
|
1462
|
+
const ch = rawPost[j];
|
|
1463
|
+
if (ch === SPAN_START_MARK || ch === SPAN_END_MARK) {
|
|
1464
|
+
eventPostPositions.push(stripped.length);
|
|
1465
|
+
} else {
|
|
1466
|
+
stripped += ch;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const prefixLen = prefix.length;
|
|
1471
|
+
const result = {};
|
|
1472
|
+
for (const family of familyNames) {
|
|
1473
|
+
result[family] = spansByFamily[family].map((s) => ({ ...s }));
|
|
1474
|
+
}
|
|
1475
|
+
for (let k = 0; k < events.length; k++) {
|
|
1476
|
+
const ev = events[k];
|
|
1477
|
+
const postPos = eventPostPositions[k] != null ? eventPostPositions[k] : 0;
|
|
1478
|
+
const target = result[ev.family][ev.spanIndex];
|
|
1479
|
+
if (!target) continue;
|
|
1480
|
+
if (ev.isEnd) target.end = prefixLen + postPos;
|
|
1481
|
+
else target.start = prefixLen + postPos;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
return { text: `${prefix}${stripped}`, spansByFamily: result };
|
|
1485
|
+
}
|
|
1486
|
+
|
|
691
1487
|
gatewayBridge.on("message", (data) => {
|
|
692
1488
|
if (!sessionService.isCurrentSession(data.sessionKey)) return;
|
|
693
1489
|
const runId = data.runId || null;
|
|
1490
|
+
if (runId) {
|
|
1491
|
+
stopTypingForRun(runId, "assistant_message_committed");
|
|
1492
|
+
// Flush any pending throttled streaming text BEFORE broadcasting the
|
|
1493
|
+
// synthesized activity-idle. Pre-fix: activity-idle was broadcast first
|
|
1494
|
+
// (below), and the final flushed streaming chunk arrived at the client
|
|
1495
|
+
// AFTER stream_pipeline_turn_idle had already fired. The client's
|
|
1496
|
+
// VoiceIngressCoordinator then treated the trailing chunk as the start
|
|
1497
|
+
// of a new ingress turn (beginIngressStreamPipelineTurnIfNeeded returns
|
|
1498
|
+
// true once streamTurnIdleLogged is set), resetting
|
|
1499
|
+
// streaming_bleed_in_cursor_compose state and producing a visible
|
|
1500
|
+
// ~50-char streaming-text rewind on hardware (trace
|
|
1501
|
+
// 20260518-074144-Z ws 128→130). Flushing first ensures the final
|
|
1502
|
+
// chunk is processed during the still-active turn; the synthesized
|
|
1503
|
+
// activity-idle then runs after the buffer is up to date.
|
|
1504
|
+
clearStreamingThrottleTimer();
|
|
1505
|
+
flushPendingStreamingText();
|
|
1506
|
+
// Synthesize an activity-idle broadcast at assistant-message-commit (=
|
|
1507
|
+
// turn end at the gateway boundary). Upstream eventually emits its own
|
|
1508
|
+
// terminal activity message via gatewayBridge.on("activity",...) but
|
|
1509
|
+
// with ~1.5s median lag past run_complete, which leaves the
|
|
1510
|
+
// thinking-indicator spinner ticking visibly past the point the agent
|
|
1511
|
+
// has fully delivered its response. App-side ActivityGuardState dedupes
|
|
1512
|
+
// the later upstream terminal message by runId + activityId so the real
|
|
1513
|
+
// upstream activity remains authoritative for fields like detail/intent
|
|
1514
|
+
// — this synth only owns the state=idle transition timing. activityId
|
|
1515
|
+
// is namespaced so it cannot collide with a real upstream activityId.
|
|
1516
|
+
// origin/phase=lifecycle/end satisfies isTerminalActivityBoundary so
|
|
1517
|
+
// downstream consumers treat it as a real terminal boundary.
|
|
1518
|
+
broadcastActivity({
|
|
1519
|
+
state: "idle",
|
|
1520
|
+
runId,
|
|
1521
|
+
sessionKey: data.sessionKey || sessionService.ensureSessionKey(),
|
|
1522
|
+
origin: "lifecycle",
|
|
1523
|
+
phase: "end",
|
|
1524
|
+
category: "run_complete_synth",
|
|
1525
|
+
activityId: `run-complete-synth-${runId}`,
|
|
1526
|
+
});
|
|
1527
|
+
}
|
|
694
1528
|
const runPipeline = runId ? upstreamRunPipeline.get(runId) : null;
|
|
695
1529
|
if (runPipeline) {
|
|
696
1530
|
const completedAt = Date.now();
|
|
1531
|
+
if (operationRegistry && typeof operationRegistry.markRunPhase === "function") {
|
|
1532
|
+
operationRegistry.markRunPhase(runId, "complete");
|
|
1533
|
+
}
|
|
697
1534
|
emitDebug(
|
|
698
1535
|
"relay.protocol",
|
|
699
1536
|
"run_complete",
|
|
@@ -730,10 +1567,26 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
730
1567
|
}),
|
|
731
1568
|
);
|
|
732
1569
|
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1570
|
+
// (Moved above: flushPendingStreamingText now runs before broadcastActivity
|
|
1571
|
+
// so the client never sees a chunk arrive after stream_pipeline_turn_idle.)
|
|
1572
|
+
const sanitizedContent =
|
|
1573
|
+
data.role === "assistant"
|
|
1574
|
+
? sanitizeAssistantContentBlocks(data.content)
|
|
1575
|
+
: data.content;
|
|
1576
|
+
conversationState.addMessage(data.role, sanitizedContent);
|
|
1577
|
+
if (data.role === "assistant") {
|
|
1578
|
+
emitDebug(
|
|
1579
|
+
"openclaw.message",
|
|
1580
|
+
"agent_message",
|
|
1581
|
+
"info",
|
|
1582
|
+
{ sessionKey: data.sessionKey || sessionService.ensureSessionKey() },
|
|
1583
|
+
() => ({ text: fullMessageText(sanitizedContent), runId: data.runId || null }),
|
|
1584
|
+
);
|
|
1585
|
+
}
|
|
736
1586
|
broadcastPages();
|
|
1587
|
+
// Refresh session context (token count) after each committed assistant
|
|
1588
|
+
// message. Runs async so it doesn't block the message handler.
|
|
1589
|
+
sessionContextService.refreshActiveSessionContext().catch(() => {});
|
|
737
1590
|
|
|
738
1591
|
const voiceRuntime = getVoiceRuntime();
|
|
739
1592
|
if (voiceRuntime && typeof voiceRuntime.onAgentMessage === "function") {
|
|
@@ -752,11 +1605,15 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
752
1605
|
origin === "lifecycle" &&
|
|
753
1606
|
phase === "start"
|
|
754
1607
|
) {
|
|
1608
|
+
startTyping(runId, data.sessionKey, "lifecycle_start");
|
|
755
1609
|
const now = Date.now();
|
|
756
1610
|
const runPipeline = upstreamRunPipeline.get(runId);
|
|
757
1611
|
if (runPipeline && !runPipeline.lifecycleStartAt) {
|
|
758
1612
|
runPipeline.lifecycleStartAt = now;
|
|
759
1613
|
}
|
|
1614
|
+
if (operationRegistry && typeof operationRegistry.markRunPhase === "function") {
|
|
1615
|
+
operationRegistry.markRunPhase(runId, "lifecycle_start");
|
|
1616
|
+
}
|
|
760
1617
|
emitDebug(
|
|
761
1618
|
"relay.protocol",
|
|
762
1619
|
"run_lifecycle_start",
|
|
@@ -768,62 +1625,155 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
768
1625
|
ackToRunStartMs: runPipeline && runPipeline.ackAt ? (now - runPipeline.ackAt) : null,
|
|
769
1626
|
}),
|
|
770
1627
|
);
|
|
1628
|
+
// Signal run-start to session-context service so WebUI can show the
|
|
1629
|
+
// spinner / suppress stale token counts during an active run.
|
|
1630
|
+
cachedRunActiveSessionKey = data.sessionKey || sessionService.ensureSessionKey();
|
|
1631
|
+
sessionContextService.broadcastRunActive(true);
|
|
1632
|
+
}
|
|
1633
|
+
if (runId && isTerminalActivityBoundary(data.state, phase, origin)) {
|
|
1634
|
+
stopTypingForRun(runId, "terminal_activity_boundary");
|
|
1635
|
+
// Run ended normally — clear run-active state and refresh token count.
|
|
1636
|
+
if (cachedRunActiveSessionKey) {
|
|
1637
|
+
cachedRunActiveSessionKey = null;
|
|
1638
|
+
sessionContextService.broadcastRunActive(false);
|
|
1639
|
+
sessionContextService.refreshActiveSessionContext().catch(() => {});
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
let activity = data;
|
|
1643
|
+
let shouldRefreshProviderUsageInBackground = false;
|
|
1644
|
+
if (isProviderRateLimitedLifecycleError(data)) {
|
|
1645
|
+
if (!data.failoverPending) {
|
|
1646
|
+
const { provider: outcomeProvider } = activeProviderContext();
|
|
1647
|
+
if (outcomeProvider) {
|
|
1648
|
+
providerOutcomeState.set(outcomeProvider, {
|
|
1649
|
+
lastOutcome: "exhausted",
|
|
1650
|
+
lastOutcomeAtMs: now(),
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
const rateLimitInfo = buildRateLimitInfoFromSnapshot(projectProviderUsageSnapshot());
|
|
1655
|
+
if (rateLimitInfo) {
|
|
1656
|
+
activity = {
|
|
1657
|
+
...data,
|
|
1658
|
+
rateLimitInfo,
|
|
1659
|
+
};
|
|
1660
|
+
} else {
|
|
1661
|
+
shouldRefreshProviderUsageInBackground = true;
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
broadcastActivity(activity);
|
|
1665
|
+
if (shouldRefreshProviderUsageInBackground) {
|
|
1666
|
+
refreshProviderUsage(true).catch((err) => {
|
|
1667
|
+
logger.warn(`[relay] Provider usage refresh failed after rate limit activity: ${err.message}`);
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
if (runId && data.state === "idle" && data.isError === true && phase === "error") {
|
|
1671
|
+
upstreamRunPipeline.delete(runId);
|
|
1672
|
+
// Run ended with an error — clear run-active state and refresh token count.
|
|
1673
|
+
if (cachedRunActiveSessionKey) {
|
|
1674
|
+
cachedRunActiveSessionKey = null;
|
|
1675
|
+
sessionContextService.broadcastRunActive(false);
|
|
1676
|
+
sessionContextService.refreshActiveSessionContext().catch(() => {});
|
|
1677
|
+
}
|
|
771
1678
|
}
|
|
772
|
-
broadcastActivity(data);
|
|
773
1679
|
});
|
|
774
1680
|
|
|
775
1681
|
gatewayBridge.on("streaming", (data) => {
|
|
776
1682
|
if (!sessionService.isCurrentSession(data.sessionKey)) return;
|
|
1683
|
+
const sessionKey = data.sessionKey || sessionService.ensureSessionKey();
|
|
1684
|
+
const { provider: outcomeProvider } = activeProviderContext();
|
|
1685
|
+
if (outcomeProvider) {
|
|
1686
|
+
providerOutcomeState.set(outcomeProvider, {
|
|
1687
|
+
lastOutcome: "ready",
|
|
1688
|
+
lastOutcomeAtMs: now(),
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
777
1691
|
const runId = data.runId || null;
|
|
1692
|
+
const nowMs = now();
|
|
1693
|
+
const gatewayReceivedAtMs = Number.isFinite(data.gatewayReceivedAtMs)
|
|
1694
|
+
? Math.floor(data.gatewayReceivedAtMs)
|
|
1695
|
+
: null;
|
|
1696
|
+
const rawAssistantChars = Number.isFinite(data.rawAssistantChars)
|
|
1697
|
+
? Math.max(0, Math.floor(data.rawAssistantChars))
|
|
1698
|
+
: (typeof data.text === "string" ? data.text.length : null);
|
|
1699
|
+
const assistantDeltaChars = Number.isFinite(data.assistantDeltaChars)
|
|
1700
|
+
? Math.max(0, Math.floor(data.assistantDeltaChars))
|
|
1701
|
+
: null;
|
|
1702
|
+
const firstGatewayChunk =
|
|
1703
|
+
typeof data.firstGatewayChunk === "boolean" ? data.firstGatewayChunk : null;
|
|
778
1704
|
if (runId) {
|
|
779
|
-
const now = Date.now();
|
|
780
1705
|
const runPipeline = upstreamRunPipeline.get(runId);
|
|
781
1706
|
if (runPipeline && !runPipeline.firstStreamingAt) {
|
|
782
|
-
runPipeline.firstStreamingAt =
|
|
1707
|
+
runPipeline.firstStreamingAt = nowMs;
|
|
1708
|
+
runPipeline.firstGatewayReceivedAt = gatewayReceivedAtMs;
|
|
1709
|
+
runPipeline.firstGatewayChars = rawAssistantChars;
|
|
1710
|
+
if (operationRegistry && typeof operationRegistry.markRunPhase === "function") {
|
|
1711
|
+
operationRegistry.markRunPhase(runId, "first_stream");
|
|
1712
|
+
}
|
|
783
1713
|
emitDebug(
|
|
784
1714
|
"relay.protocol",
|
|
785
1715
|
"run_first_streaming",
|
|
786
1716
|
"debug",
|
|
787
|
-
{ sessionKey
|
|
1717
|
+
{ sessionKey, runId },
|
|
788
1718
|
() => ({
|
|
789
1719
|
messageId: runPipeline.messageId,
|
|
790
|
-
sendToFirstStreamingMs:
|
|
791
|
-
ackToFirstStreamingMs: runPipeline.ackAt ? (
|
|
1720
|
+
sendToFirstStreamingMs: nowMs - runPipeline.sendStartedAt,
|
|
1721
|
+
ackToFirstStreamingMs: runPipeline.ackAt ? (nowMs - runPipeline.ackAt) : null,
|
|
792
1722
|
runStartToFirstStreamingMs: runPipeline.lifecycleStartAt
|
|
793
|
-
? (
|
|
1723
|
+
? (nowMs - runPipeline.lifecycleStartAt)
|
|
794
1724
|
: null,
|
|
1725
|
+
firstGatewayChars: rawAssistantChars,
|
|
1726
|
+
gatewayToRelayIngressMs:
|
|
1727
|
+
gatewayReceivedAtMs != null ? (nowMs - gatewayReceivedAtMs) : null,
|
|
795
1728
|
}),
|
|
796
1729
|
);
|
|
797
1730
|
}
|
|
798
1731
|
}
|
|
799
|
-
const {
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
1732
|
+
const prefix = `${agentIdentity.name || "Agent"}: `;
|
|
1733
|
+
// Parse-on-flush coalescing: store the raw cumulative text and defer the
|
|
1734
|
+
// parseTaggedSpans + markdown pass to flushPendingStreamingText. Chunks
|
|
1735
|
+
// superseded inside the throttle window never pay parser cost.
|
|
1736
|
+
pendingStreaming = {
|
|
1737
|
+
rawText: data.text,
|
|
1738
|
+
prefix,
|
|
1739
|
+
sessionKey,
|
|
1740
|
+
runId,
|
|
1741
|
+
rawAssistantChars,
|
|
1742
|
+
assistantDeltaChars,
|
|
1743
|
+
firstGatewayChunk,
|
|
1744
|
+
gatewayReceivedAtMs,
|
|
1745
|
+
flushReason: "throttled_flush",
|
|
1746
|
+
};
|
|
804
1747
|
emitDebug(
|
|
805
1748
|
"openclaw.run",
|
|
806
1749
|
"streaming",
|
|
807
1750
|
"debug",
|
|
808
1751
|
{
|
|
809
|
-
sessionKey
|
|
1752
|
+
sessionKey,
|
|
810
1753
|
runId,
|
|
811
1754
|
},
|
|
812
1755
|
() => ({
|
|
813
|
-
|
|
1756
|
+
// Raw cumulative chars: the parsed/markdown length is no longer
|
|
1757
|
+
// computed here (parse-on-flush moves it to flushPendingStreamingText).
|
|
1758
|
+
textChars: pendingStreaming ? pendingStreaming.rawText.length : 0,
|
|
1759
|
+
rawAssistantChars,
|
|
1760
|
+
assistantDeltaChars,
|
|
1761
|
+
firstGatewayChunk,
|
|
1762
|
+
gatewayReceivedAtMs,
|
|
1763
|
+
gatewayToRelayIngressMs:
|
|
1764
|
+
gatewayReceivedAtMs != null ? (nowMs - gatewayReceivedAtMs) : null,
|
|
814
1765
|
}),
|
|
815
1766
|
);
|
|
816
1767
|
|
|
817
1768
|
if (!streamingThrottleTimer) {
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
server.broadcast(handler.formatStreaming(pendingStreamingText));
|
|
1769
|
+
if (pendingStreaming) {
|
|
1770
|
+
pendingStreaming.flushReason = "first_immediate";
|
|
821
1771
|
}
|
|
822
|
-
|
|
1772
|
+
flushPendingStreamingText();
|
|
823
1773
|
streamingThrottleTimer = setTimeout(() => {
|
|
824
1774
|
streamingThrottleTimer = null;
|
|
825
1775
|
flushPendingStreamingText();
|
|
826
|
-
},
|
|
1776
|
+
}, STREAMING_REBROADCAST_THROTTLE_MS);
|
|
827
1777
|
}
|
|
828
1778
|
});
|
|
829
1779
|
|
|
@@ -832,15 +1782,27 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
832
1782
|
});
|
|
833
1783
|
|
|
834
1784
|
gatewayBridge.on("agentIdentity", (data) => {
|
|
835
|
-
|
|
836
|
-
conversationState.setAgentName(agentName || "Agent");
|
|
837
|
-
broadcastStatus();
|
|
1785
|
+
applyAgentIdentity(data, "agent_identity_event");
|
|
838
1786
|
});
|
|
839
1787
|
|
|
840
1788
|
gatewayBridge.on("connected", () => {
|
|
841
1789
|
refreshUpstreamBootstrap("connected_event").catch((err) => {
|
|
842
1790
|
logger.warn(`[relay] Upstream connected bootstrap failed: ${err.message}`);
|
|
843
1791
|
});
|
|
1792
|
+
// Give WebUI an initial session-context snapshot on reconnect so it has a
|
|
1793
|
+
// baseline token count before the first agent_end refresh fires.
|
|
1794
|
+
sessionContextService.refreshActiveSessionContext().catch(() => {});
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
gatewayBridge.on("timing", (rawEvent) => {
|
|
1798
|
+
const timing = normalizeGatewayTimingEvent(rawEvent);
|
|
1799
|
+
emitDebug(
|
|
1800
|
+
timing.category,
|
|
1801
|
+
timing.event,
|
|
1802
|
+
timing.severity,
|
|
1803
|
+
timing.context,
|
|
1804
|
+
() => timing.data,
|
|
1805
|
+
);
|
|
844
1806
|
});
|
|
845
1807
|
|
|
846
1808
|
gatewayBridge.on("protocol", (data) => {
|
|
@@ -870,9 +1832,29 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
870
1832
|
"approval_requested",
|
|
871
1833
|
"info",
|
|
872
1834
|
{ sessionKey: sessionService.ensureSessionKey() },
|
|
873
|
-
() =>
|
|
874
|
-
|
|
875
|
-
|
|
1835
|
+
() => {
|
|
1836
|
+
const request = data && data.request ? data.request : {};
|
|
1837
|
+
const approvalKind =
|
|
1838
|
+
data && data.approvalKind === "plugin"
|
|
1839
|
+
? "plugin"
|
|
1840
|
+
: data && typeof data.id === "string" && data.id.startsWith("plugin:")
|
|
1841
|
+
? "plugin"
|
|
1842
|
+
: "exec";
|
|
1843
|
+
const command =
|
|
1844
|
+
typeof request.command === "string"
|
|
1845
|
+
? request.command
|
|
1846
|
+
: approvalKind === "plugin" && typeof request.title === "string"
|
|
1847
|
+
? request.title
|
|
1848
|
+
: "";
|
|
1849
|
+
return {
|
|
1850
|
+
approvalId: data && data.id ? data.id : null,
|
|
1851
|
+
approvalKind,
|
|
1852
|
+
host: typeof request.host === "string" ? request.host : null,
|
|
1853
|
+
commandLength: command.length,
|
|
1854
|
+
requestCommandIsEmpty: command.length === 0,
|
|
1855
|
+
hasSystemRunPlan: !!(request.systemRunPlan && typeof request.systemRunPlan === "object"),
|
|
1856
|
+
};
|
|
1857
|
+
},
|
|
876
1858
|
);
|
|
877
1859
|
const server = getServer();
|
|
878
1860
|
if (server) {
|
|
@@ -909,9 +1891,19 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
909
1891
|
});
|
|
910
1892
|
|
|
911
1893
|
return {
|
|
1894
|
+
clearTyping,
|
|
1895
|
+
compactActiveSession: (sessionKey) =>
|
|
1896
|
+
sessionContextService.compactActiveSession(sessionKey),
|
|
912
1897
|
getAgentName,
|
|
1898
|
+
getAgentEmoji,
|
|
1899
|
+
getAgentAvatarDataUri,
|
|
1900
|
+
getAgentAvatarHash,
|
|
1901
|
+
getAgentAvatarDataUriByHash,
|
|
913
1902
|
getModelsCatalogSnapshot,
|
|
1903
|
+
getProviderUsageSnapshot,
|
|
914
1904
|
getSkillsCatalogSnapshot,
|
|
1905
|
+
handleCurrentSessionModelConfigChanged,
|
|
1906
|
+
handleCurrentSessionModelConfigCleared,
|
|
915
1907
|
handleSessionChanged,
|
|
916
1908
|
isConnected,
|
|
917
1909
|
start() {
|
|
@@ -921,11 +1913,126 @@ export function createUpstreamRuntime(opts = {}) {
|
|
|
921
1913
|
clearStreamingThrottleTimer();
|
|
922
1914
|
clearBootstrapRefreshTimer();
|
|
923
1915
|
bootstrapRefreshNonce += 1;
|
|
924
|
-
|
|
1916
|
+
pendingStreaming = null;
|
|
1917
|
+
activeTyping = null;
|
|
925
1918
|
inFlightModelsCatalogFetch = null;
|
|
926
1919
|
inFlightSkillsCatalogFetch = null;
|
|
927
1920
|
upstreamRunPipeline.clear();
|
|
928
1921
|
},
|
|
929
1922
|
trackAcceptedRun,
|
|
930
1923
|
};
|
|
1924
|
+
|
|
1925
|
+
function isTerminalActivityBoundary(state, phase, origin) {
|
|
1926
|
+
const normalizedOrigin = typeof origin === "string" ? origin.trim().toLowerCase() : "";
|
|
1927
|
+
if (normalizedOrigin !== "lifecycle") {
|
|
1928
|
+
return false;
|
|
1929
|
+
}
|
|
1930
|
+
const normalizedState = typeof state === "string" ? state.trim().toLowerCase() : "";
|
|
1931
|
+
if (normalizedState === "idle" || normalizedState === "error") {
|
|
1932
|
+
return true;
|
|
1933
|
+
}
|
|
1934
|
+
const normalizedPhase = typeof phase === "string" ? phase.trim().toLowerCase() : "";
|
|
1935
|
+
return (
|
|
1936
|
+
normalizedPhase === "error" ||
|
|
1937
|
+
normalizedPhase === "end" ||
|
|
1938
|
+
normalizedPhase === "complete" ||
|
|
1939
|
+
normalizedPhase === "completed" ||
|
|
1940
|
+
normalizedPhase === "done" ||
|
|
1941
|
+
normalizedPhase === "failed" ||
|
|
1942
|
+
normalizedPhase === "finish" ||
|
|
1943
|
+
normalizedPhase === "finished"
|
|
1944
|
+
);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
function isProviderRateLimitedLifecycleError(activity) {
|
|
1948
|
+
return !!(
|
|
1949
|
+
activity &&
|
|
1950
|
+
activity.isError === true &&
|
|
1951
|
+
activity.code === "provider_rate_limited" &&
|
|
1952
|
+
typeof activity.origin === "string" &&
|
|
1953
|
+
activity.origin.trim().toLowerCase() === "lifecycle" &&
|
|
1954
|
+
typeof activity.phase === "string" &&
|
|
1955
|
+
activity.phase.trim().toLowerCase() === "error"
|
|
1956
|
+
);
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
function emitTypingUpdate(state, runId, sessionKey, reason) {
|
|
1960
|
+
const server = getServer();
|
|
1961
|
+
if (
|
|
1962
|
+
!server ||
|
|
1963
|
+
!handler ||
|
|
1964
|
+
typeof handler.formatTyping !== "function" ||
|
|
1965
|
+
typeof runId !== "string" ||
|
|
1966
|
+
!runId.trim()
|
|
1967
|
+
) {
|
|
1968
|
+
return false;
|
|
1969
|
+
}
|
|
1970
|
+
const resolvedSessionKey =
|
|
1971
|
+
typeof sessionKey === "string" && sessionKey.trim()
|
|
1972
|
+
? sessionKey.trim()
|
|
1973
|
+
: sessionService.ensureSessionKey();
|
|
1974
|
+
server.broadcast(
|
|
1975
|
+
handler.formatTyping({
|
|
1976
|
+
state,
|
|
1977
|
+
runId: runId.trim(),
|
|
1978
|
+
sessionKey: resolvedSessionKey,
|
|
1979
|
+
}),
|
|
1980
|
+
);
|
|
1981
|
+
emitDebug(
|
|
1982
|
+
"app.timeline",
|
|
1983
|
+
"typing",
|
|
1984
|
+
"debug",
|
|
1985
|
+
{ sessionKey: resolvedSessionKey, runId: runId.trim() },
|
|
1986
|
+
() => ({
|
|
1987
|
+
state,
|
|
1988
|
+
reason: reason || null,
|
|
1989
|
+
}),
|
|
1990
|
+
);
|
|
1991
|
+
return true;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function startTyping(runId, sessionKey, reason) {
|
|
1995
|
+
if (typeof runId !== "string" || !runId.trim()) {
|
|
1996
|
+
return false;
|
|
1997
|
+
}
|
|
1998
|
+
const normalizedRunId = runId.trim();
|
|
1999
|
+
const resolvedSessionKey =
|
|
2000
|
+
typeof sessionKey === "string" && sessionKey.trim()
|
|
2001
|
+
? sessionKey.trim()
|
|
2002
|
+
: sessionService.ensureSessionKey();
|
|
2003
|
+
if (activeTyping && activeTyping.runId === normalizedRunId) {
|
|
2004
|
+
return false;
|
|
2005
|
+
}
|
|
2006
|
+
if (activeTyping) {
|
|
2007
|
+
clearTyping("superseded_by_new_run");
|
|
2008
|
+
}
|
|
2009
|
+
activeTyping = {
|
|
2010
|
+
runId: normalizedRunId,
|
|
2011
|
+
sessionKey: resolvedSessionKey,
|
|
2012
|
+
};
|
|
2013
|
+
return emitTypingUpdate("start", normalizedRunId, resolvedSessionKey, reason);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function stopTypingForRun(runId, reason) {
|
|
2017
|
+
if (
|
|
2018
|
+
!activeTyping ||
|
|
2019
|
+
typeof runId !== "string" ||
|
|
2020
|
+
!runId.trim() ||
|
|
2021
|
+
activeTyping.runId !== runId.trim()
|
|
2022
|
+
) {
|
|
2023
|
+
return false;
|
|
2024
|
+
}
|
|
2025
|
+
const { runId: activeRunId, sessionKey } = activeTyping;
|
|
2026
|
+
activeTyping = null;
|
|
2027
|
+
return emitTypingUpdate("stop", activeRunId, sessionKey, reason);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
function clearTyping(reason) {
|
|
2031
|
+
if (!activeTyping) {
|
|
2032
|
+
return false;
|
|
2033
|
+
}
|
|
2034
|
+
const { runId, sessionKey } = activeTyping;
|
|
2035
|
+
activeTyping = null;
|
|
2036
|
+
return emitTypingUpdate("stop", runId, sessionKey, reason || "clear_typing");
|
|
2037
|
+
}
|
|
931
2038
|
}
|