mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
18e1c3c9ba
commit
85e8699451
2 changed files with 71 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue