From e4b16bbe07db06498b378bb9b79df92d6e7e62f6 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 7 Sep 2026 18:32:57 -0700 Subject: [PATCH] [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 --- backend/apps/agents/manager/SpawnAgentRun.py | 45 ++++++++- ...test_declined_child_is_not_redispatched.py | 95 +++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 backend/tests/test_declined_child_is_not_redispatched.py diff --git a/backend/apps/agents/manager/SpawnAgentRun.py b/backend/apps/agents/manager/SpawnAgentRun.py index e1ea2f92..5f6b9f63 100644 --- a/backend/apps/agents/manager/SpawnAgentRun.py +++ b/backend/apps/agents/manager/SpawnAgentRun.py @@ -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, } diff --git a/backend/tests/test_declined_child_is_not_redispatched.py b/backend/tests/test_declined_child_is_not_redispatched.py new file mode 100644 index 00000000..47faafb8 --- /dev/null +++ b/backend/tests/test_declined_child_is_not_redispatched.py @@ -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)