feat(model-council): multi-model deliberation — parallel members + chair synthesis (#2890)

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
Aashir Athar
2026-05-30 10:59:31 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Steven Enamakel
parent d08882b219
commit acb12adf80
25 changed files with 1609 additions and 1 deletions
@@ -0,0 +1,134 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ModelCouncilResult } from '../../services/api/modelCouncilApi';
import ModelCouncilTab from './ModelCouncilTab';
const mockRunCouncil = vi.fn();
vi.mock('../../services/api/modelCouncilApi', () => ({
modelCouncilApi: { runCouncil: (...args: unknown[]) => mockRunCouncil(...args) },
}));
const RESULT: ModelCouncilResult = {
question: 'What is the capital of France?',
members: [
{ model: 'model-a', response: 'Paris is the capital.', error: null },
{ model: 'model-b', response: null, error: 'rate limited' },
],
chair_model: 'chair-model',
synthesis: 'Both that answered agree: Paris. One seat failed.',
};
const fillValidForm = () => {
fireEvent.change(screen.getByLabelText('Question'), {
target: { value: 'What is the capital of France?' },
});
fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: 'model-a' } });
fireEvent.change(screen.getByLabelText('Member model 2'), { target: { value: 'model-b' } });
fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: 'chair-model' } });
};
describe('ModelCouncilTab', () => {
beforeEach(() => {
mockRunCouncil.mockReset();
});
it('renders the compose surface with two member rows by default', () => {
render(<ModelCouncilTab />);
expect(screen.getByText('Model Council')).toBeInTheDocument();
expect(screen.getByLabelText('Question')).toBeInTheDocument();
expect(screen.getByLabelText('Member model 1')).toBeInTheDocument();
expect(screen.getByLabelText('Member model 2')).toBeInTheDocument();
expect(screen.getByLabelText('Chair model')).toBeInTheDocument();
});
it('disables Convene until question + a member + chair are all filled', () => {
render(<ModelCouncilTab />);
const run = screen.getByRole('button', { name: 'Convene council' });
expect(run).toBeDisabled();
fireEvent.change(screen.getByLabelText('Question'), { target: { value: 'q' } });
expect(run).toBeDisabled();
fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: 'm' } });
expect(run).toBeDisabled();
fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: 'c' } });
expect(run).not.toBeDisabled();
});
it('adds member rows up to the max of 5 and stops', () => {
render(<ModelCouncilTab />);
const add = screen.getByRole('button', { name: '+ Add model' });
fireEvent.click(add); // 3
fireEvent.click(add); // 4
fireEvent.click(add); // 5
expect(screen.getByLabelText('Member model 5')).toBeInTheDocument();
expect(add).toBeDisabled();
expect(screen.queryByLabelText('Member model 6')).not.toBeInTheDocument();
});
it('removes member rows but never below one', () => {
render(<ModelCouncilTab />);
// Two rows initially; remove one → one left, remove button then disabled.
fireEvent.click(screen.getByRole('button', { name: 'Remove member model 2' }));
expect(screen.queryByLabelText('Member model 2')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Remove member model 1' })).toBeDisabled();
});
it('runs the council and renders member answers side-by-side + the synthesis', async () => {
mockRunCouncil.mockResolvedValueOnce(RESULT);
render(<ModelCouncilTab />);
fillValidForm();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockRunCouncil).toHaveBeenCalledWith({
question: 'What is the capital of France?',
member_models: ['model-a', 'model-b'],
chair_model: 'chair-model',
});
await waitFor(() => {
expect(screen.getByText('Council results')).toBeInTheDocument();
});
// Member A answered; Member B failed.
expect(screen.getByText('Paris is the capital.')).toBeInTheDocument();
expect(screen.getByText('rate limited')).toBeInTheDocument();
expect(screen.getByText('Answered')).toBeInTheDocument();
expect(screen.getByText('Failed')).toBeInTheDocument();
// Synthesis from the chair.
expect(
screen.getByText('Both that answered agree: Paris. One seat failed.')
).toBeInTheDocument();
expect(screen.getByText('by chair-model')).toBeInTheDocument();
});
it('trims whitespace and drops blank member rows before calling the API', async () => {
mockRunCouncil.mockResolvedValueOnce(RESULT);
render(<ModelCouncilTab />);
fireEvent.change(screen.getByLabelText('Question'), { target: { value: ' hi ' } });
fireEvent.change(screen.getByLabelText('Member model 1'), { target: { value: ' model-a ' } });
// leave member 2 blank
fireEvent.change(screen.getByLabelText('Chair model'), { target: { value: ' chair ' } });
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockRunCouncil).toHaveBeenCalledWith({
question: 'hi',
member_models: ['model-a'],
chair_model: 'chair',
});
});
it('surfaces an error alert when the council run fails', async () => {
mockRunCouncil.mockRejectedValueOnce(new Error('all member models failed to respond'));
render(<ModelCouncilTab />);
fillValidForm();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
await waitFor(() => {
const alert = screen.getByRole('alert');
expect(alert.textContent).toMatch(/all member models failed to respond/);
});
// No results section on failure.
expect(screen.queryByText('Council results')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,260 @@
/**
* Model Council tab — ask one question, get independent answers from several
* models in parallel, then a chair model's synthesis of where they agree,
* disagree, and what unique insight each added.
*
* The orchestration (parallel member calls + chair synthesis) lives in the
* Rust core behind `openhuman.model_council_run`; this tab is the compose +
* compare surface. Model ids are entered as free text because the available
* set is provider-specific (local Ollama + any configured cloud providers)
* and the council accepts arbitrary ids.
*/
import { useCallback, useMemo, useState } from 'react';
import { useT } from '../../lib/i18n/I18nContext';
import { modelCouncilApi, type ModelCouncilResult } from '../../services/api/modelCouncilApi';
/** Matches the server-side MAX_COUNCIL_MEMBERS cap. */
const MAX_MEMBERS = 5;
/** A member row carries a stable id so React keys survive mid-list removal. */
interface MemberRow {
id: number;
value: string;
}
/** Next id = max existing + 1: unique among current rows, no ref/StrictMode hazard. */
const nextMemberId = (rows: MemberRow[]): number =>
rows.reduce((max, r) => Math.max(max, r.id), -1) + 1;
const ModelCouncilTab = () => {
const { t } = useT();
const [question, setQuestion] = useState('');
const [members, setMembers] = useState<MemberRow[]>([
{ id: 0, value: '' },
{ id: 1, value: '' },
]);
const [chair, setChair] = useState('');
const [running, setRunning] = useState(false);
const [result, setResult] = useState<ModelCouncilResult | null>(null);
const [error, setError] = useState<string | null>(null);
const filledMembers = useMemo(
() => members.map(m => m.value.trim()).filter(v => v.length > 0),
[members]
);
const canRun =
!running && question.trim().length > 0 && filledMembers.length > 0 && chair.trim().length > 0;
const updateMember = useCallback((id: number, value: string) => {
setMembers(prev => prev.map(m => (m.id === id ? { ...m, value } : m)));
}, []);
const addMember = useCallback(() => {
setMembers(prev =>
prev.length >= MAX_MEMBERS ? prev : [...prev, { id: nextMemberId(prev), value: '' }]
);
}, []);
const removeMember = useCallback((id: number) => {
setMembers(prev => (prev.length <= 1 ? prev : prev.filter(m => m.id !== id)));
}, []);
const handleRun = useCallback(async () => {
if (running) return;
const trimmedMembers = members.map(m => m.value.trim()).filter(v => v.length > 0);
if (question.trim().length === 0 || trimmedMembers.length === 0 || chair.trim().length === 0) {
return;
}
setRunning(true);
setError(null);
setResult(null);
try {
const res = await modelCouncilApi.runCouncil({
question: question.trim(),
member_models: trimmedMembers,
chair_model: chair.trim(),
});
setResult(res);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setRunning(false);
}
}, [running, members, question, chair]);
return (
<div className="space-y-4">
<div
role="note"
className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 px-3 py-2 text-xs text-stone-700 dark:text-neutral-200">
<p className="font-medium mb-1">{t('modelCouncil.title')}</p>
<p>{t('modelCouncil.intro')}</p>
</div>
{/* Question */}
<div className="space-y-1.5">
<label
htmlFor="model-council-question"
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('modelCouncil.questionLabel')}
</label>
<textarea
id="model-council-question"
value={question}
onChange={e => setQuestion(e.target.value)}
rows={3}
placeholder={t('modelCouncil.questionPlaceholder')}
aria-label={t('modelCouncil.questionLabel')}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-800 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-y"
/>
</div>
{/* Member models */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('modelCouncil.membersLabel')}
</span>
<span className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('modelCouncil.maxMembersNote').replace('{max}', String(MAX_MEMBERS))}
</span>
</div>
<ul className="space-y-1.5">
{members.map((member, index) => (
<li key={member.id} className="flex items-center gap-2">
<input
type="text"
value={member.value}
onChange={e => updateMember(member.id, e.target.value)}
placeholder={t('modelCouncil.memberPlaceholder')}
aria-label={t('modelCouncil.memberAria').replace('{n}', String(index + 1))}
className="flex-1 rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
<button
type="button"
onClick={() => removeMember(member.id)}
disabled={members.length <= 1}
aria-label={t('modelCouncil.removeMemberAria').replace('{n}', String(index + 1))}
className="shrink-0 rounded-lg border border-stone-200 dark:border-neutral-700 px-2 py-1.5 text-xs text-stone-500 dark:text-neutral-400 hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed">
</button>
</li>
))}
</ul>
<button
type="button"
onClick={addMember}
disabled={members.length >= MAX_MEMBERS}
className="rounded-lg border border-stone-200 dark:border-neutral-700 px-3 py-1 text-xs font-medium text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800 disabled:opacity-40 disabled:cursor-not-allowed">
{t('modelCouncil.addMember')}
</button>
</div>
{/* Chair model */}
<div className="space-y-1.5">
<label
htmlFor="model-council-chair"
className="text-xs font-medium text-stone-600 dark:text-neutral-300">
{t('modelCouncil.chairLabel')}
</label>
<input
id="model-council-chair"
type="text"
value={chair}
onChange={e => setChair(e.target.value)}
placeholder={t('modelCouncil.chairPlaceholder')}
aria-label={t('modelCouncil.chairLabel')}
className="w-full rounded-lg border border-stone-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
<p className="text-[10px] text-stone-400 dark:text-neutral-500">
{t('modelCouncil.chairHelp')}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => void handleRun()}
disabled={!canRun}
className="rounded-lg bg-primary-500 px-4 py-1.5 text-sm font-semibold text-white hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed">
{running ? t('modelCouncil.running') : t('modelCouncil.run')}
</button>
{running && (
<span
role="status"
aria-live="polite"
className="text-xs text-stone-500 dark:text-neutral-400">
{t('modelCouncil.runningHint')}
</span>
)}
</div>
{error && (
<p role="alert" className="text-xs text-coral-700 dark:text-coral-300">
{t('modelCouncil.errorPrefix')} {error}
</p>
)}
{result && (
<section aria-labelledby="model-council-results-heading" className="space-y-3 pt-1">
<h3
id="model-council-results-heading"
className="text-sm font-semibold text-stone-800 dark:text-neutral-100">
{t('modelCouncil.resultsHeading')}
</h3>
{/* Member answers, side by side */}
<div className="grid gap-2 sm:grid-cols-2">
{result.members.map((member, index) => (
<div
key={`${member.model}-${index}`}
className="rounded-lg border border-stone-200 dark:border-neutral-800 p-3 space-y-1.5">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-mono font-medium text-stone-700 dark:text-neutral-200 truncate">
{member.model}
</span>
<span
className={`inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-semibold uppercase tracking-wider shrink-0 ${
member.error
? 'bg-coral-100 dark:bg-coral-500/20 text-coral-700 dark:text-coral-300'
: 'bg-sage-100 dark:bg-sage-500/20 text-sage-700 dark:text-sage-300'
}`}>
{member.error
? t('modelCouncil.memberFailed')
: t('modelCouncil.memberAnswered')}
</span>
</div>
{member.error ? (
<p className="text-xs text-coral-600 dark:text-coral-400">{member.error}</p>
) : (
<p className="text-xs text-stone-600 dark:text-neutral-300 whitespace-pre-wrap break-words">
{member.response}
</p>
)}
</div>
))}
</div>
{/* Chair synthesis */}
<div className="rounded-lg border border-primary-200 dark:border-primary-500/30 bg-primary-50 dark:bg-primary-500/10 p-3 space-y-1">
<div className="flex items-center justify-between gap-2">
<h4 className="text-xs font-semibold text-stone-800 dark:text-neutral-100">
{t('modelCouncil.synthesisHeading')}
</h4>
<span className="text-[10px] font-mono text-stone-500 dark:text-neutral-400 truncate">
{t('modelCouncil.synthesisBy').replace('{model}', result.chair_model)}
</span>
</div>
<p className="text-sm text-stone-700 dark:text-neutral-200 whitespace-pre-wrap break-words">
{result.synthesis}
</p>
</div>
</section>
)}
</div>
);
};
export default ModelCouncilTab;
+24
View File
@@ -4309,6 +4309,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'مهلة الإدخال (مللي ثانية)',
'autocomplete.maxChars': 'أقصى عدد لأحرف السياق',
'autocomplete.overlayTtlMs': 'مهلة الطبقة (مللي ثانية)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'وسيط',
'graphCohesion.brokerTitle':
'ثقب بنيوي: جيران هذا الكيان غير مترابطين فيما بينهم — وهو الرابط الوحيد بينهم.',
+24
View File
@@ -4383,6 +4383,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'ডিবাউন্স (মিলিসেকেন্ড)',
'autocomplete.maxChars': 'সর্বোচ্চ প্রসঙ্গ অক্ষর',
'autocomplete.overlayTtlMs': 'ওভারলে টাইমআউট (মিলিসেকেন্ড)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'ব্রোকার',
'graphCohesion.brokerTitle':
'কাঠামোগত ছিদ্র: এই সত্তার প্রতিবেশীরা একে অপরের সাথে যুক্ত নয় — এটিই তাদের মধ্যে একমাত্র সংযোগ।',
+24
View File
@@ -4499,6 +4499,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'Entprellung (ms)',
'autocomplete.maxChars': 'Maximale Kontextzeichen',
'autocomplete.overlayTtlMs': 'Overlay-Timeout (ms)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'Broker',
'graphCohesion.brokerTitle':
'Strukturelles Loch: Die Nachbarn dieser Entität sind nicht miteinander verbunden — sie ist die einzige Verknüpfung zwischen ihnen.',
+24
View File
@@ -307,6 +307,30 @@ const en: TranslationMap = {
'memory.tab.timeline': 'Timeline',
'memory.tab.cohesion': 'Cohesion',
'memory.tab.settings': 'Settings',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'memory.analyzeNow': 'Analyze Now',
'namespaceOverview.title': 'Namespace Overview',
'namespaceOverview.intro':
+24
View File
@@ -4469,6 +4469,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'Retardo (ms)',
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
'autocomplete.overlayTtlMs': 'Tiempo de espera de superposición (ms)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'intermediario',
'graphCohesion.brokerTitle':
'Hueco estructural: los vecinos de esta entidad no están conectados entre sí — ella es el único enlace entre ellos.',
+24
View File
@@ -4485,6 +4485,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'Anti-rebond (ms)',
'autocomplete.maxChars': 'Caractères de contexte maximum',
'autocomplete.overlayTtlMs': "Délai d'affichage (ms)",
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'courtier',
'graphCohesion.brokerTitle':
'Trou structurel : les voisins de cette entité ne sont pas connectés entre eux — elle est le seul lien entre eux.',
+24
View File
@@ -4391,6 +4391,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'डिबाउंस (ms)',
'autocomplete.maxChars': 'अधिकतम संदर्भ वर्ण',
'autocomplete.overlayTtlMs': 'ओवरले समय-समाप्ति (ms)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'ब्रोकर',
'graphCohesion.brokerTitle':
'संरचनात्मक छेद: इस इकाई के पड़ोसी आपस में नहीं जुड़े — यह उनके बीच एकमात्र कड़ी है।',
+24
View File
@@ -4400,6 +4400,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'Debounce (md)',
'autocomplete.maxChars': 'Karakter konteks maks',
'autocomplete.overlayTtlMs': 'Batas waktu overlay (md)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
'Lubang struktural: tetangga entitas ini tidak saling terhubung — entitas inilah satu-satunya tautan di antara mereka.',
+24
View File
@@ -4461,6 +4461,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'Debounce (ms)',
'autocomplete.maxChars': 'Caratteri massimi di contesto',
'autocomplete.overlayTtlMs': 'Timeout overlay (ms)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
"Buco strutturale: i vicini di questa entità non sono collegati tra loro — essa è l'unico collegamento tra loro.",
+24
View File
@@ -4350,6 +4350,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': '디바운스 (ms)',
'autocomplete.maxChars': '최대 컨텍스트 문자 수',
'autocomplete.overlayTtlMs': '오버레이 시간 초과 (ms)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': '브로커',
'graphCohesion.brokerTitle':
'구조적 공백: 이 엔티티의 이웃들은 서로 연결되어 있지 않습니다 — 이들 사이의 유일한 연결고리입니다.',
+24
View File
@@ -4458,6 +4458,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'Opóźnienie (ms)',
'autocomplete.maxChars': 'Maksymalna liczba znaków kontekstu',
'autocomplete.overlayTtlMs': 'Limit czasu nakładki (ms)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
'Dziura strukturalna: sąsiedzi tej encji nie są ze sobą połączeni — jest ona jedynym łącznikiem między nimi.',
+24
View File
@@ -4457,6 +4457,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'Atraso (ms)',
'autocomplete.maxChars': 'Máximo de caracteres de contexto',
'autocomplete.overlayTtlMs': 'Tempo limite da sobreposição (ms)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'intermediador',
'graphCohesion.brokerTitle':
'Buraco estrutural: os vizinhos desta entidade não estão conectados entre si — ela é a única ligação entre eles.',
+24
View File
@@ -4427,6 +4427,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': 'Задержка (мс)',
'autocomplete.maxChars': 'Макс. символов контекста',
'autocomplete.overlayTtlMs': 'Тайм-аут наложения (мс)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': 'брокер',
'graphCohesion.brokerTitle':
'Структурная дыра: соседи этой сущности не связаны друг с другом — она единственная связь между ними.',
+24
View File
@@ -4175,6 +4175,30 @@ const messages: TranslationMap = {
'autocomplete.debounceMs': '防抖 (毫秒)',
'autocomplete.maxChars': '最大上下文字符数',
'autocomplete.overlayTtlMs': '覆盖层超时 (ms)',
'memory.tab.council': 'Council',
'modelCouncil.title': 'Model Council',
'modelCouncil.intro':
'Ask one question, get independent answers from several models in parallel, then a chair model synthesizes where they agree, where they disagree, and what each uniquely adds.',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
'modelCouncil.maxMembersNote': 'up to {max}',
'modelCouncil.memberPlaceholder': 'e.g. gpt-5.2',
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.chairLabel': 'Chair model',
'modelCouncil.chairPlaceholder': 'e.g. claude-opus-4-8',
'modelCouncil.chairHelp': 'The chair reads every member answer and writes the synthesis.',
'modelCouncil.run': 'Convene council',
'modelCouncil.running': 'Convening…',
'modelCouncil.runningHint': 'Querying members in parallel, then synthesizing…',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'graphCohesion.brokerBadge': '经纪者',
'graphCohesion.brokerTitle': '结构洞:该实体的邻居彼此之间没有连接——它是它们之间唯一的链接。',
'graphCohesion.colCohesion': '凝聚度',
+6 -1
View File
@@ -11,6 +11,7 @@ import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTa
import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab';
import MemoryTimelineTab from '../components/intelligence/MemoryTimelineTab';
import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace';
import ModelCouncilTab from '../components/intelligence/ModelCouncilTab';
import NamespaceOverviewTab from '../components/intelligence/NamespaceOverviewTab';
import { ToastContainer } from '../components/intelligence/Toast';
import PillTabBar from '../components/PillTabBar';
@@ -38,7 +39,8 @@ type IntelligenceTab =
| 'freshness'
| 'timeline'
| 'path'
| 'namespaces';
| 'namespaces'
| 'council';
export default function Intelligence() {
const { t } = useT();
@@ -124,6 +126,7 @@ export default function Intelligence() {
{ id: 'timeline', label: t('memory.tab.timeline') },
{ id: 'path', label: t('memory.tab.path') },
{ id: 'namespaces', label: t('memory.tab.namespaces') },
{ id: 'council', label: t('memory.tab.council') },
];
const activeTabDef = tabs.find(tab => tab.id === activeTab);
@@ -226,6 +229,8 @@ export default function Intelligence() {
{activeTab === 'path' && <ConnectionPathTab />}
{activeTab === 'namespaces' && <NamespaceOverviewTab />}
{activeTab === 'council' && <ModelCouncilTab />}
</div>
</div>
</div>
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { modelCouncilApi, type ModelCouncilResult, unwrapCouncilEnvelope } from './modelCouncilApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
const RESULT: ModelCouncilResult = {
question: 'What is the capital of France?',
members: [
{ model: 'model-a', response: 'Paris.', error: null },
{ model: 'model-b', response: null, error: 'rate limited' },
],
chair_model: 'chair-model',
synthesis: 'Both agree the capital is Paris (one seat failed).',
};
describe('unwrapCouncilEnvelope', () => {
it('unwraps the { result, logs } CLI envelope', () => {
expect(unwrapCouncilEnvelope({ result: RESULT, logs: ['done'] })).toEqual(RESULT);
});
it('passes a bare result through unchanged', () => {
expect(unwrapCouncilEnvelope(RESULT)).toEqual(RESULT);
});
it('passes through an object with a result field but no logs (not a real envelope)', () => {
// Only `{ result, logs }` (logs array present) is treated as the CLI
// envelope. A bare `{ result }` is returned unchanged, not unwrapped.
const notAnEnvelope = { result: RESULT } as unknown;
expect(unwrapCouncilEnvelope(notAnEnvelope)).toEqual({ result: RESULT });
});
it('does NOT unwrap a null result (guards against { result: null, logs } crashing the UI)', () => {
// A partial-error envelope with a null result must not be unwrapped to
// `null` — that would crash the component on `result.members`. The guard
// returns the payload as-is so the caller surfaces it as a malformed shape.
const withNull = { result: null, logs: ['boom'] } as unknown;
expect(unwrapCouncilEnvelope(withNull)).toEqual({ result: null, logs: ['boom'] });
});
});
describe('modelCouncilApi.runCouncil', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls openhuman.model_council_run with the params + a long timeout', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: RESULT, logs: ['ok'] });
const out = await modelCouncilApi.runCouncil({
question: 'What is the capital of France?',
member_models: ['model-a', 'model-b'],
chair_model: 'chair-model',
temperature: 0.4,
});
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.model_council_run',
params: {
question: 'What is the capital of France?',
member_models: ['model-a', 'model-b'],
chair_model: 'chair-model',
temperature: 0.4,
},
timeoutMs: 180_000,
});
expect(out).toEqual(RESULT);
});
it('returns the unwrapped result when the core wraps it in an envelope', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: RESULT, logs: ['done'] });
const out = await modelCouncilApi.runCouncil({
question: 'q',
member_models: ['a'],
chair_model: 'c',
});
expect(out.members).toHaveLength(2);
expect(out.synthesis).toContain('Paris');
});
it('returns a bare result unchanged when no envelope is present', async () => {
mockCallCoreRpc.mockResolvedValueOnce(RESULT);
const out = await modelCouncilApi.runCouncil({
question: 'q',
member_models: ['a'],
chair_model: 'c',
});
expect(out).toEqual(RESULT);
});
it('propagates errors from the RPC layer', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('all member models failed'));
await expect(
modelCouncilApi.runCouncil({ question: 'q', member_models: ['a'], chair_model: 'c' })
).rejects.toThrow('all member models failed');
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* Typed RPC wrapper for the Model Council domain.
*
* Calls `openhuman.model_council_run` — runs a question through several
* "member" models in parallel, then a "chair" model synthesizes their
* answers (surfacing agreement / disagreement / unique insight).
*
* The core returns the result inside a `{ result, logs }` CLI envelope
* (the handler attaches a completion log), so `runCouncil` unwraps it
* defensively and returns the bare `ModelCouncilResult`.
*/
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('model-council:api');
/** One member model's contribution. `response` and `error` are mutually exclusive. */
export interface CouncilMemberResult {
/** The model id this seat ran. */
model: string;
/** The model's answer, or `null` if the call failed. */
response: string | null;
/** The failure message, or `null` on success. */
error: string | null;
}
/** Full council result: every member's answer plus the chair synthesis. */
export interface ModelCouncilResult {
question: string;
members: CouncilMemberResult[];
chair_model: string;
synthesis: string;
}
export interface RunCouncilParams {
question: string;
/** Member model ids to consult (deduplicated + capped server-side). */
member_models: string[];
/** Model id that synthesizes the member answers. */
chair_model: string;
/** Optional sampling temperature applied to every call. */
temperature?: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
/**
* Unwrap the core's `{ result, logs }` CLI envelope. Returns the inner
* `result` when the envelope shape is present, otherwise passes the payload
* through unchanged — so the caller is correct whether or not the handler
* attached logs.
*/
export function unwrapCouncilEnvelope(payload: unknown): ModelCouncilResult {
const record = asRecord(payload);
if (
record &&
'result' in record &&
record.result != null &&
'logs' in record &&
Array.isArray(record.logs)
) {
return record.result as ModelCouncilResult;
}
return payload as ModelCouncilResult;
}
export const modelCouncilApi = {
runCouncil: async (params: RunCouncilParams): Promise<ModelCouncilResult> => {
log(
'run question=%s members=%o chair=%s',
params.question.slice(0, 40),
params.member_models,
params.chair_model
);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.model_council_run',
params,
// Member calls run in parallel but each is a full model round-trip plus
// a synthesis pass — give it well beyond the default 30s before timing out.
timeoutMs: 180_000,
});
const result = unwrapCouncilEnvelope(payload);
log(
'run done: %d members, synthesis %d chars',
result.members?.length ?? 0,
result.synthesis?.length ?? 0
);
return result;
},
};
@@ -7,6 +7,10 @@ test.describe('Insights Dashboard', () => {
await bootAuthenticatedPage(page, 'pw-insights-user', '/intelligence');
await waitForAppReady(page);
// The Intelligence page now defaults to the "Agent Tasks" tab (#2998), so
// select the Memory tab before asserting its workspace renders.
await page.getByRole('tab', { name: 'Memory', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Memory', exact: true })).toBeVisible();
await expect(page.locator('[data-testid="memory-workspace"]')).toBeVisible();
await expect(page.locator('[data-testid="memory-actions"]')).toBeVisible();
+3
View File
@@ -169,6 +169,8 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
controllers.extend(crate::openhuman::service::all_service_registered_controllers());
// Data migration utilities
controllers.extend(crate::openhuman::migration::all_migration_registered_controllers());
// Model Council: multi-model deliberation (parallel members + chair synthesis)
controllers.extend(crate::openhuman::model_council::all_model_council_registered_controllers());
// Unified inference domain: text / vision / local runtime / cloud providers.
// (Formerly split across inference, local_ai, and providers namespaces.)
controllers.extend(crate::openhuman::inference::all_inference_registered_controllers());
@@ -327,6 +329,7 @@ fn build_declared_controller_schemas() -> Vec<ControllerSchema> {
schemas.extend(crate::openhuman::credentials::all_credentials_controller_schemas());
schemas.extend(crate::openhuman::service::all_service_controller_schemas());
schemas.extend(crate::openhuman::migration::all_migration_controller_schemas());
schemas.extend(crate::openhuman::model_council::all_model_council_controller_schemas());
schemas.extend(crate::openhuman::inference::all_inference_controller_schemas());
schemas.extend(crate::openhuman::inference::all_local_ai_controller_schemas());
schemas.extend(crate::openhuman::embeddings::all_embeddings_controller_schemas());
+1
View File
@@ -73,6 +73,7 @@ pub mod memory_tools;
pub mod memory_tree;
pub mod migration;
pub mod migrations;
pub mod model_council;
pub mod notifications;
pub mod overlay;
pub mod people;
+419
View File
@@ -0,0 +1,419 @@
//! Model Council — multi-model deliberation core.
//!
//! A "council" runs one user question against several **member** models
//! concurrently (each a single-shot, tool-free completion via the existing
//! [`agent_chat_simple`] primitive), then asks a single **chair** model to
//! synthesize the member answers into one response that surfaces where the
//! models **agree**, where they **disagree**, and what unique insight each
//! contributes.
//!
//! ## Why single-shot (not the full agent loop)
//!
//! Council members are deliberately *plain* completions: no tools, no memory
//! writes, no multi-round agent loop. The value of a council is independent
//! perspectives on the same prompt, so each member must see exactly the same
//! input and nothing else. Reusing [`agent_chat_simple`] (which itself does a
//! single `chat_with_system(None, …)` call) keeps every member identical and
//! avoids duplicating provider-resolution logic here.
//!
//! ## Partial failure is tolerated, total failure is not
//!
//! If one member errors (model unavailable, rate-limited, …) the council still
//! proceeds: that seat is recorded as an error and the chair is told the seat
//! was empty. Only when **every** member fails do we abort — synthesizing from
//! zero answers would be meaningless.
//!
//! The pure helpers ([`validate_council_request`], [`normalize_member_models`],
//! [`build_synthesis_prompt`], [`all_members_failed`]) are split out from the
//! I/O orchestrator ([`run_council`]) so the deliberation logic is unit-tested
//! without any network or provider.
use serde::Serialize;
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::rpc::agent_chat_simple;
use crate::rpc::RpcOutcome;
/// Upper bound on how many member models a single council run may fan out to.
///
/// Each member is a real model call, so an unbounded list would let one RPC
/// spawn arbitrarily many concurrent completions. Five is generous for the
/// "compare a few frontier models" use case while keeping cost bounded.
pub const MAX_COUNCIL_MEMBERS: usize = 5;
/// One member model's contribution to a council run.
///
/// `response` and `error` are mutually exclusive: exactly one is `Some`.
/// Both are serialized (as `null` when absent) so the importer/UI can render a
/// stable shape without guessing which key exists.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct CouncilMemberResult {
/// The model id this seat ran (the resolved override passed to the provider).
pub model: String,
/// The model's answer, or `None` if the call failed.
pub response: Option<String>,
/// The failure message, or `None` on success.
pub error: Option<String>,
}
/// Full result of a council run: every member's answer plus the chair synthesis.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ModelCouncilResult {
/// The original user question, echoed back for display / logging.
pub question: String,
/// One entry per (normalized) member model, in input order.
pub members: Vec<CouncilMemberResult>,
/// The model id that produced the synthesis.
pub chair_model: String,
/// The chair's synthesized answer over the member responses.
pub synthesis: String,
}
/// Normalize the requested member model list: trim each id, drop blanks, and
/// de-duplicate while preserving first-seen order.
///
/// De-duplication matters because the result is keyed by model id in the UI;
/// two identical seats would collide and also waste a model call. PURE.
pub fn normalize_member_models(member_models: &[String]) -> Vec<String> {
let mut seen: Vec<String> = Vec::new();
for raw in member_models {
let trimmed = raw.trim();
if trimmed.is_empty() {
continue;
}
if !seen.iter().any(|m| m.as_str() == trimmed) {
seen.push(trimmed.to_string());
}
}
seen
}
/// Validate a council request against the *normalized* member list. PURE.
///
/// Returns a stable, human-readable error string on the first violation so the
/// JSON-RPC layer can surface it directly.
pub fn validate_council_request(
question: &str,
normalized_members: &[String],
chair_model: &str,
) -> Result<(), String> {
if question.trim().is_empty() {
return Err("model council: question must not be empty".to_string());
}
if normalized_members.is_empty() {
return Err("model council: at least one member model is required".to_string());
}
if normalized_members.len() > MAX_COUNCIL_MEMBERS {
return Err(format!(
"model council: too many member models ({}, max {})",
normalized_members.len(),
MAX_COUNCIL_MEMBERS
));
}
if chair_model.trim().is_empty() {
return Err("model council: chair model must not be empty".to_string());
}
Ok(())
}
/// True when every member failed, so synthesis would have nothing to work with.
///
/// An empty slice is NOT "all failed" (there were no seats to fail); callers
/// validate non-emptiness separately. PURE.
pub fn all_members_failed(members: &[CouncilMemberResult]) -> bool {
!members.is_empty() && members.iter().all(|m| m.response.is_none())
}
/// Build the chair's synthesis prompt from the question + member answers. PURE.
///
/// Failed seats are surfaced explicitly (as "[no response: …]") so the chair
/// knows a perspective is missing rather than silently synthesizing from fewer
/// answers than the user asked for. Members are labeled "Model A/B/C…" rather
/// than by raw id to keep the chair focused on the *content* of each answer.
pub fn build_synthesis_prompt(question: &str, members: &[CouncilMemberResult]) -> String {
let mut prompt = String::new();
prompt.push_str(
"You are the chair of a model council. Several AI models were each asked \
the SAME question independently. Your job is to synthesize their answers \
into one clear, balanced response for the user.\n\n",
);
prompt.push_str("Original question:\n");
prompt.push_str(question.trim());
prompt.push_str("\n\n");
prompt.push_str("Member answers:\n");
for (i, member) in members.iter().enumerate() {
let label = council_member_label(i);
prompt.push_str(&format!("\n--- Model {label} ---\n"));
match &member.response {
Some(text) => prompt.push_str(text.trim()),
None => {
let reason = member.error.as_deref().unwrap_or("unknown error");
prompt.push_str(&format!("[no response: {reason}]"));
}
}
prompt.push('\n');
}
prompt.push_str(
"\nNow write the synthesis. Explicitly cover:\n\
1. Where the models AGREE (the consensus the user can rely on).\n\
2. Where they DISAGREE or diverge (and, if you can tell, which view is \
better supported).\n\
3. Any unique insight a single model contributed that the others missed.\n\
End with a concise bottom-line recommendation. Do not invent agreement \
that is not present; if the answers genuinely conflict, say so plainly.",
);
prompt
}
/// Map a zero-based member index to a stable display label: A, B, …, Z, then
/// AA, AB, … (spreadsheet-column style). Used only to label answers for the
/// chair; never parsed back. PURE.
fn council_member_label(index: usize) -> String {
let mut n = index;
let mut label = String::new();
loop {
label.insert(0, (b'A' + (n % 26) as u8) as char);
if n < 26 {
break;
}
n = n / 26 - 1;
}
label
}
/// Run a full council: fan out to every member concurrently, then synthesize.
///
/// Reuses [`agent_chat_simple`] for both the member calls and the chair call so
/// provider resolution, prompt-injection guarding, and temperature handling are
/// all inherited unchanged. Member calls run concurrently via
/// [`futures_util::future::join_all`]; wall-clock is the slowest single member,
/// not their sum.
pub async fn run_council(
config: &Config,
question: &str,
member_models: &[String],
chair_model: &str,
temperature: Option<f64>,
) -> Result<RpcOutcome<ModelCouncilResult>, String> {
let models = normalize_member_models(member_models);
validate_council_request(question, &models, chair_model)?;
log::debug!(
"[model-council] run_council: question_len={}, members={}, chair={}, temp={:?}",
question.len(),
models.len(),
chair_model,
temperature
);
// Fan out: each member answers the SAME question as an independent
// single-shot completion. A per-member failure is captured in-band as an
// error seat rather than aborting the whole council.
let member_futures = models.iter().map(|model| {
let model = model.clone();
async move {
match agent_chat_simple(config, question, Some(model.clone()), temperature).await {
Ok(outcome) => CouncilMemberResult {
model,
response: Some(outcome.value),
error: None,
},
Err(e) => CouncilMemberResult {
model,
response: None,
error: Some(e),
},
}
}
});
let members: Vec<CouncilMemberResult> = futures_util::future::join_all(member_futures).await;
let success_count = members.iter().filter(|m| m.response.is_some()).count();
log::debug!(
"[model-council] member results: success={}, failed={}",
success_count,
members.len() - success_count
);
if all_members_failed(&members) {
log::debug!("[model-council] all members failed; aborting before synthesis");
return Err("model council: all member models failed to respond".to_string());
}
let synthesis_prompt = build_synthesis_prompt(question, &members);
log::debug!("[model-council] convening chair model: {chair_model}");
let synthesis = agent_chat_simple(
config,
&synthesis_prompt,
Some(chair_model.to_string()),
temperature,
)
.await
.map_err(|e| format!("model council: chair synthesis failed: {e}"))?
.value;
log::debug!(
"[model-council] synthesis complete: {} chars",
synthesis.len()
);
let result = ModelCouncilResult {
question: question.to_string(),
members,
chair_model: chair_model.to_string(),
synthesis,
};
Ok(RpcOutcome::single_log(
result,
"model council synthesis completed",
))
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn ok_member(model: &str, response: &str) -> CouncilMemberResult {
CouncilMemberResult {
model: model.to_string(),
response: Some(response.to_string()),
error: None,
}
}
fn err_member(model: &str, error: &str) -> CouncilMemberResult {
CouncilMemberResult {
model: model.to_string(),
response: None,
error: Some(error.to_string()),
}
}
#[test]
fn normalize_trims_drops_blanks_and_dedups_preserving_order() {
let input = vec![
" gpt ".to_string(),
"claude".to_string(),
"".to_string(),
" ".to_string(),
"gpt".to_string(), // dup of trimmed " gpt "
"gemini".to_string(),
"claude".to_string(), // dup
];
let out = normalize_member_models(&input);
assert_eq!(out, vec!["gpt", "claude", "gemini"]);
}
#[test]
fn validate_rejects_empty_question() {
let members = vec!["a".to_string()];
let err = validate_council_request(" ", &members, "chair").unwrap_err();
assert!(err.contains("question"));
}
#[test]
fn validate_rejects_no_members() {
let err = validate_council_request("q", &[], "chair").unwrap_err();
assert!(err.contains("at least one member"));
}
#[test]
fn validate_rejects_too_many_members() {
let members: Vec<String> = (0..(MAX_COUNCIL_MEMBERS + 1))
.map(|i| format!("m{i}"))
.collect();
let err = validate_council_request("q", &members, "chair").unwrap_err();
assert!(err.contains("too many"));
}
#[test]
fn validate_rejects_blank_chair() {
let members = vec!["a".to_string()];
let err = validate_council_request("q", &members, " ").unwrap_err();
assert!(err.contains("chair"));
}
#[test]
fn validate_accepts_well_formed_request() {
let members = vec!["a".to_string(), "b".to_string()];
assert!(validate_council_request("q", &members, "chair").is_ok());
}
#[test]
fn all_members_failed_is_false_when_any_succeeds() {
let members = vec![err_member("a", "boom"), ok_member("b", "hi")];
assert!(!all_members_failed(&members));
}
#[test]
fn all_members_failed_is_true_only_when_every_seat_failed() {
let members = vec![err_member("a", "boom"), err_member("b", "nope")];
assert!(all_members_failed(&members));
}
#[test]
fn all_members_failed_is_false_for_empty_slice() {
assert!(!all_members_failed(&[]));
}
#[test]
fn synthesis_prompt_includes_question_and_each_answer() {
let members = vec![
ok_member("gpt", "Paris is the capital."),
ok_member("claude", "The capital is Paris."),
];
let prompt = build_synthesis_prompt("What is the capital of France?", &members);
assert!(prompt.contains("What is the capital of France?"));
assert!(prompt.contains("Paris is the capital."));
assert!(prompt.contains("The capital is Paris."));
assert!(prompt.contains("Model A"));
assert!(prompt.contains("Model B"));
// The chair must be instructed to surface agreement + disagreement.
assert!(prompt.contains("AGREE"));
assert!(prompt.contains("DISAGREE"));
}
#[test]
fn synthesis_prompt_marks_failed_seats_with_their_error() {
let members = vec![
ok_member("gpt", "ok answer"),
err_member("claude", "rate limited"),
];
let prompt = build_synthesis_prompt("q", &members);
assert!(prompt.contains("[no response: rate limited]"));
assert!(prompt.contains("ok answer"));
}
#[test]
fn member_labels_are_spreadsheet_style() {
assert_eq!(council_member_label(0), "A");
assert_eq!(council_member_label(1), "B");
assert_eq!(council_member_label(25), "Z");
assert_eq!(council_member_label(26), "AA");
assert_eq!(council_member_label(27), "AB");
}
#[test]
fn result_serializes_with_stable_keys_and_null_for_absent_fields() {
let result = ModelCouncilResult {
question: "q".to_string(),
members: vec![ok_member("gpt", "answer"), err_member("claude", "boom")],
chair_model: "chair-model".to_string(),
synthesis: "the synthesis".to_string(),
};
let json: Value = serde_json::to_value(&result).unwrap();
assert_eq!(json["question"], "q");
assert_eq!(json["chair_model"], "chair-model");
assert_eq!(json["synthesis"], "the synthesis");
let members = json["members"].as_array().unwrap();
assert_eq!(members.len(), 2);
// Success seat: response set, error null.
assert_eq!(members[0]["model"], "gpt");
assert_eq!(members[0]["response"], "answer");
assert!(members[0]["error"].is_null());
// Failure seat: response null, error set.
assert_eq!(members[1]["model"], "claude");
assert!(members[1]["response"].is_null());
assert_eq!(members[1]["error"], "boom");
}
}
+14
View File
@@ -0,0 +1,14 @@
//! Model Council — run one question through several models in parallel and
//! synthesize their answers with a chair model.
//!
//! See [`council`] for the deliberation core (pure helpers + the
//! [`council::run_council`] orchestrator) and [`schemas`] for the JSON-RPC
//! controller surface (`openhuman.model_council_run`).
pub mod council;
mod schemas;
pub use schemas::{
all_controller_schemas as all_model_council_controller_schemas,
all_registered_controllers as all_model_council_registered_controllers,
};
+238
View File
@@ -0,0 +1,238 @@
//! JSON-RPC controller surface for the Model Council.
//!
//! Exposes a single method, `openhuman.model_council_run`, which takes a
//! question + a list of member model ids + a chair model id, runs the council
//! (see [`crate::openhuman::model_council::council`]), and returns the
//! aggregated result synchronously in the JSON-RPC response.
use serde::de::DeserializeOwned;
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::core::all::{ControllerFuture, RegisteredController};
use crate::core::{ControllerSchema, FieldSchema, TypeSchema};
use crate::openhuman::config::rpc as config_rpc;
use crate::rpc::RpcOutcome;
#[derive(Debug, Deserialize)]
struct ModelCouncilParams {
question: String,
member_models: Vec<String>,
chair_model: String,
temperature: Option<f64>,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![schemas("run")]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![RegisteredController {
schema: schemas("run"),
handler: handle_run,
}]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"run" => ControllerSchema {
namespace: "model_council",
function: "run",
description: "Run a question through several member models concurrently, then \
synthesize their answers with a chair model. Returns each member's \
answer (or error) plus the chair's synthesis.",
inputs: vec![
required_string("question", "The question to put to the council."),
required_string_array(
"member_models",
"Member model ids to consult (deduplicated; max 5).",
),
required_string(
"chair_model",
"Model id that synthesizes the member answers.",
),
optional_f64(
"temperature",
"Optional sampling temperature for all calls.",
),
],
outputs: vec![json_output(
"result",
"Council result: per-member answers + chair synthesis.",
)],
},
_ => ControllerSchema {
namespace: "model_council",
function: "unknown",
description: "Unknown model_council controller function.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Error message.",
required: true,
}],
},
}
}
fn handle_run(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!("[model-council] handle_run: received RPC request");
let p = deserialize_params::<ModelCouncilParams>(params)?;
// Log a sanitized summary only — never the full question text.
log::debug!(
"[model-council] handle_run: question_len={}, members={}, chair={}",
p.question.len(),
p.member_models.len(),
p.chair_model
);
let config = config_rpc::load_config_with_timeout().await?;
to_json(
crate::openhuman::model_council::council::run_council(
&config,
&p.question,
&p.member_models,
&p.chair_model,
p.temperature,
)
.await
.map_err(|e| {
log::debug!("[model-council] handle_run: run_council failed: {e}");
e
})?,
)
})
}
fn deserialize_params<T: DeserializeOwned>(params: Map<String, Value>) -> Result<T, String> {
serde_json::from_value(Value::Object(params)).map_err(|e| format!("invalid params: {e}"))
}
fn required_string(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::String,
comment,
required: true,
}
}
fn required_string_array(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Array(Box::new(TypeSchema::String)),
comment,
required: true,
}
}
fn optional_f64(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Option(Box::new(TypeSchema::F64)),
comment,
required: false,
}
}
fn json_output(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}
fn to_json<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {
outcome.into_cli_compatible_json()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn controller_schema_inventory_is_stable() {
let schemas = all_controller_schemas();
let functions: Vec<_> = schemas.iter().map(|schema| schema.function).collect();
assert_eq!(functions, vec!["run"]);
assert_eq!(schemas.len(), all_registered_controllers().len());
}
#[test]
fn run_schema_exposes_expected_inputs_and_method_name() {
let run = schemas("run");
assert_eq!(run.namespace, "model_council");
assert_eq!(run.function, "run");
assert_eq!(
crate::core::all::rpc_method_name(&run),
"openhuman.model_council_run"
);
assert_eq!(run.inputs.len(), 4);
assert!(run
.inputs
.iter()
.any(|input| input.name == "question" && input.required));
let members = run
.inputs
.iter()
.find(|input| input.name == "member_models")
.expect("member_models input present");
assert!(matches!(members.ty, TypeSchema::Array(_)));
assert!(members.required);
let temperature = run
.inputs
.iter()
.find(|input| input.name == "temperature")
.expect("temperature input present");
assert!(!temperature.required);
assert!(matches!(temperature.ty, TypeSchema::Option(_)));
}
#[test]
fn unknown_function_falls_back_to_error_output() {
let unknown = schemas("nope");
assert_eq!(unknown.function, "unknown");
assert_eq!(unknown.outputs[0].name, "error");
}
#[test]
fn deserialize_params_parses_a_well_formed_payload() {
let params = Map::from_iter([
("question".to_string(), Value::from("What is 2+2?")),
(
"member_models".to_string(),
Value::from(vec![Value::from("gpt"), Value::from("claude")]),
),
("chair_model".to_string(), Value::from("chair")),
("temperature".to_string(), Value::from(0.5)),
]);
let parsed = deserialize_params::<ModelCouncilParams>(params).unwrap();
assert_eq!(parsed.question, "What is 2+2?");
assert_eq!(parsed.member_models, vec!["gpt", "claude"]);
assert_eq!(parsed.chair_model, "chair");
assert_eq!(parsed.temperature, Some(0.5));
}
#[test]
fn deserialize_params_allows_omitted_temperature() {
let params = Map::from_iter([
("question".to_string(), Value::from("q")),
(
"member_models".to_string(),
Value::from(vec![Value::from("a")]),
),
("chair_model".to_string(), Value::from("chair")),
]);
let parsed = deserialize_params::<ModelCouncilParams>(params).unwrap();
assert_eq!(parsed.temperature, None);
}
#[test]
fn deserialize_params_rejects_missing_required_field() {
let params = Map::from_iter([("question".to_string(), Value::from("q"))]);
assert!(deserialize_params::<ModelCouncilParams>(params).is_err());
}
}