diff --git a/backend/apps/agents/manager/AgentLaunch.py b/backend/apps/agents/manager/AgentLaunch.py index e18e8d02..97254d2e 100644 --- a/backend/apps/agents/manager/AgentLaunch.py +++ b/backend/apps/agents/manager/AgentLaunch.py @@ -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, diff --git a/backend/apps/agents/manager/SpawnAgentRun.py b/backend/apps/agents/manager/SpawnAgentRun.py index f7be3fc6..e1ea2f92 100644 --- a/backend/apps/agents/manager/SpawnAgentRun.py +++ b/backend/apps/agents/manager/SpawnAgentRun.py @@ -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.", diff --git a/backend/apps/agents/manager/streaming/handle_result_message.py b/backend/apps/agents/manager/streaming/handle_result_message.py index 7fc3a4cb..972c5af7 100644 --- a/backend/apps/agents/manager/streaming/handle_result_message.py +++ b/backend/apps/agents/manager/streaming/handle_result_message.py @@ -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") diff --git a/backend/apps/agents/manager/subagent_budget.py b/backend/apps/agents/manager/subagent_budget.py new file mode 100644 index 00000000..f9946f77 --- /dev/null +++ b/backend/apps/agents/manager/subagent_budget.py @@ -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." + ) diff --git a/backend/tests/test_subagent_budget.py b/backend/tests/test_subagent_budget.py new file mode 100644 index 00000000..667fced1 --- /dev/null +++ b/backend/tests/test_subagent_budget.py @@ -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"