odysseus/tests/test_harmony_tool_aliasing.py
talentlesshack adfe3ab379
fix(llm): alias tool names that collide with gpt-oss built-ins (#5878)
gpt-oss (harmony) ships BUILT-IN tools named python/browser, invoked
with the raw body as the argument (to=python + bare source), while
custom functions use to=functions.NAME + JSON. Exposing our own tools
under those names makes the model answer with the built-in convention:
it emits raw code, the server parses it as JSON, and the request dies
with 'error parsing tool call: raw=import sys, ...'. In streaming mode
Ollama does not report it at all — it truncates the stream, so the turn
arrives as an empty response and the agent loop reads it as a model
stall. bash collides the same way in practice.

Measured on gpt-oss:20b via Ollama /v1, fixed agentic prompt, 12 runs
per arm: python+bash as-is 2/12, python renamed 10/12, both renamed
12/12. 74 HTTP 500s were logged server-side during investigation with
zero surfaced to the client.

Rename the colliding tools on the outbound payload and map the names
back on responses. Transport-only and gated on gpt-oss: every other
model's schemas pass through untouched (asserted in tests).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
2026-08-12 02:01:52 +01:00

78 lines
3.1 KiB
Python

"""Tools whose names collide with harmony built-ins must be aliased for gpt-oss.
gpt-oss (harmony format) ships BUILT-IN tools named `python` and `browser`,
invoked with the raw body as the argument (`to=python` + bare source), while
custom functions use `to=functions.NAME` + JSON. Exposing our own tool under a
built-in's name makes the model answer with the built-in convention: it emits
raw code, the server tries to parse it as JSON, and the request dies with
"error parsing tool call: raw='import sys, ...'". Streaming is worse — Ollama
truncates the stream instead of reporting it, so the turn looks like an empty
response and the agent loop reads it as a stall.
Measured on gpt-oss:20b via Ollama /v1 with a fixed agentic prompt:
python+bash as-is 2/6, python renamed 5/6, both renamed 6/6.
The aliasing is transport-only and gpt-oss-only: every other model's tool
schemas must pass through untouched, and real tool names must come back out.
"""
from src.llm_core import (
_alias_harmony_tools,
_unalias_harmony_tool_name,
_is_harmony_model,
)
def _tools(*names):
return [
{"type": "function", "function": {"name": n, "parameters": {}}}
for n in names
]
def _names(tools):
return [t["function"]["name"] for t in tools]
def test_gpt_oss_colliding_names_are_aliased():
out = _alias_harmony_tools(_tools("python", "bash", "web_search"), "gpt-oss:20b")
assert _names(out) == ["run_python_code", "run_shell_command", "web_search"]
def test_non_harmony_models_are_untouched():
tools = _tools("python", "bash", "web_search")
for model in ("qwen3-coder:30b", "gemma4:12b", "claude-opus-5", "gpt-4o", "llama-3.3"):
out = _alias_harmony_tools(tools, model)
assert _names(out) == ["python", "bash", "web_search"], model
assert out is tools, f"{model} should get the same list object, not a copy"
def test_aliasing_does_not_mutate_the_caller_list():
tools = _tools("python")
_alias_harmony_tools(tools, "gpt-oss:20b")
assert _names(tools) == ["python"], "caller's schema list must not be mutated"
def test_response_names_map_back_for_gpt_oss():
assert _unalias_harmony_tool_name("run_python_code", "gpt-oss:20b") == "python"
assert _unalias_harmony_tool_name("run_shell_command", "gpt-oss:20b") == "bash"
# Unrelated names pass through untouched.
assert _unalias_harmony_tool_name("web_search", "gpt-oss:20b") == "web_search"
def test_response_names_untouched_for_other_models():
# A non-harmony model that genuinely has a tool called run_python_code
# must not have it rewritten to `python`.
assert _unalias_harmony_tool_name("run_python_code", "qwen3-coder:30b") == "run_python_code"
def test_harmony_detection():
assert _is_harmony_model("gpt-oss:20b") is True
assert _is_harmony_model("GPT-OSS:120B") is True
assert _is_harmony_model("qwen3-coder:30b") is False
assert _is_harmony_model("") is False
assert _is_harmony_model(None) is False
def test_empty_and_none_tools_are_safe():
assert _alias_harmony_tools(None, "gpt-oss:20b") is None
assert _alias_harmony_tools([], "gpt-oss:20b") == []