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,399 @@
1
+ """The stale-act replay: one definition, applied to both research questions.
2
+
3
+ A step is a list of file events in the order the agent's action performed
4
+ them, each (op, path, kind, landed); see src/parsers/file_events.py for the
5
+ kinds. The replay keeps, per file, when the agent last saw it and when it last
6
+ changed, and scores every *anchored* edit (one located by existing text or
7
+ line numbers) against those two times:
8
+
9
+ opportunity an anchored edit to a file the agent had seen before
10
+ hazard an opportunity where the file changed after the agent last
11
+ saw it
12
+
13
+ Counts are in (step, file) units: several anchored edits to one file in one
14
+ action are one act, composed against one view. The rate is pooled, hazards
15
+ over opportunities summed across tasks, so a task with one edit weighs one
16
+ edit and a task with none contributes nothing.
17
+
18
+ What counts as seeing a file: any output that prints its content, whole or in
19
+ part (cat, sed -n, grep lines, an editor's view, the snippet an editor echoes
20
+ after an edit). A whole-file overwrite with text the command itself carries
21
+ also leaves the agent with a current view, since it wrote every byte. What
22
+ counts as a change: a write that landed. A failed command, a rejected editor
23
+ call and a `git checkout` of a file the agent never modified change nothing.
24
+
25
+ Arms act on the harness's own record of views, not on ours. An arm that puts
26
+ current content in the prompt (store-forwarding appends it, in-place
27
+ replacement writes it over the old view) supplies a file only when the harness
28
+ had bound that file; the credited count treats a supplied file as seen at the
29
+ start of the step, the strict count does not. The coherent-prompt rate is
30
+ measured the same way: a stale view is withdrawn only if the arm withdraws and
31
+ the harness had bound that view.
32
+ """
33
+
34
+ import os
35
+ import re
36
+ from dataclasses import dataclass, field
37
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
38
+
39
+ from wt_src.parsers.file_events import (ALL_PATHS, CONTAINER_ROOTS, command_cwd, copy_source, map_kind_path,
40
+ output_views, shell_events_with_outcome)
41
+
42
+ Event = Tuple[str, str, str, bool]
43
+
44
+
45
+ @dataclass
46
+ class ReplayResult:
47
+ steps: int = 0
48
+ opportunities: int = 0
49
+ hazards_strict: int = 0
50
+ hazards_credited: int = 0
51
+ coherent_steps: int = 0
52
+ agent_views: int = 0
53
+ harness_bound_views: int = 0
54
+ hazard_steps: List[int] = field(default_factory=list)
55
+ hazard_files: List[str] = field(default_factory=list)
56
+ hazard_pairs: List[Tuple[int, str]] = field(default_factory=list)
57
+ # Secondary, outside the headline definition: a whole-file overwrite or
58
+ # replace of a file the agent had seen, and how many of those landed on a
59
+ # version it had not seen -- a rewrite that may silently discard a change.
60
+ overwrite_opportunities: int = 0
61
+ lost_updates: int = 0
62
+ # Opportunities on a file some write had changed since the agent first saw
63
+ # it: the only ones that can go stale when nothing else writes the file
64
+ # (in the live runs, repeat edits). Every hazard is one of these.
65
+ at_risk_opportunities: int = 0
66
+ at_risk_hazards: int = 0
67
+
68
+
69
+ @dataclass
70
+ class _View:
71
+ version: int
72
+ bound: bool
73
+
74
+
75
+ class _Replayer:
76
+ """Per-file state for one trajectory, advanced one step at a time.
77
+
78
+ Staleness follows content, as the model in Section II defines it: every
79
+ landed write gives the file a new version, a restore returns it to the
80
+ committed version (0), and the agent holds a valid view when any view it
81
+ has seen shows the file's current version. A `git checkout` back to
82
+ content the agent had already seen therefore leaves it with a valid view.
83
+
84
+ An agent composes a whole action before it sees any of that action's
85
+ output, so an edit is scored against the views held when the step began.
86
+ What the step prints (a `cat`, an editor's echo, a compiler quoting a
87
+ line) counts from the next step on.
88
+ """
89
+
90
+ def __init__(self, withdraws: bool, forwards: bool, refreshes: bool, known_substitutions: bool = False):
91
+ self.withdraws, self.forwards, self.refreshes = withdraws, forwards, refreshes
92
+ # Sensitivity: a substitution on a version the agent knew leaves a
93
+ # version it knows, as a whole-file write does.
94
+ self.known_substitutions = known_substitutions
95
+ self.out = ReplayResult()
96
+ self.next_version = 1
97
+ self.version: Dict[Tuple, int] = {} # current content version
98
+ self.seen: Dict[Tuple, Set[int]] = {} # versions the agent has seen
99
+ self.supplied: Dict[Tuple, int] = {} # version the harness last supplied
100
+ self.views: Dict[Tuple, List[_View]] = {}
101
+ self.modified: Set[Tuple] = set()
102
+ self.changed_after_seen: Set[Tuple] = set()
103
+ self.h_bound: Set[Tuple] = set() # the harness has a binding
104
+ self.h_invalid: Set[Tuple] = set() # ... and has marked it invalid
105
+ self.h_kept: Set[Tuple] = set() # in-place can refresh it
106
+ self.hazard_files: Set[str] = set()
107
+
108
+ def current(self, k: Tuple) -> int:
109
+ return self.version.get(k, 0)
110
+
111
+ def supply(self, sess) -> None:
112
+ """What the arm puts in front of the agent before it acts."""
113
+ if not (self.forwards or self.refreshes):
114
+ return
115
+ for k in self.h_invalid:
116
+ if k[0] == sess and (self.forwards or k in self.h_kept):
117
+ self.supplied[k] = self.current(k)
118
+
119
+ def prompt_is_coherent(self) -> bool:
120
+ """No stale view left in the prompt after the arm's withdrawals."""
121
+ for k, vs in self.views.items():
122
+ cur = self.current(k)
123
+ if any(v.version != cur and not (self.withdraws and v.bound) for v in vs):
124
+ return False
125
+ return True
126
+
127
+ def score_edit(self, k: Tuple, held: Dict[Tuple, Set[int]]) -> bool:
128
+ """Score an anchored edit against the views held at step start;
129
+ True when none of them shows the file as it now stands."""
130
+ if k not in held:
131
+ return False
132
+ self.out.opportunities += 1
133
+ at_risk = k in self.changed_after_seen
134
+ self.out.at_risk_opportunities += at_risk
135
+ cur = self.current(k)
136
+ if cur not in held[k] and cur != self.supplied.get(k):
137
+ self.out.hazards_credited += 1
138
+ if cur not in held[k]:
139
+ self.out.hazards_strict += 1
140
+ self.out.at_risk_hazards += at_risk
141
+ self.hazard_files.add(k[1])
142
+ return True
143
+ return False
144
+
145
+ def land(self, k: Tuple, kind: str) -> None:
146
+ if k in self.seen:
147
+ self.changed_after_seen.add(k)
148
+ if kind == "restore":
149
+ if k in self.modified:
150
+ self.version[k] = 0
151
+ return
152
+ self.version[k] = self.next_version
153
+ self.next_version += 1
154
+ self.modified.add(k)
155
+
156
+ def land_all(self, sess, kind: str) -> None:
157
+ for k in list(self.modified):
158
+ if k[0] == sess:
159
+ self.land(k, kind)
160
+
161
+ def step(self, step: Dict[str, Any]) -> List[str]:
162
+ """Advance one step; return the list of file paths hazarded (empty if none)."""
163
+ sess = step.get("session", 0)
164
+ self.supply(sess)
165
+ # Files whose current contents the prompt itself carried (the synced-
166
+ # context arm): the agent sees their current version as it acts.
167
+ for path in step.get("presented") or ():
168
+ k = (sess, path)
169
+ self.seen.setdefault(k, set()).add(self.current(k))
170
+ self.out.coherent_steps += self.prompt_is_coherent()
171
+ harness_reads = set(step.get("harness_reads") or ())
172
+ held = {k: set(v) for k, v in self.seen.items()}
173
+ acted: Set[Tuple] = set()
174
+ overwrote: Set[Tuple] = set()
175
+ last_op: Dict[Tuple, str] = {}
176
+ shown: List[Tuple[Tuple, int, bool]] = []
177
+ hazarded_paths: List[str] = []
178
+ for op, path, kind, landed in step.get("events", []):
179
+ k = (sess, path)
180
+ if op == "R":
181
+ shown.append((k, self.current(k), path in harness_reads))
182
+ last_op[k] = "R"
183
+ elif path == ALL_PATHS:
184
+ if landed:
185
+ self.land_all(sess, kind)
186
+ else:
187
+ last_op[k] = "W"
188
+ src = copy_source(kind)
189
+ if kind in ("anchored", "subst") and k not in acted:
190
+ acted.add(k)
191
+ if self.score_edit(k, held):
192
+ hazarded_paths.append(path)
193
+ elif (kind in ("overwrite", "replace") or src is not None) and k not in overwrote and k in held:
194
+ overwrote.add(k)
195
+ self.out.overwrite_opportunities += 1
196
+ if self.current(k) not in held[k]:
197
+ self.out.lost_updates += 1
198
+ if landed:
199
+ # The agent knows what a write leaves when it supplied
200
+ # every byte, or copied a file whose current version it
201
+ # had seen: later edits in this same action are composed
202
+ # against that, not against the views held at step start.
203
+ s_k = (sess, src) if src is not None else None
204
+ known = kind == "overwrite" or (
205
+ s_k is not None and self.current(s_k) in self.seen.get(s_k, ())) or (
206
+ kind == "subst" and self.known_substitutions and self.current(k) in self.seen.get(k, ()))
207
+ self.land(k, "replace" if src is not None else kind)
208
+ if known:
209
+ self.seen.setdefault(k, set()).add(self.current(k))
210
+ held.setdefault(k, set()).add(self.current(k))
211
+ for k, ver, bound in shown:
212
+ self.seen.setdefault(k, set()).add(ver)
213
+ self.views.setdefault(k, []).append(_View(ver, bound))
214
+ self.out.agent_views += 1
215
+ self.out.harness_bound_views += bound
216
+ for path in step.get("output_reads") or ():
217
+ k = (sess, path)
218
+ self.seen.setdefault(k, set()).add(self.current(k))
219
+ self.harness_update(sess, step, harness_reads, last_op)
220
+ return hazarded_paths
221
+
222
+ def harness_update(self, sess, step, harness_reads, last_op) -> None:
223
+ """The harness's own bookkeeping, which is what the arms act on."""
224
+ for p in step.get("harness_writes") or ():
225
+ if (sess, p) in self.h_bound:
226
+ self.h_invalid.add((sess, p))
227
+ kept = step.get("harness_kept")
228
+ for p in harness_reads:
229
+ k = (sess, p)
230
+ self.h_bound.add(k)
231
+ if p == kept:
232
+ self.h_kept.add(k)
233
+ if last_op.get(k, "R") == "R":
234
+ self.h_invalid.discard(k)
235
+
236
+
237
+ def replay(steps: Sequence[Dict[str, Any]], *, withdraws: bool = False,
238
+ forwards: bool = False, refreshes: bool = False, known_substitutions: bool = False) -> ReplayResult:
239
+ """Score a trajectory. Each step dict carries `events`, optionally
240
+ `session` (interleaved sessions keep separate state), and for live runs
241
+ `harness_reads`, `harness_writes`, `harness_kept`."""
242
+ r = _Replayer(withdraws, forwards, refreshes, known_substitutions)
243
+ for idx, step in enumerate(steps):
244
+ paths = r.step(step)
245
+ if paths:
246
+ r.out.hazard_steps.append(idx)
247
+ for p in paths:
248
+ r.out.hazard_pairs.append((idx, p))
249
+ r.out.steps = len(steps)
250
+ r.out.hazard_files = sorted(r.hazard_files)
251
+ return r.out
252
+
253
+
254
+ # --- live (RQ2) traces --------------------------------------------------
255
+
256
+ _CONTAINER_ROOTS = CONTAINER_ROOTS
257
+
258
+
259
+ def _canon(path: str) -> Optional[str]:
260
+ """Workspace-relative spelling, or None for a path outside the workspace."""
261
+ if path == ALL_PATHS:
262
+ return path
263
+ for root in _CONTAINER_ROOTS:
264
+ if path.startswith(root):
265
+ path = path[len(root):]
266
+ break
267
+ else:
268
+ if path.startswith("/"):
269
+ return None
270
+ path = os.path.normpath(path)
271
+ return None if path.startswith("..") else path
272
+
273
+
274
+ def _as_list(v: Any) -> List[str]:
275
+ if isinstance(v, list):
276
+ return v
277
+ if isinstance(v, str) and v.startswith("["):
278
+ import ast
279
+ try:
280
+ return list(ast.literal_eval(v))
281
+ except (ValueError, SyntaxError):
282
+ return []
283
+ return []
284
+
285
+
286
+ def _live_events(s: Dict[str, Any], scripts: Optional[Dict[str, str]] = None) -> List[Event]:
287
+ cmd = s.get("command") or ""
288
+ try:
289
+ rc = int(s.get("returncode"))
290
+ except (TypeError, ValueError):
291
+ rc = None
292
+ detected = [p for p in (_canon(x) for x in _as_list(s.get("writes_detected"))) if p]
293
+ # mini-SWE-agent runs each command in a fresh `docker exec` at the
294
+ # repository root: a `cd` lasts only for the command that makes it.
295
+ raw_events = shell_events_with_outcome(cmd, str(s.get("observation") or ""), rc, cwd="", scripts=scripts)
296
+
297
+ events: List[Event] = []
298
+ parsed_w: Set[str] = set()
299
+ restore_all = False
300
+ restored_dirs: List[str] = []
301
+ for op, raw, kind, landed in raw_events:
302
+ p = _canon(raw)
303
+ if p is None:
304
+ continue
305
+ src = copy_source(kind)
306
+ if src is not None:
307
+ # A source outside the workspace carries content nothing tracks.
308
+ kind = map_kind_path(kind, _canon) if _canon(src) else "replace"
309
+ if op == "R":
310
+ events.append(("R", p, kind, True))
311
+ elif p == ALL_PATHS:
312
+ restore_all = True
313
+ else:
314
+ parsed_w.add(p)
315
+ if kind == "restore" and landed:
316
+ restored_dirs.append(p)
317
+ # The harness recorded a write it parsed or saw change on disk;
318
+ # a target it did not record did not change.
319
+ events.append(("W", p, kind, landed and p in detected))
320
+ if not raw_events and not cmd.strip():
321
+ return events
322
+ # Changes the command did not name: a restore, a script that named the
323
+ # file, or something else that touched it.
324
+ unparsed: List[Event] = []
325
+ for p in detected:
326
+ if p in parsed_w:
327
+ continue
328
+ # `git checkout -- dir/` names a directory: every file it rewrote
329
+ # under that directory went back to the committed content, not to a
330
+ # new version.
331
+ if restore_all or any(p.startswith(d + "/") for d in restored_dirs):
332
+ unparsed.append(("W", p, "restore", True))
333
+ elif p in cmd or os.path.basename(p) in cmd:
334
+ unparsed.append(("W", p, "anchored", True))
335
+ else:
336
+ unparsed.append(("W", p, "replace", True))
337
+
338
+ if unparsed:
339
+ # Event chronology: unparsed harness-detected writes (e.g. from scripts or
340
+ # build steps) that precede inspection reads in the same turn must be placed
341
+ # before those reads, so the view reflects the landed write.
342
+ read_paths = {p for op, p, _, _ in events if op == "R"}
343
+ if any(p in read_paths for _, p, _, _ in unparsed):
344
+ new_events: List[Event] = []
345
+ unparsed_placed: Set[str] = set()
346
+ for ev in events:
347
+ op, p, kind, landed = ev
348
+ if op == "R" and p in {up[1] for up in unparsed} and p not in unparsed_placed:
349
+ for up in unparsed:
350
+ if up[1] == p:
351
+ new_events.append(up)
352
+ unparsed_placed.add(p)
353
+ new_events.append(ev)
354
+ for up in unparsed:
355
+ if up[1] not in unparsed_placed:
356
+ new_events.append(up)
357
+ events = new_events
358
+ else:
359
+ events.extend(unparsed)
360
+ return events
361
+
362
+
363
+ _DEPENDENCY_NOTICE = "\n\n[DEPENDENCY NOTICE:"
364
+
365
+
366
+ def _command_output(s: Dict[str, Any]) -> str:
367
+ """The step's observation without the dependency notice.
368
+
369
+ The notice lists `file:line: text` for uses in other files. Read as
370
+ command output, each line would count as a view of that file, and the
371
+ dependency arm would be credited with views it was only pointed at. The
372
+ write-through viewport and diff stay in: they show the written file itself.
373
+ """
374
+ obs = str(s.get("observation") or "")
375
+ cut = obs.find(_DEPENDENCY_NOTICE)
376
+ return obs if cut < 0 else obs[:cut]
377
+
378
+
379
+ def live_steps(trace_steps: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]:
380
+ """Replay steps for a trace recorded by the live harness.
381
+
382
+ Events come from the command text. The harness's `writes_detected` (parsed
383
+ targets plus files whose size or modification time changed) decides which
384
+ writes landed, and its `reads_detected` is the record the arms acted on.
385
+ """
386
+ out = []
387
+ scripts: Dict[str, str] = {}
388
+ for s in trace_steps:
389
+ reads_detected = [p for p in (_canon(x) for x in _as_list(s.get("reads_detected"))) if p]
390
+ out.append({
391
+ "events": _live_events(s, scripts),
392
+ "output_reads": [p for p in (_canon(x) for x in output_views(
393
+ _command_output(s), cwd=command_cwd(str(s.get("command") or s.get("action") or ""), ""))) if p],
394
+ "harness_reads": reads_detected,
395
+ "harness_writes": [p for p in (_canon(x) for x in _as_list(s.get("writes_detected"))) if p],
396
+ "harness_kept": reads_detected[-1] if reads_detected else None,
397
+ "presented": [p for p in (_canon(x) for x in _as_list(s.get("synced_files"))) if p],
398
+ })
399
+ return out
@@ -0,0 +1 @@
1
+ """Vendored from src/parsers by tool/build.sh."""