mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-09 08:21:50 +00:00
feat(delegation): uncap max_spawn_depth (floor 1, no ceiling) (#39772)
* fix: respect disabled auto-compaction on context overflow Port from anomalyco/opencode#30749. When compression.enabled is false, NO automatic compaction trigger may fire. The proactive token-threshold paths (preflight + post-response should_compress gate) already honoured the setting, but the three provider-overflow recovery paths in the agent loop — long-context-tier 429, 413 payload-too-large, and context-overflow — called _compress_context() unconditionally, silently compressing and rotating the session against the user's explicit choice. Add a single guard at the top of the overflow-recovery dispatch: when compression is disabled and the error is one of those three overflow classes, surface a terminal error (compaction_disabled: True) telling the user to /compress manually, /new, switch to a larger-context model, or reduce attachments. Manual /compress (force=True) is unaffected — it never enters this loop. Tests: new TestOverflowWithCompactionDisabled (413 + 400 overflow don't compress when disabled; control case still compresses when enabled). Existing overflow-recovery tests updated to enable compaction explicitly (they verify the recovery fires); fixture defaults flipped to True to match production (compression.enabled defaults to True). * feat(delegation): uncap max_spawn_depth to match max_concurrent_children Removed the hard ceiling of 3 on delegation.max_spawn_depth. Depth now has a floor of 1 and no upper limit, mirroring max_concurrent_children. Cost (each level multiplies API spend) is the practical limiter, not a constant. - delegate_tool.py: drop _MAX_SPAWN_DEPTH_CAP, _get_max_spawn_depth() floors at 1 instead of clamping to [1,3]; depth-limit error string reworded - config.py / cli-config.yaml.example: doc comments say floor 1, no ceiling - docs (configuration, delegation, delegation-patterns): range 1-3 -> >=1 - tests: convert clamp-above-3 change-detector into a no-ceiling invariant, drop the _MAX_SPAWN_DEPTH_CAP==3 snapshot assert, fix warning-text assert
This commit is contained in:
parent
06268f11cc
commit
d41427504e
7 changed files with 30 additions and 30 deletions
|
|
@ -871,7 +871,7 @@ delegation:
|
|||
max_iterations: 50 # Max tool-calling turns per child (default: 50)
|
||||
# max_concurrent_children: 3 # Max parallel child agents per batch (default: 3, floor: 1, no ceiling).
|
||||
# WARNING: values above 10 multiply API cost linearly.
|
||||
# max_spawn_depth: 1 # Delegation tree depth cap (range: 1-3, default: 1 = flat).
|
||||
# max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling; default: 1 = flat).
|
||||
# Raise to 2 to allow workers to spawn their own subagents.
|
||||
# Requires role="orchestrator" on intermediate agents.
|
||||
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
|
||||
|
|
|
|||
|
|
@ -1677,9 +1677,9 @@ DEFAULT_CONFIG = {
|
|||
# "low", "minimal", "none" (empty = inherit parent's level)
|
||||
"max_concurrent_children": 3, # max parallel children per batch; floor of 1 enforced, no ceiling
|
||||
# Orchestrator role controls (see tools/delegate_tool.py:_get_max_spawn_depth
|
||||
# and _get_orchestrator_enabled). Values are clamped to [1, 3] with a
|
||||
# warning log if out of range.
|
||||
"max_spawn_depth": 1, # depth cap (1 = flat [default], 2 = orchestrator→leaf, 3 = three-level)
|
||||
# and _get_orchestrator_enabled). Floored at 1, no upper ceiling —
|
||||
# raise deliberately, each level multiplies API cost.
|
||||
"max_spawn_depth": 1, # depth (1 = flat [default], 2 = orchestrator→leaf, 3+ = deeper)
|
||||
"orchestrator_enabled": True, # kill switch for role="orchestrator"
|
||||
# When a subagent hits a dangerous-command approval prompt, the parent's
|
||||
# prompt_toolkit TUI owns stdin — a thread-local input() call from the
|
||||
|
|
|
|||
|
|
@ -838,14 +838,13 @@ class TestBlockedTools(unittest.TestCase):
|
|||
def test_constants(self):
|
||||
from tools.delegate_tool import (
|
||||
_get_max_spawn_depth, _get_orchestrator_enabled,
|
||||
_MIN_SPAWN_DEPTH, _MAX_SPAWN_DEPTH_CAP,
|
||||
_MIN_SPAWN_DEPTH,
|
||||
)
|
||||
self.assertEqual(_get_max_concurrent_children(), 3)
|
||||
self.assertEqual(MAX_DEPTH, 1)
|
||||
self.assertEqual(_get_max_spawn_depth(), 1) # default: flat
|
||||
self.assertTrue(_get_orchestrator_enabled()) # default
|
||||
self.assertEqual(_MIN_SPAWN_DEPTH, 1)
|
||||
self.assertEqual(_MAX_SPAWN_DEPTH_CAP, 3)
|
||||
|
||||
|
||||
class TestDelegationCredentialResolution(unittest.TestCase):
|
||||
|
|
@ -2084,17 +2083,14 @@ class TestMaxSpawnDepth(unittest.TestCase):
|
|||
with self.assertLogs("tools.delegate_tool", level=logging.WARNING) as cm:
|
||||
result = _get_max_spawn_depth()
|
||||
self.assertEqual(result, 1)
|
||||
self.assertTrue(any("clamping to 1" in m for m in cm.output))
|
||||
self.assertTrue(any("below floor 1" in m for m in cm.output))
|
||||
|
||||
@patch("tools.delegate_tool._load_config",
|
||||
return_value={"max_spawn_depth": 99})
|
||||
def test_max_spawn_depth_clamped_above_three(self, mock_cfg):
|
||||
import logging
|
||||
def test_max_spawn_depth_no_upper_ceiling(self, mock_cfg):
|
||||
"""No upper ceiling — high values pass through unchanged (cost is the limiter)."""
|
||||
from tools.delegate_tool import _get_max_spawn_depth
|
||||
with self.assertLogs("tools.delegate_tool", level=logging.WARNING) as cm:
|
||||
result = _get_max_spawn_depth()
|
||||
self.assertEqual(result, 3)
|
||||
self.assertTrue(any("clamping to 3" in m for m in cm.output))
|
||||
self.assertEqual(_get_max_spawn_depth(), 99)
|
||||
|
||||
@patch("tools.delegate_tool._load_config",
|
||||
return_value={"max_spawn_depth": "not-a-number"})
|
||||
|
|
|
|||
|
|
@ -134,7 +134,9 @@ MAX_DEPTH = 1 # flat by default: parent (0) -> child (1); grandchild rejected u
|
|||
# Configurable depth cap consulted by _get_max_spawn_depth; MAX_DEPTH
|
||||
# stays as the default fallback and is still the symbol tests import.
|
||||
_MIN_SPAWN_DEPTH = 1
|
||||
_MAX_SPAWN_DEPTH_CAP = 3
|
||||
# No upper ceiling on spawn depth — like max_concurrent_children, depth has a
|
||||
# floor of 1 and no ceiling. Deeper trees multiply API cost, so the default
|
||||
# stays flat (MAX_DEPTH = 1); raising the config knob is an explicit opt-in.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -392,7 +394,7 @@ def _get_child_timeout() -> float:
|
|||
|
||||
|
||||
def _get_max_spawn_depth() -> int:
|
||||
"""Read delegation.max_spawn_depth from config, clamped to [1, 3].
|
||||
"""Read delegation.max_spawn_depth from config, floored at 1 (no ceiling).
|
||||
|
||||
depth 0 = parent agent. max_spawn_depth = N means agents at depths
|
||||
0..N-1 can spawn; depth N is the leaf floor. Default 1 is flat:
|
||||
|
|
@ -400,9 +402,11 @@ def _get_max_spawn_depth() -> int:
|
|||
(blocked by this guard AND, for leaf children, by the delegation
|
||||
toolset strip in _strip_blocked_tools).
|
||||
|
||||
Raise to 2 or 3 to unlock nested orchestration. role="orchestrator"
|
||||
removes the toolset strip for depth-1 children when
|
||||
Raise to 2+ to unlock nested orchestration. role="orchestrator"
|
||||
removes the toolset strip for spawning children when
|
||||
max_spawn_depth >= 2, enabling them to spawn their own workers.
|
||||
Like max_concurrent_children, there is no upper ceiling — but each
|
||||
extra level multiplies API cost, so raise it deliberately.
|
||||
"""
|
||||
cfg = _load_config()
|
||||
val = cfg.get("max_spawn_depth")
|
||||
|
|
@ -417,16 +421,15 @@ def _get_max_spawn_depth() -> int:
|
|||
MAX_DEPTH,
|
||||
)
|
||||
return MAX_DEPTH
|
||||
clamped = max(_MIN_SPAWN_DEPTH, min(_MAX_SPAWN_DEPTH_CAP, ival))
|
||||
if clamped != ival:
|
||||
floored = max(_MIN_SPAWN_DEPTH, ival)
|
||||
if floored != ival:
|
||||
logger.warning(
|
||||
"delegation.max_spawn_depth=%d out of range [%d, %d]; " "clamping to %d",
|
||||
"delegation.max_spawn_depth=%d below floor %d; using %d",
|
||||
ival,
|
||||
_MIN_SPAWN_DEPTH,
|
||||
_MAX_SPAWN_DEPTH_CAP,
|
||||
clamped,
|
||||
floored,
|
||||
)
|
||||
return clamped
|
||||
return floored
|
||||
|
||||
|
||||
def _get_orchestrator_enabled() -> bool:
|
||||
|
|
@ -1982,7 +1985,8 @@ def delegate_task(
|
|||
f"Delegation depth limit reached (depth={depth}, "
|
||||
f"max_spawn_depth={max_spawn}). Raise "
|
||||
f"delegation.max_spawn_depth in config.yaml if deeper "
|
||||
f"nesting is required (cap: {_MAX_SPAWN_DEPTH_CAP})."
|
||||
f"nesting is required (no hard ceiling, but each level "
|
||||
f"multiplies API cost)."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -218,14 +218,14 @@ Restricting toolsets keeps the subagent focused and prevents accidental side eff
|
|||
## Constraints
|
||||
|
||||
- **Default 3 parallel tasks**: batches default to 3 concurrent subagents (configurable via `delegation.max_concurrent_children` in config.yaml, no hard ceiling, only a floor of 1)
|
||||
- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (1-3 supported); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
|
||||
- **Nested delegation is opt-in**: leaf subagents (default) cannot call `delegate_task`, `clarify`, `memory`, `send_message`, or `execute_code`. Orchestrator subagents (`role="orchestrator"`) retain `delegate_task` for further delegation, but only when `delegation.max_spawn_depth` is raised above the default of 1 (floor 1, no ceiling); the other four remain blocked. Disable globally via `delegation.orchestrator_enabled: false`.
|
||||
|
||||
### Tuning Concurrency and Depth
|
||||
|
||||
| Config | Default | Range | Effect |
|
||||
|--------|---------|-------|--------|
|
||||
| `max_concurrent_children` | 3 | >=1 | Parallel batch size per `delegate_task` call |
|
||||
| `max_spawn_depth` | 1 | 1-3 | How many delegation levels can spawn further |
|
||||
| `max_spawn_depth` | 1 | >=1 | How many delegation levels can spawn further |
|
||||
|
||||
Example: running 30 parallel workers with nested subagents:
|
||||
|
||||
|
|
|
|||
|
|
@ -1672,7 +1672,7 @@ delegation:
|
|||
# api_key: "local-key" # API key for base_url (falls back to OPENAI_API_KEY)
|
||||
# api_mode: "" # Wire protocol for base_url: "chat_completions", "codex_responses", or "anthropic_messages". Empty = auto-detect from URL (e.g. /anthropic suffix → anthropic_messages). Set explicitly for non-standard endpoints the heuristic can't detect.
|
||||
max_concurrent_children: 3 # Parallel children per batch (floor 1, no ceiling). Also via DELEGATION_MAX_CONCURRENT_CHILDREN env var.
|
||||
max_spawn_depth: 1 # Delegation tree depth cap (1-3, clamped). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3 = three levels.
|
||||
max_spawn_depth: 1 # Delegation tree depth (floor 1, no ceiling). 1 = flat (default): parent spawns leaves that cannot delegate. 2 = orchestrator children can spawn leaf grandchildren. 3+ = deeper trees.
|
||||
orchestrator_enabled: true # Global kill switch. When false, role="orchestrator" is ignored and every child is forced to leaf regardless of max_spawn_depth.
|
||||
```
|
||||
|
||||
|
|
@ -1686,7 +1686,7 @@ The delegation provider uses the same credential resolution as CLI/gateway start
|
|||
|
||||
**Precedence:** `delegation.base_url` in config → `delegation.provider` in config → parent provider (inherited). `delegation.model` in config → parent model (inherited). Setting just `model` without `provider` changes only the model name while keeping the parent's credentials (useful for switching models within the same provider like OpenRouter).
|
||||
|
||||
**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (clamped to 1-3). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns.
|
||||
**Width and depth:** `max_concurrent_children` caps how many subagents run in parallel per batch (default `3`, floor of 1, no ceiling). Can also be set via the `DELEGATION_MAX_CONCURRENT_CHILDREN` env var. When the model submits a `tasks` array longer than the cap, `delegate_task` returns a tool error explaining the limit rather than silently truncating. `max_spawn_depth` controls the delegation tree depth (floor of 1, no upper ceiling). At the default `1`, delegation is flat: children cannot spawn grandchildren, and passing `role="orchestrator"` silently degrades to `leaf`. Raise to `2` so orchestrator children can spawn leaf grandchildren; `3` for three-level trees, and higher for deeper ones. The agent opts into orchestration per call via `role="orchestrator"`; `orchestrator_enabled: false` forces every child back to leaf regardless. Cost scales multiplicatively — at `max_spawn_depth: 3` with `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. See [Subagent Delegation → Depth Limit and Nested Orchestration](features/delegation.md#depth-limit-and-nested-orchestration) for usage patterns.
|
||||
|
||||
## Clarify
|
||||
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ delegate_task(
|
|||
```
|
||||
|
||||
- `role="leaf"` (default): child cannot delegate further — identical to the flat-delegation behavior.
|
||||
- `role="orchestrator"`: child retains the `delegation` toolset. Gated by `delegation.max_spawn_depth` (default **1** = flat, so `role="orchestrator"` is a no-op at defaults). Raise `max_spawn_depth` to 2 to allow orchestrator children to spawn leaf grandchildren; 3 for three levels (cap).
|
||||
- `role="orchestrator"`: child retains the `delegation` toolset. Gated by `delegation.max_spawn_depth` (default **1** = flat, so `role="orchestrator"` is a no-op at defaults). Raise `max_spawn_depth` to 2 to allow orchestrator children to spawn leaf grandchildren; 3+ for deeper trees. There is no upper ceiling — cost is the practical limit.
|
||||
- `delegation.orchestrator_enabled: false`: global kill switch that forces every child to `leaf` regardless of the `role` parameter.
|
||||
|
||||
**Cost warning:** With `max_spawn_depth: 3` and `max_concurrent_children: 3`, the tree can reach 3×3×3 = 27 concurrent leaf agents. Each extra level multiplies spend — raise `max_spawn_depth` intentionally.
|
||||
|
|
@ -264,7 +264,7 @@ For **durable long-running work** that must survive interrupts or outlive the cu
|
|||
delegation:
|
||||
max_iterations: 50 # Max turns per child (default: 50)
|
||||
# max_concurrent_children: 3 # Parallel children per batch (default: 3)
|
||||
# max_spawn_depth: 1 # Tree depth (1-3, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3 for three levels.
|
||||
# max_spawn_depth: 1 # Tree depth (floor 1, no ceiling, default 1 = flat). Raise to 2 to allow orchestrator children to spawn leaves; 3+ for deeper trees.
|
||||
# orchestrator_enabled: true # Disable to force all children to leaf role.
|
||||
model: "google/gemini-3-flash-preview" # Optional provider/model override
|
||||
provider: "openrouter" # Optional built-in provider
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue