diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 3f74f0ec9b..a1e89fe599 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -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 " @@ -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, @@ -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: @@ -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, diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index de5caad554..e400d4129c 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -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() diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index b1d7416d8b..8e64e636ee 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -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.""" @@ -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 == []: @@ -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 @@ -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() diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index d060ce1c3d..6f65b016e5 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -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": { @@ -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": { @@ -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", diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index fde2ad5cd8..cf4fc8b5db 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -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) diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py index 593bad9365..5ce38c3467 100644 --- a/astrbot/core/star/context.py +++ b/astrbot/core/star/context.py @@ -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 注入「连续工具调用提醒」的默认配置, + # 仅当调用者没有显式通过 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() diff --git a/dashboard/src/i18n/locales/en-US/features/config-metadata.json b/dashboard/src/i18n/locales/en-US/features/config-metadata.json index a79746c9db..6526ad6a21 100644 --- a/dashboard/src/i18n/locales/en-US/features/config-metadata.json +++ b/dashboard/src/i18n/locales/en-US/features/config-metadata.json @@ -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" }, diff --git a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json index b08662b7ba..519c9eb791 100644 --- a/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json +++ b/dashboard/src/i18n/locales/ru-RU/features/config-metadata.json @@ -356,6 +356,16 @@ "Полная схема (Full)" ] }, + "repeated_tool_call_notice": { + "enable": { + "description": "Уведомление о повторных вызовах инструментов", + "hint": "Если включено, когда модель вызывает один и тот же инструмент подряд больше заданного порога, ей отправляется SYSTEM NOTICE с напоминанием проверить, не зациклилась ли она." + }, + "threshold": { + "description": "Порог срабатывания уведомления о повторных вызовах", + "hint": "Количество подряд идущих вызовов одного инструмента, после которого отправляется уведомление. По умолчанию 3." + } + }, "streaming_response": { "description": "Потоковый вывод (Streaming)" }, diff --git a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json index 75ce4fd931..dc7cf7db3e 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json +++ b/dashboard/src/i18n/locales/zh-CN/features/config-metadata.json @@ -358,6 +358,16 @@ "Full(完整参数)" ] }, + "repeated_tool_call_notice": { + "enable": { + "description": "连续工具调用提醒", + "hint": "当模型连续调用同一个工具达到阈值时,会向模型注入 SYSTEM NOTICE,提醒其检查是否陷入重复调用。" + }, + "threshold": { + "description": "触发连续调用提醒的次数", + "hint": "同一个工具连续调用达到该次数时开始提醒。默认 3。" + } + }, "streaming_response": { "description": "流式输出" }, diff --git a/tests/test_tool_loop_agent_runner.py b/tests/test_tool_loop_agent_runner.py index b4464680fb..37c2e6de6e 100644 --- a/tests/test_tool_loop_agent_runner.py +++ b/tests/test_tool_loop_agent_runner.py @@ -823,8 +823,14 @@ async def test_runner_clears_tools_for_provider_without_tool_use( async def test_same_tool_consecutive_results_include_escalating_guidance( runner, mock_tool_executor, mock_hooks ): + """默认配置下,连续调用同一工具应依次注入 L1/L2/L3 三档 SYSTEM NOTICE。 + + 阈值来自 runner.repeated_tool_notice_threshold(默认 3)。 + """ runner_cls = type(runner) - total_calls = runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD + # 显式 reset 之前无法读取实例属性,这里手动传入预期阈值 + expected_threshold = runner_cls.REPEATED_TOOL_NOTICE_DEFAULT_THRESHOLD + total_calls = expected_threshold + 2 # 覆盖 L1 / L2 / L3 三档 provider = SequentialToolProvider(["test_tool"] * total_calls) tool = FunctionTool( name="test_tool", @@ -847,6 +853,11 @@ async def test_same_tool_consecutive_results_include_escalating_guidance( streaming=False, ) + # reset() 后实例属性已规范化,可以从中读取 + threshold = runner.repeated_tool_notice_threshold + assert runner.repeated_tool_notice_enabled is True + assert threshold == expected_threshold + async for _ in runner.step_until_done(total_calls + 1): pass @@ -858,27 +869,27 @@ async def test_same_tool_consecutive_results_include_escalating_guidance( tool_contents = [str(message.content) for message in tool_messages] level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD, + streak=threshold, ) level_2_notice = runner_cls.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD, + streak=threshold + 1, ) level_3_notice = runner_cls.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD, + streak=threshold + 2, ) for streak, content in enumerate(tool_contents, start=1): - if streak < runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD: + if streak < threshold: assert level_1_notice not in content assert level_2_notice not in content assert level_3_notice not in content - elif streak < runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD: + elif streak < threshold + 1: assert level_1_notice in content assert level_2_notice not in content assert level_3_notice not in content - elif streak < runner_cls.REPEATED_TOOL_NOTICE_L3_THRESHOLD: + elif streak < threshold + 2: assert level_1_notice not in content assert level_2_notice in content assert level_3_notice not in content @@ -892,8 +903,10 @@ async def test_same_tool_consecutive_results_include_escalating_guidance( async def test_same_tool_streak_resets_after_switching_tools( runner, mock_tool_executor, mock_hooks ): + """切换工具后,连续调用计数应被重置;阈值由 runner.repeated_tool_notice_threshold 决定。""" runner_cls = type(runner) - repeated_after_reset = runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD + expected_threshold = runner_cls.REPEATED_TOOL_NOTICE_DEFAULT_THRESHOLD + repeated_after_reset = expected_threshold + 1 # 覆盖 L1 + L2 两档 provider = SequentialToolProvider( ["test_tool", "other_tool", *(["test_tool"] * repeated_after_reset)] ) @@ -924,6 +937,9 @@ async def test_same_tool_streak_resets_after_switching_tools( streaming=False, ) + threshold = runner.repeated_tool_notice_threshold + assert threshold == expected_threshold + async for _ in runner.step_until_done(repeated_after_reset + 3): pass @@ -935,11 +951,11 @@ async def test_same_tool_streak_resets_after_switching_tools( tool_contents = [str(message.content) for message in tool_messages] level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD, + streak=threshold, ) level_2_notice = runner_cls.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format( tool_name="test_tool", - streak=runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD, + streak=threshold + 1, ) assert level_1_notice not in tool_contents[0] @@ -949,10 +965,10 @@ async def test_same_tool_streak_resets_after_switching_tools( repeated_contents = tool_contents[2:] for streak_after_reset, content in enumerate(repeated_contents, start=1): - if streak_after_reset < runner_cls.REPEATED_TOOL_NOTICE_L1_THRESHOLD: + if streak_after_reset < threshold: assert level_1_notice not in content assert level_2_notice not in content - elif streak_after_reset < runner_cls.REPEATED_TOOL_NOTICE_L2_THRESHOLD: + elif streak_after_reset < threshold + 1: assert level_1_notice in content assert level_2_notice not in content else: @@ -960,6 +976,172 @@ async def test_same_tool_streak_resets_after_switching_tools( assert level_2_notice in content +@pytest.mark.asyncio +async def test_repeated_tool_notice_disabled( + runner, mock_tool_executor, mock_hooks +): + """enable=False 时,_build_repeated_tool_call_guidance 应始终返回空字符串。""" + tool = FunctionTool( + name="test_tool", + description="测试工具", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + handler=AsyncMock(), + ) + request = ProviderRequest( + prompt="禁用提醒后连续执行", + func_tool=ToolSet(tools=[tool]), + contexts=[], + ) + + await runner.reset( + provider=MockProvider(), + request=request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + repeated_tool_notice_enabled=False, + ) + + assert runner.repeated_tool_notice_enabled is False + for streak in range(1, 10): + assert runner._build_repeated_tool_call_guidance("test_tool", streak) == "" + + +@pytest.mark.asyncio +async def test_repeated_tool_notice_custom_threshold( + runner, mock_tool_executor, mock_hooks +): + """自定义 threshold=2 时,第二次连续调用即应触发 L1 提示。""" + runner_cls = type(runner) + custom_threshold = 2 + total_calls = custom_threshold + 2 # 覆盖 L1 / L2 / L3 + provider = SequentialToolProvider(["test_tool"] * total_calls) + tool = FunctionTool( + name="test_tool", + description="测试工具", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + handler=AsyncMock(), + ) + request = ProviderRequest( + prompt="自定义阈值测试", + func_tool=ToolSet(tools=[tool]), + contexts=[], + ) + + await runner.reset( + provider=provider, + request=request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + repeated_tool_notice_threshold=custom_threshold, + ) + + assert runner.repeated_tool_notice_enabled is True + assert runner.repeated_tool_notice_threshold == custom_threshold + + async for _ in runner.step_until_done(total_calls + 1): + pass + + tool_messages = [ + m for m in runner.run_context.messages if getattr(m, "role", None) == "tool" + ] + assert len(tool_messages) == total_calls + + tool_contents = [str(message.content) for message in tool_messages] + level_1_notice = runner_cls.REPEATED_TOOL_NOTICE_L1_TEMPLATE.format( + tool_name="test_tool", + streak=custom_threshold, + ) + level_2_notice = runner_cls.REPEATED_TOOL_NOTICE_L2_TEMPLATE.format( + tool_name="test_tool", + streak=custom_threshold + 1, + ) + level_3_notice = runner_cls.REPEATED_TOOL_NOTICE_L3_TEMPLATE.format( + tool_name="test_tool", + streak=custom_threshold + 2, + ) + + # 第 1 次:未达阈值,不注入 + assert level_1_notice not in tool_contents[0] + # 第 2 次:达到阈值,注入 L1 + assert level_1_notice in tool_contents[1] + assert level_2_notice not in tool_contents[1] + # 第 3 次:升级为 L2 + assert level_2_notice in tool_contents[2] + assert level_3_notice not in tool_contents[2] + # 第 4 次:升级为 L3 + assert level_3_notice in tool_contents[3] + + +def test_normalize_repeated_tool_notice_threshold(): + """_normalize_repeated_tool_notice_threshold 应正确处理各种边界输入。""" + runner_cls = ToolLoopAgentRunner + default = runner_cls.REPEATED_TOOL_NOTICE_DEFAULT_THRESHOLD + + # 正常正整数 + assert runner_cls._normalize_repeated_tool_notice_threshold(5) == 5 + # 数字字符串 + assert runner_cls._normalize_repeated_tool_notice_threshold("7") == 7 + # 浮点数(截断为整数) + assert runner_cls._normalize_repeated_tool_notice_threshold(4.9) == 4 + # 0 / 负数:兜底为 1 + assert runner_cls._normalize_repeated_tool_notice_threshold(0) == 1 + assert runner_cls._normalize_repeated_tool_notice_threshold(-3) == 1 + # 布尔值:视为非法,回退到默认值 + assert runner_cls._normalize_repeated_tool_notice_threshold(True) == default + assert runner_cls._normalize_repeated_tool_notice_threshold(False) == default + # None / 非数字字符串 / 容器:回退到默认值 + assert runner_cls._normalize_repeated_tool_notice_threshold(None) == default + assert runner_cls._normalize_repeated_tool_notice_threshold("abc") == default + assert runner_cls._normalize_repeated_tool_notice_threshold([1, 2]) == default + + +@pytest.mark.asyncio +async def test_repeated_tool_notice_persists_across_reset( + runner, mock_tool_executor, mock_hooks +): + """reset() 显式传入的参数应被保留,跨多次 reset 不丢失。""" + tool = FunctionTool( + name="test_tool", + description="测试工具", + parameters={"type": "object", "properties": {"query": {"type": "string"}}}, + handler=AsyncMock(), + ) + request = ProviderRequest( + prompt="reset 参数持久化", + func_tool=ToolSet(tools=[tool]), + contexts=[], + ) + + await runner.reset( + provider=MockProvider(), + request=request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + repeated_tool_notice_enabled=False, + repeated_tool_notice_threshold=7, + ) + assert runner.repeated_tool_notice_enabled is False + assert runner.repeated_tool_notice_threshold == 7 + + # 再次 reset 时不传参,应沿用默认(与首次 reset 的参数无关) + await runner.reset( + provider=MockProvider(), + request=request, + run_context=ContextWrapper(context=None), + tool_executor=mock_tool_executor, + agent_hooks=mock_hooks, + streaming=False, + ) + assert runner.repeated_tool_notice_enabled is True + assert runner.repeated_tool_notice_threshold == 3 + + @pytest.mark.asyncio async def test_fallback_provider_used_when_primary_raises( runner, provider_request, mock_tool_executor, mock_hooks