From 7edf6bcf11916b7fd3ac253ce55dd42c45ecb710 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Wed, 22 Apr 2026 14:07:29 +0800 Subject: [PATCH 1/7] Update test_openai_source.py --- tests/test_openai_source.py | 322 ++++++++++++++++++++++++++++++++++++ 1 file changed, 322 insertions(+) diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index 83e18137c4..fcb3863059 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -1618,3 +1618,325 @@ async def fake_create(**kwargs): assert messages[2] == {"role": "user", "content": "hello"} finally: await provider.terminate() + + +# --------------------------------------------------------------------------- +# Tests for _filter_empty_assistant_messages (unit tests for the shared helper) +# --------------------------------------------------------------------------- + + +def test_filter_empty_assistant_messages_removes_empty_string_content(): + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": ""}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 2 + assert payloads["messages"][0]["role"] == "user" + assert payloads["messages"][1]["role"] == "user" + + +def test_filter_empty_assistant_messages_removes_none_content(): + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": None}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 2 + + +def test_filter_empty_assistant_messages_removes_empty_list_content(): + """content == [] should also be treated as empty and filtered out.""" + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": []}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 2 + + +def test_filter_empty_assistant_messages_sets_empty_list_to_none_with_tool_calls(): + """content == [] with tool_calls should be set to None, not dropped.""" + tool_calls = [ + {"id": "c1", "type": "function", "function": {"name": "f", "arguments": "{}"}} + ] + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [], "tool_calls": tool_calls}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 3 + assert payloads["messages"][1]["content"] is None + assert payloads["messages"][1]["tool_calls"] is not None + + +def test_filter_empty_assistant_messages_keeps_valid_messages(): + payloads = { + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": "I'm here"}, + {"role": "user", "content": "world"}, + ] + } + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert len(payloads["messages"]) == 3 + + +def test_filter_empty_assistant_messages_noop_on_missing_messages(): + payloads: dict = {} + ProviderOpenAIOfficial._filter_empty_assistant_messages(payloads) + assert payloads == {} + + +# --------------------------------------------------------------------------- +# Tests for _query_stream: verify filtering is applied before streaming +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_query_stream_filters_empty_assistant_message(monkeypatch): + """_query_stream should filter empty assistant messages just like _query.""" + provider = _make_provider() + try: + captured_kwargs = {} + chunks = [ + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "ok"}, + "finish_reason": None, + } + ], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ), + ] + + async def fake_stream(): + for chunk in chunks: + yield chunk + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return fake_stream() + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": ""}, # Should be filtered + {"role": "user", "content": "world"}, + ], + } + + [r async for r in provider._query_stream(payloads=payloads, tools=None)] + + messages = captured_kwargs["messages"] + assert len(messages) == 2 + assert messages[0] == {"role": "user", "content": "hello"} + assert messages[1] == {"role": "user", "content": "world"} + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_stream_filters_empty_list_content_assistant_message(monkeypatch): + """_query_stream should filter assistant messages with content == [].""" + provider = _make_provider() + try: + captured_kwargs = {} + chunks = [ + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "ok"}, + "finish_reason": None, + } + ], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ), + ] + + async def fake_stream(): + for chunk in chunks: + yield chunk + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return fake_stream() + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": []}, # Should be filtered + {"role": "user", "content": "world"}, + ], + } + + [r async for r in provider._query_stream(payloads=payloads, tools=None)] + + messages = captured_kwargs["messages"] + assert len(messages) == 2 + assert messages[0] == {"role": "user", "content": "hello"} + assert messages[1] == {"role": "user", "content": "world"} + finally: + await provider.terminate() + + +@pytest.mark.asyncio +async def test_query_stream_converts_empty_list_content_to_none_with_tool_calls( + monkeypatch, +): + """_query_stream: content==[] with tool_calls should become None, not be dropped.""" + provider = _make_provider() + try: + captured_kwargs = {} + chunks = [ + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [ + { + "index": 0, + "delta": {"role": "assistant", "content": "ok"}, + "finish_reason": None, + } + ], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + ), + ChatCompletionChunk.model_validate( + { + "id": "chatcmpl-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": "gpt-4o-mini", + "choices": [], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ), + ] + + async def fake_stream(): + for chunk in chunks: + yield chunk + + async def fake_create(**kwargs): + captured_kwargs.update(kwargs) + return fake_stream() + + monkeypatch.setattr(provider.client.chat.completions, "create", fake_create) + + tool_calls = [ + { + "id": "c1", + "type": "function", + "function": {"name": "f", "arguments": "{}"}, + } + ] + payloads = { + "model": "gpt-4o-mini", + "messages": [ + {"role": "user", "content": "hello"}, + {"role": "assistant", "content": [], "tool_calls": tool_calls}, + {"role": "user", "content": "world"}, + ], + } + + [r async for r in provider._query_stream(payloads=payloads, tools=None)] + + messages = captured_kwargs["messages"] + assert len(messages) == 3 + assert messages[1]["content"] is None + assert messages[1]["tool_calls"] is not None + finally: + await provider.terminate() From a4f8439d89648848e1b40f2f93daebe6e7040640 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Wed, 22 Apr 2026 14:16:36 +0800 Subject: [PATCH 2/7] Update openai_source.py --- .../core/provider/sources/openai_source.py | 96 +++++++++++-------- 1 file changed, 56 insertions(+), 40 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 67971a2a93..836ab192e6 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -438,7 +438,7 @@ async def _fallback_to_text_only_and_retry( image_fallback_used, ) - def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient: + def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None: """创建带代理的 HTTP 客户端""" proxy = provider_config.get("proxy", "") return create_proxy_client("OpenAI", proxy) @@ -519,6 +519,41 @@ async def get_models(self): except NotFoundError as e: raise Exception(f"获取模型列表失败:{e}") + @staticmethod + def _filter_empty_assistant_messages(payloads: dict) -> None: + """Filter or fix empty assistant messages to prevent 400 errors on strict APIs. + + Some APIs (e.g. DeepSeek, Moonshot) reject assistant messages where both + ``content`` and ``tool_calls`` are absent or empty. This can happen when a + reasoning model returns only ``reasoning_content`` with no completion text, + causing the serialised ``content`` field to be an empty string or empty list. + + Three cases are handled in-place: + 1. Empty content **and** no tool_calls → drop the message entirely. + 2. Empty-string content **with** tool_calls → set content to ``None``. + 3. Empty-list content **with** tool_calls → set content to ``None``. + """ + if not isinstance(payloads.get("messages"), list): + return + cleaned: list = [] + for idx, msg in enumerate(payloads["messages"]): + if msg.get("role") == "assistant": + content = msg.get("content") + tool_calls = msg.get("tool_calls") + empty_content = ( + content == "" + or content is None + or content == [] + or (isinstance(content, list) and len(content) == 0) + ) + if not tool_calls and empty_content: + logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + continue + if tool_calls and empty_content: + msg["content"] = None + cleaned.append(msg) + payloads["messages"] = cleaned + async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if tools: model = payloads.get("model", "").lower() @@ -548,26 +583,7 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: model = payloads.get("model", "").lower() - if "messages" in payloads and isinstance(payloads["messages"], list): - cleaned_messages = [] - for idx, msg in enumerate(payloads["messages"]): - # 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误 - if msg.get("role") == "assistant": - content = msg.get("content") - tool_calls = msg.get("tool_calls") - - # 情况1: 空/null content 且无 tool_calls -> 过滤掉 - if not tool_calls and (content == "" or content is None): - logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") - continue - - # 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范) - if content == "" and tool_calls: - msg["content"] = None - - cleaned_messages.append(msg) - - payloads["messages"] = cleaned_messages + self._filter_empty_assistant_messages(payloads) completion = await self.client.chat.completions.create( **payloads, @@ -619,6 +635,8 @@ async def _query_stream( del payloads[key] self._apply_provider_specific_extra_body_overrides(extra_body) + self._filter_empty_assistant_messages(payloads) + stream = await self.client.chat.completions.create( **payloads, stream=True, @@ -854,26 +872,24 @@ async def _parse_openai_completion( # 工具集未提供 # Should be unreachable raise Exception("工具集未提供") - - if tool_call.type == "function": - # workaround for #1454 - if isinstance(tool_call.function.arguments, str): - try: + for tool in tools.func_list: + if ( + tool_call.type == "function" + and tool.name == tool_call.function.name + ): + # workaround for #1454 + if isinstance(tool_call.function.arguments, str): args = json.loads(tool_call.function.arguments) - except json.JSONDecodeError as e: - logger.error(f"解析参数失败: {e}") - args = {} - else: - args = tool_call.function.arguments - args_ls.append(args) - func_name_ls.append(tool_call.function.name) - tool_call_ids.append(tool_call.id) - - # gemini-2.5 / gemini-3 series extra_content handling - extra_content = getattr(tool_call, "extra_content", None) - if extra_content is not None: - tool_call_extra_content_dict[tool_call.id] = extra_content - + else: + args = tool_call.function.arguments + args_ls.append(args) + func_name_ls.append(tool_call.function.name) + tool_call_ids.append(tool_call.id) + + # gemini-2.5 / gemini-3 series extra_content handling + extra_content = getattr(tool_call, "extra_content", None) + if extra_content is not None: + tool_call_extra_content_dict[tool_call.id] = extra_content llm_response.role = "tool" llm_response.tools_call_args = args_ls llm_response.tools_call_name = func_name_ls From 65642309cb7fe8f21f618b7d6d3a303ef68ac392 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Wed, 22 Apr 2026 14:45:57 +0800 Subject: [PATCH 3/7] Update openai_source.py --- .../core/provider/sources/openai_source.py | 127 +++++++++--------- 1 file changed, 67 insertions(+), 60 deletions(-) diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index 836ab192e6..d29e2d993f 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -519,41 +519,6 @@ async def get_models(self): except NotFoundError as e: raise Exception(f"获取模型列表失败:{e}") - @staticmethod - def _filter_empty_assistant_messages(payloads: dict) -> None: - """Filter or fix empty assistant messages to prevent 400 errors on strict APIs. - - Some APIs (e.g. DeepSeek, Moonshot) reject assistant messages where both - ``content`` and ``tool_calls`` are absent or empty. This can happen when a - reasoning model returns only ``reasoning_content`` with no completion text, - causing the serialised ``content`` field to be an empty string or empty list. - - Three cases are handled in-place: - 1. Empty content **and** no tool_calls → drop the message entirely. - 2. Empty-string content **with** tool_calls → set content to ``None``. - 3. Empty-list content **with** tool_calls → set content to ``None``. - """ - if not isinstance(payloads.get("messages"), list): - return - cleaned: list = [] - for idx, msg in enumerate(payloads["messages"]): - if msg.get("role") == "assistant": - content = msg.get("content") - tool_calls = msg.get("tool_calls") - empty_content = ( - content == "" - or content is None - or content == [] - or (isinstance(content, list) and len(content) == 0) - ) - if not tool_calls and empty_content: - logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") - continue - if tool_calls and empty_content: - msg["content"] = None - cleaned.append(msg) - payloads["messages"] = cleaned - async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: if tools: model = payloads.get("model", "").lower() @@ -581,9 +546,28 @@ async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse: extra_body.update(custom_extra_body) self._apply_provider_specific_extra_body_overrides(extra_body) - model = payloads.get("model", "").lower() + # 在调用 self.client.chat.completions.create 之前添加清理逻辑 + if "messages" in payloads and isinstance(payloads["messages"], list): + cleaned_messages = [] + for idx, msg in enumerate(payloads["messages"]): + if msg.get("role") == "assistant": + content = msg.get("content") + tool_calls = msg.get("tool_calls") + + # 空内容:空字符串、None 或空列表 + empty_content = content in ("", None, []) + if not tool_calls and empty_content: + # 无 tool_calls 且内容为空 -> 过滤掉,避免 400 错误 + logger.debug(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + continue + + # 有 tool_calls 但 content 为空 -> 设为 None(API 可接受) + if empty_content and tool_calls: + msg["content"] = None - self._filter_empty_assistant_messages(payloads) + cleaned_messages.append(msg) + + payloads["messages"] = cleaned_messages completion = await self.client.chat.completions.create( **payloads, @@ -621,11 +605,6 @@ async def _query_stream( # 不在默认参数中的参数放在 extra_body 中 extra_body = {} - # 读取并合并 custom_extra_body 配置 - custom_extra_body = self.provider_config.get("custom_extra_body", {}) - if isinstance(custom_extra_body, dict): - extra_body.update(custom_extra_body) - to_del = [] for key in payloads: if key not in self.default_params: @@ -633,9 +612,35 @@ async def _query_stream( to_del.append(key) for key in to_del: del payloads[key] + + # 读取并合并 custom_extra_body 配置(custom 优先级高于 payload 中的扩展参数) + custom_extra_body = self.provider_config.get("custom_extra_body", {}) + if isinstance(custom_extra_body, dict): + extra_body.update(custom_extra_body) self._apply_provider_specific_extra_body_overrides(extra_body) - self._filter_empty_assistant_messages(payloads) + # 在调用 self.client.chat.completions.create 之前添加清理逻辑(与非流式 _query 方法保持一致) + if "messages" in payloads and isinstance(payloads["messages"], list): + cleaned_messages = [] + for idx, msg in enumerate(payloads["messages"]): + if msg.get("role") == "assistant": + content = msg.get("content") + tool_calls = msg.get("tool_calls") + + # 空内容:空字符串、None 或空列表 + empty_content = content in ("", None, []) + if not tool_calls and empty_content: + # 无 tool_calls 且内容为空 -> 过滤掉,避免 400 错误 + logger.debug(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)") + continue + + # 有 tool_calls 但 content 为空 -> 设为 None(API 可接受) + if empty_content and tool_calls: + msg["content"] = None + + cleaned_messages.append(msg) + + payloads["messages"] = cleaned_messages stream = await self.client.chat.completions.create( **payloads, @@ -872,24 +877,26 @@ async def _parse_openai_completion( # 工具集未提供 # Should be unreachable raise Exception("工具集未提供") - for tool in tools.func_list: - if ( - tool_call.type == "function" - and tool.name == tool_call.function.name - ): - # workaround for #1454 - if isinstance(tool_call.function.arguments, str): + + if tool_call.type == "function": + # workaround for #1454 + if isinstance(tool_call.function.arguments, str): + try: args = json.loads(tool_call.function.arguments) - else: - args = tool_call.function.arguments - args_ls.append(args) - func_name_ls.append(tool_call.function.name) - tool_call_ids.append(tool_call.id) - - # gemini-2.5 / gemini-3 series extra_content handling - extra_content = getattr(tool_call, "extra_content", None) - if extra_content is not None: - tool_call_extra_content_dict[tool_call.id] = extra_content + except json.JSONDecodeError as e: + logger.error(f"解析参数失败: {e}") + args = {} + else: + args = tool_call.function.arguments + args_ls.append(args) + func_name_ls.append(tool_call.function.name) + tool_call_ids.append(tool_call.id) + + # gemini-2.5 / gemini-3 series extra_content handling + extra_content = getattr(tool_call, "extra_content", None) + if extra_content is not None: + tool_call_extra_content_dict[tool_call.id] = extra_content + llm_response.role = "tool" llm_response.tools_call_args = args_ls llm_response.tools_call_name = func_name_ls From 65fcf4ba62e1e95b4188c00f4b9560703dd8a1a2 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Wed, 22 Apr 2026 14:55:22 +0800 Subject: [PATCH 4/7] Update tool_loop_agent_runner.py --- .../agent/runners/tool_loop_agent_runner.py | 111 ++++-------------- 1 file changed, 20 insertions(+), 91 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 81b82403e6..4bb95ba681 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -7,7 +7,7 @@ import uuid from collections.abc import AsyncIterator from contextlib import suppress -from dataclasses import dataclass, field, replace +from dataclasses import dataclass, field from pathlib import Path from mcp.types import ( @@ -42,10 +42,6 @@ ProviderRequest, ToolCallsResult, ) -from astrbot.core.provider.modalities import ( - log_context_sanitize_stats, - sanitize_contexts_by_modalities, -) from astrbot.core.provider.provider import Provider from ..context.compressor import ContextCompressor @@ -53,12 +49,7 @@ from ..context.manager import ContextManager from ..context.token_counter import EstimateTokenCounter, TokenCounter from ..hooks import BaseAgentRunHooks -from ..message import ( - AssistantMessageSegment, - Message, - ToolCallMessageSegment, - bind_checkpoint_messages, -) +from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment from ..response import AgentResponseData, AgentStats from ..run_context import ContextWrapper, TContext from ..tool_executor import BaseFunctionToolExecutor @@ -193,8 +184,10 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None if llm_resp.completion_text: parts.append(TextPart(text=llm_resp.completion_text)) if len(parts) == 0: - logger.warning("LLM returned empty assistant message with no tool calls.") - self.run_context.messages.append(Message(role="assistant", content=parts)) + logger.warning("LLM returned empty assistant message with no tool calls. Skipping message.") + # 不添加空消息到上下文,避免严格 API(如 DeepSeek R1)返回 400 错误 + else: + self.run_context.messages.append(Message(role="assistant", content=parts)) try: await self.agent_hooks.on_agent_done(self.run_context, llm_resp) @@ -302,15 +295,15 @@ async def reset( # MODIFIE the req.func_tool to use light tool schemas self.req.func_tool = light_set + messages = [] # append existing messages in the run context - messages = bind_checkpoint_messages(request.contexts or []) - if ( - request.prompt is not None - or request.image_urls - or request.audio_urls - or request.extra_user_content_parts - ): - m = await self._assemble_request_context_for_provider(request) + for msg in request.contexts: + m = Message.model_validate(msg) + if isinstance(msg, dict) and msg.get("_no_save"): + m._no_save = True + messages.append(m) + if request.prompt is not None: + m = await request.assemble_context() messages.append(Message.model_validate(m)) if request.system_prompt: messages.insert( @@ -327,42 +320,6 @@ def _read_tool_hint(self) -> str: return f"`{self.read_tool.name}`" return "the available file-read tool" - async def _assemble_request_context_for_provider( - self, - request: ProviderRequest, - ) -> dict[str, T.Any]: - modalities = self.provider.provider_config.get("modalities", None) - if not isinstance(modalities, list): - return await request.assemble_context() - - supports_image = "image" in modalities - supports_audio = "audio" in modalities - if supports_image and supports_audio: - return await request.assemble_context() - - adjusted_request = replace( - request, - image_urls=request.image_urls if supports_image else [], - audio_urls=request.audio_urls if supports_audio else [], - ) - context = await adjusted_request.assemble_context() - content = context.get("content") - if isinstance(content, str): - content_blocks: list[dict[str, T.Any]] = [{"type": "text", "text": content}] - elif isinstance(content, list): - content_blocks = content - else: - content_blocks = [] - - if not supports_image: - for _ in request.image_urls: - content_blocks.append({"type": "text", "text": "[Image]"}) - if not supports_audio: - for _ in request.audio_urls: - content_blocks.append({"type": "text", "text": "[Audio]"}) - - return {"role": "user", "content": content_blocks} - async def _write_tool_result_overflow_file( self, *, @@ -460,8 +417,8 @@ async def _iter_llm_responses( ) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" payload = { - "contexts": self._sanitize_contexts_for_provider(self.run_context.messages), - "func_tool": self._func_tool_for_provider(), + "contexts": self.run_context.messages, # list[Message] + "func_tool": self.req.func_tool, "session_id": self.req.session_id, "extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart] "abort_signal": self._abort_signal, @@ -577,35 +534,6 @@ async def _iter_llm_responses_with_fallback( completion_text="All available chat models are unavailable.", ) - def _sanitize_contexts_for_provider( - self, - contexts: list[Message] | list[dict[str, T.Any]], - ) -> list[Message] | list[dict[str, T.Any]]: - if not self._should_fix_modalities_for_provider(): - return contexts - sanitized_contexts, stats = sanitize_contexts_by_modalities( - contexts, - self.provider.provider_config.get("modalities", None), - ) - log_context_sanitize_stats(stats) - return sanitized_contexts - - def _should_fix_modalities_for_provider(self) -> bool: - modalities = self.provider.provider_config.get("modalities", None) - return isinstance(modalities, list) - - def _func_tool_for_provider(self) -> ToolSet | None: - if not self.req.func_tool: - return None - modalities = self.provider.provider_config.get("modalities", None) - if isinstance(modalities, list) and "tool_use" not in modalities: - logger.debug( - "Provider %s does not support tool_use, clearing tools for request.", - self.provider, - ) - return None - return self.req.func_tool - def _simple_print_message_role(self, tag: str = ""): roles = [] for message in self.run_context.messages: @@ -714,6 +642,7 @@ async def step(self): async for llm_response in self._iter_llm_responses_with_fallback(): if llm_response.is_chunk: + # update ttft if self.stats.time_to_first_token == 0: self.stats.time_to_first_token = time.time() - self.stats.start_time @@ -729,7 +658,7 @@ async def step(self): chain=MessageChain().message(llm_response.completion_text), ), ) - if llm_response.reasoning_content: + elif llm_response.reasoning_content: yield AgentResponse( type="streaming_delta", data=AgentResponseData( @@ -1287,7 +1216,7 @@ async def _resolve_tool_exec( if param_subset.tools and tool_names: contexts = self._build_tool_requery_context(tool_names) requery_resp = await self.provider.text_chat( - contexts=self._sanitize_contexts_for_provider(contexts), + contexts=contexts, func_tool=param_subset, model=self.req.model, session_id=self.req.session_id, @@ -1313,7 +1242,7 @@ async def _resolve_tool_exec( extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION, ) repair_resp = await self.provider.text_chat( - contexts=self._sanitize_contexts_for_provider(repair_contexts), + contexts=repair_contexts, func_tool=param_subset, model=self.req.model, session_id=self.req.session_id, From bb44c840c1c246a26359124dddf9b23a431120d9 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Wed, 22 Apr 2026 15:16:14 +0800 Subject: [PATCH 5/7] Update tool_loop_agent_runner.py --- .../agent/runners/tool_loop_agent_runner.py | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 4bb95ba681..b15a35342a 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -732,15 +732,6 @@ async def step(self): chain=MessageChain().message(llm_resp.completion_text), ), ) - if llm_resp.reasoning_content: - yield AgentResponse( - type="llm_result", - data=AgentResponseData( - chain=MessageChain(type="reasoning").message( - llm_resp.reasoning_content, - ), - ), - ) # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: @@ -762,15 +753,6 @@ async def step(self): chain=MessageChain().message(llm_resp.completion_text), ), ) - if llm_resp.reasoning_content: - yield AgentResponse( - type="llm_result", - data=AgentResponseData( - chain=MessageChain(type="reasoning").message( - llm_resp.reasoning_content, - ), - ), - ) await self._complete_with_assistant_response(llm_resp) return @@ -944,10 +926,8 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: # in 'skills_like' mode, raw.func_tool is light schema, does not have handler # so we need to get the tool from the raw tool set func_tool = self._skill_like_raw_tool_set.get_tool(func_tool_name) - available_tools = self._skill_like_raw_tool_set.names() else: func_tool = req.func_tool.get_tool(func_tool_name) - available_tools = req.func_tool.names() logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}") @@ -955,7 +935,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。") _append_tool_call_result( func_tool_id, - f"error: Tool {func_tool_name} not found. Available tools are: {', '.join(available_tools)}", + f"error: Tool {func_tool_name} not found.", ) continue From 4755a410295da0ac55d21c68e4e1e867be97e37c Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Wed, 22 Apr 2026 15:18:02 +0800 Subject: [PATCH 6/7] Update tool_loop_agent_runner.py --- .../agent/runners/tool_loop_agent_runner.py | 133 +++++++++++++++--- 1 file changed, 112 insertions(+), 21 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index b15a35342a..81b82403e6 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -7,7 +7,7 @@ import uuid from collections.abc import AsyncIterator from contextlib import suppress -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from mcp.types import ( @@ -42,6 +42,10 @@ ProviderRequest, ToolCallsResult, ) +from astrbot.core.provider.modalities import ( + log_context_sanitize_stats, + sanitize_contexts_by_modalities, +) from astrbot.core.provider.provider import Provider from ..context.compressor import ContextCompressor @@ -49,7 +53,12 @@ from ..context.manager import ContextManager from ..context.token_counter import EstimateTokenCounter, TokenCounter from ..hooks import BaseAgentRunHooks -from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment +from ..message import ( + AssistantMessageSegment, + Message, + ToolCallMessageSegment, + bind_checkpoint_messages, +) from ..response import AgentResponseData, AgentStats from ..run_context import ContextWrapper, TContext from ..tool_executor import BaseFunctionToolExecutor @@ -184,10 +193,8 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None if llm_resp.completion_text: parts.append(TextPart(text=llm_resp.completion_text)) if len(parts) == 0: - logger.warning("LLM returned empty assistant message with no tool calls. Skipping message.") - # 不添加空消息到上下文,避免严格 API(如 DeepSeek R1)返回 400 错误 - else: - self.run_context.messages.append(Message(role="assistant", content=parts)) + logger.warning("LLM returned empty assistant message with no tool calls.") + self.run_context.messages.append(Message(role="assistant", content=parts)) try: await self.agent_hooks.on_agent_done(self.run_context, llm_resp) @@ -295,15 +302,15 @@ async def reset( # MODIFIE the req.func_tool to use light tool schemas self.req.func_tool = light_set - messages = [] # append existing messages in the run context - for msg in request.contexts: - m = Message.model_validate(msg) - if isinstance(msg, dict) and msg.get("_no_save"): - m._no_save = True - messages.append(m) - if request.prompt is not None: - m = await request.assemble_context() + messages = bind_checkpoint_messages(request.contexts or []) + if ( + request.prompt is not None + or request.image_urls + or request.audio_urls + or request.extra_user_content_parts + ): + m = await self._assemble_request_context_for_provider(request) messages.append(Message.model_validate(m)) if request.system_prompt: messages.insert( @@ -320,6 +327,42 @@ def _read_tool_hint(self) -> str: return f"`{self.read_tool.name}`" return "the available file-read tool" + async def _assemble_request_context_for_provider( + self, + request: ProviderRequest, + ) -> dict[str, T.Any]: + modalities = self.provider.provider_config.get("modalities", None) + if not isinstance(modalities, list): + return await request.assemble_context() + + supports_image = "image" in modalities + supports_audio = "audio" in modalities + if supports_image and supports_audio: + return await request.assemble_context() + + adjusted_request = replace( + request, + image_urls=request.image_urls if supports_image else [], + audio_urls=request.audio_urls if supports_audio else [], + ) + context = await adjusted_request.assemble_context() + content = context.get("content") + if isinstance(content, str): + content_blocks: list[dict[str, T.Any]] = [{"type": "text", "text": content}] + elif isinstance(content, list): + content_blocks = content + else: + content_blocks = [] + + if not supports_image: + for _ in request.image_urls: + content_blocks.append({"type": "text", "text": "[Image]"}) + if not supports_audio: + for _ in request.audio_urls: + content_blocks.append({"type": "text", "text": "[Audio]"}) + + return {"role": "user", "content": content_blocks} + async def _write_tool_result_overflow_file( self, *, @@ -417,8 +460,8 @@ async def _iter_llm_responses( ) -> T.AsyncGenerator[LLMResponse, None]: """Yields chunks *and* a final LLMResponse.""" payload = { - "contexts": self.run_context.messages, # list[Message] - "func_tool": self.req.func_tool, + "contexts": self._sanitize_contexts_for_provider(self.run_context.messages), + "func_tool": self._func_tool_for_provider(), "session_id": self.req.session_id, "extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart] "abort_signal": self._abort_signal, @@ -534,6 +577,35 @@ async def _iter_llm_responses_with_fallback( completion_text="All available chat models are unavailable.", ) + def _sanitize_contexts_for_provider( + self, + contexts: list[Message] | list[dict[str, T.Any]], + ) -> list[Message] | list[dict[str, T.Any]]: + if not self._should_fix_modalities_for_provider(): + return contexts + sanitized_contexts, stats = sanitize_contexts_by_modalities( + contexts, + self.provider.provider_config.get("modalities", None), + ) + log_context_sanitize_stats(stats) + return sanitized_contexts + + def _should_fix_modalities_for_provider(self) -> bool: + modalities = self.provider.provider_config.get("modalities", None) + return isinstance(modalities, list) + + def _func_tool_for_provider(self) -> ToolSet | None: + if not self.req.func_tool: + return None + modalities = self.provider.provider_config.get("modalities", None) + if isinstance(modalities, list) and "tool_use" not in modalities: + logger.debug( + "Provider %s does not support tool_use, clearing tools for request.", + self.provider, + ) + return None + return self.req.func_tool + def _simple_print_message_role(self, tag: str = ""): roles = [] for message in self.run_context.messages: @@ -642,7 +714,6 @@ async def step(self): async for llm_response in self._iter_llm_responses_with_fallback(): if llm_response.is_chunk: - # update ttft if self.stats.time_to_first_token == 0: self.stats.time_to_first_token = time.time() - self.stats.start_time @@ -658,7 +729,7 @@ async def step(self): chain=MessageChain().message(llm_response.completion_text), ), ) - elif llm_response.reasoning_content: + if llm_response.reasoning_content: yield AgentResponse( type="streaming_delta", data=AgentResponseData( @@ -732,6 +803,15 @@ async def step(self): chain=MessageChain().message(llm_resp.completion_text), ), ) + if llm_resp.reasoning_content: + yield AgentResponse( + type="llm_result", + data=AgentResponseData( + chain=MessageChain(type="reasoning").message( + llm_resp.reasoning_content, + ), + ), + ) # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: @@ -753,6 +833,15 @@ async def step(self): chain=MessageChain().message(llm_resp.completion_text), ), ) + if llm_resp.reasoning_content: + yield AgentResponse( + type="llm_result", + data=AgentResponseData( + chain=MessageChain(type="reasoning").message( + llm_resp.reasoning_content, + ), + ), + ) await self._complete_with_assistant_response(llm_resp) return @@ -926,8 +1015,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: # in 'skills_like' mode, raw.func_tool is light schema, does not have handler # so we need to get the tool from the raw tool set func_tool = self._skill_like_raw_tool_set.get_tool(func_tool_name) + available_tools = self._skill_like_raw_tool_set.names() else: func_tool = req.func_tool.get_tool(func_tool_name) + available_tools = req.func_tool.names() logger.info(f"使用工具:{func_tool_name},参数:{func_tool_args}") @@ -935,7 +1026,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None: logger.warning(f"未找到指定的工具: {func_tool_name},将跳过。") _append_tool_call_result( func_tool_id, - f"error: Tool {func_tool_name} not found.", + f"error: Tool {func_tool_name} not found. Available tools are: {', '.join(available_tools)}", ) continue @@ -1196,7 +1287,7 @@ async def _resolve_tool_exec( if param_subset.tools and tool_names: contexts = self._build_tool_requery_context(tool_names) requery_resp = await self.provider.text_chat( - contexts=contexts, + contexts=self._sanitize_contexts_for_provider(contexts), func_tool=param_subset, model=self.req.model, session_id=self.req.session_id, @@ -1222,7 +1313,7 @@ async def _resolve_tool_exec( extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION, ) repair_resp = await self.provider.text_chat( - contexts=repair_contexts, + contexts=self._sanitize_contexts_for_provider(repair_contexts), func_tool=param_subset, model=self.req.model, session_id=self.req.session_id, From 45856e712a73f85c0b4f1da49781884bb8fb2444 Mon Sep 17 00:00:00 2001 From: lingyun14 Date: Wed, 22 Apr 2026 15:20:08 +0800 Subject: [PATCH 7/7] Update tool_loop_agent_runner.py --- astrbot/core/agent/runners/tool_loop_agent_runner.py | 6 ++++-- 1 file changed, 4 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 81b82403e6..edd9795bb3 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -193,8 +193,10 @@ async def _complete_with_assistant_response(self, llm_resp: LLMResponse) -> None if llm_resp.completion_text: parts.append(TextPart(text=llm_resp.completion_text)) if len(parts) == 0: - logger.warning("LLM returned empty assistant message with no tool calls.") - self.run_context.messages.append(Message(role="assistant", content=parts)) + logger.warning("LLM returned empty assistant message with no tool calls. Skipping message.") + # 不添加空消息到上下文,避免严格 API(如 DeepSeek R1)返回 400 错误 + else: + self.run_context.messages.append(Message(role="assistant", content=parts)) try: await self.agent_hooks.on_agent_done(self.run_context, llm_resp)