Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 107 additions & 31 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,16 +95,62 @@ class _ToolExecutionInterrupted(Exception):

ToolExecutorResultT = T.TypeVar("ToolExecutorResultT")

USER_INTERRUPTION_MESSAGE = (
"[SYSTEM: User actively interrupted the response generation. "
"Partial output before interruption is preserved.]"
)


class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
EMPTY_OUTPUT_RETRY_ATTEMPTS = 3
EMPTY_OUTPUT_RETRY_WAIT_MIN_S = 1
EMPTY_OUTPUT_RETRY_WAIT_MAX_S = 4
USER_INTERRUPTION_MESSAGE = (
"[SYSTEM: User actively interrupted the response generation. "
"Partial output before interruption is preserved.]"
)
FOLLOW_UP_NOTICE_TEMPLATE = (
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
"was in progress. Prioritize these follow-up instructions in your next "
"actions. In your very next action, briefly acknowledge to the user "
"that their follow-up message(s) were received before continuing.\n"
"{follow_up_lines}"
)
MAX_STEPS_REACHED_PROMPT = (
"Maximum tool call limit reached. "
"Stop calling tools, and based on the information you have gathered, "
"summarize your task and findings, and reply to the user directly."
)
SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE = (
"You have decided to call tool(s): {tool_names}. Now call the tool(s) "
"with required arguments using the tool schema, and follow the existing "
"tool-use rules."
)
SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION = (
"This is the second-stage tool execution step. "
"You must do exactly one of the following: "
"1. Call one of the selected tools using the provided tool schema. "
"2. If calling a tool is no longer possible or appropriate, reply to the user "
"with a brief explanation of why. "
"Do not return an empty response. "
"Do not ignore the selected tools without explanation."
)
REPEATED_TOOL_NOTICE_L1_THRESHOLD = 3
REPEATED_TOOL_NOTICE_L2_THRESHOLD = 4
REPEATED_TOOL_NOTICE_L3_THRESHOLD = 5
REPEATED_TOOL_NOTICE_L1_TEMPLATE = (
"\n\n[SYSTEM NOTICE] By the way, you have executed the same tool "
"`{tool_name}` {streak} times consecutively. Double-check whether another "
"tool, different arguments, or a summary would move the task forward better."
)
REPEATED_TOOL_NOTICE_L2_TEMPLATE = (
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
"`{tool_name}` {streak} times consecutively. Unless this repetition is "
"clearly necessary, stop repeating the same action and either switch "
"tools, refine parameters, or summarize what is still missing."
)
REPEATED_TOOL_NOTICE_L3_TEMPLATE = (
"\n\n[SYSTEM NOTICE] Important: you have executed the same tool "
"`{tool_name}` {streak} times consecutively. Repetition is now very "
"high. Continue only if each call is clearly producing new information. "
"Otherwise, change strategy, adjust arguments, or explain the limitation "
"to the user."
)

