Files
openswarm/backend/apps/agents/manager/run/turn_spoke.py
T

100 lines
3.9 KiB
Python

"""One invariant at the single exit: a finished turn always left the user something to read.
Every silent-stop fix so far has been a DETECTOR: empty finish, vanishing quit, stalled nudge,
produced-nothing, exhausted budget. Each one enumerates a shape somebody already found in the
field, which means the next shape nobody has found yet still ships as silence. That is the wrong
tier, and the ENG-354 history is the proof: the class kept coming back wearing a different hat.
So this does not classify anything. It asks the only question the user asks, at the one place every
terminal path passes through:
since you last spoke to me, has anything appeared that I can read?
If not, the honest line goes in. It does not matter whether the cause was a silent quit, a shape
nobody has named, or a bug written next year: the state "turn ended, nothing to read" stops being
representable, rather than being caught case by case.
Deliberately last: the detectors upstream produce BETTER messages because they know why. This only
fires when every one of them declined, so it is a floor, never a replacement.
"""
import logging
from typing import List
from typeguard import typechecked
from backend.apps.agents.core.models import AgentSession, Message
from backend.apps.agents.manager.run.empty_finish import (
EXHAUSTED_NOTE,
EXHAUSTED_NOTE_NO_PROGRESS,
P_ANSWER_TOOL_MARKERS,
turn_showed_work,
)
from backend.apps.agents.manager.session.history_compaction import get_branch_messages
logger = logging.getLogger(__name__)
# States where the turn is genuinely over. "stopped" is excluded on purpose: the user pressed stop,
# so they know exactly why it ended and do not need to be told.
P_TERMINAL = ("completed", "error")
@typechecked
def p_tool_name_of(msg: object) -> str:
content = getattr(msg, "content", None)
if isinstance(content, dict):
return str(content.get("tool") or content.get("tool_name") or "")
return ""
@typechecked
def turn_left_the_user_with_nothing(session: AgentSession) -> bool:
"""True when nothing readable has appeared since the user's last visible message."""
msgs: List = [m for m in get_branch_messages(session) if not getattr(m, "hidden", False)]
if not msgs:
return False
p_last_user = -1
for i, m in enumerate(msgs):
if getattr(m, "role", "") == "user":
p_last_user = i
if p_last_user < 0:
return False
for m in msgs[p_last_user + 1:]:
role = getattr(m, "role", "")
if role == "system":
return False
if role == "assistant":
text = m.content if isinstance(m.content, str) else ""
if text.strip():
return False
if role == "tool_call" and any(mk in p_tool_name_of(m) for mk in P_ANSWER_TOOL_MARKERS):
# A rendered widget IS the answer; the user is looking at it.
return False
return True
@typechecked
def ensure_turn_spoke(session: AgentSession, session_id: str) -> bool:
"""Append the honest line if the turn is over and said nothing. Returns whether it fired."""
if session.status not in P_TERMINAL:
return False
# A parked or continuing turn is not over; speaking now would be the lie.
if getattr(session, "pending_continuation", False) or getattr(session, "awaiting_reconnect", False):
return False
if not turn_left_the_user_with_nothing(session):
return False
# Point at work only when there is work to point at; a turn that did nothing gets the copy
# that names an action the user can actually take.
p_note = EXHAUSTED_NOTE if turn_showed_work(session) else EXHAUSTED_NOTE_NO_PROGRESS
session.messages.append(
Message(role="system", content=p_note, branch_id=session.active_branch_id)
)
logger.warning(
f"Agent {session_id}: turn ended with nothing readable and no detector claimed it; "
"the floor spoke instead of leaving the user with a bare Done"
)
return True