mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-08 05:50:12 +08:00
Compare commits
5 Commits
codex/fix-
...
perf/preve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d37e9a8d14 | ||
|
|
14e3576d79 | ||
|
|
8be1e92418 | ||
|
|
4590479fcc | ||
|
|
f25f2ca35c |
@@ -95,16 +95,62 @@ class _ToolExecutionInterrupted(Exception):
|
||||
|
||||
ToolExecutorResultT = T.TypeVar("ToolExecutorResultT")
|
||||
|
||||
USER_INTERRUPTION_MESSAGE = (
|
||||
"[SYSTEM: User actively interrupted the response generation. "
|
||||
"Partial output before interruption is preserved.]"
|
||||
)
|
||||
|
||||
|
||||
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
|
||||
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
|
||||
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
|
||||
USER_INTERRUPTION_MESSAGE = (
|
||||
"[SYSTEM: User actively interrupted the response generation. "
|
||||
"Partial output before interruption is preserved.]"
|
||||
)
|
||||
FOLLOW_UP_NOTICE_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
|
||||
"was in progress. Prioritize these follow-up instructions in your next "
|
||||
"actions. In your very next action, briefly acknowledge to the user "
|
||||
"that their follow-up message(s) were received before continuing.\n"
|
||||
"{follow_up_lines}"
|
||||
)
|
||||
MAX_STEPS_REACHED_PROMPT = (
|
||||
"Maximum tool call limit reached. "
|
||||
"Stop calling tools, and based on the information you have gathered, "
|
||||
"summarize your task and findings, and reply to the user directly."
|
||||
)
|
||||
SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE = (
|
||||
"You have decided to call tool(s): {tool_names}. Now call the tool(s) "
|
||||
"with required arguments using the tool schema, and follow the existing "
|
||||
"tool-use rules."
|
||||
)
|
||||
SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION = (
|
||||
"This is the second-stage tool execution step. "
|
||||
"You must do exactly one of the following: "
|
||||
"1. Call one of the selected tools using the provided tool schema. "
|
||||
"2. If calling a tool is no longer possible or appropriate, reply to the user "
|
||||
"with a brief explanation of why. "
|
||||
"Do not return an empty response. "
|
||||
"Do not ignore the selected tools without explanation."
|
||||
)
|
||||
REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3
|
||||
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
|
||||
REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
|
||||
REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
|
||||
"`{tool_name}` {streak} times consecutively. Double-check whether another "
|
||||
"tool, different arguments, or a summary would move the task forward better."
|
||||
)
|
||||
REPEATED_TOOL_NOTICE_L2_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
|
||||
"`{tool_name}` {streak} times consecutively. Unless this repetition is "
|
||||
"clearly necessary, stop repeating the same action and either switch "
|
||||
"tools, refine parameters, or summarize what is still missing."
|
||||
)
|
||||
REPEATED_TOOL_NOTICE_L3_TEMPLATE = (
|
||||
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
|
||||
"`{tool_name}` {streak} times consecutively. Repetition is now very "
|
||||
"high. Continue only if each call is clearly producing new information. "
|
||||
"Otherwise, change strategy, adjust arguments, or explain the limitation "
|
||||
"to the user."
|
||||
)
|
||||
|
||||
def _get_persona_custom_error_message(self) -> str | None:
|
||||
"""Read persona-level custom error message from event extras when available."""
|
||||
@@ -209,6 +255,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self._abort_signal = asyncio.Event()
|
||||
self._pending_follow_ups: list[FollowUpTicket] = []
|
||||
self._follow_up_seq = 0
|
||||
self._last_tool_name: str | None = None
|
||||
self._same_tool_streak = 0
|
||||
|
||||
# These two are used for tool schema mode handling
|
||||
# We now have two modes:
|
||||
@@ -413,12 +461,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
follow_up_lines = "\n".join(
|
||||
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
|
||||
)
|
||||
return (
|
||||
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
|
||||
"was in progress. Prioritize these follow-up instructions in your next "
|
||||
"actions. In your very next action, briefly acknowledge to the user "
|
||||
"that their follow-up message(s) were received before continuing.\n"
|
||||
f"{follow_up_lines}"
|
||||
return self.FOLLOW_UP_NOTICE_TEMPLATE.format(
|
||||
follow_up_lines=follow_up_lines,
|
||||
)
|
||||
|
||||
def _merge_follow_up_notice(self, content: str) -> str:
|
||||
@@ -427,6 +471,35 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
return content
|
||||
return f"{content}{notice}"
|
||||
|
||||
def _track_tool_call_streak(self, tool_name: str) -> int:
|
||||
if tool_name == self._last_tool_name:
|
||||
self._same_tool_streak += 1
|
||||
else:
|
||||
self._last_tool_name = tool_name
|
||||
self._same_tool_streak = 1
|
||||
return self._same_tool_streak
|
||||
|
||||
def _build_repeated_tool_call_guidance(self, tool_name: str, streak: int) -> str:
|
||||
if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
|
||||
return ""
|
||||
|
||||
if streak >= self.REPEATED_TOOL_NOTICE_L3_THRESHOLD:
|
||||
return self.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format(
|
||||
tool_name=tool_name,
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
if streak >= self.REPEATED_TOOL_NOTICE_L2_THRESHOLD:
|
||||
return self.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format(
|
||||
tool_name=tool_name,
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
return self.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
|
||||
tool_name=tool_name,
|
||||
streak=streak,
|
||||
)
|
||||
|
||||
@override
|
||||
async def step(self):
|
||||
"""Process a single step of the agent.
|
||||
@@ -483,7 +556,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if self._is_stop_requested():
|
||||
llm_resp_result = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=USER_INTERRUPTION_MESSAGE,
|
||||
completion_text=self.USER_INTERRUPTION_MESSAGE,
|
||||
reasoning_content=llm_response.reasoning_content,
|
||||
reasoning_signature=llm_response.reasoning_signature,
|
||||
)
|
||||
@@ -681,7 +754,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.run_context.messages.append(
|
||||
Message(
|
||||
role="user",
|
||||
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
|
||||
content=self.MAX_STEPS_REACHED_PROMPT,
|
||||
)
|
||||
)
|
||||
# 再执行最后一步
|
||||
@@ -712,6 +785,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
llm_response.tools_call_args,
|
||||
llm_response.tools_call_ids,
|
||||
):
|
||||
tool_call_streak = self._track_tool_call_streak(func_tool_name)
|
||||
yield _HandleFunctionToolsResult.from_message_chain(
|
||||
MessageChain(
|
||||
type="tool_call",
|
||||
@@ -861,7 +935,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if result_parts:
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
"\n\n".join(result_parts),
|
||||
"\n\n".join(result_parts)
|
||||
+ self._build_repeated_tool_call_guidance(
|
||||
func_tool_name, tool_call_streak
|
||||
),
|
||||
)
|
||||
|
||||
elif resp is None:
|
||||
@@ -875,7 +952,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats.end_time = time.time()
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
"The tool has no return value, or has sent the result directly to the user.",
|
||||
"The tool has no return value, or has sent the result directly to the user."
|
||||
+ self._build_repeated_tool_call_guidance(
|
||||
func_tool_name, tool_call_streak
|
||||
),
|
||||
)
|
||||
else:
|
||||
# 不应该出现其他类型
|
||||
@@ -884,7 +964,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
|
||||
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*"
|
||||
+ self._build_repeated_tool_call_guidance(
|
||||
func_tool_name, tool_call_streak
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -902,7 +985,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
logger.warning(traceback.format_exc())
|
||||
_append_tool_call_result(
|
||||
func_tool_id,
|
||||
f"error: {e!s}",
|
||||
f"error: {e!s}"
|
||||
+ self._build_repeated_tool_call_guidance(
|
||||
func_tool_name, tool_call_streak
|
||||
),
|
||||
)
|
||||
|
||||
# yield the last tool call result
|
||||
@@ -942,11 +1028,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
contexts.append(msg.model_dump()) # type: ignore[call-arg]
|
||||
elif isinstance(msg, dict):
|
||||
contexts.append(copy.deepcopy(msg))
|
||||
instruction = (
|
||||
"You have decided to call tool(s): "
|
||||
+ ", ".join(tool_names)
|
||||
+ ". Now call the tool(s) with required arguments using the tool schema, "
|
||||
"and follow the existing tool-use rules."
|
||||
instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format(
|
||||
tool_names=", ".join(tool_names)
|
||||
)
|
||||
if extra_instruction:
|
||||
instruction = f"{instruction}\n{extra_instruction}"
|
||||
@@ -1017,14 +1100,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
repair_contexts = self._build_tool_requery_context(
|
||||
tool_names,
|
||||
extra_instruction=(
|
||||
"This is the second-stage tool execution step. "
|
||||
"You must do exactly one of the following: "
|
||||
"1. Call one of the selected tools using the provided tool schema. "
|
||||
"2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. "
|
||||
"Do not return an empty response. "
|
||||
"Do not ignore the selected tools without explanation."
|
||||
),
|
||||
extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION,
|
||||
)
|
||||
repair_resp = await self.provider.text_chat(
|
||||
contexts=repair_contexts,
|
||||
@@ -1066,7 +1142,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if llm_resp.role != "assistant":
|
||||
llm_resp = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=USER_INTERRUPTION_MESSAGE,
|
||||
completion_text=self.USER_INTERRUPTION_MESSAGE,
|
||||
)
|
||||
self.final_llm_resp = llm_resp
|
||||
self._aborted = True
|
||||
|
||||
@@ -224,7 +224,6 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
|
||||
}
|
||||
|
||||
.provider-source-list {
|
||||
max-height: calc(100vh - 335px);
|
||||
overflow-y: auto;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
|
||||
@@ -11,8 +11,8 @@ import pytest
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
||||
|
||||
from astrbot.core.agent.agent import Agent
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.handoff import HandoffTool
|
||||
from astrbot.core.agent.hooks import BaseAgentRunHooks
|
||||
from astrbot.core.agent.run_context import ContextWrapper
|
||||
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
|
||||
from astrbot.core.agent.tool import FunctionTool, ToolSet
|
||||
@@ -193,6 +193,32 @@ class MockToolCallProvider(MockProvider):
|
||||
)
|
||||
|
||||
|
||||
class SequentialToolProvider(MockProvider):
|
||||
def __init__(self, tool_sequence: list[str]):
|
||||
super().__init__()
|
||||
self.tool_sequence = tool_sequence
|
||||
|
||||
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 > len(self.tool_sequence):
|
||||
return LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="这是我的最终回答",
|
||||
usage=TokenUsage(input_other=10, output=5),
|
||||
)
|
||||
|
||||
tool_name = self.tool_sequence[self.call_count - 1]
|
||||
return LLMResponse(
|
||||
role="assistant",
|
||||
completion_text="",
|
||||
tools_call_name=[tool_name],
|
||||
tools_call_args=[{"query": f"step-{self.call_count}"}],
|
||||
tools_call_ids=[f"call_{self.call_count}"],
|
||||
usage=TokenUsage(input_other=10, output=5),
|
||||
)
|
||||
|
||||
|
||||
class MockHandoffProvider(MockToolCallProvider):
|
||||
def __init__(self, handoff_tool_name: str):
|
||||
super().__init__(handoff_tool_name, {"input": "delegate this task"})
|
||||
@@ -538,6 +564,122 @@ async def test_tool_result_includes_all_calltoolresult_content(
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_tool_consecutive_results_include_escalating_guidance(
|
||||
runner, mock_tool_executor, mock_hooks
|
||||
):
|
||||
provider = SequentialToolProvider(["test_tool"] * 5)
|
||||
tool = FunctionTool(
|
||||
name="test_tool",
|
||||
description="测试工具",
|
||||
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
handler=AsyncMock(),
|
||||
)
|
||||
request = ProviderRequest(
|
||||
prompt="请连续执行工具",
|
||||
func_tool=ToolSet(tools=[tool]),
|
||||
contexts=[],
|
||||
)
|
||||
|
||||
await runner.reset(
|
||||
provider=provider,
|
||||
request=request,
|
||||
run_context=ContextWrapper(context=None),
|
||||
tool_executor=mock_tool_executor,
|
||||
agent_hooks=mock_hooks,
|
||||
streaming=False,
|
||||
)
|
||||
|
||||
async for _ in runner.step_until_done(6):
|
||||
pass
|
||||
|
||||
tool_messages = [
|
||||
m for m in runner.run_context.messages if getattr(m, "role", None) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 5
|
||||
|
||||
tool_contents = [str(message.content) for message in tool_messages]
|
||||
runner_cls = type(runner)
|
||||
level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
|
||||
tool_name="test_tool",
|
||||
streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD,
|
||||
)
|
||||
level_2_notice = runner_cls.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format(
|
||||
tool_name="test_tool",
|
||||
streak=runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD,
|
||||
)
|
||||
level_3_notice = runner_cls.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format(
|
||||
tool_name="test_tool",
|
||||
streak=runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD,
|
||||
)
|
||||
|
||||
assert level_1_notice not in tool_contents[0]
|
||||
assert level_2_notice not in tool_contents[0]
|
||||
assert level_1_notice in tool_contents[1]
|
||||
assert level_2_notice in tool_contents[2]
|
||||
assert level_3_notice in tool_contents[4]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_tool_streak_resets_after_switching_tools(
|
||||
runner, mock_tool_executor, mock_hooks
|
||||
):
|
||||
provider = SequentialToolProvider(
|
||||
["test_tool", "other_tool", "test_tool", "test_tool"]
|
||||
)
|
||||
tool_a = FunctionTool(
|
||||
name="test_tool",
|
||||
description="测试工具 A",
|
||||
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
handler=AsyncMock(),
|
||||
)
|
||||
tool_b = FunctionTool(
|
||||
name="other_tool",
|
||||
description="测试工具 B",
|
||||
parameters={"type": "object", "properties": {"query": {"type": "string"}}},
|
||||
handler=AsyncMock(),
|
||||
)
|
||||
request = ProviderRequest(
|
||||
prompt="切换工具后再重复",
|
||||
func_tool=ToolSet(tools=[tool_a, tool_b]),
|
||||
contexts=[],
|
||||
)
|
||||
|
||||
await runner.reset(
|
||||
provider=provider,
|
||||
request=request,
|
||||
run_context=ContextWrapper(context=None),
|
||||
tool_executor=mock_tool_executor,
|
||||
agent_hooks=mock_hooks,
|
||||
streaming=False,
|
||||
)
|
||||
|
||||
async for _ in runner.step_until_done(5):
|
||||
pass
|
||||
|
||||
tool_messages = [
|
||||
m for m in runner.run_context.messages if getattr(m, "role", None) == "tool"
|
||||
]
|
||||
assert len(tool_messages) == 4
|
||||
|
||||
tool_contents = [str(message.content) for message in tool_messages]
|
||||
runner_cls = type(runner)
|
||||
level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
|
||||
tool_name="test_tool",
|
||||
streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD,
|
||||
)
|
||||
level_2_notice = runner_cls.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format(
|
||||
tool_name="test_tool",
|
||||
streak=runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD,
|
||||
)
|
||||
|
||||
assert level_1_notice not in tool_contents[0]
|
||||
assert level_1_notice not in tool_contents[1]
|
||||
assert level_1_notice not in tool_contents[2]
|
||||
assert level_2_notice not in tool_contents[2]
|
||||
assert level_1_notice in tool_contents[3]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_provider_used_when_primary_raises(
|
||||
runner, provider_request, mock_tool_executor, mock_hooks
|
||||
@@ -963,7 +1105,9 @@ async def test_follow_up_accepted_when_active_and_not_stopping(
|
||||
|
||||
ticket = runner.follow_up(message_text="valid follow-up message")
|
||||
|
||||
assert ticket is not None, "Follow-up should be accepted when runner is active and not stopping"
|
||||
assert ticket is not None, (
|
||||
"Follow-up should be accepted when runner is active and not stopping"
|
||||
)
|
||||
assert ticket.text == "valid follow-up message"
|
||||
assert ticket.consumed is False
|
||||
assert ticket in runner._pending_follow_ups
|
||||
|
||||
Reference in New Issue
Block a user