diff --git a/examples/patterns/multi-perspective-code-review.ts b/examples/patterns/multi-perspective-code-review.ts index 1a23baf..d7441f9 100644 --- a/examples/patterns/multi-perspective-code-review.ts +++ b/examples/patterns/multi-perspective-code-review.ts @@ -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 + +// --------------------------------------------------------------------------- +// 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.') diff --git a/tests/multi-perspective-code-review.test.ts b/tests/multi-perspective-code-review.test.ts new file mode 100644 index 0000000..5ef2bcd --- /dev/null +++ b/tests/multi-perspective-code-review.test.ts @@ -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) + }) +})