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.
- autoforge/__init__.py +5 -0
- autoforge/agent_worktree.py +653 -0
- autoforge/certificate.py +495 -0
- autoforge/child_env.py +871 -0
- autoforge/claims.py +726 -0
- autoforge/claude_stream.py +501 -0
- autoforge/cli.py +2884 -0
- autoforge/config.py +2178 -0
- autoforge/config_fingerprint.py +374 -0
- autoforge/doctor.py +1551 -0
- autoforge/doctor_sandbox.py +691 -0
- autoforge/effect_ops.py +1272 -0
- autoforge/effects.py +3210 -0
- autoforge/engine.py +16637 -0
- autoforge/epic_commands.py +1486 -0
- autoforge/epic_graph.py +852 -0
- autoforge/errors.py +536 -0
- autoforge/evidence.py +1722 -0
- autoforge/executor.py +1601 -0
- autoforge/executor_duplex.py +985 -0
- autoforge/executor_supervised.py +144 -0
- autoforge/git_transport.py +970 -0
- autoforge/github.py +3402 -0
- autoforge/interaction.py +1027 -0
- autoforge/local_workspace.py +1253 -0
- autoforge/locking.py +1050 -0
- autoforge/loop_detect.py +660 -0
- autoforge/loop_guard.py +404 -0
- autoforge/materialize.py +722 -0
- autoforge/ownership.py +240 -0
- autoforge/pi_rpc.py +713 -0
- autoforge/premerge.py +181 -0
- autoforge/profiles.py +106 -0
- autoforge/progress.py +392 -0
- autoforge/prompts/__init__.py +173 -0
- autoforge/prompts/acceptance_actions.md +39 -0
- autoforge/prompts/analyze_execute.md +128 -0
- autoforge/prompts/common.md +132 -0
- autoforge/prompts/correction.md +12 -0
- autoforge/prompts/fix.md +252 -0
- autoforge/prompts/local_analyze_execute.md +69 -0
- autoforge/prompts/local_common.md +149 -0
- autoforge/prompts/local_fix.md +96 -0
- autoforge/prompts/local_review.md +103 -0
- autoforge/prompts/operator_acceptance.md +61 -0
- autoforge/prompts/replan_reexecute.md +991 -0
- autoforge/prompts/review.md +312 -0
- autoforge/prompts/update_epic.md +120 -0
- autoforge/prompts/update_epic_rerequest.md +85 -0
- autoforge/prompts/validation_evidence.md +26 -0
- autoforge/prompts/validation_failure.md +22 -0
- autoforge/prompts/worktree_state.md +45 -0
- autoforge/providers.py +2893 -0
- autoforge/read_context.py +389 -0
- autoforge/redaction.py +408 -0
- autoforge/replan.py +208 -0
- autoforge/replan_txn.py +1930 -0
- autoforge/result_parser.py +2234 -0
- autoforge/roadmap.py +151 -0
- autoforge/run_contract.py +408 -0
- autoforge/run_namespace.py +839 -0
- autoforge/runlog.py +764 -0
- autoforge/safefs.py +1717 -0
- autoforge/sandbox.py +1313 -0
- autoforge/sandbox_bwrap.py +815 -0
- autoforge/sandbox_pin.py +170 -0
- autoforge/sandbox_policy.py +1107 -0
- autoforge/scheduler.py +1143 -0
- autoforge/scheduler_state.py +2316 -0
- autoforge/scheduler_stop.py +421 -0
- autoforge/sdk_policy.py +988 -0
- autoforge/state.py +2417 -0
- autoforge/transitions.py +388 -0
- autoforge/validation.py +324 -0
- autoforge/worker.py +568 -0
- autoforge_cli-0.1.0a0.dist-info/METADATA +525 -0
- autoforge_cli-0.1.0a0.dist-info/RECORD +81 -0
- autoforge_cli-0.1.0a0.dist-info/WHEEL +5 -0
- autoforge_cli-0.1.0a0.dist-info/entry_points.txt +2 -0
- autoforge_cli-0.1.0a0.dist-info/licenses/LICENSE +21 -0
- autoforge_cli-0.1.0a0.dist-info/top_level.txt +1 -0
autoforge/loop_detect.py
ADDED
|
@@ -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
|