UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

655 lines (546 loc) • 20.4 kB
import { contextSupervisorMakeDecision } from '@lobechat/prompts'; import { GroupMemberWithAgent, UIChatMessage } from '@lobechat/types'; import { aiChatService } from '@/services/aiChat'; export interface SupervisorDecision { id: string; // target agent ID or "user" for DM, omit for group message instruction?: string; // agent ID who should respond target?: string; // optional instruction from supervisor to the agent } export type SupervisorDecisionList = SupervisorDecision[]; // Empty array = stop conversation export interface SupervisorTodoItem { // optional assigned owner (agent id or name) assignee?: string; content: string; finished: boolean; } export interface SupervisorDecisionResult { decisions: SupervisorDecisionList; todoUpdated: boolean; todos: SupervisorTodoItem[]; } export type SupervisorToolName = | 'create_todo' | 'finish_todo' | 'wait_for_user_input' | 'trigger_agent' | 'trigger_agent_dm'; export interface SupervisorToolCall { parameter?: unknown; tool_name: SupervisorToolName; } export interface SupervisorContext { abortController?: AbortController; allowDM?: boolean; availableAgents: GroupMemberWithAgent[]; groupId: string; messages: UIChatMessage[]; model: string; provider: string; // Group scene controls which tools are exposed (e.g., todos only in 'productive') scene?: 'casual' | 'productive'; systemPrompt?: string; todoList?: SupervisorTodoItem[]; userName?: string; } /** * Core supervisor runtime that orchestrates the conversation between agents in group chat */ export class GroupChatSupervisor { /** * Make decision on who should speak next */ async makeDecision(context: SupervisorContext): Promise<SupervisorDecisionResult> { const { availableAgents } = context; // If no agents available, stop conversation if (availableAgents.length === 0) { return { decisions: [], todoUpdated: false, todos: [] }; } try { const response = await this.callLLMForDecision(context); const result = this.parseSupervisorResponse(response, availableAgents, context); console.log('Supervisor TODO list:', result.todos); return result; } catch (error) { // Re-throw the error so it can be caught and displayed to the user via toast throw new Error( `Supervisor decision failed: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Call LLM service to get supervisor decision */ private async callLLMForDecision( context: SupervisorContext, ): Promise<SupervisorToolCall[] | string> { const contexts = contextSupervisorMakeDecision({ allowDM: context.allowDM, availableAgents: context.availableAgents .filter((agent) => agent.id) .map((agent) => ({ id: agent.id, title: agent.title })), messages: context.messages, scene: context.scene, todoList: context.todoList, userName: context.userName, }); try { const response = await aiChatService.generateJSON( { ...(contexts as any), model: context.model, provider: context.provider, }, context.abortController || new AbortController(), ); console.log('SUPERVISOR RESPONSE', JSON.stringify(response, null, 2)); // Parse the response to SupervisorToolCall[] if (Array.isArray(response)) { // Tool calls come in format: [{ name: string, arguments: object }] // We need to convert to our internal format: [{ tool_name: string, parameter: object }] return response.map((item: any) => ({ parameter: item.arguments || item.parameter, tool_name: item.name || item.tool_name, })) as SupervisorToolCall[]; } // If response is a string, try to parse it as JSON if (typeof response === 'string') { try { const parsed = JSON.parse(response); if (Array.isArray(parsed)) { return parsed.map((item: any) => ({ parameter: item.arguments || item.parameter, tool_name: item.name || item.tool_name, })) as SupervisorToolCall[]; } } catch { // Fall back to string response for legacy parsing return response; } } // For any other response format, fall back to string return typeof response === 'object' ? JSON.stringify(response) : String(response); } catch (err) { if (this.isAbortError(err, context)) { throw this.createAbortError(); } console.error('Supervisor LLM error:', err); throw err instanceof Error ? err : new Error(String(err)); } } private createAbortError() { const abortError = new Error('The operation was aborted'); abortError.name = 'AbortError'; return abortError; } private isAbortError(error: unknown, context: SupervisorContext) { if (context.abortController?.signal.aborted) return true; const name = (error as DOMException)?.name; return name === 'AbortError'; } private parseSupervisorResponse( response: SupervisorToolCall[] | string, availableAgents: GroupMemberWithAgent[], context: SupervisorContext, ): SupervisorDecisionResult { const previousTodos = (context.todoList || []).map((item) => ({ ...item })); let primaryError: unknown = null; try { const toolCalls = this.normalizeToolCalls(response); return this.processToolCalls(toolCalls, previousTodos, availableAgents, context); } catch (error) { primaryError = error; } try { const todos = this.extractLegacyTodoList(response, previousTodos); const decisions = this.parseLegacyDecisions(response, availableAgents); return { decisions, todoUpdated: false, todos }; } catch (legacyError) { const primaryMessage = primaryError instanceof Error && primaryError.message === '__LEGACY_FORMAT__' ? 'legacy format detected' : primaryError instanceof Error ? primaryError.message : String(primaryError); const legacyMessage = legacyError instanceof Error ? legacyError.message : String(legacyError); throw new Error(`Failed to parse supervisor response: ${primaryMessage} | ${legacyMessage}`); } } private normalizeToolCalls(response: SupervisorToolCall[] | string): SupervisorToolCall[] { // Tool calls are strictly formatted, so we can simplify if (Array.isArray(response)) { return response .filter((item) => item && typeof item === 'object' && item.tool_name) .map((item) => ({ parameter: item.parameter, tool_name: item.tool_name as SupervisorToolName, })); } if (typeof response === 'string') { const parsed = this.tryParseJson(response); if (Array.isArray(parsed)) { return parsed .filter((item) => item && typeof item === 'object' && item.tool_name) .map((item) => ({ parameter: item.parameter, tool_name: item.tool_name as SupervisorToolName, })); } // Check for legacy format if (parsed && typeof parsed === 'object' && ('decisions' in parsed || 'todos' in parsed)) { throw new Error('__LEGACY_FORMAT__'); } throw new Error('No tool calls array found in response'); } throw new Error('Unsupported supervisor response format'); } private processToolCalls( toolCalls: SupervisorToolCall[], previousTodos: SupervisorTodoItem[], availableAgents: GroupMemberWithAgent[], context: SupervisorContext, ): SupervisorDecisionResult { if (toolCalls.length === 0) { return { decisions: [], todoUpdated: false, todos: previousTodos }; } const todos = previousTodos.map((todo) => ({ ...todo })); const decisions: SupervisorDecisionList = []; let todoUpdated = false; toolCalls.forEach((call) => { switch (call.tool_name) { case 'create_todo': { if (context.scene === 'productive') { const changed = this.applyCreateTodo(todos, call.parameter); todoUpdated = todoUpdated || changed; } break; } case 'finish_todo': { if (context.scene === 'productive') { const changed = this.applyFinishTodo(todos, call.parameter); todoUpdated = todoUpdated || changed; } break; } case 'wait_for_user_input': { // Pause conversation - no action needed, just don't add any decisions console.log('DEBUG: Supervisor paused conversation:', call.parameter); break; } case 'trigger_agent': case 'trigger_agent_dm': { const decision = this.buildDecisionFromTool(call.parameter, availableAgents, context); console.log('DEBUG: Built decision from tool:', { decision, parameter: call.parameter, toolName: call.tool_name, }); if (decision) { decisions.push(decision); } break; } } }); console.log('DEBUG: Final decisions:', decisions); return { decisions, todoUpdated, todos }; } private applyCreateTodo(targetTodos: SupervisorTodoItem[], parameter: unknown): boolean { if (!parameter || typeof parameter !== 'object') return false; const payload = parameter as Record<string, unknown>; // New format: { todos: [...] } if (Array.isArray(payload.todos)) { let hasChanged = false; for (const todoItem of payload.todos) { const { content, assignee } = this.extractTodoData(todoItem); if (!content) continue; const exists = targetTodos.some( (todo) => todo.content.trim().toLowerCase() === content.toLowerCase() && !todo.finished, ); if (exists) continue; const newTodo: SupervisorTodoItem = { content, finished: false }; if (assignee && typeof assignee === 'string' && assignee.trim()) { newTodo.assignee = assignee.trim(); } targetTodos.push(newTodo); hasChanged = true; } return hasChanged; } // Legacy format: direct todo object (for backward compatibility) const { content, assignee } = this.extractTodoData(parameter); if (!content) return false; const exists = targetTodos.some( (todo) => todo.content.trim().toLowerCase() === content.toLowerCase() && !todo.finished, ); if (exists) return false; const newTodo: SupervisorTodoItem = { content, finished: false }; if (assignee && typeof assignee === 'string' && assignee.trim()) { newTodo.assignee = assignee.trim(); } targetTodos.push(newTodo); return true; } private extractTodoData(parameter: unknown): { assignee?: string; content: string | null } { if (typeof parameter === 'string') { const trimmed = parameter.trim(); return { content: trimmed ? trimmed : null }; } if (!parameter || typeof parameter !== 'object') return { content: null }; const payload = parameter as Record<string, unknown>; // Since we constrained the schema to require 'content', prioritize it // But keep fallbacks for backward compatibility with existing data const candidates: unknown[] = [ payload.content, // Primary field from schema payload.id, // Fallback for current format payload.title, payload.task, payload.text, payload.message, ]; for (const candidate of candidates) { if (typeof candidate === 'string' && candidate.trim()) { return { assignee: this.extractAssignee(payload), content: candidate.trim() }; } } return { content: null }; } private extractAssignee(payload: Record<string, unknown>): string | undefined { const a = payload.assignee; if (typeof a === 'string' && a.trim()) return a.trim(); return undefined; } private applyFinishTodo(targetTodos: SupervisorTodoItem[], parameter: unknown): boolean { if (!parameter || typeof parameter !== 'object') return false; const payload = parameter as Record<string, unknown>; // Since we constrained the schema to require 'index', we expect it to be present if (typeof payload.index === 'number') { return this.finishTodoByIndex(targetTodos, payload.index); } return false; } private finishTodoByIndex(targetTodos: SupervisorTodoItem[], index: number): boolean { if (!Number.isInteger(index)) return false; if (index < 0 || index >= targetTodos.length) return false; if (targetTodos[index].finished) return false; targetTodos[index].finished = true; return true; } private buildDecisionFromTool( parameter: unknown, availableAgents: GroupMemberWithAgent[], context: SupervisorContext, ): SupervisorDecision | null { if (typeof parameter === 'string') { return this.createDecisionFromPayload({ id: parameter }, availableAgents, context); } if (!parameter || typeof parameter !== 'object') return null; return this.createDecisionFromPayload( parameter as Record<string, unknown>, availableAgents, context, ); } private createDecisionFromPayload( payload: Record<string, unknown>, availableAgents: GroupMemberWithAgent[], context: SupervisorContext, ): SupervisorDecision | null { const idValue = typeof payload.id === 'string' ? payload.id : typeof payload.agentId === 'string' ? payload.agentId : typeof payload.speaker === 'string' ? payload.speaker : undefined; if (!idValue) return null; const agentExists = availableAgents.some((agent) => agent.id === idValue); if (!agentExists) return null; const instruction = typeof payload.instruction === 'string' ? payload.instruction : typeof payload.message === 'string' ? payload.message : undefined; const potentialTargets = [payload.target, payload.recipient, payload.to]; let target: string | undefined; for (const candidate of potentialTargets) { if (typeof candidate === 'string') { target = candidate; break; } } if (target && target !== 'user') { const targetExists = availableAgents.some((agent) => agent.id === target); if (!targetExists) target = undefined; } if (context.allowDM === false) { target = undefined; } return { id: idValue, instruction, target: target || undefined, }; } private parseLegacyDecisions( response: SupervisorToolCall[] | string, availableAgents: GroupMemberWithAgent[], ): SupervisorDecisionList { try { const decisions = this.normalizeLegacyDecisions(response); if (!Array.isArray(decisions)) { throw new Error('Response must include a decisions array'); } if (decisions.length === 0) { return []; } return decisions .filter( (item: any) => typeof item === 'object' && item !== null && typeof item.id === 'string' && availableAgents.some((agent) => agent.id === item.id), ) .map((item: any) => ({ id: item.id, instruction: typeof item.instruction === 'string' ? item.instruction : undefined, target: typeof item.target === 'string' ? item.target : undefined, })); } catch (error) { throw new Error( `Failed to parse supervisor decision: ${error instanceof Error ? error.message : String(error)}`, ); } } private normalizeLegacyDecisions(response: SupervisorToolCall[] | string) { if (typeof response === 'string') { const parsed = this.extractJsonObjectFromString(response); if (Array.isArray(parsed)) return parsed; if (parsed && Array.isArray((parsed as any).decisions)) return (parsed as any).decisions; const decisionsArray = this.extractJsonArrayFromString(response); if (!Array.isArray(decisionsArray)) { throw new Error('No JSON array found in response'); } return decisionsArray; } if (response && typeof response === 'object' && !Array.isArray(response)) { const decisions = (response as { decisions?: unknown }).decisions; if (Array.isArray(decisions)) return decisions; } return null; } private extractLegacyTodoList( response: SupervisorToolCall[] | string, previousTodos: SupervisorTodoItem[], ): SupervisorTodoItem[] { const normalize = (items: unknown): SupervisorTodoItem[] | undefined => { if (items === undefined) return undefined; if (!Array.isArray(items)) return []; return items .filter( (item): item is SupervisorTodoItem => typeof item === 'object' && item !== null && typeof (item as any).content === 'string' && typeof (item as any).finished === 'boolean', ) .map((item) => ({ assignee: typeof (item as any).assignee === 'string' && (item as any).assignee.trim() ? ((item as any).assignee as string).trim() : undefined, content: (item as SupervisorTodoItem).content, finished: (item as SupervisorTodoItem).finished, })); }; if (typeof response === 'string') { const parsed = this.extractJsonObjectFromString(response); if (parsed) { const normalized = normalize((parsed as { todos?: unknown }).todos); if (normalized !== undefined) return normalized; } return previousTodos; } if (response && typeof response === 'object' && !Array.isArray(response)) { const normalized = normalize((response as { todos?: unknown }).todos); if (normalized !== undefined) return normalized; } return previousTodos; } private tryParseJson(value: string): unknown { if (!value) return undefined; let parsed: unknown; try { parsed = JSON.parse(value); } catch { parsed = undefined; } if (parsed !== undefined) return parsed; const objectResult = this.extractJsonObjectFromString(value); if (objectResult !== null) return objectResult; return this.extractJsonArrayFromString(value) ?? undefined; } private extractJsonObjectFromString(response: string) { const trimmed = response.trim(); let parsed: unknown; try { parsed = JSON.parse(trimmed); } catch { parsed = undefined; } if (parsed && typeof parsed === 'object') { return parsed; } const startIndex = response.indexOf('{'); const endIndex = response.lastIndexOf('}'); if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { return null; } const jsonText = response.slice(startIndex, endIndex + 1); try { return JSON.parse(jsonText); } catch { return null; } } private extractJsonArrayFromString(response: string) { const startIndex = response.indexOf('['); const endIndex = response.lastIndexOf(']'); if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) { return null; } const jsonText = response.slice(startIndex, endIndex + 1); try { return JSON.parse(jsonText); } catch (error) { console.error('Failed to parse JSON array from supervisor response:', error); return null; } } /** * Quick validation of decision against group rules */ validateDecision(decisions: SupervisorDecisionList, context: SupervisorContext): boolean { const { availableAgents } = context; // Empty array is always valid (means stop) if (decisions.length === 0) return true; return decisions.every((decision) => { // Validate speaker exists const speakerExists = availableAgents.some((agent) => agent.id === decision.id); if (!speakerExists) return false; // Validate target exists if specified if (decision.target) { return ( decision.target === 'user' || availableAgents.some((agent) => agent.id === decision.target) ); } return true; }); } }