-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
feat(subagent): guard transfer_to calls with per-run limit #6872
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 2 commits
19eba89
922158b
c3e4abb
c4a0786
0c08ca9
e85c5bf
378cfde
c49494e
cfc13e9
7da2c40
6f6af71
35ad2b6
2869a7b
d60176d
476d6c9
3ea7f92
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -44,6 +44,100 @@ | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _HANDOFF_CALL_COUNT_EXTRA_KEY = "_subagent_handoff_call_count" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (complexity): Consider replacing the multiple generic handoff counter helpers with a single, concrete helper that encapsulates limit resolution, counter incrementing, and fail-closed behavior, and then calling it directly from You can simplify this without losing any behavior by collapsing the fragmented helpers into a single, concrete handoff counter helper and assuming the concrete event API. 1. Collapse
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _DEFAULT_MAX_HANDOFF_CALLS_PER_RUN = 8 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| _MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT = 10_000 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @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( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
sourcery-ai[bot] marked this conversation as resolved.
sourcery-ai[bot] marked this conversation as resolved.
sourcery-ai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| cls, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event: T.Any, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| key: str, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default: T.Any = None, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) -> T.Any: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if event is None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return default | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| get_extra = getattr(event, "get_extra", None) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
sourcery-ai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if get_extra is None: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return default | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return get_extra(key, default) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except TypeError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| result = get_extra(key) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return False | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| set_extra(key, value) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return True | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| except TypeError: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 cls._DEFAULT_MAX_HANDOFF_CALLS_PER_RUN | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return cls._coerce_int( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+119
to
+137
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default value
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| @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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
sourcery-ai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -246,6 +340,23 @@ async def _execute_handoff( | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| tool_args = dict(tool_args) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| input_ = tool_args.get("input") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ctx = run_context.context.context | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| allowed, max_handoff_calls = cls._check_and_increment_handoff_calls(run_context) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if not allowed: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| event = run_context.context.event | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if image_urls_prepared: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| prepared_image_urls = tool_args.get("image_urls") | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if isinstance(prepared_image_urls, list): | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -266,8 +377,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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,6 +41,7 @@ async def get_config(self): | |
| data = { | ||
| "main_enable": False, | ||
| "remove_main_duplicate_tools": False, | ||
| "max_handoff_calls_per_run": DEFAULT_MAX_HANDOFF_CALLS_PER_RUN, | ||
| "agents": [], | ||
| } | ||
|
Comment on lines
37
to
42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The default value |
||
|
|
||
|
|
@@ -50,6 +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", | ||
| DEFAULT_MAX_HANDOFF_CALLS_PER_RUN, | ||
| ) | ||
| data.setdefault("agents", []) | ||
|
|
||
| # Backward/forward compatibility: ensure each agent contains provider_id. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -343,3 +343,219 @@ 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, | ||
|
Comment on lines
+353
to
+362
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (testing): Add tests for default and malformed The new guardrails in To better validate the limit logic, consider parameterizing
These can be small Suggested implementation: assert image_urls == []
@pytest.mark.asyncio
@pytest.mark.parametrize(
"config, expected_limit",
[
# No subagent_orchestrator at all -> default
({}, 32),
# subagent_orchestrator present but unusable -> default
({"subagent_orchestrator": None}, 32),
({"subagent_orchestrator": "not-a-dict"}, 32),
# Values out of range should be clamped
({"subagent_orchestrator": {"max_handoff_calls_per_run": 0}}, 1),
({"subagent_orchestrator": {"max_handoff_calls_per_run": -5}}, 1),
({"subagent_orchestrator": {"max_handoff_calls_per_run": 129}}, 128),
# Non-int values should fall back to default
({"subagent_orchestrator": {"max_handoff_calls_per_run": "abc"}}, 32),
({"subagent_orchestrator": {"max_handoff_calls_per_run": "3.14"}}, 32),
# Valid in-range int should be honored
({"subagent_orchestrator": {"max_handoff_calls_per_run": 10}}, 10),
],
)
async def test_execute_handoff_resolves_call_limit_under_various_configs(
config: dict,
expected_limit: int,
):
"""
Exercise _resolve_handoff_call_limit/_coerce_int indirectly via _execute_handoff.
We run _execute_handoff exactly once and assert that:
* the derived limit is stored on the event extras
* the call count is incremented to 1
* we do not prematurely hit a 'handoff_call_limit_reached' condition
"""
# Event with extras storage, mirroring the shape used in other tests.
class _EventWithExtras:
def __init__(self) -> None:
self.unified_msg_origin = "webchat:FriendMessage:webchat!user!session"
self.message_obj = SimpleNamespace(message=[])
self._extras = {}
@property
def extras(self) -> dict:
return self._extras
def get_extra(self, key, default=None):
return self._extras.get(key, default)
def set_extra(self, key, value):
self._extras[key] = value
event = _EventWithExtras()
# Capture whether the handoff tool actually executes.
handoff_calls = []
async def _fake_handoff_tool(*args, **kwargs):
handoff_calls.append((args, kwargs))
# The concrete constructor signature for FunctionToolExecutor may include
# more parameters; we pass only what is relevant to handoff behavior here.
executor = FunctionToolExecutor(
handoff_tool=_fake_handoff_tool,
get_config=lambda *_args, **_kwargs: config,
)
# Act: execute one handoff
await executor._execute_handoff(event=event, tool_input={})
# Assert: we didn't prematurely stop due to the limit.
assert len(handoff_calls) == 1
# The resolved limit should be written to extras and respect the guardrails.
assert (
event.extras[FunctionToolExecutor._HANDOFF_CALL_LIMIT_EXTRA_KEY]
== expected_limit
)
# Call count should be incremented to 1 on the first call.
assert (
event.extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY]
== 1
)The above tests assume:
|
||
| } | ||
|
|
||
| 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( | ||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||
| 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 | ||
| ) | ||
|
|
||
|
|
||
| @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) | ||
|
Comment on lines
+476
to
+485
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (testing): Consider a test for the You already verify the per-run limit and that the delegate is not invoked once the limit is reached. Since Suggested implementation: @pytest.mark.asyncio
async def test_execute_handoff_rejects_calls_when_event_extra_exceeds_sanity_limit():
# Pre-populate event extras with an extremely large handoff call count and
# verify that calls are rejected even when the configured limit is higher.
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] = {
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) -> None:
self._extras[key] = value
event = _EventWithExtras()
call_count = {"tool_loop": 0}
async def _delegate(*args, **kwargs):
call_count["tool_loop"] += 1
executor = FunctionToolExecutor(
tool_name="tool_loop",
delegate=_delegate,
# Set a very high configured limit so that only the sanity limit
# can prevent additional calls.
handoff_call_limit=10**6,
)
# Because the stored count is above the sanity limit, the executor should
# clamp it and immediately reject further calls.
results = await executor.execute_handoff(event=event)
assert call_count["tool_loop"] == 0
assert results == []
# The stored count should not grow without bound and should be capped by the
# sanity limit.
assert (
event._extras[FunctionToolExecutor._HANDOFF_CALL_COUNT_EXTRA_KEY]
<= FunctionToolExecutor._MAX_HANDOFF_CALL_COUNT_SANITY_LIMIT
)
@pytest.mark.asyncio
async def test_execute_handoff_enforces_call_limit_across_multiple_calls():This patch assumes:
|
||
|
|
||
| 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 | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (complexity): Consider encapsulating all handoff-limit logic into a dedicated helper (e.g., a HandoffLimiter) instead of multiple executor classmethods to keep responsibilities localized and the API clearer.
You can keep the new functionality but reduce complexity by encapsulating all “handoff limit” concerns into a small dedicated helper, instead of spreading it across multiple generic classmethods on
FunctionToolExecutor.1. Encapsulate handoff-limiting logic
Move
_resolve_handoff_call_limit,_get_event_extra,_set_event_extra,_coerce_int, and_check_and_increment_handoff_callsinto a focused helper (or inner class) that holds the event/config context. That removes the cross-cutting helpers from the executor and turns the(bool, int)tuple into a clearer, self-documenting API.Example:
This keeps all existing behavior (including the defensive
get_extra/set_extrahandling and coercion) but localizes it to a single, discoverable place.2. Simplify
_execute_handoffcall siteWith the helper,
_execute_handoffno longer needs to know how counting works or deal with a raw(bool, int)from a distant helper; it just asks the limiter.Actionable benefits:
HandoffLimiter, not scattered across classmethods on the executor._execute_handoffreads linearly: “build args → check limiter → maybe early-return → continue.”