Browse Source

feat: fix Orchestrator UI model override & implement runtime array fallback chain (#158)

* feat: allow array of models for priority fallback in agent overrides (#153)

- AgentOverrideConfigSchema.model now accepts string | string[]
- DEFAULT_MODELS.orchestrator is undefined; no hardcoded model default
- createOrchestratorAgent accepts optional string | string[] model
- applyOverrides stores string[] as _modelArray and clears config.model
- config hook resolves first available provider model from _modelArray
  by inspecting opencodeConfig.provider at startup (avoids deadlock)
- Removed non-functional chat.message hook (input is read-only)
- background-manager resolveFallbackChain handles string[] primary model
- Updated test to reflect orchestrator has no hardcoded default model

Fixes: model array fallback now resolves at config time using provider
config inspection instead of runtime HTTP API calls.

* feat: support per-model variant in array fallback config (#153)
n24q02m 5 months ago
parent
commit
185d2e3f8d

+ 68 - 1
src/agents/index.test.ts

@@ -140,6 +140,71 @@ describe('orchestrator agent', () => {
     const orchestrator = agents.find((a) => a.name === 'orchestrator');
     expect(orchestrator?.config.variant).toBe('high');
   });
+
+  test('orchestrator stores model array with per-model variants in _modelArray', () => {
+    const config: PluginConfig = {
+      agents: {
+        orchestrator: {
+          model: [
+            { id: 'google/gemini-3-pro', variant: 'high' },
+            { id: 'github-copilot/claude-3.5-haiku' },
+            'openai/gpt-4',
+          ],
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(orchestrator?._modelArray).toEqual([
+      { id: 'google/gemini-3-pro', variant: 'high' },
+      { id: 'github-copilot/claude-3.5-haiku' },
+      { id: 'openai/gpt-4' },
+    ]);
+    expect(orchestrator?.config.model).toBeUndefined();
+  });
+});
+
+describe('per-model variant in array config', () => {
+  test('subagent stores model array with per-model variants', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: {
+          model: [
+            { id: 'google/gemini-3-flash', variant: 'low' },
+            'openai/gpt-4o-mini',
+          ],
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const explorer = agents.find((a) => a.name === 'explorer');
+    expect(explorer?._modelArray).toEqual([
+      { id: 'google/gemini-3-flash', variant: 'low' },
+      { id: 'openai/gpt-4o-mini' },
+    ]);
+    expect(explorer?.config.model).toBeUndefined();
+  });
+
+  test('top-level variant preserved alongside per-model variants', () => {
+    const config: PluginConfig = {
+      agents: {
+        orchestrator: {
+          model: [
+            { id: 'google/gemini-3-pro', variant: 'high' },
+            'openai/gpt-4',
+          ],
+          variant: 'low',
+        },
+      },
+    };
+    const agents = createAgents(config);
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    // top-level variant still set as default
+    expect(orchestrator?.config.variant).toBe('low');
+    // per-model variants stored in _modelArray
+    expect(orchestrator?._modelArray?.[0]?.variant).toBe('high');
+    expect(orchestrator?._modelArray?.[1]?.variant).toBeUndefined();
+  });
 });
 
 describe('skill permissions', () => {
@@ -238,7 +303,9 @@ describe('getAgentConfigs', () => {
     const configs = getAgentConfigs();
     expect(configs.orchestrator).toBeDefined();
     expect(configs.explorer).toBeDefined();
-    expect(configs.orchestrator.model).toBeDefined();
+    // orchestrator has no hardcoded default model; resolved at runtime via
+    // chat.message hook when _modelArray is configured, or left to the user
+    expect(configs.explorer.model).toBeDefined();
   });
 
   test('includes description in SDK config', () => {

+ 32 - 12
src/agents/index.ts

@@ -29,13 +29,24 @@ type AgentFactory = (
 
 /**
  * Apply user-provided overrides to an agent's configuration.
- * Supports overriding model, variant, and temperature.
+ * Supports overriding model (string or priority array), variant, and temperature.
+ * When model is an array, stores it as _modelArray for runtime fallback resolution
+ * and clears config.model so OpenCode does not pre-resolve a stale value.
  */
 function applyOverrides(
   agent: AgentDefinition,
   override: AgentOverrideConfig,
 ): void {
-  if (override.model) agent.config.model = override.model;
+  if (override.model) {
+    if (Array.isArray(override.model)) {
+      agent._modelArray = override.model.map((m) =>
+        typeof m === 'string' ? { id: m } : m,
+      );
+      agent.config.model = undefined; // cleared; runtime hook resolves from _modelArray
+    } else {
+      agent.config.model = override.model;
+    }
+  }
   if (override.variant) agent.config.variant = override.variant;
   if (override.temperature !== undefined)
     agent.config.temperature = override.temperature;
@@ -104,11 +115,19 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   // existing users who don't have fixer in their config yet
   const getModelForAgent = (name: SubagentName): string => {
     if (name === 'fixer' && !getAgentOverride(config, 'fixer')?.model) {
-      return (
-        getAgentOverride(config, 'librarian')?.model ?? DEFAULT_MODELS.librarian
-      );
+      const librarianOverride = getAgentOverride(config, 'librarian')?.model;
+      let librarianModel: string | undefined;
+      if (Array.isArray(librarianOverride)) {
+        const first = librarianOverride[0];
+        librarianModel =
+          typeof first === 'string' ? first : first?.id;
+      } else {
+        librarianModel = librarianOverride;
+      }
+      return librarianModel ?? (DEFAULT_MODELS.librarian as string);
     }
-    return DEFAULT_MODELS[name];
+    // Subagents always have a defined default model; cast is safe here
+    return DEFAULT_MODELS[name] as string;
   };
 
   // 1. Gather all sub-agent definitions with custom prompts
@@ -134,19 +153,20 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   });
 
   // 3. Create Orchestrator (with its own overrides and custom prompts)
+  // DEFAULT_MODELS.orchestrator is undefined; model is resolved via override or
+  // left unset so the runtime chat.message hook can pick it from _modelArray.
+  const orchestratorOverride = getAgentOverride(config, 'orchestrator');
   const orchestratorModel =
-    getAgentOverride(config, 'orchestrator')?.model ??
-    DEFAULT_MODELS.orchestrator;
+    orchestratorOverride?.model ?? DEFAULT_MODELS.orchestrator;
   const orchestratorPrompts = loadAgentPrompt('orchestrator');
   const orchestrator = createOrchestratorAgent(
     orchestratorModel,
     orchestratorPrompts.prompt,
     orchestratorPrompts.appendPrompt,
   );
-  const oOverride = getAgentOverride(config, 'orchestrator');
-  applyDefaultPermissions(orchestrator, oOverride?.skills);
-  if (oOverride) {
-    applyOverrides(orchestrator, oOverride);
+  applyDefaultPermissions(orchestrator, orchestratorOverride?.skills);
+  if (orchestratorOverride) {
+    applyOverrides(orchestrator, orchestratorOverride);
   }
 
   return [orchestrator, ...allSubAgents];

+ 16 - 3
src/agents/orchestrator.ts

@@ -4,6 +4,8 @@ export interface AgentDefinition {
   name: string;
   description?: string;
   config: AgentConfig;
+  /** Priority-ordered model entries for runtime fallback resolution. */
+  _modelArray?: Array<{ id: string; variant?: string }>;
 }
 
 const ORCHESTRATOR_PROMPT = `<Role>
@@ -141,7 +143,9 @@ When user's approach seems problematic:
 `;
 
 export function createOrchestratorAgent(
-  model: string,
+  model?:
+    | string
+    | Array<string | { id: string; variant?: string }>,
   customPrompt?: string,
   customAppendPrompt?: string,
 ): AgentDefinition {
@@ -153,14 +157,23 @@ export function createOrchestratorAgent(
     prompt = `${ORCHESTRATOR_PROMPT}\n\n${customAppendPrompt}`;
   }
 
-  return {
+  const definition: AgentDefinition = {
     name: 'orchestrator',
     description:
       'AI coding orchestrator that delegates tasks to specialist agents for optimal quality, speed, and cost',
     config: {
-      model,
       temperature: 0.1,
       prompt,
     },
   };
+
+  if (Array.isArray(model)) {
+    definition._modelArray = model.map((m) =>
+      typeof m === 'string' ? { id: m } : m,
+    );
+  } else if (typeof model === 'string' && model) {
+    definition.config.model = model;
+  }
+
+  return definition;
 }

+ 12 - 1
src/background/background-manager.ts

@@ -241,7 +241,18 @@ export class BackgroundTaskManager {
     const chain: string[] = [];
     const seen = new Set<string>();
 
-    for (const model of [primary, ...configuredChain]) {
+    // primary may be a string, an array of string|{id,variant?}, or undefined
+    let primaryIds: string[];
+    if (Array.isArray(primary)) {
+      primaryIds = primary.map((m) =>
+        typeof m === 'string' ? m : m.id,
+      );
+    } else if (typeof primary === 'string') {
+      primaryIds = [primary];
+    } else {
+      primaryIds = [];
+    }
+    for (const model of [...primaryIds, ...configuredChain]) {
       if (!model || seen.has(model)) continue;
       seen.add(model);
       chain.push(model);

+ 3 - 2
src/config/constants.ts

@@ -35,8 +35,9 @@ export const SUBAGENT_DELEGATION_RULES: Record<AgentName, readonly string[]> = {
 };
 
 // Default models for each agent
-export const DEFAULT_MODELS: Record<AgentName, string> = {
-  orchestrator: 'kimi-for-coding/k2p5',
+// orchestrator is undefined so its model is fully resolved at runtime via priority fallback
+export const DEFAULT_MODELS: Record<AgentName, string | undefined> = {
+  orchestrator: undefined,
   oracle: 'openai/gpt-5.2-codex',
   librarian: 'openai/gpt-5.1-codex-mini',
   explorer: 'openai/gpt-5.1-codex-mini',

+ 17 - 1
src/config/schema.ts

@@ -79,7 +79,20 @@ export type FallbackAgentName = (typeof FALLBACK_AGENT_NAMES)[number];
 
 // Agent override configuration (distinct from SDK's AgentConfig)
 export const AgentOverrideConfigSchema = z.object({
-  model: z.string().optional(),
+  model: z
+    .union([
+      z.string(),
+      z.array(
+        z.union([
+          z.string(),
+          z.object({
+            id: z.string(),
+            variant: z.string().optional(),
+          }),
+        ]),
+      ),
+    ])
+    .optional(),
   temperature: z.number().min(0).max(2).optional(),
   variant: z.string().optional().catch(undefined),
   skills: z.array(z.string()).optional(), // skills this agent can use ("*" = all, "!item" = exclude)
@@ -108,6 +121,9 @@ export type TmuxConfig = z.infer<typeof TmuxConfigSchema>;
 
 export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>;
 
+/** Normalized model entry with optional per-model variant. */
+export type ModelEntry = { id: string; variant?: string };
+
 export const PresetSchema = z.record(z.string(), AgentOverrideConfigSchema);
 
 export type Preset = z.infer<typeof PresetSchema>;

+ 2 - 1
src/hooks/delegate-task-retry/hook.ts

@@ -9,7 +9,8 @@ export function createDelegateTaskRetryHook(_ctx: PluginInput) {
       output: { output: unknown },
     ): Promise<void> => {
       const toolName = input.tool.toLowerCase();
-      const isDelegateTool = toolName === 'task' || toolName === 'background_task';
+      const isDelegateTool =
+        toolName === 'task' || toolName === 'background_task';
       if (!isDelegateTool) return;
 
       if (typeof output.output !== 'string') return;

+ 5 - 2
src/hooks/delegate-task-retry/index.ts

@@ -1,4 +1,7 @@
-export type { DelegateTaskErrorPattern, DetectedError } from './patterns';
-export { DELEGATE_TASK_ERROR_PATTERNS, detectDelegateTaskError } from './patterns';
 export { buildRetryGuidance } from './guidance';
 export { createDelegateTaskRetryHook } from './hook';
+export type { DelegateTaskErrorPattern, DetectedError } from './patterns';
+export {
+  DELEGATE_TASK_ERROR_PATTERNS,
+  detectDelegateTaskError,
+} from './patterns';

+ 2 - 4
src/hooks/delegate-task-retry/patterns.ts

@@ -14,8 +14,7 @@ export const DELEGATE_TASK_ERROR_PATTERNS: DelegateTaskErrorPattern[] = [
   {
     pattern: 'load_skills',
     errorType: 'missing_load_skills',
-    fixHint:
-      'Add load_skills=[] (empty array when no skill is needed).',
+    fixHint: 'Add load_skills=[] (empty array when no skill is needed).',
   },
   {
     pattern: 'category OR subagent_type',
@@ -32,8 +31,7 @@ export const DELEGATE_TASK_ERROR_PATTERNS: DelegateTaskErrorPattern[] = [
   {
     pattern: 'Unknown category',
     errorType: 'unknown_category',
-    fixHint:
-      'Use a valid category listed in the error output.',
+    fixHint: 'Use a valid category listed in the error output.',
   },
   {
     pattern: 'Unknown agent',

+ 60 - 1
src/index.ts

@@ -1,5 +1,5 @@
 import type { Plugin } from '@opencode-ai/plugin';
-import { getAgentConfigs } from './agents';
+import { createAgents, getAgentConfigs } from './agents';
 import { BackgroundTaskManager, TmuxSessionManager } from './background';
 import { loadPluginConfig, type TmuxConfig } from './config';
 import { parseList } from './config/agent-mcps';
@@ -26,8 +26,20 @@ import { log } from './utils/logger';
 
 const OhMyOpenCodeLite: Plugin = async (ctx) => {
   const config = loadPluginConfig(ctx.directory);
+  const agentDefs = createAgents(config);
   const agents = getAgentConfigs(config);
 
+  // Build a map of agent name → priority model array for runtime fallback.
+  // Populated when the user configures model as an array in their plugin config.
+  const modelArrayMap: Record<
+    string,
+    Array<{ id: string; variant?: string }>
+  > = {};
+  for (const agentDef of agentDefs) {
+    if (agentDef._modelArray && agentDef._modelArray.length > 0) {
+      modelArrayMap[agentDef.name] = agentDef._modelArray;
+    }
+  }
   // Parse tmux config with defaults
   const tmuxConfig: TmuxConfig = {
     enabled: config.tmux?.enabled ?? false,
@@ -106,6 +118,53 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
       const configAgent = opencodeConfig.agent as Record<string, unknown>;
 
+      // Runtime model fallback: resolve model arrays to the first
+      // provider/model whose provider is configured in OpenCode.
+      // NOTE: We cannot call ctx.client.provider.list() here because
+      // the HTTP server is still initializing (causes deadlock).
+      // Instead, inspect opencodeConfig.provider directly.
+      if (Object.keys(modelArrayMap).length > 0) {
+        const providerConfig =
+          (opencodeConfig.provider as Record<string, unknown>) ?? {};
+        const configuredProviders = Object.keys(providerConfig);
+
+        for (const [agentName, modelArray] of Object.entries(
+          modelArrayMap,
+        )) {
+          let resolved = false;
+          for (const modelEntry of modelArray) {
+            const slashIdx = modelEntry.id.indexOf('/');
+            if (slashIdx === -1) continue;
+            const providerID = modelEntry.id.slice(0, slashIdx);
+            if (configuredProviders.includes(providerID)) {
+              const entry = configAgent[agentName] as
+                | Record<string, unknown>
+                | undefined;
+              if (entry) {
+                entry.model = modelEntry.id;
+                if (modelEntry.variant) {
+                  entry.variant = modelEntry.variant;
+                }
+              }
+              log('[plugin] resolved model fallback', {
+                agent: agentName,
+                model: modelEntry.id,
+                variant: modelEntry.variant,
+              });
+              resolved = true;
+              break;
+            }
+          }
+          // If no provider matched, leave model unset so OpenCode
+          // uses the UI-selected model (fixes #138).
+          if (!resolved) {
+            log('[plugin] no provider match for model array', {
+              agent: agentName,
+            });
+          }
+        }
+      }
+
       // Merge MCP configs
       const configMcp = opencodeConfig.mcp as
         | Record<string, unknown>