mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-17 04:31:55 +00:00
Local customizations: vault injection (Layer 3) preserved after upstream update
This commit is contained in:
parent
4fade39c90
commit
de1a3922ed
5 changed files with 495 additions and 2 deletions
174
tests/agent/test_vault_injection.py
Normal file
174
tests/agent/test_vault_injection.py
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
"""Tests for agent/vault_injection.py — Obsidian vault auto-injection into system prompt."""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agent.vault_injection import (
|
||||
build_vault_system_prompt,
|
||||
_read_vault_file,
|
||||
_strip_yaml_frontmatter,
|
||||
WORKING_CONTEXT_CHAR_LIMIT,
|
||||
USER_PROFILE_CHAR_LIMIT,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _strip_yaml_frontmatter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestStripYamlFrontmatter:
|
||||
def test_strips_simple_frontmatter(self):
|
||||
content = "---\ndate: 2026-04-22\n---\nHello world"
|
||||
assert _strip_yaml_frontmatter(content) == "Hello world"
|
||||
|
||||
def test_no_frontmatter(self):
|
||||
content = "Just some text"
|
||||
assert _strip_yaml_frontmatter(content) == "Just some text"
|
||||
|
||||
def test_frontmatter_with_blank_lines(self):
|
||||
content = "---\ndate: 2026-04-22\nprojects: [X]\n---\n\nActual content here"
|
||||
result = _strip_yaml_frontmatter(content)
|
||||
assert result == "Actual content here"
|
||||
|
||||
def test_unclosed_frontmatter_returns_original(self):
|
||||
content = "---\ndate: 2026-04-22\nNo closing dashes"
|
||||
assert _strip_yaml_frontmatter(content) == content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _read_vault_file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestReadVaultFile:
|
||||
def test_reads_existing_file(self, tmp_path):
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("some content", encoding="utf-8")
|
||||
result = _read_vault_file(f, 4000)
|
||||
assert result == "some content"
|
||||
|
||||
def test_returns_none_for_missing_file(self, tmp_path):
|
||||
f = tmp_path / "nonexistent.md"
|
||||
result = _read_vault_file(f, 4000)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_empty_file(self, tmp_path):
|
||||
f = tmp_path / "empty.md"
|
||||
f.write_text("", encoding="utf-8")
|
||||
result = _read_vault_file(f, 4000)
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_for_whitespace_only(self, tmp_path):
|
||||
f = tmp_path / "ws.md"
|
||||
f.write_text(" \n\n ", encoding="utf-8")
|
||||
result = _read_vault_file(f, 4000)
|
||||
assert result is None
|
||||
|
||||
def test_strips_frontmatter(self, tmp_path):
|
||||
f = tmp_path / "frontmatter.md"
|
||||
f.write_text("---\ndate: 2026-04-22\n---\nReal content", encoding="utf-8")
|
||||
result = _read_vault_file(f, 4000)
|
||||
assert result == "Real content"
|
||||
|
||||
def test_truncates_long_file(self, tmp_path):
|
||||
f = tmp_path / "long.md"
|
||||
long_content = "x" * 5000
|
||||
f.write_text(long_content, encoding="utf-8")
|
||||
result = _read_vault_file(f, 100)
|
||||
assert len(result) < 200 # truncation + notice
|
||||
assert "truncated" in result
|
||||
|
||||
def test_truncation_at_newline(self, tmp_path):
|
||||
f = tmp_path / "multiline.md"
|
||||
lines = ["line " + str(i) for i in range(100)]
|
||||
content = "\n".join(lines)
|
||||
f.write_text(content, encoding="utf-8")
|
||||
# Small limit, should truncate at a newline boundary
|
||||
result = _read_vault_file(f, 50)
|
||||
assert "truncated" in result
|
||||
# Should not cut mid-line
|
||||
for line in result.split("\n"):
|
||||
if line and "truncated" not in line:
|
||||
assert line.startswith("line")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_vault_system_prompt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBuildVaultSystemPrompt:
|
||||
def test_empty_path_returns_empty(self):
|
||||
assert build_vault_system_prompt("") == ""
|
||||
|
||||
def test_nonexistent_path_returns_empty(self, tmp_path):
|
||||
assert build_vault_system_prompt(str(tmp_path / "nope")) == ""
|
||||
|
||||
def test_empty_vault_dir_returns_empty(self, tmp_path):
|
||||
assert build_vault_system_prompt(str(tmp_path)) == ""
|
||||
|
||||
def test_injects_working_context(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
agent_dir = vault / "Agent-Hermes"
|
||||
agent_dir.mkdir(parents=True)
|
||||
wc = agent_dir / "working-context.md"
|
||||
wc.write_text("---\ndate: 2026-04-22\n---\n## Current Status\n- Status: Active", encoding="utf-8")
|
||||
|
||||
result = build_vault_system_prompt(str(vault))
|
||||
assert "VAULT: WORKING CONTEXT" in result
|
||||
assert "Status: Active" in result
|
||||
|
||||
def test_injects_user_profile(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
shared_dir = vault / "Agent-Shared"
|
||||
shared_dir.mkdir(parents=True)
|
||||
up = shared_dir / "user-profile.md"
|
||||
up.write_text("# User Profile\n\nName: AJ", encoding="utf-8")
|
||||
|
||||
result = build_vault_system_prompt(str(vault))
|
||||
assert "VAULT: USER PROFILE" in result
|
||||
assert "Name: AJ" in result
|
||||
|
||||
def test_injects_both_files(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
agent_dir = vault / "Agent-Hermes"
|
||||
shared_dir = vault / "Agent-Shared"
|
||||
agent_dir.mkdir(parents=True)
|
||||
shared_dir.mkdir(parents=True)
|
||||
|
||||
(agent_dir / "working-context.md").write_text(
|
||||
"---\ndate: 2026-04-22\n---\nWorking on X", encoding="utf-8"
|
||||
)
|
||||
(shared_dir / "user-profile.md").write_text(
|
||||
"# User Profile\n\nName: AJ", encoding="utf-8"
|
||||
)
|
||||
|
||||
result = build_vault_system_prompt(str(vault))
|
||||
assert "VAULT: WORKING CONTEXT" in result
|
||||
assert "VAULT: USER PROFILE" in result
|
||||
assert "Working on X" in result
|
||||
assert "Name: AJ" in result
|
||||
|
||||
def test_skips_empty_working_context(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
agent_dir = vault / "Agent-Hermes"
|
||||
shared_dir = vault / "Agent-Shared"
|
||||
agent_dir.mkdir(parents=True)
|
||||
shared_dir.mkdir(parents=True)
|
||||
|
||||
(agent_dir / "working-context.md").write_text("", encoding="utf-8")
|
||||
(shared_dir / "user-profile.md").write_text("Name: AJ", encoding="utf-8")
|
||||
|
||||
result = build_vault_system_prompt(str(vault))
|
||||
assert "VAULT: WORKING CONTEXT" not in result
|
||||
assert "VAULT: USER PROFILE" in result
|
||||
|
||||
def test_format_matches_memory_block_style(self, tmp_path):
|
||||
vault = tmp_path / "vault"
|
||||
agent_dir = vault / "Agent-Hermes"
|
||||
agent_dir.mkdir(parents=True)
|
||||
(agent_dir / "working-context.md").write_text("Active task", encoding="utf-8")
|
||||
|
||||
result = build_vault_system_prompt(str(vault))
|
||||
# Should use the same separator as memory_tool (═══)
|
||||
assert "\u2550" in result # ═ character
|
||||
assert "VAULT: WORKING CONTEXT" in result
|
||||
161
tests/run_agent/test_vault_injection.py
Normal file
161
tests/run_agent/test_vault_injection.py
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
"""Tests for vault auto-injection integration with _build_system_prompt.
|
||||
|
||||
Verifies that vault content appears in the system prompt when vault is
|
||||
configured, and is absent otherwise.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_minimal_agent(**overrides):
|
||||
"""Create a minimal AIAgent for testing, with vault attrs settable."""
|
||||
from run_agent import AIAgent
|
||||
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
):
|
||||
a = AIAgent(
|
||||
api_key="test-k...7890",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=True,
|
||||
)
|
||||
a.client = MagicMock()
|
||||
|
||||
# Apply overrides after creation
|
||||
for k, v in overrides.items():
|
||||
setattr(a, k, v)
|
||||
|
||||
return a
|
||||
|
||||
|
||||
class TestVaultSystemPromptIntegration:
|
||||
"""Test that _build_system_prompt injects vault content when configured."""
|
||||
|
||||
def test_vault_not_injected_when_disabled(self, tmp_path):
|
||||
"""Vault content should not appear when vault_enabled=False."""
|
||||
vault_dir = tmp_path / "vault"
|
||||
agent_dir = vault_dir / "Agent-Hermes"
|
||||
agent_dir.mkdir(parents=True)
|
||||
(agent_dir / "working-context.md").write_text("Active task X", encoding="utf-8")
|
||||
|
||||
agent = _make_minimal_agent(
|
||||
_vault_enabled=False,
|
||||
_vault_path=str(vault_dir),
|
||||
)
|
||||
|
||||
prompt = agent._build_system_prompt()
|
||||
assert "VAULT: WORKING CONTEXT" not in prompt
|
||||
assert "Active task X" not in prompt
|
||||
|
||||
def test_vault_injected_when_enabled(self, tmp_path):
|
||||
"""Vault content should appear in system prompt when vault_enabled=True."""
|
||||
vault_dir = tmp_path / "vault"
|
||||
agent_dir = vault_dir / "Agent-Hermes"
|
||||
shared_dir = vault_dir / "Agent-Shared"
|
||||
agent_dir.mkdir(parents=True)
|
||||
shared_dir.mkdir(parents=True)
|
||||
(agent_dir / "working-context.md").write_text(
|
||||
"---\ndate: 2026-04-22\n---\n## Status\nActive: vault fix",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(shared_dir / "user-profile.md").write_text(
|
||||
"# User Profile\n\nName: Test User",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
agent = _make_minimal_agent(
|
||||
_vault_enabled=True,
|
||||
_vault_path=str(vault_dir),
|
||||
)
|
||||
|
||||
prompt = agent._build_system_prompt()
|
||||
assert "VAULT: WORKING CONTEXT" in prompt
|
||||
assert "Active: vault fix" in prompt
|
||||
assert "VAULT: USER PROFILE" in prompt
|
||||
assert "Name: Test User" in prompt
|
||||
|
||||
def test_vault_after_memory_blocks(self, tmp_path):
|
||||
"""Vault injection should appear after Layer 1 memory blocks."""
|
||||
# Set up memory files
|
||||
mem_dir = tmp_path / "memories"
|
||||
mem_dir.mkdir(parents=True)
|
||||
(mem_dir / "MEMORY.md").write_text("Layer 1 memory note", encoding="utf-8")
|
||||
|
||||
# Set up vault files
|
||||
vault_dir = tmp_path / "vault"
|
||||
agent_dir = vault_dir / "Agent-Hermes"
|
||||
agent_dir.mkdir(parents=True)
|
||||
(agent_dir / "working-context.md").write_text("Vault content", encoding="utf-8")
|
||||
|
||||
# Create agent with memory enabled
|
||||
from run_agent import AIAgent
|
||||
from tools.memory_tool import MemoryStore
|
||||
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=[]),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={
|
||||
"memory": {
|
||||
"memory_enabled": True,
|
||||
"user_profile_enabled": False,
|
||||
"memory_char_limit": 2200,
|
||||
"user_char_limit": 1375,
|
||||
},
|
||||
},
|
||||
),
|
||||
):
|
||||
monkeypatch_env = {}
|
||||
# Set HERMES_HOME so MemoryStore reads from tmp_path
|
||||
os.environ["HERMES_HOME"] = str(tmp_path)
|
||||
try:
|
||||
a = AIAgent(
|
||||
api_key="test-k...7890",
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=False,
|
||||
)
|
||||
a.client = MagicMock()
|
||||
finally:
|
||||
del os.environ["HERMES_HOME"]
|
||||
|
||||
a._vault_enabled = True
|
||||
a._vault_path = str(vault_dir)
|
||||
|
||||
prompt = a._build_system_prompt()
|
||||
mem_pos = prompt.find("MEMORY (your personal notes)")
|
||||
vault_pos = prompt.find("VAULT: WORKING CONTEXT")
|
||||
assert mem_pos > 0, "Layer 1 memory block not found in prompt"
|
||||
assert vault_pos > 0, "Vault block not found in prompt"
|
||||
assert mem_pos < vault_pos, "Vault should appear after Layer 1 memory"
|
||||
|
||||
def test_missing_vault_path_graceful(self, tmp_path):
|
||||
"""Agent works fine even if vault path doesn't exist."""
|
||||
agent = _make_minimal_agent(
|
||||
_vault_enabled=True,
|
||||
_vault_path="/nonexistent/vault/path",
|
||||
)
|
||||
|
||||
# Should not crash
|
||||
prompt = agent._build_system_prompt()
|
||||
assert "VAULT:" not in prompt
|
||||
|
||||
def test_no_vault_config_graceful(self):
|
||||
"""Agent works fine with no vault set (defaults)."""
|
||||
agent = _make_minimal_agent(
|
||||
_vault_enabled=False,
|
||||
_vault_path="",
|
||||
)
|
||||
|
||||
prompt = agent._build_system_prompt()
|
||||
assert "VAULT:" not in prompt
|
||||
Loading…
Add table
Add a link
Reference in a new issue