fix(kanban): nudge workers that exit without complete/block

Add a bounded turn-end stop guard for kanban workers. When a worker
tries to exit with finish_reason=stop without having called
kanban_complete or kanban_block, inject up to two synthetic nudges
so the conversation loop continues instead of exiting cleanly (which
the dispatcher records as protocol_violation).

Mirrors the existing verify-on-stop pattern: same ephemeral scaffolding
flag (_kanban_stop_synthetic), same role-alternation contract, same
_pending_verification_response fallback for budget exhaustion.

Disabled by default (gated on HERMES_KANBAN_TASK env var set by the
dispatcher); kill switch via HERMES_KANBAN_STOP_NUDGE=0.

Salvaged from #62262 by @mdc2122. The original branch was 272 commits
behind main with ~538 files of stale-base reversions; this salvage
applies only the 4 substantive files (agent/kanban_stop.py,
conversation_loop.py insertion, run_agent.py _EPHEMERAL_SCAFFOLDING_FLAGS,
tests/agent/test_kanban_stop.py).
This commit is contained in:
mdc2122 2026-07-14 16:25:53 +05:30 committed by kshitij
parent 3c2886f599
commit 03fbf6edbb
4 changed files with 251 additions and 0 deletions

View file

@ -5329,6 +5329,53 @@ def run_conversation(
final_response = None
continue
# ── Kanban worker terminal-tool stop guard ─────────────
# Workers must end with kanban_complete / kanban_block.
# Models sometimes narrate the next step ("Let me write the
# report") and stop with finish_reason=stop — a clean exit
# that the dispatcher records as protocol_violation. Nudge
# once or twice before allowing that exit.
try:
from agent.kanban_stop import build_kanban_stop_nudge
_kanban_nudge = build_kanban_stop_nudge(
messages=messages,
attempts=getattr(agent, "_kanban_stop_nudges", 0),
)
except Exception:
logger.debug("kanban stop-loop check failed", exc_info=True)
_kanban_nudge = None
if _kanban_nudge:
agent._kanban_stop_nudges = (
getattr(agent, "_kanban_stop_nudges", 0) + 1
)
final_msg["finish_reason"] = "kanban_terminal_required"
final_msg["_kanban_stop_synthetic"] = True
messages.append(final_msg)
messages.append({
"role": "user",
"content": _kanban_nudge,
"_kanban_stop_synthetic": True,
})
agent._session_messages = messages
logger.info(
"kanban stop-loop nudge issued (attempt %d) task=%s",
agent._kanban_stop_nudges,
os.environ.get("HERMES_KANBAN_TASK", ""),
)
agent._emit_status(
"⚠️ Kanban worker tried to exit without "
"kanban_complete/kanban_block — nudging to finish"
)
# Same finalizer contract as verify-on-stop: clear
# final_response while continuing so a later budget
# exhaustion path does not treat the narrated stop as
# a completed answer.
_pending_verification_response = final_response
final_response = None
continue
messages.append(final_msg)
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"

107
agent/kanban_stop.py Normal file
View file

@ -0,0 +1,107 @@
"""Turn-end guard for kanban workers.
Kanban workers must end with ``kanban_complete`` or ``kanban_block``. Models
(especially GLM / Qwen families) sometimes narrate the next step
("Let me write the report now") and stop with ``finish_reason=stop`` and no
tool calls. Hermes treats that as a clean exit ``rc=0`` dispatcher
``protocol_violation``.
This module is policy-only: when a kanban worker tries to finish without a
terminal board tool, return a bounded synthetic nudge so the conversation
loop continues instead of exiting.
"""
from __future__ import annotations
import os
from typing import Any, Iterable, Optional
_TERMINAL_KANBAN_TOOLS = frozenset({"kanban_complete", "kanban_block"})
_DEFAULT_MAX_ATTEMPTS = 2
def kanban_stop_nudge_enabled() -> bool:
"""Return whether the kanban stop-guard is active for this process.
On when ``HERMES_KANBAN_TASK`` is set (dispatcher-spawned worker), unless
``HERMES_KANBAN_STOP_NUDGE`` explicitly disables it.
"""
env = os.environ.get("HERMES_KANBAN_STOP_NUDGE")
if env is not None and env.strip().lower() in {"0", "false", "no", "off"}:
return False
task = (os.environ.get("HERMES_KANBAN_TASK") or "").strip()
return bool(task)
def _tool_call_name(tc: Any) -> str:
if isinstance(tc, dict):
fn = tc.get("function")
if isinstance(fn, dict):
return str(fn.get("name") or "")
return str(tc.get("name") or "")
fn = getattr(tc, "function", None)
if fn is not None:
return str(getattr(fn, "name", "") or "")
return str(getattr(tc, "name", "") or "")
def session_called_kanban_terminal(messages: Iterable[dict] | None) -> bool:
"""True if this conversation already invoked a terminal kanban tool."""
if not messages:
return False
for msg in messages:
if not isinstance(msg, dict):
continue
role = msg.get("role")
if role == "assistant":
for tc in msg.get("tool_calls") or []:
if _tool_call_name(tc) in _TERMINAL_KANBAN_TOOLS:
return True
elif role == "tool":
name = str(msg.get("name") or "")
if name in _TERMINAL_KANBAN_TOOLS:
return True
return False
def build_kanban_stop_nudge(
*,
messages: Iterable[dict] | None = None,
attempts: int = 0,
max_attempts: int = _DEFAULT_MAX_ATTEMPTS,
task_id: Optional[str] = None,
) -> Optional[str]:
"""Return a synthetic follow-up when a kanban worker exits without a terminal tool.
Returns ``None`` when the guard should not fire (not a kanban worker,
already completed/blocked, or nudge budget exhausted).
"""
if not kanban_stop_nudge_enabled():
return None
if attempts >= max_attempts:
return None
if session_called_kanban_terminal(messages):
return None
tid = (task_id or os.environ.get("HERMES_KANBAN_TASK") or "").strip() or "this task"
return (
"[System: You are a Hermes kanban worker. A plain-text reply is NOT a "
"terminal state for the board.\n\n"
f"Task `{tid}` is still `running`. Ending now without a board tool "
"causes a protocol violation (clean exit with no "
"`kanban_complete` / `kanban_block`).\n\n"
"Do this immediately in your next response — do not narrate intent:\n"
"1. Finish any remaining deliverable (write the required file(s) now).\n"
"2. Call `kanban_complete(summary=..., artifacts=[...])` if the work "
"is done, OR `kanban_block(reason=...)` if you are blocked.\n\n"
"Never end a turn with only a promise of future action.]"
)
__all__ = [
"build_kanban_stop_nudge",
"kanban_stop_nudge_enabled",
"session_called_kanban_terminal",
]