mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-13 05:07:40 +02:00
[eric] delegation: a child declined by the filter says so to its parent, and a third spawn for a task declined twice this turn is refused; one board re-dispatched 21 children, 14 declined
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9bdd45f791
commit
e4b16bbe07
@@ -7,7 +7,7 @@ as AgentLaunch."""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from typeguard import typechecked
|
||||
@@ -24,6 +24,37 @@ from backend.apps.agents.manager.subagent_budget import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Eric's board, 2026-08-26: one parent spawned 21 transcription children and 14 died on the policy filter, each
|
||||
# re-dispatched with a rephrased prompt because the return said only "No response from sub-agent."
|
||||
DECLINED_CHILDREN_CAP = 2
|
||||
|
||||
DECLINED_REPLY = (
|
||||
"The sub-agent's request was declined by the model provider's filter before it finished. Sending the "
|
||||
"same task to another sub-agent will be declined too, so do not spawn another for it. Tell the user what "
|
||||
"was completed and what was declined, and let them decide."
|
||||
)
|
||||
|
||||
|
||||
@typechecked
|
||||
def declined_children_this_turn(sessions: Dict[str, AgentSession], parent: AgentSession) -> List[AgentSession]:
|
||||
"""Children of this parent that ended on the filter since the parent's last real message."""
|
||||
p_asks = [m.timestamp for m in parent.messages if m.role == "user" and not m.hidden]
|
||||
p_since = p_asks[-1] if p_asks else parent.created_at
|
||||
return [
|
||||
s for s in sessions.values()
|
||||
if s.parent_session_id == parent.id and s.last_failure_kind == "policy_block" and s.created_at >= p_since
|
||||
]
|
||||
|
||||
|
||||
@typechecked
|
||||
def child_reply(child: AgentSession) -> str:
|
||||
if child.last_failure_kind == "policy_block":
|
||||
from backend.apps.agents.core.error_classify import neutralize_provider_refusal
|
||||
p_partial = neutralize_provider_refusal(last_assistant_text(child) or "")
|
||||
return DECLINED_REPLY + (f"\n\nIts last note before stopping: {p_partial}" if p_partial else "")
|
||||
return last_assistant_text(child) or "No response from sub-agent."
|
||||
|
||||
|
||||
def last_assistant_text(session: AgentSession) -> Optional[str]:
|
||||
for msg in reversed(session.messages):
|
||||
if msg.role == "assistant":
|
||||
@@ -53,6 +84,15 @@ class SpawnAgentRun(AgentManagerProtocol):
|
||||
raise ValueError(f"Parent session {parent_session_id} not found")
|
||||
parent = AgentSession(**data)
|
||||
|
||||
p_declined = declined_children_this_turn(self.sessions, parent)
|
||||
if len(p_declined) >= DECLINED_CHILDREN_CAP:
|
||||
logger.warning(f"SpawnAgent refused for {parent_session_id}: {len(p_declined)} children declined by the filter this turn")
|
||||
return {"error": (
|
||||
f"{len(p_declined)} sub-agents for this task were declined by the model provider's filter this turn. "
|
||||
"Not spawning another: the same task will be declined again. Tell the user what was completed "
|
||||
"and what was declined, and let them decide."
|
||||
)}
|
||||
|
||||
title = (prompt.strip().splitlines() or [""])[0][:60] or "Sub-agent"
|
||||
child = AgentSession(
|
||||
id=uuid4().hex,
|
||||
@@ -103,6 +143,7 @@ class SpawnAgentRun(AgentManagerProtocol):
|
||||
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.",
|
||||
"status": child.status,
|
||||
"response": child_reply(child),
|
||||
"cost_usd": child.cost_usd,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Eric's board, 2026-08-26: a parent on Opus 5 spawned 21 transcription children over an hour; 14 died on the
|
||||
policy filter and the parent re-dispatched each one with a rephrased prompt, because the return said only
|
||||
"No response from sub-agent." The child now says it was declined, and a third spawn after two declines is refused."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
from backend.apps.agents.core.models import AgentSession, Message
|
||||
from backend.apps.agents.manager.SpawnAgentRun import DECLINED_CHILDREN_CAP, child_reply, declined_children_this_turn
|
||||
|
||||
|
||||
def p_parent() -> AgentSession:
|
||||
parent = AgentSession(name="parent", model="opus-5", cwd="/tmp/pw", dashboard_id="dashX")
|
||||
parent.messages.append(Message(role="user", content="transcribe the book"))
|
||||
agent_manager.sessions[parent.id] = parent
|
||||
return parent
|
||||
|
||||
|
||||
def p_declined_child(parent: AgentSession, born: datetime) -> AgentSession:
|
||||
child = AgentSession(name="child", model="opus-5", mode="sub-agent", parent_session_id=parent.id, status="error", created_at=born)
|
||||
child.last_failure_kind = "policy_block"
|
||||
agent_manager.sessions[child.id] = child
|
||||
return child
|
||||
|
||||
|
||||
def test_a_declined_child_tells_the_parent_not_to_spawn_another(monkeypatch: MonkeyPatch) -> None:
|
||||
parent = p_parent()
|
||||
|
||||
async def blocked_loop(session_id: str, prompt: str, **kwargs: object) -> None:
|
||||
s = agent_manager.sessions[session_id]
|
||||
s.messages.append(Message(role="assistant", content="Starting on page 6.", branch_id=s.active_branch_id))
|
||||
s.status = "error"
|
||||
s.last_failure_kind = "policy_block"
|
||||
|
||||
monkeypatch.setattr(agent_manager, "run_agent_loop", blocked_loop)
|
||||
result = asyncio.run(agent_manager.spawn_agent(prompt="transcribe pages 6-14", parent_session_id=parent.id))
|
||||
assert result["status"] == "error"
|
||||
assert "declined" in result["response"] and "do not spawn another" in result["response"]
|
||||
assert "Starting on page 6." in result["response"], "the partial note still comes home"
|
||||
assert "No response from sub-agent" not in result["response"]
|
||||
|
||||
|
||||
def test_a_third_spawn_after_two_declines_this_turn_is_refused(monkeypatch: MonkeyPatch) -> None:
|
||||
parent = p_parent()
|
||||
now = datetime.now()
|
||||
for _ in range(DECLINED_CHILDREN_CAP):
|
||||
p_declined_child(parent, now + timedelta(seconds=1))
|
||||
spawned: list[str] = []
|
||||
|
||||
async def loop(session_id: str, prompt: str, **kwargs: object) -> None:
|
||||
spawned.append(session_id)
|
||||
|
||||
monkeypatch.setattr(agent_manager, "run_agent_loop", loop)
|
||||
result = asyncio.run(agent_manager.spawn_agent(prompt="transcribe pages 6-14 again", parent_session_id=parent.id))
|
||||
assert "error" in result and "declined" in result["error"]
|
||||
assert spawned == [], "no child is born for a task the filter already declined twice"
|
||||
|
||||
|
||||
def test_declines_before_the_users_latest_message_do_not_count(monkeypatch: MonkeyPatch) -> None:
|
||||
"""A new ask is a new turn: the user may have changed the task, so the cap resets on their message."""
|
||||
parent = p_parent()
|
||||
old = datetime.now() - timedelta(minutes=5)
|
||||
for _ in range(DECLINED_CHILDREN_CAP):
|
||||
p_declined_child(parent, old)
|
||||
parent.messages.append(Message(role="user", content="try a different approach"))
|
||||
assert declined_children_this_turn(agent_manager.sessions, parent) == []
|
||||
spawned: list[str] = []
|
||||
|
||||
async def loop(session_id: str, prompt: str, **kwargs: object) -> None:
|
||||
spawned.append(session_id)
|
||||
agent_manager.sessions[session_id].status = "completed"
|
||||
|
||||
monkeypatch.setattr(agent_manager, "run_agent_loop", loop)
|
||||
result = asyncio.run(agent_manager.spawn_agent(prompt="summarise instead", parent_session_id=parent.id))
|
||||
assert len(spawned) == 1 and result["status"] == "completed"
|
||||
|
||||
|
||||
def test_a_clean_child_reply_is_unchanged() -> None:
|
||||
child = AgentSession(name="c", model="opus-5", mode="sub-agent")
|
||||
child.messages.append(Message(role="assistant", content="9 phonemes: 6 to 14."))
|
||||
assert child_reply(child) == "9 phonemes: 6 to 14."
|
||||
assert child_reply(AgentSession(name="e", model="opus-5", mode="sub-agent")) == "No response from sub-agent."
|
||||
|
||||
|
||||
def test_the_stamp_is_set_by_the_policy_card_and_cleared_at_turn_start() -> None:
|
||||
import inspect
|
||||
from backend.apps.agents.agent_manager import AgentManager
|
||||
from backend.apps.agents.manager.run import handle_run_error as hre
|
||||
src = inspect.getsource(hre.handle_run_error)
|
||||
assert 'session.last_failure_kind = "policy_block"' in src
|
||||
assert src.index('session.last_failure_kind = "policy_block"') > src.index("policy_block_sibling("), "only the card path stamps; a failover that continues is not a failure"
|
||||
assert "session.last_failure_kind = None" in inspect.getsource(AgentManager.run_agent_loop)
|
||||
Reference in New Issue
Block a user