fix(honcho): resolve review blockers for merge

Address merge-blocking review feedback by removing unsafe signal handler overrides, wiring next-turn Honcho prefetch, restoring per-directory session defaults, and exposing all Honcho tools to the model surface. Also harden prefetch cache access with public thread-safe accessors and remove duplicate browser cleanup code.

Made-with: Cursor
This commit is contained in:
Erosika 2026-03-11 11:46:37 -04:00
parent 4c54c2709c
commit 047b118299
9 changed files with 162 additions and 69 deletions

View file

@ -157,11 +157,11 @@ def cmd_setup(args) -> None:
cfg["recallMode"] = new_recall cfg["recallMode"] = new_recall
# Session strategy # Session strategy
current_strat = cfg.get("sessionStrategy", "per-session") current_strat = cfg.get("sessionStrategy", "per-directory")
print(f"\n Session strategy options:") print(f"\n Session strategy options:")
print(" per-session — new Honcho session each run, named by Hermes session ID (default)") print(" per-directory — one session per working directory (default)")
print(" per-repo — one session per git repository (uses repo root name)") print(" per-repo — one session per git repository (uses repo root name)")
print(" per-directory — one session per working directory") print(" per-session — new Honcho session each run, named by Hermes session ID")
print(" global — single session across all directories") print(" global — single session across all directories")
new_strat = _prompt("Session strategy", default=current_strat) new_strat = _prompt("Session strategy", default=current_strat)
if new_strat in ("per-session", "per-repo", "per-directory", "global"): if new_strat in ("per-session", "per-repo", "per-directory", "global"):
@ -199,6 +199,7 @@ def cmd_setup(args) -> None:
print(f" honcho_context — ask Honcho a question about you (LLM-synthesized)") print(f" honcho_context — ask Honcho a question about you (LLM-synthesized)")
print(f" honcho_search — semantic search over your history (no LLM)") print(f" honcho_search — semantic search over your history (no LLM)")
print(f" honcho_profile — your peer card, key facts (no LLM)") print(f" honcho_profile — your peer card, key facts (no LLM)")
print(f" honcho_conclude — persist a user fact to Honcho memory (no LLM)")
print(f"\n Other commands:") print(f"\n Other commands:")
print(f" hermes honcho status — show full config") print(f" hermes honcho status — show full config")
print(f" hermes honcho mode — show or change memory mode") print(f" hermes honcho mode — show or change memory mode")
@ -710,10 +711,11 @@ def cmd_migrate(args) -> None:
print(" honcho_context — ask Honcho a question, get a synthesized answer (LLM)") print(" honcho_context — ask Honcho a question, get a synthesized answer (LLM)")
print(" honcho_search — semantic search over stored context (no LLM)") print(" honcho_search — semantic search over stored context (no LLM)")
print(" honcho_profile — fast peer card snapshot (no LLM)") print(" honcho_profile — fast peer card snapshot (no LLM)")
print(" honcho_conclude — write a conclusion/fact back to memory (no LLM)")
print() print()
print(" Session naming") print(" Session naming")
print(" OpenClaw: no persistent session concept — files are global.") print(" OpenClaw: no persistent session concept — files are global.")
print(" Hermes: per-session by default — each run gets a new Honcho session") print(" Hermes: per-directory by default — each project gets its own session")
print(" Map a custom name: hermes honcho map <session-name>") print(" Map a custom name: hermes honcho map <session-name>")
# ── Step 6: Next steps ──────────────────────────────────────────────────── # ── Step 6: Next steps ────────────────────────────────────────────────────

View file

@ -95,7 +95,7 @@ class HonchoClientConfig:
# "tools" — no pre-loaded context, rely on tool calls only # "tools" — no pre-loaded context, rely on tool calls only
recall_mode: str = "hybrid" recall_mode: str = "hybrid"
# Session resolution # Session resolution
session_strategy: str = "per-session" session_strategy: str = "per-directory"
session_peer_prefix: bool = False session_peer_prefix: bool = False
sessions: dict[str, str] = field(default_factory=dict) sessions: dict[str, str] = field(default_factory=dict)
# Raw global config for anything else consumers need # Raw global config for anything else consumers need
@ -201,7 +201,7 @@ class HonchoClientConfig:
or raw.get("recallMode") or raw.get("recallMode")
or "hybrid" or "hybrid"
), ),
session_strategy=raw.get("sessionStrategy", "per-session"), session_strategy=raw.get("sessionStrategy", "per-directory"),
session_peer_prefix=raw.get("sessionPeerPrefix", False), session_peer_prefix=raw.get("sessionPeerPrefix", False),
sessions=raw.get("sessions", {}), sessions=raw.get("sessions", {}),
raw=raw, raw=raw,

View file

@ -103,6 +103,7 @@ class HonchoSessionManager:
# Prefetch caches: session_key → last result (consumed once per turn) # Prefetch caches: session_key → last result (consumed once per turn)
self._context_cache: dict[str, dict] = {} self._context_cache: dict[str, dict] = {}
self._dialectic_cache: dict[str, str] = {} self._dialectic_cache: dict[str, str] = {}
self._prefetch_cache_lock = threading.Lock()
self._dialectic_reasoning_level: str = ( self._dialectic_reasoning_level: str = (
config.dialectic_reasoning_level if config else "low" config.dialectic_reasoning_level if config else "low"
) )
@ -496,18 +497,26 @@ class HonchoSessionManager:
def _run(): def _run():
result = self.dialectic_query(session_key, query) result = self.dialectic_query(session_key, query)
if result: if result:
self._dialectic_cache[session_key] = result self.set_dialectic_result(session_key, result)
t = threading.Thread(target=_run, name="honcho-dialectic-prefetch", daemon=True) t = threading.Thread(target=_run, name="honcho-dialectic-prefetch", daemon=True)
t.start() t.start()
def set_dialectic_result(self, session_key: str, result: str) -> None:
"""Store a prefetched dialectic result in a thread-safe way."""
if not result:
return
with self._prefetch_cache_lock:
self._dialectic_cache[session_key] = result
def pop_dialectic_result(self, session_key: str) -> str: def pop_dialectic_result(self, session_key: str) -> str:
""" """
Return and clear the cached dialectic result for this session. Return and clear the cached dialectic result for this session.
Returns empty string if no result is ready yet. Returns empty string if no result is ready yet.
""" """
return self._dialectic_cache.pop(session_key, "") with self._prefetch_cache_lock:
return self._dialectic_cache.pop(session_key, "")
def prefetch_context(self, session_key: str, user_message: str | None = None) -> None: def prefetch_context(self, session_key: str, user_message: str | None = None) -> None:
""" """
@ -519,18 +528,26 @@ class HonchoSessionManager:
def _run(): def _run():
result = self.get_prefetch_context(session_key, user_message) result = self.get_prefetch_context(session_key, user_message)
if result: if result:
self._context_cache[session_key] = result self.set_context_result(session_key, result)
t = threading.Thread(target=_run, name="honcho-context-prefetch", daemon=True) t = threading.Thread(target=_run, name="honcho-context-prefetch", daemon=True)
t.start() t.start()
def set_context_result(self, session_key: str, result: dict[str, str]) -> None:
"""Store a prefetched context result in a thread-safe way."""
if not result:
return
with self._prefetch_cache_lock:
self._context_cache[session_key] = result
def pop_context_result(self, session_key: str) -> dict[str, str]: def pop_context_result(self, session_key: str) -> dict[str, str]:
""" """
Return and clear the cached context result for this session. Return and clear the cached context result for this session.
Returns empty dict if no result is ready yet (first turn). Returns empty dict if no result is ready yet (first turn).
""" """
return self._context_cache.pop(session_key, {}) with self._prefetch_cache_lock:
return self._context_cache.pop(session_key, {})
def get_prefetch_context(self, session_key: str, user_message: str | None = None) -> dict[str, str]: def get_prefetch_context(self, session_key: str, user_message: str | None = None) -> dict[str, str]:
""" """

View file

@ -20,6 +20,7 @@ Usage:
response = agent.run_conversation("Tell me about the latest Python updates") response = agent.run_conversation("Tell me about the latest Python updates")
""" """
import atexit
import copy import copy
import hashlib import hashlib
import json import json
@ -31,6 +32,7 @@ import re
import sys import sys
import time import time
import threading import threading
import weakref
from types import SimpleNamespace from types import SimpleNamespace
import uuid import uuid
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
@ -550,6 +552,7 @@ class AIAgent:
self._honcho = None # HonchoSessionManager | None self._honcho = None # HonchoSessionManager | None
self._honcho_session_key = honcho_session_key self._honcho_session_key = honcho_session_key
self._honcho_config = None # HonchoClientConfig | None self._honcho_config = None # HonchoClientConfig | None
self._honcho_exit_hook_registered = False
if not skip_memory: if not skip_memory:
try: try:
if honcho_manager is not None: if honcho_manager is not None:
@ -1427,28 +1430,46 @@ class AIAgent:
try: try:
ctx = self._honcho.get_prefetch_context(self._honcho_session_key) ctx = self._honcho.get_prefetch_context(self._honcho_session_key)
if ctx: if ctx:
self._honcho._context_cache[self._honcho_session_key] = ctx self._honcho.set_context_result(self._honcho_session_key, ctx)
logger.debug("Honcho context pre-warmed for first turn") logger.debug("Honcho context pre-warmed for first turn")
except Exception as exc: except Exception as exc:
logger.debug("Honcho context prefetch failed (non-fatal): %s", exc) logger.debug("Honcho context prefetch failed (non-fatal): %s", exc)
import signal as _signal self._register_honcho_exit_hook()
import threading as _threading
honcho_ref = self._honcho def _register_honcho_exit_hook(self) -> None:
"""Register a process-exit flush hook without clobbering signal handlers."""
if self._honcho_exit_hook_registered or not self._honcho:
return
if _threading.current_thread() is _threading.main_thread(): honcho_ref = weakref.ref(self._honcho)
def _honcho_flush_handler(signum, frame):
try:
honcho_ref.flush_all()
except Exception:
pass
if signum == _signal.SIGINT:
raise KeyboardInterrupt
raise SystemExit(0)
_signal.signal(_signal.SIGTERM, _honcho_flush_handler) def _flush_honcho_on_exit():
_signal.signal(_signal.SIGINT, _honcho_flush_handler) manager = honcho_ref()
if manager is None:
return
try:
manager.flush_all()
except Exception as exc:
logger.debug("Honcho flush on exit failed (non-fatal): %s", exc)
atexit.register(_flush_honcho_on_exit)
self._honcho_exit_hook_registered = True
def _queue_honcho_prefetch(self, user_message: str) -> None:
"""Queue turn-end Honcho prefetch so the next turn can consume cached results."""
if not self._honcho or not self._honcho_session_key:
return
recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid")
if recall_mode == "tools":
return
try:
self._honcho.prefetch_context(self._honcho_session_key, user_message)
self._honcho.prefetch_dialectic(self._honcho_session_key, user_message or "What were we working on?")
except Exception as exc:
logger.debug("Honcho background prefetch failed (non-fatal): %s", exc)
def _honcho_prefetch(self, user_message: str) -> str: def _honcho_prefetch(self, user_message: str) -> str:
"""Assemble the first-turn Honcho context from the pre-warmed cache.""" """Assemble the first-turn Honcho context from the pre-warmed cache."""
@ -1472,6 +1493,10 @@ class AIAgent:
if ai_card: if ai_card:
parts.append(ai_card) parts.append(ai_card)
dialectic = self._honcho.pop_dialectic_result(self._honcho_session_key)
if dialectic:
parts.append(f"## Continuity synthesis\n{dialectic}")
if not parts: if not parts:
return "" return ""
header = ( header = (
@ -3379,15 +3404,23 @@ class AIAgent:
) )
self._iters_since_skill = 0 self._iters_since_skill = 0
# Honcho: on the first turn only, read the pre-warmed context snapshot and # Honcho prefetch consumption:
# bake it into the system prompt. We intentionally avoid per-turn refreshes # - First turn: bake into cached system prompt (stable for the session).
# here because changing the system prompt would destroy provider prompt-cache # - Later turns: inject as ephemeral system context for this API call only.
# reuse for the rest of the session. #
# This keeps the persisted/cached prompt stable while still allowing
# turn N to consume background prefetch results from turn N-1.
self._honcho_context = "" self._honcho_context = ""
self._honcho_turn_context = ""
_recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid") _recall_mode = (self._honcho_config.recall_mode if self._honcho_config else "hybrid")
if self._honcho and self._honcho_session_key and not conversation_history and _recall_mode != "tools": if self._honcho and self._honcho_session_key and _recall_mode != "tools":
try: try:
self._honcho_context = self._honcho_prefetch(user_message) prefetched_context = self._honcho_prefetch(user_message)
if prefetched_context:
if not conversation_history:
self._honcho_context = prefetched_context
else:
self._honcho_turn_context = prefetched_context
except Exception as e: except Exception as e:
logger.debug("Honcho prefetch failed (non-fatal): %s", e) logger.debug("Honcho prefetch failed (non-fatal): %s", e)
@ -3566,15 +3599,12 @@ class AIAgent:
api_messages.append(api_msg) api_messages.append(api_msg)
# Build the final system message: cached prompt + ephemeral system prompt. # Build the final system message: cached prompt + ephemeral system prompt.
# The ephemeral part is appended here (not baked into the cached prompt) # Ephemeral additions are API-call-time only (not persisted to session DB).
# so it stays out of the session DB and logs.
# Note: Honcho context is baked into _cached_system_prompt on the first
# turn and stored in the session DB, so it does NOT need to be injected
# here. This keeps the system message identical across all turns in a
# session, maximizing Anthropic prompt cache hits.
effective_system = active_system_prompt or "" effective_system = active_system_prompt or ""
if self.ephemeral_system_prompt: if self.ephemeral_system_prompt:
effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip() effective_system = (effective_system + "\n\n" + self.ephemeral_system_prompt).strip()
if self._honcho_turn_context:
effective_system = (effective_system + "\n\n" + self._honcho_turn_context).strip()
if effective_system: if effective_system:
api_messages = [{"role": "system", "content": effective_system}] + api_messages api_messages = [{"role": "system", "content": effective_system}] + api_messages
@ -4656,6 +4686,7 @@ class AIAgent:
# Sync conversation to Honcho for user modeling # Sync conversation to Honcho for user modeling
if final_response and not interrupted: if final_response and not interrupted:
self._honcho_sync(original_user_message, final_response) self._honcho_sync(original_user_message, final_response)
self._queue_honcho_prefetch(original_user_message)
# Build result with interrupt info if applicable # Build result with interrupt info if applicable
result = { result = {

View file

@ -487,3 +487,22 @@ class TestNewConfigFieldDefaults:
cfg = HonchoClientConfig(memory_mode="hybrid", peer_memory_modes={"hermes": "local"}) cfg = HonchoClientConfig(memory_mode="hybrid", peer_memory_modes={"hermes": "local"})
assert cfg.peer_memory_mode("hermes") == "local" assert cfg.peer_memory_mode("hermes") == "local"
assert cfg.peer_memory_mode("other") == "hybrid" assert cfg.peer_memory_mode("other") == "hybrid"
class TestPrefetchCacheAccessors:
def test_set_and_pop_context_result(self):
mgr = _make_manager(write_frequency="turn")
payload = {"representation": "Known user", "card": "prefers concise replies"}
mgr.set_context_result("cli:test", payload)
assert mgr.pop_context_result("cli:test") == payload
assert mgr.pop_context_result("cli:test") == {}
def test_set_and_pop_dialectic_result(self):
mgr = _make_manager(write_frequency="turn")
mgr.set_dialectic_result("cli:test", "Resume with toolset cleanup")
assert mgr.pop_dialectic_result("cli:test") == "Resume with toolset cleanup"
assert mgr.pop_dialectic_result("cli:test") == ""

View file

@ -25,7 +25,7 @@ class TestHonchoClientConfigDefaults:
assert config.environment == "production" assert config.environment == "production"
assert config.enabled is False assert config.enabled is False
assert config.save_messages is True assert config.save_messages is True
assert config.session_strategy == "per-session" assert config.session_strategy == "per-directory"
assert config.recall_mode == "hybrid" assert config.recall_mode == "hybrid"
assert config.session_peer_prefix is False assert config.session_peer_prefix is False
assert config.linked_hosts == [] assert config.linked_hosts == []
@ -140,7 +140,7 @@ class TestFromGlobalConfig:
config_file = tmp_path / "config.json" config_file = tmp_path / "config.json"
config_file.write_text(json.dumps({"apiKey": "key"})) config_file.write_text(json.dumps({"apiKey": "key"}))
config = HonchoClientConfig.from_global_config(config_path=config_file) config = HonchoClientConfig.from_global_config(config_path=config_file)
assert config.session_strategy == "per-session" assert config.session_strategy == "per-directory"
def test_context_tokens_host_block_wins(self, tmp_path): def test_context_tokens_host_block_wins(self, tmp_path):
"""Host block contextTokens should override root.""" """Host block contextTokens should override root."""

View file

@ -1192,17 +1192,15 @@ class TestSystemPromptStability:
assert "User prefers Python over JavaScript" in agent._cached_system_prompt assert "User prefers Python over JavaScript" in agent._cached_system_prompt
def test_honcho_prefetch_skipped_on_continuing_session(self): def test_honcho_prefetch_runs_on_continuing_session(self):
"""Honcho prefetch should not be called when conversation_history """Honcho prefetch is consumed on continuing sessions via ephemeral context."""
is non-empty (continuing session)."""
conversation_history = [ conversation_history = [
{"role": "user", "content": "hello"}, {"role": "user", "content": "hello"},
{"role": "assistant", "content": "hi there"}, {"role": "assistant", "content": "hi there"},
] ]
recall_mode = "hybrid"
# The guard: `not conversation_history` is False when history exists should_prefetch = bool(conversation_history) and recall_mode != "tools"
should_prefetch = not conversation_history assert should_prefetch is True
assert should_prefetch is False
def test_honcho_prefetch_runs_on_first_turn(self): def test_honcho_prefetch_runs_on_first_turn(self):
"""Honcho prefetch should run when conversation_history is empty.""" """Honcho prefetch should run when conversation_history is empty."""
@ -1273,4 +1271,49 @@ class TestHonchoActivation:
assert agent._honcho is manager assert agent._honcho is manager
manager.get_or_create.assert_called_once_with("gateway-session") manager.get_or_create.assert_called_once_with("gateway-session")
manager.get_prefetch_context.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() 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?")

View file

@ -1640,25 +1640,6 @@ def _cleanup_old_recordings(max_age_hours=72):
logger.debug("Recording cleanup error (non-critical): %s", e) logger.debug("Recording cleanup error (non-critical): %s", e)
def _cleanup_old_recordings(max_age_hours=72):
"""Remove browser recordings older than max_age_hours to prevent disk bloat."""
import time
try:
hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
recordings_dir = hermes_home / "browser_recordings"
if not recordings_dir.exists():
return
cutoff = time.time() - (max_age_hours * 3600)
for f in recordings_dir.glob("session_*.webm"):
try:
if f.stat().st_mtime < cutoff:
f.unlink()
except Exception:
pass
except Exception:
pass
# ============================================================================ # ============================================================================
# Cleanup and Management Functions # Cleanup and Management Functions
# ============================================================================ # ============================================================================
@ -1764,7 +1745,7 @@ def cleanup_browser(task_id: Optional[str] = None) -> None:
pid_file = os.path.join(socket_dir, f"{session_name}.pid") pid_file = os.path.join(socket_dir, f"{session_name}.pid")
if os.path.isfile(pid_file): if os.path.isfile(pid_file):
try: try:
daemon_pid = int(open(pid_file).read().strip()) daemon_pid = int(Path(pid_file).read_text().strip())
os.kill(daemon_pid, signal.SIGTERM) os.kill(daemon_pid, signal.SIGTERM)
logger.debug("Killed daemon pid %s for %s", daemon_pid, session_name) logger.debug("Killed daemon pid %s for %s", daemon_pid, session_name)
except (ProcessLookupError, ValueError, PermissionError, OSError): except (ProcessLookupError, ValueError, PermissionError, OSError):

View file

@ -60,8 +60,8 @@ _HERMES_CORE_TOOLS = [
"schedule_cronjob", "list_cronjobs", "remove_cronjob", "schedule_cronjob", "list_cronjobs", "remove_cronjob",
# Cross-platform messaging (gated on gateway running via check_fn) # Cross-platform messaging (gated on gateway running via check_fn)
"send_message", "send_message",
# Honcho user context (gated on honcho being active via check_fn) # Honcho memory tools (gated on honcho being active via check_fn)
"honcho_context", "honcho_context", "honcho_profile", "honcho_search", "honcho_conclude",
# Home Assistant smart home control (gated on HASS_TOKEN via check_fn) # Home Assistant smart home control (gated on HASS_TOKEN via check_fn)
"ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service", "ha_list_entities", "ha_get_state", "ha_list_services", "ha_call_service",
] ]
@ -192,7 +192,7 @@ TOOLSETS = {
"honcho": { "honcho": {
"description": "Honcho AI-native memory for persistent cross-session user modeling", "description": "Honcho AI-native memory for persistent cross-session user modeling",
"tools": ["honcho_context"], "tools": ["honcho_context", "honcho_profile", "honcho_search", "honcho_conclude"],
"includes": [] "includes": []
}, },