@@ -72,9 +72,12 @@ import {
7272 INITIAL_CONTENTLESS_GROWTH_STATE ,
7373 trackContentlessGrowth ,
7474 type ContentlessGrowthState ,
75+ DEFAULT_CONTENTLESS_GROWTH_CONFIG ,
76+ DEFAULT_REPETITION_CONFIG ,
7577 DEFAULT_TEXT_FOLDED_REPETITION_CONFIG ,
7678 DEFAULT_THINKING_REPETITION_CONFIG ,
7779 REPETITION_CHECK_INTERVAL_CHARS ,
80+ type RepetitionConfig ,
7881 type RepetitionHit ,
7982} from "./repetition.js" ;
8083import { refreshInferenceSourceBundle } from "./refresh-inference-source.js" ;
@@ -243,9 +246,28 @@ export function createSubAgentRunController(
243246 } ;
244247}
245248
246- /** Stopped-line detail for a repetition abort: the looped window plus repeat count. */
247- export function repetitionStopDetail ( hit : RepetitionHit ) : string {
248- return `window ${ JSON . stringify ( hit . window . slice ( 0 , 80 ) ) } × ${ hit . repeats } ` ;
249+ /**
250+ * Stopped-line / log detail for a repetition abort. Reports the looped
251+ * window's length and repeat count against the detector's threshold, never
252+ * the window text itself (CL-6775) — the looped text is model output, and
253+ * the parent-facing report carries a capped sample separately via `partial`.
254+ */
255+ export function repetitionStopDetail ( hit : RepetitionHit , config : RepetitionConfig | null ) : string {
256+ const threshold = config ?. repeatThreshold ;
257+ return `period ${ hit . window . length } ch × ${ hit . repeats } ${ threshold !== undefined ? ` (threshold ${ threshold } )` : "" } ` ;
258+ }
259+
260+ /** Stopped-line / log detail for a contentless/zero-width growth abort (CL-6775). */
261+ export function contentlessGrowthDetail (
262+ measured : ContentlessGrowthState | null ,
263+ stream : string | null ,
264+ ) : string {
265+ if ( measured === null ) return "contentless/zero-width flood" ;
266+ return (
267+ `contentless/zero-width flood: ${ stream ?? "stream" } ` +
268+ `${ measured . rawChars } raw/${ measured . visibleChars } visible ` +
269+ `(min ${ DEFAULT_CONTENTLESS_GROWTH_CONFIG . minVisibleChars } visible per ${ DEFAULT_CONTENTLESS_GROWTH_CONFIG . rawWindowChars } raw)`
270+ ) ;
249271}
250272
251273/** String form of an abort signal's reason (cancel detail), or undefined. */
@@ -640,9 +662,24 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
640662 // Holder object rather than a let: the value is written inside the stream
641663 // sink closure, and flow analysis would otherwise narrow a let to null at
642664 // the later catch-site reads.
643- const repetition : { hit : RepetitionHit | null ; contentless : boolean } = {
665+ // `detector` names which of the three checks fired (CL-6775): raw-text
666+ // periodicity, digit-folded thinking, or the contentless/zero-width growth
667+ // guard — recorded so the intervention log can attribute aborts to a
668+ // specific detector, not just "repetition" in general.
669+ const repetition : {
670+ hit : RepetitionHit | null ;
671+ contentless : boolean ;
672+ detector : "raw-text-periodicity" | "digit-folded-thinking" | "contentless-growth" | null ;
673+ config : RepetitionConfig | null ;
674+ contentlessMeasured : ContentlessGrowthState | null ;
675+ contentlessStream : string | null ;
676+ } = {
644677 hit : null ,
645678 contentless : false ,
679+ detector : null ,
680+ config : null ,
681+ contentlessMeasured : null ,
682+ contentlessStream : null ,
646683 } ;
647684 let charsSinceRepetitionCheck = 0 ;
648685 let charsSinceThinkingRepetitionCheck = 0 ;
@@ -660,6 +697,9 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
660697 const next = trackContentlessGrowth ( state , token ) ;
661698 if ( next . hit ) {
662699 repetition . contentless = true ;
700+ repetition . detector = "contentless-growth" ;
701+ repetition . contentlessMeasured = next . measured ;
702+ repetition . contentlessStream = stream ;
663703 runController . abort (
664704 new Error (
665705 `sub-agent ${ stream } output grew with only invisible/contentless characters (zero-width flood)` ,
@@ -689,13 +729,17 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
689729 // Two passes: digit-preserving for phrase loops, then the capped
690730 // folded pass for counter/timestamp/fence/emoji floods that are
691731 // never byte-periodic or fall under the plain window floor.
732+ const rawHit = detectRepetition ( cycleRecorder . text ( ) ) ;
692733 const hit =
693- detectRepetition ( cycleRecorder . text ( ) ) ??
734+ rawHit ??
694735 detectRepetition ( cycleRecorder . text ( ) , DEFAULT_TEXT_FOLDED_REPETITION_CONFIG , {
695736 normalizeDigits : true ,
696737 } ) ;
697738 if ( hit !== null ) {
698739 repetition . hit = hit ;
740+ repetition . detector = "raw-text-periodicity" ;
741+ repetition . config =
742+ rawHit !== null ? DEFAULT_REPETITION_CONFIG : DEFAULT_TEXT_FOLDED_REPETITION_CONFIG ;
699743 runController . abort (
700744 new Error ( `sub-agent streamed output repeated the same window ${ hit . repeats } times` ) ,
701745 ) ;
@@ -723,6 +767,8 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
723767 ) ;
724768 if ( hit !== null ) {
725769 repetition . hit = hit ;
770+ repetition . detector = "digit-folded-thinking" ;
771+ repetition . config = DEFAULT_THINKING_REPETITION_CONFIG ;
726772 runController . abort (
727773 new Error ( `sub-agent thinking output repeated the same window ${ hit . repeats } times` ) ,
728774 ) ;
@@ -844,23 +890,43 @@ export async function runSubAgent(params: RunSubAgentParams): Promise<string> {
844890 : tail ;
845891 const detail =
846892 repetition . hit !== null
847- ? repetitionStopDetail ( repetition . hit )
893+ ? repetitionStopDetail ( repetition . hit , repetition . config )
848894 : repetition . contentless
849- ? "contentless/zero-width flood"
895+ ? contentlessGrowthDetail (
896+ repetition . contentlessMeasured ,
897+ repetition . contentlessStream ,
898+ )
850899 : reason === "deadline" && resolvedDeadlineMs !== undefined
851900 ? `${ resolvedDeadlineMs } ms elapsed`
852901 : abortReasonText ( runController . signal ) ;
853902 interventions ( {
854- id : reason ,
903+ // CL-6775: which detector fired is folded into the id (rather than
904+ // a new field) so scripts/intervention-forensics.ts buckets each
905+ // detector separately without any change to its aggregation logic.
906+ id :
907+ reason === "repetition" && repetition . detector !== null
908+ ? `repetition-${ repetition . detector } `
909+ : reason ,
855910 class : "stop" ,
856911 ...( repetition . hit !== null
857912 ? {
858913 measurement : {
859914 metric : "repeats" ,
860915 value : repetition . hit . repeats ,
916+ ...( repetition . config !== null
917+ ? { threshold : repetition . config . repeatThreshold }
918+ : { } ) ,
861919 } ,
862920 }
863- : { } ) ,
921+ : repetition . contentless
922+ ? {
923+ measurement : {
924+ metric : "visibleChars" ,
925+ value : repetition . contentlessMeasured ?. visibleChars ?? 0 ,
926+ threshold : DEFAULT_CONTENTLESS_GROWTH_CONFIG . minVisibleChars ,
927+ } ,
928+ }
929+ : { } ) ,
864930 state : { totalToolCalls : toolNamesUsed . length } ,
865931 ...( detail !== undefined ? { detail } : { } ) ,
866932 } ) ;
0 commit comments