Files
hermes-agent/tools/mcp_tool_agent.py
teknium1 d956f0ae57 fix: key the tools[] pin by code version; never re-add config-excluded tools
Review follow-up on the byte-identical tools[] pin.

- The pin records the code identity that built it (checkout/build sha, else
  the release version). Written by the same code, every pinned tool that is
  still available keeps its pinned bytes, including tools whose parameters
  are derived per surface (delegate_task, text_to_speech, memory, patch).
  The per-tool "parameters differ -> take current" rule replaced those bytes
  on every surface hop and rewrote the ~44KB pin each time. A pin from other
  code (`hermes update`, legacy name lists) takes the current definitions
  once and is re-pinned.
- A pinned tool this process did not build is carried forward only while
  this agent's toolset selection allows it (enabled minus disabled toolsets
  and role reservations, before check_fn). It must also pass the session
  schema gates on the merged array, so browser_exec never comes back once
  terminal is gone. Client-surface toolsets (desktop_ui, project) still
  carry across hops: no config choice removed them there.
- The rotation compaction child inherits the parent's pin in the publish
  transaction.
- `hermes sessions recover` keeps pin rows in its system_prompts sweep and
  clears dangling pin hashes, as lost-and-found now does too. Profile moves
  carry the pin like the prompt. A continuing session whose pin is missing
  or unreadable (a row swept by an older build) pins the tools it sends on
  that turn, so later hops stay stable.
2026-09-23 15:43:51 -07:00

322 lines
17 KiB
Python

