@@ -59,6 +59,7 @@ export interface ToolLoopGuardTermination {
5959 maxRepeat : number ;
6060 errorClass : string ;
6161 silent ?: boolean ;
62+ soft ?: boolean ;
6263}
6364
6465export interface ToolSchemaValidationTermination {
@@ -177,6 +178,15 @@ export async function handleToolLoopEventLegacy(
177178 compat . validation ,
178179 ) ;
179180 if ( validationTermination ) {
181+ if ( validationTermination . soft ) {
182+ const hintChunk = createLoopGuardHintChunk ( responseMeta , normalizedToolCall , validationTermination ) ;
183+ log . debug ( "Soft-blocking schema validation loop guard in legacy (emitting hint)" , {
184+ tool : normalizedToolCall . function . name ,
185+ fingerprint : validationTermination . fingerprint ,
186+ } ) ;
187+ await onToolResult ( hintChunk ) ;
188+ return { intercepted : false , skipConverter : true } ;
189+ }
180190 return { intercepted : false , skipConverter : true , terminate : validationTermination } ;
181191 }
182192
@@ -211,6 +221,15 @@ export async function handleToolLoopEventLegacy(
211221
212222 const termination = evaluateToolLoopGuard ( toolLoopGuard , normalizedToolCall ) ;
213223 if ( termination ) {
224+ if ( termination . soft ) {
225+ const hintChunk = createLoopGuardHintChunk ( responseMeta , normalizedToolCall , termination ) ;
226+ log . debug ( "Soft-blocking tool loop guard in legacy (emitting hint)" , {
227+ tool : normalizedToolCall . function . name ,
228+ fingerprint : termination . fingerprint ,
229+ } ) ;
230+ await onToolResult ( hintChunk ) ;
231+ return { intercepted : false , skipConverter : true } ;
232+ }
214233 return { intercepted : false , skipConverter : true , terminate : termination } ;
215234 }
216235 await onInterceptedToolCall ( normalizedToolCall ) ;
@@ -341,10 +360,30 @@ export async function handleToolLoopEventV1(
341360 compat . validation ,
342361 ) ;
343362 if ( validationTermination ) {
363+ if ( validationTermination . soft ) {
364+ const hintChunk = createLoopGuardHintChunk ( responseMeta , normalizedToolCall , validationTermination ) ;
365+ log . debug ( "Soft-blocking schema validation loop guard (emitting hint)" , {
366+ tool : normalizedToolCall . function . name ,
367+ fingerprint : validationTermination . fingerprint ,
368+ repeatCount : validationTermination . repeatCount ,
369+ } ) ;
370+ await onToolResult ( hintChunk ) ;
371+ return { intercepted : false , skipConverter : true } ;
372+ }
344373 return { intercepted : false , skipConverter : true , terminate : validationTermination } ;
345374 }
346375 const termination = evaluateToolLoopGuard ( toolLoopGuard , normalizedToolCall ) ;
347376 if ( termination ) {
377+ if ( termination . soft ) {
378+ const hintChunk = createLoopGuardHintChunk ( responseMeta , normalizedToolCall , termination ) ;
379+ log . debug ( "Soft-blocking tool loop guard in validation path (emitting hint)" , {
380+ tool : normalizedToolCall . function . name ,
381+ fingerprint : termination . fingerprint ,
382+ repeatCount : termination . repeatCount ,
383+ } ) ;
384+ await onToolResult ( hintChunk ) ;
385+ return { intercepted : false , skipConverter : true } ;
386+ }
348387 return { intercepted : false , skipConverter : true , terminate : termination } ;
349388 }
350389 const reroutedWrite = tryRerouteEditToWrite (
@@ -415,6 +454,16 @@ export async function handleToolLoopEventV1(
415454
416455 const termination = evaluateToolLoopGuard ( toolLoopGuard , normalizedToolCall ) ;
417456 if ( termination ) {
457+ if ( termination . soft ) {
458+ const hintChunk = createLoopGuardHintChunk ( responseMeta , normalizedToolCall , termination ) ;
459+ log . debug ( "Soft-blocking tool loop guard (emitting hint)" , {
460+ tool : normalizedToolCall . function . name ,
461+ fingerprint : termination . fingerprint ,
462+ repeatCount : termination . repeatCount ,
463+ } ) ;
464+ await onToolResult ( hintChunk ) ;
465+ return { intercepted : false , skipConverter : true } ;
466+ }
418467 return { intercepted : false , skipConverter : true , terminate : termination } ;
419468 }
420469 await onInterceptedToolCall ( normalizedToolCall ) ;
@@ -515,6 +564,11 @@ function evaluateToolLoopGuard(
515564 } ;
516565 }
517566
567+ // First trigger (repeatCount exactly one over threshold): soft block.
568+ // Emit a hint to the model instead of killing the stream.
569+ // If the model ignores the hint and retries, subsequent triggers are hard kills.
570+ const isFirstTrigger = decision . repeatCount === decision . maxRepeat + 1 ;
571+
518572 return {
519573 reason : "loop_guard" ,
520574 message : `Tool loop guard stopped repeated failing calls to "${ toolCall . function . name } " `
@@ -525,6 +579,7 @@ function evaluateToolLoopGuard(
525579 repeatCount : decision . repeatCount ,
526580 maxRepeat : decision . maxRepeat ,
527581 errorClass : decision . errorClass ,
582+ soft : isFirstTrigger ,
528583 } ;
529584}
530585
@@ -570,12 +625,15 @@ function evaluateSchemaValidationLoopGuard(
570625 return null ;
571626 }
572627
573- log . warn ( "Tool loop guard triggered on schema validation" , {
628+ const isFirstTrigger = decision . repeatCount === decision . maxRepeat + 1 ;
629+
630+ log . debug ( "Tool loop guard triggered on schema validation" , {
574631 tool : toolCall . function . name ,
575632 fingerprint : decision . fingerprint ,
576633 repeatCount : decision . repeatCount ,
577634 maxRepeat : decision . maxRepeat ,
578635 validationSignature,
636+ soft : isFirstTrigger ,
579637 } ) ;
580638 return {
581639 reason : "loop_guard" ,
@@ -588,6 +646,7 @@ function evaluateSchemaValidationLoopGuard(
588646 repeatCount : decision . repeatCount ,
589647 maxRepeat : decision . maxRepeat ,
590648 errorClass : decision . errorClass ,
649+ soft : isFirstTrigger ,
591650 } ;
592651}
593652
@@ -663,6 +722,35 @@ function createNonFatalSchemaValidationHintChunk(
663722 } ;
664723}
665724
725+ type LoopGuardHintChunk = NonFatalSchemaValidationResultChunk ;
726+
727+ function createLoopGuardHintChunk (
728+ meta : { id : string ; created : number ; model : string } ,
729+ toolCall : OpenAiToolCall ,
730+ termination : ToolLoopGuardTermination ,
731+ ) : LoopGuardHintChunk {
732+ const content =
733+ `Tool "${ toolCall . function . name } " has been temporarily blocked after `
734+ + `${ termination . repeatCount } repeated ${ termination . errorClass } failures. `
735+ + "Do not retry this tool. Use a different approach to complete the task." ;
736+ return {
737+ id : meta . id ,
738+ object : "chat.completion.chunk" ,
739+ created : meta . created ,
740+ model : meta . model ,
741+ choices : [
742+ {
743+ index : 0 ,
744+ delta : {
745+ role : "assistant" ,
746+ content,
747+ } ,
748+ finish_reason : null ,
749+ } ,
750+ ] ,
751+ } ;
752+ }
753+
666754function safeArgTypeSummary ( event : StreamJsonToolCallEvent ) : Record < string , string > {
667755 try {
668756 let raw : unknown ;
0 commit comments