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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,21 @@ async def step(self):
)
return

# When the tool set has been removed (e.g. the forced finalization step
# after max_step), some models still emit tool calls as hallucination.
# Strip them and finalize with a plain assistant reply so the run never
# produces a dangling assistant(tool_calls) message nor leaves the
# runner stuck in RUNNING without a final response.
if llm_resp.tools_call_name and not self.req.func_tool:
logger.warning(
"LLM returned tool calls but no tools are available; "
"treating them as hallucination and finalizing with a plain response."
)
llm_resp.tools_call_name = []
llm_resp.tools_call_args = []
llm_resp.tools_call_ids = []
llm_resp.tools_call_extra_content = {}

if not llm_resp.tools_call_name:
await self._complete_with_assistant_response(llm_resp)

Expand Down Expand Up @@ -993,6 +1008,29 @@ async def step(self):
yield await self._finalize_aborted_step()
return

# Protocol safety net: every tool_call_id must have a matching tool
# result, otherwise the context would contain a dangling
# assistant(tool_calls) message that providers reject. Check each
# id independently: equal lengths do not guarantee matched ids
# (e.g. one call emitting multiple result blocks while another
# call produces none).
existing_result_ids = {
block.tool_call_id for block in tool_call_result_blocks
}
for tool_call_id in llm_resp.tools_call_ids:
if tool_call_id not in existing_result_ids:
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=tool_call_id,
content=(
"error: tool execution produced no result (tools may have been "
"removed or the call was interrupted); ignore this call and "
"answer based on the information gathered so far."
),
)
)

# 将结果添加到上下文中
parts = []
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
Expand Down
200 changes: 198 additions & 2 deletions tests/test_tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,17 @@
from astrbot.core.agent.agent import Agent
from astrbot.core.agent.handoff import HandoffTool
from astrbot.core.agent.hooks import BaseAgentRunHooks
from astrbot.core.agent.message import ImageURLPart, Message, TextPart
from astrbot.core.agent.message import (
ImageURLPart,
Message,
TextPart,
ToolCallMessageSegment,
)
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.agent.runners.tool_loop_agent_runner import (
ToolLoopAgentRunner,
_HandleFunctionToolsResult,
)
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
from astrbot.core.exceptions import EmptyModelOutputError
Expand Down Expand Up @@ -390,6 +398,60 @@ def __init__(self, handoff_tool_name: str):
super().__init__(handoff_tool_name, {"input": "delegate this task"})


class HallucinatingToolCallProvider(MockProvider):
"""Simulates a model that keeps emitting tool calls even without tools.

This is the hallucination scenario hit during the forced finalization step
after ``max_step``: tools were removed (``req.func_tool = None``) but the
model still returns a tool-call payload.
"""

def __init__(self) -> None:
super().__init__()
self.call_count = 0

async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
func_tool = kwargs.get("func_tool")
if func_tool is None:
# Hallucinated tool call: no tools were provided to the model.
return LLMResponse(
role="assistant",
completion_text="我会使用工具来完成这个任务",
tools_call_name=["non_existent_tool"],
tools_call_args=[{"query": "test"}],
tools_call_ids=["call_hallucinated"],
usage=TokenUsage(input_other=10, output=5),
)
return LLMResponse(
role="assistant",
completion_text="这是我的最终回答",
usage=TokenUsage(input_other=10, output=5),
)


class MultiCallProvider(MockProvider):
"""Returns a single turn with two tool calls."""

async def text_chat(self, **kwargs) -> LLMResponse:
self.call_count += 1
func_tool = kwargs.get("func_tool")
if func_tool is None or self.call_count > 1:
return LLMResponse(
role="assistant",
completion_text="这是我的最终回答",
usage=TokenUsage(input_other=10, output=5),
)
return LLMResponse(
role="assistant",
completion_text="",
tools_call_name=["test_tool", "test_tool"],
tools_call_args=[{"query": "a"}, {"query": "b"}],
tools_call_ids=["call_a", "call_b"],
usage=TokenUsage(input_other=10, output=5),
)


class MockHooks(BaseAgentRunHooks):
"""模拟钩子函数"""

