Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -2410,6 +2410,18 @@
# See tools/tool_search.py for full design notes and the
# openclaw-tool-search-report PDF in this PR for the rationale.
"tools": {
# Tool schema loading strategy.
# "eager" (default) — send full JSON schemas for every tool on every
# API call. Maximises tool-call accuracy; costs ~3,500-5,000
# tokens per call regardless of whether the conversation uses tools.
# "lazy" — send compact summaries (name + one-line description) for
# every tool, plus a `request_tool_schema` bridge tool. The model
# calls request_tool_schema to load a tool's full parameter schema
# on demand, then invokes the real tool. Saves tokens on
# conversational turns and dramatically reduces prompt-processing
# overhead on local models, at the cost of one extra round-trip
# when a tool IS needed.
"loading": "eager",
"tool_search": {
# Tiered disclosure: any deferrable (MCP/plugin) tool activates
# the bridge; the listing then scales with catalog size.
Expand Down
100 changes: 97 additions & 3 deletions model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ def get_tool_definitions(
disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False,
skip_tool_search_assembly: bool = False,
skip_lazy_compaction: bool = False,
) -> List[Dict[str, Any]]:
"""
Get tool definitions for model API calls with toolset-based filtering.
Expand Down Expand Up @@ -354,6 +355,7 @@ def get_tool_definitions(
bool(skip_tool_search_assembly),
_is_delegated_child_context(),
_is_dispatcher_owned_worker(),
bool(skip_lazy_compaction),
profile_scope,
)
cached = _tool_defs_cache.get(cache_key) if cache_key is not None else None
Expand All @@ -367,7 +369,8 @@ def get_tool_definitions(
return list(cached)

result = _compute_tool_definitions(enabled_toolsets, disabled_toolsets, quiet_mode,
skip_tool_search_assembly=skip_tool_search_assembly)
skip_tool_search_assembly=skip_tool_search_assembly,
skip_lazy_compaction=skip_lazy_compaction)
if quiet_mode and cache_key is not None:
# Cache the freshly-computed list, but hand callers a shallow copy so
# downstream mutations (e.g. run_agent appending memory/LCM tool
Expand All @@ -388,13 +391,29 @@ def get_tool_definitions(
return result


def _is_lazy_loading_enabled() -> bool:
"""Return True when ``tools.loading: lazy`` is configured.

Cached at module level — the config value is read once per process
and doesn't change at runtime. False on any error (import, missing
config, etc.) so the default eager path is always safe.
"""
try:
from tools.lazy_tool_loading import load_loading_mode
return load_loading_mode() == "lazy"
except Exception:
return False


def _compute_tool_definitions(
enabled_toolsets: Optional[List[str]] = None,
disabled_toolsets: Optional[List[str]] = None,
quiet_mode: bool = False,
skip_tool_search_assembly: bool = False,
skip_lazy_compaction: bool = False,
) -> List[Dict[str, Any]]:
"""Uncached implementation of :func:`get_tool_definitions`."""
global _last_resolved_tool_names
# Determine which tool names the caller wants
tools_to_include: set = set()

Expand Down Expand Up @@ -480,7 +499,14 @@ def _compute_tool_definitions(
# needed; plugins respect enabled_toolsets / disabled_toolsets like any
# other toolset.

# Ask the registry for schemas (only returns tools whose check_fn passes)
# Ask the registry for schemas (only returns tools whose check_fn passes).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This return skips the current dynamic-schema path below it. In particular, current main rebuilds execute_code with only the enabled sandbox tools at model_tools.py:471-478; request_tool_schema later reads the static registry entry, so lazy mode can return a schema that advertises disabled tools. Please route eager and on-demand schema construction through one canonical builder.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d105def. The lazy early return is removed entirely — both eager and lazy modes now flow through the same canonical path (registry.get_definitions → execute_code sandbox scoping → discord intent filtering → browser_navigate cross-ref stripping → sanitization). Lazy compaction runs as a post-processing step after all dynamic-schema work. Added a skip_lazy_compaction parameter so request_tool_schema can retrieve the full dynamic schemas via get_tool_definitions(skip_lazy_compaction=True) instead of reading the static registry entry.

# Both eager and lazy modes build the full, dynamically-correct schemas
# first — lazy mode then strips parameters from the model-facing list
# but keeps the full schemas for on-demand retrieval via
# request_tool_schema. This ensures dynamic-schema processing
# (execute_code sandbox scoping, discord intent filtering, browser_navigate
# cross-ref stripping) runs identically in both modes.
_lazy_mode = _is_lazy_loading_enabled()
filtered_tools = registry.get_definitions(tools_to_include, quiet=quiet_mode)

# The set of tool names that actually passed check_fn filtering.
Expand Down Expand Up @@ -558,7 +584,6 @@ def _compute_tool_definitions(
else:
print("🛠️ No tools selected (all filtered out or unavailable)")

global _last_resolved_tool_names
_last_resolved_tool_names = [t["function"]["name"] for t in filtered_tools]

# Sanitize schemas for broad backend compatibility. llama.cpp's
Expand All @@ -573,6 +598,36 @@ def _compute_tool_definitions(
except Exception as e: # pragma: no cover — defensive
logger.warning("Schema sanitization skipped: %s", e)

# ── Lazy loading compaction ──────────────────────────────────────
# In lazy mode, compact the now-dynamically-correct schemas to
# {name, description} summaries and inject the request_tool_schema
# bridge. The full schemas are recoverable on demand via
# request_tool_schema, which calls
# get_tool_definitions(skip_lazy_compaction=True) to rebuild the
# same canonical schema list.
if _lazy_mode and not skip_lazy_compaction:
from tools.lazy_tool_loading import compact_tool_defs, request_tool_schema_tool_def
# Compact the model-facing list. The full, dynamically-correct
# schemas are recoverable on demand via request_tool_schema, which
# calls get_tool_definitions(skip_lazy_compaction=True) to rebuild
# the same canonical schema list.
filtered_tools = compact_tool_defs(filtered_tools)
# Inject the bridge tool.
try:
filtered_tools.append(request_tool_schema_tool_def())
except Exception as _lazy_err:
logger.warning("lazy_tool_loading bridge injection skipped: %s", _lazy_err)
if not quiet_mode:
compact_names = [t["function"]["name"] for t in filtered_tools
if t["function"]["name"] != "request_tool_schema"]
if compact_names:
print(f"🔎 Lazy mode: {len(compact_names)} compact summaries + request_tool_schema bridge")
else:
print("🔎 Lazy mode: no tools selected")
_last_resolved_tool_names = [t["function"]["name"] for t in filtered_tools]
# Lazy mode skips tool_search assembly — everything is already compact.
return filtered_tools

# ── Tool Search (progressive disclosure) ────────────────────────────
# Conditionally replace MCP + plugin (non-core) tools with three bridge
# tools (tool_search / tool_describe / tool_call) when the deferrable
Expand Down Expand Up @@ -1281,6 +1336,45 @@ def _return_bridge_result(result: Any) -> Any:
disabled_toolsets=disabled_toolsets,
)

# ── Lazy loading bridge dispatch ────────────────────────────────
# request_tool_schema is a pure metadata read — handle it inline,
# just like tool_search/tool_describe. No hooks, no middleware, no
# execution — it only returns the full JSON schema for the requested
# tool so the model can call it with correct parameters.
#
# Scope and schema are derived from the same canonical
# get_tool_definitions(skip_tool_search_assembly=True,
# skip_lazy_compaction=True) call that tool_search uses for its
# catalog — never from the process-global _last_resolved_tool_names,
# which can be stale during delegated-child execution.
if function_name == "request_tool_schema":
try:
from tools.lazy_tool_loading import dispatch_request_tool_schema
# Build the full, dynamically-correct tool defs for this
# session's scope. skip_lazy_compaction=True ensures we get
# the full schemas (not compact summaries), and
# skip_tool_search_assembly=True bypasses the bridge-tool
# replacement so we see the real catalog.
full_defs = get_tool_definitions(
enabled_toolsets=enabled_toolsets,
disabled_toolsets=disabled_toolsets,
quiet_mode=True,
skip_tool_search_assembly=True,
skip_lazy_compaction=True,
) or []
scope = {(td.get("function") or {}).get("name") for td in full_defs}
# If enabled_tools was explicitly provided (e.g. by a
# restricted subagent), intersect with it for defense in depth.
if enabled_tools is not None:
scope &= set(enabled_tools)
return dispatch_request_tool_schema(
function_args or {},
tool_names_in_scope=scope,
full_tool_defs=full_defs,
)
except Exception as e:
return json.dumps({"error": f"request_tool_schema failed: {e}"})

_tool_original_args = dict(function_args)
if not skip_tool_request_middleware:
try:
Expand Down
Loading