Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions code_puppy/command_line/set_menu_catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 17 additions & 0 deletions code_puppy/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@

logger = logging.getLogger(__name__)

DEFAULT_SUBAGENT_RECURSION_LIMIT = 4


def _get_xdg_dir(env_var: str, fallback: str) -> str:
"""
Expand Down Expand Up @@ -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(
[
Expand Down Expand Up @@ -393,6 +409,7 @@ def get_config_keys():
"summarization_model",
"message_limit",
"allow_recursion",
"subagent_recursion_limit",
"auto_save_session",
"max_saved_sessions",
"http2",
Expand Down
2 changes: 2 additions & 0 deletions code_puppy/i18n/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
45 changes: 30 additions & 15 deletions code_puppy/tools/subagent_invocation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
)
Expand All @@ -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())
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions tests/command_line/test_set_menu_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
125 changes: 125 additions & 0 deletions tests/tools/test_subagent_recursion_limit.py
Original file line number Diff line number Diff line change
@@ -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()
Loading