| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import type { StatelessEvent, DirectorState } from '@/lib/types/chat'; |
| import type { ThinkingConfig } from '@/lib/types/provider'; |
| import { createLogger } from '@/lib/logger'; |
|
|
| const log = createLogger('AgentLoop'); |
|
|
| |
|
|
| |
| export interface AgentLoopStoreState { |
| stage: unknown; |
| scenes: unknown[]; |
| currentSceneId: string | null; |
| mode: string; |
| whiteboardOpen: boolean; |
| } |
|
|
| |
| export interface AgentLoopRequest { |
| config: { |
| agentIds: string[]; |
| sessionType?: string; |
| agentConfigs?: Record<string, unknown>[]; |
| [key: string]: unknown; |
| }; |
| userProfile?: { nickname?: string; bio?: string }; |
| apiKey: string; |
| baseUrl?: string; |
| model?: string; |
| providerType?: string; |
| thinkingConfig?: ThinkingConfig; |
| } |
|
|
| |
| export interface AgentLoopIterationResult { |
| directorState?: DirectorState; |
| totalAgents: number; |
| agentHadContent: boolean; |
| cueUserReceived: boolean; |
| } |
|
|
| |
| export interface AgentLoopCallbacks { |
| |
| getStoreState: () => AgentLoopStoreState; |
|
|
| |
| getMessages: () => unknown[]; |
|
|
| |
| |
| |
| |
| fetchChat: (body: Record<string, unknown>, signal: AbortSignal) => Promise<Response>; |
|
|
| |
| |
| |
| |
| |
| onEvent: (event: StatelessEvent) => void; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| onIterationEnd: () => Promise<AgentLoopIterationResult | null>; |
| } |
|
|
| |
| export interface AgentLoopOutcome { |
| |
| reason: 'end' | 'cue_user' | 'max_turns' | 'aborted' | 'empty_turns' | 'no_done'; |
| |
| directorState?: DirectorState; |
| |
| turnCount: number; |
| } |
|
|
| |
|
|
| |
| |
| |
| |
| |
| |
| export async function runAgentLoop( |
| request: AgentLoopRequest, |
| callbacks: AgentLoopCallbacks, |
| signal: AbortSignal, |
| maxTurns: number, |
| ): Promise<AgentLoopOutcome> { |
| let directorState: DirectorState | undefined = undefined; |
| let turnCount = 0; |
| let consecutiveEmptyTurns = 0; |
|
|
| while (turnCount < maxTurns) { |
| if (signal.aborted) { |
| return { reason: 'aborted', directorState, turnCount }; |
| } |
|
|
| |
| |
| const freshStoreState = callbacks.getStoreState(); |
| const currentMessages = callbacks.getMessages(); |
|
|
| |
| const body: Record<string, unknown> = { |
| messages: currentMessages, |
| storeState: freshStoreState, |
| config: request.config, |
| directorState, |
| userProfile: request.userProfile, |
| apiKey: request.apiKey, |
| baseUrl: request.baseUrl, |
| model: request.model, |
| providerType: request.providerType, |
| thinkingConfig: request.thinkingConfig, |
| }; |
|
|
| |
| const response = await callbacks.fetchChat(body, signal); |
|
|
| if (!response.ok) { |
| const errorText = await response.text(); |
| throw new Error(`API error: ${response.status} - ${errorText}`); |
| } |
|
|
| |
| const reader = response.body?.getReader(); |
| if (!reader) throw new Error('No response body'); |
|
|
| const decoder = new TextDecoder(); |
| let sseBuffer = ''; |
|
|
| try { |
| while (true) { |
| const { done, value } = await reader.read(); |
| if (done) break; |
|
|
| sseBuffer += decoder.decode(value, { stream: true }); |
| const parts = sseBuffer.split('\n\n'); |
| sseBuffer = parts.pop() || ''; |
|
|
| for (const part of parts) { |
| const line = part.trim(); |
| if (!line.startsWith('data: ')) continue; |
|
|
| try { |
| const event: StatelessEvent = JSON.parse(line.slice(6)); |
| callbacks.onEvent(event); |
| } catch { |
| |
| } |
| } |
| } |
| } finally { |
| reader.releaseLock(); |
| } |
|
|
| if (signal.aborted) { |
| return { reason: 'aborted', directorState, turnCount }; |
| } |
|
|
| |
| const iterationResult = await callbacks.onIterationEnd(); |
|
|
| |
| if (!iterationResult) { |
| return { reason: 'no_done', directorState, turnCount }; |
| } |
|
|
| |
| directorState = iterationResult.directorState; |
| turnCount = directorState?.turnCount ?? turnCount + 1; |
|
|
| |
| if (iterationResult.cueUserReceived) { |
| return { reason: 'cue_user', directorState, turnCount }; |
| } |
|
|
| |
| if (iterationResult.totalAgents === 0) { |
| return { reason: 'end', directorState, turnCount }; |
| } |
|
|
| |
| if (!iterationResult.agentHadContent) { |
| consecutiveEmptyTurns++; |
| if (consecutiveEmptyTurns >= 2) { |
| log.warn( |
| `[AgentLoop] ${consecutiveEmptyTurns} consecutive empty agent responses, stopping loop`, |
| ); |
| return { reason: 'empty_turns', directorState, turnCount }; |
| } |
| } else { |
| consecutiveEmptyTurns = 0; |
| } |
| } |
|
|
| |
| if (turnCount >= maxTurns) { |
| log.info(`[AgentLoop] Max turns (${maxTurns}) reached`); |
| } |
| return { reason: 'max_turns', directorState, turnCount }; |
| } |
|
|