feat: add per-role model selection via .env (OWNER/REVIEWER/ARBITER_MODEL, _EFFORT, _FALLBACK_ENABLED)
This commit is contained in:
15
.env.example
15
.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_SMALL_MODEL=kimi-k2.5 # Fallback small model
|
||||||
# FALLBACK_COOLDOWN_MS=600000 # Cooldown between fallback attempts (10min)
|
# 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 ---
|
# --- Advanced ---
|
||||||
# MAX_CONCURRENT_AGENTS=5 # Max parallel agent processes
|
# MAX_CONCURRENT_AGENTS=5 # Max parallel agent processes
|
||||||
# SESSION_COMMAND_ALLOWED_SENDERS= # Comma-separated Discord user IDs for session commands
|
# SESSION_COMMAND_ALLOWED_SENDERS= # Comma-separated Discord user IDs for session commands
|
||||||
|
|||||||
@@ -125,6 +125,42 @@ export function isArbiterEnabled(): boolean {
|
|||||||
return ARBITER_AGENT_TYPE !== undefined;
|
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.
|
// Max owner↔reviewer round trips per task. 0 = unlimited.
|
||||||
const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10';
|
const rawMaxRoundTrips = getEnv('PAIRED_MAX_ROUND_TRIPS') || '10';
|
||||||
export const PAIRED_MAX_ROUND_TRIPS =
|
export const PAIRED_MAX_ROUND_TRIPS =
|
||||||
|
|||||||
@@ -488,8 +488,8 @@ export async function runReviewerContainer(args: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Run a turn inside the persistent container via docker exec.
|
// Run a turn inside the persistent container via docker exec.
|
||||||
// Inject credentials at exec-time so token rotation is picked up
|
// Inject credentials and model config at exec-time so token rotation
|
||||||
// without recreating the container.
|
// and per-role model overrides are picked up without recreating the container.
|
||||||
const execArgs = ['exec', '-i'];
|
const execArgs = ['exec', '-i'];
|
||||||
const authMode = detectAuthMode();
|
const authMode = detectAuthMode();
|
||||||
if (authMode === 'api-key') {
|
if (authMode === 'api-key') {
|
||||||
@@ -504,6 +504,21 @@ export async function runReviewerContainer(args: {
|
|||||||
'';
|
'';
|
||||||
execArgs.push('-e', `CLAUDE_CODE_OAUTH_TOKEN=${oauthToken}`);
|
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');
|
execArgs.push(containerName, 'node', '/app/dist/index.js');
|
||||||
|
|
||||||
return new Promise<AgentOutput>((resolve) => {
|
return new Promise<AgentOutput>((resolve) => {
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import path from 'path';
|
|||||||
|
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
|
|
||||||
import {
|
import { DATA_DIR, IPC_POLL_INTERVAL, TIMEZONE } from './config.js';
|
||||||
DATA_DIR,
|
|
||||||
IPC_POLL_INTERVAL,
|
|
||||||
TIMEZONE,
|
|
||||||
} from './config.js';
|
|
||||||
import { readJsonFile } from './utils.js';
|
import { readJsonFile } from './utils.js';
|
||||||
import { AvailableGroup } from './agent-runner.js';
|
import { AvailableGroup } from './agent-runner.js';
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ vi.mock('./config.js', () => ({
|
|||||||
normalizeServiceId: vi.fn((serviceId: string) =>
|
normalizeServiceId: vi.fn((serviceId: string) =>
|
||||||
serviceId === 'codex' ? 'codex-main' : serviceId,
|
serviceId === 'codex' ? 'codex-main' : serviceId,
|
||||||
),
|
),
|
||||||
|
getRoleModelConfig: vi.fn(() => ({
|
||||||
|
model: undefined,
|
||||||
|
effort: undefined,
|
||||||
|
fallbackEnabled: true,
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./db.js', () => ({
|
vi.mock('./db.js', () => ({
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import {
|
|||||||
SERVICE_SESSION_SCOPE,
|
SERVICE_SESSION_SCOPE,
|
||||||
REVIEWER_AGENT_TYPE,
|
REVIEWER_AGENT_TYPE,
|
||||||
isClaudeService,
|
isClaudeService,
|
||||||
|
getRoleModelConfig,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import {
|
import {
|
||||||
activateCodexFailover,
|
activateCodexFailover,
|
||||||
@@ -177,6 +178,19 @@ export async function runAgentForGroup(
|
|||||||
roomRoleContext,
|
roomRoleContext,
|
||||||
hasHumanMessage: args.hasHumanMessage,
|
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;
|
const effectivePrompt = prompt;
|
||||||
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
|
||||||
let pairedExecutionSummary: string | null = null;
|
let pairedExecutionSummary: string | null = null;
|
||||||
@@ -208,6 +222,15 @@ export async function runAgentForGroup(
|
|||||||
if (currentLease.reviewer_service_id === null) {
|
if (currentLease.reviewer_service_id === null) {
|
||||||
return false;
|
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) {
|
if (arbiterMode) {
|
||||||
// Arbiter failed (e.g. Claude 401/429) — re-trigger arbitration with codex
|
// Arbiter failed (e.g. Claude 401/429) — re-trigger arbitration with codex
|
||||||
|
|||||||
@@ -97,8 +97,7 @@ function getDefaultLease(chatJid: string): EffectiveChannelLease {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
chat_jid: chatJid,
|
chat_jid: chatJid,
|
||||||
owner_service_id:
|
owner_service_id: CLAUDE_SERVICE_ID,
|
||||||
CLAUDE_SERVICE_ID,
|
|
||||||
reviewer_service_id: null,
|
reviewer_service_id: null,
|
||||||
arbiter_service_id: null,
|
arbiter_service_id: null,
|
||||||
activated_at: null,
|
activated_at: null,
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ import fs from 'fs';
|
|||||||
import { getAgentOutputText } from './agent-output.js';
|
import { getAgentOutputText } from './agent-output.js';
|
||||||
import { getErrorMessage } from './utils.js';
|
import { getErrorMessage } from './utils.js';
|
||||||
|
|
||||||
import {
|
import { ASSISTANT_NAME, SCHEDULER_POLL_INTERVAL, TIMEZONE } from './config.js';
|
||||||
ASSISTANT_NAME,
|
|
||||||
SCHEDULER_POLL_INTERVAL,
|
|
||||||
TIMEZONE,
|
|
||||||
} from './config.js';
|
|
||||||
import {
|
import {
|
||||||
AgentOutput,
|
AgentOutput,
|
||||||
runAgentProcess,
|
runAgentProcess,
|
||||||
|
|||||||
Reference in New Issue
Block a user