diff --git a/src/config-loader.ts b/src/config-loader.ts index 219d283e..60baefdb 100644 --- a/src/config-loader.ts +++ b/src/config-loader.ts @@ -129,6 +129,8 @@ export interface AgentOverride { readonly model?: string; readonly temperature?: number; readonly maxTokens?: number; + readonly steps?: number; + readonly maxSteps?: number; readonly thinking?: { readonly type: string; readonly budgetTokens: number; diff --git a/src/config-schemas.ts b/src/config-schemas.ts index fe9a32df..c8bfb8e9 100644 --- a/src/config-schemas.ts +++ b/src/config-schemas.ts @@ -13,6 +13,8 @@ const AgentOverrideSchema = v.object({ model: v.optional(v.string()), temperature: v.optional(v.number()), maxTokens: v.optional(v.number()), + steps: v.optional(v.number()), + maxSteps: v.optional(v.number()), thinking: v.optional(ThinkingSchema), }); @@ -38,7 +40,7 @@ export const RawMicodeConfigSchema = v.object({ }); // Safe properties that users can override in agent configs -const SAFE_AGENT_PROPERTIES = ["model", "temperature", "maxTokens", "thinking"] as const; +const SAFE_AGENT_PROPERTIES = ["model", "temperature", "maxTokens", "steps", "maxSteps", "thinking"] as const; /** * Validate and sanitize an individual agent override. diff --git a/src/index.ts b/src/index.ts index 82de9e0c..ccb29b62 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ import type { Plugin } from "@opencode-ai/plugin"; -import type { McpLocalConfig } from "@opencode-ai/sdk"; +import type { AgentConfig, McpLocalConfig } from "@opencode-ai/sdk"; import { agents, PRIMARY_AGENT_NAME } from "@/agents"; import { loadMicodeConfig, loadModelContextLimits, mergeAgentConfigs } from "@/config-loader"; @@ -107,6 +107,22 @@ function extractTextFromParts(parts: Array<{ type: string; text?: string }>): st .join(""); } +export function mergePluginAgentConfig(existingAgent: AgentConfig | undefined, pluginAgent: AgentConfig): AgentConfig { + return { + ...existingAgent, + ...pluginAgent, + }; +} + +export function mergePluginAgents( + existingAgents: Record | undefined, + pluginAgents: Record, +): Record { + return Object.fromEntries( + Object.entries(pluginAgents).map(([name, agent]) => [name, mergePluginAgentConfig(existingAgents?.[name], agent)]), + ) as Record; +} + // eslint-disable-next-line max-lines-per-function const OpenCodeConfigPlugin: Plugin = async (ctx) => { // Validate external tool dependencies at startup @@ -299,6 +315,7 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => { // Merge user config overrides into plugin agents const mergedAgents = mergeAgentConfigs(agents, userConfig); + const pluginAgents = mergePluginAgents(config.agent, mergedAgents); // Add our agents - our agents override OpenCode defaults, demote built-in build/plan to subagent config.agent = { @@ -307,9 +324,9 @@ const OpenCodeConfigPlugin: Plugin = async (ctx) => { plan: { ...config.agent?.plan, mode: "subagent" }, triage: { ...config.agent?.triage, mode: "subagent" }, docs: { ...config.agent?.docs, mode: "subagent" }, - // Our agents override - spread these LAST so they take precedence - ...Object.fromEntries(Object.entries(mergedAgents).filter(([k]) => k !== PRIMARY_AGENT_NAME)), - [PRIMARY_AGENT_NAME]: mergedAgents[PRIMARY_AGENT_NAME], + // Our agents override OpenCode defaults while preserving user fields like steps/maxSteps. + ...Object.fromEntries(Object.entries(pluginAgents).filter(([k]) => k !== PRIMARY_AGENT_NAME)), + [PRIMARY_AGENT_NAME]: pluginAgents[PRIMARY_AGENT_NAME], }; // Add MCP servers (plugin servers override defaults) diff --git a/tests/config-loader.test.ts b/tests/config-loader.test.ts index 3d9f5e3e..9a2594bd 100644 --- a/tests/config-loader.test.ts +++ b/tests/config-loader.test.ts @@ -71,7 +71,7 @@ describe("config-loader", () => { expect(config?.agents).toEqual({}); }); - it("should only allow safe properties (model, temperature, maxTokens)", async () => { + it("should only allow safe properties for agent overrides", async () => { const configPath = join(testConfigDir, "micode.json"); writeFileSync( configPath, @@ -81,6 +81,8 @@ describe("config-loader", () => { model: "openai/gpt-4o", temperature: 0.3, maxTokens: 8000, + steps: 15, + maxSteps: 20, prompt: "MALICIOUS PROMPT", // Should be filtered tools: { bash: true }, // Should be filtered }, @@ -94,6 +96,8 @@ describe("config-loader", () => { expect(config?.agents?.commander?.model).toBe("openai/gpt-4o"); expect(config?.agents?.commander?.temperature).toBe(0.3); expect(config?.agents?.commander?.maxTokens).toBe(8000); + expect(config?.agents?.commander?.steps).toBe(15); + expect(config?.agents?.commander?.maxSteps).toBe(20); // These should be filtered out expect((config?.agents?.commander as Record)?.prompt).toBeUndefined(); expect((config?.agents?.commander as Record)?.tools).toBeUndefined(); diff --git a/tests/index.test.ts b/tests/index.test.ts index c3d9a4d4..bcca6dd0 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -2,6 +2,36 @@ import { describe, expect, it } from "bun:test"; import { readFile } from "node:fs/promises"; +import { mergePluginAgents } from "../src/index"; + +describe("mergePluginAgents", () => { + it("preserves user iteration limits from existing OpenCode agent config", () => { + const merged = mergePluginAgents( + { + planner: { + model: "user/model", + mode: "primary", + steps: 15, + maxSteps: 20, + }, + }, + { + planner: { + model: "plugin/model", + mode: "subagent", + prompt: "Planner prompt", + }, + }, + ); + + expect(merged.planner.model).toBe("plugin/model"); + expect(merged.planner.mode).toBe("subagent"); + expect(merged.planner.prompt).toBe("Planner prompt"); + expect(merged.planner.steps).toBe(15); + expect(merged.planner.maxSteps).toBe(20); + }); +}); + describe("index.ts constraint-reviewer integration", () => { it("should import createConstraintReviewerHook", async () => { const source = await readFile("src/index.ts", "utf-8");