240 lines
8.0 KiB
TypeScript
240 lines
8.0 KiB
TypeScript
/**
|
||
* 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<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_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<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 };
|
||
}
|