Skip to content

Commit 4590479

Browse files
committed
feat: enhance tool execution guidance with user interruption and repetition notices
1 parent f25f2ca commit 4590479

2 files changed

Lines changed: 109 additions & 60 deletions

File tree

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 71 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -95,16 +95,62 @@ class _ToolExecutionInterrupted(Exception):
9595

9696
ToolExecutorResultT = T.TypeVar("ToolExecutorResultT")
9797

98-
USER_INTERRUPTION_MESSAGE = (
99-
"[SYSTEM: User actively interrupted the response generation. "
100-
"Partial output before interruption is preserved.]"
101-
)
102-
10398

10499
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
105100
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
106101
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
107102
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
103+
USER_INTERRUPTION_MESSAGE = (
104+
"[SYSTEM: User actively interrupted the response generation. "
105+
"Partial output before interruption is preserved.]"
106+
)
107+
FOLLOW_UP_NOTICE_TEMPLATE = (
108+
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
109+
"was in progress. Prioritize these follow-up instructions in your next "
110+
"actions. In your very next action, briefly acknowledge to the user "
111+
"that their follow-up message(s) were received before continuing.\n"
112+
"{follow_up_lines}"
113+
)
114+
MAX_STEPS_REACHED_PROMPT = (
115+
"Maximum tool call limit reached. "
116+
"Stop calling tools, and based on the information you have gathered, "
117+
"summarize your task and findings, and reply to the user directly."
118+
)
119+
SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE = (
120+
"You have decided to call tool(s): {tool_names}. Now call the tool(s) "
121+
"with required arguments using the tool schema, and follow the existing "
122+
"tool-use rules."
123+
)
124+
SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION = (
125+
"This is the second-stage tool execution step. "
126+
"You must do exactly one of the following: "
127+
"1. Call one of the selected tools using the provided tool schema. "
128+
"2. If calling a tool is no longer possible or appropriate, reply to the user "
129+
"with a brief explanation of why. "
130+
"Do not return an empty response. "
131+
"Do not ignore the selected tools without explanation."
132+
)
133+
REPEATED_TOOL_NOTICE_L1_THRESHOLD = 2
134+
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 3
135+
REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
136+
REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
137+
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
138+
"`{tool_name}` {streak} times consecutively. Double-check whether another "
139+
"tool, different arguments, or a summary would move the task forward better."
140+
)
141+
REPEATED_TOOL_NOTICE_L2_TEMPLATE = (
142+
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
143+
"`{tool_name}` {streak} times consecutively. Unless this repetition is "
144+
"clearly necessary, stop repeating the same action and either switch "
145+
"tools, refine parameters, or summarize what is still missing."
146+
)
147+
REPEATED_TOOL_NOTICE_L3_TEMPLATE = (
148+
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
149+
"`{tool_name}` {streak} times consecutively. Repetition is now very "
150+
"high. Continue only if each call is clearly producing new information. "
151+
"Otherwise, change strategy, adjust arguments, or explain the limitation "
152+
"to the user."
153+
)
108154

