mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 03:51:38 +00:00
* refactor(ui): restructure Settings IA around user-facing pages Settings sidebar shrinks to settings-only destinations grouped by attention: General/Appearance/Notifications up top, Connections, Agents & Tools, Privacy & Security, System. Sessions, Worktrees, Activity, and Memory Import leave the takeover: Sessions/Activity return to the workspace sidebar, Worktrees becomes a Sessions hub tab, Memory Import is reached from the Agents memory panel. Model Setup becomes a Models subpage. New config pages: Notifications (web push), Privacy & Security (curated policy rows + security/approvals schema sections), Advanced (uncurated schema sections + raw editor, replacing the General quick/advanced toggle). General slims to model defaults, language, and gateway host; identity moves to Profile, lobster appearance rows to Appearance. * feat(agents): widen system-agent config writes to operator parity Every config surface the Control UI can edit is now agent-writable behind the exact-operation human approval gate, except a small documented denylist ($include, auth, env, models, secrets). tools.* and plugins.entries.* writes are allowed; plugin_uninstall works for plugins that provably do not back the active default inference route (fail closed otherwise); set_default_model takes an optional agentId and live-tests a per-agent model with the same rigor as the default route. Per-agent routing fields on non-default agents are direct-writable; the default agent's route keeps the verified set_default_model/onboard paths. A new config-write-parity contract test forces explicit classification of every top-level config key. * feat(ui): sync operator display prefs through config ui.prefs Theme, theme mode, text scale, locale, and chat display prefs get a canonical server-side home in config ui.prefs. The Control UI mirrors them into localStorage for instant boot, writes local changes through to the gateway (best-effort; viewer-scope or offline clients stay device-local), and applies server-side deltas on connect and on every config snapshot reload — so an agent changing the theme through the approval gate takes effect in the UI, and prefs follow the operator across devices. * chore: prune quick.ts from max-lines baseline after slim-down * style: oxfmt changed files * chore: regenerate plugin-sdk api baseline for ui.prefs config surface * fix: dedupe runPluginUninstall dep type and narrow locale extraction * docs: update Control UI settings structure and ui.prefs sync * fix: address autoreview findings on parity guards and prefs sync - plugins.entries.* config writes get the same active-route ownership check as plugin_uninstall (fail closed when unprovable) - system-agent prompt now describes the guarded write policy instead of the old blanket refusals - per-agent set_default_model logs the agent-scoped label - legacy /config?section=... deep links redirect to the Advanced page - prefs sync: pushes coalesce and drain serially (no CAS races dropping updates), replaced config hashes mark pre-patch snapshots as stale, and the last-seen server value persists per gateway scope so reloads cannot revert unpushable viewer-scope edits - lint: theme membership checks use Sets * fix: harden uninstall route guard and scope prefs queue per gateway - plugin uninstall ownership now covers execution owners (runtime policy, embedded harness override) in addition to the provider, and re-verifies inside the commit boundary so a concurrent route switch cannot slip a destructive uninstall past the pre-approval check - prefs push queue binds to one gateway client; switching gateways drops the old queue (device-local fallback) instead of cross-writing prefs * style: top-level type import in config-page test * fix: close review races in config-write guards and prefs reconciliation - config_set/config_set_ref re-verify the inference-route guard inside the commit boundary so conditional verdicts (per-agent routing, plugin entries) cannot be flipped by a concurrent edit after approval - server prefs apply per changed field, so a server change to one pref no longer reverts unpushable local edits of the others - replaced-hash staleness retires once post-patch state is observed, so a genuine restore of the pre-patch config becomes authoritative again - moved-section deep links (communications notifications, automation approvals) redirect to their new pages * style: satisfy no-useless-assignment and loop-condition lints in prefs sync * fix(ci): resolve unhandled profile-test rejections and knip unused exports The profile page's new identity section reads context.config; the test context mock now provides it, eliminating the unhandled rejections that failed checks-ui and the compact-large shard. Exports flagged by the dependency gate (extract/patch helpers, section-key and prop types) go module-private; extraction coverage moves behind applyServerUiPrefs. * refactor(agents): move config-write policy to its own module The static path classifier and documented denylist move to config-write-policy.ts, shared by the execution guard and the parity contract test — no test-only exports left for the dependency gate to flag. Blocked-root refusals now cite the denylist's documented escalation for that root.
630 lines
20 KiB
TypeScript
630 lines
20 KiB
TypeScript
/** Higher-level agent scope helpers for model selection, fallbacks, skills, and workspaces. */
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
|
|
import { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js";
|
|
export { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js";
|
|
import {
|
|
lowercasePreservingWhitespace,
|
|
normalizeLowercaseStringOrEmpty,
|
|
normalizeOptionalString,
|
|
resolvePrimaryStringValue,
|
|
} from "@openclaw/normalization-core/string-coerce";
|
|
import type { SessionEntry } from "../config/sessions/types.js";
|
|
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
|
import type { AgentModelConfig } from "../config/types.agents-shared.js";
|
|
import type { AgentConfig } from "../config/types.agents.js";
|
|
import type { OpenClawConfig } from "../config/types.js";
|
|
import { isPathInside } from "../infra/path-guards.js";
|
|
import {
|
|
isSubagentSessionKey,
|
|
normalizeAgentId,
|
|
parseAgentSessionKey,
|
|
resolveAgentIdFromSessionKey,
|
|
} from "../routing/session-key.js";
|
|
import { resolveEffectiveAgentSkillFilter } from "../skills/discovery/agent-filter.js";
|
|
import { resolveUserPath } from "../utils.js";
|
|
import {
|
|
listAgentIds,
|
|
resolveAgentConfig,
|
|
resolveAgentWorkspaceDir,
|
|
resolveDefaultAgentId,
|
|
} from "./agent-scope-config.js";
|
|
export {
|
|
listAgentEntries,
|
|
listAgentIds,
|
|
resolveAgentConfig,
|
|
resolveAgentContextLimits,
|
|
resolveAgentDir,
|
|
resolveDefaultAgentDir,
|
|
resolveAgentWorkspaceDir,
|
|
resolveDefaultAgentId,
|
|
type ResolvedAgentConfig,
|
|
} from "./agent-scope-config.js";
|
|
|
|
/** Strip null bytes from paths to prevent ENOTDIR errors. */
|
|
function stripNullBytes(s: string): string {
|
|
return s.split("\0").join("");
|
|
}
|
|
|
|
const AUTO_FALLBACK_PRIMARY_PROBE_INTERVAL_MS = 5 * 60 * 1000;
|
|
const AUTO_FALLBACK_PRIMARY_PROBE_MAX_KEYS = 4096;
|
|
const autoFallbackPrimaryProbeState = new Map<string, number>();
|
|
|
|
function autoFallbackPrimaryProbeStateKey(params: {
|
|
sessionKey?: string | null;
|
|
primaryProvider: string;
|
|
primaryModel: string;
|
|
}): string {
|
|
return [
|
|
normalizeOptionalString(params.sessionKey) ?? "",
|
|
`${params.primaryProvider}/${params.primaryModel}`,
|
|
].join("\0");
|
|
}
|
|
|
|
function pruneAutoFallbackPrimaryProbeState(params: {
|
|
state: Map<string, number>;
|
|
now: number;
|
|
minIntervalMs: number;
|
|
maxKeys?: number;
|
|
}): void {
|
|
const maxKeys = Math.max(1, Math.trunc(params.maxKeys ?? AUTO_FALLBACK_PRIMARY_PROBE_MAX_KEYS));
|
|
const staleBefore = params.now - params.minIntervalMs;
|
|
for (const [key, lastProbeAt] of params.state) {
|
|
if (!Number.isFinite(lastProbeAt) || lastProbeAt < staleBefore) {
|
|
params.state.delete(key);
|
|
}
|
|
}
|
|
if (params.state.size <= maxKeys) {
|
|
return;
|
|
}
|
|
const removeCount = params.state.size - maxKeys;
|
|
let removed = 0;
|
|
for (const key of params.state.keys()) {
|
|
params.state.delete(key);
|
|
removed += 1;
|
|
if (removed >= removeCount) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Primary model probe metadata used to validate auto-fallback recovery. */
|
|
export type AutoFallbackPrimaryProbe = {
|
|
provider: string;
|
|
model: string;
|
|
fallbackProvider: string;
|
|
fallbackModel: string;
|
|
fallbackAuthProfileId?: string;
|
|
fallbackAuthProfileIdSource?: "auto" | "user";
|
|
};
|
|
|
|
/** Detects old auto-fallback session entries that lack primary-origin metadata. */
|
|
export function hasLegacyAutoFallbackWithoutOrigin(
|
|
entry:
|
|
| Pick<
|
|
SessionEntry,
|
|
| "modelOverrideSource"
|
|
| "modelOverrideFallbackOriginProvider"
|
|
| "modelOverrideFallbackOriginModel"
|
|
>
|
|
| null
|
|
| undefined,
|
|
): boolean {
|
|
return (
|
|
entry?.modelOverrideSource === "auto" &&
|
|
(!normalizeOptionalString(entry.modelOverrideFallbackOriginProvider) ||
|
|
!normalizeOptionalString(entry.modelOverrideFallbackOriginModel))
|
|
);
|
|
}
|
|
|
|
export function resolveAutoFallbackPrimaryProbe(params: {
|
|
entry:
|
|
| Pick<
|
|
SessionEntry,
|
|
| "providerOverride"
|
|
| "modelOverride"
|
|
| "modelOverrideSource"
|
|
| "modelOverrideFallbackOriginProvider"
|
|
| "modelOverrideFallbackOriginModel"
|
|
| "authProfileOverride"
|
|
| "authProfileOverrideSource"
|
|
| "authProfileOverrideCompactionCount"
|
|
>
|
|
| null
|
|
| undefined;
|
|
sessionKey?: string | null;
|
|
primaryProvider: string;
|
|
primaryModel: string;
|
|
now?: number;
|
|
minIntervalMs?: number;
|
|
maxTrackedProbeKeys?: number;
|
|
probeState?: Map<string, number>;
|
|
}): AutoFallbackPrimaryProbe | undefined {
|
|
const entry = params.entry;
|
|
if (!entry) {
|
|
return undefined;
|
|
}
|
|
const recoveredAutoFallbackOverride =
|
|
entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry);
|
|
if (entry.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) {
|
|
return undefined;
|
|
}
|
|
|
|
const originProvider = normalizeOptionalString(entry.modelOverrideFallbackOriginProvider);
|
|
const originModel = normalizeOptionalString(entry.modelOverrideFallbackOriginModel);
|
|
const overrideProvider = normalizeOptionalString(entry.providerOverride);
|
|
const overrideModel = normalizeOptionalString(entry.modelOverride);
|
|
const primaryProvider = normalizeOptionalString(params.primaryProvider);
|
|
const primaryModel = normalizeOptionalString(params.primaryModel);
|
|
if (!originProvider || !originModel || !overrideProvider || !overrideModel) {
|
|
return undefined;
|
|
}
|
|
if (!primaryProvider || !primaryModel) {
|
|
return undefined;
|
|
}
|
|
if (originProvider !== primaryProvider || originModel !== primaryModel) {
|
|
return undefined;
|
|
}
|
|
if (overrideProvider === originProvider && overrideModel === originModel) {
|
|
return undefined;
|
|
}
|
|
|
|
const now = params.now ?? Date.now();
|
|
const minIntervalMs = params.minIntervalMs ?? AUTO_FALLBACK_PRIMARY_PROBE_INTERVAL_MS;
|
|
const state = params.probeState ?? autoFallbackPrimaryProbeState;
|
|
pruneAutoFallbackPrimaryProbeState({
|
|
state,
|
|
now,
|
|
minIntervalMs,
|
|
maxKeys: params.maxTrackedProbeKeys,
|
|
});
|
|
const key = autoFallbackPrimaryProbeStateKey({
|
|
sessionKey: params.sessionKey,
|
|
primaryProvider: originProvider,
|
|
primaryModel: originModel,
|
|
});
|
|
const lastProbeAt = state.get(key);
|
|
if (
|
|
typeof lastProbeAt === "number" &&
|
|
Number.isFinite(lastProbeAt) &&
|
|
now - lastProbeAt < minIntervalMs
|
|
) {
|
|
return undefined;
|
|
}
|
|
const fallbackAuthProfileId = normalizeOptionalString(entry.authProfileOverride);
|
|
const fallbackAuthProfileIdSource =
|
|
entry.authProfileOverrideSource ??
|
|
(entry.authProfileOverrideCompactionCount !== undefined ? "auto" : undefined);
|
|
return {
|
|
provider: originProvider,
|
|
model: originModel,
|
|
fallbackProvider: overrideProvider,
|
|
fallbackModel: overrideModel,
|
|
...(fallbackAuthProfileId
|
|
? {
|
|
fallbackAuthProfileId,
|
|
...(fallbackAuthProfileIdSource ? { fallbackAuthProfileIdSource } : {}),
|
|
}
|
|
: {}),
|
|
};
|
|
}
|
|
|
|
export function markAutoFallbackPrimaryProbe(params: {
|
|
probe: AutoFallbackPrimaryProbe;
|
|
sessionKey?: string | null;
|
|
now?: number;
|
|
minIntervalMs?: number;
|
|
maxTrackedProbeKeys?: number;
|
|
probeState?: Map<string, number>;
|
|
}): void {
|
|
const now = params.now ?? Date.now();
|
|
const minIntervalMs = params.minIntervalMs ?? AUTO_FALLBACK_PRIMARY_PROBE_INTERVAL_MS;
|
|
const state = params.probeState ?? autoFallbackPrimaryProbeState;
|
|
pruneAutoFallbackPrimaryProbeState({
|
|
state,
|
|
now,
|
|
minIntervalMs,
|
|
maxKeys: params.maxTrackedProbeKeys,
|
|
});
|
|
const key = autoFallbackPrimaryProbeStateKey({
|
|
sessionKey: params.sessionKey,
|
|
primaryProvider: params.probe.provider,
|
|
primaryModel: params.probe.model,
|
|
});
|
|
state.set(key, now);
|
|
pruneAutoFallbackPrimaryProbeState({
|
|
state,
|
|
now,
|
|
minIntervalMs,
|
|
maxKeys: params.maxTrackedProbeKeys,
|
|
});
|
|
}
|
|
|
|
export function entryMatchesAutoFallbackPrimaryProbe(
|
|
entry:
|
|
| Pick<
|
|
SessionEntry,
|
|
| "providerOverride"
|
|
| "modelOverride"
|
|
| "modelOverrideSource"
|
|
| "modelOverrideFallbackOriginProvider"
|
|
| "modelOverrideFallbackOriginModel"
|
|
>
|
|
| null
|
|
| undefined,
|
|
probe: AutoFallbackPrimaryProbe,
|
|
): boolean {
|
|
if (!entry) {
|
|
return false;
|
|
}
|
|
const recoveredAutoFallbackOverride =
|
|
entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry);
|
|
if (entry.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) {
|
|
return false;
|
|
}
|
|
return (
|
|
normalizeOptionalString(entry.providerOverride) === probe.fallbackProvider &&
|
|
normalizeOptionalString(entry.modelOverride) === probe.fallbackModel &&
|
|
normalizeOptionalString(entry.modelOverrideFallbackOriginProvider) === probe.provider &&
|
|
normalizeOptionalString(entry.modelOverrideFallbackOriginModel) === probe.model
|
|
);
|
|
}
|
|
|
|
export function clearAutoFallbackPrimaryProbeSelection(
|
|
entry: SessionEntry,
|
|
now = Date.now(),
|
|
): void {
|
|
delete entry.providerOverride;
|
|
delete entry.modelOverride;
|
|
delete entry.modelOverrideSource;
|
|
delete entry.modelOverrideFallbackOriginProvider;
|
|
delete entry.modelOverrideFallbackOriginModel;
|
|
if (
|
|
entry.authProfileOverrideSource === "auto" ||
|
|
(entry.authProfileOverrideSource === undefined &&
|
|
entry.authProfileOverrideCompactionCount !== undefined)
|
|
) {
|
|
delete entry.authProfileOverride;
|
|
delete entry.authProfileOverrideSource;
|
|
delete entry.authProfileOverrideCompactionCount;
|
|
}
|
|
delete entry.fallbackNoticeSelectedModel;
|
|
delete entry.fallbackNoticeActiveModel;
|
|
delete entry.fallbackNoticeReason;
|
|
entry.updatedAt = now;
|
|
}
|
|
|
|
export { resolveAgentIdFromSessionKey };
|
|
|
|
export function resolveSessionAgentIds(params: {
|
|
sessionKey?: string;
|
|
config?: OpenClawConfig;
|
|
agentId?: string;
|
|
fallbackAgentId?: string;
|
|
}): {
|
|
defaultAgentId: string;
|
|
sessionAgentId: string;
|
|
} {
|
|
const defaultAgentId = resolveDefaultAgentId(params.config ?? {});
|
|
const explicitAgentIdRaw = normalizeLowercaseStringOrEmpty(params.agentId);
|
|
const explicitAgentId = explicitAgentIdRaw ? normalizeAgentId(explicitAgentIdRaw) : null;
|
|
const fallbackAgentIdRaw = normalizeLowercaseStringOrEmpty(params.fallbackAgentId);
|
|
const fallbackAgentId = fallbackAgentIdRaw ? normalizeAgentId(fallbackAgentIdRaw) : null;
|
|
const sessionKey = params.sessionKey?.trim();
|
|
const normalizedSessionKey = sessionKey ? normalizeLowercaseStringOrEmpty(sessionKey) : undefined;
|
|
const parsed = normalizedSessionKey ? parseAgentSessionKey(normalizedSessionKey) : null;
|
|
const sessionAgentId =
|
|
explicitAgentId ??
|
|
(parsed?.agentId ? normalizeAgentId(parsed.agentId) : (fallbackAgentId ?? defaultAgentId));
|
|
return { defaultAgentId, sessionAgentId };
|
|
}
|
|
|
|
export function resolveSessionAgentId(params: {
|
|
sessionKey?: string;
|
|
config?: OpenClawConfig;
|
|
agentId?: string;
|
|
fallbackAgentId?: string;
|
|
}): string {
|
|
return resolveSessionAgentIds(params).sessionAgentId;
|
|
}
|
|
|
|
export function resolveAgentExecutionContract(
|
|
cfg: OpenClawConfig | undefined,
|
|
agentId?: string | null,
|
|
): NonNullable<NonNullable<AgentDefaultsConfig["embeddedAgent"]>["executionContract"]> | undefined {
|
|
const defaultContract = cfg?.agents?.defaults?.embeddedAgent?.executionContract;
|
|
if (!cfg || !agentId) {
|
|
return defaultContract;
|
|
}
|
|
const agentConfig = resolveAgentConfig(cfg, agentId);
|
|
const agentContract = agentConfig?.embeddedAgent?.executionContract;
|
|
return agentContract ?? defaultContract;
|
|
}
|
|
|
|
export function resolveAgentSkillsFilter(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): string[] | undefined {
|
|
return resolveEffectiveAgentSkillFilter(cfg, agentId);
|
|
}
|
|
|
|
export function resolveAgentExplicitModelPrimary(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): string | undefined {
|
|
const raw = resolveAgentConfig(cfg, agentId)?.model;
|
|
return resolvePrimaryStringValue(raw);
|
|
}
|
|
|
|
export function resolveAgentEffectiveModelPrimary(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): string | undefined {
|
|
return (
|
|
resolveAgentExplicitModelPrimary(cfg, agentId) ??
|
|
resolvePrimaryStringValue(cfg.agents?.defaults?.model)
|
|
);
|
|
}
|
|
|
|
function findMutableAgentEntry(cfg: OpenClawConfig, agentId: string): AgentConfig | undefined {
|
|
const id = normalizeAgentId(agentId);
|
|
return cfg.agents?.list?.find((entry) => normalizeAgentId(entry?.id) === id);
|
|
}
|
|
|
|
function updateAgentModelPrimary(
|
|
existing: AgentModelConfig | undefined,
|
|
primary: string,
|
|
): AgentModelConfig {
|
|
if (existing && typeof existing === "object" && !Array.isArray(existing)) {
|
|
return { ...existing, primary };
|
|
}
|
|
return primary;
|
|
}
|
|
|
|
export type AgentModelPrimaryWriteTarget = "agent" | "defaults";
|
|
|
|
export function setAgentEffectiveModelPrimary(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
primary: string,
|
|
options: { forceAgent?: boolean } = {},
|
|
): AgentModelPrimaryWriteTarget {
|
|
const id = normalizeAgentId(agentId);
|
|
// forceAgent pins the write to the agent entry even without an explicit
|
|
// model, so a per-agent override never rewrites the shared default route.
|
|
if (options.forceAgent || resolveAgentExplicitModelPrimary(cfg, id)) {
|
|
const entry = findMutableAgentEntry(cfg, id);
|
|
if (entry) {
|
|
entry.model = updateAgentModelPrimary(entry.model, primary);
|
|
return "agent";
|
|
}
|
|
if (options.forceAgent) {
|
|
throw new Error(`Could not resolve configured agent "${id}".`);
|
|
}
|
|
}
|
|
cfg.agents ??= {};
|
|
cfg.agents.defaults ??= {};
|
|
cfg.agents.defaults.model = updateAgentModelPrimary(cfg.agents.defaults.model, primary);
|
|
return "defaults";
|
|
}
|
|
|
|
export function resolveAgentModelFallbacksOverride(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): string[] | undefined {
|
|
return resolveSelectedModelFallbacksOverride(resolveAgentConfig(cfg, agentId)?.model);
|
|
}
|
|
|
|
function resolveSelectedModelFallbacksOverride(
|
|
raw: AgentModelConfig | undefined,
|
|
): string[] | undefined {
|
|
if (!raw) {
|
|
return undefined;
|
|
}
|
|
if (typeof raw === "string") {
|
|
return resolvePrimaryStringValue(raw) ? [] : undefined;
|
|
}
|
|
// Important: treat an explicitly provided empty array as an override to disable global fallbacks.
|
|
if (!Object.hasOwn(raw, "fallbacks")) {
|
|
return Object.hasOwn(raw, "primary") && resolvePrimaryStringValue(raw) ? [] : undefined;
|
|
}
|
|
return Array.isArray(raw.fallbacks) ? raw.fallbacks : undefined;
|
|
}
|
|
|
|
function resolveFirstModelFallbacksOverride(
|
|
candidates: Array<AgentModelConfig | undefined>,
|
|
): string[] | undefined {
|
|
for (const candidate of candidates) {
|
|
const fallbackOverride = resolveSelectedModelFallbacksOverride(candidate);
|
|
if (fallbackOverride !== undefined) {
|
|
return fallbackOverride;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export type SubagentModelConfigSelectionSource = "subagent" | "agent" | "default-subagent";
|
|
|
|
export type SubagentModelConfigSelectionResult = {
|
|
raw: AgentModelConfig;
|
|
source: SubagentModelConfigSelectionSource;
|
|
};
|
|
|
|
export function resolveSubagentModelConfigSelectionResult(params: {
|
|
cfg: OpenClawConfig;
|
|
agentId?: string;
|
|
agentConfigOverride?: Pick<AgentConfig, "model" | "subagents">;
|
|
}): SubagentModelConfigSelectionResult | undefined {
|
|
const agentConfig =
|
|
params.agentConfigOverride ??
|
|
(params.agentId ? resolveAgentConfig(params.cfg, params.agentId) : undefined);
|
|
const candidates: SubagentModelConfigSelectionResult[] = [
|
|
...(agentConfig?.subagents?.model
|
|
? [{ raw: agentConfig.subagents.model, source: "subagent" as const }]
|
|
: []),
|
|
...(agentConfig?.model ? [{ raw: agentConfig.model, source: "agent" as const }] : []),
|
|
...(params.cfg.agents?.defaults?.subagents?.model
|
|
? [
|
|
{
|
|
raw: params.cfg.agents.defaults.subagents.model,
|
|
source: "default-subagent" as const,
|
|
},
|
|
]
|
|
: []),
|
|
];
|
|
return candidates.find((candidate) => resolvePrimaryStringValue(candidate.raw));
|
|
}
|
|
|
|
export function resolveSubagentModelConfigSelection(params: {
|
|
cfg: OpenClawConfig;
|
|
agentId?: string;
|
|
agentConfigOverride?: Pick<AgentConfig, "model" | "subagents">;
|
|
}): AgentModelConfig | undefined {
|
|
return resolveSubagentModelConfigSelectionResult(params)?.raw;
|
|
}
|
|
|
|
export function resolveSubagentModelFallbacksOverride(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): string[] | undefined {
|
|
const agentConfig = resolveAgentConfig(cfg, agentId);
|
|
const subagentFallbacks = resolveSelectedModelFallbacksOverride(agentConfig?.subagents?.model);
|
|
if (subagentFallbacks !== undefined) {
|
|
return subagentFallbacks;
|
|
}
|
|
const selection = resolveSubagentModelConfigSelectionResult({ cfg, agentId });
|
|
if (selection?.source === "agent") {
|
|
return resolveSelectedModelFallbacksOverride(agentConfig?.model);
|
|
}
|
|
if (selection?.source === "default-subagent") {
|
|
return resolveSelectedModelFallbacksOverride(cfg.agents?.defaults?.subagents?.model);
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
function resolveSubagentSpawnModelFallbacksOverride(
|
|
cfg: OpenClawConfig,
|
|
agentId: string,
|
|
): string[] | undefined {
|
|
const agentConfig = resolveAgentConfig(cfg, agentId);
|
|
return resolveFirstModelFallbacksOverride([
|
|
agentConfig?.subagents?.model,
|
|
cfg.agents?.defaults?.subagents?.model,
|
|
agentConfig?.model,
|
|
]);
|
|
}
|
|
|
|
export function resolveFallbackAgentId(params: {
|
|
agentId?: string | null;
|
|
sessionKey?: string | null;
|
|
}): string {
|
|
const explicitAgentId = normalizeOptionalString(params.agentId) ?? "";
|
|
if (explicitAgentId) {
|
|
return normalizeAgentId(explicitAgentId);
|
|
}
|
|
return resolveAgentIdFromSessionKey(params.sessionKey);
|
|
}
|
|
|
|
export function resolveRunModelFallbacksOverride(params: {
|
|
cfg: OpenClawConfig | undefined;
|
|
agentId?: string | null;
|
|
sessionKey?: string | null;
|
|
}): string[] | undefined {
|
|
if (!params.cfg) {
|
|
return undefined;
|
|
}
|
|
return resolveAgentModelFallbacksOverride(
|
|
params.cfg,
|
|
resolveFallbackAgentId({ agentId: params.agentId, sessionKey: params.sessionKey }),
|
|
);
|
|
}
|
|
|
|
export function hasConfiguredModelFallbacks(params: {
|
|
cfg: OpenClawConfig | undefined;
|
|
agentId?: string | null;
|
|
sessionKey?: string | null;
|
|
}): boolean {
|
|
const fallbacksOverride = resolveRunModelFallbacksOverride(params);
|
|
const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg?.agents?.defaults?.model);
|
|
return (fallbacksOverride ?? defaultFallbacks).length > 0;
|
|
}
|
|
|
|
export function resolveEffectiveModelFallbacks(params: {
|
|
cfg: OpenClawConfig;
|
|
agentId: string;
|
|
sessionKey?: string | null;
|
|
hasSessionModelOverride: boolean;
|
|
modelOverrideSource?: "auto" | "user";
|
|
hasAutoFallbackProvenance?: boolean;
|
|
}): string[] | undefined {
|
|
const agentFallbacksOverride = resolveAgentModelFallbacksOverride(params.cfg, params.agentId);
|
|
if (!params.hasSessionModelOverride) {
|
|
return agentFallbacksOverride;
|
|
}
|
|
const canUseConfiguredFallbacks =
|
|
params.modelOverrideSource === "auto" ||
|
|
(params.modelOverrideSource === undefined && params.hasAutoFallbackProvenance === true);
|
|
if (!canUseConfiguredFallbacks) {
|
|
return [];
|
|
}
|
|
const subagentFallbacksOverride = isSubagentSessionKey(params.sessionKey)
|
|
? resolveSubagentSpawnModelFallbacksOverride(params.cfg, params.agentId)
|
|
: undefined;
|
|
if (subagentFallbacksOverride !== undefined) {
|
|
return subagentFallbacksOverride;
|
|
}
|
|
const defaultFallbacks = resolveAgentModelFallbackValues(params.cfg.agents?.defaults?.model);
|
|
return agentFallbacksOverride ?? defaultFallbacks;
|
|
}
|
|
|
|
function normalizePathForComparison(input: string): string {
|
|
const resolved = path.resolve(stripNullBytes(resolveUserPath(input)));
|
|
let normalized = resolved;
|
|
// Prefer realpath when available to normalize aliases/symlinks (for example /tmp -> /private/tmp)
|
|
// and canonical path case without forcing case-folding on case-sensitive macOS volumes.
|
|
try {
|
|
normalized = fs.realpathSync.native(resolved);
|
|
} catch {
|
|
// Keep lexical path for non-existent directories.
|
|
}
|
|
if (process.platform === "win32") {
|
|
return lowercasePreservingWhitespace(normalized);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
export function resolveAgentIdsByWorkspacePath(
|
|
cfg: OpenClawConfig,
|
|
workspacePath: string,
|
|
): string[] {
|
|
const normalizedWorkspacePath = normalizePathForComparison(workspacePath);
|
|
const ids = listAgentIds(cfg);
|
|
const matches: Array<{ id: string; workspaceDir: string; order: number }> = [];
|
|
|
|
for (const [index, id] of ids.entries()) {
|
|
const workspaceDir = normalizePathForComparison(resolveAgentWorkspaceDir(cfg, id));
|
|
if (!isPathInside(workspaceDir, normalizedWorkspacePath)) {
|
|
continue;
|
|
}
|
|
matches.push({ id, workspaceDir, order: index });
|
|
}
|
|
|
|
matches.sort((left, right) => {
|
|
const workspaceLengthDelta = right.workspaceDir.length - left.workspaceDir.length;
|
|
if (workspaceLengthDelta !== 0) {
|
|
return workspaceLengthDelta;
|
|
}
|
|
return left.order - right.order;
|
|
});
|
|
|
|
return matches.map((entry) => entry.id);
|
|
}
|
|
|
|
export function resolveAgentIdByWorkspacePath(
|
|
cfg: OpenClawConfig,
|
|
workspacePath: string,
|
|
): string | undefined {
|
|
return resolveAgentIdsByWorkspacePath(cfg, workspacePath)[0];
|
|
}
|