+ );
+};
+
+export default ModelCouncilTab;
diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts
index 28d9df88b..73253f4d0 100644
--- a/app/src/lib/i18n/ar.ts
+++ b/app/src/lib/i18n/ar.ts
@@ -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':
'ثقب بنيوي: جيران هذا الكيان غير مترابطين فيما بينهم — وهو الرابط الوحيد بينهم.',
diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts
index 804e7646e..045c9d138 100644
--- a/app/src/lib/i18n/bn.ts
+++ b/app/src/lib/i18n/bn.ts
@@ -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':
'কাঠামোগত ছিদ্র: এই সত্তার প্রতিবেশীরা একে অপরের সাথে যুক্ত নয় — এটিই তাদের মধ্যে একমাত্র সংযোগ।',
diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts
index a1528c17f..872255754 100644
--- a/app/src/lib/i18n/de.ts
+++ b/app/src/lib/i18n/de.ts
@@ -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.',
diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts
index 14c905173..d3a55d66d 100644
--- a/app/src/lib/i18n/en.ts
+++ b/app/src/lib/i18n/en.ts
@@ -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':
diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts
index 4f74f1278..ef87e7c28 100644
--- a/app/src/lib/i18n/es.ts
+++ b/app/src/lib/i18n/es.ts
@@ -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.',
diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts
index fe1704053..603dffd93 100644
--- a/app/src/lib/i18n/fr.ts
+++ b/app/src/lib/i18n/fr.ts
@@ -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.',
diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts
index e1ad6294e..60b8a17cb 100644
--- a/app/src/lib/i18n/hi.ts
+++ b/app/src/lib/i18n/hi.ts
@@ -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':
'संरचनात्मक छेद: इस इकाई के पड़ोसी आपस में नहीं जुड़े — यह उनके बीच एकमात्र कड़ी है।',
diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts
index 946d14bf2..eb452dcd1 100644
--- a/app/src/lib/i18n/id.ts
+++ b/app/src/lib/i18n/id.ts
@@ -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.',
diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts
index a4802da2f..eb0461bfb 100644
--- a/app/src/lib/i18n/it.ts
+++ b/app/src/lib/i18n/it.ts
@@ -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.",
diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts
index 4a225fb71..b419f9f98 100644
--- a/app/src/lib/i18n/ko.ts
+++ b/app/src/lib/i18n/ko.ts
@@ -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':
'구조적 공백: 이 엔티티의 이웃들은 서로 연결되어 있지 않습니다 — 이들 사이의 유일한 연결고리입니다.',
diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts
index 7232f2219..37e949170 100644
--- a/app/src/lib/i18n/pl.ts
+++ b/app/src/lib/i18n/pl.ts
@@ -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.',
diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts
index 9c46e58c2..0ccee728d 100644
--- a/app/src/lib/i18n/pt.ts
+++ b/app/src/lib/i18n/pt.ts
@@ -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.',
diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts
index dbdb891c2..355ea7ee3 100644
--- a/app/src/lib/i18n/ru.ts
+++ b/app/src/lib/i18n/ru.ts
@@ -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':
'Структурная дыра: соседи этой сущности не связаны друг с другом — она единственная связь между ними.',
diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts
index 081809c82..a1cbc6a97 100644
--- a/app/src/lib/i18n/zh-CN.ts
+++ b/app/src/lib/i18n/zh-CN.ts
@@ -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': '凝聚度',
diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx
index 7d2410a3c..923564621 100644
--- a/app/src/pages/Intelligence.tsx
+++ b/app/src/pages/Intelligence.tsx
@@ -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' && }
{activeTab === 'namespaces' && }
+
+ {activeTab === 'council' && }
diff --git a/app/src/services/api/modelCouncilApi.test.ts b/app/src/services/api/modelCouncilApi.test.ts
new file mode 100644
index 000000000..da8d25de7
--- /dev/null
+++ b/app/src/services/api/modelCouncilApi.test.ts
@@ -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');
+ });
+});
diff --git a/app/src/services/api/modelCouncilApi.ts b/app/src/services/api/modelCouncilApi.ts
new file mode 100644
index 000000000..368e16436
--- /dev/null
+++ b/app/src/services/api/modelCouncilApi.ts
@@ -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 | null {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return null;
+ }
+ return value as Record;
+}
+
+/**
+ * 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 => {
+ log(
+ 'run question=%s members=%o chair=%s',
+ params.question.slice(0, 40),
+ params.member_models,
+ params.chair_model
+ );
+ const payload = await callCoreRpc({
+ 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;
+ },
+};
diff --git a/app/test/playwright/specs/insights-dashboard.spec.ts b/app/test/playwright/specs/insights-dashboard.spec.ts
index e586eb1fe..69f1ae005 100644
--- a/app/test/playwright/specs/insights-dashboard.spec.ts
+++ b/app/test/playwright/specs/insights-dashboard.spec.ts
@@ -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();
diff --git a/src/core/all.rs b/src/core/all.rs
index 73a784742..82400c88c 100644
--- a/src/core/all.rs
+++ b/src/core/all.rs
@@ -169,6 +169,8 @@ fn build_registered_controllers() -> Vec {
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 {
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());
diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs
index 42cd189d2..96d295b71 100644
--- a/src/openhuman/mod.rs
+++ b/src/openhuman/mod.rs
@@ -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;
diff --git a/src/openhuman/model_council/council.rs b/src/openhuman/model_council/council.rs
new file mode 100644
index 000000000..b27737bb3
--- /dev/null
+++ b/src/openhuman/model_council/council.rs
@@ -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,
+ /// The failure message, or `None` on success.
+ pub error: Option,
+}
+
+/// 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,
+ /// 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 {
+ let mut seen: Vec = 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,
+) -> Result, 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 = 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 = (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");
+ }
+}
diff --git a/src/openhuman/model_council/mod.rs b/src/openhuman/model_council/mod.rs
new file mode 100644
index 000000000..988b9aee1
--- /dev/null
+++ b/src/openhuman/model_council/mod.rs
@@ -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,
+};
diff --git a/src/openhuman/model_council/schemas.rs b/src/openhuman/model_council/schemas.rs
new file mode 100644
index 000000000..61f503be3
--- /dev/null
+++ b/src/openhuman/model_council/schemas.rs
@@ -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,
+ chair_model: String,
+ temperature: Option,
+}
+
+pub fn all_controller_schemas() -> Vec {
+ vec![schemas("run")]
+}
+
+pub fn all_registered_controllers() -> Vec {
+ 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) -> ControllerFuture {
+ Box::pin(async move {
+ log::debug!("[model-council] handle_run: received RPC request");
+ let p = deserialize_params::(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(params: Map) -> Result {
+ 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(outcome: RpcOutcome) -> Result {
+ 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::(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::(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::(params).is_err());
+ }
+}