mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Improve council agent UX and persistence (#3393)
This commit is contained in:
@@ -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
@@ -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':
|
||||
'ثقب بنيوي: جيران هذا الكيان غير مترابطين فيما بينهم — وهو الرابط الوحيد بينهم.',
|
||||
|
||||
@@ -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':
|
||||
'কাঠামোগত ছিদ্র: এই সত্তার প্রতিবেশীরা একে অপরের সাথে যুক্ত নয় — এটিই তাদের মধ্যে একমাত্র সংযোগ।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 l’agent 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 l’agent',
|
||||
'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.',
|
||||
|
||||
@@ -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':
|
||||
'संरचनात्मक छेद: इस इकाई के पड़ोसी आपस में नहीं जुड़े — यह उनके बीच एकमात्र कड़ी है।',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 l’agente 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.",
|
||||
|
||||
@@ -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':
|
||||
'구조적 공백: 이 엔티티의 이웃들은 서로 연결되어 있지 않습니다 — 이들 사이의 유일한 연결고리입니다.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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':
|
||||
'Структурная дыра: соседи этой сущности не связаны друг с другом — она единственная связь между ними.',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
`);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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-..."`)
|
||||
|
||||
@@ -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};
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user