From 24a318a98c51dfea88d90b8e5687c432b2ab4823 Mon Sep 17 00:00:00 2001 From: bsbds Date: Tue, 7 Jul 2026 11:14:00 +0800 Subject: [PATCH 01/11] fix(deepseek): parse dsml tool calls as structured calls --- src/xagent/core/model/chat/basic/deepseek.py | 40 ++- .../core/model/chat/basic/deepseek_dsml.py | 307 ++++++++++++++++++ .../core/model/chat/basic/openrouter.py | 84 ++++- tests/core/model/chat/basic/test_deepseek.py | 124 +++++++ .../model/chat/basic/test_deepseek_dsml.py | 204 ++++++++++++ .../core/model/chat/basic/test_openrouter.py | 146 +++++++++ 6 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 src/xagent/core/model/chat/basic/deepseek_dsml.py create mode 100644 tests/core/model/chat/basic/test_deepseek_dsml.py diff --git a/src/xagent/core/model/chat/basic/deepseek.py b/src/xagent/core/model/chat/basic/deepseek.py index ea3fcaffb..755cb42b7 100644 --- a/src/xagent/core/model/chat/basic/deepseek.py +++ b/src/xagent/core/model/chat/basic/deepseek.py @@ -4,6 +4,10 @@ from ...providers import is_placeholder_api_key from .base import StreamChunk +from .deepseek_dsml import ( + normalize_deepseek_dsml_response, + stream_chunks_from_chat_result, +) from .openai import PROVIDER_STATE_METADATA_KEY, OpenAICompatibleLLM logger = logging.getLogger(__name__) @@ -219,6 +223,23 @@ def _normalize_response_format( return response_format, output_config + def _normalize_deepseek_response( + self, + response: Any, + *, + tools: Optional[List[Dict[str, Any]]], + ) -> Any: + normalized_response, parsed_dsml = normalize_deepseek_dsml_response( + response, + tools=tools, + model_name=self._model_name, + ) + if parsed_dsml and isinstance(normalized_response, dict): + provider_state = self._response_provider_state(normalized_response) + if provider_state: + normalized_response[PROVIDER_STATE_METADATA_KEY] = provider_state + return normalized_response + async def chat( self, messages: List[Dict[str, str]], @@ -236,7 +257,7 @@ async def chat( output_config=output_config, ) kwargs = self._prepare_deepseek_kwargs(kwargs=kwargs) - return await super().chat( + result = await super().chat( messages=messages, temperature=temperature, max_tokens=max_tokens, @@ -247,6 +268,7 @@ async def chat( output_config=output_config, **kwargs, ) + return self._normalize_deepseek_response(result, tools=tools) async def stream_chat( self, @@ -260,6 +282,22 @@ async def stream_chat( output_config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> AsyncIterator[StreamChunk]: + if tools: + result = await self.chat( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + **kwargs, + ) + for chunk in stream_chunks_from_chat_result(result): + yield chunk + return + response_format, output_config = self._normalize_response_format( response_format=response_format, output_config=output_config, diff --git a/src/xagent/core/model/chat/basic/deepseek_dsml.py b/src/xagent/core/model/chat/basic/deepseek_dsml.py new file mode 100644 index 000000000..ecfabbd9e --- /dev/null +++ b/src/xagent/core/model/chat/basic/deepseek_dsml.py @@ -0,0 +1,307 @@ +from __future__ import annotations + +import html +import json +import re +import uuid +from typing import Any, Iterable + +from ..types import ChunkType, StreamChunk + + +class DeepSeekDSMLParseError(RuntimeError): + """Raised when DeepSeek emits DSML text that cannot safely become tool calls.""" + + +_DSML_TOKEN_RE = r"(?:||DSML||||DSML||\|\|DSML\|\||\|DSML\|)" +_DSML_TOOL_CALLS_BLOCK_RE = re.compile( + rf"<\s*{_DSML_TOKEN_RE}\s*tool_calls\s*>" + rf"(.*?)" + rf"", + re.IGNORECASE | re.DOTALL, +) +_DSML_INVOKE_RE = re.compile( + rf"<\s*{_DSML_TOKEN_RE}\s*invoke\b([^>]*)>" + rf"(.*?)" + rf"", + re.IGNORECASE | re.DOTALL, +) +_DSML_PARAMETER_RE = re.compile( + rf"<\s*{_DSML_TOKEN_RE}\s*parameter\b([^>]*)>" + rf"(.*?)" + rf"", + re.IGNORECASE | re.DOTALL, +) +_DSML_ATTR_RE = re.compile(r"""([A-Za-z_][\w:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')""") +_DSML_MARKER_RE = re.compile( + rf"<\s*/?\s*{_DSML_TOKEN_RE}\s*(?:tool_calls|invoke|parameter)\b", + re.IGNORECASE, +) + + +def contains_deepseek_dsml_tool_markup(text: Any) -> bool: + """Return true when text contains a DeepSeek DSML tool-call marker.""" + + return isinstance(text, str) and _DSML_MARKER_RE.search(text) is not None + + +def normalize_deepseek_dsml_response( + response: Any, + *, + tools: list[dict[str, Any]] | None, + model_name: str, +) -> tuple[Any, bool]: + """Convert DeepSeek DSML assistant content into Xagent tool-call payloads. + + The OpenAI-compatible base parser can only see official ``tool_calls``. Some + DeepSeek V4 routes leak their DSML tool grammar into ``message.content`` + instead. This helper is deliberately provider-scoped: callers must opt in + from DeepSeek-specific code paths and provide the tool schemas from the + current request. + """ + + if isinstance(response, str): + if not contains_deepseek_dsml_tool_markup(response): + return response, False + parsed = parse_deepseek_dsml_tool_calls( + response, + tools=tools, + model_name=model_name, + ) + return { + "type": "tool_call", + "content": parsed["content"] or None, + "tool_calls": parsed["tool_calls"], + "raw": response, + }, True + + if not isinstance(response, dict): + return response, False + + if response.get("tool_calls"): + return response, False + + content = response.get("content") + if not isinstance(content, str) or not contains_deepseek_dsml_tool_markup(content): + return response, False + + parsed = parse_deepseek_dsml_tool_calls( + content, + tools=tools, + model_name=model_name, + ) + normalized = dict(response) + normalized["type"] = "tool_call" + normalized["content"] = parsed["content"] or None + normalized["tool_calls"] = parsed["tool_calls"] + return normalized, True + + +def parse_deepseek_dsml_tool_calls( + text: str, + *, + tools: list[dict[str, Any]] | None, + model_name: str, +) -> dict[str, Any]: + """Parse complete DeepSeek DSML ``tool_calls`` blocks from assistant text.""" + + if not tools: + raise DeepSeekDSMLParseError( + f"{model_name} returned DSML tool markup, but no tools were supplied." + ) + + available_tools = _available_tool_names(tools) + if not available_tools: + raise DeepSeekDSMLParseError( + f"{model_name} returned DSML tool markup, but no callable tool schemas were supplied." + ) + + matches = list(_DSML_TOOL_CALLS_BLOCK_RE.finditer(text)) + if not matches: + raise DeepSeekDSMLParseError( + f"{model_name} returned incomplete DeepSeek DSML tool markup." + ) + + clean_parts: list[str] = [] + tool_calls: list[dict[str, Any]] = [] + last_index = 0 + + for match in matches: + clean_parts.append(text[last_index : match.start()]) + tool_calls.extend( + _parse_dsml_invokes( + match.group(1), + available_tools=available_tools, + model_name=model_name, + ) + ) + last_index = match.end() + + clean_parts.append(text[last_index:]) + clean_content = "".join(clean_parts).strip() + if contains_deepseek_dsml_tool_markup(clean_content): + raise DeepSeekDSMLParseError( + f"{model_name} returned unparsed DeepSeek DSML tool markup." + ) + if not tool_calls: + raise DeepSeekDSMLParseError( + f"{model_name} returned DSML tool markup without any valid invoke blocks." + ) + + return {"content": clean_content, "tool_calls": tool_calls} + + +def stream_chunks_from_chat_result(response: Any) -> Iterable[StreamChunk]: + """Represent a non-streaming DeepSeek chat result as stream chunks.""" + + if isinstance(response, dict): + content = _response_content(response) + if response.get("tool_calls"): + if content: + yield StreamChunk( + type=ChunkType.TOKEN, + content=content, + delta=content, + raw=response, + ) + yield StreamChunk( + type=ChunkType.TOOL_CALL, + tool_calls=list(response.get("tool_calls") or []), + finish_reason="tool_calls", + raw=response, + ) + return + + if content: + yield StreamChunk( + type=ChunkType.TOKEN, + content=content, + delta=content, + raw=response, + ) + yield StreamChunk(type=ChunkType.END, finish_reason="stop", raw=response) + return + + content = str(response) + if content: + yield StreamChunk( + type=ChunkType.TOKEN, + content=content, + delta=content, + raw=response, + ) + yield StreamChunk(type=ChunkType.END, finish_reason="stop", raw=response) + + +def _parse_dsml_invokes( + block_content: str, + *, + available_tools: set[str], + model_name: str, +) -> list[dict[str, Any]]: + invoke_matches = list(_DSML_INVOKE_RE.finditer(block_content)) + if not invoke_matches: + raise DeepSeekDSMLParseError( + f"{model_name} returned a DSML tool_calls block without invoke blocks." + ) + + tool_calls: list[dict[str, Any]] = [] + for match in invoke_matches: + attrs = _parse_dsml_attrs(match.group(1)) + tool_name = attrs.get("name", "").strip() + if not tool_name: + raise DeepSeekDSMLParseError( + f"{model_name} returned a DSML invoke without a tool name." + ) + if tool_name not in available_tools: + raise DeepSeekDSMLParseError( + f"{model_name} returned a DSML invoke for unknown tool '{tool_name}'." + ) + + arguments = _parse_dsml_parameters( + match.group(2), + model_name=model_name, + tool_name=tool_name, + ) + tool_calls.append( + { + "id": f"call_dsml_{uuid.uuid4().hex[:24]}", + "type": "function", + "function": { + "name": tool_name, + "arguments": json.dumps(arguments, ensure_ascii=False), + }, + } + ) + + return tool_calls + + +def _parse_dsml_parameters( + invoke_content: str, + *, + model_name: str, + tool_name: str, +) -> dict[str, Any]: + arguments: dict[str, Any] = {} + for match in _DSML_PARAMETER_RE.finditer(invoke_content): + attrs = _parse_dsml_attrs(match.group(1)) + name = attrs.get("name", "").strip() + string_attr = attrs.get("string", "").strip().lower() + if not name: + raise DeepSeekDSMLParseError( + f"{model_name} returned a DSML parameter without a name for tool '{tool_name}'." + ) + if name in arguments: + raise DeepSeekDSMLParseError( + f"{model_name} returned duplicate DSML parameter '{name}' for tool '{tool_name}'." + ) + if string_attr not in {"true", "false"}: + raise DeepSeekDSMLParseError( + f"{model_name} returned DSML parameter '{name}' with invalid string attribute." + ) + + raw_value = html.unescape(match.group(2)) + if string_attr == "true": + arguments[name] = raw_value + else: + arguments[name] = _parse_json_value_or_string(raw_value.strip()) + + return arguments + + +def _parse_dsml_attrs(raw_attrs: str) -> dict[str, str]: + attrs: dict[str, str] = {} + for match in _DSML_ATTR_RE.finditer(raw_attrs): + value = match.group(2) if match.group(2) is not None else match.group(3) + attrs[match.group(1).strip().lower()] = html.unescape(value or "") + return attrs + + +def _parse_json_value_or_string(value: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError: + return value + + +def _available_tool_names(tools: list[dict[str, Any]]) -> set[str]: + names: set[str] = set() + for tool in tools: + if not isinstance(tool, dict): + continue + function = tool.get("function") + if not isinstance(function, dict): + continue + name = function.get("name") + if isinstance(name, str) and name.strip(): + names.add(name.strip()) + return names + + +def _response_content(response: dict[str, Any]) -> str: + for key in ("content", "answer", "output", "message"): + value = response.get(key) + if isinstance(value, str): + return value + return "" diff --git a/src/xagent/core/model/chat/basic/openrouter.py b/src/xagent/core/model/chat/basic/openrouter.py index 5df554b06..959104aff 100644 --- a/src/xagent/core/model/chat/basic/openrouter.py +++ b/src/xagent/core/model/chat/basic/openrouter.py @@ -1,7 +1,12 @@ -from typing import Any, Dict, List, Optional +from typing import Any, AsyncIterator, Dict, List, Optional from .....config import get_openrouter_official_providers_only from ..timeout_config import TimeoutConfig +from .base import StreamChunk +from .deepseek_dsml import ( + normalize_deepseek_dsml_response, + stream_chunks_from_chat_result, +) from .openai import OpenAILLM OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" @@ -54,6 +59,9 @@ def __init__( def _is_official_openrouter_client(self) -> bool: return self.base_url.rstrip("/") == OPENROUTER_BASE_URL + def _is_deepseek_model(self) -> bool: + return _openrouter_model_author(self._model_name) == "deepseek" + def _prepare_extra_body(self, extra_body: Dict[str, Any]) -> Dict[str, Any]: if ( not get_openrouter_official_providers_only() @@ -112,3 +120,77 @@ def _prepare_provider_reasoning_extra_body( updated_extra_body.pop("enable_thinking", None) return updated_extra_body + + async def chat( + self, + messages: List[Dict[str, str]], + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[str | Dict[str, Any]] = None, + response_format: Optional[Dict[str, Any]] = None, + thinking: Optional[Dict[str, Any]] = None, + output_config: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> Any: + result = await super().chat( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + **kwargs, + ) + if not self._is_deepseek_model(): + return result + + normalized_result, _ = normalize_deepseek_dsml_response( + result, + tools=tools, + model_name=self._model_name, + ) + return normalized_result + + async def stream_chat( + self, + messages: List[Dict[str, str]], + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[str | Dict[str, Any]] = None, + response_format: Optional[Dict[str, Any]] = None, + thinking: Optional[Dict[str, Any]] = None, + output_config: Optional[Dict[str, Any]] = None, + **kwargs: Any, + ) -> AsyncIterator[StreamChunk]: + if self._is_deepseek_model() and tools: + result = await self.chat( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + **kwargs, + ) + for chunk in stream_chunks_from_chat_result(result): + yield chunk + return + + async for chunk in super().stream_chat( + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + tools=tools, + tool_choice=tool_choice, + response_format=response_format, + thinking=thinking, + output_config=output_config, + **kwargs, + ): + yield chunk diff --git a/tests/core/model/chat/basic/test_deepseek.py b/tests/core/model/chat/basic/test_deepseek.py index 8c22cc7ad..f31185a89 100644 --- a/tests/core/model/chat/basic/test_deepseek.py +++ b/tests/core/model/chat/basic/test_deepseek.py @@ -8,10 +8,36 @@ DEEPSEEK_REASONING_CONTENT_STATE_KEY, DeepSeekLLM, ) +from xagent.core.model.chat.basic.deepseek_dsml import DeepSeekDSMLParseError from xagent.core.model.chat.basic.openai import PROVIDER_STATE_METADATA_KEY, OpenAILLM from xagent.core.model.chat.types import ChunkType +def _browser_extract_text_tool() -> dict: + return { + "type": "function", + "function": { + "name": "browser_extract_text", + "description": "Extract text from a browser session", + "parameters": { + "type": "object", + "properties": {"session_id": {"type": "string"}}, + "required": ["session_id"], + }, + }, + } + + +def _browser_extract_text_dsml() -> str: + return """ +<||DSML||tool_calls> +<||DSML||invoke name="browser_extract_text"> +<||DSML||parameter name="session_id" string="true">821:react_468b98b2 + + +""".strip() + + class TestDeepSeekLLM: @pytest.fixture def llm(self): @@ -414,6 +440,68 @@ async def test_reasoning_content_is_preserved_for_text_response(self, llm, mocke assert result["reasoning_content"] == "Detailed reasoning" assert result["reasoning"] == "Detailed reasoning" + @pytest.mark.asyncio + async def test_raw_dsml_content_is_parsed_as_tool_call(self, llm, mocker): + message = SimpleNamespace( + content=_browser_extract_text_dsml(), + tool_calls=None, + reasoning_content="Need to extract browser text.", + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message)], + usage=None, + model_dump=lambda: {"id": "deepseek-dsml"}, + ) + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.return_value = response + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + + result = await llm.chat( + [{"role": "user", "content": "Read the page"}], + tools=[_browser_extract_text_tool()], + ) + + assert result["type"] == "tool_call" + assert result["content"] is None + assert result["tool_calls"][0]["function"]["name"] == "browser_extract_text" + assert ( + result["tool_calls"][0]["function"]["arguments"] + == '{"session_id": "821:react_468b98b2"}' + ) + assert result["reasoning_content"] == "Need to extract browser text." + assert result[PROVIDER_STATE_METADATA_KEY] == { + DEEPSEEK_PROVIDER_STATE_NAMESPACE: { + DEEPSEEK_REASONING_CONTENT_STATE_KEY: "Need to extract browser text." + } + } + + @pytest.mark.asyncio + async def test_raw_dsml_content_without_tools_is_rejected(self, llm, mocker): + message = SimpleNamespace( + content=_browser_extract_text_dsml(), + tool_calls=None, + reasoning_content=None, + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message)], + usage=None, + model_dump=lambda: {"id": "deepseek-dsml"}, + ) + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.return_value = response + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + + with pytest.raises(DeepSeekDSMLParseError, match="no tools were supplied"): + await llm.chat([{"role": "user", "content": "Read the page"}]) + @pytest.mark.asyncio async def test_reasoning_content_is_preserved_for_tool_calls(self, llm, mocker): tool_call = SimpleNamespace( @@ -807,6 +895,42 @@ async def stream(): end_chunk = next(chunk for chunk in chunks if chunk.type == ChunkType.END) assert end_chunk.raw["reasoning_content"] == "Think first." + @pytest.mark.asyncio + async def test_stream_chat_with_tools_uses_non_streaming_dsml_parser( + self, llm, mocker + ): + message = SimpleNamespace( + content=_browser_extract_text_dsml(), + tool_calls=None, + reasoning_content=None, + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message)], + usage=None, + model_dump=lambda: {"id": "deepseek-dsml"}, + ) + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.return_value = response + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + + chunks = [ + chunk + async for chunk in llm.stream_chat( + [{"role": "user", "content": "Read the page"}], + tools=[_browser_extract_text_tool()], + ) + ] + + assert len(chunks) == 1 + assert chunks[0].type == ChunkType.TOOL_CALL + assert chunks[0].tool_calls[0]["function"]["name"] == "browser_extract_text" + call_kwargs = mock_client.chat.completions.create.call_args.kwargs + assert "stream" not in call_kwargs + @pytest.mark.asyncio async def test_list_available_models_returns_curated_v4_models(self): models = await DeepSeekLLM.list_available_models("test-api-key") diff --git a/tests/core/model/chat/basic/test_deepseek_dsml.py b/tests/core/model/chat/basic/test_deepseek_dsml.py new file mode 100644 index 000000000..3b7f69167 --- /dev/null +++ b/tests/core/model/chat/basic/test_deepseek_dsml.py @@ -0,0 +1,204 @@ +import json + +import pytest + +from xagent.core.model.chat.basic.deepseek_dsml import ( + DeepSeekDSMLParseError, + normalize_deepseek_dsml_response, + parse_deepseek_dsml_tool_calls, +) + + +def _tool_schema(name: str) -> dict: + return { + "type": "function", + "function": { + "name": name, + "description": f"{name} tool", + "parameters": {"type": "object", "properties": {}}, + }, + } + + +def _tools(*names: str) -> list[dict]: + return [_tool_schema(name) for name in names] + + +def _dsml_block(token: str, *, tool_name: str = "get_weather") -> str: + return f""" +<{token}tool_calls> +<{token}invoke name="{tool_name}"> +<{token}parameter name="location" string="true">Boston +<{token}parameter name="limit" string="false">5 +<{token}parameter name="options" string="false">{{"fresh":true}} + + +""".strip() + + +@pytest.mark.parametrize( + "token", + [ + "|DSML|", + "||DSML||", + "|DSML|", + "||DSML||", + ], +) +def test_parse_deepseek_dsml_tool_calls_supports_known_token_variants(token): + parsed = parse_deepseek_dsml_tool_calls( + _dsml_block(token), + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + assert parsed["content"] == "" + assert len(parsed["tool_calls"]) == 1 + tool_call = parsed["tool_calls"][0] + assert tool_call["type"] == "function" + assert tool_call["function"]["name"] == "get_weather" + assert tool_call["id"].startswith("call_dsml_") + assert json.loads(tool_call["function"]["arguments"]) == { + "location": "Boston", + "limit": 5, + "options": {"fresh": True}, + } + + +def test_parse_deepseek_dsml_tool_calls_supports_multiple_invokes(): + token = "||DSML||" + text = f""" +<{token}tool_calls> +<{token}invoke name="get_weather"> +<{token}parameter name="location" string="true">Boston + +<{token}invoke name="browser_extract_text"> +<{token}parameter name="session_id" string="true">821:react_468b98b2 + + +""".strip() + + parsed = parse_deepseek_dsml_tool_calls( + text, + tools=_tools("get_weather", "browser_extract_text"), + model_name="deepseek-v4-flash", + ) + + assert [call["function"]["name"] for call in parsed["tool_calls"]] == [ + "get_weather", + "browser_extract_text", + ] + assert json.loads(parsed["tool_calls"][1]["function"]["arguments"]) == { + "session_id": "821:react_468b98b2" + } + + +def test_normalize_deepseek_dsml_response_strips_markup_from_visible_content(): + response = { + "type": "text", + "content": _dsml_block("||DSML||"), + "raw": {"id": "deepseek-text"}, + } + + normalized, parsed = normalize_deepseek_dsml_response( + response, + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + assert parsed is True + assert normalized["type"] == "tool_call" + assert normalized["content"] is None + assert "DSML" not in json.dumps(normalized["content"]) + assert normalized["raw"] == {"id": "deepseek-text"} + + +def test_parse_deepseek_dsml_tool_calls_falls_back_to_string_for_invalid_json_value(): + token = "|DSML|" + text = f""" +<{token}tool_calls> +<{token}invoke name="get_weather"> +<{token}parameter name="query" string="false">not json + + +""".strip() + + parsed = parse_deepseek_dsml_tool_calls( + text, + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + assert json.loads(parsed["tool_calls"][0]["function"]["arguments"]) == { + "query": "not json" + } + + +def test_parse_deepseek_dsml_tool_calls_preserves_string_parameter_whitespace(): + token = "|DSML|" + text = f""" +<{token}tool_calls> +<{token}invoke name="write_file"> +<{token}parameter name="content" string="true"> keep exact text + + + +""".strip() + + parsed = parse_deepseek_dsml_tool_calls( + text, + tools=_tools("write_file"), + model_name="deepseek-v4-flash", + ) + + assert json.loads(parsed["tool_calls"][0]["function"]["arguments"]) == { + "content": " keep exact text\n" + } + + +def test_parse_deepseek_dsml_tool_calls_rejects_duplicate_parameters(): + token = "|DSML|" + text = f""" +<{token}tool_calls> +<{token}invoke name="get_weather"> +<{token}parameter name="location" string="true">Boston +<{token}parameter name="location" string="true">Paris + + +""".strip() + + with pytest.raises(DeepSeekDSMLParseError, match="duplicate DSML parameter"): + parse_deepseek_dsml_tool_calls( + text, + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + +def test_parse_deepseek_dsml_tool_calls_rejects_unknown_tools(): + with pytest.raises(DeepSeekDSMLParseError, match="unknown tool"): + parse_deepseek_dsml_tool_calls( + _dsml_block("|DSML|", tool_name="unknown_tool"), + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + +def test_parse_deepseek_dsml_tool_calls_rejects_incomplete_blocks(): + text = "<|DSML|tool_calls><|DSML|invoke name=\"get_weather\">" + + with pytest.raises(DeepSeekDSMLParseError, match="incomplete"): + parse_deepseek_dsml_tool_calls( + text, + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + +def test_parse_deepseek_dsml_tool_calls_rejects_missing_request_tools(): + with pytest.raises(DeepSeekDSMLParseError, match="no tools were supplied"): + parse_deepseek_dsml_tool_calls( + _dsml_block("|DSML|"), + tools=None, + model_name="deepseek-v4-flash", + ) diff --git a/tests/core/model/chat/basic/test_openrouter.py b/tests/core/model/chat/basic/test_openrouter.py index ec0f400c1..93f5924cb 100644 --- a/tests/core/model/chat/basic/test_openrouter.py +++ b/tests/core/model/chat/basic/test_openrouter.py @@ -1,10 +1,37 @@ """Test cases for OpenRouter LLM provider behavior.""" +import json from types import SimpleNamespace import pytest from xagent.core.model.chat.basic.openrouter import OpenRouterLLM +from xagent.core.model.chat.types import ChunkType + + +def _browser_extract_text_tool() -> dict: + return { + "type": "function", + "function": { + "name": "browser_extract_text", + "description": "Extract text from a browser session", + "parameters": { + "type": "object", + "properties": {"session_id": {"type": "string"}}, + "required": ["session_id"], + }, + }, + } + + +def _browser_extract_text_dsml() -> str: + return """ +<||DSML||tool_calls> +<||DSML||invoke name="browser_extract_text"> +<||DSML||parameter name="session_id" string="true">821:react_468b98b2 + + +""".strip() @pytest.mark.asyncio @@ -284,3 +311,122 @@ async def test_structured_output_retry_disables_openrouter_reasoning( second_call = mock_client.chat.completions.create.call_args_list[1].kwargs assert second_call["extra_body"]["reasoning"] == {"enabled": False} assert second_call["extra_body"]["thinking"] == {"type": "disabled"} + + +@pytest.mark.asyncio +async def test_openrouter_deepseek_parses_raw_dsml_content(mocker, monkeypatch): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + message = SimpleNamespace( + content=_browser_extract_text_dsml(), + tool_calls=None, + reasoning_content=None, + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message)], + usage=None, + model_dump=lambda: {"id": "openrouter-dsml"}, + ) + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.return_value = response + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + + llm = OpenRouterLLM( + model_name="deepseek/deepseek-v4-flash", + api_key="test-key", + ) + + result = await llm.chat( + [{"role": "user", "content": "Read the page"}], + tools=[_browser_extract_text_tool()], + ) + + assert result["type"] == "tool_call" + assert result["content"] is None + assert result["tool_calls"][0]["function"]["name"] == "browser_extract_text" + assert json.loads(result["tool_calls"][0]["function"]["arguments"]) == { + "session_id": "821:react_468b98b2" + } + + +@pytest.mark.asyncio +async def test_openrouter_non_deepseek_does_not_parse_raw_dsml_content( + mocker, monkeypatch +): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + message = SimpleNamespace( + content=_browser_extract_text_dsml(), + tool_calls=None, + reasoning_content=None, + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message)], + usage=None, + model_dump=lambda: {"id": "openrouter-text"}, + ) + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.return_value = response + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + + llm = OpenRouterLLM( + model_name="openai/gpt-5.5", + api_key="test-key", + ) + + result = await llm.chat( + [{"role": "user", "content": "Read the page"}], + tools=[_browser_extract_text_tool()], + ) + + assert result["type"] == "text" + assert "DSML" in result["content"] + + +@pytest.mark.asyncio +async def test_openrouter_deepseek_stream_with_tools_uses_non_streaming_dsml_parser( + mocker, monkeypatch +): + monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") + message = SimpleNamespace( + content=_browser_extract_text_dsml(), + tool_calls=None, + reasoning_content=None, + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message)], + usage=None, + model_dump=lambda: {"id": "openrouter-dsml"}, + ) + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.return_value = response + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + + llm = OpenRouterLLM( + model_name="deepseek/deepseek-v4-flash", + api_key="test-key", + ) + + chunks = [ + chunk + async for chunk in llm.stream_chat( + [{"role": "user", "content": "Read the page"}], + tools=[_browser_extract_text_tool()], + ) + ] + + assert len(chunks) == 1 + assert chunks[0].type == ChunkType.TOOL_CALL + assert chunks[0].tool_calls[0]["function"]["name"] == "browser_extract_text" + call_kwargs = mock_client.chat.completions.create.call_args.kwargs + assert "stream" not in call_kwargs From d8b8d2d61102bc23a6af3b66bf9f355b543a41e4 Mon Sep 17 00:00:00 2001 From: bsbds Date: Tue, 7 Jul 2026 17:49:15 +0800 Subject: [PATCH 02/11] fix(deepseek): recover dsml tool calls in final turns --- src/xagent/core/agent/pattern/react/react.py | 33 ++++++- src/xagent/core/agent/runtime.py | 10 ++- src/xagent/core/model/chat/basic/deepseek.py | 43 ++++++++- .../core/model/chat/basic/deepseek_dsml.py | 1 + tests/core/agent/test_react.py | 90 +++++++++++++++++++ tests/core/model/chat/basic/test_deepseek.py | 76 +++++++++++++++- .../model/chat/basic/test_deepseek_dsml.py | 2 +- 7 files changed, 247 insertions(+), 8 deletions(-) diff --git a/src/xagent/core/agent/pattern/react/react.py b/src/xagent/core/agent/pattern/react/react.py index e8949eac4..8bf00f4f8 100644 --- a/src/xagent/core/agent/pattern/react/react.py +++ b/src/xagent/core/agent/pattern/react/react.py @@ -385,6 +385,15 @@ async def _run_tool_calling_loop( "tools": tool_schemas or None, "tool_choice": self.tool_choice if tool_schemas else None, } + start_final_answer_stream_immediately = True + if force_final_answer_now: + parser_kwargs = self._forced_final_parser_kwargs( + llm=llm, + tool_schemas=base_tool_schemas, + ) + if parser_kwargs: + llm_kwargs.update(parser_kwargs) + start_final_answer_stream_immediately = False if tool_schemas: answer_streamer = ReActFinalAnswerStreamer(runtime) response = await runtime.run_streaming_llm_call( @@ -393,7 +402,11 @@ async def _run_tool_calling_loop( **llm_kwargs, ) else: - response = await runtime.stream_final_answer(llm, **llm_kwargs) + response = await runtime.stream_final_answer( + llm, + start_immediately=start_final_answer_stream_immediately, + **llm_kwargs, + ) except LLMCallInterrupted: if answer_streamer is not None: await answer_streamer.fail("interrupted during LLM stream") @@ -445,6 +458,8 @@ async def _run_tool_calling_loop( self._remember_tool_call_content(tool_calls, assistant_content) self.status = "acting" self.pending_tool_calls = list(tool_calls) + if force_final_answer_now: + self.force_final_answer_next = False await runtime.checkpoint("after_llm", context=context, pattern=self) pending_result = await self._execute_pending_tool_calls( context=context, @@ -584,6 +599,22 @@ def _schema_tool_names(self, tool_schemas: list[dict[str, Any]]) -> list[str]: names.append(str(function["name"])) return names + def _forced_final_parser_kwargs( + self, + *, + llm: Any, + tool_schemas: list[dict[str, Any]], + ) -> dict[str, Any]: + """Return provider-local parser kwargs for final turns that suppress tools.""" + + if not tool_schemas: + return {} + parse_kwargs = getattr(llm, "deepseek_dsml_parse_kwargs", None) + if not callable(parse_kwargs): + return {} + result = parse_kwargs(tool_schemas) + return result if isinstance(result, dict) else {} + def _tool_decision_groups_for_tools(self, tools: list[Any]) -> dict[str, str]: groups: dict[str, str] = {} for tool in tools: diff --git a/src/xagent/core/agent/runtime.py b/src/xagent/core/agent/runtime.py index a8d1db266..50f9c4ac0 100644 --- a/src/xagent/core/agent/runtime.py +++ b/src/xagent/core/agent/runtime.py @@ -95,7 +95,13 @@ async def run_llm_call(self, llm: Any, **kwargs: Any) -> Any: finally: self._active_llm_tasks.discard(task) - async def stream_final_answer(self, llm: Any, **kwargs: Any) -> Any: + async def stream_final_answer( + self, + llm: Any, + *, + start_immediately: bool = True, + **kwargs: Any, + ) -> Any: """Stream only the final user-facing answer through the outbound boundary.""" if self.outbound_message_handler is None or not self._has_native_stream_chat( @@ -106,7 +112,7 @@ async def stream_final_answer(self, llm: Any, **kwargs: Any) -> Any: from .pattern.final_answer_stream import FinalAnswerStreamSession stream = FinalAnswerStreamSession(self) - if await stream.start() is None: + if start_immediately and await stream.start() is None: return await self.run_llm_call(llm, **kwargs) async def emit_text_delta(chunk: Any) -> None: diff --git a/src/xagent/core/model/chat/basic/deepseek.py b/src/xagent/core/model/chat/basic/deepseek.py index 755cb42b7..49a222897 100644 --- a/src/xagent/core/model/chat/basic/deepseek.py +++ b/src/xagent/core/model/chat/basic/deepseek.py @@ -5,6 +5,8 @@ from ...providers import is_placeholder_api_key from .base import StreamChunk from .deepseek_dsml import ( + DEEPSEEK_DSML_PARSE_TOOLS_KWARG, + DeepSeekDSMLParseError, normalize_deepseek_dsml_response, stream_chunks_from_chat_result, ) @@ -135,6 +137,33 @@ def _prepare_deepseek_kwargs( return updated_kwargs + def deepseek_dsml_parse_kwargs( + self, + tools: list[dict[str, Any]] | None, + ) -> dict[str, Any]: + """Return parser-only DSML schema metadata for forced-final recovery. + + ReAct forced-final turns intentionally send ``tools=None`` to DeepSeek. + If DeepSeek still emits DSML tool markup, this hidden kwarg lets this + adapter validate and convert it locally without leaking the schema into + provider request parameters. + """ + + return {DEEPSEEK_DSML_PARSE_TOOLS_KWARG: tools} if tools else {} + + def _pop_deepseek_dsml_parse_tools( + self, + kwargs: dict[str, Any], + ) -> list[dict[str, Any]] | None: + raw_tools = kwargs.pop(DEEPSEEK_DSML_PARSE_TOOLS_KWARG, None) + if raw_tools is None: + return None + if not isinstance(raw_tools, list): + raise DeepSeekDSMLParseError( + f"{DEEPSEEK_DSML_PARSE_TOOLS_KWARG} must be a list of tool schemas." + ) + return raw_tools + def _prepare_messages_for_request( self, messages: List[Dict[str, Any]], @@ -252,6 +281,7 @@ async def chat( output_config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> Any: + dsml_parse_tools = self._pop_deepseek_dsml_parse_tools(kwargs) response_format, output_config = self._normalize_response_format( response_format=response_format, output_config=output_config, @@ -268,7 +298,10 @@ async def chat( output_config=output_config, **kwargs, ) - return self._normalize_deepseek_response(result, tools=tools) + return self._normalize_deepseek_response( + result, + tools=tools or dsml_parse_tools, + ) async def stream_chat( self, @@ -282,7 +315,11 @@ async def stream_chat( output_config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> AsyncIterator[StreamChunk]: - if tools: + dsml_parse_tools = self._pop_deepseek_dsml_parse_tools(kwargs) + if tools or dsml_parse_tools: + chat_kwargs = dict(kwargs) + if dsml_parse_tools: + chat_kwargs[DEEPSEEK_DSML_PARSE_TOOLS_KWARG] = dsml_parse_tools result = await self.chat( messages=messages, temperature=temperature, @@ -292,7 +329,7 @@ async def stream_chat( response_format=response_format, thinking=thinking, output_config=output_config, - **kwargs, + **chat_kwargs, ) for chunk in stream_chunks_from_chat_result(result): yield chunk diff --git a/src/xagent/core/model/chat/basic/deepseek_dsml.py b/src/xagent/core/model/chat/basic/deepseek_dsml.py index ecfabbd9e..d6f163354 100644 --- a/src/xagent/core/model/chat/basic/deepseek_dsml.py +++ b/src/xagent/core/model/chat/basic/deepseek_dsml.py @@ -13,6 +13,7 @@ class DeepSeekDSMLParseError(RuntimeError): """Raised when DeepSeek emits DSML text that cannot safely become tool calls.""" +DEEPSEEK_DSML_PARSE_TOOLS_KWARG = "_xagent_deepseek_dsml_parse_tools" _DSML_TOKEN_RE = r"(?:||DSML||||DSML||\|\|DSML\|\||\|DSML\|)" _DSML_TOOL_CALLS_BLOCK_RE = re.compile( rf"<\s*{_DSML_TOKEN_RE}\s*tool_calls\s*>" diff --git a/tests/core/agent/test_react.py b/tests/core/agent/test_react.py index a9dd56499..17d3db0ff 100644 --- a/tests/core/agent/test_react.py +++ b/tests/core/agent/test_react.py @@ -17,6 +17,7 @@ ReActReasoningMode, ToolCallRecord, ) +from xagent.core.model.chat.basic.deepseek_dsml import DEEPSEEK_DSML_PARSE_TOOLS_KWARG from xagent.core.model.chat.types import ChunkType, StreamChunk react_module = importlib.import_module("xagent.core.agent.pattern.react.react") @@ -270,6 +271,63 @@ async def stream_chat(self, **kwargs: Any) -> Any: yield StreamChunk(type=ChunkType.END) +class StreamingDeepSeekForcedFinalToolLLM: + """DeepSeek-like fake that can recover a tool call during a forced-final turn.""" + + def __init__(self) -> None: + self.stream_calls: list[dict[str, Any]] = [] + self.parser_tool_batches: list[list[dict[str, Any]]] = [] + + def deepseek_dsml_parse_kwargs( + self, + tools: list[dict[str, Any]], + ) -> dict[str, Any]: + self.parser_tool_batches.append(tools) + return {DEEPSEEK_DSML_PARSE_TOOLS_KWARG: tools} + + async def chat(self, **kwargs: Any) -> Any: + raise AssertionError("streaming DeepSeek ReAct path should not call chat()") + + async def stream_chat(self, **kwargs: Any) -> Any: + self.stream_calls.append(kwargs) + call_number = len(self.stream_calls) + + if call_number == 1: + yield StreamChunk( + type=ChunkType.TOOL_CALL, + tool_calls=[ + { + "id": "call_initial", + "function": { + "name": "calculator", + "arguments": '{"expression":"2+2"}', + }, + } + ], + ) + yield StreamChunk(type=ChunkType.END) + return + + if call_number == 2 and kwargs.get(DEEPSEEK_DSML_PARSE_TOOLS_KWARG): + yield StreamChunk( + type=ChunkType.TOOL_CALL, + tool_calls=[ + { + "id": "call_forced_final_recovered", + "function": { + "name": "calculator", + "arguments": '{"expression":"3+3"}', + }, + } + ], + ) + yield StreamChunk(type=ChunkType.END) + return + + yield StreamChunk(type=ChunkType.TOKEN, delta="The final result is 6.") + yield StreamChunk(type=ChunkType.END) + + class StreamingPlainTextFinalAnswerLLM: def __init__(self) -> None: self.stream_calls: list[dict[str, Any]] = [] @@ -708,6 +766,38 @@ async def test_react_pattern_streams_only_final_answer_after_tool_call() -> None ] +@pytest.mark.asyncio +async def test_react_pattern_converts_deepseek_forced_final_dsml_tool_call() -> None: + llm = StreamingDeepSeekForcedFinalToolLLM() + pattern = ReActPattern(max_iterations=4, finalize_after_tool_result=True) + tool = FakeTool() + context = ExecutionContext(system_prompt="You are helpful.", execution_id="task-1") + context.add_user_message("Calculate 2+2 and then 3+3") + outbound = OutboundCollector() + runtime = PatternRuntime(execution_id="task-1", outbound_message_handler=outbound) + + result = await pattern.run(context=context, tools=[tool], llm=llm, runtime=runtime) + + assert result["success"] is True + assert result["response"] == "The final result is 6." + assert tool.calls == [{"expression": "2+2"}, {"expression": "3+3"}] + assert len(llm.stream_calls) == 3 + assert llm.stream_calls[1]["tools"] is None + assert DEEPSEEK_DSML_PARSE_TOOLS_KWARG in llm.stream_calls[1] + assert [schema["function"]["name"] for schema in llm.parser_tool_batches[0]] == [ + "calculator", + "final_answer", + "send_message", + "ask_user_question", + ] + assert [event["type"] for event in outbound.events] == [ + "final_answer_start", + "final_answer_delta", + "final_answer_end", + ] + assert outbound.events[1]["delta"] == "The final result is 6." + + @pytest.mark.asyncio async def test_react_pattern_does_not_stream_plain_text_when_tool_protocol_is_ignored() -> ( None diff --git a/tests/core/model/chat/basic/test_deepseek.py b/tests/core/model/chat/basic/test_deepseek.py index f31185a89..aaa30d861 100644 --- a/tests/core/model/chat/basic/test_deepseek.py +++ b/tests/core/model/chat/basic/test_deepseek.py @@ -8,7 +8,10 @@ DEEPSEEK_REASONING_CONTENT_STATE_KEY, DeepSeekLLM, ) -from xagent.core.model.chat.basic.deepseek_dsml import DeepSeekDSMLParseError +from xagent.core.model.chat.basic.deepseek_dsml import ( + DEEPSEEK_DSML_PARSE_TOOLS_KWARG, + DeepSeekDSMLParseError, +) from xagent.core.model.chat.basic.openai import PROVIDER_STATE_METADATA_KEY, OpenAILLM from xagent.core.model.chat.types import ChunkType @@ -479,6 +482,39 @@ async def test_raw_dsml_content_is_parsed_as_tool_call(self, llm, mocker): } } + @pytest.mark.asyncio + async def test_hidden_dsml_parse_tools_convert_without_provider_tools( + self, llm, mocker + ): + message = SimpleNamespace( + content=_browser_extract_text_dsml(), + tool_calls=None, + reasoning_content=None, + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message)], + usage=None, + model_dump=lambda: {"id": "deepseek-dsml"}, + ) + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.return_value = response + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + + result = await llm.chat( + [{"role": "user", "content": "Read the page"}], + **{DEEPSEEK_DSML_PARSE_TOOLS_KWARG: [_browser_extract_text_tool()]}, + ) + + assert result["type"] == "tool_call" + assert result["tool_calls"][0]["function"]["name"] == "browser_extract_text" + call_kwargs = mock_client.chat.completions.create.call_args.kwargs + assert "tools" not in call_kwargs + assert DEEPSEEK_DSML_PARSE_TOOLS_KWARG not in call_kwargs + @pytest.mark.asyncio async def test_raw_dsml_content_without_tools_is_rejected(self, llm, mocker): message = SimpleNamespace( @@ -931,6 +967,44 @@ async def test_stream_chat_with_tools_uses_non_streaming_dsml_parser( call_kwargs = mock_client.chat.completions.create.call_args.kwargs assert "stream" not in call_kwargs + @pytest.mark.asyncio + async def test_stream_chat_hidden_dsml_parse_tools_uses_non_streaming_parser( + self, llm, mocker + ): + message = SimpleNamespace( + content=_browser_extract_text_dsml(), + tool_calls=None, + reasoning_content=None, + ) + response = SimpleNamespace( + choices=[SimpleNamespace(message=message)], + usage=None, + model_dump=lambda: {"id": "deepseek-dsml"}, + ) + + mock_client = mocker.AsyncMock() + mock_client.chat.completions.create.return_value = response + mocker.patch( + "xagent.core.model.chat.basic.openai.AsyncOpenAI", + return_value=mock_client, + ) + + chunks = [ + chunk + async for chunk in llm.stream_chat( + [{"role": "user", "content": "Read the page"}], + **{DEEPSEEK_DSML_PARSE_TOOLS_KWARG: [_browser_extract_text_tool()]}, + ) + ] + + assert len(chunks) == 1 + assert chunks[0].type == ChunkType.TOOL_CALL + assert chunks[0].tool_calls[0]["function"]["name"] == "browser_extract_text" + call_kwargs = mock_client.chat.completions.create.call_args.kwargs + assert "stream" not in call_kwargs + assert "tools" not in call_kwargs + assert DEEPSEEK_DSML_PARSE_TOOLS_KWARG not in call_kwargs + @pytest.mark.asyncio async def test_list_available_models_returns_curated_v4_models(self): models = await DeepSeekLLM.list_available_models("test-api-key") diff --git a/tests/core/model/chat/basic/test_deepseek_dsml.py b/tests/core/model/chat/basic/test_deepseek_dsml.py index 3b7f69167..a1f08fe42 100644 --- a/tests/core/model/chat/basic/test_deepseek_dsml.py +++ b/tests/core/model/chat/basic/test_deepseek_dsml.py @@ -185,7 +185,7 @@ def test_parse_deepseek_dsml_tool_calls_rejects_unknown_tools(): def test_parse_deepseek_dsml_tool_calls_rejects_incomplete_blocks(): - text = "<|DSML|tool_calls><|DSML|invoke name=\"get_weather\">" + text = '<|DSML|tool_calls><|DSML|invoke name="get_weather">' with pytest.raises(DeepSeekDSMLParseError, match="incomplete"): parse_deepseek_dsml_tool_calls( From 625091d09f3062088bc0c8ffa35ab0f356eb632f Mon Sep 17 00:00:00 2001 From: bsbds Date: Tue, 7 Jul 2026 19:13:54 +0800 Subject: [PATCH 03/11] ref(openrouter): remove deepseek dsml handling Revert the OpenRouter side of 24a318a. The chat/stream_chat overrides duplicated the DeepSeek adapter's DSML recovery wholesale, and the forced-final recovery from d8b8d2d never reached them, so DeepSeek-via- OpenRouter would hard-fail on forced-final DSML leakage anyway. DSML recovery is now scoped to the native DeepSeek adapter only; leaked markup on OpenRouter routes passes through as plain text. --- .../core/model/chat/basic/openrouter.py | 84 +--------- .../core/model/chat/basic/test_openrouter.py | 146 ------------------ 2 files changed, 1 insertion(+), 229 deletions(-) diff --git a/src/xagent/core/model/chat/basic/openrouter.py b/src/xagent/core/model/chat/basic/openrouter.py index 959104aff..5df554b06 100644 --- a/src/xagent/core/model/chat/basic/openrouter.py +++ b/src/xagent/core/model/chat/basic/openrouter.py @@ -1,12 +1,7 @@ -from typing import Any, AsyncIterator, Dict, List, Optional +from typing import Any, Dict, List, Optional from .....config import get_openrouter_official_providers_only from ..timeout_config import TimeoutConfig -from .base import StreamChunk -from .deepseek_dsml import ( - normalize_deepseek_dsml_response, - stream_chunks_from_chat_result, -) from .openai import OpenAILLM OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" @@ -59,9 +54,6 @@ def __init__( def _is_official_openrouter_client(self) -> bool: return self.base_url.rstrip("/") == OPENROUTER_BASE_URL - def _is_deepseek_model(self) -> bool: - return _openrouter_model_author(self._model_name) == "deepseek" - def _prepare_extra_body(self, extra_body: Dict[str, Any]) -> Dict[str, Any]: if ( not get_openrouter_official_providers_only() @@ -120,77 +112,3 @@ def _prepare_provider_reasoning_extra_body( updated_extra_body.pop("enable_thinking", None) return updated_extra_body - - async def chat( - self, - messages: List[Dict[str, str]], - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[str | Dict[str, Any]] = None, - response_format: Optional[Dict[str, Any]] = None, - thinking: Optional[Dict[str, Any]] = None, - output_config: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> Any: - result = await super().chat( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - tools=tools, - tool_choice=tool_choice, - response_format=response_format, - thinking=thinking, - output_config=output_config, - **kwargs, - ) - if not self._is_deepseek_model(): - return result - - normalized_result, _ = normalize_deepseek_dsml_response( - result, - tools=tools, - model_name=self._model_name, - ) - return normalized_result - - async def stream_chat( - self, - messages: List[Dict[str, str]], - temperature: Optional[float] = None, - max_tokens: Optional[int] = None, - tools: Optional[List[Dict[str, Any]]] = None, - tool_choice: Optional[str | Dict[str, Any]] = None, - response_format: Optional[Dict[str, Any]] = None, - thinking: Optional[Dict[str, Any]] = None, - output_config: Optional[Dict[str, Any]] = None, - **kwargs: Any, - ) -> AsyncIterator[StreamChunk]: - if self._is_deepseek_model() and tools: - result = await self.chat( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - tools=tools, - tool_choice=tool_choice, - response_format=response_format, - thinking=thinking, - output_config=output_config, - **kwargs, - ) - for chunk in stream_chunks_from_chat_result(result): - yield chunk - return - - async for chunk in super().stream_chat( - messages=messages, - temperature=temperature, - max_tokens=max_tokens, - tools=tools, - tool_choice=tool_choice, - response_format=response_format, - thinking=thinking, - output_config=output_config, - **kwargs, - ): - yield chunk diff --git a/tests/core/model/chat/basic/test_openrouter.py b/tests/core/model/chat/basic/test_openrouter.py index 93f5924cb..ec0f400c1 100644 --- a/tests/core/model/chat/basic/test_openrouter.py +++ b/tests/core/model/chat/basic/test_openrouter.py @@ -1,37 +1,10 @@ """Test cases for OpenRouter LLM provider behavior.""" -import json from types import SimpleNamespace import pytest from xagent.core.model.chat.basic.openrouter import OpenRouterLLM -from xagent.core.model.chat.types import ChunkType - - -def _browser_extract_text_tool() -> dict: - return { - "type": "function", - "function": { - "name": "browser_extract_text", - "description": "Extract text from a browser session", - "parameters": { - "type": "object", - "properties": {"session_id": {"type": "string"}}, - "required": ["session_id"], - }, - }, - } - - -def _browser_extract_text_dsml() -> str: - return """ -<||DSML||tool_calls> -<||DSML||invoke name="browser_extract_text"> -<||DSML||parameter name="session_id" string="true">821:react_468b98b2 - - -""".strip() @pytest.mark.asyncio @@ -311,122 +284,3 @@ async def test_structured_output_retry_disables_openrouter_reasoning( second_call = mock_client.chat.completions.create.call_args_list[1].kwargs assert second_call["extra_body"]["reasoning"] == {"enabled": False} assert second_call["extra_body"]["thinking"] == {"type": "disabled"} - - -@pytest.mark.asyncio -async def test_openrouter_deepseek_parses_raw_dsml_content(mocker, monkeypatch): - monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") - message = SimpleNamespace( - content=_browser_extract_text_dsml(), - tool_calls=None, - reasoning_content=None, - ) - response = SimpleNamespace( - choices=[SimpleNamespace(message=message)], - usage=None, - model_dump=lambda: {"id": "openrouter-dsml"}, - ) - - mock_client = mocker.AsyncMock() - mock_client.chat.completions.create.return_value = response - mocker.patch( - "xagent.core.model.chat.basic.openai.AsyncOpenAI", - return_value=mock_client, - ) - - llm = OpenRouterLLM( - model_name="deepseek/deepseek-v4-flash", - api_key="test-key", - ) - - result = await llm.chat( - [{"role": "user", "content": "Read the page"}], - tools=[_browser_extract_text_tool()], - ) - - assert result["type"] == "tool_call" - assert result["content"] is None - assert result["tool_calls"][0]["function"]["name"] == "browser_extract_text" - assert json.loads(result["tool_calls"][0]["function"]["arguments"]) == { - "session_id": "821:react_468b98b2" - } - - -@pytest.mark.asyncio -async def test_openrouter_non_deepseek_does_not_parse_raw_dsml_content( - mocker, monkeypatch -): - monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") - message = SimpleNamespace( - content=_browser_extract_text_dsml(), - tool_calls=None, - reasoning_content=None, - ) - response = SimpleNamespace( - choices=[SimpleNamespace(message=message)], - usage=None, - model_dump=lambda: {"id": "openrouter-text"}, - ) - - mock_client = mocker.AsyncMock() - mock_client.chat.completions.create.return_value = response - mocker.patch( - "xagent.core.model.chat.basic.openai.AsyncOpenAI", - return_value=mock_client, - ) - - llm = OpenRouterLLM( - model_name="openai/gpt-5.5", - api_key="test-key", - ) - - result = await llm.chat( - [{"role": "user", "content": "Read the page"}], - tools=[_browser_extract_text_tool()], - ) - - assert result["type"] == "text" - assert "DSML" in result["content"] - - -@pytest.mark.asyncio -async def test_openrouter_deepseek_stream_with_tools_uses_non_streaming_dsml_parser( - mocker, monkeypatch -): - monkeypatch.setenv("XAGENT_OPENROUTER_OFFICIAL_PROVIDERS_ONLY", "false") - message = SimpleNamespace( - content=_browser_extract_text_dsml(), - tool_calls=None, - reasoning_content=None, - ) - response = SimpleNamespace( - choices=[SimpleNamespace(message=message)], - usage=None, - model_dump=lambda: {"id": "openrouter-dsml"}, - ) - - mock_client = mocker.AsyncMock() - mock_client.chat.completions.create.return_value = response - mocker.patch( - "xagent.core.model.chat.basic.openai.AsyncOpenAI", - return_value=mock_client, - ) - - llm = OpenRouterLLM( - model_name="deepseek/deepseek-v4-flash", - api_key="test-key", - ) - - chunks = [ - chunk - async for chunk in llm.stream_chat( - [{"role": "user", "content": "Read the page"}], - tools=[_browser_extract_text_tool()], - ) - ] - - assert len(chunks) == 1 - assert chunks[0].type == ChunkType.TOOL_CALL - assert chunks[0].tool_calls[0]["function"]["name"] == "browser_extract_text" - call_kwargs = mock_client.chat.completions.create.call_args.kwargs - assert "stream" not in call_kwargs From aca8327d4b03912fea5922b4d83c61ba76f1ae60 Mon Sep 17 00:00:00 2001 From: bsbds Date: Tue, 7 Jul 2026 19:14:06 +0800 Subject: [PATCH 04/11] ref(deepseek): simplify dsml sentinel plumbing and drop dead branches - stream_chat now peeks at the DSML parse-tools sentinel and forwards kwargs unchanged to chat(), which owns popping it, instead of pop-copy-reinsert; the native streaming path strips it so it can never reach provider request params. - chat() always returns a dict from the OpenAI-compatible response processing, so drop the string-response branch in normalize_deepseek_dsml_response, the non-dict fallback in stream_chunks_from_chat_result, and the multi-key _response_content scan in favor of a plain content lookup. --- src/xagent/core/model/chat/basic/deepseek.py | 13 +++-- .../core/model/chat/basic/deepseek_dsml.py | 57 +++++-------------- 2 files changed, 20 insertions(+), 50 deletions(-) diff --git a/src/xagent/core/model/chat/basic/deepseek.py b/src/xagent/core/model/chat/basic/deepseek.py index 49a222897..421b9f813 100644 --- a/src/xagent/core/model/chat/basic/deepseek.py +++ b/src/xagent/core/model/chat/basic/deepseek.py @@ -315,11 +315,11 @@ async def stream_chat( output_config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> AsyncIterator[StreamChunk]: - dsml_parse_tools = self._pop_deepseek_dsml_parse_tools(kwargs) - if tools or dsml_parse_tools: - chat_kwargs = dict(kwargs) - if dsml_parse_tools: - chat_kwargs[DEEPSEEK_DSML_PARSE_TOOLS_KWARG] = dsml_parse_tools + # DSML markup can only be recovered from a complete response, so any + # call that may need recovery (provider tools or parse-only schemas + # from a forced-final turn) goes through non-streaming chat(), which + # owns popping the sentinel kwarg. + if tools or kwargs.get(DEEPSEEK_DSML_PARSE_TOOLS_KWARG): result = await self.chat( messages=messages, temperature=temperature, @@ -329,12 +329,13 @@ async def stream_chat( response_format=response_format, thinking=thinking, output_config=output_config, - **chat_kwargs, + **kwargs, ) for chunk in stream_chunks_from_chat_result(result): yield chunk return + kwargs.pop(DEEPSEEK_DSML_PARSE_TOOLS_KWARG, None) response_format, output_config = self._normalize_response_format( response_format=response_format, output_config=output_config, diff --git a/src/xagent/core/model/chat/basic/deepseek_dsml.py b/src/xagent/core/model/chat/basic/deepseek_dsml.py index d6f163354..1d285d44a 100644 --- a/src/xagent/core/model/chat/basic/deepseek_dsml.py +++ b/src/xagent/core/model/chat/basic/deepseek_dsml.py @@ -61,21 +61,6 @@ def normalize_deepseek_dsml_response( current request. """ - if isinstance(response, str): - if not contains_deepseek_dsml_tool_markup(response): - return response, False - parsed = parse_deepseek_dsml_tool_calls( - response, - tools=tools, - model_name=model_name, - ) - return { - "type": "tool_call", - "content": parsed["content"] or None, - "tool_calls": parsed["tool_calls"], - "raw": response, - }, True - if not isinstance(response, dict): return response, False @@ -152,27 +137,15 @@ def parse_deepseek_dsml_tool_calls( return {"content": clean_content, "tool_calls": tool_calls} -def stream_chunks_from_chat_result(response: Any) -> Iterable[StreamChunk]: - """Represent a non-streaming DeepSeek chat result as stream chunks.""" +def stream_chunks_from_chat_result(response: dict[str, Any]) -> Iterable[StreamChunk]: + """Represent a non-streaming DeepSeek chat result as stream chunks. - if isinstance(response, dict): - content = _response_content(response) - if response.get("tool_calls"): - if content: - yield StreamChunk( - type=ChunkType.TOKEN, - content=content, - delta=content, - raw=response, - ) - yield StreamChunk( - type=ChunkType.TOOL_CALL, - tool_calls=list(response.get("tool_calls") or []), - finish_reason="tool_calls", - raw=response, - ) - return + ``chat()`` results are always dicts produced by the OpenAI-compatible + response processing, so only the dict shape is handled here. + """ + content = response.get("content") + if response.get("tool_calls"): if content: yield StreamChunk( type=ChunkType.TOKEN, @@ -180,10 +153,14 @@ def stream_chunks_from_chat_result(response: Any) -> Iterable[StreamChunk]: delta=content, raw=response, ) - yield StreamChunk(type=ChunkType.END, finish_reason="stop", raw=response) + yield StreamChunk( + type=ChunkType.TOOL_CALL, + tool_calls=list(response.get("tool_calls") or []), + finish_reason="tool_calls", + raw=response, + ) return - content = str(response) if content: yield StreamChunk( type=ChunkType.TOKEN, @@ -298,11 +275,3 @@ def _available_tool_names(tools: list[dict[str, Any]]) -> set[str]: if isinstance(name, str) and name.strip(): names.add(name.strip()) return names - - -def _response_content(response: dict[str, Any]) -> str: - for key in ("content", "answer", "output", "message"): - value = response.get(key) - if isinstance(value, str): - return value - return "" From f93551c1fb7bbcb5ca67e5c4e90d64232b3acedf Mon Sep 17 00:00:00 2001 From: bsbds Date: Wed, 8 Jul 2026 10:14:34 +0800 Subject: [PATCH 05/11] fix(deepseek): keep regular tool streams native --- src/xagent/core/model/chat/basic/deepseek.py | 9 ++-- tests/core/model/chat/basic/test_deepseek.py | 57 ++++++++++++++------ 2 files changed, 44 insertions(+), 22 deletions(-) diff --git a/src/xagent/core/model/chat/basic/deepseek.py b/src/xagent/core/model/chat/basic/deepseek.py index 421b9f813..b70d86d07 100644 --- a/src/xagent/core/model/chat/basic/deepseek.py +++ b/src/xagent/core/model/chat/basic/deepseek.py @@ -315,11 +315,9 @@ async def stream_chat( output_config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> AsyncIterator[StreamChunk]: - # DSML markup can only be recovered from a complete response, so any - # call that may need recovery (provider tools or parse-only schemas - # from a forced-final turn) goes through non-streaming chat(), which - # owns popping the sentinel kwarg. - if tools or kwargs.get(DEEPSEEK_DSML_PARSE_TOOLS_KWARG): + # DSML recovery needs a complete response, but that only applies to the + # parse-only forced-final path that opts in with the private sentinel. + if DEEPSEEK_DSML_PARSE_TOOLS_KWARG in kwargs: result = await self.chat( messages=messages, temperature=temperature, @@ -335,7 +333,6 @@ async def stream_chat( yield chunk return - kwargs.pop(DEEPSEEK_DSML_PARSE_TOOLS_KWARG, None) response_format, output_config = self._normalize_response_format( response_format=response_format, output_config=output_config, diff --git a/tests/core/model/chat/basic/test_deepseek.py b/tests/core/model/chat/basic/test_deepseek.py index aaa30d861..d2219f6c5 100644 --- a/tests/core/model/chat/basic/test_deepseek.py +++ b/tests/core/model/chat/basic/test_deepseek.py @@ -932,26 +932,50 @@ async def stream(): assert end_chunk.raw["reasoning_content"] == "Think first." @pytest.mark.asyncio - async def test_stream_chat_with_tools_uses_non_streaming_dsml_parser( + async def test_stream_chat_with_regular_tools_keeps_native_streaming( self, llm, mocker ): - message = SimpleNamespace( - content=_browser_extract_text_dsml(), - tool_calls=None, - reasoning_content=None, - ) - response = SimpleNamespace( - choices=[SimpleNamespace(message=message)], - usage=None, - model_dump=lambda: {"id": "deepseek-dsml"}, - ) + async def stream(): + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + reasoning_content=None, + content="Streaming with tools.", + tool_calls=None, + ), + finish_reason=None, + ) + ], + usage=None, + model_dump=lambda: {"id": "content"}, + ) + yield SimpleNamespace( + choices=[ + SimpleNamespace( + delta=SimpleNamespace( + reasoning_content=None, + content=None, + tool_calls=None, + ), + finish_reason="stop", + ) + ], + usage=None, + model_dump=lambda: {"id": "end"}, + ) mock_client = mocker.AsyncMock() - mock_client.chat.completions.create.return_value = response + mock_client.chat.completions.create.return_value = stream() mocker.patch( "xagent.core.model.chat.basic.openai.AsyncOpenAI", return_value=mock_client, ) + mocker.patch.object( + llm, + "chat", + side_effect=AssertionError("regular tool streams must not buffer"), + ) chunks = [ chunk @@ -961,11 +985,12 @@ async def test_stream_chat_with_tools_uses_non_streaming_dsml_parser( ) ] - assert len(chunks) == 1 - assert chunks[0].type == ChunkType.TOOL_CALL - assert chunks[0].tool_calls[0]["function"]["name"] == "browser_extract_text" + assert [chunk.delta for chunk in chunks if chunk.type == ChunkType.TOKEN] == [ + "Streaming with tools." + ] call_kwargs = mock_client.chat.completions.create.call_args.kwargs - assert "stream" not in call_kwargs + assert call_kwargs["stream"] is True + assert call_kwargs["tools"][0]["function"]["name"] == "browser_extract_text" @pytest.mark.asyncio async def test_stream_chat_hidden_dsml_parse_tools_uses_non_streaming_parser( From f103a29462d77e95717dbbb4c9bdcecd05ffa590 Mon Sep 17 00:00:00 2001 From: bsbds Date: Wed, 8 Jul 2026 10:14:57 +0800 Subject: [PATCH 06/11] fix(agent): avoid finishing tool-call preambles --- src/xagent/core/agent/runtime.py | 16 ++++++++++++- tests/core/agent/test_react.py | 39 +++++++++++++++++++++++++++++++- 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/src/xagent/core/agent/runtime.py b/src/xagent/core/agent/runtime.py index 50f9c4ac0..54cdb2596 100644 --- a/src/xagent/core/agent/runtime.py +++ b/src/xagent/core/agent/runtime.py @@ -114,10 +114,11 @@ async def stream_final_answer( stream = FinalAnswerStreamSession(self) if start_immediately and await stream.start() is None: return await self.run_llm_call(llm, **kwargs) + defer_text_delta = not start_immediately async def emit_text_delta(chunk: Any) -> None: delta = self._chunk_text_delta(chunk) - if delta: + if delta and not defer_text_delta: await stream.emit_delta(delta) try: @@ -129,6 +130,13 @@ async def emit_text_delta(chunk: Any) -> None: raise content = self._response_content(response) + if self._response_tool_calls(response): + if stream.started: + await stream.fail( + "Final-answer stream stopped because the response contained tool calls." + ) + return response + await stream.finish(content) return response @@ -379,6 +387,12 @@ def _response_content(self, response: Any) -> str: return str(value) return str(response) + def _response_tool_calls(self, response: Any) -> list[Any]: + if not isinstance(response, dict): + return [] + tool_calls = response.get("tool_calls") + return list(tool_calls) if tool_calls else [] + async def _emit_outbound(self, payload: dict[str, Any]) -> None: if self.outbound_message_handler is not None: await self._maybe_await(self.outbound_message_handler(payload)) diff --git a/tests/core/agent/test_react.py b/tests/core/agent/test_react.py index 17d3db0ff..c45c689c7 100644 --- a/tests/core/agent/test_react.py +++ b/tests/core/agent/test_react.py @@ -274,7 +274,8 @@ async def stream_chat(self, **kwargs: Any) -> Any: class StreamingDeepSeekForcedFinalToolLLM: """DeepSeek-like fake that can recover a tool call during a forced-final turn.""" - def __init__(self) -> None: + def __init__(self, *, forced_final_preamble: str | None = None) -> None: + self.forced_final_preamble = forced_final_preamble self.stream_calls: list[dict[str, Any]] = [] self.parser_tool_batches: list[list[dict[str, Any]]] = [] @@ -309,6 +310,11 @@ async def stream_chat(self, **kwargs: Any) -> Any: return if call_number == 2 and kwargs.get(DEEPSEEK_DSML_PARSE_TOOLS_KWARG): + if self.forced_final_preamble: + yield StreamChunk( + type=ChunkType.TOKEN, + delta=self.forced_final_preamble, + ) yield StreamChunk( type=ChunkType.TOOL_CALL, tool_calls=[ @@ -798,6 +804,37 @@ async def test_react_pattern_converts_deepseek_forced_final_dsml_tool_call() -> assert outbound.events[1]["delta"] == "The final result is 6." +@pytest.mark.asyncio +async def test_react_pattern_does_not_finish_forced_final_preamble_before_tool_call() -> ( + None +): + llm = StreamingDeepSeekForcedFinalToolLLM( + forced_final_preamble="I need one more calculation." + ) + pattern = ReActPattern(max_iterations=4, finalize_after_tool_result=True) + tool = FakeTool() + context = ExecutionContext(system_prompt="You are helpful.", execution_id="task-1") + context.add_user_message("Calculate 2+2 and then 3+3") + outbound = OutboundCollector() + runtime = PatternRuntime(execution_id="task-1", outbound_message_handler=outbound) + + result = await pattern.run(context=context, tools=[tool], llm=llm, runtime=runtime) + + assert result["success"] is True + assert result["response"] == "The final result is 6." + assert tool.calls == [{"expression": "2+2"}, {"expression": "3+3"}] + assert [event["type"] for event in outbound.events] == [ + "final_answer_start", + "final_answer_delta", + "final_answer_end", + ] + assert [event.get("delta") for event in outbound.events] == [ + None, + "The final result is 6.", + None, + ] + + @pytest.mark.asyncio async def test_react_pattern_does_not_stream_plain_text_when_tool_protocol_is_ignored() -> ( None From 778c46b5a2e49fa3a618efeea126d293e3f0aab8 Mon Sep 17 00:00:00 2001 From: bsbds Date: Wed, 8 Jul 2026 10:15:18 +0800 Subject: [PATCH 07/11] fix(deepseek): strip dsml around official tool calls --- .../core/model/chat/basic/deepseek_dsml.py | 80 +++++++++++-------- .../model/chat/basic/test_deepseek_dsml.py | 40 ++++++++++ 2 files changed, 87 insertions(+), 33 deletions(-) diff --git a/src/xagent/core/model/chat/basic/deepseek_dsml.py b/src/xagent/core/model/chat/basic/deepseek_dsml.py index 1d285d44a..dc61f255e 100644 --- a/src/xagent/core/model/chat/basic/deepseek_dsml.py +++ b/src/xagent/core/model/chat/basic/deepseek_dsml.py @@ -64,13 +64,21 @@ def normalize_deepseek_dsml_response( if not isinstance(response, dict): return response, False - if response.get("tool_calls"): - return response, False - content = response.get("content") if not isinstance(content, str) or not contains_deepseek_dsml_tool_markup(content): return response, False + if response.get("tool_calls"): + normalized = dict(response) + normalized["content"] = ( + _strip_dsml_tool_call_blocks( + content, + model_name=model_name, + ) + or None + ) + return normalized, True + parsed = parse_deepseek_dsml_tool_calls( content, tools=tools, @@ -102,18 +110,13 @@ def parse_deepseek_dsml_tool_calls( f"{model_name} returned DSML tool markup, but no callable tool schemas were supplied." ) - matches = list(_DSML_TOOL_CALLS_BLOCK_RE.finditer(text)) - if not matches: - raise DeepSeekDSMLParseError( - f"{model_name} returned incomplete DeepSeek DSML tool markup." - ) + matches, clean_content = _extract_dsml_tool_call_blocks( + text, + model_name=model_name, + ) - clean_parts: list[str] = [] tool_calls: list[dict[str, Any]] = [] - last_index = 0 - for match in matches: - clean_parts.append(text[last_index : match.start()]) tool_calls.extend( _parse_dsml_invokes( match.group(1), @@ -121,14 +124,7 @@ def parse_deepseek_dsml_tool_calls( model_name=model_name, ) ) - last_index = match.end() - clean_parts.append(text[last_index:]) - clean_content = "".join(clean_parts).strip() - if contains_deepseek_dsml_tool_markup(clean_content): - raise DeepSeekDSMLParseError( - f"{model_name} returned unparsed DeepSeek DSML tool markup." - ) if not tool_calls: raise DeepSeekDSMLParseError( f"{model_name} returned DSML tool markup without any valid invoke blocks." @@ -145,14 +141,15 @@ def stream_chunks_from_chat_result(response: dict[str, Any]) -> Iterable[StreamC """ content = response.get("content") + if content: + yield StreamChunk( + type=ChunkType.TOKEN, + content=content, + delta=content, + raw=response, + ) + if response.get("tool_calls"): - if content: - yield StreamChunk( - type=ChunkType.TOKEN, - content=content, - delta=content, - raw=response, - ) yield StreamChunk( type=ChunkType.TOOL_CALL, tool_calls=list(response.get("tool_calls") or []), @@ -161,16 +158,33 @@ def stream_chunks_from_chat_result(response: dict[str, Any]) -> Iterable[StreamC ) return - if content: - yield StreamChunk( - type=ChunkType.TOKEN, - content=content, - delta=content, - raw=response, - ) yield StreamChunk(type=ChunkType.END, finish_reason="stop", raw=response) +def _extract_dsml_tool_call_blocks( + text: str, + *, + model_name: str, +) -> tuple[list[re.Match[str]], str]: + matches = list(_DSML_TOOL_CALLS_BLOCK_RE.finditer(text)) + if not matches: + raise DeepSeekDSMLParseError( + f"{model_name} returned incomplete DeepSeek DSML tool markup." + ) + + clean_content = _DSML_TOOL_CALLS_BLOCK_RE.sub("", text).strip() + if contains_deepseek_dsml_tool_markup(clean_content): + raise DeepSeekDSMLParseError( + f"{model_name} returned unparsed DeepSeek DSML tool markup." + ) + return matches, clean_content + + +def _strip_dsml_tool_call_blocks(text: str, *, model_name: str) -> str: + _, clean_content = _extract_dsml_tool_call_blocks(text, model_name=model_name) + return clean_content + + def _parse_dsml_invokes( block_content: str, *, diff --git a/tests/core/model/chat/basic/test_deepseek_dsml.py b/tests/core/model/chat/basic/test_deepseek_dsml.py index a1f08fe42..8287f9787 100644 --- a/tests/core/model/chat/basic/test_deepseek_dsml.py +++ b/tests/core/model/chat/basic/test_deepseek_dsml.py @@ -113,6 +113,46 @@ def test_normalize_deepseek_dsml_response_strips_markup_from_visible_content(): assert normalized["raw"] == {"id": "deepseek-text"} +def test_parse_deepseek_dsml_tool_calls_preserves_surrounding_prose(): + parsed = parse_deepseek_dsml_tool_calls( + f"I will inspect the page first.{_dsml_block('||DSML||')}Then summarize it.", + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + assert parsed["content"] == "I will inspect the page first.Then summarize it." + assert parsed["tool_calls"][0]["function"]["name"] == "get_weather" + + +def test_normalize_deepseek_dsml_response_strips_markup_when_tool_calls_exist(): + official_tool_calls = [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location":"Boston"}', + }, + } + ] + response = { + "type": "tool_call", + "content": f"Let me check.\n\n{_dsml_block('||DSML||')}", + "tool_calls": official_tool_calls, + } + + normalized, changed = normalize_deepseek_dsml_response( + response, + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + assert changed is True + assert normalized["tool_calls"] is official_tool_calls + assert normalized["content"] == "Let me check." + assert "DSML" not in normalized["content"] + + def test_parse_deepseek_dsml_tool_calls_falls_back_to_string_for_invalid_json_value(): token = "|DSML|" text = f""" From 95dcf576acea37c7dce5b02826029a207b2e5ab9 Mon Sep 17 00:00:00 2001 From: bsbds Date: Wed, 8 Jul 2026 18:33:17 +0800 Subject: [PATCH 08/11] fix(deepseek): preserve tool calls with partial dsml --- .../core/model/chat/basic/deepseek_dsml.py | 17 ++++++----- .../model/chat/basic/test_deepseek_dsml.py | 29 +++++++++++++++++++ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/src/xagent/core/model/chat/basic/deepseek_dsml.py b/src/xagent/core/model/chat/basic/deepseek_dsml.py index dc61f255e..620ac56b5 100644 --- a/src/xagent/core/model/chat/basic/deepseek_dsml.py +++ b/src/xagent/core/model/chat/basic/deepseek_dsml.py @@ -69,14 +69,11 @@ def normalize_deepseek_dsml_response( return response, False if response.get("tool_calls"): + clean_content, stripped = _strip_complete_dsml_tool_call_blocks(content) + if not stripped: + return response, False normalized = dict(response) - normalized["content"] = ( - _strip_dsml_tool_call_blocks( - content, - model_name=model_name, - ) - or None - ) + normalized["content"] = clean_content or None return normalized, True parsed = parse_deepseek_dsml_tool_calls( @@ -185,6 +182,12 @@ def _strip_dsml_tool_call_blocks(text: str, *, model_name: str) -> str: return clean_content +def _strip_complete_dsml_tool_call_blocks(text: str) -> tuple[str, bool]: + if _DSML_TOOL_CALLS_BLOCK_RE.search(text) is None: + return text, False + return _DSML_TOOL_CALLS_BLOCK_RE.sub("", text).strip(), True + + def _parse_dsml_invokes( block_content: str, *, diff --git a/tests/core/model/chat/basic/test_deepseek_dsml.py b/tests/core/model/chat/basic/test_deepseek_dsml.py index 8287f9787..204e224f9 100644 --- a/tests/core/model/chat/basic/test_deepseek_dsml.py +++ b/tests/core/model/chat/basic/test_deepseek_dsml.py @@ -153,6 +153,35 @@ def test_normalize_deepseek_dsml_response_strips_markup_when_tool_calls_exist(): assert "DSML" not in normalized["content"] +def test_normalize_deepseek_dsml_response_keeps_official_tool_calls_with_partial_markup(): + official_tool_calls = [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location":"Boston"}', + }, + } + ] + response = { + "type": "tool_call", + "content": "Let me check.\n<||DSML||tool_calls>", + "tool_calls": official_tool_calls, + } + + normalized, changed = normalize_deepseek_dsml_response( + response, + tools=None, + model_name="deepseek-v4-flash", + ) + + assert changed is False + assert normalized is response + assert normalized["tool_calls"] is official_tool_calls + assert normalized["content"] == "Let me check.\n<||DSML||tool_calls>" + + def test_parse_deepseek_dsml_tool_calls_falls_back_to_string_for_invalid_json_value(): token = "|DSML|" text = f""" From 8cd01cfc96bee4a85b7102b090e2dba805d61225 Mon Sep 17 00:00:00 2001 From: bsbds Date: Thu, 9 Jul 2026 10:08:49 +0800 Subject: [PATCH 09/11] fix(deepseek): strip residual dsml tool markup --- .../core/model/chat/basic/deepseek_dsml.py | 18 ++++----- .../model/chat/basic/test_deepseek_dsml.py | 39 +++++++++++++++++-- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/xagent/core/model/chat/basic/deepseek_dsml.py b/src/xagent/core/model/chat/basic/deepseek_dsml.py index 620ac56b5..5bff7b425 100644 --- a/src/xagent/core/model/chat/basic/deepseek_dsml.py +++ b/src/xagent/core/model/chat/basic/deepseek_dsml.py @@ -69,7 +69,7 @@ def normalize_deepseek_dsml_response( return response, False if response.get("tool_calls"): - clean_content, stripped = _strip_complete_dsml_tool_call_blocks(content) + clean_content, stripped = _strip_dsml_markup_for_official_tool_calls(content) if not stripped: return response, False normalized = dict(response) @@ -177,15 +177,15 @@ def _extract_dsml_tool_call_blocks( return matches, clean_content -def _strip_dsml_tool_call_blocks(text: str, *, model_name: str) -> str: - _, clean_content = _extract_dsml_tool_call_blocks(text, model_name=model_name) - return clean_content +def _strip_dsml_markup_for_official_tool_calls(text: str) -> tuple[str, bool]: + """Best-effort content cleanup when provider tool_calls are authoritative.""" - -def _strip_complete_dsml_tool_call_blocks(text: str) -> tuple[str, bool]: - if _DSML_TOOL_CALLS_BLOCK_RE.search(text) is None: - return text, False - return _DSML_TOOL_CALLS_BLOCK_RE.sub("", text).strip(), True + stripped = _DSML_TOOL_CALLS_BLOCK_RE.search(text) is not None + clean_content = _DSML_TOOL_CALLS_BLOCK_RE.sub("", text).strip() + residual_marker = _DSML_MARKER_RE.search(clean_content) + if residual_marker is None: + return clean_content, stripped + return clean_content[: residual_marker.start()].strip(), True def _parse_dsml_invokes( diff --git a/tests/core/model/chat/basic/test_deepseek_dsml.py b/tests/core/model/chat/basic/test_deepseek_dsml.py index 204e224f9..9b5244c66 100644 --- a/tests/core/model/chat/basic/test_deepseek_dsml.py +++ b/tests/core/model/chat/basic/test_deepseek_dsml.py @@ -153,7 +153,7 @@ def test_normalize_deepseek_dsml_response_strips_markup_when_tool_calls_exist(): assert "DSML" not in normalized["content"] -def test_normalize_deepseek_dsml_response_keeps_official_tool_calls_with_partial_markup(): +def test_normalize_deepseek_dsml_response_strips_partial_markup_when_tool_calls_exist(): official_tool_calls = [ { "id": "call_1", @@ -176,10 +176,41 @@ def test_normalize_deepseek_dsml_response_keeps_official_tool_calls_with_partial model_name="deepseek-v4-flash", ) - assert changed is False - assert normalized is response + assert changed is True + assert normalized["tool_calls"] is official_tool_calls + assert normalized["content"] == "Let me check." + assert "DSML" not in normalized["content"] + + +def test_normalize_deepseek_dsml_response_strips_mixed_complete_and_partial_markup(): + official_tool_calls = [ + { + "id": "call_1", + "type": "function", + "function": { + "name": "get_weather", + "arguments": '{"location":"Boston"}', + }, + } + ] + response = { + "type": "tool_call", + "content": ( + f"Let me check.\n\n{_dsml_block('||DSML||')}\n<||DSML||tool_calls>" + ), + "tool_calls": official_tool_calls, + } + + normalized, changed = normalize_deepseek_dsml_response( + response, + tools=None, + model_name="deepseek-v4-flash", + ) + + assert changed is True assert normalized["tool_calls"] is official_tool_calls - assert normalized["content"] == "Let me check.\n<||DSML||tool_calls>" + assert normalized["content"] == "Let me check." + assert "DSML" not in normalized["content"] def test_parse_deepseek_dsml_tool_calls_falls_back_to_string_for_invalid_json_value(): From 16547350d7b05127bbe78dc4f730252024d8b96e Mon Sep 17 00:00:00 2001 From: bsbds Date: Thu, 9 Jul 2026 10:09:14 +0800 Subject: [PATCH 10/11] docs(deepseek): clarify dsml stream recovery scope --- src/xagent/core/model/chat/basic/deepseek.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/xagent/core/model/chat/basic/deepseek.py b/src/xagent/core/model/chat/basic/deepseek.py index b70d86d07..6489b90d3 100644 --- a/src/xagent/core/model/chat/basic/deepseek.py +++ b/src/xagent/core/model/chat/basic/deepseek.py @@ -315,8 +315,10 @@ async def stream_chat( output_config: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> AsyncIterator[StreamChunk]: - # DSML recovery needs a complete response, but that only applies to the - # parse-only forced-final path that opts in with the private sentinel. + # DSML recovery needs a complete response. Keep ordinary tool-enabled + # streams native; only forced-final turns opt into buffering with the + # private sentinel because those calls intentionally suppress provider + # tools while still needing local DSML validation. if DEEPSEEK_DSML_PARSE_TOOLS_KWARG in kwargs: result = await self.chat( messages=messages, From 84b0a1a93ff7ee4d56d99b08650eb2dc5e0f82dc Mon Sep 17 00:00:00 2001 From: bsbds Date: Thu, 9 Jul 2026 18:01:11 +0800 Subject: [PATCH 11/11] fix(deepseek): degrade incomplete dsml content --- .../core/model/chat/basic/deepseek_dsml.py | 30 ++++++++----- .../model/chat/basic/test_deepseek_dsml.py | 44 +++++++++++++++++++ 2 files changed, 64 insertions(+), 10 deletions(-) diff --git a/src/xagent/core/model/chat/basic/deepseek_dsml.py b/src/xagent/core/model/chat/basic/deepseek_dsml.py index 5bff7b425..e7c64408e 100644 --- a/src/xagent/core/model/chat/basic/deepseek_dsml.py +++ b/src/xagent/core/model/chat/basic/deepseek_dsml.py @@ -69,11 +69,14 @@ def normalize_deepseek_dsml_response( return response, False if response.get("tool_calls"): - clean_content, stripped = _strip_dsml_markup_for_official_tool_calls(content) - if not stripped: - return response, False normalized = dict(response) - normalized["content"] = clean_content or None + normalized["content"] = _strip_dsml_markup_from_visible_content(content) or None + return normalized, True + + content = _trim_unmatched_dsml_marker_tail(content) + if _DSML_TOOL_CALLS_BLOCK_RE.search(content) is None: + normalized = dict(response) + normalized["content"] = _strip_dsml_markup_from_visible_content(content) or None return normalized, True parsed = parse_deepseek_dsml_tool_calls( @@ -177,15 +180,22 @@ def _extract_dsml_tool_call_blocks( return matches, clean_content -def _strip_dsml_markup_for_official_tool_calls(text: str) -> tuple[str, bool]: - """Best-effort content cleanup when provider tool_calls are authoritative.""" - - stripped = _DSML_TOOL_CALLS_BLOCK_RE.search(text) is not None +def _strip_dsml_markup_from_visible_content(text: str) -> str: clean_content = _DSML_TOOL_CALLS_BLOCK_RE.sub("", text).strip() residual_marker = _DSML_MARKER_RE.search(clean_content) if residual_marker is None: - return clean_content, stripped - return clean_content[: residual_marker.start()].strip(), True + return clean_content + return clean_content[: residual_marker.start()].strip() + + +def _trim_unmatched_dsml_marker_tail(text: str) -> str: + complete_blocks = list(_DSML_TOOL_CALLS_BLOCK_RE.finditer(text)) + for marker in _DSML_MARKER_RE.finditer(text): + if not any( + block.start() <= marker.start() < block.end() for block in complete_blocks + ): + return text[: marker.start()].strip() + return text def _parse_dsml_invokes( diff --git a/tests/core/model/chat/basic/test_deepseek_dsml.py b/tests/core/model/chat/basic/test_deepseek_dsml.py index 9b5244c66..7e8d73189 100644 --- a/tests/core/model/chat/basic/test_deepseek_dsml.py +++ b/tests/core/model/chat/basic/test_deepseek_dsml.py @@ -213,6 +213,50 @@ def test_normalize_deepseek_dsml_response_strips_mixed_complete_and_partial_mark assert "DSML" not in normalized["content"] +def test_normalize_deepseek_dsml_response_strips_partial_markup_without_tool_calls(): + response = { + "type": "text", + "content": "Let me check.\n<||DSML||tool_calls>", + } + + normalized, changed = normalize_deepseek_dsml_response( + response, + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + assert changed is True + assert normalized["type"] == "text" + assert normalized["content"] == "Let me check." + assert "DSML" not in normalized["content"] + assert "tool_calls" not in normalized + + +def test_normalize_deepseek_dsml_response_recovers_complete_block_before_partial_markup(): + response = { + "type": "text", + "content": ( + f"Let me check.\n\n{_dsml_block('||DSML||')}\n<||DSML||tool_calls>" + ), + } + + normalized, changed = normalize_deepseek_dsml_response( + response, + tools=_tools("get_weather"), + model_name="deepseek-v4-flash", + ) + + assert changed is True + assert normalized["type"] == "tool_call" + assert normalized["content"] == "Let me check." + assert "DSML" not in normalized["content"] + assert json.loads(normalized["tool_calls"][0]["function"]["arguments"]) == { + "location": "Boston", + "limit": 5, + "options": {"fresh": True}, + } + + def test_parse_deepseek_dsml_tool_calls_falls_back_to_string_for_invalid_json_value(): token = "|DSML|" text = f"""