Skip to content
Closed
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
4 changes: 3 additions & 1 deletion agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -3357,8 +3357,10 @@ def sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]
if cid:
seen_assistant_call_ids.add(cid)
kept_tcs.append(tc)
if len(kept_tcs) != len(msg.get("tool_calls") or []):
if kept_tcs:
msg = {**msg, "tool_calls": kept_tcs}
elif len(kept_tcs) != len(msg.get("tool_calls") or []):
msg = {k: v for k, v in msg.items() if k != "tool_calls"}
deduped.append(msg)
elif role == "tool":
cid = (msg.get("tool_call_id") or "").strip()
Expand Down
56 changes: 51 additions & 5 deletions tests/run_agent/test_message_sequence_repair.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,22 +356,68 @@ def test_sanitize_drops_empty_tool_calls_array():




# ── Self-recovery: heal empty-content non-final messages ──────────────────
# Repro of the production incident: a dead stream persisted an empty-content
# assistant stub mid-transcript, and every later request 400'd with
# "all messages must have non-empty content except for the optional final
# assistant message" (INVALID_REQUEST_BODY). sanitize_api_messages now heals
# such turns on the per-call copy so the session recovers itself in memory.

def test_sanitize_preserves_populated_tool_calls():
from agent.agent_runtime_helpers import sanitize_api_messages

messages = [
{"role": "assistant", "content": None, "tool_calls": [
{"id": "call_Z", "type": "function",
"function": {"name": "foo", "arguments": "{}"}},
]},
{"role": "tool", "tool_call_id": "call_Z", "content": "r"},
]
out = sanitize_api_messages(list(messages))
assistant = [m for m in out if m.get("role") == "assistant"][0]
assert [tc["id"] for tc in assistant["tool_calls"]] == ["call_Z"]


def test_sanitize_dedup_drops_tool_calls_key_when_all_removed():
"""When dedup removes ALL tool_calls from an assistant message,
the key is dropped instead of writing tool_calls: [].

DeepSeek v4 and newer OpenAI reject empty tool_calls with HTTP 400.
The dedup pass introduced by #58327 can produce this state when
all tool_call_ids are duplicates of earlier messages in a long
history. The fix (#64335) drops the key entirely rather than
writing an empty array.
"""
from agent.agent_runtime_helpers import sanitize_api_messages

# Simulate a long conversation where the same tool_call_id appears
# in multiple assistant messages (e.g., crash/resume glitch or
# compression window re-emission). The first occurrence is kept,
# later duplicates are removed.
messages = [
{"role": "user", "content": "step 1"},
{"role": "assistant", "content": "running",
"tool_calls": [{"id": "call_A", "type": "function",
"function": {"name": "foo", "arguments": "{}"}}]},
{"role": "tool", "tool_call_id": "call_A", "content": "result 1"},
# Simulate a later assistant message that reuses call_A
# (this would be invalid, but the dedup pass handles it)
{"role": "assistant", "content": "retrying",
"tool_calls": [{"id": "call_A", "type": "function",
"function": {"name": "foo", "arguments": "{}"}}]},
]

out = sanitize_api_messages(list(messages))





# First assistant should keep tool_calls (first occurrence)
assistant1 = [m for m in out if m.get("role") == "assistant"][0]
assert "tool_calls" in assistant1
assert len(assistant1["tool_calls"]) == 1
assert assistant1["tool_calls"][0]["id"] == "call_A"

# Second assistant should have tool_calls key DROPPED
# (all tool_calls were deduped as duplicates of call_A)
assistant2 = [m for m in out if m.get("role") == "assistant"][1]
assert "tool_calls" not in assistant2
# Content should be preserved
assert assistant2["content"] == "retrying"
50 changes: 50 additions & 0 deletions tests/tools/test_delegate.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,56 @@ def test_schema_valid(self):
self.assertNotIn("acp_args", props["tasks"]["items"]["properties"])
self.assertNotIn("maxItems", props["tasks"]) # removed — limit is now runtime-configurable

