feat(delegate): remove model-facing toolsets arg — subagents always inherit parent's (#56386)

The model could pass `toolsets` (top-level and per-task) to delegate_task,
letting it choose which toolsets a subagent got. Toolset selection is a
capability-scoping decision the model should not control; subagents inherit
the parent's enabled toolsets, period.

- Remove `toolsets` from the delegate_task() signature, the registry handler,
  the top-level + per-task JSON schema, and the live dispatch path
  (run_agent._dispatch_delegate_task — this forwarded it on every model call).
- Single-task and per-task child builds now pass toolsets=None so
  _build_child_agent resolves to pure parent inheritance.
- Drop the now-dead _SUBAGENT_TOOLSETS / _TOOLSET_LIST_STR schema-hint block.
- _build_child_agent keeps its internal toolsets param + intersection helpers
  (internal API; fed the inherited value only).
- Tests: schema assertions flipped to assertNotIn; added a regression test
  proving the dispatch path never forwards a smuggled model `toolsets`.
- Docs: update delegate_task signature refs in the autonomous-ai-agents skill.
This commit is contained in:
Teknium 2026-07-01 05:35:26 -07:00 committed by GitHub
parent 1bfe08145c
commit ba0bc01d1f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 43 additions and 48 deletions

View file

@ -5621,7 +5621,6 @@ class AIAgent:
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"),

View file

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

View file

@ -262,7 +262,7 @@ def test_delegate_task_background_routes_async_and_does_not_block(monkeypatch):
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,
)
@ -422,6 +422,30 @@ def test_run_agent_dispatch_forces_background():
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

View file

@ -69,7 +69,11 @@ class TestDelegateRequirements(unittest.TestCase):
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.

View file

@ -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
@ -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,
@ -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).")
@ -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,
@ -2848,7 +2832,9 @@ def delegate_task(
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,
@ -3387,18 +3373,6 @@ DELEGATE_TASK_SCHEMA = {
"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": {
@ -3409,11 +3383,6 @@ DELEGATE_TASK_SCHEMA = {
"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": (
@ -3512,7 +3481,6 @@ registry.register(
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"),

View file

@ -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`

View file

@ -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)`