[eric] agents: a delegated child is told its step budget and running out is not an error (ENG-409)

This commit is contained in:
ciregenz
2026-08-26 12:47:14 -07:00
parent e59ba8c561
commit 6b43b820bb
5 changed files with 143 additions and 4 deletions
+2 -1
View File
@@ -45,6 +45,7 @@ def resolve_launch_tools(mode_tools: List[str], allowed: Optional[List[str]]) ->
from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol
from backend.apps.agents.manager.subagent_budget import subagent_turn_budget
class AgentLaunch(AgentManagerProtocol):
@@ -219,7 +220,7 @@ class AgentLaunch(AgentManagerProtocol):
sdk_session_id=source.sdk_session_id,
system_prompt=source.system_prompt,
allowed_tools=list(source.allowed_tools),
max_turns=source.max_turns or 25,
max_turns=subagent_turn_budget(source.max_turns),
cwd=source.cwd,
created_at=datetime.now(),
messages=new_messages,
+11 -3
View File
@@ -17,6 +17,9 @@ from backend.apps.agents.core.ws_manager import ws_manager
from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol
from backend.apps.agents.manager.session.apply_context_window import apply_context_window
from backend.apps.agents.manager.session.session_store import snapshot_session_now, load_session_data
from backend.apps.agents.manager.subagent_budget import (
SUBAGENT_MAX_TURNS, budget_briefing, subagent_turn_budget,
)
logger = logging.getLogger(__name__)
@@ -59,7 +62,7 @@ class SpawnAgentRun(AgentManagerProtocol):
mode="sub-agent",
system_prompt=parent.system_prompt,
allowed_tools=list(parent.allowed_tools),
max_turns=parent.max_turns or 25,
max_turns=subagent_turn_budget(parent.max_turns),
cwd=parent.cwd,
created_at=datetime.now(),
dashboard_id=dashboard_id or parent.dashboard_id,
@@ -86,13 +89,18 @@ class SpawnAgentRun(AgentManagerProtocol):
"message": user_msg.model_dump(mode="json"),
})
# The child is told its step budget so it can checkpoint instead of being cut off mid-task
# (ENG-409). Appended to what is SENT, not to the stored user message: the card should show
# the task the parent asked for, not our bookkeeping.
p_sent = f"{prompt}\n\n{budget_briefing(child.max_turns or SUBAGENT_MAX_TURNS)}"
if run_in_background:
# Fire-and-forget; the child's card carries its progress and result. Keep a handle in self.tasks so stop/shutdown machinery sees it.
task = asyncio.create_task(self.run_agent_loop(child.id, prompt))
task = asyncio.create_task(self.run_agent_loop(child.id, p_sent))
self.register_turn_task(child.id, task)
return {"session_id": child.id, "background": True}
await self.run_agent_loop(child.id, prompt)
await self.run_agent_loop(child.id, p_sent)
return {
"session_id": child.id,
"response": last_assistant_text(child) or "No response from sub-agent.",
@@ -48,6 +48,12 @@ def p_turn_result_error_text(message: ResultMessage, subtype: str, stop_reason:
headline = "The model hit its maximum output length before finishing"
elif stop_reason == "refusal":
headline = "The model refused to continue this turn"
elif str(subtype or "") == "error_max_turns":
# A delegated child running out of steps is a BUDGET, not a crash, and the user needs to know
# its partial work survived. `error_max_turns. Reached maximum number of turns (25)` is
# runtime language that reads as a failure and hides that (ENG-409).
from backend.apps.agents.manager.subagent_budget import out_of_turns_message, SUBAGENT_MAX_TURNS
return out_of_turns_message(SUBAGENT_MAX_TURNS)
else:
headline = "The agent runtime reported this turn failed"
label = subtype if subtype and subtype != "success" else (stop_reason or "unknown")
@@ -0,0 +1,48 @@
"""How many turns a delegated child gets, in ONE place, and how it is told about them.
A sub-agent used to get a bare 25 with no way to know it. A ten-page transcription burned 37
productive steps (26 reads + 11 commands) and died on the wall having written nothing, while
sibling agents that finished their file left usable output on disk. The budget itself is fine; a
budget nobody can see is not (ENG-409).
The cap lived as a literal `or 25` in two unrelated files, so raising it in one silently left the
other behind. It lives here now.
"""
from typing import Optional
from typeguard import typechecked
# Generous for a lookup, tight for a batch. A child that KNOWS the number can spend it deliberately,
# which is what this file is really for; raising it is a separate decision from surfacing it.
SUBAGENT_MAX_TURNS = 25
@typechecked
def subagent_turn_budget(inherited: Optional[int]) -> int:
"""A child inherits its parent's explicit budget, else the default."""
return inherited or SUBAGENT_MAX_TURNS
@typechecked
def budget_briefing(turns: int) -> str:
"""One line telling the child what it has, so it can checkpoint instead of being cut off.
Deliberately phrased as a working instruction rather than a note about the harness: on a lane
whose terms restrict third-party automated use, describing the machinery is a liability we
already removed everywhere else (CLAUDE.md, "never announce automation").
"""
return (
f"You have about {turns} tool-using steps for this task. Work in that budget: if the job is "
f"larger, save partial results to disk as you go rather than leaving everything to a final "
f"step, and say what you completed and what remains."
)
@typechecked
def out_of_turns_message(turns: int) -> str:
"""What the USER reads when a child runs out, instead of `error_max_turns`."""
return (
f"This sub-task used all {turns} of its steps before finishing. Anything it saved along the "
f"way is on disk; send a message to carry on from where it stopped."
)
+76
View File
@@ -0,0 +1,76 @@
"""A delegated child gets a budget it can see, and running out is not a crash.
Live on production 1.7.9, 2026-08-26: a ten-page transcription child burned 37 productive steps
(26 reads + 11 commands) and died at the wall having written nothing, with
`error_max_turns. Reached maximum number of turns (25)` as the whole explanation. Sibling agents
that finished their file left usable output on disk; this one's work was simply gone (ENG-409).
"""
from backend.apps.agents.manager.subagent_budget import (
SUBAGENT_MAX_TURNS, budget_briefing, out_of_turns_message, subagent_turn_budget,
)
LAUNCH = "backend/apps/agents/manager/AgentLaunch.py"
SPAWN = "backend/apps/agents/manager/SpawnAgentRun.py"
RESULT = "backend/apps/agents/manager/streaming/handle_result_message.py"
def test_the_cap_has_exactly_one_definition():
# It was a bare `or 25` in two unrelated files, so raising it in one left the other behind.
for path in (LAUNCH, SPAWN):
src = open(path).read()
assert "or 25," not in src, f"{path} still carries its own copy of the cap"
assert "subagent_turn_budget(" in src
def test_a_parent_budget_is_inherited_and_only_then_defaulted():
assert subagent_turn_budget(60) == 60, "an explicit budget must win"
assert subagent_turn_budget(None) == SUBAGENT_MAX_TURNS
assert subagent_turn_budget(0) == SUBAGENT_MAX_TURNS, "0 is not a budget"
def test_the_briefing_tells_the_child_to_checkpoint():
b = budget_briefing(25)
assert "25" in b
assert "save partial results" in b, "the whole point is that it writes before the wall"
def test_the_briefing_never_names_the_harness():
# Same rule as every other injected string: on a lane whose terms restrict third-party automated
# use, describing the machinery is a liability (CLAUDE.md, "never announce automation").
b = budget_briefing(25).lower()
for word in ("openswarm", "harness", "sub-agent", "subagent", "orchestrat"):
assert word not in b, f"the briefing must not mention {word}"
def test_running_out_reads_as_a_budget_not_a_crash():
m = out_of_turns_message(25)
assert "25" in m
assert "on disk" in m, "the user has to know partial work survived"
assert "carry on" in m
assert "error" not in m.lower() and "failed" not in m.lower()
def test_the_result_handler_uses_it_instead_of_the_runtime_string():
src = open(RESULT).read()
i_branch = src.index('error_max_turns')
i_generic = src.index('"The agent runtime reported this turn failed"')
assert i_branch < i_generic, "the budget case must be caught before the generic failure text"
assert "out_of_turns_message" in src
def test_the_child_is_actually_told_its_budget():
# A briefing nothing sends is the row-6 shape: present, reachable, doing nothing.
src = open(SPAWN).read()
assert "budget_briefing(" in src, "the child must receive the briefing, not just have one available"
i_brief = src.index("budget_briefing(")
i_run = src.index("run_agent_loop(child.id, p_sent)")
assert i_brief < i_run
def test_the_briefing_does_not_pollute_the_visible_task():
# The card should show what the parent asked for; bookkeeping rides on the sent prompt only.
src = open(SPAWN).read()
i_msg = src.index("content=prompt,")
i_sent = src.index("p_sent = f\"{prompt}")
assert i_msg < i_sent, "the stored user message must be built from the clean prompt"