diff --git a/src/index.ts b/src/index.ts index 20d8d1a..a6b27f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,7 +54,7 @@ // Orchestrator (primary entry point) // --------------------------------------------------------------------------- -export { OpenMultiAgent, executeWithRetry, computeRetryDelay } from './orchestrator/orchestrator.js' +export { OpenMultiAgent, executeWithRetry, computeRetryDelay, isSimpleGoal, selectBestAgent } from './orchestrator/orchestrator.js' export { Scheduler } from './orchestrator/scheduler.js' export type { SchedulingStrategy } from './orchestrator/scheduler.js' diff --git a/src/orchestrator/orchestrator.ts b/src/orchestrator/orchestrator.ts index 8b0073d..e5c010c 100644 --- a/src/orchestrator/orchestrator.ts +++ b/src/orchestrator/orchestrator.ts @@ -73,6 +73,123 @@ const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 } const DEFAULT_MAX_CONCURRENCY = 5 const DEFAULT_MODEL = 'claude-opus-4-6' +// --------------------------------------------------------------------------- +// Short-circuit helpers (exported for testability) +// --------------------------------------------------------------------------- + +/** + * Regex patterns that indicate a goal requires multi-agent coordination. + * + * Each pattern targets a distinct complexity signal: + * - Sequencing: "first … then", "step 1 / step 2", numbered lists + * - Coordination: "collaborate", "coordinate", "review each other" + * - Parallel work: "in parallel", "at the same time", "concurrently" + * - Multi-phase: "phase", "stage", multiple distinct action verbs joined by connectives + */ +const COMPLEXITY_PATTERNS: RegExp[] = [ + // Explicit sequencing + /\bfirst\b.{3,60}\bthen\b/i, + /\bstep\s*\d/i, + /\bphase\s*\d/i, + /\bstage\s*\d/i, + /^\s*\d+[\.\)]/m, // numbered list items ("1. …", "2) …") + + // Coordination language + /\bcollaborat/i, + /\bcoordinat/i, + /\breview\s+each\s+other/i, + /\bwork\s+together\b/i, + + // Parallel execution + /\bin\s+parallel\b/i, + /\bconcurrently\b/i, + /\bat\s+the\s+same\s+time\b/i, + + // Multiple deliverables joined by connectives + // Matches patterns like "build X, then deploy Y and test Z" + /\b(?:build|create|implement|design|write|develop)\b.{5,80}\b(?:and|then)\b.{5,80}\b(?:build|create|implement|design|write|develop|test|review|deploy)\b/i, +] + +/** + * Maximum goal length (in characters) below which a goal *may* be simple. + * + * Goals longer than this threshold almost always contain enough detail to + * warrant multi-agent decomposition. The value is generous — short-circuit + * is meant for genuinely simple, single-action goals. + */ +const SIMPLE_GOAL_MAX_LENGTH = 200 + +/** + * Determine whether a goal is simple enough to skip coordinator decomposition. + * + * A goal is considered "simple" when ALL of the following hold: + * 1. Its length is ≤ {@link SIMPLE_GOAL_MAX_LENGTH}. + * 2. It does not match any {@link COMPLEXITY_PATTERNS}. + * + * Exported for unit testing. + */ +export function isSimpleGoal(goal: string): boolean { + if (goal.length > SIMPLE_GOAL_MAX_LENGTH) return false + return !COMPLEXITY_PATTERNS.some((re) => re.test(goal)) +} + +/** + * Select the best-matching agent for a goal using keyword affinity scoring. + * + * Scores each agent by keyword overlap between the goal text and the agent's + * `name` + `systemPrompt`. Returns the highest-scoring agent, or falls back + * to the first agent when all scores are equal. + * + * The keyword extraction and scoring logic mirrors the `capability-match` + * strategy in {@link Scheduler} to keep behaviour consistent. + * + * Exported for unit testing. + */ +export function selectBestAgent(goal: string, agents: AgentConfig[]): AgentConfig { + if (agents.length <= 1) return agents[0]! + + const goalKeywords = extractKeywords(goal) + + let bestAgent = agents[0]! + let bestScore = -1 + + for (const agent of agents) { + const agentText = `${agent.name} ${agent.systemPrompt ?? ''}` + const agentKeywords = extractKeywords(agentText) + + // Score in both directions (same as Scheduler.capability-match) + const scoreA = keywordScore(agentText, goalKeywords) + const scoreB = keywordScore(goal, agentKeywords) + const score = scoreA + scoreB + + if (score > bestScore) { + bestScore = score + bestAgent = agent + } + } + + return bestAgent +} + +// ---- keyword helpers (shared with Scheduler via same logic) ---- + +const STOP_WORDS = new Set([ + 'the', 'and', 'for', 'that', 'this', 'with', 'are', 'from', 'have', + 'will', 'your', 'you', 'can', 'all', 'each', 'when', 'then', 'they', + 'them', 'their', 'about', 'into', 'more', 'also', 'should', 'must', +]) + +function extractKeywords(text: string): string[] { + return [...new Set( + text.toLowerCase().split(/\W+/).filter((w) => w.length > 3 && !STOP_WORDS.has(w)), + )] +} + +function keywordScore(text: string, keywords: string[]): number { + const lower = text.toLowerCase() + return keywords.reduce((acc, kw) => acc + (lower.includes(kw.toLowerCase()) ? 1 : 0), 0) +} + // --------------------------------------------------------------------------- // Internal helpers // --------------------------------------------------------------------------- @@ -699,6 +816,38 @@ export class OpenMultiAgent { async runTeam(team: Team, goal: string, options?: { abortSignal?: AbortSignal }): Promise { const agentConfigs = team.getAgents() + // ------------------------------------------------------------------ + // Short-circuit: skip coordinator for simple, single-action goals. + // + // When the goal is short and contains no multi-step / coordination + // signals, dispatching it to a single agent is faster and cheaper + // than spinning up a coordinator for decomposition + synthesis. + // + // The best-matching agent is selected via keyword affinity scoring + // (same algorithm as the `capability-match` scheduler strategy). + // ------------------------------------------------------------------ + if (agentConfigs.length > 0 && isSimpleGoal(goal)) { + const bestAgent = selectBestAgent(goal, agentConfigs) + + this.config.onProgress?.({ + type: 'agent_start', + agent: bestAgent.name, + data: { phase: 'short-circuit', goal }, + }) + + const result = await this.runAgent(bestAgent, goal) + const agentResults = new Map() + agentResults.set(bestAgent.name, result) + + this.config.onProgress?.({ + type: 'agent_complete', + agent: bestAgent.name, + data: { phase: 'short-circuit', result }, + }) + + return this.buildTeamRunResult(agentResults) + } + // ------------------------------------------------------------------ // Step 1: Coordinator decomposes goal into tasks // ------------------------------------------------------------------ diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts index 41d8da3..b80f119 100644 --- a/tests/orchestrator.test.ts +++ b/tests/orchestrator.test.ts @@ -215,7 +215,7 @@ describe('OpenMultiAgent', () => { }) const team = oma.createTeam('t', teamCfg()) - const result = await oma.runTeam(team, 'Research AI safety') + const result = await oma.runTeam(team, 'First research AI safety best practices, then write a comprehensive implementation guide') expect(result.success).toBe(true) // Should have coordinator result @@ -233,7 +233,7 @@ describe('OpenMultiAgent', () => { const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) const team = oma.createTeam('t', teamCfg()) - const result = await oma.runTeam(team, 'Do something') + const result = await oma.runTeam(team, 'First design the database schema, then implement the REST API endpoints') expect(result.success).toBe(true) }) diff --git a/tests/short-circuit.test.ts b/tests/short-circuit.test.ts new file mode 100644 index 0000000..ba563e7 --- /dev/null +++ b/tests/short-circuit.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { isSimpleGoal, selectBestAgent } from '../src/orchestrator/orchestrator.js' +import { OpenMultiAgent } from '../src/orchestrator/orchestrator.js' +import type { + AgentConfig, + LLMChatOptions, + LLMMessage, + LLMResponse, + OrchestratorEvent, + TeamConfig, +} from '../src/types.js' + +// --------------------------------------------------------------------------- +// isSimpleGoal — pure function tests +// --------------------------------------------------------------------------- + +describe('isSimpleGoal', () => { + describe('returns true for simple goals', () => { + const simpleGoals = [ + 'Say hello', + 'What is 2 + 2?', + 'Explain monads in one paragraph', + 'Translate this to French: Good morning', + 'List 3 blockchain security vulnerabilities', + 'Write a haiku about TypeScript', + 'Summarize this article', + '你好,回一个字:哈', + 'Fix the typo in the README', + ] + + for (const goal of simpleGoals) { + it(`"${goal}"`, () => { + expect(isSimpleGoal(goal)).toBe(true) + }) + } + }) + + describe('returns false for complex goals', () => { + it('goal with explicit sequencing (first…then)', () => { + expect(isSimpleGoal('First design the API schema, then implement the endpoints')).toBe(false) + }) + + it('goal with numbered steps', () => { + expect(isSimpleGoal('1. Design the schema\n2. Implement the API\n3. Write tests')).toBe(false) + }) + + it('goal with step N pattern', () => { + expect(isSimpleGoal('Step 1: set up the project. Step 2: write the code.')).toBe(false) + }) + + it('goal with collaboration language', () => { + expect(isSimpleGoal('Collaborate on building a REST API with tests')).toBe(false) + }) + + it('goal with coordination language', () => { + expect(isSimpleGoal('Coordinate the team to build and deploy the service')).toBe(false) + }) + + it('goal with parallel execution', () => { + expect(isSimpleGoal('Run the linter and tests in parallel')).toBe(false) + }) + + it('goal with multiple deliverables (build…and…test)', () => { + expect(isSimpleGoal('Build the REST API endpoints and then write comprehensive integration tests for each one')).toBe(false) + }) + + it('goal exceeding max length', () => { + const longGoal = 'Explain the concept of ' + 'a'.repeat(200) + expect(isSimpleGoal(longGoal)).toBe(false) + }) + + it('goal with phase markers', () => { + expect(isSimpleGoal('Phase 1 is planning, phase 2 is execution')).toBe(false) + }) + + it('goal with "work together"', () => { + expect(isSimpleGoal('Work together to build the frontend and backend')).toBe(false) + }) + + it('goal with "review each other"', () => { + expect(isSimpleGoal('Write code and review each other\'s pull requests')).toBe(false) + }) + }) + + describe('edge cases', () => { + it('empty string is simple', () => { + expect(isSimpleGoal('')).toBe(true) + }) + + it('"and" alone does not trigger complexity', () => { + // Unlike the original turbo implementation, common words like "and" + // should NOT flag a goal as complex. + expect(isSimpleGoal('Pros and cons of TypeScript')).toBe(true) + }) + + it('"then" alone does not trigger complexity', () => { + expect(isSimpleGoal('What happened then?')).toBe(true) + }) + + it('"summarize" alone does not trigger complexity', () => { + expect(isSimpleGoal('Summarize the article about AI safety')).toBe(true) + }) + + it('"analyze" alone does not trigger complexity', () => { + expect(isSimpleGoal('Analyze this error log')).toBe(true) + }) + + it('goal exactly at length boundary (200) is simple if no patterns', () => { + const goal = 'x'.repeat(200) + expect(isSimpleGoal(goal)).toBe(true) + }) + + it('goal at 201 chars is complex', () => { + const goal = 'x'.repeat(201) + expect(isSimpleGoal(goal)).toBe(false) + }) + }) +}) + +// --------------------------------------------------------------------------- +// selectBestAgent — keyword affinity scoring +// --------------------------------------------------------------------------- + +describe('selectBestAgent', () => { + it('selects agent whose systemPrompt best matches the goal', () => { + const agents: AgentConfig[] = [ + { name: 'researcher', model: 'test', systemPrompt: 'You are a research expert who analyzes data and writes reports' }, + { name: 'coder', model: 'test', systemPrompt: 'You are a software engineer who writes TypeScript code' }, + ] + + expect(selectBestAgent('Write TypeScript code for the API', agents)).toBe(agents[1]) + expect(selectBestAgent('Research the latest AI papers', agents)).toBe(agents[0]) + }) + + it('falls back to first agent when no keywords match', () => { + const agents: AgentConfig[] = [ + { name: 'alpha', model: 'test' }, + { name: 'beta', model: 'test' }, + ] + + expect(selectBestAgent('xyzzy', agents)).toBe(agents[0]) + }) + + it('returns the only agent when team has one member', () => { + const agents: AgentConfig[] = [ + { name: 'solo', model: 'test', systemPrompt: 'General purpose agent' }, + ] + + expect(selectBestAgent('anything', agents)).toBe(agents[0]) + }) + + it('considers agent name in scoring', () => { + const agents: AgentConfig[] = [ + { name: 'writer', model: 'test', systemPrompt: 'You help with tasks' }, + { name: 'reviewer', model: 'test', systemPrompt: 'You help with tasks' }, + ] + + // "review" should match "reviewer" agent name + expect(selectBestAgent('Review this pull request', agents)).toBe(agents[1]) + }) +}) + +// --------------------------------------------------------------------------- +// runTeam short-circuit integration test +// --------------------------------------------------------------------------- + +let mockAdapterResponses: string[] = [] + +vi.mock('../src/llm/adapter.js', () => ({ + createAdapter: async () => { + let callIndex = 0 + return { + name: 'mock', + async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise { + const text = mockAdapterResponses[callIndex] ?? 'default mock response' + callIndex++ + return { + id: `resp-${callIndex}`, + content: [{ type: 'text', text }], + model: options.model ?? 'mock-model', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 20 }, + } + }, + async *stream() { + yield { type: 'done' as const, data: {} } + }, + } + }, +})) + +function agentConfig(name: string, systemPrompt?: string): AgentConfig { + return { + name, + model: 'mock-model', + provider: 'openai', + systemPrompt: systemPrompt ?? `You are ${name}.`, + } +} + +function teamCfg(agents?: AgentConfig[]): TeamConfig { + return { + name: 'test-team', + agents: agents ?? [ + agentConfig('researcher', 'You research topics and analyze data'), + agentConfig('coder', 'You write TypeScript code'), + ], + sharedMemory: true, + } +} + +describe('runTeam short-circuit', () => { + beforeEach(() => { + mockAdapterResponses = [] + }) + + it('short-circuits simple goals to a single agent (no coordinator)', async () => { + // Only ONE response needed — no coordinator decomposition or synthesis + mockAdapterResponses = ['Direct answer without coordination'] + + const events: OrchestratorEvent[] = [] + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + onProgress: (e) => events.push(e), + }) + const team = oma.createTeam('t', teamCfg()) + + const result = await oma.runTeam(team, 'Say hello') + + expect(result.success).toBe(true) + expect(result.agentResults.size).toBe(1) + // Should NOT have coordinator results — short-circuit bypasses it + expect(result.agentResults.has('coordinator')).toBe(false) + }) + + it('emits progress events for short-circuit path', async () => { + mockAdapterResponses = ['done'] + + const events: OrchestratorEvent[] = [] + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + onProgress: (e) => events.push(e), + }) + const team = oma.createTeam('t', teamCfg()) + + await oma.runTeam(team, 'Say hello') + + const types = events.map(e => e.type) + expect(types).toContain('agent_start') + expect(types).toContain('agent_complete') + }) + + it('uses coordinator for complex goals', async () => { + // Complex goal — needs coordinator decomposition + execution + synthesis + mockAdapterResponses = [ + '```json\n[{"title": "Research", "description": "Research the topic", "assignee": "researcher"}]\n```', + 'Research results', + 'Final synthesis', + ] + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const team = oma.createTeam('t', teamCfg()) + + const result = await oma.runTeam( + team, + 'First research AI safety best practices, then write a comprehensive guide with code examples', + ) + + expect(result.success).toBe(true) + // Complex goal should go through coordinator + expect(result.agentResults.has('coordinator')).toBe(true) + }) + + it('selects best-matching agent for simple goals', async () => { + mockAdapterResponses = ['code result'] + + const events: OrchestratorEvent[] = [] + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + onProgress: (e) => events.push(e), + }) + const team = oma.createTeam('t', teamCfg()) + + await oma.runTeam(team, 'Write TypeScript code') + + // Should pick 'coder' agent based on keyword match + const startEvent = events.find(e => e.type === 'agent_start') + expect(startEvent?.agent).toBe('coder') + }) +})