diff --git a/headroom/transforms/smart_crusher.py b/headroom/transforms/smart_crusher.py index 4dde88736..6ec0e4443 100644 --- a/headroom/transforms/smart_crusher.py +++ b/headroom/transforms/smart_crusher.py @@ -1202,7 +1202,12 @@ class SmartCrusher(Transform): if msg.get("role") == "assistant" and msg.get("tool_calls"): for tc in msg.get("tool_calls", []): if isinstance(tc, dict): - func = tc.get("function", {}) + # `tc.get("function", {})` returns None for an explicit + # {"function": null} (the default only applies to a + # missing key), and `.get` on None raises AttributeError, + # crashing apply(). Use the null-safe form the sibling + # `_build_tool_name_index` already uses (line ~118). + func = tc.get("function") or {} args = func.get("arguments", "") if isinstance(args, str) and args: context_parts.append(args) diff --git a/tests/test_transforms/test_smart_crusher_bugs.py b/tests/test_transforms/test_smart_crusher_bugs.py index 616806d99..0981f2cba 100644 --- a/tests/test_transforms/test_smart_crusher_bugs.py +++ b/tests/test_transforms/test_smart_crusher_bugs.py @@ -163,6 +163,26 @@ class TestLosslessOnlyMode: assert json.loads(out.compressed) == rows +def test_extract_context_survives_null_function_tool_call() -> None: + # A tool_call with an explicit {"function": null} must not crash context + # extraction: `dict.get("function", {})` returns None for a present-but-null + # key, and `.get` on None raises AttributeError inside apply(). + crusher = _make_crusher() + messages = [ + { + "role": "assistant", + "tool_calls": [ + {"id": "1", "type": "function", "function": None}, + {"id": "2", "type": "function", "function": {"arguments": "keep-me"}}, + ], + }, + ] + + ctx = crusher._extract_context_from_messages(messages) + + assert "keep-me" in ctx + + # Stage 3c.1 lockstep bug-fix tests previously lived here; they probed # Python helpers (`_percentile_linear`, `_detect_sequential_pattern`, # `_detect_rare_status_values`, `_compute_k_split`) that were removed