{"repo":"NousResearch/hermes-agent","public_id":"626","display_label":"Agent tool sanitization, guardrails, and middleware hooks","summary":"These items concern adding middleware-style lifecycle hooks around agent tool calls, including pre-call sanitization, post-call interception or guardrails, subagent limits, and repair of incomplete tool calls.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":1732,"merged_at":"2026-03-17T11:24:27+00:00"},"resolution":{"state":"resolved","by":"pr-1732"},"freshness":{"state":"stale","last_verified_at":"2026-03-17T11:24:27+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":626,"pr_number":1732,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-03-17T11:24:27+00:00"}],"members":[{"kind":"issue","number":626,"title":"Feature: Agent Loop Middleware Architecture — Ordered Lifecycle Hooks, Hard Subagent Limit Enforcement & Dangling Tool Call Repair (inspired by DeerFlow)","url":null,"state":"closed"},{"kind":"pr","number":1321,"title":"feat: pre-call tool sanitization and post-call tool interception","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#1732","number":1732}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":1732,"title":"feat: pre-call sanitization and post-call tool guardrails","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["run_agent.py","tests/test_agent_guardrails.py"],"fix_hunks":[{"file":"run_agent.py","hunks":["@@ -1957,7 +1957,124 @@ def _build_system_prompt(self, system_message: str = None) -> str:\n             prompt_parts.append(PLATFORM_HINTS[platform_key])\n \n         return \"\\n\\n\".join(prompt_parts)\n-    \n+\n+    # =========================================================================\n+    # Pre/post-call guardrails (inspired by PR #1321 — @alireza78a)\n+    # =========================================================================\n+\n+    @staticmethod\n+    def _get_tool_call_id_static(tc) -> str:\n+        \"\"\"Extract call ID from a tool_call entry (dict or object).\"\"\"\n+        if isinstance(tc, dict):\n+            return tc.get(\"id\", \"\") or \"\"\n+        return getattr(tc, \"id\", \"\") or \"\"\n+\n+    @staticmethod\n+    def _sanitize_api_messages(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:\n+        \"\"\"Fix orphaned tool_call / tool_result pairs before every LLM call.\n+\n+        Runs unconditionally — not gated on whether the context compressor\n+        is present — so orphans from session loading or manual message\n+        manipulation are always caught.\n+        \"\"\"\n+        surviving_call_ids: set = set()\n+        for msg in messages:\n+            if msg.get(\"role\") == \"assistant\":\n+                for tc in msg.get(\"tool_calls\") or []:\n+                    cid = AIAgent._get_tool_call_id_static(tc)\n+                    if cid:\n+                        surviving_call_ids.add(cid)\n+\n+        result_call_ids: set = set()\n+        for msg in messages:\n+            if msg.get(\"role\") == \"tool\":\n+                cid = msg.get(\"tool_call_id\")\n+                if cid:\n+                    result_call_ids.add(cid)\n+\n+        # 1. Drop tool results with no matching assistant call\n+        orphaned_results = result_call_ids - surviving_call_ids\n+        if orphaned_results:\n+            messages = [\n+                m for m in messages\n+                if not (m.get(\"role\") == \"tool\" and m.get(\"tool_call_id\") in orphaned_results)\n+            ]\n+            logger.debug(\n+                \"Pre-call sanitizer: removed %d orphaned tool result(s)\",\n+                len(orphaned_results),\n+            )\n+\n+        # 2. Inject stub results for calls whose result was dropped\n+        missing_results = surviving_call_ids - result_call_ids\n+        if missing_results:\n+            patched: List[Dict[str, Any]] = []\n+            for msg in messages:\n+                patched.append(msg)\n+                if msg.get(\"role\") == \"assistant\":\n+                    for tc in msg.get(\"tool_calls\") or []:\n+                        cid = AIAgent._get_tool_call_id_static(tc)\n+                        if cid in missing_results:\n+                            patched.append({\n+                                \"role\": \"tool\",\n+                                \"content\": \"[Result unavailable — see context summary above]\",\n+                                \"tool_call_id\": cid,\n+                            })\n+            messages = patched\n+            logger.debug(\n+                \"Pre-call sanitizer: added %d stub tool result(s)\",\n+                len(missing_results),\n+            )\n+\n+        return messages\n+\n+    @staticmethod\n+    def _cap_delegate_task_calls(tool_calls: list) -> list:\n+        \"\"\"Truncate excess delegate_task calls to MAX_CONCURRENT_CHILDREN.\n+\n+        The delegate_tool caps the task list inside a single call, but the\n+        model can emit multiple separate delegate_task tool_calls in one\n+        turn.  This truncates the excess, preserving all non-delegate calls.\n+\n+        Returns the original list if no truncation was needed.\n+        \"\"\"\n+        from tools.delegate_tool import MAX_CONCURRENT_CHILDREN\n+        delegate_count = sum(1 for tc in tool_calls if tc.function.name == \"delegate_task\")\n+        if delegate_count <= MAX_CONCURRENT_CHILDREN:\n+            return tool_calls\n+        kept_delegates = 0\n+        truncated = []\n+        for tc in tool_calls:\n+            if tc.function.name == \"delegate_task\":\n+                if kept_delegates < MAX_CONCURRENT_CHILDREN:\n+                    truncated.append(tc)\n+                    kept_delegates += 1\n+            else:\n+                truncated.append(tc)\n+        logger.warning(\n+            \"Truncated %d excess delegate_task call(s) to enforce \"\n+            \"MAX_CONCURRENT_CHILDREN=%d limit\",\n+            delegate_count - MAX_CONCURRENT_CHILDREN, MAX_CONCURRENT_CHILDREN,\n+        )\n+        return truncated\n+\n+    @staticmethod\n+    def _deduplicate_tool_calls(tool_calls: list) -> list:\n+        \"\"\"Remove duplicate (tool_name, arguments) pairs within a single turn.\n+\n+        Only the first occurrence of each unique pair is kept.\n+        Returns the original list if no duplicates were found.\n+        \"\"\"\n+        seen: set = set()\n+        unique: list = []\n+        for tc in tool_calls:\n+            key = (tc.function.name, tc.function.arguments)\n+            if key not in seen:\n+                seen.add(key)\n+                unique.append(tc)\n+            else:\n+                logger.warning(\"Removed duplicate tool call: %s\", tc.function.name)\n+        return unique if len(unique) < len(tool_calls) else tool_calls\n+\n     def _repair_tool_call(self, tool_name: str) -> str | None:\n         \"\"\"Attempt to repair a mismatched tool name before aborting.\n ","@@ -4992,11 +5109,10 @@ def run_conversation(\n                 api_messages = apply_anthropic_cache_control(api_messages, cache_ttl=self._cache_ttl)\n \n             # Safety net: strip orphaned tool results / add stubs for missing\n-            # results before sending to the API.  The compressor handles this\n-            # during compression, but orphans can also sneak in from session\n-            # loading or manual message manipulation.\n-            if hasattr(self, 'context_compressor') and self.context_compressor:\n-                api_messages = self.context_compressor._sanitize_tool_pairs(api_messages)\n+            # results before sending to the API.  Runs unconditionally — not\n+            # gated on context_compressor — so orphans from session loading or\n+            # manual message manipulation are always caught.\n+            api_messages = self._sanitize_api_messages(api_messages)\n \n             # Calculate approximate request size for logging\n             total_chars = sum(len(str(msg)) for msg in api_messages)","@@ -6026,7 +6142,15 @@ def _stop_spinner():\n                     \n                     # Reset retry counter on successful JSON validation\n                     self._invalid_json_retries = 0\n-                    \n+\n+                    # ── Post-call guardrails ──────────────────────────\n+                    assistant_message.tool_calls = self._cap_delegate_task_calls(\n+                        assistant_message.tool_calls\n+                    )\n+                    assistant_message.tool_calls = self._deduplicate_tool_calls(\n+                        assistant_message.tool_calls\n+                    )\n+\n                     assistant_msg = self._build_assistant_message(assistant_message, finish_reason)\n                     \n                     # If this turn has both content AND tool_calls, capture the content"]},{"file":"tests/test_agent_guardrails.py","hunks":["@@ -0,0 +1,263 @@\n+\"\"\"Unit tests for AIAgent pre/post-LLM-call guardrails.\n+\n+Covers three static methods on AIAgent (inspired by PR #1321 — @alireza78a):\n+  - _sanitize_api_messages()    — Phase 1: orphaned tool pair repair\n+  - _cap_delegate_task_calls()  — Phase 2a: subagent concurrency limit\n+  - _deduplicate_tool_calls()   — Phase 2b: identical call deduplication\n+\"\"\"\n+\n+import types\n+\n+from run_agent import AIAgent\n+from tools.delegate_tool import MAX_CONCURRENT_CHILDREN\n+\n+\n+# ---------------------------------------------------------------------------\n+# Helpers\n+# ---------------------------------------------------------------------------\n+\n+def make_tc(name: str, arguments: str = \"{}\") -> types.SimpleNamespace:\n+    \"\"\"Create a minimal tool_call SimpleNamespace mirroring the OpenAI SDK object.\"\"\"\n+    tc = types.SimpleNamespace()\n+    tc.function = types.SimpleNamespace(name=name, arguments=arguments)\n+    return tc\n+\n+\n+def tool_result(call_id: str, content: str = \"ok\") -> dict:\n+    return {\"role\": \"tool\", \"tool_call_id\": call_id, \"content\": content}\n+\n+\n+def assistant_dict_call(call_id: str, name: str = \"terminal\") -> dict:\n+    \"\"\"Dict-style tool_call (as stored in message history).\"\"\"\n+    return {\"id\": call_id, \"function\": {\"name\": name, \"arguments\": \"{}\"}}\n+\n+\n+# ---------------------------------------------------------------------------\n+# Phase 1 — _sanitize_api_messages\n+# ---------------------------------------------------------------------------\n+\n+class TestSanitizeApiMessages:\n+\n+    def test_orphaned_result_removed(self):\n+        msgs = [\n+            {\"role\": \"assistant\", \"tool_calls\": [assistant_dict_call(\"c1\")]},\n+            tool_result(\"c1\"),\n+            tool_result(\"c_ORPHAN\"),\n+        ]\n+        out = AIAgent._sanitize_api_messages(msgs)\n+        assert len(out) == 2\n+        assert all(m.get(\"tool_call_id\") != \"c_ORPHAN\" for m in out)\n+\n+    def test_orphaned_call_gets_stub_result(self):\n+        msgs = [\n+            {\"role\": \"assistant\", \"tool_calls\": [assistant_dict_call(\"c2\")]},\n+        ]\n+        out = AIAgent._sanitize_api_messages(msgs)\n+        assert len(out) == 2\n+        stub = out[1]\n+        assert stub[\"role\"] == \"tool\"\n+        assert stub[\"tool_call_id\"] == \"c2\"\n+        assert stub[\"content\"]\n+\n+    def test_clean_messages_pass_through(self):\n+        msgs = [\n+            {\"role\": \"user\", \"content\": \"hello\"},\n+            {\"role\": \"assistant\", \"tool_calls\": [assistant_dict_call(\"c3\")]},\n+            tool_result(\"c3\"),\n+            {\"role\": \"assistant\", \"content\": \"done\"},\n+        ]\n+        out = AIAgent._sanitize_api_messages(msgs)\n+        assert out == msgs\n+\n+    def test_mixed_orphaned_result_and_orphaned_call(self):\n+        msgs = [\n+            {\"role\": \"assistant\", \"tool_calls\": [\n+                assistant_dict_call(\"c4\"),\n+                assistant_dict_call(\"c5\"),\n+            ]},\n+            tool_result(\"c4\"),\n+            tool_result(\"c_DANGLING\"),\n+        ]\n+        out = AIAgent._sanitize_api_messages(msgs)\n+        ids = [m.get(\"tool_call_id\") for m in out if m.get(\"role\") == \"tool\"]\n+        assert \"c_DANGLING\" not in ids\n+        assert \"c4\" in ids\n+        assert \"c5\" in ids\n+\n+    def test_empty_list_is_safe(self):\n+        assert AIAgent._sanitize_api_messages([]) == []\n+\n+    def test_no_tool_messages(self):\n+        msgs = [\n+            {\"role\": \"user\", \"content\": \"hi\"},\n+            {\"role\": \"assistant\", \"content\": \"hello\"},\n+        ]\n+        out = AIAgent._sanitize_api_messages(msgs)\n+        assert out == msgs\n+\n+    def test_sdk_object_tool_calls(self):\n+        tc_obj = types.SimpleNamespace(id=\"c6\", function=types.SimpleNamespace(\n+            name=\"terminal\", arguments=\"{}\"\n+        ))\n+        msgs = [\n+            {\"role\": \"assistant\", \"tool_calls\": [tc_obj]},\n+        ]\n+        out = AIAgent._sanitize_api_messages(msgs)\n+        assert len(out) == 2\n+        assert out[1][\"tool_call_id\"] == \"c6\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Phase 2a — _cap_delegate_task_calls\n+# ---------------------------------------------------------------------------\n+\n+class TestCapDelegateTaskCalls:\n+\n+    def test_excess_delegates_truncated(self):\n+        tcs = [make_tc(\"delegate_task\") for _ in range(MAX_CONCURRENT_CHILDREN + 2)]\n+        out = AIAgent._cap_delegate_task_calls(tcs)\n+        delegate_count = sum(1 for tc in out if tc.function.name == \"delegate_task\")\n+        assert delegate_count == MAX_CONCURRENT_CHILDREN\n+\n+    def test_non_delegate_calls_preserved(self):\n+        tcs = (\n+            [make_tc(\"delegate_task\") for _ in range(MAX_CONCURRENT_CHILDREN + 1)]\n+            + [make_tc(\"terminal\"), make_tc(\"web_search\")]\n+        )\n+        out = AIAgent._cap_delegate_task_calls(tcs)\n+        names = [tc.function.name for tc in out]\n+        assert \"terminal\" in names\n+        assert \"web_search\" in names\n+\n+    def test_at_limit_passes_through(self):\n+        tcs = [make_tc(\"delegate_task\") for _ in range(MAX_CONCURRENT_CHILDREN)]\n+        out = AIAgent._cap_delegate_task_calls(tcs)\n+        assert out is tcs\n+\n+    def test_below_limit_passes_through(self):\n+        tcs = [make_tc(\"delegate_task\") for _ in range(MAX_CONCURRENT_CHILDREN - 1)]\n+        out = AIAgent._cap_delegate_task_calls(tcs)\n+        assert out is tcs\n+\n+    def test_no_delegate_calls_unchanged(self):\n+        tcs = [make_tc(\"terminal\"), make_tc(\"web_search\")]\n+        out = AIAgent._cap_delegate_task_calls(tcs)\n+        assert out is tcs\n+\n+    def test_empty_list_safe(self):\n+        assert AIAgent._cap_delegate_task_calls([]) == []\n+\n+    def test_original_list_not_mutated(self):\n+        tcs = [make_tc(\"delegate_task\") for _ in range(MAX_CONCURRENT_CHILDREN + 2)]\n+        original_len = len(tcs)\n+        AIAgent._cap_delegate_task_calls(tcs)\n+        assert len(tcs) == original_len\n+\n+    def test_interleaved_order_preserved(self):\n+        delegates = [make_tc(\"delegate_task\", f'{{\"task\":\"{i}\"}}')\n+                     for i in range(MAX_CONCURRENT_CHILDREN + 1)]\n+        t1 = make_tc(\"terminal\", '{\"cmd\":\"ls\"}')\n+        w1 = make_tc(\"web_search\", '{\"q\":\"x\"}')\n+        tcs = [delegates[0], t1, delegates[1], w1] + delegates[2:]\n+        out = AIAgent._cap_delegate_task_calls(tcs)\n+        expected = [delegates[0], t1, delegates[1], w1] + delegates[2:MAX_CONCURRENT_CHILDREN]\n+        assert len(out) == len(expected)\n+        for i, (actual, exp) in enumerate(zip(out, expected)):\n+            assert actual is exp, f\"mismatch at index {i}\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Phase 2b — _deduplicate_tool_calls\n+# ---------------------------------------------------------------------------\n+\n+class TestDeduplicateToolCalls:\n+\n+    def test_duplicate_pair_deduplicated(self):\n+        tcs = [\n+            make_tc(\"web_search\", '{\"query\":\"foo\"}'),\n+            make_tc(\"web_search\", '{\"query\":\"foo\"}'),\n+        ]\n+        out = AIAgent._deduplicate_tool_calls(tcs)\n+        assert len(out) == 1\n+\n+    def test_multiple_duplicates(self):\n+        tcs = [\n+            make_tc(\"web_search\", '{\"q\":\"a\"}'),\n+            make_tc(\"web_search\", '{\"q\":\"a\"}'),\n+            make_tc(\"terminal\", '{\"cmd\":\"ls\"}'),\n+            make_tc(\"terminal\", '{\"cmd\":\"ls\"}'),\n+            make_tc(\"terminal\", '{\"cmd\":\"pwd\"}'),\n+        ]\n+        out = AIAgent._deduplicate_tool_calls(tcs)\n+        assert len(out) == 3\n+\n+    def test_same_tool_different_args_kept(self):\n+        tcs = [\n+            make_tc(\"terminal\", '{\"cmd\":\"ls\"}'),\n+            make_tc(\"terminal\", '{\"cmd\":\"pwd\"}'),\n+        ]\n+        out = AIAgent._deduplicate_tool_calls(tcs)\n+        assert out is tcs\n+\n+    def test_different_tools_same_args_kept(self):\n+        tcs = [\n+            make_tc(\"tool_a\", '{\"x\":1}'),\n+            make_tc(\"tool_b\", '{\"x\":1}'),\n+        ]\n+        out = AIAgent._deduplicate_tool_calls(tcs)\n+        assert out is tcs\n+\n+    def test_clean_list_unchanged(self):\n+        tcs = [\n+            make_tc(\"web_search\", '{\"q\":\"x\"}'),\n+            make_tc(\"terminal\", '{\"cmd\":\"ls\"}'),\n+        ]\n+        out = AIAgent._deduplicate_tool_calls(tcs)\n+        assert out is tcs\n+\n+    def test_empty_list_safe(self):\n+        assert AIAgent._deduplicate_tool_calls([]) == []\n+\n+    def test_first_occurrence_kept(self):\n+        tc1 = make_tc(\"terminal\", '{\"cmd\":\"ls\"}')\n+        tc2 = make_tc(\"terminal\", '{\"cmd\":\"ls\"}')\n+        out = AIAgent._deduplicate_tool_calls([tc1, tc2])\n+        assert len(out) == 1\n+        assert out[0] is tc1\n+\n+    def test_original_list_not_mutated(self):\n+        tcs = [\n+            make_tc(\"web_search\", '{\"q\":\"dup\"}'),\n+            make_tc(\"web_search\", '{\"q\":\"dup\"}'),\n+        ]\n+        original_len = len(tcs)\n+        AIAgent._deduplicate_tool_calls(tcs)\n+        assert len(tcs) == original_len\n+\n+\n+# ---------------------------------------------------------------------------\n+# _get_tool_call_id_static\n+# ---------------------------------------------------------------------------\n+\n+class TestGetToolCallIdStatic:\n+\n+    def test_dict_with_valid_id(self):\n+        assert AIAgent._get_tool_call_id_static({\"id\": \"call_123\"}) == \"call_123\"\n+\n+    def test_dict_with_none_id(self):\n+        assert AIAgent._get_tool_call_id_static({\"id\": None}) == \"\"\n+\n+    def test_dict_without_id_key(self):\n+        assert AIAgent._get_tool_call_id_static({\"function\": {}}) == \"\"\n+\n+    def test_object_with_valid_id(self):\n+        tc = types.SimpleNamespace(id=\"call_456\")\n+        assert AIAgent._get_tool_call_id_static(tc) == \"call_456\"\n+\n+    def test_object_with_none_id(self):\n+        tc = types.SimpleNamespace(id=None)\n+        assert AIAgent._get_tool_call_id_static(tc) == \"\"\n+\n+    def test_object_without_id_attr(self):\n+        tc = types.SimpleNamespace()\n+        assert AIAgent._get_tool_call_id_static(tc) == \"\"\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}