akseljoonas HF Staff commited on
Commit
d3bb2d2
·
1 Parent(s): c85e4f2

feat: add doom-loop detection for repeated tool call patterns

Browse files

Detects when the agent is stuck calling the same tools repeatedly
(identical consecutive calls or repeating sequences like A→B→A→B)
and injects a corrective prompt to break the cycle.

Files changed (2) hide show
  1. agent/core/agent_loop.py +17 -0
  2. agent/core/doom_loop.py +135 -0
agent/core/agent_loop.py CHANGED
@@ -11,6 +11,7 @@ from litellm import ChatCompletionMessageToolCall, Message, acompletion
11
  from litellm.exceptions import ContextWindowExceededError
12
 
13
  from agent.config import Config
 
14
  from agent.core.session import Event, OpType, Session
15
  from agent.core.tools import ToolRouter
16
  from agent.tools.jobs_tool import CPU_FLAVORS
@@ -291,6 +292,22 @@ class Handlers:
291
  # Compact before calling the LLM if context is near the limit
292
  await _compact_and_notify(session)
293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  messages = session.context_manager.get_messages()
295
  tools = session.tool_router.get_tool_specs_for_llm()
296
  try:
 
11
  from litellm.exceptions import ContextWindowExceededError
12
 
13
  from agent.config import Config
14
+ from agent.core.doom_loop import check_for_doom_loop
15
  from agent.core.session import Event, OpType, Session
16
  from agent.core.tools import ToolRouter
17
  from agent.tools.jobs_tool import CPU_FLAVORS
 
292
  # Compact before calling the LLM if context is near the limit
293
  await _compact_and_notify(session)
294
 
295
+ # Doom-loop detection: break out of repeated tool call patterns
296
+ doom_prompt = check_for_doom_loop(session.context_manager.items)
297
+ if doom_prompt:
298
+ session.context_manager.add_message(
299
+ Message(role="user", content=doom_prompt)
300
+ )
301
+ await session.send_event(
302
+ Event(
303
+ event_type="tool_log",
304
+ data={
305
+ "tool": "system",
306
+ "log": "Doom loop detected — injecting corrective prompt",
307
+ },
308
+ )
309
+ )
310
+
311
  messages = session.context_manager.get_messages()
312
  tools = session.tool_router.get_tool_specs_for_llm()
313
  try:
agent/core/doom_loop.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Doom-loop detection for repeated tool call patterns.
3
+
4
+ Detects when the agent is stuck calling the same tools repeatedly
5
+ and injects a corrective prompt to break the cycle.
6
+ """
7
+
8
+ import hashlib
9
+ import json
10
+ import logging
11
+ from dataclasses import dataclass
12
+
13
+ from litellm import Message
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ToolCallSignature:
20
+ """Hashable signature for a single tool call (name + args hash)."""
21
+
22
+ name: str
23
+ args_hash: str
24
+
25
+
26
+ def _hash_args(args_str: str) -> str:
27
+ """Return a short hash of the JSON arguments string."""
28
+ return hashlib.md5(args_str.encode()).hexdigest()[:12]
29
+
30
+
31
+ def extract_recent_tool_signatures(
32
+ messages: list[Message], lookback: int = 30
33
+ ) -> list[ToolCallSignature]:
34
+ """Extract tool call signatures from recent assistant messages."""
35
+ signatures: list[ToolCallSignature] = []
36
+ recent = messages[-lookback:] if len(messages) > lookback else messages
37
+
38
+ for msg in recent:
39
+ if getattr(msg, "role", None) != "assistant":
40
+ continue
41
+ tool_calls = getattr(msg, "tool_calls", None)
42
+ if not tool_calls:
43
+ continue
44
+ for tc in tool_calls:
45
+ fn = getattr(tc, "function", None)
46
+ if not fn:
47
+ continue
48
+ name = getattr(fn, "name", "") or ""
49
+ args_str = getattr(fn, "arguments", "") or ""
50
+ signatures.append(ToolCallSignature(name=name, args_hash=_hash_args(args_str)))
51
+
52
+ return signatures
53
+
54
+
55
+ def detect_identical_consecutive(
56
+ signatures: list[ToolCallSignature], threshold: int = 3
57
+ ) -> str | None:
58
+ """Return the tool name if threshold+ identical consecutive calls are found."""
59
+ if len(signatures) < threshold:
60
+ return None
61
+
62
+ count = 1
63
+ for i in range(1, len(signatures)):
64
+ if signatures[i] == signatures[i - 1]:
65
+ count += 1
66
+ if count >= threshold:
67
+ return signatures[i].name
68
+ else:
69
+ count = 1
70
+
71
+ return None
72
+
73
+
74
+ def detect_repeating_sequence(
75
+ signatures: list[ToolCallSignature],
76
+ ) -> list[ToolCallSignature] | None:
77
+ """Detect repeating patterns like [A,B,A,B] for sequences of length 2-5 with 2+ reps."""
78
+ n = len(signatures)
79
+ for seq_len in range(2, 6):
80
+ min_required = seq_len * 2
81
+ if n < min_required:
82
+ continue
83
+
84
+ # Check the tail of the signatures list
85
+ tail = signatures[-min_required:]
86
+ pattern = tail[:seq_len]
87
+
88
+ # Count how many full repetitions from the end
89
+ reps = 0
90
+ for start in range(n - seq_len, -1, -seq_len):
91
+ chunk = signatures[start : start + seq_len]
92
+ if chunk == pattern:
93
+ reps += 1
94
+ else:
95
+ break
96
+
97
+ if reps >= 2:
98
+ return pattern
99
+
100
+ return None
101
+
102
+
103
+ def check_for_doom_loop(messages: list[Message]) -> str | None:
104
+ """Check for doom loop patterns. Returns a corrective prompt or None."""
105
+ signatures = extract_recent_tool_signatures(messages, lookback=30)
106
+ if len(signatures) < 3:
107
+ return None
108
+
109
+ # Check for identical consecutive calls
110
+ tool_name = detect_identical_consecutive(signatures, threshold=3)
111
+ if tool_name:
112
+ logger.warning("Doom loop detected: %d+ identical consecutive calls to '%s'", 3, tool_name)
113
+ return (
114
+ f"[SYSTEM: DOOM LOOP DETECTED] You have called '{tool_name}' with the same "
115
+ f"arguments multiple times in a row, getting the same result each time. "
116
+ f"STOP repeating this approach — it is not working. "
117
+ f"Step back and try a fundamentally different strategy. "
118
+ f"Consider: using a different tool, changing your arguments significantly, "
119
+ f"or explaining to the user what you're stuck on and asking for guidance."
120
+ )
121
+
122
+ # Check for repeating sequences
123
+ pattern = detect_repeating_sequence(signatures)
124
+ if pattern:
125
+ pattern_desc = " → ".join(s.name for s in pattern)
126
+ logger.warning("Doom loop detected: repeating sequence [%s]", pattern_desc)
127
+ return (
128
+ f"[SYSTEM: DOOM LOOP DETECTED] You are stuck in a repeating cycle of tool calls: "
129
+ f"[{pattern_desc}]. This pattern has repeated multiple times without progress. "
130
+ f"STOP this cycle and try a fundamentally different approach. "
131
+ f"Consider: breaking down the problem differently, using alternative tools, "
132
+ f"or explaining to the user what you're stuck on and asking for guidance."
133
+ )
134
+
135
+ return None