Skip to content
Closed
3 changes: 3 additions & 0 deletions agent/agent_runtime_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -1513,6 +1513,9 @@ def invoke_tool(agent, function_name: str, function_args: dict, effective_task_i
merge=function_args.get("merge", False),
store=agent._todo_store,
)
elif function_name == "load_tool_pack":
from tools.lazy_tool_loader import load_tool_pack_for_agent
return load_tool_pack_for_agent(agent, function_args.get("pack", ""))
elif function_name == "session_search":
session_db = agent._get_session_db_for_recall()
if not session_db:
Expand Down
12 changes: 12 additions & 0 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,18 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('todo', function_args, tool_duration, result=function_result)}")
elif function_name == "load_tool_pack":
function_result = agent._invoke_tool(
function_name,
function_args,
effective_task_id,
tool_call_id=getattr(tool_call, "id", None),
messages=messages,
pre_tool_block_checked=True,
)
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
agent._vprint(f" {_get_cute_tool_message_impl('load_tool_pack', function_args, tool_duration, result=function_result)}")
elif function_name == "session_search":
session_db = agent._get_session_db_for_recall()
if not session_db:
Expand Down
631 changes: 631 additions & 0 deletions docs/audits/botparlor-system-prompt-audit-2026-05-20.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3008,7 +3008,7 @@ def _normalize_custom_provider_entry(
"api_mode", "transport", "model", "default_model", "models",
"context_length", "rate_limit_delay",
"request_timeout_seconds", "stale_timeout_seconds",
"discover_models",
"discover_models", "provider_profile", "runtime_provider", "provider",
}
for camel, snake in _CAMEL_ALIASES.items():
if camel in entry and snake not in entry:
Expand Down Expand Up @@ -3264,6 +3264,7 @@ def check_config_version() -> Tuple[int, int]:
_VALID_CUSTOM_PROVIDER_FIELDS = {
"name", "base_url", "api_key", "api_mode", "model", "models",
"context_length", "rate_limit_delay",
"provider_profile", "runtime_provider", "provider",
# key_env is read at runtime by runtime_provider.py and auxiliary_client.py
# — include it here so the set accurately describes the supported schema.
"key_env",
Expand Down
31 changes: 29 additions & 2 deletions hermes_cli/runtime_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,13 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
if api_mode:
result["api_mode"] = api_mode
profile = (
entry.get("provider_profile")
or entry.get("runtime_provider")
or entry.get("provider")
)
if isinstance(profile, str) and profile.strip():
result["provider_profile"] = profile.strip()
return result
# Also check the 'name' field if present
display_name = entry.get("name", "")
Expand All @@ -499,6 +506,13 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
api_mode = _parse_api_mode(entry.get("api_mode") or entry.get("transport"))
if api_mode:
result["api_mode"] = api_mode
profile = (
entry.get("provider_profile")
or entry.get("runtime_provider")
or entry.get("provider")
)
if isinstance(profile, str) and profile.strip():
result["provider_profile"] = profile.strip()
return result

# Fall back to custom_providers: list (legacy format)
Expand Down Expand Up @@ -539,6 +553,13 @@ def _get_named_custom_provider(requested_provider: str) -> Optional[Dict[str, An
result["key_env"] = key_env
if provider_key:
result["provider_key"] = provider_key
profile = (
entry.get("provider_profile")
or entry.get("runtime_provider")
or entry.get("provider")
)
if isinstance(profile, str) and profile.strip():
result["provider_profile"] = profile.strip()
api_mode = _parse_api_mode(entry.get("api_mode"))
if api_mode:
result["api_mode"] = api_mode
Expand Down Expand Up @@ -612,7 +633,13 @@ def _resolve_named_custom_runtime(
return None

# Check if a credential pool exists for this custom endpoint
pool_result = _try_resolve_from_custom_pool(base_url, "custom", custom_provider.get("api_mode"), provider_name=custom_provider.get("name"))
provider_profile = str(custom_provider.get("provider_profile") or "custom").strip() or "custom"
pool_result = _try_resolve_from_custom_pool(
base_url,
provider_profile,
custom_provider.get("api_mode"),
provider_name=custom_provider.get("name"),
)
if pool_result:
# Propagate the model name even when using pooled credentials —
# the pool doesn't know about the custom_providers model field.
Expand All @@ -631,7 +658,7 @@ def _resolve_named_custom_runtime(
api_key = next((candidate for candidate in api_key_candidates if has_usable_secret(candidate)), "")

result = {
"provider": "custom",
"provider": provider_profile,
"api_mode": custom_provider.get("api_mode")
or _detect_api_mode_for_url(base_url)
or "chat_completions",
Expand Down
33 changes: 32 additions & 1 deletion model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import logging
import threading
import time
from typing import Dict, Any, List, Optional, Tuple
from typing import Dict, Any, List, Optional, Set, Tuple

from tools.registry import discover_builtin_tools, registry
from toolsets import resolve_toolset, validate_toolset
Expand Down Expand Up @@ -252,6 +252,7 @@ def _run_in_worker():
# inner check_fn TTL cache in registry.py handles environment drift (Docker
# daemon start/stop, env var changes, etc.) on a 30 s horizon.
_tool_defs_cache: Dict[tuple, List[Dict[str, Any]]] = {}
_VISIBLE_TOOLS_ENV_NAMES = ("HERMES_TUI_VISIBLE_TOOLS", "HERMES_VISIBLE_TOOLS")


def _clear_tool_defs_cache() -> None:
Expand All @@ -261,6 +262,24 @@ def _clear_tool_defs_cache() -> None:
_tool_defs_cache.clear()


def _visible_tools_env_fingerprint() -> Tuple[Tuple[str, str], ...]:
return tuple((name, os.environ.get(name, "")) for name in _VISIBLE_TOOLS_ENV_NAMES)


def _get_visible_tool_filter() -> Tuple[Optional[Set[str]], Optional[str]]:
for env_name in _VISIBLE_TOOLS_ENV_NAMES:
raw = os.environ.get(env_name)
if not raw:
continue
names = {
part.strip()
for part in re.split(r"[\s,]+", raw)
if part.strip()
}
return names, env_name
return None, None


def get_tool_definitions(
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
Expand Down Expand Up @@ -301,6 +320,7 @@ def get_tool_definitions(
registry._generation,
cfg_fp,
bool(os.environ.get("HERMES_KANBAN_TASK")),
_visible_tools_env_fingerprint(),
)
cached = _tool_defs_cache.get(cache_key)
if cached is not None:
Expand Down Expand Up @@ -388,6 +408,17 @@ def _compute_tool_definitions(
# needed; plugins respect enabled_toolsets / disabled_toolsets like any
# other toolset.

visible_tools, visible_source = _get_visible_tool_filter()
if visible_tools is not None:
before_count = len(tools_to_include)
tools_to_include.intersection_update(visible_tools)
if not quiet_mode:
hidden_count = before_count - len(tools_to_include)
print(
f"🔎 Visible tool filter '{visible_source}': "
f"showing {len(tools_to_include)} tools, hiding {hidden_count}"
)

# Ask the registry for schemas (only returns tools whose check_fn passes)
filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode)

Expand Down
149 changes: 149 additions & 0 deletions tests/test_lazy_tool_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import json

import model_tools
from tools.lazy_tool_loader import TOOL_PACKS, ToolPack, load_tool_pack_for_agent
from tools.registry import registry


def _dummy_handler(args, **kwargs):
return "{}"


def _make_schema(name: str):
return {
"name": name,
"description": f"{name} test tool",
"parameters": {"type": "object", "properties": {}},
}


def test_visible_tools_env_filters_schemas_without_deregistering(monkeypatch):
shown = "test_visible_tools_alpha"
hidden = "test_visible_tools_beta"
try:
registry.register(
name=shown,
toolset="test-visible-tools",
schema=_make_schema(shown),
handler=_dummy_handler,
)
registry.register(
name=hidden,
toolset="test-visible-tools",
schema=_make_schema(hidden),
handler=_dummy_handler,
)
model_tools._clear_tool_defs_cache()
monkeypatch.delenv("HERMES_TUI_VISIBLE_TOOLS", raising=False)
monkeypatch.setenv("HERMES_VISIBLE_TOOLS", shown)

definitions = model_tools.get_tool_definitions(
enabled_toolsets=["test-visible-tools"],
quiet_mode=True,
)

assert [definition["function"]["name"] for definition in definitions] == [shown]
assert registry.get_entry(hidden) is not None
finally:
registry.deregister(shown)
registry.deregister(hidden)
model_tools._clear_tool_defs_cache()


def test_tui_visible_tools_env_takes_precedence(monkeypatch):
global_tool = "test_visible_tools_global"
tui_tool = "test_visible_tools_tui"
try:
registry.register(
name=global_tool,
toolset="test-visible-tools-precedence",
schema=_make_schema(global_tool),
handler=_dummy_handler,
)
registry.register(
name=tui_tool,
toolset="test-visible-tools-precedence",
schema=_make_schema(tui_tool),
handler=_dummy_handler,
)
model_tools._clear_tool_defs_cache()
monkeypatch.setenv("HERMES_VISIBLE_TOOLS", global_tool)
monkeypatch.setenv("HERMES_TUI_VISIBLE_TOOLS", tui_tool)

definitions = model_tools.get_tool_definitions(
enabled_toolsets=["test-visible-tools-precedence"],
quiet_mode=True,
)

assert [definition["function"]["name"] for definition in definitions] == [tui_tool]
finally:
registry.deregister(global_tool)
registry.deregister(tui_tool)
model_tools._clear_tool_defs_cache()


def test_load_tool_pack_adds_registered_schemas_to_agent():
tool_name = "mcp_botparlor_get_avatar_inventory"

class Agent:
def __init__(self):
self.tools = []
self.valid_tool_names = {"load_tool_pack"}

try:
registry.register(
name=tool_name,
toolset="mcp-botparlor",
schema=_make_schema(tool_name),
handler=_dummy_handler,
)

agent = Agent()
result = json.loads(load_tool_pack_for_agent(agent, "avatar"))

assert result["success"] is True
assert tool_name in result["loaded"]
assert tool_name in agent.valid_tool_names
assert any(
definition["function"]["name"] == tool_name
for definition in agent.tools
)
assert "mcp_botparlor_create_avatar" in result["unavailable"]
assert result["suggested_skills"] == ["botparlor-avatar-maintenance"]
finally:
registry.deregister(tool_name)


def test_assistant_pack_returns_skill_hints_and_loads_available_tools():
tool_name = "test_assistant_pack_tool"
pack_name = "test_assistant_pack"

class Agent:
def __init__(self):
self.tools = []
self.valid_tool_names = {"load_tool_pack"}

try:
TOOL_PACKS[pack_name] = ToolPack(
description="Assistant pack test.",
tools=[tool_name],
suggested_skills=["codex", "test-driven-development"],
)
registry.register(
name=tool_name,
toolset="test-assistant-pack",
schema=_make_schema(tool_name),
handler=_dummy_handler,
)

agent = Agent()
result = json.loads(load_tool_pack_for_agent(agent, pack_name))

assert result["success"] is True
assert tool_name in result["loaded"]
assert tool_name in agent.valid_tool_names
assert "codex" in result["suggested_skills"]
assert "test-driven-development" in result["suggested_skills"]
finally:
registry.deregister(tool_name)
TOOL_PACKS.pop(pack_name, None)
37 changes: 37 additions & 0 deletions tests/tools/test_mcp_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,43 @@ def _interrupting_run(coro_or_factory, timeout=30):


class TestRunOnMCPLoopInterrupts:
def test_timeout_cancels_waiting_mcp_call(self):
import tools.mcp_tool as mcp_mod

loop = asyncio.new_event_loop()
thread = threading.Thread(target=loop.run_forever, daemon=True)
thread.start()

cancelled = threading.Event()

async def _slow_call():
try:
await asyncio.sleep(5)
return "done"
except asyncio.CancelledError:
cancelled.set()
raise

old_loop = mcp_mod._mcp_loop
old_thread = mcp_mod._mcp_thread
mcp_mod._mcp_loop = loop
mcp_mod._mcp_thread = thread

try:
with pytest.raises(TimeoutError, match="configured timeout: 0.2s"):
mcp_mod._run_on_mcp_loop(_slow_call(), timeout=0.2)

deadline = time.time() + 2
while time.time() < deadline and not cancelled.is_set():
time.sleep(0.05)
assert cancelled.is_set()
finally:
loop.call_soon_threadsafe(loop.stop)
thread.join(timeout=2)
loop.close()
mcp_mod._mcp_loop = old_loop
mcp_mod._mcp_thread = old_thread

def test_interrupt_cancels_waiting_mcp_call(self):
import tools.mcp_tool as mcp_mod
from tools.interrupt import set_interrupt
Expand Down
Loading