feat: add per-role model selection via .env (OWNER/REVIEWER/ARBITER_MODEL, _EFFORT, _FALLBACK_ENABLED)

This commit is contained in:
Eyejoker
2026-03-30 23:40:36 +09:00
parent 4a5edaceb6
commit e649432a40
8 changed files with 99 additions and 14 deletions

View File

@@ -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

View File

@@ -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 =

View File

@@ -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<AgentOutput>((resolve) => {

View File

@@ -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 {

View File

@@ -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', () => ({

View File

@@ -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

View File

@@ -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,

View File

@@ -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,