diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index b56d7e62fb..deca225c5a 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -794,27 +794,28 @@ async def step(self): await self._complete_with_assistant_response(llm_resp) # 返回 LLM 结果 - if llm_resp.reasoning_content: - yield AgentResponse( - type="llm_result", - data=AgentResponseData( - chain=MessageChain(type="reasoning").message( - llm_resp.reasoning_content, + if not llm_resp.tools_call_name: + if llm_resp.reasoning_content: + yield AgentResponse( + type="llm_result", + data=AgentResponseData( + chain=MessageChain(type="reasoning").message( + llm_resp.reasoning_content, + ), ), - ), - ) - if llm_resp.result_chain: - yield AgentResponse( - type="llm_result", - data=AgentResponseData(chain=llm_resp.result_chain), - ) - elif llm_resp.completion_text: - yield AgentResponse( - type="llm_result", - data=AgentResponseData( - chain=MessageChain().message(llm_resp.completion_text), - ), - ) + ) + if llm_resp.result_chain: + yield AgentResponse( + type="llm_result", + data=AgentResponseData(chain=llm_resp.result_chain), + ) + elif llm_resp.completion_text: + yield AgentResponse( + type="llm_result", + data=AgentResponseData( + chain=MessageChain().message(llm_resp.completion_text), + ), + ) # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index a49003af17..af15ed7a7c 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -769,6 +769,20 @@ def _normalize_content(raw_content: Any, strip: bool = True) -> str: if isinstance(raw_content, str): content = raw_content.strip() if strip else raw_content + repr_like_prefix = "[{text=" + repr_like_suffix = ", type=text}]" + repr_like_max_len = 8192 + check_content = raw_content.strip() + while ( + check_content.startswith(repr_like_prefix) + and check_content.endswith(repr_like_suffix) + and len(check_content) < repr_like_max_len + ): + check_content = check_content[ + len(repr_like_prefix) : -len(repr_like_suffix) + ].strip() + if check_content != raw_content.strip(): + return check_content.strip() if strip else check_content # Check if the string is a JSON-encoded list (e.g., "[{'type': 'text', ...}]") # This can happen when streaming concatenates content that was originally list format # Only check if it looks like a complete JSON array (requires strip for check) diff --git a/tests/test_openai_source.py b/tests/test_openai_source.py index b8262090e4..a45d4145a7 100644 --- a/tests/test_openai_source.py +++ b/tests/test_openai_source.py @@ -60,6 +60,34 @@ def _make_groq_provider(overrides: dict | None = None) -> ProviderGroq: ) +def test_normalize_content_handles_text_part_repr_string(): + raw = "[{text=I will use the file reading tool., type=text}]" + + assert ProviderOpenAIOfficial._normalize_content(raw) == ( + "I will use the file reading tool." + ) + + +def test_normalize_content_handles_text_part_repr_string_without_strip(): + raw = " [{text=I will use the file reading tool., type=text}] " + + assert ProviderOpenAIOfficial._normalize_content(raw, strip=False) == ( + "I will use the file reading tool." + ) + + +def test_normalize_content_drops_empty_nested_text_part_repr_string(): + raw = "[{text=[{text=[{text=, type=text}], type=text}], type=text}]" + + assert ProviderOpenAIOfficial._normalize_content(raw) == "" + + +def test_normalize_content_handles_non_empty_nested_text_part_repr_string(): + raw = "[{text=[{text=[{text=hello, type=text}], type=text}], type=text}]" + + assert ProviderOpenAIOfficial._normalize_content(raw) == "hello" + + def test_create_http_client_uses_openai_httpx_module(monkeypatch): captured: dict[str, object] = {} diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index b4464680fb..05409f98dc 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -507,6 +507,38 @@ async def snapshot_context_manager(messages, trusted_token_usage=0): assert final_contexts[-1].content == runner.MAX_STEPS_REACHED_PROMPT +@pytest.mark.asyncio +async def test_tool_call_assistant_preface_is_not_sent_as_llm_result( + runner, provider_request, mock_tool_executor, mock_hooks, mock_provider +): + """Assistant preface text on a tool-call turn must stay internal.""" + mock_provider.should_call_tools = True + mock_provider.max_calls_before_normal_response = 1 + + await runner.reset( + provider=mock_provider, + request=provider_request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + ) + + responses = [] + async for response in runner.step_until_done(3): + responses.append(response) + + response_types = [response.type for response in responses] + assert response_types[-1] == "llm_result" + assert "llm_result" not in response_types[:-1] + + llm_results = [response for response in responses if response.type == "llm_result"] + llm_texts = [response.data["chain"].get_plain_text() for response in llm_results] + assert len(llm_results) == 1 + assert "我需要使用工具来帮助您" not in llm_texts + assert llm_texts == ["这是我的最终回答"] + + @pytest.mark.asyncio async def test_tool_loop_next_request_includes_tool_result( runner, provider_request, mock_tool_executor, mock_hooks