/** * Director Prompt Builder * * Constructs the system prompt for the director agent that decides * which agent should respond next in a multi-agent conversation. */ import type { AgentConfig } from '@/lib/orchestration/registry/types'; import { createLogger } from '@/lib/logger'; import { buildPrompt, PROMPT_IDS } from '@/lib/prompts'; import type { WhiteboardActionRecord, AgentTurnSummary } from './types'; const log = createLogger('DirectorPrompt'); /** * Build the system prompt for the director agent * * @param agents - Available agent configurations * @param conversationSummary - Condensed summary of recent conversation * @param agentResponses - Agents that have already responded this round * @param turnCount - Current turn number in this round */ export function buildDirectorPrompt( agents: AgentConfig[], conversationSummary: string, agentResponses: AgentTurnSummary[], turnCount: number, discussionContext?: { topic: string; prompt?: string } | null, triggerAgentId?: string | null, whiteboardLedger?: WhiteboardActionRecord[], userProfile?: { nickname?: string; bio?: string }, whiteboardOpen?: boolean, ): string { const agentList = agents .map((a) => `- id: "${a.id}", name: "${a.name}", role: ${a.role}, priority: ${a.priority}`) .join('\n'); const respondedList = agentResponses.length > 0 ? agentResponses .map((r) => { const wbSummary = summarizeAgentWhiteboardActions(r.whiteboardActions); const wbPart = wbSummary ? ` | Whiteboard: ${wbSummary}` : ''; return `- ${r.agentName} (${r.agentId}): "${r.contentPreview}" [${r.actionCount} actions${wbPart}]`; }) .join('\n') : 'None yet.'; const isDiscussion = !!discussionContext; const discussionSection = isDiscussion ? `\n# Discussion Mode Topic: "${discussionContext!.topic}"${discussionContext!.prompt ? `\nPrompt: "${discussionContext!.prompt}"` : ''}${triggerAgentId ? `\nInitiator: "${triggerAgentId}"` : ''} This is a student-initiated discussion, not a Q&A session.\n` : ''; const rule1 = isDiscussion ? `1. The discussion initiator${triggerAgentId ? ` ("${triggerAgentId}")` : ''} should speak first to kick off the topic. Then the teacher responds to guide the discussion. After that, other students may add their perspectives.` : "1. The teacher (role: teacher, highest priority) should usually speak first to address the user's question or topic."; const studentProfileSection = userProfile?.nickname || userProfile?.bio ? ` # Student Profile Student name: ${userProfile.nickname || 'Unknown'} ${userProfile.bio ? `Background: ${userProfile.bio}` : ''} ` : ''; const vars = { agentList, respondedList, conversationSummary, discussionSection, whiteboardSection: buildWhiteboardStateForDirector(whiteboardLedger), studentProfileSection, rule1, turnCountPlusOne: turnCount + 1, whiteboardOpenText: whiteboardOpen ? 'OPEN (slide canvas is hidden — spotlight/laser will not work)' : 'CLOSED (slide canvas is visible)', }; const prompt = buildPrompt(PROMPT_IDS.DIRECTOR, vars); if (!prompt) { throw new Error('director prompt template failed to load'); } return prompt.system; } /** * Summarize a single agent's whiteboard actions into a compact description. */ function summarizeAgentWhiteboardActions(actions: WhiteboardActionRecord[]): string { if (!actions || actions.length === 0) return ''; const parts: string[] = []; for (const a of actions) { switch (a.actionName) { case 'wb_draw_text': { const content = String(a.params.content || '').slice(0, 30); parts.push(`drew text "${content}${content.length >= 30 ? '...' : ''}"`); break; } case 'wb_draw_shape': parts.push(`drew shape(${a.params.type || 'rectangle'})`); break; case 'wb_draw_chart': { const labels = Array.isArray(a.params.labels) ? a.params.labels : (a.params.data as Record)?.labels; const chartType = a.params.chartType || a.params.type || 'bar'; parts.push( `drew chart(${chartType}${labels ? `, labels: [${(labels as string[]).slice(0, 4).join(',')}]` : ''})`, ); break; } case 'wb_draw_latex': { const latex = String(a.params.latex || '').slice(0, 30); parts.push(`drew formula "${latex}${latex.length >= 30 ? '...' : ''}"`); break; } case 'wb_draw_table': { const data = a.params.data as unknown[][] | undefined; const rows = data?.length || 0; const cols = (data?.[0] as unknown[])?.length || 0; parts.push(`drew table(${rows}×${cols})`); break; } case 'wb_draw_line': { const pts = a.params.points as string[] | undefined; const hasArrow = pts?.includes('arrow') ? ' arrow' : ''; parts.push(`drew${hasArrow} line`); break; } case 'wb_draw_code': { const lang = String(a.params.language || ''); const codeFileName = a.params.fileName ? ` "${a.params.fileName}"` : ''; parts.push(`drew code block${codeFileName} (${lang})`); break; } case 'wb_edit_code': { const op = a.params.operation || 'edit'; parts.push(`edited code (${op})`); break; } case 'wb_clear': parts.push('CLEARED whiteboard'); break; case 'wb_delete': parts.push(`deleted element "${a.params.elementId}"`); break; case 'wb_open': case 'wb_close': // Skip open/close from summary — they're structural, not content break; } } return parts.join(', '); } /** * Replay the whiteboard ledger to compute current element count and contributors. */ export function summarizeWhiteboardForDirector(ledger: WhiteboardActionRecord[]): { elementCount: number; contributors: string[]; } { let elementCount = 0; const contributorSet = new Set(); for (const record of ledger) { if (record.actionName === 'wb_clear') { elementCount = 0; // Don't reset contributors — they still participated } else if (record.actionName === 'wb_delete') { elementCount = Math.max(0, elementCount - 1); } else if (record.actionName.startsWith('wb_draw_')) { elementCount++; contributorSet.add(record.agentName); } } return { elementCount, contributors: Array.from(contributorSet), }; } /** * Build the whiteboard state section for the director prompt. * Returns empty string if there are no whiteboard actions. */ function buildWhiteboardStateForDirector(ledger?: WhiteboardActionRecord[]): string { if (!ledger || ledger.length === 0) return ''; const { elementCount, contributors } = summarizeWhiteboardForDirector(ledger); const crowdedWarning = elementCount > 5 ? '\n⚠ The whiteboard is getting crowded. Consider routing to an agent that will organize or clear it rather than adding more.' : ''; return ` # Whiteboard State Elements on whiteboard: ${elementCount} Contributors: ${contributors.length > 0 ? contributors.join(', ') : 'none'}${crowdedWarning} `; } /** * Parse the director's decision from its response * * @param content - Raw LLM response content * @returns Parsed decision with nextAgentId and shouldEnd flag */ export function parseDirectorDecision(content: string): { nextAgentId: string | null; shouldEnd: boolean; } { try { // Try to extract JSON from the response const jsonMatch = content.match(/\{[\s\S]*?"next_agent"[\s\S]*?\}/); if (jsonMatch) { const parsed = JSON.parse(jsonMatch[0]); const nextAgent = parsed.next_agent; if (!nextAgent || nextAgent === 'END') { return { nextAgentId: null, shouldEnd: true }; } return { nextAgentId: nextAgent, shouldEnd: false }; } } catch (_e) { log.warn('[Director] Failed to parse decision:', content.slice(0, 200)); } // Default: end the round if we can't parse return { nextAgentId: null, shouldEnd: true }; }