mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-02 14:28:59 +02:00
[eric] agents: a delegated child is told its step budget and running out is not an error (ENG-409)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user