5"""Pure, turn-scoped detection of repeated tool-call stalls.
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.
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.
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.
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)
38 L2 - a stall is detected
42from __future__
import annotations
46from dataclasses
import dataclass
47from typing
import Any, Mapping
49from oc_diagnostics
import _dbg
51_dbg(
"OC_CHAT.CORE",
"module",
"loaded", 4)
54@dataclass(frozen=True)
56 """The bounded facts needed to explain one detected stall."""
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
66@dataclass(frozen=True)
68 """A detected signal and the evidence that tripped it."""
71 evidence: StallEvidence
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)):
82 if value
is None or isinstance(value, (bool, int, float)):
88 """Return stable JSON for arguments supplied as JSON text or Python values."""
90 if isinstance(arguments, str):
92 parsed = json.loads(arguments)
93 except (json.JSONDecodeError, TypeError):
98 separators=(
",",
":"),
104 """Hold the three stall signals for one turn."""
111 no_progress_iterations: int,
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")
124 """Construct a detector from the three shared ceiling resolutions."""
128 repeat_calls=ceiling(
"stall_repeat_calls"),
129 error_streak=ceiling(
"stall_error_streak"),
130 no_progress_iterations=ceiling(
"stall_no_progress_iterations"),
134 def _shape(tool: str, arguments: Any) -> tuple[str, str]:
138 """Clear every observation at the start of a turn."""
139 func =
"StallDetector.reset"
153 _dbg(
"OC_CHAT.CORE", func,
"turn state cleared", 3)
156 """Allow a human-approved call shape to start a fresh count."""
157 func =
"StallDetector.reset_shape"
158 shape = self.
_shape(tool, arguments)
168 _dbg(
"OC_CHAT.CORE", func, f
"shape reset for tool={tool}", 3)
171 """Return a pending signal or deny the N-th matching proposal."""
172 func =
"StallDetector.check_proposal"
175 shape = self.
_shape(tool, arguments)
177 proposed = completed + 1
184 completed_count=completed,
185 proposed_count=proposed,
189 _dbg(
"OC_CHAT.CORE", func,
190 f
"stall=repeat tool={tool} completed={completed} proposed={proposed}", 2)
199 error_summary: str |
None,
202 iteration_complete: bool,
204 """Record one completed call and return a newly reached signal, if any."""
210 shape = self.
_shape(tool, arguments)
223 error_summary=error_summary,
226 if iteration_complete:
235 error_summary: str |
None,
238 """Advance or reset the identical-error streak."""
239 func =
"StallDetector._observe_error"
240 if outcome !=
"error" or not error_summary:
258 last_error_summary=error_summary,
260 iteration_end=iteration,
264 _dbg(
"OC_CHAT.CORE", func,
265 f
"stall=error_streak tool={tool} count={self._error_count}", 2)
269 """Fold the current iteration into the consecutive no-progress signal."""
270 func =
"StallDetector._finish_iteration"
295 iteration_end=iteration,
299 _dbg(
"OC_CHAT.CORE", func,
300 f
"stall=no_progress iterations={self._no_progress_count}", 2)
313 """Render one bounded sentence naming the loop and its evidence."""
314 evidence = stall.evidence
315 if stall.kind ==
"repeat":
317 f
"; the last error was {evidence.last_error_summary}"
318 if evidence.last_error_summary
else ""
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."
325 if stall.kind ==
"error_streak":
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."
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."
343 "canonical_arguments",
Stall|None _finish_iteration(self)
bool _iteration_has_calls
tuple[str, str] _shape(str tool, Any arguments)
int|None _no_progress_start
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)
"StallDetector" from_defaults(cls)
None _reset_iteration(self)
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)
bool _iteration_has_write
bool _iteration_all_repeated
str stall_sentence(Stall stall)
Any _normalise(Any value)
str canonical_arguments(Any arguments)