Skip to content
Merged
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
1 change: 0 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -5581,7 +5581,6 @@ def _dispatch_delegate_task(self, function_args: dict) -> str:
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"),
Expand Down
2 changes: 1 addition & 1 deletion skills/autonomous-ai-agents/hermes-agent/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,7 @@ here; full developer notes live in `AGENTS.md`, user-facing docs under

Spawn a subagent with an isolated context + terminal session.

- **Single:** `delegate_task(goal, context, toolsets)`.
- **Single:** `delegate_task(goal, context)`.
- **Batch:** `delegate_task(tasks=[{goal, ...}, ...])` runs children in
parallel, capped by `delegation.max_concurrent_children` (default 3).
- **Background:** `delegate_task(background=true)` returns a handle
Expand Down
26 changes: 25 additions & 1 deletion tests/tools/test_async_delegation.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ def slow_child(task_index, goal, child=None, parent_agent=None, **kw):
monkeypatch.setattr(dt, "_run_single_child", slow_child)
monkeypatch.setattr(dt, "_resolve_delegation_credentials", lambda *a, **k: creds)
out = dt.delegate_task(
goal="the real task", context="ctx", toolsets=["web"],
goal="the real task", context="ctx",
background=True, parent_agent=parent,
)

Expand Down Expand Up @@ -422,6 +422,30 @@ def _fake_delegate(**kwargs):
assert captured["background"] is False


def test_dispatch_never_forwards_model_toolsets():
"""The model has no toolsets argument — subagents always inherit the
parent's toolsets. Even if a model smuggles a `toolsets` key into the
tool-call args, the live dispatch path must NOT forward it to
delegate_task (which no longer accepts it) and must not crash."""
from unittest.mock import patch
import run_agent

class _FakeAgent:
_delegate_depth = 0

captured = {}

def _fake_delegate(**kwargs):
captured.update(kwargs)
return "{}"

with patch("tools.delegate_tool.delegate_task", _fake_delegate):
run_agent.AIAgent._dispatch_delegate_task(
_FakeAgent(), {"goal": "x", "toolsets": ["web", "terminal"]}
)
assert "toolsets" not in captured


