feat(memory): pluggable memory provider interface with profile isolation, review fixes, and honcho CLI restoration (#4623)
* feat(memory): add pluggable memory provider interface with profile isolation Introduces a pluggable MemoryProvider ABC so external memory backends can integrate with Hermes without modifying core files. Each backend becomes a plugin implementing a standard interface, orchestrated by MemoryManager. Key architecture: - agent/memory_provider.py — ABC with core + optional lifecycle hooks - agent/memory_manager.py — single integration point in the agent loop - agent/builtin_memory_provider.py — wraps existing MEMORY.md/USER.md Profile isolation fixes applied to all 6 shipped plugins: - Cognitive Memory: use get_hermes_home() instead of raw env var - Hindsight Memory: check $HERMES_HOME/hindsight/config.json first, fall back to legacy ~/.hindsight/ for backward compat - Hermes Memory Store: replace hardcoded ~/.hermes paths with get_hermes_home() for config loading and DB path defaults - Mem0 Memory: use get_hermes_home() instead of raw env var - RetainDB Memory: auto-derive profile-scoped project name from hermes_home path (hermes-<profile>), explicit env var overrides - OpenViking Memory: read-only, no local state, isolation via .env MemoryManager.initialize_all() now injects hermes_home into kwargs so every provider can resolve profile-scoped storage without importing get_hermes_home() themselves. Plugin system: adds register_memory_provider() to PluginContext and get_plugin_memory_providers() accessor. Based on PR #3825. 46 tests (37 unit + 5 E2E + 4 plugin registration). * refactor(memory): drop cognitive plugin, rewrite OpenViking as full provider Remove cognitive-memory plugin (#727) — core mechanics are broken: decay runs 24x too fast (hourly not daily), prefetch uses row ID as timestamp, search limited by importance not similarity. Rewrite openviking-memory plugin from a read-only search wrapper into a full bidirectional memory provider using the complete OpenViking session lifecycle API: - sync_turn: records user/assistant messages to OpenViking session (threaded, non-blocking) - on_session_end: commits session to trigger automatic memory extraction into 6 categories (profile, preferences, entities, events, cases, patterns) - prefetch: background semantic search via find() endpoint - on_memory_write: mirrors built-in memory writes to the session - is_available: checks env var only, no network calls (ABC compliance) Tools expanded from 3 to 5: - viking_search: semantic search with mode/scope/limit - viking_read: tiered content (abstract ~100tok / overview ~2k / full) - viking_browse: filesystem-style navigation (list/tree/stat) - viking_remember: explicit memory storage via session - viking_add_resource: ingest URLs/docs into knowledge base Uses direct HTTP via httpx (no openviking SDK dependency needed). Response truncation on viking_read to prevent context flooding. * fix(memory): harden Mem0 plugin — thread safety, non-blocking sync, circuit breaker - Remove redundant mem0_context tool (identical to mem0_search with rerank=true, top_k=5 — wastes a tool slot and confuses the model) - Thread sync_turn so it's non-blocking — Mem0's server-side LLM extraction can take 5-10s, was stalling the agent after every turn - Add threading.Lock around _get_client() for thread-safe lazy init (prefetch and sync threads could race on first client creation) - Add circuit breaker: after 5 consecutive API failures, pause calls for 120s instead of hammering a down server every turn. Auto-resets after cooldown. Logs a warning when tripped. - Track success/failure in prefetch, sync_turn, and all tool calls - Wait for previous sync to finish before starting a new one (prevents unbounded thread accumulation on rapid turns) - Clean up shutdown to join both prefetch and sync threads * fix(memory): enforce single external memory provider limit MemoryManager now rejects a second non-builtin provider with a warning. Built-in memory (MEMORY.md/USER.md) is always accepted. Only ONE external plugin provider is allowed at a time. This prevents tool schema bloat (some providers add 3-5 tools each) and conflicting memory backends. The warning message directs users to configure memory.provider in config.yaml to select which provider to activate. Updated all 47 tests to use builtin + one external pattern instead of multiple externals. Added test_second_external_rejected to verify the enforcement. * feat(memory): add ByteRover memory provider plugin Implements the ByteRover integration (from PR #3499 by hieuntg81) as a MemoryProvider plugin instead of direct run_agent.py modifications. ByteRover provides persistent memory via the brv CLI — a hierarchical knowledge tree with tiered retrieval (fuzzy text then LLM-driven search). Local-first with optional cloud sync. Plugin capabilities: - prefetch: background brv query for relevant context - sync_turn: curate conversation turns (threaded, non-blocking) - on_memory_write: mirror built-in memory writes to brv - on_pre_compress: extract insights before context compression Tools (3): - brv_query: search the knowledge tree - brv_curate: store facts/decisions/patterns - brv_status: check CLI version and context tree state Profile isolation: working directory at $HERMES_HOME/byterover/ (scoped per profile). Binary resolution cached with thread-safe double-checked locking. All write operations threaded to avoid blocking the agent (curate can take 120s with LLM processing). * fix(memory): thread remaining sync_turns, fix holographic, add config key Plugin fixes: - Hindsight: thread sync_turn (was blocking up to 30s via _run_in_thread) - RetainDB: thread sync_turn (was blocking on HTTP POST) - Both: shutdown now joins sync threads alongside prefetch threads Holographic retrieval fixes: - reason(): removed dead intersection_key computation (bundled but never used in scoring). Now reuses pre-computed entity_residuals directly, moved role_content encoding outside the inner loop. - contradict(): added _MAX_CONTRADICT_FACTS=500 scaling guard. Above 500 facts, only checks the most recently updated ones to avoid O(n^2) explosion (~125K comparisons at 500 is acceptable). Config: - Added memory.provider key to DEFAULT_CONFIG ("" = builtin only). No version bump needed (deep_merge handles new keys automatically). * feat(memory): extract Honcho as a MemoryProvider plugin Creates plugins/honcho-memory/ as a thin adapter over the existing honcho_integration/ package. All 4 Honcho tools (profile, search, context, conclude) move from the normal tool registry to the MemoryProvider interface. The plugin delegates all work to HonchoSessionManager — no Honcho logic is reimplemented. It uses the existing config chain: $HERMES_HOME/honcho.json -> ~/.honcho/config.json -> env vars. Lifecycle hooks: - initialize: creates HonchoSessionManager via existing client factory - prefetch: background dialectic query - sync_turn: records messages + flushes to API (threaded) - on_memory_write: mirrors user profile writes as conclusions - on_session_end: flushes all pending messages This is a prerequisite for the MemoryManager wiring in run_agent.py. Once wired, Honcho goes through the same provider interface as all other memory plugins, and the scattered Honcho code in run_agent.py can be consolidated into the single MemoryManager integration point. * feat(memory): wire MemoryManager into run_agent.py Adds 8 integration points for the external memory provider plugin, all purely additive (zero existing code modified): 1. Init (~L1130): Create MemoryManager, find matching plugin provider from memory.provider config, initialize with session context 2. Tool injection (~L1160): Append provider tool schemas to self.tools and self.valid_tool_names after memory_manager init 3. System prompt (~L2705): Add external provider's system_prompt_block alongside existing MEMORY.md/USER.md blocks 4. Tool routing (~L5362): Route provider tool calls through memory_manager.handle_tool_call() before the catchall handler 5. Memory write bridge (~L5353): Notify external provider via on_memory_write() when the built-in memory tool writes 6. Pre-compress (~L5233): Call on_pre_compress() before context compression discards messages 7. Prefetch (~L6421): Inject provider prefetch results into the current-turn user message (same pattern as Honcho turn context) 8. Turn sync + session end (~L8161, ~L8172): sync_all() after each completed turn, queue_prefetch_all() for next turn, on_session_end() + shutdown_all() at conversation end All hooks are wrapped in try/except — a failing provider never breaks the agent. The existing memory system, Honcho integration, and all other code paths are completely untouched. Full suite: 7222 passed, 4 pre-existing failures. * refactor(memory): remove legacy Honcho integration from core Extracts all Honcho-specific code from run_agent.py, model_tools.py, toolsets.py, and gateway/run.py. Honcho is now exclusively available as a memory provider plugin (plugins/honcho-memory/). Removed from run_agent.py (-457 lines): - Honcho init block (session manager creation, activation, config) - 8 Honcho methods: _honcho_should_activate, _strip_honcho_tools, _activate_honcho, _register_honcho_exit_hook, _queue_honcho_prefetch, _honcho_prefetch, _honcho_save_user_observation, _honcho_sync - _inject_honcho_turn_context module-level function - Honcho system prompt block (tool descriptions, CLI commands) - Honcho context injection in api_messages building - Honcho params from __init__ (honcho_session_key, honcho_manager, honcho_config) - HONCHO_TOOL_NAMES constant - All honcho-specific tool dispatch forwarding Removed from other files: - model_tools.py: honcho_tools import, honcho params from handle_function_call - toolsets.py: honcho toolset definition, honcho tools from core tools list - gateway/run.py: honcho params from AIAgent constructor calls Removed tests (-339 lines): - 9 Honcho-specific test methods from test_run_agent.py - TestHonchoAtexitFlush class from test_exit_cleanup_interrupt.py Restored two regex constants (_SURROGATE_RE, _BUDGET_WARNING_RE) that were accidentally removed during the honcho function extraction. The honcho_integration/ package is kept intact — the plugin delegates to it. tools/honcho_tools.py registry entries are now dead code (import commented out in model_tools.py) but the file is preserved for reference. Full suite: 7207 passed, 4 pre-existing failures. Zero regressions. * refactor(memory): restructure plugins, add CLI, clean gateway, migration notice Plugin restructure: - Move all memory plugins from plugins/<name>-memory/ to plugins/memory/<name>/ (byterover, hindsight, holographic, honcho, mem0, openviking, retaindb) - New plugins/memory/__init__.py discovery module that scans the directory directly, loading providers by name without the general plugin system - run_agent.py uses load_memory_provider() instead of get_plugin_memory_providers() CLI wiring: - hermes memory setup — interactive curses picker + config wizard - hermes memory status — show active provider, config, availability - hermes memory off — disable external provider (built-in only) - hermes honcho — now shows migration notice pointing to hermes memory setup Gateway cleanup: - Remove _get_or_create_gateway_honcho (already removed in prev commit) - Remove _shutdown_gateway_honcho and _shutdown_all_gateway_honcho methods - Remove all calls to shutdown methods (4 call sites) - Remove _honcho_managers/_honcho_configs dict references Dead code removal: - Delete tools/honcho_tools.py (279 lines, import was already commented out) - Delete tests/gateway/test_honcho_lifecycle.py (131 lines, tested removed methods) - Remove if False placeholder from run_agent.py Migration: - Honcho migration notice on startup: detects existing honcho.json or ~/.honcho/config.json, prints guidance to run hermes memory setup. Only fires when memory.provider is not set and not in quiet mode. Full suite: 7203 passed, 4 pre-existing failures. Zero regressions. * feat(memory): standardize plugin config + add per-plugin documentation Config architecture: - Add save_config(values, hermes_home) to MemoryProvider ABC - Honcho: writes to $HERMES_HOME/honcho.json (SDK native) - Mem0: writes to $HERMES_HOME/mem0.json - Hindsight: writes to $HERMES_HOME/hindsight/config.json - Holographic: writes to config.yaml under plugins.hermes-memory-store - OpenViking/RetainDB/ByteRover: env-var only (default no-op) Setup wizard (hermes memory setup): - Now calls provider.save_config() for non-secret config - Secrets still go to .env via env vars - Only memory.provider activation key goes to config.yaml Documentation: - README.md for each of the 7 providers in plugins/memory/<name>/ - Requirements, setup (wizard + manual), config reference, tools table - Consistent format across all providers The contract for new memory plugins: - get_config_schema() declares all fields (REQUIRED) - save_config() writes native config (REQUIRED if not env-var-only) - Secrets use env_var field in schema, written to .env by wizard - README.md in the plugin directory * docs: add memory providers user guide + developer guide New pages: - user-guide/features/memory-providers.md — comprehensive guide covering all 7 shipped providers (Honcho, OpenViking, Mem0, Hindsight, Holographic, RetainDB, ByteRover). Each with setup, config, tools, cost, and unique features. Includes comparison table and profile isolation notes. - developer-guide/memory-provider-plugin.md — how to build a new memory provider plugin. Covers ABC, required methods, config schema, save_config, threading contract, profile isolation, testing. Updated pages: - user-guide/features/memory.md — replaced Honcho section with link to new Memory Providers page - user-guide/features/honcho.md — replaced with migration redirect to the new Memory Providers page - sidebars.ts — added both new pages to navigation * fix(memory): auto-migrate Honcho users to memory provider plugin When honcho.json or ~/.honcho/config.json exists but memory.provider is not set, automatically set memory.provider: honcho in config.yaml and activate the plugin. The plugin reads the same config files, so all data and credentials are preserved. Zero user action needed. Persists the migration to config.yaml so it only fires once. Prints a one-line confirmation in non-quiet mode. * fix(memory): only auto-migrate Honcho when enabled + credentialed Check HonchoClientConfig.enabled AND (api_key OR base_url) before auto-migrating — not just file existence. Prevents false activation for users who disabled Honcho, stopped using it (config lingers), or have ~/.honcho/ from a different tool. * feat(memory): auto-install pip dependencies during hermes memory setup Reads pip_dependencies from plugin.yaml, checks which are missing, installs them via pip before config walkthrough. Also shows install guidance for external_dependencies (e.g. brv CLI for ByteRover). Updated all 7 plugin.yaml files with pip_dependencies: - honcho: honcho-ai - mem0: mem0ai - openviking: httpx - hindsight: hindsight-client - holographic: (none) - retaindb: requests - byterover: (external_dependencies for brv CLI) * fix: remove remaining Honcho crash risks from cli.py and gateway cli.py: removed Honcho session re-mapping block (would crash importing deleted tools/honcho_tools.py), Honcho flush on compress, Honcho session display on startup, Honcho shutdown on exit, honcho_session_key AIAgent param. gateway/run.py: removed honcho_session_key params from helper methods, sync_honcho param, _honcho.shutdown() block. tests: fixed test_cron_session_with_honcho_key_skipped (was passing removed honcho_key param to _flush_memories_for_session). * fix: include plugins/ in pyproject.toml package list Without this, plugins/memory/ wouldn't be included in non-editable installs. Hermes always runs from the repo checkout so this is belt- and-suspenders, but prevents breakage if the install method changes. * fix(memory): correct pip-to-import name mapping for dep checks The heuristic dep.replace('-', '_') fails for packages where the pip name differs from the import name: honcho-ai→honcho, mem0ai→mem0, hindsight-client→hindsight_client. Added explicit mapping table so hermes memory setup doesn't try to reinstall already-installed packages. * chore: remove dead code from old plugin memory registration path - hermes_cli/plugins.py: removed register_memory_provider(), _memory_providers list, get_plugin_memory_providers() — memory providers now use plugins/memory/ discovery, not the general plugin system - hermes_cli/main.py: stripped 74 lines of dead honcho argparse subparsers (setup, status, sessions, map, peer, mode, tokens, identity, migrate) — kept only the migration redirect - agent/memory_provider.py: updated docstring to reflect new registration path - tests: replaced TestPluginMemoryProviderRegistration with TestPluginMemoryDiscovery that tests the actual plugins/memory/ discovery system. Added 3 new tests (discover, load, nonexistent). * chore: delete dead honcho_integration/cli.py and its tests cli.py (794 lines) was the old 'hermes honcho' command handler — nobody calls it since cmd_honcho was replaced with a migration redirect. Deleted tests that imported from removed code: - tests/honcho_integration/test_cli.py (tested _resolve_api_key) - tests/honcho_integration/test_config_isolation.py (tested CLI config paths) - tests/tools/test_honcho_tools.py (tested the deleted tools/honcho_tools.py) Remaining honcho_integration/ files (actively used by the plugin): - client.py (445 lines) — config loading, SDK client creation - session.py (991 lines) — session management, queries, flush * refactor: move honcho_integration/ into the honcho plugin Moves client.py (445 lines) and session.py (991 lines) from the top-level honcho_integration/ package into plugins/memory/honcho/. No Honcho code remains in the main codebase. - plugins/memory/honcho/client.py — config loading, SDK client creation - plugins/memory/honcho/session.py — session management, queries, flush - Updated all imports: run_agent.py (auto-migration), hermes_cli/doctor.py, plugin __init__.py, session.py cross-import, all tests - Removed honcho_integration/ package and pyproject.toml entry - Renamed tests/honcho_integration/ → tests/honcho_plugin/ * docs: update architecture + gateway-internals for memory provider system - architecture.md: replaced honcho_integration/ with plugins/memory/ - gateway-internals.md: replaced Honcho-specific session routing and flush lifecycle docs with generic memory provider interface docs * fix: update stale mock path for resolve_active_host after honcho plugin migration * fix(memory): address review feedback — P0 lifecycle, ABC contract, honcho CLI restore Review feedback from Honcho devs (erosika): P0 — Provider lifecycle: - Remove on_session_end() + shutdown_all() from run_conversation() tail (was killing providers after every turn in multi-turn sessions) - Add shutdown_memory_provider() method on AIAgent for callers - Wire shutdown into CLI atexit, reset_conversation, gateway stop/expiry Bug fixes: - Remove sync_honcho=False kwarg from /btw callsites (TypeError crash) - Fix doctor.py references to dead 'hermes honcho setup' command - Cache prefetch_all() before tool loop (was re-calling every iteration) ABC contract hardening (all backwards-compatible): - Add session_id kwarg to prefetch/sync_turn/queue_prefetch - Make on_pre_compress() return str (provider insights in compression) - Add **kwargs to on_turn_start() for runtime context - Add on_delegation() hook for parent-side subagent observation - Document agent_context/agent_identity/agent_workspace kwargs on initialize() (prevents cron corruption, enables profile scoping) - Fix docstring: single external provider, not multiple Honcho CLI restoration: - Add plugins/memory/honcho/cli.py (from main's honcho_integration/cli.py with imports adapted to plugin path) - Restore full hermes honcho command with all subcommands (status, peer, mode, tokens, identity, enable/disable, sync, peers, --target-profile) - Restore auto-clone on profile creation + sync on hermes update - hermes honcho setup now redirects to hermes memory setup * fix(memory): wire on_delegation, skip_memory for cron/flush, fix ByteRover return type - Wire on_delegation() in delegate_tool.py — parent's memory provider is notified with task+result after each subagent completes - Add skip_memory=True to cron scheduler (prevents cron system prompts from corrupting user representations — closes #4052) - Add skip_memory=True to gateway flush agent (throwaway agent shouldn't activate memory provider) - Fix ByteRover on_pre_compress() return type: None -> str * fix(honcho): port profile isolation fixes from PR #4632 Ports 5 bug fixes found during profile testing (erosika's PR #4632): 1. 3-tier config resolution — resolve_config_path() now checks $HERMES_HOME/honcho.json → ~/.hermes/honcho.json → ~/.honcho/config.json (non-default profiles couldn't find shared host blocks) 2. Thread host=_host_key() through from_global_config() in cmd_setup, cmd_status, cmd_identity (--target-profile was being ignored) 3. Use bare profile name as aiPeer (not host key with dots) — Honcho's peer ID pattern is ^[a-zA-Z0-9_-]+$, dots are invalid 4. Wrap add_peers() in try/except — was fatal on new AI peers, killed all message uploads for the session 5. Gate Honcho clone behind --clone/--clone-all on profile create (bare create should be blank-slate) Also: sanitize assistant_peer_id via _sanitize_id() * fix(tests): add module cleanup fixture to test_cli_provider_resolution test_cli_provider_resolution._import_cli() wipes tools.*, cli, and run_agent from sys.modules to force fresh imports, but had no cleanup. This poisoned all subsequent tests on the same xdist worker — mocks targeting tools.file_tools, tools.send_message_tool, etc. patched the NEW module object while already-imported functions still referenced the OLD one. Caused ~25 cascade failures: send_message KeyError, process_registry FileNotFoundError, file_read_guards timeouts, read_loop_detection file-not-found, mcp_oauth None port, and provider_parity/codex_execution stale tool lists. Fix: autouse fixture saves all affected modules before each test and restores them after, matching the pattern in test_managed_browserbase_and_modal.py.
This commit is contained in:
299
tests/agent/test_memory_plugin_e2e.py
Normal file
299
tests/agent/test_memory_plugin_e2e.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""End-to-end test: a SQLite-backed memory plugin exercising the full interface.
|
||||
|
||||
This proves a real plugin can register as a MemoryProvider and get wired
|
||||
into the agent loop via MemoryManager. Uses SQLite + FTS5 (stdlib, no
|
||||
external deps, no API keys).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.memory_manager import MemoryManager
|
||||
from agent.builtin_memory_provider import BuiltinMemoryProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQLite FTS5 memory provider — a real, minimal plugin implementation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SQLiteMemoryProvider(MemoryProvider):
|
||||
"""Minimal SQLite + FTS5 memory provider for testing.
|
||||
|
||||
Demonstrates the full MemoryProvider interface with a real backend.
|
||||
No external dependencies — just stdlib sqlite3.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = ":memory:"):
|
||||
self._db_path = db_path
|
||||
self._conn = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "sqlite_memory"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return True # SQLite is always available
|
||||
|
||||
def initialize(self, session_id: str, **kwargs) -> None:
|
||||
self._conn = sqlite3.connect(self._db_path)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories
|
||||
USING fts5(content, context, session_id)
|
||||
""")
|
||||
self._session_id = session_id
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
if not self._conn:
|
||||
return ""
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
|
||||
if count == 0:
|
||||
return ""
|
||||
return (
|
||||
f"# SQLite Memory Plugin\n"
|
||||
f"Active. {count} memories stored.\n"
|
||||
f"Use sqlite_recall to search, sqlite_retain to store."
|
||||
)
|
||||
|
||||
def prefetch(self, query: str, *, session_id: str = "") -> str:
|
||||
if not self._conn or not query:
|
||||
return ""
|
||||
# FTS5 search
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT content FROM memories WHERE memories MATCH ? LIMIT 5",
|
||||
(query,)
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return ""
|
||||
results = [row[0] for row in rows]
|
||||
return "## SQLite Memory\n" + "\n".join(f"- {r}" for r in results)
|
||||
except sqlite3.OperationalError:
|
||||
return ""
|
||||
|
||||
def sync_turn(self, user_content: str, assistant_content: str, *, session_id: str = "") -> None:
|
||||
if not self._conn:
|
||||
return
|
||||
combined = f"User: {user_content}\nAssistant: {assistant_content}"
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories (content, context, session_id) VALUES (?, ?, ?)",
|
||||
(combined, "conversation", self._session_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_tool_schemas(self):
|
||||
return [
|
||||
{
|
||||
"name": "sqlite_retain",
|
||||
"description": "Store a fact to SQLite memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {"type": "string", "description": "What to remember"},
|
||||
"context": {"type": "string", "description": "Category/context"},
|
||||
},
|
||||
"required": ["content"],
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "sqlite_recall",
|
||||
"description": "Search SQLite memory.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Search query"},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||
if tool_name == "sqlite_retain":
|
||||
content = args.get("content", "")
|
||||
context = args.get("context", "explicit")
|
||||
if not content:
|
||||
return json.dumps({"error": "content is required"})
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories (content, context, session_id) VALUES (?, ?, ?)",
|
||||
(content, context, self._session_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
return json.dumps({"result": "Stored."})
|
||||
|
||||
elif tool_name == "sqlite_recall":
|
||||
query = args.get("query", "")
|
||||
if not query:
|
||||
return json.dumps({"error": "query is required"})
|
||||
try:
|
||||
rows = self._conn.execute(
|
||||
"SELECT content, context FROM memories WHERE memories MATCH ? LIMIT 10",
|
||||
(query,)
|
||||
).fetchall()
|
||||
results = [{"content": r[0], "context": r[1]} for r in rows]
|
||||
return json.dumps({"results": results})
|
||||
except sqlite3.OperationalError:
|
||||
return json.dumps({"results": []})
|
||||
|
||||
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
||||
|
||||
def on_memory_write(self, action, target, content):
|
||||
"""Mirror built-in memory writes to SQLite."""
|
||||
if action == "add" and self._conn:
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories (content, context, session_id) VALUES (?, ?, ?)",
|
||||
(content, f"builtin_{target}", self._session_id),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def shutdown(self):
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSQLiteMemoryPlugin:
|
||||
"""Full lifecycle test with the SQLite provider."""
|
||||
|
||||
def test_full_lifecycle(self):
|
||||
"""Exercise init → store → recall → sync → prefetch → shutdown."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
sqlite_mem = SQLiteMemoryProvider()
|
||||
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(sqlite_mem)
|
||||
|
||||
# Initialize
|
||||
mgr.initialize_all(session_id="test-session-1", platform="cli")
|
||||
assert sqlite_mem._conn is not None
|
||||
|
||||
# System prompt — empty at first
|
||||
prompt = mgr.build_system_prompt()
|
||||
assert "SQLite Memory Plugin" not in prompt
|
||||
|
||||
# Store via tool call
|
||||
result = json.loads(mgr.handle_tool_call(
|
||||
"sqlite_retain", {"content": "User prefers dark mode", "context": "preference"}
|
||||
))
|
||||
assert result["result"] == "Stored."
|
||||
|
||||
# System prompt now shows count
|
||||
prompt = mgr.build_system_prompt()
|
||||
assert "1 memories stored" in prompt
|
||||
|
||||
# Recall via tool call
|
||||
result = json.loads(mgr.handle_tool_call(
|
||||
"sqlite_recall", {"query": "dark mode"}
|
||||
))
|
||||
assert len(result["results"]) == 1
|
||||
assert "dark mode" in result["results"][0]["content"]
|
||||
|
||||
# Sync a turn (auto-stores conversation)
|
||||
mgr.sync_all("What's my theme?", "You prefer dark mode.")
|
||||
count = sqlite_mem._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
|
||||
assert count == 2 # 1 explicit + 1 synced
|
||||
|
||||
# Prefetch for next turn
|
||||
prefetched = mgr.prefetch_all("dark mode")
|
||||
assert "dark mode" in prefetched
|
||||
|
||||
# Memory bridge — mirroring builtin writes
|
||||
mgr.on_memory_write("add", "user", "Timezone: US Pacific")
|
||||
count = sqlite_mem._conn.execute("SELECT COUNT(*) FROM memories").fetchone()[0]
|
||||
assert count == 3
|
||||
|
||||
# Shutdown
|
||||
mgr.shutdown_all()
|
||||
assert sqlite_mem._conn is None
|
||||
|
||||
def test_tool_routing_with_builtin(self):
|
||||
"""Verify builtin + plugin tools coexist without conflict."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
sqlite_mem = SQLiteMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(sqlite_mem)
|
||||
mgr.initialize_all(session_id="test-2")
|
||||
|
||||
# Builtin has no tools
|
||||
assert len(builtin.get_tool_schemas()) == 0
|
||||
# SQLite has 2 tools
|
||||
schemas = mgr.get_all_tool_schemas()
|
||||
names = {s["name"] for s in schemas}
|
||||
assert names == {"sqlite_retain", "sqlite_recall"}
|
||||
|
||||
# Routing works
|
||||
assert mgr.has_tool("sqlite_retain")
|
||||
assert mgr.has_tool("sqlite_recall")
|
||||
assert not mgr.has_tool("memory") # builtin doesn't register this
|
||||
|
||||
def test_second_external_plugin_rejected(self):
|
||||
"""Only one external memory provider is allowed at a time."""
|
||||
mgr = MemoryManager()
|
||||
p1 = SQLiteMemoryProvider()
|
||||
p2 = SQLiteMemoryProvider()
|
||||
# Hack name for p2
|
||||
p2._name_override = "sqlite_memory_2"
|
||||
original_name = p2.__class__.name
|
||||
type(p2).name = property(lambda self: getattr(self, '_name_override', 'sqlite_memory'))
|
||||
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2) # should be rejected
|
||||
|
||||
# Only p1 was accepted
|
||||
assert len(mgr.providers) == 1
|
||||
assert mgr.provider_names == ["sqlite_memory"]
|
||||
|
||||
# Restore class
|
||||
type(p2).name = original_name
|
||||
mgr.shutdown_all()
|
||||
|
||||
def test_provider_failure_isolation(self):
|
||||
"""Failing external provider doesn't break builtin."""
|
||||
from agent.builtin_memory_provider import BuiltinMemoryProvider
|
||||
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider() # name="builtin", always accepted
|
||||
ext = SQLiteMemoryProvider()
|
||||
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(ext)
|
||||
mgr.initialize_all(session_id="test-4")
|
||||
|
||||
# Break external provider's connection
|
||||
ext._conn.close()
|
||||
ext._conn = None
|
||||
|
||||
# Sync — external fails silently, builtin (no-op sync) succeeds
|
||||
mgr.sync_all("user", "assistant") # should not raise
|
||||
|
||||
mgr.shutdown_all()
|
||||
|
||||
def test_plugin_registration_flow(self):
|
||||
"""Simulate the full plugin load → agent init path."""
|
||||
# Simulate what AIAgent.__init__ does via plugins/memory/ discovery
|
||||
provider = SQLiteMemoryProvider()
|
||||
|
||||
mem_mgr = MemoryManager()
|
||||
mem_mgr.add_provider(BuiltinMemoryProvider())
|
||||
if provider.is_available():
|
||||
mem_mgr.add_provider(provider)
|
||||
mem_mgr.initialize_all(session_id="agent-session")
|
||||
|
||||
assert len(mem_mgr.providers) == 2
|
||||
assert mem_mgr.provider_names == ["builtin", "sqlite_memory"]
|
||||
assert provider._conn is not None # initialized = connection established
|
||||
|
||||
mem_mgr.shutdown_all()
|
||||
549
tests/agent/test_memory_provider.py
Normal file
549
tests/agent/test_memory_provider.py
Normal file
@@ -0,0 +1,549 @@
|
||||
"""Tests for the memory provider interface, manager, and builtin provider."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.memory_manager import MemoryManager
|
||||
from agent.builtin_memory_provider import BuiltinMemoryProvider
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete test provider
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FakeMemoryProvider(MemoryProvider):
|
||||
"""Minimal concrete provider for testing."""
|
||||
|
||||
def __init__(self, name="fake", available=True, tools=None):
|
||||
self._name = name
|
||||
self._available = available
|
||||
self._tools = tools or []
|
||||
self.initialized = False
|
||||
self.synced_turns = []
|
||||
self.prefetch_queries = []
|
||||
self.queued_prefetches = []
|
||||
self.turn_starts = []
|
||||
self.session_end_called = False
|
||||
self.pre_compress_called = False
|
||||
self.memory_writes = []
|
||||
self.shutdown_called = False
|
||||
self._prefetch_result = ""
|
||||
self._prompt_block = ""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
def initialize(self, session_id, **kwargs):
|
||||
self.initialized = True
|
||||
self._init_kwargs = {"session_id": session_id, **kwargs}
|
||||
|
||||
def system_prompt_block(self) -> str:
|
||||
return self._prompt_block
|
||||
|
||||
def prefetch(self, query, *, session_id=""):
|
||||
self.prefetch_queries.append(query)
|
||||
return self._prefetch_result
|
||||
|
||||
def queue_prefetch(self, query, *, session_id=""):
|
||||
self.queued_prefetches.append(query)
|
||||
|
||||
def sync_turn(self, user_content, assistant_content, *, session_id=""):
|
||||
self.synced_turns.append((user_content, assistant_content))
|
||||
|
||||
def get_tool_schemas(self):
|
||||
return self._tools
|
||||
|
||||
def handle_tool_call(self, tool_name, args, **kwargs):
|
||||
return json.dumps({"handled": tool_name, "args": args})
|
||||
|
||||
def shutdown(self):
|
||||
self.shutdown_called = True
|
||||
|
||||
def on_turn_start(self, turn_number, message):
|
||||
self.turn_starts.append((turn_number, message))
|
||||
|
||||
def on_session_end(self, messages):
|
||||
self.session_end_called = True
|
||||
|
||||
def on_pre_compress(self, messages):
|
||||
self.pre_compress_called = True
|
||||
|
||||
def on_memory_write(self, action, target, content):
|
||||
self.memory_writes.append((action, target, content))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryProvider ABC tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMemoryProviderABC:
|
||||
def test_cannot_instantiate_abstract(self):
|
||||
"""ABC cannot be instantiated directly."""
|
||||
with pytest.raises(TypeError):
|
||||
MemoryProvider()
|
||||
|
||||
def test_concrete_provider_works(self):
|
||||
"""Concrete implementation can be instantiated."""
|
||||
p = FakeMemoryProvider()
|
||||
assert p.name == "fake"
|
||||
assert p.is_available()
|
||||
|
||||
def test_default_optional_hooks_are_noop(self):
|
||||
"""Optional hooks have default no-op implementations."""
|
||||
p = FakeMemoryProvider()
|
||||
# These should not raise
|
||||
p.on_turn_start(1, "hello")
|
||||
p.on_session_end([])
|
||||
p.on_pre_compress([])
|
||||
p.on_memory_write("add", "memory", "test")
|
||||
p.queue_prefetch("query")
|
||||
p.sync_turn("user", "assistant")
|
||||
p.shutdown()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MemoryManager tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMemoryManager:
|
||||
def test_empty_manager(self):
|
||||
mgr = MemoryManager()
|
||||
assert mgr.providers == []
|
||||
assert mgr.provider_names == []
|
||||
assert mgr.get_all_tool_schemas() == []
|
||||
assert mgr.build_system_prompt() == ""
|
||||
assert mgr.prefetch_all("test") == ""
|
||||
|
||||
def test_add_provider(self):
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("test1")
|
||||
mgr.add_provider(p)
|
||||
assert len(mgr.providers) == 1
|
||||
assert mgr.provider_names == ["test1"]
|
||||
|
||||
def test_get_provider_by_name(self):
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("test1")
|
||||
mgr.add_provider(p)
|
||||
assert mgr.get_provider("test1") is p
|
||||
assert mgr.get_provider("nonexistent") is None
|
||||
|
||||
def test_builtin_plus_external(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p2 = FakeMemoryProvider("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
assert mgr.provider_names == ["builtin", "external"]
|
||||
|
||||
def test_second_external_rejected(self):
|
||||
"""Only one non-builtin provider is allowed."""
|
||||
mgr = MemoryManager()
|
||||
builtin = FakeMemoryProvider("builtin")
|
||||
ext1 = FakeMemoryProvider("mem0")
|
||||
ext2 = FakeMemoryProvider("hindsight")
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(ext1)
|
||||
mgr.add_provider(ext2) # should be rejected
|
||||
assert mgr.provider_names == ["builtin", "mem0"]
|
||||
assert len(mgr.providers) == 2
|
||||
|
||||
def test_system_prompt_merges_blocks(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1._prompt_block = "Block from builtin"
|
||||
p2 = FakeMemoryProvider("external")
|
||||
p2._prompt_block = "Block from external"
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
result = mgr.build_system_prompt()
|
||||
assert "Block from builtin" in result
|
||||
assert "Block from external" in result
|
||||
|
||||
def test_system_prompt_skips_empty(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1._prompt_block = "Has content"
|
||||
p2 = FakeMemoryProvider("external")
|
||||
p2._prompt_block = ""
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
result = mgr.build_system_prompt()
|
||||
assert result == "Has content"
|
||||
|
||||
def test_prefetch_merges_results(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1._prefetch_result = "Memory from builtin"
|
||||
p2 = FakeMemoryProvider("external")
|
||||
p2._prefetch_result = "Memory from external"
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
result = mgr.prefetch_all("what do you know?")
|
||||
assert "Memory from builtin" in result
|
||||
assert "Memory from external" in result
|
||||
assert p1.prefetch_queries == ["what do you know?"]
|
||||
assert p2.prefetch_queries == ["what do you know?"]
|
||||
|
||||
def test_prefetch_skips_empty(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1._prefetch_result = "Has memories"
|
||||
p2 = FakeMemoryProvider("external")
|
||||
p2._prefetch_result = ""
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
result = mgr.prefetch_all("query")
|
||||
assert result == "Has memories"
|
||||
|
||||
def test_queue_prefetch_all(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p2 = FakeMemoryProvider("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
mgr.queue_prefetch_all("next turn")
|
||||
assert p1.queued_prefetches == ["next turn"]
|
||||
assert p2.queued_prefetches == ["next turn"]
|
||||
|
||||
def test_sync_all(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p2 = FakeMemoryProvider("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
mgr.sync_all("user msg", "assistant msg")
|
||||
assert p1.synced_turns == [("user msg", "assistant msg")]
|
||||
assert p2.synced_turns == [("user msg", "assistant msg")]
|
||||
|
||||
def test_sync_failure_doesnt_block_others(self):
|
||||
"""If one provider's sync fails, others still run."""
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1.sync_turn = MagicMock(side_effect=RuntimeError("boom"))
|
||||
p2 = FakeMemoryProvider("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
mgr.sync_all("user", "assistant")
|
||||
# p1 failed but p2 still synced
|
||||
assert p2.synced_turns == [("user", "assistant")]
|
||||
|
||||
# -- Tool routing -------------------------------------------------------
|
||||
|
||||
def test_tool_schemas_collected(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin", tools=[
|
||||
{"name": "recall_builtin", "description": "Builtin recall", "parameters": {}}
|
||||
])
|
||||
p2 = FakeMemoryProvider("external", tools=[
|
||||
{"name": "recall_ext", "description": "External recall", "parameters": {}}
|
||||
])
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
schemas = mgr.get_all_tool_schemas()
|
||||
names = {s["name"] for s in schemas}
|
||||
assert names == {"recall_builtin", "recall_ext"}
|
||||
|
||||
def test_tool_name_conflict_first_wins(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin", tools=[
|
||||
{"name": "shared_tool", "description": "From builtin", "parameters": {}}
|
||||
])
|
||||
p2 = FakeMemoryProvider("external", tools=[
|
||||
{"name": "shared_tool", "description": "From external", "parameters": {}}
|
||||
])
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
assert mgr.has_tool("shared_tool")
|
||||
result = json.loads(mgr.handle_tool_call("shared_tool", {"q": "test"}))
|
||||
assert result["handled"] == "shared_tool"
|
||||
# Should be handled by p1 (first registered)
|
||||
|
||||
def test_handle_unknown_tool(self):
|
||||
mgr = MemoryManager()
|
||||
result = json.loads(mgr.handle_tool_call("nonexistent", {}))
|
||||
assert "error" in result
|
||||
|
||||
def test_tool_routing(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin", tools=[
|
||||
{"name": "builtin_tool", "description": "Builtin", "parameters": {}}
|
||||
])
|
||||
p2 = FakeMemoryProvider("external", tools=[
|
||||
{"name": "ext_tool", "description": "External", "parameters": {}}
|
||||
])
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
r1 = json.loads(mgr.handle_tool_call("builtin_tool", {"a": 1}))
|
||||
assert r1["handled"] == "builtin_tool"
|
||||
r2 = json.loads(mgr.handle_tool_call("ext_tool", {"b": 2}))
|
||||
assert r2["handled"] == "ext_tool"
|
||||
|
||||
# -- Lifecycle hooks -----------------------------------------------------
|
||||
|
||||
def test_on_turn_start(self):
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("p")
|
||||
mgr.add_provider(p)
|
||||
mgr.on_turn_start(3, "hello")
|
||||
assert p.turn_starts == [(3, "hello")]
|
||||
|
||||
def test_on_session_end(self):
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("p")
|
||||
mgr.add_provider(p)
|
||||
mgr.on_session_end([{"role": "user", "content": "hi"}])
|
||||
assert p.session_end_called
|
||||
|
||||
def test_on_pre_compress(self):
|
||||
mgr = MemoryManager()
|
||||
p = FakeMemoryProvider("p")
|
||||
mgr.add_provider(p)
|
||||
mgr.on_pre_compress([{"role": "user", "content": "old"}])
|
||||
assert p.pre_compress_called
|
||||
|
||||
def test_on_memory_write_skips_builtin(self):
|
||||
"""on_memory_write should skip the builtin provider."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
external = FakeMemoryProvider("external")
|
||||
mgr.add_provider(builtin)
|
||||
mgr.add_provider(external)
|
||||
|
||||
mgr.on_memory_write("add", "memory", "test fact")
|
||||
assert external.memory_writes == [("add", "memory", "test fact")]
|
||||
|
||||
def test_shutdown_all_reverse_order(self):
|
||||
mgr = MemoryManager()
|
||||
order = []
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1.shutdown = lambda: order.append("builtin")
|
||||
p2 = FakeMemoryProvider("external")
|
||||
p2.shutdown = lambda: order.append("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
mgr.shutdown_all()
|
||||
assert order == ["external", "builtin"] # reverse order
|
||||
|
||||
def test_initialize_all(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p2 = FakeMemoryProvider("external")
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
mgr.initialize_all(session_id="test-123", platform="cli")
|
||||
assert p1.initialized
|
||||
assert p2.initialized
|
||||
assert p1._init_kwargs["session_id"] == "test-123"
|
||||
assert p1._init_kwargs["platform"] == "cli"
|
||||
|
||||
# -- Error resilience ---------------------------------------------------
|
||||
|
||||
def test_prefetch_failure_doesnt_block(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1.prefetch = MagicMock(side_effect=RuntimeError("network error"))
|
||||
p2 = FakeMemoryProvider("external")
|
||||
p2._prefetch_result = "external memory"
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
result = mgr.prefetch_all("query")
|
||||
assert "external memory" in result
|
||||
|
||||
def test_system_prompt_failure_doesnt_block(self):
|
||||
mgr = MemoryManager()
|
||||
p1 = FakeMemoryProvider("builtin")
|
||||
p1.system_prompt_block = MagicMock(side_effect=RuntimeError("broken"))
|
||||
p2 = FakeMemoryProvider("external")
|
||||
p2._prompt_block = "works fine"
|
||||
mgr.add_provider(p1)
|
||||
mgr.add_provider(p2)
|
||||
|
||||
result = mgr.build_system_prompt()
|
||||
assert result == "works fine"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# BuiltinMemoryProvider tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuiltinMemoryProvider:
|
||||
def test_name(self):
|
||||
p = BuiltinMemoryProvider()
|
||||
assert p.name == "builtin"
|
||||
|
||||
def test_always_available(self):
|
||||
p = BuiltinMemoryProvider()
|
||||
assert p.is_available()
|
||||
|
||||
def test_no_tools(self):
|
||||
"""Builtin provider exposes no tools (memory tool is agent-level)."""
|
||||
p = BuiltinMemoryProvider()
|
||||
assert p.get_tool_schemas() == []
|
||||
|
||||
def test_system_prompt_with_store(self):
|
||||
store = MagicMock()
|
||||
store.format_for_system_prompt.side_effect = lambda t: f"BLOCK_{t}" if t == "memory" else f"BLOCK_{t}"
|
||||
|
||||
p = BuiltinMemoryProvider(
|
||||
memory_store=store,
|
||||
memory_enabled=True,
|
||||
user_profile_enabled=True,
|
||||
)
|
||||
block = p.system_prompt_block()
|
||||
assert "BLOCK_memory" in block
|
||||
assert "BLOCK_user" in block
|
||||
|
||||
def test_system_prompt_memory_disabled(self):
|
||||
store = MagicMock()
|
||||
store.format_for_system_prompt.return_value = "content"
|
||||
|
||||
p = BuiltinMemoryProvider(
|
||||
memory_store=store,
|
||||
memory_enabled=False,
|
||||
user_profile_enabled=False,
|
||||
)
|
||||
assert p.system_prompt_block() == ""
|
||||
|
||||
def test_system_prompt_no_store(self):
|
||||
p = BuiltinMemoryProvider(memory_store=None, memory_enabled=True)
|
||||
assert p.system_prompt_block() == ""
|
||||
|
||||
def test_prefetch_returns_empty(self):
|
||||
p = BuiltinMemoryProvider()
|
||||
assert p.prefetch("anything") == ""
|
||||
|
||||
def test_store_property(self):
|
||||
store = MagicMock()
|
||||
p = BuiltinMemoryProvider(memory_store=store)
|
||||
assert p.store is store
|
||||
|
||||
def test_initialize_loads_from_disk(self):
|
||||
store = MagicMock()
|
||||
p = BuiltinMemoryProvider(memory_store=store)
|
||||
p.initialize(session_id="test")
|
||||
store.load_from_disk.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin registration tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleProviderGating:
|
||||
"""Only the configured provider should activate."""
|
||||
|
||||
def test_no_provider_configured_means_builtin_only(self):
|
||||
"""When memory.provider is empty, no plugin providers activate."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
|
||||
# Simulate what run_agent.py does when provider=""
|
||||
configured = ""
|
||||
available_plugins = [
|
||||
FakeMemoryProvider("holographic"),
|
||||
FakeMemoryProvider("mem0"),
|
||||
]
|
||||
# With empty config, no plugins should be added
|
||||
if configured:
|
||||
for p in available_plugins:
|
||||
if p.name == configured and p.is_available():
|
||||
mgr.add_provider(p)
|
||||
|
||||
assert mgr.provider_names == ["builtin"]
|
||||
|
||||
def test_configured_provider_activates(self):
|
||||
"""Only the named provider should be added."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
|
||||
configured = "holographic"
|
||||
p1 = FakeMemoryProvider("holographic")
|
||||
p2 = FakeMemoryProvider("mem0")
|
||||
p3 = FakeMemoryProvider("hindsight")
|
||||
|
||||
for p in [p1, p2, p3]:
|
||||
if p.name == configured and p.is_available():
|
||||
mgr.add_provider(p)
|
||||
|
||||
assert mgr.provider_names == ["builtin", "holographic"]
|
||||
assert p1.initialized is False # not initialized by the gating logic itself
|
||||
|
||||
def test_unavailable_provider_skipped(self):
|
||||
"""If the configured provider is unavailable, it should be skipped."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
|
||||
configured = "holographic"
|
||||
p1 = FakeMemoryProvider("holographic", available=False)
|
||||
|
||||
for p in [p1]:
|
||||
if p.name == configured and p.is_available():
|
||||
mgr.add_provider(p)
|
||||
|
||||
assert mgr.provider_names == ["builtin"]
|
||||
|
||||
def test_nonexistent_provider_results_in_builtin_only(self):
|
||||
"""If the configured name doesn't match any plugin, only builtin remains."""
|
||||
mgr = MemoryManager()
|
||||
builtin = BuiltinMemoryProvider()
|
||||
mgr.add_provider(builtin)
|
||||
|
||||
configured = "nonexistent"
|
||||
plugins = [FakeMemoryProvider("holographic"), FakeMemoryProvider("mem0")]
|
||||
|
||||
for p in plugins:
|
||||
if p.name == configured and p.is_available():
|
||||
mgr.add_provider(p)
|
||||
|
||||
assert mgr.provider_names == ["builtin"]
|
||||
|
||||
|
||||
class TestPluginMemoryDiscovery:
|
||||
"""Memory providers are discovered from plugins/memory/ directory."""
|
||||
|
||||
def test_discover_finds_providers(self):
|
||||
"""discover_memory_providers returns available providers."""
|
||||
from plugins.memory import discover_memory_providers
|
||||
providers = discover_memory_providers()
|
||||
names = [name for name, _, _ in providers]
|
||||
assert "holographic" in names # always available (no external deps)
|
||||
|
||||
def test_load_provider_by_name(self):
|
||||
"""load_memory_provider returns a working provider instance."""
|
||||
from plugins.memory import load_memory_provider
|
||||
p = load_memory_provider("holographic")
|
||||
assert p is not None
|
||||
assert p.name == "holographic"
|
||||
assert p.is_available()
|
||||
|
||||
def test_load_nonexistent_returns_none(self):
|
||||
"""load_memory_provider returns None for unknown names."""
|
||||
from plugins.memory import load_memory_provider
|
||||
assert load_memory_provider("nonexistent_provider") is None
|
||||
@@ -54,9 +54,10 @@ class TestCronSessionBypass:
|
||||
# session_store.load_transcript should never be called
|
||||
runner.session_store.load_transcript.assert_not_called()
|
||||
|
||||
def test_cron_session_with_honcho_key_skipped(self):
|
||||
def test_cron_session_with_prefix_skipped(self):
|
||||
"""Cron sessions with different prefixes are still skipped."""
|
||||
runner = _make_runner()
|
||||
runner._flush_memories_for_session("cron_daily_20260323", "some-honcho-key")
|
||||
runner._flush_memories_for_session("cron_daily_20260323")
|
||||
runner.session_store.load_transcript.assert_not_called()
|
||||
|
||||
def test_non_cron_session_proceeds(self):
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Tests for gateway-owned Honcho lifecycle helpers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform
|
||||
from gateway.platforms.base import MessageEvent
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
def _make_runner():
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner = object.__new__(GatewayRunner)
|
||||
runner._honcho_managers = {}
|
||||
runner._honcho_configs = {}
|
||||
runner._running_agents = {}
|
||||
runner._pending_messages = {}
|
||||
runner._pending_approvals = {}
|
||||
runner.adapters = {}
|
||||
runner.hooks = MagicMock()
|
||||
runner.hooks.emit = AsyncMock()
|
||||
return runner
|
||||
|
||||
|
||||
def _make_event(text="/reset"):
|
||||
return MessageEvent(
|
||||
text=text,
|
||||
source=SessionSource(
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_id="chat-1",
|
||||
user_id="user-1",
|
||||
user_name="alice",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestGatewayHonchoLifecycle:
|
||||
def test_gateway_reuses_honcho_manager_for_session_key(self):
|
||||
runner = _make_runner()
|
||||
hcfg = SimpleNamespace(
|
||||
enabled=True,
|
||||
api_key="honcho-key",
|
||||
ai_peer="hermes",
|
||||
peer_name="alice",
|
||||
context_tokens=123,
|
||||
peer_memory_mode=lambda peer: "hybrid",
|
||||
)
|
||||
manager = MagicMock()
|
||||
|
||||
with (
|
||||
patch("honcho_integration.client.HonchoClientConfig.from_global_config", return_value=hcfg),
|
||||
patch("honcho_integration.client.get_honcho_client", return_value=MagicMock()),
|
||||
patch("honcho_integration.session.HonchoSessionManager", return_value=manager) as mock_mgr_cls,
|
||||
):
|
||||
first_mgr, first_cfg = runner._get_or_create_gateway_honcho("session-key")
|
||||
second_mgr, second_cfg = runner._get_or_create_gateway_honcho("session-key")
|
||||
|
||||
assert first_mgr is manager
|
||||
assert second_mgr is manager
|
||||
assert first_cfg is hcfg
|
||||
assert second_cfg is hcfg
|
||||
mock_mgr_cls.assert_called_once()
|
||||
|
||||
def test_gateway_skips_honcho_manager_when_disabled(self):
|
||||
runner = _make_runner()
|
||||
hcfg = SimpleNamespace(
|
||||
enabled=False,
|
||||
api_key="honcho-key",
|
||||
ai_peer="hermes",
|
||||
peer_name="alice",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("honcho_integration.client.HonchoClientConfig.from_global_config", return_value=hcfg),
|
||||
patch("honcho_integration.client.get_honcho_client") as mock_client,
|
||||
patch("honcho_integration.session.HonchoSessionManager") as mock_mgr_cls,
|
||||
):
|
||||
manager, cfg = runner._get_or_create_gateway_honcho("session-key")
|
||||
|
||||
assert manager is None
|
||||
assert cfg is hcfg
|
||||
mock_client.assert_not_called()
|
||||
mock_mgr_cls.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_shuts_down_gateway_honcho_manager(self):
|
||||
runner = _make_runner()
|
||||
event = _make_event()
|
||||
runner._shutdown_gateway_honcho = MagicMock()
|
||||
runner._async_flush_memories = AsyncMock()
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store._generate_session_key.return_value = "gateway-key"
|
||||
runner.session_store._entries = {
|
||||
"gateway-key": SimpleNamespace(session_id="old-session"),
|
||||
}
|
||||
runner.session_store.reset_session.return_value = SimpleNamespace(session_id="new-session")
|
||||
|
||||
result = await runner._handle_reset_command(event)
|
||||
|
||||
runner._shutdown_gateway_honcho.assert_called_once_with("gateway-key")
|
||||
runner._async_flush_memories.assert_called_once_with("old-session", "gateway-key")
|
||||
assert "Session reset" in result
|
||||
|
||||
def test_flush_memories_reuses_gateway_session_key_and_skips_honcho_sync(self):
|
||||
runner = _make_runner()
|
||||
runner.session_store = MagicMock()
|
||||
runner.session_store.load_transcript.return_value = [
|
||||
{"role": "user", "content": "a"},
|
||||
{"role": "assistant", "content": "b"},
|
||||
{"role": "user", "content": "c"},
|
||||
{"role": "assistant", "content": "d"},
|
||||
]
|
||||
tmp_agent = MagicMock()
|
||||
|
||||
with (
|
||||
patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}),
|
||||
patch("gateway.run._resolve_gateway_model", return_value="model-name"),
|
||||
patch("run_agent.AIAgent", return_value=tmp_agent) as mock_agent_cls,
|
||||
):
|
||||
runner._flush_memories_for_session("old-session", "gateway-key")
|
||||
|
||||
mock_agent_cls.assert_called_once()
|
||||
_, kwargs = mock_agent_cls.call_args
|
||||
assert kwargs["session_id"] == "old-session"
|
||||
assert kwargs["honcho_session_key"] == "gateway-key"
|
||||
tmp_agent.run_conversation.assert_called_once()
|
||||
_, run_kwargs = tmp_agent.run_conversation.call_args
|
||||
assert run_kwargs["sync_honcho"] is False
|
||||
@@ -58,7 +58,7 @@ class TestHonchoDoctorConfigDetection:
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="***")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_integration.client.HonchoClientConfig.from_global_config",
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda: fake_config,
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ class TestHonchoDoctorConfigDetection:
|
||||
fake_config = SimpleNamespace(enabled=True, api_key="")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"honcho_integration.client.HonchoClientConfig.from_global_config",
|
||||
"plugins.memory.honcho.client.HonchoClientConfig.from_global_config",
|
||||
lambda: fake_config,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
"""Tests for Honcho CLI helpers."""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from honcho_integration.cli import _resolve_api_key, clone_honcho_for_profile, sync_honcho_profiles_quiet
|
||||
|
||||
|
||||
class TestResolveApiKey:
|
||||
def test_prefers_host_scoped_key(self):
|
||||
cfg = {
|
||||
"apiKey": "root-key",
|
||||
"hosts": {
|
||||
"hermes": {
|
||||
"apiKey": "host-key",
|
||||
}
|
||||
},
|
||||
}
|
||||
assert _resolve_api_key(cfg) == "host-key"
|
||||
|
||||
def test_falls_back_to_root_key(self):
|
||||
cfg = {
|
||||
"apiKey": "root-key",
|
||||
"hosts": {"hermes": {}},
|
||||
}
|
||||
assert _resolve_api_key(cfg) == "root-key"
|
||||
|
||||
def test_falls_back_to_env_key(self, monkeypatch):
|
||||
monkeypatch.setenv("HONCHO_API_KEY", "env-key")
|
||||
assert _resolve_api_key({}) == "env-key"
|
||||
monkeypatch.delenv("HONCHO_API_KEY", raising=False)
|
||||
|
||||
|
||||
class TestCloneHonchoForProfile:
|
||||
def test_clones_default_settings_to_new_profile(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "test-key",
|
||||
"hosts": {
|
||||
"hermes": {
|
||||
"peerName": "alice",
|
||||
"memoryMode": "honcho",
|
||||
"recallMode": "tools",
|
||||
"writeFrequency": "turn",
|
||||
"dialecticReasoningLevel": "medium",
|
||||
"enabled": True,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
with patch("honcho_integration.cli._config_path", return_value=config_file), \
|
||||
patch("honcho_integration.cli._local_config_path", return_value=config_file):
|
||||
result = clone_honcho_for_profile("coder")
|
||||
|
||||
assert result is True
|
||||
|
||||
cfg = json.loads(config_file.read_text())
|
||||
new_block = cfg["hosts"]["hermes.coder"]
|
||||
assert new_block["peerName"] == "alice"
|
||||
assert new_block["memoryMode"] == "honcho"
|
||||
assert new_block["recallMode"] == "tools"
|
||||
assert new_block["writeFrequency"] == "turn"
|
||||
assert new_block["aiPeer"] == "hermes.coder"
|
||||
assert new_block["workspace"] == "hermes" # shared, not profile-derived
|
||||
assert new_block["enabled"] is True
|
||||
|
||||
def test_skips_when_no_honcho_configured(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
|
||||
with patch("honcho_integration.cli._config_path", return_value=config_file), \
|
||||
patch("honcho_integration.cli._local_config_path", return_value=config_file):
|
||||
result = clone_honcho_for_profile("coder")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_skips_when_host_block_already_exists(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "key",
|
||||
"hosts": {
|
||||
"hermes": {"peerName": "alice"},
|
||||
"hermes.coder": {"peerName": "existing"},
|
||||
},
|
||||
}))
|
||||
|
||||
with patch("honcho_integration.cli._config_path", return_value=config_file), \
|
||||
patch("honcho_integration.cli._local_config_path", return_value=config_file):
|
||||
result = clone_honcho_for_profile("coder")
|
||||
|
||||
assert result is False
|
||||
cfg = json.loads(config_file.read_text())
|
||||
assert cfg["hosts"]["hermes.coder"]["peerName"] == "existing"
|
||||
|
||||
def test_inherits_peer_name_from_root_when_not_in_host(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "key",
|
||||
"peerName": "root-alice",
|
||||
"hosts": {"hermes": {}},
|
||||
}))
|
||||
|
||||
with patch("honcho_integration.cli._config_path", return_value=config_file), \
|
||||
patch("honcho_integration.cli._local_config_path", return_value=config_file):
|
||||
clone_honcho_for_profile("dreamer")
|
||||
|
||||
cfg = json.loads(config_file.read_text())
|
||||
assert cfg["hosts"]["hermes.dreamer"]["peerName"] == "root-alice"
|
||||
|
||||
def test_works_with_api_key_only_no_host_block(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({"apiKey": "key"}))
|
||||
|
||||
with patch("honcho_integration.cli._config_path", return_value=config_file), \
|
||||
patch("honcho_integration.cli._local_config_path", return_value=config_file):
|
||||
result = clone_honcho_for_profile("coder")
|
||||
|
||||
assert result is True
|
||||
cfg = json.loads(config_file.read_text())
|
||||
assert cfg["hosts"]["hermes.coder"]["aiPeer"] == "hermes.coder"
|
||||
assert cfg["hosts"]["hermes.coder"]["workspace"] == "hermes" # shared
|
||||
|
||||
|
||||
class TestSyncHonchoProfilesQuiet:
|
||||
def test_syncs_missing_profiles(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "key",
|
||||
"hosts": {"hermes": {"peerName": "alice", "memoryMode": "honcho"}},
|
||||
}))
|
||||
|
||||
class FakeProfile:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.is_default = name == "default"
|
||||
|
||||
profiles = [FakeProfile("default"), FakeProfile("coder"), FakeProfile("dreamer")]
|
||||
|
||||
with patch("honcho_integration.cli._config_path", return_value=config_file), \
|
||||
patch("honcho_integration.cli._local_config_path", return_value=config_file), \
|
||||
patch("hermes_cli.profiles.list_profiles", return_value=profiles):
|
||||
count = sync_honcho_profiles_quiet()
|
||||
|
||||
assert count == 2
|
||||
cfg = json.loads(config_file.read_text())
|
||||
assert "hermes.coder" in cfg["hosts"]
|
||||
assert "hermes.dreamer" in cfg["hosts"]
|
||||
|
||||
def test_returns_zero_when_no_honcho(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text("{}")
|
||||
|
||||
with patch("honcho_integration.cli._config_path", return_value=config_file), \
|
||||
patch("honcho_integration.cli._local_config_path", return_value=config_file):
|
||||
count = sync_honcho_profiles_quiet()
|
||||
|
||||
assert count == 0
|
||||
|
||||
def test_skips_already_synced(self, tmp_path):
|
||||
config_file = tmp_path / "config.json"
|
||||
config_file.write_text(json.dumps({
|
||||
"apiKey": "key",
|
||||
"hosts": {
|
||||
"hermes": {"peerName": "alice"},
|
||||
"hermes.coder": {"peerName": "existing"},
|
||||
},
|
||||
}))
|
||||
|
||||
class FakeProfile:
|
||||
def __init__(self, name):
|
||||
self.name = name
|
||||
self.is_default = name == "default"
|
||||
|
||||
with patch("honcho_integration.cli._config_path", return_value=config_file), \
|
||||
patch("hermes_cli.profiles.list_profiles", return_value=[FakeProfile("default"), FakeProfile("coder")]):
|
||||
count = sync_honcho_profiles_quiet()
|
||||
|
||||
assert count == 0
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Tests for Honcho config profile isolation.
|
||||
|
||||
Verifies that each Hermes profile writes to its own instance-local
|
||||
honcho.json ($HERMES_HOME/honcho.json) rather than the shared global
|
||||
~/.honcho/config.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_integration.cli import (
|
||||
_config_path,
|
||||
_local_config_path,
|
||||
_read_config,
|
||||
_write_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def isolated_home(tmp_path, monkeypatch):
|
||||
"""Create an isolated HERMES_HOME + real home for testing."""
|
||||
hermes_home = tmp_path / "profile_a"
|
||||
hermes_home.mkdir()
|
||||
global_dir = tmp_path / "home" / ".honcho"
|
||||
global_dir.mkdir(parents=True)
|
||||
global_config = global_dir / "config.json"
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: tmp_path / "home"))
|
||||
# GLOBAL_CONFIG_PATH is a module-level constant cached at import time,
|
||||
# so we must patch it in both the defining module and the importing module.
|
||||
import honcho_integration.client as _client_mod
|
||||
import honcho_integration.cli as _cli_mod
|
||||
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_config)
|
||||
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_config)
|
||||
|
||||
return {
|
||||
"hermes_home": hermes_home,
|
||||
"global_config": global_config,
|
||||
"local_config": hermes_home / "honcho.json",
|
||||
}
|
||||
|
||||
|
||||
class TestLocalConfigPath:
|
||||
"""_local_config_path always returns $HERMES_HOME/honcho.json."""
|
||||
|
||||
def test_returns_hermes_home_path(self, isolated_home):
|
||||
assert _local_config_path() == isolated_home["local_config"]
|
||||
|
||||
def test_differs_from_global(self, isolated_home):
|
||||
from honcho_integration.client import GLOBAL_CONFIG_PATH
|
||||
assert _local_config_path() != GLOBAL_CONFIG_PATH
|
||||
|
||||
|
||||
class TestWriteConfigIsolation:
|
||||
"""_write_config defaults to the instance-local path."""
|
||||
|
||||
def test_write_creates_local_file(self, isolated_home):
|
||||
cfg = {"apiKey": "test-key", "hosts": {"hermes": {"enabled": True}}}
|
||||
_write_config(cfg)
|
||||
|
||||
assert isolated_home["local_config"].exists()
|
||||
written = json.loads(isolated_home["local_config"].read_text())
|
||||
assert written["apiKey"] == "test-key"
|
||||
|
||||
def test_write_does_not_touch_global(self, isolated_home):
|
||||
# Pre-populate global config
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"apiKey": "global-key"})
|
||||
)
|
||||
|
||||
cfg = {"apiKey": "profile-key"}
|
||||
_write_config(cfg)
|
||||
|
||||
# Global should be untouched
|
||||
global_data = json.loads(isolated_home["global_config"].read_text())
|
||||
assert global_data["apiKey"] == "global-key"
|
||||
|
||||
# Local should have the new value
|
||||
local_data = json.loads(isolated_home["local_config"].read_text())
|
||||
assert local_data["apiKey"] == "profile-key"
|
||||
|
||||
def test_explicit_path_override_still_works(self, isolated_home):
|
||||
custom = isolated_home["hermes_home"] / "custom.json"
|
||||
_write_config({"custom": True}, path=custom)
|
||||
assert custom.exists()
|
||||
assert not isolated_home["local_config"].exists()
|
||||
|
||||
|
||||
class TestReadConfigFallback:
|
||||
"""_read_config falls back to global when no local file exists."""
|
||||
|
||||
def test_reads_local_when_exists(self, isolated_home):
|
||||
isolated_home["local_config"].write_text(
|
||||
json.dumps({"source": "local"})
|
||||
)
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "local"
|
||||
|
||||
def test_falls_back_to_global(self, isolated_home):
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"source": "global"})
|
||||
)
|
||||
# No local file exists
|
||||
assert not isolated_home["local_config"].exists()
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "global"
|
||||
|
||||
def test_local_takes_priority_over_global(self, isolated_home):
|
||||
isolated_home["local_config"].write_text(
|
||||
json.dumps({"source": "local"})
|
||||
)
|
||||
isolated_home["global_config"].write_text(
|
||||
json.dumps({"source": "global"})
|
||||
)
|
||||
cfg = _read_config()
|
||||
assert cfg["source"] == "local"
|
||||
|
||||
|
||||
class TestMultiProfileIsolation:
|
||||
"""Two profiles writing config don't interfere with each other."""
|
||||
|
||||
def test_two_profiles_get_separate_configs(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
|
||||
profile_a = tmp_path / "profile_a"
|
||||
profile_b = tmp_path / "profile_b"
|
||||
profile_a.mkdir()
|
||||
profile_b.mkdir()
|
||||
|
||||
# Profile A writes its config
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_a))
|
||||
_write_config({"apiKey": "key-a", "hosts": {"hermes": {"peerName": "alice"}}})
|
||||
|
||||
# Profile B writes its config
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile_b))
|
||||
_write_config({"apiKey": "key-b", "hosts": {"hermes": {"peerName": "bob"}}})
|
||||
|
||||
# Verify isolation
|
||||
a_data = json.loads((profile_a / "honcho.json").read_text())
|
||||
b_data = json.loads((profile_b / "honcho.json").read_text())
|
||||
|
||||
assert a_data["hosts"]["hermes"]["peerName"] == "alice"
|
||||
assert b_data["hosts"]["hermes"]["peerName"] == "bob"
|
||||
|
||||
def test_first_setup_seeds_from_global(self, tmp_path, monkeypatch):
|
||||
"""First setup reads global config, writes to local."""
|
||||
home = tmp_path / "home"
|
||||
global_dir = home / ".honcho"
|
||||
global_dir.mkdir(parents=True)
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: home))
|
||||
import honcho_integration.client as _client_mod
|
||||
import honcho_integration.cli as _cli_mod
|
||||
global_cfg_path = global_dir / "config.json"
|
||||
monkeypatch.setattr(_client_mod, "GLOBAL_CONFIG_PATH", global_cfg_path)
|
||||
monkeypatch.setattr(_cli_mod, "GLOBAL_CONFIG_PATH", global_cfg_path)
|
||||
|
||||
# Existing global config
|
||||
global_config = global_dir / "config.json"
|
||||
global_config.write_text(json.dumps({
|
||||
"apiKey": "shared-key",
|
||||
"hosts": {"hermes": {"workspace": "shared-ws"}},
|
||||
}))
|
||||
|
||||
profile = tmp_path / "new_profile"
|
||||
profile.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(profile))
|
||||
|
||||
# Read seeds from global
|
||||
cfg = _read_config()
|
||||
assert cfg["apiKey"] == "shared-key"
|
||||
|
||||
# Modify and write goes to local
|
||||
cfg["hosts"]["hermes"]["peerName"] = "new-user"
|
||||
_write_config(cfg)
|
||||
|
||||
local_config = profile / "honcho.json"
|
||||
assert local_config.exists()
|
||||
local_data = json.loads(local_config.read_text())
|
||||
assert local_data["hosts"]["hermes"]["peerName"] == "new-user"
|
||||
|
||||
# Global unchanged
|
||||
global_data = json.loads(global_config.read_text())
|
||||
assert "peerName" not in global_data["hosts"]["hermes"]
|
||||
@@ -20,8 +20,8 @@ from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_integration.client import HonchoClientConfig
|
||||
from honcho_integration.session import (
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
from plugins.memory.honcho.session import (
|
||||
HonchoSession,
|
||||
HonchoSessionManager,
|
||||
_ASYNC_SHUTDOWN,
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for honcho_integration/client.py — Honcho client configuration."""
|
||||
"""Tests for plugins/memory/honcho/client.py — Honcho client configuration."""
|
||||
|
||||
import json
|
||||
import os
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_integration.client import (
|
||||
from plugins.memory.honcho.client import (
|
||||
HonchoClientConfig,
|
||||
get_honcho_client,
|
||||
reset_honcho_client,
|
||||
@@ -461,7 +461,7 @@ class TestProfileScopedConfig:
|
||||
"hermes.dreamer": {"peerName": "dreamer-user"},
|
||||
},
|
||||
}))
|
||||
with patch("honcho_integration.client.resolve_active_host", return_value="hermes.dreamer"):
|
||||
with patch("plugins.memory.honcho.client.resolve_active_host", return_value="hermes.dreamer"):
|
||||
config = HonchoClientConfig.from_global_config(config_path=config_file)
|
||||
assert config.host == "hermes.dreamer"
|
||||
assert config.peer_name == "dreamer-user"
|
||||
@@ -469,7 +469,7 @@ class TestProfileScopedConfig:
|
||||
|
||||
class TestResetHonchoClient:
|
||||
def test_reset_clears_singleton(self):
|
||||
import honcho_integration.client as mod
|
||||
import plugins.memory.honcho.client as mod
|
||||
mod._honcho_client = MagicMock()
|
||||
assert mod._honcho_client is not None
|
||||
reset_honcho_client()
|
||||
@@ -1,9 +1,9 @@
|
||||
"""Tests for honcho_integration/session.py — HonchoSession and helpers."""
|
||||
"""Tests for plugins/memory/honcho/session.py — HonchoSession and helpers."""
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from honcho_integration.session import (
|
||||
from plugins.memory.honcho.session import (
|
||||
HonchoSession,
|
||||
HonchoSessionManager,
|
||||
)
|
||||
@@ -4,10 +4,41 @@ import types
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.auth import AuthError
|
||||
from hermes_cli import main as hermes_main
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module isolation: _import_cli() wipes tools.* / cli / run_agent from
|
||||
# sys.modules so it can re-import cli fresh. Without cleanup the wiped
|
||||
# modules leak into subsequent tests on the same xdist worker, breaking
|
||||
# mock patches that target "tools.file_tools._get_file_ops" etc.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _reset_modules(prefixes: tuple[str, ...]):
|
||||
for name in list(sys.modules):
|
||||
if any(name == p or name.startswith(p + ".") for p in prefixes):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_cli_and_tool_modules():
|
||||
"""Save and restore tools/cli/run_agent modules around every test."""
|
||||
prefixes = ("tools", "cli", "run_agent")
|
||||
original_modules = {
|
||||
name: module
|
||||
for name, module in sys.modules.items()
|
||||
if any(name == p or name.startswith(p + ".") for p in prefixes)
|
||||
}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_reset_modules(prefixes)
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
|
||||
def _install_prompt_toolkit_stubs():
|
||||
class _Dummy:
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -13,38 +13,6 @@ from unittest.mock import MagicMock, patch, call
|
||||
import pytest
|
||||
|
||||
|
||||
class TestHonchoAtexitFlush:
|
||||
"""run_agent.py — _register_honcho_exit_hook atexit handler."""
|
||||
|
||||
def test_keyboard_interrupt_during_flush_does_not_propagate(self):
|
||||
"""The atexit handler must swallow KeyboardInterrupt from flush_all()."""
|
||||
mock_manager = MagicMock()
|
||||
mock_manager.flush_all.side_effect = KeyboardInterrupt
|
||||
|
||||
# Capture functions passed to atexit.register
|
||||
registered_fns = []
|
||||
original_register = atexit.register
|
||||
|
||||
def capturing_register(fn, *args, **kwargs):
|
||||
registered_fns.append(fn)
|
||||
# Don't actually register — we don't want side effects
|
||||
|
||||
with patch("atexit.register", side_effect=capturing_register):
|
||||
from run_agent import AIAgent
|
||||
agent = object.__new__(AIAgent)
|
||||
agent._honcho = mock_manager
|
||||
agent._honcho_exit_hook_registered = False
|
||||
agent._register_honcho_exit_hook()
|
||||
|
||||
# Our handler is the last one registered
|
||||
assert len(registered_fns) >= 1, "atexit handler was not registered"
|
||||
flush_handler = registered_fns[-1]
|
||||
|
||||
# Invoke the registered handler — must not raise
|
||||
flush_handler()
|
||||
mock_manager.flush_all.assert_called_once()
|
||||
|
||||
|
||||
class TestCronJobCleanup:
|
||||
"""cron/scheduler.py — end_session + close in the finally block."""
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from honcho_integration.client import HonchoClientConfig
|
||||
from plugins.memory.honcho.client import HonchoClientConfig
|
||||
|
||||
|
||||
class TestHonchoClientConfigAutoEnable:
|
||||
|
||||
@@ -17,8 +17,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
||||
import pytest
|
||||
|
||||
import run_agent
|
||||
from honcho_integration.client import HonchoClientConfig
|
||||
from run_agent import AIAgent, _inject_honcho_turn_context
|
||||
from run_agent import AIAgent
|
||||
from agent.prompt_builder import DEFAULT_AGENT_IDENTITY
|
||||
|
||||
|
||||
@@ -1254,8 +1253,7 @@ class TestConcurrentToolExecution:
|
||||
mock_hfc.assert_called_once_with(
|
||||
"web_search", {"q": "test"}, "task-1",
|
||||
enabled_tools=list(agent.valid_tool_names),
|
||||
honcho_manager=None,
|
||||
honcho_session_key=None,
|
||||
|
||||
)
|
||||
assert result == "result"
|
||||
|
||||
@@ -2193,305 +2191,6 @@ class TestSystemPromptStability:
|
||||
# Empty string is falsy, so should fall through to fresh build
|
||||
assert "Hermes Agent" in agent._cached_system_prompt
|
||||
|
||||
def test_honcho_context_baked_into_prompt_on_first_turn(self, agent):
|
||||
"""Honcho context should be baked into _cached_system_prompt on
|
||||
the first turn, not injected separately per API call."""
|
||||
agent._honcho_context = "User prefers Python over JavaScript."
|
||||
agent._cached_system_prompt = None
|
||||
|
||||
# Simulate first turn: build fresh and bake in Honcho
|
||||
agent._cached_system_prompt = agent._build_system_prompt()
|
||||
if agent._honcho_context:
|
||||
agent._cached_system_prompt = (
|
||||
agent._cached_system_prompt + "\n\n" + agent._honcho_context
|
||||
).strip()
|
||||
|
||||
assert "User prefers Python over JavaScript" in agent._cached_system_prompt
|
||||
|
||||
def test_honcho_prefetch_runs_on_continuing_session(self):
|
||||
"""Honcho prefetch is consumed on continuing sessions via ephemeral context."""
|
||||
conversation_history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
recall_mode = "hybrid"
|
||||
should_prefetch = bool(conversation_history) and recall_mode != "tools"
|
||||
assert should_prefetch is True
|
||||
|
||||
def test_inject_honcho_turn_context_appends_system_note(self):
|
||||
content = _inject_honcho_turn_context("hello", "## Honcho Memory\nprior context")
|
||||
assert "hello" in content
|
||||
assert "Honcho memory was retrieved from prior sessions" in content
|
||||
assert "## Honcho Memory" in content
|
||||
|
||||
def test_honcho_continuing_session_keeps_turn_context_out_of_system_prompt(self, agent):
|
||||
captured = {}
|
||||
|
||||
def _fake_api_call(api_kwargs):
|
||||
captured.update(api_kwargs)
|
||||
return _mock_response(content="done", finish_reason="stop")
|
||||
|
||||
agent._honcho = object()
|
||||
agent._honcho_session_key = "session-1"
|
||||
agent._honcho_config = SimpleNamespace(
|
||||
ai_peer="hermes",
|
||||
memory_mode="hybrid",
|
||||
write_frequency="async",
|
||||
recall_mode="hybrid",
|
||||
)
|
||||
agent._use_prompt_caching = False
|
||||
conversation_history = [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "hi there"},
|
||||
]
|
||||
|
||||
with (
|
||||
patch.object(agent, "_honcho_prefetch", return_value="## Honcho Memory\nprior context"),
|
||||
patch.object(agent, "_queue_honcho_prefetch"),
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call),
|
||||
):
|
||||
result = agent.run_conversation("what were we doing?", conversation_history=conversation_history)
|
||||
|
||||
assert result["completed"] is True
|
||||
api_messages = captured["messages"]
|
||||
assert api_messages[0]["role"] == "system"
|
||||
assert "prior context" not in api_messages[0]["content"]
|
||||
current_user = api_messages[-1]
|
||||
assert current_user["role"] == "user"
|
||||
assert "what were we doing?" in current_user["content"]
|
||||
assert "prior context" in current_user["content"]
|
||||
assert "Honcho memory was retrieved from prior sessions" in current_user["content"]
|
||||
|
||||
def test_honcho_prefetch_runs_on_first_turn(self):
|
||||
"""Honcho prefetch should run when conversation_history is empty."""
|
||||
conversation_history = []
|
||||
should_prefetch = not conversation_history
|
||||
assert should_prefetch is True
|
||||
|
||||
def test_run_conversation_can_skip_honcho_sync_for_synthetic_turns(self, agent):
|
||||
captured = {}
|
||||
|
||||
def _fake_api_call(api_kwargs):
|
||||
captured.update(api_kwargs)
|
||||
return _mock_response(content="done", finish_reason="stop")
|
||||
|
||||
agent._honcho = MagicMock()
|
||||
agent._honcho_session_key = "session-1"
|
||||
agent._honcho_config = SimpleNamespace(
|
||||
ai_peer="hermes",
|
||||
memory_mode="hybrid",
|
||||
write_frequency="async",
|
||||
recall_mode="hybrid",
|
||||
)
|
||||
agent._use_prompt_caching = False
|
||||
|
||||
with (
|
||||
patch.object(agent, "_honcho_sync") as mock_sync,
|
||||
patch.object(agent, "_queue_honcho_prefetch") as mock_prefetch,
|
||||
patch.object(agent, "_persist_session"),
|
||||
patch.object(agent, "_save_trajectory"),
|
||||
patch.object(agent, "_cleanup_task_resources"),
|
||||
patch.object(agent, "_interruptible_api_call", side_effect=_fake_api_call),
|
||||
):
|
||||
result = agent.run_conversation("synthetic flush turn", sync_honcho=False)
|
||||
|
||||
assert result["completed"] is True
|
||||
assert captured["messages"][-1]["content"] == "synthetic flush turn"
|
||||
mock_sync.assert_not_called()
|
||||
mock_prefetch.assert_not_called()
|
||||
|
||||
|
||||
class TestHonchoActivation:
|
||||
def test_disabled_config_skips_honcho_init(self):
|
||||
hcfg = HonchoClientConfig(
|
||||
enabled=False,
|
||||
api_key="honcho-key",
|
||||
peer_name="user",
|
||||
ai_peer="hermes",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
patch("honcho_integration.client.HonchoClientConfig.from_global_config", return_value=hcfg),
|
||||
patch("honcho_integration.client.get_honcho_client") as mock_client,
|
||||
):
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=False,
|
||||
)
|
||||
|
||||
assert agent._honcho is None
|
||||
assert agent._honcho_config is hcfg
|
||||
mock_client.assert_not_called()
|
||||
|
||||
def test_injected_honcho_manager_skips_fresh_client_init(self):
|
||||
hcfg = HonchoClientConfig(
|
||||
enabled=True,
|
||||
api_key="honcho-key",
|
||||
memory_mode="hybrid",
|
||||
peer_name="user",
|
||||
ai_peer="hermes",
|
||||
recall_mode="hybrid",
|
||||
)
|
||||
manager = MagicMock()
|
||||
manager._config = hcfg
|
||||
manager.get_or_create.return_value = SimpleNamespace(messages=[])
|
||||
manager.get_prefetch_context.return_value = {"representation": "Known user", "card": ""}
|
||||
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
patch("honcho_integration.client.get_honcho_client") as mock_client,
|
||||
patch("tools.honcho_tools.set_session_context"),
|
||||
):
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=False,
|
||||
honcho_session_key="gateway-session",
|
||||
honcho_manager=manager,
|
||||
honcho_config=hcfg,
|
||||
)
|
||||
|
||||
assert agent._honcho is manager
|
||||
manager.get_or_create.assert_called_once_with("gateway-session")
|
||||
manager.get_prefetch_context.assert_called_once_with("gateway-session")
|
||||
manager.set_context_result.assert_called_once_with(
|
||||
"gateway-session",
|
||||
{"representation": "Known user", "card": ""},
|
||||
)
|
||||
mock_client.assert_not_called()
|
||||
|
||||
def test_recall_mode_context_suppresses_honcho_tools(self):
|
||||
hcfg = HonchoClientConfig(
|
||||
enabled=True,
|
||||
api_key="honcho-key",
|
||||
memory_mode="hybrid",
|
||||
peer_name="user",
|
||||
ai_peer="hermes",
|
||||
recall_mode="context",
|
||||
)
|
||||
manager = MagicMock()
|
||||
manager._config = hcfg
|
||||
manager.get_or_create.return_value = SimpleNamespace(messages=[])
|
||||
manager.get_prefetch_context.return_value = {"representation": "Known user", "card": ""}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"run_agent.get_tool_definitions",
|
||||
side_effect=[
|
||||
_make_tool_defs("web_search"),
|
||||
_make_tool_defs(
|
||||
"web_search",
|
||||
"honcho_context",
|
||||
"honcho_profile",
|
||||
"honcho_search",
|
||||
"honcho_conclude",
|
||||
),
|
||||
],
|
||||
),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
patch("tools.honcho_tools.set_session_context"),
|
||||
):
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=False,
|
||||
honcho_session_key="gateway-session",
|
||||
honcho_manager=manager,
|
||||
honcho_config=hcfg,
|
||||
)
|
||||
|
||||
assert "web_search" in agent.valid_tool_names
|
||||
assert "honcho_context" not in agent.valid_tool_names
|
||||
assert "honcho_profile" not in agent.valid_tool_names
|
||||
assert "honcho_search" not in agent.valid_tool_names
|
||||
assert "honcho_conclude" not in agent.valid_tool_names
|
||||
|
||||
def test_inactive_honcho_strips_stale_honcho_tools(self):
|
||||
hcfg = HonchoClientConfig(
|
||||
enabled=False,
|
||||
api_key="honcho-key",
|
||||
peer_name="user",
|
||||
ai_peer="hermes",
|
||||
)
|
||||
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search", "honcho_context")),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
patch("honcho_integration.client.HonchoClientConfig.from_global_config", return_value=hcfg),
|
||||
patch("honcho_integration.client.get_honcho_client") as mock_client,
|
||||
):
|
||||
agent = AIAgent(
|
||||
api_key="test-key-1234567890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=False,
|
||||
)
|
||||
|
||||
assert agent._honcho is None
|
||||
assert "web_search" in agent.valid_tool_names
|
||||
assert "honcho_context" not in agent.valid_tool_names
|
||||
mock_client.assert_not_called()
|
||||
|
||||
|
||||
class TestHonchoPrefetchScheduling:
|
||||
def test_honcho_prefetch_includes_cached_dialectic(self, agent):
|
||||
agent._honcho = MagicMock()
|
||||
agent._honcho_session_key = "session-key"
|
||||
agent._honcho.pop_context_result.return_value = {}
|
||||
agent._honcho.pop_dialectic_result.return_value = "Continue with the migration checklist."
|
||||
|
||||
context = agent._honcho_prefetch("what next?")
|
||||
|
||||
assert "Continuity synthesis" in context
|
||||
assert "migration checklist" in context
|
||||
|
||||
def test_queue_honcho_prefetch_skips_tools_mode(self, agent):
|
||||
agent._honcho = MagicMock()
|
||||
agent._honcho_session_key = "session-key"
|
||||
agent._honcho_config = HonchoClientConfig(
|
||||
enabled=True,
|
||||
api_key="honcho-key",
|
||||
recall_mode="tools",
|
||||
)
|
||||
|
||||
agent._queue_honcho_prefetch("what next?")
|
||||
|
||||
agent._honcho.prefetch_context.assert_not_called()
|
||||
agent._honcho.prefetch_dialectic.assert_not_called()
|
||||
|
||||
def test_queue_honcho_prefetch_runs_when_context_enabled(self, agent):
|
||||
agent._honcho = MagicMock()
|
||||
agent._honcho_session_key = "session-key"
|
||||
agent._honcho_config = HonchoClientConfig(
|
||||
enabled=True,
|
||||
api_key="honcho-key",
|
||||
recall_mode="hybrid",
|
||||
)
|
||||
|
||||
agent._queue_honcho_prefetch("what next?")
|
||||
|
||||
agent._honcho.prefetch_context.assert_called_once_with("session-key", "what next?")
|
||||
agent._honcho.prefetch_dialectic.assert_called_once_with("session-key", "what next?")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Iteration budget pressure warnings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestBudgetPressure:
|
||||
"""Budget pressure warning system (issue #414)."""
|
||||
|
||||
@@ -2629,38 +2328,8 @@ class TestSafeWriter:
|
||||
sys.stdout = original_stdout
|
||||
sys.stderr = original_stderr
|
||||
|
||||
def test_installed_before_init_time_honcho_error_prints(self):
|
||||
"""AIAgent.__init__ wraps stdout before Honcho fallback prints can fire."""
|
||||
import sys
|
||||
from run_agent import _SafeWriter
|
||||
|
||||
broken = MagicMock()
|
||||
broken.write.side_effect = OSError(5, "Input/output error")
|
||||
broken.flush.side_effect = OSError(5, "Input/output error")
|
||||
|
||||
original = sys.stdout
|
||||
sys.stdout = broken
|
||||
try:
|
||||
hcfg = HonchoClientConfig(enabled=True, api_key="test-honcho-key")
|
||||
with (
|
||||
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
|
||||
patch("run_agent.check_toolset_requirements", return_value={}),
|
||||
patch("run_agent.OpenAI"),
|
||||
patch("hermes_cli.config.load_config", return_value={"memory": {}}),
|
||||
patch("honcho_integration.client.HonchoClientConfig.from_global_config", return_value=hcfg),
|
||||
patch("honcho_integration.client.get_honcho_client", side_effect=RuntimeError("boom")),
|
||||
):
|
||||
agent = AIAgent(
|
||||
api_key="test-k...7890",
|
||||
quiet_mode=True,
|
||||
skip_context_files=True,
|
||||
skip_memory=False,
|
||||
)
|
||||
|
||||
assert isinstance(sys.stdout, _SafeWriter)
|
||||
assert agent._honcho is None
|
||||
finally:
|
||||
sys.stdout = original
|
||||
# test_installed_before_init_time_honcho_error_prints removed —
|
||||
# Honcho integration extracted to plugin (PR #4154).
|
||||
|
||||
def test_double_wrap_prevented(self):
|
||||
"""Wrapping an already-wrapped stream doesn't add layers."""
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""Regression tests for per-call Honcho tool session routing."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from dataclasses import dataclass
|
||||
|
||||
from tools import honcho_tools
|
||||
|
||||
|
||||
class TestCheckHonchoAvailable:
|
||||
"""Tests for _check_honcho_available (banner + runtime gating)."""
|
||||
|
||||
def setup_method(self):
|
||||
self.orig_manager = honcho_tools._session_manager
|
||||
self.orig_key = honcho_tools._session_key
|
||||
|
||||
def teardown_method(self):
|
||||
honcho_tools._session_manager = self.orig_manager
|
||||
honcho_tools._session_key = self.orig_key
|
||||
|
||||
def test_returns_true_when_session_active(self):
|
||||
"""Fast path: session context already injected (mid-conversation)."""
|
||||
honcho_tools._session_manager = MagicMock()
|
||||
honcho_tools._session_key = "test-key"
|
||||
assert honcho_tools._check_honcho_available() is True
|
||||
|
||||
def test_returns_true_when_configured_but_no_session(self):
|
||||
"""Slow path: honcho configured but agent not started yet (banner time)."""
|
||||
honcho_tools._session_manager = None
|
||||
honcho_tools._session_key = None
|
||||
|
||||
@dataclass
|
||||
class FakeConfig:
|
||||
enabled: bool = True
|
||||
api_key: str = "test-key"
|
||||
base_url: str = None
|
||||
|
||||
with patch("tools.honcho_tools.HonchoClientConfig", create=True):
|
||||
with patch(
|
||||
"honcho_integration.client.HonchoClientConfig"
|
||||
) as mock_cls:
|
||||
mock_cls.from_global_config.return_value = FakeConfig()
|
||||
assert honcho_tools._check_honcho_available() is True
|
||||
|
||||
def test_returns_false_when_not_configured(self):
|
||||
"""No session, no config: tool genuinely unavailable."""
|
||||
honcho_tools._session_manager = None
|
||||
honcho_tools._session_key = None
|
||||
|
||||
@dataclass
|
||||
class FakeConfig:
|
||||
enabled: bool = False
|
||||
api_key: str = None
|
||||
base_url: str = None
|
||||
|
||||
with patch(
|
||||
"honcho_integration.client.HonchoClientConfig"
|
||||
) as mock_cls:
|
||||
mock_cls.from_global_config.return_value = FakeConfig()
|
||||
assert honcho_tools._check_honcho_available() is False
|
||||
|
||||
def test_returns_false_when_import_fails(self):
|
||||
"""Graceful fallback when honcho_integration not installed."""
|
||||
import sys
|
||||
|
||||
honcho_tools._session_manager = None
|
||||
honcho_tools._session_key = None
|
||||
|
||||
# Hide honcho_integration from the import system to simulate
|
||||
# an environment where the package is not installed.
|
||||
hidden = {
|
||||
k: sys.modules.pop(k)
|
||||
for k in list(sys.modules)
|
||||
if k.startswith("honcho_integration")
|
||||
}
|
||||
try:
|
||||
with patch.dict(sys.modules, {"honcho_integration": None,
|
||||
"honcho_integration.client": None}):
|
||||
assert honcho_tools._check_honcho_available() is False
|
||||
finally:
|
||||
sys.modules.update(hidden)
|
||||
|
||||
|
||||
class TestHonchoToolSessionContext:
|
||||
def setup_method(self):
|
||||
self.orig_manager = honcho_tools._session_manager
|
||||
self.orig_key = honcho_tools._session_key
|
||||
|
||||
def teardown_method(self):
|
||||
honcho_tools._session_manager = self.orig_manager
|
||||
honcho_tools._session_key = self.orig_key
|
||||
|
||||
def test_explicit_call_context_wins_over_module_global_state(self):
|
||||
global_manager = MagicMock()
|
||||
global_manager.get_peer_card.return_value = ["global"]
|
||||
explicit_manager = MagicMock()
|
||||
explicit_manager.get_peer_card.return_value = ["explicit"]
|
||||
|
||||
honcho_tools.set_session_context(global_manager, "global-session")
|
||||
|
||||
result = json.loads(
|
||||
honcho_tools._handle_honcho_profile(
|
||||
{},
|
||||
honcho_manager=explicit_manager,
|
||||
honcho_session_key="explicit-session",
|
||||
)
|
||||
)
|
||||
|
||||
assert result == {"result": ["explicit"]}
|
||||
explicit_manager.get_peer_card.assert_called_once_with("explicit-session")
|
||||
global_manager.get_peer_card.assert_not_called()
|
||||
Reference in New Issue
Block a user