Upgrade multi-perspective code review example with structured output and free-provider support (#150)

* feat: upgrade code review example output and provider support

* test: add code review schema validation coverage

* fix: resolve review feedback on code review example
This commit is contained in:
Kinoo0 2026-04-23 17:35:21 +08:00 committed by GitHub
parent 6e8016df22
commit ed21741841
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 295 additions and 17 deletions

View File

@ -4,6 +4,7 @@
* 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
*
@ -14,9 +15,23 @@
* npx tsx examples/patterns/multi-perspective-code-review.ts
*
* Prerequisites:
* ANTHROPIC_API_KEY env var must be set.
* 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'
@ -30,13 +45,175 @@ const API_SPEC = `POST /users endpoint that:
- 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',
model: 'claude-sonnet-4-6',
...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.`,
@ -45,7 +222,7 @@ Output only the code, no explanation. Keep it under 80 lines.`,
const securityReviewer: AgentConfig = {
name: 'security-reviewer',
model: 'claude-sonnet-4-6',
...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.
@ -55,7 +232,7 @@ Keep it to 150-200 words.`,
const performanceReviewer: AgentConfig = {
name: 'performance-reviewer',
model: 'claude-sonnet-4-6',
...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.
@ -65,7 +242,7 @@ Keep it to 150-200 words.`,
const styleReviewer: AgentConfig = {
name: 'style-reviewer',
model: 'claude-sonnet-4-6',
...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.
@ -75,16 +252,21 @@ Keep it to 150-200 words.`,
const synthesizer: AgentConfig = {
name: 'synthesizer',
model: 'claude-sonnet-4-6',
...providerConfigs.strong,
systemPrompt: `You are a lead engineer synthesizing code review feedback. Review all
the feedback and original code provided in context. Produce a unified report with:
the feedback and original code provided in context. Produce a deduplicated list of
code review findings as JSON.
1. Critical issues (must fix before merge)
2. Recommended improvements (should fix)
3. Minor suggestions (nice to have)
Deduplicate overlapping feedback. Keep the report to 200-300 words.`,
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,
}
// ---------------------------------------------------------------------------
@ -102,7 +284,8 @@ function handleProgress(event: OrchestratorEvent): void {
}
const orchestrator = new OpenMultiAgent({
defaultModel: 'claude-sonnet-4-6',
defaultModel: providerConfigs.defaultModel,
defaultProvider: providerConfigs.defaultProvider,
onProgress: handleProgress,
})
@ -142,7 +325,7 @@ const tasks = [
},
{
title: 'Synthesize feedback',
description: 'Synthesize all review feedback and the original code into a unified, prioritized action item report.',
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'],
},
@ -154,6 +337,7 @@ const tasks = [
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))
@ -177,12 +361,18 @@ for (const [name, r] of result.agentResults) {
}
const synthResult = result.agentResults.get('synthesizer')
if (synthResult?.success) {
if (synthResult?.structured) {
console.log('\n' + '='.repeat(60))
console.log('UNIFIED REVIEW REPORT')
console.log('STRUCTURED REVIEW FINDINGS')
console.log('='.repeat(60))
console.log()
console.log(synthResult.output)
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.')

View File

@ -0,0 +1,88 @@
import { describe, it, expect } from 'vitest'
import { z } from 'zod'
import { Agent } from '../src/agent/agent.js'
import { AgentRunner } from '../src/agent/runner.js'
import { ToolRegistry } from '../src/tool/framework.js'
import { ToolExecutor } from '../src/tool/executor.js'
import type { AgentConfig, LLMAdapter, LLMResponse } from '../src/types.js'
const ReviewFindings = z.array(
z.object({
priority: z.enum(['critical', 'high', 'medium', 'low']),
category: z.enum(['security', 'performance', 'style']),
issue: z.string(),
fix_hint: z.string(),
}),
)
function mockAdapter(responses: string[]): LLMAdapter {
let callIndex = 0
return {
name: 'mock',
async chat() {
const text = responses[callIndex++] ?? ''
return {
id: `mock-${callIndex}`,
content: [{ type: 'text' as const, text }],
model: 'mock-model',
stop_reason: 'end_turn',
usage: { input_tokens: 10, output_tokens: 20 },
} satisfies LLMResponse
},
async *stream() {
/* unused in this test */
},
}
}
function buildMockAgent(config: AgentConfig, responses: string[]): Agent {
const adapter = mockAdapter(responses)
const registry = new ToolRegistry()
const executor = new ToolExecutor(registry)
const agent = new Agent(config, registry, executor)
const runner = new AgentRunner(adapter, registry, executor, {
model: config.model,
systemPrompt: config.systemPrompt,
maxTurns: config.maxTurns,
maxTokens: config.maxTokens,
temperature: config.temperature,
agentName: config.name,
})
;(agent as any).runner = runner
return agent
}
describe('multi-perspective code review example', () => {
it('returns structured findings that match the issue schema', async () => {
const config: AgentConfig = {
name: 'synthesizer',
model: 'mock-model',
systemPrompt: 'Return structured review findings.',
outputSchema: ReviewFindings,
}
const findings = [
{
priority: 'critical',
category: 'security',
issue: 'User input is interpolated directly into the SQL query.',
fix_hint: 'Use parameterized queries for all database writes.',
},
{
priority: 'medium',
category: 'style',
issue: 'Error responses use inconsistent wording across branches.',
fix_hint: 'Standardize error messages and response payload structure.',
},
]
const agent = buildMockAgent(config, [JSON.stringify(findings)])
const result = await agent.run('Synthesize the reviewers into structured findings.')
expect(result.success).toBe(true)
expect(Array.isArray(result.structured)).toBe(true)
expect(ReviewFindings.parse(result.structured)).toEqual(findings)
})
})