{"repo":"NousResearch/hermes-agent","public_id":"17335","display_label":"Tool name deduplication bugs and fixes across providers","summary":"These issues and PRs address two related tool-definition bugs: duplicate tool names causing silent 400 errors on strict providers (Vertex, Azure, Bedrock), and quiet-mode cache pollution in get_tool_definitions() injecting duplicate LCM schemas in Gateway.","subsystem":null,"counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":2,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":17889},"resolution":{"state":"resolved","by":"pr-17889,pr-18748"},"freshness":{"state":"verified","last_verified_at":"2026-05-02T08:51:52+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2906666666666667},"fix_edges":[{"issue_number":17335,"pr_number":17889,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":18478,"pr_number":18748,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":17335,"title":"[Bug]: get_tool_definitions() quiet-mode cache pollution causes duplicate LCM tool schemas in Gateway","url":null,"state":"closed"},{"kind":"issue","number":18478,"title":"[Bug]: Duplicate tool names cause silent 400 failures on strict providers (Google Vertex, Azure, Bedrock) after 4d363499d","url":null,"state":"closed"},{"kind":"pr","number":17337,"title":"fix(tools): isolate get_tool_definitions quiet_mode cache + dedup LCM injection (#17335)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"e0fa2cf972","number":0},{"kind":"pr","value":"#17889","number":17889}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":17889,"title":"fix(tools): isolate get_tool_definitions quiet_mode cache + dedup LCM injection (#17335)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["model_tools.py","run_agent.py","tests/test_get_tool_definitions_cache_isolation.py"],"fix_hunks":[{"file":"model_tools.py","hunks":["@@ -320,7 +320,15 @@ def get_tool_definitions(\n \n     result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode)\n     if quiet_mode:\n+        # Cache the freshly-computed list, but hand callers a shallow copy so\n+        # downstream mutations (e.g. run_agent appending memory/LCM tool\n+        # schemas to self.tools) don't poison the cache. Without this, a\n+        # long-lived Gateway process accumulates duplicate tool names across\n+        # agent inits and providers that enforce unique tool names\n+        # (DeepSeek, Xiaomi MiMo, Moonshot Kimi) reject the request with\n+        # HTTP 400. Mirrors the cache-hit path above. (issue #17335)\n         _tool_defs_cache[cache_key] = result\n+        return list(result)\n     return result\n \n "]},{"file":"run_agent.py","hunks":["@@ -2001,16 +2001,31 @@ def __init__(\n                 f\"model.context_length in config.yaml to override.\"\n             )\n \n-        # Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand)\n+        # Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand).\n+        # Skip names that are already present — the get_tool_definitions()\n+        # quiet_mode cache returned a shared list pre-#17335, so a stray\n+        # mutation here would poison subsequent agent inits in the same\n+        # Gateway process and trip provider-side 'duplicate tool name'\n+        # errors. Even with the cache fix, dedup is the right defense\n+        # against plugin paths that may register the same schemas via\n+        # ctx.register_tool(). Mirrors the memory tools dedup above.\n         self._context_engine_tool_names: set = set()\n         if hasattr(self, \"context_compressor\") and self.context_compressor and self.tools is not None:\n+            _existing_tool_names = {\n+                t.get(\"function\", {}).get(\"name\")\n+                for t in self.tools\n+                if isinstance(t, dict)\n+            }\n             for _schema in self.context_compressor.get_tool_schemas():\n+                _tname = _schema.get(\"name\", \"\")\n+                if _tname and _tname in _existing_tool_names:\n+                    continue  # already registered via plugin/cache path\n                 _wrapped = {\"type\": \"function\", \"function\": _schema}\n                 self.tools.append(_wrapped)\n-                _tname = _schema.get(\"name\", \"\")\n                 if _tname:\n                     self.valid_tool_names.add(_tname)\n                     self._context_engine_tool_names.add(_tname)\n+                    _existing_tool_names.add(_tname)\n \n         # Notify context engine of session start\n         if hasattr(self, \"context_compressor\") and self.context_compressor:"]},{"file":"tests/test_get_tool_definitions_cache_isolation.py","hunks":["@@ -0,0 +1,94 @@\n+\"\"\"Regression tests for issue #17335.\n+\n+The ``quiet_mode=True`` fast path in :func:`model_tools.get_tool_definitions`\n+memoizes results to avoid re-walking the registry on every Gateway call. The\n+cached object must NOT be aliased into callers' return values \\u2014 long-lived\n+Gateway processes mutate the returned list (``run_agent`` appends memory and\n+LCM context-engine tool schemas to ``self.tools``), and a shared list would\n+poison subsequent agent inits with duplicate tool names. Providers that\n+enforce uniqueness (DeepSeek, Xiaomi MiMo, Moonshot/Kimi) then reject the\n+API call with HTTP 400.\n+\n+These tests pin:\n+- the cache-hit path returns a fresh list (existing #17098 behavior)\n+- the first uncached call also returns a fresh list (the fix)\n+- every call returns a list that is not the cached one, even after mutation\n+\"\"\"\n+from __future__ import annotations\n+\n+import pytest\n+\n+import model_tools\n+\n+\n[redacted:email](autouse=True)\n+def _clear_cache():\n+    \"\"\"Each test starts with an empty quiet_mode cache.\"\"\"\n+    model_tools._tool_defs_cache.clear()\n+    yield\n+    model_tools._tool_defs_cache.clear()\n+\n+\n+class TestQuietModeCacheIsolation:\n+\n+    def test_first_uncached_call_returns_fresh_list(self):\n+        \"\"\"The first quiet_mode call must not alias the cached object \\u2014\n+        otherwise a caller mutating the returned list mutates the cache.\"\"\"\n+        first = model_tools.get_tool_definitions(quiet_mode=True)\n+        assert isinstance(first, list)\n+        # Find the cached value to compare identity.\n+        assert len(model_tools._tool_defs_cache) == 1\n+        cached = next(iter(model_tools._tool_defs_cache.values()))\n+        assert first is not cached, (\n+            \"issue #17335: first quiet_mode call returned the cached list \"\n+            \"by reference \\u2014 mutations will leak into subsequent calls.\"\n+        )\n+\n+    def test_cache_hit_returns_fresh_list(self):\n+        \"\"\"The cache-hit path already returned a copy pre-fix; pin it.\"\"\"\n+        first = model_tools.get_tool_definitions(quiet_mode=True)\n+        second = model_tools.get_tool_definitions(quiet_mode=True)\n+        assert first is not second\n+        cached = next(iter(model_tools._tool_defs_cache.values()))\n+        assert second is not cached\n+\n+    def test_caller_mutation_does_not_poison_cache(self):\n+        \"\"\"Simulate run_agent appending LCM tool schemas to the returned\n+        list. A second call must NOT see those appended entries.\"\"\"\n+        first = model_tools.get_tool_definitions(quiet_mode=True)\n+        baseline_len = len(first)\n+        # Caller mutates the returned list (this is what run_agent does\n+        # when it injects memory + context-engine tool schemas).\n+        first.append({\"type\": \"function\", \"function\": {\"name\": \"lcm_grep\"}})\n+        first.append({\"type\": \"function\", \"function\": {\"name\": \"lcm_expand\"}})\n+\n+        second = model_tools.get_tool_definitions(quiet_mode=True)\n+        # Length must match the original \\u2014 cache pollution would make\n+        # second 2 entries longer.\n+        assert len(second) == baseline_len, (\n+            f\"issue #17335: cache was polluted by caller mutation. \"\n+            f\"first len={baseline_len}, mutated len={len(first)}, \"\n+            f\"second-call len={len(second)} \\u2014 expected {baseline_len}.\"\n+        )\n+        names = [t.get(\"function\", {}).get(\"name\") for t in second]\n+        assert \"lcm_grep\" not in names\n+        assert \"lcm_expand\" not in names\n+\n+    def test_repeated_caller_mutation_does_not_accumulate(self):\n+        \"\"\"The original Gateway symptom: every agent init in a long-lived\n+        process appends LCM schemas, accumulating duplicates over time.\"\"\"\n+        baseline = len(model_tools.get_tool_definitions(quiet_mode=True))\n+        for _ in range(5):\n+            tools = model_tools.get_tool_definitions(quiet_mode=True)\n+            tools.append({\"type\": \"function\", \"function\": {\"name\": \"lcm_grep\"}})\n+        final = model_tools.get_tool_definitions(quiet_mode=True)\n+        assert len(final) == baseline, (\n+            f\"Cache accumulated mutations across {5} agent inits: \"\n+            f\"baseline={baseline}, final={len(final)}.\"\n+        )\n+\n+    def test_non_quiet_mode_does_not_use_cache(self):\n+        \"\"\"Sanity: quiet_mode=False (TUI path) skips the cache entirely \\u2014\n+        explains why the bug only hit Gateway.\"\"\"\n+        model_tools.get_tool_definitions(quiet_mode=False)\n+        assert len(model_tools._tool_defs_cache) == 0\n"]}]},{"kind":"pr","number":18532,"title":"fix(tools): deduplicate tool names at API boundary for Vertex/Azure/Bedrock","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#18748","number":18748}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":18748,"title":"fix(tools): dedup tool names at API boundary for Vertex/Azure/Bedrock (salvage #18532)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/anthropic_adapter.py","agent/auxiliary_client.py","tests/agent/test_anthropic_adapter.py","tests/agent/test_auxiliary_client.py"]}],"trust":{"default_tier":"verified","includes_suggested":false}}