Skip to content

Commit 571b571

Browse files
authored
feat: implement llm guidance for repetition tool call (#7388)
* feat: implement llm guidance for repetition tool call fixes: #7387 * feat: enhance tool execution guidance with user interruption and repetition notices * chore: fix test * feat: rename guidance method for repeated tool calls to improve clarity * feat: update repeated tool notice thresholds to improve user guidance
1 parent b0b6816 commit 571b571

3 files changed

Lines changed: 253 additions & 34 deletions

File tree

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 107 additions & 31 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 = 3
134+
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
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."""
@@ -209,6 +255,8 @@ async def reset(
209255
self._abort_signal = asyncio.Event()
210256
self._pending_follow_ups: list[FollowUpTicket] = []
211257
self._follow_up_seq = 0
258+
self._last_tool_name: str | None = None
259+
self._same_tool_streak = 0
212260

213261
# These two are used for tool schema mode handling
214262
# We now have two modes:
@@ -413,12 +461,8 @@ def _consume_follow_up_notice(self) -> str:
413461
follow_up_lines = "\n".join(
414462
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
415463
)
416-
return (
417-
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
418-
"was in progress. Prioritize these follow-up instructions in your next "
419-
"actions. In your very next action, briefly acknowledge to the user "
420-
"that their follow-up message(s) were received before continuing.\n"
421-
f"{follow_up_lines}"
464+
return self.FOLLOW_UP_NOTICE_TEMPLATE.format(
465+
follow_up_lines=follow_up_lines,
422466
)
423467

424468
def _merge_follow_up_notice(self, content: str) -> str:
@@ -427,6 +471,35 @@ def _merge_follow_up_notice(self, content: str) -> str:
427471
return content
428472
return f"{content}{notice}"
429473

474+
def _track_tool_call_streak(self, tool_name: str) -> int:
475+
if tool_name == self._last_tool_name:
476+
self._same_tool_streak += 1
477+
else:
478+
self._last_tool_name = tool_name
479+
self._same_tool_streak = 1
480+
return self._same_tool_streak
481+
482+
def _build_repeated_tool_call_guidance(self, tool_name: str, streak: int) -> str:
483+
if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
484+
return ""
485+
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,
490+
)
491+
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,
496+
)
497+
498+
return self.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
499+
tool_name=tool_name,
500+
streak=streak,
501+
)
502+
430503
@override
431504
async def step(self):
432505
"""Process a single step of the agent.
@@ -483,7 +556,7 @@ async def step(self):
483556
if self._is_stop_requested():
484557
llm_resp_result = LLMResponse(
485558
role="assistant",
486-
completion_text=USER_INTERRUPTION_MESSAGE,
559+
completion_text=self.USER_INTERRUPTION_MESSAGE,
487560
reasoning_content=llm_response.reasoning_content,
488561
reasoning_signature=llm_response.reasoning_signature,
489562
)
@@ -681,7 +754,7 @@ async def step_until_done(
681754
self.run_context.messages.append(
682755
Message(
683756
role="user",
684-
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
757+
content=self.MAX_STEPS_REACHED_PROMPT,
685758
)
686759
)
687760
# 再执行最后一步
@@ -712,6 +785,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
712785
llm_response.tools_call_args,
713786
llm_response.tools_call_ids,
714787
):
788+
tool_call_streak = self._track_tool_call_streak(func_tool_name)
715789
yield _HandleFunctionToolsResult.from_message_chain(
716790
MessageChain(
717791
type="tool_call",
@@ -861,7 +935,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
861935
if result_parts:
862936
_append_tool_call_result(
863937
func_tool_id,
864-
"\n\n".join(result_parts),
938+
"\n\n".join(result_parts)
939+
+ self._build_repeated_tool_call_guidance(
940+
func_tool_name, tool_call_streak
941+
),
865942
)
866943

867944
elif resp is None:
@@ -875,7 +952,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
875952
self.stats.end_time = time.time()
876953
_append_tool_call_result(
877954
func_tool_id,
878-
"The tool has no return value, or has sent the result directly to the user.",
955+
"The tool has no return value, or has sent the result directly to the user."
956+
+ self._build_repeated_tool_call_guidance(
957+
func_tool_name, tool_call_streak
958+
),
879959
)
880960
else:
881961
# 不应该出现其他类型
@@ -884,7 +964,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
884964
)
885965
_append_tool_call_result(
886966
func_tool_id,
887-
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
967+
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*"
968+
+ self._build_repeated_tool_call_guidance(
969+
func_tool_name, tool_call_streak
970+
),
888971
)
889972

890973
try:
@@ -902,7 +985,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
902985
logger.warning(traceback.format_exc())
903986
_append_tool_call_result(
904987
func_tool_id,
905-
f"error: {e!s}",
988+
f"error: {e!s}"
989+
+ self._build_repeated_tool_call_guidance(
990+
func_tool_name, tool_call_streak
991+
),
906992
)
907993

908994
# yield the last tool call result
@@ -942,11 +1028,8 @@ def _build_tool_requery_context(
9421028
contexts.append(msg.model_dump()) # type: ignore[call-arg]
9431029
elif isinstance(msg, dict):
9441030
contexts.append(copy.deepcopy(msg))
945-
instruction = (
946-
"You have decided to call tool(s): "
947-
+ ", ".join(tool_names)
948-
+ ". Now call the tool(s) with required arguments using the tool schema, "
949-
"and follow the existing tool-use rules."
1031+
instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format(
1032+
tool_names=", ".join(tool_names)
9501033
)
9511034
if extra_instruction:
9521035
instruction = f"{instruction}\n{extra_instruction}"
@@ -1017,14 +1100,7 @@ async def _resolve_tool_exec(
10171100
)
10181101
repair_contexts = self._build_tool_requery_context(
10191102
tool_names,
1020-
extra_instruction=(
1021-
"This is the second-stage tool execution step. "
1022-
"You must do exactly one of the following: "
1023-
"1. Call one of the selected tools using the provided tool schema. "
1024-
"2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. "
1025-
"Do not return an empty response. "
1026-
"Do not ignore the selected tools without explanation."
1027-
),
1103+
extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION,
10281104
)
10291105
repair_resp = await self.provider.text_chat(
10301106
contexts=repair_contexts,
@@ -1066,7 +1142,7 @@ async def _finalize_aborted_step(
10661142
if llm_resp.role != "assistant":
10671143
llm_resp = LLMResponse(
10681144
role="assistant",
1069-
completion_text=USER_INTERRUPTION_MESSAGE,
1145+
completion_text=self.USER_INTERRUPTION_MESSAGE,
10701146
)
10711147
self.final_llm_resp = llm_resp
10721148
self._aborted = True

dashboard/src/components/provider/ProviderSourcesPanel.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,6 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
224224
}
225225
226226
.provider-source-list {
227-
max-height: calc(100vh - 335px);
228227
overflow-y: auto;
229228
padding: 0;
230229
background: transparent;

0 commit comments

Comments
 (0)