265 lines
9.6 KiB
TypeScript
265 lines
9.6 KiB
TypeScript
import type { AgentConfigRegistry } from '../../agents/registry.js';
|
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
import { homedir } from 'node:os';
|
|
import { resolve } from 'node:path';
|
|
import type { ChatResponseFormat } from '../../models/types.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 {
|
|
delegate(request: {
|
|
tier: 'fast' | 'default' | 'complex' | 'local';
|
|
systemPrompt: string;
|
|
message: string;
|
|
maxTokens?: number;
|
|
responseFormat?: ChatResponseFormat;
|
|
}): Promise<{
|
|
content: string;
|
|
usage: { inputTokens: number; outputTokens: number };
|
|
tier: 'fast' | 'default' | 'complex' | 'local';
|
|
}>;
|
|
}
|
|
|
|
export interface CouncilRunDeps {
|
|
registry: AgentConfigRegistry;
|
|
orchestrator: DelegateRunner;
|
|
config: CouncilsConfig;
|
|
scaffold?: CouncilScaffold;
|
|
}
|
|
|
|
function slugifyTask(task: string): string {
|
|
const trimmed = task.trim().toLowerCase();
|
|
if (!trimmed) {
|
|
return 'council-run';
|
|
}
|
|
const slug = trimmed
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/^-+|-+$/g, '')
|
|
.slice(0, 80);
|
|
return slug || 'council-run';
|
|
}
|
|
|
|
function formatTimestamp(date: Date): string {
|
|
return date.toISOString().replace(/[:.]/g, '-');
|
|
}
|
|
|
|
function getCouncilsDir(): string {
|
|
const dataDir = process.env.FLYNN_DATA_DIR ?? resolve(homedir(), '.local/share/flynn');
|
|
return resolve(dataDir, 'councils');
|
|
}
|
|
|
|
function writeCouncilArtifacts(
|
|
task: string,
|
|
summaryLines: string[],
|
|
conversationLog: string,
|
|
resultJson: string,
|
|
): { jsonPath: string; markdownPath: string } {
|
|
const dir = getCouncilsDir();
|
|
mkdirSync(dir, { recursive: true });
|
|
|
|
const stamp = formatTimestamp(new Date());
|
|
const base = `${stamp}-${slugifyTask(task)}`;
|
|
const jsonPath = resolve(dir, `${base}.json`);
|
|
const markdownPath = resolve(dir, `${base}.md`);
|
|
|
|
const markdown = [
|
|
...summaryLines,
|
|
'',
|
|
'Conversations:',
|
|
'',
|
|
conversationLog || '(none)',
|
|
'',
|
|
'Raw Result JSON:',
|
|
'```json',
|
|
resultJson,
|
|
'```',
|
|
'',
|
|
].join('\n');
|
|
|
|
writeFileSync(jsonPath, `${resultJson}\n`, 'utf-8');
|
|
writeFileSync(markdownPath, markdown, 'utf-8');
|
|
return { jsonPath, markdownPath };
|
|
}
|
|
|
|
function classifyCouncilFailure(message: string): 'network_or_latency' | 'json_format' | 'config' | 'cap_overflow' | 'user_cancelled' | 'unknown' {
|
|
const lower = message.toLowerCase();
|
|
if (
|
|
lower.includes('operation cancelled by user')
|
|
|| lower.includes('aborterror')
|
|
|| lower.includes('aborted')
|
|
|| lower.includes('cancelled')
|
|
) {
|
|
return 'user_cancelled';
|
|
}
|
|
if (
|
|
lower.includes('connection error')
|
|
|| lower.includes('timed out')
|
|
|| lower.includes('econn')
|
|
|| lower.includes('enotfound')
|
|
|| lower.includes('all model providers failed')
|
|
) {
|
|
return 'network_or_latency';
|
|
}
|
|
if (
|
|
lower.includes('repair_failed')
|
|
|| lower.includes('parse_failed')
|
|
|| lower.includes('json')
|
|
) {
|
|
return 'json_format';
|
|
}
|
|
if (
|
|
lower.includes('not configured')
|
|
|| lower.includes('disabled')
|
|
|| lower.includes('meta_validation_failed')
|
|
|| lower.includes('grounding_failed')
|
|
|| lower.includes('bridge_validation_failed')
|
|
) {
|
|
return 'config';
|
|
}
|
|
if (
|
|
lower.includes('cap_exceeded')
|
|
|| lower.includes('cap_top_ideas')
|
|
|| lower.includes('cap_field_bullets')
|
|
|| lower.includes('cap_entry_chars')
|
|
|| lower.includes('cap_total_chars')
|
|
) {
|
|
return 'cap_overflow';
|
|
}
|
|
return 'unknown';
|
|
}
|
|
|
|
function buildFailureHint(kind: ReturnType<typeof classifyCouncilFailure>): string {
|
|
switch (kind) {
|
|
case 'network_or_latency':
|
|
return 'Likely network/provider latency issue. Check endpoint reachability and consider faster council tiers.';
|
|
case 'json_format':
|
|
return 'Likely model output-format issue. Council JSON repair/retry was unable to normalize output.';
|
|
case 'config':
|
|
return 'Likely councils/agent configuration issue. Verify council agent names, tiers, and strict validation settings.';
|
|
case 'cap_overflow':
|
|
return 'Bridge payload cap exceeded. Reduce task breadth, lower max rounds, or raise councils.defaults bridge cap settings.';
|
|
case 'user_cancelled':
|
|
return 'Run was cancelled by user input (Esc/Ctrl+C). Re-run the council task when ready.';
|
|
default:
|
|
return 'No deterministic diagnosis from error text.';
|
|
}
|
|
}
|
|
|
|
export function createCouncilRunTool(deps: CouncilRunDeps): Tool {
|
|
return {
|
|
name: 'council.run',
|
|
description:
|
|
'Run the deterministic dual-council pipeline (D/P groups with bridge-only exchange and meta merge).',
|
|
inputSchema: {
|
|
type: 'object',
|
|
properties: {
|
|
task: { type: 'string', description: 'Primary task or question to explore' },
|
|
constraints: { type: 'object', description: 'Optional constraints object (or pass string)' },
|
|
success_definition: { type: 'string', description: 'What success looks like for this run' },
|
|
budget: { type: 'object', description: 'Optional budget limits/time/cost constraints' },
|
|
timebox: { type: 'string', description: 'Optional timebox (e.g. 30m)' },
|
|
output_format: { type: 'string', description: 'Desired output format' },
|
|
max_rounds: { type: 'number', description: 'Override configured max rounds (1-6)' },
|
|
session_id: { type: 'string', description: 'Optional external session/run id' },
|
|
},
|
|
required: ['task'],
|
|
},
|
|
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
|
try {
|
|
const args = councilRunInputSchema.parse(rawArgs);
|
|
const runner = new CouncilsOrchestrator({
|
|
registry: deps.registry,
|
|
orchestrator: deps.orchestrator,
|
|
config: deps.config,
|
|
scaffold: deps.scaffold,
|
|
});
|
|
const result = await runner.run(args);
|
|
const timedTrace = result.trace.filter((event) => typeof event.latency_ms === 'number');
|
|
const totalLatencyMs = timedTrace.reduce((sum, event) => sum + (event.latency_ms ?? 0), 0);
|
|
const phaseLatency = new Map<number, number>();
|
|
for (const event of timedTrace) {
|
|
const phase = event.phase_index;
|
|
phaseLatency.set(phase, (phaseLatency.get(phase) ?? 0) + (event.latency_ms ?? 0));
|
|
}
|
|
const phaseLatencyLines = [...phaseLatency.entries()]
|
|
.sort((a, b) => a[0] - b[0])
|
|
.map(([phase, latency]) => `- Phase ${phase}: ${latency}ms`);
|
|
const slowestCalls = [...timedTrace]
|
|
.sort((a, b) => (b.latency_ms ?? 0) - (a.latency_ms ?? 0))
|
|
.slice(0, 5)
|
|
.map((event) => `- ${event.call_id}: ${event.latency_ms ?? 0}ms`);
|
|
|
|
const lines = [
|
|
`[Council pipeline v${result.pipeline_version}]`,
|
|
`Stop reason: ${result.stop_snapshot.stop_reason} (round ${result.stop_snapshot.round_reached})`,
|
|
`D shortlist: ${result.stop_snapshot.final_shortlist_D.join(', ') || 'none'}`,
|
|
`P shortlist: ${result.stop_snapshot.final_shortlist_P.join(', ') || 'none'}`,
|
|
`Bridge validated: ${result.stop_snapshot.bridge_validated ? 'yes' : 'no'}`,
|
|
`Grounding failures: ${result.stop_snapshot.grounding_failures_count}`,
|
|
'',
|
|
'Meta selection:',
|
|
`- Primary: ${result.meta.selected_primary.join(', ') || 'none'}`,
|
|
`- Secondary: ${result.meta.selected_secondary.join(', ') || 'none'}`,
|
|
`- Open questions: ${result.meta.open_questions.length}`,
|
|
`- Next experiments: ${result.meta.next_experiments.length}`,
|
|
'',
|
|
'Timing:',
|
|
`- Timed calls: ${timedTrace.length}`,
|
|
`- Total model latency (summed): ${totalLatencyMs}ms`,
|
|
...phaseLatencyLines,
|
|
...(
|
|
slowestCalls.length > 0
|
|
? ['', 'Slowest calls:', ...slowestCalls]
|
|
: []
|
|
),
|
|
'',
|
|
`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');
|
|
|
|
const resultJson = JSON.stringify(result, null, 2);
|
|
const artifacts = writeCouncilArtifacts(args.task, lines, conversationLog, resultJson);
|
|
|
|
return {
|
|
success: true,
|
|
output: [
|
|
...lines,
|
|
'',
|
|
'Artifacts:',
|
|
`- Summary report: ${artifacts.markdownPath}`,
|
|
`- Full JSON: ${artifacts.jsonPath}`,
|
|
].join('\n'),
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const kind = classifyCouncilFailure(message);
|
|
const hint = buildFailureHint(kind);
|
|
return {
|
|
success: false,
|
|
output: '',
|
|
error: [
|
|
message,
|
|
`Likely root cause: ${kind}`,
|
|
`Hint: ${hint}`,
|
|
`Council config: D=${deps.config.groups.D.arbiter_agent}/${deps.config.groups.D.freethinker_agent}@${deps.config.groups.D.model_tier ?? 'agent-tier'}, ` +
|
|
`P=${deps.config.groups.P.arbiter_agent}/${deps.config.groups.P.freethinker_agent}@${deps.config.groups.P.model_tier ?? 'agent-tier'}, ` +
|
|
`meta=${deps.config.meta_arbiter_agent}@${deps.config.meta_model_tier ?? 'agent-tier'}`,
|
|
].join('\n'),
|
|
};
|
|
}
|
|
},
|
|
};
|
|
}
|