align-agent 0.4.3__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.
- align_agent/__init__.py +11 -0
- align_agent/_audit/common.py +58 -0
- align_agent/_audit/deepswe.py +260 -0
- align_agent/_audit/excerpts.py +203 -0
- align_agent/_audit/extract.py +798 -0
- align_agent/_audit/materialize.py +624 -0
- align_agent/_audit/score.py +325 -0
- align_agent/_audit/shellparse.py +1141 -0
- align_agent/_audit/tarsur.py +153 -0
- align_agent/_vendor/src/__init__.py +3 -0
- align_agent/_vendor/src/harness/__init__.py +13 -0
- align_agent/_vendor/src/harness/arms.py +524 -0
- align_agent/_vendor/src/harness/completion.py +104 -0
- align_agent/_vendor/src/harness/dependency_notice.py +616 -0
- align_agent/_vendor/src/harness/freshness_hook.py +131 -0
- align_agent/_vendor/src/harness/lean.py +41 -0
- align_agent/_vendor/src/harness/mini_real.py +1040 -0
- align_agent/_vendor/src/harness/miniswe_scaffold.py +661 -0
- align_agent/_vendor/src/harness/tail_diff.py +292 -0
- align_agent/_vendor/src/harness/viewport.py +220 -0
- align_agent/_vendor/src/metrics/__init__.py +38 -0
- align_agent/_vendor/src/metrics/coherence.py +354 -0
- align_agent/_vendor/src/metrics/corruption_oracles.py +139 -0
- align_agent/_vendor/src/metrics/replay.py +399 -0
- align_agent/_vendor/src/metrics/statistics.py +185 -0
- align_agent/_vendor/src/metrics/verification.py +104 -0
- align_agent/_vendor/src/parsers/__init__.py +33 -0
- align_agent/_vendor/src/parsers/base_parser.py +349 -0
- align_agent/_vendor/src/parsers/file_events.py +806 -0
- align_agent/_vendor/src/parsers/miniswe_parser.py +96 -0
- align_agent/_vendor/src/parsers/openhands_parser.py +414 -0
- align_agent/_vendor/src/parsers/sweagent_parser.py +180 -0
- align_agent/_vendor/src/parsers/terminus_parser.py +106 -0
- align_agent/cli.py +101 -0
- align_agent/core.py +161 -0
- align_agent/run.py +84 -0
- align_agent/sessions.py +985 -0
- align_agent/writethrough/__init__.py +4 -0
- align_agent/writethrough/_lib/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/__init__.py +3 -0
- align_agent/writethrough/_lib/wt_src/harness/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/harness/dependency_notice.py +616 -0
- align_agent/writethrough/_lib/wt_src/harness/lean.py +41 -0
- align_agent/writethrough/_lib/wt_src/harness/tail_diff.py +292 -0
- align_agent/writethrough/_lib/wt_src/harness/viewport.py +220 -0
- align_agent/writethrough/_lib/wt_src/metrics/__init__.py +38 -0
- align_agent/writethrough/_lib/wt_src/metrics/replay.py +399 -0
- align_agent/writethrough/_lib/wt_src/parsers/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/parsers/file_events.py +806 -0
- align_agent/writethrough/claude_code.py +81 -0
- align_agent/writethrough/engine.py +647 -0
- align_agent/writethrough/hooks.py +227 -0
- align_agent/writethrough/mcp_server.py +125 -0
- align_agent/writethrough/protocol.py +68 -0
- align_agent-0.4.3.dist-info/METADATA +224 -0
- align_agent-0.4.3.dist-info/RECORD +60 -0
- align_agent-0.4.3.dist-info/WHEEL +5 -0
- align_agent-0.4.3.dist-info/entry_points.txt +6 -0
- align_agent-0.4.3.dist-info/licenses/LICENSE +21 -0
- align_agent-0.4.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"""The tail-diff coherence mechanism, decoupled from any one agent scaffold.
|
|
2
|
+
|
|
3
|
+
Unlike full-file JIT Tail Refresh (Arm 7), which dumps the entire file (2k-4.2k
|
|
4
|
+
lines, $O(N)$ tokens) and rewrites earlier turns in the history (annihilating
|
|
5
|
+
the KV-cache prefix), this mechanism:
|
|
6
|
+
1. Is strictly append-only: past conversation turns are never modified,
|
|
7
|
+
preserving 100% of the prefix KV-cache in local engines (vLLM, SGLang) and APIs.
|
|
8
|
+
2. Computes the net unified diff or line-numbered diff hunk between what the
|
|
9
|
+
agent last saw (held view) and the current live file on disk ($O(\\Delta)$ tokens).
|
|
10
|
+
3. Naturally captures multiple disjoint hunks across large files (solving the
|
|
11
|
+
single-window limitation of Arm 9 Write-Through Viewport, which misses edits
|
|
12
|
+
outside a +/- 25 line window).
|
|
13
|
+
4. Handles sequential conflicting overwrites by collapsing intermediate mutations
|
|
14
|
+
into the exact net diff from the agent's held view to live disk.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import copy
|
|
18
|
+
import difflib
|
|
19
|
+
import os
|
|
20
|
+
from typing import Any, Dict, List, Optional, Protocol, Tuple
|
|
21
|
+
|
|
22
|
+
TAIL_DIFF_CONTEXT = 3
|
|
23
|
+
TAIL_DIFF_MAX_FILES = 3
|
|
24
|
+
TAIL_DIFF_MAX_LINE = 200
|
|
25
|
+
TAIL_DIFF_MAX_CHARS = 10_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _BindingsSource(Protocol):
|
|
29
|
+
bindings: List[object] # each binding needs .file_path and .file_content
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _is_binary(path: str) -> bool:
|
|
33
|
+
try:
|
|
34
|
+
with open(path, "rb") as f:
|
|
35
|
+
return b"\0" in f.read(8192)
|
|
36
|
+
except OSError:
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _clip(line: str) -> str:
|
|
41
|
+
body = line.rstrip("\n")
|
|
42
|
+
if len(body) <= TAIL_DIFF_MAX_LINE:
|
|
43
|
+
return line
|
|
44
|
+
tail = "\n" if line.endswith("\n") else ""
|
|
45
|
+
return f"{body[:TAIL_DIFF_MAX_LINE]} ...[{len(body) - TAIL_DIFF_MAX_LINE} more characters]{tail}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def format_line_numbered_diff(
|
|
49
|
+
held_content: str,
|
|
50
|
+
disk_content: str,
|
|
51
|
+
filename: str,
|
|
52
|
+
context: int = TAIL_DIFF_CONTEXT,
|
|
53
|
+
step_seen: Optional[int] = None,
|
|
54
|
+
current_step: Optional[int] = None,
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Format a diff between held content and live disk with explicit live line numbers."""
|
|
57
|
+
held_lines = held_content.splitlines()
|
|
58
|
+
disk_lines = disk_content.splitlines()
|
|
59
|
+
matcher = difflib.SequenceMatcher(None, held_lines, disk_lines)
|
|
60
|
+
|
|
61
|
+
step_info = f" (held at Step {step_seen})" if step_seen is not None else ""
|
|
62
|
+
curr_info = f" (Step {current_step})" if current_step is not None else ""
|
|
63
|
+
|
|
64
|
+
blocks: List[str] = []
|
|
65
|
+
for tag, alo, ahi, blo, bhi in matcher.get_opcodes():
|
|
66
|
+
if tag == "equal":
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
c_alo = max(0, alo - context)
|
|
70
|
+
c_ahi = min(len(held_lines), ahi + context)
|
|
71
|
+
c_blo = max(0, blo - context)
|
|
72
|
+
c_bhi = min(len(disk_lines), bhi + context)
|
|
73
|
+
|
|
74
|
+
hunk_header = (
|
|
75
|
+
f"@@ Held Lines {c_alo + 1}-{c_ahi}{step_info} "
|
|
76
|
+
f"-> Live Disk Lines {c_blo + 1}-{c_bhi}{curr_info} @@"
|
|
77
|
+
)
|
|
78
|
+
lines_out = [hunk_header]
|
|
79
|
+
|
|
80
|
+
# Context lines before change (using current live disk line numbers)
|
|
81
|
+
for lno in range(c_blo, blo):
|
|
82
|
+
lines_out.append(f"{lno + 1:4d} {_clip(disk_lines[lno])}")
|
|
83
|
+
|
|
84
|
+
# Removed lines from held view
|
|
85
|
+
for lno in range(alo, ahi):
|
|
86
|
+
lines_out.append(f" - {_clip(held_lines[lno])}")
|
|
87
|
+
|
|
88
|
+
# Added / modified lines on disk with current line numbers
|
|
89
|
+
for lno in range(blo, bhi):
|
|
90
|
+
lines_out.append(f"{lno + 1:4d} + {_clip(disk_lines[lno])}")
|
|
91
|
+
|
|
92
|
+
# Context lines after change
|
|
93
|
+
for lno in range(bhi, c_bhi):
|
|
94
|
+
lines_out.append(f"{lno + 1:4d} {_clip(disk_lines[lno])}")
|
|
95
|
+
|
|
96
|
+
blocks.append("\n".join(lines_out))
|
|
97
|
+
|
|
98
|
+
if not blocks:
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
f"[TAIL DIFF: File '{filename}' mutated on disk vs your held view]:\n"
|
|
103
|
+
+ "\n\n".join(blocks)
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def tail_diff_blocks(
|
|
108
|
+
workspace_dir: str,
|
|
109
|
+
files: List[str],
|
|
110
|
+
freshness_hook: Optional[_BindingsSource] = None,
|
|
111
|
+
before_contents: Optional[Dict[str, str]] = None,
|
|
112
|
+
context_lines: int = TAIL_DIFF_CONTEXT,
|
|
113
|
+
line_numbered: bool = True,
|
|
114
|
+
current_step: Optional[int] = None,
|
|
115
|
+
) -> Tuple[str, List[str]]:
|
|
116
|
+
"""Compute tail-diff blocks for the specified files against held views.
|
|
117
|
+
|
|
118
|
+
The held view is the latest binding from a step before `current_step`: a
|
|
119
|
+
read in the step that wrote the file was registered with the file as it
|
|
120
|
+
stood after the write, and diffing against it would show nothing. With no
|
|
121
|
+
earlier view, the content before this step is the baseline.
|
|
122
|
+
|
|
123
|
+
Returns the formatted diff text and the list of files successfully shown.
|
|
124
|
+
"""
|
|
125
|
+
if not files:
|
|
126
|
+
return "", []
|
|
127
|
+
|
|
128
|
+
bindings = getattr(freshness_hook, "bindings", None) or []
|
|
129
|
+
from .viewport import _ignored # generated output is not file state (harness v12)
|
|
130
|
+
ignored = _ignored(workspace_dir, list(files))
|
|
131
|
+
files = [f for f in files if f not in ignored]
|
|
132
|
+
known = set(before_contents or ()) | {getattr(b, "file_path", None) for b in bindings}
|
|
133
|
+
blocks: List[str] = []
|
|
134
|
+
shown: List[str] = []
|
|
135
|
+
used_chars = 0
|
|
136
|
+
|
|
137
|
+
for rel_path in sorted(files, key=lambda p: (p not in known, p)):
|
|
138
|
+
abs_path = os.path.join(workspace_dir, rel_path) if not os.path.isabs(rel_path) else rel_path
|
|
139
|
+
if not os.path.isfile(abs_path) or _is_binary(abs_path):
|
|
140
|
+
continue
|
|
141
|
+
if len(shown) >= TAIL_DIFF_MAX_FILES:
|
|
142
|
+
break
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
|
|
146
|
+
disk_content = f.read()
|
|
147
|
+
except OSError:
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
# Locate held content and step ID
|
|
151
|
+
held_content = None
|
|
152
|
+
step_seen = None
|
|
153
|
+
for b in reversed(bindings):
|
|
154
|
+
if getattr(b, "file_path", None) != rel_path:
|
|
155
|
+
continue
|
|
156
|
+
if current_step is not None and getattr(b, "step_id", None) is not None and b.step_id >= current_step:
|
|
157
|
+
continue # this step's own read: the file after the write
|
|
158
|
+
held_content = getattr(b, "file_content", None) or getattr(b, "raw_content", None)
|
|
159
|
+
step_seen = getattr(b, "step_id", None)
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
if held_content is None and before_contents and rel_path in before_contents:
|
|
163
|
+
held_content = before_contents[rel_path]
|
|
164
|
+
|
|
165
|
+
if held_content is None:
|
|
166
|
+
# No prior view exists: agent never read this file
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
if held_content == disk_content:
|
|
170
|
+
# File matches held view: no staleness
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
if line_numbered:
|
|
174
|
+
diff_text = format_line_numbered_diff(
|
|
175
|
+
held_content, disk_content, rel_path, context=context_lines, step_seen=step_seen
|
|
176
|
+
)
|
|
177
|
+
else:
|
|
178
|
+
diff_lines = list(difflib.unified_diff(
|
|
179
|
+
held_content.splitlines(keepends=True),
|
|
180
|
+
disk_content.splitlines(keepends=True),
|
|
181
|
+
fromfile=f"{rel_path} (held at Step {step_seen})",
|
|
182
|
+
tofile=f"{rel_path} (current on disk)",
|
|
183
|
+
n=context_lines,
|
|
184
|
+
))
|
|
185
|
+
diff_text = f"[TAIL DIFF: File '{rel_path}']\n" + "".join(_clip(l) for l in diff_lines)
|
|
186
|
+
|
|
187
|
+
if not diff_text:
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if used_chars + len(diff_text) > TAIL_DIFF_MAX_CHARS:
|
|
191
|
+
diff_text = diff_text[: TAIL_DIFF_MAX_CHARS - used_chars] + "\n...[truncated]"
|
|
192
|
+
|
|
193
|
+
blocks.append(diff_text)
|
|
194
|
+
shown.append(rel_path)
|
|
195
|
+
used_chars += len(diff_text)
|
|
196
|
+
if used_chars >= TAIL_DIFF_MAX_CHARS:
|
|
197
|
+
break
|
|
198
|
+
|
|
199
|
+
if not blocks:
|
|
200
|
+
return "", []
|
|
201
|
+
|
|
202
|
+
return "\n\n" + "\n\n".join(blocks), shown
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def apply_tail_diff(
|
|
206
|
+
messages: List[Dict[str, Any]],
|
|
207
|
+
bindings: List[object],
|
|
208
|
+
workspace_root: str = ".",
|
|
209
|
+
line_numbered: bool = True,
|
|
210
|
+
) -> List[Dict[str, Any]]:
|
|
211
|
+
"""Transform context by appending net diffs of stale files to prompt tail.
|
|
212
|
+
|
|
213
|
+
Crucially:
|
|
214
|
+
- messages[:-1] is NEVER touched, guaranteeing 100% prefix KV-cache reuse.
|
|
215
|
+
- Only files that are currently stale are diffed against the agent's held view.
|
|
216
|
+
- The diff is appended to the tail message.
|
|
217
|
+
"""
|
|
218
|
+
transformed = copy.deepcopy(messages)
|
|
219
|
+
if not transformed:
|
|
220
|
+
return transformed
|
|
221
|
+
|
|
222
|
+
# Find distinct stale files
|
|
223
|
+
latest_by_file: Dict[str, object] = {}
|
|
224
|
+
for b in bindings:
|
|
225
|
+
if getattr(b, "is_invalid", False):
|
|
226
|
+
latest_by_file[getattr(b, "file_path", "")] = b
|
|
227
|
+
|
|
228
|
+
if not latest_by_file:
|
|
229
|
+
return transformed
|
|
230
|
+
|
|
231
|
+
diff_blocks: List[str] = []
|
|
232
|
+
for f_path, binding in latest_by_file.items():
|
|
233
|
+
abs_p = os.path.join(workspace_root, f_path) if not os.path.isabs(f_path) else f_path
|
|
234
|
+
if os.path.exists(abs_p) and os.path.isfile(abs_p):
|
|
235
|
+
try:
|
|
236
|
+
with open(abs_p, "r", encoding="utf-8", errors="replace") as f:
|
|
237
|
+
disk_content = f.read()
|
|
238
|
+
held_content = getattr(binding, "file_content", None) or getattr(binding, "raw_content", None)
|
|
239
|
+
if held_content is None or held_content == disk_content:
|
|
240
|
+
continue
|
|
241
|
+
|
|
242
|
+
step_seen = getattr(binding, "step_id", None)
|
|
243
|
+
inval_step = getattr(binding, "invalidation_step", None)
|
|
244
|
+
|
|
245
|
+
if line_numbered:
|
|
246
|
+
diff_block = format_line_numbered_diff(
|
|
247
|
+
held_content=held_content,
|
|
248
|
+
disk_content=disk_content,
|
|
249
|
+
filename=f_path,
|
|
250
|
+
context=TAIL_DIFF_CONTEXT,
|
|
251
|
+
step_seen=step_seen,
|
|
252
|
+
current_step=inval_step,
|
|
253
|
+
)
|
|
254
|
+
else:
|
|
255
|
+
diff_lines = list(difflib.unified_diff(
|
|
256
|
+
held_content.splitlines(keepends=True),
|
|
257
|
+
disk_content.splitlines(keepends=True),
|
|
258
|
+
fromfile=f"{f_path} (held at Step {step_seen})",
|
|
259
|
+
tofile=f"{f_path} (current on disk at Step {inval_step})",
|
|
260
|
+
n=TAIL_DIFF_CONTEXT,
|
|
261
|
+
))
|
|
262
|
+
diff_block = f"[TAIL DIFF: File '{f_path}']\n" + "".join(_clip(l) for l in diff_lines)
|
|
263
|
+
|
|
264
|
+
if diff_block:
|
|
265
|
+
diff_blocks.append(diff_block)
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
if diff_blocks:
|
|
270
|
+
forwarding_text = "\n\n" + "\n\n".join(diff_blocks)
|
|
271
|
+
if transformed[-1].get("role") in ["user", "tool"]:
|
|
272
|
+
transformed[-1]["content"] += forwarding_text
|
|
273
|
+
else:
|
|
274
|
+
transformed.append({"role": "user", "content": forwarding_text.strip()})
|
|
275
|
+
|
|
276
|
+
return transformed
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def line_number_note(path, base, new, limit=4):
|
|
280
|
+
"""Every stretch of the model's last whole-file view of `path` that has moved since, with its
|
|
281
|
+
offset. Counted from that one view, so offsets from several writes add up instead of each being
|
|
282
|
+
stated against the previous block (which, in a 60-run test, led agents to apply a shift twice or
|
|
283
|
+
miss one; see docs/results/shift_test_anthropic_claude-sonnet-5.md)."""
|
|
284
|
+
moved = [(i1 + 1, i2, j1 + 1, j2, j1 - i1) for tag, i1, i2, j1, j2 in
|
|
285
|
+
difflib.SequenceMatcher(None, base.splitlines(), new.splitlines(), autojunk=False).get_opcodes()
|
|
286
|
+
if tag == "equal" and j1 != i1]
|
|
287
|
+
if not moved:
|
|
288
|
+
return ""
|
|
289
|
+
parts = [f"lines {a}-{b} are now {c}-{d} ({o:+d})" for a, b, c, d, o in moved[:limit]]
|
|
290
|
+
if len(moved) > limit:
|
|
291
|
+
parts.append(f"and {len(moved) - limit} more moved stretches")
|
|
292
|
+
return f"[LINE NUMBERS: since your last full view of '{path}': " + "; ".join(parts) + "]"
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""The write-through viewport fix, decoupled from any one agent scaffold.
|
|
2
|
+
|
|
3
|
+
This is the entire mechanism behind Arm 9 (write-through viewport): after a
|
|
4
|
+
step writes a file, compute a small line-numbered window centered on the
|
|
5
|
+
edited region and hand it back as text to append to that step's tool output.
|
|
6
|
+
Unlike retroactive prompt-rewriting arms, this never touches earlier turns --
|
|
7
|
+
the caller appends the returned text to the *current* step's own output,
|
|
8
|
+
before it enters the message history, so the transcript stays append-only
|
|
9
|
+
and prefix caches stay valid.
|
|
10
|
+
|
|
11
|
+
Nothing here is specific to mini-SWE-agent or to any particular action
|
|
12
|
+
syntax. The three inputs are generic: a workspace directory, the relative
|
|
13
|
+
paths a step wrote (however the caller detected that -- a filesystem
|
|
14
|
+
snapshot diff is the recommended method: `os.walk` the workspace before and
|
|
15
|
+
after the step, compare (mtime_ns, size) per file, and diff the two maps.
|
|
16
|
+
Command-text parsing cannot keep up with the real range of ways a step can
|
|
17
|
+
write a file: sed -i, an editor tool, a heredoc, a Python script that opens
|
|
18
|
+
the file itself), and the file's content immediately before the step, so the
|
|
19
|
+
edited region can be located.
|
|
20
|
+
|
|
21
|
+
Minimal wiring pattern for a new scaffold:
|
|
22
|
+
|
|
23
|
+
before = snapshot_workspace(workspace_dir)
|
|
24
|
+
before_contents = {p: read(p) for p in referenced_paths}
|
|
25
|
+
... run the step ...
|
|
26
|
+
after = snapshot_workspace(workspace_dir)
|
|
27
|
+
writes = sorted(p for p, meta in after.items() if before.get(p) != meta)
|
|
28
|
+
if writes:
|
|
29
|
+
step_output += viewport_echo(workspace_dir, writes, freshness_hook, before_contents)
|
|
30
|
+
|
|
31
|
+
`freshness_hook` is optional: pass `None` (or any object with an empty
|
|
32
|
+
`.bindings` list) if the caller doesn't already track per-file observation
|
|
33
|
+
history. Its only use here is as a fallback source for a file's prior
|
|
34
|
+
content when the caller didn't capture `before_contents` for a path that
|
|
35
|
+
turns out to have been written -- with no fallback available, the viewport
|
|
36
|
+
simply centers on the file's midpoint instead of the edited region.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
import subprocess
|
|
41
|
+
from typing import Dict, List, Optional, Protocol, Tuple
|
|
42
|
+
|
|
43
|
+
VIEWPORT_HALF = 25 # lines above and below the edit centre
|
|
44
|
+
# Bounds (harness v11). The viewport is appended after the observation template
|
|
45
|
+
# has folded the command's output, so nothing else limits it. Without them,
|
|
46
|
+
# `npm test` writing coverage files (one 2.85M-character line) and
|
|
47
|
+
# `git checkout <rev> -- src/` each produced a single observation of millions
|
|
48
|
+
# of characters. The total matches the template's 10,000-character fold.
|
|
49
|
+
VIEWPORT_MAX_FILES = 3
|
|
50
|
+
VIEWPORT_MAX_LINE = 200
|
|
51
|
+
VIEWPORT_MAX_CHARS = 10_000
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def snapshot_workspace(workspace_dir: str, skip_dirs: Optional[set] = None) -> Dict[str, Tuple[int, int]]:
|
|
55
|
+
"""Map every workspace file to (mtime_ns, size).
|
|
56
|
+
|
|
57
|
+
Used to detect writes by diffing before/after a step. Inferring writes
|
|
58
|
+
from command text cannot keep up with the shell surface agents actually
|
|
59
|
+
use (sed -i, python scripts that open(...,'w'), patch, cp, tee, editors),
|
|
60
|
+
and missing a write means a stale binding is never invalidated, which
|
|
61
|
+
silently zeroes out the measurement.
|
|
62
|
+
"""
|
|
63
|
+
skip = skip_dirs or {".git", "__pycache__", "node_modules", ".egg-info"}
|
|
64
|
+
snap: Dict[str, Tuple[int, int]] = {}
|
|
65
|
+
for root, dirs, files in os.walk(workspace_dir):
|
|
66
|
+
dirs[:] = [d for d in dirs if d not in skip]
|
|
67
|
+
for name in files:
|
|
68
|
+
full = os.path.join(root, name)
|
|
69
|
+
try:
|
|
70
|
+
st = os.stat(full)
|
|
71
|
+
except OSError:
|
|
72
|
+
continue
|
|
73
|
+
snap[os.path.relpath(full, workspace_dir)] = (st.st_mtime_ns, st.st_size)
|
|
74
|
+
return snap
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def diff_snapshots(before: Dict[str, Tuple[int, int]], after: Dict[str, Tuple[int, int]]) -> List[str]:
|
|
78
|
+
"""Files created or modified between two snapshots."""
|
|
79
|
+
return sorted(p for p, meta in after.items() if before.get(p) != meta)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _BindingsSource(Protocol):
|
|
83
|
+
bindings: List[object] # each binding needs .file_path and .file_content
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _is_binary(path: str) -> bool:
|
|
87
|
+
try:
|
|
88
|
+
with open(path, "rb") as f:
|
|
89
|
+
return b"\0" in f.read(8192)
|
|
90
|
+
except OSError:
|
|
91
|
+
return True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _ignored(workspace_dir: str, paths: List[str]) -> set:
|
|
95
|
+
"""The paths git ignores in this workspace: generated output (coverage,
|
|
96
|
+
build artefacts, dependencies), not file state the agent acts on. Empty
|
|
97
|
+
when the workspace is not a git repository."""
|
|
98
|
+
if not paths:
|
|
99
|
+
return set()
|
|
100
|
+
try:
|
|
101
|
+
res = subprocess.run(["git", "-C", workspace_dir, "check-ignore", "--stdin"], input="\n".join(paths),
|
|
102
|
+
capture_output=True, text=True, timeout=30)
|
|
103
|
+
except (OSError, subprocess.SubprocessError):
|
|
104
|
+
return set()
|
|
105
|
+
return set(res.stdout.split("\n")) if res.returncode in (0, 1) else set()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _clip(line: str) -> str:
|
|
109
|
+
body = line.rstrip("\n")
|
|
110
|
+
if len(body) <= VIEWPORT_MAX_LINE:
|
|
111
|
+
return line
|
|
112
|
+
tail = "\n" if line.endswith("\n") else ""
|
|
113
|
+
return f"{body[:VIEWPORT_MAX_LINE]} ...[{len(body) - VIEWPORT_MAX_LINE} more characters]{tail}"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def viewport_blocks(
|
|
117
|
+
workspace_dir: str,
|
|
118
|
+
writes: List[str],
|
|
119
|
+
freshness_hook: Optional[_BindingsSource] = None,
|
|
120
|
+
before_contents: Optional[Dict[str, str]] = None,
|
|
121
|
+
) -> Tuple[str, List[str]]:
|
|
122
|
+
"""The viewport text for a step's writes, and the files it shows.
|
|
123
|
+
|
|
124
|
+
A line-numbered window onto each just-written file, centered on the edit.
|
|
125
|
+
`writes` are workspace-relative paths a step wrote (see module docstring
|
|
126
|
+
for how to detect these). `before_contents` maps a subset of those paths
|
|
127
|
+
to their content immediately before the step, used to locate the edited
|
|
128
|
+
region; a path without an entry there falls back to the most recent
|
|
129
|
+
binding in `freshness_hook.bindings` for that path, then to the file's
|
|
130
|
+
midpoint if neither is available.
|
|
131
|
+
|
|
132
|
+
At most VIEWPORT_MAX_FILES files are shown, lines are clipped to
|
|
133
|
+
VIEWPORT_MAX_LINE characters and the whole to VIEWPORT_MAX_CHARS; files
|
|
134
|
+
the agent had read come first, and the rest are named in a closing line.
|
|
135
|
+
Binary files are skipped. Only the returned files may be registered as
|
|
136
|
+
seen: a written file the bounds left out was not shown to the agent.
|
|
137
|
+
"""
|
|
138
|
+
if not writes:
|
|
139
|
+
return "", []
|
|
140
|
+
|
|
141
|
+
bindings = getattr(freshness_hook, "bindings", None) or []
|
|
142
|
+
ignored = _ignored(workspace_dir, list(writes))
|
|
143
|
+
writes = [w for w in writes if w not in ignored]
|
|
144
|
+
known = set(before_contents or ()) | {getattr(b, "file_path", None) for b in bindings}
|
|
145
|
+
blocks: List[str] = []
|
|
146
|
+
shown: List[str] = []
|
|
147
|
+
left_out: List[str] = []
|
|
148
|
+
used = 0
|
|
149
|
+
for rel_path in sorted(writes, key=lambda p: (p not in known, p)):
|
|
150
|
+
abs_path = os.path.join(workspace_dir, rel_path)
|
|
151
|
+
if not os.path.isfile(abs_path) or _is_binary(abs_path):
|
|
152
|
+
continue
|
|
153
|
+
if len(shown) >= VIEWPORT_MAX_FILES:
|
|
154
|
+
left_out.append(rel_path)
|
|
155
|
+
continue
|
|
156
|
+
try:
|
|
157
|
+
with open(abs_path, "r", encoding="utf-8", errors="replace") as f:
|
|
158
|
+
lines = f.readlines()
|
|
159
|
+
except OSError:
|
|
160
|
+
continue
|
|
161
|
+
|
|
162
|
+
total = len(lines)
|
|
163
|
+
# Find the centre of the edit region by diffing against the
|
|
164
|
+
# before-snapshot content. Fall back to the file midpoint.
|
|
165
|
+
centre = total // 2
|
|
166
|
+
old_content = None
|
|
167
|
+
if before_contents and rel_path in before_contents:
|
|
168
|
+
old_content = before_contents[rel_path]
|
|
169
|
+
else:
|
|
170
|
+
for b in reversed(bindings):
|
|
171
|
+
if getattr(b, "file_path", None) == rel_path and getattr(b, "file_content", None):
|
|
172
|
+
old_content = b.file_content
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
if old_content is not None:
|
|
176
|
+
old_lines = old_content.splitlines(keepends=True)
|
|
177
|
+
first_diff = None
|
|
178
|
+
for i, (a, b) in enumerate(zip(old_lines, lines)):
|
|
179
|
+
if a != b:
|
|
180
|
+
first_diff = i
|
|
181
|
+
break
|
|
182
|
+
if first_diff is None and len(old_lines) != total:
|
|
183
|
+
first_diff = min(len(old_lines), total)
|
|
184
|
+
if first_diff is not None:
|
|
185
|
+
centre = first_diff
|
|
186
|
+
|
|
187
|
+
half = VIEWPORT_HALF
|
|
188
|
+
if total <= half * 2:
|
|
189
|
+
start, end = 0, total
|
|
190
|
+
else:
|
|
191
|
+
start = max(0, centre - half)
|
|
192
|
+
end = min(total, start + half * 2)
|
|
193
|
+
if end - start < half * 2:
|
|
194
|
+
start = max(0, end - half * 2)
|
|
195
|
+
|
|
196
|
+
viewport = "".join(f"{i}\t{_clip(line)}" for i, line in enumerate(lines[start:end], start=start + 1))
|
|
197
|
+
block = f"[VIEWPORT (lines {start + 1}-{end} of {total}): {rel_path}]\n{viewport}"
|
|
198
|
+
if used + len(block) > VIEWPORT_MAX_CHARS:
|
|
199
|
+
left_out.append(rel_path)
|
|
200
|
+
continue
|
|
201
|
+
blocks.append(block)
|
|
202
|
+
shown.append(rel_path)
|
|
203
|
+
used += len(block)
|
|
204
|
+
|
|
205
|
+
if left_out:
|
|
206
|
+
names = ", ".join(left_out[:10]) + (", ..." if len(left_out) > 10 else "")
|
|
207
|
+
blocks.append(f"[{len(left_out)} more written files not shown: {names}]")
|
|
208
|
+
if not blocks:
|
|
209
|
+
return "", []
|
|
210
|
+
return "\n\n" + "\n\n".join(blocks), shown
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def viewport_echo(
|
|
214
|
+
workspace_dir: str,
|
|
215
|
+
writes: List[str],
|
|
216
|
+
freshness_hook: Optional[_BindingsSource] = None,
|
|
217
|
+
before_contents: Optional[Dict[str, str]] = None,
|
|
218
|
+
) -> str:
|
|
219
|
+
"""The text of `viewport_blocks` alone."""
|
|
220
|
+
return viewport_blocks(workspace_dir, writes, freshness_hook, before_contents)[0]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Metrics package for staleness replay and statistical analysis.
|
|
2
|
+
|
|
3
|
+
The statistics helpers pull in statsmodels, scipy and numpy. Importing them
|
|
4
|
+
here made every consumer of this package pay that cost, including the
|
|
5
|
+
experiment runner, which needs nothing but the corruption oracles: on one
|
|
6
|
+
machine a scipy/numpy version mismatch in the active interpreter turned
|
|
7
|
+
`from wt_src.metrics.corruption_oracles import ...` into an import error, which
|
|
8
|
+
has nothing to do with what the caller asked for. They are resolved on first
|
|
9
|
+
attribute access instead.
|
|
10
|
+
|
|
11
|
+
The paper's numbers come from src/metrics/replay.py (one stale-edit
|
|
12
|
+
definition for both research questions) via scripts/run_phase1_analysis.py
|
|
13
|
+
and scripts/recompute_metrics.py.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
_LAZY = {
|
|
19
|
+
"compute_windowed_odds_ratios": ".statistics",
|
|
20
|
+
"fit_length_controlled_logistic_regression": ".statistics",
|
|
21
|
+
"fit_stale_edit_association": ".statistics",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def __getattr__(name: str) -> Any:
|
|
26
|
+
module_name = _LAZY.get(name)
|
|
27
|
+
if module_name is None:
|
|
28
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
29
|
+
from importlib import import_module
|
|
30
|
+
|
|
31
|
+
return getattr(import_module(module_name, __name__), name)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"compute_windowed_odds_ratios",
|
|
36
|
+
"fit_length_controlled_logistic_regression",
|
|
37
|
+
"fit_stale_edit_association",
|
|
38
|
+
]
|