"""Central registry for all hermes-agent tools. Each tool file calls ``registry.register()`` at module level to declare its schema, handler, toolset membership, and availability check. ``model_tools.py`` queries the registry instead of maintaining its own parallel data structures. Import chain (circular-import safe): tools/registry.py (no imports from model_tools or tool files) ^ tools/*.py (import from tools.registry at module level) ^ model_tools.py (imports tools.registry + all tool modules) ^ run_agent.py, cli.py, batch_runner.py, etc. """ import ast import importlib import json import logging import sys import threading import time from pathlib import Path from typing import Callable, Dict, List, Optional, Set logger = logging.getLogger(__name__) def _is_registry_register_call(node: ast.AST) -> bool: """Return True when *node* is a ``registry.register(...)`` call expression.""" if not isinstance(node, ast.Expr) or not isinstance(node.value, ast.Call): return False func = node.value.func return ( isinstance(func, ast.Attribute) and func.attr == "register" and isinstance(func.value, ast.Name) and func.value.id == "registry" ) def _module_registers_tools(module_path: Path) -> bool: """Return True when the module contains a top-level ``registry.register(...)`` call. Only inspects module-body statements so that helper modules which happen to call ``registry.register()`` inside a function are not picked up. """ try: source = module_path.read_text(encoding="utf-8") tree = ast.parse(source, filename=str(module_path)) except (OSError, SyntaxError): return False return any(_is_registry_register_call(stmt) for stmt in tree.body) def discover_builtin_tools(tools_dir: Optional[Path] = None) -> List[str]: """Import built-in self-registering tool modules and return their module names.""" tools_path = Path(tools_dir) if tools_dir is not None else Path(__file__).resolve().parent module_names = [ f"tools.{path.stem}" for path in sorted(tools_path.glob("*.py")) if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"} and _module_registers_tools(path) ] imported: List[str] = [] for mod_name in module_names: try: importlib.import_module(mod_name) imported.append(mod_name) except Exception as e: logger.warning("Could not import tool module %s: %s", mod_name, e) return imported class ToolEntry: """Metadata for a single registered tool.""" __slots__ = ( "name", "toolset", "schema", "handler", "check_fn", "requires_env", "is_async", "description", "emoji", "max_result_size_chars", "dynamic_schema_overrides", ) def __init__(self, name, toolset, schema, handler, check_fn, requires_env, is_async, description, emoji, max_result_size_chars=None, dynamic_schema_overrides=None): self.name = name self.toolset = toolset self.schema = schema self.handler = handler self.check_fn = check_fn self.requires_env = requires_env self.is_async = is_async self.description = description self.emoji = emoji self.max_result_size_chars = max_result_size_chars # Optional zero-arg callable returning a dict of schema overrides # applied at get_definitions() time. Use for fields that depend on # runtime config (e.g. delegate_task's description must reflect the # user's current delegation.max_concurrent_children / max_spawn_depth # so the model isn't told the wrong limits). The callable is invoked # on every get_definitions() call; results are merged shallow on top # of the base schema before the {"type": "function", ...} wrap. self.dynamic_schema_overrides = dynamic_schema_overrides # --------------------------------------------------------------------------- # check_fn TTL cache # # check_fn callables like tools/terminal_tool.check_terminal_requirements # probe external state (Docker daemon, Modal SDK install, playwright binary # availability). For a long-lived CLI or gateway process, calling them on # every get_definitions() is pure waste — external state changes on human # timescales. Cache results for ~30 s so env-var flips via ``hermes tools`` # or live credential file changes propagate within a turn or two without # requiring any explicit invalidation. # # Transient-failure suppression (issue #21658 / #5304): these probes can flap. # A single ``subprocess.run([docker, "version"], timeout=5)`` that times out # under load returns False for one call, which would silently strip the entire # terminal+file toolset from whatever agent is being built at that instant — # most visibly a delegate_task subagent, which then reports "Tool read_file # does not exist". To absorb such flakes WITHOUT pinning a permanently-stale # "available" verdict, we remember the last time each check returned True and, # when a fresh probe fails within a short grace window of that last success, # we serve the last-good True instead of caching the failure. A failure that # persists past the grace window is honored normally, so a backend that really # went down stops advertising its tools. # --------------------------------------------------------------------------- _CHECK_FN_TTL_SECONDS = 30.0 # How long after a successful check a subsequent transient failure is treated # as a flake (last-good True is served) rather than a real outage. Kept short # so a genuinely-down backend is reflected within a couple of turns. _CHECK_FN_FAILURE_GRACE_SECONDS = 60.0 _check_fn_cache: Dict[Callable, tuple[float, bool]] = {} # Monotonic timestamp of the most recent True result per check_fn. _check_fn_last_good: Dict[Callable, float] = {} _check_fn_cache_lock = threading.Lock() def _check_fn_cached(fn: Callable) -> bool: """Return bool(fn()), TTL-cached across calls. Exceptions are swallowed as False. A transient False/exception within ``_CHECK_FN_FAILURE_GRACE_SECONDS`` of the last True is suppressed (the last-good True is returned and the failure is NOT cached, so the next call re-probes) to keep flaky external checks (Docker daemon busy, socket contention, probe timeout) from silently stripping tools mid-session. """ now = time.monotonic() with _check_fn_cache_lock: cached = _check_fn_cache.get(fn) if cached is not None: ts, value = cached if now - ts < _CHECK_FN_TTL_SECONDS: return value raised = False try: value = bool(fn()) except Exception: value = False raised = True with _check_fn_cache_lock: if value: _check_fn_last_good[fn] = now _check_fn_cache[fn] = (now, True) return True last_good = _check_fn_last_good.get(fn) if last_good is not None and now - last_good < _CHECK_FN_FAILURE_GRACE_SECONDS: # Recent success → treat this failure as a flake. Serve last-good # True and do NOT cache the failure, so the next call re-probes # rather than pinning a stale verdict for the full TTL. logger.warning( "check_fn %s failed (%s) within %.0fs of last success; " "treating as transient and keeping tool(s) available", getattr(fn, "__qualname__", fn), "raised" if raised else "returned False", _CHECK_FN_FAILURE_GRACE_SECONDS, ) return True # No recent success (or grace expired) — honor the failure. Log it so # silent tool loss in quiet mode (subagents) is diagnosable. logger.warning( "check_fn %s %s; dependent tools will be unavailable this turn", getattr(fn, "__qualname__", fn), "raised" if raised else "returned False", ) _check_fn_cache[fn] = (now, False) return False def invalidate_check_fn_cache() -> None: """Drop all cached ``check_fn`` results. Call after config changes that affect tool availability (e.g. ``hermes tools enable``).""" with _check_fn_cache_lock: _check_fn_cache.clear() _check_fn_last_good.clear() class ToolRegistry: """Singleton registry that collects tool schemas + handlers from tool files.""" def __init__(self): self._tools: Dict[str, ToolEntry] = {} # Durable map: plugin module namespace (handler.__globals__["__name__"]) # -> operator opt-in for built-in override. Populated at plugin load and # never cleared, so a plugin's override authorization is bound to the # code that defined the handler, independent of WHEN the register() call # happens (sync during load, or a delayed/threaded callback afterwards). self._plugin_override_policy: Dict[str, bool] = {} self._toolset_checks: Dict[str, Callable] = {} self._toolset_aliases: Dict[str, str] = {} # MCP dynamic refresh can mutate the registry while other threads are # reading tool metadata, so keep mutations serialized and readers on # stable snapshots. self._lock = threading.RLock() # Monotonically-increasing generation counter. Bumped on every # mutation (register / deregister / register_toolset_alias / MCP # refresh). External callers (e.g. get_tool_definitions) can memoize # against it: a cache entry keyed on the generation is valid for as # long as the generation hasn't changed. self._generation: int = 0 def _snapshot_state(self) -> tuple[List[ToolEntry], Dict[str, Callable]]: """Return a coherent snapshot of registry entries and toolset checks.""" with self._lock: return list(self._tools.values()), dict(self._toolset_checks) def _snapshot_entries(self) -> List[ToolEntry]: """Return a stable snapshot of registered tool entries.""" return self._snapshot_state()[0] def _toolset_has_exposable_tools( self, toolset: str, entries: List[ToolEntry], ) -> bool: """Return True when at least one tool in *toolset* would be exposed. Mirrors :meth:`get_tool_definitions` per-tool filtering so doctor, banners, and other toolset-level surfaces agree with runtime exposure. Mixed toolsets (e.g. ``terminal`` plus desktop-only ``read_terminal``) must not be gated solely by the first registered ``check_fn``. """ check_results: Dict[Callable, bool] = {} for entry in entries: if entry.toolset != toolset: continue if not entry.check_fn: return True if entry.check_fn not in check_results: check_results[entry.check_fn] = _check_fn_cached(entry.check_fn) if check_results[entry.check_fn]: return True return False def get_entry(self, name: str) -> Optional[ToolEntry]: """Return a registered tool entry by name, or None.""" with self._lock: return self._tools.get(name) def get_registered_toolset_names(self) -> List[str]: """Return sorted unique toolset names present in the registry.""" return sorted({entry.toolset for entry in self._snapshot_entries()}) def get_tool_names_for_toolset(self, toolset: str) -> List[str]: """Return sorted tool names registered under a given toolset.""" return sorted( entry.name for entry in self._snapshot_entries() if entry.toolset == toolset ) def register_toolset_alias(self, alias: str, toolset: str) -> None: """Register an explicit alias for a canonical toolset name.""" with self._lock: existing = self._toolset_aliases.get(alias) if existing and existing != toolset: logger.warning( "Toolset alias collision: '%s' (%s) overwritten by %s", alias, existing, toolset, ) self._toolset_aliases[alias] = toolset self._generation += 1 def get_registered_toolset_aliases(self) -> Dict[str, str]: """Return a snapshot of ``{alias: canonical_toolset}`` mappings.""" with self._lock: return dict(self._toolset_aliases) def get_toolset_alias_target(self, alias: str) -> Optional[str]: """Return the canonical toolset name for an alias, or None.""" with self._lock: return self._toolset_aliases.get(alias) # ------------------------------------------------------------------ # Registration # ------------------------------------------------------------------ def register_plugin_override_policy(self, module_namespace: str, allowed: bool) -> None: """Bind a plugin module namespace to its operator opt-in for built-in override. Called once per plugin at load time. Durable: never cleared, so later (even threaded/delayed) register() calls from that module are still gated by the same policy. """ with self._lock: self._plugin_override_policy[module_namespace] = bool(allowed) def _plugin_owner_of(self, handler: Callable) -> Optional[str]: """Return the plugin module namespace that defined *handler*, or None if it was not defined in a loaded plugin module. Authorization is bound to where the handler was DEFINED (``handler.__globals__["__name__"]``), which is fixed at definition time and cannot drift with the call site, thread, or timing. Lambdas and nested functions inherit the defining module's globals, so a plugin cannot launder an override through a callback. Built-in/MCP handlers live outside the plugin namespace and return None (unchanged behavior). """ try: mod = handler.__globals__.get("__name__", "") # type: ignore[attr-defined] except AttributeError: return None if mod in self._plugin_override_policy: return mod # Also gate plugin modules currently loading but not yet policy-recorded # (defensive: a handler defined in the plugin namespace is plugin code). if isinstance(mod, str) and mod.startswith("hermes_plugins."): return mod return None @staticmethod def _caller_module() -> str: """Best-effort module name of whoever called the registry method that invoked this helper (two frames up: this helper, then the registry method itself, then the actual caller). ``deregister()`` takes only a tool name — unlike ``register()`` it has no handler argument to bind authorization to via ``_plugin_owner_of``. Frame inspection is the only way to know who is asking. """ try: frame = sys._getframe(2) return frame.f_globals.get("__name__", "") or "" except Exception: return "" def register( self, name: str, toolset: str, schema: dict, handler: Callable, check_fn: Callable = None, requires_env: list = None, is_async: bool = False, description: str = "", emoji: str = "", max_result_size_chars: int | float | None = None, dynamic_schema_overrides: Callable = None, override: bool = False, ): """Register a tool. Called at module-import time by each tool file. ``override=True`` is an explicit opt-in for plugins that intend to replace an existing built-in tool implementation (e.g. swap the default browser tool for a headed-Chrome CDP backend). Without it, registrations that would shadow an existing tool from a different toolset are rejected to prevent accidental overwrites. """ with self._lock: existing = self._tools.get(name) if existing and existing.toolset != toolset: # Allow MCP-to-MCP overwrites (legitimate: server refresh, # or two MCP servers with overlapping tool names). both_mcp = ( existing.toolset.startswith("mcp-") and toolset.startswith("mcp-") ) if both_mcp: logger.debug( "Tool '%s': MCP toolset '%s' overwriting MCP toolset '%s'", name, toolset, existing.toolset, ) elif override: _owner = self._plugin_owner_of(handler) if _owner is not None and not self._plugin_override_policy.get(_owner, False): logger.error( "Tool registration REJECTED: plugin %r attempted to " "override built-in tool %r (existing toolset %r) without " "operator opt-in. Set " "plugins.entries..allow_tool_override: true " "in config.yaml to allow it.", _owner, name, existing.toolset, ) raise PermissionError( f"Plugin module {_owner!r} cannot override built-in " f"tool {name!r} without operator opt-in " f"(allow_tool_override)." ) # Explicit opt-in (or non-plugin caller): replace the tool. # Logged at INFO so the override is auditable in agent.log. logger.info( "Tool '%s': toolset '%s' overriding existing toolset '%s' " "(override=True opt-in)", name, toolset, existing.toolset, ) else: # Reject shadowing — prevent plugins/MCP from overwriting # built-in tools or vice versa. logger.error( "Tool registration REJECTED: '%s' (toolset '%s') would " "shadow existing tool from toolset '%s'. Pass " "override=True to register() if the replacement is " "intentional, or deregister the existing tool first.", name, toolset, existing.toolset, ) return self._tools[name] = ToolEntry( name=name, toolset=toolset, schema=schema, handler=handler, check_fn=check_fn, requires_env=requires_env or [], is_async=is_async, description=description or schema.get("description", ""), emoji=emoji, max_result_size_chars=max_result_size_chars, dynamic_schema_overrides=dynamic_schema_overrides, ) # Availability is now derived per-tool (_toolset_has_exposable_tools), # so this map no longer gates a toolset. It is still consumed by # get_toolset_requirements -> TOOLSET_REQUIREMENTS["check_fn"], which # banner.py reads (presence only, never called) to classify an # already-unavailable toolset as lazy-init vs disabled. Keep the # write path for that classification. if check_fn and toolset not in self._toolset_checks: self._toolset_checks[toolset] = check_fn self._generation += 1 def deregister(self, name: str) -> None: """Remove a tool from the registry. Also cleans up the toolset check if no other tools remain in the same toolset. Used by MCP dynamic tool discovery to nuke-and-repave when a server sends ``notifications/tools/list_changed``. Gated by the same operator opt-in policy ``register(override=True)`` enforces. Without this, a plugin could bypass that gate entirely by deregistering a tool it doesn't own and then calling plain ``register()`` over the now-empty slot — ``register()`` only runs its override check when an ``existing`` entry is present, so removing it first skips the check altogether. MCP toolsets (``mcp-*``) are exempt: dynamic tool discovery legitimately nukes-and-repaves its own tools on every refresh and has no plugin-override concept. """ with self._lock: entry = self._tools.get(name) if entry is None: return if not entry.toolset.startswith("mcp-"): caller_mod = self._caller_module() owner = self._plugin_owner_of(entry.handler) # Ownership check: bind to the plugin package root # (``hermes_plugins.{name}``), not the exact module string. # A handler defined in ``hermes_plugins.pkg.handlers`` is # still owned by the ``hermes_plugins.pkg`` package — exact # string equality would wrongly block root-module cleanup code # from removing tools registered by a submodule of the same # plugin (egilewski review on #55840). caller_root = ".".join(caller_mod.split(".")[:2]) owner_root = ".".join(owner.split(".")[:2]) if owner else "" same_plugin = bool(owner and caller_root == owner_root) if ( caller_mod.startswith("hermes_plugins.") and not same_plugin and not self._plugin_override_policy.get(caller_root, False) ): logger.error( "Tool deregistration REJECTED: plugin %r attempted to " "remove tool %r (toolset %r) it does not own, without " "operator opt-in. Set " "plugins.entries.%s.allow_tool_override: true in " "config.yaml to allow it.", caller_mod, name, entry.toolset, caller_mod, ) raise PermissionError( f"Plugin module {caller_mod!r} cannot deregister tool " f"{name!r} (toolset {entry.toolset!r}) without operator " f"opt-in (allow_tool_override)." ) del self._tools[name] # Drop the toolset check and aliases if this was the last tool in # that toolset. toolset_still_exists = any( e.toolset == entry.toolset for e in self._tools.values() ) if not toolset_still_exists: self._toolset_checks.pop(entry.toolset, None) self._toolset_aliases = { alias: target for alias, target in self._toolset_aliases.items() if target != entry.toolset } self._generation += 1 logger.debug("Deregistered tool: %s", name) # ------------------------------------------------------------------ # Schema retrieval # ------------------------------------------------------------------ def get_definitions(self, tool_names: Set[str], quiet: bool = False) -> List[dict]: """Return OpenAI-format tool schemas for the requested tool names. Only tools whose ``check_fn()`` returns True (or have no check_fn) are included. ``check_fn()`` results are cached for ~30 s via :func:`_check_fn_cached` to amortize repeat probes (check_terminal_ requirements probes modal/docker, browser checks probe playwright, etc.); TTL chosen so env-var changes (``hermes tools enable foo``) still take effect in near-real-time without forcing a full cache flush on every call. """ result = [] # Per-call cache on top of the 30 s TTL — handles repeat probes of the # same check_fn within one definitions pass without re-reading the # TTL clock. check_results: Dict[Callable, bool] = {} entries_by_name = {entry.name: entry for entry in self._snapshot_entries()} for name in sorted(tool_names): entry = entries_by_name.get(name) if not entry: continue if entry.check_fn: if entry.check_fn not in check_results: check_results[entry.check_fn] = _check_fn_cached(entry.check_fn) if not check_results[entry.check_fn]: if not quiet: logger.debug("Tool %s unavailable (check failed)", name) continue # Ensure schema always has a "name" field — use entry.name as fallback schema_with_name = {**entry.schema, "name": entry.name} # Apply runtime-dynamic overrides (e.g. delegate_task description # depends on current delegation.max_concurrent_children / # max_spawn_depth). Caller side (model_tools.get_tool_definitions) # already keys its memo on config.yaml mtime + size, so changes # to delegation.* in config invalidate the cache automatically. if entry.dynamic_schema_overrides is not None: try: overrides = entry.dynamic_schema_overrides() if isinstance(overrides, dict): schema_with_name.update(overrides) except Exception as exc: logger.warning( "dynamic_schema_overrides for tool %s raised %s; " "using static schema", name, exc, ) result.append({"type": "function", "function": schema_with_name}) return result # ------------------------------------------------------------------ # Dispatch # ------------------------------------------------------------------ @staticmethod def _normalize_handler_result(name: str, result): """Enforce the result shapes supported by the agent tool pipeline. Normal tool results are strings. The sole structured exception is the multimodal envelope consumed by the agent executor. Returning every other value as a string error keeps logging, hooks, budgeting, and persistence from receiving values they cannot safely slice or size. """ if isinstance(result, str): return result if ( isinstance(result, dict) and result.get("_multimodal") is True and isinstance(result.get("content"), list) ): return result result_type = type(result).__name__ logger.error( "Tool %s handler returned unsupported result type: %s", name, result_type, ) return json.dumps({ "error": f"Tool handler returned unsupported result type: {result_type}", "error_type": "tool_result_contract", "tool": name, "result_type": result_type, }, ensure_ascii=False) def dispatch(self, name: str, args: dict, **kwargs) -> str | dict: """Execute a tool handler by name. * Async handlers are bridged automatically via ``_run_async()``. * Handler results are normalized to a string or supported multimodal envelope before leaving the registry. * All exceptions are caught and returned as ``{"error": "..."}`` for consistent error format. """ entry = self.get_entry(name) if not entry: return json.dumps({"error": f"Unknown tool: {name}"}) try: if entry.is_async: from model_tools import _run_async result = _run_async(entry.handler(args, **kwargs)) else: result = entry.handler(args, **kwargs) return self._normalize_handler_result(name, result) except Exception as e: logger.exception("Tool %s dispatch error: %s", name, e) # Route through the sanitizer so framing tokens / CDATA / fences # in exception strings don't reach the model as structural noise. # See model_tools._sanitize_tool_error for rationale. raw = f"Tool execution failed: {type(e).__name__}: {e}" try: from model_tools import _sanitize_tool_error sanitized = _sanitize_tool_error(raw) except Exception: sanitized = raw # defensive: never let the sanitizer block error propagation return json.dumps({"error": sanitized}) # ------------------------------------------------------------------ # Query helpers (replace redundant dicts in model_tools.py) # ------------------------------------------------------------------ def get_max_result_size(self, name: str, default: int | float | None = None) -> int | float: """Return per-tool max result size, or *default* (or global default).""" entry = self.get_entry(name) if entry and entry.max_result_size_chars is not None: return entry.max_result_size_chars if default is not None: return default from tools.budget_config import DEFAULT_RESULT_SIZE_CHARS return DEFAULT_RESULT_SIZE_CHARS def get_all_tool_names(self) -> List[str]: """Return sorted list of all registered tool names.""" return sorted(entry.name for entry in self._snapshot_entries()) def get_schema(self, name: str) -> Optional[dict]: """Return a tool's raw schema dict, bypassing check_fn filtering. Useful for token estimation and introspection where availability doesn't matter — only the schema content does. """ entry = self.get_entry(name) return entry.schema if entry else None def get_toolset_for_tool(self, name: str) -> Optional[str]: """Return the toolset a tool belongs to, or None.""" entry = self.get_entry(name) return entry.toolset if entry else None def get_emoji(self, name: str, default: str = "⚡") -> str: """Return the emoji for a tool, or *default* if unset.""" entry = self.get_entry(name) return (entry.emoji if entry and entry.emoji else default) def get_tool_to_toolset_map(self) -> Dict[str, str]: """Return ``{tool_name: toolset_name}`` for every registered tool.""" return {entry.name: entry.toolset for entry in self._snapshot_entries()} def is_toolset_available(self, toolset: str) -> bool: """Check if a toolset has at least one exposable tool. Returns False (rather than crashing) when a per-tool check raises an unexpected exception (e.g. network error, missing import, bad config). """ entries, _ = self._snapshot_state() return self._toolset_has_exposable_tools(toolset, entries) def check_toolset_requirements(self) -> Dict[str, bool]: """Return ``{toolset: available_bool}`` for every toolset.""" entries, _ = self._snapshot_state() toolsets = sorted({entry.toolset for entry in entries}) return { toolset: self._toolset_has_exposable_tools(toolset, entries) for toolset in toolsets } def get_available_toolsets(self) -> Dict[str, dict]: """Return toolset metadata for UI display.""" toolsets: Dict[str, dict] = {} entries, _ = self._snapshot_state() for entry in entries: ts = entry.toolset if ts not in toolsets: toolsets[ts] = { "available": self._toolset_has_exposable_tools(ts, entries), "tools": [], "description": "", "requirements": [], } toolsets[ts]["tools"].append(entry.name) if entry.requires_env: for env in entry.requires_env: if env not in toolsets[ts]["requirements"]: toolsets[ts]["requirements"].append(env) return toolsets def get_toolset_requirements(self) -> Dict[str, dict]: """Build a TOOLSET_REQUIREMENTS-compatible dict for backward compat.""" result: Dict[str, dict] = {} entries, toolset_checks = self._snapshot_state() for entry in entries: ts = entry.toolset if ts not in result: result[ts] = { "name": ts, "env_vars": [], "check_fn": toolset_checks.get(ts), "setup_url": None, "tools": [], } if entry.name not in result[ts]["tools"]: result[ts]["tools"].append(entry.name) for env in entry.requires_env: if env not in result[ts]["env_vars"]: result[ts]["env_vars"].append(env) return result def check_tool_availability(self, quiet: bool = False): """Return (available_toolsets, unavailable_info) like the old function.""" available = [] unavailable = [] entries, _ = self._snapshot_state() for ts in sorted({entry.toolset for entry in entries}): ts_entries = [entry for entry in entries if entry.toolset == ts] if self._toolset_has_exposable_tools(ts, entries): available.append(ts) else: unavailable.append({ "name": ts, "env_vars": ts_entries[0].requires_env if ts_entries else [], "tools": [entry.name for entry in ts_entries], }) return available, unavailable # Module-level singleton registry = ToolRegistry() # --------------------------------------------------------------------------- # Helpers for tool response serialization # --------------------------------------------------------------------------- # Every tool handler must return a JSON string. These helpers eliminate the # boilerplate ``json.dumps({"error": msg}, ensure_ascii=False)`` that appears # hundreds of times across tool files. # # Usage: # from tools.registry import registry, tool_error, tool_result # # return tool_error("something went wrong") # return tool_error("not found", code=404) # return tool_result(success=True, data=payload) # return tool_result(items) # pass a dict directly def tool_error(message, **extra) -> str: """Return a JSON error string for tool handlers. >>> tool_error("file not found") '{"error": "file not found"}' >>> tool_error("bad input", success=False) '{"error": "bad input", "success": false}' """ result = {"error": str(message)} if extra: result.update(extra) return json.dumps(result, ensure_ascii=False) def tool_result(data=None, **kwargs) -> str: """Return a JSON result string for tool handlers. Accepts a dict positional arg *or* keyword arguments (not both): >>> tool_result(success=True, count=42) '{"success": true, "count": 42}' >>> tool_result({"key": "value"}) '{"key": "value"}' """ if data is not None: return json.dumps(data, ensure_ascii=False) return json.dumps(kwargs, ensure_ascii=False)