def _get_persona_custom_error_message(self) -> str | None:
"""Read persona-level custom error message from event extras when available."""
Expand Down Expand Up @@ -209,6 +255,8 @@ async def reset(
self._abort_signal = asyncio.Event()
self._pending_follow_ups: list[FollowUpTicket] = []
self._follow_up_seq = 0
self._last_tool_name: str | None = None
self._same_tool_streak = 0

# These two are used for tool schema mode handling
# We now have two modes:
Expand Down Expand Up @@ -413,12 +461,8 @@ def _consume_follow_up_notice(self) -> str:
follow_up_lines = "\n".join(
f"{idx}. {ticket.text}" for idx, ticket in enumerate(follow_ups, start=1)
)
return (
"\n\n[SYSTEM NOTICE] User sent follow-up messages while tool execution "
"was in progress. Prioritize these follow-up instructions in your next "
"actions. In your very next action, briefly acknowledge to the user "
"that their follow-up message(s) were received before continuing.\n"
f"{follow_up_lines}"
return self.FOLLOW_UP_NOTICE_TEMPLATE.format(
follow_up_lines=follow_up_lines,
)

def _merge_follow_up_notice(self, content: str) -> str:
Expand All @@ -427,6 +471,35 @@ def _merge_follow_up_notice(self, content: str) -> str:
return content
return f"{content}{notice}"

def _track_tool_call_streak(self, tool_name: str) -> int:
if tool_name == self._last_tool_name:
self._same_tool_streak += 1
else:
self._last_tool_name = tool_name
self._same_tool_streak = 1
return self._same_tool_streak

def _build_repeated_tool_call_guidance(self, tool_name: str, streak: int) -> str:
if streak < self.REPEATED_TOOL_NOTICE_L1_THRESHOLD:
return ""

if streak >= self.REPEATED_TOOL_NOTICE_L3_THRESHOLD:
return self.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format(
tool_name=tool_name,
streak=streak,
)

if streak >= self.REPEATED_TOOL_NOTICE_L2_THRESHOLD:
return self.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format(
tool_name=tool_name,
streak=streak,
)

return self.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format(
tool_name=tool_name,
streak=streak,
)

@override
async def step(self):
"""Process a single step of the agent.
Expand Down Expand Up @@ -483,7 +556,7 @@ async def step(self):
if self._is_stop_requested():
llm_resp_result = LLMResponse(
role="assistant",
completion_text=USER_INTERRUPTION_MESSAGE,
completion_text=self.USER_INTERRUPTION_MESSAGE,
reasoning_content=llm_response.reasoning_content,
reasoning_signature=llm_response.reasoning_signature,
)
Expand Down Expand Up @@ -681,7 +754,7 @@ async def step_until_done(
self.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
content=self.MAX_STEPS_REACHED_PROMPT,
)
)
# 再执行最后一步
Expand Down Expand Up @@ -712,6 +785,7 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
tool_call_streak = self._track_tool_call_streak(func_tool_name)
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
type="tool_call",
Expand Down Expand Up @@ -861,7 +935,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
if result_parts:
_append_tool_call_result(
func_tool_id,
"\n\n".join(result_parts),
"\n\n".join(result_parts)
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
),
)

elif resp is None:
Expand All @@ -875,7 +952,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
self.stats.end_time = time.time()
_append_tool_call_result(
func_tool_id,
"The tool has no return value, or has sent the result directly to the user.",
"The tool has no return value, or has sent the result directly to the user."
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
),
)
else:
# 不应该出现其他类型
Expand All @@ -884,7 +964,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
)
_append_tool_call_result(
func_tool_id,
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*",
"*The tool has returned an unsupported type. Please tell the user to check the definition and implementation of this tool.*"
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
),
)

try:
Expand All @@ -902,7 +985,10 @@ def _append_tool_call_result(tool_call_id: str, content: str) -> None:
logger.warning(traceback.format_exc())
_append_tool_call_result(
func_tool_id,
f"error: {e!s}",
f"error: {e!s}"
+ self._build_repeated_tool_call_guidance(
func_tool_name, tool_call_streak
),
)

# yield the last tool call result
Expand Down Expand Up @@ -942,11 +1028,8 @@ def _build_tool_requery_context(
contexts.append(msg.model_dump()) # type: ignore[call-arg]
elif isinstance(msg, dict):
contexts.append(copy.deepcopy(msg))
instruction = (
"You have decided to call tool(s): "
+ ", ".join(tool_names)
+ ". Now call the tool(s) with required arguments using the tool schema, "
"and follow the existing tool-use rules."
instruction = self.SKILLS_LIKE_REQUERY_INSTRUCTION_TEMPLATE.format(
tool_names=", ".join(tool_names)
)
if extra_instruction:
instruction = f"{instruction}\n{extra_instruction}"
Expand Down Expand Up @@ -1017,14 +1100,7 @@ async def _resolve_tool_exec(
)
repair_contexts = self._build_tool_requery_context(
tool_names,
extra_instruction=(
"This is the second-stage tool execution step. "
"You must do exactly one of the following: "
"1. Call one of the selected tools using the provided tool schema. "
"2. If calling a tool is no longer possible or appropriate, reply to the user with a brief explanation of why. "
"Do not return an empty response. "
"Do not ignore the selected tools without explanation."
),
extra_instruction=self.SKILLS_LIKE_REQUERY_REPAIR_INSTRUCTION,
)
repair_resp = await self.provider.text_chat(
contexts=repair_contexts,
Expand Down Expand Up @@ -1066,7 +1142,7 @@ async def _finalize_aborted_step(
if llm_resp.role != "assistant":
llm_resp = LLMResponse(
role="assistant",
completion_text=USER_INTERRUPTION_MESSAGE,
completion_text=self.USER_INTERRUPTION_MESSAGE,
)
self.final_llm_resp = llm_resp
self._aborted = True
Expand Down
1 change: 0 additions & 1 deletion dashboard/src/components/provider/ProviderSourcesPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,6 @@ const emitDeleteSource = (source) => emit('delete-provider-source', source)
}

.provider-source-list {
max-height: calc(100vh - 335px);
overflow-y: auto;
padding: 0;
background: transparent;
Expand Down
Loading
Loading