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:
parent
6e8016df22
commit
ed21741841
|
|
@ -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.')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue