v1.11.6: doom-loop guard (3 identical tool calls aborts recursion)

This commit is contained in:
2026-05-20 20:28:45 +00:00
parent 4ec196273b
commit f92b0810c3
8 changed files with 1050 additions and 176 deletions

View File

@@ -112,11 +112,13 @@ export type ErrorReason =
| 'tool_execution_failed'
| 'summary_after_cap_failed';
// v1.8.2: shapes stored in Message.metadata. Discriminated on `kind`.
// cap_hit — sentinel emitted when the tool budget is hit; carries the
// budget + agent name + whether Continue is still allowed.
// error — attached to a failed assistant message so the bubble can show
// a specific reason on reload (WS error frame is one-shot).
// v1.8.2 / v1.11.6: shapes stored in Message.metadata. Discriminated on `kind`.
// cap_hit — sentinel emitted when the tool budget is hit; carries the
// budget + agent name + whether Continue is still allowed.
// doom_loop — sentinel emitted when the model called the same tool with
// the same arguments threshold times in a row.
// error — attached to a failed assistant message so the bubble can show
// a specific reason on reload (WS error frame is one-shot).
export type MessageMetadata =
| {
kind: 'cap_hit';
@@ -125,6 +127,12 @@ export type MessageMetadata =
agent_name: string | null;
can_continue: boolean;
}
| {
kind: 'doom_loop';
tool_name: string;
args: Record<string, unknown>;
threshold: number;
}
| {
kind: 'error';
error_reason: ErrorReason;

View File

@@ -0,0 +1,43 @@
import { AlertCircle } from 'lucide-react';
import type { Message } from '@/api/types';
interface Props {
message: Message;
}
// v1.11.6: doom-loop sentinel. Renders the system row inserted by
// services/inference.ts insertDoomLoopSentinel when the model called the
// same tool with the same arguments threshold times in a row. Visual
// treatment mirrors CapHitSentinel (amber card + alert icon) so users learn
// "amber alert = the loop hit a guard rail and stopped" regardless of
// which guard fired. Intentionally NO Continue button — retrying with the
// same tools would just re-loop; the user needs to restate the prompt or
// switch agents instead.
export function DoomLoopSentinel({ message }: Props) {
const meta = message.metadata;
const isDoomLoop =
meta !== null && typeof meta === 'object' && meta.kind === 'doom_loop';
const toolName = isDoomLoop ? meta.tool_name : null;
const threshold = isDoomLoop ? meta.threshold : null;
return (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 text-sm">
<div className="px-3 py-2 flex items-start gap-2">
<AlertCircle className="size-4 text-amber-500 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0 space-y-1">
<div className="text-xs font-medium text-amber-700 dark:text-amber-300">
Doom loop detected
</div>
<div className="text-xs text-muted-foreground">
{toolName !== null && threshold !== null
? `Stopped after ${threshold} identical calls to ${toolName}. The model was looping.`
: message.content}
</div>
<div className="text-[11px] text-muted-foreground/80">
Send a new message with a different angle, or switch agents.
</div>
</div>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { api } from '@/api/client';
import { sessionEvents } from '@/hooks/sessionEvents';
import { sendToTerminal, terminalsRegistry, type TerminalRegistration } from '@/lib/events';
import { CapHitSentinel } from './CapHitSentinel';
import { DoomLoopSentinel } from './DoomLoopSentinel';
import { CodeBlock } from './CodeBlock';
import { Button } from '@/components/ui/button';
import {
@@ -622,6 +623,13 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
);
}
// v1.11.6: doom-loop sentinel. No Continue affordance — retrying with the
// same tools would just re-loop. The card explains what tripped and
// suggests next steps (new message angle / switch agents).
if (message.role === 'system' && message.metadata?.kind === 'doom_loop') {
return <DoomLoopSentinel message={message} />;
}
// v1.8.2: tool messages and assistant tool_calls are now rendered by
// MessageList via ToolCallLine / ToolCallGroup. Tool-role messages reach
// this point only if MessageList didn't consume them (shouldn't happen,