feat(review): add triage agent for smart specialist routing

Implement TriageAgent with heuristic fast path (skip trivial changes like
lockfiles, CI configs, docs-only) and LLM fallback via chatForRole('planner').
Orchestrator now runs triage before specialist dispatch, only invoking agents
for relevant domains instead of all 4 specialists on every change.

Uses the pre-reserved 'planner' model role that was defined in DB schema and
frontend UI but never wired to backend logic.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
This commit is contained in:
jeffusion
2026-03-05 22:02:33 +08:00
committed by 路遥知码力
parent 839d4a89bf
commit 86480dec07
3 changed files with 574 additions and 17 deletions

View File

@@ -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> = {}): ChangedFile {
return {
path: 'src/file.ts',
status: 'M',
additions: 1,
deletions: 1,
...overrides,
};
}
function makeContext(overrides: Partial<ReviewContext> = {}): 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<LLMChatResponse>
) {
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');
});
});

View File

@@ -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<TriageResult> {
// 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<TriageResult> {
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;
}
}

View File

@@ -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<string, ReflexionAgent>;
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<void> {
@@ -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启动Debatetrivial 变更跳过 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,