From 323030594efcd7be1473476bf9111bcaeb86a93a Mon Sep 17 00:00:00 2001 From: JK <219549316+HowdyDooToYou@users.noreply.github.com> Date: Mon, 27 Apr 2026 18:44:56 -0400 Subject: [PATCH] fix(agents): resolve model aliases in sessions_spawn (#59681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agents): resolve model aliases in sessions_spawn normalizeModelSelection() only trims the input — it never resolves aliases through the model alias index. When a user passes an alias like 'opus' to sessions_spawn, the child session gets patched with the raw string, which the gateway cannot match to any provider. Add resolveModelThroughAliases() to check bare strings against the configured alias map before returning from resolveSubagentSpawnModelSelection(). Fixes #57532 Refs #50736 * refactor: address review feedback on alias resolution - Accept pre-built ModelAliasIndex instead of rebuilding per call - Narrow helper signature to (string, ModelAliasIndex) → string - Remove unreachable ?? raw fallback Co-Authored-By: greptile-apps[bot] * fix(agents): resolve sessions_spawn model aliases --------- Co-authored-by: HowdyDooToYou Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com> --- CHANGELOG.md | 1 + src/agents/model-selection.test.ts | 96 ++++++++++++++++++++++++++++++ src/agents/model-selection.ts | 31 +++++++++- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5291f7666..f51f5a2fb2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Gateway/device tokens: stop echoing rotated bearer tokens from shared/admin `device.token.rotate` responses while preserving the same-device token handoff needed by token-only clients before reconnect. (#66773) Thanks @MoerAI. +- Agents/sessions_spawn: resolve configured bare model aliases for spawn model overrides using the target agent runtime default provider, carrying forward the alias-specific #69029 review fixes from #59681 without the unrelated active-session pruning path. Fixes #59681. Thanks @HowdyDooToYou. - Control UI/Talk: keep Google Live browser sessions on the WebSocket transport instead of falling back to WebRTC, validate browser Google Live WebSocket endpoints, cap Gateway relay sessions per browser connection, and remove stale browser-native voice buttons that did not use the configured Talk/TTS provider. Thanks @BunsDev. - Gateway/startup: reuse config snapshot plugin manifests for startup auto-enable before plugin bootstrap plans plugin loading. Thanks @shakkernerd. - Agents/subagents: enforce `subagents.allowAgents` for explicit same-agent `sessions_spawn(agentId=...)` calls instead of auto-allowing requester self-targets. Fixes #72827. Thanks @oiGaDio. diff --git a/src/agents/model-selection.test.ts b/src/agents/model-selection.test.ts index 21ac8e79b7a..a4c0ccb5d1b 100644 --- a/src/agents/model-selection.test.ts +++ b/src/agents/model-selection.test.ts @@ -19,6 +19,7 @@ import { resolveAllowedModelRef, resolveConfiguredModelRef, resolveSubagentConfiguredModelSelection, + resolveSubagentSpawnModelSelection, resolveThinkingDefault, resolveModelRefFromString, } from "./model-selection.js"; @@ -1534,3 +1535,98 @@ describe("resolveSubagentConfiguredModelSelection", () => { ); }); }); + +describe("resolveSubagentSpawnModelSelection", () => { + it("resolves a model alias override to its full provider/model ref", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "anthropic/claude-opus-4-6": { alias: "opus" }, + "openai/gpt-5.4": { alias: "gpt" }, + }, + }, + }, + } as OpenClawConfig; + + expect( + resolveSubagentSpawnModelSelection({ cfg, agentId: "main", modelOverride: "opus" }), + ).toBe("anthropic/claude-opus-4-6"); + }); + + it("resolves bare configured aliases with the target agent runtime default provider", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + models: { + "claude-opus-4-6": { alias: "opus" }, + }, + }, + list: [ + { + id: "research", + model: "anthropic/claude-sonnet-4-6", + }, + ], + }, + } as OpenClawConfig; + + expect( + resolveSubagentSpawnModelSelection({ + cfg, + agentId: "research", + modelOverride: "OPUS", + }), + ).toBe("anthropic/claude-opus-4-6"); + }); + + it("resolves alias in configured subagent model", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + models: { + "openai/gpt-5.4": { alias: "gpt" }, + }, + subagents: { model: "gpt" }, + }, + }, + } as OpenClawConfig; + + expect(resolveSubagentSpawnModelSelection({ cfg, agentId: "main" })).toBe("openai/gpt-5.4"); + }); + + it("passes through already-qualified provider/model refs unchanged", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + }, + }, + } as OpenClawConfig; + + expect( + resolveSubagentSpawnModelSelection({ + cfg, + agentId: "main", + modelOverride: "openai/gpt-5.4", + }), + ).toBe("openai/gpt-5.4"); + }); + + it("falls back to runtime default when no override or config", () => { + const cfg = { + agents: { + defaults: { + model: { primary: "anthropic/claude-sonnet-4-6" }, + }, + }, + } as OpenClawConfig; + + expect(resolveSubagentSpawnModelSelection({ cfg, agentId: "main" })).toBe( + "anthropic/claude-sonnet-4-6", + ); + }); +}); diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts index 368f58e7427..ac258e78120 100644 --- a/src/agents/model-selection.ts +++ b/src/agents/model-selection.ts @@ -4,6 +4,7 @@ import { toAgentModelListLike, } from "../config/model-input.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js"; import { resolveAgentConfig, resolveAgentEffectiveModelPrimary, @@ -248,6 +249,26 @@ export function resolveSubagentConfiguredModelSelection(params: { ); } +/** + * Resolve a normalized model string through a pre-built alias index, returning + * a fully qualified `provider/model` string. If the value is already qualified + * or not a known alias, returns it unchanged. + */ +function resolveModelThroughAliases(value: string, aliasIndex: ModelAliasIndex): string { + // Already a provider/model ref — no alias resolution needed. + if (value.includes("/")) { + return value; + } + // Check if the value is a known alias; if so, resolve to provider/model. + // Unknown bare strings are returned as-is (don't guess the provider). + const aliasKey = normalizeLowercaseStringOrEmpty(value); + const aliasMatch = aliasIndex.byAlias.get(aliasKey); + if (aliasMatch) { + return `${aliasMatch.ref.provider}/${aliasMatch.ref.model}`; + } + return value; +} + export function resolveSubagentSpawnModelSelection(params: { cfg: OpenClawConfig; agentId: string; @@ -257,15 +278,19 @@ export function resolveSubagentSpawnModelSelection(params: { cfg: params.cfg, agentId: params.agentId, }); - return ( + const raw = normalizeModelSelection(params.modelOverride) ?? resolveSubagentConfiguredModelSelection({ cfg: params.cfg, agentId: params.agentId, }) ?? normalizeModelSelection(resolveAgentModelPrimaryValue(params.cfg.agents?.defaults?.model)) ?? - `${runtimeDefault.provider}/${runtimeDefault.model}` - ); + `${runtimeDefault.provider}/${runtimeDefault.model}`; + const aliasIndex = buildModelAliasIndex({ + cfg: params.cfg, + defaultProvider: runtimeDefault.provider, + }); + return resolveModelThroughAliases(raw, aliasIndex); } export function buildAllowedModelSet(params: {