Skip to content
Open
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
72 changes: 71 additions & 1 deletion astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@
TextResourceContents,
)

import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
from astrbot.core.exceptions import LLMEmptyResponseError
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
Expand Down Expand Up @@ -219,6 +221,41 @@ async def _iter_llm_responses(
else:
yield await self.provider.text_chat(**payload)

def _is_empty_llm_response(self, resp: LLMResponse) -> bool:
"""Check if an LLM response is effectively empty.

This heuristic checks:
- completion_text is empty or whitespace only
- reasoning_content is empty or whitespace only
- tools_call_args is empty (no tool calls)
- result_chain has no meaningful content (Plain components with non-empty text,
or any non-Plain components like images, voice, etc.)

Returns True if the response contains no meaningful content.
"""
completion_text_stripped = (resp.completion_text or "").strip()
reasoning_content_stripped = (resp.reasoning_content or "").strip()

# Check result_chain for meaningful non-empty content (e.g., images, non-empty text)
has_result_chain_content = False
if resp.result_chain and resp.result_chain.chain:
for comp in resp.result_chain.chain:
# Skip empty Plain components
if isinstance(comp, Comp.Plain):
if comp.text and comp.text.strip():
has_result_chain_content = True
break
else:
# Non-Plain components (e.g., images, voice) are considered valid content
has_result_chain_content = True
break

return (
not completion_text_stripped
and not reasoning_content_stripped
and not resp.tools_call_args
and not has_result_chain_content
)
async def _iter_llm_responses_with_fallback(
self,
) -> T.AsyncGenerator[LLMResponse, None]:
Expand All @@ -241,11 +278,25 @@ async def _iter_llm_responses_with_fallback(
has_stream_output = False
try:
async for resp in self._iter_llm_responses(include_model=idx == 0):
# 对于流式 chunk,不立即检查是否为空,因为单个 chunk 可能只是元数据/心跳
# 流式响应的最终结果会在 resp.is_chunk=False 时返回
if resp.is_chunk:
has_stream_output = True
yield resp
continue

# 如果回复为空且无工具调用 且不是最后一个回退渠道 则引发fallback
# 此处不应判断整个消息链是否为空 因为消息链包含整个对话流 而空回复可能发生在任何阶段
# 使用辅助函数检查是否为空回复
if (
(resp.role == "assistant" or resp.role == "tool")
and self._is_empty_llm_response(resp)
and not is_last_candidate
):
logger.warning(
"Chat Model %s returns empty response, trying fallback to next provider.",
candidate_id,
)
break
if (
resp.role == "err"
and not has_stream_output
Expand Down Expand Up @@ -504,6 +555,25 @@ async def step(self):
logger.warning(
"LLM returned empty assistant message with no tool calls."
)
# 若所有fallback使用完毕后依然为空回复 则显示执行报错 避免静默
base_msg = "LLM returned empty assistant message with no tool calls."
model_id = getattr(self.run_context, "model_id", None)
provider_id = getattr(self.run_context, "provider_id", None)
run_id = getattr(self.run_context, "run_id", None)

ctx_parts = []
if model_id is not None:
ctx_parts.append(f"model_id={model_id}")
if provider_id is not None:
ctx_parts.append(f"provider_id={provider_id}")
if run_id is not None:
ctx_parts.append(f"run_id={run_id}")

if ctx_parts:
base_msg = f"{base_msg} Context: " + ", ".join(ctx_parts) + "."

raise LLMEmptyResponseError(base_msg)

self.run_context.messages.append(Message(role="assistant", content=parts))

# call the on_agent_done hook
Expand Down
4 changes: 4 additions & 0 deletions astrbot/core/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,7 @@ class AstrBotError(Exception):

class ProviderNotFoundError(AstrBotError):
"""Raised when a specified provider is not found."""


class LLMEmptyResponseError(AstrBotError):
"""Raised when LLM returns an empty assistant message with no tool calls."""
Loading