{"repo":"NousResearch/hermes-agent","public_id":"14944","display_label":"Background review scanning stale tool message history","summary":"These issues and PRs concern a bug where background review notifications incorrectly include tool messages from prior conversation history rather than only new tool results. Multiple fix attempts address how the background review scans and filters tool messages.","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":15057,"merged_at":"2026-04-24T10:10:20+00:00"},"resolution":{"state":"resolved","by":"pr-15057"},"freshness":{"state":"stale","last_verified_at":"2026-04-24T10:10:20+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":14944,"pr_number":15057,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-04-24T10:10:20+00:00"}],"members":[{"kind":"issue","number":14944,"title":"Background review notification includes stale tool results from conversation history","url":null,"state":"closed"},{"kind":"pr","number":14967,"title":"fix(agent): exclude prior-history tool messages from background review summary","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#15057","number":15057},{"kind":"pr","value":"#14969","number":14969}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":14969,"title":"fix(agent): scan only new tool results in bg review, skip snapshot history (#14944)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14967","number":14967},{"kind":"pr","value":"#15057","number":15057}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":15057,"title":"fix(agent): exclude prior-history tool messages from background review summary (salvage #14967)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/run_agent/test_background_review_summary.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -2907,6 +2907,69 @@ def _cleanup_task_resources(self, task_id: str) -> None:\n         \"If nothing stands out, just say 'Nothing to save.' and stop.\"\n     )\n \n+    @staticmethod\n+    def _summarize_background_review_actions(\n+        review_messages: List[Dict],\n+        prior_snapshot: List[Dict],\n+    ) -> List[str]:\n+        \"\"\"Build the human-facing action summary for a background review pass.\n+\n+        Walks the review agent's session messages and collects \"successful tool\n+        action\" descriptions to surface to the user (e.g. \"Memory updated\").\n+        Tool messages already present in ``prior_snapshot`` are skipped so we\n+        don't re-surface stale results from the prior conversation that the\n+        review agent inherited via ``conversation_history`` (issue #14944).\n+\n+        Matching is by ``tool_call_id`` when available, with a content-equality\n+        fallback for tool messages that lack one.\n+        \"\"\"\n+        existing_tool_call_ids = set()\n+        existing_tool_contents = set()\n+        for prior in prior_snapshot or []:\n+            if not isinstance(prior, dict) or prior.get(\"role\") != \"tool\":\n+                continue\n+            tcid = prior.get(\"tool_call_id\")\n+            if tcid:\n+                existing_tool_call_ids.add(tcid)\n+            else:\n+                content = prior.get(\"content\")\n+                if isinstance(content, str):\n+                    existing_tool_contents.add(content)\n+\n+        actions: List[str] = []\n+        for msg in review_messages or []:\n+            if not isinstance(msg, dict) or msg.get(\"role\") != \"tool\":\n+                continue\n+            tcid = msg.get(\"tool_call_id\")\n+            if tcid and tcid in existing_tool_call_ids:\n+                continue\n+            if not tcid:\n+                content_str = msg.get(\"content\")\n+                if isinstance(content_str, str) and content_str in existing_tool_contents:\n+                    continue\n+            try:\n+                data = json.loads(msg.get(\"content\", \"{}\"))\n+            except (json.JSONDecodeError, TypeError):\n+                continue\n+            if not isinstance(data, dict) or not data.get(\"success\"):\n+                continue\n+            message = data.get(\"message\", \"\")\n+            target = data.get(\"target\", \"\")\n+            if \"created\" in message.lower():\n+                actions.append(message)\n+            elif \"updated\" in message.lower():\n+                actions.append(message)\n+            elif \"added\" in message.lower() or (target and \"add\" in message.lower()):\n+                label = \"Memory\" if target == \"memory\" else \"User profile\" if target == \"user\" else target\n+                actions.append(f\"{label} updated\")\n+            elif \"Entry added\" in message:\n+                label = \"Memory\" if target == \"memory\" else \"User profile\" if target == \"user\" else target\n+                actions.append(f\"{label} updated\")\n+            elif \"removed\" in message.lower() or \"replaced\" in message.lower():\n+                label = \"Memory\" if target == \"memory\" else \"User profile\" if target == \"user\" else target\n+                actions.append(f\"{label} updated\")\n+        return actions\n+\n     def _spawn_background_review(\n         self,\n         messages_snapshot: List[Dict],","@@ -2956,32 +3019,15 @@ def _run_review():\n                     )\n \n                 # Scan the review agent's messages for successful tool actions\n-                # and surface a compact summary to the user.\n-                actions = []\n-                for msg in getattr(review_agent, \"_session_messages\", []):\n-                    if not isinstance(msg, dict) or msg.get(\"role\") != \"tool\":\n-                        continue\n-                    try:\n-                        data = json.loads(msg.get(\"content\", \"{}\"))\n-                    except (json.JSONDecodeError, TypeError):\n-                        continue\n-                    if not data.get(\"success\"):\n-                        continue\n-                    message = data.get(\"message\", \"\")\n-                    target = data.get(\"target\", \"\")\n-                    if \"created\" in message.lower():\n-                        actions.append(message)\n-                    elif \"updated\" in message.lower():\n-                        actions.append(message)\n-                    elif \"added\" in message.lower() or (target and \"add\" in message.lower()):\n-                        label = \"Memory\" if target == \"memory\" else \"User profile\" if target == \"user\" else target\n-                        actions.append(f\"{label} updated\")\n-                    elif \"Entry added\" in message:\n-                        label = \"Memory\" if target == \"memory\" else \"User profile\" if target == \"user\" else target\n-                        actions.append(f\"{label} updated\")\n-                    elif \"removed\" in message.lower() or \"replaced\" in message.lower():\n-                        label = \"Memory\" if target == \"memory\" else \"User profile\" if target == \"user\" else target\n-                        actions.append(f\"{label} updated\")\n+                # and surface a compact summary to the user. Tool messages\n+                # already present in messages_snapshot must be skipped, since\n+                # the review agent inherits that history and would otherwise\n+                # re-surface stale \"created\"/\"updated\" messages from the prior\n+                # conversation as if they just happened (issue #14944).\n+                actions = self._summarize_background_review_actions(\n+                    getattr(review_agent, \"_session_messages\", []),\n+                    messages_snapshot,\n+                )\n \n                 if actions:\n                     summary = \" · \".join(dict.fromkeys(actions))"]},{"file":"tests/run_agent/test_background_review_summary.py","hunks":["@@ -0,0 +1,130 @@\n+\"\"\"Tests for AIAgent._summarize_background_review_actions.\n+\n+Regression coverage for issue #14944: the background memory/skill review used\n+to re-surface tool results that were already present in the conversation\n+history before the review started (e.g. an earlier \"Cron job '...' created.\").\n+\"\"\"\n+\n+import json\n+\n+from run_agent import AIAgent\n+\n+\n+_summarize = AIAgent._summarize_background_review_actions\n+\n+\n+def _tool_msg(tool_call_id, payload):\n+    return {\n+        \"role\": \"tool\",\n+        \"tool_call_id\": tool_call_id,\n+        \"content\": json.dumps(payload),\n+    }\n+\n+\n+def test_skips_prior_tool_messages_by_tool_call_id():\n+    \"\"\"Stale 'created' tool result from prior history must not be re-surfaced.\"\"\"\n+    prior_payload = {\"success\": True, \"message\": \"Cron job 'remind-me' created.\"}\n+    new_payload = {\n+        \"success\": True,\n+        \"message\": \"Entry added\",\n+        \"target\": \"user\",\n+    }\n+\n+    snapshot = [\n+        {\"role\": \"user\", \"content\": \"create a reminder\"},\n+        _tool_msg(\"call_old\", prior_payload),\n+        {\"role\": \"assistant\", \"content\": \"done\"},\n+    ]\n+    review_messages = list(snapshot) + [\n+        {\"role\": \"user\", \"content\": \"<review prompt>\"},\n+        _tool_msg(\"call_new\", new_payload),\n+    ]\n+\n+    actions = _summarize(review_messages, snapshot)\n+\n+    assert \"Cron job 'remind-me' created.\" not in actions\n+    assert \"User profile updated\" in actions\n+\n+\n+def test_includes_genuinely_new_actions():\n+    new_payload = {\n+        \"success\": True,\n+        \"message\": \"Memory entry created.\",\n+    }\n+    review_messages = [_tool_msg(\"call_new\", new_payload)]\n+\n+    actions = _summarize(review_messages, prior_snapshot=[])\n+\n+    assert actions == [\"Memory entry created.\"]\n+\n+\n+def test_falls_back_to_content_equality_when_tool_call_id_missing():\n+    \"\"\"If a tool message has no tool_call_id, match prior entries by content.\"\"\"\n+    payload = {\"success\": True, \"message\": \"Cron job 'X' created.\"}\n+    raw = json.dumps(payload)\n+    prior_msg = {\"role\": \"tool\", \"content\": raw}  # no tool_call_id\n+    review_messages = [\n+        {\"role\": \"tool\", \"content\": raw},  # same content -> stale, skip\n+        _tool_msg(\"call_new\", {\"success\": True, \"message\": \"Skill created.\"}),\n+    ]\n+\n+    actions = _summarize(review_messages, [prior_msg])\n+\n+    assert \"Cron job 'X' created.\" not in actions\n+    assert \"Skill created.\" in actions\n+\n+\n+def test_ignores_failed_tool_results():\n+    bad = {\"success\": False, \"message\": \"something created but failed\"}\n+    review_messages = [_tool_msg(\"call_new\", bad)]\n+\n+    actions = _summarize(review_messages, [])\n+\n+    assert actions == []\n+\n+\n+def test_handles_non_json_tool_content_gracefully():\n+    review_messages = [\n+        {\"role\": \"tool\", \"tool_call_id\": \"x\", \"content\": \"not-json\"},\n+        _tool_msg(\"call_y\", {\"success\": True, \"message\": \"Memory updated.\"}),\n+    ]\n+\n+    actions = _summarize(review_messages, [])\n+\n+    assert actions == [\"Memory updated.\"]\n+\n+\n+def test_empty_inputs():\n+    assert _summarize([], []) == []\n+    assert _summarize(None, None) == []\n+\n+\n+def test_added_message_relabels_by_target():\n+    review_messages = [\n+        _tool_msg(\n+            \"c1\",\n+            {\"success\": True, \"message\": \"Entry added to store.\", \"target\": \"memory\"},\n+        )\n+    ]\n+\n+    actions = _summarize(review_messages, [])\n+\n+    assert actions == [\"Memory updated\"]\n+\n+\n+def test_removed_or_replaced_relabels_by_target():\n+    review_messages = [\n+        _tool_msg(\n+            \"c1\",\n+            {\"success\": True, \"message\": \"Entry removed.\", \"target\": \"user\"},\n+        ),\n+        _tool_msg(\n+            \"c2\",\n+            {\"success\": True, \"message\": \"Entry replaced.\", \"target\": \"memory\"},\n+        ),\n+    ]\n+\n+    actions = _summarize(review_messages, [])\n+\n+    assert \"User profile updated\" in actions\n+    assert \"Memory updated\" in actions\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}