File size: 35,699 Bytes
5655a42 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 | """Honcho-based session management for conversation history."""
from __future__ import annotations
import queue
import logging
import threading
import time
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, TYPE_CHECKING
from plugins.memory.honcho.client import spawn_context_thread
from plugins.memory.honcho.client import get_honcho_client
from plugins.memory.honcho.session_auth import HonchoAuthError, SessionAuthMixin
from plugins.memory.honcho.session_context import SessionContextMixin
from plugins.memory.honcho.session_migration import SessionMigrationMixin
from plugins.memory.honcho.session_peers import SessionPeersMixin
if TYPE_CHECKING:
from honcho import Honcho
logger = logging.getLogger(__name__)
# Sentinel to signal the async writer thread to shut down
_ASYNC_SHUTDOWN = object()
# Honcho persists every message and get_or_create() re-hydrates on a miss, so the local copies stay bounded.
_SESSION_MESSAGE_RETENTION = 200
_SESSION_IDLE_TTL_SECONDS = 3600
_SESSION_SWEEP_INTERVAL_SECONDS = 300
# Hard caps for a burst of distinct sessions inside one TTL window; the dicts evict least recently used,
# and _joined_author_peers drops its oldest session so its authors rejoin on their next write.
_SESSION_CACHE_MAX_SIZE = 128
_PEERS_CACHE_MAX_SIZE = 512
@dataclass
class HonchoSession:
"""A conversation session backed by Honcho: a local message cache that syncs to Honcho."""
key: str # channel:chat_id
user_peer_id: str # Honcho peer ID for the user
assistant_peer_id: str # Honcho peer ID for the assistant
honcho_session_id: str # Honcho session ID
messages: list[dict[str, Any]] = field(default_factory=list)
created_at: datetime = field(default_factory=datetime.now)
updated_at: datetime = field(default_factory=datetime.now)
metadata: dict[str, Any] = field(default_factory=dict)
# Held by _flush_session across select, send and the _synced flip. It lives with the message list it guards.
_flush_lock: threading.RLock = field(default_factory=threading.RLock, repr=False, compare=False)
def add_message(self, role: str, content: str, **kwargs: Any) -> None:
"""Add a message to the local cache."""
self.messages.append({"role": role, "content": content, "timestamp": datetime.now().isoformat(), **kwargs})
self.updated_at = datetime.now()
class HonchoSessionManager(SessionAuthMixin, SessionPeersMixin, SessionContextMixin, SessionMigrationMixin):
"""Conversation sessions backed by Honcho, alongside hermes' SQLite state and file memory.
Auth retry, peer-ID resolution, recall and memory-file migration live in the mixins."""
def __init__(
self, honcho: Honcho | None = None, context_tokens: int | None = None, config: Any | None = None,
runtime_user_peer_name: str | None = None, runtime_user_peer_name_alt: str | None = None,
):
"""``honcho`` defaults to the per-identity cached client; ``context_tokens`` caps
context() calls (None = Honcho default); the runtime peer names are the gateway
user identity (and a stable alternate) for per-user memory scoping."""
self._honcho = honcho
self._context_tokens = context_tokens
self._config = config
self._runtime_user_peer_name = runtime_user_peer_name
self._runtime_user_peer_name_alt = runtime_user_peer_name_alt
self._cache: dict[str, HonchoSession] = {}
# Sessions whose flush failed after a newer object took their cache key; flush_all() retries them.
self._retry_sessions: list[HonchoSession] = []
self._cache_lock = threading.RLock()
self._peers_cache: dict[str, Any] = {}
# honcho_session_id -> author peer IDs already joined to that session.
self._joined_author_peers: dict[str, set[str]] = {}
self._sessions_cache: dict[str, Any] = {}
self._last_idle_sweep_ts: float = 0.0
# Bumped (under _cache_lock) whenever _force_reauth rebuilds the client, so an
# in-flight resolver never stores an object bound to the discarded client.
self._client_generation = 0
# Set when a call still fails auth after a forced token refresh; cleared on the next success.
self._auth_failure: str | None = None
self._auth_notice_emitted = False
# Behavior knobs copied from config (HonchoClientConfig defaults when absent); the
# observation booleans map 1:1 to Honcho's SessionPeerConfig toggles.
for name, default in (
("write_frequency", "async"), ("dialectic_reasoning_level", "low"), ("dialectic_dynamic", True),
("dialectic_max_chars", 600), ("dialectic_max_input_chars", 10000),
("user_observe_me", True), ("user_observe_others", True),
("ai_observe_me", True), ("ai_observe_others", True),
):
setattr(self, f"_{name}", getattr(config, name) if config else default)
self._turn_counter: int = 0
# honcho session id -> observation booleans. Whole dicts are swapped in one assignment, so readers never see a partial one.
self._session_observation: dict[str, dict[str, bool]] = {}
# Prefetch cache: session_key -> last context result (consumed once per turn).
# Dialectic results are cached on the plugin side (HonchoMemoryProvider._prefetch_result)
# so session-start prewarm and turn-driven fires share one source of truth.
self._context_cache: dict[str, dict] = {}
self._prefetch_cache_lock = threading.Lock()
# Async write queue — the writer thread starts lazily on first enqueue
# (_ensure_async_writer_locked): constructing a manager must not spawn background
# work or touch the network (unit tests build managers with mocked clients).
self._async_queue: queue.Queue | None = queue.Queue() if self._write_frequency == "async" else None
self._async_thread: threading.Thread | None = None
self._async_thread_lock = threading.Lock()
# Set by shutdown/stop_async_writer: no new threads after the join, late saves flush inline.
self._shutting_down = False
@property
def honcho(self) -> Honcho:
"""The Honcho client, refreshing a near-expiry OAuth token in place. Always goes through
``get_honcho_client`` WITH this manager's bound config: a long session can't outlive its
1h access token, and daemon threads can't see the ambient ContextVar profile, so a bare
``get_honcho_client()`` would migrate them onto the first-built profile.
See #69123, #74065.
"""
self._honcho = get_honcho_client(self._config)
return self._honcho
# ----- SDK object caches (generation-guarded against client rebuilds) -----
def _cached_sdk_object(self, cache: dict[str, Any], key: str, fetch: Any) -> Any:
"""Get-or-fetch from ``cache``; a fetch that straddles a client rebuild is not cached."""
while True:
with self._cache_lock:
if key in cache:
cache[key] = cache.pop(key) # dict order is the LRU order the caps evict from
return cache[key]
generation = self._client_generation
obj = fetch()
with self._cache_lock:
if self._client_generation == generation:
return cache.setdefault(key, obj)
# Client rebuilt mid-resolve: this object holds the discarded transport. Retry.
def _cached_session(self, session_key: str) -> HonchoSession | None:
"""The locally cached session, stamped as used so recall alone keeps it out of the idle sweep."""
with self._cache_lock:
session = self._cache.get(session_key)
if session is not None:
session.updated_at = datetime.now()
self._cache[session_key] = self._cache.pop(session_key)
return session
def _sdk_session(self, session_id: str) -> Any:
"""Get or create the SDK session (cached until a client rebuild clears the cache)."""
return self._cached_sdk_object(self._sessions_cache, session_id, lambda: self.honcho.session(session_id))
def _get_or_create_peer(self, peer_id: str) -> Any:
"""Get or create a Honcho peer (one get-or-create API call, then cached)."""
return self._cached_sdk_object(
self._peers_cache, peer_id, lambda: self._authed_call("peer setup", lambda: self.honcho.peer(peer_id)))
# ----- Observation config (per session) -----
def _observation_flags(self, honcho_session_id: str) -> dict[str, bool]:
"""The four observation booleans for one session: the values synced from that session's
server config once setup ran, else the config snapshot held on the manager."""
synced = self._session_observation.get(honcho_session_id)
if synced is not None:
return dict(synced)
return {f"{kind}_{name}": getattr(self, f"_{kind}_{name}")
for kind in ("user", "ai") for name in ("observe_me", "observe_others")}
def _ai_observes_others(self, session: HonchoSession) -> bool:
"""Whether the AI peer observes other peers in this session; this picks the recall observer."""
synced = self._session_observation.get(session.honcho_session_id)
return synced["ai_observe_others"] if synced is not None else self._ai_observe_others
# ----- Session creation -----
def _configure_session_peers(self, session_id: str, user_peer: Any, assistant_peer: Any) -> dict[str, bool] | None:
"""add_peers with this session's observation config, then adopt the server's effective
config (set via the Honcho UI, it wins over local defaults). Returns the effective flags for
get_or_create to store next to the cache entry, or None when auth died mid-way (already
recorded by _authed_call)."""
peers = (("user", user_peer), ("ai", assistant_peer))
flags = self._observation_flags(session_id)
synced = dict(flags)
try:
from honcho.session import SessionPeerConfig
peer_entries = [
(peer, SessionPeerConfig(observe_me=flags[f"{kind}_observe_me"], observe_others=flags[f"{kind}_observe_others"]))
for kind, peer in peers
]
self._authed_call("session peer setup", lambda: self._sdk_session(session_id).add_peers(peer_entries))
def _adopt_server_config() -> None:
server_cfgs = self._authed_call(
"peer configuration read",
lambda: [self._sdk_session(session_id).get_peer_configuration(peer) for _, peer in peers],
)
for (kind, _), server_cfg in zip(peers, server_cfgs):
for field_name in ("observe_me", "observe_others"):
value = getattr(server_cfg, field_name)
if value is not None:
synced[f"{kind}_{field_name}"] = value
logger.debug("Honcho observation synced from server for session '%s': user(me=%s,others=%s) ai(me=%s,others=%s)",
session_id, synced["user_observe_me"], synced["user_observe_others"],
synced["ai_observe_me"], synced["ai_observe_others"])
self._guarded(_adopt_server_config, None, logging.DEBUG,
"Honcho get_peer_configuration failed (using local config): %s")
except HonchoAuthError:
return None
except Exception as e:
logger.warning("Honcho session '%s' add_peers failed (non-fatal): %s", session_id, e)
return synced
def _load_existing_messages(self, session_id: str) -> list:
"""Load prior messages via context() (one call for messages + metadata), oldest first."""
try:
ctx = self._authed_call(
"session context load",
lambda: self._sdk_session(session_id).context(summary=True, tokens=self._context_tokens))
existing_messages = ctx.messages or []
if len(existing_messages) > 1:
timestamps = [m.created_at for m in existing_messages if m.created_at]
if timestamps and timestamps != sorted(timestamps):
logger.warning("Honcho messages not chronologically ordered for session '%s', sorting", session_id)
existing_messages = sorted(existing_messages, key=lambda m: m.created_at or datetime.min)
if existing_messages:
logger.info("Honcho session '%s' retrieved (%d existing messages)", session_id, len(existing_messages))
else:
logger.info("Honcho session '%s' created (new)", session_id)
return existing_messages
except HonchoAuthError:
logger.warning("Honcho session '%s' loaded without server context: auth failed", session_id)
except Exception as e:
logger.warning("Honcho session '%s' loaded (failed to fetch context: %s)", session_id, e)
return []
def _get_or_create_honcho_session(self, session_id: str, user_peer: Any, assistant_peer: Any) -> tuple[Any, list, dict[str, bool] | None]:
"""(honcho_session, existing_messages, observation flags) with peers configured; a cached session
yields no messages and the flags stored when it was configured."""
with self._cache_lock:
if session_id in self._sessions_cache:
logger.debug("Honcho session '%s' retrieved from cache", session_id)
return self._sessions_cache[session_id], [], self._session_observation.get(session_id)
self._authed_call("session setup", lambda: self._sdk_session(session_id))
observation = self._configure_session_peers(session_id, user_peer, assistant_peer)
existing_messages: list = self._load_existing_messages(session_id) if observation is not None else []
with self._cache_lock:
honcho_session = self._sessions_cache.get(session_id)
if honcho_session is None:
# A mid-init client rebuild dropped the cached session; resolve a fresh one.
honcho_session = self._authed_call("session setup", lambda: self._sdk_session(session_id))
return honcho_session, existing_messages, observation
@staticmethod
def _has_unsynced(session: HonchoSession) -> bool:
return any(not m.get("_synced") for m in list(session.messages))
def _evict_session_locked(self, key: str, session: HonchoSession) -> None:
"""Drop one session and every entry keyed to it. Caller holds _cache_lock."""
del self._cache[key]
self._sessions_cache.pop(session.honcho_session_id, None)
self._session_observation.pop(session.honcho_session_id, None)
with self._prefetch_cache_lock:
self._context_cache.pop(key, None)
def _enforce_cache_caps_locked(self) -> None:
"""Evict least recently used entries above the hard caps. A session with unsynced messages
is never evicted: it is the only copy until the flush lands. Caller holds _cache_lock."""
for key, session in list(self._cache.items()):
if len(self._cache) <= _SESSION_CACHE_MAX_SIZE:
break
if not self._has_unsynced(session):
self._evict_session_locked(key, session)
live_ids = {s.honcho_session_id for s in self._cache.values()}
for session_id in list(self._sessions_cache):
if len(self._sessions_cache) <= _SESSION_CACHE_MAX_SIZE:
break
if session_id not in live_ids:
del self._sessions_cache[session_id]
for session_id in [sid for sid in self._session_observation if sid not in live_ids]:
del self._session_observation[session_id]
live_peers = {p for s in self._cache.values() for p in (s.user_peer_id, s.assistant_peer_id)}
for peer_id in list(self._peers_cache):
if len(self._peers_cache) <= _PEERS_CACHE_MAX_SIZE:
break
if peer_id not in live_peers:
del self._peers_cache[peer_id]
with self._prefetch_cache_lock:
for key in [k for k in self._context_cache if k not in self._cache]:
del self._context_cache[key]
def _sweep_idle_sessions_locked(self) -> int:
"""Evict sessions idle beyond _SESSION_IDLE_TTL_SECONDS together with their SDK session,
observation flags and prefetch entries, then enforce the caps. Caller holds _cache_lock."""
cutoff = time.time() - _SESSION_IDLE_TTL_SECONDS
evicted = 0
for key, session in list(self._cache.items()):
if session.updated_at.timestamp() >= cutoff or self._has_unsynced(session):
continue
self._evict_session_locked(key, session)
evicted += 1
self._enforce_cache_caps_locked()
return evicted
def _maybe_sweep_idle_sessions(self) -> None:
"""Rate-limited idle sweep, run from get_or_create so no watcher thread is needed."""
now = time.time()
with self._cache_lock:
if now - self._last_idle_sweep_ts < _SESSION_SWEEP_INTERVAL_SECONDS:
return
self._last_idle_sweep_ts = now
evicted = self._sweep_idle_sessions_locked()
if evicted:
logger.info("Honcho session cache idle sweep evicted %d session(s)", evicted)
def get_or_create(self, key: str, *, user_peer_id: str | None = None) -> HonchoSession:
"""Get an existing session or create a new one for ``key`` (usually channel:chat_id).
``user_peer_id`` replaces the resolved user peer when the session's participant is not the
runtime user, e.g. the sender bot of an a2a session."""
self._maybe_sweep_idle_sessions()
if (cached := self._cached_session(key)) is not None:
logger.debug("Local session cache hit: %s", key)
return cached
# Gateway sessions normally use the platform-native runtime identity so multi-user
# bots scope memory per user; config can alias/prefix it, or pinPeerName pins all
# identities to peerName for single-user deployments (see _resolve_user_peer_id).
# Determine peer IDs — no lock needed (read-only, no shared state mutation). See #14984.
user_peer_id = user_peer_id or self._resolve_user_peer_id(key)
assistant_peer_id = self.assistant_peer_id()
# All expensive I/O outside the lock — Honcho's persistence is source of truth.
honcho_session_id = self._sanitize_id(key)
user_peer = self._get_or_create_peer(user_peer_id)
assistant_peer = self._get_or_create_peer(assistant_peer_id)
_, existing_messages, observation = self._get_or_create_honcho_session(honcho_session_id, user_peer, assistant_peer)
session = HonchoSession(
key=key, user_peer_id=user_peer_id, assistant_peer_id=assistant_peer_id, honcho_session_id=honcho_session_id,
messages=[
{"role": "assistant" if msg.peer_id == assistant_peer_id else "user", "content": msg.content,
"timestamp": msg.created_at.isoformat() if msg.created_at else "", "_synced": True}
for msg in existing_messages
],
)
with self._cache_lock:
self._cache[key] = session
# Stored with the cache entry and dropped with it, so the dict can hold no orphan ids.
if observation is not None:
self._session_observation[honcho_session_id] = observation
self._enforce_cache_caps_locked()
return session
# ----- Writes -----
def _join_observation_flags(self, honcho_session_id: str) -> tuple[bool, bool]:
"""(observe_me, observe_others) for an author peer joining ``honcho_session_id``: the session's
server-synced values once setup ran, else the manager's config snapshot."""
flags = self._observation_flags(honcho_session_id)
return flags["user_observe_me"], flags["user_observe_others"]
def _author_peer_for_session(self, honcho_session: Any, honcho_session_id: str, author_peer_id: str) -> Any:
"""The author's peer, joined to the session the first time it writes.
Joins are remembered per session, so this costs one API call per author."""
peer = self._get_or_create_peer(author_peer_id)
with self._cache_lock:
if author_peer_id in self._joined_author_peers.get(honcho_session_id, ()):
return peer
try:
from honcho.session import SessionPeerConfig
observe_me, observe_others = self._join_observation_flags(honcho_session_id)
config = SessionPeerConfig(observe_me=observe_me, observe_others=observe_others)
honcho_session.add_peers([(peer, config)])
except Exception as e:
# The write still lands under the right peer. Only the membership (observe config) is missing.
logger.debug("Honcho author peer join failed for %s: %s", author_peer_id, e)
return peer
with self._cache_lock:
self._joined_author_peers.setdefault(honcho_session_id, set()).add(author_peer_id)
while len(self._joined_author_peers) > _SESSION_CACHE_MAX_SIZE:
self._joined_author_peers.pop(next(iter(self._joined_author_peers)))
return peer
@staticmethod
def _trim_synced_messages(session: HonchoSession) -> None:
"""Drop the oldest synced messages beyond _SESSION_MESSAGE_RETENTION. Messages are appended in
order, so trimming from the front stops at the first unsynced one and never drops it."""
excess = len(session.messages) - _SESSION_MESSAGE_RETENTION
while excess > 0 and session.messages and session.messages[0].get("_synced"):
session.messages.pop(0)
excess -= 1
def _flush_session(self, session: HonchoSession) -> bool:
"""Write unsynced messages to Honcho synchronously. The session's lock keeps the async writer and an
exit-time flush_all() from posting the same batch."""
with session._flush_lock:
return self._flush_session_locked(session)
def _flush_session_before(self, session: HonchoSession, deadline: float | None) -> bool:
"""_flush_session that starts only while ``deadline`` has time left and waits for the session's lock no
longer than that; False, with nothing sent, otherwise. An upload that has started is not interrupted: the
SDK has no per-call timeout, so it runs to the client's HTTP timeout."""
if deadline is None:
self._flush_session(session)
return True
remaining = deadline - time.monotonic()
if remaining <= 0 or not session._flush_lock.acquire(timeout=remaining):
return False
try:
self._flush_session(session) # re-enters the RLock already held above
finally:
session._flush_lock.release()
return True
def _flush_session_locked(self, session: HonchoSession) -> bool:
new_messages = [m for m in session.messages if not m.get("_synced")]
if not new_messages:
return True
# Resolved inside the operation so a retry after a client rebuild gets fresh objects.
def _sync_messages() -> int:
user_peer = self._get_or_create_peer(session.user_peer_id)
assistant_peer = self._get_or_create_peer(session.assistant_peer_id)
honcho_session = self._sessions_cache.get(session.honcho_session_id)
if honcho_session is None:
honcho_session, _, observation = self._get_or_create_honcho_session(
session.honcho_session_id, user_peer, assistant_peer)
if observation is not None:
with self._cache_lock:
self._session_observation[session.honcho_session_id] = observation
honcho_messages = []
for m in new_messages:
if m["role"] != "user":
honcho_messages.append(assistant_peer.message(m["content"]))
continue
author_peer_id = m.get("author_peer_id")
peer = (self._author_peer_for_session(honcho_session, session.honcho_session_id, author_peer_id)
if author_peer_id else user_peer)
honcho_messages.append(peer.message(m["content"]))
honcho_session.add_messages(honcho_messages)
return len(honcho_messages)
try:
logger.debug("Synced %d messages to Honcho for %s", self._authed_call("message sync", _sync_messages), session.key)
ok = True
except Exception as e:
logger.error("Failed to sync messages to Honcho: %s", e)
ok = False
for msg in new_messages:
msg["_synced"] = ok
if ok:
# Under the cache lock so the eviction check never iterates a list being trimmed.
with self._cache_lock:
self._trim_synced_messages(session)
return ok
def _try_flush(self, session: HonchoSession, level: int, msg: str) -> bool:
"""_flush_session that logs (never raises) a failure; False when the batch didn't land."""
try:
if self._flush_session(session):
return True
logger.log(level, msg)
except Exception as e:
logger.log(level, msg + ": %s", e)
return False
def _async_writer_loop(self) -> None:
"""Background daemon thread: drains the async write queue, retrying each batch once."""
while True:
try:
item = self._async_queue.get(timeout=5)
if item is _ASYNC_SHUTDOWN:
break
if not self._try_flush(item, logging.WARNING, "Honcho async write failed, retrying once"):
if self._shutting_down:
# The shutdown flush is already attempting this session within its own budget.
logger.error("Honcho async write failed while shutting down, so the batch stays unsynced")
continue
time.sleep(2)
self._try_flush(item, logging.ERROR, "Honcho async write retry failed, dropping batch")
except queue.Empty:
continue
except Exception as e:
logger.error("Honcho async writer error: %s", e)
def _reclaim_key_locked(self, session: HonchoSession) -> HonchoSession | None:
"""Put a session with unsynced messages back where flush_all() looks. Returns the newer object that
owns the key when there is one (this session's batch still needs a home), None otherwise."""
if not self._has_unsynced(session):
return None
current = self._cache.get(session.key)
if current is None:
self._cache[session.key] = session
return None if current is None or current is session else current
def _retain_for_retry(self, session: HonchoSession) -> None:
"""An evicted key takes the session back; a key a newer object owns keeps that object, and this one
waits in the retry list."""
with self._cache_lock:
if self._reclaim_key_locked(session) is not None and not any(s is session for s in self._retry_sessions):
self._retry_sessions.append(session)
def _keep_until_flushed(self, session: HonchoSession) -> None:
"""Like _retain_for_retry, but when a newer object owns the key this batch is written now."""
with self._cache_lock:
owner = self._reclaim_key_locked(session)
if owner is not None:
self._flush_now(session)
def _flush_now(self, session: HonchoSession) -> None:
"""A save-time flush whose failed batch stays reachable for flush_all(), even after an eviction."""
if not self._flush_session(session):
self._retain_for_retry(session)
def save(self, session: HonchoSession) -> None:
"""Save messages per write_frequency: "async" enqueues for the background thread, "turn"
flushes now, "session" defers until flush_all(), int N flushes every N turns."""
self._turn_counter += 1
wf = self._write_frequency
if wf == "async" and self._async_queue is not None:
# Under the writer lock, so a put cannot slip in after stop_async_writer() drained the queue.
with self._async_thread_lock:
if not self._shutting_down:
self._ensure_async_writer_locked()
self._async_queue.put(session)
return
self._flush_now(session)
elif self._shutting_down or wf == "turn" or (isinstance(wf, int) and wf > 0 and self._turn_counter % wf == 0):
self._flush_now(session)
else:
self._keep_until_flushed(session)
def flush_all(self, timeout: float | None = None) -> None:
"""Flush unsynced messages for all cached sessions, then drain the async queue inline. ``timeout`` bounds
when a flush may start and how long it waits for a session's lock, not an upload already in flight, which
runs to the client's HTTP timeout. A session skipped keeps its messages and is counted in one warning."""
deadline = None if timeout is None else time.monotonic() + timeout
skipped = self._flush_cached_before(deadline)
skipped.extend(self._drain_async_queue(deadline))
self._warn_unsynced(skipped, timeout)
def _flush_cached_before(self, deadline: float | None) -> list[HonchoSession]:
"""Flush every cached and retry-listed session that ``deadline`` allows; returns the ones it did not."""
with self._cache_lock:
sessions = list(self._cache.values())
sessions += [s for s in self._retry_sessions if not any(s is c for c in sessions)]
skipped: list[HonchoSession] = []
for session in sessions:
try:
if not self._flush_session_before(session, deadline):
skipped.append(session)
except Exception as e:
logger.error("Honcho flush_all error for %s: %s", session.key, e)
with self._cache_lock:
self._retry_sessions = [s for s in self._retry_sessions if self._has_unsynced(s)]
return skipped
def _warn_unsynced(self, skipped: list[HonchoSession], timeout: float | None) -> None:
left = [s for s in {id(s): s for s in skipped}.values() if self._has_unsynced(s)]
if left:
unsynced = sum(1 for s in left for m in list(s.messages) if not m.get("_synced"))
logger.warning("Honcho flush ran out of time after %.1fs with %d message(s) in %d session(s) still unsynced",
timeout or 0.0, unsynced, len(left))
def _drain_async_queue(self, deadline: float | None = None) -> list[HonchoSession]:
"""Flush every queued session inline. Returns the sessions ``deadline`` left unflushed."""
skipped: list[HonchoSession] = []
if self._async_queue is None:
return skipped
while not self._async_queue.empty():
try:
item = self._async_queue.get_nowait()
except queue.Empty:
break
if item is not _ASYNC_SHUTDOWN and not self._flush_session_before(item, deadline):
skipped.append(item)
return skipped
def _ensure_async_writer_locked(self) -> None:
if self._async_thread is None or not self._async_thread.is_alive():
self._async_thread = spawn_context_thread(self._async_writer_loop, name="honcho-async-writer", owner=self)
self._async_thread.start()
def stop_async_writer(self, timeout: float = 10.0) -> None:
"""Join the async writer, then drain whatever was queued before the join, both within ``timeout``.
saveMessages: false never enqueues, so the drain is a no-op there and the exit stays clean."""
self._warn_unsynced(self._stop_async_writer_before(time.monotonic() + timeout), timeout)
def _stop_async_writer_before(self, deadline: float) -> list[HonchoSession]:
"""Join the writer for what is left of ``deadline``, then drain the queue under the same deadline. A writer
still inside an upload keeps running to the client's HTTP timeout; it holds the shared client, so it is
joined, never abandoned."""
with self._async_thread_lock:
self._shutting_down = True
if self._async_queue is not None and self._async_thread is not None and self._async_thread.is_alive():
self._async_queue.put(_ASYNC_SHUTDOWN)
self._async_thread.join(timeout=max(0.0, deadline - time.monotonic()))
return self._drain_async_queue(deadline)
def shutdown(self, timeout: float = 10.0) -> None:
"""Flush everything, then stop the async writer thread, within ``timeout``. The budget stops new uploads
from starting and bounds the lock waits and the join; an upload already in flight runs to the client's
HTTP timeout. Whatever stayed unsynced is counted in one warning."""
with self._async_thread_lock:
self._shutting_down = True
if self._async_queue is not None:
deadline = time.monotonic() + timeout
skipped = self._flush_cached_before(deadline)
skipped.extend(self._drain_async_queue(deadline))
skipped.extend(self._stop_async_writer_before(deadline))
self._warn_unsynced(skipped, timeout)
# ----- Prefetch cache -----
def prefetch_context(self, session_key: str, user_message: str | None = None) -> None:
"""Fire get_prefetch_context in a background thread; consumed next turn via pop_context_result().
No-op once shutdown began, so nothing is left running after the join."""
if self._shutting_down:
return
def _run():
result = self.get_prefetch_context(session_key, user_message)
if result:
self.set_context_result(session_key, result)
spawn_context_thread(_run, name="honcho-context-prefetch", owner=self).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]:
"""Return and clear the cached context result ({} if none ready yet)."""
with self._prefetch_cache_lock:
return self._context_cache.pop(session_key, {})
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
from typing import Callable # noqa: F401,E402
from pathlib import Path # noqa: F401,E402
import hashlib # noqa: F401,E402
import re # noqa: F401,E402
# ---- END PLUGIN-COMPAT ----
|