Spaces:
Running
Running
| import { NextResponse } from "next/server"; | |
| import { z } from "zod"; | |
| import type { | |
| ParsedCommand, | |
| ResolvedChatContext, | |
| WorkPackage, | |
| } from "@/lib/work-package-types"; | |
| import { SIMULATED_EXECUTION_DISCLAIMER } from "@/lib/work-package-types"; | |
| import { runMockAgent } from "@/lib/mock-agent"; | |
| import type { ChatResponse } from "@/lib/board-actions"; | |
| import { | |
| buildExecutionPrompt, | |
| buildRealExecutionResponse, | |
| REAL_AUTOMATION_DISCLAIMER, | |
| } from "@/lib/automation-agent"; | |
| import { generateLlmText } from "@/lib/llm-client"; | |
| import { | |
| DEFAULT_LLM_BASE_URL, | |
| DEFAULT_LLM_MODEL, | |
| isLlmConfigReady, | |
| normalizeLlmConfig, | |
| } from "@/lib/llm-config"; | |
| import { compactTracePreview } from "@/lib/chat-trace"; | |
| import { recoverInvalidLlmResponse } from "@/lib/llm-response"; | |
| import { | |
| buildResolvedContext, | |
| buildThinkingSummary, | |
| } from "@/lib/chat-request-context"; | |
| import { readFile } from "node:fs/promises"; | |
| import path from "node:path"; | |
| const ChatMessageSchema = z.object({ | |
| role: z.enum(["user", "assistant", "system"]), | |
| content: z.string(), | |
| }); | |
| const ParsedCommandSchema = z.object({ | |
| referencedPackageName: z.string().optional(), | |
| mode: z.enum(["ask", "plan", "change", "execute"]).optional(), | |
| instruction: z.string(), | |
| }); | |
| const OutputTypeSchema = z.enum([ | |
| "text", | |
| "table", | |
| "code", | |
| "image_prompt", | |
| "design_brief", | |
| "test_case", | |
| "checklist", | |
| "risk_table", | |
| "bom", | |
| "sbom", | |
| "feature_list", | |
| ]); | |
| const WorkPackageOutputSchema = z.object({ | |
| id: z.string(), | |
| title: z.string(), | |
| type: OutputTypeSchema, | |
| content: z.string(), | |
| createdAt: z.string(), | |
| sourceTaskId: z.string().nullable().optional(), | |
| executionMode: z.enum(["simulated", "real"]), | |
| disclaimer: z.string(), | |
| }); | |
| const BoardActionSchema = z.object({ | |
| type: z.enum(["none", "create", "update", "append_output", "replace_all"]), | |
| workPackageId: z.string().nullable(), | |
| fields: z.record(z.string(), z.unknown()).optional(), | |
| output: WorkPackageOutputSchema.optional(), | |
| }); | |
| const LlmJsonSchema = z.object({ | |
| assistantMessage: z.string(), | |
| boardAction: BoardActionSchema, | |
| }); | |
| const ChatRequestSchema = z.object({ | |
| messages: z.array(ChatMessageSchema), | |
| workPackages: z.array(z.any()), | |
| selectedWorkPackageId: z.string().nullable().optional(), | |
| parsedCommand: ParsedCommandSchema.optional(), | |
| llmConfig: z | |
| .object({ | |
| apiKey: z.string().optional(), | |
| baseUrl: z.string().optional(), | |
| model: z.string().optional(), | |
| }) | |
| .optional(), | |
| }); | |
| function summarizeThinking(args: { | |
| context: ResolvedChatContext; | |
| model?: string; | |
| note?: string; | |
| }) { | |
| return buildThinkingSummary(args); | |
| } | |
| function withRouteMetadata( | |
| payload: ChatResponse, | |
| args: { | |
| context: ResolvedChatContext; | |
| model?: string; | |
| note?: string; | |
| }, | |
| ): ChatResponse { | |
| return { | |
| ...payload, | |
| resolvedContext: args.context, | |
| thinkingSummary: summarizeThinking(args), | |
| }; | |
| } | |
| function guardBoardActionForContext(args: { | |
| payload: z.infer<typeof LlmJsonSchema>; | |
| context: ResolvedChatContext; | |
| }) { | |
| const { payload, context } = args; | |
| const action = payload.boardAction; | |
| if (!action) { | |
| return { payload, note: undefined as string | undefined }; | |
| } | |
| if (context.boardMutationPolicy === "none" && action.type !== "none") { | |
| return { | |
| payload: { | |
| ...payload, | |
| boardAction: { type: "none", workPackageId: null }, | |
| }, | |
| note: "Blocked a board update for an ask-only request.", | |
| }; | |
| } | |
| if (context.boardMutationPolicy !== "selected_package_only") { | |
| return { payload, note: undefined as string | undefined }; | |
| } | |
| const invalidScopedTarget = | |
| action.type === "replace_all" || | |
| action.type === "create" || | |
| ((action.type === "update" || action.type === "append_output") && | |
| action.workPackageId !== context.workPackageId); | |
| if (invalidScopedTarget) { | |
| return { | |
| payload: { | |
| ...payload, | |
| boardAction: { type: "none", workPackageId: null }, | |
| }, | |
| note: "Blocked a board update that targeted a different work package.", | |
| }; | |
| } | |
| return { payload, note: undefined as string | undefined }; | |
| } | |
| function safeJsonParse(text: string): unknown { | |
| try { | |
| return JSON.parse(text); | |
| } catch { | |
| // Try extracting the first JSON object. | |
| const start = text.indexOf("{"); | |
| const end = text.lastIndexOf("}"); | |
| if (start !== -1 && end !== -1 && end > start) { | |
| const slice = text.slice(start, end + 1); | |
| return JSON.parse(slice); | |
| } | |
| throw new Error("Invalid JSON"); | |
| } | |
| } | |
| async function loadSystemPrompt(): Promise<string> { | |
| // Prefer repo root prompt; fall back to the planning pack copy. | |
| const rootPrompt = path.join(process.cwd(), "prompts/work-package-system-prompt.md"); | |
| try { | |
| return await readFile(rootPrompt, "utf8"); | |
| } catch { | |
| const packPrompt = path.join( | |
| process.cwd(), | |
| "agentic_pm_demo_codex_plans/prompts/work-package-system-prompt.md", | |
| ); | |
| return await readFile(packPrompt, "utf8"); | |
| } | |
| } | |
| function ensureExecuteDisclaimer(payload: z.infer<typeof LlmJsonSchema>) { | |
| const out = payload.boardAction?.output; | |
| if (!out) return payload; | |
| if (!out.disclaimer) { | |
| out.disclaimer = | |
| out.executionMode === "real" | |
| ? REAL_AUTOMATION_DISCLAIMER | |
| : SIMULATED_EXECUTION_DISCLAIMER; | |
| } | |
| return payload; | |
| } | |
| function resolveLlmConfig( | |
| llmConfig?: { apiKey?: string; baseUrl?: string; model?: string } | null, | |
| ) { | |
| if (isLlmConfigReady(llmConfig)) { | |
| return normalizeLlmConfig(llmConfig); | |
| } | |
| const fromEnv = normalizeLlmConfig({ | |
| apiKey: process.env.LLM_API_KEY, | |
| baseUrl: process.env.LLM_API_BASE_URL || DEFAULT_LLM_BASE_URL, | |
| model: process.env.LLM_MODEL || DEFAULT_LLM_MODEL, | |
| }); | |
| return isLlmConfigReady(fromEnv) ? fromEnv : null; | |
| } | |
| function latestUserText(messages: Array<{ role: string; content: string }>) { | |
| return [...messages].reverse().find((message) => message.role === "user")?.content; | |
| } | |
| function buildMockFallbackResponse(args: { | |
| messages: Array<{ role: "user" | "assistant" | "system"; content: string }>; | |
| workPackages: WorkPackage[]; | |
| selectedWorkPackageId?: string | null; | |
| parsedCommand?: ParsedCommand; | |
| }) { | |
| const { messages, workPackages, selectedWorkPackageId, parsedCommand } = args; | |
| if (!parsedCommand?.mode) { | |
| return undefined; | |
| } | |
| return runMockAgent({ | |
| messages: messages.map((message, index) => ({ | |
| id: `fallback-msg-${index}`, | |
| role: message.role === "system" ? "assistant" : message.role, | |
| content: message.content, | |
| createdAt: new Date().toISOString(), | |
| })), | |
| workPackages, | |
| selectedWorkPackageId: selectedWorkPackageId ?? undefined, | |
| parsedCommand, | |
| }); | |
| } | |
| export async function POST(req: Request) { | |
| const json = await req.json().catch(() => null); | |
| const parsedReq = ChatRequestSchema.safeParse(json); | |
| if (!parsedReq.success) { | |
| return NextResponse.json( | |
| { assistantMessage: "Bad request.", boardAction: { type: "none", workPackageId: null } }, | |
| { status: 400 }, | |
| ); | |
| } | |
| const { | |
| messages, | |
| workPackages, | |
| selectedWorkPackageId, | |
| parsedCommand, | |
| llmConfig, | |
| } = parsedReq.data; | |
| const resolvedConfig = resolveLlmConfig(llmConfig); | |
| const currentWorkPackages = workPackages as WorkPackage[]; | |
| const selectedWp = | |
| currentWorkPackages.find((wp) => wp.id === selectedWorkPackageId) ?? null; | |
| const mockContext = buildResolvedContext({ | |
| parsedCommand: parsedCommand as ParsedCommand | undefined, | |
| shouldAutomateBoard: !parsedCommand?.mode, | |
| selectedWorkPackage: selectedWp, | |
| provider: "mock", | |
| }); | |
| const liveContext = buildResolvedContext({ | |
| parsedCommand: parsedCommand as ParsedCommand | undefined, | |
| shouldAutomateBoard: !parsedCommand?.mode, | |
| selectedWorkPackage: selectedWp, | |
| provider: "live", | |
| }); | |
| // Mock-first: if no key is present, stay local. | |
| if (!resolvedConfig) { | |
| const resp = runMockAgent({ | |
| messages: messages.map((message, index) => ({ | |
| id: `server-msg-${index}`, | |
| role: message.role === "system" ? "assistant" : message.role, | |
| content: message.content, | |
| createdAt: new Date().toISOString(), | |
| })), | |
| workPackages: workPackages as WorkPackage[], | |
| selectedWorkPackageId: selectedWorkPackageId ?? undefined, | |
| parsedCommand: parsedCommand as ParsedCommand | undefined, | |
| }); | |
| return NextResponse.json( | |
| withRouteMetadata( | |
| { | |
| ...resp, | |
| trace: { | |
| provider: "mock", | |
| model: "mock-agent", | |
| requestPreview: compactTracePreview( | |
| latestUserText(messages) || "No user message.", | |
| ), | |
| responsePreview: compactTracePreview(resp.assistantMessage), | |
| }, | |
| } satisfies ChatResponse, | |
| { | |
| context: mockContext, | |
| }, | |
| ), | |
| ); | |
| } | |
| const systemPrompt = await loadSystemPrompt(); | |
| const userText = latestUserText(messages) || ""; | |
| if (parsedCommand?.mode === "execute") { | |
| const targetPackage = | |
| selectedWp ?? | |
| currentWorkPackages.find( | |
| (wp) => | |
| wp.title.toLowerCase() === | |
| parsedCommand.referencedPackageName?.toLowerCase() || | |
| wp.shortName.toLowerCase() === | |
| parsedCommand.referencedPackageName?.toLowerCase(), | |
| ); | |
| if (!targetPackage) { | |
| return NextResponse.json( | |
| { | |
| assistantMessage: "No matching work package was found for execution.", | |
| boardAction: { type: "none", workPackageId: null }, | |
| } satisfies ChatResponse, | |
| { status: 200 }, | |
| ); | |
| } | |
| try { | |
| const llmResult = await generateLlmText({ | |
| config: resolvedConfig, | |
| systemPrompt: | |
| "You are an expert product development PM and systems engineer. Generate complete, practical, package-specific deliverables in markdown.", | |
| userPrompt: buildExecutionPrompt({ | |
| workPackage: targetPackage, | |
| workPackages: currentWorkPackages, | |
| productIdea: userText, | |
| instruction: parsedCommand.instruction, | |
| }), | |
| temperature: 0.2, | |
| maxTokens: 2800, | |
| }); | |
| return NextResponse.json( | |
| withRouteMetadata( | |
| { | |
| ...buildRealExecutionResponse({ | |
| workPackage: targetPackage, | |
| generatedContent: llmResult.text, | |
| instruction: parsedCommand.instruction, | |
| productIdea: userText, | |
| }), | |
| trace: { | |
| provider: "live", | |
| model: resolvedConfig.model, | |
| endpoint: llmResult.endpoint, | |
| requestPreview: llmResult.requestPreview, | |
| responsePreview: llmResult.responsePreview, | |
| }, | |
| } satisfies ChatResponse, | |
| { | |
| context: liveContext, | |
| model: resolvedConfig.model, | |
| }, | |
| ), | |
| { status: 200 }, | |
| ); | |
| } catch (err) { | |
| return NextResponse.json( | |
| withRouteMetadata( | |
| { | |
| assistantMessage: `Live execution failed. (No board changes applied.)\n\n${String(err)}`, | |
| boardAction: { type: "none", workPackageId: null }, | |
| trace: { | |
| provider: "live", | |
| model: resolvedConfig.model, | |
| responsePreview: compactTracePreview(String(err)), | |
| }, | |
| } satisfies ChatResponse, | |
| { | |
| context: liveContext, | |
| model: resolvedConfig.model, | |
| note: "Provider call failed; preserving the package-scoped fallback.", | |
| }, | |
| ), | |
| { status: 200 }, | |
| ); | |
| } | |
| } | |
| const contextBlock = JSON.stringify( | |
| { | |
| selectedWorkPackageId: selectedWorkPackageId ?? null, | |
| selectedWorkPackage: selectedWp, | |
| parsedCommand: parsedCommand ?? null, | |
| workPackages: currentWorkPackages, | |
| }, | |
| null, | |
| 2, | |
| ); | |
| const conversationBlock = messages | |
| .map((message) => `${message.role.toUpperCase()}: ${message.content}`) | |
| .join("\n\n"); | |
| try { | |
| const llmResult = await generateLlmText({ | |
| config: resolvedConfig, | |
| systemPrompt, | |
| userPrompt: [ | |
| "Return JSON ONLY that matches the required contract.", | |
| "Use the context to decide whether and how the board should change.", | |
| `Context:\n${contextBlock}`, | |
| `Conversation:\n${conversationBlock}`, | |
| ].join("\n\n"), | |
| temperature: 0.2, | |
| maxTokens: 2200, | |
| responseFormat: "json_object", | |
| }); | |
| if (!llmResult.text) { | |
| return NextResponse.json( | |
| withRouteMetadata( | |
| { | |
| assistantMessage: "LLM returned an empty response.", | |
| boardAction: { type: "none", workPackageId: null }, | |
| trace: { | |
| provider: "live", | |
| model: resolvedConfig.model, | |
| endpoint: llmResult.endpoint, | |
| requestPreview: llmResult.requestPreview, | |
| }, | |
| } satisfies ChatResponse, | |
| { | |
| context: liveContext, | |
| model: resolvedConfig.model, | |
| note: "Provider returned an empty response; no board mutation was applied.", | |
| }, | |
| ), | |
| { status: 200 }, | |
| ); | |
| } | |
| let parsedJson: unknown; | |
| try { | |
| parsedJson = safeJsonParse(llmResult.text); | |
| } catch { | |
| const recovered = recoverInvalidLlmResponse({ | |
| rawText: llmResult.text, | |
| parsedCommand: parsedCommand as ParsedCommand | undefined, | |
| fallbackResponse: buildMockFallbackResponse({ | |
| messages, | |
| workPackages: currentWorkPackages, | |
| selectedWorkPackageId, | |
| parsedCommand: parsedCommand as ParsedCommand | undefined, | |
| }), | |
| }); | |
| return NextResponse.json( | |
| withRouteMetadata( | |
| { | |
| ...recovered, | |
| trace: { | |
| provider: "live", | |
| model: resolvedConfig.model, | |
| endpoint: llmResult.endpoint, | |
| requestPreview: llmResult.requestPreview, | |
| responsePreview: llmResult.responsePreview, | |
| }, | |
| } satisfies ChatResponse, | |
| { | |
| context: liveContext, | |
| model: resolvedConfig.model, | |
| note: | |
| parsedCommand?.mode === "ask" | |
| ? "Provider returned non-JSON output; preserving assistant text and skipping board mutation." | |
| : "Provider returned non-JSON output; preserving the package-scoped fallback.", | |
| }, | |
| ), | |
| { status: 200 }, | |
| ); | |
| } | |
| const validated = LlmJsonSchema.safeParse(parsedJson); | |
| if (!validated.success) { | |
| const recovered = recoverInvalidLlmResponse({ | |
| rawText: llmResult.text, | |
| parsedCommand: parsedCommand as ParsedCommand | undefined, | |
| fallbackResponse: buildMockFallbackResponse({ | |
| messages, | |
| workPackages: currentWorkPackages, | |
| selectedWorkPackageId, | |
| parsedCommand: parsedCommand as ParsedCommand | undefined, | |
| }), | |
| }); | |
| return NextResponse.json( | |
| withRouteMetadata( | |
| { | |
| ...recovered, | |
| trace: { | |
| provider: "live", | |
| model: resolvedConfig.model, | |
| endpoint: llmResult.endpoint, | |
| requestPreview: llmResult.requestPreview, | |
| responsePreview: llmResult.responsePreview, | |
| }, | |
| } satisfies ChatResponse, | |
| { | |
| context: liveContext, | |
| model: resolvedConfig.model, | |
| note: | |
| parsedCommand?.mode === "ask" | |
| ? "Provider returned invalid JSON; preserving assistant text and skipping board mutation." | |
| : "Provider returned invalid JSON; preserving the package-scoped fallback.", | |
| }, | |
| ), | |
| { status: 200 }, | |
| ); | |
| } | |
| const payload = ensureExecuteDisclaimer(validated.data); | |
| const guarded = guardBoardActionForContext({ | |
| payload, | |
| context: liveContext, | |
| }); | |
| const guardedResponse: ChatResponse = { | |
| assistantMessage: guarded.payload.assistantMessage, | |
| boardAction: guarded.payload.boardAction as ChatResponse["boardAction"], | |
| trace: { | |
| provider: "live", | |
| model: resolvedConfig.model, | |
| endpoint: llmResult.endpoint, | |
| requestPreview: llmResult.requestPreview, | |
| responsePreview: llmResult.responsePreview, | |
| }, | |
| }; | |
| return NextResponse.json( | |
| withRouteMetadata(guardedResponse, { | |
| context: liveContext, | |
| model: resolvedConfig.model, | |
| note: guarded.note, | |
| }), | |
| ); | |
| } catch (err) { | |
| const fallback = buildMockFallbackResponse({ | |
| messages, | |
| workPackages: currentWorkPackages, | |
| selectedWorkPackageId, | |
| parsedCommand: parsedCommand as ParsedCommand | undefined, | |
| }); | |
| if (fallback) { | |
| return NextResponse.json( | |
| withRouteMetadata( | |
| { | |
| ...fallback, | |
| trace: { | |
| provider: "live", | |
| model: resolvedConfig.model, | |
| responsePreview: compactTracePreview(String(err)), | |
| }, | |
| } satisfies ChatResponse, | |
| { | |
| context: liveContext, | |
| model: resolvedConfig.model, | |
| note: "Provider call failed; preserving the package-scoped fallback.", | |
| }, | |
| ), | |
| { status: 200 }, | |
| ); | |
| } | |
| return NextResponse.json( | |
| withRouteMetadata( | |
| { | |
| assistantMessage: `Network or parsing error calling LLM. (No board changes applied.)\n\n${String(err)}`, | |
| boardAction: { type: "none", workPackageId: null }, | |
| trace: { | |
| provider: "live", | |
| model: resolvedConfig.model, | |
| responsePreview: compactTracePreview(String(err)), | |
| }, | |
| } satisfies ChatResponse, | |
| { | |
| context: liveContext, | |
| model: resolvedConfig.model, | |
| note: "Provider call failed; no safe board fallback was available.", | |
| }, | |
| ), | |
| { status: 200 }, | |
| ); | |
| } | |
| } | |