feat(councils-ui): add on-demand council conversations panel and model config controls

This commit is contained in:
William Valentin
2026-02-21 11:26:04 -08:00
parent cfd7fa6fd0
commit 7c121b82c6
11 changed files with 481 additions and 4 deletions
+122
View File
@@ -10,6 +10,47 @@ export interface ConfigHandlerDeps {
modelRouter?: ModelRouter;
}
function ensureCouncilsConfig(config: Config): NonNullable<Config['councils']> {
config.councils ??= {
enabled: false,
defaults: {
max_rounds: 2,
ideas_per_round: 6,
top_ideas_for_bridge: 3,
bridge_packet_max_chars: 2500,
bridge_field_max_bullets: 6,
bridge_entry_max_chars: 300,
novelty_delta_threshold: 10,
repetition_threshold: 70,
},
strict_grounding: false,
strict_meta_validation: true,
groups: {
D: {
arbiter_agent: 'council_d_arbiter',
freethinker_agent: 'council_d_freethinker',
model_tier: 'complex',
group_prompt_prefix: 'Optimize for feasibility and speed-to-test. Prefer boring-but-true.',
novelty_bias: 'low',
risk_tolerance: 'low',
forbidden_approaches: ['moonshots', 'handwavy AI claims', 'unverified assumptions'],
},
P: {
arbiter_agent: 'council_p_arbiter',
freethinker_agent: 'council_p_freethinker',
model_tier: 'complex',
group_prompt_prefix: 'Optimize for reframing and non-obvious leverage. Weird is fine; label speculation.',
novelty_bias: 'high',
risk_tolerance: 'high',
forbidden_approaches: ['incremental tweaks', 'obvious best practices', 'purely conventional solutions'],
},
},
meta_arbiter_agent: 'council_meta_arbiter',
meta_model_tier: 'complex',
};
return config.councils;
}
/**
* Redact sensitive values from config before returning.
* Replaces API keys, tokens, passwords, and other credentials with "***".
@@ -316,6 +357,87 @@ const PATCHABLE_KEYS: Record<string, (config: Config, value: unknown) => boolean
config.agents.background_models.complex_reasoning.fallback_tier = value;
return true;
},
'councils.enabled': (config, value) => {
if (typeof value !== 'boolean') {return false;}
const councils = ensureCouncilsConfig(config);
councils.enabled = value;
return true;
},
'councils.defaults.max_rounds': (config, value) => {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 6) {return false;}
const councils = ensureCouncilsConfig(config);
councils.defaults.max_rounds = Math.floor(value);
return true;
},
'councils.groups.D.model_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.D.model_tier = value;
return true;
},
'councils.groups.P.model_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.P.model_tier = value;
return true;
},
'councils.meta_model_tier': (config, value) => {
if (value !== 'fast' && value !== 'default' && value !== 'complex' && value !== 'local') {return false;}
const councils = ensureCouncilsConfig(config);
councils.meta_model_tier = value;
return true;
},
'councils.groups.D.arbiter_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.D.arbiter_agent = value.trim();
return true;
},
'councils.groups.D.freethinker_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.D.freethinker_agent = value.trim();
return true;
},
'councils.groups.D.grounder_agent': (config, value) => {
if (typeof value !== 'string') {return false;}
const next = value.trim();
const councils = ensureCouncilsConfig(config);
councils.groups.D.grounder_agent = next.length > 0 ? next : undefined;
return true;
},
'councils.groups.P.arbiter_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.P.arbiter_agent = value.trim();
return true;
},
'councils.groups.P.freethinker_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.groups.P.freethinker_agent = value.trim();
return true;
},
'councils.groups.P.grounder_agent': (config, value) => {
if (typeof value !== 'string') {return false;}
const next = value.trim();
const councils = ensureCouncilsConfig(config);
councils.groups.P.grounder_agent = next.length > 0 ? next : undefined;
return true;
},
'councils.meta_arbiter_agent': (config, value) => {
if (typeof value !== 'string' || value.trim().length === 0) {return false;}
const councils = ensureCouncilsConfig(config);
councils.meta_arbiter_agent = value.trim();
return true;
},
'councils.scaffold_path': (config, value) => {
if (typeof value !== 'string') {return false;}
const next = value.trim();
const councils = ensureCouncilsConfig(config);
councils.scaffold_path = next.length > 0 ? next : undefined;
return true;
},
'models.default.provider': (config, value) => {
if (!MODEL_PROVIDERS.includes(String(value) as ModelProvider)) {return false;}
config.models.default.provider = value as ModelProvider;
+46
View File
@@ -1234,6 +1234,52 @@ describe('config handlers', () => {
expect(getPath(config, 'tts', 'enabled_channels')).toEqual(['telegram', 'discord']);
});
it('config.patch applies councils model and routing patches', async () => {
const config = makeConfig();
const handlers = createConfigHandlers({ config: asConfigValue(config) });
const req: GatewayRequest = {
id: 22,
method: 'config.patch',
params: {
patches: {
'councils.enabled': true,
'councils.defaults.max_rounds': 3,
'councils.groups.D.model_tier': 'complex',
'councils.groups.P.model_tier': 'fast',
'councils.meta_model_tier': 'default',
'councils.groups.D.arbiter_agent': 'd_arbiter',
'councils.groups.D.freethinker_agent': 'd_ft',
'councils.groups.P.arbiter_agent': 'p_arbiter',
'councils.groups.P.freethinker_agent': 'p_ft',
'councils.meta_arbiter_agent': 'meta_arbiter',
'councils.scaffold_path': 'docs/councils/ai-council-production-scaffold.json',
},
},
};
const result = await handlers['config.patch'](req) as GatewayResponse;
const r = result.result as { applied: string[]; rejected: string[]; persisted: boolean };
expect(r.applied).toEqual([
'councils.enabled',
'councils.defaults.max_rounds',
'councils.groups.D.model_tier',
'councils.groups.P.model_tier',
'councils.meta_model_tier',
'councils.groups.D.arbiter_agent',
'councils.groups.D.freethinker_agent',
'councils.groups.P.arbiter_agent',
'councils.groups.P.freethinker_agent',
'councils.meta_arbiter_agent',
'councils.scaffold_path',
]);
expect(r.rejected).toEqual([]);
expect(getPath(config, 'councils', 'enabled')).toBe(true);
expect(getPath(config, 'councils', 'defaults', 'max_rounds')).toBe(3);
expect(getPath(config, 'councils', 'groups', 'P', 'model_tier')).toBe('fast');
expect(getPath(config, 'councils', 'meta_model_tier')).toBe('default');
expect(getPath(config, 'councils', 'meta_arbiter_agent')).toBe('meta_arbiter');
});
it('config.patch rejects unknown keys', async () => {
const config = makeConfig();
const handlers = createConfigHandlers({ config: asConfigValue(config) });
+220
View File
@@ -14,6 +14,9 @@ let _assistantSaveState = null;
let _lastAssistantConfig = null;
let _assistantManualOverrides = new Set();
let _assistantModelDefaultsDraft = null;
let _lastCouncilTask = '';
let _lastCouncilResult = null;
let _lastCouncilError = null;
let _lastServices = [];
let _serviceConfigState = {
open: false,
@@ -216,6 +219,22 @@ function renderAssistantSaveState() {
return `<div id="ops-assistant-status" class="text-sm ${toneClass} mt-4">${escapeHtml(_assistantSaveState.message)} <span class="text-zinc-500">(at ${escapeHtml(at)})</span></div>`;
}
function extractCouncilResultFromOutput(output) {
if (typeof output !== 'string' || output.trim().length === 0) {
return null;
}
const marker = '{"pipeline_version"';
const idx = output.lastIndexOf(marker);
if (idx < 0) {
return null;
}
try {
return JSON.parse(output.slice(idx));
} catch {
return null;
}
}
// ── Initial full render ─────────────────────────────────────────
function renderSkeleton(el) {
@@ -690,6 +709,64 @@ async function triggerDailyBriefingTest(jobName, statusEl) {
}
}
async function triggerCouncilRun(task, statusEl) {
if (!_dashboardClient) {return false;}
if (!task) {
if (statusEl) {
statusEl.textContent = 'Task is required.';
statusEl.className = 'text-sm text-red-500';
}
return false;
}
if (statusEl) {
statusEl.textContent = 'Running council...';
statusEl.className = 'text-sm text-zinc-500';
}
try {
const result = await _dashboardClient.call('tools.invoke', {
tool: 'council.run',
args: { task },
});
if (!result?.success) {
_lastCouncilError = result?.error ?? 'Council run failed.';
_lastCouncilResult = null;
if (statusEl) {
statusEl.textContent = _lastCouncilError;
statusEl.className = 'text-sm text-red-500';
}
return false;
}
const parsed = extractCouncilResultFromOutput(result.output);
if (!parsed) {
_lastCouncilError = 'Council run succeeded but output could not be parsed.';
_lastCouncilResult = null;
if (statusEl) {
statusEl.textContent = _lastCouncilError;
statusEl.className = 'text-sm text-amber-500';
}
return false;
}
_lastCouncilTask = task;
_lastCouncilResult = parsed;
_lastCouncilError = null;
if (statusEl) {
statusEl.textContent = `Council run complete: ${parsed.stop_snapshot?.stop_reason ?? 'ok'}`;
statusEl.className = 'text-sm text-green-500';
}
return true;
} catch (error) {
_lastCouncilError = `Council run error: ${error instanceof Error ? error.message : String(error)}`;
_lastCouncilResult = null;
if (statusEl) {
statusEl.textContent = _lastCouncilError;
statusEl.className = 'text-sm text-red-500';
}
return false;
}
}
function updateAssistantHealth(configData) {
const el = document.getElementById('ops-assistant-health');
if (!el) {return;}
@@ -723,6 +800,11 @@ function updateAssistantHealth(configData) {
const modelTier = _assistantModelDefaultsDraft?.primaryTier ?? configData?.agents?.primary_tier ?? 'default';
const delegation = configData?.agents?.delegation ?? {};
const backgroundModels = configData?.agents?.background_models ?? {};
const councils = configData?.councils ?? {};
const councilsDefaults = councils.defaults ?? {};
const councilsGroups = councils.groups ?? {};
const councilsD = councilsGroups.D ?? {};
const councilsP = councilsGroups.P ?? {};
const tiers = configData?.models ?? {};
const modelCatalog = configData?.__modelCatalog ?? [];
const providerList = modelCatalog.length > 0
@@ -763,6 +845,10 @@ function updateAssistantHealth(configData) {
{ key: 'tool_summarisation', label: 'Tool summarisation' },
{ key: 'complex_reasoning', label: 'Complex reasoning' },
];
const councilConversations = Array.isArray(_lastCouncilResult?.conversations) ? _lastCouncilResult.conversations : [];
const councilSummary = _lastCouncilResult?.stop_snapshot
? `Last run: ${_lastCouncilResult.stop_snapshot.stop_reason} (round ${_lastCouncilResult.stop_snapshot.round_reached})`
: (_lastCouncilError ? `Last run failed: ${_lastCouncilError}` : 'No council run yet in this dashboard session.');
el.innerHTML = `
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-2 mb-4">
@@ -898,6 +984,97 @@ function updateAssistantHealth(configData) {
</button>
</div>
</div>
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
<div class="text-sm font-semibold text-zinc-50 mb-3">Councils</div>
<div class="text-sm text-zinc-500 mb-3">On-demand council orchestration settings and council role model tiers.</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<label class="flex items-center gap-2 mt-5 md:mt-0">
<input id="assist-councils-enabled" type="checkbox" ${councils.enabled ? 'checked' : ''} />
<span class="text-xs text-zinc-400">Enable councils</span>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">D model tier</span>
<select id="assist-council-d-tier" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none">
${tierOption(councilsD.model_tier ?? 'complex')}
</select>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">P model tier</span>
<select id="assist-council-p-tier" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none">
${tierOption(councilsP.model_tier ?? 'complex')}
</select>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">Meta model tier</span>
<select id="assist-council-meta-tier" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none">
${tierOption(councils.meta_model_tier ?? 'complex')}
</select>
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">D arbiter agent</span>
<input id="assist-council-d-arbiter" type="text" value="${escapeHtml(councilsD.arbiter_agent ?? 'council_d_arbiter')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">D freethinker agent</span>
<input id="assist-council-d-freethinker" type="text" value="${escapeHtml(councilsD.freethinker_agent ?? 'council_d_freethinker')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">P arbiter agent</span>
<input id="assist-council-p-arbiter" type="text" value="${escapeHtml(councilsP.arbiter_agent ?? 'council_p_arbiter')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">P freethinker agent</span>
<input id="assist-council-p-freethinker" type="text" value="${escapeHtml(councilsP.freethinker_agent ?? 'council_p_freethinker')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">Meta arbiter agent</span>
<input id="assist-council-meta-arbiter" type="text" value="${escapeHtml(councils.meta_arbiter_agent ?? 'council_meta_arbiter')}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">Scaffold path (optional)</span>
<input id="assist-council-scaffold" type="text" value="${escapeHtml(councils.scaffold_path ?? '')}" placeholder="docs/councils/ai-council-production-scaffold.json" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<label class="flex flex-col gap-1.5">
<span class="text-sm text-zinc-400">Max rounds</span>
<input id="assist-council-max-rounds" type="number" min="1" max="6" value="${escapeHtml(String(councilsDefaults.max_rounds ?? 2))}" class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
</label>
</div>
<div class="flex flex-wrap gap-2">
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="save-councils">
Save Councils
</button>
</div>
<div class="mt-4 p-3 border border-zinc-800 rounded-md bg-zinc-950/60">
<div class="text-sm font-semibold text-zinc-50 mb-2">Council Conversations</div>
<div class="text-xs text-zinc-500 mb-3">${escapeHtml(councilSummary)}</div>
<div class="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-2 mb-3">
<input id="assist-council-task" type="text" value="${escapeHtml(_lastCouncilTask)}" placeholder="Run councils on demand: e.g. design a 2-week experiment plan..." class="w-full bg-zinc-950 text-zinc-50 border border-zinc-800 rounded-md px-3 py-2 text-sm focus:border-blue-500 outline-none" />
<button class="px-3 py-1.5 text-sm font-medium rounded-md border border-zinc-700 bg-zinc-800 text-zinc-200 hover:bg-zinc-700 transition-colors assistant-action-btn" data-action="run-council">
Run Council
</button>
</div>
<div id="assist-council-status" class="text-sm text-zinc-500 mb-3"></div>
<div class="max-h-72 overflow-y-auto space-y-2">
${councilConversations.length === 0
? '<div class="text-sm text-zinc-500">No conversation log yet.</div>'
: councilConversations.map((turn, idx) => `
<details class="border border-zinc-800 rounded-md bg-zinc-900/70 p-2">
<summary class="cursor-pointer text-sm text-zinc-100">
#${idx + 1} ${escapeHtml(turn.call_id)} · ${escapeHtml(turn.agent)} @ ${escapeHtml(turn.tier)}
</summary>
<div class="mt-2 text-xs text-zinc-400">Prompt payload</div>
<pre class="text-xs text-zinc-300 whitespace-pre-wrap bg-zinc-950 border border-zinc-800 rounded p-2 mt-1">${escapeHtml(JSON.stringify(turn.prompt_payload, null, 2))}</pre>
<div class="mt-2 text-xs text-zinc-400">Response</div>
<pre class="text-xs text-zinc-300 whitespace-pre-wrap bg-zinc-950 border border-zinc-800 rounded p-2 mt-1">${escapeHtml(String(turn.response ?? ''))}</pre>
</details>
`).join('')}
</div>
</div>
</div>
<div class="mt-4 p-4 border border-zinc-800 rounded-lg bg-zinc-900">
<div class="text-sm font-semibold text-zinc-50 mb-3">Assistant Activation Checklist</div>
<div class="space-y-1 mb-4">
@@ -1064,6 +1241,49 @@ function updateAssistantHealth(configData) {
}
patches[`agents.background_models.${task}.fallback_tier`] = fallbackTier;
}
} else if (action === 'save-councils') {
const enabled = Boolean(el.querySelector('#assist-councils-enabled')?.checked);
const dTier = el.querySelector('#assist-council-d-tier')?.value ?? 'complex';
const pTier = el.querySelector('#assist-council-p-tier')?.value ?? 'complex';
const metaTier = el.querySelector('#assist-council-meta-tier')?.value ?? 'complex';
const dArbiter = (el.querySelector('#assist-council-d-arbiter')?.value ?? '').trim();
const dFreethinker = (el.querySelector('#assist-council-d-freethinker')?.value ?? '').trim();
const pArbiter = (el.querySelector('#assist-council-p-arbiter')?.value ?? '').trim();
const pFreethinker = (el.querySelector('#assist-council-p-freethinker')?.value ?? '').trim();
const metaArbiter = (el.querySelector('#assist-council-meta-arbiter')?.value ?? '').trim();
const scaffoldPath = (el.querySelector('#assist-council-scaffold')?.value ?? '').trim();
const maxRoundsRaw = Number(el.querySelector('#assist-council-max-rounds')?.value ?? 2);
const maxRounds = Number.isFinite(maxRoundsRaw) ? Math.max(1, Math.min(6, Math.floor(maxRoundsRaw))) : 2;
if (!dArbiter || !dFreethinker || !pArbiter || !pFreethinker || !metaArbiter) {
if (statusEl) {
statusEl.textContent = 'All council agent names are required.';
statusEl.className = 'text-sm text-red-500';
}
return;
}
patches = {
'councils.enabled': enabled,
'councils.defaults.max_rounds': maxRounds,
'councils.groups.D.model_tier': dTier,
'councils.groups.P.model_tier': pTier,
'councils.meta_model_tier': metaTier,
'councils.groups.D.arbiter_agent': dArbiter,
'councils.groups.D.freethinker_agent': dFreethinker,
'councils.groups.P.arbiter_agent': pArbiter,
'councils.groups.P.freethinker_agent': pFreethinker,
'councils.meta_arbiter_agent': metaArbiter,
'councils.scaffold_path': scaffoldPath,
};
} else if (action === 'run-council') {
const councilTask = (el.querySelector('#assist-council-task')?.value ?? '').trim();
const councilStatusEl = el.querySelector('#assist-council-status');
const ok = await triggerCouncilRun(councilTask, councilStatusEl);
if (ok && _lastAssistantConfig) {
updateAssistantHealth(_lastAssistantConfig);
}
return;
}
if (!patches) {return;}
const patchResult = await applyAssistantPatch(patches, statusEl);