diff --git a/src/review/__tests__/triage-agent.test.ts b/src/review/__tests__/triage-agent.test.ts new file mode 100644 index 0000000..68040fe --- /dev/null +++ b/src/review/__tests__/triage-agent.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, test } from 'bun:test'; +import type { LLMChatResponse, ModelRole } from '../../llm/types'; +import { TriageAgent } from '../agents/triage-agent'; +import type { ChangedFile, FindingCategory, ReviewContext } from '../types'; + +const ALL_DOMAINS: FindingCategory[] = [ + 'correctness', + 'security', + 'reliability', + 'maintainability', +]; + +function makeChangedFile(overrides: Partial = {}): ChangedFile { + return { + path: 'src/file.ts', + status: 'M', + additions: 1, + deletions: 1, + ...overrides, + }; +} + +function makeContext(overrides: Partial = {}): ReviewContext { + return { + workspacePath: '/tmp/workspace', + mirrorPath: '/tmp/mirror', + diff: 'diff --git a/src/file.ts b/src/file.ts\n+const x = 1;', + changedFiles: [makeChangedFile()], + parsedDiff: [], + fileContents: {}, + ...overrides, + }; +} + +function makeChatResponse(content: string | null): LLMChatResponse { + return { + content, + toolCalls: [], + finishReason: 'stop', + usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 }, + }; +} + +type ChatCall = { + role: ModelRole; + request: any; +}; + +function createMockGateway( + implementation: (role: ModelRole, request: any) => Promise +) { + const calls: ChatCall[] = []; + + return { + gateway: { + chatForRole: async (role: ModelRole, request: any) => { + calls.push({ role, request }); + return implementation(role, request); + }, + }, + getCalls: () => calls, + }; +} + +describe('TriageAgent', () => { + test('heuristic: empty changedFiles -> trivial + correctness (no LLM call)', async () => { + const { gateway, getCalls } = createMockGateway(async () => + makeChatResponse(JSON.stringify({ complexity: 'complex', relevant_domains: ALL_DOMAINS })) + ); + const agent = new TriageAgent(gateway as any); + + const result = await agent.analyze(makeContext({ changedFiles: [] })); + + expect(result.complexity).toBe('trivial'); + expect(result.relevantDomains).toEqual(['correctness']); + expect(getCalls()).toHaveLength(0); + }); + + test('heuristic: all non-code files -> trivial + correctness (no LLM call)', async () => { + const { gateway, getCalls } = createMockGateway(async () => + makeChatResponse(JSON.stringify({ complexity: 'complex', relevant_domains: ALL_DOMAINS })) + ); + const agent = new TriageAgent(gateway as any); + + const result = await agent.analyze( + makeContext({ + changedFiles: [ + makeChangedFile({ path: 'README.md' }), + makeChangedFile({ path: 'config/app.json' }), + makeChangedFile({ path: 'styles/base.css' }), + makeChangedFile({ path: 'assets/logo.png' }), + makeChangedFile({ path: 'bun.lock', additions: 10, deletions: 10 }), + ], + }) + ); + + expect(result.complexity).toBe('trivial'); + expect(result.relevantDomains).toEqual(['correctness']); + expect(getCalls()).toHaveLength(0); + }); + + test('heuristic: single file <=3 line changes -> trivial + correctness (no LLM call)', async () => { + const { gateway, getCalls } = createMockGateway(async () => + makeChatResponse(JSON.stringify({ complexity: 'complex', relevant_domains: ALL_DOMAINS })) + ); + const agent = new TriageAgent(gateway as any); + + const result = await agent.analyze( + makeContext({ + changedFiles: [makeChangedFile({ path: 'src/app.ts', additions: 1, deletions: 2 })], + }) + ); + + expect(result.complexity).toBe('trivial'); + expect(result.relevantDomains).toEqual(['correctness']); + expect(getCalls()).toHaveLength(0); + }); + + test('heuristic: security-sensitive small PR -> standard + correctness/security (no LLM call)', async () => { + const { gateway, getCalls } = createMockGateway(async () => + makeChatResponse(JSON.stringify({ complexity: 'complex', relevant_domains: ALL_DOMAINS })) + ); + const agent = new TriageAgent(gateway as any); + + const result = await agent.analyze( + makeContext({ + changedFiles: [ + makeChangedFile({ path: 'src/auth/service.ts', additions: 20, deletions: 10 }), + makeChangedFile({ path: 'src/user/profile.ts', additions: 5, deletions: 5 }), + ], + }) + ); + + expect(result.complexity).toBe('standard'); + expect(result.relevantDomains).toEqual(['correctness', 'security']); + expect(getCalls()).toHaveLength(0); + }); + + test('heuristic: large PR by file count (>20) -> complex + all domains (no LLM call)', async () => { + const { gateway, getCalls } = createMockGateway(async () => + makeChatResponse( + JSON.stringify({ complexity: 'standard', relevant_domains: ['correctness'] }) + ) + ); + const agent = new TriageAgent(gateway as any); + + const changedFiles = Array.from({ length: 21 }, (_, index) => + makeChangedFile({ path: `src/file-${index}.ts`, additions: 2, deletions: 1 }) + ); + + const result = await agent.analyze(makeContext({ changedFiles })); + + expect(result.complexity).toBe('complex'); + expect(result.relevantDomains).toEqual(ALL_DOMAINS); + expect(getCalls()).toHaveLength(0); + }); + + test('heuristic: large PR by total changes (>500) -> complex + all domains (no LLM call)', async () => { + const { gateway, getCalls } = createMockGateway(async () => + makeChatResponse( + JSON.stringify({ complexity: 'standard', relevant_domains: ['correctness'] }) + ) + ); + const agent = new TriageAgent(gateway as any); + + const result = await agent.analyze( + makeContext({ + changedFiles: [ + makeChangedFile({ path: 'src/a.ts', additions: 250, deletions: 10 }), + makeChangedFile({ path: 'src/b.ts', additions: 240, deletions: 10 }), + ], + }) + ); + + expect(result.complexity).toBe('complex'); + expect(result.relevantDomains).toEqual(ALL_DOMAINS); + expect(getCalls()).toHaveLength(0); + }); + + test('LLM fallback: standard code change calls planner and returns parsed JSON result', async () => { + const { gateway, getCalls } = createMockGateway(async () => + makeChatResponse( + JSON.stringify({ + complexity: 'standard', + relevant_domains: ['security', 'maintainability'], + rationale: '跨文件业务逻辑调整', + }) + ) + ); + const agent = new TriageAgent(gateway as any); + + const result = await agent.analyze( + makeContext({ + changedFiles: [ + makeChangedFile({ path: 'src/service/order.ts', additions: 10, deletions: 6 }), + makeChangedFile({ path: 'src/controller/order.ts', additions: 12, deletions: 8 }), + makeChangedFile({ path: 'src/repo/order.ts', additions: 8, deletions: 6 }), + ], + diff: 'diff --git a/src/service/order.ts b/src/service/order.ts\n+export function calc(){}', + }) + ); + + const calls = getCalls(); + expect(calls).toHaveLength(1); + expect(calls[0].role).toBe('planner'); + expect(calls[0].request.temperature).toBe(0); + expect(calls[0].request.responseFormat).toBe('json'); + + expect(result.complexity).toBe('standard'); + expect(result.relevantDomains).toEqual(['correctness', 'security', 'maintainability']); + expect(result.rationale).toBe('跨文件业务逻辑调整'); + }); + + test('LLM fallback: planner throws -> fallback standard + all domains', async () => { + const { gateway, getCalls } = createMockGateway(async () => { + throw new Error('planner unavailable'); + }); + const agent = new TriageAgent(gateway as any); + + const result = await agent.analyze( + makeContext({ + changedFiles: [ + makeChangedFile({ path: 'src/service/foo.ts', additions: 10, deletions: 4 }), + makeChangedFile({ path: 'src/service/bar.ts', additions: 12, deletions: 6 }), + makeChangedFile({ path: 'src/service/baz.ts', additions: 8, deletions: 10 }), + ], + }) + ); + + expect(getCalls()).toHaveLength(1); + expect(result.complexity).toBe('standard'); + expect(result.relevantDomains).toEqual(ALL_DOMAINS); + expect(result.rationale).toContain('LLM'); + }); +}); diff --git a/src/review/agents/triage-agent.ts b/src/review/agents/triage-agent.ts new file mode 100644 index 0000000..645885e --- /dev/null +++ b/src/review/agents/triage-agent.ts @@ -0,0 +1,276 @@ +/** + * Triage Agent — lightweight intent recognition using the 'planner' model role. + * + * Analyzes the change summary (file list + basic stats) to determine: + * 1. Change complexity (trivial / standard / complex) + * 2. Which specialist domains are relevant + * + * This avoids wasting tokens by running all 4 specialist agents on trivial changes + * (e.g. README typo fixes, string-only edits, pure documentation changes). + */ + +import config from '../../config'; +import type { LLMGateway } from '../../llm/gateway'; +import type { LLMMessage } from '../../llm/types'; +import { withGlobalPrompt } from '../../utils/global-prompt'; +import { logger } from '../../utils/logger'; +import type { ChangedFile, FindingCategory, ReviewContext } from '../types'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type TriageComplexity = 'trivial' | 'standard' | 'complex'; + +export interface TriageResult { + /** How complex the change is — drives how many agents to dispatch. */ + complexity: TriageComplexity; + /** Which specialist domains are relevant for this change. */ + relevantDomains: FindingCategory[]; + /** Brief rationale from the planner model. */ + rationale: string; +} + +/** All valid finding categories. */ +const ALL_DOMAINS: FindingCategory[] = [ + 'correctness', + 'security', + 'reliability', + 'maintainability', +]; + +// --------------------------------------------------------------------------- +// Triage Agent +// --------------------------------------------------------------------------- + +export class TriageAgent { + constructor(private readonly gateway: LLMGateway) {} + + /** + * Analyze the review context and return a triage decision. + * Uses the 'planner' role for a lightweight, fast LLM call. + * + * If the planner role is not configured or the call fails, + * falls back to a heuristic-based triage. + */ + async analyze(context: ReviewContext): Promise { + // First try heuristic-based fast path (no LLM call needed for obvious cases) + const heuristicResult = this.heuristicTriage(context.changedFiles); + if (heuristicResult) { + logger.info('Triage: 使用启发式规则快速分流', { + complexity: heuristicResult.complexity, + domains: heuristicResult.relevantDomains.join(','), + rationale: heuristicResult.rationale, + }); + return heuristicResult; + } + + // Fall back to LLM-based triage + try { + return await this.llmTriage(context); + } catch (error) { + logger.warn('Triage: LLM 调用失败,回退到启发式全量派发', { + error: error instanceof Error ? error.message : String(error), + }); + return { + complexity: 'standard', + relevantDomains: [...ALL_DOMAINS], + rationale: 'Triage LLM 调用失败,使用默认全量审查', + }; + } + } + + /** + * Heuristic-based triage — no LLM call needed. + * Returns null if heuristic is inconclusive (should use LLM). + */ + private heuristicTriage(changedFiles: ChangedFile[]): TriageResult | null { + if (changedFiles.length === 0) { + return { + complexity: 'trivial', + relevantDomains: ['correctness'], + rationale: '无变更文件', + }; + } + + const NON_CODE_EXTENSIONS = new Set([ + '.md', + '.txt', + '.rst', + '.adoc', // docs + '.json', + '.yaml', + '.yml', + '.toml', + '.ini', // config (non-security) + '.css', + '.scss', + '.less', + '.svg', // styles/assets + '.png', + '.jpg', + '.jpeg', + '.gif', + '.ico', + '.webp', // images + '.lock', // lockfiles + ]); + + const SECURITY_SENSITIVE_PATTERNS = [ + /auth/i, + /login/i, + /password/i, + /secret/i, + /token/i, + /crypt/i, + /permission/i, + /role/i, + /acl/i, + /cors/i, + /csrf/i, + /xss/i, + /\.env/, + /credential/i, + /oauth/i, + /jwt/i, + /session/i, + ]; + + const allNonCode = changedFiles.every((f) => { + const ext = f.path.substring(f.path.lastIndexOf('.')).toLowerCase(); + return NON_CODE_EXTENSIONS.has(ext); + }); + + if (allNonCode) { + return { + complexity: 'trivial', + relevantDomains: ['correctness'], + rationale: '所有变更文件均为非代码文件(文档/配置/资源)', + }; + } + + // Very small change (≤3 lines total) in a single file → trivial + const totalChanges = changedFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0); + if (changedFiles.length === 1 && totalChanges <= 3) { + return { + complexity: 'trivial', + relevantDomains: ['correctness'], + rationale: `单文件微量变更(${totalChanges} 行)`, + }; + } + + // Check for security-sensitive files + const hasSecurityFiles = changedFiles.some((f) => + SECURITY_SENSITIVE_PATTERNS.some((p) => p.test(f.path)) + ); + + // Large PR (many files or large changes) → complex + if (changedFiles.length > 20 || totalChanges > 500) { + return { + complexity: 'complex', + relevantDomains: [...ALL_DOMAINS], + rationale: `大规模变更(${changedFiles.length} 文件, ${totalChanges} 行)`, + }; + } + + // Security-sensitive file detected → ensure security agent is included + if (hasSecurityFiles && changedFiles.length <= 5 && totalChanges <= 100) { + return { + complexity: 'standard', + relevantDomains: ['correctness', 'security'], + rationale: '涉及安全相关文件,仅派发 correctness + security', + }; + } + + // Inconclusive — let LLM decide + return null; + } + + /** + * LLM-based triage using the 'planner' role. + */ + private async llmTriage(context: ReviewContext): Promise { + const fileSummary = context.changedFiles + .map((f) => `${f.status} ${f.path} (+${f.additions} -${f.deletions})`) + .join('\n'); + + // Use a small slice of diff for context (just the first 2000 chars for speed) + const diffPreview = context.diff.slice(0, 2000); + + const prompt = `你是代码审查分流专家。分析以下变更并判断其复杂度和需要哪些审查领域。 + +变更文件列表: +${fileSummary} + +Diff 预览(前2000字符): +${diffPreview} + +判断标准: +- **trivial**: 纯文档、注释、字符串修改、格式化、重命名等无逻辑变更 → 只需 correctness +- **standard**: 单模块逻辑修改、普通功能开发 → 按实际涉及领域选择 +- **complex**: 多模块/跨层修改、架构变更、并发/安全关键路径 → 全部领域 + +可选领域:correctness(逻辑正确性), security(安全), reliability(可靠性), maintainability(可维护性) + +返回 JSON: +{ + "complexity": "trivial" | "standard" | "complex", + "relevant_domains": ["correctness", ...], + "rationale": "简要理由" +}`; + + const messages: LLMMessage[] = [ + { + role: 'system', + content: withGlobalPrompt( + '你是代码变更分流专家,快速判断变更复杂度。返回结构化 JSON,不输出额外文字。', + config.review.globalPrompt + ), + }, + { role: 'user', content: prompt }, + ]; + + const response = await this.gateway.chatForRole('planner', { + messages, + temperature: 0, + responseFormat: 'json', + }); + + const content = response.content; + if (!content) { + throw new Error('Triage: planner 模型返回空结果'); + } + + const parsed = JSON.parse(content); + + // Validate and normalize + const complexity = (['trivial', 'standard', 'complex'] as const).includes(parsed.complexity) + ? (parsed.complexity as TriageComplexity) + : 'standard'; + + const relevantDomains: FindingCategory[] = Array.isArray(parsed.relevant_domains) + ? (parsed.relevant_domains.filter((d: string) => + ALL_DOMAINS.includes(d as FindingCategory) + ) as FindingCategory[]) + : [...ALL_DOMAINS]; + + // Ensure at least correctness is always included + if (!relevantDomains.includes('correctness')) { + relevantDomains.unshift('correctness'); + } + + const result: TriageResult = { + complexity, + relevantDomains, + rationale: parsed.rationale || '', + }; + + logger.info('Triage: LLM 分流完成', { + complexity: result.complexity, + domains: result.relevantDomains.join(','), + rationale: result.rationale, + }); + + return result; + } +} diff --git a/src/review/orchestrator.ts b/src/review/orchestrator.ts index 913e7df..c72525d 100644 --- a/src/review/orchestrator.ts +++ b/src/review/orchestrator.ts @@ -6,6 +6,7 @@ import { logger } from '../utils/logger'; import { DebateOrchestrator } from './agents/debate-orchestrator'; import { JudgeAgent } from './agents/judge-agent'; import { ReflexionAgent } from './agents/reflexion-agent'; +import { TriageAgent, type TriageResult } from './agents/triage-agent'; import { DiffExtractor } from './context/diff-extractor'; import { LocalRepoManager, LocalRepoPaths } from './context/local-repo-manager'; import { LearningSystem } from './learning/learning-system'; @@ -44,12 +45,14 @@ function summarizeGatedCount(gatedCount: number): string { export class ReviewOrchestrator { private readonly gateway: LLMGateway; private readonly toolRegistry: ToolRegistry; + private readonly agentMap: Record; private readonly correctnessAgent: ReflexionAgent; private readonly securityAgent: ReflexionAgent; private readonly reliabilityAgent: ReflexionAgent; private readonly maintainabilityAgent: ReflexionAgent; private readonly judgeAgent: JudgeAgent; private readonly debateOrchestrator: DebateOrchestrator; + private readonly triageAgent: TriageAgent; private readonly memoryStore?: VectorMemoryStore; private readonly learningSystem?: LearningSystem; @@ -121,6 +124,15 @@ export class ReviewOrchestrator { this.judgeAgent = new JudgeAgent(); this.debateOrchestrator = new DebateOrchestrator(this.gateway); + this.triageAgent = new TriageAgent(this.gateway); + + // Build agent map for dynamic dispatch + this.agentMap = { + correctness: this.correctnessAgent, + security: this.securityAgent, + reliability: this.reliabilityAgent, + maintainability: this.maintainabilityAgent, + }; } async execute(run: ReviewRun): Promise { @@ -188,6 +200,32 @@ export class ReviewOrchestrator { return; } + // ── Triage: 决定哪些 specialist 需要参与 ───────────────────────── + let triage: TriageResult | null = null; + const enableTriage = config.review.enableTriage ?? true; + + if (enableTriage) { + const triageStart = Date.now(); + await this.store.addStep({ + runId: run.id, + stepName: 'triage', + status: 'started', + startedAt: new Date(triageStart).toISOString(), + }); + + triage = await this.triageAgent.analyze(context); + + await this.store.addStep({ + runId: run.id, + stepName: 'triage', + status: 'succeeded', + startedAt: new Date(triageStart).toISOString(), + finishedAt: new Date().toISOString(), + latencyMs: Date.now() - triageStart, + }); + } + + // ── 按 triage 结果选择性派发 specialists ───────────────────────── const agentStart = Date.now(); await this.store.addStep({ runId: run.id, @@ -196,24 +234,32 @@ export class ReviewOrchestrator { startedAt: new Date(agentStart).toISOString(), }); - // 使用Reflection模式运行specialists const enableReflection = config.review.enableReflection ?? false; const maxReflectionRounds = config.review.maxReflectionRounds ?? 2; - const agentResults = await Promise.all([ - enableReflection - ? this.correctnessAgent.reviewWithReflection(run, context, maxReflectionRounds) - : this.correctnessAgent.review(run, context), - enableReflection - ? this.securityAgent.reviewWithReflection(run, context, maxReflectionRounds) - : this.securityAgent.review(run, context), - enableReflection - ? this.reliabilityAgent.reviewWithReflection(run, context, maxReflectionRounds) - : this.reliabilityAgent.review(run, context), - enableReflection - ? this.maintainabilityAgent.reviewWithReflection(run, context, maxReflectionRounds) - : this.maintainabilityAgent.review(run, context), - ]); + // Select agents based on triage result + const agentsToRun = triage + ? triage.relevantDomains.map((domain) => this.agentMap[domain]).filter(Boolean) + : [this.correctnessAgent, this.securityAgent, this.reliabilityAgent, this.maintainabilityAgent]; + + // For trivial changes, skip reflection even if globally enabled + const useReflection = triage?.complexity === 'trivial' ? false : enableReflection; + + logger.info('Specialist 派发决策', { + runId: run.id, + triageComplexity: triage?.complexity ?? 'disabled', + agentCount: agentsToRun.length, + domains: triage?.relevantDomains ?? ['correctness', 'security', 'reliability', 'maintainability'], + reflection: useReflection, + }); + + const agentResults = await Promise.all( + agentsToRun.map((agent) => + useReflection + ? agent.reviewWithReflection(run, context, maxReflectionRounds) + : agent.review(run, context) + ) + ); await this.store.addStep({ runId: run.id, @@ -226,11 +272,11 @@ export class ReviewOrchestrator { let allFindings = agentResults.flatMap((result) => result.findings); - // 对高严重性findings启动Debate + // 对高严重性findings启动Debate(trivial 变更跳过 debate) const enableDebate = config.review.enableDebate ?? false; const debateThreshold = config.review.debateThreshold ?? 'high'; - if (enableDebate && allFindings.length > 0) { + if (enableDebate && allFindings.length > 0 && triage?.complexity !== 'trivial') { const debateStart = Date.now(); await this.store.addStep({ runId: run.id,