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:
parent
1bfe08145c
commit
ba0bc01d1f
7 changed files with 43 additions and 48 deletions
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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)`。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue