From ab7055046cc2c2081a68b5705602cfd1a531c38a Mon Sep 17 00:00:00 2001 From: Jason Ma Date: Fri, 17 Jul 2026 10:49:33 +0800 Subject: [PATCH] fix(hermes): register bare tool schemas so strict providers accept them The NemoClaw Hermes plugin registered its four tools (nemoclaw_status, nemoclaw_info, nemoclaw_reload_skills, transcribe_audio) by passing a pre-wrapped OpenAI envelope (`{"type":"function","function":{...}}`) to `ctx.register_tool`. Hermes wraps registered schemas again at request-build time, so the outbound `tools[]` entries became double-wrapped (`{"type":"function","function":{"type":"function","function":{...}}}`). Lenient endpoints (NVIDIA build, Azure) tolerate the nesting, but Google Gemini's strict OpenAI-compatible endpoint rejects the whole request with HTTP 400 (`Unknown name "function" at tools[i].function`), breaking every conversation. For transcribe_audio the double-wrap also dropped its real `parameters` (file_path/model) to `{}` at the outer level. Pass the bare function object to `register_tool` so Hermes adds exactly one envelope, producing spec-valid single-wrapped tools and preserving transcribe_audio's parameters. Adds a standalone unittest that registers the tools against a fake ctx and asserts each schema is a bare function object (no nested envelope) and that transcribe_audio keeps its real parameters. Closes #7067 Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Jason Ma --- agents/hermes/plugin/__init__.py | 82 ++++++++---------- agents/hermes/plugin/test_register_tools.py | 94 +++++++++++++++++++++ 2 files changed, 131 insertions(+), 45 deletions(-) create mode 100644 agents/hermes/plugin/test_register_tools.py diff --git a/agents/hermes/plugin/__init__.py b/agents/hermes/plugin/__init__.py index 860a05484e0..e4269e9c74f 100644 --- a/agents/hermes/plugin/__init__.py +++ b/agents/hermes/plugin/__init__.py @@ -1395,16 +1395,17 @@ def register(ctx): ctx.register_tool( name="nemoclaw_status", toolset="nemoclaw", + # Pass the bare function object; Hermes wraps it in the + # {"type":"function","function":{...}} envelope at request-build time. + # Pre-wrapping here double-wraps the tool, which strict providers + # (Gemini) reject with HTTP 400 (#7067). schema={ - "type": "function", - "function": { - "name": "nemoclaw_status", - "description": ( - "Show NemoClaw sandbox status: agent type, gateway health, " - "model, provider, and inference endpoint." - ), - "parameters": {"type": "object", "properties": {}}, - }, + "name": "nemoclaw_status", + "description": ( + "Show NemoClaw sandbox status: agent type, gateway health, " + "model, provider, and inference endpoint." + ), + "parameters": {"type": "object", "properties": {}}, }, handler=_handle_status, description="NemoClaw sandbox status", @@ -1415,12 +1416,9 @@ def register(ctx): name="nemoclaw_info", toolset="nemoclaw", schema={ - "type": "function", - "function": { - "name": "nemoclaw_info", - "description": "Get NemoClaw sandbox info as structured JSON.", - "parameters": {"type": "object", "properties": {}}, - }, + "name": "nemoclaw_info", + "description": "Get NemoClaw sandbox info as structured JSON.", + "parameters": {"type": "object", "properties": {}}, }, handler=_handle_info, description="NemoClaw sandbox info (JSON)", @@ -1430,28 +1428,25 @@ def register(ctx): name="transcribe_audio", toolset="audio", schema={ - "type": "function", - "function": { - "name": "transcribe_audio", - "description": ( - "Transcribe an audio file that already exists in the Hermes " - "sandbox. In NemoClaw broker mode this uses the managed " - "OpenAI-audio gateway instead of direct OpenAI credentials." - ), - "parameters": { - "type": "object", - "properties": { - "file_path": { - "type": "string", - "description": "Path to an audio file inside the Hermes sandbox.", - }, - "model": { - "type": "string", - "description": "Optional transcription model override.", - }, + "name": "transcribe_audio", + "description": ( + "Transcribe an audio file that already exists in the Hermes " + "sandbox. In NemoClaw broker mode this uses the managed " + "OpenAI-audio gateway instead of direct OpenAI credentials." + ), + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to an audio file inside the Hermes sandbox.", + }, + "model": { + "type": "string", + "description": "Optional transcription model override.", }, - "required": ["file_path"], }, + "required": ["file_path"], }, }, handler=_handle_transcribe_audio, @@ -1463,16 +1458,13 @@ def register(ctx): name="nemoclaw_reload_skills", toolset="nemoclaw", schema={ - "type": "function", - "function": { - "name": "nemoclaw_reload_skills", - "description": ( - "Reload and re-discover skills from the skill directories. " - "Call this after new skills have been installed to make them " - "available as slash commands without restarting the gateway." - ), - "parameters": {"type": "object", "properties": {}}, - }, + "name": "nemoclaw_reload_skills", + "description": ( + "Reload and re-discover skills from the skill directories. " + "Call this after new skills have been installed to make them " + "available as slash commands without restarting the gateway." + ), + "parameters": {"type": "object", "properties": {}}, }, handler=_handle_reload_skills, description="Reload skills from disk without gateway restart", diff --git a/agents/hermes/plugin/test_register_tools.py b/agents/hermes/plugin/test_register_tools.py new file mode 100644 index 00000000000..5023552dcdf --- /dev/null +++ b/agents/hermes/plugin/test_register_tools.py @@ -0,0 +1,94 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +"""Tool-registration schema shape for the NemoClaw Hermes plugin (#7067). + +Hermes wraps each registered tool schema in a single +``{"type": "function", "function": {...}}`` envelope at request-build time. +The plugin must therefore hand ``register_tool`` the *bare* function object. +Pre-wrapping double-wraps the tool, which lenient providers tolerate but strict +ones (Google Gemini) reject with HTTP 400, and it also drops +``transcribe_audio``'s real ``parameters`` to ``{}`` at the outer level. + +Runs standalone: ``python -m unittest`` from ``agents/hermes/plugin`` (stdlib + +PyYAML only; no Hermes runtime required). +""" + +from __future__ import annotations + +import importlib.util +import os +import unittest + + +def _load_plugin_module(): + """Import the plugin __init__.py as a standalone module.""" + path = os.path.join(os.path.dirname(__file__), "__init__.py") + spec = importlib.util.spec_from_file_location("nemoclaw_hermes_plugin", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class _FakeCtx: + """Captures register_tool/register_hook calls made by register(ctx).""" + + def __init__(self): + self.tools: dict[str, dict] = {} + self.hooks: list[str] = [] + + def register_tool(self, name, schema, **_kwargs): + self.tools[name] = schema + + def register_hook(self, event, _handler=None, **_kwargs): + self.hooks.append(event) + + +class RegisterToolSchemaShapeTest(unittest.TestCase): + EXPECTED_TOOLS = ( + "nemoclaw_status", + "nemoclaw_info", + "nemoclaw_reload_skills", + "transcribe_audio", + ) + + def setUp(self): + self.plugin = _load_plugin_module() + # register() first installs Hermes runtime patches; no-op them so the + # test isolates tool-registration shape without a live Hermes. + self.plugin._install_nous_tool_broker_patch = lambda *a, **k: None + self.plugin._install_messaging_response_patch = lambda *a, **k: None + self.ctx = _FakeCtx() + self.plugin.register(self.ctx) + + def test_all_expected_tools_registered(self): + self.assertEqual(set(self.EXPECTED_TOOLS), set(self.ctx.tools)) + + def test_schemas_are_bare_function_objects_not_double_wrapped(self): + for name in self.EXPECTED_TOOLS: + schema = self.ctx.tools[name] + with self.subTest(tool=name): + # A bare function object exposes name/parameters at the top level + # and carries no nested envelope. + self.assertEqual(schema.get("name"), name) + self.assertIn("parameters", schema) + self.assertNotIn( + "function", schema, f"{name} schema is still wrapped in an envelope" + ) + self.assertNotEqual( + schema.get("type"), + "function", + f"{name} schema is still wrapped in an envelope", + ) + + def test_transcribe_audio_keeps_its_real_parameters(self): + # The destructive symptom of double-wrapping: transcribe_audio's real + # parameters end up only in the inner wrapper and the outer becomes {}. + params = self.ctx.tools["transcribe_audio"]["parameters"] + self.assertEqual(params.get("type"), "object") + self.assertIn("file_path", params.get("properties", {})) + self.assertIn("model", params.get("properties", {})) + self.assertEqual(params.get("required"), ["file_path"]) + + +if __name__ == "__main__": + unittest.main()