refactor(delegate_task): tasks-only interface + depth-derived delegation (1,201 → 773 tok/call, −36%) (#96424)

* refactor(delegate_task): depth-derived delegation (role param retired), session-filtered restrictions, background unadvertised — 1,201->819 tok/call

* refactor(delegate_task): tasks[] is the only advertised shape — single task = one-entry array (legacy goal/context/output_schema stay handler-accepted)
This commit is contained in:
Teknium
2026-08-27 07:38:53 -07:00
committed by GitHub
parent 726f0ce1b5
commit 9dfbde19db
6 changed files with 231 additions and 169 deletions
+48
View File
@@ -592,6 +592,54 @@ def _compute_tool_definitions(
]
available_tool_names.discard("browser_exec")
# delegate_task's child-restrictions rule names sibling tools (clarify,
# memory, cronjob). Warning about tools this session doesn't even have
# teaches ghost vocabulary — filter the list to tools actually present
# and drop the line entirely when none apply. Two source variants exist
# (depth-derived): the depth-off line also names delegate_task itself;
# the depth-on line lists only the siblings. Pattern order matters —
# the sibling list is a substring of the full list.
# Same session-level seam as the browser_exec gate above.
if "delegate_task" in available_tool_names:
blocked_present = [
t for t in ("clarify", "memory", "cronjob") if t in available_tool_names
]
if len(blocked_present) < 3:
full_offvariant = "delegate_task, clarify, memory, or cronjob"
full_onvariant = "clarify, memory, or cronjob"
for i, td in enumerate(filtered_tools):
fn = td.get("function", {})
desc = fn.get("description", "")
if fn.get("name") != "delegate_task":
continue
if full_offvariant in desc:
full, keep_self = full_offvariant, True
elif full_onvariant in desc:
full, keep_self = full_onvariant, False
else:
break
names = (["delegate_task"] if keep_self else []) + blocked_present
if blocked_present:
if len(names) == 1:
replacement = names[0]
elif len(names) == 2:
replacement = f"{names[0]} or {names[1]}"
else:
replacement = ", ".join(names[:-1]) + ", or " + names[-1]
desc = desc.replace(full, replacement)
else:
# No sibling tools here — drop the restriction line
# (both variants end at the following "\n").
start = desc.find("- Children cannot call " + full)
if start != -1:
end = desc.index("\n", start) + 1
desc = desc[:start] + desc[end:]
filtered_tools[i] = {
**td,
"function": {**fn, "description": desc},
}
break
if not quiet_mode:
if filtered_tools:
tool_names = [t["function"]["name"] for t in filtered_tools]
+36 -15
View File
@@ -62,9 +62,17 @@ class TestDelegateRequirements(unittest.TestCase):
def test_schema_valid(self):
self.assertEqual(DELEGATE_TASK_SCHEMA["name"], "delegate_task")
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
self.assertIn("goal", props)
# tasks[] is the only advertised spawn shape (single task = one-entry
# array); legacy top-level goal/context/output_schema stay
# handler-accepted but unadvertised.
self.assertIn("tasks", props)
self.assertIn("context", props)
self.assertNotIn("goal", props)
self.assertNotIn("context", props)
self.assertNotIn("output_schema", props)
task_props = props["tasks"]["items"]["properties"]
self.assertIn("goal", task_props)
self.assertIn("context", task_props)
self.assertIn("output_schema", task_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.
@@ -101,17 +109,18 @@ class TestDelegateRequirements(unittest.TestCase):
"context", # pass-everything-via-context rule
"respond in Chinese", # language example (weak models regress without it)
"SELF-REPORTS", # verification contract
"fetch the URL", # concrete verification verbs
"clarify", # leaf blocked-tool list
"send_message",
"clarify", # child blocked-tool list
"delegation.provider", # model inheritance / pinning
):
self.assertIn(keyword, desc, f"top-level description lost: {keyword!r}")
# send_message must NOT be named: gateway-internal vocabulary most
# sessions never see (still enforced via DELEGATE_BLOCKED_TOOLS).
self.assertNotIn("send_message", desc)
def test_dynamic_limits_moved_to_param_descriptions(self):
"""Concurrency and nesting ceilings must reach the model through the
tasks/role parameter descriptions (the top-level text no longer
carries them)."""
"""Concurrency reaches the model through the tasks parameter
description; the depth ceiling lives in the top-level description's
depth-derived recursion rule (role param is gone)."""
from tools.delegate_tool import _build_dynamic_schema_overrides
from tools.registry import registry
@@ -125,12 +134,11 @@ class TestDelegateRequirements(unittest.TestCase):
for parameters in (overrides["parameters"], definition["parameters"]):
self.assertIn("up to 7", parameters["properties"]["tasks"]["description"])
self.assertIn(
"max_spawn_depth=4", parameters["properties"]["role"]["description"]
)
# Static top-level text must not embed stale limits.
self.assertNotIn("role", parameters["properties"])
# Depth ceiling now rides the depth-derived recursion rule in the
# top-level text (only rendered when nesting is available).
self.assertIn("max_spawn_depth=4", overrides["description"])
self.assertNotIn("up to 7", overrides["description"])
self.assertNotIn("max_spawn_depth", overrides["description"])
class TestChildSystemPrompt(unittest.TestCase):
def test_goal_only(self):
@@ -1553,10 +1561,23 @@ class TestOrchestratorRoleSchema(unittest.TestCase):
delegate_task(**kwargs)
return mock_child
def test_default_role_is_leaf(self):
def test_role_is_depth_derived_not_caller_declared(self):
"""With max_spawn_depth=2 (mocked), a depth-1 child has depth budget
left, so it becomes an orchestrator automatically — no role arg
needed, and a passed legacy role arg is ignored either way."""
child = self._run_with_mock_child(_SENTINEL)
self.assertEqual(child._delegate_role, "leaf")
self.assertEqual(child._delegate_role, "orchestrator")
# Legacy explicit role='leaf' does not override the depth derivation.
child = self._run_with_mock_child("leaf")
self.assertEqual(child._delegate_role, "orchestrator")
def test_schema_no_longer_advertises_role(self):
"""`role` left the advertised schema (capability is depth-derived);
the handler still accepts it for wire compat."""
from tools.delegate_tool import DELEGATE_TASK_SCHEMA
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
self.assertNotIn("role", props)
self.assertNotIn("role", props["tasks"]["items"]["properties"])
def test_schema_omits_acp_transport_fields(self):
from tools.delegate_tool import DELEGATE_TASK_SCHEMA
+11 -5
View File
@@ -144,11 +144,17 @@ class TestBatchPlaceholderGoals(unittest.TestCase):
class TestSingleTaskBatch(unittest.TestCase):
def test_one_task_batch_rejected_pointing_to_goal_form(self):
result = _call([{"goal": GOOD_A}])
self.assertIn("error", result)
self.assertIn("goal", result["error"])
self.assertIn("2", result["error"]) # "at least 2"
def test_one_task_batch_is_valid_single_task_shape(self):
"""A one-entry tasks[] array is the canonical single-task call (the
advertised interface is tasks-only), so it must NOT be rejected —
and short goals are legitimate for a single task."""
with patch("tools.delegate_tool._run_single_child") as mock_run:
mock_run.return_value = {
"task_index": 0, "status": "completed", "summary": "done",
"api_calls": 1, "duration_seconds": 1.0, "_child_role": None,
}
result = _call([{"goal": GOOD_A}])
self.assertNotIn("error", result)
class TestValidBatchStillRuns(unittest.TestCase):
+6 -6
View File
@@ -274,7 +274,8 @@ def test_delegate_task_unknown_action_is_an_error():
def test_delegate_task_spawn_action_still_validates_goal():
out = delegate_task(action="spawn", parent_agent=_StubParent())
assert "Provide either 'goal'" in out
assert "No tasks provided" in out
assert "one-entry" in out # teaching error carries the canonical shape
def test_delegate_task_requires_parent_agent_for_control():
@@ -283,12 +284,11 @@ def test_delegate_task_requires_parent_agent_for_control():
def test_empty_tasks_array_with_goal_is_single_task_not_batch_error():
"""Small models emit tasks=[] alongside goal; that must not trip the
'Batch mode requires at least 2 tasks' gate (observed live with
gpt-5.4-mini on Nous Portal)."""
"""Small models emit tasks=[] alongside goal; that must not trip a
batch-count gate (observed live with gpt-5.4-mini on Nous Portal) —
it falls through to the no-tasks teaching error."""
out = delegate_task(tasks=[], goal="", parent_agent=_StubParent())
# Falls through to the single-goal validation, not the batch gate.
assert "Provide either 'goal'" in out
assert "No tasks provided" in out
assert "at least 2 tasks" not in out
+7 -3
View File
@@ -133,10 +133,14 @@ class TestToolSchemaSurface:
"properties"
]["tasks"]["items"]["required"]
def test_output_schema_on_top_level_goal_form(self):
def test_output_schema_advertised_per_task_only(self):
"""output_schema is advertised inside tasks[] items (the only spawn
shape); the legacy top-level param stays handler-accepted but out
of the schema."""
props = DELEGATE_TASK_SCHEMA["parameters"]["properties"]
assert "output_schema" in props
assert props["output_schema"]["type"] == "object"
assert "output_schema" not in props
task_props = props["tasks"]["items"]["properties"]
assert task_props["output_schema"]["type"] == "object"
# ---------------------------------------------------------------------------
+123 -140
View File
@@ -1640,15 +1640,15 @@ def _build_child_agent(
import uuid as _uuid
# ── Role resolution ─────────────────────────────────────────────────
# Honor the caller's role only when BOTH the kill switch and the
# child's depth allow it. This is the single point where role
# degrades to 'leaf' — keeps the rule predictable. Callers pass
# the normalised role (_normalize_role ran in delegate_task) so
# we only deal with 'leaf' or 'orchestrator' here.
# Depth-derived, not caller-declared: a child may delegate iff the
# kill switch is on and depth budget remains below max_spawn_depth.
# The legacy `role` arg no longer participates (it asked the caller
# to guess a fact the config already knows); it is still accepted and
# normalised for wire compat, but capability comes from depth alone.
child_depth = getattr(parent_agent, "_delegate_depth", 0) + 1
max_spawn = _get_max_spawn_depth()
orchestrator_ok = _get_orchestrator_enabled() and child_depth < max_spawn
effective_role = role if (role == "orchestrator" and orchestrator_ok) else "leaf"
effective_role = "orchestrator" if orchestrator_ok else "leaf"
# ── Subagent identity (stable across events, 0-indexed for TUI) ─────
# subagent_id is generated here so the progress callback, the
@@ -3580,19 +3580,16 @@ def _validate_batch_tasks(task_list: List[Dict[str, Any]]) -> Optional[str]:
"""Validate a tasks=[...] batch beyond per-task goal presence.
Returns an actionable error string, or None when the batch is valid.
Batch-only by design: the single-`goal` form legitimately uses short
goals, so these checks must never run on it.
A one-entry array is the canonical single-task shape (the advertised
interface is tasks-only; legacy top-level `goal` is wrapped into a
one-entry batch), so no minimum count is enforced. The placeholder/
template checks below still run on every entry.
Duplicate goals are deliberately NOT rejected: identical-goal fan-outs
are a legitimate pattern (best-of-N / ensemble sampling), and blocking
them broke real workflows (post-merge audit of #81141).
"""
if len(task_list) < 2:
return (
"Batch mode requires at least 2 tasks. For a single task, use "
"the `goal` parameter instead of `tasks`: "
'delegate_task(goal="...", context="...").'
)
for i, task in enumerate(task_list):
goal = str(task.get("goal", "")).strip()
@@ -3612,7 +3609,11 @@ def _validate_batch_tasks(task_list: List[Dict[str, Any]]) -> Optional[str]:
"calling delegate_task — subagents cannot resolve "
"placeholders."
)
if len(goal) < _MIN_BATCH_GOAL_LEN:
if len(goal) < _MIN_BATCH_GOAL_LEN and len(task_list) >= 2:
# Multi-task fan-outs with terse goals are usually unexpanded
# templates; a SINGLE task legitimately uses short goals
# ("Fix the tests"), so one-entry arrays keep the historical
# single-`goal` exemption.
return (
f"Task {i} goal is too short ({goal!r}). Write a specific, "
"self-contained goal of at least "
@@ -3772,7 +3773,11 @@ def delegate_task(
single_task["output_schema"] = output_schema
task_list = [single_task]
else:
return tool_error("Provide either 'goal' (single task) or 'tasks' (batch).")
return tool_error(
"No tasks provided. Pass tasks=[{goal: '...', context: '...'}, "
"...] — one entry per subagent (a single task is a one-entry "
"array)."
)
if not task_list:
return tool_error("No tasks provided.")
@@ -4648,20 +4653,45 @@ def _build_top_level_description() -> str:
top-level text stays static and duplication-free. If you add text
here, check it is not already stated in a parameter description.
"""
try:
orchestration_available = _get_max_spawn_depth() >= 2 and _get_orchestrator_enabled()
except Exception:
orchestration_available = False
# The child-restrictions rule renders per config: on nesting-enabled
# installs the orchestrator clause is load-bearing; on depth-1/disabled
# installs (the default) it would describe an unreachable state — the
# role param already explains that 'orchestrator' is inert there.
# send_message is deliberately not named: it's gateway-internal
# vocabulary most sessions never see. The list below is the fail-safe
# superset; model_tools session-filters it to the tools the session
# actually has, dropping the whole line when none apply.
# Delegation capability is depth-derived (no role param): mention
# recursion only where it's actually available.
if orchestration_available:
restrictions_rule = (
"- Children cannot call clarify, memory, or cronjob.\n"
"- Children can themselves delegate while depth remains "
f"(max_spawn_depth={_get_max_spawn_depth()}); the runtime "
"derives this from depth automatically.\n"
)
else:
restrictions_rule = (
"- Children cannot call delegate_task, clarify, memory, or "
"cronjob.\n"
)
return (
"Spawn subagents in isolated contexts; each gets its own conversation, "
"terminal session, and toolset, and only its final summary returns to "
"you. Provide 'goal' for a single task or 'tasks' for a parallel batch "
"(limits and nesting rules are in the parameter descriptions).\n\n"
"you. Pass every task in `tasks` — one entry spawns one subagent, "
"several run in parallel (limit in the tasks description).\n\n"
"Runs in the background: dispatch returns immediately with live "
"transcript paths, and the completed result (one consolidated message "
"for a batch) re-enters the conversation on its own. Do NOT wait or "
"poll; continue other work.\n\n"
"LIVE ORCHESTRATION: while children run, this tool also controls "
"them — action='list' (live children + ids), action='steer' "
"(subagent_id + message, redirect without stopping), action='stop' "
"(subagent_id, end early; partial result still returns). Steer when "
"a live transcript shows a child drifting.\n\n"
"transcript paths, and the completed result (one consolidated message, "
"results in task order) re-enters the conversation on its own. Do NOT "
"wait or poll; continue other work. While children run, `action` "
"(list/steer/stop) controls them live — steer when a transcript shows "
"a child drifting.\n\n"
"USE FOR: reasoning-heavy subtasks, work that would flood your context "
"with intermediate data, or independent parallel workstreams.\n"
"DO NOT USE FOR (use these instead):\n"
@@ -4669,7 +4699,7 @@ def _build_top_level_description() -> str:
"- A single tool call -> call the tool directly\n"
"- Tasks needing user interaction -> subagents cannot ask questions\n"
"- Durable work that must survive this session -> cronjob or "
"terminal(background=True, notify_on_complete=True); /stop, /new, or "
"terminal(background=True, notify=True); /stop, /new, or "
"process exit discards running subagents.\n\n"
"RULES:\n"
"- Children know nothing of this conversation: pass everything needed "
@@ -4679,14 +4709,10 @@ def _build_top_level_description() -> str:
"claiming \"uploaded successfully\" or \"file written\" may be wrong. "
"For external side effects (uploads, remote writes, publishing), "
"require a verifiable handle (URL, ID, absolute path) and verify it "
"yourself — fetch the URL, stat the file, read back the content — "
"before telling the user the operation succeeded.\n"
"- Leaf children (the default) cannot call delegate_task, clarify, "
"memory, send_message, or cronjob; orchestrators regain only "
"delegate_task.\n"
"- Children inherit the parent model and fallback chain unless pinned "
"globally via delegation.provider / delegation.model in config.yaml. "
"Results are returned as an array, one entry per task."
"yourself before telling the user the operation succeeded.\n"
+ restrictions_rule +
"- Children inherit the parent model unless pinned via "
"delegation.provider / delegation.model in config.yaml."
)
@@ -4697,47 +4723,31 @@ def _build_tasks_param_description() -> str:
except Exception:
max_children = _DEFAULT_MAX_CONCURRENT_CHILDREN
return (
f"Batch mode: tasks to run in parallel (up to {max_children} for this "
f"user, set via delegation.max_concurrent_children). Each gets "
"its own subagent with isolated context and terminal session. "
"When provided, top-level goal/context/role are ignored."
f"The task(s), up to {max_children} in parallel for this user (set "
"via delegation.max_concurrent_children). Each entry spawns one "
"subagent with isolated context and terminal session; a single task "
"is a one-entry array. Required when spawning."
)
def _build_role_param_description() -> str:
"""Compose the 'role' parameter description with current spawn-depth limit."""
"""Legacy helper — the `role` param is no longer advertised.
Delegation capability is depth-derived (see the role-resolution block in
_build_child_agent): a child may itself delegate iff
delegation.orchestrator_enabled and its depth < max_spawn_depth. The
handler still accepts role for wire compat (old transcripts, kanban
dispatcher) but ignores it. Kept because external callers import this
symbol; returns the depth story for any such use.
"""
try:
max_depth = _get_max_spawn_depth()
except Exception:
max_depth = MAX_DEPTH
try:
orchestrator_on = _get_orchestrator_enabled()
except Exception:
orchestrator_on = True
if max_depth >= 2 and orchestrator_on:
nesting_note = (
f"Nesting IS enabled for this user (max_spawn_depth={max_depth}): "
f"orchestrator children can themselves delegate up to {max_depth - 1} "
"more level(s) deep."
)
elif max_depth >= 2 and not orchestrator_on:
nesting_note = (
"Nesting is currently disabled "
"(delegation.orchestrator_enabled=false); 'orchestrator' is "
"silently forced to 'leaf'."
)
else:
nesting_note = (
f"Nesting is OFF for this user (max_spawn_depth={max_depth}); "
"'orchestrator' is silently forced to 'leaf'. Raise "
"delegation.max_spawn_depth in config.yaml to enable."
)
return (
"Role of the child agent. 'leaf' (default) = focused "
"worker, cannot delegate further. 'orchestrator' = can "
f"use delegate_task to spawn its own workers. {nesting_note}"
"Legacy parameter, ignored: whether a child can delegate is derived "
f"from delegation config (max_spawn_depth={max_depth}), not declared "
"by the caller."
)
@@ -4756,7 +4766,6 @@ def _build_dynamic_schema_overrides() -> dict:
k: dict(v) for k, v in DELEGATE_TASK_SCHEMA["parameters"]["properties"].items()
}
overrides_params["properties"]["tasks"]["description"] = _build_tasks_param_description()
overrides_params["properties"]["role"]["description"] = _build_role_param_description()
return {
"description": _build_top_level_description(),
@@ -4782,48 +4791,44 @@ DELEGATE_TASK_SCHEMA = {
"parameters": {
"type": "object",
"properties": {
"goal": {
"type": "string",
"description": (
"What the subagent should accomplish. Be specific and "
"self-contained -- the subagent knows nothing about your "
"conversation history."
),
},
"context": {
"type": "string",
"description": (
"Background information the subagent needs: file paths, "
"error messages, project structure, constraints. The more "
"specific you are, the better the subagent performs."
),
},
# NOTE: the handler also accepts the legacy single-goal shape —
# top-level `goal` (string), `context` (string), `output_schema`
# (object) — wrapped into a one-entry batch at dispatch. Legacy,
# unadvertised (old transcripts/callers only); tasks=[...] is the
# only advertised shape. Do not re-add these to the schema.
"tasks": {
"type": "array",
"minItems": 1,
"items": {
"type": "object",
"properties": {
"goal": {"type": "string", "description": "Task goal"},
"goal": {
"type": "string",
"description": (
"What this subagent should accomplish. Be "
"specific and self-contained — it knows "
"nothing about your conversation history."
),
},
"context": {
"type": "string",
"description": "Task-specific context",
},
"role": {
"type": "string",
"enum": ["leaf", "orchestrator"],
"description": "Per-task role override. See top-level 'role' for semantics.",
"description": (
"Background THIS child needs: file paths, "
"error messages, constraints. Each child "
"sees only its own context — repeat shared "
"background in every task that needs it."
),
},
"output_schema": {
"type": "object",
"description": (
"Optional JSON Schema the subagent's final "
"answer must validate against. The child is "
"told the contract up front; the parent "
"validates the final answer and allows one "
"bounded correction retry. The result entry "
"gains schema_valid (and schema_errors on "
"final failure). Keep schemas forgiving: "
"require only fields you will actually read."
"Optional JSON Schema this child's final "
"answer must validate against (told to the "
"child up front; parent validates with one "
"bounded correction retry; result gains "
"schema_valid, plus schema_errors on "
"failure). Keep it forgiving — require only "
"fields you will read."
),
},
},
@@ -4832,63 +4837,41 @@ DELEGATE_TASK_SCHEMA = {
# No maxItems — the runtime limit is configurable via
# delegation.max_concurrent_children (default 3) and
# enforced with a clear error in delegate_task().
# NOTE: the handler also accepts a per-task `role` — legacy,
# ignored: delegation capability is depth-derived, not
# caller-declared. Unadvertised on purpose; do not re-add.
"description": "(rebuilt at get_definitions() time)",
},
"role": {
"type": "string",
"enum": ["leaf", "orchestrator"],
"description": "(rebuilt at get_definitions() time)",
},
"output_schema": {
"type": "object",
"description": (
"Optional JSON Schema for the single-goal form — the "
"subagent's final answer must validate against it "
"(same semantics as tasks[].output_schema)."
),
},
"background": {
"type": "boolean",
"description": (
"DEPRECATED / IGNORED. Top-level single and batch "
"delegations run in the background automatically — you do "
"not need to (and cannot) opt in or out. A single result or "
"consolidated batch result re-enters the conversation when "
"the work finishes; just continue working in the meantime. "
"Setting this has no effect; the parameter remains only for "
"backward compatibility."
),
},
# NOTE: the handler also accepts `background` (bool) — DEPRECATED,
# ignored: top-level delegations always run in the background.
# Deliberately unadvertised (old transcripts/callers only); do not
# re-add to the schema.
"action": {
"type": "string",
"enum": ["spawn", "list", "steer", "stop"],
"description": (
"Default 'spawn' (omit for normal delegation). Live "
"orchestration of running subagents: 'list' shows this "
"conversation's live children (ids, goals, status, "
"transcript paths); 'steer' queues course-correction text "
"into one child (requires subagent_id + message) without "
"stopping it; 'stop' ends one child early (requires "
"subagent_id) — its partial result still returns as a "
"completion message. Control actions return immediately; "
"goal/tasks are ignored when action is not 'spawn'."
"Default 'spawn'. Live control of running children: "
"'list' = ids/goals/status/transcripts; 'steer' = queue "
"course-correction text into one child (subagent_id + "
"message) without stopping it; 'stop' = end one child "
"early (subagent_id; partial result still returns). "
"Control actions return immediately; goal/tasks are "
"ignored unless spawning."
),
},
"subagent_id": {
"type": "string",
"description": (
"Target for action='steer'/'stop'. Ids are returned in the "
"spawn dispatch response (subagent_ids) and by "
"action='list'."
"Target for action='steer'/'stop' (ids from the spawn "
"response or action='list')."
),
},
"message": {
"type": "string",
"description": (
"For action='steer': the course correction. Be directive "
"and specific — the child sees it appended to its next "
"tool result mid-run (e.g. \"Stop exploring X; focus on Y "
"and return early results\")."
"For action='steer': the course correction, appended to "
"the child's next tool result mid-run. Be directive and "
"specific."
),
},
},