def test_delegate_task_background_detaches_child_from_parent(monkeypatch):
"""A background child must NOT remain in parent._active_children —
otherwise parent-turn interrupts / cache evicts / session close would
Expand Down
6 changes: 5 additions & 1 deletion tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,11 @@ def test_schema_valid(self):
self.assertIn("goal", props)
self.assertIn("tasks", props)
self.assertIn("context", props)
self.assertIn("toolsets", props)
# toolsets is intentionally NOT exposed to the model — subagents always
# inherit the parent's toolsets. Letting the model name toolsets was a
# capability-selection surface the model should not control.
self.assertNotIn("toolsets", props)
self.assertNotIn("toolsets", props["tasks"]["items"]["properties"])
# max_iterations is intentionally NOT exposed to the model — it's
# config-authoritative via delegation.max_iterations so users get
# predictable budgets.
Expand Down
52 changes: 10 additions & 42 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,24 +111,9 @@ def _get_subagent_approval_callback():
return _subagent_auto_approve
return _subagent_auto_deny

# Build a description fragment listing toolsets available for subagents.
# Excludes toolsets where ALL tools are blocked, composite/platform toolsets
# (hermes-* prefixed), and scenario toolsets.
#
# NOTE: "delegation" is in this exclusion set so the subagent-facing
# capability hint string (_TOOLSET_LIST_STR) doesn't advertise it as a
# toolset to request explicitly — the correct mechanism for nested
# delegation is role='orchestrator', which re-adds "delegation" in
# _build_child_agent regardless of this exclusion.
_EXCLUDED_TOOLSET_NAMES = frozenset({"debugging", "safe", "delegation", "rl"})
_SUBAGENT_TOOLSETS = sorted(
name
for name, defn in TOOLSETS.items()
if name not in _EXCLUDED_TOOLSET_NAMES
and not name.startswith("hermes-")
and not all(t in DELEGATE_BLOCKED_TOOLS for t in defn.get("tools", []))
)
_TOOLSET_LIST_STR = ", ".join(f"'{n}'" for n in _SUBAGENT_TOOLSETS)
# NOTE: nested delegation is granted by role='orchestrator' (which re-adds the
# "delegation" toolset in _build_child_agent), NOT by the model naming toolsets
# — the model has no toolsets argument. Subagents inherit the parent's toolsets.

_DEFAULT_MAX_CONCURRENT_CHILDREN = 3
# One-shot guard: the high-concurrency cost advisory is emitted at most once
Expand Down Expand Up @@ -2354,7 +2339,6 @@ def _recover_tasks_from_json_string(
def delegate_task(
goal: Optional[str] = None,
context: Optional[str] = None,
toolsets: Optional[List[str]] = None,
tasks: Optional[List[Dict[str, Any]]] = None,
max_iterations: Optional[int] = None,
acp_command: Optional[str] = None,
Expand Down Expand Up @@ -2461,9 +2445,7 @@ def delegate_task(
)
task_list = tasks
elif goal and isinstance(goal, str) and goal.strip():
task_list = [
{"goal": goal, "context": context, "toolsets": toolsets, "role": top_role}
]
task_list = [{"goal": goal, "context": context, "role": top_role}]
else:
return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).")

Expand Down Expand Up @@ -2507,7 +2489,9 @@ def delegate_task(
task_index=i,
goal=t["goal"],
context=t.get("context"),
toolsets=t.get("toolsets") or toolsets,
# Subagents always inherit the parent's toolsets; the model
# cannot choose or narrow them (no model-facing toolsets arg).
toolsets=None,
model=creds["model"],
max_iterations=effective_max_iter,
task_count=n_tasks,
Expand Down Expand Up @@ -2848,7 +2832,9 @@ def _batch_interrupt():
dispatch = dispatch_async_delegation_batch(
goals=_goals,
context=context,
toolsets=toolsets,
# Metadata for the completion block only; subagents inherit the
# parent's toolsets (no model-facing toolsets arg).
toolsets=None,
role=top_role,
model=creds["model"],
session_key=_session_key,
Expand Down Expand Up @@ -3387,18 +3373,6 @@ def _build_dynamic_schema_overrides() -> dict:
"specific you are, the better the subagent performs."
),
},
"toolsets": {
"type": "array",
"items": {"type": "string"},
"description": (
"Toolsets to enable for this subagent. "
"Default: inherits your enabled toolsets. "
f"Available toolsets: {_TOOLSET_LIST_STR}. "
"Common patterns: ['terminal', 'file'] for code work, "
"['web'] for research, ['browser'] for web interaction, "
"['terminal', 'file', 'web'] for full-stack tasks."
),
},
"tasks": {
"type": "array",
"items": {
Expand All @@ -3409,11 +3383,6 @@ def _build_dynamic_schema_overrides() -> dict:
"type": "string",
"description": "Task-specific context",
},
"toolsets": {
"type": "array",
"items": {"type": "string"},
"description": f"Toolsets for this specific task. Available: {_TOOLSET_LIST_STR}. Use 'web' for network access, 'terminal' for shell, 'browser' for web interaction.",
},
"acp_command": {
"type": "string",
"description": (
Expand Down Expand Up @@ -3512,7 +3481,6 @@ def _model_background_value(args: dict, parent_agent=None) -> bool:
handler=lambda args, **kw: delegate_task(
goal=args.get("goal"),
context=args.get("context"),
toolsets=args.get("toolsets"),
tasks=args.get("tasks"),
max_iterations=args.get("max_iterations"),
acp_command=args.get("acp_command"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -647,7 +647,7 @@ here; full developer notes live in `AGENTS.md`, user-facing docs under
Synchronous subagent spawn — the parent waits for the child's summary
before continuing its own loop. Isolated context + terminal session.

- **Single:** `delegate_task(goal, context, toolsets)`.
- **Single:** `delegate_task(goal, context)`.
- **Batch:** `delegate_task(tasks=[{goal, ...}, ...])` runs children in
parallel, capped by `delegation.max_concurrent_children` (default 3).
- **Roles:** `leaf` (default; cannot re-delegate) vs `orchestrator`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,7 @@ terminal(command="tmux new-session -d -s resumed 'hermes --resume 20260225_14305

同步子 agent 生成——父 agent 等待子 agent 的摘要后再继续自身循环。隔离的上下文和终端会话。

- **单个:** `delegate_task(goal, context, toolsets)`。
- **单个:** `delegate_task(goal, context)`。
- **批量:** `delegate_task(tasks=[{goal, ...}, ...])` 并行运行子任务,上限由 `delegation.max_concurrent_children`(默认 3)控制。
- **角色:** `leaf`(默认;不能再委派)vs `orchestrator`(可以生成自己的 worker,受 `delegation.max_spawn_depth` 限制)。
- **非持久化。** 如果父 agent 被中断,子 agent 会被取消。对于必须在当前轮次之后继续的工作,使用 `cronjob` 或 `terminal(background=True, notify_on_complete=True)`。
Expand Down
Loading