open-multi-agent/examples/patterns/multi-perspective-code-revi...

379 lines
12 KiB
TypeScript

/**
* Multi-Perspective Code Review
*
* Demonstrates:
* - Dependency chain: generator produces code, three reviewers depend on it
* - Parallel execution: security, performance, and style reviewers run concurrently
* - Structured output: synthesizer returns a Zod-validated list of findings
* - Shared memory: each agent's output is automatically stored and injected
* into downstream agents' prompts by the framework
*
* Flow:
* generator → [security-reviewer, performance-reviewer, style-reviewer] (parallel) → synthesizer
*
* Run:
* npx tsx examples/patterns/multi-perspective-code-review.ts
*
* Prerequisites:
* If LLM_PROVIDER is unset, this example auto-selects the first available key
* in this fixed order: Gemini → Groq → OpenRouter → Anthropic.
* This precedence is this example's implementation choice for satisfying
* "default to whichever key is present".
* Override with LLM_PROVIDER=gemini|groq|openrouter|anthropic.
*
* Supported env vars:
* - Gemini: GEMINI_API_KEY, GOOGLE_API_KEY, or GOOGLE_AI_STUDIO_API_KEY
* - Groq: GROQ_API_KEY
* - OpenRouter: OPENROUTER_API_KEY
* - Anthropic: ANTHROPIC_API_KEY
*
* Anthropic support is kept for backward compatibility with the original
* example. It is not part of the free-provider path.
*/
import { z } from 'zod'
import { OpenMultiAgent } from '../../src/index.js'
import type { AgentConfig, OrchestratorEvent } from '../../src/types.js'
// ---------------------------------------------------------------------------
// API spec to implement
// ---------------------------------------------------------------------------
const API_SPEC = `POST /users endpoint that:
- Accepts JSON body with name (string, required), email (string, required), age (number, optional)
- Validates all fields
- Inserts into a PostgreSQL database
- Returns 201 with the created user or 400/500 on error`
// ---------------------------------------------------------------------------
// Structured output schema
// ---------------------------------------------------------------------------
const ReviewFinding = z.object({
priority: z.enum(['critical', 'high', 'medium', 'low']),
category: z.enum(['security', 'performance', 'style']),
issue: z.string().describe('A concise description of the code review finding'),
fix_hint: z.string().describe('A short, actionable suggestion for fixing the issue'),
})
const ReviewFindings = z.array(ReviewFinding)
type ProviderId = 'anthropic' | 'gemini' | 'groq' | 'openrouter'
type ProviderConfig = Pick<AgentConfig, 'provider' | 'model' | 'apiKey' | 'baseURL'>
// ---------------------------------------------------------------------------
// Provider resolution
// ---------------------------------------------------------------------------
function getGeminiApiKey(): string | undefined {
return (
process.env.GEMINI_API_KEY ??
process.env.GOOGLE_API_KEY ??
process.env.GOOGLE_AI_STUDIO_API_KEY
)
}
function inferProvider(): ProviderId {
if (getGeminiApiKey()) return 'gemini'
if (process.env.GROQ_API_KEY) return 'groq'
if (process.env.OPENROUTER_API_KEY) return 'openrouter'
if (process.env.ANTHROPIC_API_KEY) return 'anthropic'
throw new Error(
'No supported API key found. Set GEMINI_API_KEY / GOOGLE_API_KEY / GOOGLE_AI_STUDIO_API_KEY, ' +
'GROQ_API_KEY, OPENROUTER_API_KEY, or ANTHROPIC_API_KEY.',
)
}
function getSelectedProvider(): ProviderId {
const requested = process.env.LLM_PROVIDER?.trim().toLowerCase()
if (!requested) return inferProvider()
if (
requested === 'anthropic' ||
requested === 'gemini' ||
requested === 'groq' ||
requested === 'openrouter'
) {
return requested
}
throw new Error(
`Unsupported LLM_PROVIDER="${process.env.LLM_PROVIDER}". ` +
'Use one of: gemini, groq, openrouter, anthropic.',
)
}
function getProviderConfigs(provider: ProviderId): {
defaultModel: string
defaultProvider: 'anthropic' | 'gemini' | 'openai'
fast: ProviderConfig
strong: ProviderConfig
} {
switch (provider) {
case 'gemini': {
const apiKey = getGeminiApiKey()
if (!apiKey) {
throw new Error(
'LLM_PROVIDER=gemini requires GEMINI_API_KEY, GOOGLE_API_KEY, or GOOGLE_AI_STUDIO_API_KEY.',
)
}
return {
defaultModel: 'gemini-2.5-flash',
defaultProvider: 'gemini',
fast: {
provider: 'gemini',
model: 'gemini-2.5-flash',
apiKey,
},
strong: {
provider: 'gemini',
model: 'gemini-2.5-flash',
apiKey,
},
}
}
case 'groq': {
const apiKey = process.env.GROQ_API_KEY
if (!apiKey) {
throw new Error('LLM_PROVIDER=groq requires GROQ_API_KEY.')
}
return {
defaultModel: 'llama-3.3-70b-versatile',
defaultProvider: 'openai',
fast: {
provider: 'openai',
model: 'llama-3.3-70b-versatile',
apiKey,
baseURL: 'https://api.groq.com/openai/v1',
},
strong: {
provider: 'openai',
model: 'llama-3.3-70b-versatile',
apiKey,
baseURL: 'https://api.groq.com/openai/v1',
},
}
}
case 'openrouter': {
const apiKey = process.env.OPENROUTER_API_KEY
if (!apiKey) {
throw new Error('LLM_PROVIDER=openrouter requires OPENROUTER_API_KEY.')
}
return {
defaultModel: 'google/gemini-2.5-flash',
defaultProvider: 'openai',
fast: {
provider: 'openai',
model: 'google/gemini-2.5-flash',
apiKey,
baseURL: 'https://openrouter.ai/api/v1',
},
strong: {
provider: 'openai',
model: 'google/gemini-2.5-flash',
apiKey,
baseURL: 'https://openrouter.ai/api/v1',
},
}
}
case 'anthropic':
default:
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error('LLM_PROVIDER=anthropic requires ANTHROPIC_API_KEY.')
}
return {
defaultModel: 'claude-sonnet-4-6',
defaultProvider: 'anthropic',
fast: {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
},
strong: {
provider: 'anthropic',
model: 'claude-sonnet-4-6',
},
}
}
}
const selectedProvider = getSelectedProvider()
const providerConfigs = getProviderConfigs(selectedProvider)
// ---------------------------------------------------------------------------
// Agents
// ---------------------------------------------------------------------------
const generator: AgentConfig = {
name: 'generator',
...providerConfigs.fast,
systemPrompt: `You are a Node.js backend developer. Given an API spec, write a complete
Express route handler. Include imports, validation, database query, and error handling.
Output only the code, no explanation. Keep it under 80 lines.`,
maxTurns: 2,
}
const securityReviewer: AgentConfig = {
name: 'security-reviewer',
...providerConfigs.fast,
systemPrompt: `You are a security reviewer. Review the code provided in context and check
for OWASP top 10 vulnerabilities: SQL injection, XSS, broken authentication,
sensitive data exposure, etc. Write your findings as a markdown checklist.
Keep it to 150-200 words.`,
maxTurns: 2,
}
const performanceReviewer: AgentConfig = {
name: 'performance-reviewer',
...providerConfigs.fast,
systemPrompt: `You are a performance reviewer. Review the code provided in context and check
for N+1 queries, memory leaks, blocking calls, missing connection pooling, and
inefficient patterns. Write your findings as a markdown checklist.
Keep it to 150-200 words.`,
maxTurns: 2,
}
const styleReviewer: AgentConfig = {
name: 'style-reviewer',
...providerConfigs.fast,
systemPrompt: `You are a code style reviewer. Review the code provided in context and check
naming conventions, function structure, readability, error message clarity, and
consistency. Write your findings as a markdown checklist.
Keep it to 150-200 words.`,
maxTurns: 2,
}
const synthesizer: AgentConfig = {
name: 'synthesizer',
...providerConfigs.strong,
systemPrompt: `You are a lead engineer synthesizing code review feedback. Review all
the feedback and original code provided in context. Produce a deduplicated list of
code review findings as JSON.
Rules:
- Output ONLY a JSON array matching the provided schema.
- Merge overlapping reviewer comments into a single finding when they describe the same issue.
- Use category "security", "performance", or "style" only.
- Use priority "critical", "high", "medium", or "low" only.
- issue should describe the problem, not the fix.
- fix_hint should be specific and actionable.
- If the code looks clean, return an empty JSON array.`,
maxTurns: 2,
outputSchema: ReviewFindings,
}
// ---------------------------------------------------------------------------
// Orchestrator + team
// ---------------------------------------------------------------------------
function handleProgress(event: OrchestratorEvent): void {
if (event.type === 'task_start') {
console.log(` [START] ${event.task ?? '?'}${event.agent ?? '?'}`)
}
if (event.type === 'task_complete') {
const success = (event.data as { success?: boolean })?.success ?? true
console.log(` [DONE] ${event.task ?? '?'} (${success ? 'OK' : 'FAIL'})`)
}
}
const orchestrator = new OpenMultiAgent({
defaultModel: providerConfigs.defaultModel,
defaultProvider: providerConfigs.defaultProvider,
onProgress: handleProgress,
})
const team = orchestrator.createTeam('code-review-team', {
name: 'code-review-team',
agents: [generator, securityReviewer, performanceReviewer, styleReviewer, synthesizer],
sharedMemory: true,
})
// ---------------------------------------------------------------------------
// Tasks
// ---------------------------------------------------------------------------
const tasks = [
{
title: 'Generate code',
description: `Write a Node.js Express route handler for this API spec:\n\n${API_SPEC}`,
assignee: 'generator',
},
{
title: 'Security review',
description: 'Review the generated code for security vulnerabilities.',
assignee: 'security-reviewer',
dependsOn: ['Generate code'],
},
{
title: 'Performance review',
description: 'Review the generated code for performance issues.',
assignee: 'performance-reviewer',
dependsOn: ['Generate code'],
},
{
title: 'Style review',
description: 'Review the generated code for style and readability.',
assignee: 'style-reviewer',
dependsOn: ['Generate code'],
},
{
title: 'Synthesize feedback',
description: 'Synthesize all review feedback and the original code into a unified, prioritized structured findings array.',
assignee: 'synthesizer',
dependsOn: ['Security review', 'Performance review', 'Style review'],
},
]
// ---------------------------------------------------------------------------
// Run
// ---------------------------------------------------------------------------
console.log('Multi-Perspective Code Review')
console.log('='.repeat(60))
console.log(`Provider: ${selectedProvider}`)
console.log(`Spec: ${API_SPEC.split('\n')[0]}`)
console.log('Pipeline: generator → 3 reviewers (parallel) → synthesizer')
console.log('='.repeat(60))
console.log()
const result = await orchestrator.runTasks(team, tasks)
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
console.log('\n' + '='.repeat(60))
console.log(`Overall success: ${result.success}`)
console.log(`Tokens — input: ${result.totalTokenUsage.input_tokens}, output: ${result.totalTokenUsage.output_tokens}`)
console.log()
for (const [name, r] of result.agentResults) {
const icon = r.success ? 'OK ' : 'FAIL'
const tokens = `in:${r.tokenUsage.input_tokens} out:${r.tokenUsage.output_tokens}`
console.log(` [${icon}] ${name.padEnd(22)} ${tokens}`)
}
const synthResult = result.agentResults.get('synthesizer')
if (synthResult?.structured) {
console.log('\n' + '='.repeat(60))
console.log('STRUCTURED REVIEW FINDINGS')
console.log('='.repeat(60))
console.log()
console.log(JSON.stringify(synthResult.structured, null, 2))
} else if (synthResult) {
console.log('\n' + '='.repeat(60))
console.log('SYNTHESIZER OUTPUT FAILED SCHEMA VALIDATION OR DID NOT PRODUCE VALID JSON')
console.log('='.repeat(60))
console.log()
console.log(synthResult.output.slice(0, 1200))
}
console.log('\nDone.')