@@ -95,16 +95,62 @@ class _ToolExecutionInterrupted(Exception):
9595
9696ToolExecutorResultT = 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
10499class 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
0 commit comments