refactor: MoA uses lightweight API references + SDK arbiter as aggregator

Instead of spawning separate processes or using OpenRouter, MoA now:
- Queries external API models (Kimi, GLM) in parallel for opinions
- Injects opinions into the SDK arbiter's prompt
- The existing subscription-based arbiter aggregates all perspectives

No extra SDK processes, no OpenRouter dependency. Per-model config via
MOA_REF_MODELS + MOA_{NAME}_MODEL/BASE_URL/API_KEY env vars.
This commit is contained in:
Eyejoker
2026-03-31 00:26:24 +09:00
parent f4b04d6c4d
commit f98dd27712
6 changed files with 130 additions and 175 deletions

View File

@@ -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) # ARBITER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true)
# --- Mixture of Agents (MoA) --- # --- Mixture of Agents (MoA) ---
# Queries multiple models in parallel for arbiter verdicts, then aggregates. # Queries external API models in parallel before arbiter runs.
# Requires OpenAI-compatible API (OpenRouter recommended for multi-model access). # 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_ENABLED=true
# MOA_BASE_URL=https://openrouter.ai/api/v1 # MOA_REF_MODELS=kimi,glm # Comma-separated reference model names
# MOA_API_KEY=sk-or-xxx # MOA_KIMI_MODEL=kimi-k2.5 # Kimi model
# MOA_REFERENCE_MODELS=anthropic/claude-sonnet-4-6,openai/gpt-5.4,deepseek/deepseek-chat # MOA_KIMI_BASE_URL=https://api.kimi.com/coding # Kimi API endpoint
# MOA_AGGREGATOR_MODEL=anthropic/claude-opus-4-6 # 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 --- # --- Advanced ---
# MAX_CONCURRENT_AGENTS=5 # Max parallel agent processes # MAX_CONCURRENT_AGENTS=5 # Max parallel agent processes

View File

@@ -165,39 +165,35 @@ export function getRoleModelConfig(
import type { MoaConfig, MoaModelConfig } from './moa.js'; 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') || ''; * Parse MOA reference models from env.
* Format: MOA_REF_MODELS=kimi,glm (comma-separated names)
function parseMoaModels(envKey: string): MoaModelConfig[] { * Each model: MOA_{NAME}_MODEL, MOA_{NAME}_BASE_URL, MOA_{NAME}_API_KEY
const raw = getEnv(envKey) || ''; */
return raw function parseMoaReferenceModels(): MoaModelConfig[] {
const names = (getEnv('MOA_REF_MODELS') || '')
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean);
.map((model) => ({
name: model.split('/').pop() || model, return names
model, .map((name) => {
baseUrl: MOA_BASE_URL, const prefix = `MOA_${name.toUpperCase()}`;
apiKey: MOA_API_KEY, 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 { export function getMoaConfig(): MoaConfig {
const referenceModels = parseMoaModels('MOA_REFERENCE_MODELS'); const referenceModels = parseMoaReferenceModels();
const aggregatorModel = getEnv('MOA_AGGREGATOR_MODEL') || '';
return { return {
enabled: enabled:
getEnv('MOA_ENABLED') === 'true' && getEnv('MOA_ENABLED') === 'true' && referenceModels.length > 0,
referenceModels.length > 0 &&
!!aggregatorModel &&
!!MOA_API_KEY,
referenceModels, referenceModels,
aggregator: {
name: aggregatorModel.split('/').pop() || aggregatorModel,
model: aggregatorModel,
baseUrl: MOA_BASE_URL,
apiKey: MOA_API_KEY,
},
}; };
} }

View File

@@ -25,7 +25,11 @@ vi.mock('./config.js', () => ({
effort: undefined, effort: undefined,
fallbackEnabled: true, fallbackEnabled: true,
})), })),
getMoaConfig: vi.fn(() => ({ enabled: false, referenceModels: [], aggregator: {} })), getMoaConfig: vi.fn(() => ({
enabled: false,
referenceModels: [],
aggregator: {},
})),
TIMEZONE: 'Asia/Seoul', TIMEZONE: 'Asia/Seoul',
})); }));

View File

