@@ -88,13 +88,63 @@ class _ToolExecutionInterrupted(Exception):
8888
8989ToolExecutorResultT = T .TypeVar ("ToolExecutorResultT" )
9090
91- USER_INTERRUPTION_MESSAGE = (
92- "[SYSTEM: User actively interrupted the response generation. "
93- "Partial output before interruption is preserved.]"
94- )
95-
9691
9792class ToolLoopAgentRunner (BaseAgentRunner [TContext ]):
93+ EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
94+ EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
95+ EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
96+ USER_INTERRUPTION_MESSAGE = (
97+ "[SYSTEM: User actively interrupted the response generation. "
98+ "Partial output before interruption is preserved.]"
99+ )
100+ FOLLOW_UP_NOTICE_TEMPLATE = (
101+ "\n \n [SYSTEM NOTICE] User sent follow-up messages while tool execution "
102+ "was in progress. Prioritize these follow-up instructions in your next "
103+ "actions. In your very next action, briefly acknowledge to the user "
104+ "that their follow-up message(s) were received before continuing.\n "
105+ "{follow_up_lines}"
106+ )
107+ MAX_STEPS_REACHED_PROMPT = (
108+ "Maximum tool call limit reached. "
109+ "Stop calling tools, and based on the information you have gathered, "
110+ "summarize your task and findings, and reply to the user directly."
111+ )
112+ SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE = (
113+ "You have decided to call tool(s): {tool_names}. Now call the tool(s) "
114+ "with required arguments using the tool schema, and follow the existing "
115+ "tool-use rules."
116+ )
117+ SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION = (
118+ "This is the second-stage tool execution step. "
119+ "You must do exactly one of the following: "
120+ "1. Call one of the selected tools using the provided tool schema. "
121+ "2. If calling a tool is no longer possible or appropriate, reply to the user "
122+ "with a brief explanation of why. "
123+ "Do not return an empty response. "
124+ "Do not ignore the selected tools without explanation."
125+ )
126+ REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3
127+ REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
128+ REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
129+ REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
130+ "\n \n [SYSTEM NOTICE] By the way, you have executed the same tool "
131+ "`{tool_name}` {streak} times consecutively. Double-check whether another "
132+ "tool, different arguments, or a summary would move the task forward better."
133+ )
134+ REPEATED_TOOL_NOTICE_L2_TEMPLATE = (
135+ "\n \n [SYSTEM NOTICE] Important: you have executed the same tool "
136+ "`{tool_name}` {streak} times consecutively. Unless this repetition is "
137+ "clearly necessary, stop repeating the same action and either switch "
138+ "tools, refine parameters, or summarize what is still missing."
139+ )
140+ REPEATED_TOOL_NOTICE_L3_TEMPLATE = (
141+ "\n \n [SYSTEM NOTICE] Important: you have executed the same tool "
142+ "`{tool_name}` {streak} times consecutively. Repetition is now very "
143+ "high. Continue only if each call is clearly producing new information. "
144+ "Otherwise, change strategy, adjust arguments, or explain the limitation "
145+ "to the user."
146+ )
147+
98148 def _get_persona_custom_error_message (self ) -> str | None :
99149 """Read persona-level custom error message from event extras when available."""
100150 event = getattr (self .run_context .context , "event" , None )
@@ -172,6 +222,8 @@ async def reset(
172222 self ._abort_signal = asyncio .Event ()
173223 self ._pending_follow_ups : list [FollowUpTicket ] = []
174224 self ._follow_up_seq = 0
225+ self ._last_tool_name : str | None = None
226+ self ._same_tool_streak = 0
175227
176228 # These two are used for tool schema mode handling
177229 # We now have two modes:
@@ -346,12 +398,8 @@ def _consume_follow_up_notice(self) -> str:
346398 follow_up_lines = "\n " .join (
347399 f"{ idx } . { ticket .text } " for idx , ticket in enumerate (follow_ups , start = 1 )
348400 )
349- return (
350- "\n \n [SYSTEM NOTICE] User sent follow-up messages while tool execution "
351- "was in progress. Prioritize these follow-up instructions in your next "
352- "actions. In your very next action, briefly acknowledge to the user "
353- "that their follow-up message(s) were received before continuing.\n "
354- f"{ follow_up_lines } "
401+ return self .FOLLOW_UP_NOTICE_TEMPLATE .format (
402+ follow_up_lines = follow_up_lines ,
355403 )
356404
357405 def _merge_follow_up_notice (self , content : str ) -> str :
@@ -360,6 +408,35 @@ def _merge_follow_up_notice(self, content: str) -> str:
360408 return content
361409 return f"{ content } { notice } "
362410
411+ def _track_tool_call_streak (self , tool_name : str ) -> int :
412+ if tool_name == self ._last_tool_name :
413+ self ._same_tool_streak += 1
414+ else :
415+ self ._last_tool_name = tool_name
416+ self ._same_tool_streak = 1
417+ return self ._same_tool_streak
418+
419+ def _build_repeated_tool_call_guidance (self , tool_name : str , streak : int ) -> str :
420+ if streak < self .REPEATED_TOOL_NOTICE_L1_THRESHOLD :
421+ return ""
422+
423+ if streak >= self .REPEATED_TOOL_NOTICE_L3_THRESHOLD :
424+ return self .REPEATED_TOOL_NOTICE_L3_TEMPLATE .format (
425+ tool_name = tool_name ,
426+ streak = streak ,
427+ )
428+
429+ if streak >= self .REPEATED_TOOL_NOTICE_L2_THRESHOLD :
430+ return self .REPEATED_TOOL_NOTICE_L2_TEMPLATE .format (
431+ tool_name = tool_name ,
432+ streak = streak ,
433+ )
434+
435+ return self .REPEATED_TOOL_NOTICE_L1_TEMPLATE .format (
436+ tool_name = tool_name ,
437+ streak = streak ,
438+ )
439+
363440 @override
364441 async def step (self ):
365442 """Process a single step of the agent.
@@ -416,7 +493,7 @@ async def step(self):
416493 if self ._is_stop_requested ():
417494 llm_resp_result = LLMResponse (
418495 role = "assistant" ,
419- completion_text = USER_INTERRUPTION_MESSAGE ,
496+ completion_text = self . USER_INTERRUPTION_MESSAGE ,
420497 reasoning_content = llm_response .reasoning_content ,
421498 reasoning_signature = llm_response .reasoning_signature ,
422499 )
@@ -624,7 +701,7 @@ async def step_until_done(
624701 self .run_context .messages .append (
625702 Message (
626703 role = "user" ,
627- content = "工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。" ,
704+ content = self . MAX_STEPS_REACHED_PROMPT ,
628705 )
629706 )
630707 # 再执行最后一步
@@ -655,6 +732,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
655732 llm_response .tools_call_args ,
656733 llm_response .tools_call_ids ,
657734 ):
735+ tool_call_streak = self ._track_tool_call_streak (func_tool_name )
658736 yield _HandleFunctionToolsResult .from_message_chain (
659737 MessageChain (
660738 type = "tool_call" ,
@@ -880,7 +958,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
880958 )
881959 _append_tool_call_result (
882960 func_tool_id ,
883- result_content ,
961+ "\n \n " .join (result_parts )
962+ + self ._build_repeated_tool_call_guidance (
963+ func_tool_name , tool_call_streak
964+ ),
884965 )
885966
886967 elif resp is None :
@@ -894,7 +975,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
894975 self .stats .end_time = time .time ()
895976 _append_tool_call_result (
896977 func_tool_id ,
897- "The tool has no return value, or has sent the result directly to the user." ,
978+ "The tool has no return value, or has sent the result directly to the user."
979+ + self ._build_repeated_tool_call_guidance (
980+ func_tool_name , tool_call_streak
981+ ),
898982 )
899983 else :
900984 # 不应该出现其他类型
@@ -903,7 +987,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
903987 )
904988 _append_tool_call_result (
905989 func_tool_id ,
906- "*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*" ,
990+ "*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*"
991+ + self ._build_repeated_tool_call_guidance (
992+ func_tool_name , tool_call_streak
993+ ),
907994 )
908995
909996 try :
@@ -921,7 +1008,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
9211008 logger .warning (traceback .format_exc ())
9221009 _append_tool_call_result (
9231010 func_tool_id ,
924- f"error: { e !s} " ,
1011+ f"error: { e !s} "
1012+ + self ._build_repeated_tool_call_guidance (
1013+ func_tool_name , tool_call_streak
1014+ ),
9251015 )
9261016
9271017 # yield the last tool call result
@@ -959,11 +1049,8 @@ def _build_tool_requery_context(
9591049 contexts .append (msg .model_dump ()) # type: ignore[call-arg]
9601050 elif isinstance (msg , dict ):
9611051 contexts .append (copy .deepcopy (msg ))
962- instruction = (
963- "You have decided to call tool(s): "
964- + ", " .join (tool_names )
965- + ". Now call the tool(s) with required arguments using the tool schema, "
966- "and follow the existing tool-use rules."
1052+ instruction = self .SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE .format (
1053+ tool_names = ", " .join (tool_names )
9671054 )
9681055 if contexts and contexts [0 ].get ("role" ) == "system" :
9691056 content = contexts [0 ].get ("content" ) or ""
@@ -1014,6 +1101,32 @@ async def _resolve_tool_exec(
10141101 if requery_resp :
10151102 llm_resp = requery_resp
10161103
1104+ # If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
1105+ # we consider it as a failure of the LLM to follow the tool-use instruction,
1106+ # and we will retry once with a stronger instruction that explicitly requires the LLM to either call the tool or give an explanation.
1107+ if (
1108+ not llm_resp .tools_call_name
1109+ and not self ._has_meaningful_assistant_reply (llm_resp )
1110+ ):
1111+ logger .warning (
1112+ "skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction."
1113+ )
1114+ repair_contexts = self ._build_tool_requery_context (
1115+ tool_names ,
1116+ extra_instruction = self .SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION ,
1117+ )
1118+ repair_resp = await self .provider .text_chat (
1119+ contexts = repair_contexts ,
1120+ func_tool = param_subset ,
1121+ model = self .req .model ,
1122+ session_id = self .req .session_id ,
1123+ extra_user_content_parts = self .req .extra_user_content_parts ,
1124+ tool_choice = "required" ,
1125+ abort_signal = self ._abort_signal ,
1126+ )
1127+ if repair_resp :
1128+ llm_resp = repair_resp
1129+
10171130 return llm_resp , subset
10181131
10191132 def done (self ) -> bool :
@@ -1042,7 +1155,7 @@ async def _finalize_aborted_step(
10421155 if llm_resp .role != "assistant" :
10431156 llm_resp = LLMResponse (
10441157 role = "assistant" ,
1045- completion_text = USER_INTERRUPTION_MESSAGE ,
1158+ completion_text = self . USER_INTERRUPTION_MESSAGE ,
10461159 )
10471160 self .final_llm_resp = llm_resp
10481161 self ._aborted = True
0 commit comments