Skip to content
Open
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
35 changes: 29 additions & 6 deletions astrbot/core/agent/runners/tool_loop_agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"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_DEFAULT_THRESHOLD = 3
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 "
Expand Down Expand Up @@ -224,6 +222,8 @@ async def reset(
custom_token_counter: TokenCounter | None = None,
custom_compressor: ContextCompressor | None = None,
tool_schema_mode: str | None = "full",
repeated_tool_notice_enabled: bool = True,
repeated_tool_notice_threshold: int = REPEATED_TOOL_NOTICE_DEFAULT_THRESHOLD,
fallback_providers: list[Provider] | None = None,
tool_result_overflow_dir: str | None = None,
read_tool: FunctionTool | None = None,
Expand Down Expand Up @@ -280,6 +280,12 @@ async def reset(
self._follow_up_seq = 0
self._last_tool_name: str | None = None
self._same_tool_streak = 0
self.repeated_tool_notice_enabled = bool(repeated_tool_notice_enabled)
self.repeated_tool_notice_threshold = (
self._normalize_repeated_tool_notice_threshold(
repeated_tool_notice_threshold
)
)

# These two are used for tool schema mode handling
# We now have two modes:
Expand Down Expand Up @@ -666,17 +672,34 @@ def _track_tool_call_streak(self, tool_name: str) -> int:
self._same_tool_streak = 1
return self._same_tool_streak

@classmethod
def _normalize_repeated_tool_notice_threshold(cls, value: T.Any) -> int:
if isinstance(value, bool):
return cls.REPEATED_TOOL_NOTICE_DEFAULT_THRESHOLD
try:
threshold = int(value)
except (TypeError, ValueError):
return cls.REPEATED_TOOL_NOTICE_DEFAULT_THRESHOLD
return max(1, threshold)

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

l1_threshold = self.repeated_tool_notice_threshold
l2_threshold = l1_threshold + 1
l3_threshold = l1_threshold + 2

if streak < l1_threshold:
return ""

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

if streak >= self.REPEATED_TOOL_NOTICE_L2_THRESHOLD:
if streak >= l2_threshold:
return self.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format(
tool_name=tool_name,
streak=streak,
Expand Down
6 changes: 3 additions & 3 deletions astrbot/core/astr_agent_tool_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,11 +538,11 @@ async def _wake_main_agent_for_background_result(
message_type=session.message_type,
)
cron_event.role = event.role
provider_settings = ctx.get_config().get("provider_settings", {})
config = MainAgentBuildConfig(
tool_call_timeout=run_context.tool_call_timeout,
streaming_response=ctx.get_config()
.get("provider_settings", {})
.get("stream", False),
streaming_response=provider_settings.get("stream", False),
provider_settings=provider_settings,
)

req = ProviderRequest()
Expand Down
34 changes: 34 additions & 0 deletions astrbot/core/astr_main_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ class MainAgentBuildConfig:
"""
tool_schema_mode: str = "full"
"""The tool schema mode, can be 'full' or 'skills-like'."""
repeated_tool_notice_enabled: bool = True
"""Whether to inject SYSTEM NOTICE when the same tool is called repeatedly."""
repeated_tool_notice_threshold: int = 3
"""The consecutive same-tool call count that triggers the first notice."""
provider_wake_prefix: str = ""
"""The wake prefix for the provider. If the user message does not start with this prefix,
the main agent will not be triggered."""
Expand Down Expand Up @@ -1262,6 +1266,29 @@ def _get_fallback_chat_providers(
return fallbacks


def _resolve_repeated_tool_notice_config(
config: MainAgentBuildConfig,
) -> tuple[bool, int]:
provider_settings = config.provider_settings
if not isinstance(provider_settings, dict):
return (
config.repeated_tool_notice_enabled,
config.repeated_tool_notice_threshold,
)

notice_cfg = provider_settings.get("repeated_tool_call_notice")
if not isinstance(notice_cfg, dict):
return (
config.repeated_tool_notice_enabled,
config.repeated_tool_notice_threshold,
)

return (
bool(notice_cfg.get("enable", config.repeated_tool_notice_enabled)),
notice_cfg.get("threshold", config.repeated_tool_notice_threshold),
)


def _provider_supports_modality(provider: Provider, modality: str) -> bool:
modalities = provider.provider_config.get("modalities", None)
if modalities == []:
Expand Down Expand Up @@ -1553,6 +1580,11 @@ async def build_main_agent(
if event.get_platform_name() == "webchat":
asyncio.create_task(_handle_webchat(event, req, provider))

(
repeated_tool_notice_enabled,
repeated_tool_notice_threshold,
) = _resolve_repeated_tool_notice_config(config)

if req.func_tool and req.func_tool.tools:
tool_prompt = (
TOOL_CALL_PROMPT
Expand Down Expand Up @@ -1592,6 +1624,8 @@ async def build_main_agent(
truncate_turns=config.dequeue_context_length,
enforce_max_turns=config.max_context_length,
tool_schema_mode=config.tool_schema_mode,
repeated_tool_notice_enabled=repeated_tool_notice_enabled,
repeated_tool_notice_threshold=repeated_tool_notice_threshold,
fallback_providers=fallback_providers,
tool_result_overflow_dir=(
get_astrbot_system_tmp_path()
Expand Down
32 changes: 32 additions & 0 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,10 @@
"max_agent_step": 30,
"tool_call_timeout": 120,
"tool_schema_mode": "full",
"repeated_tool_call_notice": {
"enable": True,
"threshold": 3,
},
"llm_safety_mode": True,
"safety_mode_strategy": "system_prompt", # TODO: llm judge
"file_extract": {
Expand Down Expand Up @@ -2880,6 +2884,17 @@
"tool_schema_mode": {
"type": "string",
},
"repeated_tool_call_notice": {
"type": "object",
"items": {
"enable": {
"type": "bool",
},
"threshold": {
"type": "int",
},
},
},
"file_extract": {
"type": "object",
"items": {
Expand Down Expand Up @@ -3748,6 +3763,23 @@
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.repeated_tool_call_notice.enable": {
"description": "连续工具调用提醒",
"type": "bool",
"hint": "开启后,当模型连续调用同一个工具达到阈值时,会向模型注入 SYSTEM NOTICE,提醒其检查是否陷入重复调用。",
"condition": {
"provider_settings.agent_runner_type": "local",
},
},
"provider_settings.repeated_tool_call_notice.threshold": {
"description": "触发连续调用提醒的次数",
"type": "int",
"hint": "同一个工具连续调用达到该次数时,开始提醒LLM,默认 3",
"condition": {
"provider_settings.agent_runner_type": "local",
"provider_settings.repeated_tool_call_notice.enable": True,
},
},
"provider_settings.wake_prefix": {
"description": "LLM 聊天额外唤醒前缀 ",
"type": "string",
Expand Down
6 changes: 3 additions & 3 deletions astrbot/core/cron/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -337,13 +337,13 @@ async def _woke_main_agent(
if cron_payload.get("origin", "tool") == "api":
cron_event.role = "admin"

tool_call_timeout = cfg.get("provider_settings", {}).get(
"tool_call_timeout", 120
)
provider_settings = cfg.get("provider_settings", {})
tool_call_timeout = provider_settings.get("tool_call_timeout", 120)
config = MainAgentBuildConfig(
tool_call_timeout=tool_call_timeout,
llm_safety_mode=False,
streaming_response=False,
provider_settings=provider_settings,
)
req = ProviderRequest()
conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx)
Expand Down
26 changes: 26 additions & 0 deletions astrbot/core/star/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,32 @@ async def tool_loop_agent(
for k, v in kwargs.items()
if k not in ["stream", "agent_hooks", "agent_context"]
}

# 从 provider_settings 注入「连续工具调用提醒」的默认配置,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider extracting a shared helper to resolve the repeated-tool notice config once and then apply it via setdefault on other_kwargs to avoid duplicated logic and branching.

You can reduce duplication and branching here by reusing a single resolver and then applying setdefault on other_kwargs, instead of inlining the full resolution logic again.

For example, extract a helper (or reuse the semantics of _resolve_repeated_tool_notice_config) that normalizes provider_settings and returns (enabled, threshold):

# shared helper, e.g. near _resolve_repeated_tool_notice_config
def _resolve_repeated_tool_notice_from_provider_settings(provider_settings) -> tuple[bool | None, int | None]:
    if not isinstance(provider_settings, dict):
        return None, None

    notice_cfg = provider_settings.get("repeated_tool_call_notice")
    if not isinstance(notice_cfg, dict):
        return None, None

    enabled = bool(notice_cfg.get("enable", True))
    threshold = notice_cfg.get("threshold", 3)
    return enabled, threshold

Then the call site becomes simpler and easier to maintain:

other_kwargs = {
    k: v
    for k, v in kwargs.items()
    if k not in ["stream", "agent_hooks", "agent_context"]
}

try:
    cfg = self.get_config(umo=event.unified_msg_origin)
    provider_settings = cfg.get("provider_settings", {})
except Exception:
    provider_settings = {}

enabled, threshold = _resolve_repeated_tool_notice_from_provider_settings(provider_settings)

if enabled is not None:
    other_kwargs.setdefault("repeated_tool_notice_enabled", enabled)
if threshold is not None:
    other_kwargs.setdefault("repeated_tool_notice_threshold", threshold)

This keeps all behavior (including defaults and get_config error handling) but:

  • Removes the duplicated dict normalization / isinstance checks.
  • Encapsulates the config semantics in a single helper shared with other call sites.
  • Flattens the branching: always resolve once, then setdefault each kwarg independently.

# 仅当调用者没有显式通过 kwargs 覆盖时生效。
# 字段命名与 ToolLoopAgentRunner.reset() / MainAgentBuildConfig 保持一致。
if (
"repeated_tool_notice_enabled" not in other_kwargs
or "repeated_tool_notice_threshold" not in other_kwargs
):
try:
provider_settings = self.get_config(umo=event.unified_msg_origin).get(
"provider_settings", {}
)
except Exception:
provider_settings = {}
if not isinstance(provider_settings, dict):
provider_settings = {}
notice_cfg = provider_settings.get("repeated_tool_call_notice")
if isinstance(notice_cfg, dict):
if "repeated_tool_notice_enabled" not in other_kwargs:
other_kwargs["repeated_tool_notice_enabled"] = bool(
notice_cfg.get("enable", True)
)
if "repeated_tool_notice_threshold" not in other_kwargs:
other_kwargs["repeated_tool_notice_threshold"] = notice_cfg.get(
"threshold", 3
)
if request.func_tool and request.func_tool.get_tool("astrbot_file_read_tool"):
other_kwargs.setdefault(
"tool_result_overflow_dir", get_astrbot_system_tmp_path()
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/i18n/locales/en-US/features/config-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,16 @@
"Full schema"
]
},
"repeated_tool_call_notice": {
"enable": {
"description": "Repeated Tool Call Notice",
"hint": "When enabled, a SYSTEM NOTICE is injected to the model once it calls the same tool consecutively past the threshold, reminding it to check whether it is stuck in a loop."
},
"threshold": {
"description": "Trigger Threshold for Repeated Tool Calls",
"hint": "Number of consecutive calls to the same tool required before the notice is injected. Defaults to 3."
}
},
"streaming_response": {
"description": "Streaming Output"
},
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/i18n/locales/ru-RU/features/config-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,16 @@
"Полная схема (Full)"
]
},
"repeated_tool_call_notice": {
"enable": {
"description": "Уведомление о повторных вызовах инструментов",
"hint": "Если включено, когда модель вызывает один и тот же инструмент подряд больше заданного порога, ей отправляется SYSTEM NOTICE с напоминанием проверить, не зациклилась ли она."
},
"threshold": {
"description": "Порог срабатывания уведомления о повторных вызовах",
"hint": "Количество подряд идущих вызовов одного инструмента, после которого отправляется уведомление. По умолчанию 3."
}
},
"streaming_response": {
"description": "Потоковый вывод (Streaming)"
},
Expand Down
10 changes: 10 additions & 0 deletions dashboard/src/i18n/locales/zh-CN/features/config-metadata.json
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,16 @@
"Full(完整参数)"
]
},
"repeated_tool_call_notice": {
"enable": {
"description": "连续工具调用提醒",
"hint": "当模型连续调用同一个工具达到阈值时,会向模型注入 SYSTEM NOTICE,提醒其检查是否陷入重复调用。"
},
"threshold": {
"description": "触发连续调用提醒的次数",
"hint": "同一个工具连续调用达到该次数时开始提醒。默认 3。"
}
},
"streaming_response": {
"description": "流式输出"
},
Expand Down
Loading
Loading