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.
Files changed (60) hide show
  1. align_agent/__init__.py +11 -0
  2. align_agent/_audit/common.py +58 -0
  3. align_agent/_audit/deepswe.py +260 -0
  4. align_agent/_audit/excerpts.py +203 -0
  5. align_agent/_audit/extract.py +798 -0
  6. align_agent/_audit/materialize.py +624 -0
  7. align_agent/_audit/score.py +325 -0
  8. align_agent/_audit/shellparse.py +1141 -0
  9. align_agent/_audit/tarsur.py +153 -0
  10. align_agent/_vendor/src/__init__.py +3 -0
  11. align_agent/_vendor/src/harness/__init__.py +13 -0
  12. align_agent/_vendor/src/harness/arms.py +524 -0
  13. align_agent/_vendor/src/harness/completion.py +104 -0
  14. align_agent/_vendor/src/harness/dependency_notice.py +616 -0
  15. align_agent/_vendor/src/harness/freshness_hook.py +131 -0
  16. align_agent/_vendor/src/harness/lean.py +41 -0
  17. align_agent/_vendor/src/harness/mini_real.py +1040 -0
  18. align_agent/_vendor/src/harness/miniswe_scaffold.py +661 -0
  19. align_agent/_vendor/src/harness/tail_diff.py +292 -0
  20. align_agent/_vendor/src/harness/viewport.py +220 -0
  21. align_agent/_vendor/src/metrics/__init__.py +38 -0
  22. align_agent/_vendor/src/metrics/coherence.py +354 -0
  23. align_agent/_vendor/src/metrics/corruption_oracles.py +139 -0
  24. align_agent/_vendor/src/metrics/replay.py +399 -0
  25. align_agent/_vendor/src/metrics/statistics.py +185 -0
  26. align_agent/_vendor/src/metrics/verification.py +104 -0
  27. align_agent/_vendor/src/parsers/__init__.py +33 -0
  28. align_agent/_vendor/src/parsers/base_parser.py +349 -0
  29. align_agent/_vendor/src/parsers/file_events.py +806 -0
  30. align_agent/_vendor/src/parsers/miniswe_parser.py +96 -0
  31. align_agent/_vendor/src/parsers/openhands_parser.py +414 -0
  32. align_agent/_vendor/src/parsers/sweagent_parser.py +180 -0
  33. align_agent/_vendor/src/parsers/terminus_parser.py +106 -0
  34. align_agent/cli.py +101 -0
  35. align_agent/core.py +161 -0
  36. align_agent/run.py +84 -0
  37. align_agent/sessions.py +985 -0
  38. align_agent/writethrough/__init__.py +4 -0
  39. align_agent/writethrough/_lib/__init__.py +1 -0
  40. align_agent/writethrough/_lib/wt_src/__init__.py +3 -0
  41. align_agent/writethrough/_lib/wt_src/harness/__init__.py +1 -0
  42. align_agent/writethrough/_lib/wt_src/harness/dependency_notice.py +616 -0
  43. align_agent/writethrough/_lib/wt_src/harness/lean.py +41 -0
  44. align_agent/writethrough/_lib/wt_src/harness/tail_diff.py +292 -0
  45. align_agent/writethrough/_lib/wt_src/harness/viewport.py +220 -0
  46. align_agent/writethrough/_lib/wt_src/metrics/__init__.py +38 -0
  47. align_agent/writethrough/_lib/wt_src/metrics/replay.py +399 -0
  48. align_agent/writethrough/_lib/wt_src/parsers/__init__.py +1 -0
  49. align_agent/writethrough/_lib/wt_src/parsers/file_events.py +806 -0
  50. align_agent/writethrough/claude_code.py +81 -0
  51. align_agent/writethrough/engine.py +647 -0
  52. align_agent/writethrough/hooks.py +227 -0
  53. align_agent/writethrough/mcp_server.py +125 -0
  54. align_agent/writethrough/protocol.py +68 -0
  55. align_agent-0.4.3.dist-info/METADATA +224 -0
  56. align_agent-0.4.3.dist-info/RECORD +60 -0
  57. align_agent-0.4.3.dist-info/WHEEL +5 -0
  58. align_agent-0.4.3.dist-info/entry_points.txt +6 -0
  59. align_agent-0.4.3.dist-info/licenses/LICENSE +21 -0
  60. align_agent-0.4.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,806 @@
