{"repo":"NousResearch/hermes-agent","public_id":"63860","display_label":"Stale housekeeping fallback after tool-only turns","summary":"These issues and PRs address a bug where a housekeeping fallback value persists incorrectly after a substantive tool-only turn in the conversation loop, and related premature loop exit behavior with weak models.","subsystem":null,"counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":64348,"merged_at":"2026-07-14T11:03:15+00:00"},"resolution":{"state":"resolved","by":"pr-64348"},"freshness":{"state":"verified","last_verified_at":"2026-07-14T11:03:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":63860,"pr_number":64348,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-14T11:03:15+00:00"}],"members":[{"kind":"issue","number":63860,"title":"[Bug]: Stale housekeeping fallback survives a later substantive tool-only turn","url":null,"state":"closed"},{"kind":"pr","number":63888,"title":"fix(conversation): clear stale housekeeping fallback on substantive tool-only turns","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#64348","number":64348},{"kind":"pr","value":"#63860","number":63860}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":64080,"title":"fix(conversation_loop): invalidate stale housekeeping fallback on substantive tool-only turn","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#63888","number":63888},{"kind":"pr","value":"#64348","number":64348},{"kind":"pr","value":"#63860","number":63860}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":64348,"title":"fix(conversation_loop): invalidate stale housekeeping fallback on substantive tool-only turn","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/conversation_loop.py","tests/run_agent/test_conversation_fallback_state.py"],"fix_hunks":[{"file":"agent/conversation_loop.py","hunks":["@@ -4631,11 +4631,37 @@ def _perform_api_call(next_api_kwargs):\n \n                 assistant_msg = agent._build_assistant_message(assistant_message, finish_reason)\n                 \n+                turn_content = assistant_message.content or \"\"\n+\n+                # Classify tools in this turn to determine if they are all housekeeping.\n+                # This classification is needed regardless of whether the turn has visible content,\n+                # because a substantive tool-only turn must invalidate any older housekeeping fallback.\n+                _HOUSEKEEPING_TOOLS = frozenset({\n+                    \"memory\", \"todo\", \"skill_manage\", \"session_search\",\n+                })\n+                _all_housekeeping = all(\n+                    tc.function.name in _HOUSEKEEPING_TOOLS\n+                    for tc in assistant_message.tool_calls\n+                )\n+\n+                # If this turn has substantive tools (non-housekeeping), clear any older fallback.\n+                # Prevents a two-turn-old housekeeping narration from being treated as if it belonged\n+                # to the immediately preceding substantive tool turn.\n+                if assistant_message.tool_calls and not _all_housekeeping:\n+                    agent._last_content_with_tools = None\n+                    agent._last_content_tools_all_housekeeping = False\n+                    # Also clear the mute flag: a prior housekeeping turn may\n+                    # have set _mute_post_response (line ~4667), and the\n+                    # substantive tools in THIS turn should produce visible\n+                    # progress output. Without this reset, _vprint suppresses\n+                    # tool progress until the no-tool-call branch clears it at\n+                    # line ~4834 — after all tools have finished.\n+                    agent._mute_post_response = False\n+\n                 # If this turn has both content AND tool_calls, capture the content\n                 # as a fallback final response. Common pattern: model delivers its\n                 # answer and calls memory/skill tools as a side-effect in the same\n                 # turn. If the follow-up turn after tools is empty, we use this.\n-                turn_content = assistant_message.content or \"\"\n                 if turn_content and agent._has_content_after_think_block(turn_content):\n                     agent._last_content_with_tools = turn_content\n                     # Only mute subsequent output when EVERY tool call in","@@ -4643,13 +4669,6 @@ def _perform_api_call(next_api_kwargs):\n                     # skill_manage, etc.).  If any substantive tool is present\n                     # (search_files, read_file, write_file, terminal, ...),\n                     # keep output visible so the user sees progress.\n-                    _HOUSEKEEPING_TOOLS = frozenset({\n-                        \"memory\", \"todo\", \"skill_manage\", \"session_search\",\n-                    })\n-                    _all_housekeeping = all(\n-                        tc.function.name in _HOUSEKEEPING_TOOLS\n-                        for tc in assistant_message.tool_calls\n-                    )\n                     agent._last_content_tools_all_housekeeping = _all_housekeeping\n                     if _all_housekeeping and agent._has_stream_consumers():\n                         agent._mute_post_response = True"]},{"file":"tests/run_agent/test_conversation_fallback_state.py","hunks":["@@ -0,0 +1,180 @@\n+\"\"\"Regression tests for conversation loop fallback state management.\"\"\"\n+from types import SimpleNamespace\n+from unittest.mock import MagicMock, patch\n+\n+import pytest\n+\n+from run_agent import AIAgent\n+\n+\n+def _tool_defs(*names):\n+    \"\"\"Helper: create minimal tool definitions for given names.\"\"\"\n+    return [\n+        {\n+            \"type\": \"function\", \"function\": {\n+                \"name\": name,\n+                \"description\": \"test tool\",\n+                \"parameters\": {\"type\": \"object\", \"properties\": {}},\n+            }\n+        }\n+        for name in names\n+    ]\n+\n+\n+def _tool_call(name, call_id):\n+    \"\"\"Helper: create a minimal tool call object.\"\"\"\n+    return SimpleNamespace(\n+        id=call_id, type=\"function\",\n+        function=SimpleNamespace(name=name, arguments=\"{}\"),\n+    )\n+\n+\n+def _response(*, content, finish_reason, tool_calls=None):\n+    \"\"\"Helper: create a minimal API response object.\"\"\"\n+    message = SimpleNamespace(content=content, tool_calls=tool_calls)\n+    choice = SimpleNamespace(message=message, finish_reason=finish_reason)\n+    return SimpleNamespace(choices=[choice], model=\"test/model\", usage=None)\n+\n+\n+def test_substantive_tool_only_turn_invalidates_older_housekeeping_fallback():\n+    \"\"\"\n+    Regression test for #63860.\n+\n+    A cached `_last_content_with_tools` response from a housekeeping-only turn\n+    must not survive a later substantive tool-only turn. When the model returns\n+    an empty response after the substantive tool turn, the system should enter\n+    the post-tool nudge path, not use the stale housekeeping fallback.\n+\n+    Production impact: scheduled cron jobs could return early without\n+    completing their actual work (e.g., daily report job returning a\n+    housekeeping message instead of producing the report artifact).\n+\n+    Test sequence:\n+    1. Content + todo (housekeeping) → sets fallback, marks as all-housekeeping\n+    2. Empty content + web_search (substantive) → should CLEAR old fallback\n+    3. Empty content, no tool calls → should enter post-tool nudge, not use old fallback\n+    4. Content \"Recovered after nudge.\" → should be returned as final response\n+\n+    Before the fix:\n+    - Step 2 would not clear the fallback state (no visible content)\n+    - Step 3 would incorrectly use the housekeeping fallback from step 1\n+    - API calls would stop at 3, never reaching the nudge response\n+\n+    After the fix:\n+    - Step 2 classifies tools and clears the fallback because web_search is substantive\n+    - Step 3 enters the post-tool nudge path (no stale housekeeping fallback available)\n+    - Step 4 returns the nudge response as the final answer\n+    \"\"\"\n+    with (\n+        patch(\"run_agent.get_tool_definitions\", return_value=_tool_defs(\"todo\", \"web_search\")),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        agent = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1/\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+\n+    agent._cached_system_prompt = \"You are helpful.\"\n+    agent._use_prompt_caching = False\n+    agent.tool_delay = 0\n+    agent.compression_enabled = False\n+    agent.save_trajectories = False\n+    agent.valid_tool_names = {\"todo\", \"web_search\"}\n+    agent.client = MagicMock()\n+    agent.client.chat.completions.create.side_effect = [\n+        # Turn 1: Content + housekeeping tool\n+        _response(\n+            content=\"I'll begin the work.\",\n+            finish_reason=\"tool_calls\",\n+            tool_calls=[_tool_call(\"todo\", \"todo1\")],\n+        ),\n+        # Turn 2: Empty content + substantive tool (should clear stale fallback)\n+        _response(\n+            content=\"\",\n+            finish_reason=\"tool_calls\",\n+            tool_calls=[_tool_call(\"web_search\", \"search1\")],\n+        ),\n+        # Turn 3: Empty response (should enter nudge path, not use stale fallback)\n+        _response(content=\"\", finish_reason=\"stop\"),\n+        # Turn 4: Nudge response\n+        _response(content=\"Recovered after nudge.\", finish_reason=\"stop\"),\n+    ]\n+\n+    with (\n+        patch(\"run_agent.handle_function_call\", return_value=\"ok\"),\n+        patch.object(agent, \"_persist_session\"),\n+        patch.object(agent, \"_save_trajectory\"),\n+        patch.object(agent, \"_cleanup_task_resources\"),\n+    ):\n+        result = agent.run_conversation(\"do the full task\")\n+\n+    assert result[\"final_response\"] == \"Recovered after nudge.\", (\n+        f\"Expected nudge recovery response, got: {result['final_response']}. \"\n+        f\"This indicates the stale housekeeping fallback was incorrectly used.\"\n+    )\n+    assert result[\"api_calls\"] == 4, (\n+        f\"Expected 4 API calls (including nudge), got: {result['api_calls']}. \"\n+        f\"This indicates the conversation exited early without retrying.\"\n+    )\n+    assert result[\"turn_exit_reason\"].startswith(\"text_response\"), (\n+        f\"Expected text_response exit, got: {result['turn_exit_reason']}. \"\n+        f\"This indicates the wrong fallback path was taken.\"\n+    )\n+\n+\n+def test_housekeeping_only_turn_still_sets_fallback():\n+    \"\"\"Regression: pure housekeeping turns (content + only housekeeping tools)\n+    must still set the fallback so the post-response mute path works.  This\n+    verifies the fix doesn't break the original use case the fallback was\n+    designed for.\n+    \"\"\"\n+    with (\n+        patch(\"run_agent.get_tool_definitions\", return_value=_tool_defs(\"memory\")),\n+        patch(\"run_agent.check_toolset_requirements\", return_value={}),\n+        patch(\"run_agent.OpenAI\"),\n+    ):\n+        agent = AIAgent(\n+            api_key=\"[redacted:secret-assignment]\",\n+            base_url=\"https://openrouter.ai/api/v1/\",\n+            quiet_mode=True,\n+            skip_context_files=True,\n+            skip_memory=True,\n+        )\n+\n+    agent._cached_system_prompt = \"You are helpful.\"\n+    agent._use_prompt_caching = False\n+    agent.tool_delay = 0\n+    agent.compression_enabled = False\n+    agent.save_trajectories = False\n+    agent.valid_tool_names = {\"memory\"}\n+    agent.client = MagicMock()\n+    agent.client.chat.completions.create.side_effect = [\n+        # Turn 1: Content + housekeeping tool (should set fallback)\n+        _response(\n+            content=\"You're welcome!\",\n+            finish_reason=\"tool_calls\",\n+            tool_calls=[_tool_call(\"memory\", \"mem1\")],\n+        ),\n+        # Turn 2: Empty response (should use the housekeeping fallback)\n+        _response(content=\"\", finish_reason=\"stop\"),\n+    ]\n+\n+    with (\n+        patch(\"run_agent.handle_function_call\", return_value=\"ok\"),\n+        patch.object(agent, \"_persist_session\"),\n+        patch.object(agent, \"_save_trajectory\"),\n+        patch.object(agent, \"_cleanup_task_resources\"),\n+    ):\n+        result = agent.run_conversation(\"save this\")\n+\n+    assert result[\"final_response\"] == \"You're welcome!\", (\n+        f\"Expected housekeeping fallback content, got: {result['final_response']}. \"\n+        f\"Pure housekeeping turns should still set the fallback.\"\n+    )\n+    assert \"fallback_prior_turn_content\" in result.get(\"turn_exit_reason\", \"\"), (\n+        f\"Expected fallback_prior_turn_content exit, got: {result['turn_exit_reason']}.\"\n+    )\n\\ No newline at end of file\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}