Просмотр исходного кода

feat(config): support explicit agent model inheritance

HeZzz 3 недель назад
Родитель
Сommit
d2ba5690fa

+ 1 - 0
CONTEXT.md

@@ -77,6 +77,7 @@ A glossary of the terms used in this project's domain. Definitions describe what
 - **Plugin config** — The user-facing configuration loaded from `oh-my-opencode-slim.jsonc`.
 - **Preset** — A named set of per-agent overrides. The same word also names council councillor lineups (see Flagged).
 - **Model entry** — A normalized model reference with an optional variant, used in fallback chains.
+- **Model inheritance** — An explicit agent policy selecting the current `session` model or the configured `orchestrator` model when no agent model is set.
 - **Variant** — An optional model qualifier (e.g., a preview build) used in fallback resolution.
 - **Fallback / failover** — The mechanism that switches models when a call is rate-limited or returns empty.
 - **Disabled agents** — Agents turned off via config; `observer` is disabled by default.

+ 41 - 0
docs/configuration.md

@@ -361,6 +361,47 @@ Notes:
 - Display names must be unique
 - Display names cannot conflict with internal agent names like `oracle` or `explorer`
 
+### Independent agent model inheritance
+
+By default, the `fixer` agent inherits the `librarian` model when no fixer
+model is configured. To decouple agents, set `inheritModelFrom` on the agent
+that should follow the current session or the configured orchestrator model:
+
+```jsonc
+{
+  "agents": {
+    "librarian": {
+      "model": "ollama/qwen3.8:27B"
+    },
+    "fixer": {
+      "inheritModelFrom": "session"
+    }
+  }
+}
+```
+
+Supported values are:
+
+- `session`: omit the agent model so OpenCode uses the current session model
+- `orchestrator`: use the orchestrator model resolved during configuration; if
+  none is configured, fall back to the current session model
+
+`orchestrator` means the model resolved during plugin configuration. It does
+not dynamically follow a later foreground fallback to another model.
+Runtime fallback behavior is independent of `inheritModelFrom`.
+
+Model selection follows these rules:
+
+- If the same effective agent override contains both `model` and
+  `inheritModelFrom`, the explicit `model` wins.
+- If `model` is omitted, `inheritModelFrom` is an explicit higher-layer
+  directive: it clears a lower-layer `model` value, including a model supplied
+  by the host agent configuration, and resolves the requested source.
+- If neither field is present, the existing model precedence and the historical
+  fixer-to-librarian fallback remain unchanged.
+
+The setting works in both root `agents` overrides and preset agent overrides.
+
 ### Per-preset agent configuration
 
 To get per-preset behavior for any agent, built-in (`council`, `oracle`,

+ 14 - 0
oh-my-opencode-slim.schema.json

@@ -65,6 +65,13 @@
                 }
               ]
             },
+            "inheritModelFrom": {
+              "type": "string",
+              "enum": [
+                "session",
+                "orchestrator"
+              ]
+            },
             "temperature": {
               "type": "number",
               "minimum": 0,
@@ -504,6 +511,13 @@
               }
             ]
           },
