Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions src/__tests__/analyze-dashboard-events.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe('Phase 13: Live NDJSON Streaming UI Components', () => {
({ role, name, status, message, expectedIcon, expectedRoleIcon }) => {
const { unmount } = render(
<AgentCard role={role} name={name} status={status} message={message} />,
{ stdout: mockStdout as any }
{ stdout: mockStdout as any, interactive: true }
);

const output = mockStdout.frames.join('\n');
Expand All @@ -202,6 +202,7 @@ describe('Phase 13: Live NDJSON Streaming UI Components', () => {
it('returns null when no active tool is executing', () => {
const { unmount } = render(<ActiveToolPanel tool={null} />, {
stdout: mockStdout as any,
interactive: false,
});

const output = mockStdout.frames.join('\n');
Expand All @@ -212,6 +213,7 @@ describe('Phase 13: Live NDJSON Streaming UI Components', () => {
it('displays idle message when showIdle is enabled and tool is null', () => {
const { unmount } = render(<ActiveToolPanel tool={null} showIdle={true} />, {
stdout: mockStdout as any,
interactive: true,
});

const output = mockStdout.frames.join('\n');
Expand All @@ -230,7 +232,7 @@ describe('Phase 13: Live NDJSON Streaming UI Components', () => {
status: 'running',
}}
/>,
{ stdout: mockStdout as any }
{ stdout: mockStdout as any, interactive: true }
);

const output = mockStdout.frames.join('\n');
Expand All @@ -252,7 +254,7 @@ describe('Phase 13: Live NDJSON Streaming UI Components', () => {
status: 'completed',
}}
/>,
{ stdout: mockStdout as any }
{ stdout: mockStdout as any, interactive: true }
);

const output = mockStdout.frames.join('\n');
Expand All @@ -268,7 +270,7 @@ describe('Phase 13: Live NDJSON Streaming UI Components', () => {
it('renders structured consensus with root cause, confidence badge, findings, and fix command', () => {
const { unmount } = render(
<ConsensusDiagnosisView consensus={mockConsensus} />,
{ stdout: mockStdout as any }
{ stdout: mockStdout as any, interactive: true }
);

const output = mockStdout.frames.join('\n');
Expand All @@ -290,7 +292,7 @@ describe('Phase 13: Live NDJSON Streaming UI Components', () => {
const detailsText = 'Root Cause (HIGH confidence):\n Node pressure\nRecommended Remediation:\n Scale up node pool';
const { unmount } = render(
<ConsensusDiagnosisView details={detailsText} />,
{ stdout: mockStdout as any }
{ stdout: mockStdout as any, interactive: true }
);

const output = mockStdout.frames.join('\n');
Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/cli-flags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { runAnalysis } from '../analysis/analysis';
import { registerAnalyzeCommand } from '../commands/analyze';
import { registerConfigCommand } from '../commands/config';
import * as pythonBridge from '../agent/python-bridge';
import * as probesModule from '../analysis/probes';
import { clearConfig, getConfig, setMultiAgentConfig } from '../config/store';
import { logger } from '../utils/logger';
import type { ConsensusDiagnosis } from '../agent/types';
Expand Down Expand Up @@ -77,6 +78,11 @@ describe('Phase 23 - Feature Flags, Rollout Modes & Legacy Fallback (Issue #294)
delete process.env.KDM_MULTI_AGENT;

vi.mocked(pythonBridge.runAgentCouncilProcess).mockResolvedValue(MOCK_CONSENSUS);
vi.spyOn(probesModule, 'checkCouncilDependencies').mockResolvedValue({
pythonAvailable: true,
ollamaAvailable: true,
canRunCouncil: true,
});
});

afterEach(() => {
Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/e2e-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,11 @@ describe('Phase 24: Final System Architecture Integration & E2E Validation', ()
} as any);

vi.spyOn(pythonBridge, 'runAgentCouncilProcess').mockResolvedValue(CANONICAL_CONSENSUS);
vi.spyOn(probesModule, 'checkCouncilDependencies').mockResolvedValue({
pythonAvailable: true,
ollamaAvailable: true,
canRunCouncil: true,
});

const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});

Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/safety-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ async function setupPromptHarness(
{
stdout: stdout as any,
stdin: stdin as any,
interactive: true,
}
);

Expand Down Expand Up @@ -119,6 +120,7 @@ describe('SafetyPrompt Component & Interactive Safety Gate', () => {
{
stdout: mockStdout as any,
stdin: mockStdin as any,
interactive: true,
}
);

Expand Down Expand Up @@ -152,6 +154,7 @@ describe('SafetyPrompt Component & Interactive Safety Gate', () => {
{
stdout: mockStdout as any,
stdin: mockStdin as any,
interactive: true,
}
);

Expand Down Expand Up @@ -241,6 +244,7 @@ describe('SafetyPrompt Component & Interactive Safety Gate', () => {
const promise = promptSafetyConfirmation(SAMPLE_PROPOSAL, {
stdin: mockStdin as any,
stdout: mockStdout as any,
interactive: true,
});

await waitForFrame(mockStdout, 'Apply this remediation to the cluster?');
Expand All @@ -254,6 +258,7 @@ describe('SafetyPrompt Component & Interactive Safety Gate', () => {
const promise = promptSafetyConfirmation(SAMPLE_PROPOSAL, {
stdin: mockStdin as any,
stdout: mockStdout as any,
interactive: true,
});

await waitForFrame(mockStdout, 'Apply this remediation to the cluster?');
Expand Down
16 changes: 13 additions & 3 deletions src/agent/python-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url';
import { parseNDJSONLine } from './event-schema';
import type { ConsensusDiagnosis, AgentProgressEvent, NDJSONEvent } from './types';

const DEFAULT_TIMEOUT_MS = 45_000;
export const DEFAULT_TIMEOUT_MS = 120_000;
const SIGKILL_GRACE_PERIOD_MS = 1_500;
const DEFAULT_MODEL = 'llama3.1';
const DEFAULT_BASE_URL = 'http://localhost:11434';
Expand All @@ -38,7 +38,7 @@ export interface CouncilBridgeOptions {
model?: string;
/** Base URL for Ollama API (default: 'http://localhost:11434'). */
baseUrl?: string;
/** Maximum execution duration in milliseconds before watchdog triggers SIGTERM/SIGKILL (default: 45000). */
/** Maximum execution duration in milliseconds before watchdog triggers SIGTERM/SIGKILL. */
timeoutMs?: number;
/** Callback invoked on parsed NDJSON streaming events. */
onEvent?: (event: NDJSONEvent) => void;
Expand All @@ -50,6 +50,16 @@ export interface CouncilBridgeOptions {
spawnFn?: typeof spawn;
}

/** Resolves a valid per-call, environment, or default council timeout. */
export function resolveCouncilTimeoutMs(timeoutMs?: number): number {
if (Number.isSafeInteger(timeoutMs) && timeoutMs! > 0) return timeoutMs!;

const fromEnvironment = Number(process.env.KDM_COUNCIL_TIMEOUT_MS);
if (Number.isSafeInteger(fromEnvironment) && fromEnvironment > 0) return fromEnvironment;

return DEFAULT_TIMEOUT_MS;
}

/**
* Execution parameters for the Python agent council (backward-compatibility alias).
*/
Expand Down Expand Up @@ -368,7 +378,7 @@ export async function runAgentCouncilProcess(
): Promise<ConsensusDiagnosis> {
const scriptPath = getCouncilScriptPath();
const spawnImpl = options.spawnFn || spawn;
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutMs = resolveCouncilTimeoutMs(options.timeoutMs);

const payload = JSON.stringify({
failureText: options.failureText,
Expand Down
29 changes: 23 additions & 6 deletions src/analysis/__tests__/analysis-baseline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { runAnalysis, explainSingleResult, resolveBackend } from '../analysis';
import { clearConfig, setAIConfig } from '../../config/store';
import { isPythonAgentAvailable, runPythonAgentCouncil } from '../../agent/python-bridge';
import { isPythonAgentAvailable, runAgentCouncilProcess, runPythonAgentCouncil } from '../../agent/python-bridge';
import * as probesModule from '../probes';
import {
registry,
PodAnalyzer,
Expand Down Expand Up @@ -83,6 +84,7 @@ vi.mock('../../ai/factory', () => ({
}));

vi.mock('../../agent/python-bridge', () => ({
runAgentCouncilProcess: vi.fn(),
runPythonAgentCouncil: vi.fn(),
isPythonAgentAvailable: vi.fn(async () => false),
}));
Expand All @@ -108,6 +110,11 @@ let originalMultiAgentEnv: string | undefined;

describe('Baseline Pipeline Regression Tests', () => {
beforeEach(() => {
vi.spyOn(probesModule, 'checkCouncilDependencies').mockResolvedValue({
pythonAvailable: true,
ollamaAvailable: true,
canRunCouncil: true,
});
clearConfig();
registry.clear();
registry.register(PodAnalyzer);
Expand Down Expand Up @@ -299,21 +306,31 @@ describe('Baseline Pipeline Regression Tests', () => {

// Test 2: With env var flag set, pipeline still works (falls through to legacy)
process.env.KDM_MULTI_AGENT = 'true';
mockGetCompletion.mockResolvedValue(OOM_KILLED_LLM_RESPONSE);
vi.mocked(runAgentCouncilProcess).mockResolvedValue({
confidence: 'high',
rootCause: 'council diagnosis',
bestSolution: { actionTitle: 'council fix', steps: [] },
findings: [],
} as any);

const multiAgentResult = await runAnalysis({
filters: ['Pod'],
explain: true,
backend: 'openai',
noCache: true,
});
// Should still produce valid results since multi-agent falls through to legacy
// Multi-agent mode uses council output instead of the legacy single-prompt response.
expect(multiAgentResult.status).toBe('ProblemDetected');
expect(multiAgentResult.results[0].details).toBe(OOM_KILLED_LLM_RESPONSE);
expect(multiAgentResult.results[0].details).toContain('council diagnosis');

// Test 3: With options.multiAgent flag set
delete process.env.KDM_MULTI_AGENT;
mockGetCompletion.mockResolvedValue(OOM_KILLED_LLM_RESPONSE);
vi.mocked(runAgentCouncilProcess).mockResolvedValue({
confidence: 'high',
rootCause: 'council diagnosis',
bestSolution: { actionTitle: 'council fix', steps: [] },
findings: [],
} as any);

const optionsFlagResult = await runAnalysis({
filters: ['Pod'],
Expand All @@ -323,6 +340,6 @@ describe('Baseline Pipeline Regression Tests', () => {
multiAgent: true,
});
expect(optionsFlagResult.status).toBe('ProblemDetected');
expect(optionsFlagResult.results[0].details).toBe(OOM_KILLED_LLM_RESPONSE);
expect(optionsFlagResult.results[0].details).toContain('council diagnosis');
});
});
102 changes: 81 additions & 21 deletions src/analysis/analysis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export interface ExplainSingleParams {
noCache: boolean;
customHeaders?: Record<string, string>;
baseUrl?: string;
timeoutMs?: number;
onAgentProgress?: (event: AgentProgressEvent) => void;
onEvent?: (event: NDJSONEvent) => void;
}
Expand Down Expand Up @@ -238,6 +239,7 @@ async function tryRunAgentCouncil(
},
model: params.model || model,
baseUrl: params.baseUrl || baseUrl,
timeoutMs: params.timeoutMs,
onProgress: params.onAgentProgress,
onEvent: params.onEvent,
});
Expand Down Expand Up @@ -451,6 +453,7 @@ async function explainResults(results: AnalyzerResult[], options: AnalysisOption
shouldAnonymize,
noCache,
customHeaders: options.customHeaders,
timeoutMs: options.councilTimeoutMs,
});
}
}
Expand Down Expand Up @@ -573,7 +576,7 @@ async function executeCouncilForResults(
results: AnalyzerResult[],
options: AnalysisOptions,
): Promise<void> {
const primaryResult = results[0];
const primaryResult = results.find((result) => result.errors.length > 0) || results[0];
const failureText = results
.flatMap((r) => r.errors.map((e) => e.text))
.join('\n') || (options.targetResource ? `Workload ${options.targetResource} failure` : options.labelSelector ? `Workload matching selector ${options.labelSelector} failure` : 'Cluster workload failure');
Expand All @@ -588,37 +591,94 @@ async function executeCouncilForResults(
};

try {
const baseUrl = resolveOllamaBaseUrl();
const dependencies = await checkCouncilDependencies(baseUrl);
if (!dependencies.canRunCouncil) {
throw new Error(dependencies.degradationReason || 'Council dependencies are unavailable');
}

const runner = runAgentCouncilProcess || runPythonAgentCouncil;
const consensus = await runner({
failureText,
context: targetContext,
model: options.model,
baseUrl: getAIConfig().providers.find((provider) => provider.name.toLowerCase() === 'ollama')?.baseUrl || resolveOllamaBaseUrl(),
baseUrl,
timeoutMs: options.councilTimeoutMs,
});

if (consensus) {
if (primaryResult) {
(primaryResult as any).consensus = consensus;
primaryResult.details = formatConsensusExplanation(consensus);
primaryResult.fallbackStep = 'council';
} else {
results.push({
kind: targetContext.kind,
name: targetContext.name,
namespace: targetContext.namespace || 'default',
errors: [{ text: failureText }],
details: formatConsensusExplanation(consensus),
consensus,
fallbackStep: 'council',
} as any);
}
return;
if (primaryResult) {
(primaryResult as any).consensus = consensus;
primaryResult.details = formatConsensusExplanation(consensus);
primaryResult.fallbackStep = 'council';
} else {
results.push({
kind: targetContext.kind,
name: targetContext.name,
namespace: targetContext.namespace || 'default',
errors: [{ text: failureText }],
details: formatConsensusExplanation(consensus),
consensus,
fallbackStep: 'council',
} as any);
}
} catch (err) {
logger.warn(`Multi-agent council bridge failed: ${(err as Error).message}. Falling back to legacy pipeline.`);
const degradationReason = (err as Error).message;
logger.warn(`Multi-agent council bridge failed: ${degradationReason}. Falling back to diagnostics.`);
await applyCouncilFallbacks(results, options, degradationReason);
}
}

await explainResults(results, options);
/** Fills result details through the rule, cloud, or raw-summary degradation ladder. */
async function applyCouncilFallbacks(
results: AnalyzerResult[],
options: AnalysisOptions,
degradationReason: string,
): Promise<void> {
const backend = resolveBackend(options);
for (const result of results) {
if (result.errors.length > 0) {
await applyCouncilFallbackForResult(result, options, backend, degradationReason);
}
}
}

function createCouncilFallbackContext(
result: AnalyzerResult,
options: AnalysisOptions,
backend: string,
degradationReason: string,
): { context: FallbackContext; mapping: { original: string; placeholder: string }[] } {
const failureText = result.errors.map((error) => error.text).join('\n');
const { promptText, mapping } = preparePromptMapping(failureText, options.anonymize);
const prompt = buildPrompt({
failureText: promptText,
language: options.language ?? DEFAULT_LANGUAGE,
analyzerName: result.kind,
});
return {
context: {
result,
backend,
failureText: promptText,
prompt,
degradationReason,
customHeaders: options.customHeaders,
},
mapping,
};
}

async function applyCouncilFallbackForResult(
result: AnalyzerResult,
options: AnalysisOptions,
backend: string,
degradationReason: string,
): Promise<void> {
const { context, mapping } = createCouncilFallbackContext(result, options, backend, degradationReason);
if (!applyRuleEngineFallback(context) && !(await applyLegacyAiFallback(context))) {
applyRawSummaryFallback(context);
}
result.details = applyAnonymization(result.details || '', mapping, options.anonymize);
}

/**
Expand Down
Loading
Loading