feat(councils-ui): add on-demand council conversations panel and model config controls
This commit is contained in:
@@ -612,6 +612,7 @@ automation:
|
||||
# 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
|
||||
@@ -622,6 +623,7 @@ automation:
|
||||
# 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
|
||||
@@ -630,6 +632,8 @@ automation:
|
||||
# - obvious best practices
|
||||
# - purely conventional solutions
|
||||
# meta_arbiter_agent: council_meta_arbiter
|
||||
# meta_model_tier: complex
|
||||
# # scaffold_path: docs/councils/ai-council-production-scaffold.json
|
||||
|
||||
# Optional: explicit intent rules for agent routing.
|
||||
# If enabled, these rules are evaluated before default sender/channel routing.
|
||||
|
||||
@@ -19,6 +19,7 @@ Flynn supports a deterministic dual-council orchestration pipeline (`council.run
|
||||
- `CouncilDiff` (code-computed deterministic diff)
|
||||
- `MetaSelection` (JSON-only selection/merge record)
|
||||
- `CouncilRunResult` (`pipeline_version`, hashes, trace, stop snapshot)
|
||||
- `conversations` (per-agent call log with prompt payload + raw response)
|
||||
|
||||
## Tool Interface
|
||||
|
||||
@@ -42,6 +43,7 @@ Flynn supports a deterministic dual-council orchestration pipeline (`council.run
|
||||
- `prompt_payload_hash` is computed from canonical JSON payload passed to model.
|
||||
- `artifact_hash` is computed from canonical validated artifact JSON.
|
||||
- Trace ordering is deterministic (`phase_index`, then `call_id`), independent of async completion order.
|
||||
- Conversation logs are sorted by (`phase_index`, `call_id`) for stable Web UI display.
|
||||
|
||||
## Safety Rules
|
||||
|
||||
|
||||
@@ -424,6 +424,9 @@ describe('configSchema — councils', () => {
|
||||
expect(result.councils.defaults.max_rounds).toBe(2);
|
||||
expect(result.councils.defaults.top_ideas_for_bridge).toBe(3);
|
||||
expect(result.councils.defaults.bridge_packet_max_chars).toBe(2500);
|
||||
expect(result.councils.groups.D.model_tier).toBe('complex');
|
||||
expect(result.councils.groups.P.model_tier).toBe('complex');
|
||||
expect(result.councils.meta_model_tier).toBe('complex');
|
||||
expect(result.councils.groups.D.novelty_bias).toBe('low');
|
||||
expect(result.councils.groups.P.novelty_bias).toBe('high');
|
||||
expect(result.councils.meta_arbiter_agent).toBe('council_meta_arbiter');
|
||||
@@ -450,6 +453,7 @@ describe('configSchema — councils', () => {
|
||||
D: {
|
||||
arbiter_agent: 'd_arb',
|
||||
freethinker_agent: 'd_ft',
|
||||
model_tier: 'default',
|
||||
group_prompt_prefix: 'd',
|
||||
novelty_bias: 'low',
|
||||
risk_tolerance: 'medium',
|
||||
@@ -458,6 +462,7 @@ describe('configSchema — councils', () => {
|
||||
P: {
|
||||
arbiter_agent: 'p_arb',
|
||||
freethinker_agent: 'p_ft',
|
||||
model_tier: 'fast',
|
||||
group_prompt_prefix: 'p',
|
||||
novelty_bias: 'high',
|
||||
risk_tolerance: 'high',
|
||||
@@ -465,6 +470,7 @@ describe('configSchema — councils', () => {
|
||||
},
|
||||
},
|
||||
meta_arbiter_agent: 'meta',
|
||||
meta_model_tier: 'local',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -472,6 +478,8 @@ describe('configSchema — councils', () => {
|
||||
expect(result.councils.defaults.max_rounds).toBe(3);
|
||||
expect(result.councils.groups.D.arbiter_agent).toBe('d_arb');
|
||||
expect(result.councils.groups.P.freethinker_agent).toBe('p_ft');
|
||||
expect(result.councils.groups.D.model_tier).toBe('default');
|
||||
expect(result.councils.meta_model_tier).toBe('local');
|
||||
expect(result.councils.strict_grounding).toBe(true);
|
||||
expect(result.councils.meta_arbiter_agent).toBe('meta');
|
||||
});
|
||||
|
||||
@@ -865,6 +865,7 @@ const councilsGroupConfigSchema = z.object({
|
||||
freethinker_agent: z.string().min(1),
|
||||
grounder_agent: z.string().min(1).optional(),
|
||||
writer_agent: z.string().min(1).optional(),
|
||||
model_tier: modelTierEnum.default('complex'),
|
||||
group_prompt_prefix: z.string().min(1),
|
||||
novelty_bias: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||
risk_tolerance: z.enum(['low', 'medium', 'high']).default('medium'),
|
||||
@@ -890,6 +891,7 @@ const councilsSchema = z.object({
|
||||
D: councilsGroupConfigSchema.default({
|
||||
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',
|
||||
@@ -902,6 +904,7 @@ const councilsSchema = z.object({
|
||||
P: councilsGroupConfigSchema.default({
|
||||
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',
|
||||
@@ -913,6 +916,7 @@ const councilsSchema = z.object({
|
||||
}),
|
||||
}).default({}),
|
||||
meta_arbiter_agent: z.string().min(1).default('council_meta_arbiter'),
|
||||
meta_model_tier: modelTierEnum.default('complex'),
|
||||
meta_writer_agent: z.string().min(1).optional(),
|
||||
}).default({});
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type CouncilGroup,
|
||||
type CouncilRunInput,
|
||||
type CouncilRunResult,
|
||||
type CouncilConversationTurn,
|
||||
type CouncilTraceEvent,
|
||||
type IdeaAssessment,
|
||||
type IdeaCard,
|
||||
@@ -61,6 +62,7 @@ export interface CouncilsConfig {
|
||||
P: CouncilGroupConfig;
|
||||
};
|
||||
meta_arbiter_agent: string;
|
||||
meta_model_tier?: ModelTier;
|
||||
meta_writer_agent?: string;
|
||||
scaffold_path?: string;
|
||||
}
|
||||
@@ -70,6 +72,7 @@ interface CouncilGroupConfig {
|
||||
freethinker_agent: string;
|
||||
grounder_agent?: string;
|
||||
writer_agent?: string;
|
||||
model_tier?: ModelTier;
|
||||
group_prompt_prefix: string;
|
||||
novelty_bias: 'low' | 'medium' | 'high';
|
||||
risk_tolerance: 'low' | 'medium' | 'high';
|
||||
@@ -230,6 +233,7 @@ export class CouncilsOrchestrator {
|
||||
private readonly _config: CouncilsConfig;
|
||||
private readonly _scaffold?: CouncilScaffold;
|
||||
private readonly _trace: CouncilTraceEvent[] = [];
|
||||
private readonly _conversations: CouncilConversationTurn[] = [];
|
||||
|
||||
constructor(deps: {
|
||||
registry: AgentConfigRegistry;
|
||||
@@ -250,6 +254,7 @@ export class CouncilsOrchestrator {
|
||||
}
|
||||
|
||||
this._trace.length = 0;
|
||||
this._conversations.length = 0;
|
||||
const inputHash = hashCanonical(input);
|
||||
const maxRounds = input.max_rounds ?? this._config.defaults.max_rounds;
|
||||
|
||||
@@ -370,6 +375,7 @@ export class CouncilsOrchestrator {
|
||||
meta,
|
||||
stop_snapshot: stopSnapshot,
|
||||
trace: this.getSortedTrace(),
|
||||
conversations: this.getSortedConversations(),
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -395,6 +401,7 @@ export class CouncilsOrchestrator {
|
||||
promptPayload: unknown;
|
||||
modeDirective: string;
|
||||
scaffoldPrompt?: string;
|
||||
tierOverride?: ModelTier;
|
||||
maxTokens?: number;
|
||||
}): Promise<AgentCallResult> {
|
||||
const agent = this.getAgent(opts.agentName);
|
||||
@@ -404,7 +411,7 @@ export class CouncilsOrchestrator {
|
||||
const systemPrompt = `${scaffoldPrompt}${agent.systemPrompt}\n\n${opts.modeDirective}`;
|
||||
|
||||
const result = await this._delegateRunner.delegate({
|
||||
tier: agent.tier,
|
||||
tier: opts.tierOverride ?? agent.tier,
|
||||
systemPrompt,
|
||||
message,
|
||||
maxTokens: opts.maxTokens ?? 4096,
|
||||
@@ -421,6 +428,18 @@ export class CouncilsOrchestrator {
|
||||
artifact_hash: hashCanonical(result.content),
|
||||
token_usage: result.usage,
|
||||
}));
|
||||
this._conversations.push({
|
||||
schema_version: COUNCIL_SCHEMA_VERSION,
|
||||
phase_index: opts.phaseIndex,
|
||||
call_id: opts.callId,
|
||||
agent: opts.agentName,
|
||||
tier: opts.tierOverride ?? agent.tier,
|
||||
group: opts.group,
|
||||
round: opts.round,
|
||||
prompt_payload: opts.promptPayload as Record<string, unknown>,
|
||||
response: result.content,
|
||||
usage: result.usage,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -437,6 +456,7 @@ export class CouncilsOrchestrator {
|
||||
previousBrief?: CouncilBrief,
|
||||
): Promise<GroupRoundResult> {
|
||||
const groupConfig = this._config.groups[group];
|
||||
const groupTier = groupConfig.model_tier;
|
||||
const phaseBase = round * 10 + (group === 'D' ? 1 : 2);
|
||||
|
||||
const ideationPayload = {
|
||||
@@ -462,6 +482,7 @@ export class CouncilsOrchestrator {
|
||||
promptPayload: ideationPayload,
|
||||
modeDirective: 'Return JSON only: {"ideas":[IdeaContent,...]}. Do not include IDs. No prose.',
|
||||
scaffoldPrompt: this._scaffold?.prompts[group].free_thinker,
|
||||
tierOverride: groupTier,
|
||||
});
|
||||
|
||||
const ideaOutput = parseJsonWithRepair(ideation.content, (value) => ideationOutputSchema.parse(value));
|
||||
@@ -494,6 +515,7 @@ export class CouncilsOrchestrator {
|
||||
modeDirective:
|
||||
'Return JSON only. Assess provided idea IDs only. No new IDs. Include convergence_signal/novelty_score/repetition_rate.',
|
||||
scaffoldPrompt: this._scaffold?.prompts[group].arbiter,
|
||||
tierOverride: groupTier,
|
||||
});
|
||||
|
||||
const assessmentOutput = parseJsonWithRepair(assessmentRaw.content, (value) => assessmentOutputSchema.parse(value));
|
||||
@@ -550,6 +572,7 @@ export class CouncilsOrchestrator {
|
||||
modeDirective:
|
||||
'Grounder mode. Return JSON only: {"grounded":[{"idea_id", "mve", "constraints", "falsifiability_checks"}]}. No prose.',
|
||||
scaffoldPrompt: this._scaffold?.prompts[group].grounder ?? this._scaffold?.prompts[group].free_thinker,
|
||||
tierOverride: groupTier,
|
||||
});
|
||||
grounding = parseJsonWithRepair(groundingRaw.content, (value) => groundingOutputSchema.parse(value));
|
||||
} catch {
|
||||
@@ -701,6 +724,7 @@ export class CouncilsOrchestrator {
|
||||
modeDirective:
|
||||
'Return JSON only following schema with selected_primary/selected_secondary/merges/rejections/open_questions/next_experiments. Use only known idea IDs.',
|
||||
scaffoldPrompt: this._scaffold?.prompts.meta_arbiter,
|
||||
tierOverride: this._config.meta_model_tier,
|
||||
});
|
||||
|
||||
return parseJsonWithRepair(metaRaw.content, (value) => metaSelectionSchema.parse(value));
|
||||
@@ -743,6 +767,15 @@ export class CouncilsOrchestrator {
|
||||
validation_failure: normalizeOptional(event.validation_failure),
|
||||
}));
|
||||
}
|
||||
|
||||
private getSortedConversations(): CouncilConversationTurn[] {
|
||||
return [...this._conversations]
|
||||
.sort((a, b) => a.phase_index - b.phase_index || a.call_id.localeCompare(b.call_id))
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
prompt_payload: JSON.parse(canonicalStringify(entry.prompt_payload)) as Record<string, unknown>,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function createCouncilsOrchestrator(deps: {
|
||||
|
||||
@@ -170,6 +170,22 @@ export const councilTraceEventSchema = z.object({
|
||||
validation_failure: validationFailureReasonSchema.optional(),
|
||||
}).strict();
|
||||
|
||||
export const councilConversationTurnSchema = z.object({
|
||||
schema_version: schemaVersionField,
|
||||
phase_index: z.number().int().min(1),
|
||||
call_id: z.string().min(1),
|
||||
agent: z.string().min(1),
|
||||
tier: z.enum(['fast', 'default', 'complex', 'local']),
|
||||
group: councilGroupSchema.optional(),
|
||||
round: z.number().int().min(1).optional(),
|
||||
prompt_payload: z.record(z.string(), z.unknown()),
|
||||
response: z.string(),
|
||||
usage: z.object({
|
||||
inputTokens: z.number().int().min(0),
|
||||
outputTokens: z.number().int().min(0),
|
||||
}).strict(),
|
||||
}).strict();
|
||||
|
||||
export const stopSnapshotSchema = z.object({
|
||||
stop_reason: stopReasonSchema,
|
||||
round_reached: z.number().int().min(1),
|
||||
@@ -193,6 +209,7 @@ export const councilRunResultSchema = z.object({
|
||||
meta: metaSelectionSchema,
|
||||
stop_snapshot: stopSnapshotSchema,
|
||||
trace: z.array(councilTraceEventSchema),
|
||||
conversations: z.array(councilConversationTurnSchema),
|
||||
}).strict();
|
||||
|
||||
export const councilRunInputSchema = z.object({
|
||||
@@ -246,4 +263,5 @@ export type CouncilDiff = z.infer<typeof councilDiffSchema>;
|
||||
export type CouncilRunInput = z.infer<typeof councilRunInputSchema>;
|
||||
export type CouncilRunResult = z.infer<typeof councilRunResultSchema>;
|
||||
export type CouncilTraceEvent = z.infer<typeof councilTraceEventSchema>;
|
||||
export type CouncilConversationTurn = z.infer<typeof councilConversationTurnSchema>;
|
||||
export type MetaSelection = z.infer<typeof metaSelectionSchema>;
|
||||
|
||||
@@ -28,6 +28,7 @@ import type { SkillInstaller, SkillRegistry, SkillRegistryEntry, SkillRegistrySo
|
||||
import { auditLogger } from '../audit/index.js';
|
||||
import { getElevationStatusMessage, setElevationFromInput } from '../security/elevation.js';
|
||||
import { dirname, resolve } from 'path';
|
||||
import { loadCouncilScaffoldSafe } from '../councils/scaffold.js';
|
||||
|
||||
function buildProviderConfigMap(config: Config): Partial<Record<ModelProvider, ModelConfig>> {
|
||||
const providerConfigs: Partial<Record<ModelProvider, ModelConfig>> = {};
|
||||
@@ -400,9 +401,11 @@ export function createMessageRouter(deps: {
|
||||
} as AgentDelegateDeps));
|
||||
|
||||
if (deps.config.councils?.enabled) {
|
||||
const scaffold = loadCouncilScaffoldSafe(deps.config.councils.scaffold_path);
|
||||
effectiveToolRegistry.register(createCouncilRunTool({
|
||||
registry: deps.agentConfigRegistry,
|
||||
config: deps.config.councils as CouncilsConfig,
|
||||
scaffold,
|
||||
get orchestrator(): AgentOrchestrator {
|
||||
if (!lazyOrchestrator) {
|
||||
throw new Error('Agent orchestrator not yet initialized');
|
||||
@@ -841,8 +844,9 @@ export function createMessageRouter(deps: {
|
||||
const tool = createCouncilRunTool({
|
||||
registry: deps.agentConfigRegistry,
|
||||
orchestrator: agent,
|
||||
config: deps.config.councils as CouncilsConfig,
|
||||
});
|
||||
config: deps.config.councils as CouncilsConfig,
|
||||
scaffold: loadCouncilScaffoldSafe(deps.config.councils.scaffold_path),
|
||||
});
|
||||
const result = await tool.execute({ task: message });
|
||||
if (!result.success) {
|
||||
return `Council run failed: ${result.error ?? 'unknown error'}`;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentConfigRegistry } from '../../agents/registry.js';
|
||||
import type { Tool, ToolResult } from '../types.js';
|
||||
import { CouncilsOrchestrator, type CouncilsConfig } from '../../councils/orchestrator.js';
|
||||
import type { CouncilScaffold } from '../../councils/scaffold.js';
|
||||
import { councilRunInputSchema } from '../../councils/types.js';
|
||||
|
||||
interface DelegateRunner {
|
||||
@@ -20,6 +21,7 @@ export interface CouncilRunDeps {
|
||||
registry: AgentConfigRegistry;
|
||||
orchestrator: DelegateRunner;
|
||||
config: CouncilsConfig;
|
||||
scaffold?: CouncilScaffold;
|
||||
}
|
||||
|
||||
export function createCouncilRunTool(deps: CouncilRunDeps): Tool {
|
||||
@@ -48,6 +50,7 @@ export function createCouncilRunTool(deps: CouncilRunDeps): Tool {
|
||||
registry: deps.registry,
|
||||
orchestrator: deps.orchestrator,
|
||||
config: deps.config,
|
||||
scaffold: deps.scaffold,
|
||||
});
|
||||
const result = await runner.run(args);
|
||||
|
||||
@@ -64,11 +67,24 @@ export function createCouncilRunTool(deps: CouncilRunDeps): Tool {
|
||||
`- Secondary: ${result.meta.selected_secondary.join(', ') || 'none'}`,
|
||||
`- Open questions: ${result.meta.open_questions.length}`,
|
||||
`- Next experiments: ${result.meta.next_experiments.length}`,
|
||||
'',
|
||||
`Agent conversations: ${result.conversations.length}`,
|
||||
];
|
||||
|
||||
const conversationLog = result.conversations
|
||||
.map((turn, index) => {
|
||||
const prompt = JSON.stringify(turn.prompt_payload);
|
||||
return [
|
||||
`#${index + 1} ${turn.call_id} [${turn.agent} @ ${turn.tier}]`,
|
||||
`Prompt payload: ${prompt}`,
|
||||
`Response: ${turn.response}`,
|
||||
].join('\n');
|
||||
})
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: `${lines.join('\n')}\n\n${JSON.stringify(result)}`,
|
||||
output: `${lines.join('\n')}\n\n${conversationLog}\n\n${JSON.stringify(result)}`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user