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