Skip to content

Commit 395c143

Browse files
pyp0327WillemJiangCopilot
authored
chore(adpator):Adapt MindIE engine model and improve testing and fixes (bytedance#2523)
* feat(models): 适配 MindIE引擎的模型 * test: add unit tests for MindIEChatModel adapter and fix PR review comments * chore: update uv.lock with pytest-asyncio * build: add pytest-asyncio to test dependencies * fix: address PR review comments (lazy import, cache clients, safe newline escape, strict xml regex) * fix(mindie): preserve string args without JSON quotes in XML tool call serialization * fix(mindie): preserve string args without JSON quotes in XML tool call serialization * test_mindie_provider:format * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(mindie): prevent nested tool_call params from leaking into outer args * fixed by escaping XML entities in _fix_messages and unescaping during parse, with regression tests added. --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 6bd88fe commit 395c143

2 files changed

Lines changed: 100 additions & 7 deletions

File tree

backend/packages/harness/deerflow/models/mindie_provider.py

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import ast
2+
import html
23
import json
34
import re
45
import uuid
@@ -36,8 +37,8 @@ def _fix_messages(messages: list) -> list:
3637
if isinstance(msg, AIMessage) and getattr(msg, "tool_calls", []):
3738
xml_parts = []
3839
for tool in msg.tool_calls:
39-
args_xml = " ".join(f"<parameter={k}>{json.dumps(v, ensure_ascii=False)}</parameter>" for k, v in tool.get("args", {}).items())
40-
xml_parts.append(f"<tool_call> <function={tool['name']}> {args_xml} </function> </tool_call>")
40+
args_xml = " ".join(f"<parameter={html.escape(str(k), quote=False)}>{html.escape(v if isinstance(v, str) else json.dumps(v, ensure_ascii=False), quote=False)}</parameter>" for k, v in tool.get("args", {}).items())
41+
xml_parts.append(f"<tool_call> <function={html.escape(str(tool['name']), quote=False)}> {args_xml} </function> </tool_call>")
4142
full_text = f"{text}\n" + "\n".join(xml_parts) if text else "\n".join(xml_parts)
4243
fixed.append(AIMessage(content=full_text.strip() or " "))
4344
continue
@@ -80,13 +81,24 @@ def _parse_xml_tool_call_to_dict(content: str) -> tuple[str, list[dict]]:
8081
func_match = re.search(r"<function=([^>]+)>", inner_content)
8182
if not func_match:
8283
continue
83-
function_name = func_match.group(1).strip()
84+
function_name = html.unescape(func_match.group(1).strip())
85+
86+
# Ignore nested tool blocks when extracting parameters for this call.
87+
# Nested `<tool_call>` sections represent separate invocations and
88+
# their `<parameter>` tags must not leak into the current call args.
89+
param_source_parts: list[str] = []
90+
nested_cursor = 0
91+
for nested_start, nested_end, _ in _iter_tool_call_blocks(inner_content):
92+
param_source_parts.append(inner_content[nested_cursor:nested_start])
93+
nested_cursor = nested_end
94+
param_source_parts.append(inner_content[nested_cursor:])
95+
param_source = "".join(param_source_parts)
8496

8597
args = {}
8698
param_pattern = re.compile(r"<parameter=([^>]+)>(.*?)</parameter>", re.DOTALL)
87-
for param_match in param_pattern.finditer(inner_content):
88-
key = param_match.group(1).strip()
89-
raw_value = param_match.group(2).strip()
99+
for param_match in param_pattern.finditer(param_source):
100+
key = html.unescape(param_match.group(1).strip())
101+
raw_value = html.unescape(param_match.group(2).strip())
90102

91103
# Attempt to deserialize string values into native Python types
92104
# to satisfy downstream Pydantic validation.

backend/tests/test_mindie_provider.py

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ def test_ai_message_with_tool_calls_serialised_to_xml(self):
9191
assert isinstance(out, AIMessage)
9292
assert "<tool_call>" in out.content
9393
assert "<function=get_weather>" in out.content
94-
assert '<parameter=city>"London"</parameter>' in out.content
94+
assert "<parameter=city>London</parameter>" in out.content
9595
assert not getattr(out, "tool_calls", [])
9696

9797
def test_ai_message_text_preserved_before_xml(self):
@@ -116,6 +116,22 @@ def test_ai_message_multiple_tool_calls(self):
116116
assert "<function=tool_a>" in content
117117
assert "<function=tool_b>" in content
118118

119+
def test_ai_message_tool_args_are_xml_escaped(self):
120+
msg = AIMessage(
121+
content="",
122+
tool_calls=[
123+
{
124+
"name": "fn<&>",
125+
"args": {"k<&>": "v<&>"},
126+
"id": "id1",
127+
}
128+
],
129+
)
130+
result = _fix_messages([msg])
131+
content = result[0].content
132+
assert "<function=fn&lt;&amp;&gt;>" in content
133+
assert "<parameter=k&lt;&amp;&gt;>v&lt;&amp;&gt;</parameter>" in content
134+
119135
# ── ToolMessage → HumanMessage ────────────────────────────────────────────
120136

121137
def test_tool_message_becomes_human_message(self):
@@ -185,6 +201,15 @@ def test_multiple_tool_calls_parsed(self):
185201
assert calls[0]["name"] == "a"
186202
assert calls[1]["name"] == "b"
187203

204+
def test_nested_tool_call_blocks_do_not_break_parsing(self):
205+
content = "<tool_call><function=outer><parameter=q>1</parameter><tool_call><function=inner><parameter=x>2</parameter></function></tool_call></function></tool_call>"
206+
clean, calls = _parse_xml_tool_call_to_dict(content)
207+
assert clean == ""
208+
assert len(calls) == 1
209+
assert calls[0]["name"] == "outer"
210+
assert calls[0]["args"] == {"q": 1}
211+
assert "x" not in calls[0]["args"]
212+
188213
def test_text_before_tool_call_preserved(self):
189214
content = "Here is the answer.\n<tool_call><function=f><parameter=k>v</parameter></function></tool_call>"
190215
clean, calls = _parse_xml_tool_call_to_dict(content)
@@ -226,6 +251,12 @@ def test_unique_ids_generated(self):
226251
_, c2 = _parse_xml_tool_call_to_dict(block)
227252
assert c1[0]["id"] != c2[0]["id"]
228253

254+
def test_escaped_entities_are_unescaped(self):
255+
content = "<tool_call><function=fn&lt;&amp;&gt;><parameter=k&lt;&amp;&gt;>v&lt;&amp;&gt;</parameter></function></tool_call>"
256+
_, calls = _parse_xml_tool_call_to_dict(content)
257+
assert calls[0]["name"] == "fn<&>"
258+
assert calls[0]["args"]["k<&>"] == "v<&>"
259+
229260

230261
# ═════════════════════════════════════════════════════════════════════════════
231262
# 3. MindIEChatModel._patch_result_with_tools
@@ -244,6 +275,12 @@ def test_escaped_newlines_fixed(self):
244275
patched = model._patch_result_with_tools(result)
245276
assert patched.generations[0].message.content == "line1\nline2"
246277

278+
def test_escaped_newlines_inside_code_fence_preserved(self):
279+
model = self._model()
280+
result = _make_chat_result('text\\n```json\n{"k":"a\\\\nb"}\n```\\nend')
281+
patched = model._patch_result_with_tools(result)
282+
assert patched.generations[0].message.content == 'text\n```json\n{"k":"a\\\\nb"}\n```\nend'
283+
247284
def test_xml_tool_calls_extracted(self):
248285
model = self._model()
249286
content = "<tool_call><function=calc><parameter=expr>1+1</parameter></function></tool_call>"
@@ -281,6 +318,50 @@ def test_non_string_content_skipped(self):
281318
assert patched is not None
282319

283320

321+
class TestMindIEInit:
322+
def test_timeout_kwargs_are_normalized(self):
323+
captured = {}
324+
325+
def fake_init(self, **kwargs):
326+
captured.update(kwargs)
327+
328+
with patch("deerflow.models.mindie_provider.ChatOpenAI.__init__", new=fake_init):
329+
MindIEChatModel(
330+
model="mindie-test",
331+
api_key="test-key",
332+
connect_timeout=1.0,
333+
read_timeout=2.0,
334+
write_timeout=3.0,
335+
pool_timeout=4.0,
336+
)
337+
338+
timeout = captured.get("timeout")
339+
assert timeout is not None
340+
assert timeout.connect == 1.0
341+
assert timeout.read == 2.0
342+
assert timeout.write == 3.0
343+
assert timeout.pool == 4.0
344+
345+
def test_explicit_timeout_takes_precedence(self):
346+
captured = {}
347+
348+
def fake_init(self, **kwargs):
349+
captured.update(kwargs)
350+
351+
with patch("deerflow.models.mindie_provider.ChatOpenAI.__init__", new=fake_init):
352+
MindIEChatModel(
353+
model="mindie-test",
354+
api_key="test-key",
355+
timeout=9.0,
356+
connect_timeout=1.0,
357+
read_timeout=2.0,
358+
write_timeout=3.0,
359+
pool_timeout=4.0,
360+
)
361+
362+
assert captured.get("timeout") == 9.0
363+
364+
284365
# ═════════════════════════════════════════════════════════════════════════════
285366
# 4. MindIEChatModel._generate (sync)
286367
# ═════════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)