autoforge-cli 0.1.0a0__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.
Files changed (81) hide show
  1. autoforge/__init__.py +5 -0
  2. autoforge/agent_worktree.py +653 -0
  3. autoforge/certificate.py +495 -0
  4. autoforge/child_env.py +871 -0
  5. autoforge/claims.py +726 -0
  6. autoforge/claude_stream.py +501 -0
  7. autoforge/cli.py +2884 -0
  8. autoforge/config.py +2178 -0
  9. autoforge/config_fingerprint.py +374 -0
  10. autoforge/doctor.py +1551 -0
  11. autoforge/doctor_sandbox.py +691 -0
  12. autoforge/effect_ops.py +1272 -0
  13. autoforge/effects.py +3210 -0
  14. autoforge/engine.py +16637 -0
  15. autoforge/epic_commands.py +1486 -0
  16. autoforge/epic_graph.py +852 -0
  17. autoforge/errors.py +536 -0
  18. autoforge/evidence.py +1722 -0
  19. autoforge/executor.py +1601 -0
  20. autoforge/executor_duplex.py +985 -0
  21. autoforge/executor_supervised.py +144 -0
  22. autoforge/git_transport.py +970 -0
  23. autoforge/github.py +3402 -0
  24. autoforge/interaction.py +1027 -0
  25. autoforge/local_workspace.py +1253 -0
  26. autoforge/locking.py +1050 -0
  27. autoforge/loop_detect.py +660 -0
  28. autoforge/loop_guard.py +404 -0
  29. autoforge/materialize.py +722 -0
  30. autoforge/ownership.py +240 -0
  31. autoforge/pi_rpc.py +713 -0
  32. autoforge/premerge.py +181 -0
  33. autoforge/profiles.py +106 -0
  34. autoforge/progress.py +392 -0
  35. autoforge/prompts/__init__.py +173 -0
  36. autoforge/prompts/acceptance_actions.md +39 -0
  37. autoforge/prompts/analyze_execute.md +128 -0
  38. autoforge/prompts/common.md +132 -0
  39. autoforge/prompts/correction.md +12 -0
  40. autoforge/prompts/fix.md +252 -0
  41. autoforge/prompts/local_analyze_execute.md +69 -0
  42. autoforge/prompts/local_common.md +149 -0
  43. autoforge/prompts/local_fix.md +96 -0
  44. autoforge/prompts/local_review.md +103 -0
  45. autoforge/prompts/operator_acceptance.md +61 -0
  46. autoforge/prompts/replan_reexecute.md +991 -0
  47. autoforge/prompts/review.md +312 -0
  48. autoforge/prompts/update_epic.md +120 -0
  49. autoforge/prompts/update_epic_rerequest.md +85 -0
  50. autoforge/prompts/validation_evidence.md +26 -0
  51. autoforge/prompts/validation_failure.md +22 -0
  52. autoforge/prompts/worktree_state.md +45 -0
  53. autoforge/providers.py +2893 -0
  54. autoforge/read_context.py +389 -0
  55. autoforge/redaction.py +408 -0
  56. autoforge/replan.py +208 -0
  57. autoforge/replan_txn.py +1930 -0
  58. autoforge/result_parser.py +2234 -0
  59. autoforge/roadmap.py +151 -0
  60. autoforge/run_contract.py +408 -0
  61. autoforge/run_namespace.py +839 -0
  62. autoforge/runlog.py +764 -0
  63. autoforge/safefs.py +1717 -0
  64. autoforge/sandbox.py +1313 -0
  65. autoforge/sandbox_bwrap.py +815 -0
  66. autoforge/sandbox_pin.py +170 -0
  67. autoforge/sandbox_policy.py +1107 -0
  68. autoforge/scheduler.py +1143 -0
  69. autoforge/scheduler_state.py +2316 -0
  70. autoforge/scheduler_stop.py +421 -0
  71. autoforge/sdk_policy.py +988 -0
  72. autoforge/state.py +2417 -0
  73. autoforge/transitions.py +388 -0
  74. autoforge/validation.py +324 -0
  75. autoforge/worker.py +568 -0
  76. autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
  77. autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
  78. autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
  79. autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
  80. autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
  81. autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,660 @@
