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; 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 { // 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) { 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 }, ); } }