diff --git a/.env.example b/.env.example index 3ad33fe..bd6dc95 100644 --- a/.env.example +++ b/.env.example @@ -58,13 +58,17 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat # ARBITER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true) # --- Mixture of Agents (MoA) --- -# Queries multiple models in parallel for arbiter verdicts, then aggregates. -# Requires OpenAI-compatible API (OpenRouter recommended for multi-model access). +# Queries external API models in parallel before arbiter runs. +# Their opinions are injected into the arbiter's prompt for better judgment. +# The SDK arbiter (subscription-based, no extra cost) aggregates all perspectives. # MOA_ENABLED=true -# MOA_BASE_URL=https://openrouter.ai/api/v1 -# MOA_API_KEY=sk-or-xxx -# MOA_REFERENCE_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4,deepseek/deepseek-chat -# MOA_AGGREGATOR_MODEL=anthropic/claude-opus-4-6 +# MOA_REF_MODELS=kimi,glm # Comma-separated reference model names +# MOA_KIMI_MODEL=kimi-k2.5 # Kimi model +# MOA_KIMI_BASE_URL=https://api.kimi.com/coding # Kimi API endpoint +# MOA_KIMI_API_KEY=sk-kimi-xxx # Kimi API key +# MOA_GLM_MODEL=glm-4-plus # GLM model +# MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # GLM API endpoint +# MOA_GLM_API_KEY=xxx # GLM API key # --- Advanced --- # MAX_CONCURRENT_AGENTS=5 # Max parallel agent processes diff --git a/src/config.ts b/src/config.ts index ebfe9a0..2794c37 100644 --- a/src/config.ts +++ b/src/config.ts @@ -165,39 +165,35 @@ export function getRoleModelConfig( import type { MoaConfig, MoaModelConfig } from './moa.js'; -const MOA_BASE_URL = getEnv('MOA_BASE_URL') || 'https://openrouter.ai/api/v1'; -const MOA_API_KEY = getEnv('MOA_API_KEY') || ''; - -function parseMoaModels(envKey: string): MoaModelConfig[] { - const raw = getEnv(envKey) || ''; - return raw +/** + * Parse MOA reference models from env. + * Format: MOA_REF_MODELS=kimi,glm (comma-separated names) + * Each model: MOA_{NAME}_MODEL, MOA_{NAME}_BASE_URL, MOA_{NAME}_API_KEY + */ +function parseMoaReferenceModels(): MoaModelConfig[] { + const names = (getEnv('MOA_REF_MODELS') || '') .split(',') .map((s) => s.trim()) - .filter(Boolean) - .map((model) => ({ - name: model.split('/').pop() || model, - model, - baseUrl: MOA_BASE_URL, - apiKey: MOA_API_KEY, - })); + .filter(Boolean); + + return names + .map((name) => { + const prefix = `MOA_${name.toUpperCase()}`; + const model = getEnv(`${prefix}_MODEL`) || ''; + const baseUrl = getEnv(`${prefix}_BASE_URL`) || ''; + const apiKey = getEnv(`${prefix}_API_KEY`) || ''; + if (!model || !baseUrl || !apiKey) return null; + return { name, model, baseUrl, apiKey }; + }) + .filter((m): m is MoaModelConfig => m !== null); } export function getMoaConfig(): MoaConfig { - const referenceModels = parseMoaModels('MOA_REFERENCE_MODELS'); - const aggregatorModel = getEnv('MOA_AGGREGATOR_MODEL') || ''; + const referenceModels = parseMoaReferenceModels(); return { enabled: - getEnv('MOA_ENABLED') === 'true' && - referenceModels.length > 0 && - !!aggregatorModel && - !!MOA_API_KEY, + getEnv('MOA_ENABLED') === 'true' && referenceModels.length > 0, referenceModels, - aggregator: { - name: aggregatorModel.split('/').pop() || aggregatorModel, - model: aggregatorModel, - baseUrl: MOA_BASE_URL, - apiKey: MOA_API_KEY, - }, }; } diff --git a/src/message-agent-executor.test.ts b/src/message-agent-executor.test.ts index 401e847..547d239 100644 --- a/src/message-agent-executor.test.ts +++ b/src/message-agent-executor.test.ts @@ -25,7 +25,11 @@ vi.mock('./config.js', () => ({ effort: undefined, fallbackEnabled: true, })), - getMoaConfig: vi.fn(() => ({ enabled: false, referenceModels: [], aggregator: {} })), + getMoaConfig: vi.fn(() => ({ + enabled: false, + referenceModels: [], + aggregator: {}, + })), TIMEZONE: 'Asia/Seoul', })); diff --git a/src/message-agent-executor.ts b/src/message-agent-executor.ts index 2bc0e20..3bafec1 100644 --- a/src/message-agent-executor.ts +++ b/src/message-agent-executor.ts @@ -46,8 +46,7 @@ import { getRoleModelConfig, getMoaConfig, } from './config.js'; -import { buildArbiterContextPrompt } from './arbiter-context.js'; -import { runMoaArbiter } from './moa.js'; +import { collectMoaReferences, formatMoaReferencesForPrompt } from './moa.js'; import { readArbiterPrompt } from './platform-prompts.js'; import { activateCodexFailover, @@ -183,7 +182,48 @@ export async function runAgentForGroup( } } - const effectivePrompt = prompt; + // ── MoA prompt enrichment ───────────────────────────────────── + // When MoA is enabled and we're in arbiter mode, query external API + // models (Kimi, GLM, etc.) in parallel for their opinions, then inject + // those opinions into the arbiter's prompt. The SDK-based arbiter + // agent naturally aggregates all perspectives. + let moaEnrichedPrompt = prompt; + const moaConfig = getMoaConfig(); + if (arbiterMode && moaConfig.enabled && pairedExecutionContext) { + logger.info( + { + chatJid, + group: group.name, + runId, + models: moaConfig.referenceModels.map((m) => m.name), + }, + 'MoA: collecting reference opinions before arbiter', + ); + + const systemPrompt = + readArbiterPrompt(process.cwd()) || 'You are an arbiter.'; + + const references = await collectMoaReferences({ + config: moaConfig, + systemPrompt, + contextPrompt: prompt, + }); + + const moaSection = formatMoaReferencesForPrompt(references); + if (moaSection) { + moaEnrichedPrompt = prompt + '\n' + moaSection; + logger.info( + { + chatJid, + successCount: references.filter((r) => !r.error).length, + totalCount: references.length, + }, + 'MoA: injected reference opinions into arbiter prompt', + ); + } + } + + const effectivePrompt = moaEnrichedPrompt; let pairedExecutionStatus: 'succeeded' | 'failed' = 'failed'; let pairedExecutionSummary: string | null = null; let pairedExecutionCompleted = false; @@ -328,75 +368,6 @@ export async function runAgentForGroup( return 'success'; } - // ── MoA arbiter path ──────────────────────────────────────────── - // When MoA is enabled and we're in arbiter mode, query multiple - // models in parallel instead of spawning a single agent process. - const moaConfig = getMoaConfig(); - if (arbiterMode && moaConfig.enabled && pairedExecutionContext) { - logger.info( - { - chatJid, - group: group.name, - runId, - referenceModels: moaConfig.referenceModels.map((m) => m.model), - aggregator: moaConfig.aggregator.model, - }, - 'Running MoA arbiter instead of single agent', - ); - - const systemPrompt = - readArbiterPrompt(process.cwd()) || 'You are an arbiter.'; - const contextPrompt = buildArbiterContextPrompt({ - chatJid, - taskId: pairedExecutionContext.task.id, - roundTripCount: pairedExecutionContext.task.round_trip_count, - timezone: TIMEZONE, - }); - - try { - const moaResult = await runMoaArbiter({ - config: moaConfig, - systemPrompt, - contextPrompt, - }); - - pairedExecutionSummary = moaResult.verdict.slice(0, 500); - pairedExecutionStatus = 'succeeded'; - - // Build display text with reference model opinions - const referenceSection = moaResult.referenceResponses - .filter((r) => !r.error) - .map((r) => `**${r.model}**: ${r.response.split('\n')[0]}`) - .join('\n'); - const displayText = referenceSection - ? `${moaResult.verdict}\n\n---\n*MoA references: ${moaResult.referenceResponses.filter((r) => !r.error).length} models queried*\n${referenceSection}` - : moaResult.verdict; - - await onOutput?.({ - status: 'success', - result: null, - output: { visibility: 'public', text: displayText }, - phase: 'final', - }); - } catch (error) { - logger.error( - { chatJid, group: group.name, runId, error }, - 'MoA arbiter failed', - ); - pairedExecutionSummary = 'ESCALATE\nMoA arbiter failed'; - pairedExecutionStatus = 'failed'; - } - - completePairedExecutionContext({ - taskId: pairedExecutionContext.task.id, - role: 'arbiter', - status: pairedExecutionStatus, - summary: pairedExecutionSummary, - }); - pairedExecutionCompleted = true; - return pairedExecutionStatus === 'succeeded' ? 'success' : 'error'; - } - const runAttempt = async ( provider: string, ): Promise<{ diff --git a/src/message-runtime.test.ts b/src/message-runtime.test.ts index 027cb9c..3c1ab53 100644 --- a/src/message-runtime.test.ts +++ b/src/message-runtime.test.ts @@ -23,7 +23,11 @@ vi.mock('./config.js', () => ({ isClaudeService: vi.fn(() => true), isReviewService: vi.fn(() => false), isSessionCommandSenderAllowed: vi.fn(() => false), - getMoaConfig: vi.fn(() => ({ enabled: false, referenceModels: [], aggregator: {} })), + getMoaConfig: vi.fn(() => ({ + enabled: false, + referenceModels: [], + aggregator: {}, + })), TIMEZONE: 'Asia/Seoul', })); diff --git a/src/moa.ts b/src/moa.ts index 25997d7..5f186a0 100644 --- a/src/moa.ts +++ b/src/moa.ts @@ -1,9 +1,12 @@ /** - * Mixture of Agents (MoA) for arbiter verdicts. + * Mixture of Agents (MoA) — lightweight reference opinions. * - * Queries multiple LLM models in parallel, then aggregates their - * opinions into a single binding verdict. Uses OpenAI-compatible - * chat completions API (works with OpenRouter, direct providers, etc.) + * Queries external API models (Kimi, GLM, etc.) in parallel for their + * opinions on the deadlock. These opinions are then injected into the + * SDK-based arbiter's prompt so it can aggregate all perspectives. + * + * No extra SDK processes. The existing arbiter (Claude/Codex subscription) + * naturally becomes the aggregator. */ import { logger } from './logger.js'; @@ -17,7 +20,12 @@ export interface MoaModelConfig { export interface MoaConfig { enabled: boolean; referenceModels: MoaModelConfig[]; - aggregator: MoaModelConfig; +} + +export interface MoaReferenceResult { + model: string; + response: string; + error?: string; } async function queryModel( @@ -52,7 +60,9 @@ async function queryModel( if (!response.ok) { const body = await response.text().catch(() => ''); - throw new Error(`${response.status} ${response.statusText}: ${body.slice(0, 200)}`); + throw new Error( + `${response.status} ${response.statusText}: ${body.slice(0, 200)}`, + ); } const data = (await response.json()) as { @@ -66,20 +76,23 @@ async function queryModel( } } -export async function runMoaArbiter(args: { +/** + * Query all reference models in parallel and return their opinions. + * These are injected into the SDK arbiter's prompt — the arbiter + * aggregates them into a final verdict. + */ +export async function collectMoaReferences(args: { config: MoaConfig; systemPrompt: string; contextPrompt: string; -}): Promise<{ - verdict: string; - referenceResponses: { model: string; response: string; error?: string }[]; -}> { +}): Promise { const { config, systemPrompt, contextPrompt } = args; - // Phase 1: Query reference models in parallel logger.info( - { modelCount: config.referenceModels.length }, - 'MoA: querying reference models', + { + models: config.referenceModels.map((m) => m.name), + }, + 'MoA: querying reference models for opinions', ); const results = await Promise.allSettled( @@ -88,7 +101,7 @@ export async function runMoaArbiter(args: { ), ); - const referenceResponses = results.map((result, i) => { + return results.map((result, i) => { const model = config.referenceModels[i].name; if (result.status === 'fulfilled') { logger.info( @@ -104,68 +117,31 @@ export async function runMoaArbiter(args: { logger.warn({ model, error }, 'MoA: reference model failed'); return { model, response: '', error }; }); +} - const successfulResponses = referenceResponses.filter((r) => !r.error); +/** + * Format reference opinions into a section that gets appended + * to the arbiter's prompt. + */ +export function formatMoaReferencesForPrompt( + references: MoaReferenceResult[], +): string | null { + const successful = references.filter((r) => !r.error && r.response); + if (successful.length === 0) return null; - if (successfulResponses.length === 0) { - logger.error('MoA: all reference models failed, using ESCALATE'); - return { - verdict: - 'ESCALATE\n\nAll reference models failed to respond. Human judgment required.', - referenceResponses, - }; - } - - // Phase 2: Aggregate via aggregator model - const opinions = successfulResponses - .map((r, i) => `### Opinion ${i + 1} (${r.model}):\n${r.response}`) + const opinions = successful + .map((r) => `### ${r.model}:\n${r.response}`) .join('\n\n---\n\n'); - const aggregatorPrompt = [ - contextPrompt, + return [ '', - '---', - '', - `The following ${successfulResponses.length} independent AI models have each reviewed the deadlock and provided their analysis:`, + ``, + `The following ${successful.length} independent AI models have also reviewed this deadlock:`, '', opinions, '', - '---', - '', - 'Consider all perspectives above. Where they agree, that strengthens the case.', - 'Where they disagree, weigh the evidence each side presents.', - 'Render your final verdict. Start your first line with: PROCEED, REVISE, RESET, or ESCALATE.', + 'Consider these perspectives alongside the conversation. Where they agree, that strengthens the case.', + 'Where they disagree, weigh the evidence. Your verdict is final.', + '', ].join('\n'); - - logger.info( - { aggregator: config.aggregator.name }, - 'MoA: running aggregator', - ); - - try { - const verdict = await queryModel( - config.aggregator, - systemPrompt, - aggregatorPrompt, - 90_000, - ); - logger.info( - { - aggregator: config.aggregator.name, - verdictPreview: verdict.slice(0, 100), - }, - 'MoA: aggregator verdict rendered', - ); - return { verdict, referenceResponses }; - } catch (error) { - // Aggregator failed — fall back to majority vote from reference models - logger.warn( - { error }, - 'MoA: aggregator failed, falling back to first successful reference', - ); - return { - verdict: successfulResponses[0].response, - referenceResponses, - }; - } }