From 2b3bf17dfa7f75c05174198f80457e6f483d2131 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 10 May 2026 23:39:07 +0800 Subject: [PATCH] fix(kanban): call kanban_block on iteration-budget exhaustion to prevent protocol violation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a kanban worker subprocess hits the iteration budget, the agent loop strips tools and asks the model for a summary. The model cannot call kanban_block itself at that point, so the process exits rc=0 without calling kanban_complete or kanban_block — a protocol violation that the dispatcher detects as a fatal error, giving up after 1 failure and stranding downstream tasks. Fix: after _handle_max_iterations() returns, check HERMES_KANBAN_TASK and call kanban_block with a reason describing the exhaustion. The dispatcher then sees a clean block transition instead of a protocol violation, and the task can be retried or escalated by a human. Fixes [Bug] kanban-worker exits cleanly (rc=0) on iteration-budget exhaustion without calling kanban_complete or kanban_block #23216 --- run_agent.py | 36 +++++++++++++- tests/run_agent/test_run_agent.py | 82 +++++++++++++++++++++++++++++++ 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/run_agent.py b/run_agent.py index 5fdb73487a3..3425c380492 100644 --- a/run_agent.py +++ b/run_agent.py @@ -14987,7 +14987,41 @@ class AIAgent: "— requesting summary..." ) final_response = self._handle_max_iterations(messages, api_call_count) - + + # If running as a kanban worker, block the task so the dispatcher + # knows the worker could not complete (rather than treating it as a + # protocol violation). The agent loop strips tools before calling + # _handle_max_iterations, so the model cannot call kanban_block + # itself — we must do it on its behalf. + _kanban_task = os.environ.get("HERMES_KANBAN_TASK") + if _kanban_task: + try: + handle_function_call( + "kanban_block", + { + "task_id": _kanban_task, + "reason": ( + f"Iteration budget exhausted " + f"({api_call_count}/{self.max_iterations}) — " + "task could not complete within the allowed " + "iterations" + ), + }, + task_id=effective_task_id, + ) + logger.info( + "kanban_block called for task %s after iteration " + "exhaustion (%d/%d)", + _kanban_task, api_call_count, self.max_iterations, + ) + except Exception: + logger.warning( + "Failed to call kanban_block after iteration " + "exhaustion for task %s", + _kanban_task, + exc_info=True, + ) + # Determine if conversation completed successfully completed = final_response is not None and api_call_count < self.max_iterations diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 5bc485e0711..dadb7b31cce 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -3344,6 +3344,88 @@ class TestRunConversation: assert "truncated due to output length limit" in result["error"] mock_handle_function_call.assert_not_called() + def test_kanban_block_called_on_iteration_exhaustion(self, agent, monkeypatch): + """Regression: kanban worker must call kanban_block when iteration + budget is exhausted, otherwise the dispatcher sees a protocol + violation and gives up after 1 failure (issue #23216).""" + self._setup_agent(agent) + agent.max_iterations = 2 + + monkeypatch.setenv("HERMES_KANBAN_TASK", "t_test_task_123") + + # Return a tool call for every iteration to exhaust the budget. + tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") + tool_resp = _mock_response( + content="", finish_reason="tool_calls", tool_calls=[tc], + ) + # Final summary response from _handle_max_iterations. + summary_resp = _mock_response( + content="Could not finish — budget exhausted.", finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [ + tool_resp, tool_resp, summary_resp, + ] + + with ( + patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("do the kanban work") + + # The agent should have reported the task as not completed. + assert result["completed"] is False + + # Among all handle_function_call invocations, one must be + # kanban_block with the correct task_id and a reason mentioning + # iteration exhaustion. + kanban_block_calls = [ + c for c in mock_hfc.call_args_list + if c[0][0] == "kanban_block" + ] + assert len(kanban_block_calls) == 1, ( + f"Expected exactly 1 kanban_block call, got {len(kanban_block_calls)}. " + f"All calls: {mock_hfc.call_args_list}" + ) + call = kanban_block_calls[0] + assert call[0][1]["task_id"] == "t_test_task_123" + assert "Iteration budget exhausted" in call[0][1]["reason"] + + def test_no_kanban_block_when_not_in_kanban_mode(self, agent, monkeypatch): + """kanban_block must NOT be called when HERMES_KANBAN_TASK is unset.""" + self._setup_agent(agent) + agent.max_iterations = 2 + + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + + tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") + tool_resp = _mock_response( + content="", finish_reason="tool_calls", tool_calls=[tc], + ) + summary_resp = _mock_response( + content="Summary.", finish_reason="stop", + ) + agent.client.chat.completions.create.side_effect = [ + tool_resp, tool_resp, summary_resp, + ] + + with ( + patch("run_agent.handle_function_call", return_value="ok") as mock_hfc, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + agent.run_conversation("do stuff") + + kanban_block_calls = [ + c for c in mock_hfc.call_args_list + if c[0][0] == "kanban_block" + ] + assert len(kanban_block_calls) == 0, ( + "kanban_block should not be called outside kanban mode" + ) + class TestRetryExhaustion: """Regression: retry_count > max_retries was dead code (off-by-one).