director-prompt.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. /**
  2. * Director Prompt Builder
  3. *
  4. * Constructs the system prompt for the director agent that decides
  5. * which agent should respond next in a multi-agent conversation.
  6. */
  7. import type { AgentConfig } from '@/lib/orchestration/registry/types';
  8. import { createLogger } from '@/lib/logger';
  9. const log = createLogger('DirectorPrompt');
  10. /**
  11. * A single whiteboard action performed by an agent, recorded in the ledger.
  12. */
  13. export interface WhiteboardActionRecord {
  14. actionName:
  15. | 'wb_draw_text'
  16. | 'wb_draw_shape'
  17. | 'wb_draw_chart'
  18. | 'wb_draw_latex'
  19. | 'wb_draw_table'
  20. | 'wb_draw_line'
  21. | 'wb_clear'
  22. | 'wb_delete'
  23. | 'wb_open'
  24. | 'wb_close';
  25. agentId: string;
  26. agentName: string;
  27. params: Record<string, unknown>;
  28. }
  29. /**
  30. * Summary of an agent's turn in the current round
  31. */
  32. export interface AgentTurnSummary {
  33. agentId: string;
  34. agentName: string;
  35. contentPreview: string;
  36. actionCount: number;
  37. whiteboardActions: WhiteboardActionRecord[];
  38. }
  39. /**
  40. * Build the system prompt for the director agent
  41. *
  42. * @param agents - Available agent configurations
  43. * @param conversationSummary - Condensed summary of recent conversation
  44. * @param agentResponses - Agents that have already responded this round
  45. * @param turnCount - Current turn number in this round
  46. */
  47. export function buildDirectorPrompt(
  48. agents: AgentConfig[],
  49. conversationSummary: string,
  50. agentResponses: AgentTurnSummary[],
  51. turnCount: number,
  52. discussionContext?: { topic: string; prompt?: string } | null,
  53. triggerAgentId?: string | null,
  54. whiteboardLedger?: WhiteboardActionRecord[],
  55. userProfile?: { nickname?: string; bio?: string },
  56. whiteboardOpen?: boolean,
  57. ): string {
  58. const agentList = agents
  59. .map((a) => `- id: "${a.id}", name: "${a.name}", role: ${a.role}, priority: ${a.priority}`)
  60. .join('\n');
  61. const respondedList =
  62. agentResponses.length > 0
  63. ? agentResponses
  64. .map((r) => {
  65. const wbSummary = summarizeAgentWhiteboardActions(r.whiteboardActions);
  66. const wbPart = wbSummary ? ` | Whiteboard: ${wbSummary}` : '';
  67. return `- ${r.agentName} (${r.agentId}): "${r.contentPreview}" [${r.actionCount} actions${wbPart}]`;
  68. })
  69. .join('\n')
  70. : 'None yet.';
  71. const isDiscussion = !!discussionContext;
  72. const discussionSection = isDiscussion
  73. ? `\n# Discussion Mode
  74. Topic: "${discussionContext!.topic}"${discussionContext!.prompt ? `\nPrompt: "${discussionContext!.prompt}"` : ''}${triggerAgentId ? `\nInitiator: "${triggerAgentId}"` : ''}
  75. This is a student-initiated discussion, not a Q&A session.\n`
  76. : '';
  77. const rule1 = isDiscussion
  78. ? `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.`
  79. : "1. The teacher (role: teacher, highest priority) should usually speak first to address the user's question or topic.";
  80. // Build whiteboard state section for director awareness
  81. const whiteboardSection = buildWhiteboardStateForDirector(whiteboardLedger);
  82. // Build student profile section for director awareness
  83. const studentProfileSection =
  84. userProfile?.nickname || userProfile?.bio
  85. ? `
  86. # Student Profile
  87. Student name: ${userProfile.nickname || 'Unknown'}
  88. ${userProfile.bio ? `Background: ${userProfile.bio}` : ''}
  89. `
  90. : '';
  91. 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.
  92. # Available Agents
  93. ${agentList}
  94. # Agents Who Already Spoke This Round
  95. ${respondedList}
  96. # Conversation Context
  97. ${conversationSummary}
  98. ${discussionSection}${whiteboardSection}${studentProfileSection}
  99. # Rules
  100. ${rule1}
  101. 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).
  102. 3. Do NOT repeat an agent who already spoke this round unless absolutely necessary.
  103. 4. If the conversation seems complete (question answered, topic covered), output END.
  104. 5. Current turn: ${turnCount + 1}. Consider conversation length — don't let discussions drag on unnecessarily.
  105. 6. Prefer brevity — 1-2 agents responding is usually enough. Don't force every agent to speak.
  106. 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.
  107. 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.
  108. 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.
  109. # Routing Quality (CRITICAL)
  110. - 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.
  111. - 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.
  112. - 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.
  113. - GREETING RULE: If any agent has already greeted the students, no subsequent agent should greet again. Check the previews for greetings.
  114. # Output Format
  115. You MUST output ONLY a JSON object, nothing else:
  116. {"next_agent":"<agent_id>"}
  117. or
  118. {"next_agent":"USER"}
  119. or
  120. {"next_agent":"END"}`;
  121. }
  122. /**
  123. * Summarize a single agent's whiteboard actions into a compact description.
  124. */
  125. function summarizeAgentWhiteboardActions(actions: WhiteboardActionRecord[]): string {
  126. if (!actions || actions.length === 0) return '';
  127. const parts: string[] = [];
  128. for (const a of actions) {
  129. switch (a.actionName) {
  130. case 'wb_draw_text': {
  131. const content = String(a.params.content || '').slice(0, 30);
  132. parts.push(`drew text "${content}${content.length >= 30 ? '...' : ''}"`);
  133. break;
  134. }
  135. case 'wb_draw_shape':
  136. parts.push(`drew shape(${a.params.type || 'rectangle'})`);
  137. break;
  138. case 'wb_draw_chart': {
  139. const labels = Array.isArray(a.params.labels)
  140. ? a.params.labels
  141. : (a.params.data as Record<string, unknown>)?.labels;
  142. const chartType = a.params.chartType || a.params.type || 'bar';
  143. parts.push(
  144. `drew chart(${chartType}${labels ? `, labels: [${(labels as string[]).slice(0, 4).join(',')}]` : ''})`,
  145. );
  146. break;
  147. }
  148. case 'wb_draw_latex': {
  149. const latex = String(a.params.latex || '').slice(0, 30);
  150. parts.push(`drew formula "${latex}${latex.length >= 30 ? '...' : ''}"`);
  151. break;
  152. }
  153. case 'wb_draw_table': {
  154. const data = a.params.data as unknown[][] | undefined;
  155. const rows = data?.length || 0;
  156. const cols = (data?.[0] as unknown[])?.length || 0;
  157. parts.push(`drew table(${rows}×${cols})`);
  158. break;
  159. }
  160. case 'wb_draw_line': {
  161. const pts = a.params.points as string[] | undefined;
  162. const hasArrow = pts?.includes('arrow') ? ' arrow' : '';
  163. parts.push(`drew${hasArrow} line`);
  164. break;
  165. }
  166. case 'wb_clear':
  167. parts.push('CLEARED whiteboard');
  168. break;
  169. case 'wb_delete':
  170. parts.push(`deleted element "${a.params.elementId}"`);
  171. break;
  172. case 'wb_open':
  173. case 'wb_close':
  174. // Skip open/close from summary — they're structural, not content
  175. break;
  176. }
  177. }
  178. return parts.join(', ');
  179. }
  180. /**
  181. * Replay the whiteboard ledger to compute current element count and contributors.
  182. */
  183. export function summarizeWhiteboardForDirector(ledger: WhiteboardActionRecord[]): {
  184. elementCount: number;
  185. contributors: string[];
  186. } {
  187. let elementCount = 0;
  188. const contributorSet = new Set<string>();
  189. for (const record of ledger) {
  190. if (record.actionName === 'wb_clear') {
  191. elementCount = 0;
  192. // Don't reset contributors — they still participated
  193. } else if (record.actionName === 'wb_delete') {
  194. elementCount = Math.max(0, elementCount - 1);
  195. } else if (record.actionName.startsWith('wb_draw_')) {
  196. elementCount++;
  197. contributorSet.add(record.agentName);
  198. }
  199. }
  200. return {
  201. elementCount,
  202. contributors: Array.from(contributorSet),
  203. };
  204. }
  205. /**
  206. * Build the whiteboard state section for the director prompt.
  207. * Returns empty string if there are no whiteboard actions.
  208. */
  209. function buildWhiteboardStateForDirector(ledger?: WhiteboardActionRecord[]): string {
  210. if (!ledger || ledger.length === 0) return '';
  211. const { elementCount, contributors } = summarizeWhiteboardForDirector(ledger);
  212. const crowdedWarning =
  213. elementCount > 5
  214. ? '\n⚠ The whiteboard is getting crowded. Consider routing to an agent that will organize or clear it rather than adding more.'
  215. : '';
  216. return `
  217. # Whiteboard State
  218. Elements on whiteboard: ${elementCount}
  219. Contributors: ${contributors.length > 0 ? contributors.join(', ') : 'none'}${crowdedWarning}
  220. `;
  221. }
  222. /**
  223. * Parse the director's decision from its response
  224. *
  225. * @param content - Raw LLM response content
  226. * @returns Parsed decision with nextAgentId and shouldEnd flag
  227. */
  228. export function parseDirectorDecision(content: string): {
  229. nextAgentId: string | null;
  230. shouldEnd: boolean;
  231. } {
  232. try {
  233. // Try to extract JSON from the response
  234. const jsonMatch = content.match(/\{[\s\S]*?"next_agent"[\s\S]*?\}/);
  235. if (jsonMatch) {
  236. const parsed = JSON.parse(jsonMatch[0]);
  237. const nextAgent = parsed.next_agent;
  238. if (!nextAgent || nextAgent === 'END') {
  239. return { nextAgentId: null, shouldEnd: true };
  240. }
  241. return { nextAgentId: nextAgent, shouldEnd: false };
  242. }
  243. } catch (_e) {
  244. log.warn('[Director] Failed to parse decision:', content.slice(0, 200));
  245. }
  246. // Default: end the round if we can't parse
  247. return { nextAgentId: null, shouldEnd: true };
  248. }