@@ -30,10 +30,30 @@ export interface JudgeInput {
3030 outputs : OutputFile [ ] ;
3131}
3232
33- export async function runJudge ( input : JudgeInput ) : Promise < JudgeFile > {
34- const { prompt : inputSummary , canary } = buildJudgePrompt ( input ) ;
33+ export class JudgeTimeoutError extends Error {
34+ constructor ( message : string ) {
35+ super ( message ) ;
36+ this . name = 'JudgeTimeoutError' ;
37+ }
38+ }
39+
40+ export const JUDGE_SUBPROCESS_TIMEOUT_MS = 120_000 ;
41+
42+ export type SpawnJudgeFn = (
43+ claudeBin : string ,
44+ prompt : string ,
45+ opts : SpawnJudgeOptions ,
46+ ) => Promise < string > ;
47+
48+ export interface RunJudgeOptions {
49+ // Test-only: override the subprocess spawn so the retry/timeout paths can
50+ // be exercised without launching real Haiku.
51+ spawnFn ?: SpawnJudgeFn ;
52+ }
53+
54+ export async function runJudge ( input : JudgeInput , opts : RunJudgeOptions = { } ) : Promise < JudgeFile > {
3555 try {
36- const parsed = await invokeJudge ( input . claudeBin , inputSummary , canary , input . runDir ) ;
56+ const parsed = await invokeJudge ( input , opts . spawnFn ) ;
3757 const file : JudgeFile = {
3858 runFolder : input . runConfig . runFolder ,
3959 createdAt : new Date ( ) . toISOString ( ) ,
@@ -96,8 +116,27 @@ export interface JudgePromptArtifacts {
96116 canary : string ;
97117}
98118
99- export function buildJudgePrompt ( input : JudgeInput ) : JudgePromptArtifacts {
119+ export interface BuildJudgePromptOptions {
120+ // Multiplies every byte/char cap on untrusted sections. Used on timeout retry
121+ // (e.g. 0.5) to shrink the prompt so the second judge call has less to chew on.
122+ bytesCapMultiplier ?: number ;
123+ }
124+
125+ export function buildJudgePrompt (
126+ input : JudgeInput ,
127+ opts : BuildJudgePromptOptions = { } ,
128+ ) : JudgePromptArtifacts {
100129 const { runConfig, transcript, variantContent, outputs } = input ;
130+ // Default to 1 if the option is missing or non-finite/non-positive; otherwise
131+ // multiplier values like NaN or Infinity would propagate through Math.floor and
132+ // produce empty or runaway prompt sections.
133+ const rawMultiplier = opts . bytesCapMultiplier ?? 1 ;
134+ const m = Number . isFinite ( rawMultiplier ) && rawMultiplier > 0 ? rawMultiplier : 1 ;
135+ // Floors keep retries useful even if a future caller passes a very small multiplier.
136+ const promptCap = Math . max ( 256 , Math . floor ( JUDGE_PROMPT_CAP_BYTES * m ) ) ;
137+ const variantCap = Math . max ( 512 , Math . floor ( JUDGE_VARIANT_CAP_BYTES * m ) ) ;
138+ const finalMessageCap = Math . max ( 256 , Math . floor ( JUDGE_FINAL_MESSAGE_CAP_BYTES * m ) ) ;
139+ const toolSummaryCap = Math . max ( 80 , Math . floor ( JUDGE_TOOL_SUMMARY_CAP_CHARS * m ) ) ;
101140 // 64 bits of entropy each: too large to brute-force a guess from inside the
102141 // sandboxed variant, so the data fences and canary cannot be forged.
103142 const nonce = randomBytes ( 8 ) . toString ( 'hex' ) ;
@@ -136,22 +175,22 @@ export function buildJudgePrompt(input: JudgeInput): JudgePromptArtifacts {
136175 lines . push ( '' ) ;
137176
138177 lines . push ( '## Prompt given to the variant' ) ;
139- lines . push ( fence ( 'prompt' , bytesCap ( runConfig . prompt , JUDGE_PROMPT_CAP_BYTES ) ) ) ;
178+ lines . push ( fence ( 'prompt' , bytesCap ( runConfig . prompt , promptCap ) ) ) ;
140179 lines . push ( '' ) ;
141180
142181 const variantLabel =
143182 `variant ${ runConfig . variantType } ` +
144183 ( runConfig . skillOrAgentName ? ` ${ runConfig . skillOrAgentName } ` : '' ) ;
145184 lines . push ( '## Variant body' ) ;
146- lines . push ( fence ( variantLabel , bytesCap ( variantContent , JUDGE_VARIANT_CAP_BYTES ) ) ) ;
185+ lines . push ( fence ( variantLabel , bytesCap ( variantContent , variantCap ) ) ) ;
147186 lines . push ( '' ) ;
148187
149188 const finalMessage = extractFinalAssistantMessage ( transcript ) ;
150189 lines . push ( '## Final assistant message' ) ;
151- lines . push ( fence ( 'assistant message' , midEllipsis ( finalMessage , JUDGE_FINAL_MESSAGE_CAP_BYTES ) ) ) ;
190+ lines . push ( fence ( 'assistant message' , midEllipsis ( finalMessage , finalMessageCap ) ) ) ;
152191 lines . push ( '' ) ;
153192
154- const tools = extractToolSummary ( transcript ) ;
193+ const tools = extractToolSummary ( transcript , toolSummaryCap ) ;
155194 lines . push ( '## Tool calls (summary)' ) ;
156195 lines . push ( fence ( 'tool summary' , tools . length === 0 ? '(none)' : tools . join ( '\n' ) ) ) ;
157196 lines . push ( '' ) ;
@@ -240,7 +279,10 @@ interface PendingToolUse {
240279 id ?: string ;
241280}
242281
243- export function extractToolSummary ( transcript : TranscriptFile ) : string [ ] {
282+ export function extractToolSummary (
283+ transcript : TranscriptFile ,
284+ toolSummaryCap : number = JUDGE_TOOL_SUMMARY_CAP_CHARS ,
285+ ) : string [ ] {
244286 const out : string [ ] = [ ] ;
245287 // Primary pairing: by tool_use_id. Real-claude and fake-claude both emit ids,
246288 // so this is the path that actually runs in production.
@@ -269,17 +311,13 @@ export function extractToolSummary(transcript: TranscriptFile): string[] {
269311 }
270312 const tool = pair ?. tool ?? e . tool ;
271313 const args = pair ?. argsSummary ?? '' ;
272- const res = truncate ( e . resultSummary , JUDGE_TOOL_SUMMARY_CAP_CHARS ) ;
273- out . push (
274- `${ tool } (${ truncate ( args , JUDGE_TOOL_SUMMARY_CAP_CHARS ) } ) → ${ res } ${ e . isError ? ' [error]' : '' } ` ,
275- ) ;
314+ const res = truncate ( e . resultSummary , toolSummaryCap ) ;
315+ out . push ( `${ tool } (${ truncate ( args , toolSummaryCap ) } ) → ${ res } ${ e . isError ? ' [error]' : '' } ` ) ;
276316 }
277317 }
278318 // Unmatched tool uses (no result captured): emit so the judge sees the gap.
279319 for ( const t of fifo ) {
280- out . push (
281- `${ t . tool } (${ truncate ( t . argsSummary , JUDGE_TOOL_SUMMARY_CAP_CHARS ) } ) → (no result observed)` ,
282- ) ;
320+ out . push ( `${ t . tool } (${ truncate ( t . argsSummary , toolSummaryCap ) } ) → (no result observed)` ) ;
283321 }
284322 return out ;
285323}
@@ -304,42 +342,87 @@ function truncate(s: string, cap: number): string {
304342 return s . slice ( 0 , cap - 1 ) + '…' ;
305343}
306344
307- async function invokeJudge (
345+ type AttemptResult =
346+ | { ok : true ; value : JudgeModelOutput }
347+ | { ok : false ; kind : 'parse' ; error : string }
348+ | { ok : false ; kind : 'timeout' ; error : string } ;
349+
350+ async function attemptJudge (
351+ spawnFn : SpawnJudgeFn ,
308352 claudeBin : string ,
309353 prompt : string ,
310354 canary : string ,
311355 runDir : string ,
312- ) : Promise < JudgeModelOutput > {
313- const firstAttempt = await spawnJudge ( claudeBin , prompt , { jsonSchema : true } ) ;
314- await writeRawResponse ( runDir , 'first' , firstAttempt ) ;
315- if ( detectCanaryLeak ( firstAttempt , canary ) ) {
316- log . warn ( 'judge.canary-leak' , { attempt : 'first' } ) ;
356+ label : string ,
357+ ) : Promise < AttemptResult > {
358+ let raw : string ;
359+ try {
360+ raw = await spawnFn ( claudeBin , prompt , { jsonSchema : true } ) ;
361+ } catch ( err ) {
362+ if ( err instanceof JudgeTimeoutError ) {
363+ log . warn ( 'judge.attempt-timeout' , { attempt : label , error : err . message } ) ;
364+ return { ok : false , kind : 'timeout' , error : err . message } ;
365+ }
366+ throw err ;
367+ }
368+ await writeRawResponse ( runDir , label , raw ) ;
369+ if ( detectCanaryLeak ( raw , canary ) ) {
370+ log . warn ( 'judge.canary-leak' , { attempt : label } ) ;
317371 throw new Error (
318- ' judge output contained the canary token, indicating prompt injection from variant or transcript content; scores discarded' ,
372+ ` judge output contained the canary token on ${ label } attempt , indicating prompt injection from variant or transcript content; scores discarded` ,
319373 ) ;
320374 }
321- const firstParsed = tryParseJudgeOutput ( firstAttempt ) ;
322- if ( firstParsed . ok ) return firstParsed . value ;
323-
324- log . warn ( 'judge.first-attempt-invalid' , { error : firstParsed . error } ) ;
325- const retryPrompt =
326- `${ prompt } \n\n# Retry required\n` +
327- `Your previous response did not match the required shape. Parser said: "${ firstParsed . error } "\n` +
328- `Emit ONLY the JSON object described above — no markdown, no prose, no code fences. ` +
329- `The first character MUST be "{" and the last MUST be "}". Include all four score keys (accuracy, completeness, adherence, clarity) and the rationale field.` ;
330- const secondAttempt = await spawnJudge ( claudeBin , retryPrompt , { jsonSchema : true } ) ;
331- await writeRawResponse ( runDir , 'retry' , secondAttempt ) ;
332- if ( detectCanaryLeak ( secondAttempt , canary ) ) {
333- log . warn ( 'judge.canary-leak' , { attempt : 'retry' } ) ;
375+ const parsed = tryParseJudgeOutput ( raw ) ;
376+ if ( parsed . ok ) return { ok : true , value : parsed . value } ;
377+ return { ok : false , kind : 'parse' , error : parsed . error } ;
378+ }
379+
380+ export async function invokeJudge (
381+ input : JudgeInput ,
382+ spawnFn : SpawnJudgeFn = spawnJudge ,
383+ ) : Promise < JudgeModelOutput > {
384+ const { claudeBin, runDir } = input ;
385+ const built = buildJudgePrompt ( input ) ;
386+
387+ const first = await attemptJudge ( spawnFn , claudeBin , built . prompt , built . canary , runDir , 'first' ) ;
388+ if ( first . ok ) return first . value ;
389+
390+ // Build the retry prompt. A timeout is treated like a schema failure for retry
391+ // purposes (issue #12), but the retry shrinks the input to give Haiku a real
392+ // chance to finish in the next 120s window. Schema-failure retries keep the
393+ // original prompt and append a hint about the parse error.
394+ let retryPrompt : string ;
395+ let retryCanary : string ;
396+ if ( first . kind === 'timeout' ) {
397+ log . warn ( 'judge.first-attempt-invalid' , {
398+ reason : 'timeout' ,
399+ error : first . error ,
400+ retryStrategy : 'halve-input-caps' ,
401+ } ) ;
402+ const rebuilt = buildJudgePrompt ( input , { bytesCapMultiplier : 0.5 } ) ;
403+ retryPrompt = rebuilt . prompt ;
404+ retryCanary = rebuilt . canary ;
405+ } else {
406+ log . warn ( 'judge.first-attempt-invalid' , { reason : 'parse' , error : first . error } ) ;
407+ retryPrompt =
408+ `${ built . prompt } \n\n# Retry required\n` +
409+ `Your previous response did not match the required shape. Parser said: "${ first . error } "\n` +
410+ `Emit ONLY the JSON object described above — no markdown, no prose, no code fences. ` +
411+ `The first character MUST be "{" and the last MUST be "}". Include all four score keys (accuracy, completeness, adherence, clarity) and the rationale field.` ;
412+ retryCanary = built . canary ;
413+ }
414+
415+ const second = await attemptJudge ( spawnFn , claudeBin , retryPrompt , retryCanary , runDir , 'retry' ) ;
416+ if ( second . ok ) return second . value ;
417+
418+ if ( second . kind === 'timeout' ) {
334419 throw new Error (
335- 'judge output contained the canary token on retry, indicating prompt injection from variant or transcript content; scores discarded' ,
420+ `judge timed out on retry after ${ first . kind === 'timeout' ? 'an initial timeout' : 'a schema failure' } . ` +
421+ `Raw responses (if any) saved to ${ join ( runDir , 'judge.raw-response.log' ) } .` ,
336422 ) ;
337423 }
338- const secondParsed = tryParseJudgeOutput ( secondAttempt ) ;
339- if ( secondParsed . ok ) return secondParsed . value ;
340-
341424 throw new Error (
342- `judge output invalid after retry: ${ secondParsed . error } . Raw responses saved to ${ join ( runDir , 'judge.raw-response.log' ) } .` ,
425+ `judge output invalid after retry: ${ second . error } . Raw responses saved to ${ join ( runDir , 'judge.raw-response.log' ) } .` ,
343426 ) ;
344427}
345428
@@ -353,7 +436,7 @@ async function writeRawResponse(runDir: string, label: string, raw: string): Pro
353436 }
354437}
355438
356- interface SpawnJudgeOptions {
439+ export interface SpawnJudgeOptions {
357440 jsonSchema : boolean ;
358441}
359442
@@ -396,8 +479,12 @@ function spawnJudge(claudeBin: string, prompt: string, opts: SpawnJudgeOptions):
396479 proc . stderr . on ( 'data' , ( d ) => ( stderr += d . toString ( ) ) ) ;
397480 const timer = setTimeout ( ( ) => {
398481 proc . kill ( 'SIGKILL' ) ;
399- reject ( new Error ( `judge subprocess timed out after 120s` ) ) ;
400- } , 120_000 ) ;
482+ reject (
483+ new JudgeTimeoutError (
484+ `judge subprocess timed out after ${ Math . round ( JUDGE_SUBPROCESS_TIMEOUT_MS / 1000 ) } s` ,
485+ ) ,
486+ ) ;
487+ } , JUDGE_SUBPROCESS_TIMEOUT_MS ) ;
401488 proc . on ( 'error' , ( err ) => {
402489 clearTimeout ( timer ) ;
403490 reject ( err ) ;
0 commit comments