oc_chat
Loading...
Searching...
No Matches
stall_detector.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/core/stall_detector.py
4
5"""Pure, turn-scoped detection of repeated tool-call stalls.
6
7Purpose
8-------
9Detect a genuine runaway tool loop from observations the ReAct loop already
10owns. The detector has no event-bus, presenter, session or dispatch dependency;
11it records completed calls and evaluates the next proposed call.
12
13Responsibilities
14----------------
15- Canonicalise tool arguments so key order and string whitespace are irrelevant.
16- Deny the N-th identical proposal after N-1 matching calls completed.
17- Detect identical-error streaks and iterations containing no new read or write.
18- Carry short structured evidence that the loop can render for the operator.
19
20Contracts
21---------
22- All state is turn-scoped and :meth:`reset` removes it completely.
23- :meth:`observe` is called exactly once per completed call.
24- A write-class call or any new call shape resets the no-progress streak.
25- Thresholds are positive integers; configuration resolution lives elsewhere.
26
27Dependencies
28------------
29- oc_diagnostics (structured debug logging via _dbg)
30- json (parsing tool arguments supplied as JSON text and dumping every argument set as sorted, compact canonical JSON)
31- dataclasses (the frozen StallEvidence and Stall records)
32- oc_chat.core.defaults (lazy; ceiling - the three stall thresholds for from_defaults)
33
34Diagnostics
35-----------
36Domain: OC_CHAT.CORE
37Levels:
38 L2 - a stall is detected
39 L3 - state resets
40 L4 - module load
41"""
42from __future__ import annotations
43
44import json
45import re
46from dataclasses import dataclass
47from typing import Any, Mapping
48
49from oc_diagnostics import _dbg
50
51_dbg("OC_CHAT.CORE", "module", "loaded", 4)
52
53
54@dataclass(frozen=True)
56 """The bounded facts needed to explain one detected stall."""
57
58 tool: str
59 completed_count: int
60 proposed_count: int | None = None
61 last_error_summary: str | None = None
62 iteration_start: int | None = None
63 iteration_end: int | None = None
64
65
66@dataclass(frozen=True)
67class Stall:
68 """A detected signal and the evidence that tripped it."""
69
70 kind: str
71 evidence: StallEvidence
72
73
74def _normalise(value: Any) -> Any:
75 """Return a JSON-compatible value with whitespace-normalised strings."""
76 if isinstance(value, str):
77 return re.sub(r"\s+", " ", value).strip()
78 if isinstance(value, Mapping):
79 return {str(key): _normalise(item) for key, item in value.items()}
80 if isinstance(value, (list, tuple)):
81 return [_normalise(item) for item in value]
82 if value is None or isinstance(value, (bool, int, float)):
83 return value
84 return repr(value)
85
86
87def canonical_arguments(arguments: Any) -> str:
88 """Return stable JSON for arguments supplied as JSON text or Python values."""
89 parsed = arguments
90 if isinstance(arguments, str):
91 try:
92 parsed = json.loads(arguments)
93 except (json.JSONDecodeError, TypeError):
94 parsed = arguments
95 return json.dumps(
96 _normalise(parsed),
97 ensure_ascii=False,
98 separators=(",", ":"),
99 sort_keys=True,
100 )
101
102
104 """Hold the three stall signals for one turn."""
105
107 self,
108 *,
109 repeat_calls: int,
110 error_streak: int,
111 no_progress_iterations: int,
112 ) -> None:
113 thresholds = (repeat_calls, error_streak, no_progress_iterations)
114 if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0
115 for value in thresholds):
116 raise ValueError("stall thresholds must be positive integers")
117 self.repeat_calls = repeat_calls
118 self.error_streak = error_streak
119 self.no_progress_iterations = no_progress_iterations
120 self.reset()
121
122 @classmethod
123 def from_defaults(cls) -> "StallDetector":
124 """Construct a detector from the three shared ceiling resolutions."""
125 from oc_chat.core.defaults import ceiling
126
127 return cls(
128 repeat_calls=ceiling("stall_repeat_calls"),
129 error_streak=ceiling("stall_error_streak"),
130 no_progress_iterations=ceiling("stall_no_progress_iterations"),
131 )
132
133 @staticmethod
134 def _shape(tool: str, arguments: Any) -> tuple[str, str]:
135 return str(tool), canonical_arguments(arguments)
136
137 def reset(self) -> None:
138 """Clear every observation at the start of a turn."""
139 func = "StallDetector.reset"
140 self._shape_counts: dict[tuple[str, str], int] = {}
141 self._shape_last_error: dict[tuple[str, str], str | None] = {}
142 self._error_summary: str | None = None
144 self._error_start: int | None = None
146 self._no_progress_start: int | None = None
147 self._iteration: int | None = None
152 self._pending: Stall | None = None
153 _dbg("OC_CHAT.CORE", func, "turn state cleared", 3)
154
155 def reset_shape(self, tool: str, arguments: Any) -> None:
156 """Allow a human-approved call shape to start a fresh count."""
157 func = "StallDetector.reset_shape"
158 shape = self._shape(tool, arguments)
159 self._shape_counts.pop(shape, None)
160 self._shape_last_error.pop(shape, None)
161 self._error_summary = None
162 self._error_count = 0
163 self._error_start = None
164 self._no_progress_count = 0
165 self._no_progress_start = None
166 self._pending = None
167 self._reset_iteration()
168 _dbg("OC_CHAT.CORE", func, f"shape reset for tool={tool}", 3)
169
170 def check_proposal(self, tool: str, arguments: Any) -> Stall | None:
171 """Return a pending signal or deny the N-th matching proposal."""
172 func = "StallDetector.check_proposal"
173 if self._pending is not None:
174 return self._pending
175 shape = self._shape(tool, arguments)
176 completed = self._shape_counts.get(shape, 0)
177 proposed = completed + 1
178 if proposed < self.repeat_calls:
179 return None
180 stall = Stall(
181 kind="repeat",
182 evidence=StallEvidence(
183 tool=str(tool),
184 completed_count=completed,
185 proposed_count=proposed,
186 last_error_summary=self._shape_last_error.get(shape),
187 ),
188 )
189 _dbg("OC_CHAT.CORE", func,
190 f"stall=repeat tool={tool} completed={completed} proposed={proposed}", 2)
191 return stall
192
194 self,
195 *,
196 tool: str,
197 arguments: Any,
198 outcome: str,
199 error_summary: str | None,
200 is_write: bool,
201 iteration: int,
202 iteration_complete: bool,
203 ) -> Stall | None:
204 """Record one completed call and return a newly reached signal, if any."""
205 if self._iteration is not None and iteration != self._iteration:
206 self._finish_iteration()
207 if self._iteration is None:
208 self._iteration = iteration
209
210 shape = self._shape(tool, arguments)
211 repeated = self._shape_counts.get(shape, 0) > 0
212 self._shape_counts[shape] = self._shape_counts.get(shape, 0) + 1
213 self._shape_last_error[shape] = error_summary
214
215 self._iteration_has_calls = True
216 self._iteration_all_repeated = self._iteration_all_repeated and repeated
217 self._iteration_has_write = self._iteration_has_write or bool(is_write)
218 self._iteration_tool = str(tool)
219
220 detected = self._observe_error(
221 tool=str(tool),
222 outcome=outcome,
223 error_summary=error_summary,
224 iteration=iteration,
225 )
226 if iteration_complete:
227 detected = detected or self._finish_iteration()
228 return detected
229
231 self,
232 *,
233 tool: str,
234 outcome: str,
235 error_summary: str | None,
236 iteration: int,
237 ) -> Stall | None:
238 """Advance or reset the identical-error streak."""
239 func = "StallDetector._observe_error"
240 if outcome != "error" or not error_summary:
241 self._error_summary = None
242 self._error_count = 0
243 self._error_start = None
244 return None
245 if error_summary == self._error_summary:
246 self._error_count += 1
247 else:
248 self._error_summary = error_summary
249 self._error_count = 1
250 self._error_start = iteration
251 if self._error_count < self.error_streak:
252 return None
253 stall = Stall(
254 kind="error_streak",
255 evidence=StallEvidence(
256 tool=tool,
257 completed_count=self._error_count,
258 last_error_summary=error_summary,
259 iteration_start=self._error_start,
260 iteration_end=iteration,
261 ),
262 )
263 self._pending = self._pending or stall
264 _dbg("OC_CHAT.CORE", func,
265 f"stall=error_streak tool={tool} count={self._error_count}", 2)
266 return stall
267
268 def _finish_iteration(self) -> Stall | None:
269 """Fold the current iteration into the consecutive no-progress signal."""
270 func = "StallDetector._finish_iteration"
271 if self._iteration is None:
272 return None
273 iteration = self._iteration
274 qualifies = (
277 and not self._iteration_has_write
278 )
279 if qualifies:
280 self._no_progress_count += 1
281 if self._no_progress_count == 1:
282 self._no_progress_start = iteration
283 else:
284 self._no_progress_count = 0
285 self._no_progress_start = None
286
287 stall = None
289 stall = Stall(
290 kind="no_progress",
291 evidence=StallEvidence(
292 tool=self._iteration_tool,
293 completed_count=self._no_progress_count,
294 iteration_start=self._no_progress_start,
295 iteration_end=iteration,
296 ),
297 )
298 self._pending = self._pending or stall
299 _dbg("OC_CHAT.CORE", func,
300 f"stall=no_progress iterations={self._no_progress_count}", 2)
301 self._reset_iteration()
302 return stall
303
304 def _reset_iteration(self) -> None:
305 self._iteration = None
306 self._iteration_has_calls = False
307 self._iteration_all_repeated = True
308 self._iteration_has_write = False
309 self._iteration_tool = ""
310
311
312def stall_sentence(stall: Stall) -> str:
313 """Render one bounded sentence naming the loop and its evidence."""
314 evidence = stall.evidence
315 if stall.kind == "repeat":
316 error = (
317 f"; the last error was {evidence.last_error_summary}"
318 if evidence.last_error_summary else ""
319 )
320 return (
321 f"Stall detected: proposed call {evidence.proposed_count} to "
322 f"`{evidence.tool}` repeats identical arguments after "
323 f"{evidence.completed_count} completed calls{error}; the turn is halted."
324 )
325 if stall.kind == "error_streak":
326 return (
327 f"Stall detected: {evidence.completed_count} consecutive calls through "
328 f"`{evidence.tool}` failed with the same error "
329 f"{evidence.last_error_summary}; the turn is halted."
330 )
331 return (
332 f"Stall detected: iterations {evidence.iteration_start}-"
333 f"{evidence.iteration_end} made no progress across "
334 f"{evidence.completed_count} repeated read-only iterations, ending with "
335 f"`{evidence.tool}`; the turn is halted."
336 )
337
338
339__all__ = [
340 "Stall",
341 "StallDetector",
342 "StallEvidence",
343 "canonical_arguments",
344 "stall_sentence",
345]
tuple[str, str] _shape(str tool, Any arguments)
None reset_shape(self, str tool, Any arguments)
None __init__(self, *, int repeat_calls, int error_streak, int no_progress_iterations)
Stall|None check_proposal(self, str tool, Any arguments)
Stall|None observe(self, *, str tool, Any arguments, str outcome, str|None error_summary, bool is_write, int iteration, bool iteration_complete)
Stall|None _observe_error(self, *, str tool, str outcome, str|None error_summary, int iteration)
str stall_sentence(Stall stall)
str canonical_arguments(Any arguments)