feat(agent-model): add per-agent model override with default-reset UX and runtime sync (#651)
This commit is contained in:
@@ -81,6 +81,8 @@ export interface AgentSummary {
|
||||
name: string;
|
||||
isDefault: boolean;
|
||||
modelDisplay: string;
|
||||
modelRef: string | null;
|
||||
overrideModelRef: string | null;
|
||||
inheritedModel: boolean;
|
||||
workspace: string;
|
||||
agentDir: string;
|
||||
@@ -91,29 +93,38 @@ export interface AgentSummary {
|
||||
export interface AgentsSnapshot {
|
||||
agents: AgentSummary[];
|
||||
defaultAgentId: string;
|
||||
defaultModelRef: string | null;
|
||||
configuredChannelTypes: string[];
|
||||
channelOwners: Record<string, string>;
|
||||
channelAccountOwners: Record<string, string>;
|
||||
}
|
||||
|
||||
function formatModelLabel(model: unknown): string | null {
|
||||
function resolveModelRef(model: unknown): string | null {
|
||||
if (typeof model === 'string' && model.trim()) {
|
||||
const trimmed = model.trim();
|
||||
const parts = trimmed.split('/');
|
||||
return parts[parts.length - 1] || trimmed;
|
||||
return model.trim();
|
||||
}
|
||||
|
||||
if (model && typeof model === 'object') {
|
||||
const primary = (model as AgentModelConfig).primary;
|
||||
if (typeof primary === 'string' && primary.trim()) {
|
||||
const parts = primary.trim().split('/');
|
||||
return parts[parts.length - 1] || primary.trim();
|
||||
return primary.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function formatModelLabel(model: unknown): string | null {
|
||||
const modelRef = resolveModelRef(model);
|
||||
if (modelRef) {
|
||||
const trimmed = modelRef;
|
||||
const parts = trimmed.split('/');
|
||||
return parts[parts.length - 1] || trimmed;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeAgentName(name: string): string {
|
||||
return name.trim() || 'Agent';
|
||||
}
|
||||
@@ -487,10 +498,13 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
||||
channelOwners[channelType] = primaryOwner;
|
||||
}
|
||||
|
||||
const defaultModelLabel = formatModelLabel((config.agents as AgentsConfig | undefined)?.defaults?.model);
|
||||
const defaultModelConfig = (config.agents as AgentsConfig | undefined)?.defaults?.model;
|
||||
const defaultModelLabel = formatModelLabel(defaultModelConfig);
|
||||
const defaultModelRef = resolveModelRef(defaultModelConfig);
|
||||
const agents: AgentSummary[] = entries.map((entry) => {
|
||||
const explicitModelRef = resolveModelRef(entry.model);
|
||||
const modelLabel = formatModelLabel(entry.model) || defaultModelLabel || 'Not configured';
|
||||
const inheritedModel = !formatModelLabel(entry.model) && Boolean(defaultModelLabel);
|
||||
const inheritedModel = !explicitModelRef && Boolean(defaultModelLabel);
|
||||
const entryIdNorm = normalizeAgentIdForBinding(entry.id);
|
||||
const ownedChannels = agentChannelSets.get(entryIdNorm) ?? new Set<string>();
|
||||
return {
|
||||
@@ -498,6 +512,8 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
||||
name: entry.name || (entry.id === MAIN_AGENT_ID ? MAIN_AGENT_NAME : entry.id),
|
||||
isDefault: entry.id === defaultAgentId,
|
||||
modelDisplay: modelLabel,
|
||||
modelRef: explicitModelRef || defaultModelRef || null,
|
||||
overrideModelRef: explicitModelRef,
|
||||
inheritedModel,
|
||||
workspace: entry.workspace || (entry.id === MAIN_AGENT_ID ? getDefaultWorkspacePath(config) : `~/.openclaw/workspace-${entry.id}`),
|
||||
agentDir: entry.agentDir || getDefaultAgentDirPath(entry.id),
|
||||
@@ -511,6 +527,7 @@ async function buildSnapshotFromConfig(config: AgentConfigDocument): Promise<Age
|
||||
return {
|
||||
agents,
|
||||
defaultAgentId,
|
||||
defaultModelRef,
|
||||
configuredChannelTypes: configuredChannels.map((channelType) => toUiChannelType(channelType)),
|
||||
channelOwners,
|
||||
channelAccountOwners,
|
||||
@@ -598,6 +615,44 @@ export async function updateAgentName(agentId: string, name: string): Promise<Ag
|
||||
});
|
||||
}
|
||||
|
||||
function isValidModelRef(modelRef: string): boolean {
|
||||
const firstSlash = modelRef.indexOf('/');
|
||||
return firstSlash > 0 && firstSlash < modelRef.length - 1;
|
||||
}
|
||||
|
||||
export async function updateAgentModel(agentId: string, modelRef: string | null): Promise<AgentsSnapshot> {
|
||||
return withConfigLock(async () => {
|
||||
const config = await readOpenClawConfig() as AgentConfigDocument;
|
||||
const { agentsConfig, entries } = normalizeAgentsConfig(config);
|
||||
const index = entries.findIndex((entry) => entry.id === agentId);
|
||||
if (index === -1) {
|
||||
throw new Error(`Agent "${agentId}" not found`);
|
||||
}
|
||||
|
||||
const normalizedModelRef = typeof modelRef === 'string' ? modelRef.trim() : '';
|
||||
const nextEntry: AgentListEntry = { ...entries[index] };
|
||||
|
||||
if (!normalizedModelRef) {
|
||||
delete nextEntry.model;
|
||||
} else {
|
||||
if (!isValidModelRef(normalizedModelRef)) {
|
||||
throw new Error('modelRef must be in "provider/model" format');
|
||||
}
|
||||
nextEntry.model = { primary: normalizedModelRef };
|
||||
}
|
||||
|
||||
entries[index] = nextEntry;
|
||||
config.agents = {
|
||||
...agentsConfig,
|
||||
list: entries,
|
||||
};
|
||||
|
||||
await writeOpenClawConfig(config);
|
||||
logger.info('Updated agent model', { agentId, modelRef: normalizedModelRef || null });
|
||||
return buildSnapshotFromConfig(config);
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteAgentConfig(agentId: string): Promise<{ snapshot: AgentsSnapshot; removedEntry: AgentListEntry }> {
|
||||
return withConfigLock(async () => {
|
||||
if (agentId === MAIN_AGENT_ID) {
|
||||
|
||||
Reference in New Issue
Block a user