diff --git a/astrbot/core/agent/message.py b/astrbot/core/agent/message.py index ad3b57cb25..a89f058427 100644 --- a/astrbot/core/agent/message.py +++ b/astrbot/core/agent/message.py @@ -195,6 +195,9 @@ class Message(BaseModel): tool_call_id: str | None = None """The ID of the tool call.""" + reasoning_content: str | None = None + """The reasoning content from thinking mode providers (e.g. DeepSeek).""" + _no_save: bool = PrivateAttr(default=False) _checkpoint_after: CheckpointData | None = PrivateAttr(default=None) @@ -212,6 +215,10 @@ def check_content_required(self): if self.role == "assistant" and self.tool_calls is not None: return self + # assistant + reasoning_content is not None: allow content to be None + if self.role == "assistant" and self.reasoning_content is not None: + return self + # other all cases: content is required if self.content is None: raise ValueError( @@ -226,6 +233,8 @@ def serialize(self, handler): data.pop("tool_calls", None) if self.tool_call_id is None: data.pop("tool_call_id", None) + if self.reasoning_content is None: + data.pop("reasoning_content", None) return data diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index cf70b41504..fe976ae376 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -194,7 +194,13 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None parts.append(TextPart(text=llm_resp.completion_text)) if len(parts) == 0: logger.warning("LLM returned empty assistant message with no tool calls.") - self.run_context.messages.append(Message(role="assistant", content=parts)) + self.run_context.messages.append( + Message( + role="assistant", + content=parts, + reasoning_content=llm_resp.reasoning_content, + ) + ) try: await self.agent_hooks.on_agent_done(self.run_context, llm_resp) @@ -891,6 +897,7 @@ async def step(self): tool_calls_info=AssistantMessageSegment( tool_calls=llm_resp.to_openai_to_calls_model(), content=parts, + reasoning_content=llm_resp.reasoning_content, ), tool_calls_result=tool_call_result_blocks, ) @@ -1371,7 +1378,13 @@ async def _finalize_aborted_step( if llm_resp.completion_text: parts.append(TextPart(text=llm_resp.completion_text)) if parts: - self.run_context.messages.append(Message(role="assistant", content=parts)) + self.run_context.messages.append( + Message( + role="assistant", + content=parts, + reasoning_content=llm_resp.reasoning_content, + ) + ) try: await self.agent_hooks.on_agent_done(self.run_context, llm_resp) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index eb01ff01dc..d4148ab22b 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -524,9 +524,12 @@ def _sanitize_assistant_messages(payloads: dict) -> None: """在请求发送前过滤/规范化空的 assistant 消息。 严格 API(Moonshot、DeepSeek Reasoner 等)会在 assistant 消息同时缺少 - ``content`` 和 ``tool_calls`` 时返回 400。把 ``""`` / ``None`` / ``[]`` - 都视作空内容:无 tool_calls 时整条过滤掉;有 tool_calls 时将 content - 设为 ``None`` 以符合 OpenAI 规范。就地修改 ``payloads["messages"]``。 + ``content``、``tool_calls`` 和 ``reasoning_content`` 时返回 400。 + + 把 ``""`` / ``None`` / ``[]`` 都视作空内容: + - 无 tool_calls/reasoning_content 时整条过滤掉; + - 有 tool_calls 或 reasoning_content 时将 content 设为 ``None``。 + 就地修改 ``payloads["messages"]``。 """ messages = payloads.get("messages") if not isinstance(messages, list): @@ -543,12 +546,13 @@ def _is_empty(content: Any) -> bool: content = msg.get("content") tool_calls = msg.get("tool_calls") + has_reasoning_content = "reasoning_content" in msg - if _is_empty(content) and not tool_calls: + if _is_empty(content) and not tool_calls and not has_reasoning_content: logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") continue - if _is_empty(content) and tool_calls: + if _is_empty(content) and (tool_calls or has_reasoning_content): msg["content"] = None cleaned.append(msg) @@ -582,8 +586,6 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: extra_body.update(custom_extra_body) self._apply_provider_specific_extra_body_overrides(extra_body) - model = payloads.get("model", "").lower() - self._sanitize_assistant_messages(payloads) completion = await self.client.chat.completions.create( @@ -982,22 +984,33 @@ def _finally_convert_payload(self, payloads: dict) -> None: """Finally convert the payload. Such as think part conversion, tool inject.""" model = payloads.get("model", "").lower() is_gemini = "gemini" in model + messages = payloads.get("messages", []) + if not isinstance(messages, list): + return + + for message in messages: + if not isinstance(message, dict): + continue - for message in payloads.get("messages", []): if message.get("role") == "assistant" and isinstance( message.get("content"), list ): - reasoning_content = "" + reasoning_content: str | None = None new_content = [] # not including think part for part in message["content"]: - if part.get("type") == "think": - reasoning_content += str(part.get("think")) + if isinstance(part, dict) and part.get("type") == "think": + reasoning_content = (reasoning_content or "") + str( + part.get("think") or "" + ) else: new_content.append(part) + # Some providers (Grok, etc.) reject empty content lists. # When all parts were think blocks, fall back to None. message["content"] = new_content or None - if reasoning_content: + if reasoning_content is not None and not message.get( + "reasoning_content" + ): message["reasoning_content"] = reasoning_content # Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象), @@ -1012,6 +1025,24 @@ def _finally_convert_payload(self, payloads: dict) -> None: {"result": content}, ensure_ascii=False ) + if self._is_deepseek_thinking_model(model): + self._normalize_deepseek_thinking_messages(messages) + + @staticmethod + def _is_deepseek_thinking_model(model: str) -> bool: + return "deepseek" in model and ( + "v4" in model or "reasoner" in model or "flash" in model + ) + + @staticmethod + def _normalize_deepseek_thinking_messages(messages: list[dict]) -> None: + for message in messages: + if not isinstance(message, dict) or message.get("role") != "assistant": + continue + message.setdefault("reasoning_content", "") + if message.get("content") == []: + message["content"] = None + async def _handle_api_error( self, e: Exception, diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index ec5e79f492..73a1e75d12 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -245,6 +245,40 @@ async def test_openai_payload_keeps_reasoning_content_in_assistant_history(): await provider.terminate() +@pytest.mark.asyncio +async def test_deepseek_thinking_payload_adds_empty_reasoning_content_to_assistant_history(): + provider = _make_provider({"model": "deepseek-v4-flash"}) + try: + payloads = { + "model": "deepseek-v4-flash", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "foreign assistant reply"}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "search", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "call_1", "content": "result"}, + ], + } + + provider._finally_convert_payload(payloads) + + assert payloads["messages"][1]["reasoning_content"] == "" + assert payloads["messages"][2]["reasoning_content"] == "" + assert "reasoning_content" not in payloads["messages"][0] + assert "reasoning_content" not in payloads["messages"][3] + finally: + await provider.terminate() + + @pytest.mark.asyncio async def test_groq_payload_drops_reasoning_content_from_assistant_history(): provider = _make_groq_provider() @@ -1375,6 +1409,128 @@ async def fake_create(**kwargs): await provider.terminate() +@pytest.mark.asyncio +async def test_query_keeps_reasoning_only_assistant_message(monkeypatch): + """Test that reasoning-only assistant messages are preserved for thinking models.""" + provider = _make_provider() + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "reasoning_content": "deepseek reasoning", + }, + {"role": "user", "content": "world"}, + ], + } + + await provider._query(payloads=payloads, tools=None) + + messages = captured_kwargs["messages"] + assert len(messages) == 3 + assert messages[1] == { + "role": "assistant", + "content": None, + "reasoning_content": "deepseek reasoning", + } + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_keeps_empty_reasoning_content_assistant_message(monkeypatch): + """Test that empty reasoning_content still marks an assistant message as valid.""" + provider = _make_provider() + try: + captured_kwargs = {} + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return ChatCompletion.model_validate( + { + "id": "chatcmpl-test", + "object": "chat.completion", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "ok", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ) + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + { + "role": "assistant", + "content": None, + "reasoning_content": "", + }, + {"role": "user", "content": "world"}, + ], + } + + await provider._query(payloads=payloads, tools=None) + + messages = captured_kwargs["messages"] + assert len(messages) == 3 + assert messages[1] == { + "role": "assistant", + "content": None, + "reasoning_content": "", + } + finally: + await provider.terminate() + + @pytest.mark.asyncio async def test_query_converts_empty_content_to_none_with_tool_calls(monkeypatch): """Test that empty content with tool_calls is converted to None (OpenAI spec)."""