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/cli.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""chad — a local, MLX-backed, Claude-Code-style coding agent.
|
|
3
|
+
|
|
4
|
+
One model (Ornith — 35B on big Macs, 9B on small), one entrypoint, run with uv:
|
|
5
|
+
|
|
6
|
+
uv run chad # interactive full-screen TUI
|
|
7
|
+
uv run chad "fix the bug in greet.py" # one-shot, headless
|
|
8
|
+
uv run chad -c # resume this directory's conversation
|
|
9
|
+
|
|
10
|
+
Rare long-session knobs live in env vars — see README "Advanced".
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import platform
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from . import config
|
|
19
|
+
from .agent import Agent, repl
|
|
20
|
+
from .engine import Engine
|
|
21
|
+
|
|
22
|
+
# Package dir is src/chad/; the project root (two levels up) is the dev clone. If a
|
|
23
|
+
# locally-built weights tree exists at <root>/models/ it's preferred (see _pick_model);
|
|
24
|
+
# otherwise — the normal case — the default model ships from Hugging Face and downloads
|
|
25
|
+
# into the shared HF cache on first use. Point CHAD_MODEL at any local dir to override.
|
|
26
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
27
|
+
_PROJECT_ROOT = os.path.dirname(os.path.dirname(_HERE))
|
|
28
|
+
|
|
29
|
+
# The shipped models, on Hugging Face. Naming follows Unsloth's dynamic-quant
|
|
30
|
+
# convention so the quant scheme is recognizable (UD = Unsloth Dynamic; Q2_K_XL =
|
|
31
|
+
# 2-bit experts with a high-bit backbone/router), plus an -MLX suffix for format
|
|
32
|
+
# discoverability. The quant itself is MLX group-64 affine, not llama.cpp Q2_K
|
|
33
|
+
# k-quants — the model card says so; the tag is for recognition, not bit-for-bit
|
|
34
|
+
# equivalence.
|
|
35
|
+
_HF_35B = "nathansutton/Ornith-1.0-35B-UD-Q2_K_XL-MLX" # default: 35B MoE, ~12 GB resident
|
|
36
|
+
_HF_9B = "nathansutton/Ornith-1.0-9B-UD-Q4_K_XL-MLX" # low-RAM fallback, ~5 GB resident
|
|
37
|
+
# A dev clone that already built the weights locally should use them rather than
|
|
38
|
+
# re-download — prefer these dirs when present.
|
|
39
|
+
_LOCAL_35B = os.path.join(_PROJECT_ROOT, "models", "Ornith-1.0-35B-dyn2-q2-awq")
|
|
40
|
+
_LOCAL_9B = os.path.join(_PROJECT_ROOT, "models", "Ornith-1.0-9B-4bit-awq")
|
|
41
|
+
# The 35B (2-bit experts / 6-bit backbone) is ~12 GB resident + KV + runtime ≈ 14 GB
|
|
42
|
+
# peak. Comfortable at ≥24 GB; on 16/18 GB MacBook Pros the default Metal wired limit
|
|
43
|
+
# (~2/3 RAM) sits below that, so we fall back to the 9B (fits easily) automatically.
|
|
44
|
+
_BIG_RAM_GB = 23.5
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# These are the STRICT siblings of config.env_int/env_float: a non-numeric value raises
|
|
48
|
+
# (int()/float() propagate) rather than warning-and-defaulting. test_cli.py pins that
|
|
49
|
+
# contract, and a garbled CHAD_MAX_CONTEXT/CHAD_KV_BITS should fail loud at startup rather
|
|
50
|
+
# than silently reverting to the model default. Kept inline here on purpose; the lenient
|
|
51
|
+
# config helpers back the mid-run budget knobs in agent.py instead.
|
|
52
|
+
def _env_int(name):
|
|
53
|
+
val = os.environ.get(name)
|
|
54
|
+
return int(val) if val else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _env_float(name):
|
|
58
|
+
val = os.environ.get(name)
|
|
59
|
+
return float(val) if val else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _version_string():
|
|
63
|
+
"""chad <version> (<vcs commit>) — commit resolves for git installs via
|
|
64
|
+
dist-info/direct_url.json, or from the dev clone's .git; absent otherwise."""
|
|
65
|
+
from . import __version__
|
|
66
|
+
detail = ""
|
|
67
|
+
try:
|
|
68
|
+
import json
|
|
69
|
+
from importlib.metadata import distribution
|
|
70
|
+
raw = distribution("chad").read_text("direct_url.json") or ""
|
|
71
|
+
commit = json.loads(raw).get("vcs_info", {}).get("commit_id", "") if raw else ""
|
|
72
|
+
if not commit and os.path.isdir(os.path.join(_PROJECT_ROOT, ".git")):
|
|
73
|
+
commit = subprocess.check_output(
|
|
74
|
+
["git", "-C", _PROJECT_ROOT, "rev-parse", "--short", "HEAD"],
|
|
75
|
+
text=True, stderr=subprocess.DEVNULL).strip()
|
|
76
|
+
if commit:
|
|
77
|
+
detail = f" ({commit[:12]})"
|
|
78
|
+
except Exception: # noqa: BLE001 — version detail is best-effort, never fatal
|
|
79
|
+
pass
|
|
80
|
+
return f"chad {__version__}{detail}"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def ram_aware_ctx_limit(eff_ctx, budget_bytes, active_bytes, kv_bytes_per_token,
|
|
84
|
+
reserve_gb=1.5, safety=0.90, gen_margin=2048, floor=8192):
|
|
85
|
+
"""Plan 036: largest prompt-token budget (= the compaction trigger) that keeps the
|
|
86
|
+
growing KV cache inside a safe slice of the Metal *recommended working set*, given the
|
|
87
|
+
model's already-resident footprint and the *measured* per-token KV cost. Pure +
|
|
88
|
+
measured — replaces the magic `CTX_CAP = 120_000`, which was an OOM guard set blind to
|
|
89
|
+
the real per-token cost (it over-compacts: on a 24 GB M4 Pro the 20 KiB/token hybrid
|
|
90
|
+
cache fits ~175 k tokens, not 120 k).
|
|
91
|
+
|
|
92
|
+
`budget*safety` leaves a headroom band below Apple's recommendation; subtract the
|
|
93
|
+
resident model+SSM floor (`active_bytes`) and a scratch reserve for prefill/decode
|
|
94
|
+
buffers to get the bytes free for KV; divide by `kv_bytes_per_token` for the token
|
|
95
|
+
ceiling. Capped at `eff_ctx − gen_margin` (the model's real window) and floored so a
|
|
96
|
+
tight box still gets a usable window. Self-calibrates per machine (16 GB → small,
|
|
97
|
+
64 GB → near the window). Returns None if inputs are unusable so the caller can keep
|
|
98
|
+
the old fixed cap."""
|
|
99
|
+
if not (budget_bytes and kv_bytes_per_token and active_bytes):
|
|
100
|
+
return None
|
|
101
|
+
usable = budget_bytes * safety - active_bytes - reserve_gb * 1e9
|
|
102
|
+
if usable <= 0:
|
|
103
|
+
return floor
|
|
104
|
+
ram_ctx = int(usable / kv_bytes_per_token)
|
|
105
|
+
return max(floor, min(eff_ctx - gen_margin, ram_ctx))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _compute_ctx_limit(eng):
|
|
109
|
+
"""The auto-compaction threshold for a loaded engine. On this non-trimmable hybrid
|
|
110
|
+
cache, compaction forces a full body re-prefill (plan 035: ~79 % of all prefill), so
|
|
111
|
+
we compact as rarely as RAM safely allows: size the trigger from the live Metal
|
|
112
|
+
budget + the model's *measured* per-token KV cost (plan 036) instead of a blind 120 k
|
|
113
|
+
cap that over-compacts. CHAD_CTX_LIMIT still wins (evals/tests); CHAD_CTX_RESERVE_GB
|
|
114
|
+
tunes the scratch headroom. Falls back to the old fixed cap if the memory APIs or the
|
|
115
|
+
KV measurement are unavailable. Needs eng.load() to have run (reads effective_ctx +
|
|
116
|
+
kv_bytes_per_token)."""
|
|
117
|
+
ctx_limit = _env_int("CHAD_CTX_LIMIT")
|
|
118
|
+
if not ctx_limit:
|
|
119
|
+
try:
|
|
120
|
+
import mlx.core as mx
|
|
121
|
+
ctx_limit = ram_aware_ctx_limit(
|
|
122
|
+
eng.effective_ctx,
|
|
123
|
+
mx.device_info()["max_recommended_working_set_size"],
|
|
124
|
+
mx.get_active_memory(), eng.kv_bytes_per_token,
|
|
125
|
+
reserve_gb=_env_float("CHAD_CTX_RESERVE_GB") or 1.5)
|
|
126
|
+
except Exception: # noqa: BLE001 — never let memory probing break startup
|
|
127
|
+
ctx_limit = None
|
|
128
|
+
if not ctx_limit:
|
|
129
|
+
ctx_limit = min(max(4096, eng.effective_ctx - 2048), 120_000) # old fixed cap
|
|
130
|
+
return ctx_limit
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _preflight():
|
|
134
|
+
"""chad runs only on Apple Silicon — MLX has no CPU/CUDA build. Hard-stop with a
|
|
135
|
+
human message instead of letting `uv sync`/import fail cryptically elsewhere."""
|
|
136
|
+
if platform.system() != "Darwin" or platform.machine() != "arm64":
|
|
137
|
+
sys.stderr.write(
|
|
138
|
+
"chad: requires an Apple Silicon Mac (arm64 macOS).\n"
|
|
139
|
+
f" detected: {platform.system()} {platform.machine() or '?'}\n"
|
|
140
|
+
" MLX ships no CPU/CUDA build — there is no supported non-Apple path.\n")
|
|
141
|
+
sys.exit(1)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _detect_ram_gb():
|
|
145
|
+
"""Physical RAM in GiB via sysctl, or None if it can't be read."""
|
|
146
|
+
try:
|
|
147
|
+
out = subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True)
|
|
148
|
+
return int(out.strip()) / (1024 ** 3)
|
|
149
|
+
except Exception: # noqa: BLE001 — any failure → caller picks the safe (smaller) model
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _pick_model():
|
|
154
|
+
"""Resolve the model id and a human label for *why* it was chosen.
|
|
155
|
+
|
|
156
|
+
Order: explicit CHAD_MODEL override → RAM-aware default (35B on big boxes, 9B on
|
|
157
|
+
small) → prefer a locally-built models/ dir over the HF repo when it exists.
|
|
158
|
+
"""
|
|
159
|
+
env = config.env_str("CHAD_MODEL")
|
|
160
|
+
if env:
|
|
161
|
+
return env, "CHAD_MODEL override"
|
|
162
|
+
ram = _detect_ram_gb()
|
|
163
|
+
if ram is not None and ram < _BIG_RAM_GB:
|
|
164
|
+
local, repo = _LOCAL_9B, _HF_9B
|
|
165
|
+
why = f"9B (default; {ram:.0f} GB RAM < {_BIG_RAM_GB:.0f} GB, 35B would be tight)"
|
|
166
|
+
else:
|
|
167
|
+
local, repo = _LOCAL_35B, _HF_35B
|
|
168
|
+
why = "35B (default)"
|
|
169
|
+
return (local if os.path.isdir(local) else repo), why
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _ensure_model(model_id):
|
|
173
|
+
"""If model_id is a HF repo id not yet in the local cache, confirm and download it
|
|
174
|
+
into ~/.cache/huggingface (shared, resumable, paid once per machine). Local dirs
|
|
175
|
+
and already-cached repos return immediately. Headless (no TTY) auto-downloads."""
|
|
176
|
+
if os.path.isdir(model_id):
|
|
177
|
+
return # a local path — nothing to fetch
|
|
178
|
+
from huggingface_hub import snapshot_download, try_to_load_from_cache
|
|
179
|
+
if isinstance(try_to_load_from_cache(model_id, "config.json"), str):
|
|
180
|
+
return # already in the HF cache
|
|
181
|
+
size = "~12 GB" if "35B" in model_id else "~5 GB"
|
|
182
|
+
sys.stderr.write(
|
|
183
|
+
f"\nchad needs the model '{model_id}' "
|
|
184
|
+
f"({size} — minutes on fast fiber, ~20 min on 100 Mbit; resumable).\n"
|
|
185
|
+
"It downloads once into ~/.cache/huggingface and is reused across projects.\n")
|
|
186
|
+
if sys.stdin.isatty():
|
|
187
|
+
ans = input("Download now? [Y/n] ").strip().lower()
|
|
188
|
+
if ans and ans not in ("y", "yes"):
|
|
189
|
+
sys.stderr.write(
|
|
190
|
+
"Aborted. Set CHAD_MODEL to a local model dir to skip the download.\n")
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
else:
|
|
193
|
+
sys.stderr.write("[headless: downloading automatically]\n")
|
|
194
|
+
try:
|
|
195
|
+
snapshot_download(model_id) # tqdm progress to stderr
|
|
196
|
+
except Exception as e: # noqa: BLE001 — offline / gated / typo'd repo → guidance, not a traceback
|
|
197
|
+
sys.stderr.write(
|
|
198
|
+
f"\nchad: could not download '{model_id}'\n"
|
|
199
|
+
f" cause: {type(e).__name__}: {e}\n"
|
|
200
|
+
" fix: check your connection; if the repo is gated, run `hf auth login`.\n"
|
|
201
|
+
" Or point CHAD_MODEL at a local model dir you've already built.\n")
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _fail_model_load(model_id, err):
|
|
206
|
+
"""Turn a raw model-load traceback into problem / cause / fix and exit."""
|
|
207
|
+
sys.stderr.write(f"\nchad: could not load model '{model_id}'\n")
|
|
208
|
+
sys.stderr.write(f" cause: {type(err).__name__}: {err}\n")
|
|
209
|
+
if os.path.isdir(model_id):
|
|
210
|
+
sys.stderr.write(
|
|
211
|
+
" fix: the local model dir looks incomplete or corrupt. Re-build it, or\n"
|
|
212
|
+
" unset CHAD_MODEL to fall back to the Hugging Face download.\n")
|
|
213
|
+
else:
|
|
214
|
+
sys.stderr.write(
|
|
215
|
+
" fix: a partial/corrupt download or not enough free RAM. Re-run (the HF\n"
|
|
216
|
+
f" download resumes), or try the smaller model: CHAD_MODEL={_HF_9B}\n")
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _maybe_home_dir_note():
|
|
221
|
+
"""chad snapshots the working directory into context at startup, so the home dir is
|
|
222
|
+
rarely the intended workspace. Nudge once — no exit, no behavior change. Home-dir
|
|
223
|
+
only: guessing "is this a project" from marker files false-positives on legit
|
|
224
|
+
non-git work dirs."""
|
|
225
|
+
if os.getcwd() == os.path.expanduser("~"):
|
|
226
|
+
sys.stderr.write(
|
|
227
|
+
"note: running in your home directory — chad works best inside a project "
|
|
228
|
+
"(cd into one and rerun).\n")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _pick_session(items):
|
|
232
|
+
"""Prompt the user to pick one of `items` (from session.list_sessions) by number.
|
|
233
|
+
Returns the chosen item, or None to start fresh. Requires a TTY — the caller
|
|
234
|
+
guards that before calling."""
|
|
235
|
+
from . import session
|
|
236
|
+
sys.stderr.write("Resume which session? (this directory's recent sessions)\n")
|
|
237
|
+
for i, it in enumerate(items, 1):
|
|
238
|
+
sys.stderr.write(f" {i}. {session.describe(it)}\n")
|
|
239
|
+
try:
|
|
240
|
+
raw = input("session number (blank to cancel): ").strip()
|
|
241
|
+
except (EOFError, KeyboardInterrupt):
|
|
242
|
+
return None
|
|
243
|
+
if not raw:
|
|
244
|
+
return None
|
|
245
|
+
try:
|
|
246
|
+
n = int(raw)
|
|
247
|
+
except ValueError:
|
|
248
|
+
sys.stderr.write("not a number; starting fresh\n")
|
|
249
|
+
return None
|
|
250
|
+
if 1 <= n <= len(items):
|
|
251
|
+
return items[n - 1]
|
|
252
|
+
sys.stderr.write("out of range; starting fresh\n")
|
|
253
|
+
return None
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def main():
|
|
257
|
+
ap = argparse.ArgumentParser(
|
|
258
|
+
prog="chad",
|
|
259
|
+
description="Local MLX-backed coding agent (Ornith). Run with `uv run chad`.",
|
|
260
|
+
)
|
|
261
|
+
ap.add_argument("--version", action="version", version=_version_string())
|
|
262
|
+
ap.add_argument("task", nargs="?",
|
|
263
|
+
help="one-shot task to run headless and exit; omit for the interactive TUI")
|
|
264
|
+
ap.add_argument("-c", "--continue", dest="cont", action="store_true",
|
|
265
|
+
help="resume the most recent saved conversation for this directory")
|
|
266
|
+
ap.add_argument("--resume", action="store_true",
|
|
267
|
+
help="list this directory's recent sessions and pick one by number "
|
|
268
|
+
"(resuming forks: the picked session is never overwritten)")
|
|
269
|
+
ap.add_argument("--plan", action="store_true",
|
|
270
|
+
help="start in read-only plan mode (investigate and propose, no edits)")
|
|
271
|
+
ap.add_argument("--yolo", action="store_true",
|
|
272
|
+
help="auto-approve bash/write/edit (skip confirmation prompts)")
|
|
273
|
+
ap.add_argument("--no-think", action="store_true",
|
|
274
|
+
help="skip the model's <think> reasoning blocks (faster)")
|
|
275
|
+
ap.add_argument("--think-budget", type=int, default=None, dest="think_budget",
|
|
276
|
+
help="soft-cap each step's <think> run at N tokens, then force-close "
|
|
277
|
+
"it and continue (escalates when stuck); off by default. Also "
|
|
278
|
+
"settable via CHAD_THINK_BUDGET.")
|
|
279
|
+
ap.add_argument("--turn-budget-tokens", type=int, default=None, dest="turn_budget_tokens",
|
|
280
|
+
help="runaway-turn governor: end a turn once it has burned N cumulative "
|
|
281
|
+
"prefill tokens WITHOUT landing+verifying a change (nudges at ~50%%, "
|
|
282
|
+
"banks a progress note and stops at ~80%%). Defaults to 3× the context "
|
|
283
|
+
"limit; CHAD_NO_GOVERNOR=1 disables. Also CHAD_TURN_BUDGET_TOKENS.")
|
|
284
|
+
ap.add_argument("--turn-budget-s", type=float, default=None, dest="turn_budget_s",
|
|
285
|
+
help="wall-clock variant of --turn-budget-tokens (seconds); off by "
|
|
286
|
+
"default (interactive: the human is the wall clock). Also "
|
|
287
|
+
"CHAD_TURN_BUDGET_S.")
|
|
288
|
+
ap.add_argument("--auto-continue", type=int, default=0, dest="auto_continue",
|
|
289
|
+
help="on a one-shot run, if the governor hard-stops a turn, relaunch a "
|
|
290
|
+
"FRESH turn (cleared context) seeded with the progress note, up to N "
|
|
291
|
+
"times (default 0 = off). Sheds the ramble and the huge prefill.")
|
|
292
|
+
# Backend selection (plan 046 spike). Default 'mlx' is the in-process engine and the
|
|
293
|
+
# whole point of chad; 'openai' drives the SAME harness against an OpenAI-compatible
|
|
294
|
+
# endpoint to separate harness-value from engine-value (honest degradations apply —
|
|
295
|
+
# see openai_engine.py). The MLX path below is untouched when --backend is unset.
|
|
296
|
+
ap.add_argument("--backend", choices=("mlx", "openai"), default="mlx",
|
|
297
|
+
help="inference backend: 'mlx' (default, in-process KV cache) or "
|
|
298
|
+
"'openai' (spike: run the chad harness against an OpenAI-compatible "
|
|
299
|
+
"/v1/chat/completions endpoint; requires --base-url).")
|
|
300
|
+
ap.add_argument("--base-url", dest="base_url", default=None,
|
|
301
|
+
help="OpenAI-compatible base URL for --backend openai (e.g. "
|
|
302
|
+
"http://localhost:8080/v1). Also CHAD_OPENAI_BASE_URL.")
|
|
303
|
+
ap.add_argument("--api-key-env", dest="api_key_env", default=None,
|
|
304
|
+
help="name of the env var holding the API key for --backend openai; the "
|
|
305
|
+
"key is read from that var, never passed on the command line.")
|
|
306
|
+
ap.add_argument("--repl", action="store_true",
|
|
307
|
+
help="plain line REPL instead of the full-screen TUI")
|
|
308
|
+
# Back-compat: -p/--prompt was the old one-shot spelling, now the positional task.
|
|
309
|
+
ap.add_argument("-p", "--prompt", dest="prompt_flag", help=argparse.SUPPRESS)
|
|
310
|
+
args = ap.parse_args()
|
|
311
|
+
|
|
312
|
+
_preflight() # Apple Silicon only — fail clearly before importing/loading MLX
|
|
313
|
+
# --think-budget (plan 039) reaches the TUI/REPL Agents through the same env knob
|
|
314
|
+
# their __init__ reads, so the flag works on every entrypoint, not just headless.
|
|
315
|
+
if args.think_budget is not None:
|
|
316
|
+
os.environ["CHAD_THINK_BUDGET"] = str(args.think_budget)
|
|
317
|
+
# --turn-budget-* (plan 040) reach the TUI/REPL Agents through the same env knobs
|
|
318
|
+
# their __init__ reads, so the governor is configurable on every entrypoint.
|
|
319
|
+
if args.turn_budget_tokens is not None:
|
|
320
|
+
os.environ["CHAD_TURN_BUDGET_TOKENS"] = str(args.turn_budget_tokens)
|
|
321
|
+
if args.turn_budget_s is not None:
|
|
322
|
+
os.environ["CHAD_TURN_BUDGET_S"] = str(args.turn_budget_s)
|
|
323
|
+
task = args.task or args.prompt_flag
|
|
324
|
+
# Ornith; no draft, ever. RAM-aware default, local-dir-preferred, HF fallback.
|
|
325
|
+
model_id, why = _pick_model()
|
|
326
|
+
|
|
327
|
+
# Advanced, rarely-touched knobs live in env vars to keep the CLI sane:
|
|
328
|
+
# CHAD_MAX_CONTEXT YaRN-extend the window (e.g. 131072 for 128k)
|
|
329
|
+
# CHAD_CTX_LIMIT prompt-token budget before old tool outputs compact
|
|
330
|
+
# CHAD_KV_BITS quantize the KV cache (e.g. 8) to save RAM on long runs
|
|
331
|
+
max_context = _env_int("CHAD_MAX_CONTEXT")
|
|
332
|
+
kv_bits = _env_int("CHAD_KV_BITS")
|
|
333
|
+
|
|
334
|
+
# ds4-style on-disk KV warm-start of the stable system+tools prefix.
|
|
335
|
+
cache_dir = os.path.expanduser("~/.cache/chad/kv")
|
|
336
|
+
|
|
337
|
+
if args.backend == "openai":
|
|
338
|
+
# Plan 046 spike: drive the chad harness against an OpenAI-compatible endpoint
|
|
339
|
+
# instead of the in-process MLX engine. Only a tokenizer is loaded locally (to
|
|
340
|
+
# render/decode prompts); generation is proxied over HTTP. Weights are NOT
|
|
341
|
+
# downloaded (we don't run them here). The MLX default path is untouched.
|
|
342
|
+
from .openai_engine import OpenAIEngine
|
|
343
|
+
base_url = args.base_url or config.env_str("CHAD_OPENAI_BASE_URL")
|
|
344
|
+
if not base_url:
|
|
345
|
+
sys.stderr.write("chad --backend openai needs --base-url (or CHAD_OPENAI_BASE_URL), "
|
|
346
|
+
"e.g. http://localhost:8080/v1\n")
|
|
347
|
+
sys.exit(1)
|
|
348
|
+
api_key = os.environ.get(args.api_key_env, "") if args.api_key_env else ""
|
|
349
|
+
eng = OpenAIEngine(model_id=model_id, base_url=base_url, api_key=api_key,
|
|
350
|
+
effective_ctx=max_context or 32768)
|
|
351
|
+
sys.stderr.write(f"backend=openai · base_url={base_url} · model={model_id} "
|
|
352
|
+
f"(tokenizer local, generation proxied) ...\n")
|
|
353
|
+
else:
|
|
354
|
+
_ensure_model(model_id) # first-run download-on-consent if it's an uncached HF repo
|
|
355
|
+
eng = Engine(
|
|
356
|
+
model_id=model_id,
|
|
357
|
+
draft_id=None,
|
|
358
|
+
kv_bits=kv_bits,
|
|
359
|
+
max_context=max_context,
|
|
360
|
+
cache_dir=cache_dir,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
# The full-screen TUI loads the 11 GB of weights on a BACKGROUND thread so the banner
|
|
364
|
+
# + input come up in ~0.6 s and you can read/queue while it loads (the load itself is
|
|
365
|
+
# disk-bound and can't be made faster). Headless one-shot and the plain --repl still
|
|
366
|
+
# load synchronously — there's nothing to interact with until the model answers, and
|
|
367
|
+
# a background download prompt would be worse than a blocking one. `--backend openai`
|
|
368
|
+
# only loads a tokenizer (cheap), so it stays synchronous too.
|
|
369
|
+
background = args.backend != "openai" and not task and not args.repl
|
|
370
|
+
|
|
371
|
+
ctx_limit = None
|
|
372
|
+
if not background:
|
|
373
|
+
if args.backend != "openai":
|
|
374
|
+
sys.stderr.write(f"loading {os.path.basename(model_id.rstrip('/'))} [{why}] ...\n")
|
|
375
|
+
try:
|
|
376
|
+
load_s = eng.load()
|
|
377
|
+
except Exception as e: # noqa: BLE001 — convert any load failure into guidance
|
|
378
|
+
_fail_model_load(model_id, e)
|
|
379
|
+
ctx_limit = _compute_ctx_limit(eng)
|
|
380
|
+
sys.stderr.write(f"ready in {load_s:.1f}s | context {eng.effective_ctx} tokens "
|
|
381
|
+
f"(compact at {ctx_limit})\n")
|
|
382
|
+
|
|
383
|
+
start_mode = "plan" if args.plan else ("auto" if args.yolo else "normal")
|
|
384
|
+
thinking = not args.no_think
|
|
385
|
+
|
|
386
|
+
# Resume seeds a FRESH Agent's messages; that Agent mints a new session_id, so the
|
|
387
|
+
# picked/newest session is copied, never overwritten (implicit fork — plan 043).
|
|
388
|
+
# --resume : list recent sessions and pick by number (needs a TTY).
|
|
389
|
+
# -c : the most recent session (unchanged simple case).
|
|
390
|
+
resume = None
|
|
391
|
+
if args.resume:
|
|
392
|
+
from . import session
|
|
393
|
+
items = session.list_sessions(os.getcwd(), limit=10)
|
|
394
|
+
if not items:
|
|
395
|
+
sys.stderr.write("no saved sessions for this directory; starting fresh\n")
|
|
396
|
+
elif not sys.stdin.isatty():
|
|
397
|
+
sys.stderr.write("chad --resume needs an interactive terminal to pick a "
|
|
398
|
+
"session; use -c to resume the most recent one.\n")
|
|
399
|
+
sys.exit(1)
|
|
400
|
+
else:
|
|
401
|
+
pick = _pick_session(items)
|
|
402
|
+
if pick:
|
|
403
|
+
data = session.load_session(os.getcwd(), pick["session_id"])
|
|
404
|
+
if data:
|
|
405
|
+
resume = data["messages"]
|
|
406
|
+
sys.stderr.write(f"resuming (forked): {session.describe(pick)}\n")
|
|
407
|
+
elif args.cont:
|
|
408
|
+
from . import session
|
|
409
|
+
data = session.load_session(os.getcwd())
|
|
410
|
+
if data:
|
|
411
|
+
resume = data["messages"]
|
|
412
|
+
sys.stderr.write(f"resuming session ({session.session_summary(os.getcwd())})\n")
|
|
413
|
+
else:
|
|
414
|
+
sys.stderr.write("no saved session for this directory; starting fresh\n")
|
|
415
|
+
|
|
416
|
+
if task:
|
|
417
|
+
# A one-shot run is inherently unattended: the interactive confirm prompt
|
|
418
|
+
# reads from stdin, which EOFs with no TTY and would abort every edit. So
|
|
419
|
+
# auto-approve mutating tools unless the user asked for read-only --plan.
|
|
420
|
+
run_mode = start_mode
|
|
421
|
+
if run_mode == "normal" and not sys.stdin.isatty():
|
|
422
|
+
run_mode = "auto"
|
|
423
|
+
sys.stderr.write("[headless: auto-approving tools (use --plan for read-only)]\n")
|
|
424
|
+
agent = Agent(eng, yolo=(run_mode == "auto"), ctx_limit=ctx_limit,
|
|
425
|
+
mode=run_mode, thinking=thinking, resume=resume, persist=True,
|
|
426
|
+
think_budget=args.think_budget,
|
|
427
|
+
turn_budget_tokens=args.turn_budget_tokens,
|
|
428
|
+
turn_budget_s=args.turn_budget_s)
|
|
429
|
+
agent.run_turn(task)
|
|
430
|
+
# Plan 040: if the governor hard-stopped the turn on budget, optionally relaunch a
|
|
431
|
+
# FRESH turn (new context + reset KV cache) seeded with the deterministic progress
|
|
432
|
+
# note — shedding both the ramble and the huge prefill the stuck model dragged.
|
|
433
|
+
continues = args.auto_continue
|
|
434
|
+
while agent.budget_note and continues > 0:
|
|
435
|
+
note = agent.budget_note
|
|
436
|
+
continues -= 1
|
|
437
|
+
sys.stderr.write("[governor] previous turn ran out of budget; continuing fresh "
|
|
438
|
+
"with a progress note\n")
|
|
439
|
+
eng.reset()
|
|
440
|
+
agent = Agent(eng, yolo=(run_mode == "auto"), ctx_limit=ctx_limit,
|
|
441
|
+
mode=run_mode, thinking=thinking, persist=True,
|
|
442
|
+
think_budget=args.think_budget,
|
|
443
|
+
turn_budget_tokens=args.turn_budget_tokens,
|
|
444
|
+
turn_budget_s=args.turn_budget_s)
|
|
445
|
+
agent.run_turn(f"{task}\n\n[{note}]")
|
|
446
|
+
agent.save() # persist so a follow-up `chad -c "..."` picks up the thread
|
|
447
|
+
elif args.repl:
|
|
448
|
+
repl(eng, yolo=args.yolo, ctx_limit=ctx_limit, resume=resume, thinking=thinking)
|
|
449
|
+
else:
|
|
450
|
+
from .engine import peek_context_window
|
|
451
|
+
from .tui import run_tui
|
|
452
|
+
_maybe_home_dir_note()
|
|
453
|
+
# Cheap config-only window for the banner + a provisional compaction limit, both
|
|
454
|
+
# shown instantly; `finalize` runs the real load on the TUI's background thread and
|
|
455
|
+
# returns (load_s, ctx_limit) once weights are in.
|
|
456
|
+
window = peek_context_window(model_id, max_context)
|
|
457
|
+
provisional = ctx_limit or _env_int("CHAD_CTX_LIMIT") \
|
|
458
|
+
or min(max(4096, (window or 32768) - 2048), 120_000)
|
|
459
|
+
|
|
460
|
+
def finalize():
|
|
461
|
+
load_s = eng.load()
|
|
462
|
+
return load_s, _compute_ctx_limit(eng)
|
|
463
|
+
|
|
464
|
+
run_tui(eng, provisional, mode=start_mode, thinking=thinking, resume=resume,
|
|
465
|
+
ctx_window=window, finalize=finalize)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
if __name__ == "__main__":
|
|
469
|
+
main()
|
chad/compaction.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Context compaction for long agentic sessions (extracted from agent.py).
|
|
2
|
+
|
|
3
|
+
Operates on a plain `messages` list plus two callbacks — `render()` (returns the
|
|
4
|
+
current prompt token ids) and `emit(kind, text)` (status/info display) — so it
|
|
5
|
+
carries no Agent state. `Agent` calls `compact_if_needed(self.messages, self._render,
|
|
6
|
+
self._emit, self.ctx_limit, prompt_ids)`; the `compact_now` (/compact) path reuses
|
|
7
|
+
`_headtail`/`_COLLAPSED` via the Agent aliases. Behavior is byte-identical to the old
|
|
8
|
+
in-class method — same passes, same thresholds (0.7 target, keep-last-N), same order.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from . import skills
|
|
12
|
+
|
|
13
|
+
_COLLAPSED = "[…earlier output trimmed to save context…]"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _headtail(text: str, head: int = 12, tail: int = 8, max_chars: int = 8000) -> str:
|
|
17
|
+
"""Keep the first/last few lines of a long output; drop the middle. The
|
|
18
|
+
head and tail carry the most signal (what a command was / how it ended),
|
|
19
|
+
and this preserves far more than a bare stub while still reclaiming space.
|
|
20
|
+
A char cap also clips pathological single-line blobs (minified files etc.)
|
|
21
|
+
that have too few newlines for line-based trimming to help."""
|
|
22
|
+
lines = text.splitlines()
|
|
23
|
+
if len(lines) > head + tail + 3:
|
|
24
|
+
text = "\n".join(
|
|
25
|
+
lines[:head]
|
|
26
|
+
+ [f" {_COLLAPSED} ({len(lines) - head - tail} lines)"]
|
|
27
|
+
+ lines[-tail:])
|
|
28
|
+
if len(text) > max_chars:
|
|
29
|
+
keep = max_chars // 2
|
|
30
|
+
text = text[:keep] + f"\n {_COLLAPSED}\n" + text[-keep:]
|
|
31
|
+
return text
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def compact_if_needed(messages, render, emit, ctx_limit, prompt_ids):
|
|
35
|
+
"""Context compaction for long agentic sessions. On a non-trimmable cache
|
|
36
|
+
(Ornith) any prefix change forces a full re-prefill, so compaction is
|
|
37
|
+
expensive — we must reclaim enough in ONE pass that it won't re-trigger next
|
|
38
|
+
step (otherwise: full re-prefill every step). We therefore reclaim down to a
|
|
39
|
+
target well below the limit, escalating until we get there:
|
|
40
|
+
1. strip stale <think> reasoning from older assistant turns;
|
|
41
|
+
2. head/tail-truncate the oldest large tool outputs;
|
|
42
|
+
3. as a last resort, drop the oldest messages entirely.
|
|
43
|
+
Recent context (last few tool results / assistant turns) is kept verbatim."""
|
|
44
|
+
if len(prompt_ids) <= ctx_limit:
|
|
45
|
+
return prompt_ids
|
|
46
|
+
target = int(ctx_limit * 0.7) # reclaim to here so we don't recompact soon
|
|
47
|
+
emit("status", "Compacting context")
|
|
48
|
+
before = len(prompt_ids)
|
|
49
|
+
|
|
50
|
+
def cur_len():
|
|
51
|
+
return len(render())
|
|
52
|
+
|
|
53
|
+
def trunc_tools(keep_recent, head, tail, max_chars):
|
|
54
|
+
# Activated skill instructions are durable behavioral guidance — exempt them
|
|
55
|
+
# from truncation (silently shrinking them degrades the agent with no error).
|
|
56
|
+
idxs = [i for i, m in enumerate(messages)
|
|
57
|
+
if m.get("role") == "tool" and _COLLAPSED not in m["content"]
|
|
58
|
+
and not skills.is_skill_message(m)]
|
|
59
|
+
stop = len(idxs) - keep_recent if keep_recent else len(idxs)
|
|
60
|
+
for i in idxs[:stop]:
|
|
61
|
+
if len(messages[i]["content"]) > 400:
|
|
62
|
+
messages[i]["content"] = _headtail(
|
|
63
|
+
messages[i]["content"], head, tail, max_chars)
|
|
64
|
+
|
|
65
|
+
# pass 1: drop old reasoning (keep the last 2 assistant turns' thinking)
|
|
66
|
+
for i in [i for i, m in enumerate(messages)
|
|
67
|
+
if m.get("role") == "assistant" and "</think>" in m["content"]][:-2]:
|
|
68
|
+
c = messages[i]["content"]
|
|
69
|
+
messages[i]["content"] = c.split("</think>", 1)[1].lstrip("\n")
|
|
70
|
+
|
|
71
|
+
# pass 2: head/tail-truncate older tool outputs (keep the last 4 verbatim)
|
|
72
|
+
if cur_len() > target:
|
|
73
|
+
trunc_tools(keep_recent=4, head=12, tail=8, max_chars=8000)
|
|
74
|
+
# pass 3: drop the oldest messages outright (keep system + recent window) —
|
|
75
|
+
# old context is less useful than recent, so shed it before touching recent.
|
|
76
|
+
# NEVER delete the system prompt or the most recent real user query (the
|
|
77
|
+
# active task) and everything after it: removing the active query makes the
|
|
78
|
+
# chat template raise "No user query found in messages" and crashes the turn.
|
|
79
|
+
def _last_user_idx():
|
|
80
|
+
for i in range(len(messages) - 1, 0, -1):
|
|
81
|
+
if messages[i].get("role") == "user":
|
|
82
|
+
return i
|
|
83
|
+
return None
|
|
84
|
+
BATCH = 8 # delete up to this many oldest messages between budget re-checks
|
|
85
|
+
guard = 0
|
|
86
|
+
while cur_len() > target and guard < 500:
|
|
87
|
+
lu = _last_user_idx()
|
|
88
|
+
ceil = lu if lu is not None else len(messages)
|
|
89
|
+
# Oldest deletable indices: after the system prompt, before the active user query,
|
|
90
|
+
# and never an activated skill message (durable guidance is kept verbatim). Collect
|
|
91
|
+
# up to BATCH of them in one scan so we re-tokenize (cur_len) once per batch instead
|
|
92
|
+
# of once per deletion — render() tokenizes the whole transcript, so per-message
|
|
93
|
+
# re-checks made pass 3 O(N²).
|
|
94
|
+
victims = [i for i in range(1, ceil)
|
|
95
|
+
if not skills.is_skill_message(messages[i])][:BATCH]
|
|
96
|
+
if not victims: # nothing left to shed without touching protected content
|
|
97
|
+
break
|
|
98
|
+
for i in reversed(victims): # delete high→low so earlier indices stay valid
|
|
99
|
+
del messages[i]
|
|
100
|
+
guard += 1
|
|
101
|
+
# pass 4: last resort — recent outputs alone still exceed target; truncate
|
|
102
|
+
# everything that's left, hard.
|
|
103
|
+
if cur_len() > target:
|
|
104
|
+
trunc_tools(keep_recent=0, head=6, tail=4, max_chars=2000)
|
|
105
|
+
|
|
106
|
+
new_ids = render()
|
|
107
|
+
emit("info", f" [compacted context: {before}→{len(new_ids)} tokens]")
|
|
108
|
+
return new_ids
|
chad/config.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Single source of truth for CHAD_* configuration.
|
|
2
|
+
|
|
3
|
+
Typed accessors so every flag parses env the same way, and one place docs/tests can point
|
|
4
|
+
at (the substrate the docs drifted away from — plan 051). The int/float helpers fold in
|
|
5
|
+
the lenient-parse contract plan 052 gave `agent.py`: a non-numeric value warns and degrades
|
|
6
|
+
to the default instead of raising, so a typo in a budget knob can't abort startup.
|
|
7
|
+
|
|
8
|
+
Callers that resolve a value ONCE at import for hot-loop reasons keep doing so
|
|
9
|
+
(`agent._PREFILL_TRACE`, `diag._DISABLED`, `validate.VALIDATE`) — they just call `flag`/
|
|
10
|
+
`env_str` at import instead of hand-rolling `os.environ.get`. Nothing here is read on the
|
|
11
|
+
per-call path unless the caller chooses to; these are thin wrappers, not a live registry.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
log = logging.getLogger("chad") # same named logger as diag.log; no chad-module imports
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def flag(name: str) -> bool:
|
|
21
|
+
"""A CHAD_* boolean: truthy iff the var is set to a non-empty value. Matches the
|
|
22
|
+
repo's dominant `bool(os.environ.get(...))` / `not os.environ.get(...)` convention.
|
|
23
|
+
NOTE: any non-empty value is true, including "0" — use `eq(name, "1")` when a var
|
|
24
|
+
demands a strict `== "1"` opt-in rather than mere presence."""
|
|
25
|
+
return bool(os.environ.get(name))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def eq(name: str, expected: str) -> bool:
|
|
29
|
+
"""Strict equality against the raw env value (no strip/case-fold). For the rare var
|
|
30
|
+
whose contract is exactly `== "1"` and would be loosened by `flag`."""
|
|
31
|
+
return os.environ.get(name) == expected
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def env_str(name, default=None):
|
|
35
|
+
"""The env value if set to a non-empty string, else `default`. Mirrors the common
|
|
36
|
+
`os.environ.get(name) or <fallback>` idiom (empty string collapses to the default)."""
|
|
37
|
+
return os.environ.get(name) or default
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def env_int(name, default=None):
|
|
41
|
+
"""Parse an int from env var `name`, or fall back to `default`. Unset/empty → default;
|
|
42
|
+
a non-numeric value warns and degrades to the default instead of raising (lenient-parse
|
|
43
|
+
rule, plan 052) so a typo can't abort startup."""
|
|
44
|
+
v = os.environ.get(name)
|
|
45
|
+
if not v:
|
|
46
|
+
return default
|
|
47
|
+
try:
|
|
48
|
+
return int(v)
|
|
49
|
+
except ValueError:
|
|
50
|
+
log.warning("ignoring non-integer %s=%r; using default %r", name, v, default)
|
|
51
|
+
return default
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def env_float(name, default=None):
|
|
55
|
+
"""float sibling of `env_int` — same lenient-parse contract."""
|
|
56
|
+
v = os.environ.get(name)
|
|
57
|
+
if not v:
|
|
58
|
+
return default
|
|
59
|
+
try:
|
|
60
|
+
return float(v)
|
|
61
|
+
except ValueError:
|
|
62
|
+
log.warning("ignoring non-float %s=%r; using default %r", name, v, default)
|
|
63
|
+
return default
|