mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
cd8a96491e
* fix: repair canonical session keys with doctor * test: align canonical validation with reopen boundary * test: align session fixtures with canonical keys * fix: copy only canonical session repair winners * fix: cover canonical repair edge cases * test: remove retired preview helper import * test: align canonical repair delivery scope * fix: close canonical repair validation gaps * fix: preserve destination-only repair generations * fix: preserve canonical repair window projections * style: format canonical repair imports * fix: preserve canonical repair delivery ownership * fix: preserve winner-owned cross-store state * fix: guard canonical delivery rehoming * fix: align canonical session contracts after rebase * test: accept internal canonical repair fixtures * test: move sharing alias coverage to doctor
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import {
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
} from "@openclaw/normalization-core/string-coerce";
|
|
import {
|
|
readAcpSessionMeta,
|
|
readAcpSessionMetaForEntry,
|
|
repairAcpSessionMetaKeyForMigration,
|
|
} from "../acp/runtime/session-meta.js";
|
|
import { resolveModelAgentRuntimeMetadata } from "../agents/agent-runtime-metadata.js";
|
|
import {
|
|
listAgentEntries,
|
|
listAgentIds,
|
|
resolveAgentEffectiveModelPrimary,
|
|
resolveAgentModelFallbacksOverride,
|
|
resolveAgentWorkspaceDir,
|
|
} from "../agents/agent-scope.js";
|
|
import { resolveAgentAvatarUrlFromSource } from "../agents/identity-avatar-file.js";
|
|
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
|
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
|
|
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
|
|
import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js";
|
|
import { insideGitCheckout } from "../agents/worktrees/git.js";
|
|
import { listThinkingLevelOptions } from "../auto-reply/thinking.js";
|
|
import { getRuntimeConfig } from "../config/io.js";
|
|
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
|
|
import {
|
|
resolveAgentMainSessionKey,
|
|
type SessionEntry,
|
|
type SessionScope,
|
|
} from "../config/sessions.js";
|
|
import { canonicalSessionKeyMigrationRequiredError } from "../config/sessions/session-canonical-key.js";
|
|
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
|
import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
|
import { isAcpSessionKey } from "../sessions/session-key-utils.js";
|
|
import { listGatewayAgentsBasic } from "./agent-list.js";
|
|
import { resolveGatewaySessionThinkingDefault } from "./session-utils-model.js";
|
|
import {
|
|
resolveGatewaySessionStoreTarget,
|
|
resolveGatewaySessionStoreTargetWithStore,
|
|
} from "./session-utils-store-lookup.js";
|
|
import type { GatewayAgentRow } from "./session-utils.types.js";
|
|
|
|
/**
|
|
* 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, confirmed ACP runtime session keys, or when the owning agent still
|
|
* exists (#65524).
|
|
*/
|
|
export function resolveDeletedAgentIdFromSessionKey(
|
|
cfg: OpenClawConfig,
|
|
sessionKey: string,
|
|
entry?: SessionEntry | null,
|
|
options?: { acpMetadataSessionKey?: string | null },
|
|
): string | null {
|
|
const parsed = parseAgentSessionKey(sessionKey);
|
|
if (!parsed) {
|
|
return null;
|
|
}
|
|
const agentId = normalizeAgentId(parsed.agentId);
|
|
if (listAgentIds(cfg).includes(agentId)) {
|
|
return null;
|
|
}
|
|
if (isAcpSessionKey(sessionKey) && !parsed.rest.startsWith("acp:binding:")) {
|
|
// Free ACP runtime keys use agent:<harnessId>:acp:<uuid>, but key shape is
|
|
// not proof: ACP bridge sessions can use ACP-shaped keys without SessionAcpMeta.
|
|
// Configured acp:binding keys stay owner-scoped even when ACP metadata exists.
|
|
const acpMeta = readAcpMetaForDeletedAgentCheck({
|
|
cfg,
|
|
sessionKey,
|
|
entry,
|
|
acpMetadataSessionKey: options?.acpMetadataSessionKey,
|
|
});
|
|
if (acpMeta) {
|
|
return null;
|
|
}
|
|
}
|
|
return agentId;
|
|
}
|
|
|
|
function readAcpMetaForDeletedAgentCheck(params: {
|
|
cfg: OpenClawConfig;
|
|
sessionKey: string;
|
|
entry?: Pick<SessionEntry, "acp" | "lifecycleRevision"> | null;
|
|
acpMetadataSessionKey?: string | null;
|
|
}) {
|
|
if (params.entry?.acp) {
|
|
return params.entry.acp;
|
|
}
|
|
|
|
const acpMetadataSessionKey = normalizeOptionalString(params.acpMetadataSessionKey);
|
|
const directKeys = new Set<string>();
|
|
if (acpMetadataSessionKey) {
|
|
directKeys.add(acpMetadataSessionKey);
|
|
} else {
|
|
const acpMeta = readAcpSessionMeta({ sessionKey: params.sessionKey, cfg: params.cfg });
|
|
if (acpMeta) {
|
|
return acpMeta;
|
|
}
|
|
}
|
|
directKeys.add(params.sessionKey);
|
|
|
|
for (const directKey of directKeys) {
|
|
const acpMeta = readAcpSessionMetaForEntry({
|
|
sessionKey: directKey,
|
|
entry: params.entry ?? undefined,
|
|
});
|
|
if (acpMeta) {
|
|
return acpMeta;
|
|
}
|
|
}
|
|
|
|
repairAcpSessionMetaKeyForMigration({
|
|
sessionKey: params.sessionKey,
|
|
candidateSessionKeys: directKeys,
|
|
entry: params.entry ?? undefined,
|
|
});
|
|
return readAcpSessionMetaForEntry({
|
|
sessionKey: params.sessionKey,
|
|
entry: params.entry ?? undefined,
|
|
});
|
|
}
|
|
|
|
function loadSessionEntryWithMode(
|
|
sessionKey: string,
|
|
opts: { agentId?: string; clone?: boolean; includeStoreChildEntries?: boolean } | undefined,
|
|
readOnly: boolean,
|
|
) {
|
|
const cfg = getRuntimeConfig();
|
|
const key = normalizeOptionalString(sessionKey) ?? "";
|
|
const target = resolveGatewaySessionStoreTargetWithStore({
|
|
cfg,
|
|
key,
|
|
...(opts?.clone === false ? { clone: false } : {}),
|
|
...(opts?.agentId ? { agentId: opts.agentId } : {}),
|
|
...(readOnly
|
|
? {
|
|
exactRead: true,
|
|
readOnly: true,
|
|
...(opts?.includeStoreChildEntries ? { includeStoreChildEntries: true } : {}),
|
|
}
|
|
: {}),
|
|
});
|
|
const storePath = target.storePath;
|
|
const store = target.store;
|
|
const canonicalMatch = resolveCanonicalSessionStoreMatchFromStoreKeys(store, target.storeKeys);
|
|
const legacyKey = canonicalMatch?.key !== target.canonicalKey ? canonicalMatch?.key : undefined;
|
|
const entry =
|
|
readOnly && opts?.clone !== false && canonicalMatch?.entry
|
|
? structuredClone(canonicalMatch.entry)
|
|
: canonicalMatch?.entry;
|
|
return {
|
|
cfg,
|
|
storePath,
|
|
store,
|
|
entry,
|
|
canonicalKey: target.canonicalKey,
|
|
storeKeys: target.storeKeys,
|
|
legacyKey,
|
|
};
|
|
}
|
|
|
|
export function loadSessionEntry(sessionKey: string, opts?: { agentId?: string; clone?: boolean }) {
|
|
return loadSessionEntryWithMode(sessionKey, opts, false);
|
|
}
|
|
|
|
export function loadSessionEntryReadOnly(
|
|
sessionKey: string,
|
|
opts?: { agentId?: string; clone?: boolean; includeStoreChildEntries?: boolean },
|
|
) {
|
|
return loadSessionEntryWithMode(sessionKey, opts, true);
|
|
}
|
|
|
|
/** Returns the one canonical entry and the exact persisted key that owns it. */
|
|
export function resolveCanonicalSessionStoreMatchFromStoreKeys(
|
|
store: Record<string, SessionEntry>,
|
|
storeKeys: string[],
|
|
): { key: string; entry: SessionEntry } | undefined {
|
|
let selected: { key: string; entry: SessionEntry } | undefined;
|
|
for (const key of storeKeys) {
|
|
const entry = store[key];
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
const match = { key, entry };
|
|
if (selected) {
|
|
throw canonicalSessionKeyMigrationRequiredError(
|
|
`duplicate rows resolve to canonical session key ${storeKeys[0] ?? key}`,
|
|
);
|
|
}
|
|
selected = match;
|
|
}
|
|
if (selected && selected.key !== storeKeys[0]) {
|
|
throw canonicalSessionKeyMigrationRequiredError(
|
|
`non-canonical persisted row resolves to session key ${storeKeys[0] ?? selected.key}`,
|
|
);
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
export function resolveCanonicalSessionEntryFromStoreKeys(
|
|
store: Record<string, SessionEntry>,
|
|
storeKeys: string[],
|
|
): SessionEntry | undefined {
|
|
return resolveCanonicalSessionStoreMatchFromStoreKeys(store, storeKeys)?.entry;
|
|
}
|
|
|
|
export function resolveCanonicalGatewaySessionStoreKey(params: {
|
|
cfg: OpenClawConfig;
|
|
key: string;
|
|
store: Record<string, SessionEntry>;
|
|
agentId?: string;
|
|
}) {
|
|
const target = resolveGatewaySessionStoreTarget({
|
|
cfg: params.cfg,
|
|
key: params.key,
|
|
store: params.store,
|
|
...(params.agentId ? { agentId: params.agentId } : {}),
|
|
});
|
|
const primaryKey = target.canonicalKey;
|
|
resolveCanonicalSessionStoreMatchFromStoreKeys(params.store, target.storeKeys);
|
|
return { target, primaryKey, entry: params.store[primaryKey] };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
export function isGroupOrChannelDisplaySession(
|
|
entry: SessionEntry | undefined,
|
|
parsed: { kind?: "group" | "channel" } | null,
|
|
): boolean {
|
|
return (
|
|
entry?.chatType === "group" ||
|
|
entry?.chatType === "channel" ||
|
|
parsed?.kind === "group" ||
|
|
parsed?.kind === "channel"
|
|
);
|
|
}
|
|
|
|
function isStorePathTemplate(store?: string): boolean {
|
|
return typeof store === "string" && store.includes("{agentId}");
|
|
}
|
|
|
|
export function resolveConcreteSessionStorePath(storePath: string | undefined): string | undefined {
|
|
const trimmed = storePath?.trim();
|
|
if (!trimmed || trimmed === "(multiple)" || isStorePathTemplate(trimmed)) {
|
|
return undefined;
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
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 {
|
|
// Agent rows expose model identity to clients; credential-profile binding stays in
|
|
// canonical config and is consumed only by execution-time model selection.
|
|
const primary = splitTrailingAuthProfile(
|
|
resolveAgentEffectiveModelPrimary(cfg, agentId) ?? "",
|
|
).model;
|
|
const fallbackOverride = resolveAgentModelFallbacksOverride(cfg, agentId);
|
|
const defaultFallbacks = resolveAgentModelFallbackValues(cfg.agents?.defaults?.model);
|
|
const fallbacks = normalizeFallbackList(
|
|
(fallbackOverride ?? defaultFallbacks).map((value) => splitTrailingAuthProfile(value).model),
|
|
);
|
|
if (!primary && fallbacks.length === 0) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
...(primary ? { primary } : {}),
|
|
...(fallbacks.length > 0 ? { fallbacks } : {}),
|
|
};
|
|
}
|
|
|
|
export function listAgentsForGateway(
|
|
cfg: OpenClawConfig,
|
|
modelCatalog?: ModelCatalogEntry[],
|
|
options?: {
|
|
modelCatalogByAgentId?: ReadonlyMap<string, ModelCatalogEntry[]>;
|
|
includeSystem?: boolean;
|
|
},
|
|
): {
|
|
defaultId: string;
|
|
mainKey: string;
|
|
scope: SessionScope;
|
|
agents: GatewayAgentRow[];
|
|
} {
|
|
const basic = listGatewayAgentsBasic(cfg);
|
|
const configuredById = new Map<string, { identity?: GatewayAgentRow["identity"] }>();
|
|
for (const entry of listAgentEntries(cfg)) {
|
|
if (!entry?.id) {
|
|
continue;
|
|
}
|
|
const agentId = normalizeAgentId(entry.id);
|
|
const avatar = normalizeOptionalString(entry.identity?.avatar);
|
|
const avatarUrl = resolveAgentAvatarUrlFromSource(cfg, agentId, avatar);
|
|
const identity = entry.identity
|
|
? {
|
|
name: normalizeOptionalString(entry.identity.name),
|
|
theme: normalizeOptionalString(entry.identity.theme),
|
|
emoji: normalizeOptionalString(entry.identity.emoji),
|
|
avatar,
|
|
avatarUrl,
|
|
}
|
|
: undefined;
|
|
configuredById.set(agentId, { identity });
|
|
}
|
|
const roster = options?.includeSystem
|
|
? basic.agents
|
|
: basic.agents.filter((entry) => entry.kind !== "system");
|
|
const agents = roster.map((entry) => {
|
|
const { id } = entry;
|
|
const meta = configuredById.get(id);
|
|
const model = resolveGatewayAgentModel(cfg, id);
|
|
const resolvedModel = resolveDefaultModelForAgent({ cfg, agentId: id });
|
|
const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: id });
|
|
const agentRuntime = resolveModelAgentRuntimeMetadata({
|
|
cfg,
|
|
agentId: id,
|
|
provider: resolvedModel.provider,
|
|
model: resolvedModel.model,
|
|
sessionKey,
|
|
acpRuntime: false,
|
|
});
|
|
const thinkingRuntime = resolveEffectiveAgentRuntime({
|
|
cfg,
|
|
provider: resolvedModel.provider,
|
|
modelId: resolvedModel.model,
|
|
agentId: id,
|
|
sessionKey,
|
|
});
|
|
const agentModelCatalog = options?.modelCatalogByAgentId?.get(id) ?? modelCatalog;
|
|
const thinkingLevels = listThinkingLevelOptions(
|
|
resolvedModel.provider,
|
|
resolvedModel.model,
|
|
agentModelCatalog,
|
|
thinkingRuntime,
|
|
);
|
|
const workspace = resolveAgentWorkspaceDir(cfg, id);
|
|
// Must mirror the sessions.create worktree preflight: subdirectory workspaces inside a
|
|
// repo are worktree-capable, so the UI toggle and the create path cannot diverge.
|
|
const workspaceGit = insideGitCheckout(workspace);
|
|
return Object.assign(
|
|
{
|
|
id,
|
|
...(options?.includeSystem ? { kind: entry.kind } : {}),
|
|
name: entry.name,
|
|
identity: meta?.identity,
|
|
workspace,
|
|
workspaceGit,
|
|
agentRuntime,
|
|
thinkingLevels,
|
|
thinkingOptions: thinkingLevels.map((level) => level.label),
|
|
thinkingDefault: resolveGatewaySessionThinkingDefault({
|
|
cfg,
|
|
provider: resolvedModel.provider,
|
|
model: resolvedModel.model,
|
|
agentId: id,
|
|
modelCatalog: agentModelCatalog,
|
|
agentRuntime: thinkingRuntime,
|
|
}),
|
|
},
|
|
model ? { model } : {},
|
|
);
|
|
});
|
|
return { defaultId: basic.defaultId, mainKey: basic.mainKey, scope: basic.scope, agents };
|
|
}
|