diff --git a/astrbot/core/agent/runners/dify/dify_agent_runner.py b/astrbot/core/agent/runners/dify/dify_agent_runner.py index 93f8d3570d..41d4cd4ec3 100644 --- a/astrbot/core/agent/runners/dify/dify_agent_runner.py +++ b/astrbot/core/agent/runners/dify/dify_agent_runner.py @@ -1,5 +1,6 @@ import base64 import os +import re import sys import typing as T @@ -192,12 +193,14 @@ async def _execute_dify_request(self): # 如果是流式响应,发送增量数据 if self.streaming and chunk["answer"]: - yield AgentResponse( - type="streaming_delta", - data=AgentResponseData( - chain=MessageChain().message(chunk["answer"]) - ), - ) + delta = self._strip_think_tags(chunk["answer"]) + if delta: + yield AgentResponse( + type="streaming_delta", + data=AgentResponseData( + chain=MessageChain().message(delta) + ), + ) elif chunk["event"] == "message_end": logger.debug("Dify message end") break @@ -279,11 +282,23 @@ async def _execute_dify_request(self): data=AgentResponseData(chain=chain), ) + @staticmethod + def _strip_think_tags(text: str) -> str: + """Remove ... blocks and orphan tags from text. + + Some models (e.g. DeepSeek-R1) embed chain-of-thought inside tags + even when thinking mode is disabled on the Dify side. This mirrors the + same cleanup done in openai_source._parse_openai_completion. + """ + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = re.sub(r"\s*$", "", text) + return text.strip() + async def parse_dify_result(self, chunk: dict | str) -> MessageChain: """解析 Dify 的响应结果""" if isinstance(chunk, str): - # Chat - return MessageChain(chain=[Comp.Plain(chunk)]) + # Chat — strip any tags the underlying model may have emitted + return MessageChain(chain=[Comp.Plain(self._strip_think_tags(chunk))]) async def parse_file(item: dict): match item["type"]: @@ -303,8 +318,8 @@ async def parse_file(item: dict): output = chunk["data"]["outputs"][self.workflow_output_key] chains = [] if isinstance(output, str): - # 纯文本输出 - chains.append(Comp.Plain(output)) + # 纯文本输出,过滤 标签 + chains.append(Comp.Plain(self._strip_think_tags(output))) elif isinstance(output, list): # 主要适配 Dify 的 HTTP 请求结点的多模态输出 for item in output: @@ -313,10 +328,10 @@ async def parse_file(item: dict): not isinstance(item, dict) or item.get("dify_model_identity", "") != "__dify__file__" ): - chains.append(Comp.Plain(str(output))) + chains.append(Comp.Plain(self._strip_think_tags(str(output)))) break else: - chains.append(Comp.Plain(str(output))) + chains.append(Comp.Plain(self._strip_think_tags(str(output)))) # scan file files = chunk["data"].get("files", []) diff --git a/tests/test_dify_think_filter.py b/tests/test_dify_think_filter.py new file mode 100644 index 0000000000..e72c585bb1 --- /dev/null +++ b/tests/test_dify_think_filter.py @@ -0,0 +1,48 @@ +"""Unit tests for DifyAgentRunner._strip_think_tags""" + +import pytest + +from astrbot.core.agent.runners.dify.dify_agent_runner import DifyAgentRunner + +strip = DifyAgentRunner._strip_think_tags + + +class TestStripThinkTags: + def test_no_tags(self): + """Normal text without any think tags should be unchanged.""" + assert strip("Hello, world!") == "Hello, world!" + + def test_single_think_block(self): + """A complete ... block should be removed.""" + result = strip("let me reasonHere is my answer.") + assert result == "Here is my answer." + + def test_think_block_with_newlines(self): + """Multi-line think blocks should be removed.""" + text = "\nStep 1: think\nStep 2: conclude\n\nFinal answer." + assert strip(text) == "Final answer." + + def test_multiple_think_blocks(self): + """Multiple consecutive think blocks should all be removed.""" + text = "block1Middleblock2End" + assert strip(text) == "MiddleEnd" + + def test_orphan_closing_tag(self): + """A trailing without an opening tag should be removed.""" + assert strip("Some text") == "Some text" + + def test_empty_think_block(self): + """An empty block should be removed.""" + assert strip("Answer") == "Answer" + + def test_only_think_content(self): + """If the entire string is a think block, result should be empty string.""" + assert strip("all reasoning") == "" + + def test_whitespace_trimmed(self): + """Leading/trailing whitespace after stripping should be removed.""" + assert strip(" x Answer ") == "Answer" + + def test_no_modification_when_no_think(self): + """Strings without think tags must be returned as-is (stripped).""" + assert strip(" plain text ") == "plain text"