diff --git a/headroom/ccr/tool_calls.py b/headroom/ccr/tool_calls.py index 16fa18878..46e5feacb 100644 --- a/headroom/ccr/tool_calls.py +++ b/headroom/ccr/tool_calls.py @@ -73,8 +73,8 @@ def is_ccr_tool_call(tool_call: dict[str, Any]) -> bool: """Return true when a provider-native tool call names the CCR retrieval tool.""" return ( tool_call.get("name") == CCR_TOOL_NAME - or tool_call.get("function", {}).get("name") == CCR_TOOL_NAME - or tool_call.get("functionCall", {}).get("name") == CCR_TOOL_NAME + or (tool_call.get("function") or {}).get("name") == CCR_TOOL_NAME + or (tool_call.get("functionCall") or {}).get("name") == CCR_TOOL_NAME ) diff --git a/headroom/ccr/tool_injection.py b/headroom/ccr/tool_injection.py index 90a83c9a6..20337285d 100644 --- a/headroom/ccr/tool_injection.py +++ b/headroom/ccr/tool_injection.py @@ -467,7 +467,10 @@ def parse_tool_call( name = tool_call.get("name") input_data = tool_call.get("input", {}) elif provider == "openai": - function = tool_call.get("function", {}) + # `get("function", {})` returns None for an explicit {"function": null} + # (the default only applies to a missing key), so `.get` below would + # raise AttributeError on a malformed/partial tool call. Coalesce to {}. + function = tool_call.get("function") or {} name = function.get("name") # OpenAI passes args as JSON string args_str = function.get("arguments", "{}") @@ -478,7 +481,8 @@ def parse_tool_call( input_data = {} elif provider == "google": # Google/Gemini format: {"functionCall": {"name": "...", "args": {...}}} - function_call = tool_call.get("functionCall", {}) + # Coalesce to {} so an explicit {"functionCall": null} does not crash. + function_call = tool_call.get("functionCall") or {} name = function_call.get("name") input_data = function_call.get("args", {}) elif provider == "openai_responses": diff --git a/tests/test_ccr_tool_calls.py b/tests/test_ccr_tool_calls.py index b70b16320..bf2522b42 100644 --- a/tests/test_ccr_tool_calls.py +++ b/tests/test_ccr_tool_calls.py @@ -58,6 +58,36 @@ def test_has_ccr_tool_calls_uses_provider_native_names() -> None: ) +def test_ccr_detection_survives_null_function_tool_call() -> None: + # A partial/streamed OpenAI tool call with an explicit {"function": null} + # must not crash detection: dict.get("function", {}) returns None for a + # present-but-null key, and .get on None raises AttributeError. + response = { + "choices": [ + { + "message": { + "tool_calls": [ + {"id": "call_1", "type": "function", "function": None}, + { + "id": "call_2", + "type": "function", + "function": { + "name": CCR_TOOL_NAME, + "arguments": '{"hash": "' + HASH + '"}', + }, + }, + ] + } + } + ] + } + + assert has_ccr_tool_calls(response, "openai") + ccr_calls, other_calls = parse_ccr_tool_calls(response, "openai") + assert ccr_calls == [CCRToolCall(tool_call_id="call_2", hash_key=HASH)] + assert other_calls == [{"id": "call_1", "type": "function", "function": None}] + + def test_parse_ccr_tool_calls_splits_retrievals_from_other_tools() -> None: response = { "content": [ diff --git a/tests/test_ccr_tool_injection.py b/tests/test_ccr_tool_injection.py index d4506bc33..6bf2ccc13 100644 --- a/tests/test_ccr_tool_injection.py +++ b/tests/test_ccr_tool_injection.py @@ -310,6 +310,12 @@ class TestParseToolCall: assert hash_key == "def456abc123def456abc123" + def test_parse_null_function_returns_none_without_crashing(self): + """A tool call with an explicit {"function": null} / {"functionCall": null} + must return None, not raise AttributeError.""" + assert parse_tool_call({"id": "c1", "function": None}, "openai") is None + assert parse_tool_call({"functionCall": None}, "google") is None + def test_parse_normalises_uppercase_hash_to_lowercase(self): """An uppercase hash echoed by the model must be lowercased so it matches the store (which keys entries by a lowercase hash)."""