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.
322 lines
17 KiB
Python
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
|