From ad778f4e617c91e743332236db993d0fd63513aa Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Mon, 13 Jul 2026 00:56:55 -0700 Subject: [PATCH] =?UTF-8?q?feat(eco):=20/eco=20=E2=80=94=20RTK-style=20tok?= =?UTF-8?q?en=20compression=20for=20Bash=20tool=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the token-compression methods from the RTK reference analysis (my-docs/token-compression/RTK/) into clawcodex, toggled per session with a new /eco slash command. When on, the model-bound rendering of Bash results is compressed by deterministic filters; the raw output stays recoverable. New src/eco/ package (imports nothing from tool_system): - filters.py — pure (command, exit_code, text) -> FilterHit|None filters: test-runner failure focus (pytest/cargo/go/jest native output; all-green runs collapse to one line, failures kept verbatim with relevant-line selection, capped at 10), command-family-scoped noise stripping (git advice/progress, pip/npm/cargo/docker/apt/brew ceremony), log dedup with [xN] counts over normalized lines, and a recoverable head cap for >400-line success outputs. - engine.py — first-hit dispatch with RTK's safety invariants: never_worse guard against the exact baseline the mapper would emit, lossy hits require a tee file + recovery hint or are discarded (guard pre-check avoids orphan files), 10 MiB input cap, any exception -> passthrough. - tee.py — per-session raw recovery files (co-located with the Step-11 tool-results dir) with collision-proof names (time_ns+pid+counter+slug), 1 MiB UTF-8-boundary-safe cap, keep-newest-50 rotation, and runnable hints: [full output: ...] / [see remaining: tail -n +N ...]. - guard.py / state.py — chars/4 estimator, never_worse, process-global session toggle + honest savings stats (passthroughs record nothing). Wiring: bash_tool computes eco on the full pre-truncation output and stores ecoContent (wire content only; raw stdout/stderr fields untouched — note the TUI transcript renders the mapped content, so the user sees the same compact string); mapper honors it ahead of the stdout/stderr assembly, byte-identical when absent. /eco command (toggle/on/off/status with per-filter savings), agent-server "eco" control, TUI SLASHES entry + dispatch case. Excluded by construction: interrupted/timeout/background/cd/image paths, non-Bash tools. Exit codes and is_error are never altered. A green test summary with a non-zero exit is treated as untrusted and passed through. Evaluation on a live command corpus: -91% aggregate tokens (pytest -v -96%, failing pytest -74% with failures preserved, find -95%, repetitive logs -98%, small/compact outputs pass through untouched). 53 new tests; design + implementation critic-reviewed (B1 tee-collision blocker and M1-M3 majors fixed with regression tests). Co-Authored-By: Claude Fable 5 --- src/command_system/__init__.py | 3 + src/command_system/builtins.py | 2 + src/command_system/eco_command.py | 100 ++++ src/eco/__init__.py | 35 ++ src/eco/engine.py | 149 ++++++ src/eco/filters.py | 569 +++++++++++++++++++++ src/eco/guard.py | 32 ++ src/eco/state.py | 89 ++++ src/eco/tee.py | 126 +++++ src/server/agent_server.py | 36 ++ src/tool_system/tools/bash/bash_tool.py | 113 ++++- tests/test_bash_eco_integration.py | 159 ++++++ tests/test_eco.py | 635 ++++++++++++++++++++++++ ui-tui/src/gatewayClient.ts | 11 + 14 files changed, 2049 insertions(+), 10 deletions(-) create mode 100644 src/command_system/eco_command.py create mode 100644 src/eco/__init__.py create mode 100644 src/eco/engine.py create mode 100644 src/eco/filters.py create mode 100644 src/eco/guard.py create mode 100644 src/eco/state.py create mode 100644 src/eco/tee.py create mode 100644 tests/test_bash_eco_integration.py create mode 100644 tests/test_eco.py diff --git a/src/command_system/__init__.py b/src/command_system/__init__.py index a460c4612..ea3241d8b 100644 --- a/src/command_system/__init__.py +++ b/src/command_system/__init__.py @@ -76,6 +76,7 @@ from .output_style_command import OUTPUT_STYLE_COMMAND, OutputStyleCommand from .export_command import EXPORT_COMMAND, ExportCommand from .theme_command import THEME_COMMAND, ThemeCommand +from .eco_command import ECO_COMMAND, eco_command_call from .effort_command import EFFORT_COMMAND, EffortCommand from .model_command import MODEL_COMMAND, ModelCommand from .logo_command import LOGO_COMMAND, LogoCommand @@ -165,6 +166,8 @@ "ExportCommand", "THEME_COMMAND", "ThemeCommand", + "ECO_COMMAND", + "eco_command_call", "EFFORT_COMMAND", "EffortCommand", "MODEL_COMMAND", diff --git a/src/command_system/builtins.py b/src/command_system/builtins.py index d36d454ca..001c0ccb6 100644 --- a/src/command_system/builtins.py +++ b/src/command_system/builtins.py @@ -29,6 +29,7 @@ from .security_review import SECURITY_REVIEW_COMMAND from .statusline import STATUSLINE_COMMAND from .theme_command import THEME_COMMAND +from .eco_command import ECO_COMMAND from .effort_command import EFFORT_COMMAND from .model_command import MODEL_COMMAND from .logo_command import LOGO_COMMAND @@ -1331,6 +1332,7 @@ def get_builtin_commands() -> list[Command]: OUTPUT_STYLE_COMMAND, EXPORT_COMMAND, THEME_COMMAND, + ECO_COMMAND, EFFORT_COMMAND, MODEL_COMMAND, LOGO_COMMAND, diff --git a/src/command_system/eco_command.py b/src/command_system/eco_command.py new file mode 100644 index 000000000..79da5e5cf --- /dev/null +++ b/src/command_system/eco_command.py @@ -0,0 +1,100 @@ +"""/eco — toggle RTK-style token compression of Bash tool output. + +Session-scoped switch over :mod:`src.eco` (the same process-global session +state shape as ``/effort ultracode``). When on, the wire rendering of Bash +results is compressed by deterministic filters (test-runner failure focus, +noise stripping, log dedup, recoverable head caps) with the raw output teed +per session. The TUI transcript renders the same compact string the model +sees (there is no separate raw display path for Bash); the raw +stdout/stderr fields still ride the output dict and the tee files. Exit +codes and error semantics are untouched. + +Grammar: bare ``/eco`` toggles; ``on`` / ``off`` are explicit; +``status``/``stats`` reports the switch plus session savings. Unknown args → +usage. All paths are headless-safe (no UI surface needed). +""" + +from __future__ import annotations + +from .types import CommandContext, LocalCommand, LocalCommandResult + +_USAGE = ( + "Usage: /eco [on|off|status]\n\n" + "Compresses Bash tool output before it reaches the model (60-90% fewer\n" + "tokens on test runs, installs, and noisy logs). The transcript shows\n" + "the same compact rendering; whenever content is capped or summarized,\n" + "the full raw output is saved per session and referenced via\n" + "[full output: ...] hints, so nothing is unrecoverable." +) + +_ON_MSG = ( + "Eco mode on: Bash output is compressed before reaching the model " + "(test failures kept, noise stripped, long output capped) — the " + "transcript shows the same compact rendering. Capped or summarized " + "content is saved per session and linked via [full output: ...] hints. " + "/eco off to disable." +) + +_OFF_MSG = "Eco mode off: Bash output reaches the model unmodified." + + +def _status_text() -> str: + from src.eco import eco_stats, is_eco_session + + stats = eco_stats() + state = "on" if is_eco_session() else "off" + lines = [f"Eco mode: {state}"] + if stats.commands: + lines.append( + f" Compressed {stats.commands} command output(s): " + f"~{stats.baseline_tokens:,} → ~{stats.eco_tokens:,} tokens " + f"(saved ~{stats.saved_tokens:,}, {stats.savings_pct:.0f}%)" + ) + for name, (uses, saved) in sorted( + stats.by_filter.items(), key=lambda kv: -kv[1][1] + ): + lines.append(f" {name}: {uses} use(s), ~{saved:,} tokens saved") + else: + lines.append(" No compressions recorded this session yet.") + return "\n".join(lines) + + +def eco_command_call(args: str, context: CommandContext) -> LocalCommandResult: + """Handle /eco — toggle, explicit on/off, or status.""" + from src.eco import is_eco_session, set_eco_session + + arg = (args or "").strip().lower() + + if arg in ("status", "stats"): + return LocalCommandResult(type="text", value=_status_text()) + if arg in ("help", "-h", "--help"): + return LocalCommandResult(type="text", value=_USAGE) + + if arg == "": + target = not is_eco_session() + elif arg in ("on", "enable", "true", "1"): + target = True + elif arg in ("off", "disable", "false", "0"): + target = False + else: + return LocalCommandResult( + type="text", value=f"Unknown argument: {args.strip()}\n\n{_USAGE}" + ) + + set_eco_session(target) + if target: + return LocalCommandResult(type="text", value=_ON_MSG) + # Turning off keeps the stats (still reportable via /eco status). + return LocalCommandResult(type="text", value=f"{_OFF_MSG}\n{_status_text()}") + + +ECO_COMMAND = LocalCommand( + name="eco", + description="Toggle Bash-output token compression (RTK-style)", + argument_hint="[on|off|status]", + supports_non_interactive=True, +) +ECO_COMMAND.set_call(eco_command_call) + + +__all__ = ["ECO_COMMAND", "eco_command_call"] diff --git a/src/eco/__init__.py b/src/eco/__init__.py new file mode 100644 index 000000000..40cf093f9 --- /dev/null +++ b/src/eco/__init__.py @@ -0,0 +1,35 @@ +"""eco — RTK-style token compression for Bash tool output. + +Ported methods from the RTK reference analysis (my-docs/token-compression/RTK/): +deterministic, filter-based compression of the *model-bound* rendering of Bash +tool results, guarded so it can never make things worse and never lose data +unrecoverably. Toggled per session with the ``/eco`` slash command. + +Layering (mirrors RTK's core): pure string filters (:mod:`filters`), a +never-worse guard + chars/4 estimator (:mod:`guard`), raw-output tee recovery +(:mod:`tee`), session state + savings stats (:mod:`state`), and the dispatch +engine (:mod:`engine`). Nothing in this package imports tool_system — the Bash +tool calls in, not the other way around. +""" + +from .engine import EcoOutcome, compress_bash_output +from .guard import estimate_tokens, never_worse +from .state import ( + eco_stats, + is_eco_session, + record_compression, + reset_eco, + set_eco_session, +) + +__all__ = [ + "EcoOutcome", + "compress_bash_output", + "estimate_tokens", + "never_worse", + "eco_stats", + "is_eco_session", + "record_compression", + "reset_eco", + "set_eco_session", +] diff --git a/src/eco/engine.py b/src/eco/engine.py new file mode 100644 index 000000000..8e8fc2e09 --- /dev/null +++ b/src/eco/engine.py @@ -0,0 +1,149 @@ +"""Dispatch engine: raw Bash output → compressed model-bound rendering. + +Order of operations per command (RTK runner/emit_guarded ported to our +harness position): + +1. Normalize the *full* pre-truncation output (``\\r`` frames, ANSI) — this + processed text is both the filter input and the tee payload, so + ``tail -n +N`` hints line up exactly with what the filter counted. +2. First matching filter wins; no match → ``None`` (passthrough — the caller + ships its baseline untouched and nothing is recorded). +3. Loss accounting: ``safe_loss`` hits (pure-ceremony strips) ship as-is; + every other hit must tee the processed text and append a recovery hint — + tee unavailable → the hit is DISCARDED (RTK's never-lossy-without-recovery + rule, main.rs:1341). +4. ``never_worse`` against the exact baseline the mapper would otherwise + emit; baseline wins → passthrough. +5. Record savings (only real compressions — passthroughs never dilute stats). + +Any exception → passthrough (a compressor must never break the tool). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from pathlib import Path + +from .filters import FILTERS, normalize_carriage_returns, strip_ansi +from .guard import estimate_tokens, never_worse +from .state import record_compression +from .tee import full_hint, tail_hint, tee_raw + +logger = logging.getLogger(__name__) + +# A "compressed" rendering larger than this is a filter bug; the existing +# bash truncate_output contract stays intact above us. +_MAX_ECO_CHARS = 30_000 + +# Inputs beyond this skip eco entirely (RTK RAW_CAP spirit): the regex passes +# would be pure cost, and the Step-11 layer already gives +# giant results a preview + full-file pointer. +_MAX_INPUT_CHARS = 10_485_760 + +# Conservative allowance for a recovery-hint line when pre-checking the guard +# before writing the tee file (paths are ~60-120 chars → ~30 tokens). +_HINT_TOKEN_ALLOWANCE = 30 + + +@dataclass(frozen=True) +class EcoOutcome: + content: str + filter_name: str + baseline_tokens: int + eco_tokens: int + + @property + def saved_tokens(self) -> int: + return max(0, self.baseline_tokens - self.eco_tokens) + + +def _slug_for(command: str) -> str: + return "_".join(command.strip().split())[:40] or "cmd" + + +def compress_bash_output( + command: str, + exit_code: int, + full_text: str, + baseline: str, + tee_dir: Path | None, +) -> EcoOutcome | None: + """Compress one Bash result; ``None`` means "ship the baseline". + + ``full_text`` is the pre-truncation stdout+stderr assembly (the mapper's + shape, but unbounded); ``baseline`` is exactly what the mapper would emit + without eco. ``tee_dir`` is the per-session eco directory (None → only + safe-loss filters can fire). + """ + try: + if not baseline.strip(): + return None + if len(full_text) > _MAX_INPUT_CHARS: + return None + + processed = strip_ansi(normalize_carriage_returns(full_text)) + baseline_tokens = estimate_tokens(baseline) + + for filt in FILTERS: + try: + hit = filt(command, exit_code, processed) + except Exception: # noqa: BLE001 — one bad filter must not break the chain + logger.debug("[eco] filter %r failed", filt, exc_info=True) + continue + if hit is None: + continue + if not hit.body.strip(): + # Filters must never produce empty output (the downstream + # empty-content marker would misreport "no output"). + continue + + body = hit.body + if not hit.safe_loss: + if tee_dir is None: + continue + # Guard pre-check BEFORE writing: if the body plus a typical + # hint can't beat the baseline, don't leave an orphan file. + if ( + estimate_tokens(body) + _HINT_TOKEN_ALLOWANCE + > baseline_tokens + ): + continue + path = tee_raw(processed, _slug_for(command), tee_dir) + if path is None: + # Tiny content (< MIN_TEE_SIZE) or write failure: loss + # would be unrecoverable → discard the hit. + continue + hint = ( + tail_hint(path, hit.tail_offset) + if hit.tail_offset is not None + else full_hint(path) + ) + body = f"{body}\n{hint}" + if ( + len(body) > _MAX_ECO_CHARS + or never_worse(baseline, body) == baseline + ): + # The real hint pushed it over after all — remove the + # now-unreferenced tee file and pass through. + try: + path.unlink(missing_ok=True) + except OSError: + pass + continue + else: + if len(body) > _MAX_ECO_CHARS or never_worse(baseline, body) == baseline: + continue + + eco_tokens = estimate_tokens(body) + record_compression(hit.name, baseline_tokens, eco_tokens) + return EcoOutcome( + content=body, + filter_name=hit.name, + baseline_tokens=baseline_tokens, + eco_tokens=eco_tokens, + ) + return None + except Exception: # noqa: BLE001 — the engine must never break the Bash tool + logger.debug("[eco] engine failed; passing through", exc_info=True) + return None diff --git a/src/eco/filters.py b/src/eco/filters.py new file mode 100644 index 000000000..a574e7020 --- /dev/null +++ b/src/eco/filters.py @@ -0,0 +1,569 @@ +"""Pure output filters for eco compression (ported from RTK's method set). + +Every filter is a pure function ``(command, exit_code, text) -> FilterHit | None`` +over the already-assembled model-bound text. ``None`` means "not my shape" — +the engine tries the next filter and ultimately passes through. Filters follow +RTK's fidelity rules (my-docs/token-compression/RTK/05-safety-and-fidelity.md): + +* drop or count lines — never rewrite kept-line content; +* error/failure lines always survive; +* parse uncertainty → ``None`` (a test filter that can't find a summary line + refuses rather than guessing); +* two loss classes, mirroring RTK strip-vs-truncate: ``safe_loss`` drops are + pure ceremony (progress bars, spinner frames, advice lines) and may ship + without recovery; everything else requires the engine to tee the raw output + and append a recovery hint, or be discarded. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# ── caps (RTK core/truncate.rs classes) ───────────────────────────────────── +CAP_FAILURES = 10 # failure blocks shown per test run +FAILURE_DETAIL_LINES = 5 # relevant lines kept inside one failure block +CAP_ERRORS = 20 # error lines in log summaries +CAP_WARNINGS = 10 +LARGE_OUTPUT_LINES = 400 # success outputs longer than this get head-capped +HEAD_KEEP = 60 # lines kept by the large-output cap +LOG_MIN_LINES = 80 # minimum size before log dedup considers an output +_MAX_LINE = 300 # per-line clamp inside summaries (multi-MB one-liners) + + +@dataclass(frozen=True) +class FilterHit: + """A successful compression of one output.""" + + name: str + body: str + # True when the dropped content is pure ceremony (RTK "strip" class): + # progress/spinner/advice lines that carry zero decision value. Safe-loss + # hits may ship without a tee file; all other hits require recovery. + safe_loss: bool = False + # For head-cap style hits: the 1-based line offset where hidden content + # starts in the teed file (drives the runnable ``tail -n +N`` hint). + tail_offset: int | None = None + + +_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*(?:\x07|\x1b\\)") + + +def strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + +def normalize_carriage_returns(text: str) -> str: + """Keep only the final frame of ``\\r``-animated progress lines.""" + if "\r" not in text: + return text + out_lines = [] + for line in text.split("\n"): + if "\r" in line: + line = line.split("\r")[-1] + out_lines.append(line) + return "\n".join(out_lines) + + +def _clamp(line: str) -> str: + if len(line) <= _MAX_LINE: + return line + return line[: _MAX_LINE - 3] + "..." + + +def _collapse_blank_runs(lines: list[str]) -> list[str]: + out: list[str] = [] + blanks = 0 + for line in lines: + if line.strip() == "": + blanks += 1 + if blanks > 1: + continue + else: + blanks = 0 + out.append(line) + return out + + +# ───────────────────────────────────────────────────────────────────────────── +# 1. test_runner — failure focus (RTK's ~90% class, doc 03 §2.5/2.6/2.7) +# +# Native-output parsers; detection is output-signature based (a summary line +# is REQUIRED — no summary, no compression), so aliases like `make test` +# compress too and non-test output is never mangled. +# ───────────────────────────────────────────────────────────────────────────── + +_PYTEST_SUMMARY_RE = re.compile( + r"^=*\s*((?:\d+ (?:passed|failed|error|errors|skipped|xfailed|xpassed|warnings?|deselected)" + r"(?:, )?)+ in [\d.]+s(?: \([^)]*\))?)\s*=*$" +) +_PYTEST_SECTION_RE = re.compile(r"^=+ (FAILURES|ERRORS) =+$") +_PYTEST_SHORT_SUMMARY_RE = re.compile(r"^=+ short test summary info =+$") +_PYTEST_FAIL_HEADER_RE = re.compile(r"^_{3,}\s*(.*?)\s*_{3,}$") +_PYTEST_RELEVANT_RE = re.compile( + r"^(>|E |E$)|assert|[Ee]rror|Exception|\.py:\d+", +) + + +def _filter_pytest(exit_code: int, text: str) -> FilterHit | None: + lines = text.split("\n") + summary_line = None + for line in reversed(lines): + if _PYTEST_SUMMARY_RE.match(line.strip()): + summary_line = line.strip().strip("=").strip() + break + if summary_line is None: + return None + + # All green → one line. Only when the exit code agrees (critic M3): a + # green summary + non-zero exit means something ELSE in the output + # failed (a chained command, a plugin crash) — parse untrusted, pass + # through rather than mask it behind an all-green one-liner. + if "failed" not in summary_line and "error" not in summary_line: + if exit_code != 0: + return None + return FilterHit(name="pytest", body=f"Pytest: {summary_line}") + + # Collect failure blocks from the FAILURES/ERRORS section and the short + # summary (RTK pytest_cmd.rs state machine, simplified to native output). + failures: list[list[str]] = [] + short_summary: list[str] = [] + in_fail_section = False + in_short = False + current: list[str] = [] + for line in lines: + stripped = line.strip() + if _PYTEST_SECTION_RE.match(stripped): + in_fail_section, in_short = True, False + continue + if _PYTEST_SHORT_SUMMARY_RE.match(stripped): + if current: + failures.append(current) + current = [] + in_fail_section, in_short = False, True + continue + if _PYTEST_SUMMARY_RE.match(stripped): + in_fail_section = in_short = False + continue + if in_short: + if stripped.startswith(("FAILED", "ERROR", "XPASS")): + short_summary.append(_clamp(stripped)) + continue + if in_fail_section: + m = _PYTEST_FAIL_HEADER_RE.match(stripped) + if m and m.group(1): + if current: + failures.append(current) + current = [m.group(1)] + elif current and stripped: + current.append(line.rstrip()) + if current: + failures.append(current) + + out: list[str] = [f"Pytest: {summary_line}"] + shown = failures[:CAP_FAILURES] + for i, block in enumerate(shown, 1): + out.append("") + out.append(f"{i}. [FAIL] {block[0]}") + kept = 0 + for detail in block[1:]: + if kept >= FAILURE_DETAIL_LINES: + break + if _PYTEST_RELEVANT_RE.search(detail.strip()): + out.append(f" {_clamp(detail.strip())}") + kept += 1 + if len(failures) > CAP_FAILURES: + out.append(f" ... +{len(failures) - CAP_FAILURES} more failures") + if short_summary and not failures: + out.append("") + out.extend(f" {s}" for s in short_summary[:CAP_FAILURES]) + if len(short_summary) > CAP_FAILURES: + out.append(f" ... +{len(short_summary) - CAP_FAILURES} more") + return FilterHit(name="pytest", body="\n".join(out)) + + +_CARGO_RESULT_RE = re.compile( + r"^test result: (ok|FAILED)\. (\d+) passed; (\d+) failed;.*$" +) +_CARGO_FAIL_BLOCK_RE = re.compile(r"^---- (.+) ----$") + + +def _filter_cargo_test(exit_code: int, text: str) -> FilterHit | None: + lines = text.split("\n") + results = [l.strip() for l in lines if _CARGO_RESULT_RE.match(l.strip())] + if not results: + return None + total_passed = sum(int(_CARGO_RESULT_RE.match(r).group(2)) for r in results) # type: ignore[union-attr] + total_failed = sum(int(_CARGO_RESULT_RE.match(r).group(3)) for r in results) # type: ignore[union-attr] + + if total_failed == 0: + if exit_code != 0: + return None # green summary but failing exit — parse untrusted (M3) + n = len(results) + suites = f" across {n} suites" if n > 1 else "" + return FilterHit( + name="cargo-test", + body=f"cargo test: ok — {total_passed} passed{suites}", + ) + + out = [f"cargo test: {total_passed} passed, {total_failed} failed"] + blocks = 0 + in_block = False + detail = 0 + for line in lines: + stripped = line.strip() + m = _CARGO_FAIL_BLOCK_RE.match(stripped) + if m: + if blocks >= CAP_FAILURES: + in_block = False + continue + blocks += 1 + detail = 0 + in_block = True + out.append("") + out.append(f"[FAIL] {m.group(1)}") + continue + if in_block: + if not stripped or stripped.startswith("failures:"): + in_block = False + continue + if detail < FAILURE_DETAIL_LINES: + out.append(f" {_clamp(stripped)}") + detail += 1 + hidden = total_failed - blocks + if hidden > 0: + out.append(f" ... +{hidden} more failures") + return FilterHit(name="cargo-test", body="\n".join(out)) + + +_GO_FAIL_TEST_RE = re.compile(r"^--- FAIL: (\S+)") +_GO_SUMMARY_OK_RE = re.compile(r"^ok\s+\S+\s+([\d.]+s|\(cached\))") +# Package-fail lines carry a duration / (cached) / [build failed] suffix — +# jest's "FAIL src/x.test.ts" has none of those, so the go parser can never +# claim jest output. go's bare trailing "FAIL" verdict line is handled +# separately (it is not a package). +_GO_SUMMARY_FAIL_RE = re.compile( + r"^FAIL\s+\S+\s+([\d.]+s|\(cached\)|\[build failed\])$" +) + + +def _filter_go_test(exit_code: int, text: str) -> FilterHit | None: + lines = text.split("\n") + ok_pkgs = sum(1 for l in lines if _GO_SUMMARY_OK_RE.match(l.strip())) + fail_pkgs = sum(1 for l in lines if _GO_SUMMARY_FAIL_RE.match(l.strip())) + fail_tests = [ + m.group(1) + for l in lines + if (m := _GO_FAIL_TEST_RE.match(l.strip())) is not None + ] + # Single-package `go test` runs end with a bare FAIL/PASS verdict and no + # per-package summary line; require the --- FAIL marker alongside it so + # nothing else can masquerade as go output. + has_bare_verdict = any(l.strip() in ("FAIL", "PASS", "ok") for l in lines) + if ok_pkgs + fail_pkgs == 0 and not (fail_tests and has_bare_verdict): + return None + if fail_pkgs == 0 and not fail_tests: + if exit_code != 0: + return None # green summary but failing exit — parse untrusted (M3) + return FilterHit(name="go-test", body=f"go test: ok — {ok_pkgs} packages") + + failed_pkg_display = fail_pkgs if fail_pkgs else 1 + out = [ + f"go test: {failed_pkg_display} package(s) failed" + + (f", {ok_pkgs} ok" if ok_pkgs else "") + ] + # Keep each failing test header + its indented output (capped). + blocks = 0 + in_fail = False + detail = 0 + for line in lines: + if _GO_FAIL_TEST_RE.match(line.strip()): + if blocks >= CAP_FAILURES: + in_fail = False + continue + blocks += 1 + detail = 0 + in_fail = True + out.append(_clamp(line.strip())) + continue + if in_fail: + if line.startswith((" ", "\t")) and detail < FAILURE_DETAIL_LINES: + out.append(f" {_clamp(line.strip())}") + detail += 1 + continue + in_fail = False + stripped = line.strip() + if _GO_SUMMARY_FAIL_RE.match(stripped) and len(out) < 60: + out.append(_clamp(stripped)) + if len(fail_tests) > CAP_FAILURES: + out.append(f" ... +{len(fail_tests) - CAP_FAILURES} more failing tests") + return FilterHit(name="go-test", body="\n".join(out)) + + +_JEST_TESTS_RE = re.compile( + r"^Tests:\s+(?:(\d+) failed, )?(?:(\d+) skipped, )?(\d+) passed, (\d+) total" +) +_JEST_FAIL_FILE_RE = re.compile(r"^FAIL\s+(\S+)") +_JEST_FAIL_CASE_RE = re.compile(r"^\s*(✕|✗|×)\s+(.*)$") + + +def _filter_jest(exit_code: int, text: str) -> FilterHit | None: + lines = text.split("\n") + summary = None + for line in reversed(lines): + m = _JEST_TESTS_RE.match(line.strip()) + if m: + summary = m + break + if summary is None: + return None + failed = int(summary.group(1) or 0) + if failed == 0: + if exit_code != 0: + return None # green summary but failing exit — parse untrusted (M3) + return FilterHit( + name="jest", + body=f"Tests: {summary.group(3)} passed, {summary.group(4)} total — ok", + ) + out = [summary.group(0)] + fail_files = [l.strip() for l in lines if _JEST_FAIL_FILE_RE.match(l.strip())] + out.extend(_clamp(f) for f in fail_files[:CAP_FAILURES]) + cases = [m.group(2) for l in lines if (m := _JEST_FAIL_CASE_RE.match(l))] + for c in cases[:CAP_FAILURES]: + out.append(f" ✕ {_clamp(c)}") + # Keep assertion context lines (`expect(...)`, `Expected:`, `Received:`). + ctx = [ + l.strip() + for l in lines + if l.strip().startswith(("expect(", "Expected", "Received", "at ")) + ] + out.extend(f" {_clamp(c)}" for c in ctx[: FAILURE_DETAIL_LINES * min(failed, CAP_FAILURES)]) + return FilterHit(name="jest", body="\n".join(out)) + + +def filter_test_runner(command: str, exit_code: int, text: str) -> FilterHit | None: + # jest before go: both use a bare "FAIL" keyword, but jest's "Tests:" + # summary is unmistakable while go's FAIL lines are duration-suffixed. + for parser in (_filter_pytest, _filter_cargo_test, _filter_jest, _filter_go_test): + hit = parser(exit_code, text) + if hit is not None: + return hit + return None + + +# ───────────────────────────────────────────────────────────────────────────── +# 2. noise_strip — ceremony line removal (RTK TOML-corpus class, doc 04) +# +# This is RTK's "strip" loss class: dropped lines are pure ceremony, so hits +# are safe_loss (no tee required). To keep that promise, every pattern group +# is SCOPED to its tool family via a command matcher (critic M2 — RTK's TOML +# filters gate on match_command for the same reason): "Downloading x" from +# some unrelated script is only ceremony when the command is actually a +# package manager. A tool invoked behind `make`/a script is simply not +# compressed (RTK accepts the same under-coverage for safety). The only +# universal pattern is a line of pure spinner glyphs — ceremony in any +# context. Error/summary lines survive by construction: no pattern below can +# match them. +# ───────────────────────────────────────────────────────────────────────────── + +# Progress bars: [=====> ] 45% / 45%|████ — shared by downloader families. +_PROGRESS_BAR_PATTERNS = ( + re.compile(r"^\s*[\[|(]?[=\-#>. ]{6,}[\]|)]?\s*\d{1,3}(\.\d+)?%.*$"), + re.compile(r"^\s*\d{1,3}(\.\d+)?%\s*[|▏▎▍▌▋▊▉█]+.*$"), +) + +_NOISE_GROUPS: tuple[tuple[re.Pattern[str] | None, tuple[re.Pattern[str], ...]], ...] = ( + ( + re.compile(r"(^|[\s;&|(])git\b"), + ( + re.compile(r"^(remote: )?(Enumerating|Counting|Compressing|Receiving|Resolving|Writing) (objects|deltas):"), + re.compile(r"^remote: (Total|Compressing|Counting)\b"), + # status/pull advice lines — UI guidance, meaningless to the model + re.compile(r'^\s*\(use "git [^"]+"'), + re.compile(r"^\s*\(fix conflicts and run"), + ), + ), + ( + re.compile(r"(^|[\s;&|(])(pip3?|uv)\b|python3?(\.\d+)? -m pip\b"), + ( + re.compile(r"^(Collecting|Downloading|Using cached|Requirement already satisfied)[ :]"), + re.compile(r"^\s*(Preparing metadata|Installing build dependencies|Getting requirements to build)"), + re.compile(r"^\s*Downloading\b"), + *_PROGRESS_BAR_PATTERNS, + ), + ), + ( + re.compile(r"(^|[\s;&|(])(npm|npx|yarn|pnpm|bun)\b"), + ( + re.compile(r"^npm (WARN|notice)\b"), + re.compile(r"^\s*reify:"), + re.compile(r"^Progress: resolved \d+"), + *_PROGRESS_BAR_PATTERNS, + ), + ), + ( + re.compile(r"(^|[\s;&|(])cargo\b"), + ( + re.compile(r"^\s*(Compiling|Downloading|Downloaded|Checking|Fresh|Updating crates.io index)\s"), + ), + ), + ( + re.compile(r"(^|[\s;&|(])docker\b"), + ( + re.compile(r"^[0-9a-f]{12}: (Pulling|Waiting|Verifying|Download complete|Pull complete|Downloading|Extracting)"), + re.compile(r"^(Pulling from|Digest: sha256:)"), + *_PROGRESS_BAR_PATTERNS, + ), + ), + ( + re.compile(r"(^|[\s;&|(])(apt|apt-get|aptitude)\b"), + ( + re.compile(r"^(Get:\d+|Hit:\d+|Reading package lists|Building dependency tree|Reading state information)"), + *_PROGRESS_BAR_PATTERNS, + ), + ), + ( + re.compile(r"(^|[\s;&|(])brew\b"), + ( + re.compile(r"^(==> (Downloading|Fetching|Pouring)|#{3,}\s*\d*\.?\d*%?$)"), + *_PROGRESS_BAR_PATTERNS, + ), + ), + # Universal: a line consisting solely of spinner glyphs is ceremony + # regardless of what produced it. + ( + None, + (re.compile(r"^(⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧|⠇|⠏)+\s*$"),), + ), +) + + +def filter_noise_strip(command: str, exit_code: int, text: str) -> FilterHit | None: + active: list[re.Pattern[str]] = [] + for matcher, patterns in _NOISE_GROUPS: + if matcher is None or matcher.search(command): + active.extend(patterns) + if not active: + return None + lines = text.split("\n") + kept: list[str] = [] + dropped = 0 + for line in lines: + if any(p.search(line) for p in active): + dropped += 1 + continue + kept.append(line) + if dropped == 0: + return None + kept = _collapse_blank_runs(kept) + return FilterHit(name="noise-strip", body="\n".join(kept).strip("\n"), safe_loss=True) + + +# ───────────────────────────────────────────────────────────────────────────── +# 3. log_dedup — repeated log lines → counts (RTK log_cmd.rs, doc 03 §2.8) +# ───────────────────────────────────────────────────────────────────────────── + +_TS_RE = re.compile(r"^\[?\d{4}[-/]\d{2}[-/]\d{2}[T ]\d{2}:\d{2}:\d{2}[.,]?\d*\]?\s*") +_UUID_RE = re.compile(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}") +_HEX_RE = re.compile(r"0x[0-9a-fA-F]+") +_NUM_RE = re.compile(r"\b\d{4,}\b") +_PATH_RE = re.compile(r"/[\w./\-]+") +_LOGGY_RE = re.compile( + r"^\[?\d{4}[-/]\d{2}[-/]\d{2}|\b(ERROR|WARN(ING)?|INFO|DEBUG|TRACE|FATAL|CRITICAL)\b" +) +_ERR_WORD_RE = re.compile(r"error|fatal|panic|critical|alert|emerg|severe", re.IGNORECASE) +_WARN_WORD_RE = re.compile(r"warn|notice", re.IGNORECASE) + + +def _normalize_log_line(line: str) -> str: + line = _TS_RE.sub("", line) + line = _UUID_RE.sub("", line) + line = _HEX_RE.sub("", line) + line = _NUM_RE.sub("", line) + line = _PATH_RE.sub("", line) + return line.strip() + + +def filter_log_dedup(command: str, exit_code: int, text: str) -> FilterHit | None: + lines = [l for l in text.split("\n") if l.strip()] + if len(lines) < LOG_MIN_LINES: + return None + loggy = sum(1 for l in lines if _LOGGY_RE.search(l)) + if loggy < len(lines) * 0.5: + return None + + err_counts: dict[str, int] = {} + err_first: dict[str, str] = {} + warn_counts: dict[str, int] = {} + warn_first: dict[str, str] = {} + info = 0 + other = 0 + for line in lines: + norm = _normalize_log_line(line) + if _ERR_WORD_RE.search(line): + err_counts[norm] = err_counts.get(norm, 0) + 1 + err_first.setdefault(norm, line.strip()) + elif _WARN_WORD_RE.search(line): + warn_counts[norm] = warn_counts.get(norm, 0) + 1 + warn_first.setdefault(norm, line.strip()) + elif "info" in line.lower(): + info += 1 + else: + other += 1 + + out = [ + f"Log summary ({len(lines)} lines): " + f"{sum(err_counts.values())} errors ({len(err_counts)} unique), " + f"{sum(warn_counts.values())} warnings ({len(warn_counts)} unique), " + f"{info} info, {other} other" + ] + if err_counts: + out.append("") + out.append("[ERRORS]") + for norm, count in sorted(err_counts.items(), key=lambda kv: -kv[1])[:CAP_ERRORS]: + prefix = f"[×{count}] " if count > 1 else "" + out.append(f" {prefix}{_clamp(err_first[norm])}") + if len(err_counts) > CAP_ERRORS: + out.append(f" ... +{len(err_counts) - CAP_ERRORS} more unique errors") + if warn_counts: + out.append("") + out.append("[WARNINGS]") + for norm, count in sorted(warn_counts.items(), key=lambda kv: -kv[1])[:CAP_WARNINGS]: + prefix = f"[×{count}] " if count > 1 else "" + out.append(f" {prefix}{_clamp(warn_first[norm])}") + if len(warn_counts) > CAP_WARNINGS: + out.append(f" ... +{len(warn_counts) - CAP_WARNINGS} more unique warnings") + return FilterHit(name="log-dedup", body="\n".join(out)) + + +# ───────────────────────────────────────────────────────────────────────────── +# 4. large_success_cap — recoverable head window (RTK strategy 12) +# +# Fallback for successful, no-better-filter outputs: keep the head, point at +# the rest with a runnable `tail -n +N` hint. The recoverable version of the +# blind `[N lines truncated]` marker bash's truncate_output produces today. +# ───────────────────────────────────────────────────────────────────────────── + + +def filter_large_success(command: str, exit_code: int, text: str) -> FilterHit | None: + if exit_code != 0: + return None + lines = text.split("\n") + if len(lines) <= LARGE_OUTPUT_LINES: + return None + head = lines[:HEAD_KEEP] + body = "\n".join(head) + f"\n... (+{len(lines) - HEAD_KEEP} more lines)" + return FilterHit( + name="head-cap", + body=body, + tail_offset=HEAD_KEEP + 1, + ) + + +# Ordered registry: most specific first; first hit wins (RTK dispatch order). +FILTERS = ( + filter_test_runner, + filter_log_dedup, + filter_noise_strip, + filter_large_success, +) diff --git a/src/eco/guard.py b/src/eco/guard.py new file mode 100644 index 000000000..1bfd38c42 --- /dev/null +++ b/src/eco/guard.py @@ -0,0 +1,32 @@ +"""Never-worse guard + token estimator (port of RTK guard.rs / tracking.rs). + +``estimate_tokens`` is the ~4-chars-per-token heuristic RTK uses for its +savings accounting and guard decisions — deliberately fast and deterministic +(no tokenizer import). It exists alongside :mod:`src.token_estimation` +(tiktoken-backed) on purpose: the guard runs inline on every Bash result and +must not pay encoder costs or vary by installed extras. +""" + +from __future__ import annotations + +import math + + +def estimate_tokens(text: str) -> int: + """Estimate tokens as ``ceil(len/4)`` (RTK tracking.rs:1284).""" + if not text: + return 0 + return math.ceil(len(text) / 4.0) + + +def never_worse(raw: str, filtered: str) -> str: + """Return ``filtered`` unless it would emit MORE tokens than ``raw``. + + The outermost safety wrapper on every compressed emission (RTK guard.rs): + a "compact" rendering that estimates larger than the original loses + automatically, bounding the worst case at zero savings. Ties keep + ``filtered``. + """ + if estimate_tokens(filtered) > estimate_tokens(raw): + return raw + return filtered diff --git a/src/eco/state.py b/src/eco/state.py new file mode 100644 index 000000000..9e312aab4 --- /dev/null +++ b/src/eco/state.py @@ -0,0 +1,89 @@ +"""Session state + savings stats for /eco. + +Process-global session toggle, the same shape as +:mod:`src.workflow.ultracode` (``/effort ultracode``): the ``/eco`` command +and the Bash tool share it without plumbing. This also means eco applies to +subagent Bash calls (they run in the same process and burn the same context +tokens — intended), and on a multi-session transport one process has one +switch (documented ultracode precedent). + +Stats are honest the RTK way: only actual compressions are recorded +(passthroughs contribute nothing, so they can't dilute the averages — +RTK tracking.rs ``track_passthrough`` records 0/0 for the same reason). +""" + +from __future__ import annotations + +import threading +from dataclasses import dataclass, field + + +@dataclass +class EcoStats: + commands: int = 0 + baseline_tokens: int = 0 + eco_tokens: int = 0 + # filter name -> (uses, tokens saved); insertion-ordered for display. + by_filter: dict[str, tuple[int, int]] = field(default_factory=dict) + + @property + def saved_tokens(self) -> int: + return max(0, self.baseline_tokens - self.eco_tokens) + + @property + def savings_pct(self) -> float: + if self.baseline_tokens <= 0: + return 0.0 + return 100.0 * self.saved_tokens / self.baseline_tokens + + +_lock = threading.Lock() +_session_on = False +_stats = EcoStats() + + +def set_eco_session(on: bool) -> None: + """Turn Bash-output compression on/off for this session.""" + global _session_on + with _lock: + _session_on = bool(on) + + +def is_eco_session() -> bool: + """Whether /eco compression is currently enabled.""" + with _lock: + return _session_on + + +def record_compression( + filter_name: str, baseline_tokens: int, eco_tokens: int +) -> None: + """Record one successful compression (called by the engine only).""" + with _lock: + _stats.commands += 1 + _stats.baseline_tokens += max(0, int(baseline_tokens)) + _stats.eco_tokens += max(0, int(eco_tokens)) + uses, saved = _stats.by_filter.get(filter_name, (0, 0)) + _stats.by_filter[filter_name] = ( + uses + 1, + saved + max(0, int(baseline_tokens) - int(eco_tokens)), + ) + + +def eco_stats() -> EcoStats: + """A snapshot copy of the session's savings stats.""" + with _lock: + return EcoStats( + commands=_stats.commands, + baseline_tokens=_stats.baseline_tokens, + eco_tokens=_stats.eco_tokens, + by_filter=dict(_stats.by_filter), + ) + + +def reset_eco() -> None: + """Clear the toggle and stats (test/teardown helper).""" + global _session_on, _stats + with _lock: + _session_on = False + _stats = EcoStats() diff --git a/src/eco/tee.py b/src/eco/tee.py new file mode 100644 index 000000000..033af0d87 --- /dev/null +++ b/src/eco/tee.py @@ -0,0 +1,126 @@ +"""Raw-output recovery for eco compression (port of RTK tee.rs). + +The license to be lossy: before any filter drops content, the full raw output +is written to a per-session file and the compact rendering ends with a +pointer the model can act on: + + [full output: ~/.clawcodex///eco/1707_pytest.log] + [see remaining: tail -n +61 ~/.clawcodex/.../eco/1707_ls.log] + +The engine enforces RTK's hard rule (main.rs:1341): if the tee write fails, +the lossy rendering is *discarded* and the baseline ships instead — an +omission marker may only appear when the omitted content is actually +retrievable. + +Directory: callers pass it in (the Bash tool derives it from the session's +tool-results dir, keeping eco artifacts co-located with the existing +```` files). Files over 1 MiB are truncated at a UTF-8 +boundary with an explicit marker; tiny outputs (< 500 B) are not teed — +their loss is cheaper than the hint. +""" + +from __future__ import annotations + +import itertools +import logging +import os +import time +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Monotonic per-process counter: with time_ns + pid it makes tee filenames +# collision-proof even for identical commands fired in the same instant from +# parallel subagents (critic B1 — a clobbered tee file would make an +# already-emitted recovery hint point at the WRONG command's output). +_counter = itertools.count() + +# Below this, recovery isn't worth a file + hint line (RTK MIN_TEE_SIZE). +MIN_TEE_SIZE = 500 +# Per-file cap (RTK DEFAULT_MAX_FILE_SIZE). +MAX_TEE_FILE_SIZE = 1_048_576 +# Per-directory cap — a runaway loop of huge commands shouldn't fill the disk. +MAX_TEE_FILES = 50 +_SLUG_MAX = 40 + + +def sanitize_slug(slug: str) -> str: + """Filesystem-safe slug: alnum/_/- kept, everything else ``_``, 40 chars.""" + cleaned = "".join( + c if (c.isascii() and (c.isalnum() or c in "_-")) else "_" for c in slug + ) + return cleaned[:_SLUG_MAX] or "cmd" + + +def _truncate_utf8_boundary(text: str, max_bytes: int) -> str: + raw = text.encode("utf-8") + if len(raw) <= max_bytes: + return text + cut = raw[:max_bytes] + # Back off to a valid UTF-8 boundary (at most 3 continuation bytes). + while cut and (cut[-1] & 0xC0) == 0x80: + cut = cut[:-1] + if cut and cut[-1] >= 0xC0: + cut = cut[:-1] + return ( + cut.decode("utf-8", errors="ignore") + + f"\n\n--- truncated at {max_bytes} bytes ---" + ) + + +def _rotate(directory: Path) -> None: + try: + files = sorted( + (p for p in directory.iterdir() if p.suffix == ".log"), + key=lambda p: p.name, + ) + for old in files[: max(0, len(files) - MAX_TEE_FILES)]: + old.unlink(missing_ok=True) + except OSError: + logger.debug("[eco] tee rotation failed", exc_info=True) + + +def tee_raw(content: str, slug: str, directory: Path) -> Path | None: + """Write ``content`` to ``directory/{epoch}_{slug}.log``. + + Returns the path, or None when skipped (tiny content) or the write + failed — the caller must then fall back to the baseline rendering. + """ + if len(content.encode("utf-8", errors="ignore")) < MIN_TEE_SIZE: + return None + try: + directory.mkdir(parents=True, exist_ok=True) + # time_ns + pid + counter → unique per call, chronologically sortable + # (rotation sorts by name), and never overwrites an earlier file whose + # recovery hint is already in the conversation. + name = ( + f"{time.time_ns()}_{os.getpid()}_{next(_counter)}" + f"_{sanitize_slug(slug)}.log" + ) + path = directory / name + path.write_text( + _truncate_utf8_boundary(content, MAX_TEE_FILE_SIZE), encoding="utf-8" + ) + _rotate(directory) + return path + except OSError: + logger.debug("[eco] tee write failed", exc_info=True) + return None + + +def _display_path(path: Path) -> str: + try: + return f"~/{path.relative_to(Path.home())}" + except ValueError: + return str(path) + + +def full_hint(path: Path) -> str: + """``[full output: ~/...]`` — read the file for everything.""" + return f"[full output: {_display_path(path)}]" + + +def tail_hint(path: Path, line_offset: int) -> str: + """``[see remaining: tail -n +N ~/...]`` — a directly runnable pointer to + the first hidden line (RTK force_tee_tail_hint).""" + return f"[see remaining: tail -n +{line_offset} {_display_path(path)}]" diff --git a/src/server/agent_server.py b/src/server/agent_server.py index 1ea696b6c..a70626aca 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -706,6 +706,9 @@ async def _handle_control_request(self, msg: dict) -> None: if subtype == "advisor": self._do_advisor_command(request_id, inner.get("arg")) return + if subtype == "eco": + self._do_eco_command(request_id, inner.get("arg")) + return if subtype == "worktree_status": await self._do_worktree_status(request_id) return @@ -2504,6 +2507,39 @@ def _remove() -> dict: self._worktree_done = True self._reply(request_id, result) + def _do_eco_command(self, request_id: object, arg: object) -> None: + """Control handler for /eco — toggle Bash-output token compression. + + Bridges to the command-system implementation (eco_command_call), + which owns the grammar (toggle / on / off / status). The state is + process-global session state (src/eco/state.py, the ultracode + shape) — NOT persisted user settings — so no ``single_session`` + gate: flipping it affects this worker process only, exactly like + ``/effort ultracode``. + """ + try: + from src.command_system.eco_command import eco_command_call + from src.command_system.types import CommandContext + + ctx = CommandContext( + workspace_root=Path(self.cwd), + cwd=Path(self.cwd), + conversation=getattr(self.session, "conversation", None), + cost_tracker=None, + history=None, + ) + result = eco_command_call(str(arg or ""), ctx) + from src.eco import is_eco_session + + self._reply(request_id, { + "ok": True, + "enabled": is_eco_session(), + "text": str(getattr(result, "value", "") or ""), + }) + except Exception as exc: # noqa: BLE001 — must not kill the control channel + logger.exception("[agent-server] eco command failed") + self._reply(request_id, {"ok": False, "error": str(exc)}) + def _do_advisor_command(self, request_id: object, arg: object) -> None: """Control handler for /advisor — configure the reviewer model. diff --git a/src/tool_system/tools/bash/bash_tool.py b/src/tool_system/tools/bash/bash_tool.py index 0fa87cb10..ab6a23062 100644 --- a/src/tool_system/tools/bash/bash_tool.py +++ b/src/tool_system/tools/bash/bash_tool.py @@ -723,6 +723,20 @@ def _bash_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult: if is_silent_command(command): output["noOutputExpected"] = True + # /eco: compress the model-bound rendering. The raw stdout/stderr stay in + # the output dict (and, for lossy filters, in the per-session tee file); + # note the TUI transcript renders the mapped content, so the user sees + # the same compact string the model does. Runs on the full pre-truncation + # output so the tee recovery file is complete. + _maybe_apply_eco( + output, + command=command, + exit_code=completed_returncode, + full_stdout=completed_stdout, + full_stderr=completed_stderr, + context=context, + ) + return ToolResult( name=BASH_TOOL_NAME, output=output, @@ -730,6 +744,82 @@ def _bash_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult: ) +def _assemble_bash_body(stdout: str, stderr: str) -> str: + """The stdout+stderr part of the model-bound content (no interrupt + marker, no returnCodeInterpretation). Factored out so the eco engine's + never-worse guard compares against exactly what the mapper would emit.""" + processed_stdout = strip_leading_blank_lines(stdout).rstrip() if stdout else "" + parts: list[str] = [] + if processed_stdout: + parts.append(processed_stdout) + if stderr and stderr.strip(): + parts.append(stderr.strip()) + return "\n".join(parts) + + +def _eco_tee_dir(context: ToolContext) -> Path | None: + """Per-session directory for eco raw-recovery files, co-located with the + Step-11 ``tool-results`` persistence dir (``...//eco/``).""" + try: + from src.services.tool_execution.tool_result_persistence import ( + resolve_tool_results_dir, + ) + + return resolve_tool_results_dir(context).parent / "eco" + except Exception: # noqa: BLE001 — recovery dir is best-effort + return None + + +def _maybe_apply_eco( + output: dict[str, Any], + *, + command: str, + exit_code: int, + full_stdout: str, + full_stderr: str, + context: ToolContext, +) -> None: + """When ``/eco`` is on, attach a compressed model-bound rendering. + + Sets ``output["ecoContent"]`` (consumed by ``_bash_map_result_to_api``) + plus ``ecoFilter``/``ecoSavedTokens`` metadata. Never raises; never + touches the raw stdout/stderr fields, exit code, or error semantics. + (The TUI renders the mapped content, so the compact rendering is also + what the user sees in the transcript.) Skips image output (the data URI + must reach the image mapper intact). The interrupted/timeout/background/ + cd paths return before this is called. + """ + try: + from src.eco import is_eco_session + + if not is_eco_session(): + return + if output.get("isImage"): + return + from src.eco.engine import compress_bash_output + + baseline = _assemble_bash_body( + output.get("stdout", ""), output.get("stderr", "") + ) + if not baseline.strip(): + return + outcome = compress_bash_output( + command=command, + exit_code=exit_code, + full_text=_assemble_bash_body(full_stdout, full_stderr), + baseline=baseline, + tee_dir=_eco_tee_dir(context), + ) + if outcome is not None: + output["ecoContent"] = outcome.content + output["ecoFilter"] = outcome.filter_name + output["ecoSavedTokens"] = outcome.saved_tokens + except Exception: # noqa: BLE001 — eco must never break the Bash tool + import logging as _logging + + _logging.getLogger(__name__).debug("[eco] apply failed", exc_info=True) + + def _bash_map_result_to_api(output: Any, tool_use_id: str) -> dict[str, Any]: if isinstance(output, dict): # ``run_in_background: true`` responses carry a task id + a canned @@ -759,19 +849,22 @@ def _bash_map_result_to_api(output: Any, tool_use_id: str) -> dict[str, Any]: interpretation = output.get("returnCodeInterpretation") interrupted = output.get("interrupted", False) - processed_stdout = strip_leading_blank_lines(stdout).rstrip() if stdout else "" + # /eco: a compressed rendering replaces the stdout+stderr assembly on + # the wire only — the display fields stay raw. Interrupt marker and + # returnCodeInterpretation append after it either way. (eco is never + # set on interrupted results, so the joined string is byte-identical + # to the historical assembly whenever ecoContent is absent.) + eco_content = output.get("ecoContent") + if isinstance(eco_content, str) and eco_content.strip(): + body = eco_content + else: + body = _assemble_bash_body(stdout, stderr) parts: list[str] = [] - if processed_stdout: - parts.append(processed_stdout) - - error_parts: list[str] = [] - if stderr and stderr.strip(): - error_parts.append(stderr.strip()) + if body: + parts.append(body) if interrupted: - error_parts.append("Command was aborted before completion") - if error_parts: - parts.append("\n".join(error_parts)) + parts.append("Command was aborted before completion") if interpretation: parts.append(interpretation) diff --git a/tests/test_bash_eco_integration.py b/tests/test_bash_eco_integration.py new file mode 100644 index 000000000..5a0af7adb --- /dev/null +++ b/tests/test_bash_eco_integration.py @@ -0,0 +1,159 @@ +"""End-to-end tests: /eco through the real Bash tool + result mapper. + +Contract under test (design invariants): +- eco off → byte-identical wire content to the historical assembly; +- eco on → ``ecoContent`` replaces only the stdout+stderr assembly on the + wire; the output dict's ``stdout``/``stderr`` display fields stay raw; + exit codes / is_error / returnCodeInterpretation are untouched; +- lossy compressions write a recovery file and reference it. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from src.eco.state import is_eco_session, reset_eco, set_eco_session +from src.tool_system.context import ToolContext +from src.tool_system.tools.bash import bash_tool as bash_mod +from src.tool_system.tools.bash.bash_tool import ( + _assemble_bash_body, + _bash_call, + _bash_map_result_to_api, +) + + +@pytest.fixture(autouse=True) +def _fresh_eco_state(): + reset_eco() + yield + reset_eco() + + +@pytest.fixture() +def ctx(tmp_path: Path) -> ToolContext: + c = ToolContext(workspace_root=tmp_path) + c.cwd = tmp_path + return c + + +@pytest.fixture() +def eco_dir(tmp_path: Path, monkeypatch) -> Path: + d = tmp_path / "eco-tee" + monkeypatch.setattr(bash_mod, "_eco_tee_dir", lambda _ctx: d) + return d + + +def _wire_content(result) -> str: + block = _bash_map_result_to_api(result.output, "toolu_test") + return block["content"] + + +NOISY_INSTALL = ( + "printf 'Collecting requests\\n" + " Downloading requests-2.31.0-py3-none-any.whl (62 kB)\\n" + "Requirement already satisfied: idna in ./venv\\n" + "Successfully installed requests-2.31.0\\n'" +) + + +def test_eco_off_leaves_wire_content_untouched(ctx: ToolContext, eco_dir: Path): + result = _bash_call({"command": NOISY_INSTALL}, ctx) + assert "ecoContent" not in result.output + content = _wire_content(result) + assert "Downloading requests" in content + assert "Successfully installed requests-2.31.0" in content + + +def test_eco_on_compresses_wire_and_keeps_raw_fields(ctx: ToolContext, eco_dir: Path): + set_eco_session(True) + # noise_strip groups are command-scoped; the trailing comment makes this + # printf-simulated output count as a pip-family command. + result = _bash_call({"command": f"{NOISY_INSTALL} # pip install requests"}, ctx) + + # Wire content (which the TUI transcript also renders): compressed. + assert result.output.get("ecoFilter") == "noise-strip" + content = _wire_content(result) + assert content == "Successfully installed requests-2.31.0" + + # Raw output-dict fields survive for persistence/consumers. + assert "Downloading requests" in result.output["stdout"] + + # Execution semantics untouched. + assert result.output["exit_code"] == 0 + assert result.is_error in (False, None) + assert result.output.get("ecoSavedTokens", 0) > 0 + + +def test_eco_large_output_head_cap_with_recovery(ctx: ToolContext, eco_dir: Path): + set_eco_session(True) + result = _bash_call({"command": "seq 1 1000"}, ctx) + + content = _wire_content(result) + assert content.startswith("1\n2\n") + assert "(+940 more lines)" in content + assert "[see remaining: tail -n +61 " in content + + # Recovery file exists and offsets line up. + logs = list(eco_dir.glob("*.log")) + assert len(logs) == 1 + assert logs[0].read_text(encoding="utf-8").splitlines()[60] == "61" + + # Display stdout still carries the raw sequence (up to bash's own cap). + assert result.output["stdout"].startswith("1\n2\n") + assert "961" in result.output["stdout"] + + +def test_eco_passthrough_keeps_exact_baseline(ctx: ToolContext, eco_dir: Path): + """A small, clean output must ship byte-identically with eco on.""" + cmd = "echo hello; echo err >&2" + result_off = _bash_call({"command": cmd}, ctx) + baseline = _wire_content(result_off) + + set_eco_session(True) + result_on = _bash_call({"command": cmd}, ctx) + assert "ecoContent" not in result_on.output + assert _wire_content(result_on) == baseline + assert "hello" in baseline and "err" in baseline + + +def test_eco_failing_command_semantics_unchanged(ctx: ToolContext, eco_dir: Path): + set_eco_session(True) + result = _bash_call({"command": "echo boom >&2; exit 3"}, ctx) + assert result.output["exit_code"] == 3 + content = _wire_content(result) + assert "boom" in content + # returnCodeInterpretation (if any) still appended by the mapper. + interp = result.output.get("returnCodeInterpretation") + if interp: + assert interp in content + + +def test_eco_lossy_hit_without_tee_falls_back(ctx: ToolContext, monkeypatch): + """Tee dir unavailable → head-cap (lossy) must NOT fire.""" + monkeypatch.setattr(bash_mod, "_eco_tee_dir", lambda _ctx: None) + set_eco_session(True) + result = _bash_call({"command": "seq 1 1000"}, ctx) + assert "ecoContent" not in result.output + assert "1000" in _wire_content(result) + + +def test_mapper_appends_interpretation_after_eco_content(): + output = { + "cwd": "/w", + "exit_code": 1, + "stdout": "raw out", + "stderr": "", + "ecoContent": "compact", + "returnCodeInterpretation": "grep: no matches found", + } + block = _bash_map_result_to_api(output, "toolu_x") + assert block["content"] == "compact\ngrep: no matches found" + + +def test_assemble_bash_body_matches_mapper_for_plain_results(): + output = {"stdout": "\n\n \nout line\n", "stderr": " err line \n"} + body = _assemble_bash_body(output["stdout"], output["stderr"]) + block = _bash_map_result_to_api(dict(output), "toolu_y") + assert block["content"] == body == "out line\nerr line" diff --git a/tests/test_eco.py b/tests/test_eco.py new file mode 100644 index 000000000..745e6e921 --- /dev/null +++ b/tests/test_eco.py @@ -0,0 +1,635 @@ +"""Unit tests for the /eco compression module (src/eco/). + +Fixture texts are realistic captures of the tools' native output shapes +(pytest, cargo, go, jest, pip, git, log streams) — the RTK discipline: test +against real output, assert both content preservation AND a savings floor. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from src.eco.engine import compress_bash_output +from src.eco.filters import ( + FilterHit, + filter_large_success, + filter_log_dedup, + filter_noise_strip, + filter_test_runner, + normalize_carriage_returns, + strip_ansi, +) +from src.eco.guard import estimate_tokens, never_worse +from src.eco.state import ( + eco_stats, + is_eco_session, + record_compression, + reset_eco, + set_eco_session, +) +from src.eco.tee import ( + MAX_TEE_FILES, + full_hint, + sanitize_slug, + tail_hint, + tee_raw, +) + + +@pytest.fixture(autouse=True) +def _fresh_eco_state(): + reset_eco() + yield + reset_eco() + + +def _savings_pct(raw: str, filtered: str) -> float: + return 100.0 * (1 - estimate_tokens(filtered) / max(1, estimate_tokens(raw))) + + +# ── guard ──────────────────────────────────────────────────────────────────── + + +def test_estimate_tokens_chars_over_four_ceil(): + assert estimate_tokens("") == 0 + assert estimate_tokens("abcd") == 1 + assert estimate_tokens("abcde") == 2 + + +def test_never_worse_prefers_smaller_and_keeps_ties(): + raw = "x" * 400 + assert never_worse(raw, "ok") == "ok" + assert never_worse("{}", "{\n 'pretty': true\n}") == "{}" + assert never_worse("abcd", "wxyz") == "wxyz" # tie keeps filtered + + +# ── state ──────────────────────────────────────────────────────────────────── + + +def test_state_toggle_and_stats(): + assert not is_eco_session() + set_eco_session(True) + assert is_eco_session() + record_compression("pytest", 1000, 100) + record_compression("pytest", 500, 50) + stats = eco_stats() + assert stats.commands == 2 + assert stats.saved_tokens == 1350 + assert stats.by_filter["pytest"] == (2, 1350) + assert 85.0 < stats.savings_pct < 95.0 + reset_eco() + assert not is_eco_session() + assert eco_stats().commands == 0 + + +# ── tee ────────────────────────────────────────────────────────────────────── + + +def test_tee_writes_and_hints(tmp_path: Path): + content = "line\n" * 200 # > MIN_TEE_SIZE + path = tee_raw(content, "cargo test --all", tmp_path) + assert path is not None and path.exists() + assert path.suffix == ".log" + assert "cargo_test" in path.name + assert full_hint(path).startswith("[full output: ") + assert tail_hint(path, 61).startswith("[see remaining: tail -n +61 ") + + +def test_tee_skips_tiny_content(tmp_path: Path): + assert tee_raw("short", "cmd", tmp_path) is None + + +def test_tee_same_second_same_slug_never_collides(tmp_path: Path): + """Critic B1: two identical commands in the same instant must get + distinct files — a clobbered tee would make the first result's already- + emitted recovery hint point at the WRONG command's output.""" + content_a = "AAAA\n" * 200 + content_b = "BBBB\n" * 200 + p1 = tee_raw(content_a, "pytest -q tests/", tmp_path) + p2 = tee_raw(content_b, "pytest -q tests/", tmp_path) + assert p1 is not None and p2 is not None + assert p1 != p2 + assert "AAAA" in p1.read_text(encoding="utf-8") + assert "BBBB" in p2.read_text(encoding="utf-8") + + +def test_tee_rotation_keeps_newest(tmp_path: Path): + for i in range(MAX_TEE_FILES + 5): + (tmp_path / f"{1000 + i:010d}_old.log").write_text("x" * 600) + tee_raw("y" * 600, "new", tmp_path) + remaining = list(tmp_path.glob("*.log")) + assert len(remaining) == MAX_TEE_FILES + # The oldest files were removed. + assert not (tmp_path / f"{1000:010d}_old.log").exists() + + +def test_tee_truncates_at_utf8_boundary(tmp_path: Path): + big = "😀" * 300_000 # 1.2 MB of 4-byte chars + path = tee_raw(big, "emoji", tmp_path) + assert path is not None + text = path.read_text(encoding="utf-8") # must not raise + assert "--- truncated at" in text + + +def test_sanitize_slug(): + assert sanitize_slug("go test ./...") == "go_test______" + assert len(sanitize_slug("a" * 100)) == 40 + assert sanitize_slug("") == "cmd" + + +# ── filters: test_runner ───────────────────────────────────────────────────── + +PYTEST_FAIL = """============================= test session starts ============================== +platform darwin -- Python 3.11.6, pytest-8.0.0, pluggy-1.4.0 +rootdir: /work/proj +collected 120 items + +tests/test_auth.py ........................ [ 20%] +tests/test_core.py ..........F........................... [ 52%] +tests/test_util.py ......................................................[100%] + +=================================== FAILURES =================================== +_________________________________ test_refresh _________________________________ + + def test_refresh(): + token = make_token() +> assert refresh(token) is not None +E AssertionError: assert None is not None + +tests/test_core.py:88: AssertionError +=========================== short test summary info ============================ +FAILED tests/test_core.py::test_refresh - AssertionError: assert None is not... +==================== 1 failed, 119 passed in 4.32s ==================== +""" + +PYTEST_PASS = """============================= test session starts ============================== +collected 250 items + +tests/test_a.py ................................................ [ 40%] +tests/test_b.py ................................................ [ 80%] +tests/test_c.py .......................... [100%] + +============================= 250 passed in 12.50s ============================= +""" + + +def test_pytest_failure_focus_keeps_failures_and_saves(): + hit = filter_test_runner("pytest", 1, PYTEST_FAIL) + assert hit is not None and hit.name == "pytest" + assert "1 failed, 119 passed" in hit.body + assert "test_refresh" in hit.body + assert "AssertionError" in hit.body + assert "tests/test_core.py:88" in hit.body + # Passing progress lines are gone. + assert "test_auth.py" not in hit.body + assert _savings_pct(PYTEST_FAIL, hit.body) >= 60.0 + + +def test_pytest_all_pass_one_liner(): + hit = filter_test_runner("pytest", 0, PYTEST_PASS) + assert hit is not None + assert hit.body == "Pytest: 250 passed in 12.50s" + assert _savings_pct(PYTEST_PASS, hit.body) >= 60.0 + + +def test_pytest_quiet_mode_summary_without_wrapper(): + text = "..........\n10 passed in 0.10s\n" + hit = filter_test_runner("pytest -q", 0, text) + assert hit is not None + assert "10 passed" in hit.body + + +def test_test_runner_refuses_without_summary(): + assert filter_test_runner("pytest", 1, "Traceback...\nboom\n") is None + + +def test_green_summary_with_failing_exit_passes_through(): + """Critic M3: a green summary + non-zero exit means something else in the + output failed — never mask it behind an all-green one-liner.""" + assert filter_test_runner("pytest && cargo build", 1, PYTEST_PASS) is None + cargo_green = ( + "running 3 tests\ntest a ... ok\n\n" + "test result: ok. 3 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s\n" + ) + assert filter_test_runner("cargo test", 101, cargo_green) is None + assert filter_test_runner("go test ./...", 1, "ok \texample.com/a\t0.1s\n") is None + jest_green = "Tests: 5 passed, 5 total\n" + assert filter_test_runner("npx jest", 1, jest_green) is None + + +CARGO_FAIL = """ Compiling proj v0.1.0 (/work/proj) + Finished test [unoptimized + debuginfo] target(s) in 2.31s + Running unittests src/lib.rs + +running 42 tests +test util::tests::test_parse ... ok +test util::tests::test_format ... ok +test core::tests::test_overflow ... FAILED + +failures: + +---- core::tests::test_overflow stdout ---- +thread 'core::tests::test_overflow' panicked at src/core.rs:99:5: +assertion `left == right` failed + left: 4 + right: 5 + +failures: + core::tests::test_overflow + +test result: FAILED. 41 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s +""" + + +def test_cargo_test_failure_focus(): + hit = filter_test_runner("cargo test", 101, CARGO_FAIL) + assert hit is not None and hit.name == "cargo-test" + assert "41 passed, 1 failed" in hit.body + assert "core::tests::test_overflow" in hit.body + assert "panicked at src/core.rs:99:5" in hit.body + assert "test_parse" not in hit.body # passing tests dropped + assert _savings_pct(CARGO_FAIL, hit.body) >= 50.0 + + +def test_cargo_test_pass_one_liner(): + text = "running 10 tests\n" + "test a ... ok\n" * 10 + ( + "\ntest result: ok. 10 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s\n" + ) + hit = filter_test_runner("cargo test", 0, text) + assert hit is not None + assert hit.body == "cargo test: ok — 10 passed" + + +GO_FAIL = """=== RUN TestParse +--- FAIL: TestParse (0.00s) + parse_test.go:21: expected 5, got 4 +=== RUN TestFormat +--- PASS: TestFormat (0.00s) +FAIL +FAIL\texample.com/pkg/util\t0.012s +ok \texample.com/pkg/core\t0.031s +""" + + +def test_go_test_failure_focus(): + hit = filter_test_runner("go test ./...", 1, GO_FAIL) + assert hit is not None and hit.name == "go-test" + assert "1 package(s) failed" in hit.body + assert "TestParse" in hit.body + assert "expected 5, got 4" in hit.body + assert "TestFormat" not in hit.body + + +def test_go_test_all_ok(): + text = "ok \texample.com/a\t0.1s\nok \texample.com/b\t0.2s\n" + hit = filter_test_runner("go test ./...", 0, text) + assert hit is not None + assert hit.body == "go test: ok — 2 packages" + + +JEST_FAIL = """ FAIL src/auth.test.ts + auth + ✓ signs in (12 ms) + ✕ refreshes token (5 ms) + + ● auth › refreshes token + + expect(received).toBe(expected) + + Expected: "ok" + Received: undefined + + at Object. (src/auth.test.ts:30:25) + + PASS src/util.test.ts + +Tests: 1 failed, 41 passed, 42 total +Snapshots: 0 total +Time: 3.214 s +""" + + +def test_jest_failure_focus(): + hit = filter_test_runner("npx jest", 1, JEST_FAIL) + assert hit is not None and hit.name == "jest" + assert "1 failed, 41 passed" in hit.body + assert "refreshes token" in hit.body + assert "Expected" in hit.body + + +# ── filters: noise_strip ───────────────────────────────────────────────────── + +PIP_NOISY = """Collecting requests + Downloading requests-2.31.0-py3-none-any.whl (62 kB) +Collecting idna<4,>=2.5 + Using cached idna-3.6-py3-none-any.whl (61 kB) +Requirement already satisfied: certifi in ./venv/lib (2024.2.2) +Installing collected packages: idna, requests +Successfully installed idna-3.6 requests-2.31.0 +""" + +GIT_PUSH_NOISY = """Enumerating objects: 12, done. +Counting objects: 100% (12/12), done. +Compressing objects: 100% (6/6), done. +Writing objects: 100% (7/7), 1.02 KiB | 1.02 MiB/s, done. +remote: Resolving deltas: 100% (4/4), completed with 4 local objects. +To github.com:me/proj.git + abc1234..def5678 main -> main +""" + + +def test_noise_strip_pip(): + hit = filter_noise_strip("pip install requests", 0, PIP_NOISY) + assert hit is not None and hit.safe_loss + assert "Successfully installed idna-3.6 requests-2.31.0" in hit.body + assert "Installing collected packages" in hit.body # informative, kept + assert "Downloading" not in hit.body + assert "Requirement already satisfied" not in hit.body + + +def test_noise_strip_git_push_keeps_result(): + hit = filter_noise_strip("git push", 0, GIT_PUSH_NOISY) + assert hit is not None + assert "main -> main" in hit.body + assert "To github.com:me/proj.git" in hit.body + assert "Enumerating objects" not in hit.body + assert "Resolving deltas" not in hit.body + + +def test_noise_strip_git_status_advice_lines(): + text = ( + "On branch main\n" + "Changes not staged for commit:\n" + ' (use "git add ..." to update what will be committed)\n' + ' (use "git restore ..." to discard changes)\n' + "\tmodified: src/app.py\n" + ) + hit = filter_noise_strip("git status", 0, text) + assert hit is not None + assert "modified: src/app.py" in hit.body + assert "use \"git add" not in hit.body + + +def test_noise_strip_none_when_clean(): + assert filter_noise_strip("echo hi", 0, "hi\n") is None + + +def test_noise_strip_scoped_to_command_family(): + """Critic M2: family patterns must NOT fire for unrelated commands — a + script legitimately printing 'Downloading x' keeps its line.""" + text = "Downloading dataset shard 3\nCollecting metrics: done\n" + assert filter_noise_strip("./run_pipeline.sh", 0, text) is None + assert filter_noise_strip("python train.py", 0, text) is None + # The same lines ARE ceremony when pip actually ran. + hit = filter_noise_strip("pip install foo", 0, text) + assert hit is not None and "Downloading" not in hit.body + + +def test_noise_strip_preserves_git_state_lines(): + """Fidelity rescue (RTK doc 05): in-progress state must survive.""" + text = ( + "interactive rebase in progress; onto abc1234\n" + "You are currently rebasing branch 'main' on 'abc1234'.\n" + ' (use "git commit --amend" to amend the current commit)\n' + "Unmerged paths:\n" + "\tboth modified: src/app.py\n" + "HEAD detached at abc1234\n" + ) + hit = filter_noise_strip("git status", 0, text) + assert hit is not None + assert "rebase in progress" in hit.body + assert "You are currently rebasing" in hit.body + assert "Unmerged paths:" in hit.body + assert "both modified: src/app.py" in hit.body + assert "HEAD detached at abc1234" in hit.body + assert "git commit --amend" not in hit.body # only the advice line drops + + +def test_noise_strip_preserves_errors(): + text = "Collecting bad-pkg\nERROR: No matching distribution found for bad-pkg\n" + hit = filter_noise_strip("pip install bad-pkg", 1, text) + assert hit is not None + assert "ERROR: No matching distribution found" in hit.body + + +# ── filters: log_dedup ─────────────────────────────────────────────────────── + + +def test_log_dedup_counts_repeats(): + lines = [] + for i in range(120): + lines.append(f"2024-01-01 10:00:{i % 60:02d} ERROR Connection failed to /api/server port 8080") + for i in range(30): + lines.append(f"2024-01-01 10:01:{i % 60:02d} INFO heartbeat ok seq {i}") + text = "\n".join(lines) + hit = filter_log_dedup("docker logs app", 0, text) + assert hit is not None and hit.name == "log-dedup" + assert "[×120]" in hit.body + assert "Connection failed" in hit.body + assert _savings_pct(text, hit.body) >= 80.0 + + +def test_log_dedup_ignores_short_output(): + text = "\n".join("2024-01-01 10:00:00 ERROR x" for _ in range(10)) + assert filter_log_dedup("cmd", 0, text) is None + + +def test_log_dedup_ignores_non_loggy_output(): + text = "\n".join(f"file_{i}.txt" for i in range(200)) + assert filter_log_dedup("ls", 0, text) is None + + +# ── filters: large_success_cap ─────────────────────────────────────────────── + + +def test_large_success_caps_with_tail_offset(): + text = "\n".join(f"item {i}" for i in range(1000)) + hit = filter_large_success("find .", 0, text) + assert hit is not None and hit.name == "head-cap" + assert hit.tail_offset == 61 + assert "item 0" in hit.body and "item 59" in hit.body + assert "item 60" not in hit.body.split("...")[0] + assert "+940 more lines" in hit.body + + +def test_large_success_skips_failures_and_small_output(): + text = "\n".join(f"item {i}" for i in range(1000)) + assert filter_large_success("find .", 2, text) is None + assert filter_large_success("ls", 0, "a\nb\nc") is None + + +# ── helpers ────────────────────────────────────────────────────────────────── + + +def test_strip_ansi_and_carriage_returns(): + assert strip_ansi("\x1b[31mred\x1b[0m plain") == "red plain" + assert normalize_carriage_returns("progress 10%\rprogress 99%\rdone\nnext") == "done\nnext" + + +# ── engine ─────────────────────────────────────────────────────────────────── + + +def test_engine_passthrough_on_plain_output(tmp_path: Path): + out = compress_bash_output("echo hi", 0, "hi", "hi", tmp_path) + assert out is None + assert eco_stats().commands == 0 + + +def test_engine_head_cap_requires_tee(tmp_path: Path): + text = "\n".join(f"line {i}" for i in range(1000)) + # With a tee dir: hit + runnable hint. + out = compress_bash_output("find .", 0, text, text, tmp_path) + assert out is not None + assert "[see remaining: tail -n +61 " in out.content + logs = list(tmp_path.glob("*.log")) + assert len(logs) == 1 + # The teed file contains the full processed text (offsets line up). + assert logs[0].read_text(encoding="utf-8").splitlines()[60] == "line 60" + # Without a tee dir: the lossy hit must be discarded. + reset_eco() + assert compress_bash_output("find .", 0, text, text, None) is None + assert eco_stats().commands == 0 + + +def test_engine_safe_loss_needs_no_tee(): + out = compress_bash_output( + "pip install requests", 0, PIP_NOISY, PIP_NOISY, None + ) + assert out is not None + assert out.filter_name == "noise-strip" + assert "[full output:" not in out.content + + +def test_engine_records_stats(): + compress_bash_output("pip install requests", 0, PIP_NOISY, PIP_NOISY, None) + stats = eco_stats() + assert stats.commands == 1 + assert stats.saved_tokens > 0 + assert "noise-strip" in stats.by_filter + + +def test_engine_never_worse_discards_bloaty_filter(monkeypatch, tmp_path: Path): + import src.eco.engine as engine_mod + + def bloaty(command, exit_code, text): + return FilterHit(name="bloat", body=text + "x" * 500, safe_loss=True) + + monkeypatch.setattr(engine_mod, "FILTERS", (bloaty,)) + assert compress_bash_output("cmd", 0, "small", "small", tmp_path) is None + + +def test_engine_guard_reject_leaves_no_orphan_tee(monkeypatch, tmp_path: Path): + """A lossy hit the guard rejects must not leave an unreferenced file.""" + import src.eco.engine as engine_mod + + text = "x" * 2000 # big enough to tee + + def barely_smaller(command, exit_code, t): + # Body smaller than baseline but not by enough to cover the hint. + return FilterHit(name="tight", body=t[:-8], safe_loss=False) + + monkeypatch.setattr(engine_mod, "FILTERS", (barely_smaller,)) + assert compress_bash_output("cmd", 0, text, text, tmp_path) is None + assert list(tmp_path.glob("*.log")) == [] + + +def test_engine_skips_giant_input(monkeypatch, tmp_path: Path): + import src.eco.engine as engine_mod + + called = [] + + def spy(command, exit_code, text): + called.append(1) + return None + + monkeypatch.setattr(engine_mod, "FILTERS", (spy,)) + giant = "x" * (engine_mod._MAX_INPUT_CHARS + 1) + assert compress_bash_output("cmd", 0, giant, "small", tmp_path) is None + assert called == [] + + +def test_engine_skips_empty_filter_output(monkeypatch, tmp_path: Path): + import src.eco.engine as engine_mod + + def empty(command, exit_code, text): + return FilterHit(name="empty", body=" ", safe_loss=True) + + monkeypatch.setattr(engine_mod, "FILTERS", (empty,)) + assert compress_bash_output("cmd", 0, "content", "content", tmp_path) is None + + +def test_engine_filter_exception_falls_through(monkeypatch, tmp_path: Path): + import src.eco.engine as engine_mod + + def boom(command, exit_code, text): + raise RuntimeError("bad filter") + + monkeypatch.setattr( + engine_mod, "FILTERS", (boom, engine_mod.FILTERS[2]) + ) # noise_strip after the broken one + out = compress_bash_output( + "pip install requests", 0, PIP_NOISY, PIP_NOISY, None + ) + assert out is not None and out.filter_name == "noise-strip" + + +def test_engine_strips_ansi_before_filtering(tmp_path: Path): + noisy = "\x1b[32mCollecting requests\x1b[0m\nSuccessfully installed requests\n" + out = compress_bash_output("pip install requests", 0, noisy, noisy, None) + assert out is not None + assert out.content == "Successfully installed requests" + + +# ── /eco command ───────────────────────────────────────────────────────────── + + +def _ctx(tmp_path: Path): + from src.command_system.types import CommandContext + + return CommandContext( + workspace_root=tmp_path, + cwd=tmp_path, + conversation=None, + cost_tracker=None, + history=None, + ) + + +def test_eco_command_toggle_and_status(tmp_path: Path): + from src.command_system.eco_command import eco_command_call + + ctx = _ctx(tmp_path) + r = eco_command_call("", ctx) + assert "Eco mode on" in r.value + assert is_eco_session() + r = eco_command_call("status", ctx) + assert "Eco mode: on" in r.value + r = eco_command_call("", ctx) + assert "Eco mode off" in r.value + assert not is_eco_session() + r = eco_command_call("on", ctx) + assert is_eco_session() + r = eco_command_call("off", ctx) + assert not is_eco_session() + r = eco_command_call("bogus", ctx) + assert "Unknown argument" in r.value + assert "Usage: /eco" in r.value + + +def test_eco_command_status_reports_stats(tmp_path: Path): + from src.command_system.eco_command import eco_command_call + + record_compression("pytest", 2000, 200) + r = eco_command_call("status", _ctx(tmp_path)) + assert "Compressed 1 command output(s)" in r.value + assert "pytest" in r.value + assert "90%" in r.value + + +def test_eco_command_registered(): + from src.command_system.builtins import get_builtin_commands + + names = [c.name for c in get_builtin_commands()] + assert "eco" in names diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 567af1304..252d5ecfb 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -345,6 +345,7 @@ const SLASHES: ReadonlyArray<{ desc: string; hint?: string; name: string }> = [ { desc: 'Compact the conversation to save context', name: '/compact' }, { desc: 'Show context-window usage', name: '/context' }, { desc: 'Show the total cost and duration of the current session', name: '/cost' }, + { desc: 'Toggle Bash-output token compression (RTK-style)', hint: '[on|off|status]', name: '/eco' }, { desc: 'Undo recent turns', hint: '[]', name: '/rewind' }, { desc: 'Toggle extended thinking', hint: '[on|off|toggle]', name: '/thinking' }, { @@ -1091,6 +1092,16 @@ export class GatewayClient extends EventEmitter { return out(formatTotalCost(r)) } + case 'eco': { + const r = (await this.controlQuery('eco', { arg: arg ?? '' })) as any + + if (!r || Object.keys(r).length === 0) {return out('eco: backend not ready')} + + if (r.ok === false) {return out(`eco: ${r.error ?? 'failed'}`)} + + return out(String(r.text ?? `Eco mode ${r.enabled ? 'on' : 'off'}.`)) + } + case 'effort': { const r = (await this.controlQuery('set_effort', { effort: arg ?? null })) as any