Skip to content

Commit fe0eb16

Browse files
committed
feat(guard): graduated response — soft-block on first trigger instead of killing stream
Instead of immediately terminating the stream when the tool loop guard triggers, the first trigger now emits a non-fatal SSE hint telling the model to try a different approach. Only subsequent triggers escalate to hard termination (stream kill). This fixes issue #51 where the "task" tool's validation errors would kill the conversation after just 3 attempts, giving the model no chance to recover. Changes: - Add `soft` field to ToolLoopGuardTermination (first trigger = soft) - Add createLoopGuardHintChunk() for non-fatal hint emission - Handle soft blocks in all 5 guard call sites (3 V1, 2 legacy) - Success loop termination unchanged (still silent) - Guard detection logic unchanged (tool-loop-guard.ts not modified)
1 parent f82b3b9 commit fe0eb16

File tree

3 files changed

+410
-1
lines changed

3 files changed

+410
-1
lines changed

src/provider/runtime-interception.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface ToolLoopGuardTermination {
5959
maxRepeat: number;
6060
errorClass: string;
6161
silent?: boolean;
62+
soft?: boolean;
6263
}
6364

6465
export 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+
666754
function safeArgTypeSummary(event: StreamJsonToolCallEvent): Record<string, string> {
667755
try {
668756
let raw: unknown;

0 commit comments

Comments
 (0)