From d85f5bf6cc832a11b05255186cc04e9b305b985c Mon Sep 17 00:00:00 2001 From: xbzz1018 Date: Tue, 6 Oct 2026 16:47:21 +0800 Subject: [PATCH] fix(models): parse numeric MindIE XML tool arguments (#6030) * fix(models): parse numeric MindIE XML tool arguments Signed-off-by: lovett55 * chore(models): narrow MindIE numeric parsing change Signed-off-by: lovett55 * fix(models): reject unsafe MindIE numeric literals * fix(models): preserve nested MindIE JSON numbers on conversion errors Keep the entire XML argument as raw text when JSON numeric conversion fails. Reserve Python-literal fallback for JSON syntax errors so nested underflow cannot silently become zero. Port the malformed-number regression tests from PR #6207 and cover nested underflow, representable tiny values, and the existing literal fallback. Co-authored-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com> --------- Signed-off-by: lovett55 Co-authored-by: Willem Jiang Co-authored-by: Yufeng He <40085740+he-yufeng@users.noreply.github.com> --- README.md | 5 ++ .../harness/deerflow/models/AGENTS.md | 10 ++++ .../deerflow/models/mindie_provider.py | 41 ++++++++++++- backend/tests/test_mindie_provider.py | 60 +++++++++++++++++++ 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ac72b24ac2..ff86ea6022 100644 --- a/README.md +++ b/README.md @@ -2279,6 +2279,11 @@ DeerFlow is model-agnostic — it works with any LLM that implements the OpenAI- - **Multimodal inputs** for image understanding and video comprehension - **Strong tool-use** for reliable function calling and structured outputs +MindIE XML tool-call arguments support signed, fractional, and exponent JSON +numbers. If a number overflows, underflows to zero, or exceeds Python's integer +digit limit, the complete argument stays a string, including nested JSON lists +and objects, so parsing does not silently change its value. + ## Embedded Python Client For `DeerFlowClient(agent_name="researcher")`, the named agent's `mcp_plugins` diff --git a/backend/packages/harness/deerflow/models/AGENTS.md b/backend/packages/harness/deerflow/models/AGENTS.md index 7b187a00ac..5b181276d4 100644 --- a/backend/packages/harness/deerflow/models/AGENTS.md +++ b/backend/packages/harness/deerflow/models/AGENTS.md @@ -1,3 +1,13 @@ +### MindIE XML numeric arguments (`mindie_provider.py`) + +The XML parser recognizes JSON numeric syntax, including signed, fractional, +and exponent values. Invalid numeric forms such as `007`, `3.`, and `+3` remain +strings. Numeric conversion failures preserve the entire argument, including +lists or objects with overflowing or underflowing floats or oversized integers. +Only JSON syntax errors may use the existing Python-literal fallback; never retry +numeric conversion failures through `ast.literal_eval`, which can silently turn +nested underflow into zero. Regression coverage is in `tests/test_mindie_provider.py`. + ### Model Factory (`packages/harness/deerflow/models/factory.py`) Request-admission waits follow the next scheduled admission and configured diff --git a/backend/packages/harness/deerflow/models/mindie_provider.py b/backend/packages/harness/deerflow/models/mindie_provider.py index 7c6d68364f..7ceae51be2 100644 --- a/backend/packages/harness/deerflow/models/mindie_provider.py +++ b/backend/packages/harness/deerflow/models/mindie_provider.py @@ -1,15 +1,42 @@ import ast import html import json +import math import re import uuid from collections.abc import Iterator +from decimal import Decimal, InvalidOperation import httpx from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage, ToolMessage from langchain_core.outputs import ChatGenerationChunk, ChatResult from langchain_openai import ChatOpenAI +_JSON_NUMBER_RE = re.compile(r"^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$") + + +def _parse_json_float(value: str) -> float: + """Parse a JSON float without silently overflowing or underflowing.""" + try: + precise = Decimal(value) + except InvalidOperation as exc: + raise ValueError(f"invalid JSON number: {value}") from exc + converted = float(precise) + if not math.isfinite(converted) or (precise != 0 and converted == 0.0): + raise ValueError(f"JSON number is outside the representable float range: {value}") + return converted + + +def _safe_literal(value: object) -> bool: + """Reject non-finite floats nested in an AST fallback value.""" + if isinstance(value, float): + return math.isfinite(value) + if isinstance(value, list): + return all(_safe_literal(item) for item in value) + if isinstance(value, dict): + return all(_safe_literal(item) for item in value.values()) + return True + def _fix_messages(messages: list) -> list: """Sanitize incoming messages for MindIE compatibility. @@ -122,14 +149,22 @@ def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]: # Attempt to deserialize string values into native Python types # to satisfy downstream Pydantic validation. parsed_value = raw_value - if raw_value.startswith(("[", "{")) or raw_value in ("true", "false", "null") or raw_value.isdigit(): + if raw_value.startswith(("[", "{")) or raw_value in ("true", "false", "null") or _JSON_NUMBER_RE.fullmatch(raw_value): try: - parsed_value = json.loads(raw_value) + parsed_value = json.loads(raw_value, parse_float=_parse_json_float) except json.JSONDecodeError: try: - parsed_value = ast.literal_eval(raw_value) + candidate = ast.literal_eval(raw_value) + if _safe_literal(candidate): + parsed_value = candidate except (ValueError, SyntaxError): pass + except ValueError: + # Preserve the entire argument when JSON numeric conversion + # rejects overflow, underflow, or the integer digit limit. + # Retrying containers with literal_eval would turn nested + # underflowing numbers into zero and bypass this validation. + pass args[key] = parsed_value diff --git a/backend/tests/test_mindie_provider.py b/backend/tests/test_mindie_provider.py index ad7fa3d86e..cd0276593e 100644 --- a/backend/tests/test_mindie_provider.py +++ b/backend/tests/test_mindie_provider.py @@ -330,6 +330,66 @@ class TestParseXmlToolCalls: _, calls = _parse_xml_tool_call_to_dict(content) assert calls[0]["args"]["n"] == 42 + @pytest.mark.parametrize( + ("raw_value", "expected"), + [("-3", -3), ("3.14", 3.14), ("1e-3", 1e-3)], + ) + def test_signed_fractional_and_exponent_params_deserialised(self, raw_value, expected): + content = f"{raw_value}" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == expected + + @pytest.mark.parametrize("raw_value", ["9" * 5000, "1e400", "1e-400"]) + def test_unsafe_numeric_params_stay_strings(self, raw_value): + content = f"{raw_value}" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == raw_value + + def test_leading_zero_param_stays_string(self): + content = "007" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == "007" + + def test_trailing_dot_param_stays_string(self): + content = "3." + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == "3." + + def test_leading_plus_param_stays_string(self): + content = "+3" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == "+3" + + @pytest.mark.parametrize( + "raw_value", + ["[1e-400]", '{"n":1e-400}', '[{"n":-1e-400}]', '{"n":[0.5,1e-400]}', '{"n":[1e400]}'], + ) + def test_unsafe_nested_json_numbers_preserve_entire_argument(self, raw_value): + content = f"{raw_value}" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == raw_value + + @pytest.mark.parametrize( + ("raw_value", "expected"), + [ + ("[0.0,1e-308,5e-324]", [0.0, 1e-308, 5e-324]), + ('{"n":-1e-3,"zero":0.0}', {"n": -1e-3, "zero": 0.0}), + ], + ) + def test_representable_nested_json_numbers_deserialised(self, raw_value, expected): + content = f"{raw_value}" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == expected + + @pytest.mark.parametrize( + ("raw_value", "expected"), + [("[True,1.5]", [True, 1.5]), ("{'n':1.5}", {"n": 1.5}), ("[1e400,]", "[1e400,]")], + ) + def test_python_literal_fallback_preserves_finite_value_policy(self, raw_value, expected): + content = f"{raw_value}" + _, calls = _parse_xml_tool_call_to_dict(content) + assert calls[0]["args"]["n"] == expected + def test_list_param_deserialised(self): content = '["a","b"]' _, calls = _parse_xml_tool_call_to_dict(content)