def test_top_level_description_compact_and_complete(self):
"""The top-level description must stay compact while keeping every
contract that exists nowhere else in the schema (keyword-level, not
prose-literal, so rewording doesn't break CI)."""
from tools.delegate_tool import _build_top_level_description

desc = _build_top_level_description()
# Compaction ceiling: the old description was ~4,000 chars.
self.assertLessEqual(len(desc), 2200)
# Contracts only the top-level text carries:
for keyword in (
"background", # async semantics
"wait or poll", # no-poll rule
"execute_code", # mechanical-work routing
"cronjob", # durable-work routing
"/stop", # non-durability warning
"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",
"delegation.provider", # model inheritance / pinning
):
self.assertIn(keyword, desc, f"top-level description lost: {keyword!r}")

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)."""
from tools.delegate_tool import _build_dynamic_schema_overrides
from tools.registry import registry

with (
patch("tools.delegate_tool._get_max_concurrent_children", return_value=7),
patch("tools.delegate_tool._get_max_spawn_depth", return_value=4),
patch("tools.delegate_tool._get_orchestrator_enabled", return_value=True),
):
overrides = _build_dynamic_schema_overrides()
definition = registry.get_definitions({"delegate_task"})[0]["function"]

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("up to 7", overrides["description"])
self.assertNotIn("max_spawn_depth", overrides["description"])

class TestChildSystemPrompt(unittest.TestCase):
def test_goal_only(self):
prompt = _build_child_system_prompt("Fix the tests")
Expand Down
146 changes: 42 additions & 104 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -3649,112 +3649,50 @@ def _load_config() -> dict:


def _build_top_level_description() -> str:
"""Compose the delegate_task tool description with current runtime limits.

The model needs to know its actual ceilings (not the framework defaults),
otherwise it self-caps at "default 3" / "default 2" even when the user has
raised delegation.max_concurrent_children / max_spawn_depth. Called both
at module import (to seed DELEGATE_TASK_SCHEMA) and on every
get_definitions() call via dynamic_schema_overrides.
"""Compose the delegate_task tool description.

Deliberately carries ONLY guidance that exists nowhere else in the
schema. Batch/concurrency limits live in the 'tasks' parameter
description and the nesting clause lives in the 'role' parameter
description (both rebuilt per get_definitions() call with the user's
actual delegation.max_concurrent_children / max_spawn_depth), so the
top-level text stays static and duplication-free. If you add text
here, check it is not already stated in a parameter description.
"""
try:
max_children = _get_max_concurrent_children()
except Exception:
max_children = _DEFAULT_MAX_CONCURRENT_CHILDREN
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_clause = (
f"Nested delegation IS enabled for this user "
f"(max_spawn_depth={max_depth}): pass role='orchestrator' on a "
f"child to let it spawn its own workers, up to {max_depth - 1} "
f"additional level(s) deep."
)
elif max_depth >= 2 and not orchestrator_on:
nesting_clause = (
f"Nested delegation is DISABLED on this install "
f"(delegation.orchestrator_enabled=false), even though "
f"max_spawn_depth={max_depth}. role='orchestrator' is silently "
f"forced to 'leaf'."
)
else:
nesting_clause = (
f"Nested delegation is OFF for this user "
f"(max_spawn_depth={max_depth}): every child is a leaf and "
f"cannot delegate further. Raise delegation.max_spawn_depth in "
f"config.yaml to enable nesting."
)

