Skip to content

Commit a579ac3

Browse files
authored
Merge branch 'master' into master
2 parents 06cb7ea + 8c6c00a commit a579ac3

57 files changed

Lines changed: 2494 additions & 149 deletions

Some content is hidden

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

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,7 @@ Connect AstrBot to your favorite chat platform.
157157
| LINE | Official |
158158
| Satori | Official |
159159
| Misskey | Official |
160+
| Mattermost | Official |
160161
| WhatsApp (Coming Soon) | Official |
161162
| [Matrix](https://github.com/stevessr/astrbot_plugin_matrix_adapter) | Community |
162163
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | Community |

astrbot/cli/__init__.py

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

astrbot/core/agent/runners/tool_loop_agent_runner.py

Lines changed: 136 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -88,13 +88,63 @@ class _ToolExecutionInterrupted(Exception):
8888

8989
ToolExecutorResultT = 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

9792
class 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

Comments
 (0)