From b8728cd6fcefd7c59b0eaad26f44f7f74b559e26 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 23 Feb 2026 02:45:57 +0800 Subject: [PATCH 01/34] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D5081=E5=8F=B7PR?= =?UTF-8?q?=E5=9C=A8=E5=AD=90=E4=BB=A3=E7=90=86=E6=89=A7=E8=A1=8C=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=BB=BB=E5=8A=A1=E6=97=B6,=E6=9C=AA=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E4=BD=BF=E7=94=A8=E7=B3=BB=E7=BB=9F=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9A=84=E6=B5=81=E5=BC=8F/=E9=9D=9E=E6=B5=81=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E7=9A=84=E9=97=AE=E9=A2=98(#5081)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_agent_tool_exec.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 0a8aadb8fd..8e42a5eefc 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -148,6 +148,7 @@ async def _execute_handoff( contexts=contexts, max_steps=30, run_hooks=tool.agent.run_hooks, + stream=ctx.get_config().get("provider_settings", {}).get("stream", False), ) yield mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text=llm_resp.completion_text)] @@ -314,7 +315,7 @@ async def _wake_main_agent_for_background_result( message_type=session.message_type, ) cron_event.role = event.role - config = MainAgentBuildConfig(tool_call_timeout=3600) + config = MainAgentBuildConfig(tool_call_timeout=3600, streaming_response=ctx.get_config().get("provider_settings", {}).get("stream", False)) req = ProviderRequest() conv = await _get_session_conv(event=cron_event, plugin_context=ctx) From 746ffd3edbacaa7e415d90bba0bfd6276efee4e5 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 23 Feb 2026 03:09:31 +0800 Subject: [PATCH 02/34] =?UTF-8?q?feat:=E4=B8=BA=E5=AD=90=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=BF=9C=E7=A8=8B=E5=9B=BE=E7=89=87URL?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/handoff.py | 5 +++++ astrbot/core/astr_agent_tool_exec.py | 2 ++ 2 files changed, 7 insertions(+) diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py index 79a2945cdb..8fb6ce5d12 100644 --- a/astrbot/core/agent/handoff.py +++ b/astrbot/core/agent/handoff.py @@ -44,6 +44,11 @@ def default_parameters(self) -> dict: "type": "string", "description": "The input to be handed off to another agent. This should be a clear and concise request or task.", }, + "image_urls": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional: List of public image URLs for multi-modal tasks (e.g. video generation reference images).", + }, "background_task": { "type": "boolean", "description": ( diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 8e42a5eefc..bfda7b17ac 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -99,6 +99,7 @@ async def _execute_handoff( **tool_args, ): input_ = tool_args.get("input") + image_urls = tool_args.get("image_urls") # make toolset for the agent tools = tool.agent.tools @@ -143,6 +144,7 @@ async def _execute_handoff( event=event, chat_provider_id=prov_id, prompt=input_, + image_urls=image_urls, system_prompt=tool.agent.instructions, tools=toolset, contexts=contexts, From 140c014168fd1ab3bb08c24eaafc71b3353a3758 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 23 Feb 2026 22:13:41 +0800 Subject: [PATCH 03/34] fix: update description for image_urls parameter in HandoffTool to clarify usage in multimodal tasks --- astrbot/core/agent/handoff.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/agent/handoff.py b/astrbot/core/agent/handoff.py index 8fb6ce5d12..8475009d3f 100644 --- a/astrbot/core/agent/handoff.py +++ b/astrbot/core/agent/handoff.py @@ -47,7 +47,7 @@ def default_parameters(self) -> dict: "image_urls": { "type": "array", "items": {"type": "string"}, - "description": "Optional: List of public image URLs for multi-modal tasks (e.g. video generation reference images).", + "description": "Optional: An array of image sources (public HTTP URLs or local file paths) used as references in multimodal tasks such as video generation.", }, "background_task": { "type": "boolean", From 3d13673122a4081d03e57b3276d0ec9eb71d91d7 Mon Sep 17 00:00:00 2001 From: Soulter <905617992@qq.com> Date: Mon, 23 Feb 2026 22:15:30 +0800 Subject: [PATCH 04/34] ruff format --- astrbot/core/astr_agent_tool_exec.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index bfda7b17ac..19b4519783 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -317,7 +317,12 @@ async def _wake_main_agent_for_background_result( message_type=session.message_type, ) cron_event.role = event.role - config = MainAgentBuildConfig(tool_call_timeout=3600, streaming_response=ctx.get_config().get("provider_settings", {}).get("stream", False)) + config = MainAgentBuildConfig( + tool_call_timeout=3600, + streaming_response=ctx.get_config() + .get("provider_settings", {}) + .get("stream", False), + ) req = ProviderRequest() conv = await _get_session_conv(event=cron_event, plugin_context=ctx) From 8a44647c98169d1ef25cd9a816689ac94cf81e80 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Sun, 1 Mar 2026 00:51:05 +0800 Subject: [PATCH 05/34] =?UTF-8?q?fix:=E4=BF=AE=E6=AD=A3=E5=AD=90agent?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E6=AD=A3=E7=A1=AE=E6=8E=A5=E6=94=B6=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=9B=BE=E7=89=87(=E5=8F=82=E8=80=83=E5=9B=BE)?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_agent_tool_exec.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 46ec4346b3..64e27d1f53 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -11,6 +11,7 @@ from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.message import Message +from astrbot.core.message.components import Image from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor @@ -165,6 +166,23 @@ async def _execute_handoff( ): input_ = tool_args.get("input") image_urls = tool_args.get("image_urls") + if image_urls is None: + image_urls = [] + elif isinstance(image_urls, str): + image_urls = [image_urls] + + # 获取当前事件中的图片 + event = run_context.context.event + if event.message_obj and event.message_obj.message: + for component in event.message_obj.message: + if isinstance(component, Image): + try: + # 调用组件的 convert_to_file_path 异步方法 + path = await component.convert_to_file_path() + if path and path not in image_urls: + image_urls.append(path) + except Exception as e: + logger.error(f"转换图片失败: {e}") # Build handoff toolset from registered tools plus runtime computer tools. toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) From cb22ac4c0e914aa78b217f1b5e4d5fd702ff8eca Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Sun, 1 Mar 2026 01:17:39 +0800 Subject: [PATCH 06/34] =?UTF-8?q?fix:=E5=A2=9E=E5=BC=BAimage=5Furls?= =?UTF-8?q?=E6=8E=A5=E6=94=B6=E7=9A=84=E9=B2=81=E6=A3=92=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_agent_tool_exec.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 64e27d1f53..c4c6ea0f31 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -170,6 +170,11 @@ async def _execute_handoff( image_urls = [] elif isinstance(image_urls, str): image_urls = [image_urls] + else: + try: + image_urls = list(image_urls) + except (TypeError, ValueError): + image_urls = [image_urls] # 获取当前事件中的图片 event = run_context.context.event From d6b5fd128b66f410022e79863541765469eb936c Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Sun, 1 Mar 2026 01:32:27 +0800 Subject: [PATCH 07/34] =?UTF-8?q?fix:ruff=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/astr_agent_tool_exec.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index c4c6ea0f31..6518fdbeb8 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -11,7 +11,6 @@ from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool from astrbot.core.agent.message import Message -from astrbot.core.message.components import Image from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor @@ -27,6 +26,7 @@ SEND_MESSAGE_TO_USER_TOOL, ) from astrbot.core.cron.events import CronMessageEvent +from astrbot.core.message.components import Image from astrbot.core.message.message_event_result import ( CommandResult, MessageChain, @@ -175,7 +175,7 @@ async def _execute_handoff( image_urls = list(image_urls) except (TypeError, ValueError): image_urls = [image_urls] - + # 获取当前事件中的图片 event = run_context.context.event if event.message_obj and event.message_obj.message: From 123efc29bde43ba99278445a18c015a42b4e6655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 1 Mar 2026 16:20:16 +0900 Subject: [PATCH 08/34] fix: harden handoff image_urls preprocessing --- astrbot/core/astr_agent_tool_exec.py | 111 ++++++++++++++++++------ tests/unit/test_astr_agent_tool_exec.py | 107 +++++++++++++++++++++++ 2 files changed, 193 insertions(+), 25 deletions(-) create mode 100644 tests/unit/test_astr_agent_tool_exec.py diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 6518fdbeb8..5aa9dd0188 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -1,6 +1,7 @@ import asyncio import inspect import json +import os import traceback import typing as T import uuid @@ -36,9 +37,87 @@ from astrbot.core.provider.entites import ProviderRequest from astrbot.core.provider.register import llm_tools from astrbot.core.utils.history_saver import persist_agent_history +from astrbot.core.utils.string_utils import normalize_and_dedupe_strings class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): + _ALLOWED_IMAGE_EXTENSIONS = { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".tif", + ".tiff", + ".svg", + ".heic", + } + + @classmethod + def _is_supported_image_ref(cls, image_ref: str) -> bool: + if not image_ref: + return False + lowered = image_ref.lower() + if lowered.startswith(("http://", "https://", "base64://")): + return True + file_path = image_ref[8:] if lowered.startswith("file:///") else image_ref + ext = os.path.splitext(file_path)[1].lower() + return ext in cls._ALLOWED_IMAGE_EXTENSIONS + + @classmethod + async def _prepare_handoff_image_urls( + cls, + run_context: ContextWrapper[AstrAgentContext], + tool_args: dict[str, T.Any], + ) -> list[str]: + image_urls = tool_args.get("image_urls") + if image_urls is None: + candidates: list[T.Any] = [] + elif isinstance(image_urls, str): + candidates = [image_urls] + else: + try: + candidates = list(image_urls) + except (TypeError, ValueError): + candidates = [image_urls] + + normalized = normalize_and_dedupe_strings(candidates) + sanitized = [item for item in normalized if cls._is_supported_image_ref(item)] + dropped_count = len(normalized) - len(sanitized) + if dropped_count > 0: + logger.warning( + "Dropped %d invalid image_urls entries in handoff tool args.", + dropped_count, + ) + + # Merge current event image attachments so sub-agent behavior matches main-agent flow. + event = getattr(run_context.context, "event", None) + message_obj = getattr(event, "message_obj", None) + message = getattr(message_obj, "message", None) + if message: + for idx, component in enumerate(message): + if not isinstance(component, Image): + continue + try: + path = await component.convert_to_file_path() + if ( + path + and cls._is_supported_image_ref(path) + and path not in sanitized + ): + sanitized.append(path) + except Exception as e: + logger.error( + "Failed to convert handoff image component at index %d: %s", + idx, + e, + exc_info=True, + ) + + tool_args["image_urls"] = sanitized + return sanitized + @classmethod async def execute(cls, tool, run_context, **tool_args): """执行函数调用。 @@ -165,29 +244,7 @@ async def _execute_handoff( **tool_args, ): input_ = tool_args.get("input") - image_urls = tool_args.get("image_urls") - if image_urls is None: - image_urls = [] - elif isinstance(image_urls, str): - image_urls = [image_urls] - else: - try: - image_urls = list(image_urls) - except (TypeError, ValueError): - image_urls = [image_urls] - - # 获取当前事件中的图片 - event = run_context.context.event - if event.message_obj and event.message_obj.message: - for component in event.message_obj.message: - if isinstance(component, Image): - try: - # 调用组件的 convert_to_file_path 异步方法 - path = await component.convert_to_file_path() - if path and path not in image_urls: - image_urls.append(path) - except Exception as e: - logger.error(f"转换图片失败: {e}") + image_urls = await cls._prepare_handoff_image_urls(run_context, tool_args) # Build handoff toolset from registered tools plus runtime computer tools. toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) @@ -286,8 +343,12 @@ async def _do_handoff_background( ) -> None: """Run the subagent handoff and, on completion, wake the main agent.""" result_text = "" + prepared_tool_args = dict(tool_args) try: - async for r in cls._execute_handoff(tool, run_context, **tool_args): + await cls._prepare_handoff_image_urls(run_context, prepared_tool_args) + async for r in cls._execute_handoff( + tool, run_context, **prepared_tool_args + ): if isinstance(r, mcp.types.CallToolResult): for content in r.content: if isinstance(content, mcp.types.TextContent): @@ -304,7 +365,7 @@ async def _do_handoff_background( task_id=task_id, tool_name=tool.name, result_text=result_text, - tool_args=tool_args, + tool_args=prepared_tool_args, note=( event.get_extra("background_note") or f"Background task for subagent '{tool.agent.name}' finished." diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py new file mode 100644 index 0000000000..32b2bf0e01 --- /dev/null +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -0,0 +1,107 @@ +from types import SimpleNamespace + +import mcp +import pytest + +from astrbot.core.agent.run_context import ContextWrapper +from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor +from astrbot.core.message.components import Image + + +class _DummyEvent: + def __init__(self, message_components: list[object] | None = None) -> None: + self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" + self.message_obj = SimpleNamespace(message=message_components or []) + + def get_extra(self, _key: str): + return None + + +class _DummyTool: + def __init__(self) -> None: + self.name = "transfer_to_subagent" + self.agent = SimpleNamespace(name="subagent") + + +def _build_run_context(message_components: list[object] | None = None): + event = _DummyEvent(message_components=message_components) + ctx = SimpleNamespace(event=event, context=SimpleNamespace()) + return ContextWrapper(context=ctx) + + +@pytest.mark.asyncio +async def test_prepare_handoff_image_urls_normalizes_filters_and_appends_event_image( + monkeypatch: pytest.MonkeyPatch, +): + async def _fake_convert_to_file_path(self): + return "/tmp/event_image.png" + + monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) + + run_context = _build_run_context([Image(file="file:///tmp/original.png")]) + tool_args = { + "image_urls": ( + " https://example.com/a.png ", + "/tmp/not_an_image.txt", + "/tmp/local.webp", + 123, + ) + } + + image_urls = await FunctionToolExecutor._prepare_handoff_image_urls( + run_context, + tool_args, + ) + + assert image_urls == [ + "https://example.com/a.png", + "/tmp/local.webp", + "/tmp/event_image.png", + ] + assert tool_args["image_urls"] == image_urls + + +@pytest.mark.asyncio +async def test_do_handoff_background_reports_prepared_image_urls( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict = {} + + async def _fake_prepare(cls, run_context, tool_args): + tool_args["image_urls"] = ["prepared://image.png"] + return tool_args["image_urls"] + + async def _fake_execute_handoff(cls, tool, run_context, **tool_args): + yield mcp.types.CallToolResult( + content=[mcp.types.TextContent(type="text", text="ok")] + ) + + async def _fake_wake(cls, run_context, **kwargs): + captured.update(kwargs) + + monkeypatch.setattr( + FunctionToolExecutor, + "_prepare_handoff_image_urls", + classmethod(_fake_prepare), + ) + monkeypatch.setattr( + FunctionToolExecutor, + "_execute_handoff", + classmethod(_fake_execute_handoff), + ) + monkeypatch.setattr( + FunctionToolExecutor, + "_wake_main_agent_for_background_result", + classmethod(_fake_wake), + ) + + run_context = _build_run_context() + await FunctionToolExecutor._do_handoff_background( + tool=_DummyTool(), + run_context=run_context, + task_id="task-id", + input="hello", + image_urls="https://example.com/raw.png", + ) + + assert captured["tool_args"]["image_urls"] == ["prepared://image.png"] From 27706d8b827cc6f52c88ab82d12fc28481595ac5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 1 Mar 2026 16:27:43 +0900 Subject: [PATCH 09/34] fix: refactor handoff image_urls preprocessing flow --- astrbot/core/astr_agent_tool_exec.py | 74 +++++++++++++++---------- tests/unit/test_astr_agent_tool_exec.py | 45 ++++++++++----- 2 files changed, 74 insertions(+), 45 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 5aa9dd0188..cfb03220d4 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -5,6 +5,8 @@ import traceback import typing as T import uuid +from collections.abc import Sequence +from collections.abc import Set as AbstractSet import mcp @@ -66,22 +68,23 @@ def _is_supported_image_ref(cls, image_ref: str) -> bool: return ext in cls._ALLOWED_IMAGE_EXTENSIONS @classmethod - async def _prepare_handoff_image_urls( - cls, - run_context: ContextWrapper[AstrAgentContext], - tool_args: dict[str, T.Any], - ) -> list[str]: - image_urls = tool_args.get("image_urls") + def _coerce_image_urls(cls, image_urls: T.Any) -> list[T.Any]: if image_urls is None: - candidates: list[T.Any] = [] - elif isinstance(image_urls, str): - candidates = [image_urls] - else: - try: - candidates = list(image_urls) - except (TypeError, ValueError): - candidates = [image_urls] + return [] + if isinstance(image_urls, str): + return [image_urls] + if isinstance(image_urls, (Sequence, AbstractSet)) and not isinstance( + image_urls, (str, bytes, bytearray) + ): + return list(image_urls) + logger.warning( + "Unsupported image_urls type in handoff tool args: %s", + type(image_urls).__name__, + ) + return [] + @classmethod + def _filter_supported_image_urls(cls, candidates: list[T.Any]) -> list[str]: normalized = normalize_and_dedupe_strings(candidates) sanitized = [item for item in normalized if cls._is_supported_image_ref(item)] dropped_count = len(normalized) - len(sanitized) @@ -90,8 +93,13 @@ async def _prepare_handoff_image_urls( "Dropped %d invalid image_urls entries in handoff tool args.", dropped_count, ) + return sanitized - # Merge current event image attachments so sub-agent behavior matches main-agent flow. + @classmethod + async def _iter_event_image_paths( + cls, run_context: ContextWrapper[AstrAgentContext] + ) -> list[str]: + paths: list[str] = [] event = getattr(run_context.context, "event", None) message_obj = getattr(event, "message_obj", None) message = getattr(message_obj, "message", None) @@ -101,12 +109,8 @@ async def _prepare_handoff_image_urls( continue try: path = await component.convert_to_file_path() - if ( - path - and cls._is_supported_image_ref(path) - and path not in sanitized - ): - sanitized.append(path) + if path and cls._is_supported_image_ref(path): + paths.append(path) except Exception as e: logger.error( "Failed to convert handoff image component at index %d: %s", @@ -114,9 +118,18 @@ async def _prepare_handoff_image_urls( e, exc_info=True, ) + return paths - tool_args["image_urls"] = sanitized - return sanitized + @classmethod + async def _prepare_handoff_image_urls( + cls, + run_context: ContextWrapper[AstrAgentContext], + image_urls: T.Any, + ) -> list[str]: + candidates = cls._coerce_image_urls(image_urls) + event_paths = await cls._iter_event_image_paths(run_context) + candidates.extend(event_paths) + return cls._filter_supported_image_urls(candidates) @classmethod async def execute(cls, tool, run_context, **tool_args): @@ -138,7 +151,7 @@ async def execute(cls, tool, run_context, **tool_args): ): yield r return - async for r in cls._execute_handoff(tool, run_context, **tool_args): + async for r in cls._execute_handoff(tool, run_context, tool_args): yield r return @@ -241,10 +254,14 @@ async def _execute_handoff( cls, tool: HandoffTool, run_context: ContextWrapper[AstrAgentContext], - **tool_args, + tool_args: dict[str, T.Any], ): input_ = tool_args.get("input") - image_urls = await cls._prepare_handoff_image_urls(run_context, tool_args) + image_urls = await cls._prepare_handoff_image_urls( + run_context, + tool_args.get("image_urls"), + ) + tool_args["image_urls"] = image_urls # Build handoff toolset from registered tools plus runtime computer tools. toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) @@ -345,10 +362,7 @@ async def _do_handoff_background( result_text = "" prepared_tool_args = dict(tool_args) try: - await cls._prepare_handoff_image_urls(run_context, prepared_tool_args) - async for r in cls._execute_handoff( - tool, run_context, **prepared_tool_args - ): + async for r in cls._execute_handoff(tool, run_context, prepared_tool_args): if isinstance(r, mcp.types.CallToolResult): for content in r.content: if isinstance(content, mcp.types.TextContent): diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 32b2bf0e01..66d08683ff 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -39,18 +39,16 @@ async def _fake_convert_to_file_path(self): monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) run_context = _build_run_context([Image(file="file:///tmp/original.png")]) - tool_args = { - "image_urls": ( - " https://example.com/a.png ", - "/tmp/not_an_image.txt", - "/tmp/local.webp", - 123, - ) - } + image_urls_input = ( + " https://example.com/a.png ", + "/tmp/not_an_image.txt", + "/tmp/local.webp", + 123, + ) image_urls = await FunctionToolExecutor._prepare_handoff_image_urls( run_context, - tool_args, + image_urls_input, ) assert image_urls == [ @@ -58,7 +56,24 @@ async def _fake_convert_to_file_path(self): "/tmp/local.webp", "/tmp/event_image.png", ] - assert tool_args["image_urls"] == image_urls + + +@pytest.mark.asyncio +async def test_prepare_handoff_image_urls_skips_failed_event_image_conversion( + monkeypatch: pytest.MonkeyPatch, +): + async def _fake_convert_to_file_path(self): + raise RuntimeError("boom") + + monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) + + run_context = _build_run_context([Image(file="file:///tmp/original.png")]) + image_urls = await FunctionToolExecutor._prepare_handoff_image_urls( + run_context, + ["https://example.com/a.png"], + ) + + assert image_urls == ["https://example.com/a.png"] @pytest.mark.asyncio @@ -67,11 +82,11 @@ async def test_do_handoff_background_reports_prepared_image_urls( ): captured: dict = {} - async def _fake_prepare(cls, run_context, tool_args): - tool_args["image_urls"] = ["prepared://image.png"] - return tool_args["image_urls"] + async def _unexpected_prepare(cls, run_context, image_urls): + raise AssertionError("background path should not pre-prepare image urls") - async def _fake_execute_handoff(cls, tool, run_context, **tool_args): + async def _fake_execute_handoff(cls, tool, run_context, tool_args): + tool_args["image_urls"] = ["prepared://image.png"] yield mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text="ok")] ) @@ -82,7 +97,7 @@ async def _fake_wake(cls, run_context, **kwargs): monkeypatch.setattr( FunctionToolExecutor, "_prepare_handoff_image_urls", - classmethod(_fake_prepare), + classmethod(_unexpected_prepare), ) monkeypatch.setattr( FunctionToolExecutor, From 8c02f85d02c376917bec162bb4bd8ed234b98460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 1 Mar 2026 16:32:36 +0900 Subject: [PATCH 10/34] refactor: simplify handoff image_urls data flow --- astrbot/core/astr_agent_tool_exec.py | 84 +++++++++++-------------- tests/unit/test_astr_agent_tool_exec.py | 21 ++----- 2 files changed, 42 insertions(+), 63 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index cfb03220d4..59ed596a97 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -68,38 +68,26 @@ def _is_supported_image_ref(cls, image_ref: str) -> bool: return ext in cls._ALLOWED_IMAGE_EXTENSIONS @classmethod - def _coerce_image_urls(cls, image_urls: T.Any) -> list[T.Any]: - if image_urls is None: - return [] - if isinstance(image_urls, str): - return [image_urls] - if isinstance(image_urls, (Sequence, AbstractSet)) and not isinstance( - image_urls, (str, bytes, bytearray) + async def _collect_handoff_image_urls( + cls, + run_context: ContextWrapper[AstrAgentContext], + image_urls_raw: T.Any, + ) -> list[str]: + candidates: list[T.Any] = [] + if image_urls_raw is None: + pass + elif isinstance(image_urls_raw, str): + candidates.append(image_urls_raw) + elif isinstance(image_urls_raw, (Sequence, AbstractSet)) and not isinstance( + image_urls_raw, (str, bytes, bytearray) ): - return list(image_urls) - logger.warning( - "Unsupported image_urls type in handoff tool args: %s", - type(image_urls).__name__, - ) - return [] - - @classmethod - def _filter_supported_image_urls(cls, candidates: list[T.Any]) -> list[str]: - normalized = normalize_and_dedupe_strings(candidates) - sanitized = [item for item in normalized if cls._is_supported_image_ref(item)] - dropped_count = len(normalized) - len(sanitized) - if dropped_count > 0: + candidates.extend(image_urls_raw) + else: logger.warning( - "Dropped %d invalid image_urls entries in handoff tool args.", - dropped_count, + "Unsupported image_urls type in handoff tool args: %s", + type(image_urls_raw).__name__, ) - return sanitized - @classmethod - async def _iter_event_image_paths( - cls, run_context: ContextWrapper[AstrAgentContext] - ) -> list[str]: - paths: list[str] = [] event = getattr(run_context.context, "event", None) message_obj = getattr(event, "message_obj", None) message = getattr(message_obj, "message", None) @@ -110,7 +98,7 @@ async def _iter_event_image_paths( try: path = await component.convert_to_file_path() if path and cls._is_supported_image_ref(path): - paths.append(path) + candidates.append(path) except Exception as e: logger.error( "Failed to convert handoff image component at index %d: %s", @@ -118,18 +106,16 @@ async def _iter_event_image_paths( e, exc_info=True, ) - return paths - @classmethod - async def _prepare_handoff_image_urls( - cls, - run_context: ContextWrapper[AstrAgentContext], - image_urls: T.Any, - ) -> list[str]: - candidates = cls._coerce_image_urls(image_urls) - event_paths = await cls._iter_event_image_paths(run_context) - candidates.extend(event_paths) - return cls._filter_supported_image_urls(candidates) + normalized = normalize_and_dedupe_strings(candidates) + sanitized = [item for item in normalized if cls._is_supported_image_ref(item)] + dropped_count = len(normalized) - len(sanitized) + if dropped_count > 0: + logger.warning( + "Dropped %d invalid image_urls entries in handoff tool args.", + dropped_count, + ) + return sanitized @classmethod async def execute(cls, tool, run_context, **tool_args): @@ -151,7 +137,7 @@ async def execute(cls, tool, run_context, **tool_args): ): yield r return - async for r in cls._execute_handoff(tool, run_context, tool_args): + async for r in cls._execute_handoff(tool, run_context, **tool_args): yield r return @@ -254,14 +240,17 @@ async def _execute_handoff( cls, tool: HandoffTool, run_context: ContextWrapper[AstrAgentContext], - tool_args: dict[str, T.Any], + **tool_args: T.Any, ): input_ = tool_args.get("input") - image_urls = await cls._prepare_handoff_image_urls( + image_urls = await cls._collect_handoff_image_urls( run_context, tool_args.get("image_urls"), ) - tool_args["image_urls"] = image_urls + effective_tool_args: dict[str, T.Any] = { + **tool_args, + "image_urls": image_urls, + } # Build handoff toolset from registered tools plus runtime computer tools. toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) @@ -295,7 +284,7 @@ async def _execute_handoff( event=event, chat_provider_id=prov_id, prompt=input_, - image_urls=image_urls, + image_urls=effective_tool_args["image_urls"], system_prompt=tool.agent.instructions, tools=toolset, contexts=contexts, @@ -360,9 +349,8 @@ async def _do_handoff_background( ) -> None: """Run the subagent handoff and, on completion, wake the main agent.""" result_text = "" - prepared_tool_args = dict(tool_args) try: - async for r in cls._execute_handoff(tool, run_context, prepared_tool_args): + async for r in cls._execute_handoff(tool, run_context, **tool_args): if isinstance(r, mcp.types.CallToolResult): for content in r.content: if isinstance(content, mcp.types.TextContent): @@ -379,7 +367,7 @@ async def _do_handoff_background( task_id=task_id, tool_name=tool.name, result_text=result_text, - tool_args=prepared_tool_args, + tool_args=tool_args, note=( event.get_extra("background_note") or f"Background task for subagent '{tool.agent.name}' finished." diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 66d08683ff..29647b8bdc 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -30,7 +30,7 @@ def _build_run_context(message_components: list[object] | None = None): @pytest.mark.asyncio -async def test_prepare_handoff_image_urls_normalizes_filters_and_appends_event_image( +async def test_collect_handoff_image_urls_normalizes_filters_and_appends_event_image( monkeypatch: pytest.MonkeyPatch, ): async def _fake_convert_to_file_path(self): @@ -46,7 +46,7 @@ async def _fake_convert_to_file_path(self): 123, ) - image_urls = await FunctionToolExecutor._prepare_handoff_image_urls( + image_urls = await FunctionToolExecutor._collect_handoff_image_urls( run_context, image_urls_input, ) @@ -59,7 +59,7 @@ async def _fake_convert_to_file_path(self): @pytest.mark.asyncio -async def test_prepare_handoff_image_urls_skips_failed_event_image_conversion( +async def test_collect_handoff_image_urls_skips_failed_event_image_conversion( monkeypatch: pytest.MonkeyPatch, ): async def _fake_convert_to_file_path(self): @@ -68,7 +68,7 @@ async def _fake_convert_to_file_path(self): monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) run_context = _build_run_context([Image(file="file:///tmp/original.png")]) - image_urls = await FunctionToolExecutor._prepare_handoff_image_urls( + image_urls = await FunctionToolExecutor._collect_handoff_image_urls( run_context, ["https://example.com/a.png"], ) @@ -82,11 +82,7 @@ async def test_do_handoff_background_reports_prepared_image_urls( ): captured: dict = {} - async def _unexpected_prepare(cls, run_context, image_urls): - raise AssertionError("background path should not pre-prepare image urls") - - async def _fake_execute_handoff(cls, tool, run_context, tool_args): - tool_args["image_urls"] = ["prepared://image.png"] + async def _fake_execute_handoff(cls, tool, run_context, **tool_args): yield mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text="ok")] ) @@ -94,11 +90,6 @@ async def _fake_execute_handoff(cls, tool, run_context, tool_args): async def _fake_wake(cls, run_context, **kwargs): captured.update(kwargs) - monkeypatch.setattr( - FunctionToolExecutor, - "_prepare_handoff_image_urls", - classmethod(_unexpected_prepare), - ) monkeypatch.setattr( FunctionToolExecutor, "_execute_handoff", @@ -119,4 +110,4 @@ async def _fake_wake(cls, run_context, **kwargs): image_urls="https://example.com/raw.png", ) - assert captured["tool_args"]["image_urls"] == ["prepared://image.png"] + assert captured["tool_args"]["image_urls"] == "https://example.com/raw.png" From 3023742f21441ca3693f280f3d6cf7fa2fb94e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 1 Mar 2026 16:35:45 +0900 Subject: [PATCH 11/34] fix: filter non-string handoff image_urls entries --- astrbot/core/astr_agent_tool_exec.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 59ed596a97..294a690d78 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -73,7 +73,7 @@ async def _collect_handoff_image_urls( run_context: ContextWrapper[AstrAgentContext], image_urls_raw: T.Any, ) -> list[str]: - candidates: list[T.Any] = [] + candidates: list[str] = [] if image_urls_raw is None: pass elif isinstance(image_urls_raw, str): @@ -81,7 +81,17 @@ async def _collect_handoff_image_urls( elif isinstance(image_urls_raw, (Sequence, AbstractSet)) and not isinstance( image_urls_raw, (str, bytes, bytearray) ): - candidates.extend(image_urls_raw) + non_string_count = 0 + for item in image_urls_raw: + if isinstance(item, str): + candidates.append(item) + else: + non_string_count += 1 + if non_string_count > 0: + logger.warning( + "Dropped %d non-string image_urls entries in handoff tool args.", + non_string_count, + ) else: logger.warning( "Unsupported image_urls type in handoff tool args: %s", From c89ce3f5506dd96b07760cd6d8675f7ee6529fdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 1 Mar 2026 16:41:27 +0900 Subject: [PATCH 12/34] refactor: streamline handoff image url collection --- astrbot/core/astr_agent_tool_exec.py | 45 ++++++++++++++++--------- tests/unit/test_astr_agent_tool_exec.py | 26 ++++++++++++-- 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 294a690d78..8ec171b659 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -65,14 +65,13 @@ def _is_supported_image_ref(cls, image_ref: str) -> bool: return True file_path = image_ref[8:] if lowered.startswith("file:///") else image_ref ext = os.path.splitext(file_path)[1].lower() - return ext in cls._ALLOWED_IMAGE_EXTENSIONS + if ext in cls._ALLOWED_IMAGE_EXTENSIONS: + return True + # Keep support for extension-less temp files returned by image converters. + return ext == "" and os.path.exists(file_path) @classmethod - async def _collect_handoff_image_urls( - cls, - run_context: ContextWrapper[AstrAgentContext], - image_urls_raw: T.Any, - ) -> list[str]: + def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]: candidates: list[str] = [] if image_urls_raw is None: pass @@ -97,7 +96,13 @@ async def _collect_handoff_image_urls( "Unsupported image_urls type in handoff tool args: %s", type(image_urls_raw).__name__, ) + return candidates + @classmethod + async def _collect_image_urls_from_message( + cls, run_context: ContextWrapper[AstrAgentContext] + ) -> list[str]: + urls: list[str] = [] event = getattr(run_context.context, "event", None) message_obj = getattr(event, "message_obj", None) message = getattr(message_obj, "message", None) @@ -107,8 +112,8 @@ async def _collect_handoff_image_urls( continue try: path = await component.convert_to_file_path() - if path and cls._is_supported_image_ref(path): - candidates.append(path) + if path: + urls.append(path) except Exception as e: logger.error( "Failed to convert handoff image component at index %d: %s", @@ -116,6 +121,17 @@ async def _collect_handoff_image_urls( e, exc_info=True, ) + return urls + + @classmethod + async def _collect_handoff_image_urls( + cls, + run_context: ContextWrapper[AstrAgentContext], + image_urls_raw: T.Any, + ) -> list[str]: + candidates: list[str] = [] + candidates.extend(cls._collect_image_urls_from_args(image_urls_raw)) + candidates.extend(await cls._collect_image_urls_from_message(run_context)) normalized = normalize_and_dedupe_strings(candidates) sanitized = [item for item in normalized if cls._is_supported_image_ref(item)] @@ -147,7 +163,7 @@ async def execute(cls, tool, run_context, **tool_args): ): yield r return - async for r in cls._execute_handoff(tool, run_context, **tool_args): + async for r in cls._execute_handoff(tool, run_context, tool_args): yield r return @@ -250,17 +266,14 @@ async def _execute_handoff( cls, tool: HandoffTool, run_context: ContextWrapper[AstrAgentContext], - **tool_args: T.Any, + tool_args: dict[str, T.Any], ): input_ = tool_args.get("input") image_urls = await cls._collect_handoff_image_urls( run_context, tool_args.get("image_urls"), ) - effective_tool_args: dict[str, T.Any] = { - **tool_args, - "image_urls": image_urls, - } + tool_args["image_urls"] = image_urls # Build handoff toolset from registered tools plus runtime computer tools. toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) @@ -294,7 +307,7 @@ async def _execute_handoff( event=event, chat_provider_id=prov_id, prompt=input_, - image_urls=effective_tool_args["image_urls"], + image_urls=image_urls, system_prompt=tool.agent.instructions, tools=toolset, contexts=contexts, @@ -360,7 +373,7 @@ async def _do_handoff_background( """Run the subagent handoff and, on completion, wake the main agent.""" result_text = "" try: - async for r in cls._execute_handoff(tool, run_context, **tool_args): + async for r in cls._execute_handoff(tool, run_context, tool_args): if isinstance(r, mcp.types.CallToolResult): for content in r.content: if isinstance(content, mcp.types.TextContent): diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 29647b8bdc..75b963d1d6 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -82,7 +82,8 @@ async def test_do_handoff_background_reports_prepared_image_urls( ): captured: dict = {} - async def _fake_execute_handoff(cls, tool, run_context, **tool_args): + async def _fake_execute_handoff(cls, tool, run_context, tool_args): + tool_args["image_urls"] = ["https://example.com/raw.png"] yield mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text="ok")] ) @@ -110,4 +111,25 @@ async def _fake_wake(cls, run_context, **kwargs): image_urls="https://example.com/raw.png", ) - assert captured["tool_args"]["image_urls"] == "https://example.com/raw.png" + assert captured["tool_args"]["image_urls"] == ["https://example.com/raw.png"] + + +@pytest.mark.asyncio +async def test_collect_handoff_image_urls_keeps_extensionless_existing_event_file( + monkeypatch: pytest.MonkeyPatch, +): + async def _fake_convert_to_file_path(self): + return "/tmp/astrbot-handoff-image" + + monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) + monkeypatch.setattr( + "astrbot.core.astr_agent_tool_exec.os.path.exists", lambda _: True + ) + + run_context = _build_run_context([Image(file="file:///tmp/original.png")]) + image_urls = await FunctionToolExecutor._collect_handoff_image_urls( + run_context, + [], + ) + + assert image_urls == ["/tmp/astrbot-handoff-image"] From 166fb194dff6158eb7158638ef4126958c3acf08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 1 Mar 2026 16:49:21 +0900 Subject: [PATCH 13/34] refactor: share handoff image ref validation utilities --- astrbot/core/astr_agent_tool_exec.py | 38 +++------ astrbot/core/utils/image_ref_utils.py | 61 +++++++++++++++ .../core/utils/quoted_message/image_refs.py | 13 +--- tests/unit/test_astr_agent_tool_exec.py | 78 ++++++++++++++++++- 4 files changed, 151 insertions(+), 39 deletions(-) create mode 100644 astrbot/core/utils/image_ref_utils.py diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 8ec171b659..64325fad3e 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -1,7 +1,6 @@ import asyncio import inspect import json -import os import traceback import typing as T import uuid @@ -39,36 +38,19 @@ from astrbot.core.provider.entites import ProviderRequest from astrbot.core.provider.register import llm_tools from astrbot.core.utils.history_saver import persist_agent_history +from astrbot.core.utils.image_ref_utils import ( + ALLOWED_IMAGE_EXTENSIONS, + is_supported_image_ref, +) from astrbot.core.utils.string_utils import normalize_and_dedupe_strings class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): - _ALLOWED_IMAGE_EXTENSIONS = { - ".png", - ".jpg", - ".jpeg", - ".gif", - ".webp", - ".bmp", - ".tif", - ".tiff", - ".svg", - ".heic", - } + _ALLOWED_IMAGE_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS @classmethod def _is_supported_image_ref(cls, image_ref: str) -> bool: - if not image_ref: - return False - lowered = image_ref.lower() - if lowered.startswith(("http://", "https://", "base64://")): - return True - file_path = image_ref[8:] if lowered.startswith("file:///") else image_ref - ext = os.path.splitext(file_path)[1].lower() - if ext in cls._ALLOWED_IMAGE_EXTENSIONS: - return True - # Keep support for extension-less temp files returned by image converters. - return ext == "" and os.path.exists(file_path) + return is_supported_image_ref(image_ref) @classmethod def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]: @@ -87,12 +69,12 @@ def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]: else: non_string_count += 1 if non_string_count > 0: - logger.warning( + logger.debug( "Dropped %d non-string image_urls entries in handoff tool args.", non_string_count, ) else: - logger.warning( + logger.debug( "Unsupported image_urls type in handoff tool args: %s", type(image_urls_raw).__name__, ) @@ -137,8 +119,8 @@ async def _collect_handoff_image_urls( sanitized = [item for item in normalized if cls._is_supported_image_ref(item)] dropped_count = len(normalized) - len(sanitized) if dropped_count > 0: - logger.warning( - "Dropped %d invalid image_urls entries in handoff tool args.", + logger.debug( + "Dropped %d invalid image_urls entries in handoff image inputs.", dropped_count, ) return sanitized diff --git a/astrbot/core/utils/image_ref_utils.py b/astrbot/core/utils/image_ref_utils.py new file mode 100644 index 0000000000..b5ad265986 --- /dev/null +++ b/astrbot/core/utils/image_ref_utils.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import os +from urllib.parse import unquote, urlparse + +ALLOWED_IMAGE_EXTENSIONS = { + ".png", + ".jpg", + ".jpeg", + ".gif", + ".webp", + ".bmp", + ".tif", + ".tiff", + ".svg", + ".heic", +} + + +def resolve_file_url_path(image_ref: str) -> str: + parsed = urlparse(image_ref) + if parsed.scheme != "file": + return image_ref + + path = unquote(parsed.path or "") + netloc = unquote(parsed.netloc or "") + + # Keep support for file:///path and file:// forms. + if netloc and netloc.lower() != "localhost": + path = f"//{netloc}{path}" if path else netloc + elif not path and netloc: + path = netloc + + if os.name == "nt" and len(path) > 2 and path[0] == "/" and path[2] == ":": + path = path[1:] + + return path or image_ref + + +def is_supported_image_ref( + image_ref: str, + *, + allow_extensionless_existing_local_file: bool = True, +) -> bool: + if not image_ref: + return False + + lowered = image_ref.lower() + if lowered.startswith(("http://", "https://", "base64://")): + return True + + file_path = ( + resolve_file_url_path(image_ref) if lowered.startswith("file://") else image_ref + ) + ext = os.path.splitext(file_path)[1].lower() + if ext in ALLOWED_IMAGE_EXTENSIONS: + return True + if not allow_extensionless_existing_local_file: + return False + # Keep support for extension-less temp files returned by image converters. + return ext == "" and os.path.exists(file_path) diff --git a/astrbot/core/utils/quoted_message/image_refs.py b/astrbot/core/utils/quoted_message/image_refs.py index 009d6844a2..a1ea815516 100644 --- a/astrbot/core/utils/quoted_message/image_refs.py +++ b/astrbot/core/utils/quoted_message/image_refs.py @@ -3,16 +3,9 @@ import os from urllib.parse import urlsplit -IMAGE_EXTENSIONS = { - ".jpg", - ".jpeg", - ".png", - ".webp", - ".bmp", - ".tif", - ".tiff", - ".gif", -} +from astrbot.core.utils.image_ref_utils import ALLOWED_IMAGE_EXTENSIONS + +IMAGE_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS def normalize_file_like_url(path: str | None) -> str | None: diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 75b963d1d6..814af8e2d0 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -76,6 +76,61 @@ async def _fake_convert_to_file_path(self): assert image_urls == ["https://example.com/a.png"] +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("image_refs", "expected_supported_refs"), + [ + pytest.param( + ( + "https://example.com/valid.png", + "base64://iVBORw0KGgoAAAANSUhEUgAAAAUA", + "file:///tmp/photo.heic", + "file://localhost/tmp/vector.svg", + "file://fileserver/share/image.webp", + "file:///tmp/not-image.txt", + "mailto:user@example.com", + "random-string-without-scheme-or-extension", + ), + { + "https://example.com/valid.png", + "base64://iVBORw0KGgoAAAANSUhEUgAAAAUA", + "file:///tmp/photo.heic", + "file://localhost/tmp/vector.svg", + "file://fileserver/share/image.webp", + }, + id="mixed_supported_and_unsupported_refs", + ), + ], +) +async def test_collect_handoff_image_urls_filters_supported_schemes_and_extensions( + image_refs: tuple[str, ...], + expected_supported_refs: set[str], +): + run_context = _build_run_context([]) + result = await FunctionToolExecutor._collect_handoff_image_urls( + run_context, image_refs + ) + assert set(result) == expected_supported_refs + + +@pytest.mark.asyncio +async def test_collect_handoff_image_urls_collects_event_image_when_args_is_none( + monkeypatch: pytest.MonkeyPatch, +): + async def _fake_convert_to_file_path(self): + return "/tmp/event_only.png" + + monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) + + run_context = _build_run_context([Image(file="file:///tmp/original.png")]) + image_urls = await FunctionToolExecutor._collect_handoff_image_urls( + run_context, + None, + ) + + assert image_urls == ["/tmp/event_only.png"] + + @pytest.mark.asyncio async def test_do_handoff_background_reports_prepared_image_urls( monkeypatch: pytest.MonkeyPatch, @@ -123,7 +178,7 @@ async def _fake_convert_to_file_path(self): monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) monkeypatch.setattr( - "astrbot.core.astr_agent_tool_exec.os.path.exists", lambda _: True + "astrbot.core.utils.image_ref_utils.os.path.exists", lambda _: True ) run_context = _build_run_context([Image(file="file:///tmp/original.png")]) @@ -133,3 +188,24 @@ async def _fake_convert_to_file_path(self): ) assert image_urls == ["/tmp/astrbot-handoff-image"] + + +@pytest.mark.asyncio +async def test_collect_handoff_image_urls_filters_extensionless_missing_event_file( + monkeypatch: pytest.MonkeyPatch, +): + async def _fake_convert_to_file_path(self): + return "/tmp/astrbot-handoff-missing-image" + + monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) + monkeypatch.setattr( + "astrbot.core.utils.image_ref_utils.os.path.exists", lambda _: False + ) + + run_context = _build_run_context([Image(file="file:///tmp/original.png")]) + image_urls = await FunctionToolExecutor._collect_handoff_image_urls( + run_context, + [], + ) + + assert image_urls == [] From 4319c51a80cb783ce3e98774978b4511e4444211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 1 Mar 2026 16:54:56 +0900 Subject: [PATCH 14/34] refactor: simplify handoff image url processing --- astrbot/core/astr_agent_tool_exec.py | 88 ++++++++++++++----------- astrbot/core/utils/image_ref_utils.py | 29 +++++++- tests/unit/test_astr_agent_tool_exec.py | 36 +++++++++- 3 files changed, 109 insertions(+), 44 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 64325fad3e..702a9d74d1 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -37,48 +37,31 @@ from astrbot.core.platform.message_session import MessageSession from astrbot.core.provider.entites import ProviderRequest from astrbot.core.provider.register import llm_tools +from astrbot.core.utils.astrbot_path import get_astrbot_temp_path from astrbot.core.utils.history_saver import persist_agent_history -from astrbot.core.utils.image_ref_utils import ( - ALLOWED_IMAGE_EXTENSIONS, - is_supported_image_ref, -) +from astrbot.core.utils.image_ref_utils import is_supported_image_ref from astrbot.core.utils.string_utils import normalize_and_dedupe_strings class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): - _ALLOWED_IMAGE_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS - - @classmethod - def _is_supported_image_ref(cls, image_ref: str) -> bool: - return is_supported_image_ref(image_ref) - @classmethod def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]: - candidates: list[str] = [] if image_urls_raw is None: - pass - elif isinstance(image_urls_raw, str): - candidates.append(image_urls_raw) - elif isinstance(image_urls_raw, (Sequence, AbstractSet)) and not isinstance( + return [] + + if isinstance(image_urls_raw, str): + return [image_urls_raw] + + if isinstance(image_urls_raw, (Sequence, AbstractSet)) and not isinstance( image_urls_raw, (str, bytes, bytearray) ): - non_string_count = 0 - for item in image_urls_raw: - if isinstance(item, str): - candidates.append(item) - else: - non_string_count += 1 - if non_string_count > 0: - logger.debug( - "Dropped %d non-string image_urls entries in handoff tool args.", - non_string_count, - ) - else: - logger.debug( - "Unsupported image_urls type in handoff tool args: %s", - type(image_urls_raw).__name__, - ) - return candidates + return [item for item in image_urls_raw if isinstance(item, str)] + + logger.debug( + "Unsupported image_urls type in handoff tool args: %s", + type(image_urls_raw).__name__, + ) + return [] @classmethod async def _collect_image_urls_from_message( @@ -116,7 +99,16 @@ async def _collect_handoff_image_urls( candidates.extend(await cls._collect_image_urls_from_message(run_context)) normalized = normalize_and_dedupe_strings(candidates) - sanitized = [item for item in normalized if cls._is_supported_image_ref(item)] + extensionless_local_roots = (get_astrbot_temp_path(),) + sanitized = [ + item + for item in normalized + if is_supported_image_ref( + item, + allow_extensionless_existing_local_file=True, + extensionless_local_roots=extensionless_local_roots, + ) + ] dropped_count = len(normalized) - len(sanitized) if dropped_count > 0: logger.debug( @@ -145,7 +137,7 @@ async def execute(cls, tool, run_context, **tool_args): ): yield r return - async for r in cls._execute_handoff(tool, run_context, tool_args): + async for r in cls._execute_handoff(tool, run_context, **tool_args): yield r return @@ -248,13 +240,19 @@ async def _execute_handoff( cls, tool: HandoffTool, run_context: ContextWrapper[AstrAgentContext], - tool_args: dict[str, T.Any], + *, + image_urls_prepared: bool = False, + **tool_args: T.Any, ): + tool_args = dict(tool_args) input_ = tool_args.get("input") - image_urls = await cls._collect_handoff_image_urls( - run_context, - tool_args.get("image_urls"), - ) + if image_urls_prepared: + image_urls = normalize_and_dedupe_strings(tool_args.get("image_urls")) + else: + image_urls = await cls._collect_handoff_image_urls( + run_context, + tool_args.get("image_urls"), + ) tool_args["image_urls"] = image_urls # Build handoff toolset from registered tools plus runtime computer tools. @@ -354,8 +352,18 @@ async def _do_handoff_background( ) -> None: """Run the subagent handoff and, on completion, wake the main agent.""" result_text = "" + tool_args = dict(tool_args) + tool_args["image_urls"] = await cls._collect_handoff_image_urls( + run_context, + tool_args.get("image_urls"), + ) try: - async for r in cls._execute_handoff(tool, run_context, tool_args): + async for r in cls._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + **tool_args, + ): if isinstance(r, mcp.types.CallToolResult): for content in r.content: if isinstance(content, mcp.types.TextContent): diff --git a/astrbot/core/utils/image_ref_utils.py b/astrbot/core/utils/image_ref_utils.py index b5ad265986..204e576312 100644 --- a/astrbot/core/utils/image_ref_utils.py +++ b/astrbot/core/utils/image_ref_utils.py @@ -1,6 +1,8 @@ from __future__ import annotations import os +from collections.abc import Sequence +from pathlib import Path from urllib.parse import unquote, urlparse ALLOWED_IMAGE_EXTENSIONS = { @@ -37,10 +39,27 @@ def resolve_file_url_path(image_ref: str) -> str: return path or image_ref +def _is_path_within_roots(path: str, roots: Sequence[str]) -> bool: + try: + candidate = Path(path).resolve(strict=False) + except Exception: + return False + + for root in roots: + try: + root_path = Path(root).resolve(strict=False) + candidate.relative_to(root_path) + return True + except Exception: + continue + return False + + def is_supported_image_ref( image_ref: str, *, - allow_extensionless_existing_local_file: bool = True, + allow_extensionless_existing_local_file: bool = False, + extensionless_local_roots: Sequence[str] | None = None, ) -> bool: if not image_ref: return False @@ -57,5 +76,11 @@ def is_supported_image_ref( return True if not allow_extensionless_existing_local_file: return False + if not extensionless_local_roots: + return False # Keep support for extension-less temp files returned by image converters. - return ext == "" and os.path.exists(file_path) + return ( + ext == "" + and os.path.exists(file_path) + and _is_path_within_roots(file_path, extensionless_local_roots) + ) diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 814af8e2d0..189fc5ad8d 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -137,8 +137,10 @@ async def test_do_handoff_background_reports_prepared_image_urls( ): captured: dict = {} - async def _fake_execute_handoff(cls, tool, run_context, tool_args): - tool_args["image_urls"] = ["https://example.com/raw.png"] + async def _fake_execute_handoff( + cls, tool, run_context, image_urls_prepared=False, **tool_args + ): + assert image_urls_prepared is True yield mcp.types.CallToolResult( content=[mcp.types.TextContent(type="text", text="ok")] ) @@ -177,6 +179,9 @@ async def _fake_convert_to_file_path(self): return "/tmp/astrbot-handoff-image" monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) + monkeypatch.setattr( + "astrbot.core.astr_agent_tool_exec.get_astrbot_temp_path", lambda: "/tmp" + ) monkeypatch.setattr( "astrbot.core.utils.image_ref_utils.os.path.exists", lambda _: True ) @@ -198,6 +203,9 @@ async def _fake_convert_to_file_path(self): return "/tmp/astrbot-handoff-missing-image" monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) + monkeypatch.setattr( + "astrbot.core.astr_agent_tool_exec.get_astrbot_temp_path", lambda: "/tmp" + ) monkeypatch.setattr( "astrbot.core.utils.image_ref_utils.os.path.exists", lambda _: False ) @@ -209,3 +217,27 @@ async def _fake_convert_to_file_path(self): ) assert image_urls == [] + + +@pytest.mark.asyncio +async def test_collect_handoff_image_urls_filters_extensionless_file_outside_temp_root( + monkeypatch: pytest.MonkeyPatch, +): + async def _fake_convert_to_file_path(self): + return "/var/tmp/astrbot-handoff-image" + + monkeypatch.setattr(Image, "convert_to_file_path", _fake_convert_to_file_path) + monkeypatch.setattr( + "astrbot.core.astr_agent_tool_exec.get_astrbot_temp_path", lambda: "/tmp" + ) + monkeypatch.setattr( + "astrbot.core.utils.image_ref_utils.os.path.exists", lambda _: True + ) + + run_context = _build_run_context([Image(file="file:///tmp/original.png")]) + image_urls = await FunctionToolExecutor._collect_handoff_image_urls( + run_context, + [], + ) + + assert image_urls == [] From ed178e571238bb1649adbf1d53402162269eb43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=82=B9=E6=B0=B8=E8=B5=AB?= <1259085392@qq.com> Date: Sun, 1 Mar 2026 16:58:19 +0900 Subject: [PATCH 15/34] refactor: honor prepared handoff image urls contract --- astrbot/core/astr_agent_tool_exec.py | 10 ++++- tests/unit/test_astr_agent_tool_exec.py | 53 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 702a9d74d1..fd6db5a98e 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -247,7 +247,15 @@ async def _execute_handoff( tool_args = dict(tool_args) input_ = tool_args.get("input") if image_urls_prepared: - image_urls = normalize_and_dedupe_strings(tool_args.get("image_urls")) + prepared_image_urls = tool_args.get("image_urls") + if isinstance(prepared_image_urls, list): + image_urls = prepared_image_urls + else: + logger.debug( + "Expected prepared handoff image_urls as list[str], got %s.", + type(prepared_image_urls).__name__, + ) + image_urls = [] else: image_urls = await cls._collect_handoff_image_urls( run_context, diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 189fc5ad8d..9d405f1ab5 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -171,6 +171,59 @@ async def _fake_wake(cls, run_context, **kwargs): assert captured["tool_args"]["image_urls"] == ["https://example.com/raw.png"] +@pytest.mark.asyncio +async def test_execute_handoff_skips_renormalize_when_image_urls_prepared( + monkeypatch: pytest.MonkeyPatch, +): + captured: dict = {} + + def _boom(_items): + raise RuntimeError("normalize should not be called") + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: {"provider_settings": {}}, + ) + event = _DummyEvent([]) + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + agent=SimpleNamespace( + name="subagent", + tools=[], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + monkeypatch.setattr( + "astrbot.core.astr_agent_tool_exec.normalize_and_dedupe_strings", _boom + ) + + results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="hello", + image_urls=["https://example.com/raw.png"], + ): + results.append(result) + + assert len(results) == 1 + assert captured["image_urls"] == ["https://example.com/raw.png"] + + @pytest.mark.asyncio async def test_collect_handoff_image_urls_keeps_extensionless_existing_event_file( monkeypatch: pytest.MonkeyPatch, From 3c580132e0da4e340021af76088806a6a0e74538 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Sun, 1 Mar 2026 22:54:36 +0800 Subject: [PATCH 16/34] =?UTF-8?q?#fix:=E5=85=BC=E5=AE=B9openai=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/agent/runners/tool_loop_agent_runner.py | 10 ++++++++++ astrbot/core/provider/sources/openai_source.py | 13 ++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 743b280070..bead4628dc 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -258,6 +258,16 @@ async def _iter_llm_responses_with_fallback( ) break + # 如果回复为空且无工具调用 且不是最后一个回退渠道 则引发fallback(仅适配非流) + if ( + not resp.completion_text + and not resp.tools_call_args + and not is_last_candidate + and not has_stream_output + ): + logger.warning("Chat Model %s returns empty response, trying fallback to next provider.", candidate_id) + break + yield resp return diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index adee24073d..3838907a8e 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -307,10 +307,21 @@ async def _query_stream( state = ChatCompletionStreamState() async for chunk in stream: + # 兼容非标准返回处理 补全tool_call.type字段 + if chunk.choices: + for choice in chunk.choices: + if choice.delta and choice.delta.tool_calls: + for tool_call in choice.delta.tool_calls: + if tool_call.type is None: + tool_call.type = "function" + try: state.handle_chunk(chunk) except Exception as e: - logger.warning("Saving chunk state error: " + str(e)) + logger.warning( + f"Saving chunk state error: {type(e).__name__}: {e}. Chunk data: {chunk}", + exc_info=True, + ) if len(chunk.choices) == 0: continue delta = chunk.choices[0].delta From 1efd07c352144e087c5ece4925adbc669014a1fd Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Sun, 1 Mar 2026 23:26:04 +0800 Subject: [PATCH 17/34] =?UTF-8?q?fix:=E8=B0=83=E6=95=B4=E7=A9=BA=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E5=BC=82=E5=B8=B8=E6=97=B6=E7=9A=84=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E7=BA=A7=E5=88=AB=E8=87=B3debug=20=E4=BF=AE=E6=AD=A3openai?= =?UTF-8?q?=E5=93=8D=E5=BA=94=E5=85=BC=E5=AE=B9=E6=80=A7=E5=88=A4=E6=96=AD?= =?UTF-8?q?=E6=9B=BE=E7=BB=8F=E8=BF=87=E5=A4=9A=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/openai_source.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 3838907a8e..985ef6f36c 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -308,20 +308,21 @@ async def _query_stream( async for chunk in stream: # 兼容非标准返回处理 补全tool_call.type字段 - if chunk.choices: - for choice in chunk.choices: - if choice.delta and choice.delta.tool_calls: - for tool_call in choice.delta.tool_calls: - if tool_call.type is None: - tool_call.type = "function" + for choice in chunk.choices or []: + if not choice.delta or not choice.delta.tool_calls: + continue + for tool_call in choice.delta.tool_calls: + if tool_call.type is None: + tool_call.type = "function" try: state.handle_chunk(chunk) except Exception as e: - logger.warning( + logger.debug( f"Saving chunk state error: {type(e).__name__}: {e}. Chunk data: {chunk}", exc_info=True, ) + logger.warning(f"Saving chunk state error: {e}") if len(chunk.choices) == 0: continue delta = chunk.choices[0].delta From 106aab1580380604b81eab2a5ece37561ea7f204 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Sun, 1 Mar 2026 23:37:39 +0800 Subject: [PATCH 18/34] =?UTF-8?q?fix:=E8=B0=83=E6=95=B4=E7=A9=BA=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E5=88=A4=E6=96=AD=E6=9D=A1=E4=BB=B6=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=9B=B4=E5=A4=9A=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index bead4628dc..af66becdbe 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -258,16 +258,21 @@ async def _iter_llm_responses_with_fallback( ) break - # 如果回复为空且无工具调用 且不是最后一个回退渠道 则引发fallback(仅适配非流) + # 如果回复为空且无任何有效产出 且不是最后一个回退渠道 则引发fallback(仅适配非流) if ( not resp.completion_text and not resp.tools_call_args + and not resp.reasoning_content + and not resp.result_chain and not is_last_candidate and not has_stream_output ): - logger.warning("Chat Model %s returns empty response, trying fallback to next provider.", candidate_id) + logger.warning( + "Chat Model %s returns empty response, trying fallback to next provider.", + candidate_id, + ) break - + yield resp return From eae8995b036f950dc25c921ad4db47863594f8b7 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 00:06:36 +0800 Subject: [PATCH 19/34] =?UTF-8?q?fix:=E7=A7=BB=E9=99=A4openai=E9=9D=9E?= =?UTF-8?q?=E6=A0=87=E5=85=BC=E5=AE=B9=E4=B8=AD=E7=9A=84=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E9=93=BE=E5=88=A4=E6=96=AD=20=E5=9B=A0=E4=B8=BA=E7=A9=BA?= =?UTF-8?q?=E5=9B=9E=E5=A4=8D=E5=8F=AF=E8=83=BD=E5=87=BA=E7=8E=B0=E5=9C=A8?= =?UTF-8?q?=E4=BB=BB=E4=BD=95=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 1 - 1 file changed, 1 deletion(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index af66becdbe..7cfc9dd97f 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -263,7 +263,6 @@ async def _iter_llm_responses_with_fallback( not resp.completion_text and not resp.tools_call_args and not resp.reasoning_content - and not resp.result_chain and not is_last_candidate and not has_stream_output ): From d263f0cfcbab748c481a96ab5fdc0a103fec05c0 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 00:21:32 +0800 Subject: [PATCH 20/34] =?UTF-8?q?fix:=E6=9A=82=E6=97=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E7=A9=BA=E5=9B=9E=E5=A4=8D=E5=88=A4=E6=96=AD=20=E5=9B=A0?= =?UTF-8?q?=E4=B8=BA=E8=AF=A5=E9=97=AE=E9=A2=98=E9=9A=BE=E4=BB=A5=E5=A4=8D?= =?UTF-8?q?=E7=8E=B0=20=E4=BC=BC=E4=B9=8E=E9=9C=80=E8=A6=81=E6=9B=B4?= =?UTF-8?q?=E5=A4=9A=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/runners/tool_loop_agent_runner.py | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 7cfc9dd97f..e137d4f247 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -242,7 +242,13 @@ async def _iter_llm_responses_with_fallback( try: async for resp in self._iter_llm_responses(include_model=idx == 0): if resp.is_chunk: - has_stream_output = True + if ( + resp.completion_text + or resp.reasoning_content + or resp.tools_call_ids + or (resp.result_chain and resp.result_chain.chain) + ): + has_stream_output = True yield resp continue @@ -258,20 +264,6 @@ async def _iter_llm_responses_with_fallback( ) break - # 如果回复为空且无任何有效产出 且不是最后一个回退渠道 则引发fallback(仅适配非流) - if ( - not resp.completion_text - and not resp.tools_call_args - and not resp.reasoning_content - and not is_last_candidate - and not has_stream_output - ): - logger.warning( - "Chat Model %s returns empty response, trying fallback to next provider.", - candidate_id, - ) - break - yield resp return From 040915cf28f8753e00a02b581182f02950cad182 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 00:33:49 +0800 Subject: [PATCH 21/34] =?UTF-8?q?fix:=E5=9B=9E=E9=80=80=E4=B8=8A=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index e137d4f247..8f3bbd972e 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -242,13 +242,7 @@ async def _iter_llm_responses_with_fallback( try: async for resp in self._iter_llm_responses(include_model=idx == 0): if resp.is_chunk: - if ( - resp.completion_text - or resp.reasoning_content - or resp.tools_call_ids - or (resp.result_chain and resp.result_chain.chain) - ): - has_stream_output = True + has_stream_output = True yield resp continue @@ -263,7 +257,7 @@ async def _iter_llm_responses_with_fallback( candidate_id, ) break - + yield resp return From 4c3d32e7cace78353fa1ffcc14512c6f38d93f85 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 00:43:00 +0800 Subject: [PATCH 22/34] =?UTF-8?q?fix:=E8=A1=A5=E5=85=A8=E6=B3=A8=E9=87=8A?= =?UTF-8?q?=E5=8F=8Aruff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 2 +- astrbot/core/provider/sources/openai_source.py | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 8f3bbd972e..743b280070 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -257,7 +257,7 @@ async def _iter_llm_responses_with_fallback( candidate_id, ) break - + yield resp return diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 985ef6f36c..2a50fe26b7 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -307,12 +307,15 @@ async def _query_stream( state = ChatCompletionStreamState() async for chunk in stream: - # 兼容非标准返回处理 补全tool_call.type字段 + # 兼容处理:部分非标准聚合平台(如通过newapi适配层转接的 Gemini)在流式返回 tool_calls 时, + # 可能会缺失 type 字段。由于 openai SDK 的 ChatCompletionStreamState.handle_chunk + # 内部有 assert tool.type == "function" 的断言,缺少该字段会导致 AssertionError。 + # 因此,若检测到 tool_call 且 type 为空,在此处手动补全为 "function"。 for choice in chunk.choices or []: if not choice.delta or not choice.delta.tool_calls: continue for tool_call in choice.delta.tool_calls: - if tool_call.type is None: + if hasattr(tool_call, "type") and tool_call.type is None: tool_call.type = "function" try: From 88dcd5811603f7f733178c392ef1e71f86f2eb4e Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 00:55:51 +0800 Subject: [PATCH 23/34] =?UTF-8?q?fix:=E5=88=A0=E9=99=A4=E5=A4=9A=E4=BD=99?= =?UTF-8?q?=E8=B0=83=E8=AF=95=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/openai_source.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 2a50fe26b7..497085a2cf 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -315,16 +315,12 @@ async def _query_stream( if not choice.delta or not choice.delta.tool_calls: continue for tool_call in choice.delta.tool_calls: - if hasattr(tool_call, "type") and tool_call.type is None: + if getattr(tool_call, "type", None) in (None, ""): tool_call.type = "function" try: state.handle_chunk(chunk) except Exception as e: - logger.debug( - f"Saving chunk state error: {type(e).__name__}: {e}. Chunk data: {chunk}", - exc_info=True, - ) logger.warning(f"Saving chunk state error: {e}") if len(chunk.choices) == 0: continue From 2566fb88c9b90c558f1fde2f415c86243a910760 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 00:59:29 +0800 Subject: [PATCH 24/34] =?UTF-8?q?fix:=E8=B0=83=E6=95=B4=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/openai_source.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 497085a2cf..bb17b7b25c 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -321,6 +321,10 @@ async def _query_stream( try: state.handle_chunk(chunk) except Exception as e: + logger.debug( + f"Saving chunk state error: {type(e).__name__}: {e}.", + exc_info=True, + ) logger.warning(f"Saving chunk state error: {e}") if len(chunk.choices) == 0: continue From 20ca527ee0b38a31a3877807fc64589f3f943316 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 01:06:46 +0800 Subject: [PATCH 25/34] =?UTF-8?q?fix:=E4=BF=AE=E6=AD=A3chunk.choices=20?= =?UTF-8?q?=E7=A9=BA=E5=80=BC/=E7=A9=BA=E5=88=97=E8=A1=A8=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/openai_source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index bb17b7b25c..67e06ba4f0 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -326,7 +326,8 @@ async def _query_stream( exc_info=True, ) logger.warning(f"Saving chunk state error: {e}") - if len(chunk.choices) == 0: + + if not chunk.choices or len(chunk.choices) == 0: continue delta = chunk.choices[0].delta # logger.debug(f"chunk delta: {delta}") From 6cd21739ac99ce0c706e7ecbba7cf66c53ec5418 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 01:18:57 +0800 Subject: [PATCH 26/34] =?UTF-8?q?fix:=E9=87=8D=E6=96=B0=E5=8A=A0=E5=85=A5?= =?UTF-8?q?=E7=A9=BA=E5=9B=9E=E5=A4=8D=E5=A4=84=E7=90=86=20=E5=B7=B2?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=B9=B6=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/agent/runners/tool_loop_agent_runner.py | 13 +++++++++++++ astrbot/core/provider/sources/openai_source.py | 6 +++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 743b280070..971de70d73 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -241,6 +241,19 @@ async def _iter_llm_responses_with_fallback( has_stream_output = False try: async for resp in self._iter_llm_responses(include_model=idx == 0): + # 如果回复为空且无工具调用 且不是最后一个回退渠道 则引发fallback(仅适配非流) + # 此处不应判断整个消息链是否为空 因为消息链包含整个对话流 而空回复可能发生在任何阶段 + if ( + not resp.completion_text + and not resp.tools_call_args + and not is_last_candidate + ): + logger.warning( + "Chat Model %s returns empty response, trying fallback to next provider.", + candidate_id, + ) + break + if resp.is_chunk: has_stream_output = True yield resp diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 67e06ba4f0..c657c0cb56 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -315,7 +315,7 @@ async def _query_stream( if not choice.delta or not choice.delta.tool_calls: continue for tool_call in choice.delta.tool_calls: - if getattr(tool_call, "type", None) in (None, ""): + if hasattr(tool_call, "type") and tool_call.type in (None, ""): tool_call.type = "function" try: @@ -326,8 +326,8 @@ async def _query_stream( exc_info=True, ) logger.warning(f"Saving chunk state error: {e}") - - if not chunk.choices or len(chunk.choices) == 0: + + if not chunk.choices: continue delta = chunk.choices[0].delta # logger.debug(f"chunk delta: {delta}") From b50d5bbcaf9535d971de7ca861f1971cecd3347a Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 01:24:15 +0800 Subject: [PATCH 27/34] =?UTF-8?q?fix:=E5=88=A0=E9=99=A4=E9=94=99=E8=AF=AF?= =?UTF-8?q?=E7=9A=84=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 971de70d73..9c2612d064 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -241,7 +241,7 @@ async def _iter_llm_responses_with_fallback( has_stream_output = False try: async for resp in self._iter_llm_responses(include_model=idx == 0): - # 如果回复为空且无工具调用 且不是最后一个回退渠道 则引发fallback(仅适配非流) + # 如果回复为空且无工具调用 且不是最后一个回退渠道 则引发fallback # 此处不应判断整个消息链是否为空 因为消息链包含整个对话流 而空回复可能发生在任何阶段 if ( not resp.completion_text From b0d07a91ab9f1b43e62b2432a2dfc19af31df34d Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 01:32:31 +0800 Subject: [PATCH 28/34] =?UTF-8?q?fix:=E5=85=BC=E5=AE=B9=E5=A4=84=E7=90=86?= =?UTF-8?q?=E4=B8=AD=E5=A2=9E=E5=8A=A0warning=E6=97=A5=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 3 +++ astrbot/core/provider/sources/openai_source.py | 1 + 2 files changed, 4 insertions(+) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 9c2612d064..38e98a85ee 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -517,6 +517,9 @@ async def step(self): logger.warning( "LLM returned empty assistant message with no tool calls." ) + # 若所有fallback使用完毕后依然为空回复 则显示执行报错 避免静默 + raise RuntimeError("LLM returned empty assistant message with no tool calls.") + self.run_context.messages.append(Message(role="assistant", content=parts)) # call the on_agent_done hook diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index c657c0cb56..ff8e8dc7ed 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -316,6 +316,7 @@ async def _query_stream( continue for tool_call in choice.delta.tool_calls: if hasattr(tool_call, "type") and tool_call.type in (None, ""): + logger.warning(f"tool_call.type is empty, manually set to 'function'") tool_call.type = "function" try: From e8a8d09c1d80ab8c09176e668a477e58cc241278 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 01:37:54 +0800 Subject: [PATCH 29/34] =?UTF-8?q?fix:=E5=A2=9E=E5=8A=A0=E7=A9=BA=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E5=88=A4=E6=96=AD=E6=9D=A1=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 1 + 1 file changed, 1 insertion(+) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 38e98a85ee..9ef5fbbcff 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -245,6 +245,7 @@ async def _iter_llm_responses_with_fallback( # 此处不应判断整个消息链是否为空 因为消息链包含整个对话流 而空回复可能发生在任何阶段 if ( not resp.completion_text + and not resp.reasoning_content and not resp.tools_call_args and not is_last_candidate ): From 5ec49e9270004e4b349d2bdf207534ec807b1c7c Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 02:14:17 +0800 Subject: [PATCH 30/34] =?UTF-8?q?fix:=E8=B4=B5=E4=BC=90=E8=AF=9D=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E5=9B=9E=E5=A4=8D=E5=90=88=E6=B3=95=E6=80=A7(?= =?UTF-8?q?=E5=8E=BB=E9=99=A4=E7=A9=BA=E5=AD=97=E7=AC=A6=20=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E7=BB=84=E4=BB=B6=E5=BC=95=E7=94=A8=E7=AD=89)=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=B0=E7=9A=84=E7=A9=BA=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=BC=82=E5=B8=B8=E7=B1=BB=20=E5=B9=B6=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E8=AF=A5=E5=BC=82=E5=B8=B8=E6=8A=A5=E5=91=8A=E7=A9=BA=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E9=94=99=E8=AF=AF=20=E4=BF=AE=E6=94=B9openai=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8=E4=B8=AD=E7=9A=84=E5=85=BC=E5=AE=B9=E6=97=A5?= =?UTF-8?q?=E5=BF=97=20=E9=99=8D=E7=BA=A7=E4=B8=BAdebug=E4=B8=94=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E8=AF=A6=E7=BB=86=E4=BF=A1=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/runners/tool_loop_agent_runner.py | 25 ++++++++++++++++--- astrbot/core/exceptions.py | 4 +++ .../core/provider/sources/openai_source.py | 8 +++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 9ef5fbbcff..8437da7c27 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -20,9 +20,11 @@ from astrbot.core.agent.tool import ToolSet from astrbot.core.agent.tool_image_cache import tool_image_cache from astrbot.core.message.components import Json +import astrbot.core.message.components as Comp from astrbot.core.message.message_event_result import ( MessageChain, ) +from astrbot.core.exceptions import LLMEmptyResponseError from astrbot.core.persona_error_reply import ( extract_persona_custom_error_message_from_event, ) @@ -243,10 +245,27 @@ async def _iter_llm_responses_with_fallback( async for resp in self._iter_llm_responses(include_model=idx == 0): # 如果回复为空且无工具调用 且不是最后一个回退渠道 则引发fallback # 此处不应判断整个消息链是否为空 因为消息链包含整个对话流 而空回复可能发生在任何阶段 + # 规范化检查:去除空白字符后判断是否为空,同时检查 result_chain 中是否有非文本内容 + completion_text_stripped = (resp.completion_text or "").strip() + reasoning_content_stripped = (resp.reasoning_content or "").strip() + # 检查 result_chain 是否包含有意义的非空内容(如图片、非空文本等) + has_result_chain_content = False + if resp.result_chain and resp.result_chain.chain: + for comp in resp.result_chain.chain: + # 跳过空的 Plain 组件 + if isinstance(comp, Comp.Plain): + if comp.text and comp.text.strip(): + has_result_chain_content = True + break + else: + # 非 Plain 组件(如图片、语音等)视为有效内容 + has_result_chain_content = True + break if ( - not resp.completion_text - and not resp.reasoning_content + not completion_text_stripped + and not reasoning_content_stripped and not resp.tools_call_args + and not has_result_chain_content and not is_last_candidate ): logger.warning( @@ -519,7 +538,7 @@ async def step(self): "LLM returned empty assistant message with no tool calls." ) # 若所有fallback使用完毕后依然为空回复 则显示执行报错 避免静默 - raise RuntimeError("LLM returned empty assistant message with no tool calls.") + raise LLMEmptyResponseError("LLM returned empty assistant message with no tool calls.") self.run_context.messages.append(Message(role="assistant", content=parts)) diff --git a/astrbot/core/exceptions.py b/astrbot/core/exceptions.py index e637d4930f..bc2e896ef2 100644 --- a/astrbot/core/exceptions.py +++ b/astrbot/core/exceptions.py @@ -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.""" diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index ff8e8dc7ed..694a7f67e0 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -306,6 +306,7 @@ async def _query_stream( state = ChatCompletionStreamState() + chunk_index = 0 async for chunk in stream: # 兼容处理:部分非标准聚合平台(如通过newapi适配层转接的 Gemini)在流式返回 tool_calls 时, # 可能会缺失 type 字段。由于 openai SDK 的 ChatCompletionStreamState.handle_chunk @@ -316,8 +317,13 @@ async def _query_stream( continue for tool_call in choice.delta.tool_calls: if hasattr(tool_call, "type") and tool_call.type in (None, ""): - logger.warning(f"tool_call.type is empty, manually set to 'function'") + logger.debug( + f"[{self.get_model()}] tool_call.type is empty in chunk {chunk_index} " + f"(provider: {self.provider_config.get('id', 'unknown')}), " + f"manually set to 'function'" + ) tool_call.type = "function" + chunk_index += 1 try: state.handle_chunk(chunk) From 67950e72372a0e683fd0420327230da817c3cc89 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 02:52:17 +0800 Subject: [PATCH 31/34] =?UTF-8?q?fix:=E5=B0=86=E7=A9=BA=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E5=88=A4=E6=96=AD=E6=8F=90=E5=8F=96=E4=B8=BA=E8=BE=85=E5=8A=A9?= =?UTF-8?q?=E5=87=BD=E6=95=B0=E5=B9=B6=E5=9C=A8=E6=B5=81=E7=A8=8B=E4=B8=AD?= =?UTF-8?q?=E5=BC=95=E7=94=A8=20=E7=B2=BE=E7=AE=80=E7=A9=BA=E5=9B=9E?= =?UTF-8?q?=E5=A4=8D=E5=BC=82=E5=B8=B8=E8=AE=B0=E5=BD=95=20=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=85=BC=E5=AE=B9=E5=A4=84=E7=90=86=E4=B8=AD=E7=9A=84?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E4=BF=A1=E6=81=AF=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/runners/tool_loop_agent_runner.py | 88 +++++++++++++------ .../core/provider/sources/openai_source.py | 7 +- 2 files changed, 65 insertions(+), 30 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 8437da7c27..ab29c88045 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -221,6 +221,42 @@ 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]: @@ -243,29 +279,18 @@ 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 # 此处不应判断整个消息链是否为空 因为消息链包含整个对话流 而空回复可能发生在任何阶段 - # 规范化检查:去除空白字符后判断是否为空,同时检查 result_chain 中是否有非文本内容 - completion_text_stripped = (resp.completion_text or "").strip() - reasoning_content_stripped = (resp.reasoning_content or "").strip() - # 检查 result_chain 是否包含有意义的非空内容(如图片、非空文本等) - has_result_chain_content = False - if resp.result_chain and resp.result_chain.chain: - for comp in resp.result_chain.chain: - # 跳过空的 Plain 组件 - if isinstance(comp, Comp.Plain): - if comp.text and comp.text.strip(): - has_result_chain_content = True - break - else: - # 非 Plain 组件(如图片、语音等)视为有效内容 - has_result_chain_content = True - break + # 使用辅助函数检查是否为空回复 if ( - not completion_text_stripped - and not reasoning_content_stripped - and not resp.tools_call_args - and not has_result_chain_content + self._is_empty_llm_response(resp) and not is_last_candidate ): logger.warning( @@ -274,11 +299,6 @@ async def _iter_llm_responses_with_fallback( ) break - if resp.is_chunk: - has_stream_output = True - yield resp - continue - if ( resp.role == "err" and not has_stream_output @@ -538,7 +558,23 @@ async def step(self): "LLM returned empty assistant message with no tool calls." ) # 若所有fallback使用完毕后依然为空回复 则显示执行报错 避免静默 - raise LLMEmptyResponseError("LLM returned empty assistant message with no tool calls.") + 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)) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 694a7f67e0..92811150b0 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -328,11 +328,10 @@ async def _query_stream( try: state.handle_chunk(chunk) except Exception as e: - logger.debug( - f"Saving chunk state error: {type(e).__name__}: {e}.", - exc_info=True, + logger.warning( + f"[{self.get_model()}] Saving chunk state error: {e} " + f"(provider: {self.provider_config.get('id', 'unknown')})" ) - logger.warning(f"Saving chunk state error: {e}") if not chunk.choices: continue From 6ab529c309cf7f95c3c08e0661d4ce1c8d926373 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 03:04:31 +0800 Subject: [PATCH 32/34] =?UTF-8?q?fix:=E5=A4=84=E7=90=86=E9=9D=9E=E6=A0=87?= =?UTF-8?q?=E8=81=9A=E5=90=88=E5=B9=B3=E5=8F=B0=E5=8F=AF=E8=83=BD=E7=BC=BA?= =?UTF-8?q?=E5=A4=B1=E5=B7=A5=E5=85=B7=E8=B0=83=E7=94=A8=E4=B8=ADtype?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- astrbot/core/provider/sources/openai_source.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 92811150b0..27c3debde2 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -316,9 +316,11 @@ async def _query_stream( if not choice.delta or not choice.delta.tool_calls: continue for tool_call in choice.delta.tool_calls: - if hasattr(tool_call, "type") and tool_call.type in (None, ""): + # 使用 getattr 处理 type 字段可能完全缺失的情况 + tool_type = getattr(tool_call, "type", None) + if tool_type is None or tool_type == "": logger.debug( - f"[{self.get_model()}] tool_call.type is empty in chunk {chunk_index} " + f"[{self.get_model()}] tool_call.type is missing or empty in chunk {chunk_index} " f"(provider: {self.provider_config.get('id', 'unknown')}), " f"manually set to 'function'" ) From 7157720c0ae70c2b3c540cd0ea38d1169b22e5c8 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 03:10:14 +0800 Subject: [PATCH 33/34] ruff --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index ab29c88045..32443e2acb 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -289,10 +289,7 @@ async def _iter_llm_responses_with_fallback( # 如果回复为空且无工具调用 且不是最后一个回退渠道 则引发fallback # 此处不应判断整个消息链是否为空 因为消息链包含整个对话流 而空回复可能发生在任何阶段 # 使用辅助函数检查是否为空回复 - if ( - self._is_empty_llm_response(resp) - and not is_last_candidate - ): + if 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, @@ -575,7 +572,7 @@ async def step(self): 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 From 833a37f1b2636fd59f54500bd8fa91423def9016 Mon Sep 17 00:00:00 2001 From: Chen <61995987@qq.com> Date: Mon, 2 Mar 2026 03:12:11 +0800 Subject: [PATCH 34/34] ruff --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 32443e2acb..0cd9201eca 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -15,16 +15,16 @@ 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 -import astrbot.core.message.components as Comp from astrbot.core.message.message_event_result import ( MessageChain, ) -from astrbot.core.exceptions import LLMEmptyResponseError from astrbot.core.persona_error_reply import ( extract_persona_custom_error_message_from_event, )