Browse Source

feat: add per-agent model variant support (#24)

Add configurable reasoning variants (low/medium/high) per agent via plugin
config. Variants are injected into SDK prompt requests when spawning
background or sync agent sessions.

Changes:
- Add variant field to agent override config schema
- Implement normalizeAgentName, resolveAgentVariant, applyAgentVariant utils
- Inject variants into both async (BackgroundTaskManager) and sync paths
- Add debug logging for variant resolution and application
- Add unit tests for variant utilities (18 tests)
- Document agent variants in README

Config example:
  {
    "agents": {
      "oracle": { "variant": "high" },
      "explore": { "variant": "low" }
    }
  }
Ivan Marshall Widjaja 2 months ago
parent
commit
b5cff1ecd4

+ 4 - 0
.gitignore

@@ -8,6 +8,7 @@ dist/
 .env
 .env.local
 .env.*.local
+.ignore
 
 # Editor/IDE
 .vscode/
@@ -36,3 +37,6 @@ coverage/
 tmp/
 temp/
 local
+
+.sisyphus/
+.hive/

+ 25 - 0
README.md

@@ -41,6 +41,8 @@
   - [Playwright Integration](#playwright-integration)
 - [🔌 **MCP Servers**](#mcp-servers)
 - [⚙️ **Configuration**](#configuration)
+  - [Disabling Agents](#disabling-agents)
+  - [Agent Variants](#agent-variants)
 - [🗑️ **Uninstallation**](#uninstallation)
 
 ---
@@ -535,6 +537,29 @@ You can disable specific agents using the `disabled_agents` array:
 }
 ```
 
+### Agent Variants
+
+You can configure model reasoning variants per agent. Variants control the model's reasoning effort level when spawning background/subagent sessions.
+
+```json
+{
+  "agents": {
+    "oracle": {
+      "variant": "high"
+    },
+    "explore": {
+      "variant": "low"
+    }
+  }
+}
+```
+
+| Option | Type | Values | Description |
+|--------|------|--------|-------------|
+| `agents.<name>.variant` | string | `"low"`, `"medium"`, `"high"` | Reasoning effort level for this agent |
+
+Variants are applied when the plugin launches background tasks or sync agent calls.
+
 ---
 
 ## Uninstallation

+ 1 - 0
src/config/schema.ts

@@ -6,6 +6,7 @@ export const AgentOverrideConfigSchema = z.object({
   temperature: z.number().min(0).max(2).optional(),
   prompt: z.string().optional(),
   prompt_append: z.string().optional(),
+  variant: z.string().optional().catch(undefined),
   disable: z.boolean().optional(),
 });
 

+ 35 - 6
src/features/background-manager.ts

@@ -1,6 +1,19 @@
 import type { PluginInput } from "@opencode-ai/plugin";
 import { POLL_INTERVAL_BACKGROUND_MS, POLL_INTERVAL_SLOW_MS } from "../config";
 import type { TmuxConfig } from "../config/schema";
+import type { PluginConfig } from "../config";
+import { applyAgentVariant, resolveAgentVariant } from "../utils";
+import { log } from "../shared/logger";
+type PromptBody = {
+  messageID?: string;
+  model?: { providerID: string; modelID: string };
+  agent?: string;
+  noReply?: boolean;
+  system?: string;
+  tools?: { [key: string]: boolean };
+  parts: Array<{ type: "text"; text: string }>;
+  variant?: string;
+};
 
 type OpencodeClient = PluginInput["client"];
 
@@ -34,11 +47,13 @@ export class BackgroundTaskManager {
   private directory: string;
   private pollInterval?: ReturnType<typeof setInterval>;
   private tmuxEnabled: boolean;
+  private config?: PluginConfig;
 
-  constructor(ctx: PluginInput, tmuxConfig?: TmuxConfig) {
+  constructor(ctx: PluginInput, tmuxConfig?: TmuxConfig, config?: PluginConfig) {
     this.client = ctx.client;
     this.directory = ctx.directory;
     this.tmuxEnabled = tmuxConfig?.enabled ?? false;
+    this.config = config;
   }
 
   async launch(opts: LaunchOptions): Promise<BackgroundTask> {
@@ -79,13 +94,27 @@ export class BackgroundTaskManager {
       promptQuery.model = opts.model;
     }
 
+    log(`[background-manager] launching task for agent="${opts.agent}"`, { description: opts.description });
+    const resolvedVariant = resolveAgentVariant(this.config, opts.agent);
+    const promptBody = applyAgentVariant(resolvedVariant, {
+      agent: opts.agent,
+      tools: { background_task: false, task: false },
+      parts: [{ type: "text" as const, text: opts.prompt }],
+    } as PromptBody) as unknown as {
+      messageID?: string;
+      model?: { providerID: string; modelID: string };
+      agent?: string;
+      noReply?: boolean;
+      system?: string;
+      tools?: { [key: string]: boolean };
+      parts: Array<{ type: "text"; text: string }>;
+      variant?: string;
+    };
+
+
     await this.client.session.prompt({
       path: { id: session.data.id },
-      body: {
-        agent: opts.agent,
-        tools: { background_task: false, task: false },
-        parts: [{ type: "text", text: opts.prompt }],
-      },
+      body: promptBody,
       query: promptQuery,
     });
 

+ 2 - 2
src/index.ts

@@ -42,8 +42,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     startTmuxCheck();
   }
 
-  const backgroundManager = new BackgroundTaskManager(ctx, tmuxConfig);
-  const backgroundTools = createBackgroundTools(ctx, backgroundManager, tmuxConfig);
+  const backgroundManager = new BackgroundTaskManager(ctx, tmuxConfig, config);
+  const backgroundTools = createBackgroundTools(ctx, backgroundManager, tmuxConfig, config);
   const mcps = createBuiltinMcps(config.disabled_mcps);
   const skillMcpManager = SkillMcpManager.getInstance();
   const skillTools = createSkillTools(skillMcpManager);

+ 25 - 7
src/tools/background.ts

@@ -8,6 +8,9 @@ import {
   STABLE_POLLS_THRESHOLD,
 } from "../config";
 import type { TmuxConfig } from "../config/schema";
+import type { PluginConfig } from "../config";
+import { applyAgentVariant, resolveAgentVariant } from "../utils";
+import { log } from "../shared/logger";
 
 const z = tool.schema;
 
@@ -21,7 +24,8 @@ type ToolContext = {
 export function createBackgroundTools(
   ctx: PluginInput,
   manager: BackgroundTaskManager,
-  tmuxConfig?: TmuxConfig
+  tmuxConfig?: TmuxConfig,
+  pluginConfig?: PluginConfig
 ): Record<string, ToolDefinition> {
   const agentNames = getAgentNames().join(", ");
 
@@ -47,7 +51,7 @@ Sync mode blocks until completion and returns the result directly.`,
       const isSync = args.sync === true;
 
       if (isSync) {
-        return await executeSync(description, prompt, agent, tctx, ctx, tmuxConfig, args.session_id as string | undefined);
+        return await executeSync(description, prompt, agent, tctx, ctx, tmuxConfig, pluginConfig, args.session_id as string | undefined);
       }
 
       const task = await manager.launch({
@@ -140,6 +144,7 @@ async function executeSync(
   toolContext: ToolContext,
   ctx: PluginInput,
   tmuxConfig?: TmuxConfig,
+  pluginConfig?: PluginConfig,
   existingSessionId?: string
 ): Promise<string> {
   let sessionID: string;
@@ -175,14 +180,27 @@ async function executeSync(
   }
 
   // Disable recursive delegation tools to prevent infinite loops
+  log(`[background-sync] launching sync task for agent="${agent}"`, { description });
+  const resolvedVariant = resolveAgentVariant(pluginConfig, agent);
+
+  type PromptBody = {
+    agent: string;
+    tools: { background_task: boolean; task: boolean };
+    parts: Array<{ type: "text"; text: string }>;
+    variant?: string;
+  };
+
+  const baseBody: PromptBody = {
+    agent,
+    tools: { background_task: false, task: false },
+    parts: [{ type: "text" as const, text: prompt }],
+  };
+  const promptBody = applyAgentVariant(resolvedVariant, baseBody);
+
   try {
     await ctx.client.session.prompt({
       path: { id: sessionID },
-      body: {
-        agent,
-        tools: { background_task: false, task: false },
-        parts: [{ type: "text", text: prompt }],
-      },
+      body: promptBody,
     });
   } catch (error) {
     return `Error: Failed to send prompt: ${error instanceof Error ? error.message : String(error)}

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

@@ -0,0 +1,140 @@
+import { describe, expect, test } from "bun:test";
+import {
+  normalizeAgentName,
+  resolveAgentVariant,
+  applyAgentVariant,
+} from "./agent-variant";
+import type { PluginConfig } from "../config";
+
+describe("normalizeAgentName", () => {
+  test("returns name unchanged if no @ prefix", () => {
+    expect(normalizeAgentName("oracle")).toBe("oracle");
+  });
+
+  test("strips @ prefix from agent name", () => {
+    expect(normalizeAgentName("@oracle")).toBe("oracle");
+  });
+
+  test("trims whitespace", () => {
+    expect(normalizeAgentName("  oracle  ")).toBe("oracle");
+  });
+
+  test("handles @ prefix with whitespace", () => {
+    expect(normalizeAgentName("  @explore  ")).toBe("explore");
+  });
+
+  test("handles empty string", () => {
+    expect(normalizeAgentName("")).toBe("");
+  });
+});
+
+describe("resolveAgentVariant", () => {
+  test("returns undefined when config is undefined", () => {
+    expect(resolveAgentVariant(undefined, "oracle")).toBeUndefined();
+  });
+
+  test("returns undefined when agents is undefined", () => {
+    const config = {} as PluginConfig;
+    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+  });
+
+  test("returns undefined when agent has no variant", () => {
+    const config = {
+      agents: {
+        oracle: { model: "gpt-4" },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+  });
+
+  test("returns variant when configured", () => {
+    const config = {
+      agents: {
+        oracle: { variant: "high" },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, "oracle")).toBe("high");
+  });
+
+  test("normalizes agent name with @ prefix", () => {
+    const config = {
+      agents: {
+        oracle: { variant: "low" },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, "@oracle")).toBe("low");
+  });
+
+  test("returns undefined for empty string variant", () => {
+    const config = {
+      agents: {
+        oracle: { variant: "" },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+  });
+
+  test("returns undefined for whitespace-only variant", () => {
+    const config = {
+      agents: {
+        oracle: { variant: "   " },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+  });
+
+  test("trims variant whitespace", () => {
+    const config = {
+      agents: {
+        oracle: { variant: "  medium  " },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, "oracle")).toBe("medium");
+  });
+
+  test("returns undefined for non-string variant", () => {
+    const config = {
+      agents: {
+        oracle: { variant: 123 as unknown as string },
+      },
+    } as PluginConfig;
+    expect(resolveAgentVariant(config, "oracle")).toBeUndefined();
+  });
+});
+
+describe("applyAgentVariant", () => {
+  test("returns body unchanged when variant is undefined", () => {
+    const body = { agent: "oracle", parts: [] };
+    const result = applyAgentVariant(undefined, body);
+    expect(result).toEqual(body);
+    expect(result).toBe(body); // Same reference
+  });
+
+  test("returns body unchanged when body already has variant", () => {
+    const body = { agent: "oracle", variant: "medium", parts: [] };
+    const result = applyAgentVariant("high", body);
+    expect(result.variant).toBe("medium");
+    expect(result).toBe(body); // Same reference
+  });
+
+  test("applies variant to body without variant", () => {
+    const body = { agent: "oracle", parts: [] };
+    const result = applyAgentVariant("high", body);
+    expect(result.variant).toBe("high");
+    expect(result.agent).toBe("oracle");
+    expect(result).not.toBe(body); // New object
+  });
+
+  test("preserves all existing body properties", () => {
+    const body = {
+      agent: "oracle",
+      parts: [{ type: "text" as const, text: "hello" }],
+      tools: { background_task: false },
+    };
+    const result = applyAgentVariant("low", body);
+    expect(result.agent).toBe("oracle");
+    expect(result.parts).toEqual([{ type: "text", text: "hello" }]);
+    expect(result.tools).toEqual({ background_task: false });
+    expect(result.variant).toBe("low");
+  });
+});

+ 45 - 0
src/utils/agent-variant.ts

@@ -0,0 +1,45 @@
+import type { PluginConfig } from "../config";
+import { log } from "../shared/logger";
+
+export function normalizeAgentName(agentName: string): string {
+  const trimmed = agentName.trim();
+  return trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
+}
+
+export function resolveAgentVariant(
+  config: PluginConfig | undefined,
+  agentName: string
+): string | undefined {
+  const normalized = normalizeAgentName(agentName);
+  const rawVariant = config?.agents?.[normalized]?.variant;
+
+  if (typeof rawVariant !== "string") {
+    log(`[variant] no variant configured for agent "${normalized}"`);
+    return undefined;
+  }
+
+  const trimmed = rawVariant.trim();
+  if (trimmed.length === 0) {
+    log(`[variant] empty variant for agent "${normalized}" (ignored)`);
+    return undefined;
+  }
+
+  log(`[variant] resolved variant="${trimmed}" for agent "${normalized}"`);
+  return trimmed;
+}
+
+export function applyAgentVariant<T extends { variant?: string }>(
+  variant: string | undefined,
+  body: T
+): T {
+  if (!variant) {
+    log("[variant] no variant to apply (skipped)");
+    return body;
+  }
+  if (body.variant) {
+    log(`[variant] body already has variant="${body.variant}" (not overriding)`);
+    return body;
+  }
+  log(`[variant] applied variant="${variant}" to prompt body`);
+  return { ...body, variant };
+}

+ 1 - 0
src/utils/index.ts

@@ -1,2 +1,3 @@
 export * from "./polling";
 export * from "./tmux";
+export * from "./agent-variant";