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
589 changes: 573 additions & 16 deletions assets/oh-my-opencode.schema.json

Large diffs are not rendered by default.

13 changes: 12 additions & 1 deletion src/agents/sisyphus-junior.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
createAgentToolRestrictions,
type PermissionValue,
} from "../shared/permission-compat"
import { selectAvailableModel } from "../features/model-rotation/state-manager"

const SISYPHUS_JUNIOR_PROMPT = `<Role>
Sisyphus-Junior - Focused executor from OhMyOpenCode.
Expand Down Expand Up @@ -90,7 +91,17 @@ export function createSisyphusJuniorAgentWithOverrides(
override = undefined
}

const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
const rawModel = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
const model: string = (() => {
if (typeof rawModel === "string") return rawModel
if (Array.isArray(rawModel) && rawModel.length > 0) {
if (override?.rotation?.enabled) {
return selectAvailableModel(rawModel) ?? rawModel[0]
}
return rawModel[0]
}
return SISYPHUS_JUNIOR_DEFAULTS.model
})()
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature

const promptAppend = override?.prompt_append
Expand Down
7 changes: 1 addition & 6 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,4 @@ export type OverridableAgentName =

export type AgentName = BuiltinAgentName

export type AgentOverrideConfig = Partial<AgentConfig> & {
prompt_append?: string
variant?: string
}

export type AgentOverrides = Partial<Record<OverridableAgentName, AgentOverrideConfig>>
export type { AgentOverrideConfig, AgentOverrides } from "../config/schema"
44 changes: 31 additions & 13 deletions src/agents/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,20 +45,38 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.Sisyphus.thinking).toBeUndefined()
})

test("Oracle with default model has reasoningEffort", () => {
// #given - no overrides, using systemDefaultModel for other agents
// Oracle uses its own default model (openai/gpt-5.2) from the factory singleton
test("Oracle with default model has reasoningEffort", () => {
// #given - no overrides, using systemDefaultModel for other agents
// Oracle uses its own default model (openai/gpt-5.2) from the factory singleton

// #when
const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)

// #then - Oracle uses systemDefaultModel since model is now required
expect(agents.oracle.model).toBe("anthropic/claude-opus-4-5")
expect(agents.oracle.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
expect(agents.oracle.reasoningEffort).toBeUndefined()
})

test("does not throw when systemDefaultModel is omitted", () => {
const agents = createBuiltinAgents()
expect(agents.Sisyphus).toBeDefined()
expect(agents["orchestrator-sisyphus"]).toBeUndefined()
})

test("creates orchestrator-sisyphus when systemDefaultModel is omitted but override model exists", () => {
const agents = createBuiltinAgents([], {
"orchestrator-sisyphus": {
model: TEST_DEFAULT_MODEL,
},
})

expect(agents["orchestrator-sisyphus"]).toBeDefined()
expect(agents["orchestrator-sisyphus"]?.model).toBe(TEST_DEFAULT_MODEL)
})

test("Oracle with GPT model override has reasoningEffort, no thinking", () => {

// #when
const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)

// #then - Oracle uses systemDefaultModel since model is now required
expect(agents.oracle.model).toBe("anthropic/claude-opus-4-5")
expect(agents.oracle.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
expect(agents.oracle.reasoningEffort).toBeUndefined()
})

test("Oracle with GPT model override has reasoningEffort, no thinking", () => {
// #given
const overrides = {
oracle: { model: "openai/gpt-5.2" },
Expand Down
50 changes: 40 additions & 10 deletions src/agents/utils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
import type { CategoriesConfig, CategoryConfig, GitMasterConfig, RotationConfig } from "../config/schema"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
Expand All @@ -15,6 +15,17 @@ import type { AvailableAgent } from "./sisyphus-prompt-builder"
import { deepMerge } from "../shared"
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
import { selectAvailableModel } from "../features/model-rotation/state-manager"

function normalizeModel(model: string | string[] | undefined, fallback: string, rotation?: RotationConfig): string {
if (!model) return fallback
if (typeof model === "string") return model
if (model.length === 0) return fallback
if (rotation?.enabled && model.length > 1) {
return selectAvailableModel(model) ?? model[0]
}
return model[0]
}

type AgentSource = AgentFactory | AgentConfig

Expand Down Expand Up @@ -118,13 +129,29 @@ function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
): AgentConfig {
const { prompt_append, ...rest } = override
const { prompt_append, model, rotation, ...rest } = override
const merged = deepMerge(base, rest as Partial<AgentConfig>)

if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + prompt_append
}

if (model) {
const models = Array.isArray(model) ? model : [model]

if (models.length > 0) {
const selectedModel = rotation?.enabled && models.length > 1
? (selectAvailableModel(models) ?? models[0])
: models[0]

merged.model = selectedModel
}
}

if (rotation) {
merged.rotation = rotation as RotationConfig | undefined
}

return merged
}

Expand All @@ -136,9 +163,7 @@ export function createBuiltinAgents(
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig
): Record<string, AgentConfig> {
if (!systemDefaultModel) {
throw new Error("createBuiltinAgents requires systemDefaultModel")
}
const defaultModel = systemDefaultModel ?? ""

const result: Record<string, AgentConfig> = {}
const availableAgents: AvailableAgent[] = []
Expand All @@ -155,7 +180,7 @@ export function createBuiltinAgents(
if (disabledAgents.includes(agentName)) continue

const override = agentOverrides[agentName]
const model = override?.model ?? systemDefaultModel
const model = normalizeModel(override?.model, defaultModel, override?.rotation)

let config = buildAgent(source, model, mergedCategories, gitMasterConfig)

Expand All @@ -182,7 +207,7 @@ export function createBuiltinAgents(

if (!disabledAgents.includes("Sisyphus")) {
const sisyphusOverride = agentOverrides["Sisyphus"]
const sisyphusModel = sisyphusOverride?.model ?? systemDefaultModel
const sisyphusModel = normalizeModel(sisyphusOverride?.model, defaultModel, sisyphusOverride?.rotation)

let sisyphusConfig = createSisyphusAgent(sisyphusModel, availableAgents)

Expand All @@ -198,9 +223,14 @@ export function createBuiltinAgents(
result["Sisyphus"] = sisyphusConfig
}

if (!disabledAgents.includes("orchestrator-sisyphus")) {
const orchestratorOverride = agentOverrides["orchestrator-sisyphus"]
const orchestratorModel = orchestratorOverride?.model ?? systemDefaultModel
const orchestratorOverride = agentOverrides["orchestrator-sisyphus"]

if (!disabledAgents.includes("orchestrator-sisyphus") && (defaultModel || orchestratorOverride?.model)) {
const orchestratorModel = normalizeModel(
orchestratorOverride?.model,
defaultModel,
orchestratorOverride?.rotation
)
let orchestratorConfig = createOrchestratorSisyphusAgent({
model: orchestratorModel,
availableAgents,
Expand Down
22 changes: 21 additions & 1 deletion src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const HookNameSchema = z.enum([
"agent-usage-reminder",
"non-interactive-env",
"interactive-bash-session",
"model-rotation",

"thinking-block-validator",
"ralph-loop",
Expand All @@ -93,11 +94,27 @@ export const HookNameSchema = z.enum([
export const BuiltinCommandNameSchema = z.enum([
"init-deep",
"start-work",
"rotation-status",
])

/**
* Model rotation configuration for a single agent
* Allows round-robin rotation through multiple models with usage limits and cooldowns
*/
export const RotationConfigSchema = z.object({
/** Enable model rotation for this agent (default: false) */
enabled: z.boolean().default(false),
/** What to track: 'calls' (request count) or 'tokens' (token usage) */
limitType: z.enum(["calls", "tokens"]).default("calls"),
/** Usage limit threshold */
limitValue: z.number().min(1).default(100),
/** Cooldown period in milliseconds (default: 3600000 = 1 hour) */
cooldownMs: z.number().min(0).default(3600000),
})

export const AgentOverrideConfigSchema = z.object({
/** @deprecated Use `category` instead. Model is inherited from category defaults. */
model: z.string().optional(),
model: z.union([z.string(), z.array(z.string())]).optional(),
variant: z.string().optional(),
/** Category name to inherit model and other settings from CategoryConfig */
category: z.string().optional(),
Expand All @@ -116,6 +133,8 @@ export const AgentOverrideConfigSchema = z.object({
.regex(/^#[0-9A-Fa-f]{6}$/)
.optional(),
permission: AgentPermissionSchema.optional(),
/** Model rotation configuration for round-robin through multiple models */
rotation: RotationConfigSchema.optional(),
})

export const AgentOverridesSchema = z.object({
Expand Down Expand Up @@ -339,5 +358,6 @@ export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
export type CategoriesConfig = z.infer<typeof CategoriesConfigSchema>
export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
export type RotationConfig = z.infer<typeof RotationConfigSchema>

export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
7 changes: 7 additions & 0 deletions src/features/builtin-commands/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop"
import { REFACTOR_TEMPLATE } from "./templates/refactor"
import { START_WORK_TEMPLATE } from "./templates/start-work"
import { ROTATION_STATUS_TEMPLATE } from "./templates/rotation-status"

const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
"init-deep": {
Expand Down Expand Up @@ -70,6 +71,12 @@ $ARGUMENTS
</user-request>`,
argumentHint: "[plan-name]",
},
"rotation-status": {
description: "(builtin) Display model rotation status for all agents",
template: `<command-instruction>
${ROTATION_STATUS_TEMPLATE}
</command-instruction>`,
},
}

