File size: 14,258 Bytes
391c43e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 | // lib/llm/coordinator.ts
// Multi-agent coordinator β intercepts `agent` commands from the orchestrator loop
// and spawns child AgentLoops. Passes all other tool calls through to the inner executor.
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>;
/**
* Build a provider/executor scoped to a child agent's progress reporter.
* Without these, children share the parent's instances β whose progress
* events (streaming deltas, tool statuses) leak unwrapped into the main
* UI channel instead of being nested under agent_progress.
*/
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;
/** Child-loop events forwarded to the parent UI, wrapped in agent_progress. */
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();
}
/**
* Returns a ToolExecutor that intercepts `agent` commands and spawns child loops,
* passing everything else through to the inner executor.
*/
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') {
// Dedup: some models emit the same agent command as multiple tool calls
// in one turn. Only dedup within the same turn β an identical
// re-delegation in a later turn is legitimate.
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);
// Don't record errored batches as executed β a retry must run, not
// get a false "already executed" response.
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 helpers ---
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 '';
}
}
/**
* Parse an agent command string into typed prompts.
* Accepts both `agent` (primary) and `delegate` (backward compat alias).
*
* Forms:
* agent task "do X" "do Y" β 2 parallel task agents
* agent explore "single question" β 1 agent
* agent explore unquoted text β 1 agent (backward compat)
* agent type << 'EOF'\nprompt\nEOF β 1 agent (heredoc)
*/
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();
// Heredoc: agent type << 'EOF'\nprompt\nEOF β always single agent
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() }];
// Inline: agent type followed by prompt(s)
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();
// Extract top-level quoted strings using a state machine.
// Naive regex fails because HTML/code content contains inner quotes.
const topLevelPrompts = this.extractTopLevelQuotedStrings(rest);
if (topLevelPrompts.length >= 2) {
return topLevelPrompts.map(prompt => ({ type, prompt }));
}
if (topLevelPrompts.length === 1) {
return [{ type, prompt: topLevelPrompts[0] }];
}
// Unquoted text β single agent
return [{ type, prompt: rest }];
}
/**
* Extract top-level quoted strings from an agent command's argument portion.
* Uses a state machine to handle nested quotes in HTML/code content.
* Only splits on quotes that start after whitespace (top-level boundary).
*/
private extractTopLevelQuotedStrings(input: string): string[] {
const prompts: string[] = [];
let i = 0;
while (i < input.length) {
// Skip whitespace between prompts
while (i < input.length && /\s/.test(input[i])) i++;
if (i >= input.length) break;
const quoteChar = input[i];
if (quoteChar !== '"' && quoteChar !== "'") {
// Not a quoted string β this is unquoted trailing text, consume rest
prompts.push(input.slice(i).trim());
break;
}
// Found opening quote β scan for the matching UNESCAPED closing quote
// at the same level (the next quote char preceded by whitespace or at end).
// Strategy: find the closing quote that is followed by either:
// - end of string
// - whitespace then another quote char (next prompt)
// - whitespace then end of string
i++; // skip opening quote
const start = i;
let foundClosing = false;
while (i < input.length) {
const ch = input[i];
if (ch === '\\') { i += 2; continue; } // skip escaped chars
// Track heredoc-style content (<<) β skip until delimiter
if (ch === '<' && i + 1 < input.length && input[i + 1] === '<') {
// Inside heredoc β skip to matching EOF/delimiter
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) {
// Check if this is the closing top-level quote:
// It should be followed by whitespace+quote, whitespace+end, or end
const after = input.slice(i + 1).trimStart();
if (after.length === 0 || after[0] === '"' || after[0] === "'") {
// This is the closing quote
prompts.push(input.slice(start, i).trim());
i++; // skip closing quote
foundClosing = true;
break;
}
// Otherwise it's an inner quote β keep scanning
}
i++;
}
// If we ran off the end without finding a closing quote, take what we have
if (!foundClosing && i >= input.length) {
const content = input.slice(start).trim();
if (content) prompts.push(content);
}
}
return prompts;
}
/**
* Run one child agent. Creates a fresh AgentLoop with restricted config.
*/
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,
});
// One scoped reporter for the child's loop, provider, and executor β
// everything the child emits is filtered and wrapped in agent_progress.
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,
});
},
};
// Create fresh context for child. The child's system prompt differs from the
// orchestrator's, so the parent's getFreshContext must not leak into it.
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,
// Inner-level executor β children cannot spawn sub-agents
executor: this.config.createChildExecutor?.(childProgress) ?? this.innerExecutor,
context: childContext,
progress: childProgress,
cost: this.config.cost, // Shared β accumulates into parent
});
this.runningChildren.add(childLoop);
let result: AgentLoopResult;
try {
result = await childLoop.run(prompt);
} finally {
this.runningChildren.delete(childLoop);
}
// Extract last assistant message as the body
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 };
}
/**
* Run sub-agents and combine their results into a single string for the parent context.
*/
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 '';
}
}
|