return (
"Spawn one or more subagents to work on tasks in isolated contexts. "
"Each subagent gets its own conversation, terminal session, and toolset. "
"Only the final summary is returned -- intermediate tool results "
"never enter your context window.\n\n"
"TWO MODES (one of 'goal' or 'tasks' is required):\n"
"1. Single task: provide 'goal' (+ optional context and role).\n"
f"2. Batch (parallel): provide 'tasks' array with up to {max_children} "
f"items concurrently for this user (configured via "
f"delegation.max_concurrent_children in config.yaml). {nesting_clause}\n\n"
"BOTH MODES RUN IN THE BACKGROUND. delegate_task returns immediately — "
"you and the user keep working, and the completed result re-enters "
"the conversation as a new message. A "
"batch returns one handle, runs N subagents concurrently, and delivers "
"one consolidated result after ALL of them finish. Do NOT wait or poll; "
"just continue with other work after dispatching.\n\n"
"LIVE TRANSCRIPTS: the dispatch response includes 'live_transcripts' — "
"one append-only human-readable log file per task (under "
"cache/delegation/live/<delegation_id>/). Each child streams its "
"assistant text, tool calls, and tool results there while it runs. "
"Read (or `tail -f` in a terminal) those paths any time you or the "
"user want to see what a subagent is actually doing instead of "
"waiting for the final summary.\n\n"
"WHEN TO USE delegate_task:\n"
"- Reasoning-heavy subtasks (debugging, code review, research synthesis)\n"
"- Tasks that would flood your context with intermediate data\n"
"- Parallel independent workstreams (research A and B simultaneously)\n\n"
"WHEN NOT TO USE (use these instead):\n"
"- Mechanical multi-step work with no reasoning needed -> use execute_code\n"
"- Single tool call -> just call the tool directly\n"
"- Tasks needing user interaction -> subagents cannot use clarify\n"
"- Durable long-running work that must outlive the current turn -> "
"use cronjob (action='create') or terminal(background=True, "
"notify_on_complete=True) instead. Background delegations are NOT "
"durable: if the parent session is closed (/new) or the process exits "
"before a subagent finishes, that subagent's work is discarded, and "
"/stop cancels every running background subagent.\n\n"
"IMPORTANT:\n"
"- Subagents have NO memory of your conversation. Pass all relevant "
"info (file paths, error messages, constraints) via the 'context' field.\n"
"- If the user is writing in a non-English language, or asked for "
"output in a specific language / tone / style, say so in 'context' "
"(e.g. \"respond in Chinese\", \"return output in Japanese\"). "
"Otherwise subagents default to English and their summaries will "
"contaminate your final reply with the wrong language.\n"
"- Subagent summaries are SELF-REPORTS, not verified facts. A subagent "
"that claims \"uploaded successfully\" or \"file written\" may be wrong. "
"For operations with external side-effects (HTTP POST/PUT, remote "
"writes, file creation at shared paths, publishing), require the "
"subagent to return a verifiable handle (URL, ID, absolute path, HTTP "
"status) and verify it yourself — fetch the URL, stat the file, read "
"back the content — before telling the user the operation succeeded.\n"
"- Leaf subagents (role='leaf', the default) CANNOT call: "
"delegate_task, clarify, memory, send_message.\n"
"- Orchestrator subagents (role='orchestrator') retain "
"delegate_task so they can spawn their own workers, but still "
"cannot use clarify, memory, or send_message. "
f"Orchestrators are bounded by max_spawn_depth={max_depth} for this "
f"user and can be disabled globally via "
"delegation.orchestrator_enabled=false.\n"
"- Subagent model is NOT selectable per call: children inherit the parent model (plus its fallback chain) unless you pin all subagents to a model via delegation.provider / delegation.model in config.yaml.\n"
"- Each subagent gets its own terminal session (separate working directory and state).\n"
"- Results are always returned as an array, one entry per task."
"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"
"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"
"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"
"- Mechanical multi-step work with no reasoning needed -> execute_code\n"
"- 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 "
"process exit discards running subagents.\n\n"
"RULES:\n"
"- Children know nothing of this conversation: pass everything needed "
"via 'context', including any required output language, tone, or "
"style (e.g. \"respond in Chinese\").\n"
"- Child summaries are SELF-REPORTS, not verified facts: a child "
"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."
)


Expand Down
2 changes: 1 addition & 1 deletion website/docs/reference/tools-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ These two tools live in the `browser` toolset but only register when a Chrome De

| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `delegate_task` | Spawn one or more subagents to work on tasks in isolated contexts. Each subagent gets its own conversation, terminal session, and toolset. Only the final summary is returned -- intermediate tool results never enter your context window. TWO… | — |
| `delegate_task` | 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… | — |

## `feishu_doc` toolset

Expand Down
Loading