From cfbbd24601f788a8051c22fa389debae3e724b42 Mon Sep 17 00:00:00 2001 From: EchoOfZion Date: Mon, 6 Apr 2026 19:24:56 +0900 Subject: [PATCH] feat: skip coordinator for simple goals in runTeam() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a goal is short (<200 chars) and contains no multi-step or coordination signals, runTeam() now dispatches directly to the best-matching agent — skipping the coordinator decomposition and synthesis round-trips. This saves ~2 LLM calls worth of tokens and latency for genuinely simple goals. Complexity detection uses regex patterns for sequencing markers (first...then, step N, numbered lists), coordination language (collaborate, coordinate, work together), parallel execution signals, and multi-deliverable patterns. Agent selection reuses the same keyword-affinity scoring as the capability-match scheduler strategy to pick the most relevant agent from the team roster. - Add isSimpleGoal() and selectBestAgent() (exported for testing) - Add 35 unit tests covering heuristic edge cases and integration - Update existing runTeam tests to use complex goals Co-Authored-By: Claude --- src/index.ts | 2 +- src/orchestrator/orchestrator.ts | 149 ++++++++++++++++ tests/orchestrator.test.ts | 4 +- tests/short-circuit.test.ts | 290 +++++++++++++++++++++++++++++++ 4 files changed, 442 insertions(+), 3 deletions(-) create mode 100644 tests/short-circuit.test.ts 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') + }) +})