diff --git a/tests/tools/test_delegate.py b/tests/tools/test_delegate.py index 8a3efe8eeef..e41137c14d1 100644 --- a/tests/tools/test_delegate.py +++ b/tests/tools/test_delegate.py @@ -75,6 +75,55 @@ class TestDelegateRequirements(unittest.TestCase): self.assertNotIn("max_iterations", props) self.assertNotIn("maxItems", props["tasks"]) # removed — limit is now runtime-configurable + def test_schema_description_advertises_runtime_limits(self): + """The model must see the user's actual concurrency / spawn-depth caps, + not the framework defaults. Without this, models that read 'default 3' + will self-cap below the user's real limit. + """ + from tools.delegate_tool import ( + _build_dynamic_schema_overrides, + _get_max_concurrent_children, + _get_max_spawn_depth, + ) + + overrides = _build_dynamic_schema_overrides() + max_children = _get_max_concurrent_children() + max_depth = _get_max_spawn_depth() + + desc = overrides["description"] + tasks_desc = overrides["parameters"]["properties"]["tasks"]["description"] + role_desc = overrides["parameters"]["properties"]["role"]["description"] + + # Top-level description names the user's concurrency limit explicitly. + self.assertIn(f"up to {max_children}", desc) + # Top-level description names the user's spawn-depth limit explicitly. + self.assertIn(f"max_spawn_depth={max_depth}", desc) + # tasks parameter description repeats the concurrency cap. + self.assertIn(f"up to {max_children}", tasks_desc) + # role parameter description names the spawn-depth limit. + self.assertIn(f"max_spawn_depth={max_depth}", role_desc) + # The misleading "default 3" / "default 2" wording is gone from + # every dynamic surface (model-facing). + for surface in (desc, tasks_desc, role_desc): + self.assertNotIn("default 3", surface) + self.assertNotIn("default 2", surface) + + def test_schema_overrides_applied_via_get_definitions(self): + """Registry.get_definitions() must apply dynamic_schema_overrides so + the model API call sees current values, not the static import-time text. + """ + from tools.registry import registry + defs = registry.get_definitions({"delegate_task"}) + self.assertEqual(len(defs), 1) + fn = defs[0]["function"] + # Description should mention the user's actual limits, not "default 3". + from tools.delegate_tool import ( + _get_max_concurrent_children, + _get_max_spawn_depth, + ) + self.assertIn(f"up to {_get_max_concurrent_children()}", fn["description"]) + self.assertIn(f"max_spawn_depth={_get_max_spawn_depth()}", fn["description"]) + class TestChildSystemPrompt(unittest.TestCase): def test_goal_only(self): diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 3856ce77662..e0511eeb647 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2446,17 +2446,62 @@ def _load_config() -> dict: # OpenAI Function-Calling Schema # --------------------------------------------------------------------------- -DELEGATE_TASK_SCHEMA = { - "name": "delegate_task", - "description": ( + +def _build_top_level_description() -> str: + """Compose the delegate_task tool description with current runtime limits. + + The model needs to know its actual ceilings (not the framework defaults), + otherwise it self-caps at "default 3" / "default 2" even when the user has + raised delegation.max_concurrent_children / max_spawn_depth. Called both + at module import (to seed DELEGATE_TASK_SCHEMA) and on every + get_definitions() call via dynamic_schema_overrides. + """ + try: + max_children = _get_max_concurrent_children() + except Exception: + max_children = _DEFAULT_MAX_CONCURRENT_CHILDREN + try: + max_depth = _get_max_spawn_depth() + except Exception: + max_depth = MAX_DEPTH + try: + orchestrator_on = _get_orchestrator_enabled() + except Exception: + orchestrator_on = True + + if max_depth >= 2 and orchestrator_on: + nesting_clause = ( + f"Nested delegation IS enabled for this user " + f"(max_spawn_depth={max_depth}): pass role='orchestrator' on a " + f"child to let it spawn its own workers, up to {max_depth - 1} " + f"additional level(s) deep." + ) + elif max_depth >= 2 and not orchestrator_on: + nesting_clause = ( + f"Nested delegation is DISABLED on this install " + f"(delegation.orchestrator_enabled=false), even though " + f"max_spawn_depth={max_depth}. role='orchestrator' is silently " + f"forced to 'leaf'." + ) + else: + nesting_clause = ( + f"Nested delegation is OFF for this user " + f"(max_spawn_depth={max_depth}): every child is a leaf and " + f"cannot delegate further. Raise delegation.max_spawn_depth in " + f"config.yaml to enable nesting." + ) + + return ( "Spawn one or more subagents to work on tasks in isolated contexts. " "Each subagent gets its own conversation, terminal session, and toolset. " "Only the final summary is returned -- intermediate tool results " "never enter your context window.\n\n" "TWO MODES (one of 'goal' or 'tasks' is required):\n" "1. Single task: provide 'goal' (+ optional context, toolsets)\n" - "2. Batch (parallel): provide 'tasks' array with up to delegation.max_concurrent_children items (default 3, configurable via config.yaml, no hard ceiling). " - "All run concurrently and results are returned together. Nested delegation requires role='orchestrator' and delegation.max_spawn_depth >= 2.\n\n" + f"2. Batch (parallel): provide 'tasks' array with up to {max_children} " + f"items concurrently for this user (configured via " + f"delegation.max_concurrent_children in config.yaml). " + f"All run in parallel and results are returned together. {nesting_clause}\n\n" "WHEN TO USE delegate_task:\n" "- Reasoning-heavy subtasks (debugging, code review, research synthesis)\n" "- Tasks that would flood your context with intermediate data\n" @@ -2492,11 +2537,101 @@ DELEGATE_TASK_SCHEMA = { "- Orchestrator subagents (role='orchestrator') retain " "delegate_task so they can spawn their own workers, but still " "cannot use clarify, memory, send_message, or execute_code. " - "Orchestrators are bounded by delegation.max_spawn_depth " - "(default 2) and can be disabled globally via " + f"Orchestrators are bounded by max_spawn_depth={max_depth} for this " + f"user and can be disabled globally via " "delegation.orchestrator_enabled=false.\n" "- Each subagent gets its own terminal session (separate working directory and state).\n" "- Results are always returned as an array, one entry per task." + ) + + +def _build_tasks_param_description() -> str: + """Compose the 'tasks' parameter description with current concurrency limit.""" + try: + max_children = _get_max_concurrent_children() + except Exception: + max_children = _DEFAULT_MAX_CONCURRENT_CHILDREN + return ( + f"Batch mode: tasks to run in parallel (up to {max_children} for this " + f"user, set via delegation.max_concurrent_children). Each gets " + "its own subagent with isolated context and terminal session. " + "When provided, top-level goal/context/toolsets are ignored." + ) + + +def _build_role_param_description() -> str: + """Compose the 'role' parameter description with current spawn-depth limit.""" + try: + max_depth = _get_max_spawn_depth() + except Exception: + max_depth = MAX_DEPTH + try: + orchestrator_on = _get_orchestrator_enabled() + except Exception: + orchestrator_on = True + + if max_depth >= 2 and orchestrator_on: + nesting_note = ( + f"Nesting IS enabled for this user (max_spawn_depth={max_depth}): " + f"orchestrator children can themselves delegate up to {max_depth - 1} " + "more level(s) deep." + ) + elif max_depth >= 2 and not orchestrator_on: + nesting_note = ( + "Nesting is currently disabled " + "(delegation.orchestrator_enabled=false); 'orchestrator' is " + "silently forced to 'leaf'." + ) + else: + nesting_note = ( + f"Nesting is OFF for this user (max_spawn_depth={max_depth}); " + "'orchestrator' is silently forced to 'leaf'. Raise " + "delegation.max_spawn_depth in config.yaml to enable." + ) + + return ( + "Role of the child agent. 'leaf' (default) = focused " + "worker, cannot delegate further. 'orchestrator' = can " + f"use delegate_task to spawn its own workers. {nesting_note}" + ) + + +def _build_dynamic_schema_overrides() -> dict: + """Return per-call schema overrides reflecting current config. + + Plugged into ToolEntry.dynamic_schema_overrides so every + get_definitions() pass rewrites the description fields to the user's + actual limits. + """ + overrides_params = { + **DELEGATE_TASK_SCHEMA["parameters"], + } + # Deep-copy properties so we don't mutate the static schema dict. + overrides_params["properties"] = { + k: dict(v) for k, v in DELEGATE_TASK_SCHEMA["parameters"]["properties"].items() + } + overrides_params["properties"]["tasks"]["description"] = _build_tasks_param_description() + overrides_params["properties"]["role"]["description"] = _build_role_param_description() + return { + "description": _build_top_level_description(), + "parameters": overrides_params, + } + + +DELEGATE_TASK_SCHEMA = { + "name": "delegate_task", + # NOTE: description / tasks.description / role.description are placeholder + # values. The real text is generated per get_definitions() call by + # _build_dynamic_schema_overrides() (registered via + # dynamic_schema_overrides below) so the model sees the user's actual + # delegation.max_concurrent_children / max_spawn_depth, not the framework + # defaults. Building these lazily (instead of at module import) also + # avoids forcing cli.CLI_CONFIG to load before the test conftest can + # redirect HERMES_HOME. + "description": ( + "Spawn one or more subagents in isolated contexts. " + "Description is rebuilt at every get_definitions() call to reflect " + "the user's current delegation limits." ), "parameters": { "type": "object", @@ -2564,24 +2699,12 @@ DELEGATE_TASK_SCHEMA = { # No maxItems — the runtime limit is configurable via # delegation.max_concurrent_children (default 3) and # enforced with a clear error in delegate_task(). - "description": ( - "Batch mode: tasks to run in parallel (limit configurable via delegation.max_concurrent_children, default 3). Each gets " - "its own subagent with isolated context and terminal session. " - "When provided, top-level goal/context/toolsets are ignored." - ), + "description": "(rebuilt at get_definitions() time)", }, "role": { "type": "string", "enum": ["leaf", "orchestrator"], - "description": ( - "Role of the child agent. 'leaf' (default) = focused " - "worker, cannot delegate further. 'orchestrator' = can " - "use delegate_task to spawn its own workers. Requires " - "delegation.max_spawn_depth >= 2 in config; ignored " - "(treated as 'leaf') when the child would exceed " - "max_spawn_depth or when " - "delegation.orchestrator_enabled=false." - ), + "description": "(rebuilt at get_definitions() time)", }, "acp_command": { "type": "string", @@ -2627,4 +2750,5 @@ registry.register( ), check_fn=check_delegate_requirements, emoji="🔀", + dynamic_schema_overrides=_build_dynamic_schema_overrides, ) diff --git a/tools/registry.py b/tools/registry.py index 342078191a0..9cac53084bd 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -80,12 +80,12 @@ class ToolEntry: __slots__ = ( "name", "toolset", "schema", "handler", "check_fn", "requires_env", "is_async", "description", "emoji", - "max_result_size_chars", + "max_result_size_chars", "dynamic_schema_overrides", ) def __init__(self, name, toolset, schema, handler, check_fn, requires_env, is_async, description, emoji, - max_result_size_chars=None): + max_result_size_chars=None, dynamic_schema_overrides=None): self.name = name self.toolset = toolset self.schema = schema @@ -96,6 +96,14 @@ class ToolEntry: self.description = description self.emoji = emoji self.max_result_size_chars = max_result_size_chars + # Optional zero-arg callable returning a dict of schema overrides + # applied at get_definitions() time. Use for fields that depend on + # runtime config (e.g. delegate_task's description must reflect the + # user's current delegation.max_concurrent_children / max_spawn_depth + # so the model isn't told the wrong limits). The callable is invoked + # on every get_definitions() call; results are merged shallow on top + # of the base schema before the {"type": "function", ...} wrap. + self.dynamic_schema_overrides = dynamic_schema_overrides # --------------------------------------------------------------------------- @@ -235,6 +243,7 @@ class ToolRegistry: description: str = "", emoji: str = "", max_result_size_chars: int | float | None = None, + dynamic_schema_overrides: Callable = None, ): """Register a tool. Called at module-import time by each tool file.""" with self._lock: @@ -272,6 +281,7 @@ class ToolRegistry: description=description or schema.get("description", ""), emoji=emoji, max_result_size_chars=max_result_size_chars, + dynamic_schema_overrides=dynamic_schema_overrides, ) if check_fn and toolset not in self._toolset_checks: self._toolset_checks[toolset] = check_fn @@ -337,6 +347,22 @@ class ToolRegistry: continue # Ensure schema always has a "name" field — use entry.name as fallback schema_with_name = {**entry.schema, "name": entry.name} + # Apply runtime-dynamic overrides (e.g. delegate_task description + # depends on current delegation.max_concurrent_children / + # max_spawn_depth). Caller side (model_tools.get_tool_definitions) + # already keys its memo on config.yaml mtime + size, so changes + # to delegation.* in config invalidate the cache automatically. + if entry.dynamic_schema_overrides is not None: + try: + overrides = entry.dynamic_schema_overrides() + if isinstance(overrides, dict): + schema_with_name.update(overrides) + except Exception as exc: + logger.warning( + "dynamic_schema_overrides for tool %s raised %s; " + "using static schema", + name, exc, + ) result.append({"type": "function", "function": schema_with_name}) return result