chad-code 0.1.0__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.
- chad/__init__.py +7 -0
- chad/agent.py +1093 -0
- chad/base_engine.py +109 -0
- chad/bench.py +243 -0
- chad/cli.py +469 -0
- chad/compaction.py +108 -0
- chad/config.py +63 -0
- chad/diag.py +100 -0
- chad/engine.py +883 -0
- chad/guardrails.py +373 -0
- chad/ignore.py +18 -0
- chad/lsp.py +323 -0
- chad/mcp.py +719 -0
- chad/mcp_oauth.py +329 -0
- chad/openai_engine.py +252 -0
- chad/prompt.py +305 -0
- chad/render.py +462 -0
- chad/repomap.py +767 -0
- chad/session.py +281 -0
- chad/skills.py +407 -0
- chad/symbols.py +360 -0
- chad/syntaxgate.py +105 -0
- chad/toolcall_parse.py +115 -0
- chad/tools.py +962 -0
- chad/tui.py +921 -0
- chad/validate.py +361 -0
- chad_code-0.1.0.dist-info/METADATA +370 -0
- chad_code-0.1.0.dist-info/RECORD +32 -0
- chad_code-0.1.0.dist-info/WHEEL +5 -0
- chad_code-0.1.0.dist-info/entry_points.txt +4 -0
- chad_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- chad_code-0.1.0.dist-info/top_level.txt +1 -0
chad/guardrails.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Guardrail decision predicates for run_turn (extracted from agent.py).
|
|
2
|
+
|
|
3
|
+
These are the heuristics that separate "stops cleanly" from "loops forever / declares
|
|
4
|
+
false success": the loop guard, the verify-before-done / empty-done gating, the
|
|
5
|
+
tool-result bookkeeping (did_work / made_edit / unverified_edit), and the no-tool-call
|
|
6
|
+
nudge selection. They were previously inline in the 290-line run_turn loop and only
|
|
7
|
+
exercised by the slow model-backed eval suite.
|
|
8
|
+
|
|
9
|
+
Each function here is pure (or pure-ish: it returns a decision, the caller still owns
|
|
10
|
+
the counters and the message appends), so run_turn calls them in place of the old
|
|
11
|
+
inline boolean/branch expressions WITHOUT changing control flow or ordering — and they
|
|
12
|
+
can be unit-tested directly (see test_agent_guards.py). Thresholds, branch order, and
|
|
13
|
+
nudge text are byte-identical to the old inline code.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
# Catastrophic, near-never-intentional shell shapes. chad runs bash on the user's
|
|
20
|
+
# machine and reads UNTRUSTED repo files (`read`, `@mentions`) whose contents can
|
|
21
|
+
# drive a tool call — so in --yolo/auto mode a prompt-injected `rm -rf ~` would
|
|
22
|
+
# otherwise execute with no human in the loop. This denylist is deliberately tiny
|
|
23
|
+
# and high-confidence (recursive force-deletes of roots/home, raw disk/filesystem
|
|
24
|
+
# writes, fork bombs, curl|sh pipe-to-shell) so it almost never fires on real dev
|
|
25
|
+
# work; when it does, auto mode forces a confirm (or blocks, headless). Set
|
|
26
|
+
# CHAD_NO_DESTRUCTIVE_GUARD=1 to disable. NOT a security boundary — a sandbox is —
|
|
27
|
+
# just a seatbelt against the obvious catastrophe.
|
|
28
|
+
_DESTRUCTIVE_BASH = (
|
|
29
|
+
# recursive force-rm whose target is rooted at / or ~ or $HOME (any depth), or
|
|
30
|
+
# is a bare `*` / `.` (whole-cwd). A relative path like `build/` or `./out` is
|
|
31
|
+
# NOT matched — only roots and home subtrees, where an injected delete is fatal.
|
|
32
|
+
re.compile(r"\brm\s+(?:-\w+\s+)*-\w*[rR]\w*\s+(?:-\w+\s+)*(?:[/~]\S*|\$HOME\S*|[.*](?:\s|$))"),
|
|
33
|
+
re.compile(r"\b(mkfs|fdisk|parted)\b"),
|
|
34
|
+
re.compile(r"\bdd\b[^\n]*\bof=/dev/"),
|
|
35
|
+
re.compile(r">\s*/dev/(sd|disk|nvme|hd)"),
|
|
36
|
+
re.compile(r":\(\)\s*\{.*\|.*&.*\}"), # fork bomb :(){ :|:& };:
|
|
37
|
+
re.compile(r"\b(curl|wget)\b[^\n|]*\|\s*(sudo\s+)?(ba)?sh\b"), # curl … | sh
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_destructive_bash(command: str) -> bool:
|
|
42
|
+
"""True if a bash command matches the catastrophic denylist (see _DESTRUCTIVE_BASH).
|
|
43
|
+
Pure and testable. Caller decides what to do (force-confirm / block)."""
|
|
44
|
+
return any(p.search(command) for p in _DESTRUCTIVE_BASH)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def bash_result_verifies(result: str) -> bool:
|
|
48
|
+
"""A bash tool result clears the unverified-edit flag only on a clean run.
|
|
49
|
+
|
|
50
|
+
A clean exit-0 command (including one with no output, `[no output]`) or any
|
|
51
|
+
real stdout counts as a verification. The four `[`-prefixed sentinels below
|
|
52
|
+
all mean the check did NOT actually pass, so they must NOT clear the flag:
|
|
53
|
+
a non-zero exit, a timeout, a user interrupt (ctrl-c), or a launch failure.
|
|
54
|
+
"""
|
|
55
|
+
return not result.startswith(
|
|
56
|
+
("[exit", "[timed out", "[interrupted", "[failed to launch"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Tools that count as real work (did_work) — includes the symbolic read/search/edit
|
|
60
|
+
# tools, but NOT planning/done. Kept as a named constant so the set is testable.
|
|
61
|
+
SUBSTANTIVE_TOOLS = ("grep", "glob", "read", "write", "edit", "bash",
|
|
62
|
+
"repo_map", "overview", "view_symbol", "find_symbol",
|
|
63
|
+
"find_refs", "replace_symbol", "insert_symbol", "rename_symbol")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def update_work_flags(name, args, result, did_work, made_edit, unverified_edit):
|
|
67
|
+
"""Update the (did_work, made_edit, unverified_edit) guardrail flags after one
|
|
68
|
+
tool result; returns the new triple. Byte-identical to run_turn's old inline
|
|
69
|
+
bookkeeping: a substantive tool counts as real work; a successful edit/write
|
|
70
|
+
(text or symbolic) sets made_edit and — unless it's a pure prose/doc file —
|
|
71
|
+
arms unverified_edit; a bash run that didn't error/timeout/interrupt clears it
|
|
72
|
+
(a failing test keeps it dirty so the model re-runs)."""
|
|
73
|
+
if name in SUBSTANTIVE_TOOLS:
|
|
74
|
+
did_work = True
|
|
75
|
+
landed_edit = (
|
|
76
|
+
(name in ("write", "edit") and result.startswith(("[wrote", "[edited")))
|
|
77
|
+
or (name in ("replace_symbol", "insert_symbol")
|
|
78
|
+
and result.startswith(("[replaced", "[inserted")))
|
|
79
|
+
or (name == "rename_symbol" and result.startswith("[renamed")))
|
|
80
|
+
if landed_edit:
|
|
81
|
+
made_edit = True
|
|
82
|
+
# A pure prose/doc file (README, CLAUDE.md, notes) has nothing to "run",
|
|
83
|
+
# so don't arm the verify-before-done nudge for it — otherwise /init and
|
|
84
|
+
# doc edits waste steps being told to run a test that doesn't exist.
|
|
85
|
+
# Code/config edits still require verification.
|
|
86
|
+
is_doc = str(args.get("path", "")).lower().endswith(
|
|
87
|
+
(".md", ".markdown", ".rst", ".txt"))
|
|
88
|
+
if not is_doc:
|
|
89
|
+
unverified_edit = True
|
|
90
|
+
elif name == "bash" and bash_result_verifies(result):
|
|
91
|
+
unverified_edit = False
|
|
92
|
+
return did_work, made_edit, unverified_edit
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def done_rejection(did_work, unverified_edit, empty_done_nudges, verify_nudges):
|
|
96
|
+
"""Whether a `done` should be rejected, in run_turn's order. Returns 'empty'
|
|
97
|
+
(no real work yet — the markdown-code-fence failure mode), 'verify' (files
|
|
98
|
+
changed but nothing run to verify them), or None (accept). The caller bumps the
|
|
99
|
+
matching counter and appends the corresponding nudge."""
|
|
100
|
+
if not did_work and empty_done_nudges < 2:
|
|
101
|
+
return "empty"
|
|
102
|
+
if unverified_edit and verify_nudges < 2:
|
|
103
|
+
return "verify"
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def nudge_for_no_calls(text, hit_cap, made_edit, unverified_edit, read_only_intent,
|
|
108
|
+
action_task, truncation_nudges, answer_nudges, verify_nudges,
|
|
109
|
+
open_tool_call):
|
|
110
|
+
"""Pick the nudge for a step that produced NO tool call, in the original priority
|
|
111
|
+
order: (1) TRUNCATED — hit the token cap mid-thought, so it isn't an answer;
|
|
112
|
+
(2) ANSWERED ON PAPER — produced/described code but never applied it; (3) UNVERIFIED
|
|
113
|
+
EDIT — edited but never ran the check. Pure: returns (kind, nudge_text) or
|
|
114
|
+
(None, None); the caller bumps the matching counter and appends the nudge. `kind`
|
|
115
|
+
is one of 'truncated' / 'no-edit' / 'unverified-edit'. Byte-identical to the old
|
|
116
|
+
inline branch (open_tool_call is run_turn's _has_open_tool_call(text))."""
|
|
117
|
+
has_code = "```" in text
|
|
118
|
+
if hit_cap and truncation_nudges < 2:
|
|
119
|
+
if open_tool_call:
|
|
120
|
+
# Cut off mid tool-call — almost always a `write` whose content
|
|
121
|
+
# exceeded the token budget. Retrying it whole just truncates
|
|
122
|
+
# again; guide it to land the file in bounded pieces.
|
|
123
|
+
nudge = ("[your tool call was cut off at the length limit — the "
|
|
124
|
+
"content was too long to emit in one call. Do NOT retry it "
|
|
125
|
+
"whole. Create the file with `write` using only the FIRST "
|
|
126
|
+
"portion of the content, then append the rest with one or "
|
|
127
|
+
"more `edit` calls. Emit one complete tool call at a time.]")
|
|
128
|
+
else:
|
|
129
|
+
nudge = ("[your reply was cut off at the length limit before you "
|
|
130
|
+
"called any tool. Do NOT re-paste what you already wrote. "
|
|
131
|
+
"Take the next single concrete action now as a real "
|
|
132
|
+
"<tool_call> — e.g. write the file — one tool at a time.]")
|
|
133
|
+
return "truncated", nudge
|
|
134
|
+
if (not read_only_intent) and (action_task or has_code) \
|
|
135
|
+
and not made_edit and answer_nudges < 2:
|
|
136
|
+
nudge = ("[you described the change but did not apply it — markdown code "
|
|
137
|
+
"blocks are NOT executed and code in your reply does NOT touch "
|
|
138
|
+
"any file. To CREATE a new file use the `write` tool (path + full "
|
|
139
|
+
"content); to change an existing one use `edit`/`replace_symbol`. "
|
|
140
|
+
"Then run it with bash and call done. Do not answer with code in "
|
|
141
|
+
"prose.]")
|
|
142
|
+
return "no-edit", nudge
|
|
143
|
+
if unverified_edit and verify_nudges < 2:
|
|
144
|
+
nudge = ("[not finished: you edited the file but the check has not passed. "
|
|
145
|
+
"Run the project's check/tests with bash; if it fails, read the "
|
|
146
|
+
"error, fix the code, and re-run. Don't stop until it passes, "
|
|
147
|
+
"then call done.]")
|
|
148
|
+
return "unverified-edit", nudge
|
|
149
|
+
return None, None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def landing_nudge(step, max_steps, made_edit, unverified_edit, landing_nudges):
|
|
153
|
+
"""Near the step cap with the task not cleanly landed, push the model to stop
|
|
154
|
+
exploring and commit its highest-value edit before the hard cut-off. Fires at most
|
|
155
|
+
once. Without this the run dies silently at max_steps with whatever it had — the
|
|
156
|
+
demonstrated failure: 40 steps of environment/import probing, zero edits applied.
|
|
157
|
+
'Cleanly landed' = an edit was made and has since been verified. Returns the nudge
|
|
158
|
+
text (or None); the caller bumps landing_nudges and appends it as a tool message."""
|
|
159
|
+
if landing_nudges >= 1:
|
|
160
|
+
return None
|
|
161
|
+
remaining = max_steps - step
|
|
162
|
+
if remaining > 3: # only inside the last 3 steps
|
|
163
|
+
return None
|
|
164
|
+
if made_edit and not unverified_edit: # already landed and verified — let it finish
|
|
165
|
+
return None
|
|
166
|
+
if not made_edit:
|
|
167
|
+
return (f"[only {remaining} step(s) left before this turn is force-stopped, and "
|
|
168
|
+
"you have not applied a single edit yet. STOP exploring and verifying the "
|
|
169
|
+
"environment. Make your highest-value edit now with edit/write/"
|
|
170
|
+
"replace_symbol, verify it, and call done.]")
|
|
171
|
+
return (f"[only {remaining} step(s) left before this turn is force-stopped. You "
|
|
172
|
+
"edited but never ran the check. Run the project's tests now, fix the code "
|
|
173
|
+
"if it fails, then call done — do not start any new exploration.]")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def update_thrash(name, result, consecutive_failed_bash):
|
|
177
|
+
"""Track a run of bash commands that errored back-to-back with no edit between them
|
|
178
|
+
— the environment-thrash / flailing-probe signature (repeatedly guessing the test
|
|
179
|
+
runner, or `python -c "import X"` checks that keep exiting non-zero). A failed bash
|
|
180
|
+
increments the run; ANY landed edit OR a clean bash resets it; other tools leave it
|
|
181
|
+
untouched (an interleaved read/grep is normal investigation). Returns the new count."""
|
|
182
|
+
if name == "bash":
|
|
183
|
+
return 0 if bash_result_verifies(result) else consecutive_failed_bash + 1
|
|
184
|
+
if name in ("write", "edit", "replace_symbol", "insert_symbol", "rename_symbol"):
|
|
185
|
+
return 0
|
|
186
|
+
return consecutive_failed_bash
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def bash_thrash_nudge(consecutive_failed_bash, thrash_nudges):
|
|
190
|
+
"""After 4+ consecutive failed bash commands with no edit, nudge (bounded to 2) to
|
|
191
|
+
break the probe loop. The exact-call loop guard misses this because each failing
|
|
192
|
+
command differs by a few chars (`from acr import A` / `from inflo import B`), so its
|
|
193
|
+
signatures never repeat. Returns the nudge text or None."""
|
|
194
|
+
if consecutive_failed_bash >= 4 and thrash_nudges < 2:
|
|
195
|
+
return ("[several commands in a row have failed and you have not edited any file. "
|
|
196
|
+
"Stop probing the environment. If you cannot run the tests, make your "
|
|
197
|
+
"planned edit anyway and verify at the end; to check whether a symbol "
|
|
198
|
+
"exists use find_symbol/overview, not `python -c import`. Take a different "
|
|
199
|
+
"action now.]")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# --- soft think-cap (plan 039) -----------------------------------------------------
|
|
204
|
+
# <think> blocks are 36–41% of all generated tokens on the eval suite and decode is
|
|
205
|
+
# bandwidth-bound, so an unbounded reasoning run is the single largest wall-clock
|
|
206
|
+
# multiplier the harness controls. When armed, run_turn stops a step's <think> run once
|
|
207
|
+
# it exceeds this cap and force-closes the block (prefix-safe — see
|
|
208
|
+
# agent.close_unclosed_think), then continues. The cap ESCALATES with a turn's
|
|
209
|
+
# stuck-signals so a genuinely hard step gets more reasoning room instead of being
|
|
210
|
+
# chunked repeatedly into re-thinks.
|
|
211
|
+
THINK_CAP_RAMP = (1024, 2048, 4096)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def think_budget(stuck_level: int, base: int = 512) -> int:
|
|
215
|
+
"""Per-step <think>-token cap. `stuck_level` 0 => `base` (the cheap default); each
|
|
216
|
+
increment climbs THINK_CAP_RAMP (clamped to the top), giving more reasoning room when
|
|
217
|
+
run_turn has a concrete stuck-signal for this turn — a prior cap hit, or a loop /
|
|
218
|
+
thrash / verify-fail nudge. Never returns below `base` (so a caller that sets a large
|
|
219
|
+
base is respected). Pure and testable; run_turn owns `stuck_level` and `base`."""
|
|
220
|
+
if stuck_level <= 0:
|
|
221
|
+
return base
|
|
222
|
+
return max(base, THINK_CAP_RAMP[min(stuck_level - 1, len(THINK_CAP_RAMP) - 1)])
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --- runaway-turn governor (plan 040) ----------------------------------------------
|
|
226
|
+
# chad's dominant failure mode is timeout, not wrong answers: on the polyglot sweep a
|
|
227
|
+
# PASSING task burns 14–35k prefill tokens; a FAILING one balloons to 130–187k before
|
|
228
|
+
# dying at the wall. Grinding a turn that's already 100k-prefill deep with no green test
|
|
229
|
+
# almost never converges — the cheapest good outcome is to STOP, bank what was learned,
|
|
230
|
+
# and (optionally) relaunch fresh. The existing guards are all *local* (repeat-call
|
|
231
|
+
# loop, consecutive failed bash, landing nudge near max_steps); this one watches the
|
|
232
|
+
# *global* trajectory: budget consumed vs progress made.
|
|
233
|
+
#
|
|
234
|
+
# It's a pure checkpoint state machine. run_turn tracks the cumulative prefill tokens
|
|
235
|
+
# (self.prefill_tokens) + wall clock and a per-band "did real work land+verify" signal,
|
|
236
|
+
# and consults turn_governor at each budget-fraction checkpoint. Soft = one strong nudge
|
|
237
|
+
# at ~50%; hard = end the turn with a deterministic progress note at ~80%. Because real
|
|
238
|
+
# work resets the checkpoint (progress=True => never fire), a genuinely slow-but-working
|
|
239
|
+
# turn is never interrupted — only the pathological no-progress tail binds.
|
|
240
|
+
GOV_SOFT_FRAC = 0.5 # first checkpoint: nudge if no progress yet
|
|
241
|
+
GOV_HARD_FRAC = 0.8 # second checkpoint: bank a note and end the turn
|
|
242
|
+
BUDGET_SENTINEL = "[budget]" # run_turn return prefix on a hard governor stop
|
|
243
|
+
|
|
244
|
+
GOVERNOR_SOFT_NUDGE = (
|
|
245
|
+
"[you have consumed half of this turn's budget without landing AND verifying a "
|
|
246
|
+
"single change. Stop exploring. State your current single best hypothesis in one "
|
|
247
|
+
"sentence, then act on it directly: make the edit and run the check. Do NOT re-read "
|
|
248
|
+
"files you have already read or re-run commands you have already run.]")
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def budget_fraction(tokens, token_budget, wall_s=0.0, wall_budget_s=None) -> float:
|
|
252
|
+
"""Fraction of the turn budget consumed = the max of the token-budget ratio and the
|
|
253
|
+
wall-clock ratio (whichever is tighter drives the governor). A budget that is falsy
|
|
254
|
+
(None/0) is ignored; if neither is set, returns 0.0 so the governor never fires (the
|
|
255
|
+
off state). Pure and testable."""
|
|
256
|
+
fracs = []
|
|
257
|
+
if token_budget:
|
|
258
|
+
fracs.append(tokens / token_budget)
|
|
259
|
+
if wall_budget_s:
|
|
260
|
+
fracs.append(wall_s / wall_budget_s)
|
|
261
|
+
return max(fracs) if fracs else 0.0
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def budget_band(frac: float) -> int:
|
|
265
|
+
"""Which checkpoint band a consumed-fraction falls in: 0 (below the soft mark),
|
|
266
|
+
1 (soft..hard), 2 (at/over the hard mark). run_turn fires the governor only when the
|
|
267
|
+
band *advances*, evaluating the just-completed band's progress."""
|
|
268
|
+
if frac >= GOV_HARD_FRAC:
|
|
269
|
+
return 2
|
|
270
|
+
if frac >= GOV_SOFT_FRAC:
|
|
271
|
+
return 1
|
|
272
|
+
return 0
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def turn_governor(band, progress, soft_fired, *, disabled=False):
|
|
276
|
+
"""Decision for a checkpoint the turn just crossed into. `band` is the band being
|
|
277
|
+
entered (1 = soft ~50%, 2 = hard ~80%); `progress` is whether a change landed AND was
|
|
278
|
+
verified during the band we're leaving; `soft_fired` whether the soft nudge already
|
|
279
|
+
went out this turn. Returns 'hard' (end + bank a note), 'soft' (one nudge), or None.
|
|
280
|
+
Real progress in the completed band resets the checkpoint — a slow-but-working turn is
|
|
281
|
+
never interrupted. `disabled` (CHAD_NO_GOVERNOR) always returns None. Pure/testable."""
|
|
282
|
+
if disabled or progress:
|
|
283
|
+
return None
|
|
284
|
+
if band >= 2:
|
|
285
|
+
return "hard"
|
|
286
|
+
if band == 1 and not soft_fired:
|
|
287
|
+
return "soft"
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def advance_governor(gov_band, new_band, progress, soft_fired):
|
|
292
|
+
"""Walk the governor across every budget checkpoint crossed in a SINGLE step
|
|
293
|
+
(`gov_band` -> `new_band`). Returns `(decision, gov_band, progress)`: the first non-None
|
|
294
|
+
`turn_governor` result (or None), the updated band, and the carried-over progress flag.
|
|
295
|
+
|
|
296
|
+
A single step can leap two bands at once (e.g. a large re-prefill that consumes ~30%+ of
|
|
297
|
+
the budget in one go, jumping 0 -> 2). The earned `progress` is credited to EVERY band
|
|
298
|
+
crossed in this one step — you can't re-earn progress mid-jump, so a step that genuinely
|
|
299
|
+
landed+verified a change must not be hard-stopped just because it also spanned two bands
|
|
300
|
+
(plan 052). Progress is consumed (reset to False) once any band is crossed, so the next
|
|
301
|
+
band must re-earn it; a step that crosses nothing leaves the flag untouched. Pure/testable."""
|
|
302
|
+
decision = None
|
|
303
|
+
crossed = gov_band < new_band
|
|
304
|
+
while gov_band < new_band:
|
|
305
|
+
decision = turn_governor(gov_band + 1, progress, soft_fired)
|
|
306
|
+
gov_band += 1
|
|
307
|
+
if decision:
|
|
308
|
+
break
|
|
309
|
+
if crossed:
|
|
310
|
+
progress = False
|
|
311
|
+
return decision, gov_band, progress
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# Tool results that landed a file change / were run, used to reconstruct a progress note
|
|
315
|
+
# deterministically (no model call) from the transcript.
|
|
316
|
+
_EDIT_TOOLS = ("write", "edit", "replace_symbol", "insert_symbol", "rename_symbol")
|
|
317
|
+
_ERROR_PREFIXES = ("[exit", "[timed out", "[failed to launch", "[tool error", "[denied")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def progress_note(messages, max_lines: int = 20) -> str:
|
|
321
|
+
"""Synthesize a ≤`max_lines` progress note from the transcript with NO model call, so
|
|
322
|
+
a hard-stopped turn can seed a fresh relaunch (sheds the ramble AND the huge prefill
|
|
323
|
+
the stuck model was dragging around). Deterministic: pulls files edited and commands
|
|
324
|
+
run from the assistant turns' own <tool_call> blocks (via parse_tool_calls), plus the
|
|
325
|
+
last error seen in a tool result. Prefer facts the executor cannot reconstruct from a
|
|
326
|
+
clean context — what was already tried and what failed last."""
|
|
327
|
+
from .toolcall_parse import parse_tool_calls
|
|
328
|
+
edited, commands = [], []
|
|
329
|
+
last_error = None
|
|
330
|
+
for m in messages:
|
|
331
|
+
role = m.get("role")
|
|
332
|
+
content = m.get("content", "") or ""
|
|
333
|
+
if role == "assistant":
|
|
334
|
+
for name, args in parse_tool_calls(content):
|
|
335
|
+
if name == "bash":
|
|
336
|
+
cmd = str(args.get("command", "")).strip()
|
|
337
|
+
if cmd and cmd not in commands:
|
|
338
|
+
commands.append(cmd)
|
|
339
|
+
elif name in _EDIT_TOOLS:
|
|
340
|
+
p = str(args.get("path", "")).strip()
|
|
341
|
+
if p and p not in edited:
|
|
342
|
+
edited.append(p)
|
|
343
|
+
elif role == "tool":
|
|
344
|
+
if content.startswith(_ERROR_PREFIXES) or "Traceback" in content:
|
|
345
|
+
last_error = content
|
|
346
|
+
lines = ["Progress so far (auto-summarized — the previous attempt ran out of budget):"]
|
|
347
|
+
if edited:
|
|
348
|
+
lines.append("Files already edited: " + ", ".join(edited[-8:]))
|
|
349
|
+
if commands:
|
|
350
|
+
lines.append("Commands already tried (do not blindly repeat):")
|
|
351
|
+
lines += [f" $ {c[:120]}" for c in commands[-4:]]
|
|
352
|
+
if last_error:
|
|
353
|
+
lines.append("Last error seen:")
|
|
354
|
+
lines += [" " + ln[:120] for ln in last_error.strip().splitlines()[-4:]]
|
|
355
|
+
if len(lines) == 1:
|
|
356
|
+
lines.append("(no edits, commands, or errors were recorded before the budget ran out)")
|
|
357
|
+
return "\n".join(lines[:max_lines])
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def loop_signature(calls) -> str:
|
|
361
|
+
"""Canonical signature of a tool-call set, for the repeated-call loop guard."""
|
|
362
|
+
return json.dumps(calls, sort_keys=True)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def is_repeat_loop(seen_before: int) -> bool:
|
|
366
|
+
"""True on the 3rd+ identical call-set (seen_before is the count BEFORE this
|
|
367
|
+
occurrence was recorded, so >=2 means this is at least the third)."""
|
|
368
|
+
return seen_before >= 2
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def loop_should_abort(loop_nudges: int) -> bool:
|
|
372
|
+
"""After incrementing the loop-nudge counter, more than 2 nudges -> abort."""
|
|
373
|
+
return loop_nudges > 2
|
chad/ignore.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Single source of truth for directories never worth walking.
|
|
2
|
+
|
|
3
|
+
`IGNORE_DIRS` is the base set every tree-walk skips (VCS internals, virtualenvs,
|
|
4
|
+
build/cache output). `REPOMAP_EXTRA` are the additional dirs the repo-analysis tools
|
|
5
|
+
(repomap) skip — model weights, installed packages, caches — which a symbol *editor*
|
|
6
|
+
doesn't need to exclude. Kept here so a new ignore entry is added in exactly one place.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
IGNORE_DIRS = (".git", "node_modules", "__pycache__", ".venv", "venv",
|
|
10
|
+
".mypy_cache", ".pytest_cache", "dist", "build")
|
|
11
|
+
|
|
12
|
+
# repomap (whole-repo indexing) additionally skips these; a symbol edit doesn't.
|
|
13
|
+
REPOMAP_EXTRA = (".cache", "models", "site-packages")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def slash_wrapped(names) -> tuple:
|
|
17
|
+
"""`/name/` substring forms for path-contains tests (the symbols/repomap style)."""
|
|
18
|
+
return tuple(f"/{d}/" for d in names)
|