feat(delegate): show user's actual concurrency / spawn-depth limits in tool description (#22694)

The delegate_task tool description hardcoded 'default 3' / 'default 2' for
max_concurrent_children / max_spawn_depth, which misled the model on any
install that raised these limits — the schema text said 'default 3' even
when the user had set max_concurrent_children=15 / max_spawn_depth=3, so
the model would self-cap at 3 and never use the headroom.

Make the description dynamic. ToolEntry gains an optional
dynamic_schema_overrides callable; registry.get_definitions() merges its
output on top of the static schema before returning it. delegate_tool
registers a builder that reads the current delegation.* config and emits:

- 'up to N items concurrently for this user' (N = max_concurrent_children)
- 'Nested delegation IS enabled / OFF for this user (max_spawn_depth=N)'
- 'orchestrator children can themselves delegate up to M more level(s)'
- 'orchestrator_enabled=false' when the kill switch is set

The model_tools cache key already includes config.yaml mtime+size, so
edits to delegation.* in config invalidate the cached tool definitions
without an explicit hook. CLI_CONFIG staleness within a process is a
pre-existing limitation of _load_config and out of scope here.

Static description / tasks.description / role.description in
DELEGATE_TASK_SCHEMA are placeholders so module import doesn't trigger
cli.CLI_CONFIG load before the test conftest can redirect HERMES_HOME.
This commit is contained in:
Teknium 2026-05-09 11:07:53 -07:00 committed by GitHub
parent 000ddb8a93
commit 1f4200debf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 222 additions and 23 deletions

View file

@ -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):

View file

@ -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,
)

View file

@ -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