Improve council agent UX and persistence (#3393)

This commit is contained in:
Steven Enamakel
2026-06-05 10:04:13 -04:00
committed by GitHub
parent 5d2a4f934f
commit c0ac77fcac
32 changed files with 5635 additions and 307 deletions
@@ -1,134 +1,650 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ModelCouncilResult } from '../../services/api/modelCouncilApi';
import type { CouncilDefinition } from '../../services/api/councilRegistryApi';
import type { CouncilMemberResult, ModelCouncilResult } from '../../services/api/modelCouncilApi';
import ModelCouncilTab from './ModelCouncilTab';
const mockRunCouncil = vi.fn();
const mockListCouncils = vi.fn();
const mockUpsertCouncil = vi.fn();
const mockDeleteCouncil = vi.fn();
const mockAnswerMember = vi.fn();
const mockSynthesizeCouncil = vi.fn();
const mockLoadAISettings = vi.fn();
const mockLoadLocalProviderSnapshot = vi.fn();
const mockListProviderModels = vi.fn();
const mockDispatch = vi.fn();
const mockState = {
agentProfiles: {
profiles: [
{
id: 'default',
name: 'Default Agent',
description: 'Default',
agentId: 'openhuman.default',
modelOverride: 'profile-model',
builtIn: true,
},
{
id: 'critic',
name: 'Critic',
description: 'Finds gaps',
agentId: 'critic-agent',
modelOverride: 'critic-model',
builtIn: false,
},
],
activeProfileId: 'default',
status: 'idle',
error: null,
},
};
vi.mock('../../services/api/modelCouncilApi', () => ({
modelCouncilApi: { runCouncil: (...args: unknown[]) => mockRunCouncil(...args) },
modelCouncilApi: {
answerMember: (...args: unknown[]) => mockAnswerMember(...args),
synthesizeCouncil: (...args: unknown[]) => mockSynthesizeCouncil(...args),
},
}));
vi.mock('../../services/api/councilRegistryApi', () => ({
councilRegistryApi: {
list: (...args: unknown[]) => mockListCouncils(...args),
upsert: (...args: unknown[]) => mockUpsertCouncil(...args),
delete: (...args: unknown[]) => mockDeleteCouncil(...args),
},
}));
vi.mock('../../services/api/aiSettingsApi', () => ({
loadAISettings: (...args: unknown[]) => mockLoadAISettings(...args),
loadLocalProviderSnapshot: (...args: unknown[]) => mockLoadLocalProviderSnapshot(...args),
listProviderModels: (...args: unknown[]) => mockListProviderModels(...args),
}));
vi.mock('../../store/hooks', () => ({
useAppDispatch: () => mockDispatch,
useAppSelector: (selector: (state: typeof mockState) => unknown) => selector(mockState),
}));
vi.mock('../../features/human/Mascot', () => ({
RiveMascot: ({ face }: { face?: string }) => <div data-testid="rive-mascot" data-face={face} />,
getMascotPalette: () => ({ bodyFill: '#F7D145', neckShadowColor: '#B23C05' }),
hexToArgbInt: () => 0xfff7d145,
}));
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' },
{ model: 'gpt-5.2', response: 'Paris is the capital.', error: null },
{ model: 'critic-model', response: null, error: 'rate limited' },
],
chair_model: 'chair-model',
chair_model: 'claude-opus-4-8',
synthesis: 'Both that answered agree: Paris. One seat failed.',
};
const fillValidForm = () => {
const DEFAULT_MEMBERS: CouncilMemberResult[] = [
{ model: 'reasoning-v1', response: 'Paris is the capital.', error: null },
{ model: 'reasoning-v1', response: 'France uses Paris as its capital.', error: null },
{ model: 'reasoning-v1', response: 'The answer is Paris.', error: null },
];
const DEFAULT_COUNCIL: CouncilDefinition = {
id: 'default-council',
name: 'Default council',
description: 'Balanced analyst, builder, and skeptic jury.',
jury_count: 3,
debate_rounds: 3,
seats: [
{
id: 0,
mode: 'default',
profile_id: '',
name: 'Analyst',
model: 'reasoning-v1',
brief: 'Evidence, assumptions, and risk.',
},
{
id: 1,
mode: 'default',
profile_id: '',
name: 'Builder',
model: 'reasoning-v1',
brief: 'Practical implementation path.',
},
{
id: 2,
mode: 'default',
profile_id: '',
name: 'Skeptic',
model: 'reasoning-v1',
brief: 'Failure modes and missing context.',
},
],
judge: { mode: 'default', profile_id: '', name: 'Chief Judge', model: 'reasoning-v1' },
shared_reasoning: [
'# Shared reasoning',
'- Claims the council agrees on:',
'- Open disagreements:',
'- Evidence or constraints to preserve:',
'- Judge synthesis notes:',
].join('\n'),
created_at_ms: 1,
updated_at_ms: 1,
};
const fillQuestion = () => {
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' } });
};
const mockProgressiveSuccess = (members: CouncilMemberResult[] = DEFAULT_MEMBERS) => {
mockAnswerMember.mockImplementation(async ({ model }: { model: string }) => {
const index = mockAnswerMember.mock.calls.length - 1;
return members[index] ?? { model, response: `answer ${index + 1}`, error: null };
});
mockSynthesizeCouncil.mockResolvedValue(RESULT);
};
const renderCouncilList = async () => {
render(<ModelCouncilTab />);
await screen.findByRole('button', { name: 'Open council' });
};
const renderOpenCouncil = async () => {
await renderCouncilList();
fireEvent.click(screen.getByRole('button', { name: 'Open council' }));
await screen.findByLabelText('Question');
};
const renderEditCouncil = async () => {
await renderOpenCouncil();
fireEvent.click(screen.getByRole('button', { name: 'Edit current council' }));
await screen.findByLabelText('Council name');
};
const saveCouncilSettings = async () => {
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Save council' }));
});
await screen.findByLabelText('Question');
};
describe('ModelCouncilTab', () => {
beforeEach(() => {
mockRunCouncil.mockReset();
mockListCouncils.mockReset();
mockUpsertCouncil.mockReset();
mockDeleteCouncil.mockReset();
mockAnswerMember.mockReset();
mockSynthesizeCouncil.mockReset();
mockLoadAISettings.mockReset();
mockLoadLocalProviderSnapshot.mockReset();
mockListProviderModels.mockReset();
mockDispatch.mockReset();
mockListCouncils.mockResolvedValue([DEFAULT_COUNCIL]);
mockUpsertCouncil.mockImplementation(async council => ({
...council,
id: council.id || 'saved',
}));
mockDeleteCouncil.mockResolvedValue(true);
mockLoadAISettings.mockResolvedValue({
cloudProviders: [
{
id: 'openai-id',
slug: 'openai',
label: 'OpenAI',
endpoint: 'https://api.openai.com/v1',
auth_style: 'bearer',
has_api_key: true,
},
{
id: 'anthropic-id',
slug: 'anthropic',
label: 'Anthropic',
endpoint: 'https://api.anthropic.com/v1',
auth_style: 'anthropic',
has_api_key: false,
},
],
routing: {},
});
mockLoadLocalProviderSnapshot.mockResolvedValue({
status: null,
diagnostics: null,
presets: null,
installedModels: [{ name: 'llama3.2:latest', chat_capable: true }],
});
mockListProviderModels.mockImplementation(async (provider: string) => {
if (provider === 'openhuman') return [{ id: 'managed-reasoning' }];
if (provider === 'openai') return [{ id: 'gpt-4o' }, { id: 'gpt-4o-mini' }];
return [];
});
});
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('renders the council list first, then opens the default council', async () => {
await renderCouncilList();
expect(screen.getByText('Councils')).toBeInTheDocument();
expect(screen.getByText('Default council')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Open council' }));
await screen.findByLabelText('Question');
expect(screen.getByText('Default council')).toBeInTheDocument();
expect(screen.queryByText('Council settings')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Debate turns')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Shared reasoning file')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Convene council' })).toBeInTheDocument();
});
it('disables Convene until question + a member + chair are all filled', () => {
render(<ModelCouncilTab />);
it('allows the default council to be deleted from the persisted registry', async () => {
await renderCouncilList();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Delete Default council' }));
});
expect(mockDeleteCouncil).toHaveBeenCalledWith('default-council');
expect(screen.queryByText('Default council')).not.toBeInTheDocument();
expect(screen.getByText('No councils yet. Add one to get started.')).toBeInTheDocument();
});
it('uses the jury count setting to resize the roster up to five', async () => {
await renderEditCouncil();
expect(screen.queryByLabelText('Question')).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Convene council' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '5' }));
expect(screen.getAllByTestId('rive-mascot')).toHaveLength(5);
expect(screen.getAllByText('Juror 5')).toHaveLength(2);
expect(screen.getByLabelText('Juror 5 name')).toBeInTheDocument();
});
it('disables Convene until a question is filled because seats and judge have defaults', async () => {
await renderOpenCouncil();
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' } });
fillQuestion();
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('shows mascot deliberation and agent thoughts while the council is running', async () => {
let resolveFirst: (value: CouncilMemberResult) => void = () => {};
let resolveSecond: (value: CouncilMemberResult) => void = () => {};
let resolveThird: (value: CouncilMemberResult) => void = () => {};
let resolveSynthesis: (value: ModelCouncilResult) => void = () => {};
mockAnswerMember
.mockImplementation(async ({ model }: { model: string }) => ({
model,
response: `follow-up thought ${mockAnswerMember.mock.calls.length}`,
error: null,
}))
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveFirst = resolve;
})
)
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveSecond = resolve;
})
)
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveThird = resolve;
})
);
mockSynthesizeCouncil.mockReturnValueOnce(
new Promise<ModelCouncilResult>(resolve => {
resolveSynthesis = resolve;
})
);
await renderOpenCouncil();
fillQuestion();
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',
expect(screen.getByText('Council deliberation')).toBeInTheDocument();
expect(screen.getAllByText('Thinking')).toHaveLength(3);
expect(screen.getByText('Judge')).toBeInTheDocument();
expect(
screen.getByText(/Waiting for juror answers, then reading the shared reasoning file/)
).toBeInTheDocument();
expect(screen.getAllByTestId('rive-mascot')).toHaveLength(4);
expect(screen.getAllByTestId('rive-mascot')[0]).toHaveAttribute('data-face', 'thinking');
await act(async () => {
resolveFirst({
model: 'reasoning-v1',
response: 'First juror live thought: Paris.',
error: null,
});
});
expect(screen.getByText('First juror live thought: Paris.')).toBeInTheDocument();
expect(screen.getByText('Round 1')).toBeInTheDocument();
expect(screen.getAllByText('Thinking')).toHaveLength(2);
expect(screen.getByText('Answered')).toBeInTheDocument();
expect(screen.getByText('Judge')).toBeInTheDocument();
await act(async () => {
resolveSecond({ model: 'reasoning-v1', response: 'Second juror agrees.', error: null });
resolveThird({ model: 'reasoning-v1', response: 'Third juror agrees.', error: null });
});
await waitFor(() => {
expect(screen.getByText('Synthesizing')).toBeInTheDocument();
});
await act(async () => {
resolveSynthesis(RESULT);
});
await waitFor(() => {
expect(screen.queryByText('Council deliberation')).not.toBeInTheDocument();
});
});
it('streams failed juror status without blocking other juror thoughts', async () => {
let resolveFirst: (value: CouncilMemberResult) => void = () => {};
let resolveSecond: (value: CouncilMemberResult) => void = () => {};
let resolveThird: (value: CouncilMemberResult) => void = () => {};
mockAnswerMember
.mockImplementation(async ({ model }: { model: string }) => ({
model,
response: `follow-up answer ${mockAnswerMember.mock.calls.length}`,
error: null,
}))
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveFirst = resolve;
})
)
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveSecond = resolve;
})
)
.mockReturnValueOnce(
new Promise<CouncilMemberResult>(resolve => {
resolveThird = resolve;
})
);
mockSynthesizeCouncil.mockResolvedValueOnce(RESULT);
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
await act(async () => {
resolveFirst({ model: 'reasoning-v1', response: null, error: 'rate limited' });
});
expect(screen.getByText('rate limited')).toBeInTheDocument();
expect(screen.getByText('Failed')).toBeInTheDocument();
expect(screen.getAllByText('Thinking')).toHaveLength(2);
await act(async () => {
resolveSecond({ model: 'reasoning-v1', response: 'Second juror answer.', error: null });
resolveThird({ model: 'reasoning-v1', response: 'Third juror answer.', error: null });
});
await waitFor(() => {
expect(mockSynthesizeCouncil).toHaveBeenCalledWith({
question: expect.any(String),
members: [
{
model: 'reasoning-v1',
response: expect.stringContaining('[failed: rate limited]'),
error: null,
},
{
model: 'reasoning-v1',
response: expect.stringContaining('Second juror answer.'),
error: null,
},
{
model: 'reasoning-v1',
response: expect.stringContaining('Third juror answer.'),
error: null,
},
],
chair_model: 'reasoning-v1',
});
});
});
it('appends juror turns to the shared scratchpad before the next debate round', async () => {
mockAnswerMember.mockImplementation(async ({ model }: { model: string }) => ({
model,
response: `round ${mockAnswerMember.mock.calls.length} update`,
error: null,
}));
mockSynthesizeCouncil.mockResolvedValueOnce(RESULT);
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
await waitFor(() => {
expect(mockSynthesizeCouncil).toHaveBeenCalled();
});
expect(mockAnswerMember.mock.calls[3][0].question).toContain('Round 1 updates');
expect(mockAnswerMember.mock.calls[3][0].question).toContain('round 1 update');
});
it('lets a juror model be selected from routing hints', async () => {
mockProgressiveSuccess();
await renderEditCouncil();
fireEvent.click(screen.getByLabelText('Member model 1'));
expect(screen.getByRole('dialog', { name: 'Member model 1' })).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Reasoning/ }));
await saveCouncilSettings();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockAnswerMember.mock.calls.map(call => call[0].model).slice(0, 3)).toEqual([
'hint:reasoning',
'reasoning-v1',
'reasoning-v1',
]);
});
it('enables provider and model dropdowns only after choosing Custom', async () => {
await renderEditCouncil();
fireEvent.click(screen.getByLabelText('Member model 1'));
const dialog = screen.getByRole('dialog', { name: 'Member model 1' });
const providerSelect = within(dialog).getByLabelText('Model provider');
const modelSelect = within(dialog).getByLabelText('Model id');
expect(providerSelect).toBeDisabled();
expect(modelSelect).toBeDisabled();
fireEvent.click(within(dialog).getByRole('button', { name: /Provider \+ model/ }));
await waitFor(() => expect(providerSelect).not.toBeDisabled());
expect(
within(providerSelect).getByRole('option', { name: 'Managed (openhuman)' })
).toBeInTheDocument();
expect(
within(providerSelect).getByRole('option', { name: 'OpenAI (openai)' })
).toBeInTheDocument();
expect(
within(providerSelect).queryByRole('option', { name: 'Anthropic (anthropic)' })
).not.toBeInTheDocument();
await waitFor(() => expect(modelSelect).not.toBeDisabled());
expect(
within(modelSelect).getByRole('option', { name: 'managed-reasoning' })
).toBeInTheDocument();
fireEvent.change(providerSelect, { target: { value: 'openai' } });
await waitFor(() => {
expect(within(modelSelect).getByRole('option', { name: 'gpt-4o' })).toBeInTheDocument();
});
fireEvent.change(modelSelect, { target: { value: 'gpt-4o' } });
fireEvent.click(within(dialog).getByRole('button', { name: 'Use provider model' }));
expect(screen.getByLabelText('Member model 1')).toHaveTextContent('openai:gpt-4o');
});
it('lets a council seat use a saved profile and submits that profile model', async () => {
mockProgressiveSuccess();
await renderEditCouncil();
const firstSeat = screen.getByLabelText('Juror 1 name').closest('article');
expect(firstSeat).not.toBeNull();
fireEvent.click(within(firstSeat as HTMLElement).getByRole('tab', { name: 'Profile' }));
fireEvent.change(screen.getByLabelText('Juror 1 profile'), { target: { value: 'critic' } });
await saveCouncilSettings();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockAnswerMember.mock.calls.map(call => call[0].model)).toEqual([
'critic-model',
'reasoning-v1',
'reasoning-v1',
'critic-model',
'reasoning-v1',
'reasoning-v1',
'critic-model',
'reasoning-v1',
'reasoning-v1',
]);
expect(mockSynthesizeCouncil).toHaveBeenCalledWith({
question: expect.stringContaining('shared_reasoning.md'),
members: expect.any(Array),
chair_model: 'reasoning-v1',
});
expect(mockAnswerMember.mock.calls[0][0].question).toContain('User question:');
expect(mockAnswerMember.mock.calls[0][0].question).toContain('What is the capital of France?');
expect(mockAnswerMember.mock.calls[0][0].question).toContain('Debate round 1 of 3.');
expect(mockAnswerMember.mock.calls[8][0].question).toContain('Debate round 3 of 3.');
});
it('lets the judge agent use a saved profile unless a model override is typed', async () => {
mockProgressiveSuccess();
await renderEditCouncil();
fireEvent.change(screen.getByLabelText('Judge agent'), { target: { value: 'profile' } });
fireEvent.change(screen.getByLabelText('Judge profile'), { target: { value: 'critic' } });
await saveCouncilSettings();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockAnswerMember.mock.calls.map(call => call[0].model)).toEqual([
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
'reasoning-v1',
]);
expect(mockSynthesizeCouncil).toHaveBeenCalledWith({
question: expect.any(String),
members: expect.any(Array),
chair_model: 'critic-model',
});
});
it('renders member answers side-by-side + the synthesis', async () => {
mockProgressiveSuccess(RESULT.members);
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
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();
expect(screen.getByText('by claude-opus-4-8')).toBeInTheDocument();
expect(screen.getByText('Debate usage')).toBeInTheDocument();
expect(screen.getByText('Total')).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 ' } });
it('renders council markdown instead of showing raw markdown markers', async () => {
mockProgressiveSuccess([
{ model: 'reasoning-v1', response: '**Paris** is the capital.', error: null },
{ model: 'reasoning-v1', response: '- France\n- Paris', error: null },
{ model: 'reasoning-v1', response: '`Paris` remains the answer.', error: null },
]);
mockSynthesizeCouncil.mockResolvedValueOnce({
...RESULT,
members: [
{ model: 'reasoning-v1', response: '**Paris** is the capital.', error: null },
{ model: 'reasoning-v1', response: '- France\n- Paris', error: null },
],
synthesis: '## Consensus\n\nThe answer is **Paris**.',
});
await renderOpenCouncil();
fillQuestion();
await act(async () => {
fireEvent.click(screen.getByRole('button', { name: 'Convene council' }));
});
expect(mockRunCouncil).toHaveBeenCalledWith({
question: 'hi',
member_models: ['model-a'],
chair_model: 'chair',
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Consensus' })).toBeInTheDocument();
});
const results = screen.getByText('Council results').closest('section');
expect(results).not.toBeNull();
expect(screen.getAllByText('Paris').some(node => node.tagName.toLowerCase() === 'strong')).toBe(
true
);
expect(within(results as HTMLElement).queryByText(/\*\*Paris\*\*/)).not.toBeInTheDocument();
});
it('surfaces an error alert when the council run fails', async () => {
mockRunCouncil.mockRejectedValueOnce(new Error('all member models failed to respond'));
render(<ModelCouncilTab />);
fillValidForm();
mockAnswerMember.mockResolvedValue({
model: 'reasoning-v1',
response: null,
error: 'downstream',
});
mockSynthesizeCouncil.mockRejectedValueOnce(new Error('all member models failed to respond'));
await renderOpenCouncil();
fillQuestion();
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();
});
});
File diff suppressed because it is too large Load Diff
+99
View File
@@ -4521,6 +4521,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4529,18 +4557,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'إعدادات المجلس',
'modelCouncil.settingsSummary': '{count} محلفين · القاضي: {judge}',
'modelCouncil.juryCountLabel': 'عدد المحلفين',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'وكيل القاضي',
'modelCouncil.defaultJudge': 'القاضي الافتراضي',
'modelCouncil.savedProfile': 'ملف محفوظ',
'modelCouncil.customAgent': 'وكيل مخصص',
'modelCouncil.judgeProfileLabel': 'ملف القاضي',
'modelCouncil.chooseProfile': 'اختر ملفا',
'modelCouncil.judgeNameLabel': 'اسم القاضي',
'modelCouncil.judgeNamePlaceholder': 'القاضي الرئيسي',
'modelCouncil.rosterHeading': 'قائمة المجلس',
'modelCouncil.rosterHelp':
'يمكن لكل محلف أن يرث الوكيل الافتراضي، أو يستخدم ملفا محفوظا، أو يصبح ملفا مخصصا لهذا التشغيل.',
'modelCouncil.loadingProfiles': 'جار تحميل الملفات…',
'modelCouncil.jurorLabel': 'محلف {n}',
'modelCouncil.profileModeLabel': 'مصدر الملف',
'modelCouncil.mode.default': 'افتراضي',
'modelCouncil.mode.profile': 'ملف',
'modelCouncil.mode.custom': 'مخصص',
'modelCouncil.memberProfileAria': 'ملف المحلف {n}',
'modelCouncil.memberNameAria': 'اسم المحلف {n}',
'modelCouncil.memberNamePlaceholder': 'اسم الوكيل',
'modelCouncil.profileModelPlaceholder': 'تجاوز نموذج الملف',
'modelCouncil.memberBriefAria': 'موجز المحلف {n}',
'modelCouncil.memberBriefPlaceholder': 'منظور هذا المحلف',
'modelCouncil.jurorFallback': 'محلف',
'modelCouncil.sharedReasoningLabel': 'ملف التفكير المشترك',
'modelCouncil.sharedReasoningHelp':
'يعمل المجلس على هذه المسودة المشتركة قبل أن يكتب القاضي الخلاصة.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'وسيط',
'graphCohesion.brokerTitle':
'ثقب بنيوي: جيران هذا الكيان غير مترابطين فيما بينهم — وهو الرابط الوحيد بينهم.',
+99
View File
@@ -4604,6 +4604,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4612,18 +4640,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'কাউন্সিল সেটিংস',
'modelCouncil.settingsSummary': '{count} জন জুরর · বিচারক: {judge}',
'modelCouncil.juryCountLabel': 'জুরর সংখ্যা',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'বিচারক এজেন্ট',
'modelCouncil.defaultJudge': 'ডিফল্ট বিচারক',
'modelCouncil.savedProfile': 'সংরক্ষিত প্রোফাইল',
'modelCouncil.customAgent': 'কাস্টম এজেন্ট',
'modelCouncil.judgeProfileLabel': 'বিচারক প্রোফাইল',
'modelCouncil.chooseProfile': 'একটি প্রোফাইল বেছে নিন',
'modelCouncil.judgeNameLabel': 'বিচারকের নাম',
'modelCouncil.judgeNamePlaceholder': 'প্রধান বিচারক',
'modelCouncil.rosterHeading': 'কাউন্সিল তালিকা',
'modelCouncil.rosterHelp':
'প্রতিটি জুরর ডিফল্ট এজেন্ট নিতে পারে, সংরক্ষিত প্রোফাইল ব্যবহার করতে পারে, অথবা এই রানের জন্য কাস্টম প্রোফাইল হতে পারে।',
'modelCouncil.loadingProfiles': 'প্রোফাইল লোড হচ্ছে…',
'modelCouncil.jurorLabel': 'জুরর {n}',
'modelCouncil.profileModeLabel': 'প্রোফাইল উৎস',
'modelCouncil.mode.default': 'ডিফল্ট',
'modelCouncil.mode.profile': 'প্রোফাইল',
'modelCouncil.mode.custom': 'কাস্টম',
'modelCouncil.memberProfileAria': 'জুরর {n} প্রোফাইল',
'modelCouncil.memberNameAria': 'জুরর {n} নাম',
'modelCouncil.memberNamePlaceholder': 'এজেন্টের নাম',
'modelCouncil.profileModelPlaceholder': 'প্রোফাইল মডেল ওভাররাইড করুন',
'modelCouncil.memberBriefAria': 'জুরর {n} ব্রিফ',
'modelCouncil.memberBriefPlaceholder': 'এই জুররের দৃষ্টিভঙ্গি',
'modelCouncil.jurorFallback': 'জুরর',
'modelCouncil.sharedReasoningLabel': 'শেয়ার করা reasoning ফাইল',
'modelCouncil.sharedReasoningHelp':
'বিচারক সংশ্লেষ লেখার আগে কাউন্সিল এই শেয়ার করা খসড়ায় কাজ করে।',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'ব্রোকার',
'graphCohesion.brokerTitle':
'কাঠামোগত ছিদ্র: এই সত্তার প্রতিবেশীরা একে অপরের সাথে যুক্ত নয় — এটিই তাদের মধ্যে একমাত্র সংযোগ।',
+99
View File
@@ -4726,6 +4726,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4734,18 +4762,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Council-Einstellungen',
'modelCouncil.settingsSummary': '{count} Juroren · Richter: {judge}',
'modelCouncil.juryCountLabel': 'Anzahl der Juroren',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Richter-Agent',
'modelCouncil.defaultJudge': 'Standardrichter',
'modelCouncil.savedProfile': 'Gespeichertes Profil',
'modelCouncil.customAgent': 'Eigener Agent',
'modelCouncil.judgeProfileLabel': 'Richterprofil',
'modelCouncil.chooseProfile': 'Profil auswählen',
'modelCouncil.judgeNameLabel': 'Name des Richters',
'modelCouncil.judgeNamePlaceholder': 'Vorsitzender Richter',
'modelCouncil.rosterHeading': 'Council-Besetzung',
'modelCouncil.rosterHelp':
'Jeder Juror kann den Standardagenten erben, ein gespeichertes Profil nutzen oder für diesen Lauf ein eigenes Profil werden.',
'modelCouncil.loadingProfiles': 'Profile werden geladen…',
'modelCouncil.jurorLabel': 'Juror {n}',
'modelCouncil.profileModeLabel': 'Profilquelle',
'modelCouncil.mode.default': 'Standard',
'modelCouncil.mode.profile': 'Profil',
'modelCouncil.mode.custom': 'Eigen',
'modelCouncil.memberProfileAria': 'Profil von Juror {n}',
'modelCouncil.memberNameAria': 'Name von Juror {n}',
'modelCouncil.memberNamePlaceholder': 'Agentenname',
'modelCouncil.profileModelPlaceholder': 'Profilmodell überschreiben',
'modelCouncil.memberBriefAria': 'Briefing für Juror {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspektive dieses Jurors',
'modelCouncil.jurorFallback': 'Juror',
'modelCouncil.sharedReasoningLabel': 'Gemeinsame Reasoning-Datei',
'modelCouncil.sharedReasoningHelp':
'Der Council arbeitet an diesem gemeinsamen Entwurf, bevor der Richter die Synthese schreibt.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'Broker',
'graphCohesion.brokerTitle':
'Strukturelles Loch: Die Nachbarn dieser Entität sind nicht miteinander verbunden — sie ist die einzige Verknüpfung zwischen ihnen.',
+99
View File
@@ -344,6 +344,34 @@ const en: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -352,18 +380,89 @@ const en: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Council settings',
'modelCouncil.settingsSummary': '{count} jurors · judge: {judge}',
'modelCouncil.juryCountLabel': 'Jury count',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Judge agent',
'modelCouncil.defaultJudge': 'Default judge',
'modelCouncil.savedProfile': 'Saved profile',
'modelCouncil.customAgent': 'Custom agent',
'modelCouncil.judgeProfileLabel': 'Judge profile',
'modelCouncil.chooseProfile': 'Choose a profile',
'modelCouncil.judgeNameLabel': 'Judge name',
'modelCouncil.judgeNamePlaceholder': 'Chief Judge',
'modelCouncil.rosterHeading': 'Council roster',
'modelCouncil.rosterHelp':
'Each juror can inherit the default agent, use a saved profile, or become a custom profile for this run.',
'modelCouncil.loadingProfiles': 'Loading profiles…',
'modelCouncil.jurorLabel': 'Juror {n}',
'modelCouncil.profileModeLabel': 'Profile source',
'modelCouncil.mode.default': 'Default',
'modelCouncil.mode.profile': 'Profile',
'modelCouncil.mode.custom': 'Custom',
'modelCouncil.memberProfileAria': 'Juror {n} profile',
'modelCouncil.memberNameAria': 'Juror {n} name',
'modelCouncil.memberNamePlaceholder': 'Agent name',
'modelCouncil.profileModelPlaceholder': 'Override profile model',
'modelCouncil.memberBriefAria': 'Juror {n} brief',
'modelCouncil.memberBriefPlaceholder': 'Perspective for this juror',
'modelCouncil.jurorFallback': 'Juror',
'modelCouncil.sharedReasoningLabel': 'Shared reasoning file',
'modelCouncil.sharedReasoningHelp':
'The council works against this shared scratchpad before the judge writes the synthesis.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated token counts from debate prompts and responses. Real pricing requires provider usage from the council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'memory.analyzeNow': 'Analyze Now',
'namespaceOverview.title': 'Namespace Overview',
'namespaceOverview.intro':
+99
View File
@@ -4698,6 +4698,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4706,18 +4734,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Configuración del consejo',
'modelCouncil.settingsSummary': '{count} jurados · juez: {judge}',
'modelCouncil.juryCountLabel': 'Número de jurados',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Agente juez',
'modelCouncil.defaultJudge': 'Juez predeterminado',
'modelCouncil.savedProfile': 'Perfil guardado',
'modelCouncil.customAgent': 'Agente personalizado',
'modelCouncil.judgeProfileLabel': 'Perfil del juez',
'modelCouncil.chooseProfile': 'Elige un perfil',
'modelCouncil.judgeNameLabel': 'Nombre del juez',
'modelCouncil.judgeNamePlaceholder': 'Juez principal',
'modelCouncil.rosterHeading': 'Lista del consejo',
'modelCouncil.rosterHelp':
'Cada jurado puede heredar el agente predeterminado, usar un perfil guardado o convertirse en un perfil personalizado para esta ejecución.',
'modelCouncil.loadingProfiles': 'Cargando perfiles…',
'modelCouncil.jurorLabel': 'Jurado {n}',
'modelCouncil.profileModeLabel': 'Origen del perfil',
'modelCouncil.mode.default': 'Predeterminado',
'modelCouncil.mode.profile': 'Perfil',
'modelCouncil.mode.custom': 'Personalizado',
'modelCouncil.memberProfileAria': 'Perfil del jurado {n}',
'modelCouncil.memberNameAria': 'Nombre del jurado {n}',
'modelCouncil.memberNamePlaceholder': 'Nombre del agente',
'modelCouncil.profileModelPlaceholder': 'Sobrescribir modelo del perfil',
'modelCouncil.memberBriefAria': 'Brief del jurado {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspectiva de este jurado',
'modelCouncil.jurorFallback': 'Jurado',
'modelCouncil.sharedReasoningLabel': 'Archivo de razonamiento compartido',
'modelCouncil.sharedReasoningHelp':
'El consejo trabaja sobre este borrador compartido antes de que el juez escriba la síntesis.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'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.',
+99
View File
@@ -4713,6 +4713,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4721,18 +4749,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Paramètres du conseil',
'modelCouncil.settingsSummary': '{count} jurés · juge : {judge}',
'modelCouncil.juryCountLabel': 'Nombre de jurés',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Agent juge',
'modelCouncil.defaultJudge': 'Juge par défaut',
'modelCouncil.savedProfile': 'Profil enregistré',
'modelCouncil.customAgent': 'Agent personnalisé',
'modelCouncil.judgeProfileLabel': 'Profil du juge',
'modelCouncil.chooseProfile': 'Choisir un profil',
'modelCouncil.judgeNameLabel': 'Nom du juge',
'modelCouncil.judgeNamePlaceholder': 'Juge principal',
'modelCouncil.rosterHeading': 'Composition du conseil',
'modelCouncil.rosterHelp':
'Chaque juré peut hériter de lagent par défaut, utiliser un profil enregistré ou devenir un profil personnalisé pour cette exécution.',
'modelCouncil.loadingProfiles': 'Chargement des profils…',
'modelCouncil.jurorLabel': 'Juré {n}',
'modelCouncil.profileModeLabel': 'Source du profil',
'modelCouncil.mode.default': 'Défaut',
'modelCouncil.mode.profile': 'Profil',
'modelCouncil.mode.custom': 'Personnalisé',
'modelCouncil.memberProfileAria': 'Profil du juré {n}',
'modelCouncil.memberNameAria': 'Nom du juré {n}',
'modelCouncil.memberNamePlaceholder': 'Nom de lagent',
'modelCouncil.profileModelPlaceholder': 'Remplacer le modèle du profil',
'modelCouncil.memberBriefAria': 'Brief du juré {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspective de ce juré',
'modelCouncil.jurorFallback': 'Juré',
'modelCouncil.sharedReasoningLabel': 'Fichier de raisonnement partagé',
'modelCouncil.sharedReasoningHelp':
'Le conseil travaille sur ce brouillon partagé avant que le juge rédige la synthèse.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'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.',
+99
View File
@@ -4614,6 +4614,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4622,18 +4650,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'काउंसिल सेटिंग्स',
'modelCouncil.settingsSummary': '{count} जूरी · जज: {judge}',
'modelCouncil.juryCountLabel': 'जूरी संख्या',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'जज एजेंट',
'modelCouncil.defaultJudge': 'डिफ़ॉल्ट जज',
'modelCouncil.savedProfile': 'सहेजी गई प्रोफ़ाइल',
'modelCouncil.customAgent': 'कस्टम एजेंट',
'modelCouncil.judgeProfileLabel': 'जज प्रोफ़ाइल',
'modelCouncil.chooseProfile': 'प्रोफ़ाइल चुनें',
'modelCouncil.judgeNameLabel': 'जज का नाम',
'modelCouncil.judgeNamePlaceholder': 'मुख्य जज',
'modelCouncil.rosterHeading': 'काउंसिल रोस्टर',
'modelCouncil.rosterHelp':
'हर जूरर डिफ़ॉल्ट एजेंट अपना सकता है, सहेजी प्रोफ़ाइल इस्तेमाल कर सकता है, या इस रन के लिए कस्टम प्रोफ़ाइल बन सकता है।',
'modelCouncil.loadingProfiles': 'प्रोफ़ाइल लोड हो रही हैं…',
'modelCouncil.jurorLabel': 'जूरर {n}',
'modelCouncil.profileModeLabel': 'प्रोफ़ाइल स्रोत',
'modelCouncil.mode.default': 'डिफ़ॉल्ट',
'modelCouncil.mode.profile': 'प्रोफ़ाइल',
'modelCouncil.mode.custom': 'कस्टम',
'modelCouncil.memberProfileAria': 'जूरर {n} प्रोफ़ाइल',
'modelCouncil.memberNameAria': 'जूरर {n} नाम',
'modelCouncil.memberNamePlaceholder': 'एजेंट नाम',
'modelCouncil.profileModelPlaceholder': 'प्रोफ़ाइल मॉडल ओवरराइड करें',
'modelCouncil.memberBriefAria': 'जूरर {n} ब्रीफ',
'modelCouncil.memberBriefPlaceholder': 'इस जूरर का दृष्टिकोण',
'modelCouncil.jurorFallback': 'जूरर',
'modelCouncil.sharedReasoningLabel': 'साझा रीजनिंग फ़ाइल',
'modelCouncil.sharedReasoningHelp':
'जज के संश्लेषण से पहले काउंसिल इस साझा ड्राफ़्ट पर काम करती है।',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'ब्रोकर',
'graphCohesion.brokerTitle':
'संरचनात्मक छेद: इस इकाई के पड़ोसी आपस में नहीं जुड़े — यह उनके बीच एकमात्र कड़ी है।',
+99
View File
@@ -4622,6 +4622,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4630,18 +4658,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Pengaturan dewan',
'modelCouncil.settingsSummary': '{count} juri · hakim: {judge}',
'modelCouncil.juryCountLabel': 'Jumlah juri',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Agen hakim',
'modelCouncil.defaultJudge': 'Hakim default',
'modelCouncil.savedProfile': 'Profil tersimpan',
'modelCouncil.customAgent': 'Agen kustom',
'modelCouncil.judgeProfileLabel': 'Profil hakim',
'modelCouncil.chooseProfile': 'Pilih profil',
'modelCouncil.judgeNameLabel': 'Nama hakim',
'modelCouncil.judgeNamePlaceholder': 'Hakim utama',
'modelCouncil.rosterHeading': 'Daftar dewan',
'modelCouncil.rosterHelp':
'Setiap juri dapat mewarisi agen default, memakai profil tersimpan, atau menjadi profil kustom untuk run ini.',
'modelCouncil.loadingProfiles': 'Memuat profil…',
'modelCouncil.jurorLabel': 'Juri {n}',
'modelCouncil.profileModeLabel': 'Sumber profil',
'modelCouncil.mode.default': 'Default',
'modelCouncil.mode.profile': 'Profil',
'modelCouncil.mode.custom': 'Kustom',
'modelCouncil.memberProfileAria': 'Profil juri {n}',
'modelCouncil.memberNameAria': 'Nama juri {n}',
'modelCouncil.memberNamePlaceholder': 'Nama agen',
'modelCouncil.profileModelPlaceholder': 'Timpa model profil',
'modelCouncil.memberBriefAria': 'Brief juri {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspektif juri ini',
'modelCouncil.jurorFallback': 'Juri',
'modelCouncil.sharedReasoningLabel': 'File penalaran bersama',
'modelCouncil.sharedReasoningHelp':
'Dewan bekerja pada draf bersama ini sebelum hakim menulis sintesis.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
'Lubang struktural: tetangga entitas ini tidak saling terhubung — entitas inilah satu-satunya tautan di antara mereka.',
+99
View File
@@ -4687,6 +4687,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4695,18 +4723,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Impostazioni del consiglio',
'modelCouncil.settingsSummary': '{count} giurati · giudice: {judge}',
'modelCouncil.juryCountLabel': 'Numero di giurati',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Agente giudice',
'modelCouncil.defaultJudge': 'Giudice predefinito',
'modelCouncil.savedProfile': 'Profilo salvato',
'modelCouncil.customAgent': 'Agente personalizzato',
'modelCouncil.judgeProfileLabel': 'Profilo del giudice',
'modelCouncil.chooseProfile': 'Scegli un profilo',
'modelCouncil.judgeNameLabel': 'Nome del giudice',
'modelCouncil.judgeNamePlaceholder': 'Giudice capo',
'modelCouncil.rosterHeading': 'Roster del consiglio',
'modelCouncil.rosterHelp':
'Ogni giurato può ereditare lagente predefinito, usare un profilo salvato o diventare un profilo personalizzato per questa esecuzione.',
'modelCouncil.loadingProfiles': 'Caricamento profili…',
'modelCouncil.jurorLabel': 'Giurato {n}',
'modelCouncil.profileModeLabel': 'Origine del profilo',
'modelCouncil.mode.default': 'Predefinito',
'modelCouncil.mode.profile': 'Profilo',
'modelCouncil.mode.custom': 'Personalizzato',
'modelCouncil.memberProfileAria': 'Profilo del giurato {n}',
'modelCouncil.memberNameAria': 'Nome del giurato {n}',
'modelCouncil.memberNamePlaceholder': 'Nome agente',
'modelCouncil.profileModelPlaceholder': 'Sovrascrivi modello del profilo',
'modelCouncil.memberBriefAria': 'Brief del giurato {n}',
'modelCouncil.memberBriefPlaceholder': 'Prospettiva di questo giurato',
'modelCouncil.jurorFallback': 'Giurato',
'modelCouncil.sharedReasoningLabel': 'File di ragionamento condiviso',
'modelCouncil.sharedReasoningHelp':
'Il consiglio lavora su questa bozza condivisa prima che il giudice scriva la sintesi.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
"Buco strutturale: i vicini di questa entità non sono collegati tra loro — essa è l'unico collegamento tra loro.",
+99
View File
@@ -4563,6 +4563,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4571,18 +4599,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': '위원회 설정',
'modelCouncil.settingsSummary': '배심원 {count}명 · 판사: {judge}',
'modelCouncil.juryCountLabel': '배심원 수',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': '판사 에이전트',
'modelCouncil.defaultJudge': '기본 판사',
'modelCouncil.savedProfile': '저장된 프로필',
'modelCouncil.customAgent': '사용자 지정 에이전트',
'modelCouncil.judgeProfileLabel': '판사 프로필',
'modelCouncil.chooseProfile': '프로필 선택',
'modelCouncil.judgeNameLabel': '판사 이름',
'modelCouncil.judgeNamePlaceholder': '수석 판사',
'modelCouncil.rosterHeading': '위원회 명단',
'modelCouncil.rosterHelp':
'각 배심원은 기본 에이전트를 상속하거나 저장된 프로필을 사용하거나 이번 실행용 사용자 지정 프로필이 될 수 있습니다.',
'modelCouncil.loadingProfiles': '프로필 불러오는 중…',
'modelCouncil.jurorLabel': '배심원 {n}',
'modelCouncil.profileModeLabel': '프로필 출처',
'modelCouncil.mode.default': '기본',
'modelCouncil.mode.profile': '프로필',
'modelCouncil.mode.custom': '사용자 지정',
'modelCouncil.memberProfileAria': '배심원 {n} 프로필',
'modelCouncil.memberNameAria': '배심원 {n} 이름',
'modelCouncil.memberNamePlaceholder': '에이전트 이름',
'modelCouncil.profileModelPlaceholder': '프로필 모델 재정의',
'modelCouncil.memberBriefAria': '배심원 {n} 브리프',
'modelCouncil.memberBriefPlaceholder': '이 배심원의 관점',
'modelCouncil.jurorFallback': '배심원',
'modelCouncil.sharedReasoningLabel': '공유 추론 파일',
'modelCouncil.sharedReasoningHelp':
'판사가 종합문을 쓰기 전에 위원회가 이 공유 초안으로 작업합니다.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': '브로커',
'graphCohesion.brokerTitle':
'구조적 공백: 이 엔티티의 이웃들은 서로 연결되어 있지 않습니다 — 이들 사이의 유일한 연결고리입니다.',
+99
View File
@@ -4651,6 +4651,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4659,18 +4687,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Ustawienia rady',
'modelCouncil.settingsSummary': '{count} jurorów · sędzia: {judge}',
'modelCouncil.juryCountLabel': 'Liczba jurorów',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Agent sędzia',
'modelCouncil.defaultJudge': 'Domyślny sędzia',
'modelCouncil.savedProfile': 'Zapisany profil',
'modelCouncil.customAgent': 'Niestandardowy agent',
'modelCouncil.judgeProfileLabel': 'Profil sędziego',
'modelCouncil.chooseProfile': 'Wybierz profil',
'modelCouncil.judgeNameLabel': 'Imię sędziego',
'modelCouncil.judgeNamePlaceholder': 'Główny sędzia',
'modelCouncil.rosterHeading': 'Skład rady',
'modelCouncil.rosterHelp':
'Każdy juror może dziedziczyć domyślnego agenta, użyć zapisanego profilu albo stać się profilem niestandardowym dla tego uruchomienia.',
'modelCouncil.loadingProfiles': 'Ładowanie profili…',
'modelCouncil.jurorLabel': 'Juror {n}',
'modelCouncil.profileModeLabel': 'Źródło profilu',
'modelCouncil.mode.default': 'Domyślny',
'modelCouncil.mode.profile': 'Profil',
'modelCouncil.mode.custom': 'Własny',
'modelCouncil.memberProfileAria': 'Profil jurora {n}',
'modelCouncil.memberNameAria': 'Imię jurora {n}',
'modelCouncil.memberNamePlaceholder': 'Nazwa agenta',
'modelCouncil.profileModelPlaceholder': 'Nadpisz model profilu',
'modelCouncil.memberBriefAria': 'Brief jurora {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspektywa tego jurora',
'modelCouncil.jurorFallback': 'Juror',
'modelCouncil.sharedReasoningLabel': 'Wspólny plik rozumowania',
'modelCouncil.sharedReasoningHelp':
'Rada pracuje na tym wspólnym szkicu, zanim sędzia napisze syntezę.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'broker',
'graphCohesion.brokerTitle':
'Dziura strukturalna: sąsiedzi tej encji nie są ze sobą połączeni — jest ona jedynym łącznikiem między nimi.',
+99
View File
@@ -4684,6 +4684,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4692,18 +4720,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Configurações do conselho',
'modelCouncil.settingsSummary': '{count} jurados · juiz: {judge}',
'modelCouncil.juryCountLabel': 'Quantidade de jurados',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Agente juiz',
'modelCouncil.defaultJudge': 'Juiz padrão',
'modelCouncil.savedProfile': 'Perfil salvo',
'modelCouncil.customAgent': 'Agente personalizado',
'modelCouncil.judgeProfileLabel': 'Perfil do juiz',
'modelCouncil.chooseProfile': 'Escolha um perfil',
'modelCouncil.judgeNameLabel': 'Nome do juiz',
'modelCouncil.judgeNamePlaceholder': 'Juiz principal',
'modelCouncil.rosterHeading': 'Lista do conselho',
'modelCouncil.rosterHelp':
'Cada jurado pode herdar o agente padrão, usar um perfil salvo ou virar um perfil personalizado para esta execução.',
'modelCouncil.loadingProfiles': 'Carregando perfis…',
'modelCouncil.jurorLabel': 'Jurado {n}',
'modelCouncil.profileModeLabel': 'Origem do perfil',
'modelCouncil.mode.default': 'Padrão',
'modelCouncil.mode.profile': 'Perfil',
'modelCouncil.mode.custom': 'Personalizado',
'modelCouncil.memberProfileAria': 'Perfil do jurado {n}',
'modelCouncil.memberNameAria': 'Nome do jurado {n}',
'modelCouncil.memberNamePlaceholder': 'Nome do agente',
'modelCouncil.profileModelPlaceholder': 'Substituir modelo do perfil',
'modelCouncil.memberBriefAria': 'Brief do jurado {n}',
'modelCouncil.memberBriefPlaceholder': 'Perspectiva deste jurado',
'modelCouncil.jurorFallback': 'Jurado',
'modelCouncil.sharedReasoningLabel': 'Arquivo de raciocínio compartilhado',
'modelCouncil.sharedReasoningHelp':
'O conselho trabalha neste rascunho compartilhado antes de o juiz escrever a síntese.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'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.',
+99
View File
@@ -4651,6 +4651,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4659,18 +4687,89 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': 'Настройки совета',
'modelCouncil.settingsSummary': '{count} присяжных · судья: {judge}',
'modelCouncil.juryCountLabel': 'Число присяжных',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': 'Агент-судья',
'modelCouncil.defaultJudge': 'Судья по умолчанию',
'modelCouncil.savedProfile': 'Сохраненный профиль',
'modelCouncil.customAgent': 'Пользовательский агент',
'modelCouncil.judgeProfileLabel': 'Профиль судьи',
'modelCouncil.chooseProfile': 'Выберите профиль',
'modelCouncil.judgeNameLabel': 'Имя судьи',
'modelCouncil.judgeNamePlaceholder': 'Главный судья',
'modelCouncil.rosterHeading': 'Состав совета',
'modelCouncil.rosterHelp':
'Каждый присяжный может наследовать агента по умолчанию, использовать сохраненный профиль или стать пользовательским профилем для этого запуска.',
'modelCouncil.loadingProfiles': 'Загрузка профилей…',
'modelCouncil.jurorLabel': 'Присяжный {n}',
'modelCouncil.profileModeLabel': 'Источник профиля',
'modelCouncil.mode.default': 'По умолчанию',
'modelCouncil.mode.profile': 'Профиль',
'modelCouncil.mode.custom': 'Свой',
'modelCouncil.memberProfileAria': 'Профиль присяжного {n}',
'modelCouncil.memberNameAria': 'Имя присяжного {n}',
'modelCouncil.memberNamePlaceholder': 'Имя агента',
'modelCouncil.profileModelPlaceholder': 'Переопределить модель профиля',
'modelCouncil.memberBriefAria': 'Бриф присяжного {n}',
'modelCouncil.memberBriefPlaceholder': 'Перспектива этого присяжного',
'modelCouncil.jurorFallback': 'Присяжный',
'modelCouncil.sharedReasoningLabel': 'Общий файл рассуждений',
'modelCouncil.sharedReasoningHelp':
'Совет работает с этим общим черновиком до того, как судья пишет синтез.',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': 'брокер',
'graphCohesion.brokerTitle':
'Структурная дыра: соседи этой сущности не связаны друг с другом — она единственная связь между ними.',
+98
View File
@@ -4384,6 +4384,34 @@ const messages: TranslationMap = {
'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.listTitle': 'Councils',
'modelCouncil.listIntro':
'Choose a saved council, edit its agents and judge, or create a new council for a specific kind of decision.',
'modelCouncil.addCouncil': 'New council',
'modelCouncil.openCouncil': 'Open council',
'modelCouncil.editCouncil': 'Edit council',
'modelCouncil.editCurrentCouncil': 'Edit current council',
'modelCouncil.editCouncilAria': 'Edit {name}',
'modelCouncil.deleteCouncilAria': 'Delete {name}',
'modelCouncil.backToCouncils': 'Back to councils',
'modelCouncil.saveCouncil': 'Save council',
'modelCouncil.savingCouncil': 'Saving…',
'modelCouncil.cancelEdit': 'Cancel',
'modelCouncil.loadingCouncils': 'Loading councils…',
'modelCouncil.noCouncils': 'No councils yet. Add one to get started.',
'modelCouncil.noCouncilDescription': 'No description',
'modelCouncil.registryErrorPrefix': 'Council registry failed:',
'modelCouncil.councilNameLabel': 'Council name',
'modelCouncil.councilDescriptionLabel': 'Description',
'modelCouncil.councilDescriptionPlaceholder': 'When to use this council',
'modelCouncil.selectModel': 'Select',
'modelCouncil.modelPickerHelp': 'Select a routing hint or pin an exact provider model.',
'modelCouncil.closeModelPicker': 'Close',
'modelCouncil.modelPickerHints': 'Hints',
'modelCouncil.modelPickerProviderModel': 'Provider + model',
'modelCouncil.modelProviderLabel': 'Model provider',
'modelCouncil.modelIdLabel': 'Model id',
'modelCouncil.useProviderModel': 'Use provider model',
'modelCouncil.questionLabel': 'Question',
'modelCouncil.questionPlaceholder': 'Ask the council a question…',
'modelCouncil.membersLabel': 'Member models',
@@ -4392,18 +4420,88 @@ const messages: TranslationMap = {
'modelCouncil.memberAria': 'Member model {n}',
'modelCouncil.removeMemberAria': 'Remove member model {n}',
'modelCouncil.addMember': '+ Add model',
'modelCouncil.settingsTitle': '委员会设置',
'modelCouncil.settingsSummary': '{count} 名陪审员 · 法官:{judge}',
'modelCouncil.juryCountLabel': '陪审员数量',
'modelCouncil.debateRoundsLabel': 'Debate turns',
'modelCouncil.debateRoundsHelp':
'Each juror keeps debating across turns before the judge writes the final synthesis.',
'modelCouncil.judgeAgentLabel': '法官代理',
'modelCouncil.defaultJudge': '默认法官',
'modelCouncil.savedProfile': '已保存配置',
'modelCouncil.customAgent': '自定义代理',
'modelCouncil.judgeProfileLabel': '法官配置',
'modelCouncil.chooseProfile': '选择配置',
'modelCouncil.judgeNameLabel': '法官名称',
'modelCouncil.judgeNamePlaceholder': '首席法官',
'modelCouncil.rosterHeading': '委员会名单',
'modelCouncil.rosterHelp':
'每位陪审员都可以继承默认代理、使用已保存配置,或在本次运行中成为自定义配置。',
'modelCouncil.loadingProfiles': '正在加载配置…',
'modelCouncil.jurorLabel': '陪审员 {n}',
'modelCouncil.profileModeLabel': '配置来源',
'modelCouncil.mode.default': '默认',
'modelCouncil.mode.profile': '配置',
'modelCouncil.mode.custom': '自定义',
'modelCouncil.memberProfileAria': '陪审员 {n} 配置',
'modelCouncil.memberNameAria': '陪审员 {n} 名称',
'modelCouncil.memberNamePlaceholder': '代理名称',
'modelCouncil.profileModelPlaceholder': '覆盖配置模型',
'modelCouncil.memberBriefAria': '陪审员 {n} 简报',
'modelCouncil.memberBriefPlaceholder': '这位陪审员的视角',
'modelCouncil.jurorFallback': '陪审员',
'modelCouncil.sharedReasoningLabel': '共享推理文件',
'modelCouncil.sharedReasoningHelp': '委员会先围绕这个共享草稿工作,再由法官撰写综合结论。',
'modelCouncil.liveScratchpadHelp':
'Juror updates are appended here after each round, then fed into the next round.',
'modelCouncil.liveScratchpadBadge': 'Live',
'modelCouncil.scratchpadRoundHeading': 'Round {round} updates',
'modelCouncil.scratchpadNoResponse': 'No response',
'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.deliberationHeading': 'Council deliberation',
'modelCouncil.deliberationHelp':
'Mascots show each agent thinking while the shared reasoning file is in play.',
'modelCouncil.debateRoundInstruction':
'Respond to the other jurors so far. Preserve disagreements, revise your view when warranted, and keep your current conclusion explicit.',
'modelCouncil.debateFinalInstruction':
'This is your final juror turn. Converge where the evidence supports it, call out remaining disagreement, and hand the judge a clear conclusion.',
'modelCouncil.roundLabel': 'Round {round}',
'modelCouncil.currentRoundThinking': 'Continuing the next turn…',
'modelCouncil.thinkingBadge': 'Thinking',
'modelCouncil.thinkingWithBrief': 'Working from this perspective: {brief}',
'modelCouncil.thought.evidence':
'Checking evidence, assumptions, and what the shared reasoning file already captures.',
'modelCouncil.thought.plan': 'Sketching the practical path and where execution could get stuck.',
'modelCouncil.thought.risk':
'Looking for failure modes, missing context, and brittle conclusions.',
'modelCouncil.thought.tradeoffs':
'Comparing tradeoffs before handing the judge a concise position.',
'modelCouncil.thought.synthesis':
'Preparing a distinct view so the judge can compare real disagreement.',
'modelCouncil.judgeWaitingBadge': 'Judge',
'modelCouncil.judgeWaitingThought':
'Waiting for juror answers, then reading the shared reasoning file to synthesize.',
'modelCouncil.judgeSynthesizingBadge': 'Synthesizing',
'modelCouncil.judgeSynthesizingThought':
'Reading the live juror thoughts and writing the final council synthesis.',
'modelCouncil.errorPrefix': 'Council failed:',
'modelCouncil.resultsHeading': 'Council results',
'modelCouncil.memberAnswered': 'Answered',
'modelCouncil.memberFailed': 'Failed',
'modelCouncil.synthesisHeading': 'Synthesis',
'modelCouncil.synthesisBy': 'by {model}',
'modelCouncil.usageHeading': 'Debate usage',
'modelCouncil.usageEstimated':
'Estimated from debate prompts and responses until provider usage is attached to council RPC.',
'modelCouncil.usageEstimatedBadge': 'Estimated tokens',
'modelCouncil.usageInputTokens': 'Input',
'modelCouncil.usageOutputTokens': 'Output',
'modelCouncil.usageTotalTokens': 'Total',
'graphCohesion.brokerBadge': '经纪者',
'graphCohesion.brokerTitle': '结构洞:该实体的邻居彼此之间没有连接——它是它们之间唯一的链接。',
'graphCohesion.colCohesion': '凝聚度',
@@ -0,0 +1,89 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { type CouncilDefinition, councilRegistryApi } from './councilRegistryApi';
const mockCallCoreRpc = vi.fn();
vi.mock('../coreRpcClient', () => ({
callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args),
}));
const COUNCIL: CouncilDefinition = {
id: 'default-council',
name: 'Default council',
description: 'Balanced analyst, builder, and skeptic jury.',
jury_count: 3,
debate_rounds: 3,
seats: [
{
id: 0,
mode: 'default',
profile_id: '',
name: 'Analyst',
model: 'reasoning-v1',
brief: 'Evidence, assumptions, and risk.',
},
],
judge: { mode: 'default', profile_id: '', name: 'Chief Judge', model: 'reasoning-v1' },
shared_reasoning: '# Shared reasoning',
created_at_ms: 1,
updated_at_ms: 2,
};
describe('councilRegistryApi', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('lists councils and unwraps the core envelope', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: [COUNCIL], logs: ['listed'] });
await expect(councilRegistryApi.list()).resolves.toEqual([COUNCIL]);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.council_registry_list',
params: {},
});
});
it('gets a council by id and accepts a bare core result', async () => {
mockCallCoreRpc.mockResolvedValueOnce(COUNCIL);
await expect(councilRegistryApi.get('default-council')).resolves.toEqual(COUNCIL);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.council_registry_get',
params: { id: 'default-council' },
});
});
it('preserves null from get when the council is missing', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: null, logs: ['missing'] });
await expect(councilRegistryApi.get('missing')).resolves.toBeNull();
});
it('upserts a council through the registry controller', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: COUNCIL, logs: ['saved'] });
await expect(councilRegistryApi.upsert(COUNCIL)).resolves.toEqual(COUNCIL);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.council_registry_upsert',
params: { council: COUNCIL },
});
});
it('deletes a council and returns the persisted deletion result', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: true, logs: ['deleted'] });
await expect(councilRegistryApi.delete('default-council')).resolves.toBe(true);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.council_registry_delete',
params: { id: 'default-council' },
});
});
it('propagates registry RPC failures', async () => {
mockCallCoreRpc.mockRejectedValueOnce(new Error('registry unavailable'));
await expect(councilRegistryApi.list()).rejects.toThrow('registry unavailable');
});
});
@@ -0,0 +1,87 @@
import debug from 'debug';
import { callCoreRpc } from '../coreRpcClient';
const log = debug('council-registry:api');
export type CouncilSeatMode = 'default' | 'profile' | 'custom';
export interface CouncilSeatDefinition {
id: number;
mode: CouncilSeatMode;
profile_id: string;
name: string;
model: string;
brief: string;
}
export interface CouncilJudgeDefinition {
mode: CouncilSeatMode;
profile_id: string;
name: string;
model: string;
}
export interface CouncilDefinition {
id: string;
name: string;
description: string;
jury_count: number;
debate_rounds: number;
seats: CouncilSeatDefinition[];
judge: CouncilJudgeDefinition;
shared_reasoning: string;
created_at_ms: number;
updated_at_ms: 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>;
}
function unwrapEnvelope<T>(payload: unknown): T {
const record = asRecord(payload);
if (record && 'result' in record && 'logs' in record && Array.isArray(record.logs)) {
return record.result as T;
}
return payload as T;
}
export const councilRegistryApi = {
list: async (): Promise<CouncilDefinition[]> => {
log('list councils');
const payload = await callCoreRpc<unknown>({
method: 'openhuman.council_registry_list',
params: {},
});
return unwrapEnvelope<CouncilDefinition[]>(payload);
},
get: async (id: string): Promise<CouncilDefinition | null> => {
log('get council id=%s', id);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.council_registry_get',
params: { id },
});
return unwrapEnvelope<CouncilDefinition | null>(payload);
},
upsert: async (council: CouncilDefinition): Promise<CouncilDefinition> => {
log('upsert council id=%s name=%s', council.id, council.name);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.council_registry_upsert',
params: { council },
});
return unwrapEnvelope<CouncilDefinition>(payload);
},
delete: async (id: string): Promise<boolean> => {
log('delete council id=%s', id);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.council_registry_delete',
params: { id },
});
return unwrapEnvelope<boolean>(payload);
},
};
@@ -96,3 +96,60 @@ describe('modelCouncilApi.runCouncil', () => {
).rejects.toThrow('all member models failed');
});
});
describe('modelCouncilApi.answerMember', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls openhuman.model_council_answer_member with a long timeout', async () => {
const member = { model: 'reasoning-v1', response: 'Paris.', error: null };
mockCallCoreRpc.mockResolvedValueOnce({ result: member, logs: ['answered'] });
await expect(
modelCouncilApi.answerMember({
question: 'What is the capital of France?',
model: 'reasoning-v1',
temperature: 0.2,
})
).resolves.toEqual(member);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.model_council_answer_member',
params: {
question: 'What is the capital of France?',
model: 'reasoning-v1',
temperature: 0.2,
},
timeoutMs: 180_000,
});
});
});
describe('modelCouncilApi.synthesizeCouncil', () => {
beforeEach(() => {
mockCallCoreRpc.mockReset();
});
it('calls openhuman.model_council_synthesize and unwraps the result', async () => {
mockCallCoreRpc.mockResolvedValueOnce({ result: RESULT, logs: ['synthesized'] });
await expect(
modelCouncilApi.synthesizeCouncil({
question: 'What is the capital of France?',
members: RESULT.members,
chair_model: 'chair-model',
temperature: 0.1,
})
).resolves.toEqual(RESULT);
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.model_council_synthesize',
params: {
question: 'What is the capital of France?',
members: RESULT.members,
chair_model: 'chair-model',
temperature: 0.1,
},
timeoutMs: 180_000,
});
});
});
+39 -1
View File
@@ -35,7 +35,7 @@ export interface ModelCouncilResult {
export interface RunCouncilParams {
question: string;
/** Member model ids to consult (deduplicated + capped server-side). */
/** Member model ids or "default" seats to consult (repeated seats preserved; capped server-side). */
member_models: string[];
/** Model id that synthesizes the member answers. */
chair_model: string;
@@ -43,6 +43,19 @@ export interface RunCouncilParams {
temperature?: number;
}
export interface RunCouncilMemberParams {
question: string;
model: string;
temperature?: number;
}
export interface SynthesizeCouncilParams {
question: string;
members: CouncilMemberResult[];
chair_model: string;
temperature?: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null;
@@ -71,6 +84,31 @@ export function unwrapCouncilEnvelope(payload: unknown): ModelCouncilResult {
}
export const modelCouncilApi = {
answerMember: async (params: RunCouncilMemberParams): Promise<CouncilMemberResult> => {
log('answer member question=%s model=%s', params.question.slice(0, 40), params.model);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.model_council_answer_member',
params,
timeoutMs: 180_000,
});
return unwrapCouncilEnvelope(payload) as unknown as CouncilMemberResult;
},
synthesizeCouncil: async (params: SynthesizeCouncilParams): Promise<ModelCouncilResult> => {
log(
'synthesize question=%s members=%d chair=%s',
params.question.slice(0, 40),
params.members.length,
params.chair_model
);
const payload = await callCoreRpc<unknown>({
method: 'openhuman.model_council_synthesize',
params,
timeoutMs: 180_000,
});
return unwrapCouncilEnvelope(payload);
},
runCouncil: async (params: RunCouncilParams): Promise<ModelCouncilResult> => {
log(
'run question=%s members=%o chair=%s',
+1
View File
@@ -32,6 +32,7 @@
"mascot:render": "pnpm --dir remotion render:runtime-assets",
"merge-pr": "bash scripts/shortcuts/review/merge.sh",
"mock:api": "node scripts/mock-api-server.mjs",
"council:seed": "node scripts/seed-councils.mjs",
"pr:checklist": "node scripts/check-pr-checklist.mjs",
"pr:sync-main": "node scripts/merge-main-into-open-prs.mjs",
"rabbit": "bash scripts/rabbit/cli.sh",
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { spawnSync } from 'node:child_process';
const DEFAULT_MODEL = 'reasoning-v1';
const CORE_BIN = process.env.OPENHUMAN_CORE_BIN || resolve('target/debug/openhuman-core');
const SHARED_REASONING = [
'# Shared reasoning',
'- Claims the council agrees on:',
'- Open disagreements:',
'- Evidence or constraints to preserve:',
'- Judge synthesis notes:',
].join('\n');
const seedCouncils = [
{
id: 'default-council',
name: 'Default council',
description: 'Balanced analyst, builder, and skeptic jury.',
jury_count: 3,
debate_rounds: 3,
seats: [
seat(0, 'Analyst', 'Evidence, assumptions, and risk.'),
seat(1, 'Builder', 'Practical implementation path.'),
seat(2, 'Skeptic', 'Failure modes and missing context.'),
],
judge: judge('Chief Judge'),
shared_reasoning: SHARED_REASONING,
},
{
id: 'product-review-council',
name: 'Product Review Council',
description: 'Evaluates UX, customer impact, and product tradeoffs before shipping.',
jury_count: 4,
debate_rounds: 3,
seats: [
seat(0, 'User Advocate', 'Protect the user journey, accessibility, and recovery paths.'),
seat(1, 'Product Strategist', 'Weigh goals, scope, prioritization, and long-term leverage.'),
seat(2, 'Systems Builder', 'Find the practical implementation path and integration risks.'),
seat(3, 'Skeptic', 'Challenge weak assumptions, missing evidence, and failure modes.'),
],
judge: judge('Product Judge'),
shared_reasoning: SHARED_REASONING,
},
{
id: 'architecture-review-council',
name: 'Architecture Review Council',
description: 'Reviews technical design, ownership boundaries, performance, and maintainability.',
jury_count: 4,
debate_rounds: 3,
seats: [
seat(0, 'Domain Owner', 'Keep responsibilities in the right module and surface API contracts.'),
seat(1, 'Reliability Engineer', 'Focus on failure handling, observability, and deterministic tests.'),
seat(2, 'Frontend Lead', 'Preserve ergonomic UI flows and avoid duplicated business rules.'),
seat(3, 'Cost Reviewer', 'Evaluate token, provider, and runtime cost implications.'),
],
judge: judge('Architecture Judge'),
shared_reasoning: SHARED_REASONING,
},
{
id: 'research-council',
name: 'Research Council',
description: 'Separates evidence gathering, source quality, synthesis, and uncertainty.',
jury_count: 3,
debate_rounds: 3,
seats: [
seat(0, 'Evidence Scout', 'Collect relevant facts, sources, and constraints.'),
seat(1, 'Methodologist', 'Evaluate source quality and reasoning validity.'),
seat(2, 'Synthesizer', 'Turn the debate into a concise answer with caveats.'),
],
judge: judge('Research Judge'),
shared_reasoning: SHARED_REASONING,
},
];
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
process.exit(0);
}
if (!existsSync(CORE_BIN) && !options.dryRun) {
console.error(`Core binary not found: ${CORE_BIN}`);
console.error('Build it with: cargo build --manifest-path Cargo.toml --bin openhuman-core');
console.error('Or set OPENHUMAN_CORE_BIN to a compiled openhuman-core binary.');
process.exit(1);
}
const env = { ...process.env };
if (options.workspace) {
env.OPENHUMAN_WORKSPACE = options.workspace;
}
if (options.dryRun) {
console.log(JSON.stringify({ councils: seedCouncils }, null, 2));
process.exit(0);
}
if (options.replace) {
const existing = callCore('openhuman.council_registry_list', {}, env);
const councils = Array.isArray(existing.result) ? existing.result : [];
for (const council of councils) {
callCore('openhuman.council_registry_delete', { id: council.id }, env);
}
}
for (const council of seedCouncils) {
callCore('openhuman.council_registry_upsert', { council }, env);
console.log(`Seeded council: ${council.name} (${council.id})`);
}
const workspaceRoot =
options.workspace || process.env.OPENHUMAN_WORKSPACE || 'the default OpenHuman workspace root';
console.log(`Seeded ${seedCouncils.length} councils through the core registry at ${workspaceRoot}.`);
function seat(id, name, brief) {
return {
id,
mode: 'default',
profile_id: '',
name,
model: DEFAULT_MODEL,
brief,
};
}
function judge(name) {
return {
mode: 'default',
profile_id: '',
name,
model: DEFAULT_MODEL,
};
}
function callCore(method, params, env) {
const child = spawnSync(
CORE_BIN,
['call', '--method', method, '--params', JSON.stringify(params)],
{
cwd: resolve('.'),
env,
encoding: 'utf8',
}
);
if (child.status !== 0) {
if (child.stdout.trim()) console.error(child.stdout.trim());
if (child.stderr.trim()) console.error(child.stderr.trim());
throw new Error(`openhuman-core call failed for ${method}`);
}
const stdout = child.stdout.trim();
if (!stdout) return {};
try {
return JSON.parse(stdout);
} catch (error) {
throw new Error(`openhuman-core returned non-JSON output for ${method}: ${stdout}`);
}
}
function parseArgs(args) {
const parsed = {
dryRun: false,
help: false,
replace: false,
workspace: '',
};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--dry-run') {
parsed.dryRun = true;
} else if (arg === '--help' || arg === '-h') {
parsed.help = true;
} else if (arg === '--replace') {
parsed.replace = true;
} else if (arg === '--workspace') {
const value = args[index + 1];
if (!value) throw new Error('--workspace requires a path');
parsed.workspace = resolve(value);
index += 1;
} else {
throw new Error(`Unknown option: ${arg}`);
}
}
return parsed;
}
function printHelp() {
console.log(`Seed saved councils through the Rust core registry.
Usage:
pnpm council:seed [--workspace <path>] [--replace] [--dry-run]
Options:
--workspace <path> Use this OPENHUMAN_WORKSPACE root for core persistence.
--replace Delete existing councils before seeding.
--dry-run Print seed payloads without calling openhuman-core.
Environment:
OPENHUMAN_CORE_BIN Override the core binary path. Defaults to target/debug/openhuman-core.
OPENHUMAN_WORKSPACE Workspace root used by the core when --workspace is omitted.
`);
}
+4
View File
@@ -169,6 +169,9 @@ 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());
// Saved council definitions for the desktop Model Council surface.
controllers
.extend(crate::openhuman::council_registry::all_council_registry_registered_controllers());
// Model Council: multi-model deliberation (parallel members + chair synthesis)
controllers.extend(crate::openhuman::model_council::all_model_council_registered_controllers());
// Background command monitors for agent-scoped event sources
@@ -336,6 +339,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::council_registry::all_council_registry_controller_schemas());
schemas.extend(crate::openhuman::model_council::all_model_council_controller_schemas());
schemas.extend(crate::openhuman::monitor::all_monitor_controller_schemas());
schemas.extend(crate::openhuman::inference::all_inference_controller_schemas());
+67 -1
View File
@@ -739,7 +739,7 @@ impl Agent {
.unwrap_or(config.default_temperature)
);
Self::build_session_agent_inner(config, agent_id, target_def.as_ref(), None, None)
Self::build_session_agent_inner(config, agent_id, target_def.as_ref(), None, None, false)
}
/// Same as [`Self::from_config_for_agent`] but also appends a
@@ -771,6 +771,7 @@ impl Agent {
target_def.as_ref(),
Some(reflection_chunks),
None,
false,
)
}
@@ -813,9 +814,60 @@ impl Agent {
target_def.as_ref(),
reflection_chunks,
profile_prompt_suffix,
false,
)
}
/// Constructs a council juror that runs the normal agent tool loop with
/// only read-only tools visible/executable.
///
/// Model council calls need research/memory/search before a juror writes a
/// turn, but they must not mutate files, memory, schedules, wallets, or the
/// host. This constructor reuses the standard harness and provider wiring
/// while filtering the registry before tool specs and policy are built.
pub fn from_config_for_read_only_council_juror(
config: &Config,
juror_name: &str,
model_override: Option<String>,
temperature: Option<f64>,
prompt_suffix: String,
) -> Result<Self> {
let mut agent = Self::build_session_agent_inner(
config,
"orchestrator",
None,
None,
Some(prompt_suffix),
true,
)?;
let safe_name: String = juror_name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '-' {
c
} else {
'_'
}
})
.collect();
agent.set_event_context(
format!("model-council-{safe_name}"),
"model_council_readonly",
);
agent.set_agent_definition_name(format!("model_council_{safe_name}"));
if let Some(model) = model_override
.map(|m| m.trim().to_string())
.filter(|m| !m.is_empty())
{
agent.model_name = model;
}
if let Some(temp) = temperature {
agent.temperature = temp;
}
agent.auto_save = false;
Ok(agent)
}
/// Internal constructor that consumes the optionally-resolved agent
/// definition. Split out from [`Agent::from_config_for_agent`] so
/// the lookup + logging live in one place and the heavy-lifting
@@ -833,6 +885,7 @@ impl Agent {
target_def: Option<&crate::openhuman::agent::harness::definition::AgentDefinition>,
reflection_chunks: Option<Vec<crate::openhuman::subconscious::SourceChunk>>,
profile_prompt_suffix: Option<String>,
read_only_tools_only: bool,
) -> Result<Self> {
let runtime: Arc<dyn host_runtime::RuntimeAdapter> =
Arc::from(host_runtime::create_runtime(&config.runtime)?);
@@ -896,6 +949,19 @@ impl Agent {
}
}
if read_only_tools_only {
let before = tools.len();
tools.retain(|tool| {
tool.permission_level() <= tools::PermissionLevel::ReadOnly
&& !matches!(tool.scope(), tools::ToolScope::CliRpcOnly)
});
log::info!(
"[agent::builder] read-only tool filter applied: before={} after={}",
before,
tools.len()
);
}
// Route the main agent's chat through the unified per-workload
// factory so the user's "Reasoning" routing in the AI settings
// panel (e.g. `reasoning_provider = "anthropic:claude-..."`)
+12
View File
@@ -0,0 +1,12 @@
//! Persistent council definitions for the desktop Model Council surface.
mod schemas;
mod store;
pub mod types;
pub use schemas::{
all_controller_schemas as all_council_registry_controller_schemas,
all_registered_controllers as all_council_registry_registered_controllers,
};
pub use store::{delete_council, get_council, list_councils, upsert_council};
pub use types::{CouncilDefinition, CouncilJudgeDefinition, CouncilSeatDefinition};
+199
View File
@@ -0,0 +1,199 @@
//! JSON-RPC controller surface for persistent council definitions.
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::openhuman::council_registry::types::CouncilDefinition;
use crate::rpc::RpcOutcome;
#[derive(Debug, Deserialize)]
struct CouncilIdParams {
id: String,
}
#[derive(Debug, Deserialize)]
struct UpsertCouncilParams {
council: CouncilDefinition,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![
schemas("list"),
schemas("get"),
schemas("upsert"),
schemas("delete"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![
RegisteredController {
schema: schemas("list"),
handler: handle_list,
},
RegisteredController {
schema: schemas("get"),
handler: handle_get,
},
RegisteredController {
schema: schemas("upsert"),
handler: handle_upsert,
},
RegisteredController {
schema: schemas("delete"),
handler: handle_delete,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
match function {
"list" => ControllerSchema {
namespace: "council_registry",
function: "list",
description: "List saved model council definitions for the current workspace.",
inputs: vec![],
outputs: vec![json_output("result", "Saved council definitions.")],
},
"get" => ControllerSchema {
namespace: "council_registry",
function: "get",
description: "Load one saved model council definition by id.",
inputs: vec![required_string("id", "Council definition id.")],
outputs: vec![json_output(
"result",
"Council definition, or null if not found.",
)],
},
"upsert" => ControllerSchema {
namespace: "council_registry",
function: "upsert",
description: "Create or update a saved model council definition.",
inputs: vec![required_json("council", "Council definition payload.")],
outputs: vec![json_output("result", "Saved council definition.")],
},
"delete" => ControllerSchema {
namespace: "council_registry",
function: "delete",
description: "Delete a saved model council definition by id.",
inputs: vec![required_string("id", "Council definition id.")],
outputs: vec![json_output("result", "True when a council was deleted.")],
},
_ => ControllerSchema {
namespace: "council_registry",
function: "unknown",
description: "Unknown council_registry controller function.",
inputs: vec![],
outputs: vec![FieldSchema {
name: "error",
ty: TypeSchema::String,
comment: "Error message.",
required: true,
}],
},
}
}
fn handle_list(_: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::council_registry::list_councils(&config)?)
})
}
fn handle_get(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = deserialize_params::<CouncilIdParams>(params)?;
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::council_registry::get_council(
&config, &p.id,
)?)
})
}
fn handle_upsert(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = deserialize_params::<UpsertCouncilParams>(params)?;
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::council_registry::upsert_council(
&config, p.council,
)?)
})
}
fn handle_delete(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let p = deserialize_params::<CouncilIdParams>(params)?;
let config = config_rpc::load_config_with_timeout().await?;
to_json(crate::openhuman::council_registry::delete_council(
&config, &p.id,
)?)
})
}
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_json(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Json,
comment,
required: true,
}
}
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!["list", "get", "upsert", "delete"]);
assert_eq!(schemas.len(), all_registered_controllers().len());
}
#[test]
fn schemas_expose_expected_rpc_names() {
let list = schemas("list");
assert_eq!(
crate::core::all::rpc_method_name(&list),
"openhuman.council_registry_list"
);
let upsert = schemas("upsert");
assert_eq!(
crate::core::all::rpc_method_name(&upsert),
"openhuman.council_registry_upsert"
);
assert!(upsert.inputs.iter().any(|input| input.name == "council"));
}
}
+284
View File
@@ -0,0 +1,284 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::openhuman::config::Config;
use crate::openhuman::council_registry::types::{
default_council, CouncilDefinition, DEFAULT_COUNCIL_ID, DEFAULT_MODEL,
};
use crate::rpc::RpcOutcome;
const STORE_DIR: &str = "council_registry";
const STORE_FILE: &str = "councils.json";
const MAX_JURY_COUNT: usize = 5;
const MIN_JURY_COUNT: usize = 1;
const MAX_DEBATE_ROUNDS: usize = 4;
const MIN_DEBATE_ROUNDS: usize = 2;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct CouncilStore {
#[serde(default)]
councils: Vec<CouncilDefinition>,
}
pub fn list_councils(config: &Config) -> Result<RpcOutcome<Vec<CouncilDefinition>>, String> {
let path = store_path(config);
let mut store = load_store_with_initial_default(&path)?;
sort_councils(&mut store.councils);
Ok(RpcOutcome::single_log(
store.councils,
"council registry listed",
))
}
pub fn get_council(
config: &Config,
id: &str,
) -> Result<RpcOutcome<Option<CouncilDefinition>>, String> {
let path = store_path(config);
let store = load_store_with_initial_default(&path)?;
let council = store.councils.into_iter().find(|council| council.id == id);
Ok(RpcOutcome::single_log(council, "council registry loaded"))
}
pub fn upsert_council(
config: &Config,
council: CouncilDefinition,
) -> Result<RpcOutcome<CouncilDefinition>, String> {
let path = store_path(config);
let mut store = load_store_with_initial_default(&path)?;
let now_ms = now_ms();
let mut normalized = normalize_council(council, now_ms);
if let Some(existing) = store
.councils
.iter()
.find(|candidate| candidate.id == normalized.id)
{
normalized.created_at_ms = existing.created_at_ms;
}
store
.councils
.retain(|candidate| candidate.id != normalized.id);
store.councils.push(normalized.clone());
sort_councils(&mut store.councils);
save_store(&path, &store)?;
Ok(RpcOutcome::single_log(normalized, "council registry saved"))
}
pub fn delete_council(config: &Config, id: &str) -> Result<RpcOutcome<bool>, String> {
let path = store_path(config);
let mut store = load_store(&path)?;
let before = store.councils.len();
store.councils.retain(|council| council.id != id);
let deleted = before != store.councils.len() || (!path.exists() && id == DEFAULT_COUNCIL_ID);
save_store(&path, &store)?;
Ok(RpcOutcome::single_log(deleted, "council registry deleted"))
}
fn store_path(config: &Config) -> PathBuf {
config.workspace_dir.join(STORE_DIR).join(STORE_FILE)
}
fn load_store(path: &Path) -> Result<CouncilStore, String> {
if !path.exists() {
return Ok(CouncilStore::default());
}
let contents = fs::read_to_string(path)
.map_err(|e| format!("failed to read council registry {}: {e}", path.display()))?;
if contents.trim().is_empty() {
return Ok(CouncilStore::default());
}
serde_json::from_str(&contents)
.map_err(|e| format!("failed to parse council registry {}: {e}", path.display()))
}
fn load_store_with_initial_default(path: &Path) -> Result<CouncilStore, String> {
if !path.exists() {
return Ok(CouncilStore {
councils: vec![default_council(now_ms())],
});
}
load_store(path)
}
fn save_store(path: &Path, store: &CouncilStore) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| {
format!(
"failed to create council registry dir {}: {e}",
parent.display()
)
})?;
}
let contents = serde_json::to_string_pretty(store)
.map_err(|e| format!("failed to serialize council registry: {e}"))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, contents).map_err(|e| {
format!(
"failed to write council registry {}: {e}",
tmp_path.display()
)
})?;
fs::rename(&tmp_path, path)
.map_err(|e| format!("failed to replace council registry {}: {e}", path.display()))
}
fn normalize_council(mut council: CouncilDefinition, now_ms: i64) -> CouncilDefinition {
council.id = if council.id.trim().is_empty() {
format!("council-{}", uuid::Uuid::new_v4())
} else {
council.id.trim().to_string()
};
council.name = if council.name.trim().is_empty() {
"Untitled council".to_string()
} else {
council.name.trim().to_string()
};
council.description = council.description.trim().to_string();
council.jury_count = council.jury_count.clamp(MIN_JURY_COUNT, MAX_JURY_COUNT);
council.debate_rounds = council
.debate_rounds
.clamp(MIN_DEBATE_ROUNDS, MAX_DEBATE_ROUNDS);
council.seats.truncate(council.jury_count);
for (index, seat) in council.seats.iter_mut().enumerate() {
seat.name = if seat.name.trim().is_empty() {
format!("Juror {}", index + 1)
} else {
seat.name.trim().to_string()
};
seat.mode = normalize_mode(&seat.mode);
seat.profile_id = seat.profile_id.trim().to_string();
seat.model = normalize_model(&seat.model);
seat.brief = seat.brief.trim().to_string();
}
while council.seats.len() < council.jury_count {
let id = council.seats.iter().map(|seat| seat.id).max().unwrap_or(0) + 1;
council.seats.push(
crate::openhuman::council_registry::types::CouncilSeatDefinition {
id,
mode: "default".to_string(),
profile_id: String::new(),
name: format!("Juror {}", council.seats.len() + 1),
model: DEFAULT_MODEL.to_string(),
brief: String::new(),
},
);
}
council.judge.mode = normalize_mode(&council.judge.mode);
council.judge.profile_id = council.judge.profile_id.trim().to_string();
council.judge.name = if council.judge.name.trim().is_empty() {
"Chief Judge".to_string()
} else {
council.judge.name.trim().to_string()
};
council.judge.model = normalize_model(&council.judge.model);
if council.shared_reasoning.trim().is_empty() {
council.shared_reasoning =
crate::openhuman::council_registry::types::DEFAULT_SHARED_REASONING.to_string();
}
if council.created_at_ms <= 0 {
council.created_at_ms = now_ms;
}
council.updated_at_ms = now_ms;
council
}
fn normalize_mode(value: &str) -> String {
match value.trim() {
"profile" => "profile".to_string(),
"custom" => "custom".to_string(),
_ => "default".to_string(),
}
}
fn normalize_model(value: &str) -> String {
let trimmed = value.trim();
if trimmed.is_empty() {
DEFAULT_MODEL.to_string()
} else {
trimmed.to_string()
}
}
fn sort_councils(councils: &mut [CouncilDefinition]) {
councils.sort_by(|a, b| {
let default_order = (a.id != DEFAULT_COUNCIL_ID).cmp(&(b.id != DEFAULT_COUNCIL_ID));
default_order
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
.then_with(|| a.id.cmp(&b.id))
});
}
fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn config_for_workspace(workspace: &Path) -> Config {
let mut config = Config::default();
config.workspace_dir = workspace.to_path_buf();
config
}
#[test]
fn list_returns_built_in_default_when_store_is_empty() {
let tmp = tempfile::tempdir().unwrap();
let config = config_for_workspace(tmp.path());
let payload = list_councils(&config).unwrap().value;
assert_eq!(payload.len(), 1);
assert_eq!(payload[0].id, DEFAULT_COUNCIL_ID);
assert_eq!(payload[0].jury_count, 3);
assert_eq!(payload[0].seats.len(), 3);
assert_eq!(payload[0].seats[0].model, DEFAULT_MODEL);
assert_eq!(payload[0].judge.model, DEFAULT_MODEL);
}
#[test]
fn upsert_get_and_delete_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let config = config_for_workspace(tmp.path());
let mut council = default_council(1);
council.id.clear();
council.name = " Product review ".to_string();
council.jury_count = 8;
council.debate_rounds = 1;
let saved = upsert_council(&config, council).unwrap().value;
assert!(saved.id.starts_with("council-"));
assert_eq!(saved.name, "Product review");
assert_eq!(saved.jury_count, MAX_JURY_COUNT);
assert_eq!(saved.debate_rounds, MIN_DEBATE_ROUNDS);
let fetched = get_council(&config, &saved.id).unwrap().value.unwrap();
assert_eq!(fetched.id, saved.id);
let deleted = delete_council(&config, &saved.id).unwrap().value;
assert!(deleted);
let missing = get_council(&config, &saved.id).unwrap().value;
assert!(missing.is_none());
}
#[test]
fn default_council_can_be_deleted_and_registry_stays_empty() {
let tmp = tempfile::tempdir().unwrap();
let config = config_for_workspace(tmp.path());
let deleted = delete_council(&config, DEFAULT_COUNCIL_ID).unwrap().value;
assert!(deleted);
let payload = list_councils(&config).unwrap().value;
assert!(payload.is_empty());
}
}
+126
View File
@@ -0,0 +1,126 @@
use serde::{Deserialize, Serialize};
pub const DEFAULT_COUNCIL_ID: &str = "default-council";
pub const DEFAULT_MODEL: &str = crate::openhuman::config::MODEL_REASONING_V1;
pub const DEFAULT_SHARED_REASONING: &str = "# Shared reasoning\n- Claims the council agrees on:\n- Open disagreements:\n- Evidence or constraints to preserve:\n- Judge synthesis notes:";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CouncilDefinition {
#[serde(default)]
pub id: String,
pub name: String,
#[serde(default)]
pub description: String,
#[serde(default = "default_jury_count")]
pub jury_count: usize,
#[serde(default = "default_debate_rounds")]
pub debate_rounds: usize,
#[serde(default = "default_seats")]
pub seats: Vec<CouncilSeatDefinition>,
#[serde(default = "default_judge")]
pub judge: CouncilJudgeDefinition,
#[serde(default = "default_shared_reasoning")]
pub shared_reasoning: String,
#[serde(default)]
pub created_at_ms: i64,
#[serde(default)]
pub updated_at_ms: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CouncilSeatDefinition {
pub id: u64,
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default)]
pub profile_id: String,
pub name: String,
#[serde(default = "default_model")]
pub model: String,
#[serde(default)]
pub brief: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct CouncilJudgeDefinition {
#[serde(default = "default_mode")]
pub mode: String,
#[serde(default)]
pub profile_id: String,
pub name: String,
#[serde(default = "default_model")]
pub model: String,
}
pub fn default_council(now_ms: i64) -> CouncilDefinition {
CouncilDefinition {
id: DEFAULT_COUNCIL_ID.to_string(),
name: "Default council".to_string(),
description: "Balanced analyst, builder, and skeptic jury.".to_string(),
jury_count: default_jury_count(),
debate_rounds: default_debate_rounds(),
seats: default_seats(),
judge: default_judge(),
shared_reasoning: default_shared_reasoning(),
created_at_ms: now_ms,
updated_at_ms: now_ms,
}
}
fn default_jury_count() -> usize {
3
}
fn default_debate_rounds() -> usize {
3
}
fn default_mode() -> String {
"default".to_string()
}
fn default_model() -> String {
DEFAULT_MODEL.to_string()
}
fn default_shared_reasoning() -> String {
DEFAULT_SHARED_REASONING.to_string()
}
fn default_judge() -> CouncilJudgeDefinition {
CouncilJudgeDefinition {
mode: "default".to_string(),
profile_id: String::new(),
name: "Chief Judge".to_string(),
model: DEFAULT_MODEL.to_string(),
}
}
fn default_seats() -> Vec<CouncilSeatDefinition> {
vec![
CouncilSeatDefinition {
id: 0,
mode: "default".to_string(),
profile_id: String::new(),
name: "Analyst".to_string(),
model: DEFAULT_MODEL.to_string(),
brief: "Evidence, assumptions, and risk.".to_string(),
},
CouncilSeatDefinition {
id: 1,
mode: "default".to_string(),
profile_id: String::new(),
name: "Builder".to_string(),
model: DEFAULT_MODEL.to_string(),
brief: "Practical implementation path.".to_string(),
},
CouncilSeatDefinition {
id: 2,
mode: "default".to_string(),
profile_id: String::new(),
name: "Skeptic".to_string(),
model: DEFAULT_MODEL.to_string(),
brief: "Failure modes and missing context.".to_string(),
},
]
}
+1
View File
@@ -35,6 +35,7 @@ pub mod config;
pub mod connectivity;
pub mod context;
pub mod cost;
pub mod council_registry;
pub mod credentials;
pub mod cron;
pub mod cwd_jail;
+205 -64
View File
@@ -1,20 +1,22 @@
//! 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.
//! concurrently. Member turns use the standard agent harness with a restricted
//! read-only tool registry, so jurors can recall memory, search, and inspect
//! context before answering without mutating user state. A single **chair**
//! model then synthesizes 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)
//! ## Why read-only agent loops
//!
//! 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.
//! Council members may need read-only context gathering before a turn, but a
//! deliberation surface must not let a juror write files, store/forget memory,
//! schedule work, or run host mutations. The member runner therefore reuses the
//! normal [`Agent`] turn loop while constructing the session through
//! `from_config_for_read_only_council_juror`, which filters the tool registry
//! before tool specs are sent to the provider. The chair synthesis remains a
//! plain completion over the collected debate record.
//!
//! ## Partial failure is tolerated, total failure is not
//!
@@ -28,8 +30,9 @@
//! I/O orchestrator ([`run_council`]) so the deliberation logic is unit-tested
//! without any network or provider.
use serde::Serialize;
use serde::{Deserialize, Serialize};
use crate::openhuman::agent::Agent;
use crate::openhuman::config::Config;
use crate::openhuman::inference::local::rpc::agent_chat_simple;
use crate::rpc::RpcOutcome;
@@ -46,7 +49,7 @@ pub const MAX_COUNCIL_MEMBERS: usize = 5;
/// `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)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CouncilMemberResult {
/// The model id this seat ran (the resolved override passed to the provider).
pub model: String,
@@ -57,7 +60,7 @@ pub struct CouncilMemberResult {
}
/// Full result of a council run: every member's answer plus the chair synthesis.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ModelCouncilResult {
/// The original user question, echoed back for display / logging.
pub question: String,
@@ -69,23 +72,56 @@ pub struct ModelCouncilResult {
pub synthesis: String,
}
/// Normalize the requested member model list: trim each id, drop blanks, and
/// de-duplicate while preserving first-seen order.
/// Sentinel model id that means "use the configured default model".
///
/// 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.
/// The council UI uses this for default-profile jurors and the default judge so
/// it does not bypass the user's configured provider with a hard-coded model id.
pub const DEFAULT_MODEL_SENTINEL: &str = "default";
/// Normalize the requested member model list: trim each id and drop blanks
/// while preserving seat order.
///
/// Repeated model ids are intentionally retained. The council UX can create
/// several jurors that share a model but carry different profile/flavor context
/// in the prompt, so deduplicating here would silently reduce the configured
/// jury count. 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());
}
member_models
.iter()
.map(|raw| raw.trim())
.filter(|trimmed| !trimmed.is_empty())
.map(str::to_string)
.collect()
}
fn is_default_model_sentinel(model: &str) -> bool {
model.trim().eq_ignore_ascii_case(DEFAULT_MODEL_SENTINEL)
}
fn configured_default_model(config: &Config) -> String {
config
.default_model
.as_deref()
.map(str::trim)
.filter(|model| !model.is_empty())
.unwrap_or(crate::openhuman::config::DEFAULT_MODEL)
.to_string()
}
fn model_override_for_call(model: &str) -> Option<String> {
if is_default_model_sentinel(model) {
None
} else {
Some(model.trim().to_string())
}
}
fn model_label_for_result(config: &Config, model: &str) -> String {
if is_default_model_sentinel(model) {
configured_default_model(config)
} else {
model.trim().to_string()
}
seen
}
/// Validate a council request against the *normalized* member list. PURE.
@@ -116,6 +152,16 @@ pub fn validate_council_request(
Ok(())
}
fn validate_member_request(question: &str, model: &str) -> Result<(), String> {
if question.trim().is_empty() {
return Err("model council: question must not be empty".to_string());
}
if model.trim().is_empty() {
return Err("model council: member 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
@@ -206,26 +252,9 @@ pub async fn run_council(
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 member_futures = models
.iter()
.map(|model| run_member_answer_inner(config, question, model, temperature));
let members: Vec<CouncilMemberResult> = futures_util::future::join_all(member_futures).await;
let success_count = members.iter().filter(|m| m.response.is_some()).count();
@@ -235,22 +264,66 @@ pub async fn run_council(
members.len() - success_count
);
synthesize_members(config, question, members, chair_model, temperature).await
}
/// Run one council member seat and return its answer or failure in-band.
///
/// This is used by the desktop UI for progressive deliberation: each juror can
/// complete independently and the UI can show that real answer immediately
/// while the remaining seats are still thinking.
pub async fn answer_member(
config: &Config,
question: &str,
model: &str,
temperature: Option<f64>,
) -> Result<RpcOutcome<CouncilMemberResult>, String> {
validate_member_request(question, model)?;
let result = run_member_answer_inner(config, question, model, temperature).await;
Ok(RpcOutcome::single_log(
result,
"model council member completed",
))
}
/// Ask the chair to synthesize a set of already-collected member answers.
pub async fn synthesize_members(
config: &Config,
question: &str,
members: Vec<CouncilMemberResult>,
chair_model: &str,
temperature: Option<f64>,
) -> Result<RpcOutcome<ModelCouncilResult>, String> {
if question.trim().is_empty() {
return Err("model council: question must not be empty".to_string());
}
if members.is_empty() {
return Err("model council: at least one member answer is required".to_string());
}
if members.len() > MAX_COUNCIL_MEMBERS {
return Err(format!(
"model council: too many member answers ({}, max {})",
members.len(),
MAX_COUNCIL_MEMBERS
));
}
if chair_model.trim().is_empty() {
return Err("model council: chair model must not be empty".to_string());
}
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;
let chair_model_label = model_label_for_result(config, chair_model);
let chair_model_override = model_override_for_call(chair_model);
log::debug!("[model-council] convening chair model: {chair_model_label}");
let synthesis = agent_chat_simple(config, &synthesis_prompt, chair_model_override, temperature)
.await
.map_err(|e| format!("model council: chair synthesis failed: {e}"))?
.value;
log::debug!(
"[model-council] synthesis complete: {} chars",
synthesis.len()
@@ -259,7 +332,7 @@ pub async fn run_council(
let result = ModelCouncilResult {
question: question.to_string(),
members,
chair_model: chair_model.to_string(),
chair_model: chair_model_label,
synthesis,
};
Ok(RpcOutcome::single_log(
@@ -268,6 +341,59 @@ pub async fn run_council(
))
}
async fn run_member_answer_inner(
config: &Config,
question: &str,
model: &str,
temperature: Option<f64>,
) -> CouncilMemberResult {
let result_model = model_label_for_result(config, model);
let model_override = if is_default_model_sentinel(model) {
Some(configured_default_model(config))
} else {
model_override_for_call(model)
};
let profile_prompt = build_read_only_juror_profile_prompt(&result_model);
match Agent::from_config_for_read_only_council_juror(
config,
&result_model,
model_override,
temperature,
profile_prompt,
) {
Ok(mut agent) => match agent.run_single(question).await {
Ok(response) => CouncilMemberResult {
model: result_model,
response: Some(response),
error: None,
},
Err(e) => CouncilMemberResult {
model: result_model,
response: None,
error: Some(e.to_string()),
},
},
Err(e) => CouncilMemberResult {
model: result_model,
response: None,
error: Some(e.to_string()),
},
}
}
fn build_read_only_juror_profile_prompt(model_label: &str) -> String {
format!(
"You are a model-council juror running as {model_label}.\n\
You may use only the read-only tools exposed to this session, such as \
memory recall, search, fetch, listing, and diagnostic tools. Never try \
to write files, store or forget memory, schedule work, send messages, \
execute commands, or mutate user state.\n\
Before each answer, use read-only tools when they would materially \
improve factual grounding. Then write a concise council thought and \
your current conclusion for this debate turn."
)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -290,18 +416,33 @@ mod tests {
}
#[test]
fn normalize_trims_drops_blanks_and_dedups_preserving_order() {
fn normalize_trims_drops_blanks_and_preserves_repeated_seats() {
let input = vec![
" gpt ".to_string(),
"claude".to_string(),
"".to_string(),
" ".to_string(),
"gpt".to_string(), // dup of trimmed " gpt "
"gpt".to_string(), // repeated model: separate council seat
"gemini".to_string(),
"claude".to_string(), // dup
"claude".to_string(), // repeated model: separate council seat
];
let out = normalize_member_models(&input);
assert_eq!(out, vec!["gpt", "claude", "gemini"]);
assert_eq!(out, vec!["gpt", "claude", "gpt", "gemini", "claude"]);
}
#[test]
fn default_sentinel_resolves_to_configured_default_model() {
let mut config = Config::default();
config.default_model = Some("configured-model".to_string());
assert_eq!(
model_label_for_result(&config, DEFAULT_MODEL_SENTINEL),
"configured-model"
);
assert_eq!(model_override_for_call(DEFAULT_MODEL_SENTINEL), None);
assert_eq!(
model_override_for_call("explicit-model"),
Some("explicit-model".to_string())
);
}
#[test]
+165 -11
View File
@@ -1,9 +1,9 @@
//! 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.
//! Exposes model-council controller methods. `openhuman.model_council_run`
//! remains the batch API. `openhuman.model_council_answer_member` and
//! `openhuman.model_council_synthesize` let the desktop UI show progressive
//! per-juror answers before the judge synthesis completes.
use serde::de::DeserializeOwned;
use serde::Deserialize;
@@ -22,15 +22,44 @@ struct ModelCouncilParams {
temperature: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct ModelCouncilMemberParams {
question: String,
model: String,
temperature: Option<f64>,
}
#[derive(Debug, Deserialize)]
struct ModelCouncilSynthesizeParams {
question: String,
members: Vec<crate::openhuman::model_council::council::CouncilMemberResult>,
chair_model: String,
temperature: Option<f64>,
}
pub fn all_controller_schemas() -> Vec<ControllerSchema> {
vec![schemas("run")]
vec![
schemas("run"),
schemas("answer_member"),
schemas("synthesize"),
]
}
pub fn all_registered_controllers() -> Vec<RegisteredController> {
vec![RegisteredController {
schema: schemas("run"),
handler: handle_run,
}]
vec![
RegisteredController {
schema: schemas("run"),
handler: handle_run,
},
RegisteredController {
schema: schemas("answer_member"),
handler: handle_answer_member,
},
RegisteredController {
schema: schemas("synthesize"),
handler: handle_synthesize,
},
]
}
pub fn schemas(function: &str) -> ControllerSchema {
@@ -45,7 +74,7 @@ pub fn schemas(function: &str) -> ControllerSchema {
required_string("question", "The question to put to the council."),
required_string_array(
"member_models",
"Member model ids to consult (deduplicated; max 5).",
"Member model ids or `default` seats to consult (repeated seats preserved; max 5).",
),
required_string(
"chair_model",
@@ -61,6 +90,46 @@ pub fn schemas(function: &str) -> ControllerSchema {
"Council result: per-member answers + chair synthesis.",
)],
},
"answer_member" => ControllerSchema {
namespace: "model_council",
function: "answer_member",
description: "Run one council juror as a single model call. Returns that member's \
answer or failure in-band so the UI can update progressively.",
inputs: vec![
required_string("question", "The question to put to this council member."),
required_string("model", "Member model id or `default` sentinel."),
optional_f64(
"temperature",
"Optional sampling temperature for the member call.",
),
],
outputs: vec![json_output(
"result",
"One member answer with response or error.",
)],
},
"synthesize" => ControllerSchema {
namespace: "model_council",
function: "synthesize",
description: "Ask the judge model to synthesize already-collected council member \
answers. Used after progressive member calls complete.",
inputs: vec![
required_string("question", "The original council question."),
required_json_array(
"members",
"Collected member answers from model_council.answer_member.",
),
required_string(
"chair_model",
"Model id or `default` sentinel that synthesizes the member answers.",
),
optional_f64(
"temperature",
"Optional sampling temperature for the synthesis call.",
),
],
outputs: vec![json_output("result", "Council result with chair synthesis.")],
},
_ => ControllerSchema {
namespace: "model_council",
function: "unknown",
@@ -105,6 +174,60 @@ fn handle_run(params: Map<String, Value>) -> ControllerFuture {
})
}
fn handle_answer_member(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!("[model-council] handle_answer_member: received RPC request");
let p = deserialize_params::<ModelCouncilMemberParams>(params)?;
log::debug!(
"[model-council] handle_answer_member: question_len={}, model={}",
p.question.len(),
p.model
);
let config = config_rpc::load_config_with_timeout().await?;
to_json(
crate::openhuman::model_council::council::answer_member(
&config,
&p.question,
&p.model,
p.temperature,
)
.await
.map_err(|e| {
log::debug!("[model-council] handle_answer_member failed: {e}");
e
})?,
)
})
}
fn handle_synthesize(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
log::debug!("[model-council] handle_synthesize: received RPC request");
let p = deserialize_params::<ModelCouncilSynthesizeParams>(params)?;
log::debug!(
"[model-council] handle_synthesize: question_len={}, members={}, chair={}",
p.question.len(),
p.members.len(),
p.chair_model
);
let config = config_rpc::load_config_with_timeout().await?;
to_json(
crate::openhuman::model_council::council::synthesize_members(
&config,
&p.question,
p.members,
&p.chair_model,
p.temperature,
)
.await
.map_err(|e| {
log::debug!("[model-council] handle_synthesize 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}"))
}
@@ -127,6 +250,15 @@ fn required_string_array(name: &'static str, comment: &'static str) -> FieldSche
}
}
fn required_json_array(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
ty: TypeSchema::Array(Box::new(TypeSchema::Json)),
comment,
required: true,
}
}
fn optional_f64(name: &'static str, comment: &'static str) -> FieldSchema {
FieldSchema {
name,
@@ -157,7 +289,7 @@ mod tests {
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!(functions, vec!["run", "answer_member", "synthesize"]);
assert_eq!(schemas.len(), all_registered_controllers().len());
}
@@ -198,6 +330,28 @@ mod tests {
assert_eq!(unknown.outputs[0].name, "error");
}
#[test]
fn progressive_schemas_expose_expected_methods() {
let member = schemas("answer_member");
assert_eq!(
crate::core::all::rpc_method_name(&member),
"openhuman.model_council_answer_member"
);
assert!(member.inputs.iter().any(|input| input.name == "model"));
let synthesize = schemas("synthesize");
assert_eq!(
crate::core::all::rpc_method_name(&synthesize),
"openhuman.model_council_synthesize"
);
let members = synthesize
.inputs
.iter()
.find(|input| input.name == "members")
.expect("members input present");
assert!(matches!(members.ty, TypeSchema::Array(_)));
}
#[test]
fn deserialize_params_parses_a_well_formed_payload() {
let params = Map::from_iter([
+349
View File
@@ -897,6 +897,38 @@ enabled = false
toml::from_str(&cfg).expect("config toml must match Config schema");
}
fn write_model_council_unreachable_inference_config(openhuman_dir: &Path, api_origin: &str) {
let cfg = format!(
r#"api_url = "{api_origin}"
inference_url = "http://127.0.0.1:9/v1"
api_key = "test-key"
default_model = "e2e-unreachable-model"
default_temperature = 0.7
chat_onboarding_completed = true
[secrets]
encrypt = false
"#
);
fn write_config_file(config_dir: &Path, cfg: &str) {
std::fs::create_dir_all(config_dir).expect("mkdir openhuman");
let path = config_dir.join("config.toml");
std::fs::write(&path, cfg).expect("write config");
}
write_config_file(openhuman_dir, &cfg);
if openhuman_dir
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new(".openhuman"))
{
write_config_file(&openhuman_dir.join("users").join("local"), &cfg);
}
let _: openhuman_core::openhuman::config::Config =
toml::from_str(&cfg).expect("config toml must match Config schema");
}
fn ensure_test_rpc_auth() {
JSON_RPC_AUTH_INIT.get_or_init(|| {
// SAFETY: set_var is inside get_or_init so it runs exactly once across
@@ -1755,6 +1787,323 @@ async fn json_rpc_protocol_auth_and_agent_hello() {
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_model_council_runs_with_default_sentinel_and_repeated_jury_seats() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
let store = post_json_rpc(
&rpc_base,
18_000,
"openhuman.auth_store_session",
json!({
"token": "e2e-test-jwt",
"user_id": "e2e-user"
}),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
with_chat_completion_models(|models| models.clear());
with_chat_completion_requests(|requests| requests.clear());
let council_question = [
"Council workspace: shared_reasoning.md",
"Council roster:",
"1. Analyst (default) — Evidence and risk.",
"2. Builder (default) — Implementation path.",
"3. Critic (critic-model) — Missing context.",
"",
"shared_reasoning.md:",
"# Shared reasoning",
"- Claims the council agrees on:",
"",
"User question:",
"What should the council decide?",
]
.join("\n");
let council = post_json_rpc(
&rpc_base,
18_001,
"openhuman.model_council_run",
json!({
"question": council_question,
"member_models": ["default", "default", "critic-model"],
"chair_model": "default",
"temperature": 0.2
}),
)
.await;
let outer = assert_no_jsonrpc_error(&council, "model_council_run");
let result = outer.get("result").unwrap_or(outer);
assert_eq!(
result.get("question").and_then(Value::as_str),
Some(council_question.as_str())
);
let members = result
.get("members")
.and_then(Value::as_array)
.expect("members array");
assert_eq!(members.len(), 3, "repeated default seats must not dedupe");
assert_eq!(
members
.iter()
.map(|member| member.get("model").and_then(Value::as_str).unwrap_or(""))
.collect::<Vec<_>>(),
vec!["e2e-mock-model", "e2e-mock-model", "critic-model"]
);
assert!(members.iter().all(|member| {
member.get("response").and_then(Value::as_str).is_some()
&& member.get("error").is_some_and(Value::is_null)
}));
assert_eq!(
result.get("chair_model").and_then(Value::as_str),
Some("e2e-mock-model")
);
assert!(
result
.get("synthesis")
.and_then(Value::as_str)
.unwrap_or_default()
.contains("e2e mock"),
"mock chair synthesis should be returned: {result}"
);
let captured_models = with_chat_completion_models(|models| models.clone());
assert_eq!(
captured_models,
vec![
"e2e-mock-model",
"e2e-mock-model",
"critic-model",
"e2e-mock-model"
],
"three member calls plus one default chair call should be made"
);
let captured_requests = with_chat_completion_requests(|requests| requests.clone());
assert_eq!(captured_requests.len(), 4);
assert!(
captured_requests[3]
.pointer("/body/messages")
.and_then(Value::as_array)
.is_some_and(|messages| messages.iter().any(|message| {
message
.get("content")
.and_then(Value::as_str)
.is_some_and(|content| {
content.contains("shared_reasoning.md")
&& content.contains("Model A")
&& content.contains("Model C")
})
})),
"chair prompt should include shared reasoning and all member labels: {:?}",
captured_requests[3]
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_model_council_progressive_member_answers_then_synthesizes() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_min_config(&openhuman_home, &mock_origin);
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_min_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
let store = post_json_rpc(
&rpc_base,
18_050,
"openhuman.auth_store_session",
json!({
"token": "e2e-test-jwt",
"user_id": "e2e-user"
}),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
with_chat_completion_models(|models| models.clear());
with_chat_completion_requests(|requests| requests.clear());
let question =
"Council workspace: shared_reasoning.md\n\nUser question:\nStream juror thoughts";
let first = post_json_rpc(
&rpc_base,
18_051,
"openhuman.model_council_answer_member",
json!({
"question": question,
"model": "default",
"temperature": 0.2
}),
)
.await;
let first_outer = assert_no_jsonrpc_error(&first, "model_council_answer_member first");
let first_member = first_outer.get("result").unwrap_or(first_outer).clone();
assert_eq!(
first_member.get("model").and_then(Value::as_str),
Some("e2e-mock-model")
);
assert!(first_member
.get("response")
.and_then(Value::as_str)
.is_some_and(|response| response.contains("e2e mock")));
let second = post_json_rpc(
&rpc_base,
18_052,
"openhuman.model_council_answer_member",
json!({
"question": question,
"model": "critic-model"
}),
)
.await;
let second_outer = assert_no_jsonrpc_error(&second, "model_council_answer_member second");
let second_member = second_outer.get("result").unwrap_or(second_outer).clone();
assert_eq!(
second_member.get("model").and_then(Value::as_str),
Some("critic-model")
);
let synthesis = post_json_rpc(
&rpc_base,
18_053,
"openhuman.model_council_synthesize",
json!({
"question": question,
"members": [first_member, second_member],
"chair_model": "default"
}),
)
.await;
let outer = assert_no_jsonrpc_error(&synthesis, "model_council_synthesize");
let result = outer.get("result").unwrap_or(outer);
assert_eq!(
result.get("chair_model").and_then(Value::as_str),
Some("e2e-mock-model")
);
assert_eq!(
result
.get("members")
.and_then(Value::as_array)
.map(Vec::len),
Some(2)
);
assert!(result
.get("synthesis")
.and_then(Value::as_str)
.is_some_and(|text| text.contains("e2e mock")));
let captured_models = with_chat_completion_models(|models| models.clone());
assert_eq!(
captured_models,
vec!["e2e-mock-model", "critic-model", "e2e-mock-model"],
"two progressive member calls plus one default chair call should be made"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_model_council_reports_total_member_failure_before_synthesis() {
let _env_lock = json_rpc_e2e_env_lock();
let tmp = tempdir().expect("tempdir");
let home = tmp.path();
let openhuman_home = home.join(".openhuman");
let _home_guard = EnvVarGuard::set_to_path("HOME", home);
let _workspace_guard = EnvVarGuard::unset("OPENHUMAN_WORKSPACE");
let _backend_url_guard = EnvVarGuard::unset("BACKEND_URL");
let _vite_backend_guard = EnvVarGuard::unset("VITE_BACKEND_URL");
let _api_url_guard = EnvVarGuard::unset("OPENHUMAN_API_URL");
let (mock_addr, mock_join) = serve_on_ephemeral(mock_upstream_router()).await;
let mock_origin = format!("http://{}", mock_addr);
write_model_council_unreachable_inference_config(&openhuman_home, &mock_origin);
let user_scoped_dir = openhuman_home.join("users").join("e2e-user");
write_model_council_unreachable_inference_config(&user_scoped_dir, &mock_origin);
let (rpc_addr, rpc_join) = serve_on_ephemeral(build_core_http_router(false)).await;
let rpc_base = format!("http://{}", rpc_addr);
let store = post_json_rpc(
&rpc_base,
18_100,
"openhuman.auth_store_session",
json!({
"token": "e2e-test-jwt",
"user_id": "e2e-user"
}),
)
.await;
assert_no_jsonrpc_error(&store, "store_session");
with_chat_completion_requests(|requests| requests.clear());
let council = post_json_rpc(
&rpc_base,
18_101,
"openhuman.model_council_run",
json!({
"question": "Council workspace: shared_reasoning.md\n\nUser question:\nFail deterministically",
"member_models": ["default", "default"],
"chair_model": "default"
}),
)
.await;
let err = assert_jsonrpc_error(&council, "model_council_run all failed");
assert!(
err.get("message")
.and_then(Value::as_str)
.unwrap_or_default()
.contains("model council: all member models failed to respond"),
"unexpected model council error: {err}"
);
assert!(
with_chat_completion_requests(|requests| requests.is_empty()),
"unreachable direct inference should not hit mock backend"
);
mock_join.abort();
rpc_join.abort();
}
#[tokio::test]
async fn json_rpc_prompt_injection_is_rejected_before_model_call() {
let _env_lock = json_rpc_e2e_env_lock();