mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 23:55:12 +00:00
2249 lines
70 KiB
TypeScript
2249 lines
70 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { resolveAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
|
|
import {
|
|
listAgentIds,
|
|
resolveAgentConfig,
|
|
resolveAgentEffectiveModelPrimary,
|
|
resolveAgentModelFallbacksOverride,
|
|
resolveAgentWorkspaceDir,
|
|
resolveDefaultAgentId,
|
|
} from "../agents/agent-scope.js";
|
|
import { lookupContextTokens, resolveContextTokensForModel } from "../agents/context.js";
|
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
|
import {
|
|
findModelCatalogEntry,
|
|
modelSupportsInput,
|
|
type ModelCatalogEntry,
|
|
} from "../agents/model-catalog.js";
|
|
import {
|
|
inferUniqueProviderFromConfiguredModels,
|
|
isCliProvider,
|
|
normalizeStoredOverrideModel,
|
|
parseModelRef,
|
|
resolveConfiguredModelRef,
|
|
resolveDefaultModelForAgent,
|
|
resolvePersistedSelectedModelRef,
|
|
resolveThinkingDefault,
|
|
} from "../agents/model-selection.js";
|
|
import {
|
|
buildSubagentRunReadIndex,
|
|
countActiveDescendantRuns,
|
|
getSessionDisplaySubagentRunByChildSessionKey,
|
|
getSubagentSessionRuntimeMs,
|
|
getSubagentSessionStartedAt,
|
|
isSubagentRunLive,
|
|
listSubagentRunsForController,
|
|
resolveSubagentSessionStatus,
|
|
} from "../agents/subagent-registry-read.js";
|
|
import {
|
|
RECENT_ENDED_SUBAGENT_CHILD_SESSION_MS,
|
|
shouldKeepSubagentRunChildLink,
|
|
} from "../agents/subagent-run-liveness.js";
|
|
import { listThinkingLevelOptions } from "../auto-reply/thinking.js";
|
|
import { getRuntimeConfig } from "../config/io.js";
|
|
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
|
|
import { resolveStateDir } from "../config/paths.js";
|
|
import {
|
|
buildGroupDisplayName,
|
|
loadSessionStore,
|
|
resolveAllAgentSessionStoreTargetsSync,
|
|
resolveAgentMainSessionKey,
|
|
resolveFreshSessionTotalTokens,
|
|
resolveStorePath,
|
|
type SessionEntry,
|
|
type SessionStoreTarget,
|
|
type SessionScope,
|
|
} from "../config/sessions.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { openRootFileSync } from "../infra/boundary-file-read.js";
|
|
import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.js";
|
|
import {
|
|
DEFAULT_AGENT_ID,
|
|
normalizeAgentId,
|
|
normalizeMainKey,
|
|
parseAgentSessionKey,
|
|
} from "../routing/session-key.js";
|
|
import { isCronRunSessionKey } from "../sessions/session-key-utils.js";
|
|
import {
|
|
AVATAR_MAX_BYTES,
|
|
isAvatarDataUrl,
|
|
isAvatarHttpUrl,
|
|
isPathWithinRoot,
|
|
isWorkspaceRelativeAvatarPath,
|
|
resolveAvatarMime,
|
|
} from "../shared/avatar-policy.js";
|
|
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
normalizeOptionalLowercaseString,
|
|
} from "../shared/string-coerce.js";
|
|
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js";
|
|
import { estimateUsageCost, resolveModelCostConfig } from "../utils/usage-format.js";
|
|
import {
|
|
canonicalizeSpawnedByForAgent,
|
|
resolveSessionStoreAgentId,
|
|
resolveSessionStoreKey,
|
|
resolveStoredSessionKeyForAgentStore,
|
|
} from "./session-store-key.js";
|
|
import {
|
|
readRecentSessionUsageFromTranscript,
|
|
readSessionTitleFieldsFromTranscriptAsync,
|
|
readSessionTitleFieldsFromTranscript,
|
|
} from "./session-utils.fs.js";
|
|
import type {
|
|
GatewayAgentRow,
|
|
GatewaySessionRow,
|
|
GatewaySessionsDefaults,
|
|
SessionRunStatus,
|
|
SessionsListResult,
|
|
} from "./session-utils.types.js";
|
|
|
|
export {
|
|
archiveFileOnDisk,
|
|
archiveSessionTranscripts,
|
|
attachOpenClawTranscriptMeta,
|
|
capArrayByJsonBytes,
|
|
readFirstUserMessageFromTranscript,
|
|
readLastMessagePreviewFromTranscript,
|
|
readLatestSessionUsageFromTranscriptAsync,
|
|
readLatestRecentSessionUsageFromTranscriptAsync,
|
|
readRecentSessionUsageFromTranscriptAsync,
|
|
readRecentSessionMessagesAsync,
|
|
readRecentSessionMessagesWithStatsAsync,
|
|
readRecentSessionTranscriptLines,
|
|
readRecentSessionUsageFromTranscript,
|
|
readSessionMessageCountAsync,
|
|
readSessionTitleFieldsFromTranscript,
|
|
readSessionTitleFieldsFromTranscriptAsync,
|
|
readSessionPreviewItemsFromTranscript,
|
|
readSessionMessagesAsync,
|
|
visitSessionMessagesAsync,
|
|
resolveSessionTranscriptCandidates,
|
|
} from "./session-utils.fs.js";
|
|
export type { ReadSessionMessagesAsyncOptions } from "./session-utils.fs.js";
|
|
export { canonicalizeSpawnedByForAgent, resolveSessionStoreKey } from "./session-store-key.js";
|
|
export type {
|
|
GatewayAgentRow,
|
|
GatewaySessionRow,
|
|
GatewaySessionsDefaults,
|
|
SessionsListResult,
|
|
SessionsPatchResult,
|
|
SessionsPreviewEntry,
|
|
SessionsPreviewResult,
|
|
} from "./session-utils.types.js";
|
|
|
|
const DERIVED_TITLE_MAX_LEN = 60;
|
|
|
|
function tryResolveExistingPath(value: string): string | null {
|
|
try {
|
|
return fs.realpathSync(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function resolveIdentityAvatarUrl(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
avatar: string | undefined,
|
|
): string | undefined {
|
|
if (!avatar) {
|
|
return undefined;
|
|
}
|
|
const trimmed = normalizeOptionalString(avatar) ?? "";
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
if (isAvatarDataUrl(trimmed) || isAvatarHttpUrl(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
if (!isWorkspaceRelativeAvatarPath(trimmed)) {
|
|
return undefined;
|
|
}
|
|
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
|
const workspaceRoot = tryResolveExistingPath(workspaceDir) ?? path.resolve(workspaceDir);
|
|
const resolvedCandidate = path.resolve(workspaceRoot, trimmed);
|
|
if (!isPathWithinRoot(workspaceRoot, resolvedCandidate)) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const opened = openRootFileSync({
|
|
absolutePath: resolvedCandidate,
|
|
rootPath: workspaceRoot,
|
|
rootRealPath: workspaceRoot,
|
|
boundaryLabel: "workspace root",
|
|
maxBytes: AVATAR_MAX_BYTES,
|
|
skipLexicalRootCheck: true,
|
|
});
|
|
if (!opened.ok) {
|
|
return undefined;
|
|
}
|
|
try {
|
|
const buffer = fs.readFileSync(opened.fd);
|
|
const mime = resolveAvatarMime(resolvedCandidate);
|
|
return `data:${mime};base64,${buffer.toString("base64")}`;
|
|
} finally {
|
|
fs.closeSync(opened.fd);
|
|
}
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function formatSessionIdPrefix(sessionId: string, updatedAt?: number | null): string {
|
|
const prefix = sessionId.slice(0, 8);
|
|
if (updatedAt && updatedAt > 0) {
|
|
const d = new Date(updatedAt);
|
|
const date = d.toISOString().slice(0, 10);
|
|
return `${prefix} (${date})`;
|
|
}
|
|
return prefix;
|
|
}
|
|
|
|
function truncateTitle(text: string, maxLen: number): string {
|
|
if (text.length <= maxLen) {
|
|
return text;
|
|
}
|
|
const cut = text.slice(0, maxLen - 1);
|
|
const lastSpace = cut.lastIndexOf(" ");
|
|
if (lastSpace > maxLen * 0.6) {
|
|
return cut.slice(0, lastSpace) + "…";
|
|
}
|
|
return cut + "…";
|
|
}
|
|
|
|
export function deriveSessionTitle(
|
|
entry: SessionEntry | undefined,
|
|
firstUserMessage?: string | null,
|
|
): string | undefined {
|
|
if (!entry) {
|
|
return undefined;
|
|
}
|
|
|
|
if (normalizeOptionalString(entry.displayName)) {
|
|
return normalizeOptionalString(entry.displayName);
|
|
}
|
|
|
|
if (normalizeOptionalString(entry.subject)) {
|
|
return normalizeOptionalString(entry.subject);
|
|
}
|
|
|
|
if (firstUserMessage?.trim()) {
|
|
const normalized = firstUserMessage.replace(/\s+/g, " ").trim();
|
|
return truncateTitle(normalized, DERIVED_TITLE_MAX_LEN);
|
|
}
|
|
|
|
if (entry.sessionId) {
|
|
return formatSessionIdPrefix(entry.sessionId, entry.updatedAt);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function resolveSessionRuntimeMs(
|
|
run: { startedAt?: number; endedAt?: number; accumulatedRuntimeMs?: number } | null,
|
|
now: number,
|
|
) {
|
|
return getSubagentSessionRuntimeMs(run, now);
|
|
}
|
|
|
|
function resolvePositiveNumber(value: number | null | undefined): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
}
|
|
|
|
function resolveNonNegativeNumber(value: number | null | undefined): number | undefined {
|
|
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
}
|
|
|
|
function resolveLatestCompactionCheckpoint(
|
|
entry?: Pick<SessionEntry, "compactionCheckpoints"> | null,
|
|
): NonNullable<SessionEntry["compactionCheckpoints"]>[number] | undefined {
|
|
const checkpoints = entry?.compactionCheckpoints;
|
|
if (!Array.isArray(checkpoints) || checkpoints.length === 0) {
|
|
return undefined;
|
|
}
|
|
return checkpoints.reduce((latest, checkpoint) =>
|
|
!latest || checkpoint.createdAt > latest.createdAt ? checkpoint : latest,
|
|
);
|
|
}
|
|
|
|
function buildCompactionCheckpointPreview(
|
|
checkpoint: NonNullable<SessionEntry["compactionCheckpoints"]>[number] | undefined,
|
|
): GatewaySessionRow["latestCompactionCheckpoint"] {
|
|
if (!checkpoint) {
|
|
return undefined;
|
|
}
|
|
const checkpointId = normalizeOptionalString(checkpoint.checkpointId);
|
|
const createdAt = checkpoint.createdAt;
|
|
const reason = checkpoint.reason;
|
|
if (!checkpointId || typeof createdAt !== "number" || !Number.isFinite(createdAt)) {
|
|
return undefined;
|
|
}
|
|
if (
|
|
reason !== "manual" &&
|
|
reason !== "auto-threshold" &&
|
|
reason !== "overflow-retry" &&
|
|
reason !== "timeout-retry"
|
|
) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
checkpointId,
|
|
createdAt,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
function resolveEstimatedSessionCostUsd(params: {
|
|
cfg: OpenClawConfig;
|
|
provider?: string;
|
|
model?: string;
|
|
entry?: Pick<
|
|
SessionEntry,
|
|
"estimatedCostUsd" | "inputTokens" | "outputTokens" | "cacheRead" | "cacheWrite"
|
|
>;
|
|
explicitCostUsd?: number;
|
|
}): number | undefined {
|
|
const explicitCostUsd = resolveNonNegativeNumber(
|
|
params.explicitCostUsd ?? params.entry?.estimatedCostUsd,
|
|
);
|
|
if (explicitCostUsd !== undefined) {
|
|
return explicitCostUsd;
|
|
}
|
|
const input = resolvePositiveNumber(params.entry?.inputTokens);
|
|
const output = resolvePositiveNumber(params.entry?.outputTokens);
|
|
const cacheRead = resolvePositiveNumber(params.entry?.cacheRead);
|
|
const cacheWrite = resolvePositiveNumber(params.entry?.cacheWrite);
|
|
if (
|
|
input === undefined &&
|
|
output === undefined &&
|
|
cacheRead === undefined &&
|
|
cacheWrite === undefined
|
|
) {
|
|
return undefined;
|
|
}
|
|
const cost = resolveModelCostConfig({
|
|
provider: params.provider,
|
|
model: params.model,
|
|
config: params.cfg,
|
|
});
|
|
if (!cost) {
|
|
return undefined;
|
|
}
|
|
const estimated = estimateUsageCost({
|
|
usage: {
|
|
...(input !== undefined ? { input } : {}),
|
|
...(output !== undefined ? { output } : {}),
|
|
...(cacheRead !== undefined ? { cacheRead } : {}),
|
|
...(cacheWrite !== undefined ? { cacheWrite } : {}),
|
|
},
|
|
cost,
|
|
});
|
|
return resolveNonNegativeNumber(estimated);
|
|
}
|
|
|
|
const STALE_STORE_ONLY_CHILD_LINK_MS = 60 * 60 * 1_000;
|
|
|
|
function isFinitePositiveTimestamp(value: unknown): value is number {
|
|
return typeof value === "number" && Number.isFinite(value) && value > 0;
|
|
}
|
|
|
|
function isTerminalSessionStatus(status: unknown): status is Exclude<SessionRunStatus, "running"> {
|
|
return status === "done" || status === "failed" || status === "killed" || status === "timeout";
|
|
}
|
|
|
|
function shouldKeepStoreOnlyChildLink(entry: SessionEntry, now: number): boolean {
|
|
if (isTerminalSessionStatus(entry.status) || isFinitePositiveTimestamp(entry.endedAt)) {
|
|
const endedAt = isFinitePositiveTimestamp(entry.endedAt) ? entry.endedAt : entry.updatedAt;
|
|
return (
|
|
isFinitePositiveTimestamp(endedAt) && now - endedAt <= RECENT_ENDED_SUBAGENT_CHILD_SESSION_MS
|
|
);
|
|
}
|
|
if (entry.status === "running" || isFinitePositiveTimestamp(entry.startedAt)) {
|
|
return true;
|
|
}
|
|
return (
|
|
isFinitePositiveTimestamp(entry.updatedAt) &&
|
|
now - entry.updatedAt <= STALE_STORE_ONLY_CHILD_LINK_MS
|
|
);
|
|
}
|
|
|
|
type SessionListRowContext = {
|
|
subagentRuns: ReturnType<typeof buildSubagentRunReadIndex>;
|
|
storeChildSessionsByKey: Map<string, string[]>;
|
|
selectedModelByOverrideRef: Map<string, ReturnType<typeof resolveSessionModelRef>>;
|
|
thinkingLevelsByModelRef: Map<string, ReturnType<typeof listThinkingLevelOptions>>;
|
|
thinkingDefaultByAgentModelRef: Map<string, string>;
|
|
};
|
|
|
|
function resolveRuntimeChildSessionKeys(
|
|
controllerSessionKey: string,
|
|
now = Date.now(),
|
|
subagentRuns?: SessionListRowContext["subagentRuns"],
|
|
): string[] | undefined {
|
|
const childSessionKeys = new Set<string>();
|
|
const controllerKey = controllerSessionKey.trim();
|
|
const runs = subagentRuns
|
|
? (subagentRuns.runsByControllerSessionKey.get(controllerKey) ?? [])
|
|
: listSubagentRunsForController(controllerSessionKey);
|
|
for (const entry of runs) {
|
|
const childSessionKey = normalizeOptionalString(entry.childSessionKey);
|
|
if (!childSessionKey) {
|
|
continue;
|
|
}
|
|
const latest = subagentRuns
|
|
? subagentRuns.getDisplaySubagentRun(childSessionKey)
|
|
: getSessionDisplaySubagentRunByChildSessionKey(childSessionKey);
|
|
if (!latest) {
|
|
continue;
|
|
}
|
|
const latestControllerSessionKey =
|
|
normalizeOptionalString(latest?.controllerSessionKey) ||
|
|
normalizeOptionalString(latest?.requesterSessionKey);
|
|
if (latestControllerSessionKey !== controllerSessionKey) {
|
|
continue;
|
|
}
|
|
if (
|
|
!shouldKeepSubagentRunChildLink(latest, {
|
|
activeDescendants: subagentRuns
|
|
? subagentRuns.countActiveDescendantRuns(childSessionKey)
|
|
: countActiveDescendantRuns(childSessionKey),
|
|
now,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
childSessionKeys.add(childSessionKey);
|
|
}
|
|
const childSessions = Array.from(childSessionKeys);
|
|
return childSessions.length > 0 ? childSessions : undefined;
|
|
}
|
|
|
|
function addChildSessionKey(
|
|
childSessionsByKey: Map<string, string[]>,
|
|
parentKey: string,
|
|
childKey: string,
|
|
) {
|
|
const current = childSessionsByKey.get(parentKey);
|
|
if (current) {
|
|
if (!current.includes(childKey)) {
|
|
current.push(childKey);
|
|
}
|
|
return;
|
|
}
|
|
childSessionsByKey.set(parentKey, [childKey]);
|
|
}
|
|
|
|
function buildStoreChildSessionIndex(
|
|
store: Record<string, SessionEntry>,
|
|
now = Date.now(),
|
|
subagentRuns?: SessionListRowContext["subagentRuns"],
|
|
): Map<string, string[]> {
|
|
const childSessionsByKey = new Map<string, string[]>();
|
|
for (const [key, entry] of Object.entries(store)) {
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
const parentKeys = [
|
|
normalizeOptionalString(entry.spawnedBy),
|
|
normalizeOptionalString(entry.parentSessionKey),
|
|
].filter((value): value is string => Boolean(value) && value !== key);
|
|
if (parentKeys.length === 0) {
|
|
continue;
|
|
}
|
|
const latest = subagentRuns
|
|
? subagentRuns.getDisplaySubagentRun(key)
|
|
: getSessionDisplaySubagentRunByChildSessionKey(key);
|
|
let latestControllerSessionKey: string | undefined;
|
|
if (latest) {
|
|
latestControllerSessionKey =
|
|
normalizeOptionalString(latest.controllerSessionKey) ||
|
|
normalizeOptionalString(latest.requesterSessionKey);
|
|
if (
|
|
!shouldKeepSubagentRunChildLink(latest, {
|
|
activeDescendants: subagentRuns
|
|
? subagentRuns.countActiveDescendantRuns(key)
|
|
: countActiveDescendantRuns(key),
|
|
now,
|
|
})
|
|
) {
|
|
continue;
|
|
}
|
|
} else if (!shouldKeepStoreOnlyChildLink(entry, now)) {
|
|
continue;
|
|
}
|
|
for (const parentKey of parentKeys) {
|
|
if (latestControllerSessionKey && latestControllerSessionKey !== parentKey) {
|
|
continue;
|
|
}
|
|
addChildSessionKey(childSessionsByKey, parentKey, key);
|
|
}
|
|
}
|
|
return childSessionsByKey;
|
|
}
|
|
|
|
function buildSessionListRowContext(params: {
|
|
store: Record<string, SessionEntry>;
|
|
now: number;
|
|
}): SessionListRowContext {
|
|
const subagentRuns = buildSubagentRunReadIndex(params.now);
|
|
return {
|
|
subagentRuns,
|
|
storeChildSessionsByKey: buildStoreChildSessionIndex(params.store, params.now, subagentRuns),
|
|
selectedModelByOverrideRef: new Map(),
|
|
thinkingLevelsByModelRef: new Map(),
|
|
thinkingDefaultByAgentModelRef: new Map(),
|
|
};
|
|
}
|
|
|
|
function createSessionRowModelCacheKey(provider: string | undefined, model: string | undefined) {
|
|
return `${normalizeLowercaseStringOrEmpty(provider)}\0${normalizeOptionalString(model) ?? ""}`;
|
|
}
|
|
|
|
function createSessionRowThinkingDefaultCacheKey(params: {
|
|
agentId: string | undefined;
|
|
provider: string | undefined;
|
|
model: string | undefined;
|
|
}) {
|
|
return `${normalizeAgentId(params.agentId)}\0${createSessionRowModelCacheKey(
|
|
params.provider,
|
|
params.model,
|
|
)}`;
|
|
}
|
|
|
|
function resolveSessionSelectedModelRef(params: {
|
|
cfg: OpenClawConfig;
|
|
entry?: SessionEntry;
|
|
agentId: string;
|
|
rowContext?: SessionListRowContext;
|
|
}): ReturnType<typeof resolveSessionModelRef> | null {
|
|
const override = normalizeStoredOverrideModel({
|
|
providerOverride: params.entry?.providerOverride,
|
|
modelOverride: params.entry?.modelOverride,
|
|
});
|
|
if (!override.modelOverride) {
|
|
return null;
|
|
}
|
|
if (!params.rowContext) {
|
|
return resolveSessionModelRef(params.cfg, params.entry, params.agentId);
|
|
}
|
|
const key = [
|
|
normalizeAgentId(params.agentId),
|
|
override.providerOverride ?? "",
|
|
override.modelOverride,
|
|
].join("\0");
|
|
const cached = params.rowContext.selectedModelByOverrideRef.get(key);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const selected = resolveSessionModelRef(params.cfg, params.entry, params.agentId);
|
|
params.rowContext.selectedModelByOverrideRef.set(key, selected);
|
|
return selected;
|
|
}
|
|
|
|
function resolveSessionRowThinkingLevels(params: {
|
|
provider: string;
|
|
model: string;
|
|
modelCatalog?: ModelCatalogEntry[];
|
|
rowContext?: SessionListRowContext;
|
|
}): ReturnType<typeof listThinkingLevelOptions> {
|
|
if (!params.rowContext) {
|
|
return listThinkingLevelOptions(params.provider, params.model, params.modelCatalog);
|
|
}
|
|
const key = createSessionRowModelCacheKey(params.provider, params.model);
|
|
const cached = params.rowContext.thinkingLevelsByModelRef.get(key);
|
|
if (cached) {
|
|
return cached;
|
|
}
|
|
const levels = listThinkingLevelOptions(params.provider, params.model, params.modelCatalog);
|
|
params.rowContext.thinkingLevelsByModelRef.set(key, levels);
|
|
return levels;
|
|
}
|
|
|
|
function resolveSessionRowThinkingDefault(params: {
|
|
cfg: OpenClawConfig;
|
|
provider: string;
|
|
model: string;
|
|
agentId: string;
|
|
modelCatalog?: ModelCatalogEntry[];
|
|
rowContext?: SessionListRowContext;
|
|
}): string {
|
|
if (!params.rowContext) {
|
|
return resolveGatewaySessionThinkingDefault({
|
|
cfg: params.cfg,
|
|
provider: params.provider,
|
|
model: params.model,
|
|
agentId: params.agentId,
|
|
modelCatalog: params.modelCatalog,
|
|
});
|
|
}
|
|
const key = createSessionRowThinkingDefaultCacheKey(params);
|
|
if (params.rowContext.thinkingDefaultByAgentModelRef.has(key)) {
|
|
return params.rowContext.thinkingDefaultByAgentModelRef.get(key)!;
|
|
}
|
|
const defaultLevel = resolveGatewaySessionThinkingDefault({
|
|
cfg: params.cfg,
|
|
provider: params.provider,
|
|
model: params.model,
|
|
agentId: params.agentId,
|
|
modelCatalog: params.modelCatalog,
|
|
});
|
|
params.rowContext.thinkingDefaultByAgentModelRef.set(key, defaultLevel);
|
|
return defaultLevel;
|
|
}
|
|
|
|
function seedSessionRowContextDefaultThinking(params: {
|
|
rowContext: SessionListRowContext;
|
|
cfg: OpenClawConfig;
|
|
defaults: GatewaySessionsDefaults;
|
|
}) {
|
|
if (
|
|
!params.defaults.modelProvider ||
|
|
!params.defaults.model ||
|
|
!params.defaults.thinkingDefault
|
|
) {
|
|
return;
|
|
}
|
|
params.rowContext.thinkingDefaultByAgentModelRef.set(
|
|
createSessionRowThinkingDefaultCacheKey({
|
|
agentId: resolveDefaultAgentId(params.cfg),
|
|
provider: params.defaults.modelProvider,
|
|
model: params.defaults.model,
|
|
}),
|
|
params.defaults.thinkingDefault,
|
|
);
|
|
}
|
|
|
|
function mergeChildSessionKeys(
|
|
runtimeChildSessions: string[] | undefined,
|
|
storeChildSessions: string[] | undefined,
|
|
): string[] | undefined {
|
|
if (!runtimeChildSessions?.length) {
|
|
return storeChildSessions?.length ? storeChildSessions : undefined;
|
|
}
|
|
if (!storeChildSessions?.length) {
|
|
return runtimeChildSessions;
|
|
}
|
|
return Array.from(new Set([...runtimeChildSessions, ...storeChildSessions]));
|
|
}
|
|
|
|
function resolveChildSessionKeys(
|
|
controllerSessionKey: string,
|
|
store: Record<string, SessionEntry>,
|
|
now = Date.now(),
|
|
subagentRuns?: SessionListRowContext["subagentRuns"],
|
|
): string[] | undefined {
|
|
const runtimeChildSessions = resolveRuntimeChildSessionKeys(
|
|
controllerSessionKey,
|
|
now,
|
|
subagentRuns,
|
|
);
|
|
const storeChildSessions = buildStoreChildSessionIndex(store, now, subagentRuns).get(
|
|
controllerSessionKey,
|
|
);
|
|
return mergeChildSessionKeys(runtimeChildSessions, storeChildSessions);
|
|
}
|
|
|
|
function resolveTranscriptUsageFallback(params: {
|
|
cfg: OpenClawConfig;
|
|
key: string;
|
|
entry?: SessionEntry;
|
|
storePath: string;
|
|
fallbackProvider?: string;
|
|
fallbackModel?: string;
|
|
maxTranscriptBytes?: number;
|
|
}): {
|
|
estimatedCostUsd?: number;
|
|
totalTokens?: number;
|
|
totalTokensFresh?: boolean;
|
|
contextTokens?: number;
|
|
modelProvider?: string;
|
|
model?: string;
|
|
} | null {
|
|
const entry = params.entry;
|
|
if (!entry?.sessionId) {
|
|
return null;
|
|
}
|
|
const parsed = parseAgentSessionKey(params.key);
|
|
const agentId = parsed?.agentId
|
|
? normalizeAgentId(parsed.agentId)
|
|
: resolveDefaultAgentId(params.cfg);
|
|
const snapshot = readRecentSessionUsageFromTranscript(
|
|
entry.sessionId,
|
|
params.storePath,
|
|
entry.sessionFile,
|
|
agentId,
|
|
typeof params.maxTranscriptBytes === "number" ? params.maxTranscriptBytes : 256 * 1024,
|
|
);
|
|
if (!snapshot) {
|
|
return null;
|
|
}
|
|
const modelProvider = snapshot.modelProvider ?? params.fallbackProvider;
|
|
const model = snapshot.model ?? params.fallbackModel;
|
|
const contextTokens = resolveContextTokensForModel({
|
|
cfg: params.cfg,
|
|
provider: modelProvider,
|
|
model,
|
|
// Gateway/session listing is read-only; don't start async model discovery.
|
|
allowAsyncLoad: false,
|
|
});
|
|
const estimatedCostUsd = resolveEstimatedSessionCostUsd({
|
|
cfg: params.cfg,
|
|
provider: modelProvider,
|
|
model,
|
|
explicitCostUsd: snapshot.costUsd,
|
|
entry: {
|
|
inputTokens: snapshot.inputTokens,
|
|
outputTokens: snapshot.outputTokens,
|
|
cacheRead: snapshot.cacheRead,
|
|
cacheWrite: snapshot.cacheWrite,
|
|
},
|
|
});
|
|
return {
|
|
modelProvider,
|
|
model,
|
|
totalTokens: resolvePositiveNumber(snapshot.totalTokens),
|
|
totalTokensFresh: snapshot.totalTokensFresh === true,
|
|
contextTokens: resolvePositiveNumber(contextTokens),
|
|
estimatedCostUsd,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Returns the owning agent id if the session key belongs to an agent that is no
|
|
* longer present in config (deleted). Returns null for non-agent legacy/global
|
|
* keys, or when the owning agent still exists (#65524).
|
|
*/
|
|
export function resolveDeletedAgentIdFromSessionKey(
|
|
cfg: OpenClawConfig,
|
|
sessionKey: string,
|
|
): string | null {
|
|
const parsed = parseAgentSessionKey(sessionKey);
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
const agentId = normalizeAgentId(parsed.agentId);
|
|
if (listAgentIds(cfg).includes(agentId)) {
|
|
return null;
|
|
}
|
|
return agentId;
|
|
}
|
|
|
|
export function loadSessionEntry(sessionKey: string) {
|
|
const cfg = getRuntimeConfig();
|
|
const key = normalizeOptionalString(sessionKey) ?? "";
|
|
const target = resolveGatewaySessionStoreTarget({
|
|
cfg,
|
|
key,
|
|
});
|
|
const storePath = target.storePath;
|
|
const store = loadSessionStore(storePath);
|
|
const freshestMatch = resolveFreshestSessionStoreMatchFromStoreKeys(store, target.storeKeys);
|
|
const legacyKey = freshestMatch?.key !== target.canonicalKey ? freshestMatch?.key : undefined;
|
|
return {
|
|
cfg,
|
|
storePath,
|
|
store,
|
|
entry: freshestMatch?.entry,
|
|
canonicalKey: target.canonicalKey,
|
|
legacyKey,
|
|
};
|
|
}
|
|
|
|
export function resolveFreshestSessionStoreMatchFromStoreKeys(
|
|
store: Record<string, SessionEntry>,
|
|
storeKeys: string[],
|
|
): { key: string; entry: SessionEntry } | undefined {
|
|
let freshest: { key: string; entry: SessionEntry } | undefined;
|
|
for (const key of storeKeys) {
|
|
const entry = store[key];
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
const match = { key, entry };
|
|
if (!freshest || (match.entry.updatedAt ?? 0) > (freshest.entry.updatedAt ?? 0)) {
|
|
freshest = match;
|
|
}
|
|
}
|
|
return freshest;
|
|
}
|
|
|
|
export function resolveFreshestSessionEntryFromStoreKeys(
|
|
store: Record<string, SessionEntry>,
|
|
storeKeys: string[],
|
|
): SessionEntry | undefined {
|
|
return resolveFreshestSessionStoreMatchFromStoreKeys(store, storeKeys)?.entry;
|
|
}
|
|
|
|
function findFreshestStoreMatch(
|
|
store: Record<string, SessionEntry>,
|
|
...candidates: string[]
|
|
): { entry: SessionEntry; key: string } | undefined {
|
|
const matches = new Map<string, { entry: SessionEntry; key: string }>();
|
|
for (const candidate of candidates) {
|
|
const trimmed = normalizeOptionalString(candidate) ?? "";
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
const exact = store[trimmed];
|
|
if (exact) {
|
|
matches.set(trimmed, { entry: exact, key: trimmed });
|
|
}
|
|
for (const key of findStoreKeysIgnoreCase(store, trimmed)) {
|
|
const entry = store[key];
|
|
if (entry) {
|
|
matches.set(key, { entry, key });
|
|
}
|
|
}
|
|
}
|
|
if (matches.size === 0) {
|
|
return undefined;
|
|
}
|
|
let freshest: { entry: SessionEntry; key: string } | undefined;
|
|
for (const match of matches.values()) {
|
|
if (!freshest || (match.entry.updatedAt ?? 0) > (freshest.entry.updatedAt ?? 0)) {
|
|
freshest = match;
|
|
}
|
|
}
|
|
return freshest;
|
|
}
|
|
|
|
/**
|
|
* Find all on-disk store keys that match the given key case-insensitively.
|
|
* Returns every key from the store whose lowercased form equals the target's lowercased form.
|
|
*/
|
|
export function findStoreKeysIgnoreCase(
|
|
store: Record<string, unknown>,
|
|
targetKey: string,
|
|
): string[] {
|
|
const lowered = normalizeLowercaseStringOrEmpty(targetKey);
|
|
const matches: string[] = [];
|
|
for (const key of Object.keys(store)) {
|
|
if (normalizeLowercaseStringOrEmpty(key) === lowered) {
|
|
matches.push(key);
|
|
}
|
|
}
|
|
return matches;
|
|
}
|
|
|
|
/**
|
|
* Remove legacy key variants for one canonical session key.
|
|
* Candidates can include aliases (for example, "agent:ops:main" when canonical is "agent:ops:work").
|
|
*/
|
|
export function pruneLegacyStoreKeys(params: {
|
|
store: Record<string, unknown>;
|
|
canonicalKey: string;
|
|
candidates: Iterable<string>;
|
|
}) {
|
|
const keysToDelete = new Set<string>();
|
|
for (const candidate of params.candidates) {
|
|
const trimmed = normalizeOptionalString(candidate ?? "") ?? "";
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
if (trimmed !== params.canonicalKey) {
|
|
keysToDelete.add(trimmed);
|
|
}
|
|
for (const match of findStoreKeysIgnoreCase(params.store, trimmed)) {
|
|
if (match !== params.canonicalKey) {
|
|
keysToDelete.add(match);
|
|
}
|
|
}
|
|
}
|
|
for (const key of keysToDelete) {
|
|
delete params.store[key];
|
|
}
|
|
}
|
|
|
|
export function migrateAndPruneGatewaySessionStoreKey(params: {
|
|
cfg: OpenClawConfig;
|
|
key: string;
|
|
store: Record<string, SessionEntry>;
|
|
}) {
|
|
const target = resolveGatewaySessionStoreTarget({
|
|
cfg: params.cfg,
|
|
key: params.key,
|
|
store: params.store,
|
|
});
|
|
const primaryKey = target.canonicalKey;
|
|
const freshestMatch = resolveFreshestSessionStoreMatchFromStoreKeys(
|
|
params.store,
|
|
target.storeKeys,
|
|
);
|
|
if (freshestMatch) {
|
|
const currentPrimary = params.store[primaryKey];
|
|
if (!currentPrimary || (freshestMatch.entry.updatedAt ?? 0) > (currentPrimary.updatedAt ?? 0)) {
|
|
params.store[primaryKey] = freshestMatch.entry;
|
|
}
|
|
}
|
|
pruneLegacyStoreKeys({
|
|
store: params.store,
|
|
canonicalKey: primaryKey,
|
|
candidates: target.storeKeys,
|
|
});
|
|
return { target, primaryKey, entry: params.store[primaryKey] };
|
|
}
|
|
|
|
export function classifySessionKey(key: string, entry?: SessionEntry): GatewaySessionRow["kind"] {
|
|
if (key === "global") {
|
|
return "global";
|
|
}
|
|
if (key === "unknown") {
|
|
return "unknown";
|
|
}
|
|
if (entry?.chatType === "group" || entry?.chatType === "channel") {
|
|
return "group";
|
|
}
|
|
if (key.includes(":group:") || key.includes(":channel:")) {
|
|
return "group";
|
|
}
|
|
return "direct";
|
|
}
|
|
|
|
export function parseGroupKey(
|
|
key: string,
|
|
): { channel?: string; kind?: "group" | "channel"; id?: string } | null {
|
|
const agentParsed = parseAgentSessionKey(key);
|
|
const rawKey = agentParsed?.rest ?? key;
|
|
const parts = rawKey.split(":").filter(Boolean);
|
|
if (parts.length >= 3) {
|
|
const [channel, kind, ...rest] = parts;
|
|
if (kind === "group" || kind === "channel") {
|
|
const id = rest.join(":");
|
|
return { channel, kind, id };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isStorePathTemplate(store?: string): boolean {
|
|
return typeof store === "string" && store.includes("{agentId}");
|
|
}
|
|
|
|
function listExistingAgentIdsFromDisk(): string[] {
|
|
const root = resolveStateDir();
|
|
const agentsDir = path.join(root, "agents");
|
|
try {
|
|
const entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
|
return entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => normalizeAgentId(entry.name))
|
|
.filter(Boolean);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function listConfiguredAgentIds(cfg: OpenClawConfig): string[] {
|
|
const ids = new Set<string>();
|
|
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
|
ids.add(defaultId);
|
|
|
|
for (const entry of cfg.agents?.list ?? []) {
|
|
if (entry?.id) {
|
|
ids.add(normalizeAgentId(entry.id));
|
|
}
|
|
}
|
|
|
|
for (const id of listExistingAgentIdsFromDisk()) {
|
|
ids.add(id);
|
|
}
|
|
|
|
const sorted = Array.from(ids).filter(Boolean);
|
|
sorted.sort((a, b) => a.localeCompare(b));
|
|
return sorted.includes(defaultId)
|
|
? [defaultId, ...sorted.filter((id) => id !== defaultId)]
|
|
: sorted;
|
|
}
|
|
|
|
function normalizeFallbackList(values: readonly string[]): string[] {
|
|
const out: string[] = [];
|
|
const seen = new Set<string>();
|
|
for (const value of values) {
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
const key = normalizeLowercaseStringOrEmpty(trimmed);
|
|
if (seen.has(key)) {
|
|
continue;
|
|
}
|
|
seen.add(key);
|
|
out.push(trimmed);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function resolveGatewayAgentModel(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): GatewayAgentRow["model"] | undefined {
|
|
const primary = resolveAgentEffectiveModelPrimary(cfg, agentId)?.trim();
|
|
const fallbackOverride = resolveAgentModelFallbacksOverride(cfg, agentId);
|
|
const defaultFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.model);
|
|
const fallbacks = normalizeFallbackList(fallbackOverride ?? defaultFallbacks);
|
|
if (!primary && fallbacks.length === 0) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
...(primary ? { primary } : {}),
|
|
...(fallbacks.length > 0 ? { fallbacks } : {}),
|
|
};
|
|
}
|
|
|
|
export function listAgentsForGateway(cfg: OpenClawConfig): {
|
|
defaultId: string;
|
|
mainKey: string;
|
|
scope: SessionScope;
|
|
agents: GatewayAgentRow[];
|
|
} {
|
|
const defaultId = normalizeAgentId(resolveDefaultAgentId(cfg));
|
|
const mainKey = normalizeMainKey(cfg.session?.mainKey);
|
|
const scope = cfg.session?.scope ?? "per-sender";
|
|
const configuredById = new Map<
|
|
string,
|
|
{ name?: string; identity?: GatewayAgentRow["identity"] }
|
|
>();
|
|
for (const entry of cfg.agents?.list ?? []) {
|
|
if (!entry?.id) {
|
|
continue;
|
|
}
|
|
const identity = entry.identity
|
|
? {
|
|
name: normalizeOptionalString(entry.identity.name),
|
|
theme: normalizeOptionalString(entry.identity.theme),
|
|
emoji: normalizeOptionalString(entry.identity.emoji),
|
|
avatar: normalizeOptionalString(entry.identity.avatar),
|
|
avatarUrl: resolveIdentityAvatarUrl(
|
|
cfg,
|
|
normalizeAgentId(entry.id),
|
|
normalizeOptionalString(entry.identity.avatar),
|
|
),
|
|
}
|
|
: undefined;
|
|
configuredById.set(normalizeAgentId(entry.id), {
|
|
name: normalizeOptionalString(entry.name),
|
|
identity,
|
|
});
|
|
}
|
|
const explicitIds = new Set(
|
|
(cfg.agents?.list ?? [])
|
|
.map((entry) => (entry?.id ? normalizeAgentId(entry.id) : ""))
|
|
.filter(Boolean),
|
|
);
|
|
const allowedIds = explicitIds.size > 0 ? new Set([...explicitIds, defaultId]) : null;
|
|
let agentIds = listConfiguredAgentIds(cfg).filter((id) =>
|
|
allowedIds ? allowedIds.has(id) : true,
|
|
);
|
|
if (mainKey && !agentIds.includes(mainKey) && (!allowedIds || allowedIds.has(mainKey))) {
|
|
agentIds = [...agentIds, mainKey];
|
|
}
|
|
const agents = agentIds.map((id) => {
|
|
const meta = configuredById.get(id);
|
|
const model = resolveGatewayAgentModel(cfg, id);
|
|
return Object.assign(
|
|
{
|
|
id,
|
|
name: meta?.name,
|
|
identity: meta?.identity,
|
|
workspace: resolveAgentWorkspaceDir(cfg, id),
|
|
agentRuntime: resolveAgentRuntimeMetadata(cfg, id),
|
|
},
|
|
model ? { model } : {},
|
|
);
|
|
});
|
|
return { defaultId, mainKey, scope, agents };
|
|
}
|
|
|
|
function buildGatewaySessionStoreScanTargets(params: {
|
|
cfg: OpenClawConfig;
|
|
key: string;
|
|
canonicalKey: string;
|
|
agentId: string;
|
|
}): string[] {
|
|
const targets = new Set<string>();
|
|
if (params.canonicalKey) {
|
|
targets.add(params.canonicalKey);
|
|
}
|
|
if (params.key && params.key !== params.canonicalKey) {
|
|
targets.add(params.key);
|
|
}
|
|
if (params.canonicalKey === "global" || params.canonicalKey === "unknown") {
|
|
return [...targets];
|
|
}
|
|
const agentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: params.agentId });
|
|
if (params.canonicalKey === agentMainKey) {
|
|
targets.add(`agent:${params.agentId}:main`);
|
|
}
|
|
return [...targets];
|
|
}
|
|
|
|
function resolveGatewaySessionStoreCandidates(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): SessionStoreTarget[] {
|
|
const storeConfig = cfg.session?.store;
|
|
const defaultTarget = {
|
|
agentId,
|
|
storePath: resolveStorePath(storeConfig, { agentId }),
|
|
};
|
|
if (!isStorePathTemplate(storeConfig)) {
|
|
return [defaultTarget];
|
|
}
|
|
const targets = new Map<string, SessionStoreTarget>();
|
|
targets.set(defaultTarget.storePath, defaultTarget);
|
|
for (const target of resolveAllAgentSessionStoreTargetsSync(cfg)) {
|
|
if (target.agentId === agentId) {
|
|
targets.set(target.storePath, target);
|
|
}
|
|
}
|
|
return [...targets.values()];
|
|
}
|
|
|
|
function resolveGatewaySessionStoreLookup(params: {
|
|
cfg: OpenClawConfig;
|
|
key: string;
|
|
canonicalKey: string;
|
|
agentId: string;
|
|
initialStore?: Record<string, SessionEntry>;
|
|
}): {
|
|
storePath: string;
|
|
store: Record<string, SessionEntry>;
|
|
match: { entry: SessionEntry; key: string } | undefined;
|
|
} {
|
|
const scanTargets = buildGatewaySessionStoreScanTargets(params);
|
|
const candidates = resolveGatewaySessionStoreCandidates(params.cfg, params.agentId);
|
|
const fallback = candidates[0] ?? {
|
|
agentId: params.agentId,
|
|
storePath: resolveStorePath(params.cfg.session?.store, { agentId: params.agentId }),
|
|
};
|
|
let selectedStorePath = fallback.storePath;
|
|
let selectedStore = params.initialStore ?? loadSessionStore(fallback.storePath);
|
|
let selectedMatch = findFreshestStoreMatch(selectedStore, ...scanTargets);
|
|
let selectedUpdatedAt = selectedMatch?.entry.updatedAt ?? Number.NEGATIVE_INFINITY;
|
|
|
|
for (let index = 1; index < candidates.length; index += 1) {
|
|
const candidate = candidates[index];
|
|
if (!candidate) {
|
|
continue;
|
|
}
|
|
const store = loadSessionStore(candidate.storePath);
|
|
const match = findFreshestStoreMatch(store, ...scanTargets);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
const updatedAt = match.entry.updatedAt ?? 0;
|
|
// Mirror combined-store merge behavior so follow-up mutations target the
|
|
// same backing store that won the listing merge when ids collide.
|
|
if (!selectedMatch || updatedAt >= selectedUpdatedAt) {
|
|
selectedStorePath = candidate.storePath;
|
|
selectedStore = store;
|
|
selectedMatch = match;
|
|
selectedUpdatedAt = updatedAt;
|
|
}
|
|
}
|
|
|
|
return {
|
|
storePath: selectedStorePath,
|
|
store: selectedStore,
|
|
match: selectedMatch,
|
|
};
|
|
}
|
|
|
|
function resolveExplicitDeletedLegacyMainStoreTarget(params: {
|
|
cfg: OpenClawConfig;
|
|
key: string;
|
|
scanLegacyKeys?: boolean;
|
|
}): {
|
|
agentId: string;
|
|
storePath: string;
|
|
canonicalKey: string;
|
|
storeKeys: string[];
|
|
} | null {
|
|
const parsed = parseAgentSessionKey(params.key);
|
|
const legacyAgentId = normalizeAgentId(parsed?.agentId);
|
|
if (
|
|
!parsed ||
|
|
legacyAgentId !== DEFAULT_AGENT_ID ||
|
|
listAgentIds(params.cfg).includes(legacyAgentId)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Only preserve agent:main:* when it is backed by a discovered deleted-main store.
|
|
// Shared-store legacy aliases should continue remapping to the configured default agent.
|
|
const canonicalKey = resolveStoredSessionKeyForAgentStore({
|
|
cfg: params.cfg,
|
|
agentId: legacyAgentId,
|
|
sessionKey: params.key,
|
|
});
|
|
const agentMainKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId: legacyAgentId });
|
|
const legacyAgentMainKey = `agent:${legacyAgentId}:main`;
|
|
const lookupSeeds = Array.from(
|
|
new Set([params.key, canonicalKey, agentMainKey, legacyAgentMainKey]),
|
|
);
|
|
let best:
|
|
| {
|
|
storePath: string;
|
|
store: Record<string, SessionEntry>;
|
|
match: { entry: SessionEntry; key: string };
|
|
}
|
|
| undefined;
|
|
for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg)) {
|
|
if (target.agentId !== legacyAgentId) {
|
|
continue;
|
|
}
|
|
const store = loadSessionStore(target.storePath);
|
|
const match = findFreshestStoreMatch(store, ...lookupSeeds);
|
|
if (!match) {
|
|
continue;
|
|
}
|
|
if (!best || (match.entry.updatedAt ?? 0) >= (best.match.entry.updatedAt ?? 0)) {
|
|
best = { storePath: target.storePath, store, match };
|
|
}
|
|
}
|
|
if (!best) {
|
|
return null;
|
|
}
|
|
|
|
const storeKeys = new Set<string>([canonicalKey]);
|
|
if (params.key !== canonicalKey) {
|
|
storeKeys.add(params.key);
|
|
}
|
|
storeKeys.add(best.match.key);
|
|
if (params.scanLegacyKeys !== false) {
|
|
for (const seed of lookupSeeds) {
|
|
storeKeys.add(seed);
|
|
for (const legacyKey of findStoreKeysIgnoreCase(best.store, seed)) {
|
|
storeKeys.add(legacyKey);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
agentId: legacyAgentId,
|
|
storePath: best.storePath,
|
|
canonicalKey,
|
|
storeKeys: Array.from(storeKeys),
|
|
};
|
|
}
|
|
|
|
export function resolveGatewaySessionStoreTarget(params: {
|
|
cfg: OpenClawConfig;
|
|
key: string;
|
|
scanLegacyKeys?: boolean;
|
|
store?: Record<string, SessionEntry>;
|
|
}): {
|
|
agentId: string;
|
|
storePath: string;
|
|
canonicalKey: string;
|
|
storeKeys: string[];
|
|
} {
|
|
const key = normalizeOptionalString(params.key) ?? "";
|
|
const explicitDeletedMainTarget = resolveExplicitDeletedLegacyMainStoreTarget({
|
|
cfg: params.cfg,
|
|
key,
|
|
scanLegacyKeys: params.scanLegacyKeys,
|
|
});
|
|
if (explicitDeletedMainTarget) {
|
|
return explicitDeletedMainTarget;
|
|
}
|
|
|
|
const canonicalKey = resolveSessionStoreKey({
|
|
cfg: params.cfg,
|
|
sessionKey: key,
|
|
});
|
|
const agentId = resolveSessionStoreAgentId(params.cfg, canonicalKey);
|
|
const { storePath, store } = resolveGatewaySessionStoreLookup({
|
|
cfg: params.cfg,
|
|
key,
|
|
canonicalKey,
|
|
agentId,
|
|
initialStore: params.store,
|
|
});
|
|
|
|
if (canonicalKey === "global" || canonicalKey === "unknown") {
|
|
const storeKeys = key && key !== canonicalKey ? [canonicalKey, key] : [key];
|
|
return { agentId, storePath, canonicalKey, storeKeys };
|
|
}
|
|
|
|
const storeKeys = new Set<string>();
|
|
storeKeys.add(canonicalKey);
|
|
if (key && key !== canonicalKey) {
|
|
storeKeys.add(key);
|
|
}
|
|
if (params.scanLegacyKeys !== false) {
|
|
// Scan the on-disk store for case variants of every target to find
|
|
// legacy mixed-case entries (e.g. "agent:ops:MAIN" when canonical is "agent:ops:work").
|
|
const scanTargets = buildGatewaySessionStoreScanTargets({
|
|
cfg: params.cfg,
|
|
key,
|
|
canonicalKey,
|
|
agentId,
|
|
});
|
|
for (const seed of scanTargets) {
|
|
for (const legacyKey of findStoreKeysIgnoreCase(store, seed)) {
|
|
storeKeys.add(legacyKey);
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
agentId,
|
|
storePath,
|
|
canonicalKey,
|
|
storeKeys: Array.from(storeKeys),
|
|
};
|
|
}
|
|
|
|
export { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js";
|
|
|
|
export function resolveGatewaySessionThinkingDefault(params: {
|
|
cfg: OpenClawConfig;
|
|
provider: string;
|
|
model: string;
|
|
agentId?: string;
|
|
modelCatalog?: ModelCatalogEntry[];
|
|
}) {
|
|
const agentThinkingDefault = params.agentId
|
|
? resolveAgentConfig(params.cfg, params.agentId)?.thinkingDefault
|
|
: undefined;
|
|
return (
|
|
agentThinkingDefault ??
|
|
resolveThinkingDefault({
|
|
cfg: params.cfg,
|
|
provider: params.provider,
|
|
model: params.model,
|
|
catalog: params.modelCatalog,
|
|
})
|
|
);
|
|
}
|
|
|
|
export function getSessionDefaults(
|
|
cfg: OpenClawConfig,
|
|
modelCatalog?: ModelCatalogEntry[],
|
|
): GatewaySessionsDefaults {
|
|
const resolved = resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: DEFAULT_PROVIDER,
|
|
defaultModel: DEFAULT_MODEL,
|
|
});
|
|
const contextTokens =
|
|
cfg.agents?.defaults?.contextTokens ??
|
|
lookupContextTokens(resolved.model, { allowAsyncLoad: false }) ??
|
|
DEFAULT_CONTEXT_TOKENS;
|
|
const thinkingLevels = listThinkingLevelOptions(resolved.provider, resolved.model, modelCatalog);
|
|
return {
|
|
modelProvider: resolved.provider ?? null,
|
|
model: resolved.model ?? null,
|
|
contextTokens: contextTokens ?? null,
|
|
thinkingLevels,
|
|
thinkingOptions: thinkingLevels.map((level) => level.label),
|
|
thinkingDefault: resolveGatewaySessionThinkingDefault({
|
|
cfg,
|
|
provider: resolved.provider,
|
|
model: resolved.model,
|
|
modelCatalog,
|
|
}),
|
|
};
|
|
}
|
|
|
|
export function resolveSessionModelRef(
|
|
cfg: OpenClawConfig,
|
|
entry?:
|
|
| SessionEntry
|
|
| Pick<SessionEntry, "model" | "modelProvider" | "modelOverride" | "providerOverride">,
|
|
agentId?: string,
|
|
): { provider: string; model: string } {
|
|
const resolved = agentId
|
|
? resolveDefaultModelForAgent({ cfg, agentId })
|
|
: resolveConfiguredModelRef({
|
|
cfg,
|
|
defaultProvider: DEFAULT_PROVIDER,
|
|
defaultModel: DEFAULT_MODEL,
|
|
});
|
|
|
|
const normalizedOverride = normalizeStoredOverrideModel({
|
|
providerOverride: entry?.providerOverride,
|
|
modelOverride: entry?.modelOverride,
|
|
});
|
|
|
|
const persisted = resolvePersistedSelectedModelRef({
|
|
defaultProvider: resolved.provider || DEFAULT_PROVIDER,
|
|
runtimeProvider: entry?.modelProvider,
|
|
runtimeModel: entry?.model,
|
|
overrideProvider: normalizedOverride.providerOverride,
|
|
overrideModel: normalizedOverride.modelOverride,
|
|
});
|
|
if (persisted) {
|
|
return persisted;
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
export async function resolveGatewayModelSupportsImages(params: {
|
|
loadGatewayModelCatalog: (params?: { readOnly?: boolean }) => Promise<ModelCatalogEntry[]>;
|
|
provider?: string;
|
|
model?: string;
|
|
}): Promise<boolean> {
|
|
if (!params.model) {
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
const catalog = await params.loadGatewayModelCatalog({ readOnly: false });
|
|
const modelEntry = findModelCatalogEntry(catalog, {
|
|
provider: params.provider,
|
|
modelId: params.model,
|
|
});
|
|
const normalizedProvider = normalizeOptionalLowercaseString(
|
|
params.provider ?? modelEntry?.provider,
|
|
);
|
|
const normalizedCandidates = [
|
|
normalizeLowercaseStringOrEmpty(params.model),
|
|
normalizeLowercaseStringOrEmpty(modelEntry?.name),
|
|
].filter(Boolean);
|
|
if (modelEntry) {
|
|
if (modelSupportsInput(modelEntry, "image")) {
|
|
return true;
|
|
}
|
|
// Legacy safety shim for stale persisted Foundry rows that predate
|
|
// provider-owned capability normalization.
|
|
if (
|
|
normalizedProvider === "microsoft-foundry" &&
|
|
normalizedCandidates.some(
|
|
(candidate) =>
|
|
candidate.startsWith("gpt-") ||
|
|
candidate.startsWith("o1") ||
|
|
candidate.startsWith("o3") ||
|
|
candidate.startsWith("o4") ||
|
|
candidate === "computer-use-preview",
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
if (
|
|
normalizedProvider === "claude-cli" &&
|
|
normalizedCandidates.some(
|
|
(candidate) =>
|
|
candidate === "opus" ||
|
|
candidate === "sonnet" ||
|
|
candidate === "haiku" ||
|
|
candidate.startsWith("claude-"),
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
if (
|
|
normalizedProvider === "claude-cli" &&
|
|
normalizedCandidates.some(
|
|
(candidate) =>
|
|
candidate === "opus" ||
|
|
candidate === "sonnet" ||
|
|
candidate === "haiku" ||
|
|
candidate.startsWith("claude-"),
|
|
)
|
|
) {
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function resolveSessionModelIdentityRef(
|
|
cfg: OpenClawConfig,
|
|
entry?:
|
|
| SessionEntry
|
|
| Pick<SessionEntry, "model" | "modelProvider" | "modelOverride" | "providerOverride">,
|
|
agentId?: string,
|
|
fallbackModelRef?: string,
|
|
): { provider?: string; model: string } {
|
|
const runtimeModel = entry?.model?.trim();
|
|
const runtimeProvider = entry?.modelProvider?.trim();
|
|
if (runtimeModel) {
|
|
if (runtimeProvider) {
|
|
return { provider: runtimeProvider, model: runtimeModel };
|
|
}
|
|
const inferredProvider = inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: runtimeModel,
|
|
});
|
|
if (inferredProvider) {
|
|
return { provider: inferredProvider, model: runtimeModel };
|
|
}
|
|
if (runtimeModel.includes("/")) {
|
|
const parsedRuntime = parseModelRef(runtimeModel, DEFAULT_PROVIDER);
|
|
if (parsedRuntime) {
|
|
return { provider: parsedRuntime.provider, model: parsedRuntime.model };
|
|
}
|
|
return { model: runtimeModel };
|
|
}
|
|
return { model: runtimeModel };
|
|
}
|
|
const fallbackRef = fallbackModelRef?.trim();
|
|
if (fallbackRef) {
|
|
const parsedFallback = parseModelRef(fallbackRef, DEFAULT_PROVIDER);
|
|
if (parsedFallback) {
|
|
return { provider: parsedFallback.provider, model: parsedFallback.model };
|
|
}
|
|
const inferredProvider = inferUniqueProviderFromConfiguredModels({
|
|
cfg,
|
|
model: fallbackRef,
|
|
});
|
|
if (inferredProvider) {
|
|
return { provider: inferredProvider, model: fallbackRef };
|
|
}
|
|
return { model: fallbackRef };
|
|
}
|
|
const resolved = resolveSessionModelRef(cfg, entry, agentId);
|
|
return { provider: resolved.provider, model: resolved.model };
|
|
}
|
|
|
|
export function resolveSessionDisplayModelIdentityRef(params: {
|
|
cfg: OpenClawConfig;
|
|
agentId: string;
|
|
provider?: string;
|
|
model?: string;
|
|
}): { provider?: string; model?: string } {
|
|
const provider = normalizeOptionalString(params.provider);
|
|
const model = normalizeOptionalString(params.model);
|
|
if (!provider || !model || !isCliProvider(provider, params.cfg)) {
|
|
return { provider, model };
|
|
}
|
|
|
|
const defaultRef = resolveDefaultModelForAgent({ cfg: params.cfg, agentId: params.agentId });
|
|
if (model.includes("/")) {
|
|
const parsedModel = parseModelRef(model, defaultRef.provider);
|
|
if (parsedModel && !isCliProvider(parsedModel.provider, params.cfg)) {
|
|
return parsedModel;
|
|
}
|
|
}
|
|
|
|
const inferredProvider = inferUniqueProviderFromConfiguredModels({
|
|
cfg: params.cfg,
|
|
model,
|
|
});
|
|
if (inferredProvider && !isCliProvider(inferredProvider, params.cfg)) {
|
|
return { provider: inferredProvider, model };
|
|
}
|
|
|
|
const parsedModel = parseModelRef(model, defaultRef.provider);
|
|
if (parsedModel && !isCliProvider(parsedModel.provider, params.cfg)) {
|
|
return parsedModel;
|
|
}
|
|
|
|
return {
|
|
provider: defaultRef.provider || provider,
|
|
model,
|
|
};
|
|
}
|
|
|
|
export function buildGatewaySessionRow(params: {
|
|
cfg: OpenClawConfig;
|
|
storePath: string;
|
|
store: Record<string, SessionEntry>;
|
|
key: string;
|
|
entry?: SessionEntry;
|
|
modelCatalog?: ModelCatalogEntry[];
|
|
now?: number;
|
|
includeDerivedTitles?: boolean;
|
|
includeLastMessage?: boolean;
|
|
transcriptUsageMaxBytes?: number;
|
|
storeChildSessionsByKey?: Map<string, string[]>;
|
|
rowContext?: SessionListRowContext;
|
|
skipTranscriptUsageFallback?: boolean;
|
|
lightweightListRow?: boolean;
|
|
}): GatewaySessionRow {
|
|
const { cfg, storePath, store, key, entry } = params;
|
|
const lightweight = params.lightweightListRow === true;
|
|
const skipTranscriptUsage = params.skipTranscriptUsageFallback === true;
|
|
const now = params.now ?? Date.now();
|
|
const updatedAt = entry?.updatedAt ?? null;
|
|
const parsed = parseGroupKey(key);
|
|
const channel = entry?.channel ?? parsed?.channel;
|
|
const subject = entry?.subject;
|
|
const groupChannel = entry?.groupChannel;
|
|
const space = entry?.space;
|
|
const id = parsed?.id;
|
|
const origin = entry?.origin;
|
|
const originLabel = origin?.label;
|
|
const displayName =
|
|
entry?.displayName ??
|
|
(channel
|
|
? buildGroupDisplayName({
|
|
provider: channel,
|
|
subject,
|
|
groupChannel,
|
|
space,
|
|
id,
|
|
key,
|
|
})
|
|
: undefined) ??
|
|
entry?.label ??
|
|
originLabel;
|
|
const deliveryFields = normalizeSessionDeliveryFields(entry);
|
|
const parsedAgent = parseAgentSessionKey(key);
|
|
const sessionAgentId = normalizeAgentId(parsedAgent?.agentId ?? resolveDefaultAgentId(cfg));
|
|
const rowContext = params.rowContext;
|
|
const subagentRun = rowContext
|
|
? rowContext.subagentRuns.getDisplaySubagentRun(key)
|
|
: getSessionDisplaySubagentRunByChildSessionKey(key);
|
|
const subagentOwner =
|
|
normalizeOptionalString(subagentRun?.controllerSessionKey) ||
|
|
normalizeOptionalString(subagentRun?.requesterSessionKey);
|
|
const liveSubagentRunActive = isSubagentRunLive(subagentRun);
|
|
const persistedSessionStatus = entry?.status;
|
|
const persistedSessionEndedAt = entry?.endedAt;
|
|
const persistedSessionStartedAt = entry?.startedAt;
|
|
const persistedSessionRuntimeMs = entry?.runtimeMs;
|
|
const subagentRunState = subagentRun
|
|
? liveSubagentRunActive
|
|
? "active"
|
|
: typeof subagentRun.endedAt === "number" ||
|
|
persistedSessionStatus === "done" ||
|
|
persistedSessionStatus === "failed" ||
|
|
persistedSessionStatus === "killed" ||
|
|
persistedSessionStatus === "timeout" ||
|
|
typeof persistedSessionEndedAt === "number"
|
|
? "historical"
|
|
: "interrupted"
|
|
: undefined;
|
|
const subagentStatus = subagentRun
|
|
? liveSubagentRunActive
|
|
? resolveSubagentSessionStatus(subagentRun)
|
|
: persistedSessionStatus === "running"
|
|
? undefined
|
|
: (persistedSessionStatus ??
|
|
(typeof subagentRun.endedAt === "number"
|
|
? resolveSubagentSessionStatus(subagentRun)
|
|
: undefined))
|
|
: undefined;
|
|
const subagentStartedAt = subagentRun
|
|
? liveSubagentRunActive
|
|
? getSubagentSessionStartedAt(subagentRun)
|
|
: (persistedSessionStartedAt ?? getSubagentSessionStartedAt(subagentRun))
|
|
: undefined;
|
|
const subagentEndedAt = subagentRun
|
|
? liveSubagentRunActive
|
|
? subagentRun.endedAt
|
|
: (persistedSessionEndedAt ?? subagentRun.endedAt)
|
|
: undefined;
|
|
const subagentRuntimeMs = subagentRun
|
|
? liveSubagentRunActive
|
|
? resolveSessionRuntimeMs(subagentRun, now)
|
|
: (persistedSessionRuntimeMs ??
|
|
(typeof subagentRun.endedAt === "number"
|
|
? resolveSessionRuntimeMs(subagentRun, now)
|
|
: undefined))
|
|
: undefined;
|
|
const selectedModel = resolveSessionSelectedModelRef({
|
|
cfg,
|
|
entry,
|
|
agentId: sessionAgentId,
|
|
rowContext,
|
|
});
|
|
const resolvedModel = resolveSessionModelIdentityRef(
|
|
cfg,
|
|
entry,
|
|
sessionAgentId,
|
|
subagentRun?.model,
|
|
);
|
|
const runtimeModelPresent =
|
|
Boolean(entry?.model?.trim()) || Boolean(entry?.modelProvider?.trim());
|
|
const needsTranscriptTotalTokens =
|
|
resolvePositiveNumber(resolveFreshSessionTotalTokens(entry)) === undefined;
|
|
const needsTranscriptContextTokens = resolvePositiveNumber(entry?.contextTokens) === undefined;
|
|
const needsTranscriptEstimatedCostUsd =
|
|
!skipTranscriptUsage &&
|
|
resolveEstimatedSessionCostUsd({
|
|
cfg,
|
|
provider: resolvedModel.provider,
|
|
model: resolvedModel.model ?? DEFAULT_MODEL,
|
|
entry,
|
|
}) === undefined;
|
|
const transcriptUsage =
|
|
!skipTranscriptUsage &&
|
|
(needsTranscriptTotalTokens || needsTranscriptContextTokens || needsTranscriptEstimatedCostUsd)
|
|
? resolveTranscriptUsageFallback({
|
|
cfg,
|
|
key,
|
|
entry,
|
|
storePath,
|
|
fallbackProvider: resolvedModel.provider,
|
|
fallbackModel: resolvedModel.model ?? DEFAULT_MODEL,
|
|
maxTranscriptBytes: params.transcriptUsageMaxBytes,
|
|
})
|
|
: null;
|
|
const preferLiveSubagentModelIdentity =
|
|
Boolean(subagentRun?.model?.trim()) && subagentStatus === "running";
|
|
const shouldUseTranscriptModelIdentity =
|
|
runtimeModelPresent &&
|
|
!preferLiveSubagentModelIdentity &&
|
|
(needsTranscriptTotalTokens || needsTranscriptContextTokens);
|
|
const resolvedModelIdentity = {
|
|
provider: resolvedModel.provider,
|
|
model: resolvedModel.model ?? DEFAULT_MODEL,
|
|
};
|
|
const modelIdentity = shouldUseTranscriptModelIdentity
|
|
? {
|
|
provider: transcriptUsage?.modelProvider ?? resolvedModelIdentity.provider,
|
|
model: transcriptUsage?.model ?? resolvedModelIdentity.model,
|
|
}
|
|
: resolvedModelIdentity;
|
|
const { provider: modelProvider, model } = modelIdentity;
|
|
const totalTokens =
|
|
resolvePositiveNumber(resolveFreshSessionTotalTokens(entry)) ??
|
|
resolvePositiveNumber(transcriptUsage?.totalTokens);
|
|
const totalTokensFresh =
|
|
typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0
|
|
? true
|
|
: transcriptUsage?.totalTokensFresh === true;
|
|
const childSessions = params.storeChildSessionsByKey
|
|
? mergeChildSessionKeys(
|
|
resolveRuntimeChildSessionKeys(key, now, rowContext?.subagentRuns),
|
|
params.storeChildSessionsByKey.get(key),
|
|
)
|
|
: resolveChildSessionKeys(key, store, now, rowContext?.subagentRuns);
|
|
const latestCompactionCheckpoint = buildCompactionCheckpointPreview(
|
|
resolveLatestCompactionCheckpoint(entry),
|
|
);
|
|
const agentRuntime = resolveAgentRuntimeMetadata(cfg, sessionAgentId);
|
|
const selectedOrRuntimeModelProvider = selectedModel?.provider ?? modelProvider;
|
|
const selectedOrRuntimeModel = selectedModel?.model ?? model;
|
|
const rowModelIdentity = lightweight
|
|
? { provider: selectedOrRuntimeModelProvider, model: selectedOrRuntimeModel }
|
|
: resolveSessionDisplayModelIdentityRef({
|
|
cfg,
|
|
agentId: sessionAgentId,
|
|
provider: selectedOrRuntimeModelProvider,
|
|
model: selectedOrRuntimeModel,
|
|
});
|
|
const rowModelProvider = rowModelIdentity.provider;
|
|
const rowModel = rowModelIdentity.model;
|
|
const estimatedCostUsd = lightweight
|
|
? resolveNonNegativeNumber(entry?.estimatedCostUsd)
|
|
: (resolveEstimatedSessionCostUsd({
|
|
cfg,
|
|
provider: rowModelProvider,
|
|
model: rowModel,
|
|
entry,
|
|
}) ?? resolveNonNegativeNumber(transcriptUsage?.estimatedCostUsd));
|
|
const contextTokens = lightweight
|
|
? resolvePositiveNumber(entry?.contextTokens)
|
|
: (resolvePositiveNumber(entry?.contextTokens) ??
|
|
resolvePositiveNumber(transcriptUsage?.contextTokens) ??
|
|
resolvePositiveNumber(
|
|
resolveContextTokensForModel({
|
|
cfg,
|
|
provider: rowModelProvider,
|
|
model: rowModel,
|
|
allowAsyncLoad: false,
|
|
}),
|
|
));
|
|
|
|
let derivedTitle: string | undefined;
|
|
let lastMessagePreview: string | undefined;
|
|
if (entry?.sessionId && (params.includeDerivedTitles || params.includeLastMessage)) {
|
|
const fields = readSessionTitleFieldsFromTranscript(
|
|
entry.sessionId,
|
|
storePath,
|
|
entry.sessionFile,
|
|
sessionAgentId,
|
|
);
|
|
if (params.includeDerivedTitles) {
|
|
derivedTitle = deriveSessionTitle(entry, fields.firstUserMessage);
|
|
}
|
|
if (params.includeLastMessage && fields.lastMessagePreview) {
|
|
lastMessagePreview = fields.lastMessagePreview;
|
|
}
|
|
}
|
|
|
|
const thinkingProvider = rowModelProvider ?? DEFAULT_PROVIDER;
|
|
const thinkingModel = rowModel ?? DEFAULT_MODEL;
|
|
const thinkingLevels = resolveSessionRowThinkingLevels({
|
|
provider: thinkingProvider,
|
|
model: thinkingModel,
|
|
modelCatalog: params.modelCatalog,
|
|
rowContext,
|
|
});
|
|
const thinkingDefault = resolveSessionRowThinkingDefault({
|
|
cfg,
|
|
provider: thinkingProvider,
|
|
model: thinkingModel,
|
|
agentId: sessionAgentId,
|
|
modelCatalog: params.modelCatalog,
|
|
rowContext,
|
|
});
|
|
const pluginExtensions =
|
|
!lightweight && entry ? projectPluginSessionExtensionsSync({ sessionKey: key, entry }) : [];
|
|
|
|
return {
|
|
key,
|
|
spawnedBy: subagentOwner || entry?.spawnedBy,
|
|
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
|
|
forkedFromParent: entry?.forkedFromParent,
|
|
spawnDepth: entry?.spawnDepth,
|
|
subagentRole: entry?.subagentRole,
|
|
subagentControlScope: entry?.subagentControlScope,
|
|
kind: classifySessionKey(key, entry),
|
|
label: entry?.label,
|
|
displayName,
|
|
derivedTitle,
|
|
lastMessagePreview,
|
|
channel,
|
|
subject,
|
|
groupChannel,
|
|
space,
|
|
chatType: entry?.chatType,
|
|
origin,
|
|
updatedAt,
|
|
sessionId: entry?.sessionId,
|
|
systemSent: entry?.systemSent,
|
|
abortedLastRun: entry?.abortedLastRun,
|
|
thinkingLevel: entry?.thinkingLevel,
|
|
thinkingLevels,
|
|
thinkingOptions: thinkingLevels.map((level) => level.label),
|
|
thinkingDefault,
|
|
fastMode: entry?.fastMode,
|
|
verboseLevel: entry?.verboseLevel,
|
|
traceLevel: entry?.traceLevel,
|
|
reasoningLevel: entry?.reasoningLevel,
|
|
elevatedLevel: entry?.elevatedLevel,
|
|
sendPolicy: entry?.sendPolicy,
|
|
inputTokens: entry?.inputTokens,
|
|
outputTokens: entry?.outputTokens,
|
|
totalTokens,
|
|
totalTokensFresh,
|
|
estimatedCostUsd,
|
|
status: subagentRun ? subagentStatus : entry?.status,
|
|
subagentRunState,
|
|
hasActiveSubagentRun: subagentRun ? liveSubagentRunActive : undefined,
|
|
startedAt: subagentRun ? subagentStartedAt : entry?.startedAt,
|
|
endedAt: subagentRun ? subagentEndedAt : entry?.endedAt,
|
|
runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs,
|
|
parentSessionKey: subagentOwner || entry?.parentSessionKey,
|
|
childSessions,
|
|
responseUsage: entry?.responseUsage,
|
|
modelProvider: rowModelProvider,
|
|
model: rowModel,
|
|
agentRuntime,
|
|
contextTokens,
|
|
deliveryContext: deliveryFields.deliveryContext,
|
|
lastChannel: deliveryFields.lastChannel ?? entry?.lastChannel,
|
|
lastTo: deliveryFields.lastTo ?? entry?.lastTo,
|
|
lastAccountId: deliveryFields.lastAccountId ?? entry?.lastAccountId,
|
|
lastThreadId: deliveryFields.lastThreadId ?? entry?.lastThreadId,
|
|
compactionCheckpointCount: entry?.compactionCheckpoints?.length,
|
|
latestCompactionCheckpoint,
|
|
pluginExtensions: pluginExtensions.length > 0 ? pluginExtensions : undefined,
|
|
};
|
|
}
|
|
|
|
function resolveSessionListSearchDisplayName(
|
|
key: string,
|
|
entry?: SessionEntry,
|
|
): string | undefined {
|
|
if (entry?.displayName) {
|
|
return entry.displayName;
|
|
}
|
|
const parsed = parseGroupKey(key);
|
|
const channel = entry?.channel ?? parsed?.channel;
|
|
if (!channel) {
|
|
return undefined;
|
|
}
|
|
return buildGroupDisplayName({
|
|
provider: channel,
|
|
subject: entry?.subject,
|
|
groupChannel: entry?.groupChannel,
|
|
space: entry?.space,
|
|
id: parsed?.id,
|
|
key,
|
|
});
|
|
}
|
|
|
|
export function loadGatewaySessionRow(
|
|
sessionKey: string,
|
|
options?: {
|
|
includeDerivedTitles?: boolean;
|
|
includeLastMessage?: boolean;
|
|
now?: number;
|
|
transcriptUsageMaxBytes?: number;
|
|
},
|
|
): GatewaySessionRow | null {
|
|
const { cfg, storePath, store, entry, canonicalKey } = loadSessionEntry(sessionKey);
|
|
if (!entry) {
|
|
return null;
|
|
}
|
|
return buildGatewaySessionRow({
|
|
cfg,
|
|
storePath,
|
|
store,
|
|
key: canonicalKey,
|
|
entry,
|
|
now: options?.now,
|
|
includeDerivedTitles: options?.includeDerivedTitles,
|
|
includeLastMessage: options?.includeLastMessage,
|
|
transcriptUsageMaxBytes: options?.transcriptUsageMaxBytes,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Number of session rows to build per batch before yielding to the event loop.
|
|
* Keeps the main thread responsive during large session list operations while
|
|
* avoiding excessive yielding overhead for small stores.
|
|
*/
|
|
const SESSIONS_LIST_YIELD_BATCH_SIZE = 10;
|
|
const SESSIONS_LIST_TOP_N_LIMIT = 200;
|
|
const SESSIONS_LIST_DEFAULT_LIMIT = 100;
|
|
|
|
type SessionEntryPair = [string, SessionEntry];
|
|
type SessionEntrySelection = {
|
|
entries: SessionEntryPair[];
|
|
totalCount: number;
|
|
limitApplied?: number;
|
|
};
|
|
|
|
function compareSessionEntryPairsByUpdatedAt(a: SessionEntryPair, b: SessionEntryPair): number {
|
|
return (b[1]?.updatedAt ?? 0) - (a[1]?.updatedAt ?? 0);
|
|
}
|
|
|
|
function resolveSessionsListLimit(
|
|
opts: import("./protocol/index.js").SessionsListParams,
|
|
defaultLimit?: number,
|
|
): number | undefined {
|
|
if (typeof opts.limit !== "number" || !Number.isFinite(opts.limit)) {
|
|
return defaultLimit;
|
|
}
|
|
return Math.max(1, Math.floor(opts.limit));
|
|
}
|
|
|
|
function selectNewestLimitedEntries(
|
|
entries: SessionEntryPair[],
|
|
limit: number,
|
|
): SessionEntryPair[] {
|
|
const selected: SessionEntryPair[] = [];
|
|
for (const entry of entries) {
|
|
const insertAt = selected.findIndex(
|
|
(candidate) => compareSessionEntryPairsByUpdatedAt(entry, candidate) < 0,
|
|
);
|
|
if (insertAt >= 0) {
|
|
selected.splice(insertAt, 0, entry);
|
|
if (selected.length > limit) {
|
|
selected.pop();
|
|
}
|
|
} else if (selected.length < limit) {
|
|
selected.push(entry);
|
|
}
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
function sortAndLimitSessionEntries(
|
|
entries: SessionEntryPair[],
|
|
limit: number | undefined,
|
|
): SessionEntryPair[] {
|
|
if (limit !== undefined && limit <= SESSIONS_LIST_TOP_N_LIMIT) {
|
|
return selectNewestLimitedEntries(entries, limit);
|
|
}
|
|
const sorted = entries.toSorted(compareSessionEntryPairsByUpdatedAt);
|
|
return limit === undefined ? sorted : sorted.slice(0, limit);
|
|
}
|
|
|
|
function filterSessionEntries(params: {
|
|
store: Record<string, SessionEntry>;
|
|
opts: import("./protocol/index.js").SessionsListParams;
|
|
now: number;
|
|
rowContext?: SessionListRowContext;
|
|
}): SessionEntryPair[] {
|
|
const { store, opts, now } = params;
|
|
const rowContext = params.rowContext;
|
|
const includeGlobal = opts.includeGlobal === true;
|
|
const includeUnknown = opts.includeUnknown === true;
|
|
const spawnedBy = typeof opts.spawnedBy === "string" ? opts.spawnedBy : "";
|
|
const label = normalizeOptionalString(opts.label) ?? "";
|
|
const agentId = typeof opts.agentId === "string" ? normalizeAgentId(opts.agentId) : "";
|
|
const search = normalizeLowercaseStringOrEmpty(opts.search);
|
|
const activeMinutes =
|
|
typeof opts.activeMinutes === "number" && Number.isFinite(opts.activeMinutes)
|
|
? Math.max(1, Math.floor(opts.activeMinutes))
|
|
: undefined;
|
|
|
|
let entries = Object.entries(store)
|
|
.filter(([key]) => {
|
|
if (isCronRunSessionKey(key)) {
|
|
return false;
|
|
}
|
|
if (!includeGlobal && key === "global") {
|
|
return false;
|
|
}
|
|
if (!includeUnknown && key === "unknown") {
|
|
return false;
|
|
}
|
|
if (agentId) {
|
|
if (key === "global" || key === "unknown") {
|
|
return false;
|
|
}
|
|
const parsed = parseAgentSessionKey(key);
|
|
if (!parsed) {
|
|
return false;
|
|
}
|
|
return normalizeAgentId(parsed.agentId) === agentId;
|
|
}
|
|
return true;
|
|
})
|
|
.filter(([key, entry]) => {
|
|
if (!spawnedBy) {
|
|
return true;
|
|
}
|
|
if (key === "unknown" || key === "global") {
|
|
return false;
|
|
}
|
|
const latest = rowContext
|
|
? rowContext.subagentRuns.getDisplaySubagentRun(key)
|
|
: getSessionDisplaySubagentRunByChildSessionKey(key);
|
|
if (latest) {
|
|
const latestControllerSessionKey =
|
|
normalizeOptionalString(latest.controllerSessionKey) ||
|
|
normalizeOptionalString(latest.requesterSessionKey);
|
|
return (
|
|
latestControllerSessionKey === spawnedBy &&
|
|
shouldKeepSubagentRunChildLink(latest, {
|
|
activeDescendants: rowContext
|
|
? rowContext.subagentRuns.countActiveDescendantRuns(key)
|
|
: countActiveDescendantRuns(key),
|
|
now,
|
|
})
|
|
);
|
|
}
|
|
return (
|
|
shouldKeepStoreOnlyChildLink(entry, now) &&
|
|
(entry?.spawnedBy === spawnedBy || entry?.parentSessionKey === spawnedBy)
|
|
);
|
|
})
|
|
.filter(([, entry]) => {
|
|
if (!label) {
|
|
return true;
|
|
}
|
|
return entry?.label === label;
|
|
});
|
|
|
|
if (search) {
|
|
entries = entries.filter(([key, entry]) => {
|
|
const fields = [
|
|
resolveSessionListSearchDisplayName(key, entry),
|
|
entry?.label,
|
|
entry?.subject,
|
|
entry?.sessionId,
|
|
key,
|
|
];
|
|
return fields.some(
|
|
(f) => typeof f === "string" && normalizeLowercaseStringOrEmpty(f).includes(search),
|
|
);
|
|
});
|
|
}
|
|
|
|
if (activeMinutes !== undefined) {
|
|
const cutoff = now - activeMinutes * 60_000;
|
|
entries = entries.filter(([, entry]) => (entry?.updatedAt ?? 0) >= cutoff);
|
|
}
|
|
|
|
return entries;
|
|
}
|
|
|
|
function selectSessionEntries(params: {
|
|
store: Record<string, SessionEntry>;
|
|
opts: import("./protocol/index.js").SessionsListParams;
|
|
now: number;
|
|
rowContext?: SessionListRowContext;
|
|
defaultLimit?: number;
|
|
}): SessionEntrySelection {
|
|
const filtered = filterSessionEntries(params);
|
|
const limit = resolveSessionsListLimit(params.opts, params.defaultLimit);
|
|
const entries = sortAndLimitSessionEntries(filtered, limit);
|
|
return {
|
|
entries,
|
|
totalCount: filtered.length,
|
|
limitApplied: limit,
|
|
};
|
|
}
|
|
|
|
export function filterAndSortSessionEntries(params: {
|
|
store: Record<string, SessionEntry>;
|
|
opts: import("./protocol/index.js").SessionsListParams;
|
|
now: number;
|
|
rowContext?: SessionListRowContext;
|
|
}): [string, SessionEntry][] {
|
|
return selectSessionEntries(params).entries;
|
|
}
|
|
|
|
export function listSessionsFromStore(params: {
|
|
cfg: OpenClawConfig;
|
|
storePath: string;
|
|
store: Record<string, SessionEntry>;
|
|
modelCatalog?: ModelCatalogEntry[];
|
|
opts: import("./protocol/index.js").SessionsListParams;
|
|
}): SessionsListResult {
|
|
const { cfg, storePath, store, opts } = params;
|
|
const now = Date.now();
|
|
const sessionListTranscriptUsageMaxBytes = 64 * 1024;
|
|
const sessionListTranscriptFieldRows = 100;
|
|
let rowContext: SessionListRowContext | undefined;
|
|
const getRowContext = () => {
|
|
rowContext ??= buildSessionListRowContext({ store, now });
|
|
return rowContext;
|
|
};
|
|
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
|
const includeLastMessage = opts.includeLastMessage === true;
|
|
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
|
|
|
|
const selection = selectSessionEntries({
|
|
store,
|
|
opts,
|
|
now,
|
|
rowContext: hasSpawnedByFilter ? getRowContext() : undefined,
|
|
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
|
|
});
|
|
const { entries, totalCount, limitApplied } = selection;
|
|
const defaults = getSessionDefaults(cfg, params.modelCatalog);
|
|
if (entries.length > 0) {
|
|
seedSessionRowContextDefaultThinking({ rowContext: getRowContext(), cfg, defaults });
|
|
}
|
|
|
|
const sessions = entries.map(([key, entry], index) => {
|
|
const includeTranscriptFields = index < sessionListTranscriptFieldRows;
|
|
return buildGatewaySessionRow({
|
|
cfg,
|
|
storePath,
|
|
store,
|
|
key,
|
|
entry,
|
|
modelCatalog: params.modelCatalog,
|
|
now,
|
|
includeDerivedTitles: includeTranscriptFields && includeDerivedTitles,
|
|
includeLastMessage: includeTranscriptFields && includeLastMessage,
|
|
transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes,
|
|
storeChildSessionsByKey: getRowContext().storeChildSessionsByKey,
|
|
rowContext: getRowContext(),
|
|
});
|
|
});
|
|
|
|
return {
|
|
ts: now,
|
|
path: storePath,
|
|
count: sessions.length,
|
|
totalCount,
|
|
limitApplied,
|
|
hasMore: sessions.length < totalCount,
|
|
defaults,
|
|
sessions,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Async version of listSessionsFromStore that yields to the event loop between
|
|
* batches of session row builds. This prevents large session stores from
|
|
* blocking the event loop during sessions.list requests.
|
|
*
|
|
* The synchronous file I/O in readSessionTitleFieldsFromTranscript (head/tail
|
|
* reads for derived titles and last-message previews) is the dominant blocker.
|
|
* By yielding every SESSIONS_LIST_YIELD_BATCH_SIZE rows, we keep the event
|
|
* loop responsive for WebSocket heartbeats, channel I/O, and concurrent RPC.
|
|
*/
|
|
export async function listSessionsFromStoreAsync(params: {
|
|
cfg: OpenClawConfig;
|
|
storePath: string;
|
|
store: Record<string, SessionEntry>;
|
|
modelCatalog?: ModelCatalogEntry[];
|
|
opts: import("./protocol/index.js").SessionsListParams;
|
|
}): Promise<SessionsListResult> {
|
|
const { cfg, storePath, store, opts } = params;
|
|
const now = Date.now();
|
|
const sessionListTranscriptUsageMaxBytes = 64 * 1024;
|
|
const sessionListTranscriptFieldRows = 100;
|
|
let rowContext: SessionListRowContext | undefined;
|
|
const getRowContext = () => {
|
|
rowContext ??= buildSessionListRowContext({ store, now });
|
|
return rowContext;
|
|
};
|
|
const includeDerivedTitles = opts.includeDerivedTitles === true;
|
|
const includeLastMessage = opts.includeLastMessage === true;
|
|
const hasSpawnedByFilter = typeof opts.spawnedBy === "string" && opts.spawnedBy.length > 0;
|
|
|
|
const selection = selectSessionEntries({
|
|
store,
|
|
opts,
|
|
now,
|
|
rowContext: hasSpawnedByFilter ? getRowContext() : undefined,
|
|
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
|
|
});
|
|
const { entries, totalCount, limitApplied } = selection;
|
|
const defaults = getSessionDefaults(cfg, params.modelCatalog);
|
|
if (entries.length > 0) {
|
|
seedSessionRowContextDefaultThinking({ rowContext: getRowContext(), cfg, defaults });
|
|
}
|
|
|
|
const sessions: GatewaySessionRow[] = [];
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const [key, entry] = entries[i];
|
|
const includeTranscriptFields = i < sessionListTranscriptFieldRows;
|
|
const row = buildGatewaySessionRow({
|
|
cfg,
|
|
storePath,
|
|
store,
|
|
key,
|
|
entry,
|
|
modelCatalog: params.modelCatalog,
|
|
now,
|
|
includeDerivedTitles: false,
|
|
includeLastMessage: false,
|
|
transcriptUsageMaxBytes: sessionListTranscriptUsageMaxBytes,
|
|
storeChildSessionsByKey: getRowContext().storeChildSessionsByKey,
|
|
rowContext: getRowContext(),
|
|
skipTranscriptUsageFallback: true,
|
|
lightweightListRow: true,
|
|
});
|
|
if (
|
|
entry?.sessionId &&
|
|
includeTranscriptFields &&
|
|
(includeDerivedTitles || includeLastMessage)
|
|
) {
|
|
const parsed = parseAgentSessionKey(key);
|
|
const sessionAgentId = parsed?.agentId
|
|
? normalizeAgentId(parsed.agentId)
|
|
: resolveDefaultAgentId(cfg);
|
|
const fields = await readSessionTitleFieldsFromTranscriptAsync(
|
|
entry.sessionId,
|
|
storePath,
|
|
entry.sessionFile,
|
|
sessionAgentId,
|
|
);
|
|
if (includeDerivedTitles) {
|
|
row.derivedTitle = deriveSessionTitle(entry, fields.firstUserMessage);
|
|
}
|
|
if (includeLastMessage && fields.lastMessagePreview) {
|
|
row.lastMessagePreview = fields.lastMessagePreview;
|
|
}
|
|
}
|
|
sessions.push(row);
|
|
// Yield to the event loop between batches so WebSocket heartbeats,
|
|
// channel I/O, and concurrent RPC calls are not starved.
|
|
if ((i + 1) % SESSIONS_LIST_YIELD_BATCH_SIZE === 0 && i + 1 < entries.length) {
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
}
|
|
}
|
|
|
|
return {
|
|
ts: now,
|
|
path: storePath,
|
|
count: sessions.length,
|
|
totalCount,
|
|
limitApplied,
|
|
hasMore: sessions.length < totalCount,
|
|
defaults,
|
|
sessions,
|
|
};
|
|
}
|