@@ -46,8 +46,7 @@ import {
getRoleModelConfig, getRoleModelConfig,
getMoaConfig, getMoaConfig,
} from './config.js'; } from './config.js';
import { buildArbiterContextPrompt } from './arbiter-context.js'; import { collectMoaReferences, formatMoaReferencesForPrompt } from './moa.js';
import { runMoaArbiter } from './moa.js';
import { readArbiterPrompt } from './platform-prompts.js'; import { readArbiterPrompt } from './platform-prompts.js';
import { import {
activateCodexFailover, 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 pairedExecutionStatus: 'succeeded' | 'failed' = 'failed';
let pairedExecutionSummary: string | null = null; let pairedExecutionSummary: string | null = null;
let pairedExecutionCompleted = false; let pairedExecutionCompleted = false;
@@ -328,75 +368,6 @@ export async function runAgentForGroup(
return 'success'; 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 ( const runAttempt = async (
provider: string, provider: string,
): Promise<{ ): Promise<{

View File

@@ -23,7 +23,11 @@ vi.mock('./config.js', () => ({
isClaudeService: vi.fn(() => true), isClaudeService: vi.fn(() => true),
isReviewService: vi.fn(() => false), isReviewService: vi.fn(() => false),
isSessionCommandSenderAllowed: vi.fn(() => false), isSessionCommandSenderAllowed: vi.fn(() => false),
getMoaConfig: vi.fn(() => ({ enabled: false, referenceModels: [], aggregator: {} })), getMoaConfig: vi.fn(() => ({
enabled: false,
referenceModels: [],
aggregator: {},
})),
TIMEZONE: 'Asia/Seoul', TIMEZONE: 'Asia/Seoul',
})); }));

View File

@@ -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 * Queries external API models (Kimi, GLM, etc.) in parallel for their
* opinions into a single binding verdict. Uses OpenAI-compatible * opinions on the deadlock. These opinions are then injected into the
* chat completions API (works with OpenRouter, direct providers, etc.) * 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'; import { logger } from './logger.js';
@@ -17,7 +20,12 @@ export interface MoaModelConfig {
export interface MoaConfig { export interface MoaConfig {
enabled: boolean; enabled: boolean;
referenceModels: MoaModelConfig[]; referenceModels: MoaModelConfig[];
aggregator: MoaModelConfig; }
export interface MoaReferenceResult {
model: string;
response: string;
error?: string;
} }
async function queryModel( async function queryModel(
@@ -52,7 +60,9 @@ async function queryModel(
if (!response.ok) { if (!response.ok) {
const body = await response.text().catch(() => ''); 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 { 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; config: MoaConfig;
systemPrompt: string; systemPrompt: string;
contextPrompt: string; contextPrompt: string;
}): Promise<{ }): Promise<MoaReferenceResult[]> {
verdict: string;
referenceResponses: { model: string; response: string; error?: string }[];
}> {
const { config, systemPrompt, contextPrompt } = args; const { config, systemPrompt, contextPrompt } = args;
// Phase 1: Query reference models in parallel
logger.info( 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( 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; const model = config.referenceModels[i].name;
if (result.status === 'fulfilled') { if (result.status === 'fulfilled') {
logger.info( logger.info(
@@ -104,68 +117,31 @@ export async function runMoaArbiter(args: {
logger.warn({ model, error }, 'MoA: reference model failed'); logger.warn({ model, error }, 'MoA: reference model failed');
return { model, response: '', error }; return { model, response: '', error };
}); });
const successfulResponses = referenceResponses.filter((r) => !r.error);
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 * Format reference opinions into a section that gets appended
.map((r, i) => `### Opinion ${i + 1} (${r.model}):\n${r.response}`) * 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;
const opinions = successful
.map((r) => `### ${r.model}:\n${r.response}`)
.join('\n\n---\n\n'); .join('\n\n---\n\n');
const aggregatorPrompt = [ return [
contextPrompt,
'', '',
'---', `<moa-references count="${successful.length}">`,
'', `The following ${successful.length} independent AI models have also reviewed this deadlock:`,
`The following ${successfulResponses.length} independent AI models have each reviewed the deadlock and provided their analysis:`,
'', '',
opinions, opinions,
'', '',
'---', 'Consider these perspectives alongside the conversation. Where they agree, that strengthens the case.',
'', 'Where they disagree, weigh the evidence. Your verdict is final.',
'Consider all perspectives above. Where they agree, that strengthens the case.', '</moa-references>',
'Where they disagree, weigh the evidence each side presents.',
'Render your final verdict. Start your first line with: PROCEED, REVISE, RESET, or ESCALATE.',
].join('\n'); ].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,
};
}
} }