diff --git a/src/agents/agent-scope-config.test.ts b/src/agents/agent-scope-config.test.ts index 926f7ab89588..52668b13c54f 100644 --- a/src/agents/agent-scope-config.test.ts +++ b/src/agents/agent-scope-config.test.ts @@ -7,6 +7,7 @@ import { listAgentEntriesWithSource, listAgentIds, resolveAgentConfig, + resolveAgentOperationAgentId, resolveAgentWorkspaceDir, resolveAmbientOwnerAgentId, resolveDefaultAgentDir, @@ -154,6 +155,37 @@ describe("agent roster resolution", () => { expect(resolveDefaultAgentDir(config)).toBe("/tmp/openclaw-beta-agent"); }); + it("preserves legacy default ownership for non-explicit CLI operations", () => { + const config = { + agents: { + entries: { main: {}, ops: { default: true } }, + }, + }; + + expect(resolveAgentOperationAgentId(config)).toBe("ops"); + expect( + resolveAgentOperationAgentId({ + ...config, + agents: { + ...config.agents, + ownership: "explicit" as const, + defaults: { systemAgent: { agentId: "main" } }, + }, + }), + ).toBe("main"); + }); + + it("preserves retained legacy ownership for migrated CLI operations", () => { + const cfg = migratePersistedImplicitMainRoster({ + agents: { + entries: { ops: { default: true }, research: {} }, + }, + }).config as OpenClawConfig; + + expect(cfg.agents?.entries?.ops?.default).toBeUndefined(); + expect(resolveAgentOperationAgentId(cfg)).toBe("ops"); + }); + it("resolves defaults only for the rosterless implicit main agent", () => { const defaults = { fastModeDefault: "auto" as const }; diff --git a/src/agents/agent-scope-config.ts b/src/agents/agent-scope-config.ts index 4aaf22f4ce68..133f7a814016 100644 --- a/src/agents/agent-scope-config.ts +++ b/src/agents/agent-scope-config.ts @@ -242,6 +242,18 @@ export function resolveAmbientOwnerAgentId( return tryResolveAmbientOwnerAgentId(cfg, requestedAgentId) ?? resolveSoleAgentId(cfg, context); } +/** Resolves a CLI operation owner while preserving legacy default markers outside explicit fleets. */ +export function resolveAgentOperationAgentId( + cfg: OpenClawConfig, + requestedAgentId?: string, + context?: AgentSelectionContext, +): string { + if (requestedAgentId !== undefined || cfg.agents?.ownership === "explicit") { + return resolveAmbientOwnerAgentId(cfg, requestedAgentId, context); + } + return tryResolveLegacyCompatibilityAgentId(cfg) ?? resolveDefaultAgentId(cfg, context); +} + /** * @deprecated Ambient system work uses resolveAmbientOwnerAgentId so the configured * system agent is honored; explicit-selection surfaces use resolveSoleAgentId. This diff --git a/src/cli/capability-cli/shared.ts b/src/cli/capability-cli/shared.ts index c5057d3b831a..9778475beb33 100644 --- a/src/cli/capability-cli/shared.ts +++ b/src/cli/capability-cli/shared.ts @@ -2,7 +2,7 @@ import { parseStrictFiniteNumber, parseStrictPositiveInteger, } from "@openclaw/normalization-core/number-coercion"; -import { listAgentIds, resolveAmbientOwnerAgentId } from "../../agents/agent-scope-config.js"; +import { listAgentIds, resolveAgentOperationAgentId } from "../../agents/agent-scope-config.js"; import { resolveAgentDir } from "../../agents/agent-scope.js"; import { listProfilesForProvider, @@ -111,7 +111,7 @@ export function resolveCapabilityProviderAgentId( if (rawAgentId !== undefined && !requestedAgentId) { throw new Error("--agent must not be blank"); } - const agentId = resolveAmbientOwnerAgentId(cfg, requestedAgentId, { + const agentId = resolveAgentOperationAgentId(cfg, requestedAgentId, { surface, hint: "Pass --agent or set agents.defaults.systemAgent.agentId.", }); diff --git a/src/commands/channels/add-wizard.ts b/src/commands/channels/add-wizard.ts index eea4721527dd..9a6df71c19a4 100644 --- a/src/commands/channels/add-wizard.ts +++ b/src/commands/channels/add-wizard.ts @@ -2,7 +2,8 @@ // prompter) and the gateway `wizard.start {flow:"channels"}` RPC (session // prompter driving the Control UI / native clients). import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentOperationAgentId } from "../../agents/agent-scope-config.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { getLoadedChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js"; import { formatUnknownChannelMessage } from "../../cli/error-format.js"; @@ -50,7 +51,7 @@ export async function resolveInitialWizardChannelTarget( const resolved = resolveChannelSetupEntries({ cfg, installedPlugins: listActiveChannelSetupPlugins(), - workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), + workspaceDir: resolveAgentWorkspaceDir(cfg, resolveAgentOperationAgentId(cfg)), }); const matchedEntry = resolved.entries.find( @@ -211,7 +212,7 @@ export async function runChannelsAddWizardFlow(params: ChannelsAddWizardFlowPara initialValue: true, }); if (bindNow) { - const defaultAgentId = resolveDefaultAgentId(nextConfig); + const defaultAgentId = resolveAgentOperationAgentId(nextConfig); for (const target of bindTargets) { const targetAgentId = await prompter.select({ message: `Send ${target.channel}/${target.accountId} messages to agent`, diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 77280f2b0773..e01b372a78b5 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -1,7 +1,8 @@ import { parseStrictNonNegativeInteger } from "@openclaw/normalization-core/number-coercion"; // Implements guided and non-interactive `openclaw channels add` account setup. import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentOperationAgentId } from "../../agents/agent-scope-config.js"; +import { resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { applyPreparedChannelAccountConfiguration, type ChannelAccountMutationPlugin, @@ -64,7 +65,7 @@ async function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | nul ({ listTrustedChannelPluginCatalogEntries }) => listTrustedChannelPluginCatalogEntries({ cfg, - workspaceDir: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)), + workspaceDir: resolveAgentWorkspaceDir(cfg, resolveAgentOperationAgentId(cfg)), }), ) : await import("../../channels/plugins/catalog.js").then( @@ -187,7 +188,7 @@ async function channelsAddCommandImpl( let channel = normalizeChannelId(rawChannel); let catalogEntry = await resolveCatalogChannelEntry(rawChannel, nextConfig); const resolveWorkspaceDir = () => - resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig)); + resolveAgentWorkspaceDir(nextConfig, resolveAgentOperationAgentId(nextConfig)); // May load a scoped plugin when the channel is not already registered. const loadScopedPlugin = async ( channelId: ChannelId, diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index 89c88ae346d4..758ed191205f 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -223,6 +223,8 @@ function makeRuntime(): RuntimeEnv { function promptModelAllowlistOptions(index = 0) { return mocks.promptModelAllowlist.mock.calls[index]?.[0] as | { + agentDir?: string; + agentId?: string; allowedKeys?: string[]; initialSelections?: string[]; loadCatalog?: boolean; @@ -501,7 +503,6 @@ describe("promptAuthConfig", () => { expect(mocks.promptModelAllowlist).toHaveBeenCalledOnce(); expect(promptModelAllowlistOptions()?.preferredProvider).toBe("openai"); - expect(mocks.applyPrimaryModel).toHaveBeenCalledWith(expect.any(Object), "openai/gpt-5.5"); expect(result.agents?.defaults?.model).toEqual({ primary: "openai/gpt-5.5", fallbacks: ["openai/gpt-5.3-codex"], @@ -512,6 +513,41 @@ describe("promptAuthConfig", () => { ]); }); + it("canonicalizes a selected agent's legacy Codex primary before updating its allowlist", async () => { + vi.clearAllMocks(); + mocks.promptAuthChoiceGrouped.mockResolvedValue("openai-device-code"); + mocks.resolvePreferredProviderForAuthChoice.mockResolvedValue("openai"); + const config = { + agents: { + ownership: "explicit" as const, + defaults: { + systemAgent: { agentId: "ops" }, + model: { primary: "anthropic/claude-sonnet-4-6" }, + }, + entries: { + main: {}, + ops: { model: { primary: "codex/gpt-5.5" } }, + }, + }, + } satisfies OpenClawConfig; + mocks.applyAuthChoice.mockResolvedValue({ config }); + mocks.promptModelAllowlist.mockResolvedValue({ + models: ["openai/gpt-5.5"], + scopeKeys: ["openai/gpt-5.5"], + }); + mocks.resolveProviderPluginChoiceCore.mockReturnValue(null); + + const result = await promptAuthConfig(config, makeRuntime(), noopPrompter, { + agentId: "ops", + agentDir: "/tmp/ops-agent", + workspaceDir: "/tmp/ops-workspace", + }); + + expect(result.agents?.entries?.ops?.model).toEqual({ primary: "openai/gpt-5.5" }); + expect(result.agents?.entries?.ops?.modelPolicy?.allow).toEqual(["openai/gpt-5.5"]); + expect(result.agents?.defaults?.model).toEqual({ primary: "anthropic/claude-sonnet-4-6" }); + }); + it("keeps the selected provider scope when existing config has another provider", async () => { vi.clearAllMocks(); mocks.promptAuthChoiceGrouped.mockResolvedValue("github-copilot"); @@ -752,4 +788,115 @@ describe("promptAuthConfig", () => { expect(mocks.applyAuthChoice).toHaveBeenCalledTimes(2); expect(mocks.promptModelAllowlist).toHaveBeenCalledTimes(1); }); + + it("writes model policy to the explicit configure target instead of global defaults", async () => { + vi.clearAllMocks(); + mocks.promptAuthChoiceGrouped.mockResolvedValue("skip"); + mocks.promptDefaultModel.mockResolvedValue({ model: "openai/gpt-5.5" }); + mocks.promptModelAllowlist.mockResolvedValue({ models: ["openai/gpt-5.5"] }); + + const result = await promptAuthConfig( + { + agents: { + ownership: "explicit", + defaults: { systemAgent: { agentId: "ops" } }, + entries: { main: {}, ops: {} }, + }, + }, + makeRuntime(), + noopPrompter, + { agentId: "ops", agentDir: "/tmp/ops-agent", workspaceDir: "/tmp/ops-workspace" }, + ); + + expect(result.agents?.entries?.ops?.model).toEqual({ primary: "openai/gpt-5.5" }); + expect(result.agents?.entries?.ops?.modelPolicy?.allow).toEqual(["openai/gpt-5.5"]); + expect(result.agents?.defaults?.model).toBeUndefined(); + expect(result.agents?.defaults?.modelPolicy).toBeUndefined(); + expect(promptModelAllowlistOptions()).toMatchObject({ + agentId: "ops", + agentDir: "/tmp/ops-agent", + }); + }); + + it("projects provider-auth model defaults onto the explicit target", async () => { + vi.clearAllMocks(); + mocks.promptAuthChoiceGrouped.mockResolvedValue("provider-auth"); + mocks.applyAuthChoice.mockResolvedValue({ + config: { + agents: { + ownership: "explicit" as const, + defaults: { model: { primary: "provider/global" } }, + entries: { main: {}, OPS: {} }, + }, + }, + agentModelOverride: "provider/selected", + }); + mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); + + const config = { + agents: { + ownership: "explicit" as const, + defaults: { + systemAgent: { agentId: "ops" }, + model: { primary: "provider/original" }, + }, + entries: { main: {}, OPS: {} }, + }, + }; + const result = await promptAuthConfig(config, makeRuntime(), noopPrompter, { + agentId: "ops", + agentDir: "/tmp/ops-agent", + workspaceDir: "/tmp/ops-workspace", + }); + + expect(mocks.applyAuthChoice).toHaveBeenCalledWith( + expect.objectContaining({ setDefaultModel: false }), + ); + expect(result.agents?.entries?.OPS?.model).toEqual({ primary: "provider/selected" }); + expect(result.agents?.defaults?.model).toEqual({ primary: "provider/original" }); + expect(result.agents?.entries?.ops).toBeUndefined(); + }); + + it("projects custom-provider model metadata onto the explicit target", async () => { + vi.clearAllMocks(); + mocks.promptAuthChoiceGrouped.mockResolvedValue("custom-api-key"); + mocks.promptCustomApiConfig.mockResolvedValue({ + config: { + agents: { + ownership: "explicit" as const, + entries: { + main: {}, + OPS: { + model: { primary: "custom/model" }, + models: { "custom/model": { alias: "Custom" } }, + }, + }, + }, + models: { providers: { custom: { models: [{ id: "model" }] } } }, + }, + providerId: "custom", + modelId: "model", + }); + + const config = { + agents: { + ownership: "explicit" as const, + defaults: { systemAgent: { agentId: "ops" } }, + entries: { main: {}, OPS: {} }, + }, + }; + const result = await promptAuthConfig(config, makeRuntime(), noopPrompter, { + agentId: "ops", + agentDir: "/tmp/ops-agent", + workspaceDir: "/tmp/ops-workspace", + }); + + expect(mocks.promptCustomApiConfig).toHaveBeenCalledWith( + expect.objectContaining({ target: expect.objectContaining({ agentId: "ops" }) }), + ); + expect(result.agents?.entries?.OPS?.model).toEqual({ primary: "custom/model" }); + expect(result.agents?.entries?.OPS?.models).toEqual({ "custom/model": { alias: "Custom" } }); + expect(result.agents?.defaults?.model).toBeUndefined(); + expect(result.models?.providers?.custom?.models).toEqual([{ id: "model" }]); + }); }); diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index a5bd57b607d1..3c8262d8b597 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -1,6 +1,6 @@ // Configure wizard model/auth selection and gateway auth config helpers. +import { resolveMutableAgentEntry } from "../agents/agent-scope-config.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; -import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { OpenClawConfig, GatewayAuthConfig } from "../config/config.js"; import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; @@ -11,11 +11,16 @@ import { applyAuthChoice, resolvePreferredProviderForAuthChoice } from "./auth-c import { applyModelAllowlist, applyModelFallbacksFromSelection, - applyPrimaryModel, promptDefaultModel, promptModelAllowlist, } from "./model-picker.js"; import { loadStaticManifestCatalogRowsForList } from "./models/list.manifest-catalog.js"; +import { + applyAgentModelDefaults, + applyOnboardingPrimaryModel, + resolveOnboardingAgentTarget, +} from "./onboard-agent-target.js"; +import type { OnboardingAgentTarget } from "./onboard-agent-target.js"; import { promptCustomApiConfig } from "./onboard-custom.js"; import { randomToken } from "./random-token.js"; @@ -113,9 +118,11 @@ function resolveProviderFromModelRef(model: string | undefined): string | undefi function resolveCanonicalOpenAISelectionForLegacyCodexPrimary( cfg: OpenClawConfig, + target: OnboardingAgentTarget, selectedModels: readonly string[], ): string | undefined { - const currentModel = cfg.agents?.defaults?.model; + const currentModel = + resolveMutableAgentEntry(cfg, target.agentId)?.model ?? cfg.agents?.defaults?.model; const primary = typeof currentModel === "string" ? currentModel.trim() @@ -203,6 +210,7 @@ export async function promptAuthConfig( cfg: OpenClawConfig, runtime: RuntimeEnv, prompter: WizardPrompter, + target: OnboardingAgentTarget = resolveOnboardingAgentTarget(cfg), ): Promise { let next = cfg; let authChoice = "skip"; @@ -210,7 +218,7 @@ export async function promptAuthConfig( while (true) { authChoice = await promptAuthChoiceGrouped({ prompter, - store: ensureAuthProfileStore(undefined, { + store: ensureAuthProfileStore(target.agentDir, { allowKeychainPrompt: false, }), includeSkip: true, @@ -226,7 +234,7 @@ export async function promptAuthConfig( }); if (authChoice === "custom-api-key") { - const customResult = await promptCustomApiConfig({ prompter, runtime, config: next }); + const customResult = await promptCustomApiConfig({ prompter, runtime, config: next, target }); next = customResult.config; break; } @@ -241,14 +249,16 @@ export async function promptAuthConfig( loadCatalog: true, browseCatalogOnDemand: true, preferredProvider, - workspaceDir: resolveDefaultAgentWorkspaceDir(), + agentId: target.agentId, + agentDir: target.agentDir, + workspaceDir: target.workspaceDir, runtime, }); if (modelSelection.config) { next = modelSelection.config; } if (modelSelection.model) { - next = applyPrimaryModel(next, modelSelection.model); + next = applyOnboardingPrimaryModel(next, target, modelSelection.model); preferredProvider = resolveProviderFromModelRef(modelSelection.model) ?? preferredProvider; } break; @@ -260,10 +270,24 @@ export async function promptAuthConfig( config: next, prompter, runtime, - setDefaultModel: true, + agentId: target.agentId, + agentDir: target.agentDir, + setDefaultModel: false, preserveExistingDefaultModel: true, }); next = applied.config; + if (applied.agentModelOverride) { + const targeted = applyOnboardingPrimaryModel(next, target, applied.agentModelOverride); + next = { + ...targeted, + agents: { + ...targeted.agents, + ...(beforeAuthConfig.agents?.defaults === undefined + ? { defaults: undefined } + : { defaults: beforeAuthConfig.agents.defaults }), + }, + }; + } preferredProvider = resolveConfiguredProviderFromAuthChange({ before: beforeAuthConfig, after: next, @@ -279,7 +303,7 @@ export async function promptAuthConfig( const modelPrompt = await resolveProviderChoiceModelPrompt({ authChoice, config: next, - workspaceDir: resolveDefaultAgentWorkspaceDir(), + workspaceDir: target.workspaceDir, env: process.env, }); const promptProvider = @@ -297,7 +321,9 @@ export async function promptAuthConfig( const allowlistSelection = await promptModelAllowlist({ config: next, prompter, - workspaceDir: resolveDefaultAgentWorkspaceDir(), + agentId: target.agentId, + agentDir: target.agentDir, + workspaceDir: target.workspaceDir, env: process.env, allowedKeys: modelPrompt?.allowedKeys, initialSelections: modelPrompt?.initialSelections, @@ -307,19 +333,24 @@ export async function promptAuthConfig( loadCatalog: shouldLoadModelCatalog, }); if (allowlistSelection.models) { + const selectedModels = allowlistSelection.models; const canonicalPrimary = resolveCanonicalOpenAISelectionForLegacyCodexPrimary( next, - allowlistSelection.models, + target, + selectedModels, ); if (canonicalPrimary) { - next = applyPrimaryModel(next, canonicalPrimary); + next = applyOnboardingPrimaryModel(next, target, canonicalPrimary); } - next = applyModelFallbacksFromSelection(next, allowlistSelection.models, { - scopeKeys: allowlistSelection.scopeKeys, - }); - next = applyModelAllowlist(next, allowlistSelection.models, { - scopeKeys: allowlistSelection.scopeKeys, - }); + next = applyAgentModelDefaults(next, target, (projected) => + applyModelAllowlist( + applyModelFallbacksFromSelection(projected, selectedModels, { + scopeKeys: allowlistSelection.scopeKeys, + }), + selectedModels, + { scopeKeys: allowlistSelection.scopeKeys }, + ), + ); } } diff --git a/src/commands/configure.wizard.ts b/src/commands/configure.wizard.ts index eba48bdfa6b1..f325fc3dd208 100644 --- a/src/commands/configure.wizard.ts +++ b/src/commands/configure.wizard.ts @@ -763,7 +763,7 @@ export async function runConfigureWizard( await provisionWorkspace(); }, model: async () => { - nextConfig = await promptAuthConfig(nextConfig, runtime, prompter); + nextConfig = await promptAuthConfig(nextConfig, runtime, prompter, resolveSetupTarget()); }, web: async () => { nextConfig = await promptWebToolsConfig(nextConfig, runtime, prompter); diff --git a/src/commands/onboard-agent-target.test.ts b/src/commands/onboard-agent-target.test.ts index 62df215ca991..0137c1c13d0a 100644 --- a/src/commands/onboard-agent-target.test.ts +++ b/src/commands/onboard-agent-target.test.ts @@ -8,6 +8,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { withEnvAsync } from "../test-utils/env.js"; import { applyOnboardingPrimaryModel, + applyAgentModelDefaults, ensureOnboardingAgentWorkspace, resolveOnboardingAgentTarget, resolveSystemAgentOnboardingTarget, @@ -71,6 +72,121 @@ describe("onboarding agent target", () => { }); }); + it("keeps explicit agent model mutations on the system-agent entry", () => { + const config = { + agents: { + ownership: "explicit" as const, + defaults: { model: { primary: "openai/global" } }, + entries: { + main: {}, + ops: { model: { primary: "openai/old" } }, + }, + }, + }; + const target = resolveSystemAgentOnboardingTarget({ + ...config, + agents: { + ...config.agents, + defaults: { systemAgent: { agentId: "ops" } }, + }, + }); + + const updated = applyAgentModelDefaults(config, target, (projected) => ({ + ...projected, + agents: { + ...projected.agents, + defaults: { + ...projected.agents?.defaults, + model: { primary: "openai/new" }, + }, + }, + })); + + expect(updated.agents?.entries?.ops?.model).toEqual({ primary: "openai/new" }); + expect(updated.agents?.defaults?.model).toEqual({ primary: "openai/global" }); + expect(updated.agents?.entries?.main?.model).toBeUndefined(); + }); + + it("preserves the authored key when projecting explicit agent defaults", () => { + const config = { + agents: { + ownership: "explicit" as const, + entries: { main: {}, OPS: { model: { primary: "old/model" } } }, + }, + }; + const target = resolveOnboardingAgentTarget(config, "ops"); + const updated = applyAgentModelDefaults(config, target, (projected) => ({ + ...projected, + agents: { + ...projected.agents, + defaults: { ...projected.agents?.defaults, model: { primary: "new/model" } }, + }, + })); + + expect(updated.agents?.entries?.OPS?.model).toEqual({ primary: "new/model" }); + expect(updated.agents?.entries?.ops).toBeUndefined(); + }); + + it("preserves every list-form agent when applying the primary model", () => { + const config = { + agents: { + ownership: "explicit" as const, + list: [ + { id: "main", name: "Main", model: { primary: "openai/main" } }, + { + id: "OPS", + name: "Operations", + model: { primary: "openai/old", fallbacks: ["openai/fallback"] }, + models: { "openai/old": { alias: "Old" } }, + }, + ], + }, + }; + const target = resolveOnboardingAgentTarget(config, "ops"); + + const updated = applyOnboardingPrimaryModel(config, target, "openai/new"); + + expect(updated.agents?.list).toBeUndefined(); + expect(updated.agents?.entries).toEqual({ + main: { name: "Main", model: { primary: "openai/main" } }, + OPS: { + name: "Operations", + model: { primary: "openai/new", fallbacks: ["openai/fallback"] }, + models: { "openai/old": { alias: "Old" }, "openai/new": {} }, + }, + }); + }); + + it("preserves every list-form agent when projecting model policy", () => { + const config = { + agents: { + ownership: "explicit" as const, + list: [ + { id: "main", modelPolicy: { allow: ["openai/main"] } }, + { id: "ops", modelPolicy: { allow: ["openai/old"] } }, + ], + }, + }; + const target = resolveOnboardingAgentTarget(config, "ops"); + + const updated = applyAgentModelDefaults(config, target, (projected) => ({ + ...projected, + agents: { + ...projected.agents, + defaults: { + ...projected.agents?.defaults, + modelPolicy: { allow: ["openai/new"] }, + }, + }, + })); + + expect(updated.agents?.list).toBeUndefined(); + expect(updated.agents?.entries).toEqual({ + main: { modelPolicy: { allow: ["openai/main"] } }, + ops: { modelPolicy: { allow: ["openai/new"] } }, + }); + }); + it("provisions the configured default agent workspace and sessions", async () => { const stateDir = tempDirs.make("openclaw-onboard-target-"); const globalWorkspace = path.join(stateDir, "global-workspace"); diff --git a/src/commands/onboard-agent-target.ts b/src/commands/onboard-agent-target.ts index 5d9e55cb3a53..79641ea34810 100644 --- a/src/commands/onboard-agent-target.ts +++ b/src/commands/onboard-agent-target.ts @@ -1,8 +1,11 @@ // Resolves one concrete agent owner for onboarding auth, model, workspace, and session effects. import { + listAgentEntries, resolveAgentDir, resolveAgentWorkspaceDir, + resolveMutableAgentEntry, resolveSoleAgentId, + toAgentEntriesRecord, } from "../agents/agent-scope-config.js"; import { tryResolveLegacyCompatibilityAgentId } from "../config/legacy.default-agent-owner.js"; import { @@ -11,6 +14,7 @@ import { resolveAgentModelFallbackValues, } from "../config/model-input.js"; import type { OptionalBootstrapFileName } from "../config/types.agent-defaults.js"; +import type { AgentEntryConfig } from "../config/types.agents.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage } from "../infra/errors.js"; import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; @@ -65,42 +69,100 @@ export async function ensureOnboardingAgentWorkspace( } } +function replaceOnboardingAgentEntry( + config: OpenClawConfig, + updated: OpenClawConfig, + target: OnboardingAgentTarget, + nextEntry: AgentEntryConfig, +): OpenClawConfig { + const entries = listAgentEntries(config); + const index = entries.findIndex((entry) => normalizeAgentId(entry.id) === target.agentId); + const nextEntries = [...entries]; + const replacement = { id: index >= 0 ? entries[index]!.id : target.agentId, ...nextEntry }; + if (index >= 0) { + nextEntries[index] = replacement; + } else { + nextEntries.push(replacement); + } + const { list: _list, entries: _entries, ...agents } = config.agents ?? {}; + return { + ...updated, + agents: { + ...agents, + entries: toAgentEntriesRecord(nextEntries), + }, + }; +} + export function applyOnboardingPrimaryModel( config: OpenClawConfig, target: OnboardingAgentTarget, model: string, ): OpenClawConfig { - const authoredEntryKey = Object.keys(config.agents?.entries ?? {}).find( - (key) => normalizeAgentId(key) === target.agentId, - ); - const entry = authoredEntryKey ? config.agents?.entries?.[authoredEntryKey] : undefined; - if (entry?.model === undefined) { + const entry = resolveMutableAgentEntry(config, target.agentId); + if (entry?.model === undefined && config.agents?.ownership !== "explicit") { return applyPrimaryModel(config, model); } const primary = normalizeAgentModelRefForConfig(model); - const fallbackValues = resolveAgentModelFallbackValues(entry.model).map((fallback) => + const fallbackValues = resolveAgentModelFallbackValues(entry?.model).map((fallback) => normalizeAgentModelRefForConfig(fallback), ); - const models = normalizeAgentModelMapForConfig(entry.models ?? {}); - return { + const models = normalizeAgentModelMapForConfig(entry?.models ?? {}); + return replaceOnboardingAgentEntry(config, config, target, { + ...entry, + model: { + ...(fallbackValues.length > 0 ? { fallbacks: fallbackValues } : {}), + primary, + }, + models: { + ...models, + [primary]: models[primary] ?? {}, + }, + }); +} + +/** Apply a model-default mutation to one agent without flattening it globally. */ +export function applyAgentModelDefaults( + config: OpenClawConfig, + target: OnboardingAgentTarget, + mutate: (config: OpenClawConfig) => OpenClawConfig, +): OpenClawConfig { + const entry = resolveMutableAgentEntry(config, target.agentId); + const projected = { ...config, agents: { ...config.agents, - entries: { - ...config.agents?.entries, - [authoredEntryKey ?? target.agentId]: { - ...entry, - model: { - ...(fallbackValues.length > 0 ? { fallbacks: fallbackValues } : {}), - primary, - }, - models: { - ...models, - [primary]: models[primary] ?? {}, - }, - }, + defaults: { + ...config.agents?.defaults, + ...(entry?.model !== undefined ? { model: entry.model } : {}), + ...(entry?.models !== undefined ? { models: entry.models } : {}), + ...(entry?.modelPolicy !== undefined ? { modelPolicy: entry.modelPolicy } : {}), }, }, }; + return projectAgentModelDefaults(config, target, mutate(projected)); +} + +/** Move a defaults-based model mutation onto one agent while preserving its other config changes. */ +export function projectAgentModelDefaults( + config: OpenClawConfig, + target: OnboardingAgentTarget, + updated: OpenClawConfig, +): OpenClawConfig { + const entry = resolveMutableAgentEntry(config, target.agentId); + if (!entry && config.agents?.ownership !== "explicit") { + return updated; + } + const updatedDefaults = updated.agents?.defaults; + const { model: _model, models: _models, modelPolicy: _modelPolicy, ...entryRest } = entry ?? {}; + const nextEntry = { + ...entryRest, + ...(updatedDefaults?.model !== undefined ? { model: updatedDefaults.model } : {}), + ...(updatedDefaults?.models !== undefined ? { models: updatedDefaults.models } : {}), + ...(updatedDefaults?.modelPolicy !== undefined + ? { modelPolicy: updatedDefaults.modelPolicy } + : {}), + }; + return replaceOnboardingAgentEntry(config, updated, target, nextEntry); } diff --git a/src/commands/onboard-custom-config.test.ts b/src/commands/onboard-custom-config.test.ts index fe986cdc3dbf..c7903feab77f 100644 --- a/src/commands/onboard-custom-config.test.ts +++ b/src/commands/onboard-custom-config.test.ts @@ -49,6 +49,67 @@ function applyCustomModelConfigWithContextWindow(contextWindow?: number) { }); } +it("keeps explicit custom-provider model state on the authored agent entry", () => { + const result = applyCustomApiConfig({ + config: { + agents: { + ownership: "explicit", + defaults: { systemAgent: { agentId: "ops" } }, + entries: { main: {}, OPS: {} }, + }, + }, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + providerId: "custom", + alias: "Custom", + target: { agentId: "ops", agentDir: "/tmp/ops-agent", workspaceDir: "/tmp/ops-workspace" }, + }); + + expect(result.config.agents?.entries?.OPS?.model).toEqual({ primary: "custom/foo-large" }); + expect(result.config.agents?.entries?.OPS?.models).toEqual({ + "custom/foo-large": { alias: "Custom" }, + }); + expect(result.config.agents?.defaults?.model).toBeUndefined(); + expect(result.config.models?.providers?.custom?.models?.map((model) => model.id)).toEqual([ + "foo-large", + ]); +}); + +it("preserves a list-form roster when applying custom-provider model state", () => { + const result = applyCustomApiConfig({ + config: { + agents: { + ownership: "explicit", + defaults: { systemAgent: { agentId: "ops" } }, + list: [ + { id: "main", name: "Main" }, + { id: "ops", name: "Operations" }, + ], + }, + }, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + providerId: "custom", + alias: "Custom", + target: { agentId: "ops", agentDir: "/tmp/ops-agent", workspaceDir: "/tmp/ops-workspace" }, + }); + + expect(result.config.agents?.list).toBeUndefined(); + expect(result.config.agents?.entries).toEqual({ + main: { name: "Main" }, + ops: { + name: "Operations", + model: { primary: "custom/foo-large" }, + models: { "custom/foo-large": { alias: "Custom" } }, + }, + }); + expect(result.config.models?.providers?.custom?.models?.map((model) => model.id)).toEqual([ + "foo-large", + ]); +}); + it("uses expanded max_tokens for openai verification probes", () => { const request = buildOpenAiVerificationProbeRequest({ baseUrl: "https://example.com/v1", diff --git a/src/commands/onboard-custom-config.ts b/src/commands/onboard-custom-config.ts index d6a350c340c4..99abb5344b0c 100644 --- a/src/commands/onboard-custom-config.ts +++ b/src/commands/onboard-custom-config.ts @@ -18,6 +18,7 @@ import { isSecretRef, type SecretInput } from "../config/types.secrets.js"; import { applyPrimaryModel } from "../plugins/provider-model-primary.js"; import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; import { normalizeAlias } from "./models/alias-name.js"; +import { projectAgentModelDefaults, type OnboardingAgentTarget } from "./onboard-agent-target.js"; /** * Wizard default for non-Azure custom APIs when context length is unknown. @@ -193,6 +194,7 @@ type ApplyCustomApiConfigParams = { providerId?: string; alias?: string; supportsImageInput?: boolean; + target?: OnboardingAgentTarget; }; /** Raw CLI flag values for non-interactive custom API setup. */ @@ -733,6 +735,10 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom }; } + if (params.target && params.config.agents?.ownership === "explicit") { + config = projectAgentModelDefaults(params.config, params.target, config); + } + return { config, providerId, diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 037b7b0ff4a8..0597b9f3dbfe 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -13,6 +13,7 @@ import { fetchWithTimeout } from "../utils/fetch-timeout.js"; import { normalizeSecretInput } from "../utils/normalize-secret-input.js"; import { t } from "../wizard/i18n/index.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import type { OnboardingAgentTarget } from "./onboard-agent-target.js"; import { applyCustomApiConfig, buildAnthropicVerificationProbeRequest, @@ -238,6 +239,7 @@ export async function promptCustomApiConfig(params: { prompter: WizardPrompter; runtime: RuntimeEnv; config: OpenClawConfig; + target?: OnboardingAgentTarget; secretInputMode?: SecretInputMode; }): Promise { const { prompter, runtime, config } = params; @@ -412,6 +414,7 @@ export async function promptCustomApiConfig(params: { providerId: providerIdInput, alias: aliasInput, supportsImageInput, + ...(params.target ? { target: params.target } : {}), }); if (result.providerIdRenamedFrom && result.providerId) { diff --git a/src/flows/model-picker.ts b/src/flows/model-picker.ts index 59e83e0bbbe8..b451f71c512f 100644 --- a/src/flows/model-picker.ts +++ b/src/flows/model-picker.ts @@ -1164,6 +1164,7 @@ export async function promptModelAllowlist(params: { config: OpenClawConfig; prompter: WizardPrompter; message?: string; + agentId?: string; agentDir?: string; workspaceDir?: string; env?: NodeJS.ProcessEnv; @@ -1173,7 +1174,7 @@ export async function promptModelAllowlist(params: { loadCatalog?: boolean; providerScopedCatalog?: boolean; }): Promise { - const cfg = params.config; + const cfg = resolveModelPickerConfig(params.config, params.agentId); const pickerAgentDir = resolvePickerAgentDir({ cfg, ...(params.agentDir !== undefined ? { agentDir: params.agentDir } : {}),