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.
This commit is contained in:
teknium1
2026-09-23 15:43:51 -07:00
committed by Teknium
parent da70c46124
commit d956f0ae57
12 changed files with 211 additions and 63 deletions
+12 -4
View File
@@ -659,12 +659,20 @@ def _persist_system_prompt(agent, failure_message: str, *, persist_tools: bool =
def _restore_pinned_tools(agent, session_row) -> list:
"""Pin ``agent.tools`` to the session's persisted array (tools freeze); returns the names
this surface built BEFORE the pin merged a previous surface's tools back in."""
from tools.mcp_tool_agent import agent_tool_names, restore_agent_tool_prefix
from tools.mcp_tool_agent import agent_tool_names, persist_agent_tool_names, restore_agent_tool_prefix
built_for_this_surface = agent_tool_names(agent)
saved_tools = session_row.get("tool_names") if session_row else None
try:
saved_tools = session_row.get("tool_names") if session_row else None
if saved_tools:
restore_agent_tool_prefix(agent, json.loads(saved_tools))
pin = json.loads(saved_tools) if saved_tools else None
except ValueError:
pin = None # a pin hash whose row an older build's cleanup swept resolves to itself
try:
if pin:
restore_agent_tool_prefix(agent, pin)
elif session_row is not None and not getattr(agent, "_persist_disabled", False):
# No usable pin (swept row, a session from before pins): pin what this turn sends,
# or every later hop re-derives tools[] until the next compaction.
persist_agent_tool_names(agent)
except Exception:
logger.debug("tool prefix restore skipped", exc_info=True)
return built_for_this_surface
+3 -2
View File
@@ -18,8 +18,8 @@ from hermes_cli.session_schema_history import SCHEMA_HISTORY, reachable_physical
from hermes_state_ids import SESSION_ID_PATTERN # timestamp prefix: strongest sentinel for schema-less rows
from hermes_cli.session_recovery import (
_AUXILIARY_TABLE_SCHEMAS, _AUXILIARY_TABLES, _CANONICAL_TABLES, _count_rows, _immediate_transaction,
_placeholder_titles, _quoted_columns, _table_columns,
_AUXILIARY_TABLE_SCHEMAS, _AUXILIARY_TABLES, _CANONICAL_TABLES, _DANGLING_TOOL_PIN, _count_rows,
_immediate_transaction, _placeholder_titles, _quoted_columns, _table_columns,
)
logger = logging.getLogger(__name__)
@@ -830,6 +830,7 @@ def stub_missing_parent_sessions(dest: sqlite3.Connection) -> dict[str, Any]:
"UPDATE sessions SET system_prompt_hash = NULL WHERE system_prompt_hash IS NOT NULL AND NOT EXISTS "
"(SELECT 1 FROM system_prompts WHERE system_prompts.hash = sessions.system_prompt_hash)"
)
dest.execute(f"UPDATE sessions SET tool_names = NULL WHERE {_DANGLING_TOOL_PIN}")
return result
+11 -1
View File
@@ -742,6 +742,8 @@ def _reconcile(destination: sqlite3.Connection, table: str, where: str, mutation
_DEPENDENT_TABLES = ("messages", "session_model_usage", "compression_locks", "telegram_dm_topic_bindings")
_DANGLING_TOOL_PIN = (
"length(tool_names) = 64 AND NOT EXISTS (SELECT 1 FROM system_prompts WHERE system_prompts.hash = sessions.tool_names)")
_RELINK_COUNTERS = ("session_prompt_refs_cleared", "sessions_parent_cleared")
@@ -768,9 +770,17 @@ def _cleanup_partial_orphans(destination: sqlite3.Connection) -> dict[str, Any]:
"SELECT 1 FROM system_prompts WHERE system_prompts.hash = sessions.system_prompt_hash)",
"UPDATE sessions SET system_prompt_hash = NULL",
)
# A tools[] pin is a system_prompts row too, referenced by hash from sessions.tool_names
# (legacy rows hold an inline JSON list, never 64 chars of hex).
result["session_prompt_refs_cleared"] += _reconcile(
destination, "sessions",
_DANGLING_TOOL_PIN,
"UPDATE sessions SET tool_names = NULL",
)
result["system_prompts_removed"] = _reconcile(
destination, "system_prompts",
"NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.system_prompt_hash = system_prompts.hash)",
"NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.system_prompt_hash = system_prompts.hash) "
"AND NOT EXISTS (SELECT 1 FROM sessions WHERE sessions.tool_names = system_prompts.hash)",
"DELETE FROM system_prompts",
)
for table in _DEPENDENT_TABLES:
+6 -4
View File
@@ -208,17 +208,19 @@ class SessionCompressionMixin:
_insert_session_row's compression-fork backfill: the child stays on the parent's profile and keeps
gateway routing/origin columns; no owner on either side -> this store's profile."""
system_prompt_hash = self._store_system_prompt(conn, system_prompt)
# The child continues the parent's tools[] pin (the compaction refresh re-pinned it just
# before publish), or its first hop to another surface re-derives the array.
conn.execute(
"""INSERT INTO sessions (
id, source, model, model_config, system_prompt,
system_prompt_hash,
system_prompt_hash, tool_names,
parent_session_id, cwd, git_branch, git_repo_root,
profile_name, user_id, session_key, chat_id, chat_type,
thread_id, display_name, origin_json, started_at
) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
) VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
child_session_id, source, model, json.dumps(model_config) if model_config else None,
system_prompt_hash, parent_session_id, cwd or parent["cwd"], parent["git_branch"],
system_prompt_hash, parent["tool_names"], parent_session_id, cwd or parent["cwd"], parent["git_branch"],
parent["git_repo_root"],
profile_name or parent["profile_name"] or self._own_profile_name(),
parent["user_id"], parent["session_key"], parent["chat_id"], parent["chat_type"],
@@ -262,7 +264,7 @@ class SessionCompressionMixin:
parent = conn.execute(
"""SELECT ended_at, end_reason, cwd, git_branch, git_repo_root,
user_id, session_key, chat_id, chat_type,
thread_id, display_name, origin_json, profile_name
thread_id, display_name, origin_json, profile_name, tool_names
FROM sessions WHERE id = ?""",
(parent_session_id,),
).fetchone()
+11 -6
View File
@@ -185,16 +185,18 @@ class SessionProfileRepairMixin:
session = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone()
if session is None:
return None
prompt = None
if session["system_prompt_hash"]:
row = conn.execute(
"SELECT prompt FROM system_prompts WHERE hash = ?", (session["system_prompt_hash"],)).fetchone()
prompt = row[0] if row else None
def _stored(prompt_hash):
row = conn.execute("SELECT prompt FROM system_prompts WHERE hash = ?", (prompt_hash,)).fetchone()
return row[0] if row else None
prompt = _stored(session["system_prompt_hash"]) if session["system_prompt_hash"] else None
# The tools[] pin is content-addressed the same way; a legacy inline list resolves to None.
tool_pin = _stored(session["tool_names"]) if session["tool_names"] else None
messages = [dict(r) for r in conn.execute(
"SELECT * FROM messages WHERE session_id = ? ORDER BY id", (session_id,))]
usage = [dict(r) for r in conn.execute(
"SELECT * FROM session_model_usage WHERE session_id = ?", (session_id,))]
return {"session": dict(session), "system_prompt": prompt, "messages": messages, "usage": usage}
return {"session": dict(session), "system_prompt": prompt, "tool_pin": tool_pin, "messages": messages,
"usage": usage}
return self._read_retrying_ioerr(_read)
def import_moved_session(self, payload: Dict[str, Any], *, profile_name: str) -> str:
@@ -220,6 +222,9 @@ class SessionProfileRepairMixin:
suffix = f" ({session_id[-12:]})"
session["title"] = title[:self.MAX_TITLE_LENGTH - len(suffix)] + suffix
session["system_prompt_hash"] = self._store_system_prompt(conn, payload.get("system_prompt"))
if payload.get("tool_pin") is not None or len(session.get("tool_names") or "") == 64:
# A pin hash means nothing in this store: re-store the pin, or drop an unresolvable ref.
session["tool_names"] = self._store_system_prompt(conn, payload.get("tool_pin"))
self._insert_row(conn, "sessions", session, skip=frozenset())
for message in payload.get("messages") or []:
self._insert_row(conn, "messages", {**message, "session_id": session_id}, skip=_MESSAGE_MOVE_SKIP)
+6 -6
View File
@@ -661,12 +661,12 @@ class SessionSessionsMixin:
self._delete_unreferenced_system_prompts(conn)
self._execute_write(_do)
def update_session_tool_names(self, session_id: str, tools: Optional[List[Any]]) -> None:
"""Persist the session's ``tools[]`` pin so a rebuilt AIAgent sends the same bytes; ``None``
clears. The array repeats across sessions like a system prompt does, so it is stored in the
same content-addressed ``system_prompts`` table and the column holds its hash (legacy rows:
an inline JSON name list); ``get_session`` resolves either."""
payload = json.dumps(list(tools)) if tools is not None else None
def update_session_tool_names(self, session_id: str, pin: Any) -> None:
"""Persist the session's ``tools[]`` pin (JSON-serializable) so a rebuilt AIAgent sends the
same bytes; ``None`` clears. The array repeats across sessions like a system prompt does, so it
is stored in the same content-addressed ``system_prompts`` table and the column holds its hash
(legacy rows: an inline JSON name list); ``get_session`` resolves either."""
payload = json.dumps(pin) if pin is not None else None
def _do(conn):
conn.execute("UPDATE sessions SET tool_names = ? WHERE id = ?",
(self._store_system_prompt(conn, payload), session_id))
+26 -3
View File
@@ -18,7 +18,7 @@ from __future__ import annotations
import json
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
import pytest
@@ -40,6 +40,7 @@ def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"):
# for the legacy restore tests (the reconstruction tests enable it).
agent._use_prompt_caching = False
agent._build_system_prompt = MagicMock(return_value=prebuilt_prompt)
agent.enabled_toolsets = agent.disabled_toolsets = None # all toolsets, as an unrestricted agent
return agent
@@ -284,6 +285,7 @@ class TestStoredPromptReuse:
from unittest.mock import patch as _patch
from hermes_state import SessionDB
from tools.mcp_tool_agent import tool_pin_version
def _tool(name):
return {"type": "function", "function": {"name": name, "description": f"{name} v1", "parameters": {}}}
@@ -292,7 +294,7 @@ class TestStoredPromptReuse:
with SessionDB(db_path=tmp_path / "state.db") as db:
db.create_session("test-session-id", source="tui")
db.update_system_prompt("test-session-id", "Model: old-model\nProvider: openrouter")
db.update_session_tool_names("test-session-id", pinned)
db.update_session_tool_names("test-session-id", {"version": tool_pin_version(), "tools": pinned})
agent = _make_agent(session_db=db)
agent.side_agent = False
agent._bot_mode_protocol = False
@@ -304,7 +306,28 @@ class TestStoredPromptReuse:
agent._build_system_prompt.assert_called_once()
assert agent.tools == pinned
assert "skill_manage" in agent.valid_tool_names
assert json.loads(db.get_session("test-session-id")["tool_names"]) == pinned
assert json.loads(db.get_session("test-session-id")["tool_names"])["tools"] == pinned
def test_a_swept_pin_row_is_re_pinned_on_the_next_turn(self, tmp_path):
"""``hermes sessions recover`` from an older build deleted pin rows it did not know about,
leaving ``tool_names`` a hash that resolves to itself. The next turn must pin what it sends,
or every later surface hop re-derives tools[] for the rest of the session."""
from hermes_state import SessionDB
tools = [{"type": "function", "function": {"name": "read_file", "description": "", "parameters": {}}}]
with SessionDB(db_path=tmp_path / "state.db") as db:
db.create_session("test-session-id", source="tui")
db.update_system_prompt("test-session-id", "BUILT_PROMPT")
db._conn.execute("UPDATE sessions SET tool_names = ? WHERE id = 'test-session-id'", ("ab" * 32,))
db._conn.commit()
agent = _make_agent(session_db=db)
agent._persist_disabled = False
agent.tools = list(tools)
with patch("agent.conversation_loop._stored_prompt_matches_runtime", return_value=True):
_restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}])
assert agent._cached_system_prompt == "BUILT_PROMPT"
assert json.loads(db.get_session("test-session-id")["tool_names"])["tools"] == tools
# ---------------------------------------------------------------------------
@@ -183,6 +183,43 @@ def test_compression_child_uses_content_addressed_prompt(db):
assert _prompt_count(db) == 1
def test_compression_child_continues_the_parents_tool_pin(db):
pin = {"version": "sha", "tools": [{"type": "function", "function": {"name": "read_file"}}]}
db.create_session("parent", "telegram")
db.update_session_tool_names("parent", pin)
db.append_message("parent", "user", "original")
assert db.try_acquire_compression_lock("parent", "holder", ttl_seconds=60)
db.publish_compression_child(
parent_session_id="parent", child_session_id="child", source="telegram", system_prompt="p",
messages=[{"role": "user", "content": "summary"}], compression_lock_holder="holder")
assert json.loads(db.get_session("child")["tool_names"]) == pin
def test_recovery_cleanup_keeps_tool_pins_and_drops_dangling_pin_refs(tmp_path):
from hermes_cli.session_recovery import _cleanup_partial_orphans
pin = {"version": "sha", "tools": [{"type": "function", "function": {"name": "read_file"}}]}
with SessionDB(db_path=tmp_path / "state.db") as db:
db.create_session("pinned", "tui")
db.update_session_tool_names("pinned", pin)
db.create_session("dangling", "tui")
db._conn.execute("UPDATE sessions SET tool_names = ? WHERE id = 'dangling'", ("ab" * 32,))
db.create_session("legacy", "tui")
db._conn.execute("UPDATE sessions SET tool_names = ? WHERE id = 'legacy'", ('["read_file"]',))
db._conn.commit()
conn = sqlite3.connect(str(tmp_path / "state.db"), isolation_level=None)
conn.row_factory = sqlite3.Row
_cleanup_partial_orphans(conn)
conn.close()
with SessionDB(db_path=tmp_path / "state.db") as db:
assert json.loads(db.get_session("pinned")["tool_names"]) == pin
assert db.get_session("dangling")["tool_names"] is None
assert db.get_session("legacy")["tool_names"] == '["read_file"]'
def test_imported_prompts_are_deduplicated(tmp_path):
prompt = "shared imported prompt"
source = SessionDB(db_path=tmp_path / "source.db")
+40 -14
View File
@@ -338,25 +338,31 @@ def test_eviction_rebuild_restores_the_sessions_saved_tool_order(monkeypatch):
def test_resume_on_another_surface_restores_the_pinned_tool_bytes(monkeypatch, tmp_path):
"""One durable session hops gateway -> ``-q --resume``: the new process derives different
bytes for the SAME tools (tool_search's per-surface deferred catalog, a dynamic schema
override, the one-shot footprint pruning skill_manage). tools[] heads every request, so
the pin must hand back exactly what the session already sent, or every hop re-prefills."""
bytes for the SAME tools (tool_search's per-surface deferred catalog, per-surface dynamic
PARAMETERS like delegate_task's, the one-shot footprint pruning skill_manage). tools[] heads
every request, so a pin written by the same code hands back exactly what the session sent;
one written by other code (``hermes update``) takes the current definitions instead."""
from hermes_state import SessionDB
from tools import registry as registry_mod
def _described(name, description):
def _described(name, description, **params):
tool = _tool(name)
tool["function"]["description"] = description
tool["function"]["parameters"] = {"type": "object", "properties": params}
return tool
sent = _agent([])
sent.tools = [_tool("read_file"), _described("skill_manage", "lands in /home/u/.hermes/skills"),
sent.tools = [_tool("read_file"), _described("delegate_task", "delegate", group={"type": "string"}),
_described("skill_manage", "lands in /home/u/.hermes/skills"),
_described("tool_search", "Search 6 additional tools.")]
static = {"skill_manage": _described("skill_manage", "lands in the profile's skills dir")["function"]}
monkeypatch.setattr(registry_mod.registry, "get_all_entries",
lambda: [types.SimpleNamespace(name=n) for n in ("read_file", "skill_manage")], raising=False)
lambda: [types.SimpleNamespace(name=n) for n in ("read_file", "delegate_task", "skill_manage")],
raising=False)
monkeypatch.setattr(registry_mod.registry, "get_entry",
lambda name, **kw: types.SimpleNamespace(name=name, schema=static[name]), raising=False)
this_surface = [_tool("read_file"), _described("delegate_task", "delegate"), # drops `group` here
_described("tool_search", "Search 5 additional tools.")]
with SessionDB(db_path=tmp_path / "state.db") as db:
sent._session_db = db
for sid in ("s1", "s2"):
@@ -367,21 +373,41 @@ def test_resume_on_another_surface_restores_the_pinned_tool_bytes(monkeypatch, t
stored = db._conn.execute("SELECT COUNT(*) FROM system_prompts").fetchone()[0]
resumed = _agent([])
resumed.tools = [_tool("read_file"), _described("tool_search", "Search 5 additional tools.")]
resumed.tools, resumed._session_db, resumed.session_id = list(this_surface), db, "s1"
_mcp_agent.restore_agent_tool_prefix(resumed, json.loads(db.get_session("s1")["tool_names"]))
repinned = db.get_session("s1")["tool_names"]
# `hermes update` changed read_file's parameters: the handler validates the NEW
# signature, so that one tool takes the fresh def while the rest stay pinned.
upgraded_read = _tool("read_file")
upgraded_read["function"]["parameters"] = {"type": "object", "properties": {"path_v2": {"type": "string"}}}
# The pin came from other code: every tool built here takes this build's definition.
monkeypatch.setattr(_mcp_agent, "tool_pin_version", lambda: "sha-after-hermes-update")
updated = _agent([])
updated.tools = [upgraded_read, _described("tool_search", "Search 5 additional tools.")]
updated.tools, updated._session_db, updated.session_id = list(this_surface), db, "s2"
_mcp_agent.restore_agent_tool_prefix(updated, json.loads(db.get_session("s2")["tool_names"]))
upgraded_pin = json.loads(db.get_session("s2")["tool_names"])
assert json.dumps(resumed.tools) == json.dumps(sent.tools)
assert resumed.valid_tool_names == {"read_file", "skill_manage", "tool_search"}
assert resumed.valid_tool_names == {"read_file", "delegate_task", "skill_manage", "tool_search"}
assert stored == 1
assert updated.tools == [upgraded_read, *sent.tools[1:]]
assert json.loads(repinned)["tools"] == sent.tools # unchanged pin, no rewrite per hop
assert updated.tools == [*this_surface[:2], {"type": "function", "function": {**static["skill_manage"]}},
this_surface[2]]
assert upgraded_pin == {"version": "sha-after-hermes-update", "tools": updated.tools}
def test_a_pin_never_re_adds_a_tool_this_sessions_config_excludes(monkeypatch):
"""A pin from a surface where ``terminal`` was allowed must not hand it back where config
disables it, nor ``browser_exec`` (host Python) once ``terminal`` is gone. A client-surface
tool (``focus_pane``) is still carried: no config choice removed it here."""
import model_tools # noqa: F401 registers the real tools
monkeypatch.setattr(_mcp_agent, "persist_agent_tool_names", lambda agent: None)
pin = {"version": _mcp_agent.tool_pin_version(),
"tools": [_tool(n) for n in ("read_file", "terminal", "browser_exec", "focus_pane")]}
agent = _agent(["read_file"], enabled=["hermes-cli"], disabled=["terminal"])
_mcp_agent.restore_agent_tool_prefix(agent, pin)
assert [t["function"]["name"] for t in agent.tools] == ["read_file", "focus_pane"]
assert agent.valid_tool_names == {"read_file", "focus_pane"}
def test_reprobe_tool_availability_drops_cached_check_fn_verdicts(monkeypatch):
+53 -22
View File
@@ -148,52 +148,83 @@ def reprobe_tool_availability() -> None:
_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,
not just names: another process or surface derives different bytes for the same tool."""
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, _agent_tool_defs(agent))
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 restore_agent_tool_prefix(agent, saved: list) -> bool:
"""Fold a freshly built agent's ``tools`` onto the session's pinned array; True if changed.
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. A pinned tool still available here (built
fresh, or registered but failing its probe) keeps its pinned BYTES: this process derives
others for it (tool_search's per-surface catalog, dynamic schema overrides, the ``-q``
footprint) and tools[] heads every request. Deregistered tools drop, tools new to this
process append at the tail. Legacy name-only pins take the fresh/registry schema once."""
if not saved:
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 _current_def(name):
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}}
def _pinned_def(item):
if not isinstance(item, dict):
return _current_def(item)
# Surfaces differ only in descriptions; changed PARAMETERS mean the handler's contract
# moved (``hermes update``), and the model must not keep calling the old signature.
current = _current_def(_def_name(item))
params = lambda d: (d.get("function") or {}).get("parameters") # noqa: E731
return current if current is not None and params(current) != params(item) else item
merged = [d for d in map(_pinned_def, saved) if d and (_def_name(d) in fresh or _def_name(d) in registered_names)]
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)
@@ -202,7 +233,7 @@ def restore_agent_tool_prefix(agent, saved: list) -> bool:
with _agent_tools_lock:
agent.tools = merged
agent.valid_tool_names = merged_names
if merged != list(saved):
if not same_code or merged != list(pinned):
persist_agent_tool_names(agent)
return changed
+4
View File
@@ -69,6 +69,10 @@ def _core_without(*excluded, kanban=True):
# tts, image_gen, home-assistant, cron, kanban and computer-use.
_CODING_TOOLS = _core_without("image_generate", "text_to_speech", "cronjob_manage", "computer_use", *_HA_TOOLS, kanban=False)
# Toolsets a CLIENT adds to its own sessions (tui_gateway/server.py::_gui_surface_toolsets), never
# config: another surface lacking them made no configuration choice.
CLIENT_SURFACE_TOOLSETS = frozenset({"project", "desktop_ui"})
# Core toolset definitions: individual tools or references to other toolsets.
TOOLSETS = {
# Basic toolsets - individual tool categories
+2 -1
View File
@@ -1843,7 +1843,8 @@ def _gui_surface_toolsets(platform: str) -> set[str]:
"""Toolsets that exist because of the CLIENT (both off ``_HERMES_CORE_TOOLS``; this is the one gate).
``platform`` is the SESSION's source, never a process env var: the desktop may drive a URL/cloud
backend where ``HERMES_DESKTOP`` is unset (AGENTS.md surface rule)."""
return {"project", "desktop_ui"} if platform == "desktop" else {"project"}
from toolsets import CLIENT_SURFACE_TOOLSETS
return set(CLIENT_SURFACE_TOOLSETS) if platform == "desktop" else {"project"}
def _with_session_toolsets(selection, platform: str | None) -> list[str]: