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/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)