From 85e869945138f06471501046c5725eac119dea58 Mon Sep 17 00:00:00 2001 From: TenderDeve Date: Mon, 27 Jul 2026 19:14:51 +0530 Subject: [PATCH] fix(learn): keep traceback tail in tool-error digest preview (#2596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `_format_tool_call` in `headroom/learn/analyzer.py` built the error preview with a head-only slice — `tc.output[:200]`. For tracebacks the root cause (`ExceptionType: message`) is at the **tail**, so the digest showed only `Traceback (most recent call last):` plus the first frame and dropped the actual diagnosis. The issue reports 46% of 715 measured errors were truncated past the 200-char head. Closes #2590 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - Added `_truncate_head_tail()` helper that collapses newlines and, when over budget, keeps both the head and the tail joined by `…`. - `_format_tool_call` now uses it for error output so the exception line survives truncation. Short errors are returned unchanged (no marker). ## Testing - [x] Unit tests pass (`pytest`) - [x] Linting passes (`ruff check .`) - [ ] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality ### Test Output ```text $ uv run pytest tests/test_learn/test_analyzer.py::TestDigestBuilder -q 9 passed in 1.47s $ uv run ruff check headroom/learn/analyzer.py tests/test_learn/test_analyzer.py All checks passed! ``` ## Real Behavior Proof - Environment: headroom @ main, Python 3.14, uv - Exact command / steps: added a long synthetic traceback (`KeyError: 'the-actual-root-cause'` at the tail) as a failing tool call and built the digest. - Observed result: digest now contains both `Traceback` and `KeyError: 'the-actual-root-cause'`, separated by `…`; short errors have no `…`. - Not tested: mypy not run locally. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review ## Checklist - [x] My code follows the project's style guidelines - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [x] I did **not** edit `CHANGELOG.md` ## Additional Notes Truncation budget stays at 200 chars (now split head/tail). mypy not run locally; happy to adjust if CI flags anything. --- headroom/learn/analyzer.py | 25 ++++++++++++++-- tests/test_learn/test_analyzer.py | 48 +++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/headroom/learn/analyzer.py b/headroom/learn/analyzer.py index 92bd4fb35..8fea17b4e 100644 --- a/headroom/learn/analyzer.py +++ b/headroom/learn/analyzer.py @@ -371,6 +371,26 @@ def _format_event(event: SessionEvent) -> str | None: return None +_ERROR_PREVIEW_MAX = 200 + + +def _truncate_head_tail(text: str, max_chars: int = _ERROR_PREVIEW_MAX) -> str: + """Collapse newlines and truncate, keeping both the head and the tail. + + A head-only slice drops the end of a traceback, which is exactly where the + root cause (``ExceptionType: message``) lives, so the digest would show only + the preamble and lose the diagnosis (see #2590). Keep both ends instead. + """ + text = text.replace("\n", " ").strip() + if len(text) <= max_chars: + return text + sep = " … " + keep = max_chars - len(sep) + head = keep // 2 + tail = keep - head + return f"{text[:head].rstrip()}{sep}{text[-tail:].lstrip()}" + + def _format_tool_call(tc: ToolCall) -> str: """Format a single tool call into a compact digest line.""" status = "ERROR" if tc.is_error else "OK" @@ -380,8 +400,9 @@ def _format_tool_call(tc: ToolCall) -> str: input_str = tc.input_summary[:120] if tc.is_error: - # Include truncated error output for failures - output_preview = tc.output[:200].replace("\n", " ").strip() + # Include truncated error output for failures, keeping the tail so a + # traceback's root cause survives (#2590). + output_preview = _truncate_head_tail(tc.output) return f" [{tc.msg_index}] {tc.name}: {input_str} → {status}{error_cat}: {output_preview}" else: # Just indicate success with size diff --git a/tests/test_learn/test_analyzer.py b/tests/test_learn/test_analyzer.py index 4230755b4..2d711227f 100644 --- a/tests/test_learn/test_analyzer.py +++ b/tests/test_learn/test_analyzer.py @@ -155,6 +155,54 @@ class TestDigestBuilder: digest = _build_digest(_project(), []) assert "0 sessions" in digest or "test-project" in digest + def test_long_error_output_preserves_tail_root_cause(self): + # A traceback's diagnosis lives at the tail; a head-only slice would drop it (#2590). + traceback = ( + "Traceback (most recent call last):\n" + + "\n".join( + f' File "mod{i}.py", line {i}, in fn{i}\n call_{i}()' for i in range(40) + ) + + "\nKeyError: 'the-actual-root-cause'" + ) + sessions = [ + SessionData( + session_id="s1", + tool_calls=[ + _tc( + name="Bash", + output=traceback, + is_error=True, + error_category=ErrorCategory.UNKNOWN, + msg_index=0, + ) + ], + ) + ] + digest = _build_digest(_project(), sessions) + assert "Traceback" in digest + assert "KeyError: 'the-actual-root-cause'" in digest + assert "…" in digest + + def test_short_error_output_not_truncated(self): + short = "ModuleNotFoundError: No module named 'foo'" + sessions = [ + SessionData( + session_id="s1", + tool_calls=[ + _tc( + name="Bash", + output=short, + is_error=True, + error_category=ErrorCategory.MODULE_NOT_FOUND, + msg_index=0, + ) + ], + ) + ] + digest = _build_digest(_project(), sessions) + assert short in digest + assert "…" not in digest + # ============================================================================= # Prior Patterns Injection Tests