Browse Source

Merge pull request #365 from alvinunreal/custom-agent-orchestrator-prompt

feat(agents): support custom subagent routing prompts
Alvin 3 months ago
parent
commit
1996a020ec

+ 4 - 0
README.md

@@ -83,6 +83,10 @@ The default generated configuration looks like this:
 
 To use Kimi, GitHub Copilot, ZAI Coding Plan, or a mixed-provider setup, use **[Configuration](docs/configuration.md)** for the full reference. If you want a ready-made starting point, check the **[Author's Preset](docs/authors-preset.md)** and **[$30 Preset](docs/thirty-dollars-preset.md)** - the `$30` preset is the best cheap setup.
 
+The configuration guide also covers custom subagents via `agents.<name>`, where
+you can define both a normal `prompt` and an `orchestratorPrompt` block for
+delegation.
+
 You can also mix and match any models per agent. For model suggestions, see the **Recommended Models** listed under each agent below.
 
 ### ✅ Verify Your Setup

+ 27 - 0
docs/configuration.md

@@ -90,6 +90,9 @@ All config files support **JSONC** (JSON with Comments):
 | `presets.<name>.<agent>.skills` | string[] | — | Skills the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.mcps` | string[] | — | MCPs the agent can use (`"*"`, `"!item"`, explicit list) |
 | `presets.<name>.<agent>.options` | object | — | Provider-specific model options passed to the AI SDK (e.g., `textVerbosity`, `thinking` budget) |
+| `agents.<customAgent>.model` | string\|array | — | Required for custom agents inferred from unknown `agents` keys |
+| `agents.<customAgent>.prompt` | string | — | Full execution prompt for a custom agent |
+| `agents.<customAgent>.orchestratorPrompt` | string | — | Exact `@agent` block injected into the orchestrator prompt; must start with `@<agent-name>` |
 | `agents.<agent>.displayName` | string | — | Custom user-facing alias for the agent in the active config |
 | `showStartupToast` | boolean | `true` | Show the startup activation toast (`oh-my-opencode-slim is active`) when OpenCode starts |
 | `multiplexer.type` | string | `"none"` | Multiplexer mode: `auto`, `tmux`, `zellij`, or `none` |
@@ -159,3 +162,27 @@ Notes:
 - `@` prefixes and surrounding whitespace are normalized automatically
 - Display names must be unique
 - Display names cannot conflict with internal agent names like `oracle` or `explorer`
+
+### Custom Agents
+
+Unknown keys under `agents` are treated as custom subagents. A custom agent needs
+its own `model`, a normal `prompt`, and optionally an `orchestratorPrompt` that
+teaches the orchestrator exactly when to delegate to it.
+
+```jsonc
+{
+  "agents": {
+    "janitor": {
+      "model": "github-copilot/gpt-5.4",
+      "prompt": "You are Janitor. Audit codebase entropy, dead code, docs drift, naming inconsistencies, and unnecessary complexity. Prefer analysis and plans over direct edits.",
+      "orchestratorPrompt": "@janitor\n- Role: Maintenance specialist for codebase cleanup and entropy reduction\n- **Delegate when:** after large refactors • cleanup/technical-debt review • dead code or docs drift is suspected\n- **Don't delegate when:** feature implementation • urgent debugging • UI/UX work"
+    }
+  }
+}
+```
+
+Notes:
+
+- Custom agent names must be safe identifiers such as `janitor` or `security-reviewer`
+- Custom agents without a `model` are skipped with a warning
+- Disabled custom agents are not registered or injected into the orchestrator prompt

+ 22 - 2
oh-my-opencode-slim.schema.json

@@ -218,6 +218,7 @@
                   "type": "string"
                 },
                 {
+                  "minItems": 1,
                   "type": "array",
                   "items": {
                     "anyOf": [
@@ -263,6 +264,14 @@
                 "type": "string"
               }
             },
+            "prompt": {
+              "type": "string",
+              "minLength": 1
+            },
+            "orchestratorPrompt": {
+              "type": "string",
+              "minLength": 1
+            },
             "options": {
               "type": "object",
               "propertyNames": {
@@ -274,7 +283,8 @@
               "type": "string",
               "minLength": 1
             }
-          }
+          },
+          "additionalProperties": false
         }
       }
     },
@@ -292,6 +302,7 @@
                 "type": "string"
               },
               {
+                "minItems": 1,
                 "type": "array",
                 "items": {
                   "anyOf": [
@@ -337,6 +348,14 @@
               "type": "string"
             }
           },
+          "prompt": {
+            "type": "string",
+            "minLength": 1
+          },
+          "orchestratorPrompt": {
+            "type": "string",
+            "minLength": 1
+          },
           "options": {
             "type": "object",
             "propertyNames": {
@@ -348,7 +367,8 @@
             "type": "string",
             "minLength": 1
           }
-        }
+        },
+        "additionalProperties": false
       }
     },
     "disabled_agents": {

+ 128 - 0
src/agents/custom.test.ts

@@ -0,0 +1,128 @@
+import { describe, expect, spyOn, test } from 'bun:test';
+import type { PluginConfig } from '../config';
+import { createAgents, getAgentConfigs } from './index';
+
+describe('custom-agent creation', () => {
+  test('infers custom agents from unknown keys', () => {
+    const config: PluginConfig = {
+      agents: {
+        explorer: { model: 'openai/gpt-5.4-mini' },
+        reviewer: {
+          model: 'openai/gpt-5.4',
+          prompt: 'You are the custom reviewer agent.',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const names = agents.map((agent) => agent.name);
+
+    expect(names).toContain('reviewer');
+
+    const customAgent = agents.find((agent) => agent.name === 'reviewer');
+    expect(customAgent).toBeDefined();
+    expect(customAgent?.config.model).toBe('openai/gpt-5.4');
+    expect(customAgent?.config.prompt).toBe(
+      'You are the custom reviewer agent.',
+    );
+  });
+
+  test('supports prompt and orchestratorPrompt for custom agents', () => {
+    const config: PluginConfig = {
+      agents: {
+        'test-auditor': {
+          model: 'openai/gpt-5.4-mini',
+          prompt: 'You are a custom subagent for auditing.',
+          orchestratorPrompt:
+            '@test-auditor\n- Role: Compliance audit specialist',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const customAgent = agents.find((agent) => agent.name === 'test-auditor');
+
+    expect(customAgent).toBeDefined();
+    expect(customAgent?.config.prompt).toBe(
+      'You are a custom subagent for auditing.',
+    );
+
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
+    expect(orchestrator?.config.prompt).toContain(
+      '@test-auditor\n- Role: Compliance audit specialist',
+    );
+  });
+
+  test('skips custom agents without a model', () => {
+    const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
+
+    try {
+      const config: PluginConfig = {
+        agents: {
+          janitor: {
+            prompt: 'You are Janitor.',
+            orchestratorPrompt: '@janitor\n- Role: Cleanup specialist',
+          },
+        },
+      };
+
+      const agentDefs = createAgents(config);
+      expect(
+        agentDefs.find((agent) => agent.name === 'janitor'),
+      ).toBeUndefined();
+      expect(warnSpy).toHaveBeenCalledWith(
+        "[oh-my-opencode] Custom agent 'janitor' skipped: 'model' is required",
+      );
+    } finally {
+      warnSpy.mockRestore();
+    }
+  });
+
+  test('does not create or inject disabled custom agents', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['test-auditor', 'designer'],
+      agents: {
+        'test-auditor': {
+          model: 'openai/gpt-5.4-mini',
+          prompt: 'You are a disabled custom agent.',
+        },
+      },
+    };
+
+    const agentDefs = createAgents(config);
+    const names = agentDefs.map((agent) => agent.name);
+    expect(names).not.toContain('test-auditor');
+
+    const sdkConfigs = getAgentConfigs(config);
+    expect(sdkConfigs['test-auditor']).toBeUndefined();
+  });
+
+  test('rejects unsafe custom agent names', () => {
+    const config: PluginConfig = {
+      agents: {
+        'unsafe/name': {
+          model: 'openai/gpt-5.4-mini',
+        },
+      },
+    };
+
+    expect(() => createAgents(config)).toThrow();
+  });
+
+  test('accepts arbitrary orchestratorPrompt text for custom agents', () => {
+    const config: PluginConfig = {
+      agents: {
+        janitor: {
+          model: 'openai/gpt-5.4-mini',
+          orchestratorPrompt: '@cleanup\n- Role: Cleanup specialist',
+        },
+      },
+    };
+
+    const agents = createAgents(config);
+    const orchestrator = agents.find((agent) => agent.name === 'orchestrator');
+    expect(orchestrator?.config.prompt).toContain(
+      '@cleanup\n- Role: Cleanup specialist',
+    );
+  });
+});

+ 3 - 3
src/agents/display-name.test.ts

@@ -101,7 +101,7 @@ describe('displayName', () => {
     };
 
     expect(() => createAgents(config)).toThrow(
-      "displayName 'oracle' conflicts with internal agent name",
+      "displayName 'oracle' conflicts with an agent name",
     );
   });
 
@@ -113,7 +113,7 @@ describe('displayName', () => {
     };
 
     expect(() => createAgents(config)).toThrow(
-      "displayName 'oracle' conflicts with internal agent name",
+      "displayName 'oracle' conflicts with an agent name",
     );
   });
 
@@ -125,7 +125,7 @@ describe('displayName', () => {
     };
 
     expect(() => createAgents(config)).toThrow(
-      /displayName.*conflicts with internal agent name/,
+      /displayName.*conflicts with an agent name/,
     );
   });
 

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

@@ -4,6 +4,7 @@ import {
   AgentOverrideConfigSchema,
   DEFAULT_DISABLED_AGENTS,
   DEFAULT_MODELS,
+  PluginConfigSchema,
   SUBAGENT_NAMES,
 } from '../config';
 import {
@@ -536,6 +537,109 @@ describe('AgentOverrideConfigSchema options validation', () => {
     });
     expect(result.success).toBe(false);
   });
+
+  test('rejects empty model arrays', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: [],
+    });
+    expect(result.success).toBe(false);
+  });
+
+  test('accepts prompt and orchestratorPrompt override fields', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.4',
+      prompt: 'You are a specialized reviewer.',
+      orchestratorPrompt: '@reviewer\n- Role: Specialized reviewer',
+    });
+    expect(result.success).toBe(true);
+    if (result.success) {
+      expect(result.data.prompt).toBe('You are a specialized reviewer.');
+      expect(result.data.orchestratorPrompt).toBe(
+        '@reviewer\n- Role: Specialized reviewer',
+      );
+    }
+  });
+
+  test('rejects empty prompt fields', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.4',
+      prompt: '',
+    });
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects empty orchestratorPrompt fields', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.4',
+      orchestratorPrompt: '',
+    });
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects description field on overrides', () => {
+    const result = AgentOverrideConfigSchema.safeParse({
+      model: 'openai/gpt-5.4',
+      description: 'not supported for custom agents',
+    } as Record<string, unknown>);
+    expect(result.success).toBe(false);
+  });
+});
+
+describe('PluginConfigSchema custom-agent-only prompt fields', () => {
+  test('rejects prompt on built-in top-level agent overrides', () => {
+    const result = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: {
+          model: 'openai/gpt-5.4',
+          prompt: 'ignored built-in prompt override',
+        },
+      },
+    });
+
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects orchestratorPrompt on built-in top-level agent overrides', () => {
+    const result = PluginConfigSchema.safeParse({
+      agents: {
+        explorer: {
+          model: 'openai/gpt-5.4-mini',
+          orchestratorPrompt: '@explorer\n- Role: should be invalid here',
+        },
+      },
+    });
+
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects custom-only prompt fields on built-in preset agents', () => {
+    const result = PluginConfigSchema.safeParse({
+      presets: {
+        openai: {
+          oracle: {
+            model: 'openai/gpt-5.4',
+            prompt: 'ignored preset built-in prompt override',
+          },
+        },
+      },
+    });
+
+    expect(result.success).toBe(false);
+  });
+
+  test('allows prompt fields on custom agents', () => {
+    const result = PluginConfigSchema.safeParse({
+      agents: {
+        janitor: {
+          model: 'openai/gpt-5.4-mini',
+          prompt: 'You are Janitor.',
+          orchestratorPrompt: '@janitor\n- Role: Cleanup specialist',
+        },
+      },
+    });
+
+    expect(result.success).toBe(true);
+  });
 });
 
 describe('disabled_agents', () => {
@@ -606,6 +710,20 @@ describe('disabled_agents', () => {
     expect(enabled).toContain('explorer');
   });
 
+  test('getEnabledAgentNames includes enabled custom agents', () => {
+    const config: PluginConfig = {
+      disabled_agents: ['janitor'],
+      agents: {
+        janitor: { model: 'openai/gpt-5.4-mini' },
+        reviewer: { model: 'openai/gpt-5.4-mini' },
+      },
+    };
+
+    const enabled = getEnabledAgentNames(config);
+    expect(enabled).toContain('reviewer');
+    expect(enabled).not.toContain('janitor');
+  });
+
   test('empty disabled_agents creates all agents including observer', () => {
     const config: PluginConfig = {
       disabled_agents: [],

+ 143 - 7
src/agents/index.ts

@@ -6,6 +6,7 @@ import {
   DEFAULT_DISABLED_AGENTS,
   DEFAULT_MODELS,
   getAgentOverride,
+  getCustomAgentNames,
   loadAgentPrompt,
   type PluginConfig,
   PROTECTED_AGENTS,
@@ -21,7 +22,11 @@ import { createFixerAgent } from './fixer';
 import { createLibrarianAgent } from './librarian';
 import { createObserverAgent } from './observer';
 import { createOracleAgent } from './oracle';
-import { type AgentDefinition, createOrchestratorAgent } from './orchestrator';
+import {
+  type AgentDefinition,
+  createOrchestratorAgent,
+  resolvePrompt,
+} from './orchestrator';
 
 export type { AgentDefinition } from './orchestrator';
 
@@ -38,6 +43,10 @@ function normalizeDisplayName(displayName: string): string {
   return trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
 }
 
+function escapeRegExp(value: string): string {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
 // Agent Configuration Helpers
 
 /**
@@ -74,6 +83,51 @@ function applyOverrides(
   }
 }
 
+function isKnownAgentName(name: string): boolean {
+  return (ALL_AGENT_NAMES as readonly string[]).includes(name);
+}
+
+function normalizeCustomAgentName(name: string): string {
+  return name.trim();
+}
+
+function isSafeCustomAgentName(name: string): boolean {
+  return /^[a-z][a-z0-9_-]*$/i.test(name) && !isKnownAgentName(name);
+}
+
+function hasCustomAgentModel(
+  override: AgentOverrideConfig | undefined,
+): override is AgentOverrideConfig & {
+  model: NonNullable<AgentOverrideConfig['model']>;
+} {
+  if (!override?.model) {
+    return false;
+  }
+
+  return !Array.isArray(override.model) || override.model.length > 0;
+}
+
+function buildCustomAgentDefinition(
+  name: string,
+  override: AgentOverrideConfig,
+  filePrompt?: string,
+  fileAppendPrompt?: string,
+): AgentDefinition {
+  const basePrompt = override.prompt ?? `You are the ${name} specialist.`;
+
+  return {
+    name,
+    config: {
+      model:
+        typeof override.model === 'string'
+          ? override.model
+          : (DEFAULT_MODELS.orchestrator ?? DEFAULT_MODELS.oracle),
+      temperature: 0.2,
+      prompt: resolvePrompt(basePrompt, filePrompt, fileAppendPrompt),
+    },
+  } as AgentDefinition;
+}
+
 function injectDisplayNames(
   orchestrator: AgentDefinition,
   nameMap: Map<string, string>,
@@ -84,7 +138,7 @@ function injectDisplayNames(
 
   for (const [internalName, displayName] of nameMap) {
     prompt = prompt.replace(
-      new RegExp(`@${internalName}\\b`, 'g'),
+      new RegExp(`@${escapeRegExp(internalName)}\\b`, 'g'),
       `@${normalizeDisplayName(displayName)}`,
     );
   }
@@ -198,8 +252,43 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
       );
     });
 
-  // 2. Apply overrides and default permissions to each agent
-  const allSubAgents = protoSubAgents.map((agent) => {
+  // 1b. Discover unknown keys in config.agents as custom subagents.
+  const customAgentNames = getCustomAgentNames(config)
+    .map(normalizeCustomAgentName)
+    .filter((name) => name.length > 0)
+    .filter((name) => {
+      if (!isSafeCustomAgentName(name)) {
+        throw new Error(`Unsafe custom agent name '${name}'`);
+      }
+      if (disabled.has(name)) {
+        return false;
+      }
+      return true;
+    });
+
+  const protoCustomAgents = customAgentNames.flatMap((name) => {
+    const override = getAgentOverride(config, name);
+    if (!hasCustomAgentModel(override)) {
+      console.warn(
+        `[oh-my-opencode] Custom agent '${name}' skipped: 'model' is required`,
+      );
+      return [];
+    }
+
+    const customPrompts = loadAgentPrompt(name, config?.preset);
+
+    return [
+      buildCustomAgentDefinition(
+        name,
+        override,
+        customPrompts.prompt,
+        customPrompts.appendPrompt,
+      ),
+    ];
+  });
+
+  // 2. Apply overrides and default permissions to built-in subagents
+  const builtInSubAgents = protoSubAgents.map((agent) => {
     const override = getAgentOverride(config, agent.name);
     if (override) {
       applyOverrides(agent, override);
@@ -208,6 +297,17 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     return agent;
   });
 
+  const customSubAgents = protoCustomAgents.map((agent) => {
+    const override = getAgentOverride(config, agent.name);
+    if (override) {
+      applyOverrides(agent, override);
+    }
+    applyDefaultPermissions(agent, override?.skills);
+    return agent;
+  });
+
+  const allSubAgents = [...builtInSubAgents, ...customSubAgents];
+
   // 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.
@@ -237,6 +337,14 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     }
   }
 
+  // 3b. Append custom orchestrator hints from custom agent overrides.
+  const customOrchestratorPrompts = customSubAgents
+    .map((agent) => {
+      const override = getAgentOverride(config, agent.name);
+      return override?.orchestratorPrompt;
+    })
+    .filter((prompt): prompt is string => Boolean(prompt));
+
   // Validate display names
   const usedDisplayNames = new Set<string>();
   for (const [, displayName] of displayNameMap) {
@@ -249,9 +357,12 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
     usedDisplayNames.add(normalizedDisplayName);
   }
   for (const displayName of usedDisplayNames) {
-    if ((ALL_AGENT_NAMES as readonly string[]).includes(displayName)) {
+    if (
+      (ALL_AGENT_NAMES as readonly string[]).includes(displayName) ||
+      customAgentNames.includes(displayName)
+    ) {
       throw new Error(
-        `displayName '${displayName}' conflicts with internal agent name`,
+        `displayName '${displayName}' conflicts with an agent name`,
       );
     }
   }
@@ -259,6 +370,23 @@ export function createAgents(config?: PluginConfig): AgentDefinition[] {
   // Inject display names into orchestrator prompt (complete map)
   injectDisplayNames(orchestrator, displayNameMap);
 
+  if (customOrchestratorPrompts.length > 0) {
+    const rewrittenPrompts = customOrchestratorPrompts.map((promptText) => {
+      let text = promptText;
+      for (const [internalName, displayName] of displayNameMap) {
+        text = text.replace(
+          new RegExp(`@${escapeRegExp(internalName)}\\b`, 'g'),
+          `@${normalizeDisplayName(displayName)}`,
+        );
+      }
+      return text;
+    });
+
+    orchestrator.config.prompt = `${orchestrator.config.prompt}\n\n${rewrittenPrompts.join(
+      '\n\n',
+    )}`;
+  }
+
   return [orchestrator, ...allSubAgents];
 }
 
@@ -294,6 +422,8 @@ export function getAgentConfigs(
       sdkConfig.mode = 'subagent';
     } else if (name === 'orchestrator') {
       sdkConfig.mode = 'primary';
+    } else {
+      sdkConfig.mode = 'subagent';
     }
   };
 
@@ -355,5 +485,11 @@ export function getDisabledAgents(config?: PluginConfig): Set<string> {
  */
 export function getEnabledAgentNames(config?: PluginConfig): string[] {
   const disabled = getDisabledAgents(config);
-  return ALL_AGENT_NAMES.filter((name) => !disabled.has(name));
+  const customAgentNames = getCustomAgentNames(config).filter(
+    (name) => !disabled.has(name),
+  );
+  return [
+    ...ALL_AGENT_NAMES.filter((name) => !disabled.has(name)),
+    ...customAgentNames,
+  ];
 }

+ 1 - 1
src/config/index.ts

@@ -2,4 +2,4 @@ export * from './constants';
 export * from './council-schema';
 export { loadAgentPrompt, loadPluginConfig } from './loader';
 export * from './schema';
-export { getAgentOverride } from './utils';
+export { getAgentOverride, getCustomAgentNames } from './utils';

+ 20 - 0
src/config/loader.test.ts

@@ -169,6 +169,26 @@ describe('loadPluginConfig', () => {
     expect(loadPluginConfig(projectDir)).toEqual({});
   });
 
+  test('rejects custom-only prompt fields on built-in agents in config files', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        agents: {
+          oracle: {
+            model: 'openai/gpt-5.4',
+            prompt: 'This should be rejected for built-in agents.',
+          },
+        },
+      }),
+    );
+
+    expect(loadPluginConfig(projectDir)).toEqual({});
+  });
+
   test('respects OPENCODE_CONFIG_DIR for user config location', () => {
     const customDir = fs.mkdtempSync(
       path.join(os.tmpdir(), 'omc-opencode-config-'),

+ 108 - 58
src/config/schema.ts

@@ -1,4 +1,5 @@
 import { z } from 'zod';
+import { AGENT_ALIASES, ALL_AGENT_NAMES } from './constants';
 import { CouncilConfigSchema } from './council-schema';
 
 const FALLBACK_AGENT_NAMES = [
@@ -79,28 +80,34 @@ const FallbackChainsSchema = z
 export type FallbackAgentName = (typeof FALLBACK_AGENT_NAMES)[number];
 
 // Agent override configuration (distinct from SDK's AgentConfig)
-export const AgentOverrideConfigSchema = z.object({
-  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)
-  mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
-  options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
-  displayName: z.string().min(1).optional(),
-});
+export const AgentOverrideConfigSchema = z
+  .object({
+    model: z
+      .union([
+        z.string(),
+        z
+          .array(
+            z.union([
+              z.string(),
+              z.object({
+                id: z.string(),
+                variant: z.string().optional(),
+              }),
+            ]),
+          )
+          .min(1),
+      ])
+      .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)
+    mcps: z.array(z.string()).optional(), // MCPs this agent can use ("*" = all, "!item" = exclude)
+    prompt: z.string().min(1).optional(),
+    orchestratorPrompt: z.string().min(1).optional(),
+    options: z.record(z.string(), z.unknown()).optional(), // provider-specific model options (e.g., textVerbosity, thinking budget)
+    displayName: z.string().min(1).optional(),
+  })
+  .strict();
 
 // Multiplexer type options
 export const MultiplexerTypeSchema = z.enum(['auto', 'tmux', 'zellij', 'none']);
@@ -229,42 +236,85 @@ export const FailoverConfigSchema = z.object({
 
 export type FailoverConfig = z.infer<typeof FailoverConfigSchema>;
 
-// Main plugin config
-export const PluginConfigSchema = z.object({
-  preset: z.string().optional(),
-  setDefaultAgent: z.boolean().optional(),
-  scoringEngineVersion: z.enum(['v1', 'v2-shadow', 'v2']).optional(),
-  balanceProviderUsage: z.boolean().optional(),
-  showStartupToast: z
-    .boolean()
-    .optional()
-    .describe(
-      'Show the startup activation toast when OpenCode starts. Defaults to true.',
-    ),
-  manualPlan: ManualPlanSchema.optional(),
-  presets: z.record(z.string(), PresetSchema).optional(),
-  agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
-  disabled_agents: z
-    .array(z.string())
-    .optional()
-    .describe(
-      'Agent names to disable completely. ' +
-        'Disabled agents are not instantiated and cannot be delegated to. ' +
-        'Orchestrator and council internal agents (councillor) cannot be disabled. ' +
-        "By default, 'observer' is disabled. Remove it from this list and configure a vision-capable model to enable.",
-    ),
-  disabled_mcps: z.array(z.string()).optional(),
-  // Multiplexer config (new unified config - preferred)
-  multiplexer: MultiplexerConfigSchema.optional(),
-  // Legacy tmux config (for backward compatibility)
-  // When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
-  tmux: TmuxConfigSchema.optional(),
-  websearch: WebsearchConfigSchema.optional(),
-  interview: InterviewConfigSchema.optional(),
-  todoContinuation: TodoContinuationConfigSchema.optional(),
-  fallback: FailoverConfigSchema.optional(),
-  council: CouncilConfigSchema.optional(),
-});
+function validateCustomOnlyPromptFields(
+  overrides: Record<string, z.infer<typeof AgentOverrideConfigSchema>>,
+  ctx: z.RefinementCtx,
+  pathPrefix: Array<string | number>,
+): void {
+  for (const [name, override] of Object.entries(overrides)) {
+    const isBuiltInOrAlias =
+      (ALL_AGENT_NAMES as readonly string[]).includes(name) ||
+      AGENT_ALIASES[name] !== undefined;
+
+    if (!isBuiltInOrAlias) {
+      continue;
+    }
+
+    if (override.prompt !== undefined) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        path: [...pathPrefix, name, 'prompt'],
+        message: 'prompt is only supported for custom agents',
+      });
+    }
+
+    if (override.orchestratorPrompt !== undefined) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        path: [...pathPrefix, name, 'orchestratorPrompt'],
+        message: 'orchestratorPrompt is only supported for custom agents',
+      });
+    }
+  }
+}
+
+export const PluginConfigSchema = z
+  .object({
+    preset: z.string().optional(),
+    setDefaultAgent: z.boolean().optional(),
+    scoringEngineVersion: z.enum(['v1', 'v2-shadow', 'v2']).optional(),
+    balanceProviderUsage: z.boolean().optional(),
+    showStartupToast: z
+      .boolean()
+      .optional()
+      .describe(
+        'Show the startup activation toast when OpenCode starts. Defaults to true.',
+      ),
+    manualPlan: ManualPlanSchema.optional(),
+    presets: z.record(z.string(), PresetSchema).optional(),
+    agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
+    disabled_agents: z
+      .array(z.string())
+      .optional()
+      .describe(
+        'Agent names to disable completely. ' +
+          'Disabled agents are not instantiated and cannot be delegated to. ' +
+          'Orchestrator and council internal agents (councillor) cannot be disabled. ' +
+          "By default, 'observer' is disabled. Remove it from this list and configure a vision-capable model to enable.",
+      ),
+    disabled_mcps: z.array(z.string()).optional(),
+    // Multiplexer config (new unified config - preferred)
+    multiplexer: MultiplexerConfigSchema.optional(),
+    // Legacy tmux config (for backward compatibility)
+    // When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
+    tmux: TmuxConfigSchema.optional(),
+    websearch: WebsearchConfigSchema.optional(),
+    interview: InterviewConfigSchema.optional(),
+    todoContinuation: TodoContinuationConfigSchema.optional(),
+    fallback: FailoverConfigSchema.optional(),
+    council: CouncilConfigSchema.optional(),
+  })
+  .superRefine((value, ctx) => {
+    if (value.agents) {
+      validateCustomOnlyPromptFields(value.agents, ctx, ['agents']);
+    }
+
+    if (value.presets) {
+      for (const [presetName, preset] of Object.entries(value.presets)) {
+        validateCustomOnlyPromptFields(preset, ctx, ['presets', presetName]);
+      }
+    }
+  });
 
 export type PluginConfig = z.infer<typeof PluginConfigSchema>;
 

+ 66 - 0
src/config/utils.test.ts

@@ -0,0 +1,66 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginConfig } from './schema';
+import { getAgentOverride, getCustomAgentNames } from './utils';
+
+describe('getAgentOverride', () => {
+  test('reads override by explicit custom agent key', () => {
+    const config = {
+      agents: {
+        'custom-reviewer': { model: 'openai/gpt-5.4-mini' },
+      },
+    } as PluginConfig;
+
+    const override = getAgentOverride(config, 'custom-reviewer');
+
+    expect(override).toBeDefined();
+    expect(override?.model).toBe('openai/gpt-5.4-mini');
+  });
+
+  test('reads override from legacy alias when mapped', () => {
+    const config = {
+      agents: {
+        explore: { model: 'openai/gpt-5.4-mini' },
+      },
+    } as PluginConfig;
+
+    const override = getAgentOverride(config, 'explorer');
+
+    expect(override).toBeDefined();
+    expect(override?.model).toBe('openai/gpt-5.4-mini');
+  });
+
+  test('returns undefined when no override exists', () => {
+    const config = {
+      agents: {
+        explorer: { model: 'openai/gpt-5.4-mini' },
+      },
+    } as PluginConfig;
+
+    expect(getAgentOverride(config, 'no-such-agent')).toBeUndefined();
+  });
+});
+
+describe('getCustomAgentNames', () => {
+  test('returns only unknown non-alias agent keys', () => {
+    const config = {
+      agents: {
+        explorer: { model: 'openai/gpt-5.4-mini' },
+        explore: { model: 'openai/gpt-5.4-mini' },
+        janitor: { model: 'openai/gpt-5.4-mini' },
+      },
+    } as PluginConfig;
+
+    expect(getCustomAgentNames(config)).toEqual(['janitor']);
+  });
+
+  test('returns an empty list when no custom agents exist', () => {
+    const config = {
+      agents: {
+        explorer: { model: 'openai/gpt-5.4-mini' },
+        oracle: { model: 'openai/gpt-5.4' },
+      },
+    } as PluginConfig;
+
+    expect(getCustomAgentNames(config)).toEqual([]);
+  });
+});

+ 20 - 1
src/config/utils.ts

@@ -1,4 +1,4 @@
-import { AGENT_ALIASES } from './constants';
+import { AGENT_ALIASES, ALL_AGENT_NAMES } from './constants';
 import type { AgentOverrideConfig, PluginConfig } from './schema';
 
 /**
@@ -21,3 +21,22 @@ export function getAgentOverride(
     ]
   );
 }
+
+/**
+ * Get custom agent names declared in config.agents.
+ *
+ * Custom agents are unknown keys that are neither built-in agent names nor
+ * legacy aliases.
+ */
+export function getCustomAgentNames(
+  config: PluginConfig | undefined,
+): string[] {
+  const overrides = config?.agents ?? {};
+  return Object.keys(overrides).filter((name) => {
+    if (AGENT_ALIASES[name] !== undefined) {
+      return false;
+    }
+
+    return !(ALL_AGENT_NAMES as readonly string[]).includes(name);
+  });
+}

+ 20 - 0
src/utils/agent-variant.test.ts

@@ -204,6 +204,26 @@ describe('rewriteDisplayNameMentions', () => {
       ),
     ).toBe('email foo@advisor.com and ask @oracle directly');
   });
+
+  test('resolves custom agents by displayName for variant/runtime lookups', () => {
+    const config = {
+      agents: {
+        'custom-reviewer': {
+          displayName: 'reviewer',
+          variant: 'high',
+          model: 'openai/gpt-5.4',
+        },
+      },
+    } as PluginConfig;
+
+    expect(resolveRuntimeAgentName(config, '@reviewer')).toBe(
+      'custom-reviewer',
+    );
+    expect(
+      rewriteDisplayNameMentions(config, 'ask @reviewer for details'),
+    ).toBe('ask @custom-reviewer for details');
+    expect(resolveAgentVariant(config, '@reviewer')).toBe('high');
+  });
 });
 
 describe('applyAgentVariant', () => {

+ 11 - 2
src/utils/agent-variant.ts

@@ -1,6 +1,7 @@
 import {
   ALL_AGENT_NAMES,
   getAgentOverride,
+  getCustomAgentNames,
   type PluginConfig,
 } from '../config';
 import { log } from './logger';
@@ -20,6 +21,14 @@ export function normalizeAgentName(agentName: string): string {
   return trimmed.startsWith('@') ? trimmed.slice(1) : trimmed;
 }
 
+function getRuntimeAgentNames(config?: PluginConfig): string[] {
+  const unique = new Set<string>([
+    ...ALL_AGENT_NAMES,
+    ...getCustomAgentNames(config),
+  ]);
+  return [...unique];
+}
+
 /**
  * Resolves the variant configuration for a specific agent.
  *
@@ -77,7 +86,7 @@ export function resolveRuntimeAgentName(
     return normalized;
   }
 
-  for (const internalName of ALL_AGENT_NAMES) {
+  for (const internalName of getRuntimeAgentNames(config)) {
     const displayName = getAgentOverride(config, internalName)?.displayName;
     if (!displayName) {
       continue;
@@ -109,7 +118,7 @@ export function rewriteDisplayNameMentions(
 
   let rewritten = text;
 
-  for (const internalName of ALL_AGENT_NAMES) {
+  for (const internalName of getRuntimeAgentNames(config)) {
     const displayName = getAgentOverride(config, internalName)?.displayName;
     if (!displayName) {
       continue;