mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix(honcho): plugin drift overhaul -- observation config, chunking, setup wizard, docs, dead code cleanup
Salvaged from PR #5045 by erosika. - Replace memoryMode/peer_memory_modes with granular per-peer observation config - Add message chunking for Honcho API limits (25k chars default) - Add dialectic input guard (10k chars default) - Add dialecticDynamic toggle for reasoning level auto-bump - Rewrite setup wizard with cloud/local deployment picker - Switch peer card/profile/search from session.context() to direct peer APIs - Add server-side observation sync via get_peer_configuration() - Fix base_url/baseUrl config mismatch for self-hosted setups - Fix local auth leak (cloud API keys no longer sent to local instances) - Remove dead code: memoryMode, peer_memory_modes, linkedHosts, suppress flags, SOUL.md aiPeer sync - Add post_setup hook to memory_setup.py for provider-specific setup wizards - Comprehensive README rewrite with full config reference - New optional skill: autonomous-ai-agents/honcho - Expanded memory-providers.md with multi-profile docs - 9 new tests (chunking, dialectic guard, peer lookups), 14 dead tests removed - Fix 2 pre-existing TestResolveConfigPath filesystem isolation failures
This commit is contained in:
parent
12724e6295
commit
c02c3dc723
12 changed files with 1265 additions and 443 deletions
|
|
@ -2,13 +2,11 @@
|
|||
|
||||
Covers:
|
||||
- write_frequency parsing (async / turn / session / int)
|
||||
- memory_mode parsing
|
||||
- resolve_session_name with session_title
|
||||
- HonchoSessionManager.save() routing per write_frequency
|
||||
- async writer thread lifecycle and retry
|
||||
- flush_all() drains pending messages
|
||||
- shutdown() joins the thread
|
||||
- memory_mode gating helpers (unit-level)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -42,10 +40,9 @@ def _make_session(**kwargs) -> HonchoSession:
|
|||
)
|
||||
|
||||
|
||||
def _make_manager(write_frequency="turn", memory_mode="hybrid") -> HonchoSessionManager:
|
||||
def _make_manager(write_frequency="turn") -> HonchoSessionManager:
|
||||
cfg = HonchoClientConfig(
|
||||
write_frequency=write_frequency,
|
||||
memory_mode=memory_mode,
|
||||
api_key="test-key",
|
||||
enabled=True,
|
||||
)
|
||||
|
|
@ -106,77 +103,6 @@ class TestWriteFrequencyParsing:
|
|||
assert cfg.write_frequency == "async"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# memory_mode parsing from config file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMemoryModeParsing:
|
||||
def test_hybrid(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "memoryMode": "hybrid"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
|
||||
def test_honcho_only(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k", "memoryMode": "honcho"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "honcho"
|
||||
|
||||
def test_defaults_to_hybrid(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({"apiKey": "k"}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
|
||||
def test_host_block_overrides_root(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"memoryMode": "hybrid",
|
||||
"hosts": {"hermes": {"memoryMode": "honcho"}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "honcho"
|
||||
|
||||
def test_object_form_sets_default_and_overrides(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"hosts": {"hermes": {"memoryMode": {
|
||||
"default": "hybrid",
|
||||
"hermes": "honcho",
|
||||
}}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
assert cfg.peer_memory_mode("hermes") == "honcho"
|
||||
assert cfg.peer_memory_mode("unknown") == "hybrid" # falls through to default
|
||||
|
||||
def test_object_form_no_default_falls_back_to_hybrid(self, tmp_path):
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"hosts": {"hermes": {"memoryMode": {"hermes": "honcho"}}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
assert cfg.peer_memory_mode("hermes") == "honcho"
|
||||
assert cfg.peer_memory_mode("other") == "hybrid"
|
||||
|
||||
def test_global_string_host_object_override(self, tmp_path):
|
||||
"""Host object form overrides global string."""
|
||||
cfg_file = tmp_path / "config.json"
|
||||
cfg_file.write_text(json.dumps({
|
||||
"apiKey": "k",
|
||||
"memoryMode": "honcho",
|
||||
"hosts": {"hermes": {"memoryMode": {"default": "hybrid", "hermes": "honcho"}}},
|
||||
}))
|
||||
cfg = HonchoClientConfig.from_global_config(config_path=cfg_file)
|
||||
assert cfg.memory_mode == "hybrid" # host default wins over global "honcho"
|
||||
assert cfg.peer_memory_mode("hermes") == "honcho"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_session_name with session_title
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -519,27 +445,10 @@ class TestNewConfigFieldDefaults:
|
|||
cfg = HonchoClientConfig()
|
||||
assert cfg.write_frequency == "async"
|
||||
|
||||
def test_memory_mode_default(self):
|
||||
cfg = HonchoClientConfig()
|
||||
assert cfg.memory_mode == "hybrid"
|
||||
|
||||
def test_write_frequency_set(self):
|
||||
cfg = HonchoClientConfig(write_frequency="turn")
|
||||
assert cfg.write_frequency == "turn"
|
||||
|
||||
def test_memory_mode_set(self):
|
||||
cfg = HonchoClientConfig(memory_mode="honcho")
|
||||
assert cfg.memory_mode == "honcho"
|
||||
|
||||
def test_peer_memory_mode_falls_back_to_global(self):
|
||||
cfg = HonchoClientConfig(memory_mode="honcho")
|
||||
assert cfg.peer_memory_mode("any-peer") == "honcho"
|
||||
|
||||
def test_peer_memory_mode_override(self):
|
||||
cfg = HonchoClientConfig(memory_mode="hybrid", peer_memory_modes={"hermes": "honcho"})
|
||||
assert cfg.peer_memory_mode("hermes") == "honcho"
|
||||
assert cfg.peer_memory_mode("other") == "hybrid"
|
||||
|
||||
|
||||
class TestPrefetchCacheAccessors:
|
||||
def test_set_and_pop_context_result(self):
|
||||
|
|
|
|||
|
|
@ -30,7 +30,6 @@ class TestHonchoClientConfigDefaults:
|
|||
assert config.session_strategy == "per-directory"
|
||||
assert config.recall_mode == "hybrid"
|
||||
assert config.session_peer_prefix is False
|
||||
assert config.linked_hosts == []
|
||||
assert config.sessions == {}
|
||||
|
||||
|
||||
|
|
@ -106,7 +105,6 @@ class TestFromGlobalConfig:
|
|||
"hermes": {
|
||||
"workspace": "override-ws",
|
||||
"aiPeer": "override-ai",
|
||||
"linkedHosts": ["cursor"],
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
|
@ -116,7 +114,6 @@ class TestFromGlobalConfig:
|
|||
# Host block workspace overrides root workspace
|
||||
assert config.workspace_id == "override-ws"
|
||||
assert config.ai_peer == "override-ai"
|
||||
assert config.linked_hosts == ["cursor"]
|
||||
assert config.environment == "staging"
|
||||
assert config.peer_name == "alice"
|
||||
assert config.enabled is True
|
||||
|
|
@ -297,41 +294,6 @@ class TestResolveSessionName:
|
|||
assert result == "custom-session"
|
||||
|
||||
|
||||
class TestGetLinkedWorkspaces:
|
||||
def test_resolves_linked_hosts(self):
|
||||
config = HonchoClientConfig(
|
||||
workspace_id="hermes-ws",
|
||||
linked_hosts=["cursor", "windsurf"],
|
||||
raw={
|
||||
"hosts": {
|
||||
"cursor": {"workspace": "cursor-ws"},
|
||||
"windsurf": {"workspace": "windsurf-ws"},
|
||||
}
|
||||
},
|
||||
)
|
||||
workspaces = config.get_linked_workspaces()
|
||||
assert "cursor-ws" in workspaces
|
||||
assert "windsurf-ws" in workspaces
|
||||
|
||||
def test_excludes_own_workspace(self):
|
||||
config = HonchoClientConfig(
|
||||
workspace_id="hermes-ws",
|
||||
linked_hosts=["other"],
|
||||
raw={"hosts": {"other": {"workspace": "hermes-ws"}}},
|
||||
)
|
||||
workspaces = config.get_linked_workspaces()
|
||||
assert workspaces == []
|
||||
|
||||
def test_uses_host_key_as_fallback(self):
|
||||
config = HonchoClientConfig(
|
||||
workspace_id="hermes-ws",
|
||||
linked_hosts=["cursor"],
|
||||
raw={"hosts": {"cursor": {}}}, # no workspace field
|
||||
)
|
||||
workspaces = config.get_linked_workspaces()
|
||||
assert "cursor" in workspaces
|
||||
|
||||
|
||||
class TestResolveConfigPath:
|
||||
def test_prefers_hermes_home_when_exists(self, tmp_path):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
|
|
@ -346,14 +308,22 @@ class TestResolveConfigPath:
|
|||
def test_falls_back_to_global_when_no_local(self, tmp_path):
|
||||
hermes_home = tmp_path / "hermes"
|
||||
hermes_home.mkdir()
|
||||
# No honcho.json in HERMES_HOME
|
||||
# No honcho.json in HERMES_HOME — also isolate ~/.hermes so
|
||||
# the default-profile fallback doesn't hit the real filesystem.
|
||||
fake_home = tmp_path / "fakehome"
|
||||
fake_home.mkdir()
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}):
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(hermes_home)}), \
|
||||
patch.object(Path, "home", return_value=fake_home):
|
||||
result = resolve_config_path()
|
||||
assert result == GLOBAL_CONFIG_PATH
|
||||
|
||||
def test_falls_back_to_global_without_hermes_home_env(self):
|
||||
with patch.dict(os.environ, {}, clear=False):
|
||||
def test_falls_back_to_global_without_hermes_home_env(self, tmp_path):
|
||||
fake_home = tmp_path / "fakehome"
|
||||
fake_home.mkdir()
|
||||
|
||||
with patch.dict(os.environ, {}, clear=False), \
|
||||
patch.object(Path, "home", return_value=fake_home):
|
||||
os.environ.pop("HERMES_HOME", None)
|
||||
result = resolve_config_path()
|
||||
assert result == GLOBAL_CONFIG_PATH
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"""Tests for plugins/memory/honcho/session.py — HonchoSession and helpers."""
|
||||
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from plugins.memory.honcho.session import (
|
||||
HonchoSession,
|
||||
HonchoSessionManager,
|
||||
)
|
||||
from plugins.memory.honcho import HonchoMemoryProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -187,3 +189,175 @@ class TestManagerCacheOps:
|
|||
assert keys == {"k1", "k2"}
|
||||
s1_info = next(s for s in sessions if s["key"] == "k1")
|
||||
assert s1_info["message_count"] == 1
|
||||
|
||||
|
||||
class TestPeerLookupHelpers:
|
||||
def _make_cached_manager(self):
|
||||
mgr = HonchoSessionManager()
|
||||
session = HonchoSession(
|
||||
key="telegram:123",
|
||||
user_peer_id="robert",
|
||||
assistant_peer_id="hermes",
|
||||
honcho_session_id="telegram-123",
|
||||
)
|
||||
mgr._cache[session.key] = session
|
||||
return mgr, session
|
||||
|
||||
def test_get_peer_card_uses_direct_peer_lookup(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
user_peer = MagicMock()
|
||||
user_peer.get_card.return_value = ["Name: Robert"]
|
||||
mgr._get_or_create_peer = MagicMock(return_value=user_peer)
|
||||
|
||||
assert mgr.get_peer_card(session.key) == ["Name: Robert"]
|
||||
user_peer.get_card.assert_called_once_with()
|
||||
|
||||
def test_search_context_uses_peer_context_response(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
user_peer = MagicMock()
|
||||
user_peer.context.return_value = SimpleNamespace(
|
||||
representation="Robert runs neuralancer",
|
||||
peer_card=["Location: Melbourne"],
|
||||
)
|
||||
mgr._get_or_create_peer = MagicMock(return_value=user_peer)
|
||||
|
||||
result = mgr.search_context(session.key, "neuralancer")
|
||||
|
||||
assert "Robert runs neuralancer" in result
|
||||
assert "- Location: Melbourne" in result
|
||||
user_peer.context.assert_called_once_with(search_query="neuralancer")
|
||||
|
||||
def test_get_prefetch_context_fetches_user_and_ai_from_peer_api(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
user_peer = MagicMock()
|
||||
user_peer.context.return_value = SimpleNamespace(
|
||||
representation="User representation",
|
||||
peer_card=["Name: Robert"],
|
||||
)
|
||||
ai_peer = MagicMock()
|
||||
ai_peer.context.return_value = SimpleNamespace(
|
||||
representation="AI representation",
|
||||
peer_card=["Owner: Robert"],
|
||||
)
|
||||
mgr._get_or_create_peer = MagicMock(side_effect=[user_peer, ai_peer])
|
||||
|
||||
result = mgr.get_prefetch_context(session.key)
|
||||
|
||||
assert result == {
|
||||
"representation": "User representation",
|
||||
"card": "Name: Robert",
|
||||
"ai_representation": "AI representation",
|
||||
"ai_card": "Owner: Robert",
|
||||
}
|
||||
user_peer.context.assert_called_once_with()
|
||||
ai_peer.context.assert_called_once_with()
|
||||
|
||||
def test_get_ai_representation_uses_peer_api(self):
|
||||
mgr, session = self._make_cached_manager()
|
||||
ai_peer = MagicMock()
|
||||
ai_peer.context.return_value = SimpleNamespace(
|
||||
representation="AI representation",
|
||||
peer_card=["Owner: Robert"],
|
||||
)
|
||||
mgr._get_or_create_peer = MagicMock(return_value=ai_peer)
|
||||
|
||||
result = mgr.get_ai_representation(session.key)
|
||||
|
||||
assert result == {
|
||||
"representation": "AI representation",
|
||||
"card": "Owner: Robert",
|
||||
}
|
||||
ai_peer.context.assert_called_once_with()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Message chunking
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestChunkMessage:
|
||||
def test_short_message_single_chunk(self):
|
||||
result = HonchoMemoryProvider._chunk_message("hello world", 100)
|
||||
assert result == ["hello world"]
|
||||
|
||||
def test_exact_limit_single_chunk(self):
|
||||
msg = "x" * 100
|
||||
result = HonchoMemoryProvider._chunk_message(msg, 100)
|
||||
assert result == [msg]
|
||||
|
||||
def test_splits_at_paragraph_boundary(self):
|
||||
msg = "first paragraph.\n\nsecond paragraph."
|
||||
# limit=30: total is 35, forces split; second chunk with prefix is 29, fits
|
||||
result = HonchoMemoryProvider._chunk_message(msg, 30)
|
||||
assert len(result) == 2
|
||||
assert result[0] == "first paragraph."
|
||||
assert result[1] == "[continued] second paragraph."
|
||||
|
||||
def test_splits_at_sentence_boundary(self):
|
||||
msg = "First sentence. Second sentence. Third sentence is here."
|
||||
result = HonchoMemoryProvider._chunk_message(msg, 35)
|
||||
assert len(result) >= 2
|
||||
# First chunk should end at a sentence boundary (rstripped)
|
||||
assert result[0].rstrip().endswith(".")
|
||||
|
||||
def test_splits_at_word_boundary(self):
|
||||
msg = "word " * 20 # 100 chars
|
||||
result = HonchoMemoryProvider._chunk_message(msg, 30)
|
||||
assert len(result) >= 2
|
||||
# No words should be split mid-word
|
||||
for chunk in result:
|
||||
clean = chunk.replace("[continued] ", "")
|
||||
assert not clean.startswith(" ")
|
||||
|
||||
def test_continuation_prefix(self):
|
||||
msg = "a" * 200
|
||||
result = HonchoMemoryProvider._chunk_message(msg, 50)
|
||||
assert len(result) >= 2
|
||||
assert not result[0].startswith("[continued]")
|
||||
for chunk in result[1:]:
|
||||
assert chunk.startswith("[continued] ")
|
||||
|
||||
def test_empty_message(self):
|
||||
result = HonchoMemoryProvider._chunk_message("", 100)
|
||||
assert result == [""]
|
||||
|
||||
def test_large_message_many_chunks(self):
|
||||
msg = "word " * 10000 # 50k chars
|
||||
result = HonchoMemoryProvider._chunk_message(msg, 25000)
|
||||
assert len(result) >= 2
|
||||
for chunk in result:
|
||||
assert len(chunk) <= 25000
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dialectic input guard
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDialecticInputGuard:
|
||||
def test_long_query_truncated(self):
|
||||
"""Queries exceeding dialectic_max_input_chars are truncated."""
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
cfg = HonchoClientConfig(dialectic_max_input_chars=100)
|
||||
mgr = HonchoSessionManager(config=cfg)
|
||||
mgr._dialectic_max_input_chars = 100
|
||||
|
||||
# Create a cached session so dialectic_query doesn't bail early
|
||||
session = HonchoSession(
|
||||
key="test", user_peer_id="u", assistant_peer_id="a",
|
||||
honcho_session_id="s",
|
||||
)
|
||||
mgr._cache["test"] = session
|
||||
|
||||
# Mock the peer to capture the query
|
||||
mock_peer = MagicMock()
|
||||
mock_peer.chat.return_value = "answer"
|
||||
mgr._get_or_create_peer = MagicMock(return_value=mock_peer)
|
||||
|
||||
long_query = "word " * 100 # 500 chars, exceeds 100 limit
|
||||
mgr.dialectic_query("test", long_query)
|
||||
|
||||
# The query passed to chat() should be truncated
|
||||
actual_query = mock_peer.chat.call_args[0][0]
|
||||
assert len(actual_query) <= 100
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue