diff --git a/apps/server/src/lib/sdk-options.ts b/apps/server/src/lib/sdk-options.ts index 1ff16c25..361d332e 100644 --- a/apps/server/src/lib/sdk-options.ts +++ b/apps/server/src/lib/sdk-options.ts @@ -58,13 +58,13 @@ export const TOOL_PRESETS = { */ export const MAX_TURNS = { /** Quick operations that shouldn't need many iterations */ - quick: 5, + quick: 50, /** Standard operations */ - standard: 20, + standard: 100, /** Long-running operations like full spec generation */ - extended: 50, + extended: 250, /** Very long operations that may require extensive exploration */ maximum: 1000, @@ -143,6 +143,12 @@ export interface CreateSdkOptionsConfig { /** Optional abort controller for cancellation */ abortController?: AbortController; + + /** Optional output format for structured outputs */ + outputFormat?: { + type: "json_schema"; + schema: Record; + }; } /** @@ -194,7 +200,7 @@ export function createFeatureGenerationOptions( * * Configuration: * - Uses read-only tools for analysis - * - Quick turns for focused suggestions + * - Standard turns to allow thorough codebase exploration and structured output generation * - Opus model by default for thorough analysis */ export function createSuggestionsOptions( @@ -203,11 +209,12 @@ export function createSuggestionsOptions( return { ...getBaseOptions(), model: getModelForUseCase("suggestions", config.model), - maxTurns: MAX_TURNS.quick, + maxTurns: MAX_TURNS.extended, // Increased from quick (5) to standard (20) to allow codebase exploration + structured output cwd: config.cwd, allowedTools: [...TOOL_PRESETS.readOnly], ...(config.systemPrompt && { systemPrompt: config.systemPrompt }), ...(config.abortController && { abortController: config.abortController }), + ...(config.outputFormat && { outputFormat: config.outputFormat }), }; } diff --git a/apps/server/src/routes/suggestions/generate-suggestions.ts b/apps/server/src/routes/suggestions/generate-suggestions.ts index 5cc5abef..d5972be8 100644 --- a/apps/server/src/routes/suggestions/generate-suggestions.ts +++ b/apps/server/src/routes/suggestions/generate-suggestions.ts @@ -9,6 +9,39 @@ import { createSuggestionsOptions } from "../../lib/sdk-options.js"; const logger = createLogger("Suggestions"); +/** + * JSON Schema for suggestions output + */ +const suggestionsSchema = { + type: "object", + properties: { + suggestions: { + type: "array", + items: { + type: "object", + properties: { + id: { type: "string" }, + category: { type: "string" }, + description: { type: "string" }, + steps: { + type: "array", + items: { type: "string" }, + }, + priority: { + type: "number", + minimum: 1, + maximum: 3, + }, + reasoning: { type: "string" }, + }, + required: ["category", "description", "steps", "priority", "reasoning"], + }, + }, + }, + required: ["suggestions"], + additionalProperties: false, +}; + export async function generateSuggestions( projectPath: string, suggestionType: string, @@ -36,19 +69,7 @@ For each suggestion, provide: 4. Priority (1=high, 2=medium, 3=low) 5. Brief reasoning for why this would help -Format your response as JSON: -{ - "suggestions": [ - { - "id": "suggestion-123", - "category": "Category", - "description": "What to implement", - "steps": ["Step 1", "Step 2"], - "priority": 1, - "reasoning": "Why this helps" - } - ] -}`; +The response will be automatically formatted as structured JSON.`; events.emit("suggestions:event", { type: "suggestions_progress", @@ -58,16 +79,21 @@ Format your response as JSON: const options = createSuggestionsOptions({ cwd: projectPath, abortController, + outputFormat: { + type: "json_schema", + schema: suggestionsSchema, + }, }); const stream = query({ prompt, options }); let responseText = ""; + let structuredOutput: { suggestions: Array> } | null = null; for await (const msg of stream) { if (msg.type === "assistant" && msg.message.content) { for (const block of msg.message.content) { if (block.type === "text") { - responseText = block.text; + responseText += block.text; events.emit("suggestions:event", { type: "suggestions_progress", content: block.text, @@ -81,18 +107,34 @@ Format your response as JSON: } } } else if (msg.type === "result" && msg.subtype === "success") { - responseText = msg.result || responseText; + // Check for structured output + const resultMsg = msg as any; + if (resultMsg.structured_output) { + structuredOutput = resultMsg.structured_output as { + suggestions: Array>; + }; + logger.debug("Received structured output:", structuredOutput); + } + } else if (msg.type === "result") { + const resultMsg = msg as any; + if (resultMsg.subtype === "error_max_structured_output_retries") { + logger.error("Failed to produce valid structured output after retries"); + throw new Error("Could not produce valid suggestions output"); + } else if (resultMsg.subtype === "error_max_turns") { + logger.error("Hit max turns limit before completing suggestions generation"); + logger.warn(`Response text length: ${responseText.length} chars`); + // Still try to parse what we have + } } } - // Parse suggestions from response + // Use structured output if available, otherwise fall back to parsing text try { - const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/); - if (jsonMatch) { - const parsed = JSON.parse(jsonMatch[0]); + if (structuredOutput && structuredOutput.suggestions) { + // Use structured output directly events.emit("suggestions:event", { type: "suggestions_complete", - suggestions: parsed.suggestions.map( + suggestions: structuredOutput.suggestions.map( (s: Record, i: number) => ({ ...s, id: s.id || `suggestion-${Date.now()}-${i}`, @@ -100,7 +142,23 @@ Format your response as JSON: ), }); } else { - throw new Error("No valid JSON found in response"); + // Fallback: try to parse from text (for backwards compatibility) + logger.warn("No structured output received, attempting to parse from text"); + const jsonMatch = responseText.match(/\{[\s\S]*"suggestions"[\s\S]*\}/); + if (jsonMatch) { + const parsed = JSON.parse(jsonMatch[0]); + events.emit("suggestions:event", { + type: "suggestions_complete", + suggestions: parsed.suggestions.map( + (s: Record, i: number) => ({ + ...s, + id: s.id || `suggestion-${Date.now()}-${i}`, + }) + ), + }); + } else { + throw new Error("No valid JSON found in response"); + } } } catch (error) { // Log the parsing error for debugging diff --git a/apps/server/tests/unit/lib/sdk-options.test.ts b/apps/server/tests/unit/lib/sdk-options.test.ts index 1cb2be26..4187dd08 100644 --- a/apps/server/tests/unit/lib/sdk-options.test.ts +++ b/apps/server/tests/unit/lib/sdk-options.test.ts @@ -40,9 +40,9 @@ describe("sdk-options.ts", () => { describe("MAX_TURNS", () => { it("should export turn presets", async () => { const { MAX_TURNS } = await import("@/lib/sdk-options.js"); - expect(MAX_TURNS.quick).toBe(5); - expect(MAX_TURNS.standard).toBe(20); - expect(MAX_TURNS.extended).toBe(50); + expect(MAX_TURNS.quick).toBe(50); + expect(MAX_TURNS.standard).toBe(100); + expect(MAX_TURNS.extended).toBe(250); expect(MAX_TURNS.maximum).toBe(1000); }); }); @@ -141,7 +141,7 @@ describe("sdk-options.ts", () => { const options = createSuggestionsOptions({ cwd: "/test/path" }); expect(options.cwd).toBe("/test/path"); - expect(options.maxTurns).toBe(MAX_TURNS.quick); + expect(options.maxTurns).toBe(MAX_TURNS.extended); expect(options.allowedTools).toEqual([...TOOL_PRESETS.readOnly]); }); });