diff --git a/.env.example b/.env.example index 6225669..461f183 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,21 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # FALLBACK_SMALL_MODEL=kimi-k2.5 # Fallback small model # FALLBACK_COOLDOWN_MS=600000 # Cooldown between fallback attempts (10min) +# --- Per-role model overrides (paired rooms) --- +# Override global CLAUDE_MODEL/CODEX_MODEL per role. +# Model is injected based on each role's agent type (claude-code → CLAUDE_MODEL, codex → CODEX_MODEL). +# OWNER_MODEL=gpt-5.4 # Owner model override +# OWNER_EFFORT=xhigh # Owner effort override +# OWNER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true) +# +# REVIEWER_MODEL=claude-opus-4-6 # Reviewer model override +# REVIEWER_EFFORT=high # Reviewer effort override +# REVIEWER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true) +# +# ARBITER_MODEL=claude-sonnet-4-6 # Arbiter model override +# ARBITER_EFFORT=high # Arbiter effort override +# ARBITER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true) + # --- Advanced --- # MAX_CONCURRENT_AGENTS=5 # Max parallel agent processes # SESSION_COMMAND_ALLOWED_SENDERS= # Comma-separated Discord user IDs for session commands diff --git a/src/config.ts b/src/config.ts index a8fac3e..4f00c9b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -125,6 +125,42 @@ export function isArbiterEnabled(): boolean { return ARBITER_AGENT_TYPE !== undefined; } +// ── Per-role model configuration ───────────────────────────────── + +export interface RoleModelConfig { + /** Model name override (e.g. 'claude-opus-4-6', 'gpt-5.4'). */ + model?: string; + /** Effort level override. */ + effort?: string; + /** Whether to fall back to codex when primary provider fails. Default: true. */ + fallbackEnabled: boolean; +} + +function buildRoleModelConfig(envPrefix: string): RoleModelConfig { + return { + model: getEnv(`${envPrefix}_MODEL`) || undefined, + effort: getEnv(`${envPrefix}_EFFORT`) || undefined, + fallbackEnabled: getEnv(`${envPrefix}_FALLBACK_ENABLED`) !== 'false', + }; +} + +export const OWNER_MODEL_CONFIG = buildRoleModelConfig('OWNER'); +export const REVIEWER_MODEL_CONFIG = buildRoleModelConfig('REVIEWER'); +export const ARBITER_MODEL_CONFIG = buildRoleModelConfig('ARBITER'); + +export function getRoleModelConfig( + role: 'owner' | 'reviewer' | 'arbiter', +): RoleModelConfig { + switch (role) { + case 'owner': + return OWNER_MODEL_CONFIG; + case 'reviewer': + return REVIEWER_MODEL_CONFIG; + case 'arbiter': + return ARBITER_MODEL_CONFIG; + } +} + // Max owner↔reviewer round trips per task. 0 = unlimited. const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10'; export const PAIRED_MAX_ROUND_TRIPS = diff --git a/src/container-runner.ts b/src/container-runner.ts index a28b824..d4a9945 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -488,8 +488,8 @@ export async function runReviewerContainer(args: { ); // Run a turn inside the persistent container via docker exec. - // Inject credentials at exec-time so token rotation is picked up - // without recreating the container. + // Inject credentials and model config at exec-time so token rotation + // and per-role model overrides are picked up without recreating the container. const execArgs = ['exec', '-i']; const authMode = detectAuthMode(); if (authMode === 'api-key') { @@ -504,6 +504,21 @@ export async function runReviewerContainer(args: { ''; execArgs.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`); } + // Inject model/effort overrides at exec-time so per-role config + // takes effect even with persistent containers. + const modelEnvKeys = [ + 'CLAUDE_MODEL', + 'CLAUDE_EFFORT', + 'CODEX_MODEL', + 'CODEX_EFFORT', + ]; + if (envOverrides) { + for (const key of modelEnvKeys) { + if (envOverrides[key]) { + execArgs.push('-e', `${key}=${envOverrides[key]}`); + } + } + } execArgs.push(containerName, 'node', '/app/dist/index.js'); return new Promise((resolve) => { diff --git a/src/ipc.ts b/src/ipc.ts index 1f27695..3ac2abc 100644 --- a/src/ipc.ts +++ b/src/ipc.ts @@ -3,11 +3,7 @@ import path from 'path'; import { CronExpressionParser } from 'cron-parser'; -import { - DATA_DIR, - IPC_POLL_INTERVAL, - TIMEZONE, -} from './config.js'; +import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js'; import { readJsonFile } from './utils.js'; import { AvailableGroup } from './agent-runner.js'; import { diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 1a355cc..f6c0a54 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -20,6 +20,11 @@ vi.mock('./config.js', () => ({ normalizeServiceId: vi.fn((serviceId: string) => serviceId === 'codex' ? 'codex-main' : serviceId, ), + getRoleModelConfig: vi.fn(() => ({ + model: undefined, + effort: undefined, + fallbackEnabled: true, + })), })); vi.mock('./db.js', () => ({ diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 856a9a5..7b56c7d 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -41,6 +41,7 @@ import { SERVICE_SESSION_SCOPE, REVIEWER_AGENT_TYPE, isClaudeService, + getRoleModelConfig, } from './config.js'; import { activateCodexFailover, @@ -177,6 +178,19 @@ export async function runAgentForGroup( roomRoleContext, hasHumanMessage: args.hasHumanMessage, }); + // Inject role-specific model overrides into envOverrides + if (pairedExecutionContext) { + const roleConfig = getRoleModelConfig(activeRole); + if (roleConfig.model) { + const modelKey = isClaudeCodeAgent ? 'CLAUDE_MODEL' : 'CODEX_MODEL'; + pairedExecutionContext.envOverrides[modelKey] = roleConfig.model; + } + if (roleConfig.effort) { + const effortKey = isClaudeCodeAgent ? 'CLAUDE_EFFORT' : 'CODEX_EFFORT'; + pairedExecutionContext.envOverrides[effortKey] = roleConfig.effort; + } + } + const effectivePrompt = prompt; let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionSummary: string | null = null; @@ -208,6 +222,15 @@ export async function runAgentForGroup( if (currentLease.reviewer_service_id === null) { return false; } + // Per-role fallback toggle + const roleConfig = getRoleModelConfig(activeRole); + if (!roleConfig.fallbackEnabled) { + logger.info( + { chatJid, group: group.name, role: activeRole, reason }, + 'Fallback disabled for role, skipping handoff', + ); + return false; + } if (arbiterMode) { // Arbiter failed (e.g. Claude 401/429) — re-trigger arbitration with codex diff --git a/src/service-routing.ts b/src/service-routing.ts index e28bf9d..f0fc7dd 100644 --- a/src/service-routing.ts +++ b/src/service-routing.ts @@ -97,8 +97,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease { return { chat_jid: chatJid, - owner_service_id: - CLAUDE_SERVICE_ID, + owner_service_id: CLAUDE_SERVICE_ID, reviewer_service_id: null, arbiter_service_id: null, activated_at: null, diff --git a/src/task-scheduler.ts b/src/task-scheduler.ts index 9f6dbe9..5cb972f 100644 --- a/src/task-scheduler.ts +++ b/src/task-scheduler.ts @@ -4,11 +4,7 @@ import fs from 'fs'; import { getAgentOutputText } from './agent-output.js'; import { getErrorMessage } from './utils.js'; -import { - ASSISTANT_NAME, - SCHEDULER_POLL_INTERVAL, - TIMEZONE, -} from './config.js'; +import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js'; import { AgentOutput, runAgentProcess,