| |
| |
| |
|
|
| import { AgentLoop } from './core/agent-loop'; |
| import { ContextManagerImpl } from './core/context-manager'; |
| import { track } from '@/lib/telemetry'; |
| import type { |
| ToolExecutor, |
| ToolCall, |
| ToolResult, |
| ToolExecContext, |
| ProviderAdapter, |
| ProgressReporter, |
| CostTracker, |
| AgentLoopConfig, |
| CompactionConfig, |
| AgentLoopResult, |
| } from './core/types'; |
|
|
| export interface CoordinatorConfig { |
| innerExecutor: ToolExecutor; |
| provider: ProviderAdapter; |
| progress: ProgressReporter; |
| cost: CostTracker; |
| projectId: string; |
| chatMode: boolean; |
| compactionConfig: CompactionConfig; |
| buildSystemPrompt: (agentType: string) => Promise<string>; |
| |
| |
| |
| |
| |
| |
| createChildProvider?: (progress: ProgressReporter) => ProviderAdapter; |
| createChildExecutor?: (progress: ProgressReporter) => ToolExecutor; |
| } |
|
|
| export class MultiAgentCoordinator { |
| private innerExecutor: ToolExecutor; |
| private runningChildren = new Set<AgentLoop>(); |
| private stopped = false; |
| private lastAgentKey = ''; |
| private lastAgentTurnId: number | null = null; |
|
|
| |
| private static readonly FORWARDED_CHILD_EVENTS = new Set([ |
| 'tool_status', 'tool_result', 'error', 'stopped', 'nudge', 'exit_reason', |
| ]); |
|
|
| private static readonly MAX_PARALLEL_AGENTS = 8; |
|
|
| constructor(private config: CoordinatorConfig) { |
| this.innerExecutor = config.innerExecutor; |
| } |
|
|
| stop(): void { |
| this.stopped = true; |
| for (const child of this.runningChildren) { |
| child.stop(); |
| } |
| this.runningChildren.clear(); |
| } |
|
|
| |
| |
| |
| |
| createWrappedExecutor(): ToolExecutor { |
| return { |
| getDefinitions: (agentType: string) => this.innerExecutor.getDefinitions(agentType), |
| execute: async (toolCall: ToolCall, context: ToolExecContext): Promise<ToolResult> => { |
| const cmd = this.extractCmd(toolCall); |
| const agents = this.parseAgentCommand(cmd); |
| if (agents && context.agentType === 'orchestrator') { |
| |
| |
| |
| const key = agents.map(a => `${a.type}:${a.prompt.trim()}`).sort().join('|'); |
| const turnId = context.turnId ?? null; |
| if (key === this.lastAgentKey && turnId !== null && turnId === this.lastAgentTurnId) { |
| return { |
| tool_call_id: toolCall.id, |
| content: '(Duplicate agent call β already executed this turn. Results are above.)', |
| success: true, |
| }; |
| } |
| const result = await this.runAgents(agents); |
| |
| |
| if (result.startsWith('Error:')) { |
| this.lastAgentKey = ''; |
| this.lastAgentTurnId = null; |
| } else { |
| this.lastAgentKey = key; |
| this.lastAgentTurnId = turnId; |
| } |
| return { tool_call_id: toolCall.id, content: result, success: true }; |
| } |
| this.lastAgentKey = ''; |
| this.lastAgentTurnId = null; |
| return this.innerExecutor.execute(toolCall, context); |
| }, |
| }; |
| } |
|
|
| |
|
|
| private extractCmd(toolCall: ToolCall): string { |
| try { |
| const args = JSON.parse(toolCall.function.arguments); |
| const cmd = args.command ?? args.cmd; |
| return typeof cmd === 'string' ? cmd : ''; |
| } catch { |
| return ''; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| private parseAgentCommand(rawCmd: string): { type: string; prompt: string }[] | null { |
| if (!rawCmd) return null; |
| const start = rawCmd.trimStart(); |
| if (!start.startsWith('agent ') && !start.startsWith('delegate ')) return null; |
| const trimmed = rawCmd.trim(); |
|
|
| |
| const heredocRe = /^(?:agent|delegate)\s+(explore|task|plan)\s*<<-?\s*['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\2\s*$/; |
| const hm = trimmed.match(heredocRe); |
| if (hm) return [{ type: hm[1], prompt: hm[3].trim() }]; |
|
|
| |
| const inlineRe = /^(?:agent|delegate)\s+(explore|task|plan)\s+([\s\S]+)$/; |
| const im = trimmed.match(inlineRe); |
| if (!im) return null; |
|
|
| const type = im[1]; |
| const rest = im[2].trim(); |
|
|
| |
| |
| const topLevelPrompts = this.extractTopLevelQuotedStrings(rest); |
|
|
| if (topLevelPrompts.length >= 2) { |
| return topLevelPrompts.map(prompt => ({ type, prompt })); |
| } |
|
|
| if (topLevelPrompts.length === 1) { |
| return [{ type, prompt: topLevelPrompts[0] }]; |
| } |
|
|
| |
| return [{ type, prompt: rest }]; |
| } |
|
|
| |
| |
| |
| |
| |
| private extractTopLevelQuotedStrings(input: string): string[] { |
| const prompts: string[] = []; |
| let i = 0; |
|
|
| while (i < input.length) { |
| |
| while (i < input.length && /\s/.test(input[i])) i++; |
| if (i >= input.length) break; |
|
|
| const quoteChar = input[i]; |
| if (quoteChar !== '"' && quoteChar !== "'") { |
| |
| prompts.push(input.slice(i).trim()); |
| break; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| i++; |
| const start = i; |
| let foundClosing = false; |
|
|
| while (i < input.length) { |
| const ch = input[i]; |
| if (ch === '\\') { i += 2; continue; } |
|
|
| |
| if (ch === '<' && i + 1 < input.length && input[i + 1] === '<') { |
| |
| const heredocMatch = input.slice(i).match(/^<<-?\s*['"]?(\w+)['"]?\s*\n/); |
| if (heredocMatch) { |
| const delimiter = heredocMatch[1]; |
| const endIdx = input.indexOf('\n' + delimiter, i + heredocMatch[0].length); |
| if (endIdx !== -1) { |
| i = endIdx + delimiter.length + 1; |
| continue; |
| } |
| } |
| } |
|
|
| if (ch === quoteChar) { |
| |
| |
| const after = input.slice(i + 1).trimStart(); |
| if (after.length === 0 || after[0] === '"' || after[0] === "'") { |
| |
| prompts.push(input.slice(start, i).trim()); |
| i++; |
| foundClosing = true; |
| break; |
| } |
| |
| } |
|
|
| i++; |
| } |
|
|
| |
| if (!foundClosing && i >= input.length) { |
| const content = input.slice(start).trim(); |
| if (content) prompts.push(content); |
| } |
| } |
|
|
| return prompts; |
| } |
|
|
| |
| |
| |
| private async runAgentChild( |
| type: string, |
| prompt: string, |
| agentIndex: number |
| ): Promise<{ type: string; prompt: string; body: string }> { |
| if (this.stopped) { |
| return { type, prompt, body: '(Cancelled β parent stopped)' }; |
| } |
|
|
| track('agent_spawned', { type }); |
|
|
| const promptLabel = prompt.length > 80 ? prompt.slice(0, 80) + '...' : prompt; |
| const startedAt = Date.now(); |
| this.config.progress.onEvent('agent_progress', { |
| type, |
| event: 'agent_start', |
| agentIndex, |
| agentPrompt: promptLabel, |
| }); |
|
|
| |
| |
| const childProgress: ProgressReporter = { |
| onEvent: (event: string, data?: Record<string, unknown>) => { |
| if (!MultiAgentCoordinator.FORWARDED_CHILD_EVENTS.has(event)) return; |
| this.config.progress.onEvent('agent_progress', { |
| type, |
| event, |
| data, |
| agentIndex, |
| agentPrompt: promptLabel, |
| }); |
| }, |
| }; |
|
|
| |
| |
| const childContext = new ContextManagerImpl({ |
| ...this.config.compactionConfig, |
| getFreshContext: undefined, |
| }); |
| const systemPrompt = await this.config.buildSystemPrompt(type); |
| childContext.setSystemPrompt(systemPrompt); |
|
|
| const childConfig: AgentLoopConfig = { |
| maxIterations: type === 'explore' ? 5 : type === 'plan' ? 10 : 30, |
| maxNudges: type === 'explore' ? 1 : 2, |
| maxDuplicateToolCalls: 3, |
| agentType: type, |
| isReadOnly: this.config.chatMode || type === 'explore' || type === 'plan', |
| }; |
|
|
| const childLoop = new AgentLoop({ |
| config: childConfig, |
| provider: this.config.createChildProvider?.(childProgress) ?? this.config.provider, |
| |
| executor: this.config.createChildExecutor?.(childProgress) ?? this.innerExecutor, |
| context: childContext, |
| progress: childProgress, |
| cost: this.config.cost, |
| }); |
|
|
| this.runningChildren.add(childLoop); |
| let result: AgentLoopResult; |
| try { |
| result = await childLoop.run(prompt); |
| } finally { |
| this.runningChildren.delete(childLoop); |
| } |
|
|
| |
| const messages = childContext.getMessages(); |
| const lastAssistant = [...messages].reverse().find(m => m.role === 'assistant'); |
| let rawResult = ''; |
| if (lastAssistant) { |
| rawResult = typeof lastAssistant.content === 'string' |
| ? lastAssistant.content |
| : JSON.stringify(lastAssistant.content); |
| } |
| const maxLen = 2500; |
| const body = rawResult.length > maxLen |
| ? rawResult.slice(0, maxLen) + '\n... (truncated)' |
| : rawResult; |
|
|
| this.config.progress.onEvent('agent_progress', { |
| type, |
| event: 'agent_done', |
| agentIndex, |
| agentPrompt: promptLabel, |
| data: { |
| body: body.slice(0, 120), |
| success: result.success, |
| elapsed: Math.round((Date.now() - startedAt) / 1000), |
| }, |
| }); |
|
|
| return { type, prompt, body }; |
| } |
|
|
| |
| |
| |
| private async runAgents(agents: { type: string; prompt: string }[]): Promise<string> { |
| if (agents.length > MultiAgentCoordinator.MAX_PARALLEL_AGENTS) { |
| const cap = MultiAgentCoordinator.MAX_PARALLEL_AGENTS; |
| return `Error: Too many parallel agents (${agents.length}). Maximum is ${cap}. Break the work into smaller batches.`; |
| } |
|
|
| if (agents.length === 1) { |
| const { type, prompt } = agents[0]; |
| const r = await this.runAgentChild(type, prompt, 1); |
| const label = prompt.length > 120 ? prompt.slice(0, 120) + '...' : prompt; |
| return `[agent ${type} β done] "${label}"\n\n${r.body || '(no result)'}\n\n${this.getAgentFooter(type)}`; |
| } |
|
|
| const settled = await Promise.allSettled( |
| agents.map(({ type, prompt }, i) => this.runAgentChild(type, prompt, i + 1)) |
| ); |
|
|
| const type = agents[0].type; |
| const sections: string[] = []; |
|
|
| for (let i = 0; i < settled.length; i++) { |
| const s = settled[i]; |
| const label = agents[i].prompt.length > 100 |
| ? agents[i].prompt.slice(0, 100) + '...' |
| : agents[i].prompt; |
|
|
| if (s.status === 'fulfilled') { |
| sections.push(`[${i + 1}/${agents.length}] "${label}"\n${s.value.body || '(no result)'}`); |
| } else { |
| sections.push(`[${i + 1}/${agents.length}] "${label}"\nError: ${s.reason}`); |
| } |
| } |
|
|
| return `[agent ${type} β done] ${agents.length} agents completed\n\n${sections.join('\n\n')}\n\n${this.getAgentFooter(type)}`; |
| } |
|
|
| private getAgentFooter(type: string): string { |
| if (type === 'explore') return 'Use these findings to inform your next steps. The explore agent was read-only β no files were modified.'; |
| if (type === 'plan') return 'This is an analysis only β no files were modified. Implement the changes yourself based on this plan.'; |
| if (type === 'task') return 'This sub-task is done and its files were modified. Do not repeat this same agent call.'; |
| return ''; |
| } |
| } |
|
|