feat: skip coordinator for simple goals in runTeam()

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 <noreply@anthropic.com>
This commit is contained in:
EchoOfZion 2026-04-06 19:24:56 +09:00
parent 607ba57a69
commit cfbbd24601
4 changed files with 442 additions and 3 deletions

View File

@ -54,7 +54,7 @@
// Orchestrator (primary entry point) // 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 { Scheduler } from './orchestrator/scheduler.js'
export type { SchedulingStrategy } from './orchestrator/scheduler.js' export type { SchedulingStrategy } from './orchestrator/scheduler.js'

View File

@ -73,6 +73,123 @@ const ZERO_USAGE: TokenUsage = { input_tokens: 0, output_tokens: 0 }
const DEFAULT_MAX_CONCURRENCY = 5 const DEFAULT_MAX_CONCURRENCY = 5
const DEFAULT_MODEL = 'claude-opus-4-6' 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 // Internal helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -699,6 +816,38 @@ export class OpenMultiAgent {
async runTeam(team: Team, goal: string, options?: { abortSignal?: AbortSignal }): Promise<TeamRunResult> { async runTeam(team: Team, goal: string, options?: { abortSignal?: AbortSignal }): Promise<TeamRunResult> {
const agentConfigs = team.getAgents() 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<string, AgentRunResult>()
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 // Step 1: Coordinator decomposes goal into tasks
// ------------------------------------------------------------------ // ------------------------------------------------------------------

View File

@ -215,7 +215,7 @@ describe('OpenMultiAgent', () => {
}) })
const team = oma.createTeam('t', teamCfg()) 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) expect(result.success).toBe(true)
// Should have coordinator result // Should have coordinator result
@ -233,7 +233,7 @@ describe('OpenMultiAgent', () => {
const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) const oma = new OpenMultiAgent({ defaultModel: 'mock-model' })
const team = oma.createTeam('t', teamCfg()) 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) expect(result.success).toBe(true)
}) })

290
tests/short-circuit.test.ts Normal file
View File

@ -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<LLMResponse> {
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')
})
})