generate-pbl.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. /**
  2. * PBL Generation - Agentic Loop using Vercel AI SDK
  3. *
  4. * Core generation engine that designs a complete PBL project through
  5. * multi-step tool calling with generateText + stopWhen.
  6. *
  7. * Replaces PBL-Nano's Anthropic SDK direct calls with Vercel AI SDK
  8. * for multi-model compatibility.
  9. */
  10. import { tool, stepCountIs } from 'ai';
  11. import { callLLM } from '@/lib/ai/llm';
  12. import { z } from 'zod';
  13. import type { LanguageModel } from 'ai';
  14. import type { PBLProjectConfig } from './types';
  15. import { ModeMCP } from './mcp/mode-mcp';
  16. import { ProjectMCP } from './mcp/project-mcp';
  17. import { AgentMCP } from './mcp/agent-mcp';
  18. import { IssueboardMCP } from './mcp/issueboard-mcp';
  19. import { buildPBLSystemPrompt } from './pbl-system-prompt';
  20. import type { PBLMode } from './types';
  21. export interface GeneratePBLConfig {
  22. projectTopic: string;
  23. projectDescription: string;
  24. targetSkills: string[];
  25. issueCount?: number;
  26. language: string;
  27. }
  28. export interface GeneratePBLCallbacks {
  29. onProgress?: (message: string) => void;
  30. }
  31. /**
  32. * Generate a complete PBL project configuration using an agentic loop.
  33. *
  34. * Uses Vercel AI SDK's generateText with tools and stopWhen to drive
  35. * a multi-step conversation where the LLM designs the project by
  36. * calling MCP tools.
  37. */
  38. export async function generatePBLContent(
  39. config: GeneratePBLConfig,
  40. model: LanguageModel,
  41. callbacks?: GeneratePBLCallbacks,
  42. ): Promise<PBLProjectConfig> {
  43. const { language } = config;
  44. // Initialize shared state
  45. const projectConfig: PBLProjectConfig = {
  46. projectInfo: { title: '', description: '' },
  47. agents: [],
  48. issueboard: { agent_ids: [], issues: [], current_issue_id: null },
  49. chat: { messages: [] },
  50. };
  51. // Create MCP instances operating on shared state
  52. const modeMCP = new ModeMCP(
  53. ['project_info', 'agent', 'issueboard', 'idle'] as PBLMode[],
  54. 'project_info' as PBLMode,
  55. );
  56. const projectMCP = new ProjectMCP(projectConfig);
  57. const agentMCP = new AgentMCP(projectConfig);
  58. const issueboardMCP = new IssueboardMCP(projectConfig, agentMCP, language);
  59. callbacks?.onProgress?.('Starting PBL project generation...');
  60. // Define tools with Zod schemas, delegating to MCP instances
  61. const pblTools = {
  62. set_mode: tool({
  63. description:
  64. 'Switch the current working mode. Available modes: project_info, agent, issueboard, idle.',
  65. inputSchema: z.object({
  66. mode: z.enum(['project_info', 'agent', 'issueboard', 'idle']),
  67. }),
  68. execute: async ({ mode }) => modeMCP.setMode(mode as PBLMode),
  69. }),
  70. // Project info tools
  71. get_project_info: tool({
  72. description:
  73. 'Get the current project information (title and description). Requires project_info mode.',
  74. inputSchema: z.object({}),
  75. execute: async () => {
  76. if (modeMCP.getCurrentMode() !== 'project_info') {
  77. return { success: false, error: 'Must be in project_info mode.' };
  78. }
  79. return projectMCP.getProjectInfo();
  80. },
  81. }),
  82. update_title: tool({
  83. description: 'Update the project title. Requires project_info mode.',
  84. inputSchema: z.object({
  85. title: z.string().describe('The new project title'),
  86. }),
  87. execute: async ({ title }) => {
  88. if (modeMCP.getCurrentMode() !== 'project_info') {
  89. return { success: false, error: 'Must be in project_info mode.' };
  90. }
  91. return projectMCP.updateTitle(title);
  92. },
  93. }),
  94. update_description: tool({
  95. description: 'Update the project description. Requires project_info mode.',
  96. inputSchema: z.object({
  97. description: z.string().describe('The new project description'),
  98. }),
  99. execute: async ({ description }) => {
  100. if (modeMCP.getCurrentMode() !== 'project_info') {
  101. return { success: false, error: 'Must be in project_info mode.' };
  102. }
  103. return projectMCP.updateDescription(description);
  104. },
  105. }),
  106. // Agent tools
  107. list_project_agents: tool({
  108. description: 'List all agent roles defined for the project. Requires agent mode.',
  109. inputSchema: z.object({}),
  110. execute: async () => {
  111. if (modeMCP.getCurrentMode() !== 'agent') {
  112. return { success: false, error: 'Must be in agent mode.' };
  113. }
  114. return agentMCP.listAgents();
  115. },
  116. }),
  117. create_agent: tool({
  118. description: 'Create a new agent role for the project. Requires agent mode.',
  119. inputSchema: z.object({
  120. name: z.string().describe('Agent name (e.g., "Data Analyst", "Project Manager")'),
  121. system_prompt: z.string().describe("System prompt describing the agent's responsibilities"),
  122. default_mode: z.string().describe('Default environment mode (e.g., "chat")'),
  123. actor_role: z.string().optional().describe('Role description'),
  124. role_division: z
  125. .enum(['management', 'development'])
  126. .optional()
  127. .describe('Role division (default: development)'),
  128. }),
  129. execute: async (params) => {
  130. if (modeMCP.getCurrentMode() !== 'agent') {
  131. return { success: false, error: 'Must be in agent mode.' };
  132. }
  133. return agentMCP.createAgent(params);
  134. },
  135. }),
  136. update_agent: tool({
  137. description: "Update an agent role's properties. Requires agent mode.",
  138. inputSchema: z.object({
  139. name: z.string().describe('The agent name to update'),
  140. new_name: z.string().optional().describe('New agent name'),
  141. system_prompt: z.string().optional().describe('New system prompt'),
  142. default_mode: z.string().optional().describe('New default mode'),
  143. actor_role: z.string().optional().describe('New role description'),
  144. role_division: z.enum(['management', 'development']).optional(),
  145. }),
  146. execute: async (params) => {
  147. if (modeMCP.getCurrentMode() !== 'agent') {
  148. return { success: false, error: 'Must be in agent mode.' };
  149. }
  150. return agentMCP.updateAgent(params);
  151. },
  152. }),
  153. delete_agent: tool({
  154. description: 'Delete an agent role. Requires agent mode.',
  155. inputSchema: z.object({
  156. name: z.string().describe('The agent name to delete'),
  157. }),
  158. execute: async ({ name }) => {
  159. if (modeMCP.getCurrentMode() !== 'agent') {
  160. return { success: false, error: 'Must be in agent mode.' };
  161. }
  162. return agentMCP.deleteAgent(name);
  163. },
  164. }),
  165. // Issueboard tools
  166. create_issueboard: tool({
  167. description: 'Create/reset the issueboard. Requires issueboard mode.',
  168. inputSchema: z.object({}),
  169. execute: async () => {
  170. if (modeMCP.getCurrentMode() !== 'issueboard') {
  171. return { success: false, error: 'Must be in issueboard mode.' };
  172. }
  173. return issueboardMCP.createIssueboard();
  174. },
  175. }),
  176. get_issueboard: tool({
  177. description: 'Get the current issueboard configuration. Requires issueboard mode.',
  178. inputSchema: z.object({}),
  179. execute: async () => {
  180. if (modeMCP.getCurrentMode() !== 'issueboard') {
  181. return { success: false, error: 'Must be in issueboard mode.' };
  182. }
  183. return issueboardMCP.getIssueboard();
  184. },
  185. }),
  186. update_issueboard_agents: tool({
  187. description: 'Update the agent list for the issueboard. Requires issueboard mode.',
  188. inputSchema: z.object({
  189. agent_ids: z.array(z.string()).describe('List of agent names to assign'),
  190. }),
  191. execute: async ({ agent_ids }) => {
  192. if (modeMCP.getCurrentMode() !== 'issueboard') {
  193. return { success: false, error: 'Must be in issueboard mode.' };
  194. }
  195. return issueboardMCP.updateIssueboardAgents(agent_ids);
  196. },
  197. }),
  198. create_issue: tool({
  199. description:
  200. 'Create a new issue in the issueboard. Automatically creates Question and Judge agents. Requires issueboard mode.',
  201. inputSchema: z.object({
  202. title: z.string().describe('Issue title'),
  203. description: z.string().describe('Issue description'),
  204. person_in_charge: z.string().describe('Person responsible (use an agent role name)'),
  205. participants: z.array(z.string()).optional().describe('Participant names'),
  206. notes: z.string().optional().describe('Additional notes'),
  207. parent_issue: z.string().nullable().optional().describe('Parent issue ID for sub-issues'),
  208. index: z.number().optional().describe('Order index'),
  209. }),
  210. execute: async (params) => {
  211. if (modeMCP.getCurrentMode() !== 'issueboard') {
  212. return { success: false, error: 'Must be in issueboard mode.' };
  213. }
  214. return issueboardMCP.createIssue(params);
  215. },
  216. }),
  217. list_issues: tool({
  218. description: 'List all issues in the issueboard. Requires issueboard mode.',
  219. inputSchema: z.object({}),
  220. execute: async () => {
  221. if (modeMCP.getCurrentMode() !== 'issueboard') {
  222. return { success: false, error: 'Must be in issueboard mode.' };
  223. }
  224. return issueboardMCP.listIssues();
  225. },
  226. }),
  227. update_issue: tool({
  228. description: 'Update an existing issue. Requires issueboard mode.',
  229. inputSchema: z.object({
  230. issue_id: z.string().describe('The issue ID to update'),
  231. title: z.string().optional(),
  232. description: z.string().optional(),
  233. person_in_charge: z.string().optional(),
  234. participants: z.array(z.string()).optional(),
  235. notes: z.string().optional(),
  236. parent_issue: z.string().nullable().optional(),
  237. index: z.number().optional(),
  238. }),
  239. execute: async (params) => {
  240. if (modeMCP.getCurrentMode() !== 'issueboard') {
  241. return { success: false, error: 'Must be in issueboard mode.' };
  242. }
  243. return issueboardMCP.updateIssue(params);
  244. },
  245. }),
  246. delete_issue: tool({
  247. description: 'Delete an issue and its sub-issues. Requires issueboard mode.',
  248. inputSchema: z.object({
  249. issue_id: z.string().describe('The issue ID to delete'),
  250. }),
  251. execute: async ({ issue_id }) => {
  252. if (modeMCP.getCurrentMode() !== 'issueboard') {
  253. return { success: false, error: 'Must be in issueboard mode.' };
  254. }
  255. return issueboardMCP.deleteIssue(issue_id);
  256. },
  257. }),
  258. reorder_issues: tool({
  259. description: 'Reorder issues. Requires issueboard mode.',
  260. inputSchema: z.object({
  261. issue_ids: z.array(z.string()).describe('Issue IDs in desired order'),
  262. }),
  263. execute: async ({ issue_ids }) => {
  264. if (modeMCP.getCurrentMode() !== 'issueboard') {
  265. return { success: false, error: 'Must be in issueboard mode.' };
  266. }
  267. return issueboardMCP.reorderIssues(issue_ids);
  268. },
  269. }),
  270. };
  271. // Run the agentic loop
  272. const systemPrompt = buildPBLSystemPrompt(config);
  273. const _result = await callLLM(
  274. {
  275. model,
  276. system: systemPrompt,
  277. prompt:
  278. language === 'zh-CN'
  279. ? `请设计一个PBL项目。现在从 project_info 模式开始,先设置项目标题和描述。`
  280. : `Design a PBL project. Start in project_info mode by setting the project title and description.`,
  281. tools: pblTools,
  282. stopWhen: stepCountIs(30),
  283. onStepFinish: ({ toolCalls, text }) => {
  284. if (text) {
  285. callbacks?.onProgress?.(`Thinking: ${text.slice(0, 100)}...`);
  286. }
  287. if (toolCalls) {
  288. for (const tc of toolCalls) {
  289. callbacks?.onProgress?.(`Tool: ${tc.toolName}`);
  290. }
  291. }
  292. },
  293. },
  294. 'pbl-generate',
  295. );
  296. // Check if mode reached idle; if not, the LLM may have stopped early
  297. if (modeMCP.getCurrentMode() !== 'idle') {
  298. callbacks?.onProgress?.(
  299. 'Warning: Generation did not reach idle mode. Project may be incomplete.',
  300. );
  301. }
  302. callbacks?.onProgress?.('PBL structure generated. Running post-processing...');
  303. // Post-processing: activate first issue and generate initial questions
  304. await postProcessPBL(projectConfig, model, language, callbacks);
  305. callbacks?.onProgress?.('PBL project generation complete!');
  306. return projectConfig;
  307. }
  308. /**
  309. * Post-processing after the agentic loop:
  310. * 1. Activate the first issue
  311. * 2. Generate initial questions for it using the Question Agent
  312. * 3. Add welcome message to chat
  313. */
  314. async function postProcessPBL(
  315. config: PBLProjectConfig,
  316. model: LanguageModel,
  317. language: string,
  318. callbacks?: GeneratePBLCallbacks,
  319. ): Promise<void> {
  320. const { issueboard, agents } = config;
  321. if (issueboard.issues.length === 0) {
  322. return;
  323. }
  324. // Sort by index and activate first
  325. const sortedIssues = [...issueboard.issues].sort((a, b) => a.index - b.index);
  326. const firstIssue = sortedIssues[0];
  327. firstIssue.is_active = true;
  328. issueboard.current_issue_id = firstIssue.id;
  329. callbacks?.onProgress?.(`Activating first issue: ${firstIssue.title}`);
  330. // Generate initial questions for the first issue
  331. const questionAgent = agents.find((a) => a.name === firstIssue.question_agent_name);
  332. if (!questionAgent) {
  333. callbacks?.onProgress?.('Warning: Question agent not found for first issue.');
  334. return;
  335. }
  336. try {
  337. callbacks?.onProgress?.('Generating initial questions for first issue...');
  338. const context =
  339. language === 'zh-CN'
  340. ? `## 任务信息
  341. **标题**: ${firstIssue.title}
  342. **描述**: ${firstIssue.description}
  343. **负责人**: ${firstIssue.person_in_charge}
  344. ${firstIssue.participants.length > 0 ? `**参与者**: ${firstIssue.participants.join('、')}` : ''}
  345. ${firstIssue.notes ? `**备注**: ${firstIssue.notes}` : ''}
  346. ## 你的任务
  347. 根据以上任务信息,生成1-3个具体、可操作的引导问题,帮助学生理解和完成这个任务。每个问题应:
  348. - 引导学生达成关键学习目标
  349. - 具体且可操作
  350. - 帮助分解问题
  351. - 鼓励批判性思考
  352. 请以编号列表格式回答。`
  353. : `## Issue Information
  354. **Title**: ${firstIssue.title}
  355. **Description**: ${firstIssue.description}
  356. **Person in Charge**: ${firstIssue.person_in_charge}
  357. ${firstIssue.participants.length > 0 ? `**Participants**: ${firstIssue.participants.join(', ')}` : ''}
  358. ${firstIssue.notes ? `**Notes**: ${firstIssue.notes}` : ''}
  359. ## Your Task
  360. Based on the issue information above, generate 1-3 specific, actionable questions that will help students understand and complete this issue. Each question should:
  361. - Guide students toward key learning objectives
  362. - Be specific and actionable
  363. - Help break down the problem
  364. - Encourage critical thinking
  365. Format your response as a numbered list.`;
  366. const questionResult = await callLLM(
  367. {
  368. model,
  369. system: questionAgent.system_prompt,
  370. prompt: context,
  371. },
  372. 'pbl-post-process',
  373. );
  374. const generatedQuestions = questionResult.text;
  375. firstIssue.generated_questions = generatedQuestions;
  376. // Add welcome message to chat
  377. const welcomeMessage =
  378. language === 'zh-CN'
  379. ? `你好!我是这个任务的提问助手:"${firstIssue.title}"\n\n为了引导你的学习,我准备了一些问题:\n\n${generatedQuestions}\n\n随时 @question 我来获取帮助或澄清!`
  380. : `Hello! I'm your Question Agent for this issue: "${firstIssue.title}"\n\nTo help guide your work, I've prepared some questions for you:\n\n${generatedQuestions}\n\nFeel free to @question me anytime if you need help or clarification!`;
  381. config.chat.messages.push({
  382. id: `msg_welcome_${Date.now()}`,
  383. agent_name: firstIssue.question_agent_name,
  384. message: welcomeMessage,
  385. timestamp: Date.now(),
  386. read_by: [],
  387. });
  388. callbacks?.onProgress?.('Initial questions generated and welcome message added.');
  389. } catch (error) {
  390. callbacks?.onProgress?.(
  391. `Warning: Failed to generate initial questions: ${error instanceof Error ? error.message : String(error)}`,
  392. );
  393. }
  394. }