| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277 |
- /**
- * 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';
- const log = createLogger('DirectorPrompt');
- /**
- * A single whiteboard action performed by an agent, recorded in the ledger.
- */
- export interface WhiteboardActionRecord {
- actionName:
- | 'wb_draw_text'
- | 'wb_draw_shape'
- | 'wb_draw_chart'
- | 'wb_draw_latex'
- | 'wb_draw_table'
- | 'wb_draw_line'
- | 'wb_clear'
- | 'wb_delete'
- | 'wb_open'
- | 'wb_close';
- agentId: string;
- agentName: string;
- params: Record<string, unknown>;
- }
- /**
- * Summary of an agent's turn in the current round
- */
- export interface AgentTurnSummary {
- agentId: string;
- agentName: string;
- contentPreview: string;
- actionCount: number;
- whiteboardActions: WhiteboardActionRecord[];
- }
- /**
- * 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.";
- // Build whiteboard state section for director awareness
- const whiteboardSection = buildWhiteboardStateForDirector(whiteboardLedger);
- // Build student profile section for director awareness
- const studentProfileSection =
- userProfile?.nickname || userProfile?.bio
- ? `
- # Student Profile
- Student name: ${userProfile.nickname || 'Unknown'}
- ${userProfile.bio ? `Background: ${userProfile.bio}` : ''}
- `
- : '';
- return `You are the Director of a multi-agent classroom. Your job is to decide which agent should speak next based on the conversation context.
- # Available Agents
- ${agentList}
- # Agents Who Already Spoke This Round
- ${respondedList}
- # Conversation Context
- ${conversationSummary}
- ${discussionSection}${whiteboardSection}${studentProfileSection}
- # Rules
- ${rule1}
- 2. After the teacher, consider whether a student agent would add value (ask a follow-up question, crack a joke, take notes, offer a different perspective).
- 3. Do NOT repeat an agent who already spoke this round unless absolutely necessary.
- 4. If the conversation seems complete (question answered, topic covered), output END.
- 5. Current turn: ${turnCount + 1}. Consider conversation length — don't let discussions drag on unnecessarily.
- 6. Prefer brevity — 1-2 agents responding is usually enough. Don't force every agent to speak.
- 7. You can output {"next_agent":"USER"} to cue the user to speak. Use this when a student asks the user a direct question or when the topic naturally calls for user input.
- 8. Consider whiteboard state when routing: if the whiteboard is already crowded, avoid dispatching agents that are likely to add more whiteboard content unless they would clear or organize it.
- 9. Whiteboard is currently ${whiteboardOpen ? 'OPEN (slide canvas is hidden — spotlight/laser will not work)' : 'CLOSED (slide canvas is visible)'}. When the whiteboard is open, do not expect spotlight or laser actions to have visible effect.
- # Routing Quality (CRITICAL)
- - ROLE DIVERSITY: Do NOT dispatch two agents of the same role consecutively. After a teacher speaks, the next should be a student or assistant — not another teacher-like response. After an assistant rephrases, dispatch a student who asks a question, not another assistant who also rephrases.
- - CONTENT DEDUP: Read the "Agents Who Already Spoke" previews carefully. If an agent already explained a concept thoroughly, do NOT dispatch another agent to explain the same concept. Instead, dispatch an agent who will ASK a question, CHALLENGE an assumption, CONNECT to another topic, or TAKE NOTES.
- - DISCUSSION PROGRESSION: Each new agent should advance the conversation. Good progression: explain → question → deeper explanation → different perspective → summary. Bad progression: explain → re-explain → rephrase → paraphrase.
- - GREETING RULE: If any agent has already greeted the students, no subsequent agent should greet again. Check the previews for greetings.
- # Output Format
- You MUST output ONLY a JSON object, nothing else:
- {"next_agent":"<agent_id>"}
- or
- {"next_agent":"USER"}
- or
- {"next_agent":"END"}`;
- }
- /**
- * 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<string, unknown>)?.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_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<string>();
- 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 };
- }
|