From b3b2fad5e74bcd1e794e3bac1106e50af22b94ba Mon Sep 17 00:00:00 2001 From: TJ Webb Date: Tue, 21 Jul 2026 13:06:54 -0700 Subject: [PATCH] feat: cap nested sub-agent invocation depth Adds a configurable global recursion limit with a default of four while preserving the stricter GPT-5.6 caller policy. Refs #660 --- code_puppy/command_line/set_menu_catalog.py | 12 ++ code_puppy/config.py | 17 +++ code_puppy/i18n/locales/en-US.json | 2 + code_puppy/tools/subagent_invocation.py | 45 ++++--- tests/command_line/test_set_menu_values.py | 6 + tests/test_config.py | 2 + tests/tools/test_subagent_recursion_limit.py | 125 +++++++++++++++++++ 7 files changed, 194 insertions(+), 15 deletions(-) create mode 100644 tests/tools/test_subagent_recursion_limit.py diff --git a/code_puppy/command_line/set_menu_catalog.py b/code_puppy/command_line/set_menu_catalog.py index 1d3823d39..3f37ac462 100644 --- a/code_puppy/command_line/set_menu_catalog.py +++ b/code_puppy/command_line/set_menu_catalog.py @@ -57,6 +57,7 @@ get_safety_permission_level, get_smooth_response_stream, get_smooth_thinking_stream, + get_subagent_recursion_limit, get_subagent_verbose, get_summarization_model_name, get_suppress_informational_messages, @@ -158,6 +159,17 @@ type_hint="bool", effective_getter=get_enable_streaming, ), + Setting( + key="subagent_recursion_limit", + display_name="Sub-agent Recursion Limit", + description=( + "Maximum nested sub-agent depth. The main agent is depth 0; " + "directly invoked sub-agents are depth 1. Set to 0 to disable " + "sub-agent invocation." + ), + type_hint="int", + effective_getter=get_subagent_recursion_limit, + ), Setting( key="subagent_verbose", display_name="Sub-agent Verbose", diff --git a/code_puppy/config.py b/code_puppy/config.py index 1271f95b7..27d46a55a 100644 --- a/code_puppy/config.py +++ b/code_puppy/config.py @@ -11,6 +11,8 @@ logger = logging.getLogger(__name__) +DEFAULT_SUBAGENT_RECURSION_LIMIT = 4 + def _get_xdg_dir(env_var: str, fallback: str) -> str: """ @@ -79,6 +81,20 @@ def get_subagent_verbose() -> bool: return str(cfg_val).strip().lower() in {"1", "true", "yes", "on"} +def get_subagent_recursion_limit() -> int: + """Return the maximum nested sub-agent depth (default 4).""" + cfg_val = get_value("subagent_recursion_limit") + if cfg_val is None: + return DEFAULT_SUBAGENT_RECURSION_LIMIT + + try: + limit = int(str(cfg_val).strip()) + except (TypeError, ValueError): + return DEFAULT_SUBAGENT_RECURSION_LIMIT + + return limit if limit >= 0 else DEFAULT_SUBAGENT_RECURSION_LIMIT + + # Pack agents - the specialized sub-agents coordinated by Pack Leader PACK_AGENT_NAMES = frozenset( [ @@ -393,6 +409,7 @@ def get_config_keys(): "summarization_model", "message_limit", "allow_recursion", + "subagent_recursion_limit", "auto_save_session", "max_saved_sessions", "http2", diff --git a/code_puppy/i18n/locales/en-US.json b/code_puppy/i18n/locales/en-US.json index fa967d109..485e58bba 100644 --- a/code_puppy/i18n/locales/en-US.json +++ b/code_puppy/i18n/locales/en-US.json @@ -12,6 +12,8 @@ "other": "Wrote {count} files." }, "error.generic": "Something went wrong: {detail}", + "subagent.recursion_limit_reached": "Sub-agent recursion limit ({limit}) reached; cannot invoke '{agent}'.", + "subagent.gpt_5_6_recursion_blocked": "GPT-5.6 sub-agents cannot invoke '{agent}'.", "version.current": "Current version: {version}", "version.latest": "Latest version: {version}", "version.update_available": "A new version of code puppy is available: {version}", diff --git a/code_puppy/tools/subagent_invocation.py b/code_puppy/tools/subagent_invocation.py index 79d04dc5e..a2e4b8504 100644 --- a/code_puppy/tools/subagent_invocation.py +++ b/code_puppy/tools/subagent_invocation.py @@ -14,7 +14,8 @@ on_agent_run_context, on_wrap_pydantic_agent, ) -from code_puppy.config import get_message_limit +from code_puppy.config import get_message_limit, get_subagent_recursion_limit +from code_puppy.i18n import t from code_puppy.messaging import ( SubAgentInvocationMessage, SubAgentResponseMessage, @@ -35,6 +36,7 @@ ) from code_puppy.tools.common import generate_group_id from code_puppy.tools.subagent_context import ( + get_subagent_depth, get_subagent_model_name, subagent_context, ) @@ -43,7 +45,13 @@ _active_subagent_tasks: Set[asyncio.Task] = set() +def _subagent_recursion_blocked() -> bool: + """Return whether another invocation would exceed the configured depth.""" + return get_subagent_depth() >= get_subagent_recursion_limit() + + def _gpt_5_6_recursion_blocked() -> bool: + """Preserve the stricter single-level policy for GPT-5.6 callers.""" from code_puppy.agents._builder import _is_gpt_5_6_family return _is_gpt_5_6_family(get_subagent_model_name()) @@ -60,13 +68,33 @@ async def _invoke_agent_impl( """Invoke a sub-agent, optionally suppressing its standard response message.""" from code_puppy.agents.agent_manager import load_agent + group_id = generate_group_id("invoke_agent", agent_name) + if _subagent_recursion_blocked(): + error = t( + "subagent.recursion_limit_reached", + limit=get_subagent_recursion_limit(), + agent=agent_name, + ) + elif _gpt_5_6_recursion_blocked(): + error = t("subagent.gpt_5_6_recursion_blocked", agent=agent_name) + else: + error = None + + if error: + emit_error(error, message_group=group_id) + return AgentInvokeOutput( + response=None, + agent_name=agent_name, + model_name=model_name, + error=error, + ) + # Validate user-provided session_id if given if session_id is not None: try: _validate_session_id(session_id) except ValueError as e: # Return error immediately if session_id is invalid - group_id = generate_group_id("invoke_agent", agent_name) emit_error(str(e), message_group=group_id) return AgentInvokeOutput( response=None, @@ -75,9 +103,6 @@ async def _invoke_agent_impl( error=str(e), ) - # Generate a group ID for this tool execution - group_id = generate_group_id("invoke_agent", agent_name) - # Check if this is an existing session or a new one # For user-provided session_id, check if it exists # For None, we'll generate a new one below @@ -163,16 +188,6 @@ async def _invoke_agent_impl( if not effective_model_name: raise ValueError("No model configured for sub-agent invocation") - if _gpt_5_6_recursion_blocked(): - error = f"GPT-5.6 sub-agents cannot invoke '{agent_name}'." - emit_error(error, message_group=group_id) - return AgentInvokeOutput( - response=None, - agent_name=agent_name, - model_name=model_name, - error=error, - ) - # Only proceed if we have a valid model configuration if effective_model_name not in models_config: raise ValueError( diff --git a/tests/command_line/test_set_menu_values.py b/tests/command_line/test_set_menu_values.py index 3f758c5ca..ced6f56ad 100644 --- a/tests/command_line/test_set_menu_values.py +++ b/tests/command_line/test_set_menu_values.py @@ -139,6 +139,12 @@ def test_allow_recursion_is_curated(self): keys = {s.key for _, s in iter_curated_settings()} assert "allow_recursion" in keys + def test_subagent_recursion_limit_is_curated(self): + settings = {s.key: s for _, s in iter_curated_settings()} + setting = settings["subagent_recursion_limit"] + assert setting.type_hint == "int" + assert setting.effective_getter is not None + def test_is_sensitive_key_for_puppy_token(self): assert is_sensitive_key("puppy_token") is True assert is_sensitive_key("yolo_mode") is False diff --git a/tests/test_config.py b/tests/test_config.py index 5405c7484..5a60589af 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -335,6 +335,7 @@ def test_get_config_keys_with_existing_keys( "retry_main_strategy", "retry_subagent_max_attempts", "retry_subagent_strategy", + "subagent_recursion_limit", "summarization_model", "suppress_directory_listing", "temperature", @@ -402,6 +403,7 @@ def test_get_config_keys_empty_config( "retry_main_strategy", "retry_subagent_max_attempts", "retry_subagent_strategy", + "subagent_recursion_limit", "summarization_model", "suppress_directory_listing", "temperature", diff --git a/tests/tools/test_subagent_recursion_limit.py b/tests/tools/test_subagent_recursion_limit.py new file mode 100644 index 000000000..e2f02c87c --- /dev/null +++ b/tests/tools/test_subagent_recursion_limit.py @@ -0,0 +1,125 @@ +"""Tests for the global nested sub-agent recursion limit.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from code_puppy.config import ( + DEFAULT_SUBAGENT_RECURSION_LIMIT, + get_config_keys, + get_subagent_recursion_limit, +) +from code_puppy.tools.agent_tools import AgentInvokeOutput +from code_puppy.tools.subagent_context import subagent_context +from code_puppy.tools.subagent_invocation import ( + _subagent_recursion_blocked, + register_invoke_agent, + register_invoke_agent_with_model, +) + + +@pytest.mark.parametrize("configured", [None, "", "not-a-number", "-1"]) +def test_recursion_limit_defaults_for_missing_or_invalid_values(configured): + with patch("code_puppy.config.get_value", return_value=configured): + assert get_subagent_recursion_limit() == DEFAULT_SUBAGENT_RECURSION_LIMIT == 4 + + +@pytest.mark.parametrize(("configured", "expected"), [("0", 0), ("7", 7)]) +def test_recursion_limit_uses_configured_nonnegative_integer(configured, expected): + with patch("code_puppy.config.get_value", return_value=configured): + assert get_subagent_recursion_limit() == expected + + +def test_recursion_limit_is_discoverable_by_config_commands(): + assert "subagent_recursion_limit" in get_config_keys() + + +def test_recursion_guard_allows_calls_below_limit_and_blocks_at_limit(): + with patch( + "code_puppy.tools.subagent_invocation.get_subagent_recursion_limit", + return_value=2, + ): + assert not _subagent_recursion_blocked() + with subagent_context("first"): + assert not _subagent_recursion_blocked() + with subagent_context("second"): + assert _subagent_recursion_blocked() + + +def _capture_tool(register_tool): + agent = MagicMock() + captured = {} + agent.tool.side_effect = lambda func: captured.setdefault("tool", func) + register_tool(agent) + return captured["tool"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("register_tool", "extra_kwargs"), + [ + (register_invoke_agent, {}), + (register_invoke_agent_with_model, {"model_name": "test-model"}), + ], +) +async def test_invocation_tools_stop_before_loading_agent_at_limit( + register_tool, extra_kwargs +): + tool = _capture_tool(register_tool) + + with ( + patch( + "code_puppy.tools.subagent_invocation.get_subagent_recursion_limit", + return_value=1, + ), + patch("code_puppy.tools.subagent_invocation.emit_error") as emit_error, + patch("code_puppy.agents.agent_manager.load_agent") as load_agent, + subagent_context("parent"), + ): + result = await tool( + MagicMock(), + agent_name="child", + prompt="delegate this", + **extra_kwargs, + ) + + assert isinstance(result, AgentInvokeOutput) + assert result.response is None + assert result.error == ( + "Sub-agent recursion limit (1) reached; cannot invoke 'child'." + ) + emit_error.assert_called_once() + load_agent.assert_not_called() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("register_tool", "extra_kwargs"), + [ + (register_invoke_agent, {}), + (register_invoke_agent_with_model, {"model_name": "test-model"}), + ], +) +async def test_invocation_tools_preserve_gpt_5_6_single_level_cap( + register_tool, extra_kwargs +): + tool = _capture_tool(register_tool) + + with ( + patch( + "code_puppy.tools.subagent_invocation.get_subagent_recursion_limit", + return_value=4, + ), + patch("code_puppy.tools.subagent_invocation.emit_error"), + patch("code_puppy.agents.agent_manager.load_agent") as load_agent, + subagent_context("parent", "gpt-5.6-sol"), + ): + result = await tool( + MagicMock(), + agent_name="child", + prompt="delegate this", + **extra_kwargs, + ) + + assert result.error == "GPT-5.6 sub-agents cannot invoke 'child'." + load_agent.assert_not_called()