fix(transforms/smart_crusher): don't crash on a tool call with a null function (#2232)
## Description
A tool call whose `function` field is explicitly `null` crashes
SmartCrusher's per-request context extraction.
`_extract_context_from_messages` (called at the top of `apply()`) walks
recent assistant tool calls:
```python
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict):
func = tc.get("function", {})
args = func.get("arguments", "")
```
`dict.get("function", {})` only substitutes `{}` when the key is
**missing**. When the key is present but `null` — `{"id": "1", "type":
"function", "function": null}`, which clients emit for a partial or
streamed tool call — `func` is `None`, and `None.get("arguments")`
raises `AttributeError`. That propagates out of
`_extract_context_from_messages` and crashes `apply()` for the entire
request, so the request either errors or has to fail open to
uncompressed with a logged traceback.
The sibling `_build_tool_name_index` in the same file already guards
this exact shape with `(tc.get("function") or {})` — this call site just
wasn't updated to match.
## Fix
Use the same null-safe form:
```python
func = tc.get("function") or {}
```
`None` (and any other falsy value) now collapses to `{}`, the null tool
call contributes no context, and extraction continues to the next call.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- `headroom/transforms/smart_crusher.py`: `tc.get("function", {})` →
`tc.get("function") or {}` in `_extract_context_from_messages`.
- `tests/test_transforms/test_smart_crusher_bugs.py`: new test asserting
a `{"function": null}` tool call doesn't crash extraction and later
calls are still read.
- `CHANGELOG.md`: Bug Fixes entry.
## Testing
- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed
### Test Output
```text
$ uvx ruff@0.15.17 check headroom/transforms/smart_crusher.py tests/test_transforms/test_smart_crusher_bugs.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/transforms/smart_crusher.py
Success: no issues found in 1 source file
```
## Real Behavior Proof
- Environment: Windows 11, Python 3.12, `uvx ruff@0.15.17` / `uvx
mypy@1.20.2`. A full `pytest` OOM-kills this box (ML stack import), so I
reproduced the extraction loop with a dependency-free script and left
the full pytest to CI.
- Exact command / steps: ran an assistant message with tool calls
`[{"function": null}, {"function": {"arguments": "keep-me"}}]` through
the OLD `get("function", {})` loop and the NEW `get("function") or {}`
loop.
- Observed result: OLD raises `AttributeError` on the null function; NEW
skips it and returns `"keep-me"` from the following call.
- Not tested: a live proxy request carrying a null-function tool call;
full local `pytest` deferred to CI (OOM).
## 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
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
The "unit tests pass locally" box is unchecked because the full suite
imports the ML stack, which I can't run here. The new test uses the
existing `_make_crusher` helper in
`tests/test_transforms/test_smart_crusher_bugs.py`, so it runs under the
normal CI pytest job; behaviour is additionally verified by the
standalone proof above.
---------
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user