| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { Agent, AgentType, agentRegistry } from './agent'; |
| import { vfs } from '@/lib/vfs'; |
| import { checkpointManager, Checkpoint } from '@/lib/vfs/checkpoint'; |
| import { saveManager } from '@/lib/vfs/save-manager'; |
| import { configManager } from '@/lib/config/storage'; |
| import { getProvider, getModelContextLength } from '@/lib/llm/providers/registry'; |
| import type { ProviderId } from '@/lib/llm/providers/types'; |
| import { CostCalculator } from './cost-calculator'; |
| import { ToolCall, UsageInfo, ContentBlock } from './types'; |
| import { logger } from '@/lib/utils'; |
| import { buildFileTree, ReasoningDetail } from './streaming-parser'; |
| import { drainRuntimeErrors, formatRuntimeErrors, resetRuntimeErrors } from '@/lib/preview/runtime-errors'; |
| import { buildSystemPrompt, buildProjectContext, buildCompactionPrompt } from './system-prompt'; |
| import { withInterviewAgenda } from '@/lib/interview/agenda'; |
| import { getInterviewTemplate } from '@/lib/interview/templates'; |
| import { buildCompletionFeedback, summarizeCompletion, type ItemCheckResult } from '@/lib/interview/completion'; |
| import type { InterviewTemplate } from '@/lib/interview/types'; |
| import { runStructuredJudge } from '@/lib/testing/judge'; |
| import { runAssertions } from '@/lib/testing/assertion-runner'; |
| import { evaluateRelevantSkills } from './skill-evaluator'; |
| import { skillsService } from '@/lib/vfs/skills'; |
| import { track } from '@/lib/telemetry'; |
| import { extractToolAnalytics, extractSkillRead, bucketInterviewTemplateId } from '@/lib/telemetry/tool-analytics'; |
| import type { ServerOrchestratorContext } from '@/lib/server-generate/types'; |
| import type { ApprovalRequest, ApprovalOutcome, PermissionMode, GateDecision } from './permissions'; |
| import type { ResolvedAssignment, ModelRef } from '@/lib/llm/models/assignment'; |
| import { transcribeAudio } from '@/lib/llm/transcribe'; |
| import { generateImage } from '@/lib/llm/image-gen'; |
|
|
| import { AgentLoop } from './core/agent-loop'; |
| import { ContextManagerImpl } from './core/context-manager'; |
| import { OswsProviderAdapter, PausableApiError } from './provider-adapter'; |
| import { OswsToolExecutor } from './tool-executor'; |
| import { MultiAgentCoordinator } from './coordinator'; |
| import type { Message, ProgressReporter, CostTracker, AgentLoopConfig, AgentLoopResult, CompactionConfig } from './core/types'; |
|
|
| |
| |
| |
|
|
| export interface AgentMessage { |
| role: 'system' | 'user' | 'assistant' | 'tool'; |
| content: string | ContentBlock[]; |
| tool_calls?: ToolCall[]; |
| tool_call_id?: string; |
| reasoning_details?: ReasoningDetail[]; |
| ui_metadata?: { |
| checkpointId?: string; |
| cost?: number; |
| usage?: UsageInfo; |
| isSyntheticError?: boolean; |
| projectContext?: string; |
| displayContent?: string | ContentBlock[]; |
| isCompactSummary?: boolean; |
| focusContext?: { domPath: string; snippet: string }; |
| semanticBlocks?: Array<{ name: string; domPath: string; position: string; description: string }>; |
| attachedFiles?: Array<{ name: string }>; |
| }; |
| } |
|
|
| export interface PendingImage { |
| id: string; |
| data: string; |
| mediaType: string; |
| preview: string; |
| } |
|
|
| export interface PendingAudio { |
| id: string; |
| data: string; |
| format: 'wav'; |
| durationMs: number; |
| transcript?: string; |
| } |
|
|
| export interface PendingFile { |
| id: string; |
| name: string; |
| content: string; |
| size: number; |
| } |
|
|
| export interface ConversationNode { |
| id: string; |
| agent_type: AgentType; |
| messages: AgentMessage[]; |
| metadata: { |
| started_at: number; |
| completed_at?: number; |
| cost: number; |
| status: 'running' | 'completed' | 'failed'; |
| }; |
| } |
|
|
| export interface ContextBreakdown { |
| systemPromptChars: number; |
| userMessageChars: number; |
| assistantTextChars: number; |
| toolCallArgChars: number; |
| toolResultChars: number; |
| reasoningChars: number; |
| totalChars: number; |
| } |
|
|
| export interface MultiAgentResult { |
| success: boolean; |
| summary: string; |
| |
| exitReason?: string; |
| conversation: ConversationNode[]; |
| totalCost: number; |
| totalUsage: UsageInfo; |
| checkpointId?: string; |
| toolCount?: number; |
| turnCount?: number; |
| apiErrorCount?: number; |
| contextBreakdowns?: ContextBreakdown[]; |
| } |
|
|
| |
| |
| |
|
|
| export class MultiAgentOrchestrator { |
| private projectId: string; |
| private rootAgent: Agent; |
| private conversations: Map<string, ConversationNode> = new Map(); |
| private currentConversationId: string; |
| private onProgress?: (message: string, step?: unknown) => void; |
| private chatMode: boolean; |
| private model?: string; |
| private assignment?: ResolvedAssignment; |
| private serverContext: ServerOrchestratorContext | null; |
| private interviewTemplateId?: string; |
| private interviewTemplate?: InterviewTemplate; |
| private onApprovalNeeded?: (req: ApprovalRequest) => Promise<ApprovalOutcome>; |
| private permissionMode?: PermissionMode; |
| private permissionOverrides?: Record<string, GateDecision>; |
|
|
| private stopped = false; |
| private abortController = new AbortController(); |
| private pauseResolve: (() => void) | null = null; |
|
|
| private totalCost = 0; |
| private totalUsage: UsageInfo = { promptTokens: 0, completionTokens: 0, totalTokens: 0, cost: 0 }; |
| private taskCost = 0; |
| private taskTokens = 0; |
| private toolCallCount = 0; |
| private turnCount = 0; |
| private apiErrorCount = 0; |
| private contextBreakdowns: ContextBreakdown[] = []; |
|
|
| private static readonly COMPACTION_THRESHOLD = 0.60; |
| private static readonly RECENT_KEEP_RATIO = 0.20; |
| private static readonly SUMMARY_TOKEN_RATIO = 0.10; |
| private static readonly DEFAULT_COMPACTION_LIMIT = 128000; |
|
|
| constructor( |
| projectId: string, |
| agentType: AgentType = 'orchestrator', |
| onProgress?: (message: string, step?: unknown) => void, |
| options?: { chatMode?: boolean; model?: string; assignment?: ResolvedAssignment; serverContext?: ServerOrchestratorContext; interviewTemplateId?: string; interviewTemplate?: InterviewTemplate; onApprovalNeeded?: (req: ApprovalRequest) => Promise<ApprovalOutcome>; permissionMode?: PermissionMode; permissionOverrides?: Record<string, GateDecision> } |
| ) { |
| this.projectId = projectId; |
| this.onProgress = onProgress; |
| this.chatMode = options?.chatMode ?? false; |
| this.model = options?.model; |
| this.assignment = options?.assignment; |
| this.serverContext = options?.serverContext ?? null; |
| this.interviewTemplateId = options?.interviewTemplateId; |
| this.interviewTemplate = options?.interviewTemplate; |
| this.onApprovalNeeded = options?.onApprovalNeeded; |
| this.permissionMode = options?.permissionMode; |
| this.permissionOverrides = options?.permissionOverrides; |
|
|
| const agent = agentRegistry.get(agentType); |
| if (!agent) throw new Error(`Agent type "${agentType}" not found`); |
| this.rootAgent = agent; |
|
|
| this.currentConversationId = this.createConversation(agentType); |
| } |
|
|
| |
| |
| |
| setAssignment(assignment: ResolvedAssignment): void { |
| this.assignment = assignment; |
| if (assignment?.agent?.model) this.model = assignment.agent.model; |
| } |
|
|
| continue(): void { |
| if (this.pauseResolve) { |
| |
| |
| |
| this.pauseResolve(); |
| this.pauseResolve = null; |
| } |
| } |
|
|
| stop(): void { |
| this.stopped = true; |
| this.abortController.abort(); |
| if (this.pauseResolve) { |
| this.pauseResolve(); |
| this.pauseResolve = null; |
| } |
| logger.info('[MultiAgentOrchestrator] Execution stopped by user'); |
| } |
|
|
| importConversation(messages: AgentMessage[]): void { |
| const conversation = this.conversations.get(this.currentConversationId); |
| if (!conversation) throw new Error('Cannot import conversation: root conversation not found'); |
| conversation.messages = messages; |
| logger.info(`[MultiAgentOrchestrator] Imported ${messages.length} conversation messages`); |
| } |
|
|
| async execute( |
| userPrompt: string, |
| options?: { |
| images?: Array<{ data: string; mediaType: string }>; |
| audio?: Array<{ data: string; format: 'wav'; transcript?: string }>; |
| voiceInput?: ModelRef | 'agent' | 'browser' | null; |
| files?: Array<{ name: string; content: string }>; |
| focusContext?: { domPath: string; snippet: string }; |
| semanticBlocks?: Array<{ name: string; domPath: string; position: string; description: string }>; |
| displayPrompt?: string; |
| } |
| ): Promise<MultiAgentResult> { |
| logger.info('[MultiAgentOrchestrator] Starting execution', { agent: this.rootAgent.type }); |
|
|
| this.stopped = false; |
| this.abortController = new AbortController(); |
| this.taskCost = 0; |
| this.taskTokens = 0; |
|
|
| |
| const conversation = this.conversations.get(this.currentConversationId)!; |
| conversation.metadata.status = 'running'; |
| while (conversation.messages.length > 0) { |
| const last = conversation.messages[conversation.messages.length - 1]; |
| if (last.role === 'user' && typeof last.content === 'string' && last.content.includes('Before finishing, run the status command')) { |
| conversation.messages.pop(); |
| } else { |
| break; |
| } |
| } |
|
|
| try { |
| |
| let fileTreeStr: string | undefined; |
| try { |
| const files = await this.getVFS().listDirectory(this.projectId, '/'); |
| if (files.length > 0) fileTreeStr = buildFileTree(files); |
| } catch { } |
|
|
| const serverCtxMeta = this.getVFS().getServerContextMetadata(); |
| const modelSupportsTools = this.checkModelSupportsTools(); |
| let systemPrompt = await buildSystemPrompt(this.chatMode, serverCtxMeta, this.projectId, this.rootAgent.type, modelSupportsTools, this.isImageGenAvailable(), this.isWebSearchAvailable()); |
| if (this.rootAgent.type === 'interview') { |
| systemPrompt = withInterviewAgenda(systemPrompt, this.interviewTemplateId, this.interviewTemplate); |
| } |
|
|
| const hasExistingSystemMessage = conversation.messages.some(m => m.role === 'system'); |
| if (!hasExistingSystemMessage) { |
| this.addMessage(this.currentConversationId, { role: 'system', content: systemPrompt }); |
| } |
|
|
| let projectContext = ''; |
| if (!hasExistingSystemMessage) { |
| projectContext = await buildProjectContext(fileTreeStr, serverCtxMeta); |
| } |
|
|
| |
| let skillHint = ''; |
| try { |
| const evalEnabled = await skillsService.isEvaluationEnabled(); |
| if (evalEnabled && this.rootAgent.type === 'orchestrator') { |
| const skillsMeta = await skillsService.getEnabledSkillsMetadata(); |
| if (skillsMeta.length > 0) { |
| const { provider, apiKey, model } = this.getProviderConfig(); |
| const evalResult = await evaluateRelevantSkills(userPrompt, skillsMeta, fileTreeStr || '', provider, apiKey, model); |
| if (evalResult.usage) { |
| const cost = CostCalculator.calculateCost(evalResult.usage, evalResult.usage.provider, evalResult.usage.model, true); |
| this.totalCost += cost; |
| this.taskCost += cost; |
| this.totalUsage.completionTokens += evalResult.usage.completionTokens; |
| this.totalUsage.totalTokens += evalResult.usage.totalTokens; |
| this.taskTokens += evalResult.usage.totalTokens; |
| this.getConfig().updateSessionCost({ ...evalResult.usage, cost }, cost); |
| } |
| this.onProgress?.('skill_evaluation', { skills: skillsMeta.map(s => s.id), matched: evalResult.skillIds, usage: evalResult.usage }); |
| if (evalResult.skillIds.length > 0) { |
| skillHint = `Skill evaluation: read ${evalResult.skillIds.map(s => `/.skills/${s}.md`).join(', ')} before proceeding.\n\n`; |
| } |
| } |
| } |
| } catch { } |
|
|
| |
| const messagePrefix = (projectContext ? projectContext + '\n\n' : '') + skillHint; |
| const cleanPrompt = options?.displayPrompt ?? userPrompt; |
|
|
| |
| |
| |
| let fileText = ''; |
| if (options?.files?.length) { |
| for (const f of options.files) { |
| fileText += `\n\n--- Attached file: ${f.name} ---\n${f.content}`; |
| } |
| } |
| |
| |
| |
| const modelBlocks: ContentBlock[] = []; |
| const displayBlocks: ContentBlock[] = []; |
| if (options?.images?.length) { |
| for (const img of options.images) { |
| const block: ContentBlock = { |
| type: 'image_url' as const, |
| image_url: { url: `data:${img.mediaType};base64,${img.data}` }, |
| }; |
| modelBlocks.push(block); |
| displayBlocks.push(block); |
| } |
| } |
|
|
| |
| |
| |
| |
| let voiceText = ''; |
| let voiceFailed = false; |
| if (options?.audio?.length) { |
| const vi = options.voiceInput; |
| const agent = this.assignment?.agent; |
| const passToAgent = vi === 'agent' |
| || !!(vi && typeof vi === 'object' && agent && vi.provider === agent.provider && vi.model === agent.model); |
| for (const clip of options.audio) { |
| const block: ContentBlock = { |
| type: 'input_audio' as const, |
| input_audio: { data: clip.data, format: clip.format }, |
| }; |
| if (passToAgent) { |
| modelBlocks.push(block); |
| displayBlocks.push(block); |
| continue; |
| } |
| if (clip.data) displayBlocks.push(block); |
| let text = clip.transcript?.trim() ?? ''; |
| if (!text && vi && typeof vi === 'object') { |
| try { |
| text = await transcribeAudio({ data: clip.data, format: clip.format }, vi); |
| } catch (e) { |
| logger.warn('[MultiAgentOrchestrator] voice transcription failed', e); |
| voiceFailed = true; |
| } |
| } |
| if (text) voiceText += `${text}\n\n`; |
| } |
| } |
|
|
| const userText = messagePrefix + voiceText + userPrompt + fileText; |
| |
| const displayText = voiceText |
| + (voiceFailed ? '[Voice transcription failed — check the voice-input model for this project]\n\n' : '') |
| + cleanPrompt; |
|
|
| const userContent: string | ContentBlock[] = modelBlocks.length > 0 |
| ? [{ type: 'text' as const, text: userText }, ...modelBlocks] |
| : userText; |
| const displayContent: string | ContentBlock[] = displayBlocks.length > 0 |
| ? [{ type: 'text' as const, text: displayText }, ...displayBlocks] |
| : displayText; |
|
|
| this.addMessage(this.currentConversationId, { |
| role: 'user', |
| content: userContent, |
| ui_metadata: { |
| displayContent, |
| ...(projectContext ? { projectContext } : {}), |
| ...(options?.focusContext ? { focusContext: options.focusContext } : {}), |
| ...(options?.semanticBlocks?.length ? { semanticBlocks: options.semanticBlocks } : {}), |
| ...(options?.files?.length ? { attachedFiles: options.files.map(f => ({ name: f.name })) } : {}), |
| }, |
| }); |
|
|
| |
| const loopResult = await this.runLoop(); |
|
|
| |
| if (this.rootAgent.type !== 'setup') { |
| await this.recordAutoCheckpoint(`After: ${userPrompt.substring(0, 60)}`); |
| } |
|
|
| return { |
| success: loopResult.success, |
| summary: loopResult.summary, |
| exitReason: loopResult.exitReason, |
| conversation: Array.from(this.conversations.values()), |
| totalCost: this.totalCost, |
| totalUsage: this.totalUsage, |
| toolCount: this.toolCallCount, |
| turnCount: this.turnCount, |
| apiErrorCount: this.apiErrorCount, |
| contextBreakdowns: this.contextBreakdowns.length > 0 ? this.contextBreakdowns : undefined, |
| }; |
| } catch (error) { |
| const errorMessage = error instanceof Error ? error.message : 'Unknown error'; |
| logger.error('[MultiAgentOrchestrator] Execution error:', errorMessage); |
| this.onProgress?.('error', { message: errorMessage, type: 'execution_error', stack: error instanceof Error ? error.stack : undefined }); |
| if (this.rootAgent.type !== 'setup') { |
| await this.recordAutoCheckpoint(`After failure: ${userPrompt.substring(0, 60)}`); |
| } |
| return { |
| success: false, |
| summary: `Error: ${errorMessage}`, |
| exitReason: 'execution_error', |
| conversation: Array.from(this.conversations.values()), |
| totalCost: this.totalCost, |
| totalUsage: this.totalUsage, |
| toolCount: this.toolCallCount, |
| turnCount: this.turnCount, |
| apiErrorCount: this.apiErrorCount, |
| contextBreakdowns: this.contextBreakdowns.length > 0 ? this.contextBreakdowns : undefined, |
| }; |
| } |
| } |
|
|
| |
| |
| |
|
|
| private async runLoop(): Promise<AgentLoopResult> { |
| const conversation = this.conversations.get(this.currentConversationId)!; |
| const compactionLimit = this.resolveCompactionLimit(); |
|
|
| |
| |
| const compactionEnabled = this.assignment?.autoCompact !== false; |
| const compactionConfig: CompactionConfig = { |
| contextLength: compactionLimit, |
| threshold: compactionEnabled ? Math.floor(compactionLimit * MultiAgentOrchestrator.COMPACTION_THRESHOLD) : Infinity, |
| recentKeepRatio: MultiAgentOrchestrator.RECENT_KEEP_RATIO, |
| summaryTokenRatio: MultiAgentOrchestrator.SUMMARY_TOKEN_RATIO, |
| buildCompactionPrompt, |
| |
| |
| getFreshContext: async () => { |
| let fileTreeStr: string | undefined; |
| try { |
| const files = await this.getVFS().listDirectory(this.projectId, '/'); |
| if (files.length > 0) fileTreeStr = buildFileTree(files); |
| } catch { } |
| const serverCtxMeta = this.getVFS().getServerContextMetadata(); |
| const systemPrompt = await buildSystemPrompt( |
| this.chatMode, serverCtxMeta, this.projectId, this.rootAgent.type, this.checkModelSupportsTools(), this.isImageGenAvailable(), this.isWebSearchAvailable() |
| ); |
| const projectContext = await buildProjectContext(fileTreeStr, serverCtxMeta); |
| return { systemPrompt, projectContext }; |
| }, |
| }; |
| const contextManager = new ContextManagerImpl(compactionConfig); |
|
|
| |
| const priorMessages = conversation.messages.slice(0, -1); |
| const lastUserMsg = conversation.messages[conversation.messages.length - 1]; |
| contextManager.importMessages(this.toPortableMessages(priorMessages)); |
|
|
| |
| let skipNextUserMessage = true; |
| contextManager.onMessageAdded = (msg: Message) => { |
| if (skipNextUserMessage && msg.role === 'user') { |
| skipNextUserMessage = false; |
| return; |
| } |
| const agentMsg: AgentMessage = { role: msg.role, content: msg.content as string | ContentBlock[] }; |
| if (msg.tool_calls) agentMsg.tool_calls = msg.tool_calls; |
| if (msg.tool_call_id) agentMsg.tool_call_id = msg.tool_call_id; |
| if (msg.reasoning_details?.length) agentMsg.reasoning_details = msg.reasoning_details; |
| if (msg.metadata?.isCompactSummary) agentMsg.ui_metadata = { isCompactSummary: true }; |
| conversation.messages.push(agentMsg); |
| this.onProgress?.('conversation_message', { message: agentMsg }); |
| }; |
|
|
| |
| |
| |
| contextManager.onMessagesReplaced = (newMessages: Message[]) => { |
| conversation.messages = newMessages.map(msg => { |
| const agentMsg: AgentMessage = { role: msg.role, content: msg.content as string | ContentBlock[] }; |
| if (msg.tool_calls) agentMsg.tool_calls = msg.tool_calls; |
| if (msg.tool_call_id) agentMsg.tool_call_id = msg.tool_call_id; |
| if (msg.reasoning_details?.length) agentMsg.reasoning_details = msg.reasoning_details; |
| if (msg.metadata?.isCompactSummary) agentMsg.ui_metadata = { isCompactSummary: true }; |
| return agentMsg; |
| }); |
| skipNextUserMessage = false; |
| this.onProgress?.('conversation_replaced', { messages: conversation.messages }); |
| |
| |
| |
| track('compaction_fired', { token_count: this.totalUsage.promptTokens }); |
| }; |
|
|
| |
| const progress: ProgressReporter = { |
| onEvent: (event: string, data?: Record<string, unknown>) => { |
| if (event === 'nudge') track('task_nudged'); |
| this.onProgress?.(event, data); |
| }, |
| }; |
|
|
| |
| const costTracker: CostTracker = { |
| record: (usage, provider, model) => { |
| const cost = CostCalculator.calculateCost(usage, provider, model, true); |
| this.totalCost += cost; |
| this.taskCost += cost; |
| |
| this.totalUsage.promptTokens = usage.promptTokens; |
| this.totalUsage.completionTokens += usage.completionTokens; |
| this.totalUsage.totalTokens += usage.totalTokens; |
| this.taskTokens += usage.totalTokens; |
| this.getConfig().updateSessionCost({ ...usage, cost }, cost); |
|
|
| const sessionId = this.getConfig().getCurrentSession?.()?.sessionId; |
| if (!this.projectId.startsWith('test-') && this.rootAgent.type !== 'setup') { |
| this.getVFS().updateProjectCost(this.projectId, { |
| cost, provider: usage.provider || provider || 'unknown', |
| tokenUsage: { input: usage.promptTokens, output: usage.completionTokens }, |
| sessionId, mode: 'absolute', |
| }).catch(err => logger.error('Failed to update project cost:', err)); |
| } |
|
|
| this.onProgress?.('usage', { usage, totalCost: this.totalCost, totalUsage: { ...this.totalUsage }, taskCost: this.taskCost, taskTokens: this.taskTokens }); |
| }, |
| getTurnCost: () => 0, |
| getTotalCost: () => this.totalCost, |
| getTotalUsage: () => ({ ...this.totalUsage }), |
| resetTurn: () => { }, |
| }; |
|
|
| |
| const providerAdapter = new OswsProviderAdapter({ |
| getProviderConfig: () => this.getProviderConfig(), |
| getApiUrl: () => this.getApiUrl(), |
| getReasoningEnabled: (m) => this.getConfig().getReasoningEnabled(m), |
| getDebugStreamEnabled: () => this.getConfig().getDebugStreamEnabled(), |
| getModelPricing: (p, m) => this.getConfig().getModelPricing(p, m), |
| getCachedModels: (p) => this.getConfig().getCachedModels(p), |
| progress, |
| }); |
|
|
| |
| |
| |
| const imageGen = this.assignment?.imageGen; |
| const generateImageCapability = this.isImageGenAvailable() |
| ? async (prompt: string, opts: { aspectRatio?: string; imageSize?: string }) => { |
| const ref = imageGen === 'agent' ? this.assignment!.agent : imageGen as ModelRef; |
| const apiKey = this.getConfig().getProviderApiKey(ref.provider) || ''; |
| |
| |
| |
| const modalities = this.getModelOutputModalities(ref); |
| return generateImage({ provider: ref.provider, apiKey, model: ref.model, prompt, modalities, ...opts }); |
| } |
| : undefined; |
| const buildToolExecutor = (executorProgress: ProgressReporter) => { |
| const executor = new OswsToolExecutor({ |
| projectId: this.projectId, |
| progress: executorProgress, |
| getAgent: () => this.rootAgent, |
| chatMode: this.chatMode, |
| abortSignal: this.abortController.signal, |
| generateImage: generateImageCapability, |
| onApprovalNeeded: this.onApprovalNeeded, |
| permissionMode: this.permissionMode, |
| permissionOverrides: this.permissionOverrides, |
| }); |
| executor.onAfterExecute = async (toolCall, result, durationMs) => { |
| track('tool_call', { |
| ...extractToolAnalytics(toolCall.function.name, toolCall.function.arguments, result.success), |
| ...(typeof durationMs === 'number' ? { duration_ms: durationMs } : {}), |
| }); |
| const skillRead = extractSkillRead(toolCall.function.arguments); |
| if (skillRead) track('skill_read', skillRead); |
| }; |
| return executor; |
| }; |
| const toolExecutor = buildToolExecutor(progress); |
|
|
| |
| |
| |
| const coordinator = new MultiAgentCoordinator({ |
| innerExecutor: toolExecutor, |
| provider: providerAdapter, |
| progress, |
| cost: costTracker, |
| projectId: this.projectId, |
| chatMode: this.chatMode, |
| compactionConfig, |
| createChildProvider: (childProgress: ProgressReporter) => new OswsProviderAdapter({ |
| getProviderConfig: () => this.getProviderConfig(), |
| getApiUrl: () => this.getApiUrl(), |
| getReasoningEnabled: (m) => this.getConfig().getReasoningEnabled(m), |
| getDebugStreamEnabled: () => this.getConfig().getDebugStreamEnabled(), |
| getModelPricing: (p, m) => this.getConfig().getModelPricing(p, m), |
| getCachedModels: (p) => this.getConfig().getCachedModels(p), |
| progress: childProgress, |
| }), |
| createChildExecutor: (childProgress: ProgressReporter) => buildToolExecutor(childProgress), |
| buildSystemPrompt: async (agentType: string) => { |
| const serverCtxMeta = this.getVFS().getServerContextMetadata(); |
| return buildSystemPrompt( |
| this.chatMode || agentType === 'explore' || agentType === 'plan', |
| serverCtxMeta, this.projectId, agentType as AgentType, true, this.isImageGenAvailable(), this.isWebSearchAvailable() |
| ); |
| }, |
| }); |
|
|
| |
| const loopConfig: AgentLoopConfig = { |
| maxIterations: this.rootAgent.maxIterations, |
| maxNudges: 3, |
| maxDuplicateToolCalls: 3, |
| agentType: this.rootAgent.type, |
| isReadOnly: this.chatMode || this.rootAgent.isReadOnly, |
| completionGate: this.buildCompletionGate(), |
| onPausableError: async (error: Error) => { |
| this.apiErrorCount++; |
| const isPausable = error instanceof PausableApiError; |
| this.onProgress?.('error_paused', { |
| message: error.message, |
| status: isPausable ? (error as PausableApiError).status : 0, |
| errorType: isPausable ? (error as PausableApiError).errorType : 'unknown', |
| provider: isPausable ? (error as PausableApiError).provider : '', |
| model: isPausable ? (error as PausableApiError).model : '', |
| }); |
| if (isPausable) { |
| track('api_error', { |
| provider: (error as PausableApiError).provider, |
| model: (error as PausableApiError).model, |
| error_type: (error as PausableApiError).errorType, |
| error_category: (error as PausableApiError).errorCategory, |
| status_code: (error as PausableApiError).status, |
| }); |
| } else { |
| |
| |
| |
| track('api_error', { |
| error_type: error.name || 'unknown', |
| error_category: 'unknown', |
| }); |
| } |
| await new Promise<void>(resolve => { this.pauseResolve = resolve; }); |
| return this.stopped ? 'stop' : 'continue'; |
| }, |
| }; |
|
|
| |
| this.resetErrors(); |
|
|
| |
| const loop = new AgentLoop({ |
| config: loopConfig, |
| provider: providerAdapter, |
| executor: coordinator.createWrappedExecutor(), |
| context: contextManager, |
| progress, |
| cost: costTracker, |
| }); |
|
|
| |
| const originalStop = this.stop.bind(this); |
| this.stop = () => { |
| originalStop(); |
| loop.stop(); |
| coordinator.stop(); |
| }; |
|
|
| const userContent = lastUserMsg?.content ?? ''; |
| let result: AgentLoopResult; |
| try { |
| result = await loop.run(userContent); |
| } finally { |
| this.stop = originalStop; |
| } |
|
|
| this.toolCallCount += result.toolCount; |
| this.turnCount += result.turnCount; |
|
|
| |
| conversation.metadata.completed_at = Date.now(); |
| conversation.metadata.status = result.success ? 'completed' : 'failed'; |
| conversation.metadata.cost = this.totalCost; |
|
|
| |
| const breakdown = this.measureContextBreakdown(conversation.messages); |
| this.contextBreakdowns.push(breakdown); |
|
|
| return result; |
| } |
|
|
| |
| |
| |
|
|
| |
| private getConfig(): any { |
| return this.serverContext ? this.serverContext.config : configManager; |
| } |
|
|
| private getVFS() { |
| return this.serverContext?.vfs ?? vfs; |
| } |
|
|
| private getApiUrl(): string { |
| if (this.serverContext) return this.serverContext.apiBaseUrl + '/api/generate'; |
| return typeof window !== 'undefined' ? `${window.location.origin}/api/generate` : '/api/generate'; |
| } |
|
|
| private checkModelSupportsTools(): boolean { |
| const { provider, model } = this.getProviderConfig(); |
| const cached = this.getConfig().getCachedModels(provider); |
| if (cached?.models?.length) { |
| const entry = (cached.models as { id: string; supportsFunctions?: boolean }[]).find(m => m.id === model); |
| if (entry && entry.supportsFunctions === false) return false; |
| } |
| return true; |
| } |
|
|
| |
| private getModelOutputModalities(ref: ModelRef): string[] | undefined { |
| const entry = this.getConfig().getCachedModels(ref.provider)?.models |
| ?.find((m: { id: string; outputModalities?: string[] }) => m.id === ref.model); |
| return entry?.outputModalities?.length ? entry.outputModalities : undefined; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| private isImageGenAvailable(): boolean { |
| const imageGen = this.assignment?.imageGen; |
| if (!imageGen) return false; |
| if (imageGen !== 'agent') return true; |
| return !!this.getModelOutputModalities(this.assignment!.agent)?.includes('image'); |
| } |
|
|
| private isWebSearchAvailable(): boolean { |
| return configManager.isWebSearchConfigured(); |
| } |
|
|
| |
| private buildCompletionGate(): (() => Promise<string | null>) | undefined { |
| if (this.rootAgent.type === 'orchestrator') { |
| return async () => { |
| await new Promise(resolve => setTimeout(resolve, 400)); |
| const errors = this.drainErrors(); |
| if (errors.length > 0) return formatRuntimeErrors(errors); |
| return null; |
| }; |
| } |
| if (this.rootAgent.type === 'interview' && this.interviewTemplateId) { |
| const template = this.interviewTemplate ?? getInterviewTemplate(this.interviewTemplateId); |
| if (template) return () => this.runInterviewCompletionGate(template); |
| } |
| return undefined; |
| } |
|
|
| |
| |
| |
| |
| |
| private async runInterviewCompletionGate(template: InterviewTemplate): Promise<string | null> { |
| const required = template.items.filter(i => i.required !== false); |
| if (required.length === 0) return null; |
|
|
| const results: ItemCheckResult[] = []; |
|
|
| try { |
| |
| const conversation = Array.from(this.conversations.values()); |
| for (const item of required) { |
| const fileAssertions = item.completion.filter(a => a.type !== 'judge'); |
| if (fileAssertions.length === 0) continue; |
| const ars = await runAssertions(this.projectId, conversation, fileAssertions); |
| for (const ar of ars) { |
| results.push({ itemId: item.id, passed: ar.passed, reason: ar.passed ? undefined : (ar.actual || 'check failed') }); |
| } |
| } |
|
|
| |
| const judgeChecks: { itemId: string; criteria: string }[] = []; |
| for (const item of required) { |
| for (const a of item.completion) { |
| if (a.type === 'judge') judgeChecks.push({ itemId: item.id, criteria: a.criteria }); |
| } |
| } |
| if (judgeChecks.length > 0) { |
| const files = await this.readArtifactFiles(template); |
| const { provider, apiKey, model } = this.getProviderConfig(); |
| const judged = await runStructuredJudge( |
| judgeChecks.map(c => c.criteria), |
| { |
| prompt: `Interview: ${template.title} — ${template.description}`, |
| files, |
| summary: 'The interviewer reported the agenda complete.', |
| }, |
| { provider: provider as ProviderId, apiKey, model }, |
| ); |
| if (judged.usage) this.recordSideCost(judged.usage); |
| judged.verdicts.forEach((v, i) => { |
| results.push({ itemId: judgeChecks[i].itemId, passed: v.passed, reason: v.passed ? undefined : v.reasoning }); |
| }); |
| } |
| } catch (err) { |
| |
| logger.warn('[Interview] completion gate evaluation failed, allowing completion', err); |
| this.onProgress?.('interview_gate', { complete: true, errored: true, items: [] }); |
| return null; |
| } |
|
|
| const feedback = buildCompletionFeedback(template.items, results); |
| if (feedback === null) { |
| track('interview_completed', { |
| template: bucketInterviewTemplateId(template.id), |
| required_items: required.length, |
| }); |
| } |
| this.onProgress?.('interview_gate', { |
| complete: feedback === null, |
| items: summarizeCompletion(template.items, results), |
| ...(feedback === null && template.handoff ? { handoff: template.handoff } : {}), |
| }); |
| return feedback; |
| } |
|
|
| |
| private async readArtifactFiles(template: InterviewTemplate): Promise<Record<string, string>> { |
| const files: Record<string, string> = {}; |
| for (const artifact of template.artifacts) { |
| try { |
| const f = await this.getVFS().readFile(this.projectId, artifact.path); |
| const content = f && typeof f.content === 'string' ? f.content : ''; |
| if (content) files[artifact.path] = content; |
| } catch { } |
| } |
| return files; |
| } |
|
|
| |
| |
| |
| |
| |
| private recordSideCost(usage: UsageInfo): void { |
| const provider = usage.provider || ''; |
| const model = usage.model || this.model || ''; |
| const cost = CostCalculator.calculateCost(usage, provider, model, true); |
| this.totalCost += cost; |
| this.taskCost += cost; |
| this.totalUsage.completionTokens += usage.completionTokens; |
| this.totalUsage.totalTokens += usage.totalTokens; |
| this.taskTokens += usage.totalTokens; |
| this.getConfig().updateSessionCost({ ...usage, cost }, cost); |
| this.onProgress?.('usage', { |
| usage, totalCost: this.totalCost, totalUsage: { ...this.totalUsage }, |
| taskCost: this.taskCost, taskTokens: this.taskTokens, |
| }); |
| } |
|
|
| private getProviderConfig(): { provider: string; apiKey: string; model: string; baseUrl: string } { |
| if (this.serverContext) { |
| const cfg = this.getConfig(); |
| const provider = cfg.getSelectedProvider(); |
| return { provider, apiKey: cfg.getProviderApiKey(provider) || '', model: cfg.getProviderModel(provider) || this.model || 'default-model', baseUrl: '' }; |
| } |
| const agent = this.assignment?.agent; |
| const provider = (agent?.provider ?? configManager.getSelectedProvider()) as ProviderId; |
| const providerConfig = getProvider(provider); |
| const apiKey = configManager.getProviderApiKey(provider); |
| const model = agent?.model || configManager.getProviderModel(provider) || this.model || undefined; |
| if (providerConfig.apiKeyRequired && !apiKey && !providerConfig.usesOAuth) { |
| throw new Error(`API key not configured for provider: ${provider}`); |
| } |
| return { provider, apiKey: apiKey || '', model: model || 'default-model', baseUrl: providerConfig.baseUrl || '' }; |
| } |
|
|
| private resolveCompactionLimit(): number { |
| const { provider, model } = this.getProviderConfig(); |
| |
| const userLimit = this.assignment?.compactLimit ?? this.getConfig().getCompactionLimit(provider); |
| if (userLimit) return userLimit; |
| const registryLimit = getModelContextLength(provider as ProviderId, model); |
| if (registryLimit) return registryLimit; |
| const cachedLimit = this.getConfig().getModelContextLengthFromCache(provider, model); |
| if (cachedLimit) return cachedLimit; |
| return MultiAgentOrchestrator.DEFAULT_COMPACTION_LIMIT; |
| } |
|
|
| private resetErrors(): void { |
| if (!this.serverContext) resetRuntimeErrors(); |
| } |
|
|
| private drainErrors() { |
| return this.serverContext ? [] : drainRuntimeErrors(); |
| } |
|
|
| private addMessage(conversationId: string, message: AgentMessage): void { |
| const conv = this.conversations.get(conversationId); |
| if (!conv) throw new Error(`Conversation ${conversationId} not found`); |
| conv.messages.push(message); |
| if (conversationId === this.currentConversationId) { |
| this.onProgress?.('conversation_message', { message }); |
| } |
| } |
|
|
| private createConversation(agentType: AgentType): string { |
| const id = `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; |
| this.conversations.set(id, { |
| id, |
| agent_type: agentType, |
| messages: [], |
| metadata: { started_at: Date.now(), cost: 0, status: 'running' }, |
| }); |
| return id; |
| } |
|
|
| private async recordAutoCheckpoint(description: string): Promise<Checkpoint | null> { |
| if (this.serverContext) return null; |
| const checkpoint = await checkpointManager.createCheckpoint(this.projectId, description, { |
| kind: 'auto', |
| baseRevisionId: saveManager.getSavedCheckpointId(this.projectId), |
| }); |
| this.onProgress?.('checkpoint_created', { checkpointId: checkpoint.id, description, timestamp: checkpoint.timestamp }); |
| return checkpoint; |
| } |
|
|
| private measureContextBreakdown(messages: AgentMessage[]): ContextBreakdown { |
| let systemPromptChars = 0, userMessageChars = 0, assistantTextChars = 0; |
| let toolCallArgChars = 0, toolResultChars = 0, reasoningChars = 0; |
| for (const msg of messages) { |
| const contentLen = typeof msg.content === 'string' ? msg.content.length : JSON.stringify(msg.content).length; |
| switch (msg.role) { |
| case 'system': systemPromptChars += contentLen; break; |
| case 'user': userMessageChars += contentLen; break; |
| case 'assistant': |
| assistantTextChars += contentLen; |
| if (msg.tool_calls) for (const tc of msg.tool_calls) toolCallArgChars += (tc.function.arguments || '').length; |
| if (msg.reasoning_details) for (const rd of msg.reasoning_details) reasoningChars += (rd.text || '').length + (rd.signature || '').length; |
| break; |
| case 'tool': toolResultChars += contentLen; break; |
| } |
| } |
| const totalChars = systemPromptChars + userMessageChars + assistantTextChars + toolCallArgChars + toolResultChars + reasoningChars; |
| return { systemPromptChars, userMessageChars, assistantTextChars, toolCallArgChars, toolResultChars, reasoningChars, totalChars }; |
| } |
|
|
| private toPortableMessages(messages: AgentMessage[]): Message[] { |
| return messages.map(({ ui_metadata, ...rest }) => ({ |
| ...rest, |
| |
| ...(ui_metadata?.isCompactSummary ? { metadata: { isCompactSummary: true } } : {}), |
| }) as Message); |
| } |
| } |
|
|