From 0fada4d0fcdebb7b69f7cf525478f8e372011861 Mon Sep 17 00:00:00 2001 From: Ivan Marshall Widjaja <60992624+imarshallwidjaja@users.noreply.github.com> Date: Mon, 12 Jan 2026 19:46:47 +1100 Subject: [PATCH] fix(config): allow Sisyphus-Junior agent customization via oh-my-opencode.json (#648) * fix(config): allow Sisyphus-Junior agent customization via oh-my-opencode.json Allow users to configure Sisyphus-Junior agent via agents["Sisyphus-Junior"] in oh-my-opencode.json, removing hardcoded defaults while preserving safety constraints. Closes #623 Changes: - Add "Sisyphus-Junior" to AgentOverridesSchema and OverridableAgentNameSchema - Create createSisyphusJuniorAgentWithOverrides() helper with guardrails - Update config-handler to use override helper instead of hardcoded values - Fix README category wording (runtime presets, not separate agents) Honored override fields: - model, temperature, top_p, tools, permission, description, color, prompt_append Safety guardrails enforced post-merge: - mode forced to "subagent" (cannot change) - prompt is append-only (base discipline text preserved) - blocked tools (task, sisyphus_task, call_omo_agent) always denied - disable: true ignores override block, uses defaults Category interaction: - sisyphus_task(category=...) runs use the base Sisyphus-Junior agent config - Category model/temperature overrides take precedence at request time - To change model for a category, set categories..model (not agent override) - Categories are runtime presets applied to Sisyphus-Junior, not separate agents Tests: 15 new tests in sisyphus-junior.test.ts, 3 new schema tests Co-Authored-By: Sisyphus * test(sisyphus-junior): add guard assertion for prompt anchor text Add validation that baseEndIndex is not -1 before using it for ordering assertion. Previously, if "Dense > verbose." text changed in the base prompt, indexOf would return -1 and any positive appendIndex would pass. Co-Authored-By: Sisyphus --------- Co-authored-by: Sisyphus --- README.md | 2 +- assets/oh-my-opencode.schema.json | 123 ++++++++++++++ src/agents/sisyphus-junior.test.ts | 230 ++++++++++++++++++++++++++ src/agents/sisyphus-junior.ts | 68 +++++++- src/config/schema.test.ts | 73 ++++++++ src/config/schema.ts | 2 + src/plugin-handlers/config-handler.ts | 9 +- src/shared/permission-compat.ts | 2 + 8 files changed, 502 insertions(+), 7 deletions(-) create mode 100644 src/agents/sisyphus-junior.test.ts diff --git a/README.md b/README.md index adcd8ef0..b71ef41d 100644 --- a/README.md +++ b/README.md @@ -1058,7 +1058,7 @@ Configure concurrency limits for background agent tasks. This controls how many ### Categories -Categories enable domain-specific task delegation via the `sisyphus_task` tool. Each category pre-configures a specialized `Sisyphus-Junior-{category}` agent with optimized model settings and prompts. +Categories enable domain-specific task delegation via the `sisyphus_task` tool. Each category applies runtime presets (model, temperature, prompt additions) when calling the `Sisyphus-Junior` agent. **Default Categories:** diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 327c2d91..02c8aa0d 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -465,6 +465,129 @@ } } }, + "Sisyphus-Junior": { + "type": "object", + "properties": { + "model": { + "type": "string" + }, + "category": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + } + }, + "temperature": { + "type": "number", + "minimum": 0, + "maximum": 2 + }, + "top_p": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "prompt": { + "type": "string" + }, + "prompt_append": { + "type": "string" + }, + "tools": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "disable": { + "type": "boolean" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "subagent", + "primary", + "all" + ] + }, + "color": { + "type": "string", + "pattern": "^#[0-9A-Fa-f]{6}$" + }, + "permission": { + "type": "object", + "properties": { + "edit": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "bash": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + ] + }, + "webfetch": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "doom_loop": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + }, + "external_directory": { + "type": "string", + "enum": [ + "ask", + "allow", + "deny" + ] + } + } + } + } + }, "OpenCode-Builder": { "type": "object", "properties": { diff --git a/src/agents/sisyphus-junior.test.ts b/src/agents/sisyphus-junior.test.ts new file mode 100644 index 00000000..b6e00b28 --- /dev/null +++ b/src/agents/sisyphus-junior.test.ts @@ -0,0 +1,230 @@ +import { describe, expect, test } from "bun:test" +import { createSisyphusJuniorAgentWithOverrides, SISYPHUS_JUNIOR_DEFAULTS } from "./sisyphus-junior" + +describe("createSisyphusJuniorAgentWithOverrides", () => { + describe("honored fields", () => { + test("applies model override", () => { + // #given + const override = { model: "openai/gpt-5.2" } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.model).toBe("openai/gpt-5.2") + }) + + test("applies temperature override", () => { + // #given + const override = { temperature: 0.5 } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.temperature).toBe(0.5) + }) + + test("applies top_p override", () => { + // #given + const override = { top_p: 0.9 } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.top_p).toBe(0.9) + }) + + test("applies description override", () => { + // #given + const override = { description: "Custom description" } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.description).toBe("Custom description") + }) + + test("applies color override", () => { + // #given + const override = { color: "#FF0000" } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.color).toBe("#FF0000") + }) + + test("appends prompt_append to base prompt", () => { + // #given + const override = { prompt_append: "Extra instructions here" } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.prompt).toContain("You work ALONE") + expect(result.prompt).toContain("Extra instructions here") + }) + }) + + describe("defaults", () => { + test("uses default model when no override", () => { + // #given + const override = {} + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.model).toBe(SISYPHUS_JUNIOR_DEFAULTS.model) + }) + + test("uses default temperature when no override", () => { + // #given + const override = {} + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.temperature).toBe(SISYPHUS_JUNIOR_DEFAULTS.temperature) + }) + }) + + describe("disable semantics", () => { + test("disable: true causes override block to be ignored", () => { + // #given + const override = { + disable: true, + model: "openai/gpt-5.2", + temperature: 0.9, + } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then - defaults should be used, not the overrides + expect(result.model).toBe(SISYPHUS_JUNIOR_DEFAULTS.model) + expect(result.temperature).toBe(SISYPHUS_JUNIOR_DEFAULTS.temperature) + }) + }) + + describe("constrained fields", () => { + test("mode is forced to subagent", () => { + // #given + const override = { mode: "primary" as const } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.mode).toBe("subagent") + }) + + test("prompt override is ignored (discipline text preserved)", () => { + // #given + const override = { prompt: "Completely new prompt that replaces everything" } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.prompt).toContain("You work ALONE") + expect(result.prompt).not.toBe("Completely new prompt that replaces everything") + }) + }) + + describe("tool safety (blocked tools enforcement)", () => { + test("blocked tools remain blocked even if override tries to enable them via tools format", () => { + // #given + const override = { + tools: { + task: true, + sisyphus_task: true, + call_omo_agent: true, + read: true, + }, + } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + const tools = result.tools as Record | undefined + const permission = result.permission as Record | undefined + if (tools) { + expect(tools.task).toBe(false) + expect(tools.sisyphus_task).toBe(false) + expect(tools.call_omo_agent).toBe(false) + expect(tools.read).toBe(true) + } + if (permission) { + expect(permission.task).toBe("deny") + expect(permission.sisyphus_task).toBe("deny") + expect(permission.call_omo_agent).toBe("deny") + } + }) + + test("blocked tools remain blocked when using permission format override", () => { + // #given + const override = { + permission: { + task: "allow", + sisyphus_task: "allow", + call_omo_agent: "allow", + read: "allow", + }, + } as { permission: Record } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override as Parameters[0]) + + // #then - blocked tools should be denied regardless + const tools = result.tools as Record | undefined + const permission = result.permission as Record | undefined + if (tools) { + expect(tools.task).toBe(false) + expect(tools.sisyphus_task).toBe(false) + expect(tools.call_omo_agent).toBe(false) + } + if (permission) { + expect(permission.task).toBe("deny") + expect(permission.sisyphus_task).toBe("deny") + expect(permission.call_omo_agent).toBe("deny") + } + }) + }) + + describe("prompt composition", () => { + test("base prompt contains discipline constraints", () => { + // #given + const override = {} + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + expect(result.prompt).toContain("Sisyphus-Junior") + expect(result.prompt).toContain("You work ALONE") + expect(result.prompt).toContain("BLOCKED ACTIONS") + }) + + test("prompt_append is added after base prompt", () => { + // #given + const override = { prompt_append: "CUSTOM_MARKER_FOR_TEST" } + + // #when + const result = createSisyphusJuniorAgentWithOverrides(override) + + // #then + const baseEndIndex = result.prompt!.indexOf("Dense > verbose.") + const appendIndex = result.prompt!.indexOf("CUSTOM_MARKER_FOR_TEST") + expect(baseEndIndex).not.toBe(-1) // Guard: anchor text must exist in base prompt + expect(appendIndex).toBeGreaterThan(baseEndIndex) + }) + }) +}) diff --git a/src/agents/sisyphus-junior.ts b/src/agents/sisyphus-junior.ts index 13568225..affbe49c 100644 --- a/src/agents/sisyphus-junior.ts +++ b/src/agents/sisyphus-junior.ts @@ -1,9 +1,10 @@ import type { AgentConfig } from "@opencode-ai/sdk" import { isGptModel } from "./types" -import type { CategoryConfig } from "../config/schema" +import type { AgentOverrideConfig, CategoryConfig } from "../config/schema" import { createAgentToolRestrictions, migrateAgentConfig, + supportsNewPermissionSystem, } from "../shared/permission-compat" const SISYPHUS_JUNIOR_PROMPT = ` @@ -77,6 +78,71 @@ function buildSisyphusJuniorPrompt(promptAppend?: string): string { // Core tools that Sisyphus-Junior must NEVER have access to const BLOCKED_TOOLS = ["task", "sisyphus_task", "call_omo_agent"] +export const SISYPHUS_JUNIOR_DEFAULTS = { + model: "anthropic/claude-sonnet-4-5", + temperature: 0.1, +} as const + +export function createSisyphusJuniorAgentWithOverrides( + override: AgentOverrideConfig | undefined +): AgentConfig { + if (override?.disable) { + override = undefined + } + + const model = override?.model ?? SISYPHUS_JUNIOR_DEFAULTS.model + const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature + + const promptAppend = override?.prompt_append + const prompt = buildSisyphusJuniorPrompt(promptAppend) + + const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS) + + let toolsConfig: Record = {} + if (supportsNewPermissionSystem()) { + const userPermission = (override?.permission ?? {}) as Record + const basePermission = (baseRestrictions as { permission: Record }).permission + const merged: Record = { ...userPermission } + for (const tool of BLOCKED_TOOLS) { + merged[tool] = "deny" + } + toolsConfig = { permission: { ...merged, ...basePermission } } + } else { + const userTools = override?.tools ?? {} + const baseTools = (baseRestrictions as { tools: Record }).tools + const merged: Record = { ...userTools } + for (const tool of BLOCKED_TOOLS) { + merged[tool] = false + } + toolsConfig = { tools: { ...merged, ...baseTools } } + } + + const base: AgentConfig = { + description: override?.description ?? + "Sisyphus-Junior - Focused task executor. Same discipline, no delegation.", + mode: "subagent" as const, + model, + temperature, + maxTokens: 64000, + prompt, + color: override?.color ?? "#20B2AA", + ...toolsConfig, + } + + if (override?.top_p !== undefined) { + base.top_p = override.top_p + } + + if (isGptModel(model)) { + return { ...base, reasoningEffort: "medium" } as AgentConfig + } + + return { + ...base, + thinking: { type: "enabled", budgetTokens: 32000 }, + } as AgentConfig +} + export function createSisyphusJuniorAgent( categoryConfig: CategoryConfig, promptAppend?: string diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 6c935d2a..f75a1d41 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -315,3 +315,76 @@ describe("BuiltinCategoryNameSchema", () => { } }) }) + +describe("Sisyphus-Junior agent override", () => { + test("schema accepts agents['Sisyphus-Junior'] and retains the key after parsing", () => { + // #given + const config = { + agents: { + "Sisyphus-Junior": { + model: "openai/gpt-5.2", + temperature: 0.2, + }, + }, + } + + // #when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.agents?.["Sisyphus-Junior"]).toBeDefined() + expect(result.data.agents?.["Sisyphus-Junior"]?.model).toBe("openai/gpt-5.2") + expect(result.data.agents?.["Sisyphus-Junior"]?.temperature).toBe(0.2) + } + }) + + test("schema accepts Sisyphus-Junior with prompt_append", () => { + // #given + const config = { + agents: { + "Sisyphus-Junior": { + prompt_append: "Additional instructions for Sisyphus-Junior", + }, + }, + } + + // #when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.agents?.["Sisyphus-Junior"]?.prompt_append).toBe( + "Additional instructions for Sisyphus-Junior" + ) + } + }) + + test("schema accepts Sisyphus-Junior with tools override", () => { + // #given + const config = { + agents: { + "Sisyphus-Junior": { + tools: { + read: true, + write: false, + }, + }, + }, + } + + // #when + const result = OhMyOpenCodeConfigSchema.safeParse(config) + + // #then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.agents?.["Sisyphus-Junior"]?.tools).toEqual({ + read: true, + write: false, + }) + } + }) +}) diff --git a/src/config/schema.ts b/src/config/schema.ts index 07600afb..2d551539 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -39,6 +39,7 @@ export const OverridableAgentNameSchema = z.enum([ "build", "plan", "Sisyphus", + "Sisyphus-Junior", "OpenCode-Builder", "Prometheus (Planner)", "Metis (Plan Consultant)", @@ -119,6 +120,7 @@ export const AgentOverridesSchema = z.object({ build: AgentOverrideConfigSchema.optional(), plan: AgentOverrideConfigSchema.optional(), Sisyphus: AgentOverrideConfigSchema.optional(), + "Sisyphus-Junior": AgentOverrideConfigSchema.optional(), "OpenCode-Builder": AgentOverrideConfigSchema.optional(), "Prometheus (Planner)": AgentOverrideConfigSchema.optional(), "Metis (Plan Consultant)": AgentOverrideConfigSchema.optional(), diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index b16f8fb3..ec69a807 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -1,5 +1,5 @@ import { createBuiltinAgents } from "../agents"; -import { createSisyphusJuniorAgent } from "../agents/sisyphus-junior"; +import { createSisyphusJuniorAgentWithOverrides } from "../agents/sisyphus-junior"; import { loadUserCommands, loadProjectCommands, @@ -152,10 +152,9 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { Sisyphus: builtinAgents.Sisyphus, }; - agentConfig["Sisyphus-Junior"] = createSisyphusJuniorAgent({ - model: "anthropic/claude-sonnet-4-5", - temperature: 0.1, - }); + agentConfig["Sisyphus-Junior"] = createSisyphusJuniorAgentWithOverrides( + pluginConfig.agents?.["Sisyphus-Junior"] + ); if (builderEnabled) { const { name: _buildName, ...buildConfigWithoutName } = diff --git a/src/shared/permission-compat.ts b/src/shared/permission-compat.ts index f29df34f..08cf5780 100644 --- a/src/shared/permission-compat.ts +++ b/src/shared/permission-compat.ts @@ -1,5 +1,7 @@ import { supportsNewPermissionSystem } from "./opencode-version" +export { supportsNewPermissionSystem } + export type PermissionValue = "ask" | "allow" | "deny" export interface LegacyToolsFormat {