Files
hermes-agent/plugins/memory/honcho/recall_sync.py
Erosika 4b916022ab fix(honcho): surface the peer notice and audit the injection on the recall sync path
With recallSync on, prefetch popped only the auth notice and returned without writing the injection log. A session whose init failed for a missing user peer never told the model that memory was off, and the audit file stayed empty for every turn. The recall sync branch now pops the peer notice the way the async branch does and records each turn as injected or recall-sync-empty.
2026-09-13 19:05:39 +05:30

120 lines
5.6 KiB
Python

"""Bounded current-query recall; background completion never publishes recall state."""
from __future__ import annotations
import logging
import math
import threading
import time
from agent.memory_provider import spawn_context_thread
logger = logging.getLogger("plugins.memory.honcho")
def prefetch_sync(provider, query: str) -> str:
timeout = provider._config.timeout if provider._config else None
budget = timeout if timeout is not None and math.isfinite(timeout) and timeout > 0 else 5.0
deadline = time.monotonic() + budget
if provider._is_trivial_prompt(query):
return ""
# No lock wait can extend the request budget. Keep the slot occupied even after
# a caller times out, until the actual worker (including any HTTP call) exits.
if not provider._recall_sync_lock.acquire(blocking=False):
return ""
cancelled = threading.Event()
try:
worker = provider._recall_sync_thread
if worker is not None and worker.is_alive():
return ""
generation = provider._recall_generation = object()
session, turn = provider._session_key, provider._turn_count
if not provider._session_ready():
provider._start_session_init_background(blocking=False)
if provider._init_thread is not None:
provider._init_thread.join(timeout=max(0.0, deadline - time.monotonic()))
if not provider._session_ready():
return provider._pop_auth_notice() or provider._pop_peer_notice()
manager = provider._manager
if (generation is not provider._recall_generation or session != provider._session_key
or turn != provider._turn_count or time.monotonic() >= deadline):
return ""
context_due = (
not (provider._injection_frequency == "first-turn" and turn > 1)
and turn - provider._last_context_turn >= provider._context_cadence
)
dialectic_due = turn - provider._last_dialectic_turn >= provider._effective_cadence()
if not context_due and not dialectic_due:
return ""
# Resolve all mutable settings before starting the worker. The captured
# manager/session remain its only I/O owner, even if the provider is reused.
levels = [provider._resolve_pass_level(i, query=query) for i in range(provider._dialectic_depth)]
rewriter = provider._query_rewriter if provider._query_rewrite_enabled else None
holder = {}
def expired() -> bool:
return (cancelled.is_set() or generation is not provider._recall_generation
or time.monotonic() >= deadline)
def retrieve() -> None:
try:
if expired():
return
base = provider._format_first_turn_context(manager.get_prefetch_context(
session, query, current_query_only=True,
) or {}) if context_due else ""
if expired():
return
dialectic = ""
if dialectic_due:
rewritten = ""
if rewriter:
try:
rewritten = rewriter(query).strip()
except Exception as exc:
logger.debug("Honcho query rewriter failed: %s", exc)
results = []
for i, level in enumerate(levels):
if expired():
return
if results and provider._signal_sufficient(results[-1]):
break
prompt = (provider._build_dialectic_prompt(i, results, not base) if results else
rewritten or "Recall evidence relevant to the user's current request.")
prompt = f"Current user request:\n{query}\n\n{prompt}"
result = manager.dialectic_query(session, prompt, reasoning_level=level,
peer="user", raise_errors=True)
if result and result.strip():
results.append(result)
dialectic = results[-1] if results else ""
holder["result"] = (base, dialectic)
except Exception as exc:
logger.debug("Honcho synchronous recall failed: %s", exc)
worker = spawn_context_thread(retrieve, name="honcho-recall-sync", owner=provider)
provider._recall_sync_thread = worker
worker.start()
worker.join(timeout=max(0.0, deadline - time.monotonic()))
if (worker.is_alive() or time.monotonic() >= deadline or "result" not in holder
or generation is not provider._recall_generation
or provider._manager is not manager or provider._session_key != session
or provider._turn_count != turn):
return ""
base, dialectic = holder["result"]
if context_due:
provider._last_context_turn = turn
if dialectic_due:
provider._last_dialectic_turn = turn
if dialectic:
provider._dialectic_empty_streak = 0
else:
provider._dialectic_empty_streak += 1
parts = [provider._pop_auth_notice(), base, dialectic]
return provider._truncate_to_budget("\n\n".join(part for part in parts if part and part.strip()))
finally:
# Thread.join may return before a coarse host clock crosses the deadline.
# Record caller abandonment explicitly before a late HTTP call can resume.
cancelled.set()
provider._recall_sync_lock.release()