Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions astrbot/core/agent/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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(
Expand All @@ -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


Expand Down
17 changes: 15 additions & 2 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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)
Expand Down
55 changes: 43 additions & 12 deletions astrbot/core/provider/sources/openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 对象),
Expand All @@ -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,
Expand Down
156 changes: 156 additions & 0 deletions tests/test_openai_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)."""
Expand Down