Fleet telemetry (14d, v0.21.5) shows the builtin store returning success=False on 34-47% of add/replace/remove calls. Live `hermes chat -q` runs against origin/main reproduced the dominant causes: - Drift guard refused every replace/remove/batch on a file whose only difference from the tool's own layout was whitespace (blank lines around the delimiter, an empty entry). Parsing only strips whitespace, so flushing such a file loses nothing; the round-trip signal could not protect any content and turned the store read-only until hand-fixed (#107270). The guard now fires only on the signal that can actually lose foreign text: one entry larger than the whole store's limit. - An over-budget add/replace/batch said "would exceed the limit" but not by how much, so models retried with consolidations that were still too big. Every over-budget message now states the exact chars to free and points at the one-batch fix. - An old_text that matched nothing (paraphrased, stale after a concurrent write, or several entries glued with the delimiter) returned no hint on the batch path. Zero-match errors now carry `closest_entries` (top word overlap) on both paths, and flag a delimiter-spanning old_text. - A batch replace missing its own `content` now says to set it on the operation object. Fixes #107270. Same diagnosis as #107278 (@KoNit-K) and #61612 (@webtecnica), which normalized the round-trip comparison instead of dropping it.
256 lines
9.4 KiB
Python
256 lines
9.4 KiB
Python
"""Behavior contracts for journey node edit/delete (agent.learning_mutations).
|
|
|
|
Exercises the real on-disk resolution (skills dir + MEMORY.md/USER.md chunking)
|
|
against a temp HERMES_HOME, never mocks — the id→file mapping is the whole point.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from agent import learning_mutations as lm
|
|
from hermes_constants import get_hermes_home
|
|
|
|
_SKILL = """---
|
|
name: my-skill
|
|
description: A test skill.
|
|
---
|
|
|
|
# My Skill
|
|
|
|
Body.
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def home():
|
|
base = get_hermes_home()
|
|
(base / "memories").mkdir(parents=True, exist_ok=True)
|
|
(base / "memories" / "MEMORY.md").write_text("alpha note\nline two\n§\nbeta note", encoding="utf-8")
|
|
(base / "memories" / "USER.md").write_text("user profile note", encoding="utf-8")
|
|
skill = base / "skills" / "my-skill"
|
|
skill.mkdir(parents=True, exist_ok=True)
|
|
(skill / "SKILL.md").write_text(_SKILL, encoding="utf-8")
|
|
return base
|
|
|
|
|
|
def test_parse_node_kind():
|
|
assert lm.parse_node_kind("memory:memory:0") == "memory"
|
|
assert lm.parse_node_kind("memory:profile:3") == "memory"
|
|
assert lm.parse_node_kind("debugging-hermes") == "skill"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_edit_memory_replaces_chunk(home):
|
|
assert lm.edit_node("memory:profile:2", "rewritten profile")["ok"]
|
|
assert (home / "memories" / "USER.md").read_text(encoding="utf-8").strip() == "rewritten profile"
|
|
|
|
|
|
def test_edit_memory_refuses_an_entry_over_the_memory_tools_limit(home):
|
|
"""Same char cap as the memory tool's replace: an over-limit entry reads as external drift to
|
|
every later mutation, so the tool's own remove/replace would refuse (with a .bak) until the
|
|
file is fixed by hand."""
|
|
from tools.memory_tool import MemoryStore
|
|
|
|
result = lm.edit_node("memory:profile:2", "x" * 5000)
|
|
|
|
assert not result["ok"] and "Shorten" in result["message"]
|
|
assert (home / "memories" / "USER.md").read_text(encoding="utf-8") == "user profile note"
|
|
assert MemoryStore().remove("user", "user profile note")["success"]
|
|
|
|
# The cap gates what an edit adds, never a delete: pruning is the way out of a file that is
|
|
# already over its total (lowered memory_char_limit, well-formed hand edits).
|
|
from tools.memory_tool import ENTRY_DELIMITER
|
|
|
|
path = home / "memories" / "USER.md"
|
|
path.write_text(ENTRY_DELIMITER.join(f"entry {i} " + "y" * 500 for i in range(4)), encoding="utf-8")
|
|
assert lm.delete_node("memory:profile:2")["ok"]
|
|
assert len(MemoryStore._read_file(path)) == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_skill_detail_returns_skill_md(home):
|
|
d = lm.node_detail("my-skill")
|
|
assert d["ok"] and d["kind"] == "skill"
|
|
assert "name: my-skill" in d["content"]
|
|
|
|
|
|
|
|
|
|
def test_delete_pinned_skill_refused(home):
|
|
from tools import skill_usage
|
|
|
|
skill_usage.set_pinned("my-skill", True)
|
|
res = lm.delete_node("my-skill")
|
|
assert not res["ok"]
|
|
assert "pinned" in res["message"]
|
|
assert (home / "skills" / "my-skill").exists()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_memory_writes_match_memory_tool_format(home):
|
|
"""A journey mutation must leave the file byte-identical to what the memory
|
|
tool itself writes — same §-join, no trailing-newline drift — so the two
|
|
surfaces never fight over format and indices stay aligned."""
|
|
from tools.memory_tool import ENTRY_DELIMITER, MemoryStore
|
|
|
|
assert lm.edit_node("memory:memory:0", "alpha rewritten")["ok"]
|
|
path = home / "memories" / "MEMORY.md"
|
|
entries = MemoryStore._read_file(path)
|
|
|
|
assert entries == ["alpha rewritten", "beta note"]
|
|
assert path.read_text(encoding="utf-8") == ENTRY_DELIMITER.join(entries)
|
|
|
|
# A Notepad BOM must not make the first card's id permanently "stale": the graph and the
|
|
# store must parse the file the same way.
|
|
path.write_text("alpha note\n§\nbeta note", encoding="utf-8-sig")
|
|
from agent.learning_graph import build_learning_graph
|
|
|
|
first = next(n for n in build_learning_graph()["nodes"] if n["kind"] == "memory")
|
|
assert first["label"] == "alpha note" # the BOM is not part of the card's title
|
|
assert lm.edit_node(first["id"], "alpha sans bom")["ok"]
|
|
assert MemoryStore._read_file(path) == ["alpha sans bom", "beta note"]
|
|
|
|
|
|
# ── Locking / drift (issue #119668) ─────────────────────────────────────────
|
|
# A Journey mutation shares MEMORY.md with the live agent's memory tool, so it must
|
|
# take the same lock, re-read under it and honour the drift guard — otherwise a
|
|
# memory the agent stored in the meantime is rewritten away from a stale snapshot.
|
|
|
|
|
|
def _race_memory_tool_add(monkeypatch, content: str) -> threading.Thread:
|
|
"""Start a lock-respecting ``memory_tool`` writer that appends *content* the
|
|
moment the Journey mutation resolves its node id (the read half of its
|
|
read-modify-write), then give it a moment to land. Under a correct lock the
|
|
writer blocks until the mutation has written; without one it interleaves and
|
|
the mutation's write clobbers it."""
|
|
from agent import learning_graph
|
|
from tools.memory_tool import MemoryStore
|
|
|
|
located, landed = threading.Event(), threading.Event()
|
|
real_cards = learning_graph._memory_cards
|
|
|
|
def _cards_then_let_writer_in():
|
|
cards = real_cards()
|
|
located.set()
|
|
landed.wait(timeout=0.5)
|
|
return cards
|
|
|
|
def _writer():
|
|
located.wait(timeout=5)
|
|
MemoryStore().add("memory", content)
|
|
landed.set()
|
|
|
|
monkeypatch.setattr(learning_graph, "_memory_cards", _cards_then_let_writer_in)
|
|
thread = threading.Thread(target=_writer, daemon=True)
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
def test_delete_memory_keeps_concurrent_memory_tool_add(home, monkeypatch):
|
|
from tools.memory_tool import MemoryStore
|
|
|
|
writer = _race_memory_tool_add(monkeypatch, "gamma note")
|
|
assert lm.delete_node("memory:memory:0")["ok"]
|
|
writer.join(timeout=5)
|
|
|
|
assert MemoryStore._read_file(home / "memories" / "MEMORY.md") == ["beta note", "gamma note"]
|
|
|
|
|
|
def test_edit_memory_keeps_concurrent_memory_tool_add(home, monkeypatch):
|
|
from tools.memory_tool import MemoryStore
|
|
|
|
writer = _race_memory_tool_add(monkeypatch, "gamma note")
|
|
assert lm.edit_node("memory:memory:0", "alpha rewritten")["ok"]
|
|
writer.join(timeout=5)
|
|
|
|
assert MemoryStore._read_file(home / "memories" / "MEMORY.md") == ["alpha rewritten", "beta note", "gamma note"]
|
|
|
|
|
|
@pytest.mark.parametrize("mutate", [lambda: lm.delete_node("memory:memory:0"),
|
|
lambda: lm.edit_node("memory:memory:0", "alpha rewritten")],
|
|
ids=["delete", "edit"])
|
|
def test_memory_drift_is_refused_with_backup_like_memory_tool(home, mutate):
|
|
"""Free-form text appended without § delimiters (one "entry" past the whole-store
|
|
limit) is what the memory tool's drift guard exists for: snapshot to .bak, refuse,
|
|
leave the file untouched. A Journey mutation must not flatten it silently."""
|
|
from tools.memory_tool_store import _drift_error
|
|
|
|
path = home / "memories" / "MEMORY.md"
|
|
raw = "alpha note\n§\nbeta note\n\n## Vendor Master\n" + "x" * 2300 + "\n"
|
|
path.write_text(raw, encoding="utf-8")
|
|
|
|
res = mutate()
|
|
|
|
assert not res["ok"]
|
|
assert path.read_text(encoding="utf-8") == raw
|
|
(backup,) = home.glob("memories/MEMORY.md.bak.*")
|
|
assert backup.read_text(encoding="utf-8") == raw
|
|
assert res["message"] == _drift_error(path, str(backup))["error"]
|
|
|
|
|
|
def test_unreadable_memory_file_is_refused_unchanged(home):
|
|
path = home / "memories" / "MEMORY.md"
|
|
path.write_bytes(b"alpha note\n\xc3\x28\n\xc2\xa7\nbeta")
|
|
|
|
res = lm.delete_node("memory:memory:0")
|
|
|
|
assert not res["ok"] and "could not be read" in res["message"]
|
|
assert path.read_bytes() == b"alpha note\n\xc3\x28\n\xc2\xa7\nbeta"
|
|
|
|
|
|
# ── Node identity: the card the user clicked, not the index it sat at ────────
|
|
|
|
|
|
def _memory_entries(home) -> list[str]:
|
|
from tools.memory_tool import MemoryStore
|
|
|
|
return MemoryStore._read_file(home / "memories" / "MEMORY.md")
|
|
|
|
|
|
def _write_memory_file(home, *entries):
|
|
from tools.memory_tool import ENTRY_DELIMITER
|
|
|
|
(home / "memories" / "MEMORY.md").write_text(ENTRY_DELIMITER.join(entries), encoding="utf-8")
|
|
|
|
|
|
def _journey_id(home, label: str) -> str:
|
|
"""The node id Journey renders for the card whose text starts with *label*."""
|
|
from agent.learning_graph import build_learning_graph
|
|
|
|
nodes = [n for n in build_learning_graph()["nodes"] if n["kind"] == "memory"]
|
|
index = next(i for i, entry in enumerate(_memory_entries(home)) if entry.startswith(label))
|
|
return nodes[index]["id"]
|
|
|
|
|
|
@pytest.mark.parametrize("op", ["edit", "delete"])
|
|
def test_mutation_targets_the_clicked_card_when_the_list_shifted_before_submit(home, op):
|
|
"""The window a displayed-index identity cannot see: an earlier entry is removed AFTER the
|
|
graph is drawn and BEFORE the edit/delete is submitted, so by the time the mutation reads the
|
|
file that index names somebody else's card."""
|
|
node_id = _journey_id(home, "beta") # what Journey showed the user
|
|
_write_memory_file(home, "beta note", "gamma note")
|
|
|
|
mutate = (lambda: lm.edit_node(node_id, "beta rewritten")) if op == "edit" else (lambda: lm.delete_node(node_id))
|
|
assert mutate()["ok"]
|
|
assert _memory_entries(home) == (["beta rewritten", "gamma note"] if op == "edit" else ["gamma note"])
|
|
|
|
# The clicked card is gone now; its id must not fall back to whatever sits at that index.
|
|
result = mutate()
|
|
assert result["ok"] is False and "stale" in result["message"]
|