109155
def _get_persona_custom_error_message(self) -> str | None:
110156
"""Read persona-level custom error message from event extras when available."""
@@ -415,12 +461,8 @@ def _consume_follow_up_notice(self) -> str:
415461
follow_up_lines = "\n".join(
416462
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
417463
)
418-
return (
419-
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
420-
"was in progress. Prioritize these follow-up instructions in your next "
421-
"actions. In your very next action, briefly acknowledge to the user "
422-
"that their follow-up message(s) were received before continuing.\n"
423-
f"{follow_up_lines}"
464+
return self.FOLLOW_UP_NOTICE_TEMPLATE.format(
465+
follow_up_lines=follow_up_lines,
424466
)
425467

426468
def _merge_follow_up_notice(self, content: str) -> str:
@@ -438,30 +480,24 @@ def _track_tool_call_streak(self, tool_name: str) -> int:
438480
return self._same_tool_streak
439481

440482
def _build_same_tool_guidance(self, tool_name: str, streak: int) -> str:
441-
if streak < 3:
483+
if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
442484
return ""
443485

444-
if streak >= 5:
445-
return (
446-
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
447-
f"`{tool_name}` {streak} times consecutively. Repetition is now very "
448-
"high. Continue only if each call is clearly producing new information. "
449-
"Otherwise, change strategy, adjust arguments, or explain the limitation "
450-
"to the user."
486+
if streak >= self.REPEATED_TOOL_NOTICE_L3_THRESHOLD:
487+
return self.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format(
488+
tool_name=tool_name,
489+
streak=streak,
451490
)
452491

453-
if streak >= 3:
454-
return (
455-
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
456-
f"`{tool_name}` {streak} times consecutively. Unless this repetition is "
457-
"clearly necessary, stop repeating the same action and either switch "
458-
"tools, refine parameters, or summarize what is still missing."
492+
if streak >= self.REPEATED_TOOL_NOTICE_L2_THRESHOLD:
493+
return self.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format(
494+
tool_name=tool_name,
495+
streak=streak,
459496
)
460497

461-
return (
462-
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
463-
f"`{tool_name}` {streak} times consecutively. Double-check whether another "
464-
"tool, different arguments, or a summary would move the task forward better."
498+
return self.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
499+
tool_name=tool_name,
500+
streak=streak,
465501
)
466502

467503
@override
@@ -520,7 +556,7 @@ async def step(self):
520556
if self._is_stop_requested():
521557
llm_resp_result = LLMResponse(
522558
role="assistant",
523-
completion_text=USER_INTERRUPTION_MESSAGE,
559+
completion_text=self.USER_INTERRUPTION_MESSAGE,
524560
reasoning_content=llm_response.reasoning_content,
525561
reasoning_signature=llm_response.reasoning_signature,
526562
)
@@ -718,7 +754,7 @@ async def step_until_done(
718754
self.run_context.messages.append(
719755
Message(
720756
role="user",
721-
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
757+
content=self.MAX_STEPS_REACHED_PROMPT,
722758
)
723759
)
724760
# 再执行最后一步
@@ -990,11 +1026,8 @@ def _build_tool_requery_context(
9901026
contexts.append(msg.model_dump()) # type: ignore[call-arg]
9911027
elif isinstance(msg, dict):
9921028
contexts.append(copy.deepcopy(msg))
993-
instruction = (
994-
"You have decided to call tool(s): "
995-
+ ", ".join(tool_names)
996-
+ ". Now call the tool(s) with required arguments using the tool schema, "
997-
"and follow the existing tool-use rules."
1029+
instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format(
1030+
tool_names=", ".join(tool_names)
9981031
)
9991032
if extra_instruction:
10001033
instruction = f"{instruction}\n{extra_instruction}"
@@ -1065,14 +1098,7 @@ async def _resolve_tool_exec(
10651098
)
10661099
repair_contexts = self._build_tool_requery_context(
10671100
tool_names,
1068-
extra_instruction=(
1069-
"This is the second-stage tool execution step. "
1070-
"You must do exactly one of the following: "
1071-
"1. Call one of the selected tools using the provided tool schema. "
1072-
"2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. "
1073-
"Do not return an empty response. "
1074-
"Do not ignore the selected tools without explanation."
1075-
),
1101+
extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION,
10761102
)
10771103
repair_resp = await self.provider.text_chat(
10781104
contexts=repair_contexts,
@@ -1114,7 +1140,7 @@ async def _finalize_aborted_step(
11141140
if llm_resp.role != "assistant":
11151141
llm_resp = LLMResponse(
11161142
role="assistant",
1117-
completion_text=USER_INTERRUPTION_MESSAGE,
1143+
completion_text=self.USER_INTERRUPTION_MESSAGE,
11181144
)
11191145
self.final_llm_resp = llm_resp
11201146
self._aborted = True

tests/test_tool_loop_agent_runner.py

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
1212

1313
from astrbot.core.agent.agent import Agent
14-
from astrbot.core.agent.hooks import BaseAgentRunHooks
1514
from astrbot.core.agent.handoff import HandoffTool
15+
from astrbot.core.agent.hooks import BaseAgentRunHooks
1616
from astrbot.core.agent.run_context import ContextWrapper
1717
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
1818
from astrbot.core.agent.tool import FunctionTool, ToolSet
@@ -599,14 +599,25 @@ async def test_same_tool_consecutive_results_include_escalating_guidance(
599599
assert len(tool_messages) == 5
600600

601601
tool_contents = [str(message.content) for message in tool_messages]
602-
assert "same tool" not in tool_contents[0]
603-
assert "By the way" in tool_contents[1]
604-
assert "2 times consecutively" in tool_contents[1]
605-
assert "Important" in tool_contents[2]
606-
assert "3 times consecutively" in tool_contents[2]
607-
assert "Important" in tool_contents[4]
608-
assert "5 times consecutively" in tool_contents[4]
609-
assert "very high" in tool_contents[4]
602+
runner_cls = type(runner)
603+
level_1_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_1_TEMPLATE.format(
604+
tool_name="test_tool",
605+
streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_1_THRESHOLD,
606+
)
607+
level_2_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_2_TEMPLATE.format(
608+
tool_name="test_tool",
609+
streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_2_THRESHOLD,
610+
)
611+
level_3_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_3_TEMPLATE.format(
612+
tool_name="test_tool",
613+
streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_3_THRESHOLD,
614+
)
615+
616+
assert level_1_notice not in tool_contents[0]
617+
assert level_2_notice not in tool_contents[0]
618+
assert level_1_notice in tool_contents[1]
619+
assert level_2_notice in tool_contents[2]
620+
assert level_3_notice in tool_contents[4]
610621

611622

612623
@pytest.mark.asyncio
@@ -652,11 +663,21 @@ async def test_same_tool_streak_resets_after_switching_tools(
652663
assert len(tool_messages) == 4
653664

654665
tool_contents = [str(message.content) for message in tool_messages]
655-
assert "same tool" not in tool_contents[0]
656-
assert "same tool" not in tool_contents[1]
657-
assert "same tool" not in tool_contents[2]
658-
assert "By the way" in tool_contents[3]
659-
assert "`test_tool` 2 times consecutively" in tool_contents[3]
666+
runner_cls = type(runner)
667+
level_1_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_1_TEMPLATE.format(
668+
tool_name="test_tool",
669+
streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_1_THRESHOLD,
670+
)
671+
level_2_notice = runner_cls.SAME_TOOL_NOTICE_LEVEL_2_TEMPLATE.format(
672+
tool_name="test_tool",
673+
streak=runner_cls.SAME_TOOL_NOTICE_LEVEL_2_THRESHOLD,
674+
)
675+
676+
assert level_1_notice not in tool_contents[0]
677+
assert level_1_notice not in tool_contents[1]
678+
assert level_1_notice not in tool_contents[2]
679+
assert level_2_notice not in tool_contents[2]
680+
assert level_1_notice in tool_contents[3]
660681

661682

662683
@pytest.mark.asyncio
@@ -1084,7 +1105,9 @@ async def test_follow_up_accepted_when_active_and_not_stopping(
10841105

10851106
ticket = runner.follow_up(message_text="valid follow-up message")
10861107

1087-
assert ticket is not None, "Follow-up should be accepted when runner is active and not stopping"
1108+
assert ticket is not None, (
1109+
"Follow-up should be accepted when runner is active and not stopping"
1110+
)
10881111
assert ticket.text == "valid follow-up message"
10891112
assert ticket.consumed is False
10901113
assert ticket in runner._pending_follow_ups

0 commit comments

Comments
 (0)