Files
hermes-agent/plugins/memory/honcho/session_context.py
Erosika beab8b6f27 fix(honcho): keep observation flags across a flush rebuild, never orphan an evicted session, namespace dashboard logins
`_flush_session` discarded the observation flags when it rebuilt an evicted SDK session, and the
cached path returned none, so recall fell back to the config snapshot. Both paths now return and
store the flags. A deferred `save()` on a session the cap evicted puts it back in the cache, or
flushes it inline when a newer object owns the key. `save()` and `stop_async_writer()` share the
writer lock, and the writer drains its queue after the join, so a put that raced shutdown is
written. The trim after a flush runs under the cache lock. The shutdown join takes the remaining
budget instead of a fixed ten seconds.

The injection audit file is created owner-only, and `logging: "false"` reads as off. The desktop
passes `<provider>:<user id>` so a basic-auth alice and an OIDC alice are two peers. When a
gateway platform supplies no user id, the peer notice and tool error no longer recommend
peerName, which would merge every user of that gateway onto one peer. README documents
`injection.sessionStart`, `logging`, and what a dashboard login does to peer resolution.
2026-09-13 19:05:39 +05:30

418 lines
23 KiB
Python

"""Recall/read-path cluster for HonchoSessionManager: context, cards, search, conclusions, dialectic."""
from __future__ import annotations
import logging
import re
from typing import Any, Callable
from plugins.memory.honcho.session_auth import HonchoAuthError
logger = logging.getLogger("plugins.memory.honcho.session")
_FAILED = object() # sentinel: a guarded call raised (distinct from a legitimately empty/None result)
# Reasoning-channel markers a summarizer model can leave inside a persisted session summary.
_THINK_BLOCK_RE = re.compile(
r"<\s*(?:think|thinking|reasoning|thought|reasoning_scratchpad)\s*>.*?"
r"<\s*/\s*(?:think|thinking|reasoning|thought|reasoning_scratchpad)\s*>",
re.IGNORECASE | re.DOTALL,
)
_THINK_CLOSE_RE = re.compile(r"</\s*think\s*>", re.IGNORECASE)
_PLANNING_HEAD_RE = re.compile(
r"^(?:i need to create a thorough|the instruction says to focus on capturing key facts|first, let me review)",
re.IGNORECASE,
)
def usable_honcho_summary(text: object) -> str | None:
"""A session summary safe to inject, or None to omit it. Honcho summarizers can persist the
model's planning text and a trailing ``</think>`` as the summary body; the text after that
close survives, planning-only or still-tagged text is dropped."""
raw = "" if text is None else str(text)
if not raw.strip():
return None
# The observed payload has no opening tag: planning prose, then ``</think>``, then the summary.
close = _THINK_CLOSE_RE.search(raw)
if close:
raw = raw[close.end():]
cleaned = _THINK_BLOCK_RE.sub("", raw).strip()
lowered = cleaned.lower()
if not cleaned or "<think" in lowered or "</think" in lowered or _PLANNING_HEAD_RE.search(cleaned):
return None
return cleaned
class SessionContextMixin:
"""Peer context / card / search / conclusion / dialectic operations against Honcho."""
def _guarded(self, fn: Callable[[], Any], default: Any, level: int, msg: str, *args: Any) -> Any:
"""Run ``fn``; re-raise HonchoAuthError, log (exc appended to args) and return default otherwise."""
try:
return fn()
except HonchoAuthError:
raise
except Exception as e:
logger.log(level, msg, *args, e)
return default
def _guarded_authed(self, label: str, fn: Callable[[], Any], default: Any, level: int, msg: str, *args: Any) -> Any:
"""``_guarded`` around ``_authed_call(label, fn)`` (401 -> forced refresh + one retry)."""
return self._guarded(lambda: self._authed_call(label, fn), default, level, msg, *args)
def _guarded_session(
self, session_key: str, fn: Callable[[Any], Any], default: Any, level: int, msg: str, *args: Any,
) -> Any:
"""``_guarded`` over ``fn(session)`` for the cached session; ``default`` when no session is cached."""
session = self._cached_session(session_key)
return self._guarded(lambda: fn(session), default, level, msg, *args) if session else default
@staticmethod
def _normalize_card(card: Any) -> list[str]:
"""Normalize Honcho card payloads into a plain list of strings."""
return ([str(item) for item in card if item] if isinstance(card, list) else [str(card)]) if card else []
@staticmethod
def _target_kwargs(target: str | None) -> dict[str, str]:
"""SDK peer getters take ``target=`` only when observing another peer."""
return {} if target is None else {"target": target}
def _fetch_peer_card(self, peer_id: str, *, target: str | None = None) -> list[str]:
"""Fetch a peer card from the peer object (session.context() can return an empty card)."""
def _get_card() -> Any:
peer = self._get_or_create_peer(peer_id)
getters = (getattr(peer, n, None) for n in ("get_card", "card")) # "card" is the legacy SDK getter
getter = next((g for g in getters if callable(g)), None)
return getter(**self._target_kwargs(target)) if getter else None
return self._normalize_card(self._authed_call("peer card fetch", _get_card))
def _fetch_peer_context(
self, peer_id: str, search_query: str | None = None, *, target: str | None = None,
) -> dict[str, Any]:
"""Fetch representation + peer card from a peer object; when peer.context() leaves either
empty, fall back to the dedicated representation / card getters. Raises HonchoAuthError when
auth is dead or a 401 survives the forced refresh; the fallback chain would just repeat it."""
context_kwargs: dict[str, Any] = self._target_kwargs(target)
if search_query is not None:
context_kwargs["search_query"] = search_query
peer = lambda: self._get_or_create_peer(peer_id) # noqa: E731
failed = "Direct %s failed for '%%s': %%s"
ctx = self._guarded_authed(
"peer context fetch", lambda: peer().context(**context_kwargs), None, logging.DEBUG,
failed % "peer.context()", peer_id,
)
representation = getattr(ctx, "representation", None) or getattr(ctx, "peer_representation", None) or ""
card = self._normalize_card(getattr(ctx, "peer_card", None))
if not representation:
representation = self._guarded_authed(
"peer representation fetch", lambda: peer().representation(**self._target_kwargs(target)),
"", logging.DEBUG, failed % "peer.representation()", peer_id,
) or ""
if not card:
card = self._guarded(
lambda: self._fetch_peer_card(peer_id, target=target), [], logging.DEBUG,
failed % "peer card fetch", peer_id,
)
return {"representation": representation, "card": card}
def _peer_context_strings(self, peer_id: str, search_query: str | None = None, *, target: str | None = None):
"""``_fetch_peer_context`` flattened to ``(representation, newline-joined card)`` for prompt injection."""
ctx = self._fetch_peer_context(peer_id, search_query, target=target)
return ctx["representation"], "\n".join(ctx["card"])
def get_prefetch_context(
self, session_key: str, user_message: str | None = None, *, current_query_only: bool = False,
) -> dict[str, str]:
"""Pre-fetch user + AI peer context (representation, card) plus the session summary.
``user_message`` is passed as search_query so Honcho returns topic-relevant conclusions.
Stops early (returning what it has) once auth is dead."""
session = self._cached_session(session_key)
if not session:
return {}
result: dict[str, str] = {}
def _summary() -> None:
if session.honcho_session_id not in self._sessions_cache:
return
ctx = self._authed_call(
"session summary fetch",
lambda: self._sdk_session(session.honcho_session_id).context(summary=True, tokens=self._context_tokens),
)
if ctx.summary and (summary := usable_honcho_summary(getattr(ctx.summary, "content", None))):
result["summary"] = summary
def _user() -> None:
observer_peer_id, target_peer_id = self._resolve_observer_target(session, "user")
if current_query_only:
# The legacy fallback getters do not accept search_query. An empty
# or failed current-query lookup must not silently become generic recall.
ctx = self._authed_call("peer context fetch", lambda: self._get_or_create_peer(observer_peer_id).context(
search_query=user_message, target=target_peer_id or session.user_peer_id,
))
result["representation"] = getattr(ctx, "representation", None) or getattr(ctx, "peer_representation", None) or ""
result["card"] = "\n".join(self._normalize_card(getattr(ctx, "peer_card", None)))
return
result["representation"], result["card"] = self._peer_context_strings(
observer_peer_id, search_query=user_message or None, target=target_peer_id or session.user_peer_id,
)
def _ai() -> None:
result["ai_representation"], result["ai_card"] = self._peer_context_strings(
session.assistant_peer_id, target=session.assistant_peer_id,
)
for step, level, msg in (
(_summary, logging.DEBUG, "Failed to fetch session summary from Honcho: %s"),
(_user, logging.WARNING, "Failed to fetch user context from Honcho: %s"),
(_ai, logging.DEBUG, "Failed to fetch AI peer context from Honcho: %s"),
):
try:
step()
except HonchoAuthError:
if current_query_only:
raise
break # Auth is dead; the pop_auth_notice path tells the model why context is missing.
except Exception as e:
if current_query_only:
raise
logger.log(level, msg, e)
return result
def get_session_context(self, session_key: str, peer: str = "user") -> dict[str, Any]:
"""Fetch session-level context (summary, representation, card, recent messages).
Raises HonchoAuthError so callers can tell rejected credentials from no context."""
session = self._cached_session(session_key)
if not session:
return {}
if session.honcho_session_id not in self._sessions_cache:
# Fall back to peer-level context, respecting the requested peer.
peer_id = self._resolve_peer_id(session, peer)
return self._fetch_peer_context(peer_id, target=peer_id)
def _fetch() -> dict[str, Any]:
observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer)
ctx = self._authed_call(
"session context fetch",
lambda: self._sdk_session(session.honcho_session_id).context(
summary=True, tokens=self._context_tokens,
peer_target=target_peer_id or observer_peer_id, peer_perspective=observer_peer_id,
),
)
result: dict[str, Any] = {}
if ctx.summary and (summary := usable_honcho_summary(getattr(ctx.summary, "content", ctx.summary))):
result["summary"] = summary
if ctx.peer_representation:
result["representation"] = ctx.peer_representation
if ctx.peer_card:
result["card"] = "\n".join(ctx.peer_card)
if ctx.messages:
result["recent_messages"] = [
{"role": getattr(m, "peer_id", "unknown"), "content": (m.content or "")[:500]}
for m in ctx.messages[-10:]
]
return result
return self._guarded(_fetch, {}, logging.DEBUG, "Session context fetch failed: %s")
def get_peer_card(self, session_key: str, peer: str = "user") -> list[str]:
"""Fetch a peer card (curated facts, no LLM). [] if unavailable; raises HonchoAuthError."""
def _fetch(session: Any) -> list[str]:
observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer)
card = self._fetch_peer_card(observer_peer_id, target=target_peer_id)
# Some backends store cards on the target peer, not the observer-target slot.
return card or (self._fetch_peer_card(target_peer_id) if target_peer_id else [])
return self._guarded_session(session_key, _fetch, [], logging.DEBUG, "Failed to fetch peer card from Honcho: %s")
def search_context(self, session_key: str, query: str, max_tokens: int = 800, peer: str = "user") -> str:
"""Hybrid search over raw messages visible from ``peer``'s perspective, all sessions. Snippets
accumulate until ``max_tokens`` (~4 chars/token) is exhausted. Returns "" when nothing matches;
raises HonchoAuthError on rejected credentials."""
session = self._cached_session(session_key)
q = (query or "").strip()[:4000] # Honcho caps query length for the embedding model.
if not session or not q:
return ""
peer_id = self._resolve_peer_id(session, peer)
char_budget = max(200, int(max_tokens) * 4)
limit = max(3, min(20, char_budget // 300))
messages = self._guarded_authed(
"message search", lambda: self.honcho.search(q, filters={"peer_perspective": peer_id}, limit=limit),
_FAILED, logging.DEBUG, "Honcho message search failed (peer_perspective=%s): %s", peer_id,
)
if messages is _FAILED:
# Older Honcho versions lack the perspective filter; fall back to peer-authored search.
messages = self._guarded_authed(
"peer search", lambda: self._get_or_create_peer(peer_id).search(q, limit=limit),
None, logging.DEBUG, "Honcho peer search fallback also failed: %s",
)
if not messages:
return ""
# Author labels distinguish user-stated facts from assistant-derived ones.
lines: list[str] = []
for m in messages:
content = (getattr(m, "content", "") or "").strip()
if not content:
continue
author = getattr(m, "peer_id", "") or "unknown"
who = "assistant" if author == session.assistant_peer_id else author
sess = getattr(m, "session_id", "") or ""
entry = f"[{who}{f' · {sess}' if sess else ''}] {content[:1200]}"
# Budget left after the joined snippets so far plus the separator this entry would need.
remaining = char_budget - len("\n\n".join(lines)) - (2 if lines else 0)
if remaining <= 0:
break
truncated = len(entry) > remaining
entry = entry[:remaining].rstrip()
if not entry:
break
lines.append(entry)
if truncated:
break
return "\n\n".join(lines)
def _conclusions_scope(self, session: Any, target_peer_id: str) -> Any:
"""ConclusionScope for observing target_peer_id; shared by create/delete/list."""
ai_observes = target_peer_id == session.assistant_peer_id or self._ai_observes_others(session)
observer = self._get_or_create_peer(session.assistant_peer_id if ai_observes else target_peer_id)
return observer.conclusions_of(target_peer_id)
def create_conclusion(self, session_key: str, content: str, peer: str = "user") -> bool:
"""Write a conclusion (durable fact) about ``peer`` back to Honcho."""
if not content or not content.strip():
return False
session = self._cached_session(session_key)
if not session:
logger.warning("No session cached for '%s', skipping conclusion", session_key)
return False
def _create() -> bool:
target_peer_id = self._resolve_peer_id(session, peer)
if target_peer_id is None:
logger.warning("Could not resolve conclusion peer '%s' for session '%s'", peer, session_key)
return False
payload = [{"content": content.strip(), "session_id": session.honcho_session_id}]
self._authed_call("conclusion create", lambda: self._conclusions_scope(session, target_peer_id).create(payload))
logger.info("Created conclusion about %s for %s: %s", target_peer_id, session_key, content[:80])
return True
return self._guarded(_create, False, logging.ERROR, "Failed to create conclusion: %s")
def delete_conclusion(self, session_key: str, conclusion_id: str, peer: str = "user") -> bool:
"""Delete a conclusion by ID. Use only for PII removal."""
def _delete(session: Any) -> bool:
target_peer_id = self._resolve_peer_id(session, peer)
self._authed_call(
"conclusion delete", lambda: self._conclusions_scope(session, target_peer_id).delete(conclusion_id),
)
logger.info("Deleted conclusion %s for %s", conclusion_id, session_key)
return True
return self._guarded_session(
session_key, _delete, False, logging.ERROR, "Failed to delete conclusion %s: %s", conclusion_id,
)
def list_conclusions(self, session_key: str, query: str | None = None, peer: str = "user", limit: int = 20):
"""List (or semantically search with ``query``) conclusions as {"id", "content"} dicts."""
def _list(session: Any) -> list[dict]:
target_peer_id = self._resolve_peer_id(session, peer)
if target_peer_id is None:
return []
def _fetch() -> Any:
scope = self._conclusions_scope(session, target_peer_id)
return scope.query(query, top_k=limit) if query else scope.list(size=limit).items
return [{"id": c.id, "content": c.content} for c in self._authed_call("conclusion list", _fetch)]
return self._guarded_session(session_key, _list, [], logging.DEBUG, "Honcho list_conclusions failed: %s")
def set_peer_card(self, session_key: str, card: list[str], peer: str = "user") -> list[str] | None:
"""Replace a peer's card. Returns the updated card, or None on failure."""
def _update(session: Any) -> list[str] | None:
observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer)
if observer_peer_id is None:
logger.warning("Could not resolve peer '%s' for set_peer_card in session '%s'", peer, session_key)
return None
result = self._authed_call(
"peer card update",
lambda: self._get_or_create_peer(observer_peer_id).set_card(card, **self._target_kwargs(target_peer_id)),
)
logger.info("Updated peer card observer=%s target=%s (%d facts)",
observer_peer_id, target_peer_id or observer_peer_id, len(card))
return result
return self._guarded_session(session_key, _update, None, logging.ERROR, "Failed to set peer card: %s")
def seed_ai_identity(self, session_key: str, content: str, source: str = "manual") -> bool:
"""Seed the AI peer's representation from text (SOUL.md, exported chats, ...), sent as an
assistant-peer message so Honcho's reasoning model incorporates it. Unlike the other
operations, auth failures are logged and swallowed here too."""
if not content or not content.strip():
return False
session = self._cached_session(session_key)
if not session:
logger.warning("No session cached for '%s', skipping AI seed", session_key)
return False
if session.honcho_session_id not in self._sessions_cache:
logger.warning("No Honcho session cached for '%s', skipping AI seed", session_key)
return False
wrapped = f"<ai_identity_seed>\n<source>{source}</source>\n\n{content.strip()}\n</ai_identity_seed>"
def _seed() -> bool:
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
self._sdk_session(session.honcho_session_id).add_messages([assistant_peer.message(wrapped)])
logger.info("Seeded AI identity from '%s' into %s", source, session_key)
return True
try:
return self._authed_call("identity seed", _seed)
except Exception as e:
logger.error("Failed to seed AI identity: %s", e)
return False
def get_ai_representation(self, session_key: str) -> dict[str, str]:
"""Fetch the AI peer's representation + card ("" values if unavailable)."""
def _fetch(session: Any) -> dict[str, str]:
rep, card = self._peer_context_strings(session.assistant_peer_id, target=session.assistant_peer_id)
return {"representation": rep, "card": card}
return self._guarded_session(
session_key, _fetch, {"representation": "", "card": ""}, logging.DEBUG,
"Failed to fetch AI representation: %s",
)
def dialectic_query(
self, session_key: str, query: str, reasoning_level: str | None = None, peer: str = "user",
apply_injection_cap: bool = True, raise_errors: bool = False,
) -> str:
"""Ask Honcho's dialectic endpoint about a peer (LLM on the backend; run off-thread).
``reasoning_level`` is honored only when dialecticDynamic is true. ``apply_injection_cap``
clips to ``dialecticMaxChars`` (automatic injection only). ``raise_errors`` re-raises backend
failures instead of returning "" so explicit tool calls can tell a timeout from an empty answer.
Raises HonchoAuthError when credentials are rejected after a forced refresh and one retry.
Args: session_key: The session key to query against. query: Natural language question.
reasoning_level: Override the configured default (dialecticReasoningLevel). If None or
dialecticDynamic is false, uses the configured default. peer: Which peer to query — "user" (default)
or "ai". apply_injection_cap: Clip automatic injections to ``dialecticMaxChars``. Explicit
``honcho_reasoning`` calls pass False because Honcho already bounds their output. raise_errors:
Re-raise backend failures instead of returning "". Explicit tool calls pass True so a timeout or
server error surfaces as an error, not as "no result" (#36098 issue 4: collapsing failures to ""
made auth errors, timeouts, and genuinely-empty answers indistinguishable).
"""
session = self._cached_session(session_key)
target_peer_id = self._resolve_peer_id(session, peer) if session else None
if target_peer_id is None:
return ""
if len(query) > self._dialectic_max_input_chars:
query = query[:self._dialectic_max_input_chars].rsplit(" ", 1)[0]
level = reasoning_level if (self._dialectic_dynamic and reasoning_level) else self._dialectic_reasoning_level
def _chat_once() -> str:
# The AI peer observes others when allowed; otherwise each peer queries its own context.
if self._ai_observes_others(session) and target_peer_id != session.assistant_peer_id:
observer = self._get_or_create_peer(session.assistant_peer_id)
return observer.chat(query, target=target_peer_id, reasoning_level=level) or ""
return self._get_or_create_peer(target_peer_id).chat(query, reasoning_level=level) or ""
try:
result = self._authed_call("dialectic query", _chat_once)
cap = self._dialectic_max_chars if apply_injection_cap else 0
return result[:cap].rsplit(" ", 1)[0] + " …" if result and cap and len(result) > cap else result
except HonchoAuthError:
raise
except Exception as e:
logger.warning("Honcho dialectic query failed: %s", e)
if raise_errors:
raise
return ""