fix(learn): keep traceback tail in tool-error digest preview (#2596)

## 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.
This commit is contained in:
TenderDeve 2026-07-27 19:14:51 +05:30 committed by GitHub
parent 18e1c3c9ba
commit 85e8699451
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 71 additions and 2 deletions

View file

@ -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

View file

@ -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