Files
EJClaw/src/moa.ts
2026-04-30 01:31:05 +09:00

240 lines
6.4 KiB
TypeScript

/**
* Mixture of Agents (MoA) — lightweight reference opinions.
*
* 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';
export interface MoaModelConfig {
name: string;
model: string;
baseUrl: string;
apiKey: string;
/** API format: 'openai' (default) or 'anthropic' (Messages API). */
apiFormat: 'openai' | 'anthropic';
}
export interface MoaConfig {
enabled: boolean;
referenceModels: MoaModelConfig[];
}
export interface MoaReferenceResult {
model: string;
response: string;
error?: string;
}
export interface MoaReferenceStatus {
model: string;
checkedAt: string;
ok: boolean;
error: string | null;
responseLength?: number;
}
const referenceStatuses = new Map<string, MoaReferenceStatus>();
function normalizeError(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function recordReferenceStatus(
status: Omit<MoaReferenceStatus, 'checkedAt'>,
): MoaReferenceStatus {
const next: MoaReferenceStatus = {
...status,
checkedAt: new Date().toISOString(),
};
referenceStatuses.set(status.model, next);
return next;
}
export function getMoaReferenceStatuses(): MoaReferenceStatus[] {
return [...referenceStatuses.values()];
}
async function queryModel(
model: MoaModelConfig,
systemPrompt: string,
userPrompt: string,
timeoutMs = 60_000,
): Promise<string> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const base = model.baseUrl.replace(/\/+$/, '');
const isAnthropic = model.apiFormat === 'anthropic';
const url = isAnthropic
? `${base}/v1/messages`
: `${base}/chat/completions`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (isAnthropic) {
headers['x-api-key'] = model.apiKey;
headers['anthropic-version'] = '2023-06-01';
} else {
headers['Authorization'] = `Bearer ${model.apiKey}`;
}
const body = isAnthropic
? {
model: model.model,
system: systemPrompt,
max_tokens: 2048,
messages: [{ role: 'user', content: userPrompt }],
}
: {
model: model.model,
max_tokens: 2048,
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
};
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: controller.signal,
});
if (!response.ok) {
const respBody = await response.text().catch(() => '');
throw new Error(
`${response.status} ${response.statusText}: ${respBody.slice(0, 200)}`,
);
}
const data = await response.json();
// Parse response based on format
let content: string | undefined;
if (isAnthropic) {
const blocks = (data as { content?: { type: string; text: string }[] })
.content;
content = blocks
?.filter((b: { type: string }) => b.type === 'text')
.map((b: { text: string }) => b.text)
.join('');
} else {
content = (data as { choices?: { message?: { content?: string } }[] })
.choices?.[0]?.message?.content;
}
if (!content) throw new Error('Empty response from model');
return content;
} finally {
clearTimeout(timer);
}
}
/**
* 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<MoaReferenceResult[]> {
const { config, systemPrompt, contextPrompt } = args;
logger.info(
{
models: config.referenceModels.map((m) => m.name),
},
'MoA: querying reference models for opinions',
);
const results = await Promise.allSettled(
config.referenceModels.map((model) =>
queryModel(model, systemPrompt, contextPrompt),
),
);
return results.map((result, i) => {
const model = config.referenceModels[i].name;
if (result.status === 'fulfilled') {
recordReferenceStatus({
model,
ok: true,
error: null,
responseLength: result.value.length,
});
logger.info(
{ model, responseLen: result.value.length },
'MoA: reference model responded',
);
return { model, response: result.value };
}
const error = normalizeError(result.reason);
recordReferenceStatus({ model, ok: false, error });
logger.warn({ model, error }, 'MoA: reference model failed');
return { model, response: '', error };
});
}
export async function probeMoaReferenceModel(
model: MoaModelConfig,
): Promise<MoaReferenceStatus> {
try {
const response = await queryModel(
model,
'You are a configuration health check. Reply with a short plain-text OK.',
'Reply exactly: OK',
20_000,
);
return recordReferenceStatus({
model: model.name,
ok: true,
error: null,
responseLength: response.length,
});
} catch (err) {
return recordReferenceStatus({
model: model.name,
ok: false,
error: normalizeError(err),
});
}
}
/**
* 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;
const opinions = successful
.map((r) => `### ${r.model}:\n${r.response}`)
.join('\n\n---\n\n');
return [
'',
`<moa-references count="${successful.length}">`,
`The following ${successful.length} independent AI models have also reviewed this deadlock:`,
'',
opinions,
'',
'Consider these perspectives alongside the conversation. Where they agree, that strengthens the case.',
'Where they disagree, weigh the evidence. Your verdict is final.',
'</moa-references>',
].join('\n');
}