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 ,
@@ -150,6 +157,32 @@ def _get_persona_custom_error_message(self) -> str | None:
150157 event = getattr (self .run_context .context , "event" , None )
151158 return extract_persona_custom_error_message_from_event (event )
152159
160+ async def _complete_with_assistant_response (self , llm_resp : LLMResponse ) -> None :
161+ """Finalize the current step as a plain assistant response with no tool calls."""
162+ self .final_llm_resp = llm_resp
163+ self ._transition_state (AgentState .DONE )
164+ self .stats .end_time = time .time ()
165+
166+ parts = []
167+ if llm_resp .reasoning_content or llm_resp .reasoning_signature :
168+ parts .append (
169+ ThinkPart (
170+ think = llm_resp .reasoning_content ,
171+ encrypted = llm_resp .reasoning_signature ,
172+ )
173+ )
174+ if llm_resp .completion_text :
175+ parts .append (TextPart (text = llm_resp .completion_text ))
176+ if len (parts ) == 0 :
177+ logger .warning ("LLM returned empty assistant message with no tool calls." )
178+ self .run_context .messages .append (Message (role = "assistant" , content = parts ))
179+
180+ try :
181+ await self .agent_hooks .on_agent_done (self .run_context , llm_resp )
182+ except Exception as e :
183+ logger .error (f"Error in on_agent_done hook: { e } " , exc_info = True )
184+ self ._resolve_unconsumed_follow_ups ()
185+
153186 @override
154187 async def reset (
155188 self ,
@@ -305,31 +338,61 @@ async def _iter_llm_responses_with_fallback(
305338 candidate_id ,
306339 )
307340 self .provider = candidate
308- has_stream_output = False
309341 try :
310- async for resp in self ._iter_llm_responses (include_model = idx == 0 ):
311- if resp .is_chunk :
312- has_stream_output = True
313- yield resp
314- continue
315-
316- if (
317- resp .role == "err"
318- and not has_stream_output
319- and (not is_last_candidate )
320- ):
321- last_err_response = resp
322- logger .warning (
323- "Chat Model %s returns error response, trying fallback to next provider." ,
324- candidate_id ,
325- )
326- break
327-
328- yield resp
329- return
342+ retrying = AsyncRetrying (
343+ retry = retry_if_exception_type (EmptyModelOutputError ),
344+ stop = stop_after_attempt (self .EMPTY_OUTPUT_RETRY_ATTEMPTS ),
345+ wait = wait_exponential (
346+ multiplier = 1 ,
347+ min = self .EMPTY_OUTPUT_RETRY_WAIT_MIN_S ,
348+ max = self .EMPTY_OUTPUT_RETRY_WAIT_MAX_S ,
349+ ),
350+ reraise = True ,
351+ )
330352
331- if has_stream_output :
332- return
353+ async for attempt in retrying :
354+ has_stream_output = False
355+ with attempt :
356+ try :
357+ async for resp in self ._iter_llm_responses (
358+ include_model = idx == 0
359+ ):
360+ if resp .is_chunk :
361+ has_stream_output = True
362+ yield resp
363+ continue
364+
365+ if (
366+ resp .role == "err"
367+ and not has_stream_output
368+ and (not is_last_candidate )
369+ ):
370+ last_err_response = resp
371+ logger .warning (
372+ "Chat Model %s returns error response, trying fallback to next provider." ,
373+ candidate_id ,
374+ )
375+ break
376+
377+ yield resp
378+ return
379+
380+ if has_stream_output :
381+ return
382+ except EmptyModelOutputError :
383+ if has_stream_output :
384+ logger .warning (
385+ "Chat Model %s returned empty output after streaming started; skipping empty-output retry." ,
386+ candidate_id ,
387+ )
388+ else :
389+ logger .warning (
390+ "Chat Model %s returned empty output on attempt %s/%s." ,
391+ candidate_id ,
392+ attempt .retry_state .attempt_number ,
393+ self .EMPTY_OUTPUT_RETRY_ATTEMPTS ,
394+ )
395+ raise
333396 except Exception as exc : # noqa: BLE001
334397 last_exception = exc
335398 logger .warning (
@@ -540,35 +603,7 @@ async def step(self):
540603 return
541604
542605 if not llm_resp .tools_call_name :
543- # 如果没有工具调用,转换到完成状态
544- self .final_llm_resp = llm_resp
545- self ._transition_state (AgentState .DONE )
546- self .stats .end_time = time .time ()
547-
548- # record the final assistant message
549- parts = []
550-
551- if llm_resp .reasoning_content or llm_resp .reasoning_signature :
552- parts .append (
553- ThinkPart (
554- think = llm_resp .reasoning_content ,
555- encrypted = llm_resp .reasoning_signature ,
556- )
557- )
558- if llm_resp .completion_text :
559- parts .append (TextPart (text = llm_resp .completion_text ))
560- if len (parts ) == 0 :
561- logger .warning (
562- "LLM returned empty assistant message with no tool calls."
563- )
564- self .run_context .messages .append (Message (role = "assistant" , content = parts ))
565-
566- # call the on_agent_done hook
567- try :
568- await self .agent_hooks .on_agent_done (self .run_context , llm_resp )
569- except Exception as e :
570- logger .error (f"Error in on_agent_done hook: { e } " , exc_info = True )
571- self ._resolve_unconsumed_follow_ups ()
606+ await self ._complete_with_assistant_response (llm_resp )
572607
573608 # 返回 LLM 结果
574609 if llm_resp .result_chain :
@@ -588,6 +623,24 @@ async def step(self):
588623 if llm_resp .tools_call_name :
589624 if self .tool_schema_mode == "skills_like" :
590625 llm_resp , _ = await self ._resolve_tool_exec (llm_resp )
626+ if not llm_resp .tools_call_name :
627+ logger .warning (
628+ "skills_like tool re-query returned no tool calls; fallback to assistant response."
629+ )
630+ if llm_resp .result_chain :
631+ yield AgentResponse (
632+ type = "llm_result" ,
633+ data = AgentResponseData (chain = llm_resp .result_chain ),
634+ )
635+ elif llm_resp .completion_text :
636+ yield AgentResponse (
637+ type = "llm_result" ,
638+ data = AgentResponseData (
639+ chain = MessageChain ().message (llm_resp .completion_text ),
640+ ),
641+ )
642+ await self ._complete_with_assistant_response (llm_resp )
643+ return
591644
592645 tool_call_result_blocks = []
593646 cached_images = [] # Collect cached images for LLM visibility
@@ -1040,7 +1093,9 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
10401093 )
10411094
10421095 def _build_tool_requery_context (
1043- self , tool_names : list [str ]
1096+ self ,
1097+ tool_names : list [str ],
1098+ extra_instruction : str | None = None ,
10441099 ) -> list [dict [str , T .Any ]]:
10451100 """Build contexts for re-querying LLM with param-only tool schemas."""
10461101 contexts : list [dict [str , T .Any ]] = []
@@ -1052,13 +1107,20 @@ def _build_tool_requery_context(
10521107 instruction = self .SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE .format (
10531108 tool_names = ", " .join (tool_names )
10541109 )
1110+ if extra_instruction :
1111+ instruction = f"{ instruction } \n { extra_instruction } "
10551112 if contexts and contexts [0 ].get ("role" ) == "system" :
10561113 content = contexts [0 ].get ("content" ) or ""
10571114 contexts [0 ]["content" ] = f"{ content } \n { instruction } "
10581115 else :
10591116 contexts .insert (0 , {"role" : "system" , "content" : instruction })
10601117 return contexts
10611118
1119+ @staticmethod
1120+ def _has_meaningful_assistant_reply (llm_resp : LLMResponse ) -> bool :
1121+ text = (llm_resp .completion_text or "" ).strip ()
1122+ return bool (text )
1123+
10621124 def _build_tool_subset (self , tool_set : ToolSet , tool_names : list [str ]) -> ToolSet :
10631125 """Build a subset of tools from the given tool set based on tool names."""
10641126 subset = ToolSet ()
@@ -1096,6 +1158,7 @@ async def _resolve_tool_exec(
10961158 model = self .req .model ,
10971159 session_id = self .req .session_id ,
10981160 extra_user_content_parts = self .req .extra_user_content_parts ,
1161+ tool_choice = "required" ,
10991162 abort_signal = self ._abort_signal ,
11001163 )
11011164 if requery_resp :
0 commit comments