Skip to content

Commit 13d0b8c

Browse files
committed
Merge branch 'dev' and fix tool_loop_agent_runner conflict
2 parents b802ede + 571b571 commit 13d0b8c

3 files changed

Lines changed: 278 additions & 26 deletions

File tree

astrbot/core/agent/runners/tool_loop_agent_runner.py

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

9696
ToolExecutorResultT = 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."""
@@ -186,6 +232,8 @@ async def reset(
186232
self._abort_signal = asyncio.Event()
187233
self._pending_follow_ups: list[FollowUpTicket] = []
188234
self._follow_up_seq = 0
235+
self._last_tool_name: str | None = None
236+
self._same_tool_streak = 0
189237

190238
# These two are used for tool schema mode handling
191239
# We now have two modes:
@@ -418,12 +466,8 @@ def _consume_follow_up_notice(self) -> str:
418466
follow_up_lines = "\n".join(
419467
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
420468
)
421-
return (
422-
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
423-
"was in progress. Prioritize these follow-up instructions in your next "
424-
"actions. In your very next action, briefly acknowledge to the user "
425-
"that their follow-up message(s) were received before continuing.\n"
426-
f"{follow_up_lines}"
469+
return self.FOLLOW_UP_NOTICE_TEMPLATE.format(
470+
follow_up_lines=follow_up_lines,
427471
)
428472

429473
def _merge_follow_up_notice(self, content: str) -> str:
@@ -432,6 +476,35 @@ def _merge_follow_up_notice(self, content: str) -> str:
432476
return content
433477
return f"{content}{notice}"
434478

479+
def _track_tool_call_streak(self, tool_name: str) -> int:
480+
if tool_name == self._last_tool_name:
481+
self._same_tool_streak += 1
482+
else:
483+
self._last_tool_name = tool_name
484+
self._same_tool_streak = 1
485+
return self._same_tool_streak
486+
487+
def _build_repeated_tool_call_guidance(self, tool_name: str, streak: int) -> str:
488+
if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
489+
return ""
490+
491+
if streak >= self.REPEATED_TOOL_NOTICE_L3_THRESHOLD:
492+
return self.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format(
493+
tool_name=tool_name,
494+
streak=streak,
495+
)
496+
497+
if streak >= self.REPEATED_TOOL_NOTICE_L2_THRESHOLD:
498+
return self.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format(
499+
tool_name=tool_name,
500+
streak=streak,
501+
)
502+
503+
return self.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
504+
tool_name=tool_name,
505+
streak=streak,
506+
)
507+
435508
@override
436509
async def step(self):
437510
"""Process a single step of the agent.
@@ -488,7 +561,7 @@ async def step(self):
488561
if self._is_stop_requested():
489562
llm_resp_result = LLMResponse(
490563
role="assistant",
491-
completion_text=USER_INTERRUPTION_MESSAGE,
564+
completion_text=self.USER_INTERRUPTION_MESSAGE,
492565
reasoning_content=llm_response.reasoning_content,
493566
reasoning_signature=llm_response.reasoning_signature,
494567
)
@@ -694,7 +767,7 @@ async def step_until_done(self, max_step: int):
694767
self.run_context.messages.append(
695768
Message(
696769
role="user",
697-
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
770+
content=self.MAX_STEPS_REACHED_PROMPT,
698771
)
699772
)
700773
# 再执行最后一步
@@ -751,6 +824,7 @@ def _handle_image_content(
751824
llm_response.tools_call_ids,
752825
strict=True,
753826
):
827+
tool_call_streak = self._track_tool_call_streak(func_tool_name)
754828
yield _HandleFunctionToolsResult.from_message_chain(
755829
MessageChain(
756830
type="tool_call",
@@ -901,7 +975,10 @@ def _handle_image_content(
901975
if result_parts:
902976
_append_tool_call_result(
903977
func_tool_id,
904-
"\n\n".join(result_parts),
978+
"\n\n".join(result_parts)
979+
+ self._build_repeated_tool_call_guidance(
980+
func_tool_name, tool_call_streak
981+
),
905982
)
906983

907984
elif resp is None:
@@ -915,7 +992,10 @@ def _handle_image_content(
915992
self.stats.end_time = time.time()
916993
_append_tool_call_result(
917994
func_tool_id,
918-
"The tool has no return value, or has sent the result directly to the user.",
995+
"The tool has no return value, or has sent the result directly to the user."
996+
+ self._build_repeated_tool_call_guidance(
997+
func_tool_name, tool_call_streak
998+
),
919999
)
9201000
else:
9211001
# 不应该出现其他类型
@@ -924,7 +1004,10 @@ def _handle_image_content(
9241004
)
9251005
_append_tool_call_result(
9261006
func_tool_id,
927-
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
1007+
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*"
1008+
+ self._build_repeated_tool_call_guidance(
1009+
func_tool_name, tool_call_streak
1010+
),
9281011
)
9291012

9301013
try:
@@ -942,7 +1025,10 @@ def _handle_image_content(
9421025
logger.warning(traceback.format_exc())
9431026
_append_tool_call_result(
9441027
func_tool_id,
945-
f"error: {e!s}",
1028+
f"error: {e!s}"
1029+
+ self._build_repeated_tool_call_guidance(
1030+
func_tool_name, tool_call_streak
1031+
),
9461032
)
9471033

9481034
# yield the last tool call result
@@ -980,11 +1066,8 @@ def _build_tool_requery_context(
9801066
contexts.append(msg.model_dump())
9811067
elif isinstance(msg, dict):
9821068
contexts.append(copy.deepcopy(msg))
983-
instruction = (
984-
"You have decided to call tool(s): "
985-
+ ", ".join(tool_names)
986-
+ ". Now call the tool(s) with required arguments using the tool schema, "
987-
"and follow the existing tool-use rules."
1069+
instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format(
1070+
tool_names=", ".join(tool_names)
9881071
)
9891072
if extra_instruction:
9901073
instruction = f"{instruction}\n{extra_instruction}"
@@ -1037,6 +1120,32 @@ async def _resolve_tool_exec(
10371120
if requery_resp:
10381121
llm_resp = requery_resp
10391122

1123+
# If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
1124+
# we consider it as a failure of the LLM to follow the tool-use instruction,
1125+
# and we will retry once with a stronger instruction that explicitly requires the LLM to either call the tool or give an explanation.
1126+
if (
1127+
not llm_resp.tools_call_name
1128+
and not self._has_meaningful_assistant_reply(llm_resp)
1129+
):
1130+
logger.warning(
1131+
"skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction."
1132+
)
1133+
repair_contexts = self._build_tool_requery_context(
1134+
tool_names,
1135+
extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION,
1136+
)
1137+
repair_resp = await self.provider.text_chat(
1138+
contexts=repair_contexts,
1139+
func_tool=param_subset,
1140+
model=self.req.model,
1141+
session_id=self.req.session_id,
1142+
extra_user_content_parts=self.req.extra_user_content_parts,
1143+
tool_choice="required",
1144+
abort_signal=self._abort_signal,
1145+
)
1146+
if repair_resp:
1147+
llm_resp = repair_resp
1148+
10401149
return llm_resp, subset
10411150

10421151
def done(self) -> bool:
@@ -1065,7 +1174,7 @@ async def _finalize_aborted_step(
10651174
if llm_resp.role != "assistant":
10661175
llm_resp = LLMResponse(
10671176
role="assistant",
1068-
completion_text=USER_INTERRUPTION_MESSAGE,
1177+
completion_text=self.USER_INTERRUPTION_MESSAGE,
10691178
)
10701179
self.final_llm_resp = llm_resp
10711180
self._aborted = True

dashboard/src/components/provider/ProviderSourcesPanel.vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,6 @@ const emitDeleteSource = (source) => emit("delete-provider-source", source);
259259
}
260260
261261
.provider-source-list {
262-
max-height: calc(100vh - 335px);
263262
overflow-y: auto;
264263
padding: 0;
265264
background: transparent;

0 commit comments

Comments
 (0)