fix(#2852): forward model overrides from categories/agent config to subagents

- fix(switcher): use lastIndexOf for multi-slash model IDs (e.g. aws/anthropic/claude-sonnet-4)
- fix(model-resolution): same lastIndexOf fix in doctor parseProviderModel
- fix(call-omo-agent): resolve model from agent config and forward to both
  background and sync executors via DelegatedModelConfig
- fix(subagent-resolver): inherit category model/variant when agent uses
  category reference without explicit model override
- test: add model override forwarding tests for call-omo-agent
- test: add multi-slash model ID test for switcher
This commit is contained in:
YeonGyu-Kim
2026-03-27 13:59:06 +09:00
parent 40a92138ea
commit 670d8ab175
12 changed files with 185 additions and 17 deletions
@@ -2,6 +2,7 @@ import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared"
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
import type { FallbackEntry } from "../../shared/model-requirements"
import { resolveMessageContext } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
@@ -20,6 +21,7 @@ export async function executeBackground(
manager: BackgroundManager,
client: PluginInput["client"],
fallbackChain?: FallbackEntry[],
model?: DelegatedModelConfig,
): Promise<string> {
try {
const messageDir = getMessageDir(toolContext.sessionID)
@@ -50,6 +52,7 @@ export async function executeBackground(
parentMessageID: toolContext.messageID,
parentAgent,
parentTools: getSessionTools(toolContext.sessionID),
model,
fallbackChain,
})
@@ -3,6 +3,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import { subagentSessions, syncSubagentSessions } from "../../features/claude-code-session-state"
import { clearSessionFallbackChain, setSessionFallbackChain } from "../../hooks/model-fallback/hook"
import { getAgentToolRestrictions, log } from "../../shared"
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
import type { FallbackEntry } from "../../shared/model-requirements"
import { waitForCompletion } from "./completion-poller"
import { processMessages } from "./message-processor"
@@ -46,6 +47,7 @@ export async function executeSync(
deps: ExecuteSyncDeps = defaultDeps,
fallbackChain?: FallbackEntry[],
spawnReservation?: SpawnReservation,
model?: DelegatedModelConfig,
): Promise<string> {
let sessionID: string | undefined
let createdSessionForExecution = false
@@ -88,6 +90,8 @@ export async function executeSync(
question: false,
},
parts: [{ type: "text", text: args.prompt }],
...(model ? { model: { providerID: model.providerID, modelID: model.modelID } } : {}),
...(model?.variant ? { variant: model.variant } : {}),
},
})
} catch (error) {
+100
View File
@@ -165,6 +165,106 @@ describe("createCallOmoAgent", () => {
])
})
test("forwards model override from agent config to background executor (#2852)", async () => {
//#given
const launch = mock((_input: { model?: { providerID: string; modelID: string }; fallbackChain?: unknown[] }) => Promise.resolve({
id: "task-model",
sessionID: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
}))
const managerWithLaunch = {
launch,
getTask: mock(() => undefined),
}
const toolDef = createCallOmoAgent(
mockCtx,
managerWithLaunch,
[],
{
explore: {
model: "aws/anthropic/claude-sonnet-4",
},
},
)
const executeFunc = toolDef.execute as Function
//#when
await executeFunc(
{
description: "Test model override",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
const firstLaunchCall = launch.mock.calls[0]
if (firstLaunchCall === undefined) {
throw new Error("Expected launch to be called")
}
const [launchArgs] = firstLaunchCall
expect(launchArgs.model).toEqual({
providerID: "aws",
modelID: "anthropic/claude-sonnet-4",
})
})
test("forwards model variant from agent config to background executor (#2852)", async () => {
//#given
const launch = mock((_input: { model?: { providerID: string; modelID: string; variant?: string } }) => Promise.resolve({
id: "task-variant",
sessionID: "sub-session",
description: "Test task",
agent: "explore",
status: "pending",
}))
const managerWithLaunch = {
launch,
getTask: mock(() => undefined),
}
const toolDef = createCallOmoAgent(
mockCtx,
managerWithLaunch,
[],
{
explore: {
model: "openai/gpt-5.4",
variant: "high",
},
},
)
const executeFunc = toolDef.execute as Function
//#when
await executeFunc(
{
description: "Test variant",
prompt: "Test prompt",
subagent_type: "explore",
run_in_background: true,
},
{ sessionID: "test", messageID: "msg", agent: "test", abort: new AbortController().signal }
)
//#then
const firstLaunchCall = launch.mock.calls[0]
if (firstLaunchCall === undefined) {
throw new Error("Expected launch to be called")
}
const [launchArgs] = firstLaunchCall
expect(launchArgs.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
variant: "high",
})
})
test("should return a tool error when sync spawn depth validation fails", async () => {
//#given
reserveSubagentSpawnMock.mockRejectedValueOnce(new Error("Subagent spawn blocked: child depth 4 exceeds background_task.maxDepth=3."))
+28 -8
View File
@@ -3,20 +3,22 @@ import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
import type { AllowedAgentType, CallOmoAgentArgs, ToolContextWithMetadata } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
import type { CategoriesConfig, AgentOverrides } from "../../config/schema"
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
import type { FallbackEntry } from "../../shared/model-requirements"
import { AGENT_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { normalizeModelFormat } from "../../shared/model-format-normalizer"
import { normalizeFallbackModels } from "../../shared/model-resolver"
import { buildFallbackChainFromModels } from "../../shared/fallback-chain-from-models"
import { log } from "../../shared"
import { executeBackground } from "./background-executor"
import { executeSync } from "./sync-executor"
function resolveFallbackChainForCallOmoAgent(args: {
function resolveModelAndFallbackChain(args: {
subagentType: string
agentOverrides?: AgentOverrides
userCategories?: CategoriesConfig
}): FallbackEntry[] | undefined {
}): { model: DelegatedModelConfig | undefined; fallbackChain: FallbackEntry[] | undefined } {
const { subagentType, agentOverrides, userCategories } = args
const agentConfigKey = getAgentConfigKey(subagentType)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
@@ -26,14 +28,32 @@ function resolveFallbackChainForCallOmoAgent(args: {
? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1]
: undefined)
let model: DelegatedModelConfig | undefined
if (agentOverride?.model) {
const normalized = normalizeModelFormat(agentOverride.model)
if (normalized) {
model = agentOverride.variant ? { ...normalized, variant: agentOverride.variant } : normalized
log("[call_omo_agent] Resolved model override from agent config", {
agent: subagentType,
model: agentOverride.model,
variant: agentOverride.variant,
})
}
}
const normalizedFallbackModels = normalizeFallbackModels(
agentOverride?.fallback_models
?? (agentOverride?.category ? userCategories?.[agentOverride.category]?.fallback_models : undefined)
)
const defaultProviderID = agentRequirement?.fallbackChain?.[0]?.providers?.[0] ?? "opencode"
const defaultProviderID = model?.providerID
?? agentRequirement?.fallbackChain?.[0]?.providers?.[0]
?? "opencode"
const configuredFallbackChain = buildFallbackChainFromModels(normalizedFallbackModels, defaultProviderID)
return configuredFallbackChain ?? agentRequirement?.fallbackChain
return {
model,
fallbackChain: configuredFallbackChain ?? agentRequirement?.fallbackChain,
}
}
export function createCallOmoAgent(
@@ -82,7 +102,7 @@ export function createCallOmoAgent(
return `Error: Agent "${normalizedAgent}" is disabled via disabled_agents configuration. Remove it from disabled_agents in your oh-my-opencode.json to use it.`
}
const fallbackChain = resolveFallbackChainForCallOmoAgent({
const { model: resolvedModel, fallbackChain } = resolveModelAndFallbackChain({
subagentType: args.subagent_type,
agentOverrides,
userCategories,
@@ -92,21 +112,21 @@ export function createCallOmoAgent(
if (args.session_id) {
return `Error: session_id is not supported in background mode. Use run_in_background=false to continue an existing session.`
}
return await executeBackground(args, toolCtx, backgroundManager, ctx.client, fallbackChain)
return await executeBackground(args, toolCtx, backgroundManager, ctx.client, fallbackChain, resolvedModel)
}
if (!args.session_id) {
let spawnReservation: Awaited<ReturnType<BackgroundManager["reserveSubagentSpawn"]>> | undefined
try {
spawnReservation = await backgroundManager.reserveSubagentSpawn(toolCtx.sessionID)
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, spawnReservation)
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, spawnReservation, resolvedModel)
} catch (error) {
spawnReservation?.rollback()
return `Error: ${error instanceof Error ? error.message : String(error)}`
}
}
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain)
return await executeSync(args, toolCtx, ctx, undefined, fallbackChain, undefined, resolvedModel)
},
})
}
+12 -6
View File
@@ -101,12 +101,15 @@ Create the work plan directly - that's your job as the planning agent.`,
const agentOverride = agentOverrides?.[agentConfigKey as keyof typeof agentOverrides]
?? (agentOverrides ? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentConfigKey)?.[1] : undefined)
const agentRequirement = AGENT_MODEL_REQUIREMENTS[agentConfigKey]
const agentCategoryModel = agentOverride?.category
? userCategories?.[agentOverride.category]?.model
: undefined
const normalizedAgentFallbackModels = normalizeFallbackModels(
agentOverride?.fallback_models
?? (agentOverride?.category ? userCategories?.[agentOverride.category]?.fallback_models : undefined)
)
if (agentOverride?.model || agentRequirement || matchedAgent.model) {
if (agentOverride?.model || agentCategoryModel || agentRequirement || matchedAgent.model) {
const availableModels = await getAvailableModelsForDelegateTask(client)
const normalizedMatchedModel = matchedAgent.model
@@ -117,7 +120,7 @@ Create the work plan directly - that's your job as the planning agent.`,
: undefined
const resolution = resolveModelForDelegateTask({
userModel: agentOverride?.model,
userModel: agentOverride?.model ?? agentCategoryModel,
userFallbackModels: flattenToFallbackModelStrings(normalizedAgentFallbackModels),
categoryDefaultModel: matchedAgentModelStr,
fallbackChain: agentRequirement?.fallbackChain,
@@ -133,16 +136,19 @@ Create the work plan directly - that's your job as the planning agent.`,
const variantToUse = agentOverride?.variant ?? resolution.variant
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
}
} else if (resolutionSkipped && agentOverride?.model) {
} else if (resolutionSkipped && (agentOverride?.model ?? agentCategoryModel)) {
// Cold cache: resolution was skipped but user explicitly configured a model.
// Honor the user override directly — don't fall through to hardcoded fallback chain.
const normalized = normalizeModelFormat(agentOverride.model)
const normalized = normalizeModelFormat((agentOverride?.model ?? agentCategoryModel)!)
if (normalized) {
const variantToUse = agentOverride?.variant
const agentCategoryVariant = agentOverride?.category
? userCategories?.[agentOverride.category]?.variant
: undefined
const variantToUse = agentOverride?.variant ?? agentCategoryVariant
categoryModel = variantToUse ? { ...normalized, variant: variantToUse } : normalized
log("[delegate-task] Cold cache: using explicit user override for subagent", {
agent: agentToUse,
model: agentOverride.model,
model: agentOverride?.model ?? agentCategoryModel,
})
}
}