diff --git a/slime/agent/adapters/common.py b/slime/agent/adapters/common.py index ed5d2e06a4..c84c5b7ec5 100644 --- a/slime/agent/adapters/common.py +++ b/slime/agent/adapters/common.py @@ -95,6 +95,28 @@ def json_arguments(value: Any) -> str: return json.dumps(value, ensure_ascii=False) +def dict_arguments(value: Any) -> dict: + """Tool-call arguments for chat-template rendering. + + OpenAI wire responses carry ``function.arguments`` as a JSON string; Qwen-family + chat templates expect a mapping and iterate ``arguments.items()``. Decode echoed + wire strings back to dicts at the render boundary. + """ + if isinstance(value, dict): + return value + if isinstance(value, str): + try: + decoded = json.loads(value or "{}") + except json.JSONDecodeError: + decoded = value + if isinstance(decoded, dict): + return decoded + return {"_raw_arguments": decoded} + if value is None: + return {} + return {"_raw_arguments": value} + + def render_token_ids(chain: AdapterChain, tokenizer) -> list[int]: enc = tokenizer.apply_chat_template( chain.chat_messages, diff --git a/slime/agent/adapters/openai.py b/slime/agent/adapters/openai.py index f9d91d8ff5..e27bedeeaa 100644 --- a/slime/agent/adapters/openai.py +++ b/slime/agent/adapters/openai.py @@ -23,6 +23,7 @@ from slime.agent.adapters.common import ADAPTER_KEY, REASONING_PARSER_KEY, TOKENIZER_KEY, TOOL_PARSER_KEY from slime.agent.adapters.common import AdapterChain as Chain from slime.agent.adapters.common import BaseAdapter, call_sglang_generate +from slime.agent.adapters.common import dict_arguments as _dict_arguments from slime.agent.adapters.common import json_arguments as _json_arguments from slime.agent.adapters.common import ok_response, render_token_ids, request_session_id from slime.agent.adapters.common import stable_hash as _hash @@ -105,7 +106,7 @@ def _normalize_tool_call(call: dict[str, Any]) -> dict[str, Any]: "type": "function", "function": { "name": name, - "arguments": _json_arguments(arguments), + "arguments": _dict_arguments(arguments), }, } if call.get("id"): diff --git a/tests/test_agent_adapters.py b/tests/test_agent_adapters.py index 47906c2e82..05d7fcccd8 100644 --- a/tests/test_agent_adapters.py +++ b/tests/test_agent_adapters.py @@ -12,7 +12,7 @@ sys.path.insert(0, str(REPO_ROOT)) from slime.agent.adapters import anthropic, openai -from slime.agent.adapters.common import SGLANG_URL_KEY +from slime.agent.adapters.common import SGLANG_URL_KEY, dict_arguments from slime.agent.trajectory import TurnRecord @@ -167,6 +167,20 @@ def test_anthropic_translation_keeps_tool_results_and_tool_schema(): ] +@pytest.mark.unit +def test_openai_render_tool_call_arguments_are_dicts(): + # Echoed wire JSON string is decoded back to a mapping for chat-template rendering. + normalized = openai._normalize_tool_call({"function": {"name": "lookup", "arguments": '{"q": "slime"}'}}) + assert normalized["function"]["arguments"] == {"q": "slime"} + # Valid-JSON-but-non-dict arguments are wrapped so ``arguments.items()`` stays safe. + wrapped = openai._normalize_tool_call({"function": {"name": "lookup", "arguments": "[1, 2]"}}) + assert wrapped["function"]["arguments"] == {"_raw_arguments": [1, 2]} + # Falsy native non-dicts are still real argument values; only None means "no arguments". + assert dict_arguments(0) == {"_raw_arguments": 0} + assert dict_arguments([]) == {"_raw_arguments": []} + assert dict_arguments(None) == {} + + @pytest.mark.unit def test_openai_translation_and_responses_input_shapes(): chat_messages = openai._translate_chat_messages( @@ -205,7 +219,7 @@ def test_openai_translation_and_responses_input_shapes(): { "id": "call_1", "type": "function", - "function": {"name": "lookup", "arguments": '{"q": "slime"}'}, + "function": {"name": "lookup", "arguments": {"q": "slime"}}, } ], },