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/__init__.py +5 -0
- agentflow/__main__.py +5 -0
- agentflow/aggregate.py +1015 -0
- agentflow/artifacts.py +331 -0
- agentflow/capabilities.py +124 -0
- agentflow/cli.py +2108 -0
- agentflow/cli_contract.py +238 -0
- agentflow/contracts.py +263 -0
- agentflow/coverage.py +438 -0
- agentflow/draft_plan.py +402 -0
- agentflow/events.py +201 -0
- agentflow/execution.py +1027 -0
- agentflow/execution_coverage.py +646 -0
- agentflow/git.py +89 -0
- agentflow/handoff.py +112 -0
- agentflow/hunks.py +245 -0
- agentflow/locks.py +71 -0
- agentflow/mcp_server.py +710 -0
- agentflow/packs.py +398 -0
- agentflow/porcelain.py +1014 -0
- agentflow/proof.py +1062 -0
- agentflow/receipts.py +604 -0
- agentflow/recommend.py +299 -0
- agentflow/review.py +924 -0
- agentflow/review_runner.py +516 -0
- agentflow/risk.py +328 -0
- agentflow/runtime.py +863 -0
- agentflow/stuck.py +344 -0
- agentflow/validation.py +696 -0
- agentflow/versioning.py +120 -0
- agentflow/viewer.py +391 -0
- agentflow/workflow_contract.py +164 -0
- agentflow_proof-1.0.0.dist-info/METADATA +524 -0
- agentflow_proof-1.0.0.dist-info/RECORD +38 -0
- agentflow_proof-1.0.0.dist-info/WHEEL +5 -0
- agentflow_proof-1.0.0.dist-info/entry_points.txt +3 -0
- agentflow_proof-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentflow_proof-1.0.0.dist-info/top_level.txt +1 -0
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
|