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:
16
.env.example
16
.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)
|
# 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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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<{
|
||||||
|
|||||||
@@ -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',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
116
src/moa.ts
116
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
|
* 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user