"""Live-agent tool-list maintenance after MCP (re)discovery: refreshing an
AIAgent's tools/tool names, preserving the cached tools[] prefix across rebuilds,
and re-injecting post-build tools."""
import logging
import json
import threading
from typing import Optional
from tools.mcp_tool_common import _core
logger = logging.getLogger("tools.mcp_tool")
# Serializes in-place swaps of ``agent.tools`` / ``agent.valid_tool_names`` by
# the reload RPC, gateway reload and late-binding refresh thread; the run loop
# reads them during tool iteration and must never see a half-updated pair.
_agent_tools_lock = threading.Lock()
def _def_name(tool_def: dict) -> str:
return (tool_def.get("function") or {}).get("name", "")
def _agent_tool_defs(agent) -> list:
return list(getattr(agent, "tools", None) or [])
def agent_tool_names(agent) -> list:
"""Names of ``agent.tools`` in wire order (unnamed entries skipped)."""
return [name for name in map(_def_name, _agent_tool_defs(agent)) if name]
def _resolve_refresh_toolsets(agent, enabled_override, disabled_override):
"""Explicit reloads pass freshly-resolved toolsets (so a server just ENABLED in config is
picked up) and the agent's selection is updated to match; automatic paths pass nothing
and reuse the build-time selection."""
enabled = getattr(agent, "enabled_toolsets", None)
disabled = getattr(agent, "disabled_toolsets", None)
if enabled_override is not None or disabled_override is not None:
enabled = enabled_override if enabled_override is not None else enabled
disabled = disabled_override if disabled_override is not None else disabled
agent.enabled_toolsets, agent.disabled_toolsets = enabled, disabled
return enabled, disabled
def _tool_defs_content_changed(agent, new_defs: list) -> bool:
"""Byte-level diff of the serialized tool arrays (dynamic schemas change CONTENT under
stable names); False if either side fails to serialize."""
try:
dump = lambda defs: json.dumps(defs, sort_keys=True, separators=(",", ":"), default=str) # noqa: E731
return dump(_agent_tool_defs(agent)) != dump(new_defs)
except Exception: # noqa: BLE001
return False
def _drop_side_agent_tools(agent, new_defs: list, new_names: set) -> tuple:
from tools.connectors.turn import side_agent_tool_drops
drops = side_agent_tool_drops(agent)
if not drops:
return new_defs, new_names
return [entry for entry in new_defs if _def_name(entry) not in drops], new_names - drops
def _publish_tool_snapshot(
agent, new_defs: list, new_names: set, *, snapshot_generation: int,
staged_engine_names: set, content_aware: bool, prefix_registered: Optional[set]) -> Optional[set]:
"""Single atomic read-diff-publish under ``_agent_tools_lock`` so ``added`` matches what
was published and a stale (older-generation) rebuild can't overwrite a newer one. Returns
the added names, or None when nothing was published (unchanged, or a newer snapshot won)."""
with _agent_tools_lock:
# Tolerate an agent that never set the generation (or a non-int mock).
published_gen = getattr(agent, "_tool_snapshot_generation", -1)
published_gen = published_gen if isinstance(published_gen, int) else -1
if snapshot_generation < published_gen:
return None # a newer snapshot already won
current_defs = _agent_tool_defs(agent)
current = {_def_name(t) for t in current_defs}
if prefix_registered is not None:
new_defs, new_names = _merge_preserving_prefix(current_defs, new_defs, prefix_registered)
new_defs, new_names = _drop_side_agent_tools(agent, new_defs, new_names)
# Record the generation even when unchanged so an in-flight older caller can't clobber.
agent._tool_snapshot_generation = max(published_gen, snapshot_generation)
# Same NAME set: no change for MCP-reload callers. Content-aware callers
# (compaction boundary) also diff serialized bytes.
if new_names == current and not (content_aware and _tool_defs_content_changed(agent, new_defs)):
return None
agent.tools = new_defs
agent.valid_tool_names = new_names
# Publish context-engine routing names atomically with the snapshot.
engine_names = getattr(agent, "_context_engine_tool_names", None)
if isinstance(engine_names, set):
engine_names.clear()
engine_names.update(staged_engine_names)
return new_names - current
def refresh_agent_mcp_tools(
agent, *, enabled_override=None, disabled_override=None, quiet_mode: bool = True,
content_aware: bool = False, preserve_prefix: bool = False) -> set:
"""Re-derive an already-built agent's tool snapshot from the live registry; returns the
newly-added tool names (empty when unchanged). The agent snapshots ``agent.tools`` at build
time, so servers that connect later (slow OAuth, ``/reload-mcp``) are invisible until
rebuilt. Shared by the TUI RPC, gateway reload, late-binding thread and between-turns
refresh: respects the toolset filter, diffs by tool NAME (a count compare misses an
equal-size swap), re-injects the memory-provider / context-engine tools ``agent_init``
appends after ``get_tool_definitions`` plus guarded session capabilities, and publishes
``(tools, valid_tool_names)`` together.
``preserve_prefix``: for rebuilds inside a live conversation the tool array is a cached
request prefix and any moved byte re-prefills the whole history — existing tools keep their
slot (schemas still refresh), a still-registered tool whose ``check_fn`` merely flapped is
carried forward (``check_fn`` gates exposure, never invocation), a deregistered tool is
dropped, new tools append at the tail. The caller owns the prompt-cache contract."""
from model_tools import get_tool_definitions
from tools.registry import registry
enabled, disabled = _resolve_refresh_toolsets(agent, enabled_override, disabled_override)
# Generation captured BEFORE the slow get_tool_definitions call (a slower caller holding an
# OLDER set must not clobber a newer one); definitions computed OUTSIDE the lock.
snapshot_generation = registry._generation
new_defs = list(get_tool_definitions(enabled_toolsets=enabled, disabled_toolsets=disabled, quiet_mode=quiet_mode) or [])
new_names = {_def_name(t) for t in new_defs}
# Post-build families re-appended on LOCALS only; live attributes untouched until publish.
staged_engine_names = _reinject_post_build_tools(agent, new_defs, new_names)
_reinject_authorized_dynamic_tools(agent, new_defs, new_names)
# Registry membership is read OUTSIDE ``_agent_tools_lock``: taking ``registry._lock``
# under the tools lock would be the first nesting of the two.
prefix_registered: Optional[set] = None
if preserve_prefix:
try:
prefix_registered = {entry.name for entry in registry.get_all_entries()}
except Exception: # noqa: BLE001
pass # fail open to the plain rebuild
added = _publish_tool_snapshot(
agent, new_defs, new_names, snapshot_generation=snapshot_generation,
staged_engine_names=staged_engine_names, content_aware=content_aware, prefix_registered=prefix_registered)
if added is None:
return set()
persist_agent_tool_names(agent) # re-pin so a rebuild after agent-cache eviction restores this order
return added
def reprobe_tool_availability() -> None:
"""Explicit ``/reload-mcp`` hatch out of the tools[] freeze: drop the ``check_fn`` verdict
cache AND the ``get_tool_definitions`` memo (keyed on registry generation, so it would
otherwise replay the stale verdicts)."""
from model_tools import _clear_tool_defs_cache
from tools.registry import invalidate_check_fn_cache
invalidate_check_fn_cache()
_clear_tool_defs_cache()
def tool_pin_version() -> str:
"""The code identity a tools[] pin was built by (checkout/build sha, else the release version).
Cached per process: an updated checkout only reaches a process through a restart."""
from hermes_cli import __version__
from hermes_cli.build_info import get_code_identity
identity = get_code_identity()
return identity.get("sha") or identity.get("version") or __version__
def persist_agent_tool_names(agent) -> None:
"""Best-effort: write ``agent.tools`` to the session row (freeze pin). The full definitions,
keyed by the code that built them: another process or surface derives different bytes."""
db = getattr(agent, "_session_db", None)
session_id = getattr(agent, "session_id", None)
if not db or not session_id:
return
try:
db.update_session_tool_names(session_id, {"version": tool_pin_version(), "tools": _agent_tool_defs(agent)})
except Exception: # noqa: BLE001
logger.debug("tool_names persist skipped", exc_info=True)
def _config_permitted_names(agent) -> set:
"""Tool names this agent's toolset selection allows before ``check_fn``: all a pin may carry
forward. A client-surface toolset counts as allowed (only its client can add it, so its absence
here is no config choice); ``disabled_toolsets`` and role reservations still strip it."""
from model_tools import _select_tool_names
from toolsets import CLIENT_SURFACE_TOOLSETS
enabled = getattr(agent, "enabled_toolsets", None)
if enabled is not None:
enabled = [*enabled, *CLIENT_SURFACE_TOOLSETS]
return _select_tool_names(enabled, getattr(agent, "disabled_toolsets", None), True)
def _drop_gated_carried_tools(merged: list, carried: set) -> list:
"""A carried tool also passes the session-level schema gates the fresh build applied
(``browser_exec`` needs ``terminal`` in the same array), judged on the merged array."""
from model_tools import _DYNAMIC_SCHEMA_REWRITERS
available = {_def_name(t) for t in merged}
return [t for t in merged if _def_name(t) not in carried or _def_name(t) not in _DYNAMIC_SCHEMA_REWRITERS
or _DYNAMIC_SCHEMA_REWRITERS[_def_name(t)](t, available) is not None]
def restore_agent_tool_prefix(agent, saved) -> bool:
"""Fold a freshly built agent's ``tools`` onto the session's pin; True if changed.
A fresh AIAgent (gateway cache eviction, ``--resume`` in a new process, a surface hop) has no
predecessor to preserve, so the pin stands in. Pinned by the SAME code, a tool still available
here keeps its pinned BYTES, whatever this process derives for it (tool_search's per-surface
catalog, per-surface dynamic parameters, the ``-q`` footprint): tools[] heads every request.
Pinned by other code (``hermes update``, a legacy name list) a tool's contract may have moved,
so each takes its current definition. A pinned tool this process did not build is carried
only while its toolset config allows it here; deregistered tools drop, new tools append."""
pinned, version = (saved.get("tools") or [], saved.get("version")) if isinstance(saved, dict) else (saved, None)
if not pinned:
return False
from tools.registry import registry
fresh_defs = _agent_tool_defs(agent)
fresh = {_def_name(t): t for t in fresh_defs}
registered_names = {entry.name for entry in registry.get_all_entries()}
same_code = version is not None and version == tool_pin_version()
def _pinned_def(item):
name = item if isinstance(item, str) else _def_name(item)
if isinstance(item, dict) and same_code:
return item
if name in fresh:
return fresh[name]
entry = registry.get_entry(name)
return None if entry is None else {"type": "function", "function": {**entry.schema, "name": entry.name}}
pinned_defs = [d for d in map(_pinned_def, pinned) if d]
carried = {_def_name(d) for d in pinned_defs if _def_name(d) not in fresh and _def_name(d) in registered_names}
carried &= _config_permitted_names(agent) if carried else set()
merged = [d for d in pinned_defs if _def_name(d) in fresh or _def_name(d) in carried]
pinned_names = {_def_name(d) for d in merged}
merged.extend(t for t in fresh_defs if _def_name(t) not in pinned_names)
merged = _drop_gated_carried_tools(merged, carried)
merged_names = {_def_name(t) for t in merged}
_reinject_authorized_dynamic_tools(agent, merged, merged_names)
merged, merged_names = _drop_side_agent_tools(agent, merged, merged_names)
changed = merged != fresh_defs
if changed:
with _agent_tools_lock:
agent.tools = merged
agent.valid_tool_names = merged_names
if not same_code or merged != list(pinned):
persist_agent_tool_names(agent)
return changed
def _merge_preserving_prefix(current_defs: list, new_defs: list, registered_names: set) -> tuple[list, set]:
"""Fold a fresh tool snapshot into a live one without moving existing bytes. Ordered by
``current_defs`` (the cached request prefix): a name in both keeps its slot but takes the
fresh schema; a name only in the live list is kept if still registered (``check_fn``
flapped), else dropped; a name only in the fresh list is appended at the tail.
The bridge tools keep their BUILT entry, not the fresh one: ``tool_search``'s description
is derived from the session (deferred count, listing, whether ``manage_connections`` was
present), so a late MCP server or a ``check_fn`` flap would rewrite it every turn. Search
reads the live catalog at dispatch, so the stale count costs nothing."""
from tools.tool_search_catalog import BRIDGE_TOOL_NAMES
fresh = {_def_name(entry): entry for entry in new_defs if _def_name(entry)}
merged = []
for entry in current_defs:
name = _def_name(entry)
replacement = fresh.pop(name, None)
if name in BRIDGE_TOOL_NAMES:
merged.append(entry)
elif replacement is not None:
merged.append(replacement)
elif name and name in registered_names:
merged.append(entry)
merged.extend(fresh.values())
return merged, {_def_name(t) for t in merged}
def _reinject_authorized_dynamic_tools(agent, tools_list: list, name_set: set) -> None:
"""``message_agent`` is injected by an auth gate, never registered, so a registry-derived
rebuild drops it. Scrub any stale copy from the STAGED pair and re-add it only when the live
gate re-authorizes, so the publisher exposes a coherent ``(tools, valid_tool_names)``."""
from tools.bot_mode_dm import MESSAGE_AGENT_TOOL_NAME, message_agent_authorized, message_agent_tool_schema
tools_list[:] = [entry for entry in tools_list if _def_name(entry) != MESSAGE_AGENT_TOOL_NAME]
name_set.discard(MESSAGE_AGENT_TOOL_NAME)
if message_agent_authorized(agent):
tools_list.append(message_agent_tool_schema())
name_set.add(MESSAGE_AGENT_TOOL_NAME)
def _reinject_post_build_tools(agent, tools_list: list, name_set: set) -> set:
"""Append memory-provider and context-engine tools onto the caller's staged ``tools_list``
/ ``name_set`` (never the live agent attributes), mirroring ``agent_init``'s post-build
injection. Idempotent and fail-soft. Returns the context-engine routing names THIS rebuild
appended: a name already owned by a registry/plugin tool is not claimed, matching agent_init."""
def _add(schema) -> bool:
name = schema.get("name", "") if isinstance(schema, dict) else ""
if not name or name in name_set:
return False
tools_list.append({"type": "function", "function": schema})
name_set.add(name)
return True
def _schema_getter(attr: str, method: str):
getter = getattr(getattr(agent, attr, None) or None, method, None)
return getter if callable(getter) else None
enabled = getattr(agent, "enabled_toolsets", None)
try:
get_mem_schemas = _schema_getter("_memory_manager", "get_all_tool_schemas")
if get_mem_schemas is not None:
from agent.memory_manager import memory_provider_tools_enabled # same gate inject_memory_provider_tools uses
if memory_provider_tools_enabled(
enabled, getattr(agent, "disabled_toolsets", None), memory_tool_present="memory" in name_set):
for schema in get_mem_schemas():
_add(schema)
except Exception:
logger.debug("Memory-provider tool re-injection skipped", exc_info=True)
# The `context_engine` toolset is intentionally empty, so lcm_* tools exist only via this
# append. Honor the enabled_toolsets gate agent_init uses, or a restricted-toolset platform
# would re-leak tools the build excluded.
# See #5544.
staged_engine_names: set = set()
try:
get_schemas = _schema_getter("context_compressor", "get_tool_schemas")
if (enabled is None or "context_engine" in enabled) and get_schemas is not None:
# Claim the routing name only when WE appended the schema.
staged_engine_names.update(s["name"] for s in get_schemas() if _add(s))
except Exception:
logger.debug("Context-engine tool re-injection skipped", exc_info=True)
return staged_engine_names