diff --git a/slime/agent/adapters/anthropic.py b/slime/agent/adapters/anthropic.py index 0a48b09fd5..380d910069 100644 --- a/slime/agent/adapters/anthropic.py +++ b/slime/agent/adapters/anthropic.py @@ -36,6 +36,11 @@ class -> translation -> reply building -> request framing) is shared between logger = logging.getLogger(__name__) +def _representable_tool_uses(tool_uses: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Keep only calls whose arguments can be represented as an input object.""" + return [tu for tu in tool_uses if tu.get("arguments_valid", isinstance(tu.get("input"), dict))] + + class AnthropicAdapter(BaseAdapter): """Anthropic Messages-compatible HTTP adapter: wire translation and reply framing only; the turn machinery is inherited from BaseAdapter.""" @@ -64,7 +69,7 @@ def _build_reply(self, parsed, raw_finish, translated, tools_schema) -> Reply: blocks, stop_reason, manager_message = _build_reply_parts(parsed, raw_finish) return Reply( manager_message=manager_message, - finish_reason=manager_finish_reason(parsed.tool_uses, raw_finish), + finish_reason=manager_finish_reason(_representable_tool_uses(parsed.tool_uses), raw_finish), wire=(blocks, stop_reason), ) @@ -160,8 +165,9 @@ def _build_reply_parts( if parsed.text: blocks.append({"type": "text", "text": parsed.text}) + representable_tool_uses = _representable_tool_uses(parsed.tool_uses) manager_tcs: list[dict] = [] - for tu in parsed.tool_uses: + for tu in representable_tool_uses: tu_id = f"toolu_{secrets.token_hex(8)}" blocks.append({"type": "tool_use", "id": tu_id, "name": tu["name"], "input": tu["input"]}) # tu_id is wire-only; tool_call_dict drops it so the leaf matches its echo @@ -170,7 +176,7 @@ def _build_reply_parts( if not blocks: blocks.append({"type": "text", "text": ""}) - if parsed.tool_uses: + if representable_tool_uses: stop_reason = "tool_use" elif finish == "length": stop_reason = "max_tokens" diff --git a/slime/agent/adapters/openai.py b/slime/agent/adapters/openai.py index ad3d2e4d87..9f652eb1ad 100644 --- a/slime/agent/adapters/openai.py +++ b/slime/agent/adapters/openai.py @@ -226,6 +226,12 @@ def _build_reply_parts(parsed: ParsedModelOutput, finish: str) -> tuple[dict[str args_dict = tu.get("input") or {} if not isinstance(args_dict, dict): args_dict = {"_raw_arguments": str(args_dict)} + raw_arguments = tu.get("raw_arguments") + wire_arguments = ( + raw_arguments + if isinstance(raw_arguments, str) + else json.dumps(args_dict, ensure_ascii=False, sort_keys=True) + ) call_id = f"call_{secrets.token_hex(12)}" wire_tool_calls.append( { @@ -233,7 +239,7 @@ def _build_reply_parts(parsed: ParsedModelOutput, finish: str) -> tuple[dict[str "type": "function", "function": { "name": name, - "arguments": json.dumps(args_dict, ensure_ascii=False, sort_keys=True), + "arguments": wire_arguments, }, } ) diff --git a/slime/agent/parsing.py b/slime/agent/parsing.py index 56bc85a4bc..abcd4014e4 100644 --- a/slime/agent/parsing.py +++ b/slime/agent/parsing.py @@ -22,6 +22,18 @@ class ParsedModelOutput: ill_formed: bool = False +def _parse_tool_arguments(raw_arguments: str | None) -> tuple[dict[str, Any], str, bool]: + """Return canonical mapping, original wire text, and object validity.""" + raw = raw_arguments if raw_arguments is not None else "{}" + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return {"_raw_arguments": raw}, raw, False + if not isinstance(parsed, dict): + return {"_raw_arguments": raw}, raw, False + return parsed, raw, True + + def parse_model_output( raw_output: str, *, @@ -77,12 +89,17 @@ def parse_tool_uses( except Exception: logger.exception("[agent.parsing] sglang tool-call parsing failed; falling back") for c in calls: - try: - args = json.loads(c.parameters or "{}") - except json.JSONDecodeError: - args = {"_raw_arguments": c.parameters} + args, raw_arguments, arguments_valid = _parse_tool_arguments(c.parameters) + if not arguments_valid: ill_formed = True - tool_uses.append({"name": c.name or "tool", "input": args}) + tool_uses.append( + { + "name": c.name or "tool", + "input": args, + "raw_arguments": raw_arguments, + "arguments_valid": arguments_valid, + } + ) if not tool_uses and tools_schema: body_text, tool_uses = parse_xml_tool_uses(body_text, tools_schema) diff --git a/tests/test_agent/test_adapters.py b/tests/test_agent/test_adapters.py index 852a9cf973..e4c98d09c3 100644 --- a/tests/test_agent/test_adapters.py +++ b/tests/test_agent/test_adapters.py @@ -28,7 +28,12 @@ from tests.test_agent._fakes import FakeSGLangServer, FakeTokenizer # noqa: E402 from slime.agent.adapters import anthropic, openai # noqa: E402 -from slime.agent.parsing import parse_model_output, parse_xml_tool_uses # noqa: E402 +from slime.agent.parsing import ( # noqa: E402 + ParsedModelOutput, + _parse_tool_arguments, + parse_model_output, + parse_xml_tool_uses, +) from slime.utils.types import Sample # noqa: E402 NUM_GPUS = 0 @@ -160,6 +165,52 @@ def test_openai_translation_developer_to_system_and_tool_calls_to_dict(): ] +def test_openai_reply_preserves_raw_tool_arguments(): + raw = '{"cmd": "pytest"' + parsed = ParsedModelOutput( + reasoning="", + text="", + tool_uses=[ + { + "name": "bash", + "input": {"_raw_arguments": raw}, + "raw_arguments": raw, + "arguments_valid": False, + } + ], + ill_formed=True, + ) + + wire, manager, finish = openai._build_reply_parts(parsed, "stop") + + assert wire["tool_calls"][0]["function"]["arguments"] == raw + assert manager["tool_calls"][0]["function"]["arguments"] == {"_raw_arguments": raw} + assert finish == "tool_calls" + + +def test_anthropic_reply_drops_unrepresentable_tool_arguments(): + raw = '{"cmd": "pytest"' + parsed = ParsedModelOutput( + reasoning="", + text="", + tool_uses=[ + { + "name": "bash", + "input": {"_raw_arguments": raw}, + "raw_arguments": raw, + "arguments_valid": False, + } + ], + ill_formed=True, + ) + + blocks, stop_reason, manager = anthropic._build_reply_parts(parsed, "stop") + + assert blocks == [{"type": "text", "text": ""}] + assert stop_reason == "end_turn" + assert manager == {"role": "assistant", "content": ""} + + # =========================================================================== # ยง3 non-stream JSON + token capture (real HTTP, real /generate) # =========================================================================== @@ -430,6 +481,23 @@ def test_parse_model_output_plain_text_no_parsers(): assert parsed.reasoning == "" +@pytest.mark.parametrize( + ("raw", "expected", "valid"), + [ + (' {"q" : "slime"} ', {"q": "slime"}, True), + ('{"cmd": "pytest"', {"_raw_arguments": '{"cmd": "pytest"'}, False), + ('["not", "an", "object"]', {"_raw_arguments": '["not", "an", "object"]'}, False), + (None, {}, True), + ], +) +def test_parse_tool_arguments_preserves_wire_text(raw, expected, valid): + parsed, wire_text, is_valid = _parse_tool_arguments(raw) + + assert parsed == expected + assert wire_text == (raw if raw is not None else "{}") + assert is_valid is valid + + def test_parse_model_output_think_split_fallback(): # The qwen3 reasoning parser lives in sglang (lazy import); skip where the # lean CPU CI env has no sglang installed.