From 03fbf6edbb92a5306c15dbb1bf437d68ebeea655 Mon Sep 17 00:00:00 2001 From: mdc2122 <259970464+mdc2122@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:25:53 +0530 Subject: [PATCH] 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). --- agent/conversation_loop.py | 47 ++++++++++++++ agent/kanban_stop.py | 107 ++++++++++++++++++++++++++++++++ run_agent.py | 2 + tests/agent/test_kanban_stop.py | 95 ++++++++++++++++++++++++++++ 4 files changed, 251 insertions(+) create mode 100644 agent/kanban_stop.py create mode 100644 tests/agent/test_kanban_stop.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 2468fcfa090b..23b977973642 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -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})" diff --git a/agent/kanban_stop.py b/agent/kanban_stop.py new file mode 100644 index 000000000000..bb97ddd425f0 --- /dev/null +++ b/agent/kanban_stop.py @@ -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", +] diff --git a/run_agent.py b/run_agent.py index fe378f396ae9..805f5ced6fb0 100644 --- a/run_agent.py +++ b/run_agent.py @@ -232,6 +232,8 @@ _EPHEMERAL_SCAFFOLDING_FLAGS = ( # transcript and breaks prompt-prefix cache reuse on later turns. (#55733) "_verification_stop_synthetic", "_pre_verify_synthetic", + # kanban worker stop-guard: narrated exit without kanban_complete/block + "_kanban_stop_synthetic", ) diff --git a/tests/agent/test_kanban_stop.py b/tests/agent/test_kanban_stop.py new file mode 100644 index 000000000000..62ba78a42a79 --- /dev/null +++ b/tests/agent/test_kanban_stop.py @@ -0,0 +1,95 @@ +"""Tests for the kanban worker turn-end stop guard.""" + +from __future__ import annotations + +import pytest + +from agent.kanban_stop import ( + build_kanban_stop_nudge, + kanban_stop_nudge_enabled, + session_called_kanban_terminal, +) + + +@pytest.fixture +def clear_kanban_env(monkeypatch): + for var in ("HERMES_KANBAN_TASK", "HERMES_KANBAN_STOP_NUDGE"): + monkeypatch.delenv(var, raising=False) + return monkeypatch + + +def test_disabled_without_kanban_task(clear_kanban_env): + assert kanban_stop_nudge_enabled() is False + assert build_kanban_stop_nudge(messages=[]) is None + + +def test_enabled_with_kanban_task(clear_kanban_env): + clear_kanban_env.setenv("HERMES_KANBAN_TASK", "t_abc") + assert kanban_stop_nudge_enabled() is True + + +def test_env_can_disable(clear_kanban_env): + clear_kanban_env.setenv("HERMES_KANBAN_TASK", "t_abc") + clear_kanban_env.setenv("HERMES_KANBAN_STOP_NUDGE", "0") + assert kanban_stop_nudge_enabled() is False + assert build_kanban_stop_nudge(messages=[]) is None + + +def test_nudge_when_no_terminal_tool(clear_kanban_env): + clear_kanban_env.setenv("HERMES_KANBAN_TASK", "t_46be8aa5") + messages = [ + {"role": "user", "content": "work kanban task"}, + { + "role": "assistant", + "content": "Let me write the comprehensive recipe.", + "tool_calls": [ + { + "id": "1", + "type": "function", + "function": {"name": "kanban_heartbeat", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "name": "kanban_heartbeat", "tool_call_id": "1", "content": "ok"}, + ] + nudge = build_kanban_stop_nudge(messages=messages, attempts=0) + assert nudge is not None + assert "kanban_complete" in nudge + assert "kanban_block" in nudge + assert "t_46be8aa5" in nudge + assert "protocol violation" in nudge.lower() or "protocol" in nudge.lower() + + +def test_no_nudge_after_kanban_complete(clear_kanban_env): + clear_kanban_env.setenv("HERMES_KANBAN_TASK", "t_abc") + messages = [ + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "1", + "type": "function", + "function": {"name": "kanban_complete", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "name": "kanban_complete", "tool_call_id": "1", "content": "done"}, + ] + assert session_called_kanban_terminal(messages) is True + assert build_kanban_stop_nudge(messages=messages) is None + + +def test_no_nudge_after_kanban_block(clear_kanban_env): + clear_kanban_env.setenv("HERMES_KANBAN_TASK", "t_abc") + messages = [ + {"role": "tool", "name": "kanban_block", "tool_call_id": "1", "content": "blocked"}, + ] + assert build_kanban_stop_nudge(messages=messages) is None + + +def test_nudge_budget_exhausted(clear_kanban_env): + clear_kanban_env.setenv("HERMES_KANBAN_TASK", "t_abc") + assert build_kanban_stop_nudge(messages=[], attempts=2) is None + assert build_kanban_stop_nudge(messages=[], attempts=1, max_attempts=1) is None + assert build_kanban_stop_nudge(messages=[], attempts=0, max_attempts=1) is not None