stateless-generate.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434
  1. /**
  2. * Stateless Multi-Agent Generation
  3. *
  4. * Single-pass generation with structured JSON Array output format:
  5. * [{"type":"action","name":"...","params":{...}},{"type":"text","content":"natural speech"},...]
  6. *
  7. * Key design decisions:
  8. * - Backend is stateless (all state in request/response)
  9. * - Single generation pass (no generate/tool/loop)
  10. * - Text is natural teacher speech, NOT meta-commentary
  11. * - Tool calls are silent actions - students see results only
  12. * - Action and text objects can freely interleave in the array
  13. * - Uses partial-json for robust streaming of incomplete JSON
  14. *
  15. * Multi-agent orchestration:
  16. * - When multiple agents are configured, a director agent decides who speaks
  17. * - Uses LangGraph StateGraph for the orchestration loop
  18. * - Events are streamed via LangGraph's custom stream mode
  19. */
  20. import type { LanguageModel } from 'ai';
  21. import type { StatelessChatRequest, StatelessEvent, ParsedAction } from '@/lib/types/chat';
  22. import type { ThinkingConfig } from '@/lib/types/provider';
  23. import type { WhiteboardActionRecord } from './director-prompt';
  24. import { createOrchestrationGraph, buildInitialState } from './director-graph';
  25. import { parse as parsePartialJson, Allow } from 'partial-json';
  26. import { jsonrepair } from 'jsonrepair';
  27. import { createLogger } from '@/lib/logger';
  28. const log = createLogger('StatelessGenerate');
  29. // ==================== Structured Output Parser ====================
  30. /**
  31. * Parser state for incremental JSON Array parsing.
  32. *
  33. * Accumulates raw text from the LLM stream. Once the opening `[` is found,
  34. * uses `partial-json` to incrementally parse the growing array. Emits new
  35. * complete items as they appear, and streams partial text content deltas
  36. * for the last (potentially incomplete) text item.
  37. */
  38. interface ParserState {
  39. /** Accumulated raw text from the LLM */
  40. buffer: string;
  41. /** Whether we've found the opening `[` */
  42. jsonStarted: boolean;
  43. /** Number of fully processed (emitted) items */
  44. lastParsedItemCount: number;
  45. /** Length of text content already emitted for the trailing partial text item */
  46. lastPartialTextLength: number;
  47. /** Whether parsing is complete (closing `]` found) */
  48. isDone: boolean;
  49. }
  50. /**
  51. * Create initial parser state
  52. */
  53. export function createParserState(): ParserState {
  54. return {
  55. buffer: '',
  56. jsonStarted: false,
  57. lastParsedItemCount: 0,
  58. lastPartialTextLength: 0,
  59. isDone: false,
  60. };
  61. }
  62. /**
  63. * Result from parsing a chunk
  64. */
  65. export interface ParseResult {
  66. textChunks: string[];
  67. actions: ParsedAction[];
  68. isDone: boolean;
  69. /** Ordered sequence recording original interleaving of text and action segments */
  70. ordered: Array<{ type: 'text'; index: number } | { type: 'action'; index: number }>;
  71. }
  72. /**
  73. * Emit a single parsed item into the result, returning updated segment indices.
  74. */
  75. function emitItem(
  76. item: Record<string, unknown>,
  77. result: ParseResult,
  78. textSegmentIndex: number,
  79. actionSegmentIndex: number,
  80. ): { textSegmentIndex: number; actionSegmentIndex: number } {
  81. if (item.type === 'text') {
  82. const content = (item.content as string) || '';
  83. if (content) {
  84. result.textChunks.push(content);
  85. // Use per-call array index (not cumulative segment index) so that
  86. // director-graph can read result.textChunks[entry.index] correctly.
  87. result.ordered.push({
  88. type: 'text',
  89. index: result.textChunks.length - 1,
  90. });
  91. return { textSegmentIndex: textSegmentIndex + 1, actionSegmentIndex };
  92. }
  93. } else if (item.type === 'action') {
  94. // Support both new format (name/params) and legacy format (tool_name/parameters)
  95. const action: ParsedAction = {
  96. actionId:
  97. (item.action_id as string) || `action-${Date.now()}-${Math.random().toString(36).slice(2)}`,
  98. actionName: (item.name || item.tool_name) as string,
  99. params: (item.params || item.parameters || {}) as Record<string, unknown>,
  100. };
  101. result.actions.push(action);
  102. // Use per-call array index (not cumulative segment index) so that
  103. // director-graph can read result.actions[entry.index] correctly.
  104. result.ordered.push({ type: 'action', index: result.actions.length - 1 });
  105. return { textSegmentIndex, actionSegmentIndex: actionSegmentIndex + 1 };
  106. }
  107. return { textSegmentIndex, actionSegmentIndex };
  108. }
  109. /**
  110. * Parse streaming chunks of structured JSON Array output.
  111. *
  112. * The LLM is expected to produce a JSON array like:
  113. * [{"type":"action","name":"spotlight","params":{"elementId":"img_1"}},
  114. * {"type":"text","content":"Hello students..."},...]
  115. *
  116. * This parser:
  117. * 1. Accumulates chunks into a buffer
  118. * 2. Skips any prefix before `[` (e.g. ```json\n, explanatory text)
  119. * 3. Uses partial-json to incrementally parse the growing array
  120. * 4. Emits new complete items (action→toolCall, text→textChunk)
  121. * 5. For the trailing incomplete text item, emits content deltas for streaming
  122. * 6. Marks done when the buffer contains the closing `]`
  123. *
  124. * @param chunk - New chunk of text to parse
  125. * @param state - Current parser state (mutated in place)
  126. * @returns Parsed text chunks and tool calls from this chunk
  127. */
  128. export function parseStructuredChunk(chunk: string, state: ParserState): ParseResult {
  129. const result: ParseResult = {
  130. textChunks: [],
  131. actions: [],
  132. isDone: false,
  133. ordered: [],
  134. };
  135. if (state.isDone) {
  136. return result;
  137. }
  138. state.buffer += chunk;
  139. // Step 1: Find the opening `[` if not yet found
  140. if (!state.jsonStarted) {
  141. const bracketIndex = state.buffer.indexOf('[');
  142. if (bracketIndex === -1) {
  143. return result;
  144. }
  145. // Trim everything before `[` (markdown fences, explanatory text, etc.)
  146. state.buffer = state.buffer.slice(bracketIndex);
  147. state.jsonStarted = true;
  148. }
  149. // Step 2: Check if the array is complete (closing `]` found)
  150. const trimmed = state.buffer.trimEnd();
  151. const isArrayClosed = trimmed.endsWith(']') && trimmed.length > 1;
  152. // Step 3: Try incremental parse — jsonrepair first (fixes unescaped quotes), fallback to partial-json
  153. // eslint-disable-next-line @typescript-eslint/no-explicit-any -- partial-json returns any[]
  154. let parsed: any[];
  155. try {
  156. const repaired = jsonrepair(state.buffer);
  157. parsed = JSON.parse(repaired);
  158. } catch {
  159. try {
  160. parsed = parsePartialJson(
  161. state.buffer,
  162. Allow.ARR | Allow.OBJ | Allow.STR | Allow.NUM | Allow.BOOL | Allow.NULL,
  163. );
  164. } catch {
  165. return result;
  166. }
  167. }
  168. if (!Array.isArray(parsed)) {
  169. return result;
  170. }
  171. // Step 4: Determine how many items are fully complete
  172. // When the array is closed, all items are complete.
  173. // When still streaming, items [0..N-2] are complete; item [N-1] may be partial.
  174. const completeUpTo = isArrayClosed ? parsed.length : Math.max(0, parsed.length - 1);
  175. // Count segment indices for items already emitted
  176. let textSegmentIndex = 0;
  177. let actionSegmentIndex = 0;
  178. for (let i = 0; i < state.lastParsedItemCount && i < parsed.length; i++) {
  179. const item = parsed[i];
  180. if (item?.type === 'text') textSegmentIndex++;
  181. else if (item?.type === 'action') actionSegmentIndex++;
  182. }
  183. // Step 5: Emit newly completed items
  184. for (let i = state.lastParsedItemCount; i < completeUpTo; i++) {
  185. const item = parsed[i];
  186. if (!item || typeof item !== 'object') continue;
  187. // If this item was previously the trailing partial text item, we've already
  188. // streamed its content incrementally. Only emit the remaining delta, not the full content.
  189. if (
  190. i === state.lastParsedItemCount &&
  191. state.lastPartialTextLength > 0 &&
  192. item.type === 'text'
  193. ) {
  194. const content = item.content || '';
  195. const remaining = content.slice(state.lastPartialTextLength);
  196. if (remaining) {
  197. result.textChunks.push(remaining);
  198. // Only push ordered entry when there is actual content to emit
  199. result.ordered.push({
  200. type: 'text',
  201. index: result.textChunks.length - 1,
  202. });
  203. }
  204. textSegmentIndex++;
  205. state.lastPartialTextLength = 0;
  206. continue;
  207. }
  208. const indices = emitItem(item, result, textSegmentIndex, actionSegmentIndex);
  209. textSegmentIndex = indices.textSegmentIndex;
  210. actionSegmentIndex = indices.actionSegmentIndex;
  211. }
  212. state.lastParsedItemCount = completeUpTo;
  213. // Step 6: Stream partial text delta for the trailing item
  214. if (!isArrayClosed && parsed.length > completeUpTo) {
  215. const lastItem = parsed[parsed.length - 1];
  216. if (lastItem && typeof lastItem === 'object' && lastItem.type === 'text') {
  217. const content = lastItem.content || '';
  218. if (content.length > state.lastPartialTextLength) {
  219. result.textChunks.push(content.slice(state.lastPartialTextLength));
  220. state.lastPartialTextLength = content.length;
  221. }
  222. }
  223. }
  224. // Step 7: Mark done if array is closed
  225. if (isArrayClosed) {
  226. state.isDone = true;
  227. result.isDone = true;
  228. state.lastParsedItemCount = parsed.length;
  229. state.lastPartialTextLength = 0;
  230. }
  231. return result;
  232. }
  233. /**
  234. * Finalize parsing after the stream ends.
  235. *
  236. * Handles the case where the model never produced a valid JSON array —
  237. * e.g. it output plain text instead of the expected `[...]` format.
  238. * Emits whatever content is in the buffer as a single text item so the
  239. * frontend can still display something rather than showing nothing.
  240. */
  241. export function finalizeParser(state: ParserState): ParseResult {
  242. const result: ParseResult = {
  243. textChunks: [],
  244. actions: [],
  245. isDone: true,
  246. ordered: [],
  247. };
  248. if (state.isDone) {
  249. return result;
  250. }
  251. const content = state.buffer.trim();
  252. if (!content) {
  253. return result;
  254. }
  255. if (!state.jsonStarted) {
  256. // Model never output `[` — treat entire buffer as plain text
  257. result.textChunks.push(content);
  258. result.ordered.push({ type: 'text', index: 0 });
  259. } else {
  260. // JSON started but never closed — try one final parse
  261. const finalChunk = parseStructuredChunk('', state);
  262. result.textChunks.push(...finalChunk.textChunks);
  263. result.actions.push(...finalChunk.actions);
  264. result.ordered.push(...finalChunk.ordered);
  265. // If final parse yielded nothing, emit raw text after `[` as fallback
  266. if (result.textChunks.length === 0 && result.actions.length === 0) {
  267. const bracketIndex = content.indexOf('[');
  268. const raw = content.slice(bracketIndex + 1).trim();
  269. if (raw) {
  270. result.textChunks.push(raw);
  271. result.ordered.push({ type: 'text', index: 0 });
  272. }
  273. }
  274. }
  275. state.isDone = true;
  276. return result;
  277. }
  278. // ==================== Main Generation Function ====================
  279. /**
  280. * Stateless generation with streaming via LangGraph orchestration
  281. *
  282. * @param request - The chat request with full state
  283. * @param abortSignal - Signal for cancellation
  284. * @yields StatelessEvent objects for streaming
  285. */
  286. export async function* statelessGenerate(
  287. request: StatelessChatRequest,
  288. abortSignal: AbortSignal,
  289. languageModel: LanguageModel,
  290. thinkingConfig?: ThinkingConfig,
  291. ): AsyncGenerator<StatelessEvent> {
  292. log.info(
  293. `[StatelessGenerate] Starting orchestration for agents: ${request.config.agentIds.join(', ')}`,
  294. );
  295. log.info(
  296. `[StatelessGenerate] Message count: ${request.messages.length}, turnCount: ${request.directorState?.turnCount ?? 0}`,
  297. );
  298. try {
  299. const graph = createOrchestrationGraph();
  300. const initialState = buildInitialState(request, languageModel, thinkingConfig);
  301. const stream = await graph.stream(initialState, {
  302. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  303. streamMode: 'custom' as any,
  304. signal: abortSignal,
  305. });
  306. let totalActions = 0;
  307. let totalAgents = 0;
  308. // Tracks whether the agent dispatched in this turn produced any text or actions.
  309. // Each statelessGenerate call handles exactly one agent turn (client loops externally).
  310. let agentHadContent = false;
  311. // Track current agent turn to build updated directorState
  312. let currentAgentId: string | null = null;
  313. let currentAgentName: string | null = null;
  314. let contentPreview = '';
  315. let agentActionCount = 0;
  316. const agentWbActions: WhiteboardActionRecord[] = [];
  317. for await (const chunk of stream) {
  318. const event = chunk as StatelessEvent;
  319. if (event.type === 'agent_start') {
  320. totalAgents++;
  321. currentAgentId = event.data.agentId;
  322. currentAgentName = event.data.agentName;
  323. contentPreview = '';
  324. agentActionCount = 0;
  325. agentWbActions.length = 0;
  326. }
  327. if (event.type === 'text_delta' && contentPreview.length < 100) {
  328. contentPreview = (contentPreview + event.data.content).slice(0, 100);
  329. agentHadContent = true;
  330. }
  331. if (event.type === 'action') {
  332. totalActions++;
  333. agentActionCount++;
  334. agentHadContent = true;
  335. if (event.data.actionName.startsWith('wb_')) {
  336. agentWbActions.push({
  337. actionName: event.data.actionName as WhiteboardActionRecord['actionName'],
  338. agentId: event.data.agentId,
  339. agentName: currentAgentName || event.data.agentId,
  340. params: event.data.params,
  341. });
  342. }
  343. }
  344. yield event;
  345. }
  346. // Build updated directorState from incoming state + this turn's data
  347. const incoming = request.directorState;
  348. const prevResponses = incoming?.agentResponses ?? [];
  349. const prevLedger = incoming?.whiteboardLedger ?? [];
  350. const prevTurnCount = incoming?.turnCount ?? 0;
  351. const directorState =
  352. totalAgents > 0
  353. ? {
  354. turnCount: prevTurnCount + 1,
  355. agentResponses: [
  356. ...prevResponses,
  357. {
  358. agentId: currentAgentId!,
  359. agentName: currentAgentName || currentAgentId!,
  360. contentPreview,
  361. actionCount: agentActionCount,
  362. whiteboardActions: [...agentWbActions],
  363. },
  364. ],
  365. whiteboardLedger: [...prevLedger, ...agentWbActions],
  366. }
  367. : {
  368. turnCount: prevTurnCount,
  369. agentResponses: prevResponses,
  370. whiteboardLedger: prevLedger,
  371. };
  372. yield {
  373. type: 'done',
  374. data: { totalActions, totalAgents, agentHadContent, directorState },
  375. };
  376. log.info(
  377. `[StatelessGenerate] Completed. Agents: ${totalAgents}, Actions: ${totalActions}, hadContent: ${agentHadContent}, turnCount: ${directorState.turnCount}`,
  378. );
  379. } catch (error) {
  380. if (error instanceof Error && error.name === 'AbortError') {
  381. yield { type: 'error', data: { message: 'Request interrupted' } };
  382. } else {
  383. log.error('[StatelessGenerate] Error:', error);
  384. yield {
  385. type: 'error',
  386. data: {
  387. message: error instanceof Error ? error.message : String(error),
  388. },
  389. };
  390. }
  391. }
  392. }