| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { LanguageModel } from 'ai'; |
| import type { StatelessChatRequest, StatelessEvent, ParsedAction } from '@/lib/types/chat'; |
| import type { ThinkingConfig } from '@/lib/types/provider'; |
| import type { WhiteboardActionRecord } from './types'; |
| import { createOrchestrationGraph, buildInitialState } from './director-graph'; |
| import { parse as parsePartialJson, Allow } from 'partial-json'; |
| import { jsonrepair } from 'jsonrepair'; |
| import { createLogger } from '@/lib/logger'; |
|
|
| const log = createLogger('StatelessGenerate'); |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| interface ParserState { |
| |
| buffer: string; |
| |
| jsonStarted: boolean; |
| |
| lastParsedItemCount: number; |
| |
| lastPartialTextLength: number; |
| |
| isDone: boolean; |
| } |
|
|
| |
| |
| |
| export function createParserState(): ParserState { |
| return { |
| buffer: '', |
| jsonStarted: false, |
| lastParsedItemCount: 0, |
| lastPartialTextLength: 0, |
| isDone: false, |
| }; |
| } |
|
|
| |
| |
| |
| export interface ParseResult { |
| textChunks: string[]; |
| actions: ParsedAction[]; |
| isDone: boolean; |
| |
| ordered: Array<{ type: 'text'; index: number } | { type: 'action'; index: number }>; |
| } |
|
|
| |
| |
| |
| function emitItem( |
| item: Record<string, unknown>, |
| result: ParseResult, |
| textSegmentIndex: number, |
| actionSegmentIndex: number, |
| ): { textSegmentIndex: number; actionSegmentIndex: number } { |
| if (item.type === 'text') { |
| const content = (item.content as string) || ''; |
| if (content) { |
| result.textChunks.push(content); |
| |
| |
| result.ordered.push({ |
| type: 'text', |
| index: result.textChunks.length - 1, |
| }); |
| return { textSegmentIndex: textSegmentIndex + 1, actionSegmentIndex }; |
| } |
| } else if (item.type === 'action') { |
| |
| const action: ParsedAction = { |
| actionId: |
| (item.action_id as string) || `action-${Date.now()}-${Math.random().toString(36).slice(2)}`, |
| actionName: (item.name || item.tool_name) as string, |
| params: (item.params || item.parameters || {}) as Record<string, unknown>, |
| }; |
| result.actions.push(action); |
| |
| |
| result.ordered.push({ type: 'action', index: result.actions.length - 1 }); |
| return { textSegmentIndex, actionSegmentIndex: actionSegmentIndex + 1 }; |
| } |
| return { textSegmentIndex, actionSegmentIndex }; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function parseStructuredChunk(chunk: string, state: ParserState): ParseResult { |
| const result: ParseResult = { |
| textChunks: [], |
| actions: [], |
| isDone: false, |
| ordered: [], |
| }; |
|
|
| if (state.isDone) { |
| return result; |
| } |
|
|
| state.buffer += chunk; |
|
|
| |
| if (!state.jsonStarted) { |
| const bracketIndex = state.buffer.indexOf('['); |
| if (bracketIndex === -1) { |
| return result; |
| } |
| |
| state.buffer = state.buffer.slice(bracketIndex); |
| state.jsonStarted = true; |
| } |
|
|
| |
| const trimmed = state.buffer.trimEnd(); |
| const isArrayClosed = trimmed.endsWith(']') && trimmed.length > 1; |
|
|
| |
| |
| let parsed: any[]; |
| try { |
| const repaired = jsonrepair(state.buffer); |
| parsed = JSON.parse(repaired); |
| } catch { |
| try { |
| parsed = parsePartialJson( |
| state.buffer, |
| Allow.ARR | Allow.OBJ | Allow.STR | Allow.NUM | Allow.BOOL | Allow.NULL, |
| ); |
| } catch { |
| return result; |
| } |
| } |
|
|
| if (!Array.isArray(parsed)) { |
| return result; |
| } |
|
|
| |
| |
| |
| const completeUpTo = isArrayClosed ? parsed.length : Math.max(0, parsed.length - 1); |
|
|
| |
| let textSegmentIndex = 0; |
| let actionSegmentIndex = 0; |
| for (let i = 0; i < state.lastParsedItemCount && i < parsed.length; i++) { |
| const item = parsed[i]; |
| if (item?.type === 'text') textSegmentIndex++; |
| else if (item?.type === 'action') actionSegmentIndex++; |
| } |
|
|
| |
| for (let i = state.lastParsedItemCount; i < completeUpTo; i++) { |
| const item = parsed[i]; |
| if (!item || typeof item !== 'object') continue; |
|
|
| |
| |
| if ( |
| i === state.lastParsedItemCount && |
| state.lastPartialTextLength > 0 && |
| item.type === 'text' |
| ) { |
| const content = item.content || ''; |
| const remaining = content.slice(state.lastPartialTextLength); |
| if (remaining) { |
| result.textChunks.push(remaining); |
| |
| result.ordered.push({ |
| type: 'text', |
| index: result.textChunks.length - 1, |
| }); |
| } |
| textSegmentIndex++; |
| state.lastPartialTextLength = 0; |
| continue; |
| } |
|
|
| const indices = emitItem(item, result, textSegmentIndex, actionSegmentIndex); |
| textSegmentIndex = indices.textSegmentIndex; |
| actionSegmentIndex = indices.actionSegmentIndex; |
| } |
|
|
| state.lastParsedItemCount = completeUpTo; |
|
|
| |
| if (!isArrayClosed && parsed.length > completeUpTo) { |
| const lastItem = parsed[parsed.length - 1]; |
| if (lastItem && typeof lastItem === 'object' && lastItem.type === 'text') { |
| const content = lastItem.content || ''; |
| if (content.length > state.lastPartialTextLength) { |
| result.textChunks.push(content.slice(state.lastPartialTextLength)); |
| state.lastPartialTextLength = content.length; |
| } |
| } |
| } |
|
|
| |
| if (isArrayClosed) { |
| state.isDone = true; |
| result.isDone = true; |
| state.lastParsedItemCount = parsed.length; |
| state.lastPartialTextLength = 0; |
| } |
|
|
| return result; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export function finalizeParser(state: ParserState): ParseResult { |
| const result: ParseResult = { |
| textChunks: [], |
| actions: [], |
| isDone: true, |
| ordered: [], |
| }; |
|
|
| if (state.isDone) { |
| return result; |
| } |
|
|
| const content = state.buffer.trim(); |
| if (!content) { |
| return result; |
| } |
|
|
| if (!state.jsonStarted) { |
| |
| result.textChunks.push(content); |
| result.ordered.push({ type: 'text', index: 0 }); |
| } else { |
| |
| const finalChunk = parseStructuredChunk('', state); |
| result.textChunks.push(...finalChunk.textChunks); |
| result.actions.push(...finalChunk.actions); |
| result.ordered.push(...finalChunk.ordered); |
|
|
| |
| if (result.textChunks.length === 0 && result.actions.length === 0) { |
| const bracketIndex = content.indexOf('['); |
| const raw = content.slice(bracketIndex + 1).trim(); |
| if (raw) { |
| result.textChunks.push(raw); |
| result.ordered.push({ type: 'text', index: 0 }); |
| } |
| } |
| } |
|
|
| state.isDone = true; |
| return result; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function* statelessGenerate( |
| request: StatelessChatRequest, |
| abortSignal: AbortSignal, |
| languageModel: LanguageModel, |
| thinkingConfig?: ThinkingConfig, |
| ): AsyncGenerator<StatelessEvent> { |
| log.info( |
| `[StatelessGenerate] Starting orchestration for agents: ${request.config.agentIds.join(', ')}`, |
| ); |
| log.info( |
| `[StatelessGenerate] Message count: ${request.messages.length}, turnCount: ${request.directorState?.turnCount ?? 0}`, |
| ); |
|
|
| try { |
| const graph = createOrchestrationGraph(); |
| const initialState = buildInitialState(request, languageModel, thinkingConfig); |
|
|
| const stream = await graph.stream(initialState, { |
| |
| streamMode: 'custom' as any, |
| signal: abortSignal, |
| }); |
|
|
| let totalActions = 0; |
| let totalAgents = 0; |
| |
| |
| let agentHadContent = false; |
|
|
| |
| let currentAgentId: string | null = null; |
| let currentAgentName: string | null = null; |
| let contentPreview = ''; |
| let agentActionCount = 0; |
| const agentWbActions: WhiteboardActionRecord[] = []; |
|
|
| for await (const chunk of stream) { |
| const event = chunk as StatelessEvent; |
|
|
| if (event.type === 'agent_start') { |
| totalAgents++; |
| currentAgentId = event.data.agentId; |
| currentAgentName = event.data.agentName; |
| contentPreview = ''; |
| agentActionCount = 0; |
| agentWbActions.length = 0; |
| } |
| if (event.type === 'text_delta' && contentPreview.length < 100) { |
| contentPreview = (contentPreview + event.data.content).slice(0, 100); |
| agentHadContent = true; |
| } |
| if (event.type === 'action') { |
| totalActions++; |
| agentActionCount++; |
| agentHadContent = true; |
| if (event.data.actionName.startsWith('wb_')) { |
| agentWbActions.push({ |
| actionName: event.data.actionName as WhiteboardActionRecord['actionName'], |
| agentId: event.data.agentId, |
| agentName: currentAgentName || event.data.agentId, |
| params: event.data.params, |
| }); |
| } |
| } |
|
|
| yield event; |
| } |
|
|
| |
| const incoming = request.directorState; |
| const prevResponses = incoming?.agentResponses ?? []; |
| const prevLedger = incoming?.whiteboardLedger ?? []; |
| const prevTurnCount = incoming?.turnCount ?? 0; |
|
|
| const directorState = |
| totalAgents > 0 |
| ? { |
| turnCount: prevTurnCount + 1, |
| agentResponses: [ |
| ...prevResponses, |
| { |
| agentId: currentAgentId!, |
| agentName: currentAgentName || currentAgentId!, |
| contentPreview, |
| actionCount: agentActionCount, |
| whiteboardActions: [...agentWbActions], |
| }, |
| ], |
| whiteboardLedger: [...prevLedger, ...agentWbActions], |
| } |
| : { |
| turnCount: prevTurnCount, |
| agentResponses: prevResponses, |
| whiteboardLedger: prevLedger, |
| }; |
|
|
| yield { |
| type: 'done', |
| data: { totalActions, totalAgents, agentHadContent, directorState }, |
| }; |
|
|
| log.info( |
| `[StatelessGenerate] Completed. Agents: ${totalAgents}, Actions: ${totalActions}, hadContent: ${agentHadContent}, turnCount: ${directorState.turnCount}`, |
| ); |
| } catch (error) { |
| if (error instanceof Error && error.name === 'AbortError') { |
| yield { type: 'error', data: { message: 'Request interrupted' } }; |
| } else { |
| log.error('[StatelessGenerate] Error:', error); |
| yield { |
| type: 'error', |
| data: { |
| message: error instanceof Error ? error.message : String(error), |
| }, |
| }; |
| } |
| } |
| } |
|
|