Skip to content

fix(mindie): degrade unserializable tool args to str() in XML replay - #6839

Merged
WillemJiang merged 3 commits into
bytedance:mainfrom
BlueX888:fix/prep-mindie-xml-tool-call-literal-eval-set-arg-poisons-thread
Oct 14, 2026
Merged

WillemJiang merged 3 commits into
bytedance:mainfrom
BlueX888:fix/prep-mindie-xml-tool-call-literal-eval-set-arg-poisons-thread

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Why

The XML tool-call parser in the MindIE provider falls back to ast.literal_eval for {-prefixed parameter values that json.loads rejects (backend/packages/harness/deerflow/models/mindie_provider.py:157 on main), and by design the parsed values flow into tool-call args "to satisfy downstream Pydantic validation" (mindie_provider.py:150). So a model emitting a set literal such as {1, 2, 3} yields an AIMessage whose tool-call args contain a Python set.

That AIMessage rides the conversation history. On the next turn _fix_messages re-serializes it into the XML wire format, and the args join rendered every non-str value through an unguarded json.dumps(v, ensure_ascii=False) (mindie_provider.py:81 on main). A set raises TypeError: Object of type set is not JSON serializable, and since the message stays in history, every subsequent request of the thread fails — not just the one tool call.

The ToolMessage json-block branch in the same function already tolerates exactly this class of value: its comment reads "Circular payloads raise ValueError, other non-serializable values TypeError; degrade both to str()" (mindie_provider.py:67-68), implemented as a try/except around json.dumps falling back to str() (mindie_provider.py:69-72), covered by test_tool_message_json_block_unserializable_degrades_to_str. The AIMessage args join was the one serializer without that fallback.

What changed

  • Added a _render_tool_arg helper and routed the AIMessage args join in _fix_messages through it: strings render unchanged, other values go through json.dumps(..., ensure_ascii=False) and degrade to str(v) on TypeError/ValueError — the same fallback the tool-result json block branch in the same function already uses. No behavior change for any argument that serialized successfully before.

Surface area

  • Agents / LangGraph — MindIE chat model adapter (backend/packages/harness/deerflow/models/mindie_provider.py); no graph wiring, prompt, or API-shape change.

Bug fix verification

  • Test path that reproduces the bug:
    • backend/tests/test_mindie_provider.py::TestFixMessages::test_ai_message_tool_args_unserializable_degrades_to_str
    • backend/tests/test_mindie_provider.py::TestParserToSerializerChain::test_set_literal_arg_replayed_through_fix_messages (parser → AIMessage → next-turn _fix_messages round trip)
  • Did it go red on main and green on this branch? yes — on main both fail with TypeError: Object of type set is not JSON serializable; on this branch both pass.

Validation

Run from backend/:

  • PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest tests/test_mindie_provider.py -q → 126 passed
  • PYTHONPATH=. PYTHONIOENCODING=utf-8 PYTHONUTF8=1 uv run pytest tests/test_mindie_provider.py tests/test_pii_redaction_middleware.py tests/test_setup_wizard.py tests/test_model_factory.py tests/test_tool_output_budget_middleware.py -q → 649 passed
  • make lint → ruff check .: "All checks passed!"; ruff format --check .: "2048 files already formatted"

AI assistance

Tool(s) used: ZCode agent (GLM model)

How you used it: The agent located the crash path by reading the provider and its existing tests, wrote the regression tests and the one-spot fix, and ran the validation commands listed above.

  • I've read and understand every line of this change and take responsibility for it — it's not unreviewed AI output.

The XML parser's literal_eval fallback puts values json.dumps rejects
(e.g. Python sets from set-literal arguments) into AIMessage tool_calls
args. Replaying that message from history hit the unguarded json.dumps
in _fix_messages' args join, raising TypeError and failing the whole
request — and every later turn, since the poisoned message rides the
history. Mirror the tool-result json block handling already in the same
function: degrade the value to str() so one unserializable argument
cannot fail the request.
@github-actions github-actions Bot added area:backend Gateway / runtime / core backend under backend/ risk:medium Medium risk: regular code changes size/S PR changes 20-100 lines labels Oct 13, 2026

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 4cee6f1: clean, minimal fix. _render_tool_arg is byte-identical to the old inline expression for every value that serialized before, and its TypeError/ValueError fallback mirrors the existing ToolMessage json-block branch (lines 85-88) exactly. I checked the only other json.dumps site in the file (line 86) is already guarded, so no history-replay path is left unguarded, and the set -> {1, 2, 3} -> literal_eval round trip is stable across turns rather than growing. Both new tests pin real behavior, and the parser-to-serializer chain test is the right shape for the regression. One pre-existing, adjacent suggestion about _safe_literal not recursing into sets is posted inline; fine to defer to a follow-up since this PR does not introduce it.

def _render_tool_arg(value: object) -> str:
"""Serialize a tool-call argument for the XML wire format.

The XML parser's literal_eval fallback can put values json.dumps rejects

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (pre-existing, adjacent — not introduced here): the premise this helper and TestParserToSerializerChain codify, literal_eval-produced sets landing in tool args, also exposes a small gap in _safe_literal (lines 30-38 in this file). It recurses into lists/dicts to reject non-finite floats but treats a set as an opaque value that hits return True, so ast.literal_eval('{1e400}') -> {inf} passes the gate and inf enters args as a float, while the same value nested in a list or dict is rejected and the raw string preserved (the intent documented at the except ValueError branch of _parse_xml_tool_call_to_dict, which exists to keep overflow as text instead of turning it into a float). Verified against head: literal_eval('{1e400}') == {inf} and _safe_literal of that is True. Nothing crashes either way — json.dumps renders Infinity, and this PR's str() fallback covers set replay — but if you want the overflow-rejection invariant to hold for the set value class this PR cements as supported, a set branch (all(_safe_literal(item) for item in value)) in _safe_literal would close it. Happy to see it in a follow-up.

@github-actions github-actions Bot added area:docs Documentation and Markdown only size/M PR changes 100-300 lines and removed size/S PR changes 20-100 lines labels Oct 13, 2026
@WillemJiang WillemJiang added this to the 2.2.0 milestone Oct 14, 2026

@willem-bd willem-bd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up at 869d231: the _safe_literal set-recursion gap flagged in the previous review is fixed — line 34 now recurses into (list, tuple, set), the README and models/AGENTS.md contracts were updated to match, and the new parametrized tests pin both directions (non-finite floats in nested sets/tuples/dicts keep the whole argument as text; finite set/tuple containers still parse and replay). Everything else since 4cee6f1 is additive test coverage; no new issues found. One residual hole in the same class is posted inline (dict keys are not recursed, so a non-finite float can ride in as a key); it does not crash thanks to _render_tool_arg, so fine to defer like the set gap was.

if isinstance(value, (list, tuple, set)):
return all(_safe_literal(item) for item in value)
if isinstance(value, dict):
return all(_safe_literal(item) for item in value.values())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion (residual of the set gap just fixed, pre-existing): _safe_literal recurses into dict values on this line but not dict keys, so the same class this PR closed for sets still has one hole — ast.literal_eval('{1e400: 2}') -> {inf: 2} passes the gate because the non-finite float rides in as a key, while {'k': 1e400} is rejected and kept as text. Verified against head: literal_eval('{1e400: 2}') == {inf: 2} and _safe_literal of it is True. Nothing crashes either way: json.dumps renders the float key as "Infinity", and the nested-tuple-key case ({(1e400,): 2}) fails json.dumps on the tuple key itself, which the new _render_tool_arg degrades to str(). One line closes it: all(_safe_literal(k) and _safe_literal(v) for k, v in value.items()). Happy to see it in a follow-up.

@WillemJiang
WillemJiang merged commit 631c524 into bytedance:main Oct 14, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:backend Gateway / runtime / core backend under backend/ area:docs Documentation and Markdown only risk:medium Medium risk: regular code changes size/M PR changes 100-300 lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants