mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-09-28 06:45:17 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user