From c204aaeec7566460f3370b10f4e0ca8d17d8ebb6 Mon Sep 17 00:00:00 2001 From: Chiang Guantik Date: Sat, 25 Apr 2026 22:09:22 +0800 Subject: [PATCH 1/4] fix: surface provider-scoped context length in gateway session info --- gateway/run.py | 28 ++++++++++++++++++- tests/gateway/test_session_info.py | 45 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/gateway/run.py b/gateway/run.py index f5c1858dbf564..b4d7ec0b2d12b 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -4949,6 +4949,7 @@ def _format_session_info(self) -> str: provider = None base_url = None api_key = None + data = {} try: cfg_path = _hermes_home / "config.yaml" @@ -4967,7 +4968,7 @@ def _format_session_info(self) -> str: provider = model_cfg.get("provider") or None base_url = model_cfg.get("base_url") or None except Exception: - pass + data = {} # Resolve runtime credentials for probing try: @@ -4978,6 +4979,31 @@ def _format_session_info(self) -> str: except Exception: pass + if config_context_length is None and base_url: + try: + from hermes_cli.config import get_compatible_custom_providers as _gw_gcp + custom_providers = _gw_gcp(data) + except Exception: + custom_providers = data.get("custom_providers") + if not isinstance(custom_providers, list): + custom_providers = [] + try: + for cp in custom_providers: + if not isinstance(cp, dict): + continue + cp_url = (cp.get("base_url") or "").rstrip("/") + if cp_url and cp_url == base_url.rstrip("/"): + cp_models = cp.get("models", {}) + if isinstance(cp_models, dict): + cp_model_cfg = cp_models.get(model, {}) + if isinstance(cp_model_cfg, dict): + cp_ctx = cp_model_cfg.get("context_length") + if cp_ctx is not None: + config_context_length = int(cp_ctx) + break + except (TypeError, ValueError): + pass + context_length = get_model_context_length( model, base_url=base_url or "", diff --git a/tests/gateway/test_session_info.py b/tests/gateway/test_session_info.py index 5f04b1a482feb..81778933f9a8b 100644 --- a/tests/gateway/test_session_info.py +++ b/tests/gateway/test_session_info.py @@ -52,6 +52,51 @@ def test_config_context_length(self, runner, tmp_path): assert "32K" in info assert "config" in info + def test_config_context_length_from_providers_dict(self, runner, tmp_path): + p1, p2, p3 = _patch_info( + tmp_path, + ( + "model:\n" + " default: gpt-5.4\n" + " provider: newapi-openai\n" + "providers:\n" + " newapi-openai:\n" + " api: https://example.invalid/v1\n" + " name: newapi-openai\n" + " models:\n" + " gpt-5.4:\n" + " context_length: 1050000\n" + ), + "gpt-5.4", + {"provider": "newapi-openai", "base_url": "https://example.invalid/v1", "api_key": "***"}, + ) + with p1, p2, p3: + info = runner._format_session_info() + assert "1.1M" in info + assert "config" in info + + def test_config_context_length_from_custom_providers_list(self, runner, tmp_path): + p1, p2, p3 = _patch_info( + tmp_path, + ( + "model:\n" + " default: gpt-5.4\n" + " provider: newapi-openai\n" + "custom_providers:\n" + " - name: newapi-openai\n" + " base_url: https://example.invalid/v1\n" + " models:\n" + " gpt-5.4:\n" + " context_length: 1050000\n" + ), + "gpt-5.4", + {"provider": "newapi-openai", "base_url": "https://example.invalid/v1", "api_key": "***"}, + ) + with p1, p2, p3: + info = runner._format_session_info() + assert "1.1M" in info + assert "config" in info + def test_default_fallback_hint(self, runner, tmp_path): p1, p2, p3 = _patch_info(tmp_path, "model:\n default: unknown-model-xyz\n", "unknown-model-xyz", From 12fa0bb0e7d155b0d1575686ee1ddeb0fd6a0fcc Mon Sep 17 00:00:00 2001 From: Chiang Guantik Date: Sat, 25 Apr 2026 19:49:14 +0800 Subject: [PATCH 2/4] fix(runtime): honor providers transport/api_mode for named custom providers (cherry picked from commit 5c41a410148be968ddb1e511fe231278bd55bf1c) --- hermes_cli/runtime_provider.py | 11 ++- .../test_runtime_provider_resolution.py | 82 +++++++++++++++++++ 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index cbfcbdbd6caf3..696a78bf96792 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -349,6 +349,7 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An # Fall back to inline api_key when key_env is absent or unresolvable if not resolved_api_key: resolved_api_key = str(entry.get("api_key", "") or "").strip() + resolved_api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport")) if requested_norm in {ep_name, name_norm, f"custom:{name_norm}"}: # Found match by provider key @@ -360,9 +361,8 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An "api_key": resolved_api_key, "model": entry.get("default_model", ""), } - api_mode = _parse_api_mode(entry.get("api_mode")) - if api_mode: - result["api_mode"] = api_mode + if resolved_api_mode: + result["api_mode"] = resolved_api_mode return result # Also check the 'name' field if present display_name = entry.get("name", "") @@ -378,9 +378,8 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An "api_key": resolved_api_key, "model": entry.get("default_model", ""), } - api_mode = _parse_api_mode(entry.get("api_mode")) - if api_mode: - result["api_mode"] = api_mode + if resolved_api_mode: + result["api_mode"] = resolved_api_mode return result # Fall back to custom_providers: list (legacy format) diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index a81dc9f5e21b3..8fbcae751aba5 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -719,6 +719,88 @@ def test_named_custom_provider_uses_key_env_from_providers_dict(monkeypatch): assert resolved["model"] == "acme-large" +def test_named_custom_provider_uses_transport_from_providers_dict(monkeypatch): + """providers dict transport should propagate into runtime api_mode.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "dir-key") + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "anthropic-proxy": { + "base_url": "https://proxy.example.com/messages", + "api_key": "***", + "default_model": "claude-proxy", + "name": "Anthropic Proxy", + "transport": "anthropic_messages", + } + } + }, + ) + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider should not be called for named custom providers" + ) + ), + ) + + resolved = rp.resolve_runtime_provider(requested="anthropic-proxy") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "anthropic_messages" + assert resolved["base_url"] == "https://proxy.example.com/messages" + assert resolved["api_key"] == "dir-key" + assert resolved["requested_provider"] == "anthropic-proxy" + assert resolved["source"] == "custom_provider:Anthropic Proxy" + assert resolved["model"] == "claude-proxy" + + +def test_named_custom_provider_uses_api_mode_from_providers_dict(monkeypatch): + """providers dict api_mode should propagate into runtime api_mode.""" + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.setenv("OPENAI_API_KEY", "dir-key") + monkeypatch.setattr( + rp, + "load_config", + lambda: { + "providers": { + "responses-proxy": { + "base_url": "https://proxy.example.com/v1", + "api_key": "***", + "default_model": "gpt-5-mini", + "name": "Responses Proxy", + "api_mode": "codex_responses", + } + } + }, + ) + monkeypatch.setattr( + rp, + "resolve_provider", + lambda *a, **k: (_ for _ in ()).throw( + AssertionError( + "resolve_provider should not be called for named custom providers" + ) + ), + ) + + resolved = rp.resolve_runtime_provider(requested="responses-proxy") + + assert resolved["provider"] == "custom" + assert resolved["api_mode"] == "codex_responses" + assert resolved["base_url"] == "https://proxy.example.com/v1" + assert resolved["api_key"] == "dir-key" + assert resolved["requested_provider"] == "responses-proxy" + assert resolved["source"] == "custom_provider:Responses Proxy" + assert resolved["model"] == "gpt-5-mini" + + def test_named_custom_provider_falls_back_to_openai_api_key(monkeypatch): monkeypatch.setenv("OPENAI_API_KEY", "env-openai-key") monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) From 091d7be71dc51f57eb8afcfb96a7f0266b4b0f78 Mon Sep 17 00:00:00 2001 From: Chiang Guantik Date: Sat, 25 Apr 2026 19:49:14 +0800 Subject: [PATCH 3/4] fix(mcp): shorten registered MCP tool names to fit provider limits (cherry picked from commit fce648278564812f1d0a1ade8f889a284bfe767e) --- tests/tools/test_mcp_tool.py | 81 ++++++++++++++++++++++++++++++++++++ tools/mcp_tool.py | 31 +++++++++++--- 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/tests/tools/test_mcp_tool.py b/tests/tools/test_mcp_tool.py index 1604d4adb5c06..b32e0755a71aa 100644 --- a/tests/tools/test_mcp_tool.py +++ b/tests/tools/test_mcp_tool.py @@ -309,6 +309,51 @@ def test_hyphens_sanitized_to_underscores(self): assert schema["name"] == "mcp_my_server_get_sum" assert "-" not in schema["name"] + def test_short_name_remains_unchanged(self): + from tools.mcp_tool import _convert_mcp_schema + + mcp_tool = _make_mcp_tool(name="list_dir") + schema = _convert_mcp_schema("my_server", mcp_tool) + + assert schema["name"] == "mcp_my_server_list_dir" + assert len(schema["name"]) <= 64 + + def test_long_name_shortened_to_64_chars_or_less(self): + from tools.mcp_tool import _convert_mcp_schema + + mcp_tool = _make_mcp_tool(name="cex_options_list_options_underlying_candlesticks") + schema = _convert_mcp_schema("gate_cex_pub", mcp_tool) + + assert len(schema["name"]) <= 64 + assert schema["name"].startswith("mcp_gate_cex_pub_") + + def test_long_name_shortening_is_deterministic(self): + from tools.mcp_tool import _convert_mcp_schema + + mcp_tool = _make_mcp_tool(name="tool_" + ("very_long_segment_" * 4) + "alpha") + + schema1 = _convert_mcp_schema("server_name", mcp_tool) + schema2 = _convert_mcp_schema("server_name", mcp_tool) + + assert schema1["name"] == schema2["name"] + assert len(schema1["name"]) <= 64 + + def test_different_long_names_do_not_collide(self): + from tools.mcp_tool import _convert_mcp_schema + + schema1 = _convert_mcp_schema( + "server_name", + _make_mcp_tool(name="tool_" + ("very_long_segment_" * 4) + "alpha"), + ) + schema2 = _convert_mcp_schema( + "server_name", + _make_mcp_tool(name="tool_" + ("very_long_segment_" * 4) + "beta"), + ) + + assert schema1["name"] != schema2["name"] + assert len(schema1["name"]) <= 64 + assert len(schema2["name"]) <= 64 + # --------------------------------------------------------------------------- # Check function @@ -591,6 +636,42 @@ async def fake_connect(name, config): _servers.pop("srv", None) + def test_shortened_registry_name_still_dispatches_original_tool_name(self): + from tools.registry import ToolRegistry + from tools.mcp_tool import _discover_and_register_server, _servers, MCPServerTask + + mock_registry = ToolRegistry() + original_tool_name = "tool_" + ("very_long_segment_" * 4) + "alpha" + mock_tools = [_make_mcp_tool(original_tool_name, "Do something")] + mock_session = MagicMock() + mock_session.call_tool = AsyncMock(return_value=_make_call_result("ok")) + + async def fake_connect(name, config): + server = MCPServerTask(name) + server.session = mock_session + server._tools = mock_tools + return server + + def fake_run(coro, timeout=30): + return asyncio.run(coro) + + with patch("tools.mcp_tool._connect_server", side_effect=fake_connect), \ + patch("tools.mcp_tool._run_on_mcp_loop", side_effect=fake_run), \ + patch("tools.registry.registry", mock_registry): + registered = asyncio.run( + _discover_and_register_server("server_name", {"command": "test"}) + ) + tool_name = next(name for name in registered if name.startswith("mcp_server_name_tool_")) + assert len(tool_name) <= 64 + + entry = mock_registry._tools[tool_name] + result = json.loads(entry.handler({"value": 1})) + + assert result["result"] == "ok" + mock_session.call_tool.assert_called_once_with(original_tool_name, arguments={"value": 1}) + + _servers.pop("server_name", None) + # --------------------------------------------------------------------------- # MCPServerTask (run / start / shutdown) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 565dbfca0ec22..b40d321e044ea 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -71,6 +71,7 @@ import asyncio import concurrent.futures +import hashlib import inspect import json import logging @@ -2378,6 +2379,22 @@ def sanitize_mcp_name_component(value: str) -> str: return re.sub(r"[^A-Za-z0-9_]", "_", str(value or "")) +_MAX_MCP_TOOL_NAME_LENGTH = 64 +_MCP_TOOL_NAME_HASH_LENGTH = 12 + + +def shorten_mcp_tool_name(name: str, max_length: int = _MAX_MCP_TOOL_NAME_LENGTH) -> str: + """Return a stable MCP registry name that fits provider length limits.""" + if len(name) <= max_length: + return name + + digest = hashlib.sha256(name.encode("utf-8")).hexdigest()[:_MCP_TOOL_NAME_HASH_LENGTH] + prefix_length = max_length - len(digest) - 1 + if prefix_length <= 0: + return digest[:max_length] + return f"{name[:prefix_length]}_{digest}" + + def _convert_mcp_schema(server_name: str, mcp_tool) -> dict: """Convert an MCP tool listing to the Hermes registry schema format. @@ -2391,7 +2408,7 @@ def _convert_mcp_schema(server_name: str, mcp_tool) -> dict: """ safe_tool_name = sanitize_mcp_name_component(mcp_tool.name) safe_server_name = sanitize_mcp_name_component(server_name) - prefixed_name = f"mcp_{safe_server_name}_{safe_tool_name}" + prefixed_name = shorten_mcp_tool_name(f"mcp_{safe_server_name}_{safe_tool_name}") return { "name": prefixed_name, "description": mcp_tool.description or f"MCP tool {mcp_tool.name} from {server_name}", @@ -2406,10 +2423,14 @@ def _build_utility_schemas(server_name: str) -> List[dict]: with keys: schema, handler_key. """ safe_name = sanitize_mcp_name_component(server_name) + + def _utility_name(suffix: str) -> str: + return shorten_mcp_tool_name(f"mcp_{safe_name}_{suffix}") + return [ { "schema": { - "name": f"mcp_{safe_name}_list_resources", + "name": _utility_name("list_resources"), "description": f"List available resources from MCP server '{server_name}'", "parameters": { "type": "object", @@ -2420,7 +2441,7 @@ def _build_utility_schemas(server_name: str) -> List[dict]: }, { "schema": { - "name": f"mcp_{safe_name}_read_resource", + "name": _utility_name("read_resource"), "description": f"Read a resource by URI from MCP server '{server_name}'", "parameters": { "type": "object", @@ -2437,7 +2458,7 @@ def _build_utility_schemas(server_name: str) -> List[dict]: }, { "schema": { - "name": f"mcp_{safe_name}_list_prompts", + "name": _utility_name("list_prompts"), "description": f"List available prompts from MCP server '{server_name}'", "parameters": { "type": "object", @@ -2448,7 +2469,7 @@ def _build_utility_schemas(server_name: str) -> List[dict]: }, { "schema": { - "name": f"mcp_{safe_name}_get_prompt", + "name": _utility_name("get_prompt"), "description": f"Get a prompt by name from MCP server '{server_name}'", "parameters": { "type": "object", From 773219fbd8992f82ed0899e8ec242c6de961d5cb Mon Sep 17 00:00:00 2001 From: Chiang Guantik Date: Sun, 26 Apr 2026 18:09:26 +0800 Subject: [PATCH 4/4] fix: preserve runtime in background review clones --- run_agent.py | 3 + ...t_background_review_runtime_inheritance.py | 123 ++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/run_agent/test_background_review_runtime_inheritance.py diff --git a/run_agent.py b/run_agent.py index 7187499bb1c4f..658d4167c81cc 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3172,6 +3172,9 @@ def _run_review(): quiet_mode=True, platform=self.platform, provider=self.provider, + base_url=self.base_url or None, + api_key=self.api_key or None, + api_mode=self.api_mode or None, parent_session_id=self.session_id, ) review_agent._memory_write_origin = "background_review" diff --git a/tests/run_agent/test_background_review_runtime_inheritance.py b/tests/run_agent/test_background_review_runtime_inheritance.py new file mode 100644 index 0000000000000..540b8585e6839 --- /dev/null +++ b/tests/run_agent/test_background_review_runtime_inheritance.py @@ -0,0 +1,123 @@ +"""Regression tests for background review runtime inheritance. + +Background review forks a secondary AIAgent after the main reply. When the +main session uses a named custom provider resolved to provider='custom' with +explicit runtime credentials, the fork must inherit those runtime fields. +Otherwise the review agent re-initializes as a bare custom/main provider and +fails with "No LLM provider configured". +""" + +from __future__ import annotations + +import threading + +import pytest +import yaml + +import run_agent as run_agent_module +from hermes_cli.runtime_provider import resolve_runtime_provider +from run_agent import AIAgent + + +@pytest.fixture(autouse=True) +def _isolate(tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + config = { + "model": { + "default": "gpt-5.4", + "provider": "newapi-openai", + "api_mode": "codex_responses", + }, + "providers": { + "newapi-openai": { + "api": "https://newapi.example.invalid/v1", + "name": "newapi-openai", + "api_key": "test-key", + "default_model": "gpt-5.4", + "transport": "codex_responses", + "api_mode": "codex_responses", + } + }, + "memory": { + "memory_enabled": True, + "user_profile_enabled": True, + }, + "skills": { + "creation_nudge_interval": 10, + }, + } + (hermes_home / "config.yaml").write_text(yaml.safe_dump(config), encoding="utf-8") + + +class _ImmediateThread: + def __init__(self, *, target=None, **kwargs): + self._target = target + + def start(self): + if self._target: + self._target() + + +class _SpyReviewAgent: + init_kwargs: dict | None = None + prompts: list[str] = [] + + def __init__(self, **kwargs): + type(self).init_kwargs = kwargs + self._session_messages = [] + self.closed = False + + def run_conversation(self, user_message, conversation_history=None): + type(self).prompts.append(user_message) + return {"final_response": "Nothing to save."} + + def close(self): + self.closed = True + + +@pytest.mark.parametrize( + ("review_memory", "review_skills", "expected_prompt"), + [ + (True, False, AIAgent._MEMORY_REVIEW_PROMPT), + (False, True, AIAgent._SKILL_REVIEW_PROMPT), + (True, True, AIAgent._COMBINED_REVIEW_PROMPT), + ], +) +def test_background_review_inherits_runtime_fields( + monkeypatch, + review_memory, + review_skills, + expected_prompt, +): + runtime = resolve_runtime_provider(requested="newapi-openai") + agent = AIAgent( + model="gpt-5.4", + provider=runtime.get("provider"), + base_url=runtime.get("base_url"), + api_key=runtime.get("api_key"), + api_mode=runtime.get("api_mode"), + quiet_mode=True, + max_iterations=1, + ) + + _SpyReviewAgent.init_kwargs = None + _SpyReviewAgent.prompts = [] + monkeypatch.setattr(threading, "Thread", _ImmediateThread) + monkeypatch.setattr(run_agent_module, "AIAgent", _SpyReviewAgent) + + agent._spawn_background_review( + messages_snapshot=[{"role": "user", "content": "hi"}], + review_memory=review_memory, + review_skills=review_skills, + ) + + assert _SpyReviewAgent.init_kwargs is not None + assert _SpyReviewAgent.init_kwargs["provider"] == "custom" + assert _SpyReviewAgent.init_kwargs["base_url"] == "https://newapi.example.invalid/v1" + assert _SpyReviewAgent.init_kwargs["api_key"] == "test-key" + assert _SpyReviewAgent.init_kwargs["api_mode"] == "codex_responses" + assert _SpyReviewAgent.prompts == [expected_prompt] + + agent.close()