agentflow-proof 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentflow/stuck.py ADDED
@@ -0,0 +1,344 @@
1
+ """Read-only stuck-loop detection over Agentflow's event projection.
2
+
3
+ Receipt-pattern detection only: it reasons about repeated command failures,
4
+ repeated step-verification failures, and alternating no-op command cycles from
5
+ the projected event stream. It never reads model conversation, and it never
6
+ writes to disk.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional, Tuple
14
+
15
+ from .events import project_events
16
+
17
+ DEFAULT_MIN_COMMAND_FAILURES = 3
18
+ DEFAULT_MIN_VERIFY_FAILURES = 2
19
+ DEFAULT_MIN_CYCLE_REPEATS = 3
20
+
21
+ RULES_EVALUATED = 3
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Thresholds:
26
+ min_command_failures: int = DEFAULT_MIN_COMMAND_FAILURES
27
+ min_verify_failures: int = DEFAULT_MIN_VERIFY_FAILURES
28
+ min_cycle_repeats: int = DEFAULT_MIN_CYCLE_REPEATS
29
+
30
+
31
+ def _positioned(root: Path) -> List[Dict[str, Any]]:
32
+ """Projected events, each tagged with a monotonic detector ``position``."""
33
+ events = project_events(root)
34
+ for position, event in enumerate(events):
35
+ event["position"] = position
36
+ return events
37
+
38
+
39
+ def _slices(
40
+ events: List[Dict[str, Any]]
41
+ ) -> Dict[Tuple[str, str], List[Dict[str, Any]]]:
42
+ slices: Dict[Tuple[str, str], List[Dict[str, Any]]] = {}
43
+ for event in events:
44
+ step_id = event.get("step_id")
45
+ attempt_id = event.get("attempt_id")
46
+ if step_id is None or attempt_id is None:
47
+ continue
48
+ slices.setdefault((step_id, attempt_id), []).append(event)
49
+ return slices
50
+
51
+
52
+ def _is_command(event: Dict[str, Any]) -> bool:
53
+ return event.get("type") == "command.recorded"
54
+
55
+
56
+ def _is_file_change(event: Dict[str, Any]) -> bool:
57
+ return event.get("type") == "file.changed"
58
+
59
+
60
+ def _is_step_verify(event: Dict[str, Any]) -> bool:
61
+ return (
62
+ event.get("type") == "verification.run"
63
+ and (event.get("data") or {}).get("scope") == "step"
64
+ )
65
+
66
+
67
+ def _command_failed(data: Dict[str, Any]) -> bool:
68
+ if data.get("timed_out") is True:
69
+ return True
70
+ if data.get("decision") in ("blocked", "timeout"):
71
+ return True
72
+ return data.get("exit_code") != 0
73
+
74
+
75
+ def _progress_in_span(
76
+ slice_events: List[Dict[str, Any]], first_pos: int, last_pos: int
77
+ ) -> Dict[str, int]:
78
+ file_changes = 0
79
+ passing_verifies = 0
80
+ successful_commands = 0
81
+ for event in slice_events:
82
+ position = event["position"]
83
+ if position <= first_pos or position >= last_pos:
84
+ continue
85
+ if _is_file_change(event):
86
+ file_changes += 1
87
+ elif _is_step_verify(event) and (event.get("data") or {}).get("status") == "passed":
88
+ passing_verifies += 1
89
+ elif _is_command(event) and not _command_failed(event["data"]):
90
+ successful_commands += 1
91
+ return {
92
+ "file_changes": file_changes,
93
+ "passing_verifies": passing_verifies,
94
+ "successful_commands": successful_commands,
95
+ }
96
+
97
+
98
+ def detect_stuck(
99
+ root: Path,
100
+ plan: Optional[Dict[str, Any]] = None, # reserved for future plan-aware rules
101
+ thresholds: Thresholds = Thresholds(),
102
+ ) -> Dict[str, Any]:
103
+ events = _positioned(root)
104
+ findings: List[Dict[str, Any]] = []
105
+ for (step_id, attempt_id), slice_events in _slices(events).items():
106
+ findings.extend(
107
+ _repeated_command_failure(step_id, attempt_id, slice_events, thresholds)
108
+ )
109
+ findings.extend(
110
+ _repeated_verify_failure(step_id, attempt_id, slice_events, thresholds)
111
+ )
112
+ findings.extend(
113
+ _alternating_no_op(step_id, attempt_id, slice_events, thresholds)
114
+ )
115
+ # Each rule's finding dict MUST carry "first_position" (and "last_position").
116
+ findings.sort(key=lambda finding: finding["first_position"])
117
+ return {
118
+ "status": "stuck" if findings else "ok",
119
+ "findings": findings,
120
+ "summary": {"rules_evaluated": RULES_EVALUATED, "finding_count": len(findings)},
121
+ }
122
+
123
+
124
+ def stuck_block(
125
+ root: Path, plan: Optional[Dict[str, Any]] = None
126
+ ) -> Dict[str, Any]:
127
+ """Advisory proof block: rules evaluated plus findings (no status field)."""
128
+ report = detect_stuck(root, plan)
129
+ return {"rules_evaluated": RULES_EVALUATED, "findings": report["findings"]}
130
+
131
+
132
+ def _identity(data: Dict[str, Any]) -> Tuple[Any, ...]:
133
+ return (tuple(data.get("command") or []), data.get("gate"), data.get("cwd"))
134
+
135
+
136
+ def _fingerprint(data: Dict[str, Any]) -> Dict[str, Any]:
137
+ return {
138
+ "decision": data.get("decision"),
139
+ "exit_code": data.get("exit_code"),
140
+ "stdout_sha256": data.get("stdout_sha256"),
141
+ "stderr_sha256": data.get("stderr_sha256"),
142
+ }
143
+
144
+
145
+ def _command_finding(step_id, attempt_id, streak, slice_events, thresholds):
146
+ first, last = streak[0], streak[-1]
147
+ fingerprints = [_fingerprint(event["data"]) for event in streak]
148
+ base = (fingerprints[0]["decision"], fingerprints[0]["exit_code"])
149
+ stable = all(
150
+ (fingerprint["decision"], fingerprint["exit_code"]) == base
151
+ for fingerprint in fingerprints
152
+ )
153
+ command = " ".join(streak[0]["data"].get("command") or [])
154
+ count = len(streak)
155
+ if stable:
156
+ message = (
157
+ f"Command `{command}` failed {count}x in "
158
+ f"{step_id}/{attempt_id} with no change in outcome."
159
+ )
160
+ else:
161
+ message = (
162
+ f"Command `{command}` failed {count}x consecutively in "
163
+ f"{step_id}/{attempt_id}."
164
+ )
165
+ return {
166
+ "rule": "repeated_command_failure",
167
+ "severity": "warning",
168
+ "step_id": step_id,
169
+ "attempt_id": attempt_id,
170
+ "threshold": thresholds.min_command_failures,
171
+ "message": message,
172
+ "first_position": first["position"],
173
+ "last_position": last["position"],
174
+ "first_event": first["source"],
175
+ "last_event": last["source"],
176
+ "evidence": {
177
+ "command": list(streak[0]["data"].get("command") or []),
178
+ "gate": streak[0]["data"].get("gate"),
179
+ "cwd": streak[0]["data"].get("cwd"),
180
+ "failure_count": count,
181
+ "fingerprints": fingerprints,
182
+ "receipt_ids": [event["source"]["record_id"] for event in streak],
183
+ "progress_in_span": _progress_in_span(
184
+ slice_events, first["position"], last["position"]
185
+ ),
186
+ },
187
+ "suggested_action": (
188
+ "Change inputs or approach before re-running; identical retries are "
189
+ "not progressing."
190
+ ),
191
+ }
192
+
193
+
194
+ def _repeated_command_failure(step_id, attempt_id, slice_events, thresholds):
195
+ findings: List[Dict[str, Any]] = []
196
+ streaks: Dict[Tuple[Any, ...], List[Dict[str, Any]]] = {}
197
+
198
+ def flush(key: Tuple[Any, ...]) -> None:
199
+ streak = streaks.get(key) or []
200
+ if len(streak) >= thresholds.min_command_failures:
201
+ findings.append(
202
+ _command_finding(step_id, attempt_id, streak, slice_events, thresholds)
203
+ )
204
+ streaks[key] = []
205
+
206
+ for event in slice_events:
207
+ if not _is_command(event):
208
+ continue
209
+ key = _identity(event["data"])
210
+ if _command_failed(event["data"]):
211
+ streaks.setdefault(key, []).append(event)
212
+ else:
213
+ flush(key)
214
+ for key in list(streaks.keys()):
215
+ flush(key)
216
+ return findings
217
+
218
+
219
+ def _verify_finding(step_id, attempt_id, streak, slice_events, thresholds):
220
+ first, last = streak[0], streak[-1]
221
+ count = len(streak)
222
+ return {
223
+ "rule": "repeated_verify_failure",
224
+ "severity": "warning",
225
+ "step_id": step_id,
226
+ "attempt_id": attempt_id,
227
+ "threshold": thresholds.min_verify_failures,
228
+ "message": (
229
+ f"Step verification failed {count}x in {step_id}/{attempt_id} "
230
+ f"with no new file changes."
231
+ ),
232
+ "first_position": first["position"],
233
+ "last_position": last["position"],
234
+ "first_event": first["source"],
235
+ "last_event": last["source"],
236
+ "evidence": {
237
+ "verify_count": count,
238
+ "verification_ids": [event["source"]["record_id"] for event in streak],
239
+ "progress_in_span": _progress_in_span(
240
+ slice_events, first["position"], last["position"]
241
+ ),
242
+ },
243
+ "suggested_action": (
244
+ "Verification keeps failing with no file changes; change the code "
245
+ "before re-verifying."
246
+ ),
247
+ }
248
+
249
+
250
+ def _repeated_verify_failure(step_id, attempt_id, slice_events, thresholds):
251
+ findings: List[Dict[str, Any]] = []
252
+ streak: List[Dict[str, Any]] = []
253
+
254
+ def flush() -> None:
255
+ if len(streak) >= thresholds.min_verify_failures:
256
+ findings.append(
257
+ _verify_finding(step_id, attempt_id, streak, slice_events, thresholds)
258
+ )
259
+ streak.clear()
260
+
261
+ for event in slice_events:
262
+ if _is_step_verify(event):
263
+ if (event.get("data") or {}).get("status") == "failed":
264
+ streak.append(event)
265
+ else:
266
+ flush()
267
+ elif _is_file_change(event):
268
+ flush()
269
+ flush()
270
+ return findings
271
+
272
+
273
+ def _no_progress_in_window(slice_events, first_pos, last_pos) -> bool:
274
+ for event in slice_events:
275
+ position = event["position"]
276
+ if position < first_pos or position > last_pos:
277
+ continue
278
+ if _is_file_change(event):
279
+ return False
280
+ if _is_step_verify(event) and (event.get("data") or {}).get("status") == "passed":
281
+ return False
282
+ return True
283
+
284
+
285
+ def _cycle_finding(step_id, attempt_id, window, slice_events, thresholds, period, repeats):
286
+ first, last = window[0], window[-1]
287
+ cycle = [" ".join(window[i]["data"].get("command") or []) for i in range(period)]
288
+ return {
289
+ "rule": "alternating_no_op",
290
+ "severity": "warning",
291
+ "step_id": step_id,
292
+ "attempt_id": attempt_id,
293
+ "threshold": thresholds.min_cycle_repeats,
294
+ "message": (
295
+ f"Commands {' -> '.join(cycle)} cycled {repeats}x in "
296
+ f"{step_id}/{attempt_id} without changing files or passing verification."
297
+ ),
298
+ "first_position": first["position"],
299
+ "last_position": last["position"],
300
+ "first_event": first["source"],
301
+ "last_event": last["source"],
302
+ "evidence": {
303
+ "cycle": [list(window[i]["data"].get("command") or []) for i in range(period)],
304
+ "period": period,
305
+ "repeats": repeats,
306
+ "receipt_ids": [event["source"]["record_id"] for event in window],
307
+ "progress_in_span": _progress_in_span(
308
+ slice_events, first["position"], last["position"]
309
+ ),
310
+ },
311
+ "suggested_action": (
312
+ "Commands are oscillating without progress; step back and change "
313
+ "approach instead of repeating the cycle."
314
+ ),
315
+ }
316
+
317
+
318
+ def _alternating_no_op(step_id, attempt_id, slice_events, thresholds):
319
+ commands = [event for event in slice_events if _is_command(event)]
320
+ findings: List[Dict[str, Any]] = []
321
+ total = len(commands)
322
+ for period in (2, 3):
323
+ span = period * thresholds.min_cycle_repeats
324
+ index = 0
325
+ while index + span <= total:
326
+ window = commands[index : index + span]
327
+ base = [tuple(window[i]["data"].get("command") or []) for i in range(period)]
328
+ is_cycle = all(
329
+ tuple(window[offset]["data"].get("command") or []) == base[offset % period]
330
+ for offset in range(span)
331
+ )
332
+ if is_cycle and len(set(base)) >= 2 and _no_progress_in_window(
333
+ slice_events, window[0]["position"], window[-1]["position"]
334
+ ):
335
+ findings.append(
336
+ _cycle_finding(
337
+ step_id, attempt_id, window, slice_events,
338
+ thresholds, period, thresholds.min_cycle_repeats,
339
+ )
340
+ )
341
+ index += span
342
+ else:
343
+ index += 1
344
+ return findings