From 79666ed888ce0bb5a90d34052cfbdeafeadd7c7d Mon Sep 17 00:00:00 2001 From: JeremyDev87 Date: Sat, 4 Apr 2026 10:50:01 +0900 Subject: [PATCH] feat(council): add Consensus Summary layer for multi-agent output (#1263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CouncilSummary type and generateCouncilSummary service that aggregates specialist opinions into consensus, disagreements, blocking risks, and a recommended next step. Handles partial failures gracefully — produces a degraded but useful summary even when some specialist calls fail. --- .../council-summary.service.spec.ts | 191 ++++++++++++++++++ .../collaboration/council-summary.service.ts | 174 ++++++++++++++++ .../collaboration/council-summary.types.ts | 38 ++++ apps/mcp-server/src/collaboration/index.ts | 7 + 4 files changed, 410 insertions(+) create mode 100644 apps/mcp-server/src/collaboration/council-summary.service.spec.ts create mode 100644 apps/mcp-server/src/collaboration/council-summary.service.ts create mode 100644 apps/mcp-server/src/collaboration/council-summary.types.ts diff --git a/apps/mcp-server/src/collaboration/council-summary.service.spec.ts b/apps/mcp-server/src/collaboration/council-summary.service.spec.ts new file mode 100644 index 00000000..25b1e47b --- /dev/null +++ b/apps/mcp-server/src/collaboration/council-summary.service.spec.ts @@ -0,0 +1,191 @@ +import { describe, it, expect } from 'vitest'; +import { generateCouncilSummary } from './council-summary.service'; +import { createAgentOpinion } from './types'; +import type { CouncilInput } from './council-summary.types'; + +function successInput( + agentName: string, + stance: 'approve' | 'concern' | 'reject', + reasoning: string, + suggestedChanges: string[] = [], +): CouncilInput { + return { + agentName, + opinion: createAgentOpinion({ + agentId: `specialist:${agentName.toLowerCase().replace(/\s+/g, '-')}`, + agentName, + stance, + reasoning, + suggestedChanges, + }), + }; +} + +function failedInput(agentName: string, error = 'Agent timed out'): CouncilInput { + return { agentName, opinion: null, error }; +} + +describe('generateCouncilSummary', () => { + describe('normal case: all specialists respond', () => { + it('should produce a full summary with opinions, consensus, and disagreements', () => { + const inputs: CouncilInput[] = [ + successInput('Architecture Specialist', 'approve', 'Clean layered design'), + successInput('Security Specialist', 'reject', 'Missing input validation', [ + 'Add schema validation', + ]), + successInput('Performance Specialist', 'approve', 'Bundle size within budget'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.opinions).toHaveLength(3); + expect(summary.failedAgents).toHaveLength(0); + expect(summary.partialFailure).toBe(false); + expect(summary.disagreements.length).toBeGreaterThan(0); + expect(summary.blockingRisks.length).toBeGreaterThan(0); + expect(summary.nextStep).toBeTruthy(); + }); + }); + + describe('partial failure: 1+ specialist fails', () => { + it('should produce a degraded but useful summary', () => { + const inputs: CouncilInput[] = [ + successInput('Architecture Specialist', 'approve', 'Looks good'), + failedInput('Security Specialist', 'Connection timeout'), + successInput('Performance Specialist', 'approve', 'No issues'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.opinions).toHaveLength(2); + expect(summary.failedAgents).toEqual(['Security Specialist']); + expect(summary.partialFailure).toBe(true); + expect(summary.nextStep).toBeTruthy(); + }); + + it('should produce summary even when all but one fail', () => { + const inputs: CouncilInput[] = [ + successInput('Architecture Specialist', 'concern', 'Coupling risk'), + failedInput('Security Specialist'), + failedInput('Performance Specialist'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.opinions).toHaveLength(1); + expect(summary.failedAgents).toHaveLength(2); + expect(summary.partialFailure).toBe(true); + expect(summary.nextStep).toBeTruthy(); + }); + }); + + describe('no disagreement: all agree', () => { + it('should return empty disagreements when all approve', () => { + const inputs: CouncilInput[] = [ + successInput('Architecture Specialist', 'approve', 'Clean design'), + successInput('Security Specialist', 'approve', 'Auth is solid'), + successInput('Performance Specialist', 'approve', 'Fast enough'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.disagreements).toEqual([]); + expect(summary.consensus.length).toBeGreaterThan(0); + expect(summary.blockingRisks).toEqual([]); + }); + + it('should return empty disagreements when all share the same non-approve stance', () => { + const inputs: CouncilInput[] = [ + successInput('Architecture Specialist', 'concern', 'Coupling risk'), + successInput('Security Specialist', 'concern', 'Needs review'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.disagreements).toEqual([]); + }); + }); + + describe('blocker-heavy case', () => { + it('should collect all blocking risks from reject-stance agents', () => { + const inputs: CouncilInput[] = [ + successInput('Architecture Specialist', 'reject', 'Circular dependency detected', [ + 'Break the cycle via interface', + ]), + successInput('Security Specialist', 'reject', 'SQL injection vulnerability', [ + 'Use parameterized queries', + ]), + successInput('Performance Specialist', 'concern', 'Could be faster'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.blockingRisks).toHaveLength(2); + expect(summary.blockingRisks).toContain( + 'Architecture Specialist: Circular dependency detected', + ); + expect(summary.blockingRisks).toContain('Security Specialist: SQL injection vulnerability'); + }); + }); + + describe('nextStep', () => { + it('should always have a nextStep even on total failure', () => { + const inputs: CouncilInput[] = [ + failedInput('Architecture Specialist'), + failedInput('Security Specialist'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.nextStep).toBeTruthy(); + expect(summary.opinions).toHaveLength(0); + expect(summary.partialFailure).toBe(true); + }); + + it('should recommend addressing blockers when blocking risks exist', () => { + const inputs: CouncilInput[] = [ + successInput('Security Specialist', 'reject', 'Critical vulnerability found'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.nextStep).toContain('block'); + }); + + it('should recommend proceeding when consensus is reached', () => { + const inputs: CouncilInput[] = [ + successInput('Architecture Specialist', 'approve', 'All good'), + successInput('Security Specialist', 'approve', 'Secure'), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.nextStep).toContain('Proceed'); + }); + }); + + describe('edge cases', () => { + it('should handle empty input', () => { + const summary = generateCouncilSummary([]); + + expect(summary.opinions).toEqual([]); + expect(summary.failedAgents).toEqual([]); + expect(summary.consensus).toEqual([]); + expect(summary.disagreements).toEqual([]); + expect(summary.blockingRisks).toEqual([]); + expect(summary.nextStep).toBeTruthy(); + expect(summary.partialFailure).toBe(false); + }); + + it('should include suggested changes in consensus when shared across agents', () => { + const inputs: CouncilInput[] = [ + successInput('Architecture Specialist', 'approve', 'Good', ['Add logging']), + successInput('Security Specialist', 'approve', 'Good', ['Add logging']), + ]; + + const summary = generateCouncilSummary(inputs); + + expect(summary.consensus).toContain('Shared recommendation: Add logging'); + }); + }); +}); diff --git a/apps/mcp-server/src/collaboration/council-summary.service.ts b/apps/mcp-server/src/collaboration/council-summary.service.ts new file mode 100644 index 00000000..727f6832 --- /dev/null +++ b/apps/mcp-server/src/collaboration/council-summary.service.ts @@ -0,0 +1,174 @@ +/** + * Council Summary Service — generates a structured consensus summary + * from multi-agent specialist outputs. + * + * Pure module: no side effects, no I/O. + * Handles partial failures gracefully — produces a degraded but useful + * summary even when some specialist calls fail. + */ +import type { AgentOpinion, Stance } from './types'; +import type { + CouncilInput, + CouncilSummary, + Disagreement, + DisagreementPosition, +} from './council-summary.types'; + +/** + * Generates a council summary from specialist inputs. + * Tolerates partial failures: failed specialists are recorded in `failedAgents` + * and excluded from analysis, but the summary is still produced. + */ +export function generateCouncilSummary(inputs: readonly CouncilInput[]): CouncilSummary { + const opinions: AgentOpinion[] = []; + const failedAgents: string[] = []; + + for (const input of inputs) { + if (input.opinion !== null) { + opinions.push(input.opinion); + } else { + failedAgents.push(input.agentName); + } + } + + const partialFailure = failedAgents.length > 0; + const consensus = detectConsensus(opinions); + const disagreements = detectDisagreements(opinions); + const blockingRisks = extractBlockingRisks(opinions); + const nextStep = determineNextStep(opinions, failedAgents, blockingRisks); + + return { + opinions, + failedAgents, + consensus, + disagreements, + blockingRisks, + nextStep, + partialFailure, + }; +} + +/** + * Detects consensus points: things all specialists agree on. + * - Unanimous stance → reported as consensus + * - Shared suggested changes across 2+ agents → reported + */ +function detectConsensus(opinions: readonly AgentOpinion[]): string[] { + if (opinions.length === 0) return []; + + const points: string[] = []; + const stances = new Set(opinions.map(o => o.stance)); + + if (stances.size === 1) { + const stance = opinions[0].stance; + points.push(`All specialists ${stanceVerb(stance)}`); + } + + const sharedChanges = findSharedSuggestedChanges(opinions); + for (const change of sharedChanges) { + points.push(`Shared recommendation: ${change}`); + } + + return points; +} + +/** Returns suggested changes that appear in 2+ opinions. */ +function findSharedSuggestedChanges(opinions: readonly AgentOpinion[]): string[] { + const changeCounts = new Map(); + for (const opinion of opinions) { + for (const change of opinion.suggestedChanges) { + changeCounts.set(change, (changeCounts.get(change) ?? 0) + 1); + } + } + + const shared: string[] = []; + for (const [change, count] of changeCounts) { + if (count >= 2) { + shared.push(change); + } + } + return shared; +} + +/** Human-readable verb for a stance. */ +function stanceVerb(stance: Stance): string { + switch (stance) { + case 'approve': + return 'approve'; + case 'concern': + return 'express concerns'; + case 'reject': + return 'reject'; + } +} + +/** + * Detects disagreements: points where specialists diverge in stance. + * Groups by the distinct stance sets — if all share the same stance, no disagreement. + */ +function detectDisagreements(opinions: readonly AgentOpinion[]): Disagreement[] { + if (opinions.length < 2) return []; + + const stances = new Set(opinions.map(o => o.stance)); + if (stances.size <= 1) return []; + + const positions: DisagreementPosition[] = opinions.map(o => ({ + agentName: o.agentName, + stance: o.stance, + reasoning: o.reasoning, + })); + + return [ + { + topic: 'Overall assessment', + positions, + }, + ]; +} + +/** + * Extracts blocking risks from agents with reject stance. + * Format: "AgentName: reasoning" + */ +function extractBlockingRisks(opinions: readonly AgentOpinion[]): string[] { + return opinions.filter(o => o.stance === 'reject').map(o => `${o.agentName}: ${o.reasoning}`); +} + +/** + * Determines the recommended next action based on the council state. + * Priority: blocking risks > partial failure > concerns > proceed. + */ +function determineNextStep( + opinions: readonly AgentOpinion[], + failedAgents: readonly string[], + blockingRisks: readonly string[], +): string { + if (opinions.length === 0 && failedAgents.length === 0) { + return 'No specialist input available. Run specialist analysis first.'; + } + + if (opinions.length === 0 && failedAgents.length > 0) { + return `Re-run failed specialists (${failedAgents.join(', ')}) to obtain analysis.`; + } + + if (blockingRisks.length > 0) { + return `Address ${blockingRisks.length} blocking risk(s) before proceeding.`; + } + + const concerns = opinions.filter(o => o.stance === 'concern'); + const parts: string[] = []; + + if (failedAgents.length > 0) { + parts.push(`re-run failed specialists (${failedAgents.join(', ')})`); + } + + if (concerns.length > 0) { + parts.push(`review ${concerns.length} concern(s)`); + } + + if (parts.length > 0) { + return `Proceed with caution: ${parts.join(', ')}.`; + } + + return 'Proceed with implementation — all specialists approve.'; +} diff --git a/apps/mcp-server/src/collaboration/council-summary.types.ts b/apps/mcp-server/src/collaboration/council-summary.types.ts new file mode 100644 index 00000000..41bc8a4d --- /dev/null +++ b/apps/mcp-server/src/collaboration/council-summary.types.ts @@ -0,0 +1,38 @@ +/** + * Council Summary Types — structured consensus layer on top of multi-agent outputs. + * + * Aggregates specialist opinions into consensus, disagreements, blocking risks, + * and a recommended next step. Handles partial failures gracefully. + */ +import type { AgentOpinion, Stance } from './types'; + +/** Input to the council summary generator. Null opinion indicates a failed specialist. */ +export interface CouncilInput { + readonly agentName: string; + readonly opinion: AgentOpinion | null; + readonly error?: string; +} + +/** One side of a disagreement between specialists. */ +export interface DisagreementPosition { + readonly agentName: string; + readonly stance: Stance; + readonly reasoning: string; +} + +/** A point where specialists diverge in stance. */ +export interface Disagreement { + readonly topic: string; + readonly positions: readonly DisagreementPosition[]; +} + +/** Final council summary aggregating all specialist outputs. */ +export interface CouncilSummary { + readonly opinions: readonly AgentOpinion[]; + readonly failedAgents: readonly string[]; + readonly consensus: readonly string[]; + readonly disagreements: readonly Disagreement[]; + readonly blockingRisks: readonly string[]; + readonly nextStep: string; + readonly partialFailure: boolean; +} diff --git a/apps/mcp-server/src/collaboration/index.ts b/apps/mcp-server/src/collaboration/index.ts index 695ccd1a..da1623e0 100644 --- a/apps/mcp-server/src/collaboration/index.ts +++ b/apps/mcp-server/src/collaboration/index.ts @@ -33,3 +33,10 @@ export { type SpecialistFinding, type SpecialistResult, } from './opinion-adapter'; +export type { + CouncilInput, + CouncilSummary, + Disagreement, + DisagreementPosition, +} from './council-summary.types'; +export { generateCouncilSummary } from './council-summary.service';