Skip to content

fix(proxy/memory): harden _extract_tool_calls against malformed choices and blocks#2483

Open
abhay-codes07 wants to merge 1 commit into
headroomlabs-ai:mainfrom
abhay-codes07:fix/memory-extract-tool-calls-malformed-choices
Open

fix(proxy/memory): harden _extract_tool_calls against malformed choices and blocks#2483
abhay-codes07 wants to merge 1 commit into
headroomlabs-ai:mainfrom
abhay-codes07:fix/memory-extract-tool-calls-malformed-choices

Conversation

@abhay-codes07

Copy link
Copy Markdown
Contributor

Description

MemoryHandler._extract_tool_calls reads tool calls from an upstream response. The OpenAI Chat Completions branch only truthiness-checked choices before indexing:

choices = response.get("choices", [])
if choices:
    message = choices[0].get("message", {})
    ...

An OpenAI-compatible gateway can send choices: [null] on a content-filtered or usage-only response (the same shape the sibling CCRResponseHandler._extract_assistant_message was explicitly hardened for, see its comment: "a present-but-empty choices: [] (or [null]) which OpenAI-compatible gateways can send"). With [null], choices is truthy, so choices[0].get("message", {}) runs on None and raises AttributeError. A non-dict first element (choices: ["..."]) breaks the same way.

This is not caught locally: _extract_tool_calls runs from has_memory_tool_calls, which the OpenAI handler calls in the if (...) condition before the try that wraps the rest of memory handling, so the AttributeError surfaces to the request handler rather than being swallowed and logged.

The Anthropic branch had a smaller version of the same gap: [block for block in content if block.get("type") == "tool_use"] guards content for list-ness but not each block, so a null element in a reconstructed response crashes block.get.

Fix

Guard the first choice and its message for dict-ness before calling .get, mirroring _extract_assistant_message. A malformed choice now yields no tool calls and correctly falls through to the Responses-API output[] check. Skip non-dict blocks in the Anthropic branch. Well-formed responses are unchanged.

Type of Change

  • 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: dict-guard the first choice and its message in the OpenAI branch of _extract_tool_calls; skip non-dict content blocks in the Anthropic branch.
  • tests/test_memory_handler_null_function.py: regressions for choices: [null] / non-dict choice / null message, fall-through to a Responses output[] function_call, and a non-dict Anthropic content block.

Testing

  • Unit tests pass (pytest)
  • Linting passes (ruff check .)
  • Type checking passes (mypy headroom)
  • New tests added for new functionality
  • Manual testing performed

Test Output

$ python -m pytest tests/test_memory_handler_null_function.py -q
5 passed

# with the fix reverted, the three new tests fail with
# AttributeError: 'NoneType' object has no attribute 'get'

$ 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, project venv (uv sync --extra proxy), uvx ruff@0.15.17 / uvx mypy@1.20.2, pytest in the venv.
  • Exact command / steps: called _extract_tool_calls / has_memory_tool_calls on a bare MemoryHandler (via object.__new__) with choices: [null], choices: ["x"], choices: [{"message": null}], a [null]-choices response that also carries a Responses output[] function_call, and an Anthropic response whose content list starts with a null block; then reverted memory_handler.py and re-ran.
  • Observed result: with the fix the malformed choices return [] / False, the output[] fall-through is still detected as True, and the valid Anthropic tool_use block is returned; with the fix reverted the choices: [null] and non-dict-block cases raise AttributeError: 'NoneType' object has no attribute 'get'. Ran against the actual module via tests/test_memory_handler_null_function.py.
  • Not tested: a live OpenAI-compatible gateway emitting choices: [null] on a memory-enabled request, end to end.

Review Readiness

  • I have performed a self-review
  • This PR is ready for human review

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • 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
  • I have updated the CHANGELOG.md if applicable

…es and blocks

The OpenAI branch did choices[0].get("message", {}) after only a truthiness
check on choices, so a choices: [null] (which OpenAI-compatible gateways send
on a content-filtered / usage-only response) made choices[0].get raise
AttributeError. This runs from has_memory_tool_calls, which is called outside
the try that wraps the rest of memory handling, so the error reached the
request handler. Guard the first choice and its message for dict-ness, the
same way the sibling CCRResponseHandler._extract_assistant_message already
does. Also skip non-dict blocks in the Anthropic branch so a null content
block from a reconstructed response can't crash the block.get("type") filter.
Copilot AI review requested due to automatic review settings July 22, 2026 06:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions

Copy link
Copy Markdown
Contributor

PR governance

This PR follows the template and is marked ready for human review.

@github-actions github-actions Bot added the status: ready for review Pull request body is complete and the author marked it ready for human review label Jul 22, 2026
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 88.88889% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
headroom/proxy/memory_handler.py 88.88% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@JerrettDavis JerrettDavis left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for hardening the outer choices and Anthropic block shapes. There is one remaining malformed upstream shape in the OpenAI branch that still crashes the same detection path:

response = {"choices": [{"message": {"tool_calls": [None]}}]}
handler._extract_tool_calls(response, "openai")  # returns [None]
handler.has_memory_tool_calls(response, "openai")  # AttributeError: 'NoneType' object has no attribute 'get'

I verified that locally on the latest branch. _extract_tool_calls now guards choices[0] and message, but it still returns every element from message.tool_calls without checking the element type. Since both has_memory_tool_calls and handle_memory_tool_calls immediately call .get on each returned entry, a null or string inside the tool-call list still escapes and can 500 before the memory handling try block.

Please filter OpenAI Chat Completions tool_calls to dict entries before returning them, and add a regression for tool_calls: [None, {valid memory_save call}] that proves the malformed element is skipped while the valid call is still detected.

Other checked state: PR is mergeable and visible CI is green. I did not run the full test file after finding this blocker; the reproduction above is from the checked-out latest source.

@github-actions github-actions Bot removed the status: ready for review Pull request body is complete and the author marked it ready for human review label Jul 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants