Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
792aaa3
fix(delegate): add _dispatch_delegate_task helper, forward all schema…
pefontana Apr 14, 2026
e9b72b0
refactor(delegate): add DelegateEvent enum with back-compat aliases
pefontana Apr 14, 2026
9e38cca
feat(delegate): raise default concurrency to 5, cap at 8
pefontana Apr 14, 2026
d1a0e5b
test(delegate): add tests for dispatch helper, event enum, concurrency
pefontana Apr 14, 2026
51453fa
refactor(delegate): extract _clamp_concurrency, fix stale comment
pefontana Apr 14, 2026
8d433b6
test(delegate): add thinking/completed event tests, document reserved…
pefontana Apr 14, 2026
220cb71
docs(delegate): fix remaining stale "3 concurrent" references
pefontana Apr 14, 2026
61dbd60
Merge branch 'NousResearch:main' into delegate-dispatch-cleanup
pefontana Apr 14, 2026
cbfc47a
refactor(delegate): drop unused messages param, clarify dispatch docs…
pefontana Apr 15, 2026
e2827b8
refactor(delegate): drop dead default_toolsets from CLI default config
pefontana Apr 15, 2026
c39d1d1
docs(delegate): remove default_toolsets from example config and docs
pefontana Apr 15, 2026
21a3e00
test(delegate): make default_toolsets regression test robust to user …
pefontana Apr 15, 2026
d4f1d2e
feat(delegate): add max_spawn_depth + orchestrator_enabled config
pefontana Apr 15, 2026
de89ec7
feat(delegate): add role param to delegate_task schema and signature
pefontana Apr 15, 2026
7a96d1b
feat(delegate): honor role in _build_child_agent (toolset + prompt)
pefontana Apr 15, 2026
c43fb5c
test(delegate): add end-to-end nested orchestration test
pefontana Apr 15, 2026
55aecde
docs(delegate): document role, max_spawn_depth, and orchestrator_enabled
pefontana Apr 15, 2026
7189d80
fix(delegate): handle TASK_PROGRESS and accept DelegateEvent enum in …
pefontana Apr 15, 2026
6fd5b8e
docs(delegate): update delegation-patterns.md for M3 nested delegation
pefontana Apr 15, 2026
d4f050c
chore(delegate): strip internal milestone tags from code comments
pefontana Apr 15, 2026
42b7792
fix(delegate): align max_concurrent_children default and delegation docs
pefontana Apr 16, 2026
0c23e11
Merge remote-tracking branch 'origin/main' into orchestrator-role
pefontana Apr 20, 2026
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
6 changes: 4 additions & 2 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -770,10 +770,12 @@ code_execution:
# Subagent Delegation
# =============================================================================
# The delegate_task tool spawns child agents with isolated context.
# Supports single tasks and batch mode (up to 3 parallel).
# Supports single tasks and batch mode (up to 5 parallel, max 8).
delegation:
max_iterations: 50 # Max tool-calling turns per child (default: 50)
default_toolsets: ["terminal", "file", "web"] # Default toolsets for subagents
# max_concurrent_children: 5 # Max parallel child agents (default: 5, cap: 8)
# max_spawn_depth: 2 # Tree depth cap (1-3, default: 2). Controls nesting.
# orchestrator_enabled: true # Kill switch for role="orchestrator" children (default: true).
# model: "google/gemini-3-flash-preview" # Override model for subagents (empty = inherit parent)
# provider: "openrouter" # Override provider for subagents (empty = inherit parent)
# # Resolves full credentials (base_url, api_key) automatically.
Expand Down
1 change: 0 additions & 1 deletion cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,6 @@ def load_cli_config() -> Dict[str, Any]:
},
"delegation": {
"max_iterations": 45, # Max tool-calling turns per child agent
"default_toolsets": ["terminal", "file", "web"], # Default toolsets for subagents
"model": "", # Subagent model override (empty = inherit parent model)
"provider": "", # Subagent provider override (empty = inherit parent provider)
"base_url": "", # Direct OpenAI-compatible endpoint for subagents
Expand Down
6 changes: 6 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,12 @@ def _ensure_hermes_home_managed(home: Path):
# independent of the parent's max_iterations)
"reasoning_effort": "", # reasoning effort for subagents: "xhigh", "high", "medium",
# "low", "minimal", "none" (empty = inherit parent's level)
"max_concurrent_children": 5, # max parallel children per batch; clamped to [1, 8]
# 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": 2, # depth cap (1 = flat, 2 = orchestrator→leaf, 3 = three-level)
"orchestrator_enabled": True, # kill switch for role="orchestrator"
},

# Ephemeral prefill messages file — JSON list of {role, content} dicts
Expand Down
2 changes: 1 addition & 1 deletion hermes_cli/tips.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@

# --- Tools & Capabilities ---
"execute_code runs Python scripts that call Hermes tools programmatically — results stay out of context.",
"delegate_task spawns up to 3 concurrent sub-agents with isolated contexts for parallel work.",
"delegate_task spawns up to 5 concurrent sub-agents (max 8) with isolated contexts for parallel work.",
"web_extract works on PDF URLs — pass any PDF link and it converts to markdown.",
"search_files is ripgrep-backed and faster than grep — use it instead of terminal grep.",
"patch uses 9 fuzzy matching strategies so minor whitespace differences won't break edits.",
Expand Down
43 changes: 23 additions & 20 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7536,8 +7536,27 @@ def _execute_tool_calls(self, assistant_message, messages: list, effective_task_
finally:
self._executing_tools = False

def _dispatch_delegate_task(self, function_args: dict) -> str:
"""Single call site for delegate_task dispatch.

New DELEGATE_TASK_SCHEMA fields only need to be added here to reach all
invocation paths (concurrent, sequential, inline).
"""
from tools.delegate_tool import delegate_task as _delegate_task
return _delegate_task(
goal=function_args.get("goal"),
context=function_args.get("context"),
toolsets=function_args.get("toolsets"),
tasks=function_args.get("tasks"),
max_iterations=function_args.get("max_iterations"),
acp_command=function_args.get("acp_command"),
acp_args=function_args.get("acp_args"),
role=function_args.get("role"),
parent_agent=self,
)

def _invoke_tool(self, function_name: str, function_args: dict, effective_task_id: str,
tool_call_id: Optional[str] = None) -> str:
tool_call_id: Optional[str] = None, messages: list = None) -> str:
"""Invoke a single tool and return the result string. No display logic.

Handles both agent-level tools (todo, memory, etc.) and registry-dispatched
Expand Down Expand Up @@ -7605,15 +7624,7 @@ def _invoke_tool(self, function_name: str, function_args: dict, effective_task_i
callback=self.clarify_callback,
)
elif function_name == "delegate_task":
from tools.delegate_tool import delegate_task as _delegate_task
return _delegate_task(
goal=function_args.get("goal"),
context=function_args.get("context"),
toolsets=function_args.get("toolsets"),
tasks=function_args.get("tasks"),
max_iterations=function_args.get("max_iterations"),
parent_agent=self,
)
return self._dispatch_delegate_task(function_args)
else:
return handle_function_call(
function_name, function_args, effective_task_id,
Expand Down Expand Up @@ -7776,7 +7787,7 @@ def _run_tool(index, tool_call, function_name, function_args):
pass
start = time.time()
try:
result = self._invoke_tool(function_name, function_args, effective_task_id, tool_call.id)
result = self._invoke_tool(function_name, function_args, effective_task_id, tool_call.id, messages=messages)
except Exception as tool_error:
result = f"Error executing tool '{function_name}': {tool_error}"
logger.error("_invoke_tool raised for %s: %s", function_name, tool_error, exc_info=True)
Expand Down Expand Up @@ -8129,7 +8140,6 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
if self._should_emit_quiet_tool_messages():
self._vprint(f" {_get_cute_tool_message_impl('clarify', function_args, tool_duration, result=function_result)}")
elif function_name == "delegate_task":
from tools.delegate_tool import delegate_task as _delegate_task
tasks_arg = function_args.get("tasks")
if tasks_arg and isinstance(tasks_arg, list):
spinner_label = f"🔀 delegating {len(tasks_arg)} tasks"
Expand All @@ -8144,14 +8154,7 @@ def _execute_tool_calls_sequential(self, assistant_message, messages: list, effe
self._delegate_spinner = spinner
_delegate_result = None
try:
function_result = _delegate_task(
goal=function_args.get("goal"),
context=function_args.get("context"),
toolsets=function_args.get("toolsets"),
tasks=tasks_arg,
max_iterations=function_args.get("max_iterations"),
parent_agent=self,
)
function_result = self._dispatch_delegate_task(function_args)
_delegate_result = function_result
finally:
self._delegate_spinner = None
Expand Down
4 changes: 2 additions & 2 deletions tests/agent/test_subagent_progress.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,15 @@ def test_task_index_prefix_in_batch_mode(self):

# task_index=0 in a batch of 3 → prefix "[1]"
cb0 = _build_child_progress_callback(0, "test goal", parent, task_count=3)
cb0("web_search", "test")
cb0("tool.started", "web_search", "test", {})
output = buf.getvalue()
assert "[1]" in output

# task_index=2 in a batch of 3 → prefix "[3]"
buf.truncate(0)
buf.seek(0)
cb2 = _build_child_progress_callback(2, "test goal", parent, task_count=3)
cb2("web_search", "test")
cb2("tool.started", "web_search", "test", {})
output = buf.getvalue()
assert "[3]" in output

Expand Down
36 changes: 36 additions & 0 deletions tests/hermes_cli/test_config_drift.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Regression tests for removed dead config keys.

This file guards against accidental re-introduction of config keys that were
documented or declared at some point but never actually wired up to read code.
Future dead-config regressions can accumulate here.
"""

import inspect


def test_delegation_default_toolsets_removed_from_cli_config():
"""delegation.default_toolsets was dead config — never read by
_load_config() or anywhere else. Removed.

Guards against accidental re-introduction in cli.py's CLI_CONFIG default
dict. If this test fails, someone re-added the key without wiring it up
to _load_config() in tools/delegate_tool.py.

We inspect the source of load_cli_config() instead of asserting on the
runtime CLI_CONFIG dict because CLI_CONFIG is populated by deep-merging
the user's ~/.hermes/config.yaml over the defaults (cli.py:359-366).
A contributor who still has the legacy key set in their own config
would cause a false failure, and HERMES_HOME patching via conftest
doesn't help because cli._hermes_home is frozen at module import time
(cli.py:76) — before any autouse fixture can fire. Source inspection
sidesteps all of that: it tests the defaults literal directly.
"""
from cli import load_cli_config

source = inspect.getsource(load_cli_config)
assert '"default_toolsets"' not in source, (
"delegation.default_toolsets was removed because it was never read. "
"Do not re-add it to cli.py's CLI_CONFIG default dict; use "
"tools/delegate_tool.py's DEFAULT_TOOLSETS module constant or "
"wire a new config key through _load_config()."
)
Loading
Loading