Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -1546,6 +1546,14 @@
"write_approval": False,
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
# Evidence-gated episode log (not system-prompt injected). Built-in;
# independent of external memory.provider plugins.
"episodes_enabled": True,
# FTS corpus roots under HERMES_HOME for episode(action=recall).
# "episodes" indexes the episode log; "memories" also indexes sibling
# markdown such as MEMORY.md and USER.md. Add "skills" explicitly to
# include installed skill markdown.
"episode_corpus_roots": ["episodes", "memories"],
# External memory provider plugin (empty = built-in only).
# Set to a provider name to activate: "openviking", "mem0",
# "hindsight", "holographic", "retaindb", "byterover".
Expand Down
249 changes: 249 additions & 0 deletions tests/tools/test_episode_memory.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
"""Tests for tools/episode_memory.py — evidence-gated episodes + FTS recall."""

from __future__ import annotations

import json
import os
import sqlite3
from pathlib import Path

import pytest

from tools.episode_memory import (
EPISODE_SCHEMA,
episode_tool,
get_episode,
list_episodes,
recall_episodes,
rebuild_episode_index,
remember_episode,
)
from hermes_cli.config_defaults import DEFAULT_CONFIG


@pytest.fixture
def episode_home(tmp_path, monkeypatch):
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
# Avoid config.yaml lookups pulling real user config mid-test.
monkeypatch.setattr(
"tools.episode_memory._episodes_enabled",
lambda: True,
)
monkeypatch.setattr(
"tools.episode_memory._corpus_roots",
lambda: ("episodes", "memories"),
)
return home


class TestEpisodeSchema:
def test_requires_evidence_in_description(self):
desc = EPISODE_SCHEMA["description"].lower()
assert "evidence" in desc
assert "not injected" in desc or "not" in desc and "system prompt" in desc
assert "session_search" in desc
assert "honcho" in desc # explicit non-dependency

def test_actions_listed(self):
actions = EPISODE_SCHEMA["parameters"]["properties"]["action"]["enum"]
for name in ("remember", "recall", "list", "get", "reindex"):
assert name in actions

def test_defaults_live_in_config_defaults(self):
memory = DEFAULT_CONFIG["memory"]
assert memory["episodes_enabled"] is True
assert memory["episode_corpus_roots"] == ["episodes", "memories"]

def test_core_toolset_and_delegate_boundaries(self):
from tools.delegate_tool import DELEGATE_BLOCKED_TOOLS
from toolsets import TOOLSETS, _HERMES_CORE_TOOLS

assert "episode" in _HERMES_CORE_TOOLS
assert "episode" in TOOLSETS["memory"]["tools"]
assert "episode" in DELEGATE_BLOCKED_TOOLS


class TestRemember:
def test_refuses_without_evidence(self, episode_home):
result = remember_episode(
content="Gateway reconnect needs backoff",
evidence="",
)
assert result["success"] is False
assert "evidence" in result["error"].lower()

def test_refuses_empty_content(self, episode_home):
result = remember_episode(content=" ", evidence="abc123")
assert result["success"] is False

def test_writes_markdown_and_indexes(self, episode_home):
result = remember_episode(
content="Gateway reconnect flakes when the PTY bridge races approval prompts.",
evidence="commit deadbeef",
source="review",
tags=["gateway", "desktop"],
)
assert result["success"] is True
path = Path(result["path"])
assert path.exists()
text = path.read_text(encoding="utf-8")
assert "evidence: commit deadbeef" in text
assert "source: review" in text
assert "Gateway reconnect flakes" in text

hits = recall_episodes("gateway reconnect flakes", k=3)
assert hits["success"] is True
assert hits["count"] >= 1
assert any("reconnect" in h["snippet"].lower() or "gateway" in h["path"].lower()
for h in hits["hits"])

def test_status_shaped_warns_but_writes(self, episode_home):
result = remember_episode(
content="Phase 3 done and queue is at zero for the sprint.",
evidence="/tmp/log.txt",
)
assert result["success"] is True
assert result.get("warnings")

def test_missing_fts_keeps_durable_write(
self,
episode_home,
monkeypatch,
):
def unavailable(_path):
raise sqlite3.OperationalError("no such module: fts5")

monkeypatch.setattr("tools.episode_memory._connect_index", unavailable)

result = remember_episode(
content="Gateway reconnect retries must preserve the durable event.",
evidence="commit deadbeef",
)

assert result["success"] is True
assert Path(result["path"]).exists()
assert result["index"]["available"] is False
assert result["index"]["code"] == "fts_unavailable"
assert result.get("warnings")