1
+ """Detect an agent stuck in a loop: output still flows, nothing new happens (#194).
2
+
3
+ #193 kills an agent that stops producing output. An agent that keeps
4
+ producing output while it repeats itself resets that timer forever; this
5
+ module decides when that is the case. It is pure and provider-neutral:
6
+ adapters and the executor's readers feed it monotonic times with tool names
7
+ and opaque digests, output bytes and provider retries. It reads no clock,
8
+ keeps no tool input, result or output line, and no digest leaves it.
9
+
10
+ A loop is defined over what the agent does and what comes back -- the same
11
+ action, with the same input and the same result, repeated with no new action
12
+ in between -- never over repeated text alone. Four signals:
13
+
14
+ * ``action_cycle`` (A). Every completed tool call is one action, which its
15
+ adapter fingerprints as (tool name, digest of the full canonical input,
16
+ digest of the full result) with :func:`digest` and
17
+ :func:`action_fingerprint`. A cycle of period ``p <= max_cycle_period``
18
+ repeated at least ``max_cycle_repeats`` times consecutively at the tail of
19
+ the action sequence is a loop. Re-running a test after each edit is not:
20
+ the edits in between differ.
21
+ * ``no_novelty`` (B). For ``novelty_window_seconds`` no action appeared that
22
+ the invocation had not already seen, while at least ``max_cycle_period *
23
+ max_cycle_repeats`` replayed actions fell inside the window. Below that
24
+ floor the window is inconclusive, so one long tool call (#193's concern,
25
+ the idle limit) is never read as a loop.
26
+ * ``repeated_lines`` (C). Unstructured output (OpenCode's stderr, a text
27
+ profile's stdout) is cut into lines at LF, at CRLF and at a CR alone;
28
+ each is normalized (escape sequences dropped, hex and digit runs masked,
29
+ whitespace collapsed) and hashed. A line, or a cycle of up to
30
+ ``max_cycle_period`` lines, repeated ``max_line_repeats`` times
31
+ consecutively is a loop. A line with no letter left once pytest's
32
+ progress marks are dropped (blank, a rule, a pytest dot line) says
33
+ nothing that could repeat as an action and is skipped. What a CR alone
34
+ ends is a redraw, drawn over in place by what follows: a retry message
35
+ redrawn that way is a line like any other, but a redraw that shows a
36
+ progress mark (a percentage, a bar or spinner glyph, an ASCII bar or
37
+ spinner frame) is a progress bar or a spinner, a display rather than a
38
+ line, and is skipped too.
39
+ * ``retry_storm`` (D). At least ``max_cycle_repeats`` provider retries in a
40
+ row, spanning ``novelty_window_seconds``, with no completed turn or action
41
+ in between; only an adapter that can tell a retry reports one.
42
+
43
+ Each signal has a severity: the fraction of its threshold reached, 1 or more
44
+ being conclusive. :class:`LoopMonitor` applies ``execution.loop_detection``:
45
+ from half a threshold it warns, again at most every
46
+ :data:`WARN_INTERVAL_SECONDS` and once more when the finding becomes
47
+ conclusive, never on every event; in ``kill`` mode it returns the first
48
+ conclusive finding as the verdict its caller ends the invocation on. In every
49
+ mode, ``off`` included, it keeps the calibration figures
50
+ (:meth:`LoopMonitor.calibration`) the default thresholds were checked
51
+ against (#199).
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import hashlib
57
+ import json
58
+ import os
59
+ import re
60
+ import threading
61
+ from collections import deque
62
+ from collections.abc import Callable
63
+ from dataclasses import dataclass
64
+ from typing import Protocol
65
+
66
+ from .config import LoopDetectionConfig
67
+ from .progress import MAX_TOOL_CHARS, clean
68
+
69
+ SIGNAL_ACTION_CYCLE = "action_cycle"
70
+ SIGNAL_NO_NOVELTY = "no_novelty"
71
+ SIGNAL_REPEATED_LINES = "repeated_lines"
72
+ SIGNAL_RETRY_STORM = "retry_storm"
73
+
74
+ MODE_KILL = "kill"
75
+ MODE_WARN = "warn"
76
+ MODE_OFF = "off"
77
+
78
+ # ``timeout_limit`` / ``ended_by`` of an invocation the detector ended, next
79
+ # to the executor's ``idle`` and ``max_runtime``.
80
+ LIMIT_LOOP = "loop"
81
+
82
+ # What :class:`LoopReport` says the detector did about its finding.
83
+ ACTION_KILLED = "killed"
84
+ ACTION_WARNED = "warned"
85
+
86
+ DIGEST_BYTES = 16
87
+ # Distinct actions remembered for ``no_novelty``. Past it an unseen action
88
+ # counts as new without being remembered: the bound errs toward no loop.
89
+ MAX_SEEN_ACTIONS = 65_536
90
+ # A line is hashed from its first bytes; the rest is dropped as it arrives.
91
+ MAX_LINE_BYTES = 1024
92
+ # Tool names a ``no_novelty`` finding lists.
93
+ MAX_FINDING_TOOLS = 8
94
+ # A finding is warned about from this fraction of its threshold on.
95
+ WARN_AT = 0.5
96
+ # The shortest gap between two warnings, the length of the progress
97
+ # heartbeat: a loop that goes on is mentioned again, an event stream is not
98
+ # echoed.
99
+ WARN_INTERVAL_SECONDS = 120.0
100
+
101
+ _LINE_END = re.compile(rb"\r\n|\r|\n")
102
+ # The escape sequences ``progress.clean`` removes: a colour code is not text.
103
+ _ESCAPES = re.compile(
104
+ r"\x1b\[[0-?]*[ -/]*[@-~]"
105
+ r"|\x9b[0-?]*[ -/]*[@-~]"
106
+ r"|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?"
107
+ r"|\x1b[PX^_][^\x1b]*(?:\x1b\\)?"
108
+ r"|\x1b[ -/]*[0-~]"
109
+ )
110
+ # A hex run is a word of hex digits holding at least one decimal digit (a
111
+ # commit, an address, a request id): "deadbeef" alone could be a word.
112
+ _HEX_RUN = re.compile(r"\b(?:0[xX])?(?=[0-9a-fA-F]*[0-9])[0-9a-fA-F]+\b")
113
+ _DIGIT_RUN = re.compile(r"[0-9]+")
114
+ _SPACE_RUN = re.compile(r"\s+")
115
+ _LETTER = re.compile(r"[^\W\d_]")
116
+ # A word of pytest's per-test status characters with a dot among them
117
+ # (``..F..s..``): a progress mark, not text.
118
+ _PYTEST_MARKS = re.compile(r"(?<!\S)[.FEsxX]*\.[.FEsxX]*(?!\S)")
119
+ # What only a progress display shows, looked for in a normalized redraw: a
120
+ # percentage (digits masked), a box-drawing, block, geometric or braille
121
+ # glyph (a bar, a spinner), an ASCII bar (``[=====> ]``), or an ASCII
122
+ # spinner frame (one of | / - \ alone) at either end. A message is rarely
123
+ # drawn with these, and one that is gets skipped: the test errs toward no
124
+ # loop.
125
+ _PROGRESS_MARK = re.compile(
126
+ r"#\s?%"
127
+ r"|[\u2500-\u25ff\u2800-\u28ff]"
128
+ r"|[\[|][=>#\- ]*[=>][=>#\- ]*[\]|]"
129
+ r"|^[-|/\\](?: |$)|(?:^| )[-|/\\]$"
130
+ )
131
+
132
+
133
+ def digest(value: object) -> bytes:
134
+ """A digest of ``value``'s canonical JSON: sorted keys, no whitespace.
135
+
136
+ What an adapter keeps of a tool input or result: the same content always
137
+ gives the same bytes, and nothing of the content can be read back. A
138
+ value that cannot be serialized gets a random digest, so it never makes
139
+ two actions look the same.
140
+ """
141
+ try:
142
+ text = json.dumps(
143
+ value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=repr
144
+ )
145
+ data = text.encode("utf-8", "surrogatepass")
146
+ except (TypeError, ValueError, RecursionError):
147
+ return os.urandom(DIGEST_BYTES)
148
+ return hashlib.blake2b(data, digest_size=DIGEST_BYTES).digest()
149
+
150
+
151
+ def action_fingerprint(name: str, input_digest: bytes, result_digest: bytes) -> bytes:
152
+ """One completed tool call: its name, its input's digest and its result's."""
153
+ h = hashlib.blake2b(digest_size=DIGEST_BYTES)
154
+ for part in (name.encode("utf-8", "surrogatepass"), input_digest, result_digest):
155
+ h.update(len(part).to_bytes(4, "big"))
156
+ h.update(part)
157
+ return h.digest()
158
+
159
+
160
+ def line_key(raw: bytes, *, redraw: bool = False) -> bytes | None:
161
+ """The digest of one normalized output line; ``None`` for a line that cannot loop.
162
+
163
+ Escape sequences are dropped, hex runs and then digit runs become ``#``
164
+ and whitespace collapses, so ``retrying in 5s (attempt 37)`` and
165
+ ``retrying in 6s (attempt 38)`` are the same line. A line with no letter
166
+ once pytest's progress marks are dropped cannot loop, and neither can a
167
+ ``redraw`` (a line a CR alone ended) that shows a progress mark: that is
168
+ a progress bar or a spinner frame, not a message.
169
+ """
170
+ text = _ESCAPES.sub("", raw.decode("utf-8", "replace"))
171
+ text = _DIGIT_RUN.sub("#", _HEX_RUN.sub("#", text))
172
+ text = _SPACE_RUN.sub(" ", text).strip()
173
+ if not _LETTER.search(_PYTEST_MARKS.sub("", text)):
174
+ return None
175
+ if redraw and _PROGRESS_MARK.search(text):
176
+ return None
177
+ return hashlib.blake2b(text.encode("utf-8"), digest_size=DIGEST_BYTES).digest()
178
+
179
+
180
+ def span_text(seconds: float) -> str:
181
+ """``9m14s``: how long a pattern lasted."""
182
+ total = max(0, int(seconds))
183
+ if total < 60:
184
+ return f"{total}s"
185
+ if total < 3600:
186
+ return f"{total // 60}m{total % 60:02d}s"
187
+ return f"{total // 3600}h{total % 3600 // 60:02d}m"
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class LoopFinding:
192
+ """One signal's view of the tail of the invocation.
193
+
194
+ ``repeats``: complete consecutive copies of the cycle (A, C), replayed
195
+ actions inside the window (B) or retries in a row (D). ``period``: the
196
+ cycle's length (A, C; 0 otherwise). ``tools``: the cycle's tool names in
197
+ order (A) or the tools replayed (B), cleaned. ``stream``: the output
198
+ stream (C). ``started`` and ``at``: monotonic times the pattern began and
199
+ was last seen. ``severity``: the fraction of the threshold reached.
200
+ """
201
+
202
+ signal: str
203
+ repeats: int
204
+ period: int = 0
205
+ tools: tuple[str, ...] = ()
206
+ stream: str = ""
207
+ started: float = 0.0
208
+ at: float = 0.0
209
+ severity: float = 0.0
210
+
211
+ @property
212
+ def conclusive(self) -> bool:
213
+ return self.severity >= 1.0
214
+
215
+ def describe(self) -> str:
216
+ """The pattern, for the error text and ``execution.json``."""
217
+ span = span_text(self.at - self.started)
218
+ tools = ", ".join(self.tools)
219
+ if self.signal == SIGNAL_ACTION_CYCLE:
220
+ what = (
221
+ f"the same action ({tools})"
222
+ if self.period == 1
223
+ else f"the same {self.period}-step action cycle ({tools})"
224
+ )
225
+ return f"{what} repeated {self.repeats} times over {span} with no new action"
226
+ if self.signal == SIGNAL_NO_NOVELTY:
227
+ return (
228
+ f"no new action for {span}: the last {self.repeats} actions ({tools}) "
229
+ "all repeated earlier ones"
230
+ )
231
+ if self.signal == SIGNAL_REPEATED_LINES:
232
+ what = (
233
+ f"the same {self.stream} line"
234
+ if self.period == 1
235
+ else f"the same {self.period}-line {self.stream} cycle"
236
+ )
237
+ return f"{what} repeated {self.repeats} times over {span} with no new line"
238
+ return f"{self.repeats} provider retries in a row over {span} with no completed turn"
239
+
240
+ def warning(self) -> str:
241
+ """The short form, for a progress line."""
242
+ tools = ", ".join(self.tools)
243
+ if self.signal == SIGNAL_ACTION_CYCLE:
244
+ return f"{self.period}-step cycle ({tools}) repeated {self.repeats}×"
245
+ if self.signal == SIGNAL_NO_NOVELTY:
246
+ span = span_text(self.at - self.started)
247
+ return f"no new action for {span}, {self.repeats} replayed ({tools})"
248
+ if self.signal == SIGNAL_REPEATED_LINES:
249
+ if self.period == 1:
250
+ return f"{self.stream} line repeated {self.repeats}×"
251
+ return f"{self.period}-line {self.stream} cycle repeated {self.repeats}×"
252
+ span = span_text(self.at - self.started)
253
+ return f"{self.repeats} provider retries in a row over {span}"
254
+
255
+
256
+ @dataclass(frozen=True)
257
+ class LoopReport:
258
+ """What the detector did in one invocation: the finding it killed for
259
+ (``killed``), or the strongest it warned about (``warned``)."""
260
+
261
+ action: str
262
+ finding: LoopFinding
263
+ warnings: int
264
+ # The monotonic time of the launch: the origin of the record's offsets.
265
+ origin: float
266
+
267
+ def describe(self) -> str:
268
+ return self.finding.describe()
269
+
270
+ def record(self) -> dict[str, object]:
271
+ """The ``loop`` entry of ``execution.json``: names, counts and times only.
272
+
273
+ No tool input, result, output line or digest: the description is
274
+ built from the cleaned tool names and the counts.
275
+ """
276
+ finding = self.finding
277
+ return {
278
+ "signal": finding.signal,
279
+ "action": self.action,
280
+ "period": finding.period,
281
+ "repeats": finding.repeats,
282
+ "tools": list(finding.tools),
283
+ "stream": finding.stream,
284
+ "started_after_seconds": round(max(0.0, finding.started - self.origin), 1),
285
+ "detected_after_seconds": round(max(0.0, finding.at - self.origin), 1),
286
+ "description": finding.describe(),
287
+ }
288
+
289
+
290
+ @dataclass(frozen=True)
291
+ class _Run:
292
+ period: int
293
+ repeats: int
294
+ started: float
295
+ names: tuple[str, ...]
296
+
297
+
298
+ class _CycleTracker:
299
+ """The repetition at the tail of a sequence, for every period up to a bound.
300
+
301
+ For each period ``p`` it counts how many items in a row equalled the item
302
+ ``p`` before them; a run of ``r`` such items is ``1 + r // p`` complete
303
+ copies of a ``p``-item cycle. When a run begins, the time and the names
304
+ of its first copy are kept, so a finding can say when it started and
305
+ what the cycle is. O(max_period) per item.
306
+ """
307
+
308
+ def __init__(self, max_period: int) -> None:
309
+ self._max_period = max_period
310
+ self._tail: deque[tuple[bytes, str, float]] = deque(maxlen=max_period)
311
+ self._run = [0] * (max_period + 1)
312
+ self._first: list[tuple[float, tuple[str, ...]]] = [(0.0, ())] * (max_period + 1)
313
+
314
+ def feed(self, key: bytes, name: str, now: float) -> _Run | None:
315
+ """Add one item; the strongest repetition now at the tail, if any.
316
+
317
+ Among equal repeat counts the shortest period wins, so ``A A A A``
318
+ is a 1-step cycle repeated 4 times, not a 2-step one repeated twice.
319
+ """
320
+ tail = self._tail
321
+ best: _Run | None = None
322
+ for p in range(1, self._max_period + 1):
323
+ if len(tail) < p or tail[-p][0] != key:
324
+ self._run[p] = 0
325
+ continue
326
+ if self._run[p] == 0:
327
+ copy = list(tail)[-p:]
328
+ self._first[p] = (copy[0][2], tuple(item[1] for item in copy))
329
+ self._run[p] += 1
330
+ repeats = 1 + self._run[p] // p
331
+ if repeats >= 2 and (best is None or repeats > best.repeats):
332
+ started, names = self._first[p]
333
+ best = _Run(p, repeats, started, names)
334
+ tail.append((key, name, now))
335
+ return best
336
+
337
+
338
+ class _LineFramer:
339
+ """Cut one stream's bytes into lines, keeping each line's head.
340
+
341
+ LF, CRLF and a CR alone end a line; a line a CR alone ended is flagged
342
+ as a redraw, since what follows draws over it in place. A CR that ends a
343
+ chunk is decided by the next one, so a CRLF split across two reads still
344
+ ends one line, not a redraw and then an empty line.
345
+ """
346
+
347
+ def __init__(self) -> None:
348
+ self._head = bytearray()
349
+ self._cr = False
350
+
351
+ def feed(self, data: bytes) -> list[tuple[bytes, bool]]:
352
+ """The lines ``data`` ends, each with whether a CR alone ended it."""
353
+ lines: list[tuple[bytes, bool]] = []
354
+ if not data:
355
+ return lines
356
+ if self._cr:
357
+ self._cr = False
358
+ crlf = data.startswith(b"\n")
359
+ lines.append((bytes(self._head), not crlf))
360
+ self._head.clear()
361
+ if crlf:
362
+ data = data[1:]
363
+ pos = 0
364
+ for match in _LINE_END.finditer(data):
365
+ self._take(data[pos : match.start()])
366
+ pos = match.end()
367
+ bare_cr = match.group() == b"\r"
368
+ if bare_cr and pos == len(data):
369
+ self._cr = True
370
+ return lines
371
+ lines.append((bytes(self._head), bare_cr))
372
+ self._head.clear()
373
+ self._take(data[pos:])
374
+ return lines
375
+
376
+ def _take(self, part: bytes) -> None:
377
+ room = MAX_LINE_BYTES - len(self._head)
378
+ if room > 0:
379
+ self._head += part[:room]
380
+
381
+
382
+ class LoopObserver(Protocol):
383
+ """What a clock-free protocol reducer reports to the loop detector.
384
+
385
+ The driver that owns the reducer supplies the time (see
386
+ ``providers._ClockedLoop``), so the reducer still reads no clock.
387
+ """
388
+
389
+ def action(self, name: str, fingerprint: bytes) -> None:
390
+ """A tool call completed (see :func:`action_fingerprint`)."""
391
+
392
+ def retry(self) -> None:
393
+ """The provider retried a request."""
394
+
395
+ def turn(self) -> None:
396
+ """The provider completed a turn."""
397
+
398
+
399
+ Warn = Callable[[str], None]
400
+
401
+
402
+ class LoopMonitor:
403
+ """One invocation's loop detector, under ``execution.loop_detection``.
404
+
405
+ Feed it with :meth:`action`, :meth:`retry`, :meth:`turn` and
406
+ :meth:`output`, each with the monotonic time the caller saw the event.
407
+ Each returns the verdict once, when in ``kill`` mode a signal first
408
+ becomes conclusive, and ``None`` otherwise; the caller then ends the
409
+ invocation. Warnings go to ``warn`` (a progress line); one that raises is
410
+ not called again. Thread-safe: an executor feeds it from its two reader
411
+ threads.
412
+ """
413
+
414
+ def __init__(
415
+ self,
416
+ config: LoopDetectionConfig,
417
+ origin: float,
418
+ warn: Warn | None = None,
419
+ *,
420
+ warn_interval: float = WARN_INTERVAL_SECONDS,
421
+ ) -> None:
422
+ if config.mode is None:
423
+ # Unset means "kill unless uncalibrated", which only the
424
+ # invocation's provider and phase decide (#199).
425
+ raise ValueError(
426
+ "LoopMonitor needs the mode of one invocation: pass "
427
+ "LoopDetectionConfig.for_invocation(provider, phase)"
428
+ )
429
+ self.mode: str = config.mode
430
+ self._period = config.max_cycle_period
431
+ self._cycle_repeats = config.max_cycle_repeats
432
+ self._window = float(config.novelty_window_seconds)
433
+ self._line_repeats = config.max_line_repeats
434
+ self._floor = config.max_cycle_period * config.max_cycle_repeats
435
+ self._origin = origin
436
+ self._warn = warn
437
+ self._warn_interval = warn_interval
438
+ self._lock = threading.Lock()
439
+ # Signal A.
440
+ self._cycles = _CycleTracker(self._period)
441
+ # Signal B: what was seen, when something last was new, and the
442
+ # replays since (their times, bounded by the floor, and their names).
443
+ self._seen: set[bytes] = set()
444
+ self._novel_at: float | None = None
445
+ self._replayed = 0
446
+ self._replay_times: deque[float] = deque(maxlen=self._floor)
447
+ self._replay_names: dict[str, None] = {}
448
+ # Signal C, per stream.
449
+ self._streams: dict[str, tuple[_LineFramer, _CycleTracker]] = {}
450
+ # Signal D.
451
+ self._retries = 0
452
+ self._retry_start = 0.0
453
+ # Calibration, whatever the mode.
454
+ self._actions = 0
455
+ self._max_cycle = (0, 0)
456
+ self._quiet_seconds = 0.0
457
+ self._quiet_actions = 0
458
+ self._max_lines = 0
459
+ self._max_retries = 0
460
+ # Outcome.
461
+ self._verdict: LoopFinding | None = None
462
+ self._strongest: LoopFinding | None = None
463
+ self._warnings = 0
464
+ self._warned_at: float | None = None
465
+ self._warned_conclusive = False
466
+
467
+ # -- feeding -------------------------------------------------------------
468
+ def action(self, name: str, fingerprint: bytes, now: float) -> LoopFinding | None:
469
+ """A completed tool call (see :func:`action_fingerprint`)."""
470
+ with self._lock:
471
+ tool = clean(name, MAX_TOOL_CHARS) or "tool"
472
+ self._actions += 1
473
+ self._retries = 0
474
+ run = self._cycles.feed(fingerprint, tool, now)
475
+ cycle = None
476
+ if run is not None:
477
+ if run.repeats > self._max_cycle[0]:
478
+ self._max_cycle = (run.repeats, run.period)
479
+ cycle = LoopFinding(
480
+ SIGNAL_ACTION_CYCLE,
481
+ run.repeats,
482
+ run.period,
483
+ run.names,
484
+ started=run.started,
485
+ at=now,
486
+ severity=run.repeats / self._cycle_repeats,
487
+ )
488
+ return self._consider(now, cycle, self._novelty(tool, fingerprint, now))
489
+
490
+ def retry(self, now: float) -> LoopFinding | None:
491
+ """The provider retried a request (an API error, a rate limit)."""
492
+ with self._lock:
493
+ if self._retries == 0:
494
+ self._retry_start = now
495
+ self._retries += 1
496
+ self._max_retries = max(self._max_retries, self._retries)
497
+ span = now - self._retry_start
498
+ storm = LoopFinding(
499
+ SIGNAL_RETRY_STORM,
500
+ self._retries,
501
+ started=self._retry_start,
502
+ at=now,
503
+ severity=min(self._retries / self._cycle_repeats, span / self._window),
504
+ )
505
+ return self._consider(now, storm)
506
+
507
+ def turn(self, now: float) -> None:
508
+ """The provider completed a turn: a retry streak, if any, is over."""
509
+ with self._lock:
510
+ self._retries = 0
511
+
512
+ def output(self, stream: str, data: bytes, now: float) -> LoopFinding | None:
513
+ """A chunk of unstructured output on ``stream``; hashed line by line and dropped."""
514
+ with self._lock:
515
+ framer, lines = self._streams.setdefault(
516
+ stream, (_LineFramer(), _CycleTracker(self._period))
517
+ )
518
+ verdict = None
519
+ for raw, redraw in framer.feed(data):
520
+ key = line_key(raw, redraw=redraw)
521
+ if key is None:
522
+ continue
523
+ run = lines.feed(key, stream, now)
524
+ if run is None:
525
+ continue
526
+ self._max_lines = max(self._max_lines, run.repeats)
527
+ severity = run.repeats / self._line_repeats
528
+ if severity < WARN_AT:
529
+ continue
530
+ finding = LoopFinding(
531
+ SIGNAL_REPEATED_LINES,
532
+ run.repeats,
533
+ run.period,
534
+ stream=stream,
535
+ started=run.started,
536
+ at=now,
537
+ severity=severity,
538
+ )
539
+ verdict = self._consider(now, finding) or verdict
540
+ return verdict
541
+
542
+ # -- results -------------------------------------------------------------
543
+ @property
544
+ def verdict(self) -> LoopFinding | None:
545
+ """The finding the invocation is to be killed for, in ``kill`` mode."""
546
+ with self._lock:
547
+ return self._verdict
548
+
549
+ @property
550
+ def warnings(self) -> int:
551
+ with self._lock:
552
+ return self._warnings
553
+
554
+ def report(self, *, killed: bool) -> LoopReport | None:
555
+ """The verdict, else the strongest finding warned about; ``None`` when neither.
556
+
557
+ ``killed``: whether the caller ended the invocation on the verdict.
558
+ A child that exited on its own just as the verdict came was not
559
+ killed, so its verdict is reported as a finding only.
560
+ """
561
+ with self._lock:
562
+ if self._verdict is not None:
563
+ action = ACTION_KILLED if killed else ACTION_WARNED
564
+ return LoopReport(action, self._verdict, self._warnings, self._origin)
565
+ if self._strongest is not None:
566
+ return LoopReport(ACTION_WARNED, self._strongest, self._warnings, self._origin)
567
+ return None
568
+
569
+ def calibration(self) -> dict[str, str | int | bool]:
570
+ """How close the invocation came to each threshold, for ``provider_summary``.
571
+
572
+ Recorded in every mode, so real runs show how far legitimate work
573
+ stays from the thresholds: the most consecutive copies of an action
574
+ cycle (and its period), the longest stretch with no new action (in
575
+ seconds and in replayed actions), the most consecutive copies of a
576
+ line cycle and the longest provider retry streak.
577
+ """
578
+ with self._lock:
579
+ return {
580
+ "loop_actions": self._actions,
581
+ "loop_max_cycle_repeats": self._max_cycle[0],
582
+ "loop_max_cycle_period": self._max_cycle[1],
583
+ "loop_longest_novelty_free_seconds": int(self._quiet_seconds),
584
+ "loop_longest_novelty_free_actions": self._quiet_actions,
585
+ "loop_max_line_repeats": self._max_lines,
586
+ "loop_max_retry_streak": self._max_retries,
587
+ }
588
+
589
+ # -- internals -----------------------------------------------------------
590
+ def _novelty(self, tool: str, fingerprint: bytes, now: float) -> LoopFinding | None:
591
+ if fingerprint not in self._seen:
592
+ if len(self._seen) < MAX_SEEN_ACTIONS:
593
+ self._seen.add(fingerprint)
594
+ self._novel_at = now
595
+ self._replayed = 0
596
+ self._replay_times.clear()
597
+ self._replay_names.clear()
598
+ return None
599
+ novel_at = self._novel_at if self._novel_at is not None else now
600
+ self._replayed += 1
601
+ self._replay_times.append(now)
602
+ if tool not in self._replay_names and len(self._replay_names) < MAX_FINDING_TOOLS:
603
+ self._replay_names[tool] = None
604
+ stretch = now - novel_at
605
+ self._quiet_seconds = max(self._quiet_seconds, stretch)
606
+ self._quiet_actions = max(self._quiet_actions, self._replayed)
607
+ since = now - self._window
608
+ in_window = 0
609
+ for at in reversed(self._replay_times):
610
+ if at < since:
611
+ break
612
+ in_window += 1
613
+ return LoopFinding(
614
+ SIGNAL_NO_NOVELTY,
615
+ in_window,
616
+ tools=tuple(self._replay_names),
617
+ started=novel_at,
618
+ at=now,
619
+ severity=min(stretch / self._window, in_window / self._floor),
620
+ )
621
+
622
+ def _consider(self, now: float, *candidates: LoopFinding | None) -> LoopFinding | None:
623
+ """Warn about, or return as the verdict, the strongest of ``candidates``."""
624
+ found = [f for f in candidates if f is not None and f.severity >= WARN_AT]
625
+ if not found or self.mode == MODE_OFF or self._verdict is not None:
626
+ return None
627
+ conclusive = [f for f in found if f.conclusive]
628
+ # The candidates come in signal order, the most specific first: a
629
+ # cycle names its steps, which a stretch with no novelty cannot.
630
+ finding = conclusive[0] if conclusive else max(found, key=lambda f: f.severity)
631
+ if self.mode == MODE_KILL and finding.conclusive:
632
+ self._verdict = finding
633
+ return finding
634
+ if self._strongest is None or finding.severity >= self._strongest.severity:
635
+ self._strongest = finding
636
+ due = (
637
+ self._warned_at is None
638
+ or now - self._warned_at >= self._warn_interval
639
+ or (finding.conclusive and not self._warned_conclusive)
640
+ )
641
+ if due:
642
+ self._warned_at = now
643
+ self._warnings += 1
644
+ if finding.conclusive:
645
+ self._warned_conclusive = True
646
+ text = f"loop detected, not killed (mode {self.mode}): {finding.warning()}"
647
+ else:
648
+ text = f"possible loop: {finding.warning()}"
649
+ self._say(text)
650
+ return None
651
+
652
+ def _say(self, text: str) -> None:
653
+ warn = self._warn
654
+ if warn is None:
655
+ return
656
+ try:
657
+ warn(text)
658
+ except Exception:
659
+ # A warning is observability; it never changes the outcome.
660
+ self._warn = None