mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
[eric] agents: silent-quit re-nudges are earned by new tool work, capped at 3, stalls surface honestly (ENG-121 family)
This commit is contained in:
@@ -135,6 +135,8 @@ class AgentSession(BaseModel):
|
||||
pending_continuation_prompt: Optional[str] = None
|
||||
# Silent-quit nudges spent since the user's last real message; capped at 1 so an agent that keeps ending empty can't loop.
|
||||
empty_finish_nudges: int = 0
|
||||
# Tool-call count at the last nudge: a re-nudge is only earned by NEW tool work since then.
|
||||
empty_finish_progress_mark: int = 0
|
||||
# Sanitized server names model has explicitly activated this session; _build_mcp_servers intersects connected MCPs with this. Non-bypassable; dispatch-layer gate.
|
||||
active_mcps: list[str] = Field(default_factory=list)
|
||||
# Heuristic preamble tokens (preset + tool defs + MCP descs + composed prompt); subtracted from displayed input.
|
||||
|
||||
@@ -136,6 +136,7 @@ class Messaging(AgentManagerProtocol):
|
||||
# A real user message opens a fresh silent-quit budget; the cap only guards within one ask.
|
||||
if not hidden:
|
||||
session.empty_finish_nudges = 0
|
||||
session.empty_finish_progress_mark = 0
|
||||
# Fire a background aux LLM call to generate a 3-6 word verb-phrase describing this turn ("Auditing the pull request", "Drafting your email"). The narrator pill swaps from its heuristic verb to this label as soon as it lands, usually ~500ms-1s into the turn, which is exactly when "Thinking…" starts feeling generic. Provider-agnostic via resolve_aux_model. Non-blocking; failure is silent and the heuristic stays.
|
||||
if not hidden and prompt:
|
||||
try:
|
||||
|
||||
@@ -20,14 +20,23 @@ NUDGE_PROMPT = (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
NUDGE_HARD_CAP = 3
|
||||
|
||||
|
||||
@typechecked
|
||||
def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool:
|
||||
"""Arm one hidden continue nudge when the finished turn quit silently; the loop's existing
|
||||
auto-continuation block dispatches it. Capped per user ask; never loops."""
|
||||
if getattr(session, "pending_continuation", False) or session.empty_finish_nudges >= 1:
|
||||
"""Arm a hidden continue nudge when the finished turn quit silently; the loop's existing
|
||||
auto-continuation block dispatches it. A re-nudge must be EARNED by new tool work since the
|
||||
last one (the model is visibly still working, just mute); a stalled continuation surfaces
|
||||
honestly, so this can never ping-pong a model that has nothing left to do."""
|
||||
if getattr(session, "pending_continuation", False) or session.empty_finish_nudges >= NUDGE_HARD_CAP:
|
||||
return False
|
||||
if not turn_finished_empty(session):
|
||||
return False
|
||||
p_tool_calls = p_count_tool_calls(session)
|
||||
if session.empty_finish_nudges >= 1 and p_tool_calls <= session.empty_finish_progress_mark:
|
||||
return False
|
||||
session.empty_finish_progress_mark = p_tool_calls
|
||||
session.empty_finish_nudges += 1
|
||||
session.pending_continuation = True
|
||||
session.pending_continuation_prompt = NUDGE_PROMPT
|
||||
@@ -43,6 +52,11 @@ def maybe_nudge_empty_finish(session: AgentSession, session_id: str) -> bool:
|
||||
P_ANSWER_TOOL_MARKERS = ("openswarm-ui", "ShowUI", "AskUI", "AskUserQuestion")
|
||||
|
||||
|
||||
@typechecked
|
||||
def p_count_tool_calls(session: AgentSession) -> int:
|
||||
return sum(1 for m in get_branch_messages(session) if getattr(m, "role", "") == "tool_call")
|
||||
|
||||
|
||||
def p_tool_name_of(msg: object) -> str:
|
||||
content = getattr(msg, "content", None)
|
||||
if isinstance(content, dict):
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"""The silent-quit seal: a turn that runs tools and ends with no visible answer gets one hidden
|
||||
continue nudge, never a loop. Detector shapes pinned here; the loop wiring is pinned in
|
||||
test_context_pressure_valve-style fashion against run_agent_loop."""
|
||||
"""The silent-quit seal: a turn that runs tools and ends with no visible answer gets a hidden
|
||||
continue nudge. Re-nudges must be EARNED by new tool work (the model is working but mute); a
|
||||
stalled continuation surfaces honestly, and a hard cap bounds the worst case. Detector shapes
|
||||
pinned here; the loop wiring is pinned against run_agent_loop."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from backend.apps.agents.agent_manager import agent_manager
|
||||
import backend.apps.agents.agent_manager as agent_manager_module
|
||||
from backend.apps.agents.core.models import AgentSession, Message
|
||||
from backend.apps.agents.manager.run.empty_finish import NUDGE_PROMPT, turn_finished_empty
|
||||
from backend.apps.agents.manager.run.empty_finish import (
|
||||
NUDGE_HARD_CAP,
|
||||
NUDGE_PROMPT,
|
||||
maybe_nudge_empty_finish,
|
||||
turn_finished_empty,
|
||||
)
|
||||
|
||||
|
||||
def p_session(*msgs) -> AgentSession:
|
||||
@@ -66,7 +72,7 @@ def p_install_run_fakes(monkeypatch, run_turn_fake) -> None:
|
||||
monkeypatch.setattr(agent_manager_module, "save_session", lambda sid, data: None)
|
||||
|
||||
|
||||
def test_loop_nudges_a_silent_quit_once(monkeypatch) -> None:
|
||||
def test_loop_renudges_while_progressing_then_caps(monkeypatch) -> None:
|
||||
session = AgentSession(name="t", model="sonnet", dashboard_id="d")
|
||||
agent_manager.sessions[session.id] = session
|
||||
continues: list = []
|
||||
@@ -74,7 +80,7 @@ def test_loop_nudges_a_silent_quit_once(monkeypatch) -> None:
|
||||
async def fake_run_turn(sess, session_id, prompt_content, options, options_kwargs,
|
||||
turn, thinking, stderr, resolved_model, api_type,
|
||||
global_settings, force_respawn=False):
|
||||
# Every turn ends as a silent quit: tools ran, no answer text.
|
||||
# Every turn ends as a silent quit: NEW tool work ran, no answer text.
|
||||
sess.messages.append(Message(role="tool_call", content={"tool": "Bash", "input": {}}, branch_id="main"))
|
||||
sess.messages.append(Message(role="tool_result", content={"text": "out"}, branch_id="main"))
|
||||
|
||||
@@ -88,12 +94,33 @@ def test_loop_nudges_a_silent_quit_once(monkeypatch) -> None:
|
||||
await agent_manager.run_agent_loop(session.id, "audit everything")
|
||||
await asyncio.sleep(0)
|
||||
|
||||
asyncio.run(main())
|
||||
assert continues == [{"prompt": NUDGE_PROMPT, "hidden": True}]
|
||||
assert session.empty_finish_nudges == 1
|
||||
# Each silent quit made fresh tool progress, so each earns a nudge, up to the hard cap.
|
||||
for expected in range(1, NUDGE_HARD_CAP + 1):
|
||||
continues.clear()
|
||||
asyncio.run(main())
|
||||
assert continues == [{"prompt": NUDGE_PROMPT, "hidden": True}]
|
||||
assert session.empty_finish_nudges == expected
|
||||
|
||||
# The nudged turn ALSO quits silently: the cap must hold, no second nudge, no loop.
|
||||
# At the cap even a progressing silent quit surfaces honestly: no nudge, no loop.
|
||||
continues.clear()
|
||||
asyncio.run(main())
|
||||
assert continues == []
|
||||
assert session.empty_finish_nudges == 1
|
||||
assert session.empty_finish_nudges == NUDGE_HARD_CAP
|
||||
|
||||
|
||||
def test_stalled_continuation_is_not_renudged() -> None:
|
||||
s = p_session(("user", "audit"),
|
||||
("tool_call", {"tool": "Bash", "input": {}}),
|
||||
("tool_result", {"text": "ok"}))
|
||||
assert maybe_nudge_empty_finish(s, "sid") is True
|
||||
assert s.empty_finish_nudges == 1
|
||||
# The continuation dispatched, added NOTHING, and quit silently again: no second nudge.
|
||||
s.pending_continuation = False
|
||||
assert maybe_nudge_empty_finish(s, "sid") is False
|
||||
assert s.empty_finish_nudges == 1
|
||||
# New tool work arrives: the re-nudge is earned again.
|
||||
s.messages.append(Message(role="tool_call", content={"tool": "Grep", "input": {}}, branch_id="main"))
|
||||
s.messages.append(Message(role="tool_result", content={"text": "hit"}, branch_id="main"))
|
||||
s.pending_continuation = False
|
||||
assert maybe_nudge_empty_finish(s, "sid") is True
|
||||
assert s.empty_finish_nudges == 2
|
||||
|
||||
Reference in New Issue
Block a user