Expand Down Expand Up @@ -579,6 +641,140 @@ 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_hallucinated_tool_call_when_tools_removed_finalizes_with_plain_response(
runner, provider_request, mock_tool_executor, mock_hooks
):
"""When tools are removed (forced max-step finalization), a hallucinated
tool call from the model must be stripped and finalized as a plain
assistant reply: the run completes, the user gets a response, and no
dangling assistant(tool_calls) message is appended to the context.
"""
provider = HallucinatingToolCallProvider()

await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)

# Simulate run_agent's forced finalization step: tools removed.
runner.req.func_tool = None

responses = []
async for response in runner.step():
responses.append(response)

assert runner.done(), "the run should finalize instead of staying RUNNING"
assert runner.req.func_tool is None

# The user must receive a final llm_result.
final_responses = [r for r in responses if r.type == "llm_result"]
assert len(final_responses) > 0, "a final reply should be produced"

# No dangling assistant(tool_calls) message may enter the context.
dangling = [
m
for m in runner.run_context.messages
if getattr(m, "tool_calls", None) is not None
]
assert dangling == [], "context must not contain dangling tool_calls messages"

last_message = runner.run_context.messages[-1]
assert last_message.role == "assistant"
assert getattr(last_message, "tool_calls", None) is None


@pytest.mark.asyncio
async def test_tool_calls_without_results_get_placeholder_tool_blocks(
runner, mock_provider, provider_request, mock_tool_executor, mock_hooks
):
"""If a tool call produces no result block, a placeholder tool message must
be appended so the context never contains an assistant(tool_calls) message
without a matching tool result (protocol safety net)."""
mock_provider.should_call_tools = True
mock_provider.max_calls_before_normal_response = 100

async def fake_handle_function_tools(req, llm_resp):
# Simulates an execution path that yields no tool result blocks.
if False:
yield # pragma: no cover - keep this an async generator

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,
)
runner._handle_function_tools = fake_handle_function_tools

async for _ in runner.step():
pass

messages = runner.run_context.messages
assistant_tool_calls = [
m for m in messages if getattr(m, "tool_calls", None) is not None
]
tool_results = [m for m in messages if m.role == "tool"]

assert len(assistant_tool_calls) == 1
assert len(tool_results) == 1, "missing tool results should be filled in"
assert tool_results[0].tool_call_id == "call_123"
assert "error" in tool_results[0].content.lower()


@pytest.mark.asyncio
async def test_duplicate_tool_result_ids_still_trigger_placeholder_fill(
runner, provider_request, mock_tool_executor, mock_hooks
):
"""The placeholder guard must check each tool_call_id independently:
equal block/id counts with duplicate ids (one call emitting several
result blocks while another emits none) must still fill the missing id."""
provider = MultiCallProvider()

async def fake_handle_function_tools(req, llm_resp):
# Two result blocks, both for "call_a": the count matches the two
# declared ids but "call_b" is still missing.
yield _HandleFunctionToolsResult.from_tool_call_result_blocks(
[
ToolCallMessageSegment(
role="tool", tool_call_id="call_a", content="result A"
),
ToolCallMessageSegment(
role="tool", tool_call_id="call_a", content="result A2"
),
]
)

await runner.reset(
provider=provider,
request=provider_request,
run_context=ContextWrapper(context=None),
tool_executor=mock_tool_executor,
agent_hooks=mock_hooks,
streaming=False,
)
runner._handle_function_tools = fake_handle_function_tools

async for _ in runner.step():
pass

messages = runner.run_context.messages
tool_results = [m for m in messages if m.role == "tool"]
result_ids = [m.tool_call_id for m in tool_results]

assert len(result_ids) == 3, "call_a x2 + placeholder for call_b"
assert result_ids.count("call_a") == 2
assert "call_b" in result_ids
placeholder = next(m for m in tool_results if m.tool_call_id == "call_b")
assert "error" in placeholder.content.lower()


@pytest.mark.asyncio
async def test_tool_loop_next_request_includes_tool_result(
runner, provider_request, mock_tool_executor, mock_hooks
Expand Down