director-graph.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549
  1. /**
  2. * Director Graph — LangGraph StateGraph for Multi-Agent Orchestration
  3. *
  4. * Unified graph topology (same for single and multi-agent):
  5. *
  6. * START → director ──(end)──→ END
  7. * │
  8. * └─(next)→ agent_generate ──→ director (loop)
  9. *
  10. * The director node adapts its strategy based on agent count:
  11. * - Single agent: pure code logic (no LLM). Dispatches the agent on
  12. * turn 0, then cues the user on subsequent turns.
  13. * - Multi agent: LLM-based decision (with code fast-paths for turn 0
  14. * trigger agent and turn limits).
  15. *
  16. * Uses LangGraph's custom stream mode: each node pushes StatelessEvent
  17. * chunks via config.writer() for real-time SSE delivery.
  18. */
  19. import { Annotation, StateGraph, START, END } from '@langchain/langgraph';
  20. import { SystemMessage, HumanMessage, AIMessage } from '@langchain/core/messages';
  21. import type { LangGraphRunnableConfig } from '@langchain/langgraph';
  22. import type { LanguageModel } from 'ai';
  23. import { AISdkLangGraphAdapter } from './ai-sdk-adapter';
  24. import type { StatelessEvent } from '@/lib/types/chat';
  25. import type { StatelessChatRequest } from '@/lib/types/chat';
  26. import type { ThinkingConfig } from '@/lib/types/provider';
  27. import type { AgentConfig } from '@/lib/orchestration/registry/types';
  28. import { useAgentRegistry } from '@/lib/orchestration/registry/store';
  29. import {
  30. buildStructuredPrompt,
  31. summarizeConversation,
  32. convertMessagesToOpenAI,
  33. } from './prompt-builder';
  34. import { buildDirectorPrompt, parseDirectorDecision } from './director-prompt';
  35. import { getEffectiveActions } from './tool-schemas';
  36. import type { AgentTurnSummary, WhiteboardActionRecord } from './director-prompt';
  37. import { parseStructuredChunk, createParserState, finalizeParser } from './stateless-generate';
  38. import { createLogger } from '@/lib/logger';
  39. const log = createLogger('DirectorGraph');
  40. // ==================== State Definition ====================
  41. /**
  42. * LangGraph state annotation for the orchestration graph
  43. */
  44. const OrchestratorState = Annotation.Root({
  45. // Input (set once at graph entry)
  46. messages: Annotation<StatelessChatRequest['messages']>,
  47. storeState: Annotation<StatelessChatRequest['storeState']>,
  48. availableAgentIds: Annotation<string[]>,
  49. maxTurns: Annotation<number>,
  50. languageModel: Annotation<LanguageModel>,
  51. thinkingConfig: Annotation<ThinkingConfig | null>,
  52. discussionContext: Annotation<{ topic: string; prompt?: string } | null>,
  53. triggerAgentId: Annotation<string | null>,
  54. userProfile: Annotation<{ nickname?: string; bio?: string } | null>,
  55. /** Request-scoped agent configs for generated agents (not in the default registry) */
  56. agentConfigOverrides: Annotation<Record<string, AgentConfig>>,
  57. // Mutable (updated by nodes)
  58. currentAgentId: Annotation<string | null>,
  59. turnCount: Annotation<number>,
  60. agentResponses: Annotation<AgentTurnSummary[]>({
  61. reducer: (prev, update) => [...prev, ...update],
  62. default: () => [],
  63. }),
  64. whiteboardLedger: Annotation<WhiteboardActionRecord[]>({
  65. reducer: (prev, update) => [...prev, ...update],
  66. default: () => [],
  67. }),
  68. shouldEnd: Annotation<boolean>,
  69. totalActions: Annotation<number>,
  70. });
  71. type OrchestratorStateType = typeof OrchestratorState.State;
  72. /**
  73. * Look up an agent config: request-scoped overrides first, then global registry.
  74. * This keeps the server stateless — generated agent configs travel with the request.
  75. */
  76. function resolveAgent(state: OrchestratorStateType, agentId: string): AgentConfig | undefined {
  77. return state.agentConfigOverrides[agentId] ?? useAgentRegistry.getState().getAgent(agentId);
  78. }
  79. // ==================== Director Node ====================
  80. /**
  81. * Unified director: decides which agent speaks next.
  82. *
  83. * Strategy varies by agent count:
  84. * Single agent — pure code logic, zero LLM calls:
  85. * turn 0: dispatch the sole agent
  86. * turn 1+: cue user to speak (keeps session active for follow-ups)
  87. *
  88. * Multi agent — LLM-based with code fast-paths:
  89. * turn 0 + triggerAgentId: dispatch trigger agent (skip LLM)
  90. * otherwise: LLM decides next agent / USER / END
  91. */
  92. async function directorNode(
  93. state: OrchestratorStateType,
  94. config: LangGraphRunnableConfig,
  95. ): Promise<Partial<OrchestratorStateType>> {
  96. const rawWrite = config.writer as (chunk: StatelessEvent) => void;
  97. const write = (chunk: StatelessEvent) => {
  98. try {
  99. rawWrite(chunk);
  100. } catch {
  101. /* controller closed after abort */
  102. }
  103. };
  104. const isSingleAgent = state.availableAgentIds.length <= 1;
  105. // ── Turn limit check (applies to both single & multi) ──
  106. if (state.turnCount >= state.maxTurns) {
  107. log.info(`[Director] Turn limit reached (${state.turnCount}/${state.maxTurns}), ending`);
  108. return { shouldEnd: true };
  109. }
  110. // ── Single agent: code-only director ──
  111. if (isSingleAgent) {
  112. const agentId = state.availableAgentIds[0] || 'default-1';
  113. if (state.turnCount === 0) {
  114. // First turn: dispatch the agent
  115. log.info(`[Director] Single agent: dispatching "${agentId}"`);
  116. write({ type: 'thinking', data: { stage: 'agent_loading', agentId } });
  117. return { currentAgentId: agentId, shouldEnd: false };
  118. }
  119. // Agent already responded: cue user for follow-up
  120. log.info(`[Director] Single agent: cueing user after "${agentId}"`);
  121. write({ type: 'cue_user', data: { fromAgentId: agentId } });
  122. return { shouldEnd: true };
  123. }
  124. // ── Multi agent: fast-path for first turn with trigger ──
  125. if (state.turnCount === 0 && state.triggerAgentId) {
  126. const triggerId = state.triggerAgentId;
  127. if (state.availableAgentIds.includes(triggerId)) {
  128. log.info(`[Director] First turn: dispatching trigger agent "${triggerId}"`);
  129. write({
  130. type: 'thinking',
  131. data: { stage: 'agent_loading', agentId: triggerId },
  132. });
  133. return { currentAgentId: triggerId, shouldEnd: false };
  134. }
  135. log.warn(
  136. `[Director] Trigger agent "${triggerId}" not in available agents, falling through to LLM`,
  137. );
  138. }
  139. // ── Multi agent: LLM-based decision ──
  140. const agents: AgentConfig[] = state.availableAgentIds
  141. .map((id) => resolveAgent(state, id))
  142. .filter((a): a is AgentConfig => a != null);
  143. if (agents.length === 0) {
  144. return { shouldEnd: true };
  145. }
  146. write({ type: 'thinking', data: { stage: 'director' } });
  147. const openaiMessages = convertMessagesToOpenAI(state.messages);
  148. const conversationSummary = summarizeConversation(openaiMessages);
  149. const prompt = buildDirectorPrompt(
  150. agents,
  151. conversationSummary,
  152. state.agentResponses,
  153. state.turnCount,
  154. state.discussionContext,
  155. state.triggerAgentId,
  156. state.whiteboardLedger,
  157. state.userProfile || undefined,
  158. state.storeState.whiteboardOpen,
  159. );
  160. const adapter = new AISdkLangGraphAdapter(state.languageModel, state.thinkingConfig ?? undefined);
  161. try {
  162. const result = await adapter._generate(
  163. [new SystemMessage(prompt), new HumanMessage('Decide which agent should speak next.')],
  164. { signal: config.signal } as Record<string, unknown>,
  165. );
  166. const content = result.generations[0]?.text || '';
  167. log.info(`[Director] Raw decision: ${content}`);
  168. const decision = parseDirectorDecision(content);
  169. if (decision.shouldEnd || !decision.nextAgentId) {
  170. log.info('[Director] Decision: END');
  171. return { shouldEnd: true };
  172. }
  173. if (decision.nextAgentId === 'USER') {
  174. log.info('[Director] Decision: cue USER to speak');
  175. write({
  176. type: 'cue_user',
  177. data: { fromAgentId: state.currentAgentId || undefined },
  178. });
  179. return { shouldEnd: true };
  180. }
  181. const agentExists = agents.some((a) => a.id === decision.nextAgentId);
  182. if (!agentExists) {
  183. log.warn(`[Director] Unknown agent "${decision.nextAgentId}", ending`);
  184. return { shouldEnd: true };
  185. }
  186. write({
  187. type: 'thinking',
  188. data: { stage: 'agent_loading', agentId: decision.nextAgentId },
  189. });
  190. log.info(`[Director] Decision: dispatch agent "${decision.nextAgentId}"`);
  191. return {
  192. currentAgentId: decision.nextAgentId,
  193. shouldEnd: false,
  194. };
  195. } catch (error) {
  196. log.error('[Director] Error:', error);
  197. return { shouldEnd: true };
  198. }
  199. }
  200. function directorCondition(state: OrchestratorStateType): 'agent_generate' | typeof END {
  201. return state.shouldEnd ? END : 'agent_generate';
  202. }
  203. // ==================== Agent Generate Node ====================
  204. /**
  205. * Run generation for one agent. Streams agent_start, text_delta,
  206. * action, and agent_end events via config.writer().
  207. */
  208. async function runAgentGeneration(
  209. state: OrchestratorStateType,
  210. agentId: string,
  211. config: LangGraphRunnableConfig,
  212. ): Promise<{
  213. contentPreview: string;
  214. actionCount: number;
  215. whiteboardActions: WhiteboardActionRecord[];
  216. }> {
  217. const agentConfig = resolveAgent(state, agentId);
  218. if (!agentConfig) {
  219. throw new Error(`Agent not found: ${agentId}`);
  220. }
  221. const rawWrite = config.writer as (chunk: StatelessEvent) => void;
  222. const write = (chunk: StatelessEvent) => {
  223. try {
  224. rawWrite(chunk);
  225. } catch (e) {
  226. log.warn(`[AgentGenerate] write failed for ${agentId}:`, e);
  227. }
  228. };
  229. const messageId = `assistant-${agentId}-${Date.now()}`;
  230. write({
  231. type: 'agent_start',
  232. data: {
  233. messageId,
  234. agentId,
  235. agentName: agentConfig.name,
  236. agentAvatar: agentConfig.avatar,
  237. agentColor: agentConfig.color,
  238. },
  239. });
  240. // Compute effective actions: filter by scene type for defense-in-depth
  241. // e.g. spotlight/laser stripped for non-slide scenes even if in static allowedActions
  242. const currentScene = state.storeState.currentSceneId
  243. ? state.storeState.scenes.find((s) => s.id === state.storeState.currentSceneId)
  244. : undefined;
  245. const sceneType = currentScene?.type;
  246. const effectiveActions = getEffectiveActions(agentConfig.allowedActions, sceneType);
  247. const discussionContext = state.discussionContext || undefined;
  248. const systemPrompt = buildStructuredPrompt(
  249. agentConfig,
  250. state.storeState,
  251. discussionContext,
  252. state.whiteboardLedger,
  253. state.userProfile || undefined,
  254. state.agentResponses,
  255. );
  256. const openaiMessages = convertMessagesToOpenAI(state.messages, agentId);
  257. const adapter = new AISdkLangGraphAdapter(state.languageModel, state.thinkingConfig ?? undefined);
  258. const lcMessages = [
  259. new SystemMessage(systemPrompt),
  260. ...openaiMessages.map((m) =>
  261. m.role === 'user' ? new HumanMessage(m.content) : new AIMessage(m.content),
  262. ),
  263. ];
  264. // Ensure the message list ends with a HumanMessage.
  265. // After agent-aware role mapping, other agents' messages become user role,
  266. // so trailing AIMessage is less likely. But guard against edge cases
  267. // (e.g. agent's own previous response is last in history).
  268. const lastMsg = lcMessages[lcMessages.length - 1];
  269. if (!lcMessages.some((m) => m instanceof HumanMessage)) {
  270. lcMessages.push(new HumanMessage('Please begin.'));
  271. } else if (lastMsg instanceof AIMessage) {
  272. lcMessages.push(new HumanMessage("It's your turn to speak. Respond from your perspective."));
  273. }
  274. const parserState = createParserState();
  275. let fullText = '';
  276. let actionCount = 0;
  277. const whiteboardActions: WhiteboardActionRecord[] = [];
  278. try {
  279. for await (const chunk of adapter.streamGenerate(lcMessages, {
  280. signal: config.signal,
  281. })) {
  282. if (chunk.type === 'delta') {
  283. const parseResult = parseStructuredChunk(chunk.content, parserState);
  284. // Emit events in original interleaved order via the `ordered` array.
  285. // The ordered array tracks complete items from Step 5 of the parser;
  286. // trailing partial text deltas (Step 6) are in textChunks but not in ordered.
  287. let emittedTextCount = 0;
  288. if (parseResult.ordered.length > 0 || parseResult.textChunks.length > 0) {
  289. log.debug(
  290. `[AgentGenerate] Parse: ordered=${parseResult.ordered.length} (${parseResult.ordered.map((e) => e.type).join(',')}), textChunks=${parseResult.textChunks.length}, actions=${parseResult.actions.length}, done=${parseResult.isDone}`,
  291. );
  292. }
  293. for (const entry of parseResult.ordered) {
  294. if (entry.type === 'text') {
  295. const rawText = parseResult.textChunks[entry.index];
  296. if (!rawText) {
  297. log.warn(
  298. `[AgentGenerate] Ordered text entry index=${entry.index} but textChunks[${entry.index}] is empty`,
  299. );
  300. continue;
  301. }
  302. const text = rawText.replace(/^>+\s?/gm, '');
  303. if (!text) continue;
  304. fullText += text;
  305. write({
  306. type: 'text_delta',
  307. data: { content: text, messageId },
  308. });
  309. emittedTextCount++;
  310. } else if (entry.type === 'action') {
  311. const ac = parseResult.actions[entry.index];
  312. if (!ac) continue;
  313. if (!effectiveActions.includes(ac.actionName)) {
  314. log.warn(
  315. `[AgentGenerate] Agent ${agentConfig.name} attempted disallowed action: ${ac.actionName}, skipping`,
  316. );
  317. continue;
  318. }
  319. actionCount++;
  320. // Record whiteboard actions to the ledger
  321. if (ac.actionName.startsWith('wb_')) {
  322. whiteboardActions.push({
  323. actionName: ac.actionName as WhiteboardActionRecord['actionName'],
  324. agentId,
  325. agentName: agentConfig.name,
  326. params: ac.params,
  327. });
  328. }
  329. write({
  330. type: 'action',
  331. data: {
  332. actionId: ac.actionId,
  333. actionName: ac.actionName,
  334. params: ac.params,
  335. agentId,
  336. messageId,
  337. },
  338. });
  339. }
  340. }
  341. // Emit trailing partial text deltas not covered by ordered
  342. for (let i = emittedTextCount; i < parseResult.textChunks.length; i++) {
  343. const rawText = parseResult.textChunks[i];
  344. if (!rawText) continue;
  345. const text = rawText.replace(/^>+\s?/gm, '');
  346. if (!text) continue;
  347. fullText += text;
  348. write({
  349. type: 'text_delta',
  350. data: { content: text, messageId },
  351. });
  352. }
  353. }
  354. }
  355. // Finalize: emit any remaining content if the model didn't produce valid JSON
  356. const finalResult = finalizeParser(parserState);
  357. for (const entry of finalResult.ordered) {
  358. if (entry.type === 'text') {
  359. const rawText = finalResult.textChunks[entry.index];
  360. if (!rawText) continue;
  361. const text = rawText.replace(/^>+\s?/gm, '');
  362. if (!text) continue;
  363. fullText += text;
  364. write({
  365. type: 'text_delta',
  366. data: { content: text, messageId },
  367. });
  368. }
  369. }
  370. } catch (error) {
  371. if (error instanceof Error && error.name === 'AbortError') {
  372. throw error;
  373. }
  374. log.error(`[AgentGenerate] Error for ${agentConfig.name}:`, error);
  375. write({
  376. type: 'error',
  377. data: { message: error instanceof Error ? error.message : String(error) },
  378. });
  379. }
  380. write({
  381. type: 'agent_end',
  382. data: { messageId, agentId },
  383. });
  384. return {
  385. contentPreview: fullText.slice(0, 300),
  386. actionCount,
  387. whiteboardActions,
  388. };
  389. }
  390. /**
  391. * Agent generate node — runs one agent, then loops back to director.
  392. */
  393. async function agentGenerateNode(
  394. state: OrchestratorStateType,
  395. config: LangGraphRunnableConfig,
  396. ): Promise<Partial<OrchestratorStateType>> {
  397. const agentId = state.currentAgentId;
  398. if (!agentId) {
  399. return { shouldEnd: true };
  400. }
  401. const agentConfig = resolveAgent(state, agentId);
  402. const result = await runAgentGeneration(state, agentId, config);
  403. if (!result.contentPreview && result.actionCount === 0) {
  404. log.warn(
  405. `[AgentGenerate] Agent "${agentConfig?.name || agentId}" produced empty response (no text, no actions)`,
  406. );
  407. }
  408. return {
  409. turnCount: state.turnCount + 1,
  410. totalActions: state.totalActions + result.actionCount,
  411. agentResponses: [
  412. {
  413. agentId,
  414. agentName: agentConfig?.name || agentId,
  415. contentPreview: result.contentPreview,
  416. actionCount: result.actionCount,
  417. whiteboardActions: result.whiteboardActions,
  418. },
  419. ],
  420. whiteboardLedger: result.whiteboardActions,
  421. currentAgentId: null,
  422. };
  423. }
  424. // ==================== Graph Construction ====================
  425. /**
  426. * Create the orchestration LangGraph StateGraph.
  427. *
  428. * Topology:
  429. * START → director ──(end)──→ END
  430. * │
  431. * └─(next)→ agent_generate ──→ director (loop)
  432. */
  433. export function createOrchestrationGraph() {
  434. const graph = new StateGraph(OrchestratorState)
  435. .addNode('director', directorNode)
  436. .addNode('agent_generate', agentGenerateNode)
  437. .addEdge(START, 'director')
  438. .addConditionalEdges('director', directorCondition, {
  439. agent_generate: 'agent_generate',
  440. [END]: END,
  441. })
  442. .addEdge('agent_generate', 'director');
  443. return graph.compile();
  444. }
  445. /**
  446. * Build initial state for the orchestration graph from a StatelessChatRequest
  447. * and a pre-created LanguageModel instance.
  448. */
  449. export function buildInitialState(
  450. request: StatelessChatRequest,
  451. languageModel: LanguageModel,
  452. thinkingConfig?: ThinkingConfig,
  453. ): typeof OrchestratorState.State {
  454. // Build request-scoped agent config overrides for generated agents.
  455. // These travel with each request — no server-side persistence needed.
  456. const agentConfigOverrides: Record<string, AgentConfig> = {};
  457. if (request.config.agentConfigs?.length) {
  458. for (const cfg of request.config.agentConfigs) {
  459. agentConfigOverrides[cfg.id] = {
  460. ...cfg,
  461. isDefault: false,
  462. createdAt: new Date(),
  463. updatedAt: new Date(),
  464. };
  465. }
  466. }
  467. const discussionContext = request.config.discussionTopic
  468. ? {
  469. topic: request.config.discussionTopic,
  470. prompt: request.config.discussionPrompt,
  471. }
  472. : null;
  473. const incoming = request.directorState;
  474. const turnCount = incoming?.turnCount ?? 0;
  475. return {
  476. messages: request.messages,
  477. storeState: request.storeState,
  478. availableAgentIds: request.config.agentIds,
  479. maxTurns: turnCount + 1, // Allow exactly one more director→agent cycle
  480. languageModel,
  481. thinkingConfig: thinkingConfig ?? null,
  482. discussionContext,
  483. triggerAgentId: request.config.triggerAgentId || null,
  484. userProfile: request.userProfile || null,
  485. agentConfigOverrides,
  486. currentAgentId: null,
  487. turnCount,
  488. agentResponses: incoming?.agentResponses ?? [],
  489. whiteboardLedger: incoming?.whiteboardLedger ?? [],
  490. shouldEnd: false,
  491. totalActions: 0,
  492. };
  493. }