Skip to content
Closed
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
7 changes: 5 additions & 2 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -790,8 +790,11 @@ code_execution:
# Supports single tasks and batch mode (default 3 parallel, configurable).
delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
# max_concurrent_children: 3 # Max parallel child agents (default: 3)
# max_spawn_depth: 1 # Tree depth cap (1-3, default: 1 = flat). Raise to 2 or 3 to allow orchestrator children to spawn their own workers.
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).
# 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).
# inherit_mcp_toolsets: true # When explicit child toolsets are narrowed, also keep the parent's MCP toolsets (default: true). Set false for strict intersection.
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
Expand Down
42 changes: 42 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -2130,3 +2130,45 @@ def _orchestrator_run(user_message=None, task_id=None):

if __name__ == "__main__":
unittest.main()


class TestSubagentApprovalCallback(unittest.TestCase):
"""fix(delegate): subagent threads must have approval callback set
to prevent deadlock when encountering dangerous commands."""

def test_subagent_executor_initializer_sets_approval_callback(self):
"""ThreadPoolExecutor initializer must set approval callback in
worker thread so dangerous commands do not fall through to input()."""
from concurrent.futures import ThreadPoolExecutor
from tools.terminal_tool import (
set_approval_callback as _set_cb,
_get_approval_callback,
)
from tools.delegate_tool import _subagent_auto_approve

seen_in_worker = []

def worker():
seen_in_worker.append(_get_approval_callback())

with ThreadPoolExecutor(
max_workers=1,
initializer=_set_cb,
initargs=(_subagent_auto_approve,),
) as executor:
executor.submit(worker).result()

self.assertEqual(
seen_in_worker,
[_subagent_auto_approve],
"Worker thread must have _subagent_auto_approve set as approval "
"callback — without this, dangerous commands call input() and "
"deadlock the parent TUI"
)

def test_subagent_auto_approve_returns_once(self):
"""_subagent_auto_approve must return once for any command."""
from tools.delegate_tool import _subagent_auto_approve

result = _subagent_auto_approve("rm -rf /tmp/test", "dangerous command")
self.assertEqual(result, "once")
35 changes: 30 additions & 5 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from toolsets import TOOLSETS
from tools import file_state
from utils import base_url_hostname, is_truthy_value
from tools.terminal_tool import set_approval_callback as _set_subagent_approval_cb


# Tools that children must never have access to
Expand All @@ -47,6 +48,19 @@
]
)

def _subagent_auto_approve(command: str, description: str, **kwargs) -> str:
"""Auto-approve dangerous commands in subagent threads.

Subagents run in ThreadPoolExecutor worker threads and cannot safely
call input() — it competes with the parent's prompt_toolkit TUI for
stdin causing a deadlock. This callback returns 'once' automatically
so subagents can proceed without blocking the parent UI.
"""
logger.warning(
"Subagent auto-approved dangerous command: %s (%s)", command, description
)
return "once"

# Build a description fragment listing toolsets available for subagents.
# Excludes toolsets where ALL tools are blocked, composite/platform toolsets
# (hermes-* prefixed), and scenario toolsets.
Expand Down Expand Up @@ -276,7 +290,14 @@ def _get_max_concurrent_children() -> int:
val = cfg.get("max_concurrent_children")
if val is not None:
try:
return max(1, int(val))
result = max(1, int(val))
if result > 10:
logger.warning(
"delegation.max_concurrent_children=%d: each child consumes API tokens "
"independently. High values multiply cost linearly.",
result,
)
return result
except (TypeError, ValueError):
logger.warning(
"delegation.max_concurrent_children=%r is not a valid integer; "
Expand Down Expand Up @@ -1337,9 +1358,13 @@ def _heartbeat_loop():
# Run child with a hard timeout to prevent indefinite blocking
# when the child's API call or tool-level HTTP request hangs.
child_timeout = _get_child_timeout()
_timeout_executor = ThreadPoolExecutor(max_workers=1)
_timeout_executor = ThreadPoolExecutor(
max_workers=1,
initializer=_set_subagent_approval_cb,
initargs=(_subagent_auto_approve,),
)
# Capture the worker thread so the timeout diagnostic can dump its
# Python stack (see #14726 — 0-API-call hangs are opaque without it).
# Python stack (see #14726 - 0-API-call hangs are opaque without it).
_worker_thread_holder: Dict[str, Optional[threading.Thread]] = {"t": None}

def _run_with_thread_capture():
Expand Down Expand Up @@ -2229,8 +2254,8 @@ def _load_config() -> dict:
"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). "
"All run concurrently and results are returned together.\n\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"
"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"
Expand Down
20 changes: 18 additions & 2 deletions website/docs/guides/delegation-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,24 @@ 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`.
- **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`.

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

Example: running 30 parallel workers with nested subagents:

```yaml
delegation:
max_concurrent_children: 30
max_spawn_depth: 2
```

- **Separate terminals** — each subagent gets its own terminal session with separate working directory and state
- **No conversation history** — subagents see only the `goal` and `context` the parent agent passes when calling `delegate_task`
- **Default 50 iterations** — set `max_iterations` lower for simple tasks to save cost
Expand Down