@@ -19,6 +19,7 @@ import {
1919 AgentSignals ,
2020} from '@lib/agent/agent-interface' ;
2121import { isAbsolute , resolve , sep } from 'path' ;
22+ import { z } from 'zod' ;
2223import { detectNodePackageManagers } from './package-manager.js' ;
2324import { getSkillsBaseUrl , HAIKU_MODEL } from '@lib/constants' ;
2425import type { WizardSession } from '@lib/wizard-session' ;
@@ -165,15 +166,113 @@ function buildPrompt(
165166 ] . join ( '\n' ) ;
166167}
167168
168- function extractJson ( text : string ) : unknown {
169+ /**
170+ * Zod shape of the agent's RAW detection JSON. Validated before coercion so a
171+ * malformed reply produces actionable, per-field feedback we can hand back to
172+ * the agent for a retry — the security clamping (paths, targetIds, recommended
173+ * dedup) still happens afterwards in coerceAgenticReport.
174+ */
175+ const AgenticProjectJson = z . object ( {
176+ path : z . string ( ) ,
177+ framework : z . string ( ) ,
178+ targetId : z . string ( ) . nullable ( ) ,
179+ hasPostHog : z . boolean ( ) ,
180+ recommended : z . boolean ( ) . optional ( ) ,
181+ } ) ;
182+ const AgenticReportJson = z . object ( {
183+ repoType : z . enum ( [ 'monorepo' , 'single' ] ) ,
184+ projects : z . array ( AgenticProjectJson ) ,
185+ } ) ;
186+
187+ /** How many times the agent is asked to produce a valid report before we give up and fall through best-effort. */
188+ const MAX_DETECTION_ATTEMPTS = 3 ;
189+
190+ type ParseReport =
191+ | { ok : true ; value : unknown }
192+ /**
193+ * `feedback` is the human-readable reason the reply was rejected, fed back
194+ * into the next attempt. `parsedJson` is set whenever JSON.parse itself
195+ * succeeded (only the schema failed), so the caller can still best-effort
196+ * coerce a shape-invalid-but-parseable reply once retries run out.
197+ */
198+ | { ok : false ; feedback : string ; parsedJson ?: unknown } ;
199+
200+ /**
201+ * Locate the JSON object in the agent's output, parse it, and validate it
202+ * against the report schema. NEVER throws — a non-JSON, truncated, or off-shape
203+ * reply comes back as `{ ok: false }` with feedback, so the caller drives the
204+ * retry loop instead of a strict JSON.parse crashing the whole detection.
205+ * Exported for testing.
206+ */
207+ export function parseAgentReport ( text : string ) : ParseReport {
169208 const fenced = text . match ( / ` ` ` (?: j s o n ) ? \s * ( [ \s \S ] * ?) ` ` ` / i) ;
170209 const candidate = fenced ? fenced [ 1 ] : text ;
171210 const start = candidate . indexOf ( '{' ) ;
172211 const end = candidate . lastIndexOf ( '}' ) ;
173212 if ( start === - 1 || end === - 1 || end < start ) {
174- throw new Error ( 'Agent did not return a JSON object' ) ;
213+ return {
214+ ok : false ,
215+ feedback :
216+ 'No JSON object was found in your response. Respond with ONLY a single JSON object.' ,
217+ } ;
175218 }
176- return JSON . parse ( candidate . slice ( start , end + 1 ) ) ;
219+
220+ let parsedJson : unknown ;
221+ try {
222+ parsedJson = JSON . parse ( candidate . slice ( start , end + 1 ) ) ;
223+ } catch ( err ) {
224+ return {
225+ ok : false ,
226+ feedback : `Your response was not valid JSON (${
227+ err instanceof Error ? err . message : String ( err )
228+ } ). Respond with ONLY a single, complete JSON object — no trailing commas, no truncation.`,
229+ } ;
230+ }
231+
232+ const validated = AgenticReportJson . safeParse ( parsedJson ) ;
233+ if ( ! validated . success ) {
234+ const issues = validated . error . issues
235+ . slice ( 0 , 10 )
236+ . map ( ( i ) => `- ${ i . path . join ( '.' ) || '(root)' } : ${ i . message } ` )
237+ . join ( '\n' ) ;
238+ return {
239+ ok : false ,
240+ parsedJson,
241+ feedback : `Your JSON did not match the required shape. Fix these problems:\n${ issues } ` ,
242+ } ;
243+ }
244+
245+ return { ok : true , value : validated . data } ;
246+ }
247+
248+ /**
249+ * Wrap the original task with corrective feedback for a retry. Each attempt is
250+ * a fresh agent conversation, so we restate the failure, show a snippet of the
251+ * rejected reply, and re-attach the full task prompt.
252+ */
253+ function buildRetryPrompt (
254+ basePrompt : string ,
255+ feedback : string ,
256+ previousOutput : string ,
257+ attempt : number ,
258+ ) : string {
259+ const snippet = previousOutput . trim ( ) . slice ( 0 , 600 ) ;
260+ return [
261+ `Your previous detection response could not be used (attempt ${
262+ attempt - 1
263+ } of ${ MAX_DETECTION_ATTEMPTS } ).`,
264+ '' ,
265+ 'What was wrong:' ,
266+ feedback ,
267+ '' ,
268+ ...( snippet
269+ ? [ 'Your previous response began with:' , '"""' , snippet , '"""' , '' ]
270+ : [ ] ) ,
271+ 'Run the detection again and respond with ONLY a single valid JSON object matching the required shape exactly — no prose, no markdown code fences.' ,
272+ '' ,
273+ '--- Original task ---' ,
274+ basePrompt ,
275+ ] . join ( '\n' ) ;
177276}
178277
179278/**
@@ -299,79 +398,118 @@ export async function detectProjectsWithAgent(
299398 runOptions ,
300399 ) ;
301400
302- // Keeps only the transcript tail — the report JSON is the last output.
303- const MAX_TRANSCRIPT_CHARS = 256 * 1024 ;
304- const collected : string [ ] = [ ] ;
305- let collectedChars = 0 ;
306- const collect = ( text : string ) : void => {
307- collected . push ( text ) ;
308- collectedChars += text . length ;
309- while ( collectedChars > MAX_TRANSCRIPT_CHARS && collected . length > 1 ) {
310- collectedChars -= collected . shift ( ) ! . length ;
311- }
312- } ;
313- let resultText = '' ;
314-
315- const middleware = {
316- // eslint-disable-next-line @typescript-eslint/no-explicit-any
317- onMessage : ( message : any ) : void => {
318- if ( message ?. type === 'assistant' ) {
319- for ( const block of message . message ?. content ?? [ ] ) {
320- if ( block ?. type === 'text' && typeof block . text === 'string' ) {
321- collect ( block . text ) ;
322- const line = block . text . trim ( ) ;
323- if ( line && onEvent ) {
324- onEvent ( line . length > 100 ? `${ line . slice ( 0 , 100 ) } …` : line ) ;
401+ // One agent conversation: drive the loop, collect the transcript tail (the
402+ // report JSON is the last output), and return whatever text it produced.
403+ const runDetectionAttempt = async (
404+ attemptPrompt : string ,
405+ ) : Promise < string > => {
406+ const MAX_TRANSCRIPT_CHARS = 256 * 1024 ;
407+ const collected : string [ ] = [ ] ;
408+ let collectedChars = 0 ;
409+ const collect = ( text : string ) : void => {
410+ collected . push ( text ) ;
411+ collectedChars += text . length ;
412+ while ( collectedChars > MAX_TRANSCRIPT_CHARS && collected . length > 1 ) {
413+ collectedChars -= collected . shift ( ) ! . length ;
414+ }
415+ } ;
416+ let resultText = '' ;
417+
418+ const middleware = {
419+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
420+ onMessage : ( message : any ) : void => {
421+ if ( message ?. type === 'assistant' ) {
422+ for ( const block of message . message ?. content ?? [ ] ) {
423+ if ( block ?. type === 'text' && typeof block . text === 'string' ) {
424+ collect ( block . text ) ;
425+ const line = block . text . trim ( ) ;
426+ if ( line && onEvent ) {
427+ onEvent ( line . length > 100 ? `${ line . slice ( 0 , 100 ) } …` : line ) ;
428+ }
429+ } else if ( block ?. type === 'tool_use' ) {
430+ onEvent ?.( formatToolUse ( block ) ) ;
325431 }
326- } else if ( block ?. type === 'tool_use' ) {
327- onEvent ?.( formatToolUse ( block ) ) ;
328432 }
433+ } else if (
434+ message ?. type === 'result' &&
435+ typeof message . result === 'string'
436+ ) {
437+ resultText = message . result ;
329438 }
330- } else if (
331- message ?. type === 'result' &&
332- typeof message . result === 'string'
333- ) {
334- resultText = message . result ;
335- }
336- } ,
337- // eslint-disable-next-line @typescript-eslint/no-explicit-any
338- finalize : ( _resultMessage : any , _durationMs : number ) : unknown => undefined ,
439+ } ,
440+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
441+ finalize : ( _resultMessage : any , _durationMs : number ) : unknown =>
442+ undefined ,
443+ } ;
444+
445+ const result = await executeAgent (
446+ agent ,
447+ attemptPrompt ,
448+ runOptions ,
449+ NOOP_SPINNER ,
450+ {
451+ spinnerMessage : 'Scanning the repo...' ,
452+ successMessage : 'Detection complete' ,
453+ errorMessage : 'Detection failed' ,
454+ requestRemark : false ,
455+ } ,
456+ middleware ,
457+ ) ;
458+
459+ if ( result . error ) {
460+ throw new Error ( result . message || `Agent error: ${ result . error } ` ) ;
461+ }
462+
463+ return resultText || collected . join ( '\n' ) ;
339464 } ;
340465
341- const result = await executeAgent (
342- agent ,
343- buildPrompt ( cwd , targets , purpose , recommend ) ,
344- runOptions ,
345- NOOP_SPINNER ,
346- {
347- spinnerMessage : 'Scanning the repo...' ,
348- successMessage : 'Detection complete' ,
349- errorMessage : 'Detection failed' ,
350- requestRemark : false ,
351- } ,
352- middleware ,
353- ) ;
466+ const basePrompt = buildPrompt ( cwd , targets , purpose , recommend ) ;
467+ const validTargetIds = targets . map ( ( t ) => t . id ) ;
354468
355- if ( result . error ) {
356- throw new Error ( result . message || `Agent error: ${ result . error } ` ) ;
357- }
469+ // Run detection, validate the reply with Zod, and on a malformed reply
470+ // re-prompt the agent with the specific problems — up to MAX_DETECTION_ATTEMPTS
471+ // times. The last reply that at least parsed as JSON is kept so we can
472+ // best-effort coerce it if every attempt fails validation.
473+ let prompt = basePrompt ;
474+ let lastParsedJson : unknown = undefined ;
475+
476+ for ( let attempt = 1 ; attempt <= MAX_DETECTION_ATTEMPTS ; attempt ++ ) {
477+ const output = await runDetectionAttempt ( prompt ) ;
358478
359- const output = resultText || collected . join ( '\n' ) ;
360- try {
361- return coerceAgenticReport (
362- extractJson ( output ) ,
363- targets . map ( ( t ) => t . id ) ,
364- { recommend } ,
365- ) ;
366- } catch ( err ) {
367479 // The prompt tells the agent to emit `[ABORT] detection failed` when the
368- // repo has no recognizable project manifests. Surface that (and any other
369- // non-JSON terminal output that carries the abort signal) as an empty
370- // report so the screen renders a friendly "nothing to instrument" state
371- // instead of a cryptic "Agent did not return a JSON object" error.
480+ // repo has no recognizable manifests. Surface that as an empty report so
481+ // the screen renders a friendly "nothing to instrument" state.
372482 if ( output . includes ( AgentSignals . ABORT ) ) {
373483 return { repoType : 'single' , projects : [ ] } ;
374484 }
375- throw err ;
485+
486+ const parsed = parseAgentReport ( output ) ;
487+ if ( parsed . ok ) {
488+ return coerceAgenticReport ( parsed . value , validTargetIds , { recommend } ) ;
489+ }
490+
491+ if ( parsed . parsedJson !== undefined ) lastParsedJson = parsed . parsedJson ;
492+
493+ if ( attempt < MAX_DETECTION_ATTEMPTS ) {
494+ onEvent ?.(
495+ `Detection response was malformed; retrying (${
496+ attempt + 1
497+ } /${ MAX_DETECTION_ATTEMPTS } )…`,
498+ ) ;
499+ prompt = buildRetryPrompt (
500+ basePrompt ,
501+ parsed . feedback ,
502+ output ,
503+ attempt + 1 ,
504+ ) ;
505+ }
376506 }
507+
508+ // Retries exhausted. Best effort: coerce the last reply that at least parsed
509+ // as JSON (coerceAgenticReport clamps/defaults every field, and treats
510+ // `undefined` as an empty report). Returning an empty report rather than
511+ // throwing means a headless monorepo run scopes to the install dir as-is
512+ // instead of crashing on a SyntaxError and silently mis-targeting the root.
513+ onEvent ?.( 'Detection did not return a valid report; continuing best-effort.' ) ;
514+ return coerceAgenticReport ( lastParsedJson , validTargetIds , { recommend } ) ;
377515}
0 commit comments