def test_secret_shaped_refused(self, episode_home):
result = remember_episode(
content="token ghp_abcdefghijklmnopqrstuvwxyz0123456789 is bad",
evidence="note",
)
assert result["success"] is False
assert "secret" in result["error"].lower()


class TestRecallAndList:
def test_recall_empty_index_message(self, episode_home):
result = recall_episodes("nothing here yet")
assert result["success"] is True
assert result["hits"] == []

def test_list_and_get(self, episode_home):
written = remember_episode(
content="Desktop updater must preserve local commits on macOS.",
evidence="PR #64576",
source="agent",
)
listed = list_episodes(limit=5)
assert listed["count"] >= 1
got = get_episode(written["path"])
assert got["success"] is True
assert "updater" in got["content"].lower()
assert got["meta"].get("evidence") == "PR #64576"

def test_get_rejects_path_outside_home(self, episode_home, tmp_path):
outsider = tmp_path / "outside.md"
outsider.write_text("nope", encoding="utf-8")
got = get_episode(str(outsider))
assert got["success"] is False

@pytest.mark.parametrize("protected_name", [".env", "auth.json"])
def test_get_rejects_protected_files_under_home(
self,
episode_home,
protected_name,
):
protected = episode_home / protected_name
protected.write_text("credential material", encoding="utf-8")

got = get_episode(str(protected))

assert got["success"] is False
assert "episode directory" in got["error"]

def test_get_and_list_reject_symlink_outside_episode_dir(
self,
episode_home,
):
episodes = episode_home / "memories" / "episodes"
episodes.mkdir(parents=True)
protected = episode_home / "auth.json"
protected.write_text("credential material", encoding="utf-8")
link = episodes / "credential.md"
try:
link.symlink_to(protected)
except OSError:
pytest.skip("symlinks unavailable")

got = get_episode(str(link))
listed = list_episodes()

assert got["success"] is False
assert listed["episodes"] == []

def test_recall_degrades_when_fts_is_unavailable(
self,
episode_home,
monkeypatch,
):
def unavailable(_path):
raise sqlite3.OperationalError("no such module: fts5")

monkeypatch.setattr("tools.episode_memory._connect_index", unavailable)

result = recall_episodes("gateway reconnect")

assert result["success"] is True
assert result["degraded"] is True
assert result["hits"] == []
assert result["index"]["code"] == "fts_unavailable"

def test_indexes_memories_root(self, episode_home):
mem = episode_home / "memories"
mem.mkdir(parents=True, exist_ok=True)
(mem / "MEMORY.md").write_text(
"Studio runs hermes gateway via launchd label ai.hermes.gateway\n",
encoding="utf-8",
)
rebuild_episode_index()
hits = recall_episodes("launchd hermes gateway", k=5)
assert hits["success"] is True
assert hits["count"] >= 1
assert any(h["source"] == "memories" for h in hits["hits"])


class TestEpisodeToolDispatch:
def test_tool_json_remember_and_recall(self, episode_home):
raw = episode_tool(
action="remember",
content="Toolset panel flake is a Radix menu timing class under CI contention.",
evidence="https://github.com/NousResearch/hermes-agent/actions/runs/29725368589",
source="agent",
tags="desktop,ci",
)
data = json.loads(raw)
assert data["success"] is True

raw2 = episode_tool(action="recall", query="Radix menu timing", k=3)
data2 = json.loads(raw2)
assert data2["success"] is True
assert data2["count"] >= 1

def test_unknown_action(self, episode_home):
data = json.loads(episode_tool(action="explode"))
assert data["success"] is False
5 changes: 3 additions & 2 deletions tools/delegate_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"delegate_task", # no recursive delegation
"clarify", # no user interaction
"memory", # no writes to shared MEMORY.md
"episode", # no writes to shared episode log
"send_message", # no cross-platform side effects
"cronjob", # no scheduling more work in the parent's name
]
Expand Down Expand Up @@ -3745,10 +3746,10 @@ def _build_top_level_description() -> str:
"status) and verify it yourself — fetch the URL, stat the file, read "
"back the content — before telling the user the operation succeeded.\n"
"- Leaf subagents (role='leaf', the default) CANNOT call: "
"delegate_task, clarify, memory, send_message.\n"
"delegate_task, clarify, memory, episode, send_message.\n"
"- Orchestrator subagents (role='orchestrator') retain "
"delegate_task so they can spawn their own workers, but still "
"cannot use clarify, memory, or send_message. "
"cannot use clarify, memory, episode, or send_message. "
f"Orchestrators are bounded by max_spawn_depth={max_depth} for this "
f"user and can be disabled globally via "
"delegation.orchestrator_enabled=false.\n"
Expand Down
Loading
Loading