{"repo":"NousResearch/hermes-agent","public_id":"47707","display_label":"Agent tool schema validation and unwrapping fixes","summary":"These issues and PRs address problems with context engine and memory provider tool schemas being wrapped without prior validation, where a missing top-level `name` field can cause the entire request to fail with a 400 error. The work involves validating schemas before wrapping and handling already-wrapped schemas to prevent this.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52140},"resolution":{"state":"resolved","by":"pr-52140"},"freshness":{"state":"verified","last_verified_at":"2026-06-24T21:06:20+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":47707,"pr_number":52140,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":47707,"title":"Context-engine/memory-provider tool schemas are wrapped without validating a top-level `name` — one malformed schema 400s the entire request","url":null,"state":"closed"},{"kind":"pr","number":47712,"title":"fix(agent): validate context/memory tool schemas before wrapping (#47707)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"710cd48fb1d649925e159aa08d29993f828c40ee","number":0},{"kind":"pr","value":"#52140","number":52140},{"kind":"pr","value":"#52140","number":52140},{"kind":"pr","value":"#49437","number":49437},{"kind":"pr","value":"#47712","number":47712}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":47721,"title":"fix(agent): validate context engine tool schemas before wrapping (#47707)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":47732,"title":"fix(agent): unwrap pre-wrapped tool schemas from context engines and memory providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"cedbb4cfa275bd594e1f99cb5be554cd1fc1d668","number":0},{"kind":"pr","value":"#52140","number":52140},{"kind":"pr","value":"#52140","number":52140},{"kind":"pr","value":"#47707","number":47707}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":49437,"title":"fix(agent): validate tool schemas from context engines and memory providers before wrapping","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#47712","number":47712},{"kind":"pr","value":"#47707","number":47707},{"kind":"pr","value":"#47712","number":47712},{"kind":"pr","value":"#47721","number":47721}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":52140,"title":"fix(agent): validate context/memory tool schemas before wrapping (#47707)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["agent/agent_init.py","agent/memory_manager.py","tests/agent/test_memory_provider.py"],"fix_hunks":[{"file":"agent/agent_init.py","hunks":["@@ -1621,16 +1621,27 @@ def init_agent(\n             for t in agent.tools\n             if isinstance(t, dict)\n         }\n-        for _schema in agent.context_compressor.get_tool_schemas():\n-            _tname = _schema.get(\"name\", \"\")\n-            if _tname and _tname in _existing_tool_names:\n+        from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema\n+        for _raw_schema in agent.context_compressor.get_tool_schemas():\n+            _schema = _normalize_tool_schema(_raw_schema)\n+            if _schema is None:\n+                # A schema with no resolvable name (e.g. an already-wrapped\n+                # entry) would append a nameless tool that strict providers\n+                # 400 on, disabling the whole toolset (#47707). Skip it.\n+                _ra().logger.warning(\n+                    \"Context engine returned a tool schema with no resolvable \"\n+                    \"name; skipping to avoid poisoning the request (%r)\",\n+                    _raw_schema,\n+                )\n+                continue\n+            _tname = _schema[\"name\"]\n+            if _tname in _existing_tool_names:\n                 continue  # already registered via plugin/cache path\n             _wrapped = {\"type\": \"function\", \"function\": _schema}\n             agent.tools.append(_wrapped)\n-            if _tname:\n-                agent.valid_tool_names.add(_tname)\n-                agent._context_engine_tool_names.add(_tname)\n-                _existing_tool_names.add(_tname)\n+            agent.valid_tool_names.add(_tname)\n+            agent._context_engine_tool_names.add(_tname)\n+            _existing_tool_names.add(_tname)\n \n     # Notify context engine of session start\n     if hasattr(agent, \"context_compressor\") and agent.context_compressor:"]},{"file":"agent/memory_manager.py","hunks":["@@ -46,6 +46,39 @@\n _SYNC_DRAIN_TIMEOUT_S = 5.0\n \n \n+def normalize_tool_schema(schema: Any) -> Optional[Dict[str, Any]]:\n+    \"\"\"Return a function-tool dict with a resolvable top-level ``name``.\n+\n+    Context engines and memory providers expose tool schemas via\n+    ``get_tool_schemas()``. The expected shape is a bare function schema\n+    (``{\"name\": ..., \"description\": ..., \"parameters\": ...}``) which callers\n+    wrap as ``{\"type\": \"function\", \"function\": schema}``.\n+\n+    Some providers instead return an entry that is *already* in OpenAI tool\n+    form (``{\"type\": \"function\", \"function\": {\"name\": ...}}``). Wrapping that\n+    a second time produces ``{\"type\": \"function\", \"function\": {\"type\":\n+    \"function\", \"function\": {...}}}`` whose ``function`` has no top-level\n+    ``name``. Strict providers (e.g. DeepSeek) reject the *entire* request\n+    with ``tools[N].function: missing field name`` (HTTP 400), so one bad\n+    schema disables the whole toolset and breaks every turn (#47707).\n+\n+    This helper normalizes both shapes to the bare function schema and\n+    returns ``None`` for anything without a resolvable name, so callers can\n+    skip-with-warning rather than appending a nameless tool.\n+    \"\"\"\n+    if not isinstance(schema, dict):\n+        return None\n+    # Unwrap an already-wrapped OpenAI tool entry.\n+    if schema.get(\"type\") == \"function\" and isinstance(schema.get(\"function\"), dict):\n+        schema = schema[\"function\"]\n+        if not isinstance(schema, dict):\n+            return None\n+    name = schema.get(\"name\", \"\")\n+    if not name or not isinstance(name, str):\n+        return None\n+    return schema\n+\n+\n def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:\n     \"\"\"Return whether external memory-provider tools should be exposed.\"\"\"\n     if enabled_toolsets is None:","@@ -92,11 +125,17 @@ def inject_memory_provider_tools(agent: Any) -> int:\n         agent.valid_tool_names = valid_tool_names\n \n     added = 0\n-    for schema in get_schemas():\n-        if not isinstance(schema, dict):\n+    for raw_schema in get_schemas():\n+        schema = normalize_tool_schema(raw_schema)\n+        if schema is None:\n+            logger.warning(\n+                \"Memory provider returned a tool schema with no resolvable \"\n+                \"name; skipping to avoid poisoning the request (%r)\",\n+                raw_schema,\n+            )\n             continue\n-        tool_name = schema.get(\"name\", \"\")\n-        if not tool_name or tool_name in existing_tool_names:\n+        tool_name = schema[\"name\"]\n+        if tool_name in existing_tool_names:\n             continue\n         tools.append({\"type\": \"function\", \"function\": schema})\n         valid_tool_names.add(tool_name)","@@ -370,8 +409,11 @@ def add_provider(self, provider: MemoryProvider) -> None:\n         _core_tool_names = set(_HERMES_CORE_TOOLS)\n \n         # Index tool names → provider for routing\n-        for schema in provider.get_tool_schemas():\n-            tool_name = schema.get(\"name\", \"\")\n+        for raw_schema in provider.get_tool_schemas():\n+            schema = normalize_tool_schema(raw_schema)\n+            if schema is None:\n+                continue\n+            tool_name = schema[\"name\"]\n             if tool_name in _core_tool_names:\n                 logger.warning(\n                     \"Memory provider '%s' tool '%s' shadows a reserved core \"","@@ -658,11 +700,19 @@ def get_all_tool_schemas(self) -> List[Dict[str, Any]]:\n         seen = set()\n         for provider in self._providers:\n             try:\n-                for schema in provider.get_tool_schemas():\n-                    name = schema.get(\"name\", \"\")\n+                for raw_schema in provider.get_tool_schemas():\n+                    schema = normalize_tool_schema(raw_schema)\n+                    if schema is None:\n+                        logger.warning(\n+                            \"Memory provider '%s' returned a tool schema with \"\n+                            \"no resolvable name; skipping (%r)\",\n+                            provider.name, raw_schema,\n+                        )\n+                        continue\n+                    name = schema[\"name\"]\n                     if name in _core_tool_names:\n                         continue\n-                    if name and name not in seen:\n+                    if name not in seen:\n                         schemas.append(schema)\n                         seen.add(name)\n             except Exception as e:"]},{"file":"tests/agent/test_memory_provider.py","hunks":["@@ -1487,3 +1487,103 @@ def test_no_compressor_no_injection(self):\n         \"\"\"Gate is moot without a context_compressor.\"\"\"\n         tools, names, engine_names = self._run_context_engine_injection(None, None)\n         assert tools == []\n+\n+\n+class TestNormalizeToolSchema:\n+    \"\"\"Issue #47707: one malformed tool schema must not poison the request.\n+\n+    Context engines / memory providers expose schemas via get_tool_schemas().\n+    The expected shape is a bare function schema; some providers return an\n+    entry already in OpenAI tool form ({\"type\":\"function\",\"function\":{...}}).\n+    Wrapping that a second time yields a tool whose `function` has no\n+    top-level `name`, which strict providers (DeepSeek) reject with HTTP 400\n+    `tools[N].function: missing field name` — disabling the entire toolset.\n+    \"\"\"\n+\n+    def test_bare_schema_passthrough(self):\n+        from agent.memory_manager import normalize_tool_schema\n+        s = {\"name\": \"x_grep\", \"description\": \"d\", \"parameters\": {}}\n+        assert normalize_tool_schema(s) == s\n+\n+    def test_already_wrapped_schema_is_unwrapped(self):\n+        from agent.memory_manager import normalize_tool_schema\n+        wrapped = {\n+            \"type\": \"function\",\n+            \"function\": {\"name\": \"x_grep\", \"description\": \"d\", \"parameters\": {}},\n+        }\n+        out = normalize_tool_schema(wrapped)\n+        assert out is not None\n+        assert out[\"name\"] == \"x_grep\"\n+        # Must be the inner function schema, not the wrapper.\n+        assert \"type\" not in out or out.get(\"type\") != \"function\"\n+\n+    def test_nameless_schema_rejected(self):\n+        from agent.memory_manager import normalize_tool_schema\n+        assert normalize_tool_schema({\"description\": \"no name\"}) is None\n+\n+    def test_double_wrapped_without_name_rejected(self):\n+        from agent.memory_manager import normalize_tool_schema\n+        # The exact poisoning shape from #47707.\n+        assert normalize_tool_schema(\n+            {\"type\": \"function\", \"function\": {\"type\": \"function\",\n+             \"function\": {\"name\": \"x\"}}}\n+        ) is None\n+\n+    def test_non_dict_rejected(self):\n+        from agent.memory_manager import normalize_tool_schema\n+        assert normalize_tool_schema(\"nope\") is None\n+        assert normalize_tool_schema(None) is None\n+\n+    def test_non_string_name_rejected(self):\n+        from agent.memory_manager import normalize_tool_schema\n+        assert normalize_tool_schema({\"name\": 123}) is None\n+\n+\n+class TestMemoryInjectionRejectsMalformedSchema:\n+    \"\"\"The real inject_memory_provider_tools must skip nameless schemas.\n+\n+    Without the #47707 fix, an already-wrapped schema is appended as a\n+    nameless tool ({\"type\":\"function\",\"function\":{\"type\":\"function\",...}}),\n+    poisoning the whole tool surface. With the fix it is skipped (or, for a\n+    well-formed-but-wrapped schema, unwrapped to a valid tool).\n+    \"\"\"\n+\n+    def _agent_with(self, *schemas):\n+        mgr = MemoryManager()\n+        mgr.add_provider(FakeMemoryProvider(\"ext\", tools=list(schemas)))\n+        return SimpleNamespace(\n+            _memory_manager=mgr,\n+            enabled_toolsets=None,\n+            tools=[],\n+            valid_tool_names=set(),\n+        )\n+\n+    def test_already_wrapped_schema_is_unwrapped_not_poisoned(self):\n+        agent = self._agent_with(\n+            {\"type\": \"function\",\n+             \"function\": {\"name\": \"x_grep\", \"description\": \"d\", \"parameters\": {}}}\n+        )\n+        inject_memory_provider_tools(agent)\n+        # Exactly one well-formed tool, with a top-level function name.\n+        assert len(agent.tools) == 1\n+        fn = agent.tools[0][\"function\"]\n+        assert fn[\"name\"] == \"x_grep\"\n+        # No nested double-wrap leaked through.\n+        assert fn.get(\"type\") != \"function\"\n+        assert \"x_grep\" in agent.valid_tool_names\n+\n+    def test_nameless_schema_is_skipped(self):\n+        agent = self._agent_with({\"description\": \"no name at all\"})\n+        inject_memory_provider_tools(agent)\n+        assert agent.tools == []\n+        assert agent.valid_tool_names == set()\n+\n+    def test_good_schema_still_injected_alongside_bad(self):\n+        agent = self._agent_with(\n+            {\"name\": \"good_tool\", \"description\": \"d\", \"parameters\": {}},\n+            {\"description\": \"bad, no name\"},\n+        )\n+        inject_memory_provider_tools(agent)\n+        names = {t[\"function\"][\"name\"] for t in agent.tools}\n+        assert names == {\"good_tool\"}\n+        assert agent.valid_tool_names == {\"good_tool\"}\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}