From 19eba89d689e774934b7faada0de3db857f77b81 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Mon, 23 Mar 2026 21:18:28 -0500 Subject: [PATCH 01/16] feat(subagent): add per-run handoff call limit guard --- astrbot/core/astr_agent_tool_exec.py | 95 ++++++++++++++++++- astrbot/core/config/default.py | 3 + astrbot/dashboard/routes/subagent.py | 2 + tests/unit/test_astr_agent_tool_exec.py | 121 ++++++++++++++++++++++++ 4 files changed, 219 insertions(+), 2 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 1fb4b03368..5bfff7f9e4 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -44,6 +44,71 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): + _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" + + @classmethod + def _coerce_int( + cls, + value: T.Any, + *, + default: int, + minimum: int, + maximum: int, + ) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return max(minimum, min(maximum, parsed)) + + @classmethod + def _get_event_extra( + cls, + event: T.Any, + key: str, + default: T.Any = None, + ) -> T.Any: + if event is None or not hasattr(event, "get_extra"): + return default + try: + return event.get_extra(key, default) + except TypeError: + try: + result = event.get_extra(key) + except Exception: + return default + return default if result is None else result + except Exception: + return default + + @classmethod + def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: + if event is None or not hasattr(event, "set_extra"): + return False + try: + event.set_extra(key, value) + return True + except Exception: + return False + + @classmethod + def _resolve_handoff_call_limit( + cls, + run_context: ContextWrapper[AstrAgentContext], + ) -> int: + ctx = run_context.context.context + event = run_context.context.event + cfg = ctx.get_config(umo=event.unified_msg_origin) + subagent_cfg = cfg.get("subagent_orchestrator", {}) + if not isinstance(subagent_cfg, dict): + return 8 + return cls._coerce_int( + subagent_cfg.get("max_handoff_calls_per_run", 8), + default=8, + minimum=1, + maximum=128, + ) + @classmethod def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]: if image_urls_raw is None: @@ -246,6 +311,34 @@ async def _execute_handoff( ): tool_args = dict(tool_args) input_ = tool_args.get("input") + ctx = run_context.context.context + event = run_context.context.event + max_handoff_calls = cls._resolve_handoff_call_limit(run_context) + current_handoff_count = cls._coerce_int( + cls._get_event_extra(event, cls._HANDOFF_CALL_COUNT_EXTRA_KEY, 0), + default=0, + minimum=0, + maximum=10_000, + ) + if current_handoff_count >= max_handoff_calls: + yield mcp.types.CallToolResult( + content=[ + mcp.types.TextContent( + type="text", + text=( + "error: handoff_call_limit_reached. " + f"max_handoff_calls_per_run={max_handoff_calls}. " + "Stop delegating and continue with current context." + ), + ) + ] + ) + return + cls._set_event_extra( + event, + cls._HANDOFF_CALL_COUNT_EXTRA_KEY, + current_handoff_count + 1, + ) if image_urls_prepared: prepared_image_urls = tool_args.get("image_urls") if isinstance(prepared_image_urls, list): @@ -266,8 +359,6 @@ async def _execute_handoff( # Build handoff toolset from registered tools plus runtime computer tools. toolset = cls._build_handoff_toolset(run_context, tool.agent.tools) - ctx = run_context.context.context - event = run_context.context.event umo = event.unified_msg_origin # Use per-subagent provider override if configured; otherwise fall back diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 0f43dbd06d..57362ce765 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -188,6 +188,9 @@ "subagent_orchestrator": { "main_enable": False, "remove_main_duplicate_tools": False, + # Limits total handoff tool calls in one agent run to prevent + # runaway delegation loops. + "max_handoff_calls_per_run": 8, "router_system_prompt": ( "You are a task router. Your job is to chat naturally, recognize user intent, " "and delegate work to the most suitable subagent using transfer_to_* tools. " diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py index e3d77f73ad..26087b9ede 100644 --- a/astrbot/dashboard/routes/subagent.py +++ b/astrbot/dashboard/routes/subagent.py @@ -36,6 +36,7 @@ async def get_config(self): data = { "main_enable": False, "remove_main_duplicate_tools": False, + "max_handoff_calls_per_run": 8, "agents": [], } @@ -50,6 +51,7 @@ async def get_config(self): # Ensure required keys exist. data.setdefault("main_enable", False) data.setdefault("remove_main_duplicate_tools", False) + data.setdefault("max_handoff_calls_per_run", 8) data.setdefault("agents", []) # Backward/forward compatibility: ensure each agent contains provider_id. diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 5fab9fe0a2..6789da42ba 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -343,3 +343,124 @@ async def _fake_convert_to_file_path(self): ) assert image_urls == [] + + +@pytest.mark.asyncio +async def test_execute_handoff_rejects_when_call_limit_reached(): + captured: dict = {} + + class _EventWithExtras: + def __init__(self) -> None: + self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" + self.message_obj = SimpleNamespace(message=[]) + self._extras = { + FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY: 1, + } + + def get_extra(self, key: str, default=None): + return self._extras.get(key, default) + + def set_extra(self, key: str, value): + self._extras[key] = value + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**kwargs): + captured.update(kwargs) + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: { + "provider_settings": {}, + "subagent_orchestrator": {"max_handoff_calls_per_run": 1}, + }, + ) + event = _EventWithExtras() + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + agent=SimpleNamespace( + name="subagent", + tools=[], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="hello", + image_urls=[], + ): + results.append(result) + + assert len(results) == 1 + assert "handoff_call_limit_reached" in results[0].content[0].text + assert captured == {} + + +@pytest.mark.asyncio +async def test_execute_handoff_increments_call_count_on_success(): + class _EventWithExtras: + def __init__(self) -> None: + self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" + self.message_obj = SimpleNamespace(message=[]) + self._extras: dict[str, int] = {} + + def get_extra(self, key: str, default=None): + return self._extras.get(key, default) + + def set_extra(self, key: str, value): + self._extras[key] = value + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**_kwargs): + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: { + "provider_settings": {}, + "subagent_orchestrator": {"max_handoff_calls_per_run": 2}, + }, + ) + event = _EventWithExtras() + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + agent=SimpleNamespace( + name="subagent", + tools=[], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="hello", + image_urls=[], + ): + results.append(result) + + assert len(results) == 1 + assert ( + event._extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY] + == 1 + ) From 922158b74bf15f26a9b2b0253eeb270db4e78ba8 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Mon, 23 Mar 2026 21:56:02 -0500 Subject: [PATCH 02/16] refactor(subagent): tighten handoff limit helpers and tests --- astrbot/core/astr_agent_tool_exec.py | 74 +++++++++++-------- astrbot/dashboard/routes/subagent.py | 12 +++- tests/unit/test_astr_agent_tool_exec.py | 95 +++++++++++++++++++++++++ 3 files changed, 151 insertions(+), 30 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 5bfff7f9e4..12c5c218d2 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -45,6 +45,8 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" + _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = 8 + _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 @classmethod def _coerce_int( @@ -68,27 +70,28 @@ def _get_event_extra( key: str, default: T.Any = None, ) -> T.Any: - if event is None or not hasattr(event, "get_extra"): + if event is None: return default + + get_extra = getattr(event, "get_extra", None) + if get_extra is None: + return default + try: - return event.get_extra(key, default) + return get_extra(key, default) except TypeError: - try: - result = event.get_extra(key) - except Exception: - return default + result = get_extra(key) return default if result is None else result - except Exception: - return default @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: - if event is None or not hasattr(event, "set_extra"): + set_extra = getattr(event, "set_extra", None) + if set_extra is None: return False try: - event.set_extra(key, value) + set_extra(key, value) return True - except Exception: + except TypeError: return False @classmethod @@ -101,14 +104,40 @@ def _resolve_handoff_call_limit( cfg = ctx.get_config(umo=event.unified_msg_origin) subagent_cfg = cfg.get("subagent_orchestrator", {}) if not isinstance(subagent_cfg, dict): - return 8 + return cls._DEFAULT_MAX_HANDOFF_CALLS_PER_RUN return cls._coerce_int( - subagent_cfg.get("max_handoff_calls_per_run", 8), - default=8, + subagent_cfg.get( + "max_handoff_calls_per_run", + cls._DEFAULT_MAX_HANDOFF_CALLS_PER_RUN, + ), + default=cls._DEFAULT_MAX_HANDOFF_CALLS_PER_RUN, minimum=1, maximum=128, ) + @classmethod + def _check_and_increment_handoff_calls( + cls, + run_context: ContextWrapper[AstrAgentContext], + ) -> tuple[bool, int]: + event = run_context.context.event + max_handoff_calls = cls._resolve_handoff_call_limit(run_context) + current_handoff_count = cls._coerce_int( + cls._get_event_extra(event, cls._HANDOFF_CALL_COUNT_EXTRA_KEY, 0), + default=0, + minimum=0, + maximum=cls._MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT, + ) + if current_handoff_count >= max_handoff_calls: + return False, max_handoff_calls + + cls._set_event_extra( + event, + cls._HANDOFF_CALL_COUNT_EXTRA_KEY, + current_handoff_count + 1, + ) + return True, max_handoff_calls + @classmethod def _collect_image_urls_from_args(cls, image_urls_raw: T.Any) -> list[str]: if image_urls_raw is None: @@ -312,15 +341,8 @@ async def _execute_handoff( tool_args = dict(tool_args) input_ = tool_args.get("input") ctx = run_context.context.context - event = run_context.context.event - max_handoff_calls = cls._resolve_handoff_call_limit(run_context) - current_handoff_count = cls._coerce_int( - cls._get_event_extra(event, cls._HANDOFF_CALL_COUNT_EXTRA_KEY, 0), - default=0, - minimum=0, - maximum=10_000, - ) - if current_handoff_count >= max_handoff_calls: + allowed, max_handoff_calls = cls._check_and_increment_handoff_calls(run_context) + if not allowed: yield mcp.types.CallToolResult( content=[ mcp.types.TextContent( @@ -334,11 +356,7 @@ async def _execute_handoff( ] ) return - cls._set_event_extra( - event, - cls._HANDOFF_CALL_COUNT_EXTRA_KEY, - current_handoff_count + 1, - ) + event = run_context.context.event if image_urls_prepared: prepared_image_urls = tool_args.get("image_urls") if isinstance(prepared_image_urls, list): diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py index 26087b9ede..55ba7ddd2f 100644 --- a/astrbot/dashboard/routes/subagent.py +++ b/astrbot/dashboard/routes/subagent.py @@ -4,10 +4,15 @@ from astrbot.core import logger from astrbot.core.agent.handoff import HandoffTool +from astrbot.core.config.default import DEFAULT_CONFIG from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from .route import Response, Route, RouteContext +DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = int( + DEFAULT_CONFIG.get("subagent_orchestrator", {}).get("max_handoff_calls_per_run", 8) +) + class SubAgentRoute(Route): def __init__( @@ -36,7 +41,7 @@ async def get_config(self): data = { "main_enable": False, "remove_main_duplicate_tools": False, - "max_handoff_calls_per_run": 8, + "max_handoff_calls_per_run": DEFAULT_MAX_HANDOFF_CALLS_PER_RUN, "agents": [], } @@ -51,7 +56,10 @@ async def get_config(self): # Ensure required keys exist. data.setdefault("main_enable", False) data.setdefault("remove_main_duplicate_tools", False) - data.setdefault("max_handoff_calls_per_run", 8) + data.setdefault( + "max_handoff_calls_per_run", + DEFAULT_MAX_HANDOFF_CALLS_PER_RUN, + ) data.setdefault("agents", []) # Backward/forward compatibility: ensure each agent contains provider_id. diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 6789da42ba..47ea6af50d 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -464,3 +464,98 @@ async def _fake_tool_loop_agent(**_kwargs): event._extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY] == 1 ) + + +@pytest.mark.asyncio +async def test_execute_handoff_enforces_call_limit_across_multiple_calls(): + call_count = {"tool_loop": 0} + + class _EventWithExtras: + def __init__(self) -> None: + self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" + self.message_obj = SimpleNamespace(message=[]) + self._extras: dict[str, int] = {} + + def get_extra(self, key: str, default=None): + return self._extras.get(key, default) + + def set_extra(self, key: str, value): + self._extras[key] = value + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**_kwargs): + call_count["tool_loop"] += 1 + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: { + "provider_settings": {}, + "subagent_orchestrator": {"max_handoff_calls_per_run": 2}, + }, + ) + event = _EventWithExtras() + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + agent=SimpleNamespace( + name="subagent", + tools=[], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + first_results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="first", + image_urls=[], + ): + first_results.append(result) + assert len(first_results) == 1 + assert "handoff_call_limit_reached" not in first_results[0].content[0].text + assert ( + event._extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY] + == 1 + ) + + second_results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="second", + image_urls=[], + ): + second_results.append(result) + assert len(second_results) == 1 + assert "handoff_call_limit_reached" not in second_results[0].content[0].text + assert ( + event._extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY] + == 2 + ) + + third_results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="third", + image_urls=[], + ): + third_results.append(result) + assert len(third_results) == 1 + assert "handoff_call_limit_reached" in third_results[0].content[0].text + assert ( + event._extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY] + == 2 + ) + assert call_count["tool_loop"] == 2 From c3e4abbafaab0b7f9186b167328d5c4020be0536 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Mon, 23 Mar 2026 22:02:10 -0500 Subject: [PATCH 03/16] fix(subagent): harden event extra accessors for handoff limit --- astrbot/core/astr_agent_tool_exec.py | 9 ++++++--- tests/unit/test_astr_agent_tool_exec.py | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 12c5c218d2..03239598fa 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -74,19 +74,22 @@ def _get_event_extra( return default get_extra = getattr(event, "get_extra", None) - if get_extra is None: + if get_extra is None or not callable(get_extra): return default try: return get_extra(key, default) except TypeError: - result = get_extra(key) + try: + result = get_extra(key) + except TypeError: + return default return default if result is None else result @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: set_extra = getattr(event, "set_extra", None) - if set_extra is None: + if set_extra is None or not callable(set_extra): return False try: set_extra(key, value) diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 47ea6af50d..915d50e716 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -559,3 +559,17 @@ async def _fake_tool_loop_agent(**_kwargs): == 2 ) assert call_count["tool_loop"] == 2 + + +def test_get_event_extra_returns_default_for_noncallable_get_extra(): + event = SimpleNamespace(get_extra="not-callable") + assert ( + FunctionToolExecutor._get_event_extra(event, "missing", default=42) == 42 + ) + + +def test_set_event_extra_returns_false_for_noncallable_set_extra(): + event = SimpleNamespace(set_extra="not-callable") + assert ( + FunctionToolExecutor._set_event_extra(event, "key", "value") is False + ) From c4a0786bd0cb3baeec43b1588015e8299a4faa63 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Mon, 23 Mar 2026 22:58:32 -0500 Subject: [PATCH 04/16] fix(subagent): fail closed when handoff counter cannot persist --- astrbot/core/astr_agent_tool_exec.py | 8 +- tests/unit/test_astr_agent_tool_exec.py | 134 +++++++++++++++++++++++- 2 files changed, 139 insertions(+), 3 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 03239598fa..6286a0452c 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -134,11 +134,17 @@ def _check_and_increment_handoff_calls( if current_handoff_count >= max_handoff_calls: return False, max_handoff_calls - cls._set_event_extra( + persisted = cls._set_event_extra( event, cls._HANDOFF_CALL_COUNT_EXTRA_KEY, current_handoff_count + 1, ) + if not persisted: + logger.warning( + "Failed to persist handoff call counter `%s`; reject delegation to fail closed.", + cls._HANDOFF_CALL_COUNT_EXTRA_KEY, + ) + return False, max_handoff_calls return True, max_handoff_calls @classmethod diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 915d50e716..5074744655 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -12,9 +12,13 @@ class _DummyEvent: def __init__(self, message_components: list[object] | None = None) -> None: self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" self.message_obj = SimpleNamespace(message=message_components or []) + self._extras: dict[str, object] = {} - def get_extra(self, _key: str): - return None + def get_extra(self, key: str, default=None): + return self._extras.get(key, default) + + def set_extra(self, key: str, value) -> None: + self._extras[key] = value class _DummyTool: @@ -573,3 +577,129 @@ def test_set_event_extra_returns_false_for_noncallable_set_extra(): assert ( FunctionToolExecutor._set_event_extra(event, "key", "value") is False ) + + +@pytest.mark.asyncio +async def test_execute_handoff_rejects_when_call_counter_persist_fails(): + call_count = {"tool_loop": 0} + + class _EventWithBrokenSetter: + def __init__(self) -> None: + self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" + self.message_obj = SimpleNamespace(message=[]) + self._extras: dict[str, int] = {} + + def get_extra(self, key: str, default=None): + return self._extras.get(key, default) + + def set_extra(self, _key: str, _value): + raise TypeError("set_extra signature mismatch") + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**_kwargs): + call_count["tool_loop"] += 1 + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: { + "provider_settings": {}, + "subagent_orchestrator": {"max_handoff_calls_per_run": 8}, + }, + ) + event = _EventWithBrokenSetter() + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + agent=SimpleNamespace( + name="subagent", + tools=[], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="hello", + image_urls=[], + ): + results.append(result) + + assert len(results) == 1 + assert "handoff_call_limit_reached" in results[0].content[0].text + assert call_count["tool_loop"] == 0 + + +@pytest.mark.asyncio +async def test_execute_handoff_rejects_when_event_extra_exceeds_sanity_limit(): + call_count = {"tool_loop": 0} + + class _EventWithHugeCount: + def __init__(self) -> None: + self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" + self.message_obj = SimpleNamespace(message=[]) + self._extras: dict[str, int] = { + FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY: 10**12 + } + + def get_extra(self, key: str, default=None): + return self._extras.get(key, default) + + def set_extra(self, key: str, value): + self._extras[key] = value + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**_kwargs): + call_count["tool_loop"] += 1 + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: { + "provider_settings": {}, + "subagent_orchestrator": {"max_handoff_calls_per_run": 128}, + }, + ) + event = _EventWithHugeCount() + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + agent=SimpleNamespace( + name="subagent", + tools=[], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="hello", + image_urls=[], + ): + results.append(result) + + assert len(results) == 1 + assert "handoff_call_limit_reached" in results[0].content[0].text + assert call_count["tool_loop"] == 0 + assert ( + event._extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY] + == 10**12 + ) From 0c08ca9835937d78bd6bb24402a9f3dc6f87e66c Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Mon, 23 Mar 2026 23:55:23 -0500 Subject: [PATCH 05/16] test(subagent): cover malformed handoff call-limit config --- tests/unit/test_astr_agent_tool_exec.py | 31 +++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 5074744655..9ff8172f48 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -703,3 +703,34 @@ async def _fake_tool_loop_agent(**_kwargs): event._extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY] == 10**12 ) + + +def _build_run_context_for_handoff_limit(config: dict): + event = SimpleNamespace( + unified_msg_origin="webchat:FriendMessage:webchat!user!session", + message_obj=SimpleNamespace(message=[]), + ) + context = SimpleNamespace(get_config=lambda **_kwargs: config) + return ContextWrapper(context=SimpleNamespace(event=event, context=context)) + + +@pytest.mark.parametrize( + ("config", "expected_limit"), + [ + ({}, 8), + ({"subagent_orchestrator": None}, 8), + ({"subagent_orchestrator": "not-a-dict"}, 8), + ({"subagent_orchestrator": {"max_handoff_calls_per_run": 0}}, 1), + ({"subagent_orchestrator": {"max_handoff_calls_per_run": -3}}, 1), + ({"subagent_orchestrator": {"max_handoff_calls_per_run": 129}}, 128), + ({"subagent_orchestrator": {"max_handoff_calls_per_run": "abc"}}, 8), + ({"subagent_orchestrator": {"max_handoff_calls_per_run": "3.14"}}, 8), + ({"subagent_orchestrator": {"max_handoff_calls_per_run": "10"}}, 10), + ], +) +def test_resolve_handoff_call_limit_with_malformed_and_boundary_configs( + config: dict, + expected_limit: int, +): + run_context = _build_run_context_for_handoff_limit(config) + assert FunctionToolExecutor._resolve_handoff_call_limit(run_context) == expected_limit From e85c5bff6422a0412279297ced702326969776f1 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 00:05:42 -0500 Subject: [PATCH 06/16] fix(subagent): fail closed on handoff counter read errors --- astrbot/core/astr_agent_tool_exec.py | 69 ++++++++++++++++++-- tests/unit/test_astr_agent_tool_exec.py | 85 +++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 6286a0452c..2be4ab9d87 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -78,12 +78,42 @@ def _get_event_extra( return default try: - return get_extra(key, default) - except TypeError: - try: + signature = inspect.signature(get_extra) + except (TypeError, ValueError): + signature = None + + if signature is not None: + positional_params = [ + p + for p in signature.parameters.values() + if p.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + ] + has_var_positional = any( + p.kind == inspect.Parameter.VAR_POSITIONAL + for p in signature.parameters.values() + ) + + if has_var_positional or len(positional_params) >= 2: + result = get_extra(key, default) + return default if result is None else result + if len(positional_params) >= 1: result = get_extra(key) - except TypeError: - return default + return default if result is None else result + return default + + try: + result = get_extra(key, default) + return default if result is None else result + except TypeError as e: + # Keep compatibility with legacy one-arg get_extra(key) call sites + # when signature introspection is unavailable. + if not cls._looks_like_call_signature_type_error(e): + raise + result = get_extra(key) return default if result is None else result @classmethod @@ -97,6 +127,19 @@ def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: except TypeError: return False + @classmethod + def _looks_like_call_signature_type_error(cls, exc: TypeError) -> bool: + msg = str(exc) + return any( + token in msg + for token in ( + "positional argument", + "keyword argument", + "required positional argument", + "takes", + ) + ) + @classmethod def _resolve_handoff_call_limit( cls, @@ -125,8 +168,22 @@ def _check_and_increment_handoff_calls( ) -> tuple[bool, int]: event = run_context.context.event max_handoff_calls = cls._resolve_handoff_call_limit(run_context) + try: + raw_handoff_count = cls._get_event_extra( + event, + cls._HANDOFF_CALL_COUNT_EXTRA_KEY, + 0, + ) + except Exception as e: + logger.warning( + "Failed to read handoff call counter `%s`: %s; reject delegation to fail closed.", + cls._HANDOFF_CALL_COUNT_EXTRA_KEY, + e, + ) + return False, max_handoff_calls + current_handoff_count = cls._coerce_int( - cls._get_event_extra(event, cls._HANDOFF_CALL_COUNT_EXTRA_KEY, 0), + raw_handoff_count, default=0, minimum=0, maximum=cls._MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT, diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index 9ff8172f48..eb632e2a11 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -572,6 +572,32 @@ def test_get_event_extra_returns_default_for_noncallable_get_extra(): ) +def test_get_event_extra_supports_single_arg_getter_signature(): + class _EventWithSingleArgGetter: + def __init__(self) -> None: + self._extras = {"present": "value"} + + def get_extra(self, key: str): + return self._extras.get(key) + + event = _EventWithSingleArgGetter() + assert FunctionToolExecutor._get_event_extra(event, "present", default=42) == "value" + assert FunctionToolExecutor._get_event_extra(event, "missing", default=42) == 42 + + +def test_get_event_extra_propagates_internal_type_error(): + class _EventWithBrokenGetter: + def get_extra(self, _key: str, _default=None): + raise TypeError("internal get_extra failure") + + with pytest.raises(TypeError, match="internal get_extra failure"): + FunctionToolExecutor._get_event_extra( + _EventWithBrokenGetter(), + "missing", + default=42, + ) + + def test_set_event_extra_returns_false_for_noncallable_set_extra(): event = SimpleNamespace(set_extra="not-callable") assert ( @@ -639,6 +665,65 @@ async def _fake_tool_loop_agent(**_kwargs): assert call_count["tool_loop"] == 0 +@pytest.mark.asyncio +async def test_execute_handoff_rejects_when_call_counter_read_fails(): + call_count = {"tool_loop": 0} + + class _EventWithBrokenGetter: + def __init__(self) -> None: + self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" + self.message_obj = SimpleNamespace(message=[]) + + def get_extra(self, _key: str, _default=None): + raise TypeError("internal get_extra failure") + + def set_extra(self, _key: str, _value): + return None + + async def _fake_get_current_chat_provider_id(_umo): + return "provider-id" + + async def _fake_tool_loop_agent(**_kwargs): + call_count["tool_loop"] += 1 + return SimpleNamespace(completion_text="ok") + + context = SimpleNamespace( + get_current_chat_provider_id=_fake_get_current_chat_provider_id, + tool_loop_agent=_fake_tool_loop_agent, + get_config=lambda **_kwargs: { + "provider_settings": {}, + "subagent_orchestrator": {"max_handoff_calls_per_run": 8}, + }, + ) + event = _EventWithBrokenGetter() + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + tool = SimpleNamespace( + name="transfer_to_subagent", + provider_id=None, + agent=SimpleNamespace( + name="subagent", + tools=[], + instructions="subagent-instructions", + begin_dialogs=[], + run_hooks=None, + ), + ) + + results = [] + async for result in FunctionToolExecutor._execute_handoff( + tool, + run_context, + image_urls_prepared=True, + input="hello", + image_urls=[], + ): + results.append(result) + + assert len(results) == 1 + assert "handoff_call_limit_reached" in results[0].content[0].text + assert call_count["tool_loop"] == 0 + + @pytest.mark.asyncio async def test_execute_handoff_rejects_when_event_extra_exceeds_sanity_limit(): call_count = {"tool_loop": 0} From 378cfde263052e85ae6343642df53bbdedf112d2 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 00:15:06 -0500 Subject: [PATCH 07/16] refactor(subagent): centralize handoff limit default constant --- astrbot/core/astr_agent_tool_exec.py | 23 ++++++----------------- astrbot/core/config/default.py | 4 +++- astrbot/dashboard/routes/subagent.py | 6 +----- tests/unit/test_astr_agent_tool_exec.py | 8 ++++++++ 4 files changed, 18 insertions(+), 23 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 2be4ab9d87..ea59788ea0 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -28,6 +28,7 @@ SEND_MESSAGE_TO_USER_TOOL, ) from astrbot.core.cron.events import CronMessageEvent +from astrbot.core.config.default import DEFAULT_MAX_HANDOFF_CALLS_PER_RUN from astrbot.core.message.components import Image from astrbot.core.message.message_event_result import ( CommandResult, @@ -45,7 +46,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" - _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = 8 + _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = DEFAULT_MAX_HANDOFF_CALLS_PER_RUN _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 @classmethod @@ -108,16 +109,17 @@ def _get_event_extra( try: result = get_extra(key, default) return default if result is None else result - except TypeError as e: + except TypeError: # Keep compatibility with legacy one-arg get_extra(key) call sites # when signature introspection is unavailable. - if not cls._looks_like_call_signature_type_error(e): - raise result = get_extra(key) return default if result is None else result @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: + if event is None: + return False + set_extra = getattr(event, "set_extra", None) if set_extra is None or not callable(set_extra): return False @@ -127,19 +129,6 @@ def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: except TypeError: return False - @classmethod - def _looks_like_call_signature_type_error(cls, exc: TypeError) -> bool: - msg = str(exc) - return any( - token in msg - for token in ( - "positional argument", - "keyword argument", - "required positional argument", - "takes", - ) - ) - @classmethod def _resolve_handoff_call_limit( cls, diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 57362ce765..9b4f8b30b7 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -50,6 +50,8 @@ "line", ] +DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = 8 + # 默认配置 DEFAULT_CONFIG = { "config_version": 2, @@ -190,7 +192,7 @@ "remove_main_duplicate_tools": False, # Limits total handoff tool calls in one agent run to prevent # runaway delegation loops. - "max_handoff_calls_per_run": 8, + "max_handoff_calls_per_run": DEFAULT_MAX_HANDOFF_CALLS_PER_RUN, "router_system_prompt": ( "You are a task router. Your job is to chat naturally, recognize user intent, " "and delegate work to the most suitable subagent using transfer_to_* tools. " diff --git a/astrbot/dashboard/routes/subagent.py b/astrbot/dashboard/routes/subagent.py index 55ba7ddd2f..b1bcade0c3 100644 --- a/astrbot/dashboard/routes/subagent.py +++ b/astrbot/dashboard/routes/subagent.py @@ -4,15 +4,11 @@ from astrbot.core import logger from astrbot.core.agent.handoff import HandoffTool -from astrbot.core.config.default import DEFAULT_CONFIG +from astrbot.core.config.default import DEFAULT_MAX_HANDOFF_CALLS_PER_RUN from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from .route import Response, Route, RouteContext -DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = int( - DEFAULT_CONFIG.get("subagent_orchestrator", {}).get("max_handoff_calls_per_run", 8) -) - class SubAgentRoute(Route): def __init__( diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index eb632e2a11..aa6abf3ab1 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -5,6 +5,7 @@ from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor +from astrbot.core.config.default import DEFAULT_MAX_HANDOFF_CALLS_PER_RUN from astrbot.core.message.components import Image @@ -605,6 +606,13 @@ def test_set_event_extra_returns_false_for_noncallable_set_extra(): ) +def test_handoff_default_limit_is_sourced_from_config_default(): + assert ( + FunctionToolExecutor._DEFAULT_MAX_HANDOFF_CALLS_PER_RUN + == DEFAULT_MAX_HANDOFF_CALLS_PER_RUN + ) + + @pytest.mark.asyncio async def test_execute_handoff_rejects_when_call_counter_persist_fails(): call_count = {"tool_loop": 0} From c49494e1b89bd91b4bcae49f052457f483aea11d Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 00:29:43 -0500 Subject: [PATCH 08/16] fix(subagent): support kw-only event get_extra signatures --- astrbot/core/astr_agent_tool_exec.py | 34 ++++++++++++------------- tests/unit/test_astr_agent_tool_exec.py | 13 ++++++++++ 2 files changed, 29 insertions(+), 18 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index ea59788ea0..242c8c53cf 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -84,25 +84,23 @@ def _get_event_extra( signature = None if signature is not None: - positional_params = [ - p - for p in signature.parameters.values() - if p.kind - in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, - ) - ] - has_var_positional = any( - p.kind == inspect.Parameter.VAR_POSITIONAL - for p in signature.parameters.values() + # Probe call shapes that can be bound by signature to support: + # - get_extra(key, default) + # - get_extra(key) + # - get_extra(*, key, default=None) + # - get_extra(*, key) + candidate_calls = ( + ((key, default), {}), + ((key,), {}), + ((), {"key": key, "default": default}), + ((), {"key": key}), ) - - if has_var_positional or len(positional_params) >= 2: - result = get_extra(key, default) - return default if result is None else result - if len(positional_params) >= 1: - result = get_extra(key) + for args, kwargs in candidate_calls: + try: + signature.bind_partial(*args, **kwargs) + except TypeError: + continue + result = get_extra(*args, **kwargs) return default if result is None else result return default diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index aa6abf3ab1..b58cdceb38 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -586,6 +586,19 @@ def get_extra(self, key: str): assert FunctionToolExecutor._get_event_extra(event, "missing", default=42) == 42 +def test_get_event_extra_supports_keyword_only_getter_signature(): + class _EventWithKeywordOnlyGetter: + def __init__(self) -> None: + self._extras = {"present": "value"} + + def get_extra(self, *, key: str, default=None): + return self._extras.get(key, default) + + event = _EventWithKeywordOnlyGetter() + assert FunctionToolExecutor._get_event_extra(event, "present", default=42) == "value" + assert FunctionToolExecutor._get_event_extra(event, "missing", default=42) == 42 + + def test_get_event_extra_propagates_internal_type_error(): class _EventWithBrokenGetter: def get_extra(self, _key: str, _default=None): From cfc13e9b6d610de9ae6360702bd2fcb9a50e6419 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 00:40:14 -0500 Subject: [PATCH 09/16] perf(subagent): cache event get_extra call-shape resolution --- astrbot/core/astr_agent_tool_exec.py | 88 ++++++++++++++++++---------- 1 file changed, 58 insertions(+), 30 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 242c8c53cf..3e7c9fc7fe 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -48,6 +48,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = DEFAULT_MAX_HANDOFF_CALLS_PER_RUN _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 + _GET_EXTRA_CALL_SHAPES_CACHE: dict[tuple[type, object], tuple[str, ...]] = {} @classmethod def _coerce_int( @@ -78,40 +79,67 @@ def _get_event_extra( if get_extra is None or not callable(get_extra): return default + call_shapes = cls._resolve_get_extra_call_shapes(event, get_extra) + for call_shape in call_shapes: + if call_shape == "args2": + result = get_extra(key, default) + return default if result is None else result + if call_shape == "args1": + result = get_extra(key) + return default if result is None else result + if call_shape == "kwargs2": + result = get_extra(key=key, default=default) + return default if result is None else result + if call_shape == "kwargs1": + result = get_extra(key=key) + return default if result is None else result + + return default + + @classmethod + def _resolve_get_extra_call_shapes( + cls, + event: T.Any, + get_extra: T.Callable[..., T.Any], + ) -> tuple[str, ...]: + get_extra_impl = getattr(get_extra, "__func__", get_extra) + cache_key = (type(event), get_extra_impl) + cached = cls._GET_EXTRA_CALL_SHAPES_CACHE.get(cache_key) + if cached is not None: + return cached + + call_shapes = cls._infer_get_extra_call_shapes(get_extra) + cls._GET_EXTRA_CALL_SHAPES_CACHE[cache_key] = call_shapes + return call_shapes + + @classmethod + def _infer_get_extra_call_shapes( + cls, get_extra: T.Callable[..., T.Any] + ) -> tuple[str, ...]: try: signature = inspect.signature(get_extra) except (TypeError, ValueError): - signature = None - - if signature is not None: - # Probe call shapes that can be bound by signature to support: - # - get_extra(key, default) - # - get_extra(key) - # - get_extra(*, key, default=None) - # - get_extra(*, key) - candidate_calls = ( - ((key, default), {}), - ((key,), {}), - ((), {"key": key, "default": default}), - ((), {"key": key}), - ) - for args, kwargs in candidate_calls: - try: - signature.bind_partial(*args, **kwargs) - except TypeError: - continue - result = get_extra(*args, **kwargs) - return default if result is None else result - return default + return ("args2", "args1") + + sentinel = object() + probes: tuple[tuple[str, tuple[tuple[T.Any, ...], dict[str, T.Any]]], ...] = ( + ("args2", ((sentinel, sentinel), {})), + ("args1", ((sentinel,), {})), + ("kwargs2", ((), {"key": sentinel, "default": sentinel})), + ("kwargs1", ((), {"key": sentinel})), + ) - try: - result = get_extra(key, default) - return default if result is None else result - except TypeError: - # Keep compatibility with legacy one-arg get_extra(key) call sites - # when signature introspection is unavailable. - result = get_extra(key) - return default if result is None else result + call_shapes: list[str] = [] + for shape, (args, kwargs) in probes: + try: + signature.bind_partial(*args, **kwargs) + except TypeError: + continue + call_shapes.append(shape) + + if not call_shapes: + return ("args2", "args1") + return tuple(call_shapes) @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: From 7da2c40c07e4e1c49681ad30796f9e22b1fc206c Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 01:25:25 -0500 Subject: [PATCH 10/16] refactor(subagent): simplify get_extra shape probing flow --- astrbot/core/astr_agent_tool_exec.py | 80 ++++++++-------------------- 1 file changed, 23 insertions(+), 57 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 3e7c9fc7fe..5298b0b3c5 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -48,7 +48,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = DEFAULT_MAX_HANDOFF_CALLS_PER_RUN _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 - _GET_EXTRA_CALL_SHAPES_CACHE: dict[tuple[type, object], tuple[str, ...]] = {} @classmethod def _coerce_int( @@ -79,68 +78,35 @@ def _get_event_extra( if get_extra is None or not callable(get_extra): return default - call_shapes = cls._resolve_get_extra_call_shapes(event, get_extra) - for call_shape in call_shapes: - if call_shape == "args2": - result = get_extra(key, default) - return default if result is None else result - if call_shape == "args1": - result = get_extra(key) - return default if result is None else result - if call_shape == "kwargs2": - result = get_extra(key=key, default=default) - return default if result is None else result - if call_shape == "kwargs1": - result = get_extra(key=key) - return default if result is None else result + for call in ( + lambda: get_extra(key, default), + lambda: get_extra(key), + lambda: get_extra(key=key, default=default), + lambda: get_extra(key=key), + ): + try: + result = call() + except TypeError as e: + if cls._looks_like_call_signature_type_error(e): + continue + raise + return default if result is None else result return default @classmethod - def _resolve_get_extra_call_shapes( - cls, - event: T.Any, - get_extra: T.Callable[..., T.Any], - ) -> tuple[str, ...]: - get_extra_impl = getattr(get_extra, "__func__", get_extra) - cache_key = (type(event), get_extra_impl) - cached = cls._GET_EXTRA_CALL_SHAPES_CACHE.get(cache_key) - if cached is not None: - return cached - - call_shapes = cls._infer_get_extra_call_shapes(get_extra) - cls._GET_EXTRA_CALL_SHAPES_CACHE[cache_key] = call_shapes - return call_shapes - - @classmethod - def _infer_get_extra_call_shapes( - cls, get_extra: T.Callable[..., T.Any] - ) -> tuple[str, ...]: - try: - signature = inspect.signature(get_extra) - except (TypeError, ValueError): - return ("args2", "args1") - - sentinel = object() - probes: tuple[tuple[str, tuple[tuple[T.Any, ...], dict[str, T.Any]]], ...] = ( - ("args2", ((sentinel, sentinel), {})), - ("args1", ((sentinel,), {})), - ("kwargs2", ((), {"key": sentinel, "default": sentinel})), - ("kwargs1", ((), {"key": sentinel})), + def _looks_like_call_signature_type_error(cls, exc: TypeError) -> bool: + msg = str(exc) + return any( + token in msg + for token in ( + "positional argument", + "keyword argument", + "required positional argument", + "takes", + ) ) - call_shapes: list[str] = [] - for shape, (args, kwargs) in probes: - try: - signature.bind_partial(*args, **kwargs) - except TypeError: - continue - call_shapes.append(shape) - - if not call_shapes: - return ("args2", "args1") - return tuple(call_shapes) - @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: if event is None: From 6f6af71aba3a3b7324607687617314287d91cd02 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 01:31:52 -0500 Subject: [PATCH 11/16] fix(subagent): cache get_extra call style and fail closed on setter errors --- astrbot/core/astr_agent_tool_exec.py | 77 +++++++++++++++++-------- tests/unit/test_astr_agent_tool_exec.py | 15 +++++ 2 files changed, 68 insertions(+), 24 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 5298b0b3c5..9f90972a75 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -48,6 +48,7 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = DEFAULT_MAX_HANDOFF_CALLS_PER_RUN _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 + _GET_EXTRA_CALL_STYLE_CACHE: dict[type, str] = {} @classmethod def _coerce_int( @@ -78,34 +79,62 @@ def _get_event_extra( if get_extra is None or not callable(get_extra): return default - for call in ( - lambda: get_extra(key, default), - lambda: get_extra(key), - lambda: get_extra(key=key, default=default), - lambda: get_extra(key=key), - ): - try: - result = call() - except TypeError as e: - if cls._looks_like_call_signature_type_error(e): - continue - raise - return default if result is None else result + event_type = type(event) + style = cls._GET_EXTRA_CALL_STYLE_CACHE.get(event_type) + if style is None: + style = cls._detect_get_extra_call_style(get_extra) + cls._GET_EXTRA_CALL_STYLE_CACHE[event_type] = style + + if style == "args2": + result = get_extra(key, default) + elif style == "args1": + result = get_extra(key) + elif style == "kwargs2": + result = get_extra(key=key, default=default) + elif style == "kwargs1": + result = get_extra(key=key) + else: + return default - return default + return default if result is None else result @classmethod - def _looks_like_call_signature_type_error(cls, exc: TypeError) -> bool: - msg = str(exc) - return any( - token in msg - for token in ( - "positional argument", - "keyword argument", - "required positional argument", - "takes", + def _detect_get_extra_call_style(cls, get_extra: T.Callable[..., T.Any]) -> str: + try: + signature = inspect.signature(get_extra) + except (TypeError, ValueError): + return "args2" + + params = tuple(signature.parameters.values()) + has_var_positional = any( + p.kind == inspect.Parameter.VAR_POSITIONAL for p in params + ) + has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params) + positional_count = sum( + p.kind + in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, ) + for p in params ) + keyword_only_names = { + p.name for p in params if p.kind == inspect.Parameter.KEYWORD_ONLY + } + + if has_var_positional or positional_count >= 2: + return "args2" + if positional_count >= 1: + return "args1" + if "key" in keyword_only_names and ( + "default" in keyword_only_names or has_var_keyword + ): + return "kwargs2" + if "key" in keyword_only_names: + return "kwargs1" + if has_var_keyword: + return "kwargs2" + return "unsupported" @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: @@ -118,7 +147,7 @@ def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: try: set_extra(key, value) return True - except TypeError: + except Exception: return False @classmethod diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index b58cdceb38..c0272d08f4 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -619,6 +619,21 @@ def test_set_event_extra_returns_false_for_noncallable_set_extra(): ) +def test_set_event_extra_returns_false_for_setter_exception(): + class _EventWithBrokenSetter: + def set_extra(self, _key: str, _value) -> None: + raise ValueError("broken setter") + + assert ( + FunctionToolExecutor._set_event_extra( + _EventWithBrokenSetter(), + "key", + "value", + ) + is False + ) + + def test_handoff_default_limit_is_sourced_from_config_default(): assert ( FunctionToolExecutor._DEFAULT_MAX_HANDOFF_CALLS_PER_RUN From 35ad2b697916544ed468d134aca48276de71cee8 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 01:38:36 -0500 Subject: [PATCH 12/16] refactor(subagent): simplify get_extra dispatch without reflection cache --- astrbot/core/astr_agent_tool_exec.py | 77 +++++++++------------------- 1 file changed, 24 insertions(+), 53 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 9f90972a75..aab84360bf 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -48,7 +48,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = DEFAULT_MAX_HANDOFF_CALLS_PER_RUN _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 - _GET_EXTRA_CALL_STYLE_CACHE: dict[type, str] = {} @classmethod def _coerce_int( @@ -79,62 +78,34 @@ def _get_event_extra( if get_extra is None or not callable(get_extra): return default - event_type = type(event) - style = cls._GET_EXTRA_CALL_STYLE_CACHE.get(event_type) - if style is None: - style = cls._detect_get_extra_call_style(get_extra) - cls._GET_EXTRA_CALL_STYLE_CACHE[event_type] = style - - if style == "args2": - result = get_extra(key, default) - elif style == "args1": - result = get_extra(key) - elif style == "kwargs2": - result = get_extra(key=key, default=default) - elif style == "kwargs1": - result = get_extra(key=key) - else: - return default - - return default if result is None else result + call_variants = ( + lambda: get_extra(key, default), + lambda: get_extra(key), + lambda: get_extra(key=key, default=default), + lambda: get_extra(key=key), + ) + for call in call_variants: + try: + result = call() + except TypeError as e: + if cls._looks_like_call_signature_type_error(e): + continue + raise + return default if result is None else result + return default @classmethod - def _detect_get_extra_call_style(cls, get_extra: T.Callable[..., T.Any]) -> str: - try: - signature = inspect.signature(get_extra) - except (TypeError, ValueError): - return "args2" - - params = tuple(signature.parameters.values()) - has_var_positional = any( - p.kind == inspect.Parameter.VAR_POSITIONAL for p in params - ) - has_var_keyword = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params) - positional_count = sum( - p.kind - in ( - inspect.Parameter.POSITIONAL_ONLY, - inspect.Parameter.POSITIONAL_OR_KEYWORD, + def _looks_like_call_signature_type_error(cls, exc: TypeError) -> bool: + msg = str(exc) + return any( + token in msg + for token in ( + "positional argument", + "keyword argument", + "required positional argument", + "takes", ) - for p in params ) - keyword_only_names = { - p.name for p in params if p.kind == inspect.Parameter.KEYWORD_ONLY - } - - if has_var_positional or positional_count >= 2: - return "args2" - if positional_count >= 1: - return "args1" - if "key" in keyword_only_names and ( - "default" in keyword_only_names or has_var_keyword - ): - return "kwargs2" - if "key" in keyword_only_names: - return "kwargs1" - if has_var_keyword: - return "kwargs2" - return "unsupported" @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: From 2869a7bf00fffe73880d780723db71fd0db0cc5e Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 01:47:00 -0500 Subject: [PATCH 13/16] refactor(subagent): cache get_extra accessor by event type --- astrbot/core/astr_agent_tool_exec.py | 128 +++++++++++++++++++++------ 1 file changed, 100 insertions(+), 28 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index aab84360bf..1c9f4cd9e4 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -48,6 +48,10 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = DEFAULT_MAX_HANDOFF_CALLS_PER_RUN _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 + _EVENT_EXTRA_ACCESSOR_CACHE: dict[ + type, + T.Callable[[T.Any, str, T.Any], T.Any], + ] = {} @classmethod def _coerce_int( @@ -74,38 +78,106 @@ def _get_event_extra( if event is None: return default + accessor = cls._resolve_event_extra_accessor(event) + return accessor(event, key, default) + + @staticmethod + def _signature_accepts_call( + signature: inspect.Signature, + *args: T.Any, + **kwargs: T.Any, + ) -> bool: + try: + signature.bind_partial(*args, **kwargs) + except TypeError: + return False + return True + + @classmethod + def _resolve_event_extra_accessor( + cls, + event: T.Any, + ) -> T.Callable[[T.Any, str, T.Any], T.Any]: + event_type = type(event) + cached_accessor = cls._EVENT_EXTRA_ACCESSOR_CACHE.get(event_type) + if cached_accessor is not None: + return cached_accessor + get_extra = getattr(event, "get_extra", None) if get_extra is None or not callable(get_extra): - return default + def _fallback_accessor( + _event: T.Any, + _key: str, + fallback_default: T.Any = None, + ) -> T.Any: + return fallback_default - call_variants = ( - lambda: get_extra(key, default), - lambda: get_extra(key), - lambda: get_extra(key=key, default=default), - lambda: get_extra(key=key), - ) - for call in call_variants: - try: - result = call() - except TypeError as e: - if cls._looks_like_call_signature_type_error(e): - continue - raise - return default if result is None else result - return default + cls._EVENT_EXTRA_ACCESSOR_CACHE[event_type] = _fallback_accessor + return _fallback_accessor - @classmethod - def _looks_like_call_signature_type_error(cls, exc: TypeError) -> bool: - msg = str(exc) - return any( - token in msg - for token in ( - "positional argument", - "keyword argument", - "required positional argument", - "takes", - ) - ) + try: + signature = inspect.signature(get_extra) + except (TypeError, ValueError): + def _positional_two_accessor( + event_obj: T.Any, + lookup_key: str, + fallback_default: T.Any = None, + ) -> T.Any: + result = event_obj.get_extra(lookup_key, fallback_default) + return fallback_default if result is None else result + + cls._EVENT_EXTRA_ACCESSOR_CACHE[event_type] = _positional_two_accessor + return _positional_two_accessor + + if cls._signature_accepts_call(signature, "sentinel-key", None): + def accessor( + event_obj: T.Any, + lookup_key: str, + fallback_default: T.Any = None, + ) -> T.Any: + result = event_obj.get_extra(lookup_key, fallback_default) + return fallback_default if result is None else result + elif cls._signature_accepts_call(signature, "sentinel-key"): + def accessor( + event_obj: T.Any, + lookup_key: str, + fallback_default: T.Any = None, + ) -> T.Any: + result = event_obj.get_extra(lookup_key) + return fallback_default if result is None else result + elif cls._signature_accepts_call( + signature, + key="sentinel-key", + default=None, + ): + def accessor( + event_obj: T.Any, + lookup_key: str, + fallback_default: T.Any = None, + ) -> T.Any: + result = event_obj.get_extra( + key=lookup_key, + default=fallback_default, + ) + return fallback_default if result is None else result + elif cls._signature_accepts_call(signature, key="sentinel-key"): + def accessor( + event_obj: T.Any, + lookup_key: str, + fallback_default: T.Any = None, + ) -> T.Any: + result = event_obj.get_extra(key=lookup_key) + return fallback_default if result is None else result + else: + def accessor( + _event: T.Any, + _key: str, + fallback_default: T.Any = None, + ) -> T.Any: + return fallback_default + + cls._EVENT_EXTRA_ACCESSOR_CACHE[event_type] = accessor + return accessor @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: From d60176d90a991c457031e044c28dbd37cb52f75f Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 01:51:40 -0500 Subject: [PATCH 14/16] refactor(subagent): simplify get_extra fallback flow --- astrbot/core/astr_agent_tool_exec.py | 124 +++++---------------------- 1 file changed, 23 insertions(+), 101 deletions(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 1c9f4cd9e4..101a1a6a1a 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -48,10 +48,6 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = DEFAULT_MAX_HANDOFF_CALLS_PER_RUN _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 - _EVENT_EXTRA_ACCESSOR_CACHE: dict[ - type, - T.Callable[[T.Any, str, T.Any], T.Any], - ] = {} @classmethod def _coerce_int( @@ -78,106 +74,32 @@ def _get_event_extra( if event is None: return default - accessor = cls._resolve_event_extra_accessor(event) - return accessor(event, key, default) - - @staticmethod - def _signature_accepts_call( - signature: inspect.Signature, - *args: T.Any, - **kwargs: T.Any, - ) -> bool: - try: - signature.bind_partial(*args, **kwargs) - except TypeError: - return False - return True - - @classmethod - def _resolve_event_extra_accessor( - cls, - event: T.Any, - ) -> T.Callable[[T.Any, str, T.Any], T.Any]: - event_type = type(event) - cached_accessor = cls._EVENT_EXTRA_ACCESSOR_CACHE.get(event_type) - if cached_accessor is not None: - return cached_accessor - get_extra = getattr(event, "get_extra", None) - if get_extra is None or not callable(get_extra): - def _fallback_accessor( - _event: T.Any, - _key: str, - fallback_default: T.Any = None, - ) -> T.Any: - return fallback_default + first_type_error: TypeError | None = None + if callable(get_extra): + call_variants = ( + lambda: get_extra(key, default), + lambda: get_extra(key), + lambda: get_extra(key=key, default=default), + lambda: get_extra(key=key), + ) + for call in call_variants: + try: + result = call() + return default if result is None else result + except TypeError as exc: + if first_type_error is None: + first_type_error = exc + continue - cls._EVENT_EXTRA_ACCESSOR_CACHE[event_type] = _fallback_accessor - return _fallback_accessor + extras = getattr(event, "extras", None) + if isinstance(extras, dict): + result = extras.get(key, default) + return default if result is None else result - try: - signature = inspect.signature(get_extra) - except (TypeError, ValueError): - def _positional_two_accessor( - event_obj: T.Any, - lookup_key: str, - fallback_default: T.Any = None, - ) -> T.Any: - result = event_obj.get_extra(lookup_key, fallback_default) - return fallback_default if result is None else result - - cls._EVENT_EXTRA_ACCESSOR_CACHE[event_type] = _positional_two_accessor - return _positional_two_accessor - - if cls._signature_accepts_call(signature, "sentinel-key", None): - def accessor( - event_obj: T.Any, - lookup_key: str, - fallback_default: T.Any = None, - ) -> T.Any: - result = event_obj.get_extra(lookup_key, fallback_default) - return fallback_default if result is None else result - elif cls._signature_accepts_call(signature, "sentinel-key"): - def accessor( - event_obj: T.Any, - lookup_key: str, - fallback_default: T.Any = None, - ) -> T.Any: - result = event_obj.get_extra(lookup_key) - return fallback_default if result is None else result - elif cls._signature_accepts_call( - signature, - key="sentinel-key", - default=None, - ): - def accessor( - event_obj: T.Any, - lookup_key: str, - fallback_default: T.Any = None, - ) -> T.Any: - result = event_obj.get_extra( - key=lookup_key, - default=fallback_default, - ) - return fallback_default if result is None else result - elif cls._signature_accepts_call(signature, key="sentinel-key"): - def accessor( - event_obj: T.Any, - lookup_key: str, - fallback_default: T.Any = None, - ) -> T.Any: - result = event_obj.get_extra(key=lookup_key) - return fallback_default if result is None else result - else: - def accessor( - _event: T.Any, - _key: str, - fallback_default: T.Any = None, - ) -> T.Any: - return fallback_default - - cls._EVENT_EXTRA_ACCESSOR_CACHE[event_type] = accessor - return accessor + if first_type_error is not None: + raise first_type_error + return default @classmethod def _set_event_extra(cls, event: T.Any, key: str, value: T.Any) -> bool: From 476d6c978df522b994a24aeee7a72ba47a1fad37 Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Tue, 24 Mar 2026 02:01:55 -0500 Subject: [PATCH 15/16] fix(subagent): fail closed when handoff limit resolution errors --- astrbot/core/astr_agent_tool_exec.py | 11 ++++++++- tests/unit/test_astr_agent_tool_exec.py | 33 +++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 101a1a6a1a..ebe4d7e960 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -142,7 +142,16 @@ def _check_and_increment_handoff_calls( run_context: ContextWrapper[AstrAgentContext], ) -> tuple[bool, int]: event = run_context.context.event - max_handoff_calls = cls._resolve_handoff_call_limit(run_context) + max_handoff_calls = cls._DEFAULT_MAX_HANDOFF_CALLS_PER_RUN + try: + max_handoff_calls = cls._resolve_handoff_call_limit(run_context) + except Exception as e: + logger.warning( + "Failed to resolve handoff call limit: %s; reject delegation to fail closed.", + e, + ) + return False, max_handoff_calls + try: raw_handoff_count = cls._get_event_extra( event, diff --git a/tests/unit/test_astr_agent_tool_exec.py b/tests/unit/test_astr_agent_tool_exec.py index c0272d08f4..0eb13816b2 100644 --- a/tests/unit/test_astr_agent_tool_exec.py +++ b/tests/unit/test_astr_agent_tool_exec.py @@ -760,6 +760,39 @@ async def _fake_tool_loop_agent(**_kwargs): assert call_count["tool_loop"] == 0 +def test_check_and_increment_handoff_calls_rejects_when_limit_resolution_fails(): + class _EventWithExtras: + def __init__(self) -> None: + self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session" + self.message_obj = SimpleNamespace(message=[]) + self._extras: dict[str, int] = {} + + def get_extra(self, key: str, default=None): + return self._extras.get(key, default) + + def set_extra(self, key: str, value): + self._extras[key] = value + + event = _EventWithExtras() + + def _raise_get_config(**_kwargs): + raise RuntimeError("config unavailable") + + context = SimpleNamespace(get_config=_raise_get_config) + run_context = ContextWrapper(context=SimpleNamespace(event=event, context=context)) + + allowed, max_handoff_calls = FunctionToolExecutor._check_and_increment_handoff_calls( + run_context + ) + + assert allowed is False + assert ( + max_handoff_calls + == FunctionToolExecutor._DEFAULT_MAX_HANDOFF_CALLS_PER_RUN + ) + assert event._extras == {} + + @pytest.mark.asyncio async def test_execute_handoff_rejects_when_event_extra_exceeds_sanity_limit(): call_count = {"tool_loop": 0} From 3ea7f92bad1024d2b39b997a9358c6fbb86999be Mon Sep 17 00:00:00 2001 From: Jacobinwwey Date: Sat, 28 Mar 2026 05:09:32 -0500 Subject: [PATCH 16/16] style(subagent): apply ruff formatting --- astrbot/core/astr_agent_tool_exec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index ebe4d7e960..e1828bd206 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -27,8 +27,8 @@ PYTHON_TOOL, SEND_MESSAGE_TO_USER_TOOL, ) -from astrbot.core.cron.events import CronMessageEvent from astrbot.core.config.default import DEFAULT_MAX_HANDOFF_CALLS_PER_RUN +from astrbot.core.cron.events import CronMessageEvent from astrbot.core.message.components import Image from astrbot.core.message.message_event_result import ( CommandResult,