1
+ """Ordered file events in a shell command, for the stale-act replay.
2
+
3
+ The stale-act rate asks one question of every edit: when the agent issued it,
4
+ had the file changed since the agent last saw it? Answering that needs three
5
+ things the older extractors did not give. Order: `cat f; sed -i ... f` and
6
+ `sed -i ... f; cat f` differ in exactly the way that matters. What was *shown*:
7
+ `cp a b` names a without showing it, `grep -l` prints file names and no lines,
8
+ and neither is a view. What *kind* of write: an edit located by existing text
9
+ or line numbers depends on the view it was composed against, while a heredoc
10
+ that rewrites the whole file does not, and a `git checkout` restores content
11
+ the agent never typed.
12
+
13
+ An event is (op, path, kind):
14
+ op "R" content of `path` was printed to the agent
15
+ "W" `path` was changed
16
+ kind for R: "view", or "echo" for the lines an editor prints back
17
+ after its own edit
18
+ for W: "anchored" located by existing text or lines (sed -i,
19
+ perl -i, awk inplace, patch, a script that
20
+ opens the file for writing)
21
+ "overwrite" whole content replaced by text the command
22
+ itself carries (heredoc, echo, printf)
23
+ "replace" whole content replaced by text the agent does
24
+ not see (`cmd > f`, cp of several files)
25
+ "copy:SRC" whole content replaced by SRC's (cp/mv/install
26
+ of one file onto another)
27
+ "append", "delete"
28
+ "restore" git checkout/restore/stash/reset; path "*"
29
+ when the command names no path
30
+ """
31
+
32
+ import os
33
+ import re
34
+ import shlex
35
+ from typing import Dict, List, Optional, Tuple
36
+
37
+ FileEvent = Tuple[str, str, str]
38
+
39
+ ALL_PATHS = "*"
40
+ # Workspace mount points agents write absolute paths under; stripping one of
41
+ # these gives the workspace-relative path. Shared by the live harness and the
42
+ # replay so the two canonicalize identically.
43
+ CONTAINER_ROOTS = ("/testbed/", "/app/", "/workspace/", "/root/repo/", "/home/")
44
+
45
+ _HEREDOC_OP_RE = re.compile(r"""(?<!<)<<-?(?!<)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1""")
46
+ _SEPARATORS = {";", ";;", "&&", "||", "&", "(", ")"}
47
+ _SEPARATOR_CHARS = set("();|&\n")
48
+ _REDIRECT_CHARS = set("<>&")
49
+
50
+ _VIEW_VERBS = {"cat", "head", "tail", "nl", "less", "more", "bat", "view"}
51
+ _GREP_VERBS = {"grep", "rg", "egrep", "fgrep", "ack", "ag"}
52
+ # Flags under which grep prints names or counts and never a line of content.
53
+ _GREP_NO_CONTENT = {"-l", "-L", "-c", "-q", "--files-with-matches",
54
+ "--files-without-match", "--count", "--quiet", "--silent"}
55
+ _INTERPRETERS = ("python", "perl", "ruby", "node")
56
+ _LITERAL_SOURCES = {"echo", "printf"}
57
+ _WRAPPERS = {"sudo", "command", "exec", "nohup", "time"}
58
+
59
+ _SED_SCRIPT_RE = re.compile(r"^[0-9,~$+]*\s*[a-zA-Z]([/,;|!].*)?$|^[/$^].*[/]|^[0-9,$]+[a-zA-Z]")
60
+ _DIFF_TARGET_RE = re.compile(r"^\+\+\+\s+(?:b/)?(\S+)", re.M)
61
+ _PY_OPEN_LIT = re.compile(r"""open\(\s*[rbfu]?(['"])(?P<p>[^'"\n]+)\1\s*,\s*(?:mode\s*=\s*)?[rbfu]?(['"])(?P<m>[^'"\n]*)\3""")
62
+ _PY_ASSIGN = re.compile(r"""(?P<v>\b[A-Za-z_]\w*)\s*=\s*(?:(?:pathlib\.)?Path\(\s*)?[rbfu]?(['"])(?P<p>[^'"\n]+)\2""")
63
+ _PY_OPEN_VAR = re.compile(r"""open\(\s*(?P<v>[A-Za-z_]\w*)\s*,\s*(?:mode\s*=\s*)?[rbfu]?(['"])(?P<m>[^'"\n]*)\2""")
64
+ _PY_PATH_LIT_WRITE = re.compile(r"""Path\(\s*[rbfu]?(['"])(?P<p>[^'"\n]+)\1\s*\)\s*\.write_(?:text|bytes)\(""")
65
+ _PY_VAR_WRITE = re.compile(r"""(?P<v>\b[A-Za-z_]\w*)\s*\.write_(?:text|bytes)\(""")
66
+
67
+
68
+ # Files conventionally named without an extension; any other bare word is
69
+ # taken not to be a path, so these must be listed.
70
+ COPY_PREFIX = "copy:"
71
+
72
+
73
+ def copy_source(kind: str) -> Optional[str]:
74
+ """The source path a "copy:SRC" write carries, or None."""
75
+ return kind[len(COPY_PREFIX):] if kind.startswith(COPY_PREFIX) else None
76
+
77
+
78
+ def map_kind_path(kind: str, f) -> str:
79
+ """`kind` with the source path of a copy rewritten by `f`, as the
80
+ target's path is, so the two stay in the same spelling."""
81
+ src = copy_source(kind)
82
+ return kind if src is None else COPY_PREFIX + f(src)
83
+
84
+
85
+ _KNOWN_NAMES = {
86
+ "Makefile", "makefile", "GNUmakefile", "Dockerfile", "Containerfile", "configure", "Rakefile",
87
+ "Gemfile", "Procfile", "Vagrantfile", "Jenkinsfile", "Pipfile", "Justfile", "Brewfile",
88
+ "SConstruct", "SConscript", "Snakefile", "BUILD", "WORKSPACE", "Kconfig", "Kbuild",
89
+ "LICENSE", "COPYING", "README", "CHANGELOG", "NOTICE", "AUTHORS", "INSTALL",
90
+ }
91
+ _DOTFILE = re.compile(r"\.[A-Za-z][\w.-]*")
92
+
93
+
94
+ def plausible_path(tok: str) -> bool:
95
+ """Whether a shell word can be a file path rather than an expression."""
96
+ if not tok or tok.startswith("-"):
97
+ return False
98
+ if tok in ("/dev/null", "/dev/stdout", "/dev/stderr", "null", ".", ".."):
99
+ return False
100
+ if tok.startswith("/dev/") or tok.startswith("/proc/"):
101
+ return False
102
+ if any(ch in tok for ch in "{}$*?!()<>\"'`=\n"):
103
+ return False
104
+ if tok.isdigit():
105
+ return False
106
+ base = tok.rsplit("/", 1)[-1]
107
+ return ("/" in tok or bool(re.search(r"\.[A-Za-z0-9_]{1,6}$", tok)) or base in _KNOWN_NAMES
108
+ or base.split(".", 1)[0] in _KNOWN_NAMES or bool(_DOTFILE.fullmatch(base)))
109
+
110
+
111
+ def clean(tok: str) -> str:
112
+ tok = tok.strip("'\"` ,;")
113
+ # /proc/self/cwd/ is the process's working directory.
114
+ if tok.startswith("/proc/self/cwd/"):
115
+ tok = tok[len("/proc/self/cwd/"):]
116
+ return tok[2:] if tok.startswith("./") else tok
117
+
118
+
119
+ def split_heredocs(cmd: str) -> Tuple[str, List[str]]:
120
+ """Shell text with heredoc bodies removed, and the bodies in the order
121
+ their `<<` operators appear. Every heredoc is removed, not only the first:
122
+ lines of a second body otherwise read as commands."""
123
+ lines = (cmd or "").split("\n")
124
+ out, bodies, i = [], [], 0
125
+ while i < len(lines):
126
+ line = lines[i]
127
+ out.append(line)
128
+ i += 1
129
+ for m in _HEREDOC_OP_RE.finditer(line):
130
+ delim, body = m.group(2), []
131
+ while i < len(lines) and lines[i].strip() != delim:
132
+ body.append(lines[i])
133
+ i += 1
134
+ i += 1
135
+ bodies.append("\n".join(body))
136
+ return "\n".join(out), bodies
137
+
138
+
139
+ def command_units(cmd: str) -> List[Dict]:
140
+ """Simple commands in order, each with its argv, its redirections, its
141
+ heredoc body, and the pipeline it belongs to.
142
+
143
+ Tokenising respects quoting, so a `>` inside a grep pattern is part of
144
+ the pattern and not a redirection; newlines separate commands as `;`
145
+ does."""
146
+ shell, bodies = split_heredocs(cmd)
147
+ # A backslash-newline continues a line; join before tokenising.
148
+ shell = shell.replace("\\\n", " ")
149
+ try:
150
+ lexer = shlex.shlex(shell, posix=True, punctuation_chars="();<>|&\n")
151
+ lexer.whitespace = " \t\r"
152
+ lexer.whitespace_split = True
153
+ tokens = list(lexer)
154
+ except ValueError:
155
+ return []
156
+
157
+ units: List[Dict] = []
158
+ pipe = 0
159
+ depth = 0
160
+ body_i = 0
161
+ pending: Optional[str] = None
162
+
163
+ def new() -> Dict:
164
+ return {"argv": [], "redirs": [], "heredoc": None, "pipe": pipe, "depth": depth}
165
+
166
+ cur = new()
167
+ for t in tokens:
168
+ if pending is not None:
169
+ if pending.startswith("<<") and pending != "<<<":
170
+ cur["heredoc"] = bodies[body_i] if body_i < len(bodies) else ""
171
+ body_i += 1
172
+ else:
173
+ cur["redirs"].append((pending, t))
174
+ pending = None
175
+ continue
176
+ if t == "|":
177
+ if cur["argv"] or cur["redirs"]:
178
+ units.append(cur)
179
+ cur = new()
180
+ elif t in _SEPARATORS or (t and set(t) <= _SEPARATOR_CHARS):
181
+ # Punctuation arrives grouped (`);`, `))`), so any all-separator
182
+ # token separates; its parentheses open and close subshells.
183
+ if cur["argv"] or cur["redirs"]:
184
+ units.append(cur)
185
+ pipe += 1
186
+ depth = max(0, depth + t.count("(") - t.count(")"))
187
+ cur = new()
188
+ elif set(t) <= _REDIRECT_CHARS and ("<" in t or ">" in t):
189
+ # `2>` and `2>&1` arrive as the word "2" and then the operator; a
190
+ # lone digit right before a redirect is its file descriptor.
191
+ if cur["argv"] and len(cur["argv"][-1]) == 1 and cur["argv"][-1].isdigit():
192
+ t = cur["argv"].pop() + t
193
+ pending = t
194
+ else:
195
+ cur["argv"].append(t)
196
+ if cur["argv"] or cur["redirs"]:
197
+ units.append(cur)
198
+ return units
199
+
200
+
201
+ def script_writes(script: str) -> List[str]:
202
+ """Files a Python-style script opens for writing, by literal or by a
203
+ variable bound to a literal."""
204
+ if not script:
205
+ return []
206
+ names: Dict[str, str] = {}
207
+ for m in _PY_ASSIGN.finditer(script):
208
+ names[m.group("v")] = m.group("p")
209
+ out: List[str] = []
210
+ for m in _PY_OPEN_LIT.finditer(script):
211
+ if any(c in m.group("m") for c in "wa+"):
212
+ out.append(m.group("p"))
213
+ for m in _PY_OPEN_VAR.finditer(script):
214
+ if any(c in m.group("m") for c in "wa+") and m.group("v") in names:
215
+ out.append(names[m.group("v")])
216
+ for m in _PY_PATH_LIT_WRITE.finditer(script):
217
+ out.append(m.group("p"))
218
+ for m in _PY_VAR_WRITE.finditer(script):
219
+ if m.group("v") in names:
220
+ out.append(names[m.group("v")])
221
+ return [clean(p) for p in dict.fromkeys(out) if plausible_path(clean(p))]
222
+
223
+
224
+ _PY_READ_LIT = re.compile(r"""open\(\s*[rbfu]?(['"])(?P<p>[^'"\n]+)\1\s*(?:\)|,\s*(?:mode\s*=\s*)?[rbfu]?(['"])(?P<m>[^'"\n]*)\3)""")
225
+ _PY_READ_VAR = re.compile(r"""open\(\s*(?P<v>[A-Za-z_]\w*)\s*(?:\)|,\s*(?:mode\s*=\s*)?[rbfu]?(['"])(?P<m>[^'"\n]*)\2)""")
226
+ _PY_PATH_LIT_READ = re.compile(r"""Path\(\s*[rbfu]?(['"])(?P<p>[^'"\n]+)\1\s*\)\s*\.read_(?:text|bytes)\(""")
227
+ _PY_VAR_READ = re.compile(r"""(?P<v>\b[A-Za-z_]\w*)\s*\.read_(?:text|bytes)\(""")
228
+
229
+
230
+ def script_reads(script: str) -> List[str]:
231
+ """Files a Python-style script opens for reading, by literal or by a
232
+ variable bound to a literal."""
233
+ if not script:
234
+ return []
235
+ names = {m.group("v"): m.group("p") for m in _PY_ASSIGN.finditer(script)}
236
+ out: List[str] = []
237
+ for m in _PY_READ_LIT.finditer(script):
238
+ if not m.group("m") or ("r" in m.group("m") or "+" in m.group("m")):
239
+ out.append(m.group("p"))
240
+ for m in _PY_READ_VAR.finditer(script):
241
+ if (not m.group("m") or "r" in m.group("m") or "+" in m.group("m")) and m.group("v") in names:
242
+ out.append(names[m.group("v")])
243
+ out += [m.group("p") for m in _PY_PATH_LIT_READ.finditer(script)]
244
+ out += [names[m.group("v")] for m in _PY_VAR_READ.finditer(script) if m.group("v") in names]
245
+ return [clean(p) for p in dict.fromkeys(out) if plausible_path(clean(p))]
246
+
247
+
248
+ def script_write_events(script: str) -> List[Tuple[str, str]]:
249
+ """(path, kind) for each file a script writes: an anchored edit when the
250
+ script also reads that file, a replacement when it only writes it."""
251
+ read = set(script_reads(script))
252
+ return [(p, "anchored" if p in read else "replace") for p in script_writes(script)]
253
+
254
+
255
+ def patch_targets(body: Optional[str]) -> List[str]:
256
+ if not body:
257
+ return []
258
+ return [p for p in dict.fromkeys(_DIFF_TARGET_RE.findall(body))
259
+ if p != "/dev/null" and plausible_path(p)]
260
+
261
+
262
+ def _operands_after_scripts(argv: List[str], script_flags: Tuple[str, ...]) -> Tuple[List[str], bool]:
263
+ """Operands of argv, dropping the argument of each script flag (-e X).
264
+ The second value says whether a script flag was present."""
265
+ ops, skip, seen = [], False, False
266
+ for a in argv[1:]:
267
+ if skip:
268
+ skip = False
269
+ continue
270
+ if a in script_flags:
271
+ skip, seen = True, True
272
+ continue
273
+ if a.startswith("-"):
274
+ continue
275
+ ops.append(a)
276
+ return ops, seen
277
+
278
+
279
+ Rw = Tuple[List[str], List[Tuple[str, str]]]
280
+
281
+
282
+ def _is_short_flag_with(f: str, letters: str) -> bool:
283
+ return len(f) > 1 and f[0] == "-" and f[1] != "-" and bool(set(f[1:]) & set(letters))
284
+
285
+
286
+ def _view(argv, flags, ops, u, literal) -> Rw:
287
+ return ops + [t for op, t in u["redirs"] if op == "<"], []
288
+
289
+
290
+ # One `s` command, optionally line-addressed: `s/a/b/g`, `12s|x|y|`, `$s/a/b/`.
291
+ # A backslash is matched only as the start of an escape pair: with `.` also
292
+ # allowed to match it, a script full of `\t\n` that fails to parse backtracked
293
+ # exponentially (minutes on one vuls-8659668 Qwen step).
294
+ _SUBST_CMD = re.compile(r"""^\s*(?:\d+(?:,\d+)?|\$)?\s*s(?P<d>[^\w\s\\])(?:\\.|(?!(?P=d))[^\\])*(?P=d)"""
295
+ r"""(?:\\.|(?!(?P=d))[^\\])*(?P=d)[gIip0-9]*\s*$""", re.S)
296
+
297
+
298
+ def is_substitution(script: str) -> bool:
299
+ """Whether a sed/perl script only substitutes: every command is `s///`."""
300
+ parts = [x for x in re.split(r"[;\n]", script or "") if x.strip()]
301
+ return bool(parts) and all(_SUBST_CMD.match(x) for x in parts)
302
+
303
+
304
+ def _sed_scripts(argv: List[str], files: List[str], had_e: bool) -> List[str]:
305
+ out, i = [], 1
306
+ while i < len(argv):
307
+ if argv[i] in ("-e", "--expression") and i + 1 < len(argv):
308
+ out.append(argv[i + 1]); i += 2
309
+ elif argv[i] in ("-f",):
310
+ return [] # a script file: contents unknown
311
+ else:
312
+ i += 1
313
+ return out if had_e else files[:1]
314
+
315
+
316
+ def _sed(argv, flags, ops, u, literal) -> Rw:
317
+ inplace = any(f.startswith("--in-place") or _is_short_flag_with(f, "i") for f in flags)
318
+ files, had_e = _operands_after_scripts(argv, ("-e", "-f", "--expression"))
319
+ scripts = _sed_scripts(argv, files, had_e)
320
+ if not had_e and files and (_SED_SCRIPT_RE.match(files[0]) or not plausible_path(files[0])):
321
+ files = files[1:]
322
+ # "subst" is an anchored edit whose effect the agent chose exactly; it
323
+ # scores as "anchored" unless replay(known_substitutions=True).
324
+ kind = "subst" if scripts and all(is_substitution(x) for x in scripts) else "anchored"
325
+ return ([], [(f, kind) for f in files]) if inplace else (files, [])
326
+
327
+
328
+ _GREP_ARG_FLAGS = ("-e", "-f", "--regexp", "-A", "-B", "-C", "-m", "-D", "-d",
329
+ "--after-context", "--before-context", "--context",
330
+ "--max-count", "--devices", "--directories")
331
+
332
+
333
+ def _grep(argv, flags, ops, u, literal) -> Rw:
334
+ if any(f in _GREP_NO_CONTENT or _is_short_flag_with(f, "lLcq") for f in flags):
335
+ return [], []
336
+ files, _ = _operands_after_scripts(argv, _GREP_ARG_FLAGS)
337
+ had_e = any(f in ("-e", "-f", "--regexp") for f in flags)
338
+ return (files if had_e else files[1:]), []
339
+
340
+
341
+ def _awk(argv, flags, ops, u, literal) -> Rw:
342
+ inplace = "inplace" in argv
343
+ files, _ = _operands_after_scripts(argv, ("-f", "-v", "-i"))
344
+ had_f = "-f" in argv
345
+ if not had_f and files:
346
+ files = files[1:]
347
+ return ([], [(f, "anchored") for f in files]) if inplace else (files, [])
348
+
349
+
350
+ def _perl_files(argv: List[str]) -> List[str]:
351
+ """Operands of a perl command line, skipping the -e script. The script
352
+ follows any short-flag cluster ending in e or E (-e, -pe, -0777pe)."""
353
+ files, skip = [], False
354
+ for a in argv[1:]:
355
+ if skip:
356
+ skip = False
357
+ elif a.startswith("-") and not a.startswith("--"):
358
+ skip = a[-1] in "eE"
359
+ else:
360
+ files.append(a)
361
+ return files
362
+
363
+
364
+ def _perl(argv, flags, ops, u, literal) -> Rw:
365
+ files = _perl_files(argv)
366
+ if any(f.startswith("-i") or _is_short_flag_with(f, "i") for f in flags):
367
+ exprs = [argv[i + 1] for i, a in enumerate(argv[:-1]) if a == "-e" or (a.startswith("-") and a.endswith("e") and "e" in a[1:])]
368
+ kind = "subst" if exprs and all(is_substitution(x) for x in exprs) else "anchored"
369
+ return [], [(f, kind) for f in files]
370
+ if any(_is_short_flag_with(f, "pn") for f in flags):
371
+ # `perl -pe` / `-ne` prints the files it reads: a view, not a write.
372
+ return files, []
373
+ return _interpreter(argv, flags, ops, u, literal)
374
+
375
+
376
+ def _copy(argv, flags, ops, u, literal) -> Rw:
377
+ targets = [o for o in ops if plausible_path(clean(o))]
378
+ if len(targets) < 2:
379
+ return [], []
380
+ # One source onto one target carries the source's content; several go
381
+ # into a directory, and no target has a single source.
382
+ kind = f"{COPY_PREFIX}{clean(targets[0])}" if len(targets) == 2 else "replace"
383
+ writes = [(targets[-1], kind)]
384
+ if argv[0].split("/")[-1] == "mv":
385
+ writes += [(t, "delete") for t in targets[:-1]]
386
+ return [], writes
387
+
388
+
389
+ def _rm(argv, flags, ops, u, literal) -> Rw:
390
+ return [], [(o, "delete") for o in ops]
391
+
392
+
393
+ def _tee(argv, flags, ops, u, literal) -> Rw:
394
+ if "-a" in flags or "--append" in flags:
395
+ kind = "append"
396
+ else:
397
+ kind = "overwrite" if literal else "replace"
398
+ return [], [(o, kind) for o in ops]
399
+
400
+
401
+ def _truncate(argv, flags, ops, u, literal) -> Rw:
402
+ return [], [(o, "replace") for o in ops]
403
+
404
+
405
+ def _patch(argv, flags, ops, u, literal) -> Rw:
406
+ named = [o for o in ops if plausible_path(clean(o)) and not o.endswith((".patch", ".diff"))]
407
+ return [], [(p, "anchored") for p in patch_targets(u["heredoc"]) + named]
408
+
409
+
410
+ def _git(argv, flags, ops, u, literal) -> Rw:
411
+ if not ops:
412
+ return [], []
413
+ sub, rest = ops[0], ops[1:]
414
+ after_dd = argv[argv.index("--") + 1:] if "--" in argv else None
415
+ if sub == "apply":
416
+ return [], [(p, "anchored") for p in patch_targets(u["heredoc"])]
417
+ if sub in ("checkout", "restore"):
418
+ named = after_dd if after_dd is not None else [r for r in rest if plausible_path(clean(r)) or r == "."]
419
+ if "." in named or not named:
420
+ return [], [(ALL_PATHS, "restore")]
421
+ return [], [(p, "restore") for p in named]
422
+ if sub == "stash" and (not rest or rest[0] in ("push", "save")):
423
+ return [], [(ALL_PATHS, "restore")]
424
+ if sub == "stash" and rest and rest[0] in ("pop", "apply"):
425
+ # Re-applies stashed changes: new content, not the committed one.
426
+ return [], [(ALL_PATHS, "replace")]
427
+ if sub == "reset" and "--hard" in flags:
428
+ return [], [(ALL_PATHS, "restore")]
429
+ if sub in ("diff", "show"):
430
+ # Named paths are views; the files a bare diff prints are read from
431
+ # its output (see output_views).
432
+ named = after_dd if after_dd is not None else [r for r in rest if plausible_path(clean(r))]
433
+ return named, []
434
+ return [], []
435
+
436
+
437
+ def _interpreter(argv, flags, ops, u, literal) -> Rw:
438
+ script = u["heredoc"]
439
+ if script is None:
440
+ for flag in ("-c", "-e"):
441
+ if flag in argv and argv.index(flag) + 1 < len(argv):
442
+ script = argv[argv.index(flag) + 1]
443
+ break
444
+ return [], script_write_events(script or "")
445
+
446
+
447
+ _HANDLERS = {verb: _view for verb in _VIEW_VERBS}
448
+ _HANDLERS.update({verb: _grep for verb in _GREP_VERBS})
449
+ _HANDLERS.update({"sed": _sed, "awk": _awk, "gawk": _awk, "perl": _perl,
450
+ "cp": _copy, "mv": _copy, "install": _copy, "rm": _rm,
451
+ "tee": _tee, "truncate": _truncate, "patch": _patch, "git": _git})
452
+
453
+
454
+ def _strip_prefixes(argv: List[str]) -> List[str]:
455
+ """Drop VAR=value assignments and wrappers such as sudo and timeout N."""
456
+ argv = [a for a in argv if a != ""]
457
+ while argv and (re.match(r"^[A-Za-z_]\w*=", argv[0]) or argv[0] in _WRAPPERS):
458
+ argv = argv[1:]
459
+ if argv and argv[0] == "timeout" and len(argv) > 2:
460
+ argv = argv[2:]
461
+ return argv
462
+
463
+
464
+ def _unit_events(u: Dict, literal_pipe: bool) -> List[FileEvent]:
465
+ # An empty word is BSD sed's backup suffix (`sed -i '' ...`); left in, it
466
+ # shifts the script into the file position.
467
+ argv = _strip_prefixes(u["argv"])
468
+ verb = argv[0].split("/")[-1] if argv else ""
469
+ flags = [a for a in argv[1:] if a.startswith("-")]
470
+ ops = [a for a in argv[1:] if not a.startswith("-")]
471
+ literal = literal_pipe or (verb == "cat" and u["heredoc"] is not None)
472
+ handler = _HANDLERS.get(verb) or (_interpreter if verb.startswith(_INTERPRETERS) else None)
473
+ reads, writes = handler(argv, flags, ops, u, literal) if handler else ([], [])
474
+
475
+ for op, target in u["redirs"]:
476
+ op = op.lstrip("0123456789")
477
+ if target.startswith("/dev/"):
478
+ continue
479
+ if op in (">", "&>", ">|"):
480
+ writes.append((target, "overwrite" if literal else "replace"))
481
+ elif op in (">>", "&>>"):
482
+ writes.append((target, "append"))
483
+
484
+ events: List[FileEvent] = []
485
+ for r in reads:
486
+ r = clean(r)
487
+ if plausible_path(r):
488
+ events.append(("R", r, "view"))
489
+ for w, kind in writes:
490
+ w = w if w == ALL_PATHS else clean(w)
491
+ if w == ALL_PATHS or plausible_path(w):
492
+ events.append(("W", w, kind))
493
+ return events
494
+
495
+
496
+ # A pipeline ending in one of these prints a count or a digest, never the
497
+ # content its earlier stages read.
498
+ _SINKS = {"wc", "md5sum", "sha1sum", "sha256sum", "cksum", "xxd", "od"}
499
+
500
+
501
+ def _stdout_captured(u: Dict) -> bool:
502
+ """Whether a unit's standard output goes to a file or /dev/null rather
503
+ than to the terminal the agent reads."""
504
+ for op, target in u["redirs"]:
505
+ base = op.lstrip("0123456789")
506
+ fd = op[:len(op) - len(base)] or "1"
507
+ if base in ("&>", "&>>"):
508
+ return True
509
+ if fd == "1" and base in (">", ">>", ">|") and target not in ("/dev/stdout", "/dev/stderr", "/dev/tty"):
510
+ return True
511
+ return False
512
+
513
+
514
+ def _swallowed_pipes(units: List[Dict]) -> set:
515
+ last: Dict[int, Dict] = {}
516
+ for u in units:
517
+ last[u["pipe"]] = u
518
+ out = set()
519
+ for pipe, u in last.items():
520
+ argv = _strip_prefixes(u["argv"])
521
+ verb = argv[0].split("/")[-1] if argv else ""
522
+ flags = [a for a in argv[1:] if a.startswith("-")]
523
+ if verb in _SINKS or (verb in _GREP_VERBS and any(
524
+ f in _GREP_NO_CONTENT or _is_short_flag_with(f, "lLcq") for f in flags)):
525
+ out.add(pipe)
526
+ return out
527
+
528
+
529
+ def resolve_path(cwd: Optional[str], p: str) -> str:
530
+ """`p` as named from working directory `cwd`. An unknown (None) or
531
+ repository-root ('') working directory leaves it as written."""
532
+ if not cwd or p == ALL_PATHS or p.startswith(("/", "~", "$")):
533
+ return p
534
+ return os.path.normpath(os.path.join(cwd, p))
535
+
536
+
537
+ def _cd_target(cwd: Optional[str], argv: List[str]) -> Optional[str]:
538
+ """The working directory after `cd`/`pushd` from `cwd`; None when it
539
+ cannot be known (home, a variable, `-`, above the repository root)."""
540
+ if argv[0] == "popd":
541
+ return None
542
+ args = [a for a in argv[1:] if a == "-" or not a.startswith("-")]
543
+ if not args:
544
+ return None
545
+ t = clean(args[0])
546
+ if t.startswith(("~", "$")) or t == "-":
547
+ return None
548
+ if t.startswith("/"):
549
+ return os.path.normpath(t)
550
+ if cwd is None:
551
+ return None
552
+ new = os.path.normpath(os.path.join(cwd, t)) if cwd else os.path.normpath(t)
553
+ if new == ".":
554
+ return ""
555
+ return None if new == ".." or new.startswith("../") else new
556
+
557
+
558
+ def _units_with_cwd(units: List[Dict], cwd: Optional[str]):
559
+ """Each unit with the working directory it runs in, and the working
560
+ directory the command leaves at top level. A `cd` applies to the units
561
+ after it at its own subshell depth and deeper, until that subshell ends."""
562
+ at = {0: cwd}
563
+ placed = []
564
+ for u in units:
565
+ d = u.get("depth", 0)
566
+ for k in [k for k in at if k > d]:
567
+ del at[k]
568
+ if d not in at:
569
+ at[d] = at[max(k for k in at if k < d)] if any(k < d for k in at) else cwd
570
+ argv = _strip_prefixes(u["argv"])
571
+ placed.append((u, at[d]))
572
+ if argv and argv[0].split("/")[-1] in ("cd", "pushd", "popd"):
573
+ at[d] = _cd_target(at[d], [argv[0].split("/")[-1]] + argv[1:])
574
+ return placed, at.get(0, cwd)
575
+
576
+
577
+ def command_cwd(cmd: str, cwd: Optional[str]) -> Optional[str]:
578
+ """The working directory a command leaves behind, starting from `cwd`."""
579
+ return _units_with_cwd(command_units(cmd), cwd)[1]
580
+
581
+
582
+ _SCRIPT_SUFFIXES = (".py", ".pl", ".rb", ".js")
583
+
584
+
585
+ def _run_script(u: Dict) -> Optional[str]:
586
+ """The script file an interpreter unit runs (`python fix.py ...`)."""
587
+ argv = _strip_prefixes(u["argv"])
588
+ if not argv or not argv[0].split("/")[-1].startswith(_INTERPRETERS) or u["heredoc"] is not None:
589
+ return None
590
+ if any(a in ("-c", "-e", "-m") for a in argv[1:]):
591
+ return None
592
+ arg = next((a for a in argv[1:] if not a.startswith("-")), None)
593
+ return clean(arg) if arg and clean(arg).endswith(_SCRIPT_SUFFIXES) else None
594
+
595
+
596
+ def _events_with_verbs(cmd: str, cwd: Optional[str] = None,
597
+ scripts: Optional[Dict[str, str]] = None) -> List[Tuple[str, str, str, str]]:
598
+ """`scripts`, when given, maps script files the agent created (by a
599
+ heredoc) to their text, and is updated in place: running one of them
600
+ writes the files its text writes."""
601
+ units = command_units(cmd)
602
+ literal_pipes = set()
603
+ for u in units:
604
+ first = u["argv"][0].split("/")[-1] if u["argv"] else ""
605
+ if first in _LITERAL_SOURCES or (first == "cat" and u["heredoc"] is not None):
606
+ literal_pipes.add(u["pipe"])
607
+ swallowed = _swallowed_pipes(units)
608
+ # The last unit of each pipeline whose output goes to a file: what it and
609
+ # every unit before it in the pipeline read never reaches the terminal.
610
+ captured = {u["pipe"]: i for i, u in enumerate(units) if _stdout_captured(u)}
611
+ placed, _ = _units_with_cwd(units, cwd)
612
+ composed: set = set()
613
+ out = []
614
+ for i, (u, here) in enumerate(placed):
615
+ argv = _strip_prefixes(u["argv"])
616
+ verb = argv[0].split("/")[-1] if argv else ""
617
+ if scripts is not None:
618
+ run = _run_script(u)
619
+ if run is not None and resolve_path(here, run) in scripts:
620
+ path = resolve_path(here, run)
621
+ if path in composed:
622
+ # Written and run in this one action: an edit composed now.
623
+ for p, kind in script_write_events(scripts[path]):
624
+ out.append(("W", resolve_path(here, clean(p)), kind, verb))
625
+ else:
626
+ # A saved script is a program: its writes change the files,
627
+ # but running it is not an edit composed in this action
628
+ # against the agent's view of them.
629
+ for p in script_writes(scripts[path]):
630
+ out.append(("W", resolve_path(here, clean(p)), "replace", verb))
631
+ if u["heredoc"] is not None:
632
+ for op, target in u["redirs"]:
633
+ if op.lstrip("0123456789") in (">", ">|") and clean(target).endswith(_SCRIPT_SUFFIXES):
634
+ scripts[resolve_path(here, clean(target))] = u["heredoc"]
635
+ composed.add(resolve_path(here, clean(target)))
636
+ for op, p, kind in _unit_events(u, u["pipe"] in literal_pipes):
637
+ p = resolve_path(here, p)
638
+ kind = map_kind_path(kind, lambda x: resolve_path(here, x))
639
+ # Content piped into wc or a digest never reaches the agent.
640
+ if op == "R" and u["pipe"] in swallowed and not _last_in_pipe(units, u):
641
+ continue
642
+ if op == "R" and captured.get(u["pipe"], -1) >= i:
643
+ continue
644
+ out.append((op, p, kind, verb))
645
+ return out
646
+
647
+
648
+ def _last_in_pipe(units: List[Dict], u: Dict) -> bool:
649
+ same = [x for x in units if x["pipe"] == u["pipe"]]
650
+ return same[-1] is u
651
+
652
+
653
+ def shell_file_events(cmd: str, cwd: Optional[str] = None,
654
+ scripts: Optional[Dict[str, str]] = None) -> List[FileEvent]:
655
+ """(op, path, kind) for every file a shell command shows or changes, in
656
+ the order it does so. Relative paths are resolved against `cwd` and any
657
+ `cd` the command makes; with no `cwd` they stay as written."""
658
+ return [(op, p, kind) for op, p, kind, _ in _events_with_verbs(cmd, cwd, scripts)]
659
+
660
+
661
+ # bash names the offending line and quotes it; dash gives only its number.
662
+ # Both run every complete command before that line, so the cut is at the
663
+ # line, not at the start of the command.
664
+ _BASH_SYNTAX = re.compile(r"syntax error near unexpected token[^\n]*\n[^\n]*?line (\d+): `(.*)'")
665
+ _DASH_SYNTAX = re.compile(r"(?m)^\S*sh: (\d+): Syntax error:")
666
+ _EOF_ERROR = re.compile(r"unexpected EOF while looking for matching|Unterminated quoted string|end of file unexpected")
667
+ _FORMAT_ERROR = re.compile(r"Format error:|Please always provide EXACTLY ONE action")
668
+ _SED_FAILED = re.compile(r"^sed: ", re.M)
669
+ _TRACEBACK = re.compile(r"Traceback \(most recent call last\)")
670
+ _SCRIPT_SYNTAX = re.compile(r"^(?:SyntaxError|IndentationError|TabError): ", re.M)
671
+ _NOT_FOUND = re.compile(r"(?:python\d?(?:\.\d+)?|perl|ruby|node): (?:command )?not found")
672
+
673
+
674
+ def _line_start(cmd: str, line_no: int) -> int:
675
+ """Character offset where 1-based line `line_no` begins."""
676
+ pos = 0
677
+ for _ in range(max(0, line_no - 1)):
678
+ nxt = cmd.find("\n", pos)
679
+ if nxt < 0:
680
+ return len(cmd)
681
+ pos = nxt + 1
682
+ return pos
683
+
684
+
685
+ def executed_prefix(cmd: str, observation: str) -> str:
686
+ """The part of a command the shell actually ran.
687
+
688
+ A syntax error stops the shell at the offending line, after it has run
689
+ every complete command before it; an unterminated quote swallows the rest
690
+ of the text, and we then take nothing to have run."""
691
+ obs = observation or ""
692
+ if _FORMAT_ERROR.search(obs):
693
+ return ""
694
+ m = _BASH_SYNTAX.search(obs)
695
+ if m:
696
+ text = m.group(2).strip()
697
+ idx = cmd.find(text) if text else -1
698
+ if idx >= 0:
699
+ return cmd[:cmd.rfind("\n", 0, idx) + 1] if "\n" in cmd[:idx] else ""
700
+ return cmd[:_line_start(cmd, int(m.group(1)))]
701
+ m = _DASH_SYNTAX.search(obs)
702
+ if m:
703
+ return cmd[:_line_start(cmd, int(m.group(1)))]
704
+ if _EOF_ERROR.search(obs):
705
+ return ""
706
+ return cmd
707
+
708
+
709
+ def shell_events_with_outcome(cmd: str, observation: str = "", returncode: Optional[int] = None,
710
+ cwd: Optional[str] = None,
711
+ scripts: Optional[Dict[str, str]] = None) -> List[Tuple[str, str, str, bool]]:
712
+ """Events with a `landed` flag read from the command's own output.
713
+
714
+ Only the part of the command the shell ran yields events. A sed that
715
+ printed an error changed nothing, and neither did a script that raised
716
+ and exited nonzero. Other writes are taken to have landed; a sed whose
717
+ pattern matched nothing exits cleanly and prints nothing, and is
718
+ indistinguishable here."""
719
+ obs = observation or ""
720
+ cmd = executed_prefix(cmd, obs)
721
+ sed_failed = bool(_SED_FAILED.search(obs))
722
+ # A script that raised, did not parse, or whose interpreter was missing
723
+ # wrote nothing. A heredoc cut short gives a SyntaxError with no
724
+ # Traceback header.
725
+ never_ran = (bool(_SCRIPT_SYNTAX.search(obs)) and not _TRACEBACK.search(obs) and returncode not in (0, None)) \
726
+ or returncode in (126, 127) or bool(_NOT_FOUND.search(obs))
727
+ script_failed = bool(_TRACEBACK.search(obs)) and returncode not in (0, None)
728
+ out = []
729
+ for op, p, kind, verb in _events_with_verbs(cmd, cwd, scripts):
730
+ landed = True
731
+ if op == "W" and verb == "sed" and sed_failed:
732
+ landed = False
733
+ if verb.startswith(_INTERPRETERS) and never_ran:
734
+ continue # the script never executed: no edit was attempted
735
+ if op == "W" and verb.startswith(_INTERPRETERS) and script_failed:
736
+ landed = False
737
+ out.append((op, p, kind, landed))
738
+ return out
739
+
740
+
741
+ # Error lines that name the file an operation failed on.
742
+ _NAMED_FAILURE = re.compile(
743
+ r"sed: can't read (?P<a>[^:\n]+):"
744
+ r"|cannot create (?P<b>[^:\n]+):"
745
+ r"|(?:^|[\s'\"`])(?P<c>[^\s'\"`:]+)['\"`]?: (?:No such file or directory|Permission denied|"
746
+ r"Is a directory|Read-only file system)", re.M)
747
+ _SED_EXPRESSION_ERROR = re.compile(r"^sed: -e expression #\d+, char \d+: ", re.M)
748
+
749
+
750
+ def _same_file(a: str, b: str) -> bool:
751
+ a, b = clean(a), clean(b)
752
+ return a == b or a.endswith("/" + b) or b.endswith("/" + a)
753
+
754
+
755
+ def terminal_events_with_outcome(cmd: str, output: str = "", cwd: Optional[str] = None,
756
+ scripts: Optional[Dict[str, str]] = None) -> List[Tuple[str, str, str, bool]]:
757
+ """Events for keystrokes typed into a terminal, `landed` read from what
758
+ the terminal then showed.
759
+
760
+ Unlike a subprocess's output, a pane capture carries no exit status, may
761
+ have lost its top lines, and can follow several commands. So a write is
762
+ taken not to have landed only when an error line names its file, or when
763
+ a sed expression error follows the only sed write typed."""
764
+ output = output or ""
765
+ failed = {m.group("a") or m.group("b") or m.group("c") for m in _NAMED_FAILURE.finditer(output)}
766
+ events = _events_with_verbs(cmd, cwd, scripts)
767
+ sed_writes = [i for i, (op, _, _, verb) in enumerate(events) if op == "W" and verb == "sed"]
768
+ lone_sed_failed = len(sed_writes) == 1 and bool(_SED_EXPRESSION_ERROR.search(output))
769
+ out = []
770
+ for i, (op, p, kind, verb) in enumerate(events):
771
+ landed = True
772
+ if op == "W" and (any(_same_file(p, f) for f in failed) or (lone_sed_failed and i == sed_writes[0])):
773
+ landed = False
774
+ out.append((op, p, kind, landed))
775
+ return out
776
+
777
+
778
+ # Output that quotes lines of a file: a traceback frame, a compiler or test
779
+ # diagnostic, a grep hit, a diff header. Each is a view of that file, seen
780
+ # after the command ran.
781
+ _OUT_TRACEBACK = re.compile(r'File "([^"<>\n]+)", line \d+')
782
+ _OUT_LOCATED = re.compile(r"(?m)^\s*(?:-->\s+)?(/?(?:[\w.\-]+/)*[\w.\-]+\.[A-Za-z]{1,6}):(\d+)[:\s]")
783
+ _OUT_DIFF = re.compile(r"(?m)^(?:diff --git a/(\S+) b/|\+\+\+ b/(\S+))")
784
+ _OUT_VIEWPORT = re.compile(r"\[VIEWPORT[^:]*:\s*([^\]\n]+)\]")
785
+ _OUT_TAIL_DIFF = re.compile(r"\[TAIL DIFF: File '([^'\n]+)'")
786
+
787
+
788
+ def output_views(observation: str, cwd: Optional[str] = None) -> List[str]:
789
+ """Files whose content the command's output quoted.
790
+
791
+ A traceback frame or a `file:line` diagnostic names its file relative to
792
+ where the process ran, `cwd`; a git diff header and a harness viewport
793
+ name it relative to the repository root."""
794
+ obs = observation or ""
795
+ here: List[str] = _OUT_TRACEBACK.findall(obs) + [m[0] for m in _OUT_LOCATED.findall(obs)]
796
+ rooted: List[str] = ([a or b for a, b in _OUT_DIFF.findall(obs)] + [m.strip() for m in _OUT_VIEWPORT.findall(obs)]
797
+ + _OUT_TAIL_DIFF.findall(obs))
798
+ found = [resolve_path(cwd, clean(p)) for p in here if plausible_path(clean(p))]
799
+ found += [clean(p) for p in rooted if plausible_path(clean(p))]
800
+ return list(dict.fromkeys(found))
801
+
802
+
803
+ def runs_interpreter(cmd: str) -> bool:
804
+ """Whether the command runs a script interpreter at all."""
805
+ return any((u["argv"][0].split("/")[-1] if u["argv"] else "").startswith(_INTERPRETERS)
806
+ for u in command_units(cmd))