Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/config-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});

Expand All @@ -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.
Expand Down
25 changes: 21 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<string, AgentConfig | undefined> | undefined,
pluginAgents: Record<string, AgentConfig>,
): Record<string, AgentConfig> {
return Object.fromEntries(
Object.entries(pluginAgents).map(([name, agent]) => [name, mergePluginAgentConfig(existingAgents?.[name], agent)]),
) as Record<string, AgentConfig>;
}

// eslint-disable-next-line max-lines-per-function
const OpenCodeConfigPlugin: Plugin = async (ctx) => {
// Validate external tool dependencies at startup
Expand Down Expand Up @@ -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 = {
Expand All @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion tests/config-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
},
Expand All @@ -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<string, unknown>)?.prompt).toBeUndefined();
expect((config?.agents?.commander as Record<string, unknown>)?.tools).toBeUndefined();
Expand Down
30 changes: 30 additions & 0 deletions tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down