mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
000ddb8a93
commit
1f4200debf
3 changed files with 222 additions and 23 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue