mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(proxy/memory): don't crash memory tool-call detection on a null function (#2272)
## Description
`MemoryHandler` crashes when an upstream response carries a tool call
whose `function` field is explicitly `null`.
Three sites read the nested name like `tool_call.get("function",
{}).get("name")`:
- `has_memory_tool_calls` (line ~1043) — over the response's tool calls.
- `handle_tool_calls` (line ~1110/1119) — resolving the tool name and
arguments.
- the memory tool-injection dedup (line ~562) — over the request's
tools.
`dict.get("function", {})` only substitutes `{}` for a *missing* key. A
present-but-null `{"id": "c1", "type": "function", "function": null}` —
a shape upstreams and gateways emit for a partial or streamed tool call
— makes the result `None`, and `None.get("name")` raises
`AttributeError`.
`has_memory_tool_calls` and `handle_tool_calls` both iterate the
untrusted upstream response, so a single malformed tool call takes down
memory tool-call detection and handling for the whole response.
## Fix
Coalesce `function` with `or {}` at all three sites, so a null (or any
falsy) value collapses to `{}`:
```python
name = tc.get("name") or (tc.get("function") or {}).get("name")
args_str = tc.get("arguments") or (tc.get("function") or {}).get("arguments") or "{}"
```
Real tool calls resolve exactly as before.
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/proxy/memory_handler.py`: coalesce `function` with `or {}`
in `has_memory_tool_calls`, `handle_tool_calls`, and the tool-injection
dedup.
- `tests/test_memory_handler_null_function.py`: new tests that a
null-function tool call doesn't crash detection and the real memory call
is still seen.
- `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/proxy/memory_handler.py tests/test_memory_handler_null_function.py
All checks passed!
$ uvx mypy@1.20.2 --ignore-missing-imports headroom/proxy/memory_handler.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 name-resolution logic with a dependency-free script and
left the full pytest to CI.
- Exact command / steps: ran a `{"function": null}` tool call (plus a
real `memory_save` call) through the OLD `get("function", {})` and NEW
`get("function") or {}` name resolution.
- Observed result: OLD raises `AttributeError` on the null function; NEW
returns `None` for it and still resolves the real `memory_save` name and
a plain `{"name": "memory"}`.
- Not tested: a live upstream emitting 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. `has_memory_tool_calls`
and `_extract_tool_calls` use no instance state, so the new test
exercises them on a bare instance via `object.__new__` — it runs under
the normal CI pytest job, and the standalone proof above corroborates
it. This is the memory-handler sibling of the same null-`function`
hazard I'm fixing in the CCR tool-call detection and the memory tool
adapter.
---------
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
This commit is contained in:
parent
ef1e7e403b
commit
8b7e797ed4
2 changed files with 48 additions and 4 deletions
|
|
@ -558,7 +558,7 @@ class MemoryHandler:
|
|||
# Check which tools are already present
|
||||
existing_names: set[str] = set()
|
||||
for tool in tools:
|
||||
name = tool.get("name") or tool.get("function", {}).get("name")
|
||||
name = tool.get("name") or (tool.get("function") or {}).get("name")
|
||||
if name:
|
||||
existing_names.add(name)
|
||||
|
||||
|
|
@ -1039,7 +1039,9 @@ your responses, not to drive new actions."""
|
|||
"""Check if response contains memory tool calls."""
|
||||
tool_calls = self._extract_tool_calls(response, provider)
|
||||
for tc in tool_calls:
|
||||
name = tc.get("name") or tc.get("function", {}).get("name")
|
||||
# Coalesce `function` with `or {}` so an explicit {"function": null}
|
||||
# on a malformed/partial upstream tool call doesn't crash detection.
|
||||
name = tc.get("name") or (tc.get("function") or {}).get("name")
|
||||
# Check for both custom and native memory tools
|
||||
if name in MEMORY_TOOL_NAMES or name == NATIVE_MEMORY_TOOL_NAME:
|
||||
return True
|
||||
|
|
@ -1106,7 +1108,11 @@ your responses, not to drive new actions."""
|
|||
results: list[dict[str, Any]] = []
|
||||
|
||||
for tc in tool_calls:
|
||||
tool_name = tc.get("name") or tc.get("function", {}).get("name")
|
||||
# `tc.get("function", {})` returns None for an explicit
|
||||
# {"function": null} (the default only applies to a missing key), so
|
||||
# the following `.get` would raise AttributeError on a malformed /
|
||||
# partial upstream tool call. Coalesce to {}.
|
||||
tool_name = tc.get("name") or (tc.get("function") or {}).get("name")
|
||||
tool_id = tc.get("id") or tc.get("call_id", "")
|
||||
|
||||
# Parse input data
|
||||
|
|
@ -1115,7 +1121,9 @@ your responses, not to drive new actions."""
|
|||
else:
|
||||
# Chat Completions format: function.arguments
|
||||
# Responses API format: arguments (top-level string)
|
||||
args_str = tc.get("arguments") or tc.get("function", {}).get("arguments") or "{}"
|
||||
args_str = (
|
||||
tc.get("arguments") or (tc.get("function") or {}).get("arguments") or "{}"
|
||||
)
|
||||
try:
|
||||
input_data = json.loads(args_str)
|
||||
except json.JSONDecodeError:
|
||||
|
|
|
|||
36
tests/test_memory_handler_null_function.py
Normal file
36
tests/test_memory_handler_null_function.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
"""A tool call with a null ``function`` must not crash memory tool-call
|
||||
detection in ``MemoryHandler``.
|
||||
|
||||
``tc.get("function", {}).get("name")`` raises ``AttributeError`` on an explicit
|
||||
``{"function": null}`` (the default only applies to a missing key). Both
|
||||
``has_memory_tool_calls`` and the arg extraction in ``handle_tool_calls`` read
|
||||
that shape from the untrusted upstream response. ``has_memory_tool_calls`` and
|
||||
``_extract_tool_calls`` use no instance state, so we exercise them on a bare
|
||||
instance via ``object.__new__``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from headroom.proxy.memory_handler import MemoryHandler
|
||||
|
||||
_handler = object.__new__(MemoryHandler)
|
||||
|
||||
|
||||
def _openai_response(tool_calls):
|
||||
return {"choices": [{"message": {"tool_calls": tool_calls}}]}
|
||||
|
||||
|
||||
def test_has_memory_tool_calls_survives_null_function():
|
||||
response = _openai_response(
|
||||
[
|
||||
{"id": "c1", "type": "function", "function": None},
|
||||
{"id": "c2", "type": "function", "function": {"name": "memory_save"}},
|
||||
]
|
||||
)
|
||||
# Must not raise, and must still see the real memory tool call.
|
||||
assert _handler.has_memory_tool_calls(response, "openai") is True
|
||||
|
||||
|
||||
def test_has_memory_tool_calls_all_null_functions_is_false():
|
||||
response = _openai_response([{"id": "c1", "type": "function", "function": None}])
|
||||
assert _handler.has_memory_tool_calls(response, "openai") is False
|
||||
Loading…
Add table
Add a link
Reference in a new issue