| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { Annotation, StateGraph, START, END } from '@langchain/langgraph'; |
| import { SystemMessage, HumanMessage, AIMessage } from '@langchain/core/messages'; |
| import type { LangGraphRunnableConfig } from '@langchain/langgraph'; |
| import type { LanguageModel } from 'ai'; |
|
|
| import { AISdkLangGraphAdapter } from './ai-sdk-adapter'; |
| import type { StatelessEvent } from '@/lib/types/chat'; |
| import type { StatelessChatRequest } from '@/lib/types/chat'; |
| import type { ThinkingConfig } from '@/lib/types/provider'; |
| import type { AgentConfig } from '@/lib/orchestration/registry/types'; |
| import { useAgentRegistry } from '@/lib/orchestration/registry/store'; |
| import { buildStructuredPrompt } from './prompt-builder'; |
| import { summarizeConversation } from './summarizers/conversation-summary'; |
| import { convertMessagesToOpenAI } from './summarizers/message-converter'; |
| import { buildDirectorPrompt, parseDirectorDecision } from './director-prompt'; |
| import { getEffectiveActions } from './tool-schemas'; |
| import type { AgentTurnSummary, WhiteboardActionRecord } from './types'; |
| import { parseStructuredChunk, createParserState, finalizeParser } from './stateless-generate'; |
| import { createLogger } from '@/lib/logger'; |
|
|
| const log = createLogger('DirectorGraph'); |
|
|
| |
|
|
| |
| |
| |
| const OrchestratorState = Annotation.Root({ |
| |
| messages: Annotation<StatelessChatRequest['messages']>, |
| storeState: Annotation<StatelessChatRequest['storeState']>, |
| availableAgentIds: Annotation<string[]>, |
| maxTurns: Annotation<number>, |
| languageModel: Annotation<LanguageModel>, |
| thinkingConfig: Annotation<ThinkingConfig | null>, |
| discussionContext: Annotation<{ topic: string; prompt?: string } | null>, |
| triggerAgentId: Annotation<string | null>, |
| userProfile: Annotation<{ nickname?: string; bio?: string } | null>, |
| |
| agentConfigOverrides: Annotation<Record<string, AgentConfig>>, |
|
|
| |
| currentAgentId: Annotation<string | null>, |
| turnCount: Annotation<number>, |
| agentResponses: Annotation<AgentTurnSummary[]>({ |
| reducer: (prev, update) => [...prev, ...update], |
| default: () => [], |
| }), |
| whiteboardLedger: Annotation<WhiteboardActionRecord[]>({ |
| reducer: (prev, update) => [...prev, ...update], |
| default: () => [], |
| }), |
| shouldEnd: Annotation<boolean>, |
| totalActions: Annotation<number>, |
| }); |
|
|
| type OrchestratorStateType = typeof OrchestratorState.State; |
|
|
| |
| |
| |
| |
| function resolveAgent(state: OrchestratorStateType, agentId: string): AgentConfig | undefined { |
| return state.agentConfigOverrides[agentId] ?? useAgentRegistry.getState().getAgent(agentId); |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| async function directorNode( |
| state: OrchestratorStateType, |
| config: LangGraphRunnableConfig, |
| ): Promise<Partial<OrchestratorStateType>> { |
| const rawWrite = config.writer as (chunk: StatelessEvent) => void; |
| const write = (chunk: StatelessEvent) => { |
| try { |
| rawWrite(chunk); |
| } catch { |
| |
| } |
| }; |
| const isSingleAgent = state.availableAgentIds.length <= 1; |
|
|
| |
| if (state.turnCount >= state.maxTurns) { |
| log.info(`[Director] Turn limit reached (${state.turnCount}/${state.maxTurns}), ending`); |
| return { shouldEnd: true }; |
| } |
|
|
| |
| if (isSingleAgent) { |
| const agentId = state.availableAgentIds[0] || 'default-1'; |
|
|
| if (state.turnCount === 0) { |
| |
| log.info(`[Director] Single agent: dispatching "${agentId}"`); |
| write({ type: 'thinking', data: { stage: 'agent_loading', agentId } }); |
| return { currentAgentId: agentId, shouldEnd: false }; |
| } |
|
|
| |
| log.info(`[Director] Single agent: cueing user after "${agentId}"`); |
| write({ type: 'cue_user', data: { fromAgentId: agentId } }); |
| return { shouldEnd: true }; |
| } |
|
|
| |
| if (state.turnCount === 0 && state.triggerAgentId) { |
| const triggerId = state.triggerAgentId; |
| if (state.availableAgentIds.includes(triggerId)) { |
| log.info(`[Director] First turn: dispatching trigger agent "${triggerId}"`); |
| write({ |
| type: 'thinking', |
| data: { stage: 'agent_loading', agentId: triggerId }, |
| }); |
| return { currentAgentId: triggerId, shouldEnd: false }; |
| } |
| log.warn( |
| `[Director] Trigger agent "${triggerId}" not in available agents, falling through to LLM`, |
| ); |
| } |
|
|
| |
| const agents: AgentConfig[] = state.availableAgentIds |
| .map((id) => resolveAgent(state, id)) |
| .filter((a): a is AgentConfig => a != null); |
|
|
| if (agents.length === 0) { |
| return { shouldEnd: true }; |
| } |
|
|
| write({ type: 'thinking', data: { stage: 'director' } }); |
|
|
| const openaiMessages = convertMessagesToOpenAI(state.messages); |
| const conversationSummary = summarizeConversation(openaiMessages); |
|
|
| const prompt = buildDirectorPrompt( |
| agents, |
| conversationSummary, |
| state.agentResponses, |
| state.turnCount, |
| state.discussionContext, |
| state.triggerAgentId, |
| state.whiteboardLedger, |
| state.userProfile || undefined, |
| state.storeState.whiteboardOpen, |
| ); |
|
|
| const adapter = new AISdkLangGraphAdapter(state.languageModel, state.thinkingConfig ?? undefined); |
|
|
| try { |
| const result = await adapter._generate( |
| [new SystemMessage(prompt), new HumanMessage('Decide which agent should speak next.')], |
| { signal: config.signal } as Record<string, unknown>, |
| ); |
|
|
| const content = result.generations[0]?.text || ''; |
| log.info(`[Director] Raw decision: ${content}`); |
|
|
| const decision = parseDirectorDecision(content); |
|
|
| if (decision.shouldEnd || !decision.nextAgentId) { |
| log.info('[Director] Decision: END'); |
| return { shouldEnd: true }; |
| } |
|
|
| if (decision.nextAgentId === 'USER') { |
| log.info('[Director] Decision: cue USER to speak'); |
| write({ |
| type: 'cue_user', |
| data: { fromAgentId: state.currentAgentId || undefined }, |
| }); |
| return { shouldEnd: true }; |
| } |
|
|
| const agentExists = agents.some((a) => a.id === decision.nextAgentId); |
| if (!agentExists) { |
| log.warn(`[Director] Unknown agent "${decision.nextAgentId}", ending`); |
| return { shouldEnd: true }; |
| } |
|
|
| write({ |
| type: 'thinking', |
| data: { stage: 'agent_loading', agentId: decision.nextAgentId }, |
| }); |
|
|
| log.info(`[Director] Decision: dispatch agent "${decision.nextAgentId}"`); |
| return { |
| currentAgentId: decision.nextAgentId, |
| shouldEnd: false, |
| }; |
| } catch (error) { |
| log.error('[Director] Error:', error); |
| return { shouldEnd: true }; |
| } |
| } |
|
|
| function directorCondition(state: OrchestratorStateType): 'agent_generate' | typeof END { |
| return state.shouldEnd ? END : 'agent_generate'; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| async function runAgentGeneration( |
| state: OrchestratorStateType, |
| agentId: string, |
| config: LangGraphRunnableConfig, |
| ): Promise<{ |
| contentPreview: string; |
| actionCount: number; |
| whiteboardActions: WhiteboardActionRecord[]; |
| }> { |
| const agentConfig = resolveAgent(state, agentId); |
| if (!agentConfig) { |
| throw new Error(`Agent not found: ${agentId}`); |
| } |
|
|
| const rawWrite = config.writer as (chunk: StatelessEvent) => void; |
| const write = (chunk: StatelessEvent) => { |
| try { |
| rawWrite(chunk); |
| } catch (e) { |
| log.warn(`[AgentGenerate] write failed for ${agentId}:`, e); |
| } |
| }; |
| const messageId = `assistant-${agentId}-${Date.now()}`; |
|
|
| write({ |
| type: 'agent_start', |
| data: { |
| messageId, |
| agentId, |
| agentName: agentConfig.name, |
| agentAvatar: agentConfig.avatar, |
| agentColor: agentConfig.color, |
| }, |
| }); |
|
|
| |
| |
| const currentScene = state.storeState.currentSceneId |
| ? state.storeState.scenes.find((s) => s.id === state.storeState.currentSceneId) |
| : undefined; |
| const sceneType = currentScene?.type; |
| const effectiveActions = getEffectiveActions(agentConfig.allowedActions, sceneType); |
|
|
| const discussionContext = state.discussionContext || undefined; |
| const systemPrompt = buildStructuredPrompt( |
| agentConfig, |
| state.storeState, |
| discussionContext, |
| state.whiteboardLedger, |
| state.userProfile || undefined, |
| state.agentResponses, |
| ); |
| const openaiMessages = convertMessagesToOpenAI(state.messages, agentId); |
| const adapter = new AISdkLangGraphAdapter(state.languageModel, state.thinkingConfig ?? undefined); |
|
|
| const lcMessages = [ |
| new SystemMessage(systemPrompt), |
| ...openaiMessages.map((m) => |
| m.role === 'user' ? new HumanMessage(m.content) : new AIMessage(m.content), |
| ), |
| ]; |
|
|
| |
| |
| |
| |
| const lastMsg = lcMessages[lcMessages.length - 1]; |
| if (!lcMessages.some((m) => m instanceof HumanMessage)) { |
| lcMessages.push(new HumanMessage('Please begin.')); |
| } else if (lastMsg instanceof AIMessage) { |
| lcMessages.push(new HumanMessage("It's your turn to speak. Respond from your perspective.")); |
| } |
|
|
| const parserState = createParserState(); |
| let fullText = ''; |
| let actionCount = 0; |
| const whiteboardActions: WhiteboardActionRecord[] = []; |
|
|
| try { |
| for await (const chunk of adapter.streamGenerate(lcMessages, { |
| signal: config.signal, |
| })) { |
| if (chunk.type === 'delta') { |
| const parseResult = parseStructuredChunk(chunk.content, parserState); |
|
|
| |
| |
| |
| let emittedTextCount = 0; |
| if (parseResult.ordered.length > 0 || parseResult.textChunks.length > 0) { |
| log.debug( |
| `[AgentGenerate] Parse: ordered=${parseResult.ordered.length} (${parseResult.ordered.map((e) => e.type).join(',')}), textChunks=${parseResult.textChunks.length}, actions=${parseResult.actions.length}, done=${parseResult.isDone}`, |
| ); |
| } |
| for (const entry of parseResult.ordered) { |
| if (entry.type === 'text') { |
| const rawText = parseResult.textChunks[entry.index]; |
| if (!rawText) { |
| log.warn( |
| `[AgentGenerate] Ordered text entry index=${entry.index} but textChunks[${entry.index}] is empty`, |
| ); |
| continue; |
| } |
| const text = rawText.replace(/^>+\s?/gm, ''); |
| if (!text) continue; |
| fullText += text; |
| write({ |
| type: 'text_delta', |
| data: { content: text, messageId }, |
| }); |
| emittedTextCount++; |
| } else if (entry.type === 'action') { |
| const ac = parseResult.actions[entry.index]; |
| if (!ac) continue; |
| if (!effectiveActions.includes(ac.actionName)) { |
| log.warn( |
| `[AgentGenerate] Agent ${agentConfig.name} attempted disallowed action: ${ac.actionName}, skipping`, |
| ); |
| continue; |
| } |
| actionCount++; |
| |
| if (ac.actionName.startsWith('wb_')) { |
| whiteboardActions.push({ |
| actionName: ac.actionName as WhiteboardActionRecord['actionName'], |
| agentId, |
| agentName: agentConfig.name, |
| params: ac.params, |
| }); |
| } |
| write({ |
| type: 'action', |
| data: { |
| actionId: ac.actionId, |
| actionName: ac.actionName, |
| params: ac.params, |
| agentId, |
| messageId, |
| }, |
| }); |
| } |
| } |
|
|
| |
| for (let i = emittedTextCount; i < parseResult.textChunks.length; i++) { |
| const rawText = parseResult.textChunks[i]; |
| if (!rawText) continue; |
| const text = rawText.replace(/^>+\s?/gm, ''); |
| if (!text) continue; |
| fullText += text; |
| write({ |
| type: 'text_delta', |
| data: { content: text, messageId }, |
| }); |
| } |
| } |
| } |
|
|
| |
| const finalResult = finalizeParser(parserState); |
| for (const entry of finalResult.ordered) { |
| if (entry.type === 'text') { |
| const rawText = finalResult.textChunks[entry.index]; |
| if (!rawText) continue; |
| const text = rawText.replace(/^>+\s?/gm, ''); |
| if (!text) continue; |
| fullText += text; |
| write({ |
| type: 'text_delta', |
| data: { content: text, messageId }, |
| }); |
| } |
| } |
| } catch (error) { |
| if (error instanceof Error && error.name === 'AbortError') { |
| throw error; |
| } |
| log.error(`[AgentGenerate] Error for ${agentConfig.name}:`, error); |
| write({ |
| type: 'error', |
| data: { message: error instanceof Error ? error.message : String(error) }, |
| }); |
| } |
|
|
| write({ |
| type: 'agent_end', |
| data: { messageId, agentId }, |
| }); |
|
|
| return { |
| contentPreview: fullText.slice(0, 300), |
| actionCount, |
| whiteboardActions, |
| }; |
| } |
|
|
| |
| |
| |
| async function agentGenerateNode( |
| state: OrchestratorStateType, |
| config: LangGraphRunnableConfig, |
| ): Promise<Partial<OrchestratorStateType>> { |
| const agentId = state.currentAgentId; |
| if (!agentId) { |
| return { shouldEnd: true }; |
| } |
|
|
| const agentConfig = resolveAgent(state, agentId); |
| const result = await runAgentGeneration(state, agentId, config); |
|
|
| if (!result.contentPreview && result.actionCount === 0) { |
| log.warn( |
| `[AgentGenerate] Agent "${agentConfig?.name || agentId}" produced empty response (no text, no actions)`, |
| ); |
| } |
|
|
| return { |
| turnCount: state.turnCount + 1, |
| totalActions: state.totalActions + result.actionCount, |
| agentResponses: [ |
| { |
| agentId, |
| agentName: agentConfig?.name || agentId, |
| contentPreview: result.contentPreview, |
| actionCount: result.actionCount, |
| whiteboardActions: result.whiteboardActions, |
| }, |
| ], |
| whiteboardLedger: result.whiteboardActions, |
| currentAgentId: null, |
| }; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function createOrchestrationGraph() { |
| const graph = new StateGraph(OrchestratorState) |
| .addNode('director', directorNode) |
| .addNode('agent_generate', agentGenerateNode) |
| .addEdge(START, 'director') |
| .addConditionalEdges('director', directorCondition, { |
| agent_generate: 'agent_generate', |
| [END]: END, |
| }) |
| .addEdge('agent_generate', 'director'); |
|
|
| return graph.compile(); |
| } |
|
|
| |
| |
| |
| |
| export function buildInitialState( |
| request: StatelessChatRequest, |
| languageModel: LanguageModel, |
| thinkingConfig?: ThinkingConfig, |
| ): typeof OrchestratorState.State { |
| |
| |
| const agentConfigOverrides: Record<string, AgentConfig> = {}; |
| if (request.config.agentConfigs?.length) { |
| for (const cfg of request.config.agentConfigs) { |
| agentConfigOverrides[cfg.id] = { |
| ...cfg, |
| isDefault: false, |
| createdAt: new Date(), |
| updatedAt: new Date(), |
| }; |
| } |
| } |
|
|
| const discussionContext = request.config.discussionTopic |
| ? { |
| topic: request.config.discussionTopic, |
| prompt: request.config.discussionPrompt, |
| } |
| : null; |
|
|
| const incoming = request.directorState; |
| const turnCount = incoming?.turnCount ?? 0; |
|
|
| return { |
| messages: request.messages, |
| storeState: request.storeState, |
| availableAgentIds: request.config.agentIds, |
| maxTurns: turnCount + 1, |
| languageModel, |
| thinkingConfig: thinkingConfig ?? null, |
| discussionContext, |
| triggerAgentId: request.config.triggerAgentId || null, |
| userProfile: request.userProfile || null, |
| agentConfigOverrides, |
| currentAgentId: null, |
| turnCount, |
| agentResponses: incoming?.agentResponses ?? [], |
| whiteboardLedger: incoming?.whiteboardLedger ?? [], |
| shouldEnd: false, |
| totalActions: 0, |
| }; |
| } |
|
|