export function loadBuiltinCommands(
Expand Down
44 changes: 44 additions & 0 deletions src/features/builtin-commands/templates/rotation-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export const ROTATION_STATUS_TEMPLATE = `Display model rotation status for all agents with rotation enabled.

## What to Check

For each agent with rotation enabled, show:
1. **Agent Name**: Which agent has rotation
2. **Available Models**: List of models in rotation pool
3. **Rotation Config**: Limit type, limit value, cooldown period
4. **Current State**: Which model is currently being used (if available)

## Format

For each agent, display in this format:

### {agent-name}
Models: {model1}, {model2}, {model3}
Config: {limitType}={limitValue}, cooldown={cooldownMs}ms
State: {current model or "not tracked yet"}

## Instructions

1. Determine OpenCode config directory:
- If $OPENCODE_CONFIG_DIR is set, use that
- Else on Linux/macOS use $XDG_CONFIG_HOME/opencode (or ~/.config/opencode)
- Else on Windows use %APPDATA%\\opencode (fallback: ~/.config/opencode)
2. Read rotation configuration from: {configDir}/oh-my-opencode.json
3. Read rotation state from: {configDir}/model-rotation-state.json (if exists)
3. Display all agents with rotation.enabled=true
4. For each agent, show their model pool and rotation config
5. If state file exists, show current tracked model for each agent

## Example Output

### Sisyphus
Models: github-copilot/claude-opus-4-5, github-copilot/claude-sonnet-4.5, zai-coding-plan/glm-4.7
Config: calls=100, cooldown=180000ms
State: github-copilot/claude-opus-4-5 (2 calls used)

### oracle
Models: github-copilot/claude-opus-4-5, zai-coding-plan/glm-4.7
Config: calls=50, cooldown=120000ms
State: zai-coding-plan/glm-4.7 (12 calls used)

Display status for all configured rotation agents.`
2 changes: 1 addition & 1 deletion src/features/builtin-commands/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { CommandDefinition } from "../claude-code-command-loader"

export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work"
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work" | "rotation-status"

export interface BuiltinCommandConfig {
disabled_commands?: BuiltinCommandName[]
Expand Down
7 changes: 7 additions & 0 deletions src/features/model-rotation/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<claude-mem-context>
# Recent Activity

<!-- This section is auto-generated by claude-mem. Edit content outside the tags. -->

*No recent activity*
</claude-mem-context>
60 changes: 60 additions & 0 deletions src/features/model-rotation/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* Constants for model rotation feature
*/

/** Default cooldown period: 1 hour in milliseconds */
export const DEFAULT_COOLDOWN_MS = 3600000

/** Default usage limit: 100 API calls */
export const DEFAULT_LIMIT_VALUE = 100

/** Default limit type: track API calls */
export const DEFAULT_LIMIT_TYPE: "calls" | "tokens" = "calls"

/** State file location relative to config directory */
export const STATE_FILENAME = "model-rotation-state.json"

/** Maximum age before pruning unused model state (30 days) */
export const STATE_MAX_AGE_DAYS = 30

/** Error keywords that trigger rotation */
export const ROTATION_ERROR_KEYWORDS = [
"rate limit",
"quota exceeded",
"too many requests",
"429",
"rate_limited",
"quota_exceeded",
"resource_exhausted",
"service_unavailable",
"insufficient_quota",
"overloaded",
"rate_limit_error",
"permission_denied",
"maximum quota",
"model not found",
"modelfound",
"ProviderModelNotFoundError",
"not found",
"invalid model",
"does not exist",
] as const

/** Provider-specific error codes */
export const ANTHROPIC_ERROR_CODES = {
RATE_LIMIT: 429,
OVERLOADED: 529,
QUOTA_EXCEEDED: 529,
} as const

export const OPENAI_ERROR_CODES = {
RATE_LIMIT: 429,
INSUFFICIENT_QUOTA: 429,
} as const

export const GOOGLE_ERROR_CODES = {
QUOTA_EXCEEDED: 429,
RESOURCE_EXHAUSTED: 429,
UNAVAILABLE: 503,
PERMISSION_DENIED: 403,
} as const
Loading