Skip to content

Commit 2389f27

Browse files
authored
Merge branch 'master' into master
2 parents ad34dce + afa43fc commit 2389f27

49 files changed

Lines changed: 1853 additions & 382 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

astrbot/builtin_stars/session_controller/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,8 @@ async def empty_mention_waiter(
9191
controller: SessionController,
9292
event: AstrMessageEvent,
9393
) -> None:
94+
if not event.message_str or not event.message_str.strip():
95+
return
9496
event.message_obj.message.insert(
9597
0,
9698
Comp.At(qq=event.get_self_id(), name=event.get_self_id()),

astrbot/cli/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "4.22.1"
1+
__version__ = "4.22.2"

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 153 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,18 @@
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

2026
from astrbot import logger
2127
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
2228
from astrbot.core.agent.tool import ToolSet
2329
from astrbot.core.agent.tool_image_cache import tool_image_cache
30+
from astrbot.core.exceptions import EmptyModelOutputError
2431
from astrbot.core.message.components import Json
2532
from astrbot.core.message.message_event_result import (
2633
MessageChain,
@@ -95,11 +102,41 @@ class _ToolExecutionInterrupted(Exception):
95102

96103

97104
class 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:

astrbot/core/config/default.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
77

8-
VERSION = "4.22.0"
8+
VERSION = "4.22.2"
99
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
1010
PERSONAL_WECHAT_CONFIG_METADATA = {
1111
"weixin_oc_base_url": {
@@ -1632,10 +1632,14 @@ class ChatProviderTemplate(TypedDict):
16321632
"type": "gsvi_tts_api",
16331633
"provider": "gpt_sovits_inference",
16341634
"provider_type": "text_to_speech",
1635-
"api_base": "http://127.0.0.1:5000",
1636-
"character": "",
1637-
"emotion": "default",
16381635
"enable": False,
1636+
"api_key": "",
1637+
"api_base": "http://127.0.0.1:8000",
1638+
"version": "v4",
1639+
"character": "",
1640+
"prompt_text_lang": "中文",
1641+
"emotion": "默认",
1642+
"text_lang": "中文",
16391643
"timeout": 20,
16401644
},
16411645
"FishAudio TTS(API)": {

astrbot/core/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,7 @@ class AstrBotError(Exception):
77

88
class ProviderNotFoundError(AstrBotError):
99
"""Raised when a specified provider is not found."""
10+
11+
12+
class EmptyModelOutputError(AstrBotError):
13+
"""Raised when the model response contains no usable assistant output."""

astrbot/core/pipeline/preprocess_stage/stage.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ async def process(
7676
return
7777
message_chain = event.get_messages()
7878
for idx, component in enumerate(message_chain):
79-
if isinstance(component, Record) and component.url:
80-
path = component.url.removeprefix("file://")
79+
if isinstance(component, Record):
80+
path = await component.convert_to_file_path()
8181
retry = 5
8282
for i in range(retry):
8383
try:

astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ async def process(
144144
follow_up_capture: FollowUpCapture | None = None
145145
follow_up_consumed_marked = False
146146
follow_up_activated = False
147+
typing_requested = False
147148
try:
148149
streaming_response = self.streaming_response
149150
if (enable_streaming := event.get_extra("enable_streaming")) is not None:
@@ -178,7 +179,11 @@ async def process(
178179
)
179180
return
180181

181-
await event.send_typing()
182+
try:
183+
typing_requested = True
184+
await event.send_typing()
185+
except Exception:
186+
logger.warning("send_typing failed", exc_info=True)
182187
await call_event_hook(event, EventType.OnWaitingLLMRequestEvent)
183188

184189
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
@@ -377,6 +382,11 @@ async def process(
377382
)
378383
await event.send(MessageChain().message(error_text))
379384
finally:
385+
if typing_requested:
386+
try:
387+
await event.stop_typing()
388+
except Exception:
389+
logger.warning("stop_typing failed", exc_info=True)
380390
if follow_up_capture:
381391
await finalize_follow_up_capture(
382392
follow_up_capture,

astrbot/core/platform/astr_message_event.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,12 @@ async def send_typing(self) -> None:
293293
默认实现为空,由具体平台按需重写。
294294
"""
295295

296+
async def stop_typing(self) -> None:
297+
"""停止输入中状态。
298+
299+
默认实现为空,由具体平台按需重写。
300+
"""
301+
296302
async def _pre_send(self) -> None:
297303
"""调度器会在执行 send() 前调用该方法 deprecated in v3.5.18"""
298304

0 commit comments

Comments
 (0)