chad-code 1.0.3__tar.gz → 1.0.4__tar.gz
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_code-1.0.3/src/chad_code.egg-info → chad_code-1.0.4}/PKG-INFO +1 -1
- {chad_code-1.0.3 → chad_code-1.0.4}/pyproject.toml +1 -1
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/agent.py +30 -5
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/config.py +15 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/diag.py +10 -7
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/guardrails.py +61 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/levers.py +37 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/prompt.py +36 -4
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/tools.py +34 -6
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/tui.py +4 -3
- {chad_code-1.0.3 → chad_code-1.0.4/src/chad_code.egg-info}/PKG-INFO +1 -1
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_agent_guards.py +63 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_edit.py +35 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_feel_pack.py +20 -4
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_lever_bite.py +49 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_log_redaction.py +38 -13
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_subagent.py +8 -1
- {chad_code-1.0.3 → chad_code-1.0.4}/LICENSE +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/README.md +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/setup.cfg +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/__init__.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/atif.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/base_engine.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/bench.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/cli.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/compaction.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/completion_engine.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/engine.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/ignore.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/lsp.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/mcp.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/mcp_oauth.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/mlx_fastpath.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/mlx_qsdpa.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/profiles.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/prove.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/render.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/repomap.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/session.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/skills.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/symbols.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/syntaxgate.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/toolcall_parse.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad/validate.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/SOURCES.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/dependency_links.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/entry_points.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/requires.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/src/chad_code.egg-info/top_level.txt +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_agent.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_agent_e2e.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_atif.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_bench.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_cli.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_compact_notice.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_compaction.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_completion_engine.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_config.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_confirm_preview.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_done_audit.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_drift_warn.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_edit_corruption.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_engine.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_engine_kvquant.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_garble_invariant.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_gate.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_ignore.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_intent.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_levers.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_lsp.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_mcp.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_mcp_oauth.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_mlx_fastpath.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_mlx_qsdpa.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_plan_review.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_prove.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_render.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_replace_lines.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_repomap.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_session.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_skills.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_symbols.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_syntaxgate.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_toolcall_parse.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_tools.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_tui.py +0 -0
- {chad_code-1.0.3 → chad_code-1.0.4}/tests/test_validate.py +0 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
# import name, and command name are independent. `uvx chad-code` runs the alias
|
|
5
5
|
# script added under [project.scripts].
|
|
6
6
|
name = "chad-code"
|
|
7
|
-
version = "1.0.
|
|
7
|
+
version = "1.0.4"
|
|
8
8
|
description = "Local MLX-backed, Claude-Code-style coding agent (Apple Silicon, Ornith 35B/9B)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -435,6 +435,11 @@ class Agent:
|
|
|
435
435
|
self.draft_accepted = 0
|
|
436
436
|
self.think_tokens = 0 # tokens spent inside <think> blocks (reasoning overhead)
|
|
437
437
|
self.think_capped = 0 # times the soft think-cap force-closed a step
|
|
438
|
+
# Real tool dispatches this agent has executed (fn actually ran — excludes
|
|
439
|
+
# terminal `done`, validation rejects, and harness-injected nudge messages,
|
|
440
|
+
# which reuse real tool names in the transcript). The evidence signal behind
|
|
441
|
+
# the sub-agent zero-evidence warning in _run_subagent.
|
|
442
|
+
self.tool_dispatches = 0
|
|
438
443
|
# prefill accounting: the master cost for a local model is how many *new*
|
|
439
444
|
# tokens it has to prefill across a turn (context bloat -> big prefills).
|
|
440
445
|
# This is the metric symbolic/repo-map retrieval is meant to shrink.
|
|
@@ -689,12 +694,22 @@ class Agent:
|
|
|
689
694
|
elif (not result or result.startswith("[stopped:")
|
|
690
695
|
or result.startswith("[task failed:")):
|
|
691
696
|
result = _salvage(result or "[task returned nothing]")
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
697
|
+
else:
|
|
698
|
+
# A confident, non-empty report produced with ZERO tool dispatches came
|
|
699
|
+
# from model memory, not this repo — the one sub-agent failure the empty/
|
|
700
|
+
# crashed salvage above cannot see, and the most dangerous fold-back: it
|
|
701
|
+
# reads as evidence (guardrails.subagent_evidence_warning).
|
|
702
|
+
warned = guardrails.subagent_evidence_warning(result, sub.tool_dispatches)
|
|
703
|
+
if warned is not None:
|
|
704
|
+
log.info("TASK zero-evidence warning appended | desc=%r", description)
|
|
705
|
+
result = warned
|
|
706
|
+
# tool_dispatches (not a transcript count — harness nudges reuse real tool names
|
|
707
|
+
# in tool-role messages) is the number that diagnoses a sub-agent returning
|
|
708
|
+
# nothing: it separates "never got to search" from "searched and lost its
|
|
709
|
+
# findings", and 0 with a confident report is the answered-from-memory tell.
|
|
710
|
+
log.info("TASK end | desc=%r | %.1fs | tool_dispatches=%d gen=%d prefill=%d | -> %s",
|
|
696
711
|
description, time.perf_counter() - _t0,
|
|
697
|
-
|
|
712
|
+
sub.tool_dispatches if sub else 0,
|
|
698
713
|
sub.gen_tokens if sub else 0,
|
|
699
714
|
sub.prefill_tokens if sub else 0, result_preview(result or ""))
|
|
700
715
|
return result or "[task returned nothing]"
|
|
@@ -1810,6 +1825,7 @@ class Agent:
|
|
|
1810
1825
|
result = "[denied by user]"
|
|
1811
1826
|
else:
|
|
1812
1827
|
_t0 = time.perf_counter()
|
|
1828
|
+
self.tool_dispatches += 1
|
|
1813
1829
|
try:
|
|
1814
1830
|
result = fn(args, self._should_stop)
|
|
1815
1831
|
if plan_write and result.startswith("[wrote"):
|
|
@@ -1821,6 +1837,15 @@ class Agent:
|
|
|
1821
1837
|
# whole — several calls in one step stack into one prefill, so later
|
|
1822
1838
|
# results only get what's left of the step budget (floor-protected).
|
|
1823
1839
|
result = _clip_tool_result(result, cap=_step_tool_cap(step_tool_chars))
|
|
1840
|
+
# Duplicate read-only output: if this (clipped) result is
|
|
1841
|
+
# byte-identical to a tool message still in the transcript, append a
|
|
1842
|
+
# short pointer instead of paying the body's prefill again — see
|
|
1843
|
+
# guardrails.elide_duplicate_result for the safety argument.
|
|
1844
|
+
_elided = guardrails.elide_duplicate_result(name, result, self.messages)
|
|
1845
|
+
if _elided is not None:
|
|
1846
|
+
log.info("TOOL %s duplicate result elided (%d chars)",
|
|
1847
|
+
name, len(result))
|
|
1848
|
+
result = _elided
|
|
1824
1849
|
step_tool_chars += len(result)
|
|
1825
1850
|
if _PREFILL_TRACE:
|
|
1826
1851
|
self._trace_tools_pending.append([name, round(_tool_s, 4)])
|
|
@@ -31,6 +31,21 @@ def eq(name: str, expected: str) -> bool:
|
|
|
31
31
|
return os.environ.get(name) == expected
|
|
32
32
|
|
|
33
33
|
|
|
34
|
+
def traces_enabled() -> bool:
|
|
35
|
+
"""Whether chad may write local diagnostic traces under ~/.chad — the readable
|
|
36
|
+
`session.log` (user query, tool-call args, bash/write/edit previews, result previews)
|
|
37
|
+
and the persistent input history at ~/.chad/history.
|
|
38
|
+
|
|
39
|
+
Privacy-first default: **OFF**. chad is a local, single-user agent, so these traces
|
|
40
|
+
never leave the machine, but they still record command/file previews in plaintext
|
|
41
|
+
outside the repo — so they are opt-in. Set **`CHAD_SESSION_LOG`** (any truthy value)
|
|
42
|
+
to turn them on. `CHAD_NO_SESSION_LOG`, if set, forces them OFF and wins over the
|
|
43
|
+
opt-in (a hard kill switch, kept for compatibility with the pre-opt-in default)."""
|
|
44
|
+
if flag("CHAD_NO_SESSION_LOG"):
|
|
45
|
+
return False
|
|
46
|
+
return flag("CHAD_SESSION_LOG")
|
|
47
|
+
|
|
48
|
+
|
|
34
49
|
def env_str(name, default=None):
|
|
35
50
|
"""The env value if set to a non-empty string, else `default`. Mirrors the common
|
|
36
51
|
`os.environ.get(name) or <fallback>` idiom (empty string collapses to the default)."""
|
|
@@ -9,8 +9,10 @@ declare done, which cache served the turn. This module is that trace.
|
|
|
9
9
|
|
|
10
10
|
The log is bounded (5 MB x3 rotation) and previews pass through a best-effort secret
|
|
11
11
|
redactor, but it still records command/file previews in plaintext outside the repo, so
|
|
12
|
-
treat it as sensitive.
|
|
13
|
-
|
|
12
|
+
treat it as sensitive. For that reason it is **opt-in**: privacy-first, chad writes no
|
|
13
|
+
trace under ~/.chad unless CHAD_SESSION_LOG is set (see config.traces_enabled). None of
|
|
14
|
+
this touches the model-facing transcript or the tool results the model sees — it is the
|
|
15
|
+
diagnostic trace only.
|
|
14
16
|
"""
|
|
15
17
|
import json
|
|
16
18
|
import logging
|
|
@@ -22,11 +24,12 @@ from . import config
|
|
|
22
24
|
|
|
23
25
|
_LOG_DIR = os.path.expanduser("~/.chad")
|
|
24
26
|
log = logging.getLogger("chad")
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
|
|
27
|
+
# Privacy-first default: the diagnostic session log is OFF unless opted in. Set
|
|
28
|
+
# CHAD_SESSION_LOG (any truthy value) to enable it; CHAD_NO_SESSION_LOG still forces it
|
|
29
|
+
# off (see config.traces_enabled). When disabled we install a NullHandler (so the many
|
|
30
|
+
# log.info calls stay cheap no-ops and Python never warns about missing handlers) and
|
|
31
|
+
# never create ~/.chad for the log's sake.
|
|
32
|
+
_DISABLED = not config.traces_enabled()
|
|
30
33
|
if _DISABLED:
|
|
31
34
|
log.addHandler(logging.NullHandler())
|
|
32
35
|
log.propagate = False
|
|
@@ -1412,3 +1412,64 @@ def is_repeat_loop(seen_before: int) -> bool:
|
|
|
1412
1412
|
def loop_should_abort(loop_nudges: int) -> bool:
|
|
1413
1413
|
"""After incrementing the loop-nudge counter, more than 2 nudges -> abort."""
|
|
1414
1414
|
return loop_nudges > 2
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
# --- iter-14: duplicate read-only result elision. Small models re-read files and
|
|
1418
|
+
# re-run searches they have already loaded; on a non-trimmable prefix cache every
|
|
1419
|
+
# duplicate body is appended prefill paid on every later step. Hosted harnesses solve
|
|
1420
|
+
# this with an opt-in changed-since parameter the model must remember to pass; here
|
|
1421
|
+
# the harness does it automatically. Byte-equality against the
|
|
1422
|
+
# LIVE transcript is the whole safety argument: a changed file, different args, or a
|
|
1423
|
+
# compaction rewrite/drop of the earlier message all break equality, so content is only
|
|
1424
|
+
# elided while a verbatim copy is provably still in the model's context.
|
|
1425
|
+
|
|
1426
|
+
DUP_ELIDE_MIN_CHARS = 400 # below this the pointer saves nothing over the body
|
|
1427
|
+
|
|
1428
|
+
# Read-only builtins whose output is a pure function of (args, workspace state). bash is
|
|
1429
|
+
# excluded even when read-only (env-dependent, side-effectful); task is excluded (a
|
|
1430
|
+
# sub-agent run is expensive but never byte-identical in spirit — and re-spawns already
|
|
1431
|
+
# have their own guard); MCP tools are excluded (openWorld results may legitimately
|
|
1432
|
+
# repeat, e.g. polling).
|
|
1433
|
+
DUP_ELIDABLE = {"read", "grep", "glob", "repo_map", "overview", "view_symbol",
|
|
1434
|
+
"find_symbol", "find_refs"}
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
def elide_duplicate_result(name, result, messages):
|
|
1438
|
+
"""The short replacement result when `result` is byte-identical to an earlier tool
|
|
1439
|
+
message still in the transcript, else None. Compare AFTER clipping — the transcript
|
|
1440
|
+
stores clipped content, and equality must be against what the model actually has."""
|
|
1441
|
+
if not levers.enabled("dup_result_elide"):
|
|
1442
|
+
return None
|
|
1443
|
+
if name not in DUP_ELIDABLE or len(result) < DUP_ELIDE_MIN_CHARS:
|
|
1444
|
+
return None
|
|
1445
|
+
if not any(m.get("role") == "tool" and m.get("name") == name
|
|
1446
|
+
and m.get("content") == result for m in messages):
|
|
1447
|
+
return None
|
|
1448
|
+
return (f"[identical output elided: this {name} returned exactly the same result "
|
|
1449
|
+
f"as your earlier {name} above — nothing has changed. That content is "
|
|
1450
|
+
f"still in your context; use it from there instead of re-running the "
|
|
1451
|
+
f"call.]")
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
# The zero-evidence sub-agent tell (iter-14): a confident, non-empty report produced
|
|
1455
|
+
# with no tool dispatches came from model memory, not the repo. Warn-not-reject: a
|
|
1456
|
+
# local re-spawn doubles GPU cost, a false reject breaks the turn, and a false accept
|
|
1457
|
+
# merely restores the pre-warning status quo.
|
|
1458
|
+
|
|
1459
|
+
SUBAGENT_EVIDENCE_WARNING = (
|
|
1460
|
+
"\n[warning: the sub-agent answered WITHOUT reading any files or running any "
|
|
1461
|
+
"searches — this report is from model memory, not this repository. Verify the "
|
|
1462
|
+
"claims with grep/read before relying on them.]")
|
|
1463
|
+
|
|
1464
|
+
|
|
1465
|
+
def subagent_evidence_warning(result, tool_dispatches):
|
|
1466
|
+
"""The result with the zero-evidence warning appended, or None when no warning is
|
|
1467
|
+
due (the sub-agent did real work, the result is already a failure sentinel the
|
|
1468
|
+
salvage path owns, or the lever is off)."""
|
|
1469
|
+
if not levers.enabled("subagent_evidence_warn"):
|
|
1470
|
+
return None
|
|
1471
|
+
if tool_dispatches > 0:
|
|
1472
|
+
return None
|
|
1473
|
+
if not result or not result.strip() or result.startswith(("[task", "[stopped:")):
|
|
1474
|
+
return None
|
|
1475
|
+
return result.rstrip() + SUBAGENT_EVIDENCE_WARNING
|
|
@@ -309,6 +309,35 @@ LEVERS: dict[str, Lever] = {
|
|
|
309
309
|
"harassing ops-heavy tasks that legitimately run many non-read commands.",
|
|
310
310
|
"iter13"),
|
|
311
311
|
|
|
312
|
+
# --- iter-14: tool-result economics (2026-07). Two ideas from a teardown of a
|
|
313
|
+
# token-optimizing agent harness: normalize typographic unicode when matching
|
|
314
|
+
# edits, and never re-send file content the model provably already has; both
|
|
315
|
+
# map cleanly onto a prefill-dominated local loop. ---------------------------
|
|
316
|
+
"edit_typo_match": Lever(
|
|
317
|
+
"Fourth edit-match rung: when exact / escape-normalized / whitespace-flexible "
|
|
318
|
+
"matching all miss, retry the whitespace-flexible match with typographic "
|
|
319
|
+
"punctuation folded to ASCII on BOTH sides (curly quotes, en/em dashes, "
|
|
320
|
+
"ellipsis, non-breaking space) — the drift when a model re-types prose or "
|
|
321
|
+
"docstrings it saw rendered. Still requires a unique match; never fires on "
|
|
322
|
+
"code that matched an earlier rung. OFF loses only the recovery.",
|
|
323
|
+
"iter14"),
|
|
324
|
+
"subagent_evidence_warn": Lever(
|
|
325
|
+
"A sub-agent that returns a confident, non-empty report having dispatched ZERO "
|
|
326
|
+
"tools answered from model memory, not the repo; its folded result gets an "
|
|
327
|
+
"explicit verify-before-relying warning appended. Warn-not-reject on purpose: "
|
|
328
|
+
"a local re-spawn doubles GPU cost, and a false reject breaks the turn while a "
|
|
329
|
+
"false accept merely restores the pre-warning status quo. OFF folds the "
|
|
330
|
+
"unverified report back silently.",
|
|
331
|
+
"iter14"),
|
|
332
|
+
"dup_result_elide": Lever(
|
|
333
|
+
"A read-only tool result byte-identical to a tool message still in the "
|
|
334
|
+
"transcript is replaced with a short pointer to the earlier copy instead of "
|
|
335
|
+
"re-appending the full body to the prefill. Equality against the LIVE "
|
|
336
|
+
"transcript is the safety proof: compaction rewrites or drops old messages, "
|
|
337
|
+
"which breaks equality, so content is only elided while it is genuinely "
|
|
338
|
+
"still in context. OFF re-appends duplicate output verbatim.",
|
|
339
|
+
"iter14"),
|
|
340
|
+
|
|
312
341
|
# --- from the LangChain harness-tuning playbook. -------------------------------
|
|
313
342
|
"compact_notice": Lever(
|
|
314
343
|
"After compaction, inject an in-band message telling the model its context was "
|
|
@@ -326,6 +355,14 @@ LEVERS: dict[str, Lever] = {
|
|
|
326
355
|
"Append the active model profile's prompt block to the system prompt "
|
|
327
356
|
"(model-specific accommodations; see profiles.py).",
|
|
328
357
|
"playbook"),
|
|
358
|
+
|
|
359
|
+
# --- from the ai-codex teardown: an index the model already has beats one it must
|
|
360
|
+
# fetch. chad had the ranked map but only as a tool; this puts a digest in-prompt.
|
|
361
|
+
"workspace_map": Lever(
|
|
362
|
+
"Inject a ranked repo_map digest into the system-prompt dynamic tail instead of "
|
|
363
|
+
"a flat file listing, so the model orients without a reflexive step-1 repo_map "
|
|
364
|
+
"call. Degrades to the flat listing when repomap is unavailable (see prompt.py).",
|
|
365
|
+
"ai-codex"),
|
|
329
366
|
}
|
|
330
367
|
|
|
331
368
|
|
|
@@ -209,12 +209,20 @@ def _dynamic_context() -> list:
|
|
|
209
209
|
f"- Shell: {os.environ.get('SHELL', 'unknown')}",
|
|
210
210
|
f"- Working directory: {os.getcwd()}",
|
|
211
211
|
]
|
|
212
|
-
|
|
213
|
-
if
|
|
212
|
+
ranked = _workspace_map()
|
|
213
|
+
if ranked:
|
|
214
214
|
dynamic.append(
|
|
215
|
-
"\n# Workspace
|
|
216
|
-
|
|
215
|
+
"\n# Workspace map (ranked by reference centrality; signatures only — "
|
|
216
|
+
"call repo_map for a wider/focused map, view_symbol/read for bodies)\n"
|
|
217
|
+
+ ranked
|
|
217
218
|
)
|
|
219
|
+
else:
|
|
220
|
+
snapshot = _workspace_snapshot()
|
|
221
|
+
if snapshot:
|
|
222
|
+
dynamic.append(
|
|
223
|
+
"\n# Workspace files (a real project — use grep/read to inspect before answering)\n"
|
|
224
|
+
+ snapshot
|
|
225
|
+
)
|
|
218
226
|
test_cmd = _detect_test_command()
|
|
219
227
|
if test_cmd:
|
|
220
228
|
dynamic.append(
|
|
@@ -334,6 +342,30 @@ def _detect_test_command() -> str:
|
|
|
334
342
|
return ""
|
|
335
343
|
|
|
336
344
|
|
|
345
|
+
def _workspace_map(budget: int = 600) -> str:
|
|
346
|
+
"""A small, ranked repo_map digest for the system-prompt tail, so the model orients
|
|
347
|
+
structurally at session start instead of burning a reflexive step-1 repo_map call
|
|
348
|
+
(chad already pays for the ranked index; the tool stays for a wider/focused map).
|
|
349
|
+
|
|
350
|
+
Built once per session (build_system_prompt runs once in Agent.__init__) and reuses
|
|
351
|
+
the on-disk mtime cache, so it doesn't churn the KV prefix or add per-turn cost.
|
|
352
|
+
Returns "" on any failure so _dynamic_context falls back to the flat file listing —
|
|
353
|
+
keeping behavior identical on wheel-less platforms where repomap degrades to empty."""
|
|
354
|
+
from . import levers
|
|
355
|
+
if not levers.enabled("workspace_map"):
|
|
356
|
+
return ""
|
|
357
|
+
try:
|
|
358
|
+
from . import repomap
|
|
359
|
+
digest = repomap.service().repo_map(budget_tokens=budget)
|
|
360
|
+
except Exception: # noqa: BLE001 - orientation is best-effort; degrade to snapshot
|
|
361
|
+
return ""
|
|
362
|
+
# repo_map's non-map sentinels ("[no source files found]", "[interrupted]") and any
|
|
363
|
+
# empty/whitespace result mean "no usable map" — fall back rather than inject noise.
|
|
364
|
+
if not digest or not digest.strip() or digest.strip().startswith("["):
|
|
365
|
+
return ""
|
|
366
|
+
return digest
|
|
367
|
+
|
|
368
|
+
|
|
337
369
|
def _workspace_snapshot(limit: int = 60) -> str:
|
|
338
370
|
"""A short listing of the project's code files, so the model knows it's working
|
|
339
371
|
in a real repo (Claude Code injects similar context). Prefers git-tracked files."""
|
|
@@ -382,10 +382,30 @@ def _line_offsets(data: str):
|
|
|
382
382
|
return offs
|
|
383
383
|
|
|
384
384
|
|
|
385
|
-
|
|
385
|
+
# Typographic punctuation folded to ASCII for the last-resort edit match (iter-14).
|
|
386
|
+
# Only characters a model plausibly re-types the other way when
|
|
387
|
+
# quoting prose/docstrings it saw rendered: quotes, dashes, ellipsis, nbsp. Deliberately
|
|
388
|
+
# NOT general unicode normalization — identifiers and string literals in code must not
|
|
389
|
+
# be conflated beyond this list.
|
|
390
|
+
_TYPO_MAP = str.maketrans({
|
|
391
|
+
"‘": "'", "’": "'", "‚": "'", "‛": "'", # single quotes
|
|
392
|
+
"“": '"', "”": '"', "„": '"', # double quotes
|
|
393
|
+
"–": "-", "—": "-", "―": "-", # en/em/horizontal dash
|
|
394
|
+
" ": " ", # non-breaking space
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _norm_typo(s: str) -> str:
|
|
399
|
+
return s.translate(_TYPO_MAP).replace("…", "...")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _ws_flexible_spans(data: str, old: str, typo: bool = False):
|
|
386
403
|
"""Char spans (start, end) where `old` matches a run of lines in `data` ignoring
|
|
387
|
-
each line's leading/trailing whitespace. Skips all-blank patterns (too ambiguous).
|
|
388
|
-
|
|
404
|
+
each line's leading/trailing whitespace. Skips all-blank patterns (too ambiguous).
|
|
405
|
+
With `typo=True`, additionally folds typographic punctuation to ASCII on both
|
|
406
|
+
sides before comparing (the match is still line-exact otherwise)."""
|
|
407
|
+
fold = (lambda l: _norm_typo(l).strip()) if typo else (lambda l: l.strip())
|
|
408
|
+
norm = [fold(l) for l in old.strip("\n").split("\n")]
|
|
389
409
|
if not any(norm):
|
|
390
410
|
return []
|
|
391
411
|
dlines = data.split("\n")
|
|
@@ -393,7 +413,7 @@ def _ws_flexible_spans(data: str, old: str):
|
|
|
393
413
|
n = len(norm)
|
|
394
414
|
spans = []
|
|
395
415
|
for i in range(len(dlines) - n + 1):
|
|
396
|
-
if [dlines[i + j]
|
|
416
|
+
if [fold(dlines[i + j]) for j in range(n)] == norm:
|
|
397
417
|
spans.append((offs[i], offs[i + n - 1] + len(dlines[i + n - 1])))
|
|
398
418
|
return spans
|
|
399
419
|
|
|
@@ -647,8 +667,16 @@ def tool_edit(path: str, old: str, new: str) -> str:
|
|
|
647
667
|
return f"[old string appears {c} times; make it unique by including more surrounding lines]"
|
|
648
668
|
|
|
649
669
|
# (3) whitespace-flexible: indentation / trailing-space drift, still requiring uniqueness.
|
|
670
|
+
# (4) typography-normalized: same match with curly quotes / en–em dashes / ellipsis /
|
|
671
|
+
# nbsp folded to ASCII on both sides — the drift when the model re-types prose it
|
|
672
|
+
# saw rendered (or the file uses typographic punctuation the model ASCII-fied).
|
|
673
|
+
# Tried only when rung 3 found nothing, and still requires a unique match.
|
|
650
674
|
probe = uold if uold != old else old
|
|
651
675
|
spans = _ws_flexible_spans(data, probe)
|
|
676
|
+
how = "indentation/whitespace"
|
|
677
|
+
if not spans and levers.enabled("edit_typo_match"):
|
|
678
|
+
spans = _ws_flexible_spans(data, probe, typo=True)
|
|
679
|
+
how = "typographic quotes/dashes and whitespace"
|
|
652
680
|
if len(spans) == 1:
|
|
653
681
|
s, e = spans[0]
|
|
654
682
|
head = data[s:e].split("\n")[0]
|
|
@@ -669,7 +697,7 @@ def tool_edit(path: str, old: str, new: str) -> str:
|
|
|
669
697
|
landed = raw
|
|
670
698
|
else:
|
|
671
699
|
res = _apply_edit(path, data, data[:s] + repl + data[e:],
|
|
672
|
-
" (recovered: matched ignoring
|
|
700
|
+
f" (recovered: matched ignoring {how})"
|
|
673
701
|
+ (note_new if used_unew else ""))
|
|
674
702
|
landed = repl
|
|
675
703
|
# Echo the landed region with visible whitespace (ky-timeoutMessage, session
|
|
@@ -678,7 +706,7 @@ def tool_edit(path: str, old: str, new: str) -> str:
|
|
|
678
706
|
# `_apply_edit` can return a syntaxgate rejection or drift warning instead.
|
|
679
707
|
return res + _landed_hint(landed) if res.startswith("[edited") else res
|
|
680
708
|
if len(spans) > 1:
|
|
681
|
-
return (f"[old string matches {len(spans)} places ignoring
|
|
709
|
+
return (f"[old string matches {len(spans)} places ignoring {how}; include "
|
|
682
710
|
f"more surrounding lines to make it unique]")
|
|
683
711
|
|
|
684
712
|
return (f"[old string not found; no change made.{_closest_hint(data, old)}]"
|
|
@@ -239,9 +239,10 @@ class _ChadCompleter(Completer):
|
|
|
239
239
|
|
|
240
240
|
def _make_history():
|
|
241
241
|
"""Persistent input history: `FileHistory` at ~/.chad/history (mode 0600 — it can
|
|
242
|
-
hold typed paths/snippets, like the session store), or `InMemoryHistory` when
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
hold typed paths/snippets, like the session store), or `InMemoryHistory` when local
|
|
243
|
+
traces are not opted in (see config.traces_enabled). Falls back to in-memory on any
|
|
244
|
+
filesystem error."""
|
|
245
|
+
if not config.traces_enabled():
|
|
245
246
|
return InMemoryHistory()
|
|
246
247
|
path = os.path.expanduser("~/.chad/history")
|
|
247
248
|
try:
|
|
@@ -1163,3 +1163,66 @@ if __name__ == "__main__":
|
|
|
1163
1163
|
test_classify_sync_kind()
|
|
1164
1164
|
print(f"\n{PASS} passed, {FAIL} failed")
|
|
1165
1165
|
raise SystemExit(1 if FAIL else 0)
|
|
1166
|
+
|
|
1167
|
+
|
|
1168
|
+
def test_dup_result_elide():
|
|
1169
|
+
# iter-14: a read-only result byte-identical to a tool message
|
|
1170
|
+
# still in the transcript is elided to a short pointer; anything that breaks
|
|
1171
|
+
# byte-equality (changed file, different args, compaction rewrite) flows through.
|
|
1172
|
+
from chad.guardrails import DUP_ELIDE_MIN_CHARS, elide_duplicate_result
|
|
1173
|
+
body = "1 line\n" * 200 # comfortably over the threshold
|
|
1174
|
+
check("dup: body over threshold", len(body) >= DUP_ELIDE_MIN_CHARS)
|
|
1175
|
+
msgs = [{"role": "tool", "name": "read", "content": body}]
|
|
1176
|
+
hit = elide_duplicate_result("read", body, msgs)
|
|
1177
|
+
check("dup: identical read elided", hit is not None and "elided" in hit, hit)
|
|
1178
|
+
check("dup: pointer is short", hit is not None and len(hit) < 300, hit)
|
|
1179
|
+
check("dup: changed content flows through",
|
|
1180
|
+
elide_duplicate_result("read", body + "x", msgs) is None)
|
|
1181
|
+
check("dup: name mismatch flows through",
|
|
1182
|
+
elide_duplicate_result("grep", body, msgs) is None)
|
|
1183
|
+
check("dup: bash never elided",
|
|
1184
|
+
elide_duplicate_result(
|
|
1185
|
+
"bash", body, [{"role": "tool", "name": "bash", "content": body}]) is None)
|
|
1186
|
+
check("dup: short result flows through",
|
|
1187
|
+
elide_duplicate_result(
|
|
1188
|
+
"read", "[empty]", [{"role": "tool", "name": "read", "content": "[empty]"}]) is None)
|
|
1189
|
+
# a compaction rewrite (head/tail collapse) breaks equality -> full result again
|
|
1190
|
+
collapsed = body[:100] + "\n[...collapsed...]\n" + body[-50:]
|
|
1191
|
+
check("dup: collapsed prior flows through",
|
|
1192
|
+
elide_duplicate_result(
|
|
1193
|
+
"read", body, [{"role": "tool", "name": "read", "content": collapsed}]) is None)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
def test_dup_result_elide_lever_off(monkeypatch):
|
|
1197
|
+
from chad.guardrails import elide_duplicate_result
|
|
1198
|
+
monkeypatch.setenv("CHAD_DISABLE", "dup_result_elide")
|
|
1199
|
+
body = "y" * 500
|
|
1200
|
+
check("dup lever off: no elision",
|
|
1201
|
+
elide_duplicate_result(
|
|
1202
|
+
"read", body, [{"role": "tool", "name": "read", "content": body}]) is None)
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def test_subagent_evidence_warning():
|
|
1206
|
+
# iter-14: a confident report with zero tool dispatches is the answered-from-memory
|
|
1207
|
+
# tell; it gets a verify warning appended. Real work, failure sentinels (the salvage
|
|
1208
|
+
# path owns those), and empty results are left alone.
|
|
1209
|
+
from chad.guardrails import SUBAGENT_EVIDENCE_WARNING, subagent_evidence_warning
|
|
1210
|
+
report = "The bug is in utils.py line 40: off-by-one in the loop bound."
|
|
1211
|
+
warned = subagent_evidence_warning(report, 0)
|
|
1212
|
+
check("evidence: zero-dispatch report is warned",
|
|
1213
|
+
warned == report + SUBAGENT_EVIDENCE_WARNING, warned)
|
|
1214
|
+
check("evidence: real work passes clean",
|
|
1215
|
+
subagent_evidence_warning(report, 3) is None)
|
|
1216
|
+
check("evidence: failure sentinel untouched",
|
|
1217
|
+
subagent_evidence_warning("[task returned nothing]", 0) is None)
|
|
1218
|
+
check("evidence: stopped sentinel untouched",
|
|
1219
|
+
subagent_evidence_warning("[stopped: loop]", 0) is None)
|
|
1220
|
+
check("evidence: empty result untouched",
|
|
1221
|
+
subagent_evidence_warning(" ", 0) is None)
|
|
1222
|
+
|
|
1223
|
+
|
|
1224
|
+
def test_subagent_evidence_warning_lever_off(monkeypatch):
|
|
1225
|
+
from chad.guardrails import subagent_evidence_warning
|
|
1226
|
+
monkeypatch.setenv("CHAD_DISABLE", "subagent_evidence_warn")
|
|
1227
|
+
check("evidence lever off: no warning",
|
|
1228
|
+
subagent_evidence_warning("confident memory answer", 0) is None)
|
|
@@ -245,3 +245,38 @@ if __name__ == "__main__":
|
|
|
245
245
|
test_ws_only_edit_result_echoes_landed_indentation()
|
|
246
246
|
print(f"\n{PASS} passed, {FAIL} failed")
|
|
247
247
|
raise SystemExit(1 if FAIL else 0)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def test_typo_recovery_file_has_unicode_model_sends_ascii():
|
|
251
|
+
# iter-14: file uses typographic punctuation, the model
|
|
252
|
+
# re-types it ASCII-fied. Rungs 1-3 miss; the typo-normalized rung must land.
|
|
253
|
+
before = 'MSG = "cache — warm start"\nprint(MSG)\n'
|
|
254
|
+
res, after = run(before, 'MSG = "cache - warm start"', 'MSG = "cache - hot start"')
|
|
255
|
+
check("typo(ascii old): edit landed", res.startswith("[edited"), res)
|
|
256
|
+
check("typo(ascii old): note names the rung", "typographic" in res, res)
|
|
257
|
+
check("typo(ascii old): new text is verbatim", 'MSG = "cache - hot start"' in after, after)
|
|
258
|
+
check("typo(ascii old): rest untouched", "print(MSG)" in after, after)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def test_typo_recovery_model_sends_unicode_file_has_ascii():
|
|
262
|
+
# Opposite direction: the model quotes with curly punctuation, file is ASCII.
|
|
263
|
+
before = "note = 'it is fine...'\nx = 1\n"
|
|
264
|
+
res, after = run(before, "note = ‘it is fine…’", "note = 'all good...'")
|
|
265
|
+
check("typo(unicode old): edit landed", res.startswith("[edited"), res)
|
|
266
|
+
check("typo(unicode old): replacement present", "note = 'all good...'" in after, after)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def test_typo_recovery_requires_unique_match():
|
|
270
|
+
# SAFETY: two lines that both match after folding must be rejected, file untouched.
|
|
271
|
+
before = 'a = "x — y"\nb = "x — y"\n'
|
|
272
|
+
res, after = run(before, '"x - y"', '"z"')
|
|
273
|
+
check("typo ambiguous: rejected", not res.startswith("[edited"), res)
|
|
274
|
+
check("typo ambiguous: file untouched", after == before, after)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_typo_recovery_lever_off(monkeypatch):
|
|
278
|
+
monkeypatch.setenv("CHAD_DISABLE", "edit_typo_match")
|
|
279
|
+
before = 'MSG = "cache — warm start"\nprint(MSG)\n'
|
|
280
|
+
res, after = run(before, 'MSG = "cache - warm start"', 'MSG = "x"')
|
|
281
|
+
check("typo lever off: not found", "not found" in res, res)
|
|
282
|
+
check("typo lever off: file untouched", after == before, after)
|
|
@@ -5,8 +5,8 @@ pin the *logic*:
|
|
|
5
5
|
|
|
6
6
|
* item 2 — slash-command matching, `@`-token extraction, and IGNORE_DIRS-aware path
|
|
7
7
|
completion (the completer itself just forwards to these);
|
|
8
|
-
* item 3 — FileHistory at ~/.chad/history is created 0600
|
|
9
|
-
|
|
8
|
+
* item 3 — FileHistory at ~/.chad/history is created 0600 only when opted in
|
|
9
|
+
(CHAD_SESSION_LOG); off by default and under CHAD_NO_SESSION_LOG it stays in-memory;
|
|
10
10
|
* item 4 — pygments highlighting is byte-identical to the plain path when the import is
|
|
11
11
|
unavailable (monkeypatched off) and adds color when present, with the +/- diff
|
|
12
12
|
coloring left as the outer layer.
|
|
@@ -68,8 +68,11 @@ def test_path_matches_respects_ignore_dirs(tmp_path):
|
|
|
68
68
|
|
|
69
69
|
# -- item 3: persistent history ---------------------------------------------
|
|
70
70
|
|
|
71
|
-
def
|
|
71
|
+
def test_file_history_opt_in_is_created_0600(tmp_path, monkeypatch):
|
|
72
|
+
# Persistent history is a local trace, so it follows the same opt-in gate as the
|
|
73
|
+
# session log: only written when CHAD_SESSION_LOG is set.
|
|
72
74
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
75
|
+
monkeypatch.setenv("CHAD_SESSION_LOG", "1")
|
|
73
76
|
monkeypatch.delenv("CHAD_NO_SESSION_LOG", raising=False)
|
|
74
77
|
h = _make_history()
|
|
75
78
|
p = tmp_path / ".chad" / "history"
|
|
@@ -78,8 +81,21 @@ def test_file_history_is_created_0600(tmp_path, monkeypatch):
|
|
|
78
81
|
assert stat.S_IMODE(os.stat(p).st_mode) == 0o600
|
|
79
82
|
|
|
80
83
|
|
|
81
|
-
def
|
|
84
|
+
def test_history_off_by_default_is_in_memory(tmp_path, monkeypatch):
|
|
85
|
+
# Privacy-first default: with no opt-in, history stays in memory and ~/.chad/history
|
|
86
|
+
# is never written.
|
|
82
87
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
88
|
+
monkeypatch.delenv("CHAD_SESSION_LOG", raising=False)
|
|
89
|
+
monkeypatch.delenv("CHAD_NO_SESSION_LOG", raising=False)
|
|
90
|
+
h = _make_history()
|
|
91
|
+
assert type(h).__name__ == "InMemoryHistory"
|
|
92
|
+
assert not (tmp_path / ".chad" / "history").exists()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_history_no_session_log_overrides_opt_in(tmp_path, monkeypatch):
|
|
96
|
+
# The hard kill switch wins even when the opt-in is set.
|
|
97
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
98
|
+
monkeypatch.setenv("CHAD_SESSION_LOG", "1")
|
|
83
99
|
monkeypatch.setenv("CHAD_NO_SESSION_LOG", "1")
|
|
84
100
|
h = _make_history()
|
|
85
101
|
assert type(h).__name__ == "InMemoryHistory"
|
|
@@ -674,6 +674,55 @@ def test_playbook_levers_have_dedicated_suites(monkeypatch):
|
|
|
674
674
|
assert profiles.prompt_block(None) == ""
|
|
675
675
|
|
|
676
676
|
|
|
677
|
+
# === iter-14 ===============================================================
|
|
678
|
+
|
|
679
|
+
def test_edit_typo_match_bite(monkeypatch, tmp_path):
|
|
680
|
+
"""ASCII-fied `old` against a typographic file lands only while the rung is on."""
|
|
681
|
+
n = bite("edit_typo_match")
|
|
682
|
+
src = 'MSG = "cache — warm"\n'
|
|
683
|
+
p = tmp_path / "f.py"
|
|
684
|
+
on(monkeypatch)
|
|
685
|
+
p.write_text(src)
|
|
686
|
+
assert tools.tool_edit(
|
|
687
|
+
str(p), 'MSG = "cache - warm"', 'MSG = "cache - hot"').startswith("[edited")
|
|
688
|
+
off(monkeypatch, n)
|
|
689
|
+
p.write_text(src)
|
|
690
|
+
assert "not found" in tools.tool_edit(
|
|
691
|
+
str(p), 'MSG = "cache - warm"', 'MSG = "cache - hot"')
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def test_dup_result_elide_bite(monkeypatch):
|
|
695
|
+
"""An identical read-only result is elided only while the lever is on."""
|
|
696
|
+
n = bite("dup_result_elide")
|
|
697
|
+
body = "z" * 500
|
|
698
|
+
msgs = [{"role": "tool", "name": "read", "content": body}]
|
|
699
|
+
on(monkeypatch)
|
|
700
|
+
assert guardrails.elide_duplicate_result("read", body, msgs) is not None
|
|
701
|
+
off(monkeypatch, n)
|
|
702
|
+
assert guardrails.elide_duplicate_result("read", body, msgs) is None
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def test_subagent_evidence_warn_bite(monkeypatch):
|
|
706
|
+
"""A zero-dispatch confident report is warned only while the lever is on."""
|
|
707
|
+
n = bite("subagent_evidence_warn")
|
|
708
|
+
on(monkeypatch)
|
|
709
|
+
assert guardrails.subagent_evidence_warning("found it in a.py:1", 0) is not None
|
|
710
|
+
off(monkeypatch, n)
|
|
711
|
+
assert guardrails.subagent_evidence_warning("found it in a.py:1", 0) is None
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def test_workspace_map_bite(monkeypatch):
|
|
715
|
+
"""The system prompt carries the ranked repo_map digest only while the lever is on;
|
|
716
|
+
off, it falls back to the flat file listing."""
|
|
717
|
+
from chad import prompt
|
|
718
|
+
n = bite("workspace_map")
|
|
719
|
+
on(monkeypatch)
|
|
720
|
+
assert "# Workspace map" in prompt.build_system_prompt("ornith")
|
|
721
|
+
off(monkeypatch, n)
|
|
722
|
+
p = prompt.build_system_prompt("ornith")
|
|
723
|
+
assert "# Workspace map" not in p and "# Workspace files" in p
|
|
724
|
+
|
|
725
|
+
|
|
677
726
|
# === the coverage contract =================================================
|
|
678
727
|
|
|
679
728
|
def test_every_registered_lever_has_a_bite_test():
|
|
@@ -77,30 +77,55 @@ def test_leaves_normal_text_unchanged():
|
|
|
77
77
|
check(f"unchanged: {normal!r}", _redact(normal) == normal)
|
|
78
78
|
|
|
79
79
|
|
|
80
|
-
def
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
#
|
|
84
|
-
#
|
|
80
|
+
def _diag_handler_names(**env_overrides):
|
|
81
|
+
# diag installs its handler at import time, so probe in a fresh subprocess (reload
|
|
82
|
+
# would leave the first import's RotatingFileHandler attached to the shared named
|
|
83
|
+
# logger). Returns the list of handler type names on diag.log, and asserts redact/
|
|
84
|
+
# args_preview still work regardless of whether the file handler is installed.
|
|
85
85
|
import os
|
|
86
86
|
import subprocess
|
|
87
87
|
import sys
|
|
88
|
+
import tempfile
|
|
88
89
|
|
|
89
90
|
script = (
|
|
90
91
|
"from chad import diag\n"
|
|
91
|
-
"import logging\n"
|
|
92
92
|
"diag.log.info('x')\n"
|
|
93
93
|
"names = [type(h).__name__ for h in diag.log.handlers]\n"
|
|
94
|
-
"assert names == ['NullHandler'], names\n"
|
|
95
94
|
"assert diag.redact('a' * 32) == '<redacted:32>'\n"
|
|
96
95
|
"assert diag.args_preview({'k': 'v'})\n"
|
|
97
|
-
"print('
|
|
96
|
+
"print('NAMES', names)\n"
|
|
98
97
|
)
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
98
|
+
# Point HOME at a throwaway dir so an *enabled* run can't touch the real ~/.chad.
|
|
99
|
+
with tempfile.TemporaryDirectory() as home:
|
|
100
|
+
env = dict(os.environ, HOME=home)
|
|
101
|
+
for k in ("CHAD_SESSION_LOG", "CHAD_NO_SESSION_LOG"):
|
|
102
|
+
env.pop(k, None)
|
|
103
|
+
env.update(env_overrides)
|
|
104
|
+
out = subprocess.run([sys.executable, "-c", script], env=env,
|
|
105
|
+
capture_output=True, text=True)
|
|
106
|
+
check(f"subprocess OK (stderr={out.stderr})", out.returncode == 0)
|
|
107
|
+
line = next(ln for ln in out.stdout.splitlines() if ln.startswith("NAMES "))
|
|
108
|
+
import ast
|
|
109
|
+
return ast.literal_eval(line[len("NAMES "):])
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_session_log_off_by_default():
|
|
113
|
+
# Privacy-first: with neither env var set, the diagnostic file log is OFF — only a
|
|
114
|
+
# NullHandler is attached and ~/.chad is never created for the log's sake.
|
|
115
|
+
check("default off → only NullHandler", _diag_handler_names() == ["NullHandler"])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_session_log_opt_in():
|
|
119
|
+
# CHAD_SESSION_LOG opts in: the RotatingFileHandler is installed.
|
|
120
|
+
names = _diag_handler_names(CHAD_SESSION_LOG="1")
|
|
121
|
+
check(f"opt-in → RotatingFileHandler attached ({names})",
|
|
122
|
+
"RotatingFileHandler" in names)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_no_session_log_overrides_opt_in():
|
|
126
|
+
# CHAD_NO_SESSION_LOG is a hard kill switch: it wins even when the opt-in is also set.
|
|
127
|
+
names = _diag_handler_names(CHAD_SESSION_LOG="1", CHAD_NO_SESSION_LOG="1")
|
|
128
|
+
check(f"force-off wins over opt-in → NullHandler ({names})", names == ["NullHandler"])
|
|
104
129
|
|
|
105
130
|
|
|
106
131
|
if __name__ == "__main__":
|
|
@@ -345,7 +345,14 @@ def test_normal_parent_never_spawns_mutating_subagent(monkeypatch):
|
|
|
345
345
|
default read-only request stays silent. run_turn is stubbed class-level so no model
|
|
346
346
|
or real sub-agent turn is needed."""
|
|
347
347
|
from chad.agent import Agent
|
|
348
|
-
|
|
348
|
+
|
|
349
|
+
def _stub_run_turn(self, prompt, stream=True):
|
|
350
|
+
# Simulate a sub-agent that did real work: without a dispatch the (on-topic
|
|
351
|
+
# elsewhere) zero-evidence warning would append to "ok" and muddy this check.
|
|
352
|
+
self.tool_dispatches += 1
|
|
353
|
+
return "ok"
|
|
354
|
+
|
|
355
|
+
monkeypatch.setattr(Agent, "run_turn", _stub_run_turn)
|
|
349
356
|
agent = _mk_agent(mode="normal")
|
|
350
357
|
agent.engine.push_cache = lambda: None
|
|
351
358
|
agent.engine.pop_cache = lambda: None
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|