1616 TextContent ,
1717 TextResourceContents ,
1818)
19+ from tenacity import (
20+ AsyncRetrying ,
21+ retry_if_exception_type ,
22+ stop_after_attempt ,
23+ wait_exponential ,
24+ )
1925
2026from astrbot import logger
2127from astrbot .core .agent .message import ImageURLPart , TextPart , ThinkPart
2228from astrbot .core .agent .tool import ToolSet
2329from astrbot .core .agent .tool_image_cache import tool_image_cache
30+ from astrbot .core .exceptions import EmptyModelOutputError
2431from astrbot .core .message .components import Json
2532from astrbot .core .message .message_event_result import (
2633 MessageChain ,
@@ -95,11 +102,41 @@ class _ToolExecutionInterrupted(Exception):
95102
96103
97104class ToolLoopAgentRunner (BaseAgentRunner [TContext ]):
105+ EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
106+ EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
107+ EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
108+
98109 def _get_persona_custom_error_message (self ) -> str | None :
99110 """Read persona-level custom error message from event extras when available."""
100111 event = getattr (self .run_context .context , "event" , None )
101112 return extract_persona_custom_error_message_from_event (event )
102113
114+ async def _complete_with_assistant_response (self , llm_resp : LLMResponse ) -> None :
115+ """Finalize the current step as a plain assistant response with no tool calls."""
116+ self .final_llm_resp = llm_resp
117+ self ._transition_state (AgentState .DONE )
118+ self .stats .end_time = time .time ()
119+
120+ parts = []
121+ if llm_resp .reasoning_content or llm_resp .reasoning_signature :
122+ parts .append (
123+ ThinkPart (
124+ think = llm_resp .reasoning_content ,
125+ encrypted = llm_resp .reasoning_signature ,
126+ )
127+ )
128+ if llm_resp .completion_text :
129+ parts .append (TextPart (text = llm_resp .completion_text ))
130+ if len (parts ) == 0 :
131+ logger .warning ("LLM returned empty assistant message with no tool calls." )
132+ self .run_context .messages .append (Message (role = "assistant" , content = parts ))
133+
134+ try :
135+ await self .agent_hooks .on_agent_done (self .run_context , llm_resp )
136+ except Exception as e :
137+ logger .error (f"Error in on_agent_done hook: { e } " , exc_info = True )
138+ self ._resolve_unconsumed_follow_ups ()
139+
103140 @override
104141 async def reset (
105142 self ,
@@ -253,31 +290,61 @@ async def _iter_llm_responses_with_fallback(
253290 candidate_id ,
254291 )
255292 self .provider = candidate
256- has_stream_output = False
257293 try :
258- async for resp in self ._iter_llm_responses (include_model = idx == 0 ):
259- if resp .is_chunk :
260- has_stream_output = True
261- yield resp
262- continue
263-
264- if (
265- resp .role == "err"
266- and not has_stream_output
267- and (not is_last_candidate )
268- ):
269- last_err_response = resp
270- logger .warning (
271- "Chat Model %s returns error response, trying fallback to next provider." ,
272- candidate_id ,
273- )
274- break
275-
276- yield resp
277- return
294+ retrying = AsyncRetrying (
295+ retry = retry_if_exception_type (EmptyModelOutputError ),
296+ stop = stop_after_attempt (self .EMPTY_OUTPUT_RETRY_ATTEMPTS ),
297+ wait = wait_exponential (
298+ multiplier = 1 ,
299+ min = self .EMPTY_OUTPUT_RETRY_WAIT_MIN_S ,
300+ max = self .EMPTY_OUTPUT_RETRY_WAIT_MAX_S ,
301+ ),
302+ reraise = True ,
303+ )
278304
279- if has_stream_output :
280- return
305+ async for attempt in retrying :
306+ has_stream_output = False
307+ with attempt :
308+ try :
309+ async for resp in self ._iter_llm_responses (
310+ include_model = idx == 0
311+ ):
312+ if resp .is_chunk :
313+ has_stream_output = True
314+ yield resp
315+ continue
316+
317+ if (
318+ resp .role == "err"
319+ and not has_stream_output
320+ and (not is_last_candidate )
321+ ):
322+ last_err_response = resp
323+ logger .warning (
324+ "Chat Model %s returns error response, trying fallback to next provider." ,
325+ candidate_id ,
326+ )
327+ break
328+
329+ yield resp
330+ return
331+
332+ if has_stream_output :
333+ return
334+ except EmptyModelOutputError :
335+ if has_stream_output :
336+ logger .warning (
337+ "Chat Model %s returned empty output after streaming started; skipping empty-output retry." ,
338+ candidate_id ,
339+ )
340+ else :
341+ logger .warning (
342+ "Chat Model %s returned empty output on attempt %s/%s." ,
343+ candidate_id ,
344+ attempt .retry_state .attempt_number ,
345+ self .EMPTY_OUTPUT_RETRY_ATTEMPTS ,
346+ )
347+ raise
281348 except Exception as exc : # noqa: BLE001
282349 last_exception = exc
283350 logger .warning (
@@ -463,34 +530,7 @@ async def step(self):
463530 return
464531
465532 if not llm_resp .tools_call_name :
466- # 如果没有工具调用,转换到完成状态
467- self .final_llm_resp = llm_resp
468- self ._transition_state (AgentState .DONE )
469- self .stats .end_time = time .time ()
470-
471- # record the final assistant message
472- parts = []
473- if llm_resp .reasoning_content or llm_resp .reasoning_signature :
474- parts .append (
475- ThinkPart (
476- think = llm_resp .reasoning_content ,
477- encrypted = llm_resp .reasoning_signature ,
478- )
479- )
480- if llm_resp .completion_text :
481- parts .append (TextPart (text = llm_resp .completion_text ))
482- if len (parts ) == 0 :
483- logger .warning (
484- "LLM returned empty assistant message with no tool calls."
485- )
486- self .run_context .messages .append (Message (role = "assistant" , content = parts ))
487-
488- # call the on_agent_done hook
489- try :
490- await self .agent_hooks .on_agent_done (self .run_context , llm_resp )
491- except Exception as e :
492- logger .error (f"Error in on_agent_done hook: { e } " , exc_info = True )
493- self ._resolve_unconsumed_follow_ups ()
533+ await self ._complete_with_assistant_response (llm_resp )
494534
495535 # 返回 LLM 结果
496536 if llm_resp .result_chain :
@@ -510,6 +550,24 @@ async def step(self):
510550 if llm_resp .tools_call_name :
511551 if self .tool_schema_mode == "skills_like" :
512552 llm_resp , _ = await self ._resolve_tool_exec (llm_resp )
553+ if not llm_resp .tools_call_name :
554+ logger .warning (
555+ "skills_like tool re-query returned no tool calls; fallback to assistant response."
556+ )
557+ if llm_resp .result_chain :
558+ yield AgentResponse (
559+ type = "llm_result" ,
560+ data = AgentResponseData (chain = llm_resp .result_chain ),
561+ )
562+ elif llm_resp .completion_text :
563+ yield AgentResponse (
564+ type = "llm_result" ,
565+ data = AgentResponseData (
566+ chain = MessageChain ().message (llm_resp .completion_text ),
567+ ),
568+ )
569+ await self ._complete_with_assistant_response (llm_resp )
570+ return
513571
514572 tool_call_result_blocks = []
515573 cached_images = [] # Collect cached images for LLM visibility
@@ -949,7 +1007,9 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
9491007 )
9501008
9511009 def _build_tool_requery_context (
952- self , tool_names : list [str ]
1010+ self ,
1011+ tool_names : list [str ],
1012+ extra_instruction : str | None = None ,
9531013 ) -> list [dict [str , T .Any ]]:
9541014 """Build contexts for re-querying LLM with param-only tool schemas."""
9551015 contexts : list [dict [str , T .Any ]] = []
@@ -964,13 +1024,20 @@ def _build_tool_requery_context(
9641024 + ". Now call the tool(s) with required arguments using the tool schema, "
9651025 "and follow the existing tool-use rules."
9661026 )
1027+ if extra_instruction :
1028+ instruction = f"{ instruction } \n { extra_instruction } "
9671029 if contexts and contexts [0 ].get ("role" ) == "system" :
9681030 content = contexts [0 ].get ("content" ) or ""
9691031 contexts [0 ]["content" ] = f"{ content } \n { instruction } "
9701032 else :
9711033 contexts .insert (0 , {"role" : "system" , "content" : instruction })
9721034 return contexts
9731035
1036+ @staticmethod
1037+ def _has_meaningful_assistant_reply (llm_resp : LLMResponse ) -> bool :
1038+ text = (llm_resp .completion_text or "" ).strip ()
1039+ return bool (text )
1040+
9741041 def _build_tool_subset (self , tool_set : ToolSet , tool_names : list [str ]) -> ToolSet :
9751042 """Build a subset of tools from the given tool set based on tool names."""
9761043 subset = ToolSet ()
@@ -1008,11 +1075,45 @@ async def _resolve_tool_exec(
10081075 model = self .req .model ,
10091076 session_id = self .req .session_id ,
10101077 extra_user_content_parts = self .req .extra_user_content_parts ,
1078+ tool_choice = "required" ,
10111079 abort_signal = self ._abort_signal ,
10121080 )
10131081 if requery_resp :
10141082 llm_resp = requery_resp
10151083
1084+ # If the re-query still returns no tool calls, and also does not have a meaningful assistant reply,
1085+ # we consider it as a failure of the LLM to follow the tool-use instruction,
1086+ # and we will retry once with a stronger instruction that explicitly requires the LLM to either call the tool or give an explanation.
1087+ if (
1088+ not llm_resp .tools_call_name
1089+ and not self ._has_meaningful_assistant_reply (llm_resp )
1090+ ):
1091+ logger .warning (
1092+ "skills_like tool re-query returned no tool calls and no explanation; retrying with stronger instruction."
1093+ )
1094+ repair_contexts = self ._build_tool_requery_context (
1095+ tool_names ,
1096+ extra_instruction = (
1097+ "This is the second-stage tool execution step. "
1098+ "You must do exactly one of the following: "
1099+ "1. Call one of the selected tools using the provided tool schema. "
1100+ "2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. "
1101+ "Do not return an empty response. "
1102+ "Do not ignore the selected tools without explanation."
1103+ ),
1104+ )
1105+ repair_resp = await self .provider .text_chat (
1106+ contexts = repair_contexts ,
1107+ func_tool = param_subset ,
1108+ model = self .req .model ,
1109+ session_id = self .req .session_id ,
1110+ extra_user_content_parts = self .req .extra_user_content_parts ,
1111+ tool_choice = "required" ,
1112+ abort_signal = self ._abort_signal ,
1113+ )
1114+ if repair_resp :
1115+ llm_resp = repair_resp
1116+
10161117 return llm_resp , subset
10171118
10181119 def done (self ) -> bool :
0 commit comments