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,524 @@
|
|
|
1
|
+
"""Intervention arms for RQ2: what each one does to the prompt, and its names.
|
|
2
|
+
|
|
3
|
+
Three families (names and one-line effects are in ARM_INFO below, the single
|
|
4
|
+
source every script, table and figure reads):
|
|
5
|
+
|
|
6
|
+
- Baselines: Control, Re-read reminder, Recency eviction, and Volume-matched
|
|
7
|
+
eviction (the control for Stale-view removal).
|
|
8
|
+
- Retroactive rewrite: Stale flag, Stale-view removal, Changed-hunk removal,
|
|
9
|
+
In-place refresh, Tail refresh. All rewrite earlier turns, so all break the
|
|
10
|
+
prefix cache.
|
|
11
|
+
- Tool boundary: Write-through viewport, which never rewrites an earlier turn.
|
|
12
|
+
|
|
13
|
+
Enum values are the on-disk keys in traces and results and never change.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import copy
|
|
18
|
+
import re
|
|
19
|
+
import difflib
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import List, Dict, Any, Optional
|
|
23
|
+
from .freshness_hook import ObservationBinding
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class InterventionArm(Enum):
|
|
27
|
+
ARM_0_PROMPT_ONLY = "prompt_only_baseline"
|
|
28
|
+
ARM_1_CONTROL = "control"
|
|
29
|
+
ARM_2_POSITIONAL = "positional_elision"
|
|
30
|
+
ARM_3_MARKED = "freshness_marked"
|
|
31
|
+
ARM_4_MASKED = "freshness_masked"
|
|
32
|
+
ARM_5_VOLUME_CONTROL = "volume_matched_control"
|
|
33
|
+
ARM_6_IN_PLACE = "in_place_replacement"
|
|
34
|
+
ARM_7_JIT_PREFETCH = "jit_prefetch"
|
|
35
|
+
ARM_8_FINE_GRAINED = "fine_grained_invalidation"
|
|
36
|
+
ARM_9_WRITE_THROUGH = "write_through_viewport"
|
|
37
|
+
ARM_10_WRITE_THROUGH_DIFF = "write_through_diff"
|
|
38
|
+
ARM_11_SYNCED_CONTEXT = "synced_context"
|
|
39
|
+
ARM_12_DEPENDENCY_WRITE_THROUGH = "dependency_write_through"
|
|
40
|
+
ARM_13_WRITE_THROUGH_DIFF_LEAN = "write_through_diff_lean"
|
|
41
|
+
ARM_14_WRITE_THROUGH_DIFF_LINES = "write_through_diff_lines"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True)
|
|
45
|
+
class ArmInfo:
|
|
46
|
+
"""How an arm is named and grouped everywhere outside the enum.
|
|
47
|
+
|
|
48
|
+
The enum value is the on-disk key (trace filenames, results records) and
|
|
49
|
+
never changes. Every script, table and figure takes its labels from here,
|
|
50
|
+
so an arm has exactly one name per medium.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
family: str # "baseline" | "retroactive" | "tool_boundary"
|
|
54
|
+
display: str # table / paper name
|
|
55
|
+
short: str # figure axis label
|
|
56
|
+
cli: tuple # accepted --arm spellings; first is canonical
|
|
57
|
+
macro: str # suffix of the generated paper macros (stable)
|
|
58
|
+
effect: str # what it does to the prompt, one sentence
|
|
59
|
+
core: bool = False # reported in the main paper; the rest in the extended report
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
FAMILIES = {
|
|
63
|
+
"baseline": "Baselines",
|
|
64
|
+
"retroactive": "Retroactive rewrite",
|
|
65
|
+
"tool_boundary": "Tool boundary",
|
|
66
|
+
"sync": "Synchronized context",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
ARM_INFO = {
|
|
70
|
+
InterventionArm.ARM_1_CONTROL: ArmInfo(
|
|
71
|
+
"baseline", "Control", "Control", ("control",), "Control",
|
|
72
|
+
"History is left unchanged.", core=True),
|
|
73
|
+
InterventionArm.ARM_0_PROMPT_ONLY: ArmInfo(
|
|
74
|
+
"baseline", "Re-read reminder", "Reminder", ("prompt_only", "prompt_only_baseline", "reminder"),
|
|
75
|
+
"PromptOnly",
|
|
76
|
+
"Adds an instruction to re-read a file before editing it to the system prompt; history unchanged.",
|
|
77
|
+
core=True),
|
|
78
|
+
InterventionArm.ARM_2_POSITIONAL: ArmInfo(
|
|
79
|
+
"baseline", "Recency eviction", "Recency", ("positional", "positional_elision", "recency"),
|
|
80
|
+
"Positional",
|
|
81
|
+
"Above 32K estimated tokens, replaces the oldest observations with a placeholder, whether stale or not."),
|
|
82
|
+
InterventionArm.ARM_5_VOLUME_CONTROL: ArmInfo(
|
|
83
|
+
"baseline", "Volume-matched eviction", "Vol.-matched",
|
|
84
|
+
("volume_control", "volume_matched_control"), "VolumeMatched",
|
|
85
|
+
"Evicts as many tokens as stale-view removal would, taken oldest-first from observations "
|
|
86
|
+
"that are not stale; the control for stale-view removal."),
|
|
87
|
+
InterventionArm.ARM_3_MARKED: ArmInfo(
|
|
88
|
+
"retroactive", "Stale flag", "Flag", ("marked", "freshness_marked", "stale_flag"), "Marked",
|
|
89
|
+
"Appends a notice after each stale observation; the stale text stays in place."),
|
|
90
|
+
InterventionArm.ARM_4_MASKED: ArmInfo(
|
|
91
|
+
"retroactive", "Stale-view removal", "Removal", ("masked", "freshness_masked", "removal"), "Masked",
|
|
92
|
+
"Replaces each stale observation with a placeholder telling the agent to re-read the file.",
|
|
93
|
+
core=True),
|
|
94
|
+
InterventionArm.ARM_8_FINE_GRAINED: ArmInfo(
|
|
95
|
+
"retroactive", "Changed-hunk removal", "Hunk removal",
|
|
96
|
+
("fine_grained", "fine_grained_invalidation", "hunk_removal"), "FineGrained",
|
|
97
|
+
"Removes only the changed hunks of each stale observation and keeps the unchanged lines."),
|
|
98
|
+
InterventionArm.ARM_6_IN_PLACE: ArmInfo(
|
|
99
|
+
"retroactive", "In-place refresh", "In-place", ("in_place", "in_place_replacement"), "InPlace",
|
|
100
|
+
"Overwrites each stale observation, where it sits in the history, with the file's full current content.",
|
|
101
|
+
core=True),
|
|
102
|
+
InterventionArm.ARM_7_JIT_PREFETCH: ArmInfo(
|
|
103
|
+
"retroactive", "Tail refresh", "Tail refresh", ("jit_prefetch", "tail_refresh"), "JIT",
|
|
104
|
+
"Replaces each stale observation with a placeholder and appends the file's full current "
|
|
105
|
+
"content at the end of the prompt."),
|
|
106
|
+
InterventionArm.ARM_9_WRITE_THROUGH: ArmInfo(
|
|
107
|
+
"tool_boundary", "Write-through viewport", "Write-through",
|
|
108
|
+
("write_through_viewport", "write_through"), "WriteThrough",
|
|
109
|
+
"After a step writes a file, appends a line-numbered window around the edit to that step's own "
|
|
110
|
+
"tool output; earlier turns are never rewritten.", core=True),
|
|
111
|
+
InterventionArm.ARM_10_WRITE_THROUGH_DIFF: ArmInfo(
|
|
112
|
+
"tool_boundary", "Write-through diff", "WT diff",
|
|
113
|
+
("write_through_diff", "wt_diff"), "WriteThroughDiff",
|
|
114
|
+
"After a step writes a file, appends the net line-numbered diff from the version the agent last "
|
|
115
|
+
"held to the file on disk, every hunk; earlier turns are never rewritten."),
|
|
116
|
+
InterventionArm.ARM_11_SYNCED_CONTEXT: ArmInfo(
|
|
117
|
+
"sync", "Synced context", "Synced",
|
|
118
|
+
("synced_context", "synced", "corvus"), "SyncedContext",
|
|
119
|
+
"A CORVUS-style baseline: every prompt ends with the current contents of each file the agent has "
|
|
120
|
+
"viewed, read from disk, and observations that were only a read of such a file are elided."),
|
|
121
|
+
InterventionArm.ARM_12_DEPENDENCY_WRITE_THROUGH: ArmInfo(
|
|
122
|
+
"tool_boundary", "Dependency write-through", "Dep. WT",
|
|
123
|
+
("dependency_write_through", "dep_wt"), "DependencyWriteThrough",
|
|
124
|
+
"Write-through diff, plus a notice listing uses in other files of names the write removed or "
|
|
125
|
+
"whose parameters it changed; earlier turns are never rewritten."),
|
|
126
|
+
InterventionArm.ARM_13_WRITE_THROUGH_DIFF_LEAN: ArmInfo(
|
|
127
|
+
"tool_boundary", "Write-through diff (lean)", "WT diff lean",
|
|
128
|
+
("write_through_diff_lean", "wt_diff_lean"), "WriteThroughDiffLean",
|
|
129
|
+
"Write-through diff, without a block for writes that tell the agent nothing new: whole files it "
|
|
130
|
+
"typed itself, files the same command printed after writing, and patch.txt."),
|
|
131
|
+
InterventionArm.ARM_14_WRITE_THROUGH_DIFF_LINES: ArmInfo(
|
|
132
|
+
"tool_boundary", "Write-through diff (lean) with line numbers", "WT diff lines",
|
|
133
|
+
("write_through_diff_lines", "wt_diff_lines"), "WriteThroughDiffLines",
|
|
134
|
+
"The lean write-through diff, plus a note of every stretch of the file that moved since the agent "
|
|
135
|
+
"last read it, with its offset, as the shipped hook does (tail_diff.line_number_note)."),
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
# Table and figure order: family by family, as listed above.
|
|
139
|
+
ARM_ORDER = sorted(ARM_INFO, key=lambda a: (list(FAMILIES).index(ARM_INFO[a].family),
|
|
140
|
+
list(ARM_INFO).index(a)))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def arm_from_cli(name: str) -> "InterventionArm":
|
|
144
|
+
"""The arm a --arm spelling names (any alias, or the enum value)."""
|
|
145
|
+
for arm, info in ARM_INFO.items():
|
|
146
|
+
if name == arm.value or name in info.cli:
|
|
147
|
+
return arm
|
|
148
|
+
raise ValueError(f"unknown arm {name!r}")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def arm_info(value: str) -> ArmInfo:
|
|
152
|
+
"""Info for an on-disk arm value, as stored in traces and results."""
|
|
153
|
+
return ARM_INFO[InterventionArm(value)]
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Appended to the system prompt for ARM_0_PROMPT_ONLY. One copy, shared by
|
|
157
|
+
# every scaffold, so the prompt baseline cannot drift between harnesses.
|
|
158
|
+
PROMPT_ONLY_POLICY = """
|
|
159
|
+
|
|
160
|
+
[POLICY: PROMPT-ONLY RE-READ INSTRUCTION]
|
|
161
|
+
Before modifying or referencing any file, if you have not inspected its content recently \
|
|
162
|
+
or if it was modified previously, always re-read the latest on-disk state with `cat <file>`. \
|
|
163
|
+
Never make assumptions about line numbers or file contents based on past observations."""
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True)
|
|
167
|
+
class ArmRemediation:
|
|
168
|
+
"""What an arm does to a stale observation before the prompt is sent.
|
|
169
|
+
|
|
170
|
+
Metrics need this to tell "the hazard never happened" from "the hazard
|
|
171
|
+
happened and the arm repaired it". Scoring every arm on invalidation
|
|
172
|
+
counts alone charges the repairing arms for the hazards they fix, which
|
|
173
|
+
ranked the arms that merely edited least at the top.
|
|
174
|
+
|
|
175
|
+
withdraws: the stale text is replaced, so the model cannot read it.
|
|
176
|
+
annotates: the stale text is kept and a warning is attached.
|
|
177
|
+
forwards: current file content is appended at the prompt tail.
|
|
178
|
+
refreshes: current file content is written over the stale text, where
|
|
179
|
+
it sat in the history.
|
|
180
|
+
partial: only the changed region is withdrawn; untouched lines remain,
|
|
181
|
+
and those lines are not stale.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
withdraws: bool = False
|
|
185
|
+
annotates: bool = False
|
|
186
|
+
forwards: bool = False
|
|
187
|
+
refreshes: bool = False
|
|
188
|
+
partial: bool = False
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def supplies_current(self) -> bool:
|
|
192
|
+
"""The model can see the file's current content without re-reading it."""
|
|
193
|
+
return self.forwards or self.refreshes
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Declared next to the transforms above so the two cannot drift apart. Arm 2
|
|
197
|
+
# evicts by recency rather than by staleness, so it withdraws stale content
|
|
198
|
+
# only by coincidence and is not counted as remediating.
|
|
199
|
+
ARM_REMEDIATION = {
|
|
200
|
+
InterventionArm.ARM_0_PROMPT_ONLY: ArmRemediation(),
|
|
201
|
+
InterventionArm.ARM_1_CONTROL: ArmRemediation(),
|
|
202
|
+
InterventionArm.ARM_2_POSITIONAL: ArmRemediation(),
|
|
203
|
+
InterventionArm.ARM_3_MARKED: ArmRemediation(annotates=True),
|
|
204
|
+
InterventionArm.ARM_4_MASKED: ArmRemediation(withdraws=True),
|
|
205
|
+
InterventionArm.ARM_5_VOLUME_CONTROL: ArmRemediation(),
|
|
206
|
+
InterventionArm.ARM_6_IN_PLACE: ArmRemediation(withdraws=True, refreshes=True),
|
|
207
|
+
InterventionArm.ARM_7_JIT_PREFETCH: ArmRemediation(withdraws=True, forwards=True),
|
|
208
|
+
InterventionArm.ARM_8_FINE_GRAINED: ArmRemediation(withdraws=True, partial=True),
|
|
209
|
+
InterventionArm.ARM_9_WRITE_THROUGH: ArmRemediation(forwards=True),
|
|
210
|
+
InterventionArm.ARM_10_WRITE_THROUGH_DIFF: ArmRemediation(forwards=True),
|
|
211
|
+
InterventionArm.ARM_11_SYNCED_CONTEXT: ArmRemediation(forwards=True),
|
|
212
|
+
InterventionArm.ARM_12_DEPENDENCY_WRITE_THROUGH: ArmRemediation(forwards=True),
|
|
213
|
+
InterventionArm.ARM_13_WRITE_THROUGH_DIFF_LEAN: ArmRemediation(forwards=True),
|
|
214
|
+
InterventionArm.ARM_14_WRITE_THROUGH_DIFF_LINES: ArmRemediation(forwards=True),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Arms that append the written file's state to the step's own output.
|
|
218
|
+
WRITE_THROUGH_ARMS = (InterventionArm.ARM_9_WRITE_THROUGH, InterventionArm.ARM_10_WRITE_THROUGH_DIFF,
|
|
219
|
+
InterventionArm.ARM_12_DEPENDENCY_WRITE_THROUGH,
|
|
220
|
+
InterventionArm.ARM_13_WRITE_THROUGH_DIFF_LEAN, InterventionArm.ARM_14_WRITE_THROUGH_DIFF_LINES)
|
|
221
|
+
# Write-through arms whose block is the net diff since the last view.
|
|
222
|
+
DIFF_ARMS = (InterventionArm.ARM_10_WRITE_THROUGH_DIFF, InterventionArm.ARM_12_DEPENDENCY_WRITE_THROUGH,
|
|
223
|
+
InterventionArm.ARM_13_WRITE_THROUGH_DIFF_LEAN, InterventionArm.ARM_14_WRITE_THROUGH_DIFF_LINES)
|
|
224
|
+
# Arms that skip the block for writes that tell the agent nothing new (src/harness/lean.py).
|
|
225
|
+
LEAN_ARMS = (InterventionArm.ARM_13_WRITE_THROUGH_DIFF_LEAN, InterventionArm.ARM_14_WRITE_THROUGH_DIFF_LINES)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# Synced context (Arm 11). CORVUS injects whole files; a 32K-token backbone
|
|
229
|
+
# cannot hold that on SWE-Bench Pro's large files, so the block is capped and
|
|
230
|
+
# files are shown newest-viewed first, each clipped past SYNC_MAX_FILE_CHARS.
|
|
231
|
+
# Opens the block appended to the newest message. The prompt cache breakpoint
|
|
232
|
+
# goes just before it (PostQodeModel._payload), so the history is cached and
|
|
233
|
+
# only the block is recomputed: CORVUS's layout, history then synced context.
|
|
234
|
+
SYNC_HEADER = "[SYNCED CONTEXT: current contents of the files you have viewed]"
|
|
235
|
+
SYNC_MAX_CHARS = int(os.environ.get("SYNC_MAX_CHARS", 24_000))
|
|
236
|
+
SYNC_MAX_FILE_CHARS = int(os.environ.get("SYNC_MAX_FILE_CHARS", 12_000))
|
|
237
|
+
# Under a large window (over 64K tokens) the arm re-sends whole files, as
|
|
238
|
+
# CORVUS does. These limits only guard against a pathological file (a
|
|
239
|
+
# generated bundle); the largest block the v13 pilot runs would have sent
|
|
240
|
+
# uncapped was 51K characters. A step whose block hit them records it.
|
|
241
|
+
SYNC_WIDE_MAX_CHARS = 400_000
|
|
242
|
+
SYNC_WIDE_MAX_FILE_CHARS = 200_000
|
|
243
|
+
# An observation counts as "only a read" of a file when the file's text makes
|
|
244
|
+
# up at least this share of it; anything carrying more output is kept.
|
|
245
|
+
SYNC_READ_SHARE = 0.8
|
|
246
|
+
_OUTPUT_TAG = re.compile(r"<output>\n?(.*?)</output>", re.S)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def synced_context_view(messages: List[Dict[str, Any]], bindings: List[ObservationBinding],
|
|
250
|
+
workspace_root: str, max_chars: Optional[int] = None,
|
|
251
|
+
max_file_chars: Optional[int] = None) -> "tuple[List[Dict[str, Any]], List[str]]":
|
|
252
|
+
"""The prompt the synced-context arm sends, and the files its block showed.
|
|
253
|
+
|
|
254
|
+
Returns a copy: the real history keeps every observation, as in CORVUS,
|
|
255
|
+
where the synced context is rebuilt for each reasoning cycle and never
|
|
256
|
+
stored in the trajectory.
|
|
257
|
+
"""
|
|
258
|
+
transformed = copy.deepcopy(messages)
|
|
259
|
+
registry: List[str] = []
|
|
260
|
+
for b in sorted(bindings, key=lambda b: b.step_id, reverse=True):
|
|
261
|
+
if b.file_path not in registry:
|
|
262
|
+
registry.append(b.file_path)
|
|
263
|
+
for b in bindings:
|
|
264
|
+
# The newest read is elided too, as CORVUS's sync_file returns only a
|
|
265
|
+
# marker: the file is in the block below, and the history stays
|
|
266
|
+
# append-only, so the prompt cache can reuse it.
|
|
267
|
+
if b.message_index >= len(transformed) or b.message_index < 0:
|
|
268
|
+
continue
|
|
269
|
+
content = str(transformed[b.message_index].get("content", ""))
|
|
270
|
+
# mini-swe-agent wraps a command's output in <returncode>/<output> tags.
|
|
271
|
+
inner = _OUTPUT_TAG.search(content)
|
|
272
|
+
printed = inner.group(1) if inner else content
|
|
273
|
+
body = b.file_content if b.file_content is not None else ""
|
|
274
|
+
if body and len(body) >= SYNC_READ_SHARE * len(printed):
|
|
275
|
+
transformed[b.message_index]["content"] = (
|
|
276
|
+
f"[Contents of {b.file_path} are kept current in the synced context at the end of the prompt.]")
|
|
277
|
+
max_chars = SYNC_MAX_CHARS if max_chars is None else max_chars
|
|
278
|
+
max_file_chars = SYNC_MAX_FILE_CHARS if max_file_chars is None else max_file_chars
|
|
279
|
+
blocks, shown, used = [], [], 0
|
|
280
|
+
for rel in registry:
|
|
281
|
+
path = rel if os.path.isabs(rel) else os.path.join(workspace_root, rel)
|
|
282
|
+
try:
|
|
283
|
+
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
284
|
+
text = f.read()
|
|
285
|
+
except OSError:
|
|
286
|
+
continue # deleted or unreadable: nothing current to show
|
|
287
|
+
if len(text) > max_file_chars:
|
|
288
|
+
text = text[:max_file_chars] + f"\n...[{len(text) - max_file_chars} more characters]"
|
|
289
|
+
block = f"=== {rel} ===\n{text}"
|
|
290
|
+
if used + len(block) > max_chars:
|
|
291
|
+
break
|
|
292
|
+
blocks.append(block)
|
|
293
|
+
shown.append(rel)
|
|
294
|
+
used += len(block)
|
|
295
|
+
if blocks and transformed and transformed[-1].get("role") in ("user", "tool"):
|
|
296
|
+
transformed[-1]["content"] = (str(transformed[-1].get("content", "")) +
|
|
297
|
+
"\n\n" + SYNC_HEADER + "\n" +
|
|
298
|
+
"\n\n".join(blocks))
|
|
299
|
+
return transformed, shown
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _evict_oldest_by_recency(
|
|
303
|
+
transformed: List[Dict[str, Any]],
|
|
304
|
+
budget: int,
|
|
305
|
+
min_tokens: int,
|
|
306
|
+
placeholder_template: str,
|
|
307
|
+
skip_indices: Optional[Dict[int, ObservationBinding]] = None,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Evict oldest eligible user/tool messages in place until budget tokens are freed."""
|
|
310
|
+
skip_indices = skip_indices or {}
|
|
311
|
+
evicted_tokens = 0
|
|
312
|
+
for i, msg in enumerate(transformed):
|
|
313
|
+
if i in skip_indices or msg.get("role") not in ["user", "tool"] or i <= 1:
|
|
314
|
+
continue
|
|
315
|
+
content = msg.get("content", "")
|
|
316
|
+
tok_len = len(content) // 4
|
|
317
|
+
if tok_len > min_tokens:
|
|
318
|
+
msg["content"] = placeholder_template.format(tok_len=tok_len)
|
|
319
|
+
evicted_tokens += tok_len
|
|
320
|
+
if evicted_tokens >= budget:
|
|
321
|
+
break
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def apply_intervention_arm(
|
|
325
|
+
messages: List[Dict[str, Any]],
|
|
326
|
+
bindings: List[ObservationBinding],
|
|
327
|
+
arm: InterventionArm,
|
|
328
|
+
max_tokens: int = 32000,
|
|
329
|
+
current_tokens: int = 0,
|
|
330
|
+
workspace_root: str = "."
|
|
331
|
+
) -> List[Dict[str, Any]]:
|
|
332
|
+
"""Transform conversation message history according to selected intervention arm."""
|
|
333
|
+
transformed = copy.deepcopy(messages)
|
|
334
|
+
|
|
335
|
+
if arm in [InterventionArm.ARM_1_CONTROL, InterventionArm.ARM_0_PROMPT_ONLY,
|
|
336
|
+
InterventionArm.ARM_9_WRITE_THROUGH, InterventionArm.ARM_10_WRITE_THROUGH_DIFF,
|
|
337
|
+
InterventionArm.ARM_11_SYNCED_CONTEXT, InterventionArm.ARM_12_DEPENDENCY_WRITE_THROUGH,
|
|
338
|
+
InterventionArm.ARM_13_WRITE_THROUGH_DIFF_LEAN, InterventionArm.ARM_14_WRITE_THROUGH_DIFF_LINES]:
|
|
339
|
+
# Arm 11 builds its prompt with synced_context_view, which needs the
|
|
340
|
+
# workspace; here, as for the others, the history is left unchanged.
|
|
341
|
+
# Re-read reminder, Control, Write-through viewport:
|
|
342
|
+
# Return unmodified history in context. Arm 9's intervention operates
|
|
343
|
+
# at the tool-output layer (viewport echo after writes), not by
|
|
344
|
+
# retroactively rewriting prompt history.
|
|
345
|
+
return transformed
|
|
346
|
+
|
|
347
|
+
elif arm == InterventionArm.ARM_2_POSITIONAL:
|
|
348
|
+
# Recency eviction: oldest observations first, stale or not
|
|
349
|
+
# Evict oldest observation contents when context exceeds limit
|
|
350
|
+
if current_tokens > max_tokens:
|
|
351
|
+
excess = current_tokens - max_tokens
|
|
352
|
+
_evict_oldest_by_recency(
|
|
353
|
+
transformed, excess, 100,
|
|
354
|
+
"[CONTENT ELIDED BY RECENCY: {tok_len} tokens truncated]"
|
|
355
|
+
)
|
|
356
|
+
return transformed
|
|
357
|
+
|
|
358
|
+
elif arm == InterventionArm.ARM_3_MARKED:
|
|
359
|
+
# Stale flag
|
|
360
|
+
# Inject inline warning if an observation has been invalidated
|
|
361
|
+
stale_indices = {b.message_index: b for b in bindings if b.is_invalid}
|
|
362
|
+
for idx, binding in stale_indices.items():
|
|
363
|
+
if idx < len(transformed):
|
|
364
|
+
orig_content = transformed[idx].get("content", "")
|
|
365
|
+
marker = (
|
|
366
|
+
f"\n[SYSTEM NOTICE: File '{binding.file_path}' has been modified on disk "
|
|
367
|
+
f"at Step {binding.invalidation_step}. The observation above is STALE.]"
|
|
368
|
+
)
|
|
369
|
+
if marker not in orig_content:
|
|
370
|
+
transformed[idx]["content"] = orig_content + marker
|
|
371
|
+
return transformed
|
|
372
|
+
|
|
373
|
+
elif arm == InterventionArm.ARM_4_MASKED:
|
|
374
|
+
# Stale-view removal
|
|
375
|
+
# Replace stale observation entirely with structural placeholder
|
|
376
|
+
stale_indices = {b.message_index: b for b in bindings if b.is_invalid}
|
|
377
|
+
for idx, binding in stale_indices.items():
|
|
378
|
+
if idx < len(transformed):
|
|
379
|
+
placeholder = (
|
|
380
|
+
f"[OBSERVATION EVICTED: Content of '{binding.file_path}' observed at Step {binding.step_id} "
|
|
381
|
+
f"is STALE following on-disk modification at Step {binding.invalidation_step}. "
|
|
382
|
+
f"Execute 'cat {binding.file_path}' to inspect current on-disk state.]"
|
|
383
|
+
)
|
|
384
|
+
transformed[idx]["content"] = placeholder
|
|
385
|
+
return transformed
|
|
386
|
+
|
|
387
|
+
elif arm == InterventionArm.ARM_5_VOLUME_CONTROL:
|
|
388
|
+
# Volume-matched eviction (oldest-first, from non-stale observations)
|
|
389
|
+
# Calculate tokens masked in Arm 4, but evict equivalent tokens from NON-STALE observations
|
|
390
|
+
stale_indices = {b.message_index: b for b in bindings if b.is_invalid}
|
|
391
|
+
stale_tokens = sum(len(b.raw_content) // 4 for b in stale_indices.values())
|
|
392
|
+
|
|
393
|
+
if stale_tokens > 0:
|
|
394
|
+
_evict_oldest_by_recency(
|
|
395
|
+
transformed, stale_tokens, 50,
|
|
396
|
+
"[VOLUME CONTROL EVICTED: {tok_len} tokens removed without regard to staleness]",
|
|
397
|
+
skip_indices=stale_indices,
|
|
398
|
+
)
|
|
399
|
+
return transformed
|
|
400
|
+
|
|
401
|
+
elif arm == InterventionArm.ARM_6_IN_PLACE:
|
|
402
|
+
# In-place refresh
|
|
403
|
+
# Replaces stale observations in-place with current on-disk file state
|
|
404
|
+
stale_indices = {b.message_index: b for b in bindings if b.is_invalid}
|
|
405
|
+
for idx, binding in stale_indices.items():
|
|
406
|
+
if idx < len(transformed):
|
|
407
|
+
abs_p = os.path.join(workspace_root, binding.file_path) if not os.path.isabs(binding.file_path) else binding.file_path
|
|
408
|
+
if os.path.exists(abs_p) and os.path.isfile(abs_p):
|
|
409
|
+
try:
|
|
410
|
+
with open(abs_p, "r", encoding="utf-8", errors="replace") as f:
|
|
411
|
+
fresh_content = f.read()
|
|
412
|
+
transformed[idx]["content"] = (
|
|
413
|
+
f"[OBSERVATION SYNCHRONIZED (Step {binding.invalidation_step}): "
|
|
414
|
+
f"Content of '{binding.file_path}' updated to current on-disk state]:\n"
|
|
415
|
+
f"{fresh_content}"
|
|
416
|
+
)
|
|
417
|
+
except Exception as e:
|
|
418
|
+
transformed[idx]["content"] = (
|
|
419
|
+
f"[OBSERVATION SYNCHRONIZED: File '{binding.file_path}' "
|
|
420
|
+
f"could not be re-read: {e}]"
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
transformed[idx]["content"] = (
|
|
424
|
+
f"[OBSERVATION SYNCHRONIZED: File '{binding.file_path}' "
|
|
425
|
+
f"was removed on disk at Step {binding.invalidation_step}.]"
|
|
426
|
+
)
|
|
427
|
+
return transformed
|
|
428
|
+
|
|
429
|
+
elif arm == InterventionArm.ARM_7_JIT_PREFETCH:
|
|
430
|
+
# Tail refresh: replaces stale views AND appends current content, so it
|
|
431
|
+
# rewrites earlier turns too; it is not append-only
|
|
432
|
+
# 1. Mask the old stale observation to prevent reading superseded content
|
|
433
|
+
stale_indices = {b.message_index: b for b in bindings if b.is_invalid}
|
|
434
|
+
for idx, binding in stale_indices.items():
|
|
435
|
+
if idx < len(transformed):
|
|
436
|
+
transformed[idx]["content"] = (
|
|
437
|
+
f"[OBSERVATION SUPERSEDED: Content of '{binding.file_path}' observed at Step {binding.step_id} "
|
|
438
|
+
f"is STALE after Step {binding.invalidation_step}. (Synchronized snapshot forwarded below).]"
|
|
439
|
+
)
|
|
440
|
+
|
|
441
|
+
# 2. Forward the latest on-disk state of all mutated files at the immediate prompt tail
|
|
442
|
+
if stale_indices and transformed:
|
|
443
|
+
forwarding_blocks = []
|
|
444
|
+
# Deduplicate by file path, keeping latest invalidation step
|
|
445
|
+
latest_by_file: Dict[str, ObservationBinding] = {}
|
|
446
|
+
for b in bindings:
|
|
447
|
+
if b.is_invalid:
|
|
448
|
+
latest_by_file[b.file_path] = b
|
|
449
|
+
|
|
450
|
+
for f_path, binding in latest_by_file.items():
|
|
451
|
+
abs_p = os.path.join(workspace_root, f_path) if not os.path.isabs(f_path) else f_path
|
|
452
|
+
if os.path.exists(abs_p) and os.path.isfile(abs_p):
|
|
453
|
+
try:
|
|
454
|
+
with open(abs_p, "r", encoding="utf-8", errors="replace") as f:
|
|
455
|
+
f_content = f.read()
|
|
456
|
+
forwarding_blocks.append(
|
|
457
|
+
f"[HARNESS STORE-FORWARDING BUFFER: File '{f_path}' (mutated at Step {binding.invalidation_step}) current on-disk content]:\n"
|
|
458
|
+
f"{f_content}"
|
|
459
|
+
)
|
|
460
|
+
except Exception:
|
|
461
|
+
pass
|
|
462
|
+
|
|
463
|
+
if forwarding_blocks:
|
|
464
|
+
forwarding_text = "\n\n" + "\n\n".join(forwarding_blocks)
|
|
465
|
+
# Append to the latest message if it is a user observation, or append a new user message
|
|
466
|
+
if transformed[-1].get("role") in ["user", "tool"]:
|
|
467
|
+
transformed[-1]["content"] += forwarding_text
|
|
468
|
+
else:
|
|
469
|
+
transformed.append({"role": "user", "content": forwarding_text.strip()})
|
|
470
|
+
|
|
471
|
+
return transformed
|
|
472
|
+
|
|
473
|
+
elif arm == InterventionArm.ARM_8_FINE_GRAINED:
|
|
474
|
+
# Changed-hunk removal
|
|
475
|
+
# Instead of evicting the entire observation block, only invalidate line hunks that mutated on disk
|
|
476
|
+
stale_indices = {b.message_index: b for b in bindings if b.is_invalid}
|
|
477
|
+
for idx, binding in stale_indices.items():
|
|
478
|
+
if idx < len(transformed):
|
|
479
|
+
abs_p = os.path.join(workspace_root, binding.file_path) if not os.path.isabs(binding.file_path) else binding.file_path
|
|
480
|
+
if os.path.exists(abs_p) and os.path.isfile(abs_p):
|
|
481
|
+
try:
|
|
482
|
+
with open(abs_p, "r", encoding="utf-8", errors="replace") as f:
|
|
483
|
+
fresh_content = f.read()
|
|
484
|
+
|
|
485
|
+
# Diff the file as it was read against the file as it
|
|
486
|
+
# is now. Using the observation message here compared
|
|
487
|
+
# harness text with source and marked every line
|
|
488
|
+
# changed, which made this arm identical to Arm 4.
|
|
489
|
+
# None (never read) still falls back to raw_content;
|
|
490
|
+
# "" (read, genuinely empty) must not.
|
|
491
|
+
orig_content = binding.file_content if binding.file_content is not None else binding.raw_content
|
|
492
|
+
orig_lines = orig_content.splitlines(keepends=True)
|
|
493
|
+
fresh_lines = fresh_content.splitlines(keepends=True)
|
|
494
|
+
matcher = difflib.SequenceMatcher(None, orig_lines, fresh_lines)
|
|
495
|
+
|
|
496
|
+
reconstructed_blocks = []
|
|
497
|
+
for tag, alo, ahi, blo, bhi in matcher.get_opcodes():
|
|
498
|
+
if tag == "equal":
|
|
499
|
+
reconstructed_blocks.append("".join(orig_lines[alo:ahi]))
|
|
500
|
+
elif tag in ("replace", "delete"):
|
|
501
|
+
hunk_desc = f"{alo + 1}" if alo + 1 == ahi else f"{alo + 1}-{ahi}"
|
|
502
|
+
reconstructed_blocks.append(
|
|
503
|
+
f"[HUNK INVALIDATED (Observed lines {hunk_desc}): modified on disk at Step {binding.invalidation_step}]\n"
|
|
504
|
+
)
|
|
505
|
+
elif tag == "insert":
|
|
506
|
+
num_lines = bhi - blo
|
|
507
|
+
reconstructed_blocks.append(
|
|
508
|
+
f"[HUNK INSERTED on disk at Step {binding.invalidation_step} (+{num_lines} lines)]\n"
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
transformed[idx]["content"] = "".join(reconstructed_blocks)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
transformed[idx]["content"] = (
|
|
514
|
+
f"[OBSERVATION EVICTED: Content of '{binding.file_path}' observed at Step {binding.step_id} "
|
|
515
|
+
f"is STALE (read error: {e})]"
|
|
516
|
+
)
|
|
517
|
+
else:
|
|
518
|
+
transformed[idx]["content"] = (
|
|
519
|
+
f"[OBSERVATION EVICTED: Content of '{binding.file_path}' observed at Step {binding.step_id} "
|
|
520
|
+
f"is STALE (file removed on disk)]"
|
|
521
|
+
)
|
|
522
|
+
return transformed
|
|
523
|
+
|
|
524
|
+
return transformed
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""HTTP client for chat completions, with retries for transient failures.
|
|
2
|
+
|
|
3
|
+
Shared by the LM Studio and PostQode model backends. A single blip previously
|
|
4
|
+
discarded an entire trajectory: one arm losing a step meant the whole cell was
|
|
5
|
+
abandoned and its trace never written. Three of nine arms were lost this way
|
|
6
|
+
in one run, all failing within the same second while two requests ran
|
|
7
|
+
concurrently.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import socket
|
|
14
|
+
import ssl
|
|
15
|
+
import time
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.request
|
|
18
|
+
from typing import Any, Dict, Optional
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("completion")
|
|
21
|
+
|
|
22
|
+
MAX_COMPLETION_ATTEMPTS = 5
|
|
23
|
+
# A local server that is unreachable (the Turing vLLM job being replaced at its
|
|
24
|
+
# walltime, the SSH tunnel re-establishing) is waited out for this long before
|
|
25
|
+
# the cell is failed. Only connection-level failures on a local endpoint count;
|
|
26
|
+
# cloud requests keep the plain attempt budget.
|
|
27
|
+
LOCAL_OUTAGE_WAIT_S = float(os.environ.get("LOCAL_OUTAGE_WAIT_S", 900))
|
|
28
|
+
# A TLS fault (a corrupted record, a reset mid-handshake) comes in bursts on a
|
|
29
|
+
# flaky path and says nothing about the request; it is waited out this long
|
|
30
|
+
# for any endpoint before the cell is failed. A DNS failure counts too.
|
|
31
|
+
TRANSPORT_FAULT_WAIT_S = float(os.environ.get("TRANSPORT_FAULT_WAIT_S", 1800))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def post_completion(
|
|
35
|
+
url: str,
|
|
36
|
+
payload: Dict[str, Any],
|
|
37
|
+
headers: Dict[str, str],
|
|
38
|
+
timeout: int,
|
|
39
|
+
max_attempts: int = MAX_COMPLETION_ATTEMPTS,
|
|
40
|
+
) -> Dict[str, Any]:
|
|
41
|
+
"""POST a completion request, retrying transient failures.
|
|
42
|
+
|
|
43
|
+
Retries cover network faults, 5xx and 429, and also 400 from a local
|
|
44
|
+
server, which LM Studio returns for transient states such as a model being
|
|
45
|
+
swapped or reloaded under concurrent load. A 400 from the cloud gateway is
|
|
46
|
+
treated as a genuine client error and raised immediately, since retrying a
|
|
47
|
+
malformed or oversized request only wastes the budget; its body is logged
|
|
48
|
+
so the cause is visible.
|
|
49
|
+
"""
|
|
50
|
+
is_local = "localhost" in url or "127.0.0.1" in url
|
|
51
|
+
delay = 2.0
|
|
52
|
+
last_exc: Optional[Exception] = None
|
|
53
|
+
outage_since: Optional[float] = None
|
|
54
|
+
|
|
55
|
+
attempt = 0
|
|
56
|
+
while attempt < max_attempts:
|
|
57
|
+
attempt += 1
|
|
58
|
+
req = urllib.request.Request(url, data=json.dumps(payload).encode("utf-8"), headers=headers)
|
|
59
|
+
try:
|
|
60
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
61
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
62
|
+
except urllib.error.HTTPError as exc:
|
|
63
|
+
body = ""
|
|
64
|
+
try:
|
|
65
|
+
body = exc.read().decode("utf-8", errors="replace")[:300]
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
if exc.code == 400 and ("context length" in body.lower() or "max_model_len" in body.lower()):
|
|
69
|
+
logger.warning("Context length exceeded: %s. Terminating this task.", body)
|
|
70
|
+
raise RuntimeError(f"Context length exceeded: {body}")
|
|
71
|
+
retryable = exc.code >= 500 or exc.code == 429 or (exc.code == 400 and is_local)
|
|
72
|
+
if not retryable:
|
|
73
|
+
if exc.code == 401:
|
|
74
|
+
# The caller may recover (another process refreshed the
|
|
75
|
+
# token); a real auth failure surfaces when its retry fails.
|
|
76
|
+
logger.warning("Completion unauthorized: %s", body[:120])
|
|
77
|
+
else:
|
|
78
|
+
logger.error("Completion failed permanently (HTTP %s): %s", exc.code, body)
|
|
79
|
+
raise
|
|
80
|
+
last_exc = exc
|
|
81
|
+
logger.warning("HTTP %s on attempt %d/%d: %s", exc.code, attempt, max_attempts, body)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
last_exc = exc
|
|
84
|
+
unreachable = isinstance(exc, (urllib.error.URLError, ConnectionError)) or \
|
|
85
|
+
"Remote end closed" in str(exc)
|
|
86
|
+
reason = getattr(exc, "reason", None)
|
|
87
|
+
# A failed DNS lookup means this machine lost its network, not that
|
|
88
|
+
# the request is bad: waited out like a TLS fault, for any endpoint.
|
|
89
|
+
transport = isinstance(exc, ssl.SSLError) or isinstance(reason, (ssl.SSLError, socket.gaierror))
|
|
90
|
+
if (is_local and unreachable) or transport:
|
|
91
|
+
outage_since = outage_since or time.monotonic()
|
|
92
|
+
waited = time.monotonic() - outage_since
|
|
93
|
+
if waited < (LOCAL_OUTAGE_WAIT_S if is_local and unreachable else TRANSPORT_FAULT_WAIT_S):
|
|
94
|
+
logger.warning("Local server unreachable (%.0fs so far): %r; waiting", waited, exc)
|
|
95
|
+
attempt -= 1 # an outage does not use up the attempt budget
|
|
96
|
+
time.sleep(15)
|
|
97
|
+
continue
|
|
98
|
+
logger.warning("Completion error on attempt %d/%d: %r", attempt, max_attempts, exc)
|
|
99
|
+
|
|
100
|
+
if attempt < max_attempts:
|
|
101
|
+
time.sleep(delay)
|
|
102
|
+
delay = min(delay * 2, 30.0)
|
|
103
|
+
|
|
104
|
+
raise RuntimeError(f"Completion failed after {max_attempts} attempts: {last_exc!r}")
|