+          "inheritModelFrom": {
+            "type": "string",
+            "enum": [
+              "session",
+              "orchestrator"
+            ]
+          },
           "temperature": {
             "type": "number",
             "minimum": 0,

+ 3 - 2
src/agents/codemap.md

@@ -27,7 +27,7 @@ Each agent is a **prompt-driven specialist** with a factory function that create
 - **Default prompts**: Each agent factory has a base prompt defined in its file (e.g., `explorer.ts`, `oracle.ts`)
 - **User overrides**: From `~/.config/opencode/oh-my-opencode-slim.json` via `loadAgentPrompt()`
 - **Permission wildcards**: Applied via `applyDefaultPermissions()` in `index.ts`
-- **Model resolution**: Supports both string models and priority-ordered arrays (`_modelArray`) for runtime fallback
+- **Model resolution**: Supports string models, explicit `inheritModelFrom` policies, and priority-ordered arrays (`_modelArray`) for runtime fallback
 - **Skill permissions**: Per-agent MCP and tool access controlled via `getSkillPermissionsForAgent()`
 
 ### Agent Lifecycle
@@ -120,6 +120,7 @@ export function getAgentConfigs(config?: PluginConfig): Record<string, SDKAgentC
 ### Model Resolution and Fallback
 
 - **Priority arrays**: When `model` is configured as an array in user config, it's stored as `_modelArray`
+- **Explicit inheritance**: `inheritModelFrom: "session"` leaves the agent model unset so OpenCode uses the parent session model; `"orchestrator"` follows the model resolved during configuration, not later runtime fallback
 - **Runtime fallback**: ForegroundFallbackManager resolves models at runtime when API errors occur
 - **Preset overrides**: Runtime presets can override model/variant/temperature per agent
 
@@ -192,4 +193,4 @@ These rules are filtered based on disabled agents and injected into the orchestr
 - **Strategy Pattern**: Different agents implement different strategies for different tasks
 - **Decorator Pattern**: Configuration decorators (overrides, permissions, display names) wrap agent definitions
 - **Observer Pattern**: Session tracking via `sessionAgentMap` and event handlers
-- **Chain of Responsibility**: Task delegation flows from orchestrator to specialists
+- **Chain of Responsibility**: Task delegation flows from orchestrator to specialists

+ 181 - 0
src/agents/index.test.ts

@@ -10,6 +10,7 @@ import {
 } from '../config';
 import { RuntimeConfig } from '../config/runtime';
 import {
+  applyModelInheritanceToConfig,
   createAgents,
   getAgentConfigs,
   getDisabledAgents,
@@ -162,6 +163,86 @@ describe('fixer agent fallback', () => {
     expect(fixer?.config.model).toBe(librarian?.config.model);
   });
 
+  test('fixer can follow the session model independently of librarian', () => {
+    const config: PluginConfig = {
+      preset: 'balanced',
+      presets: {
+        balanced: {
+          orchestrator: { model: 'orchestrator-model' },
+        },
+      },
+      agents: {
+        librarian: { model: 'librarian-local-model' },
+        fixer: { inheritModelFrom: 'session' },
+      },
+    };
+    const agents = createAgents(runtimeFor(config));
+    const fixer = agents.find((a) => a.name === 'fixer');
+    const librarian = agents.find((a) => a.name === 'librarian');
+
+    expect(librarian?.config.model).toBe('librarian-local-model');
+    expect(fixer?.config.model).toBeUndefined();
+  });
+
+  test('librarian can follow the orchestrator model independently of fixer', () => {
+    const config: PluginConfig = {
+      preset: 'balanced',
+      presets: {
+        balanced: {
+          orchestrator: { model: 'orchestrator-model' },
+        },
+      },
+      agents: {
+        librarian: { inheritModelFrom: 'orchestrator' },
+        fixer: { model: 'fixer-local-model' },
+      },
+    };
+    const agents = createAgents(runtimeFor(config));
+    const librarian = agents.find((a) => a.name === 'librarian');
+    const fixer = agents.find((a) => a.name === 'fixer');
+
+    expect(librarian?.config.model).toBe('orchestrator-model');
+    expect(fixer?.config.model).toBe('fixer-local-model');
+  });
+
+  test('model inheritance works when configured inside a preset', () => {
+    const config: PluginConfig = {
+      preset: 'split',
+      presets: {
+        split: {
+          orchestrator: { model: 'orchestrator-model' },
+          librarian: { model: 'librarian-local-model' },
+          fixer: { inheritModelFrom: 'session' },
+        },
+      },
+    };
+    const agents = createAgents(runtimeFor(config));
+    const fixer = agents.find((a) => a.name === 'fixer');
+    const librarian = agents.find((a) => a.name === 'librarian');
+
+    expect(librarian?.config.model).toBe('librarian-local-model');
+    expect(fixer?.config.model).toBeUndefined();
+  });
+
+  test('root inheritance clears a preset model for the same agent', () => {
+    const config: PluginConfig = {
+      preset: 'split',
+      presets: {
+        split: {
+          orchestrator: { model: 'orchestrator-model' },
+          fixer: { model: 'preset-fixer-model' },
+        },
+      },
+      agents: {
+        fixer: { inheritModelFrom: 'session' },
+      },
+    };
+    const agents = createAgents(runtimeFor(config));
+    const fixer = agents.find((a) => a.name === 'fixer');
+
+    expect(fixer?.config.model).toBeUndefined();
+  });
+
   test('fixer uses its own model when explicitly configured', () => {
     const config: PluginConfig = {
       agents: {
@@ -173,6 +254,85 @@ describe('fixer agent fallback', () => {
     const fixer = agents.find((a) => a.name === 'fixer');
     expect(fixer?.config.model).toBe('fixer-specific-model');
   });
+
+  test('explicit fixer model takes precedence over inheritance policy', () => {
+    const config: PluginConfig = {
+      agents: {
+        librarian: { model: 'librarian-model' },
+        fixer: {
+          model: 'fixer-specific-model',
+          inheritModelFrom: 'session',
+        },
+      },
+    };
+    const agents = createAgents(runtimeFor(config));
+    const fixer = agents.find((a) => a.name === 'fixer');
+
+    expect(fixer?.config.model).toBe('fixer-specific-model');
+  });
+
+  test('custom agents can follow the session model', () => {
+    const config: PluginConfig = {
+      agents: {
+        reviewer: { inheritModelFrom: 'session' },
+      },
+    };
+    const agents = createAgents(runtimeFor(config));
+    const reviewer = agents.find((a) => a.name === 'reviewer');
+
+    expect(reviewer).toBeDefined();
+    expect(reviewer?.config.model).toBeUndefined();
+  });
+
+  test('session inheritance clears a stale host model after config merging', () => {
+    const runtime = runtimeFor({
+      agents: {
+        librarian: { model: 'librarian-local-model' },
+        fixer: { inheritModelFrom: 'session' },
+      },
+    });
+    const configAgent: Record<string, unknown> = {
+      fixer: { model: 'stale-host-model', temperature: 0.2 },
+    };
+
+    applyModelInheritanceToConfig(configAgent, runtime);
+
+    expect(configAgent.fixer).toEqual({ temperature: 0.2 });
+  });
+
+  test('orchestrator inheritance replaces a stale host model', () => {
+    const runtime = runtimeFor({
+      agents: {
+        orchestrator: { model: 'orchestrator-model' },
+        librarian: { inheritModelFrom: 'orchestrator' },
+      },
+    });
+    const configAgent: Record<string, unknown> = {
+      librarian: { model: 'stale-host-model' },
+    };
+
+    applyModelInheritanceToConfig(configAgent, runtime);
+
+    expect(configAgent.librarian).toEqual({ model: 'orchestrator-model' });
+  });
+
+  test('orchestrator inheritance follows the host orchestrator model', () => {
+    const runtime = runtimeFor({
+      agents: {
+        librarian: { inheritModelFrom: 'orchestrator' },
+      },
+    });
+    runtime.captureHostConfig({
+      agent: { orchestrator: { model: 'host-orchestrator-model' } },
+    });
+    const configAgent: Record<string, unknown> = {
+      librarian: { model: 'stale-host-model' },
+    };
+
+    applyModelInheritanceToConfig(configAgent, runtime);
+
+    expect(configAgent.librarian).toEqual({ model: 'host-orchestrator-model' });
+  });
 });
 
 describe('orchestrator agent', () => {
@@ -877,6 +1037,27 @@ describe('options passthrough', () => {
 });
 
 describe('AgentOverrideConfigSchema options validation', () => {
+  test('accepts supported model inheritance sources', () => {
+    expect(
+      AgentOverrideConfigSchema.safeParse({
+        inheritModelFrom: 'session',
+      }).success,
+    ).toBe(true);
+    expect(
+      AgentOverrideConfigSchema.safeParse({
+        inheritModelFrom: 'orchestrator',
+      }).success,
+    ).toBe(true);
+  });
+
+  test('rejects unsupported model inheritance sources', () => {
+    expect(
+      AgentOverrideConfigSchema.safeParse({
+        inheritModelFrom: 'librarian',
+      }).success,
+    ).toBe(false);
+  });
+
   test('accepts valid options object', () => {
     const result = AgentOverrideConfigSchema.safeParse({
       options: { textVerbosity: 'low' },

+ 97 - 12
src/agents/index.ts

@@ -197,6 +197,68 @@ function applyOverrides(
   }
 }
 
+/**
+ * Apply an explicit model inheritance policy after the agent factory has
+ * supplied its built-in fallback model. OpenCode uses the parent session model
+ * when an agent config does not specify `model`.
+ */
+function applyModelInheritance(
+  agent: AgentDefinition,
+  override: AgentOverrideConfig | undefined,
+  orchestratorModel: string | undefined,
+): void {
+  if (override?.model !== undefined) return;
+
+  if (
+    override?.inheritModelFrom === 'session' ||
+    (override?.inheritModelFrom === 'orchestrator' &&
+      orchestratorModel === undefined)
+  ) {
+    delete agent.config.model;
+  }
+}
+
+/**
+ * Apply model inheritance to the final host agent config after the host layer
+ * has been merged. This clears stale host models for `session` inheritance,
+ * which cannot be handled by the agent definition alone.
+ */
+export function applyModelInheritanceToConfig(
+  configAgent: Record<string, unknown>,
+  runtime: RuntimeConfig,
+): void {
+  const mergedAgents = runtime.agents();
+  const orchestratorModel = getPrimaryModelFromOverride(
+    runtime.agent('orchestrator'),
+  );
+
+  for (const agentName of Object.keys(configAgent)) {
+    const override = getOverrideFromAgents(mergedAgents, agentName);
+    if (!override) continue;
+    if (
+      override.model !== undefined ||
+      override.inheritModelFrom === undefined
+    ) {
+      continue;
+    }
+
+    const resolvedName = AGENT_ALIASES[agentName] ?? agentName;
+    const entry = configAgent[resolvedName];
+    if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
+      continue;
+    }
+
+    const agentConfig = entry as Record<string, unknown>;
+    if (override.inheritModelFrom === 'session') {
+      delete agentConfig.model;
+    } else if (orchestratorModel === undefined) {
+      delete agentConfig.model;
+    } else {
+      agentConfig.model = orchestratorModel;
+    }
+  }
+}
+
 function isKnownAgentName(name: string): boolean {
   return (ALL_AGENT_NAMES as readonly string[]).includes(name);
 }
@@ -226,6 +288,7 @@ function buildCustomAgentDefinition(
   override: AgentOverrideConfig,
   filePrompt?: string,
   fileAppendPrompt?: string,
+  fallbackModel?: string,
 ): AgentDefinition {
   const defaultPrompt = appendTaskRejectionInstruction(
     `You are the ${name} specialist.`,
@@ -237,7 +300,7 @@ function buildCustomAgentDefinition(
     name,
     description,
     config: {
-      model: primaryModel ?? DEFAULT_MODELS.oracle,
+      model: primaryModel ?? fallbackModel ?? DEFAULT_MODELS.oracle,
       prompt: resolvePrompt(
         name,
         override.prompt,
@@ -366,14 +429,27 @@ export function createAgents(
   }
 
   const primaryModel = runtime.primaryModel;
+  const orchestratorOverride = getOverrideFromAgents(
+    mergedAgents,
+    'orchestrator',
+  );
+  const configuredOrchestratorModel =
+    getPrimaryModelFromOverride(orchestratorOverride);
 
-  // TEMP: If fixer has no config, inherit from librarian's model to avoid breaking
-  // existing users who don't have fixer in their config yet
+  // Preserve the historical fixer → librarian fallback unless an explicit
+  // inheritance policy opts the fixer into a different source.
   const getModelForAgent = (name: SubagentName): string => {
-    if (
-      name === 'fixer' &&
-      !getOverrideFromAgents(mergedAgents, 'fixer')?.model
-    ) {
+    const override = getOverrideFromAgents(mergedAgents, name);
+    if (override?.model === undefined) {
+      if (override?.inheritModelFrom === 'orchestrator') {
+        return configuredOrchestratorModel ?? (DEFAULT_MODELS[name] as string);
+      }
+      if (override?.inheritModelFrom === 'session') {
+        return primaryModel ?? (DEFAULT_MODELS[name] as string);
+      }
+    }
+
+    if (name === 'fixer' && override?.model === undefined) {
       const librarianOverride = getOverrideFromAgents(
         mergedAgents,
         'librarian',
@@ -439,7 +515,10 @@ export function createAgents(
 
   const protoCustomAgents = customAgentNames.flatMap((name) => {
     const override = getOverrideFromAgents(mergedAgents, name);
-    if (!hasCustomAgentModel(override)) {
+    if (
+      !hasCustomAgentModel(override) &&
+      override?.inheritModelFrom === undefined
+    ) {
       console.warn(
         `[oh-my-opencode] Custom agent '${name}' skipped: 'model' is required`,
       );
@@ -457,6 +536,9 @@ export function createAgents(
         override,
         customPrompts.prompt,
         customPrompts.appendPrompt,
+        override.inheritModelFrom === 'orchestrator'
+          ? configuredOrchestratorModel
+          : primaryModel,
       ),
     ];
   });
@@ -495,6 +577,7 @@ export function createAgents(
     if (override) {
       applyOverrides(agent, override);
     }
+    applyModelInheritance(agent, override, configuredOrchestratorModel);
     applyDefaultPermissions(agent, override?.skills, runtime.disabledSkills);
     return agent;
   });
@@ -504,6 +587,7 @@ export function createAgents(
     if (override) {
       applyOverrides(agent, override);
     }
+    applyModelInheritance(agent, override, configuredOrchestratorModel);
     applyDefaultPermissions(agent, override?.skills, runtime.disabledSkills);
     return agent;
   });
@@ -539,10 +623,6 @@ export function createAgents(
   // 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 = getOverrideFromAgents(
-    mergedAgents,
-    'orchestrator',
-  );
   const orchestratorModel =
     orchestratorOverride?.model ?? DEFAULT_MODELS.orchestrator;
   const orchestratorPrompts = loadAgentPrompt('orchestrator', {
@@ -573,6 +653,11 @@ export function createAgents(
   if (orchestratorOverride) {
     applyOverrides(orchestrator, orchestratorOverride);
   }
+  applyModelInheritance(
+    orchestrator,
+    orchestratorOverride,
+    configuredOrchestratorModel,
+  );
   applyDefaultPermissions(
     orchestrator,
     orchestratorOverride?.skills,

+ 1 - 0
src/config/codemap.md

@@ -170,6 +170,7 @@ This allows consumers to import directly from `src/config` rather than individua
 
 ### AgentOverrideConfig
 - `model`: Model ID or array of model IDs
+- `inheritModelFrom`: Explicitly inherit the current `session` model or the configured `orchestrator` model when `model` is omitted; supported for built-in and custom agents
 - `temperature`: Sampling temperature (0-2)
 - `variant`: Model variant identifier
 - `skills`: Skill allow/deny list ("*" = all, "!item" = exclude)

+ 28 - 2
src/config/runtime.ts

@@ -105,6 +105,32 @@ function primaryModelFromOverride(
   return undefined;
 }
 
+/**
+ * Merge agent layers while allowing an explicit inheritance policy to clear a
+ * model supplied by a lower-precedence layer. A missing `model` normally
+ * means "keep the lower layer", but `inheritModelFrom` is an intentional
+ * request to use another source instead.
+ */
+function mergeAgentOverrides(
+  base: Record<string, AgentOverrideConfig>,
+  override: Record<string, AgentOverrideConfig>,
+): Record<string, AgentOverrideConfig> {
+  const merged = deepMerge(base, override) ?? base;
+  for (const [name, agentOverride] of Object.entries(override)) {
+    if (
+      agentOverride.model !== undefined ||
+      agentOverride.inheritModelFrom === undefined
+    ) {
+      continue;
+    }
+    const entry = merged[name];
+    if (entry) {
+      delete entry.model;
+    }
+  }
+  return merged;
+}
+
 /** Recursive clone of plain JSON data (drops prototypes, no functions). */
 function clonePlain<T>(value: T): T {
   if (Array.isArray(value)) {
@@ -221,13 +247,13 @@ export class RuntimeConfig {
       ? this.pluginConfig.presets?.[this.pluginConfig.preset]
       : undefined;
     if (filePreset) {
-      base = deepMerge(filePreset, base) ?? base;
+      base = mergeAgentOverrides(filePreset, base);
     }
     const runtimePreset = this.runtimePresetAgents();
     if (!runtimePreset) {
       return base;
     }
-    return deepMerge(base, runtimePreset) ?? base;
+    return mergeAgentOverrides(base, runtimePreset);
   }
 
   /**

+ 6 - 0
src/config/schema.ts

@@ -50,6 +50,11 @@ export const PermissionConfigSchema = z.union([
 ]);
 
 // Agent override configuration (distinct from SDK's AgentConfig)
+export const ModelInheritanceSourceSchema = z.enum(['session', 'orchestrator']);
+export type ModelInheritanceSource = z.infer<
+  typeof ModelInheritanceSourceSchema
+>;
+
 export const AgentOverrideConfigSchema = z
   .object({
     model: z
@@ -68,6 +73,7 @@ export const AgentOverrideConfigSchema = z
           .min(1),
       ])
       .optional(),
+    inheritModelFrom: ModelInheritanceSourceSchema.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)

+ 129 - 0
src/index.test.ts

@@ -281,3 +281,132 @@ describe('plugin tool registration', () => {
     }
   });
 });
+
+describe('plugin config model inheritance', () => {
+  let originalEnv: typeof process.env;
+  const configDirs: string[] = [];
+
+  beforeEach(() => {
+    originalEnv = { ...process.env };
+    delete process.env.OH_MY_OPENCODE_SLIM_DISABLE;
+  });
+
+  afterEach(async () => {
+    process.env = originalEnv;
+    while (configDirs.length > 0) {
+      const configDir = configDirs.pop();
+      if (configDir) {
+        await rm(configDir, { recursive: true, force: true });
+      }
+    }
+  });
+
+  async function loadConfiguredPlugin(config: Record<string, unknown>) {
+    const configDir = await mkdtemp('/tmp/oh-my-opencode-inheritance-');
+    configDirs.push(configDir);
+    await Bun.write(
+      `${configDir}/oh-my-opencode-slim.json`,
+      JSON.stringify(config),
+    );
+    process.env = {
+      ...originalEnv,
+      OPENCODE_CONFIG_DIR: configDir,
+      XDG_DATA_HOME: `${configDir}/data`,
+      XDG_CACHE_HOME: `${configDir}/cache`,
+      OPENCODE_LOG_DIR: `${configDir}/logs`,
+    };
+
+    const client = createPluginClient(async () => ({}));
+    return plugin({
+      client,
+      directory: configDir,
+      worktree: configDir,
+      serverUrl: new URL('http://127.0.0.1:4096'),
+    } as never);
+  }
+
+  test('session inheritance removes a stale host model in the final config', async () => {
+    const hooks = await loadConfiguredPlugin({
+      agents: {
+        librarian: { model: 'local/librarian' },
+        fixer: { inheritModelFrom: 'session' },
+      },
+    });
+    const hostConfig: Record<string, unknown> = {
+      agent: {
+        orchestrator: { model: 'host/orchestrator' },
+        fixer: { model: 'host/stale-fixer', temperature: 0.2 },
+      },
+    };
+
+    try {
+      await hooks.config?.(hostConfig);
+
+      const agents = hostConfig.agent as Record<
+        string,
+        Record<string, unknown>
+      >;
+      expect(agents.fixer?.model).toBeUndefined();
+      expect(agents.fixer?.temperature).toBe(0.2);
+    } finally {
+      await hooks.dispose?.();
+    }
+  });
+
+  test('orchestrator inheritance uses the host orchestrator model in the final config', async () => {
+    const hooks = await loadConfiguredPlugin({
+      agents: {
+        librarian: { inheritModelFrom: 'orchestrator' },
+      },
+    });
+    const hostConfig: Record<string, unknown> = {
+      agent: {
+        orchestrator: { model: 'host/orchestrator' },
+        librarian: { model: 'host/stale-librarian' },
+      },
+    };
+
+    try {
+      await hooks.config?.(hostConfig);
+
+      const agents = hostConfig.agent as Record<
+        string,
+        Record<string, unknown>
+      >;
+      expect(agents.librarian?.model).toBe('host/orchestrator');
+    } finally {
+      await hooks.dispose?.();
+    }
+  });
+
+  test('preset inheritance clears a stale host model in the final config', async () => {
+    const hooks = await loadConfiguredPlugin({
+      preset: 'split',
+      presets: {
+        split: {
+          orchestrator: { model: 'preset/orchestrator' },
+          fixer: { inheritModelFrom: 'session' },
+        },
+      },
+    });
+    const hostConfig: Record<string, unknown> = {
+      agent: {
+        orchestrator: { model: 'host/orchestrator' },
+        fixer: { model: 'host/stale-fixer', temperature: 0.4 },
+      },
+    };
+
+    try {
+      await hooks.config?.(hostConfig);
+
+      const agents = hostConfig.agent as Record<
+        string,
+        Record<string, unknown>
+      >;
+      expect(agents.fixer?.model).toBeUndefined();
+      expect(agents.fixer?.temperature).toBe(0.4);
+    } finally {
+      await hooks.dispose?.();
+    }
+  });
+});

+ 7 - 1
src/index.ts

@@ -1,5 +1,10 @@
 import type { Plugin, ToolDefinition } from '@opencode-ai/plugin';
-import { createAgents, getAgentConfigs, isSubagent } from './agents';
+import {
+  applyModelInheritanceToConfig,
+  createAgents,
+  getAgentConfigs,
+  isSubagent,
+} from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
 import { CompanionManager } from './companion/manager';
 import { ensureCompanionVersion } from './companion/updater';
@@ -707,6 +712,7 @@ export const OhMyOpenCodeLite: Plugin = async (ctx) => {
         }
       }
       const configAgent = opencodeConfig.agent as Record<string, unknown>;
+      applyModelInheritanceToConfig(configAgent, runtime);
 
       // Model resolution for foreground agents: use _modelArray entries
       // to pick the first model for startup-time selection.