Skip to content
This repository was archived by the owner on Jul 15, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,29 @@ lossless_hermes:
> require a `VOYAGE_API_KEY` for query embeddings; without one, those modes return a clear
> operator-facing error pointing back to the FTS modes.

### Pinning the summarization model — `auxiliary.lcm_summary` (optional)

LCM's summarization and synthesis calls (compaction leaf/condensed passes, `lcm_synthesize_around`)
go through Hermes's auxiliary LLM client under the task name `lcm_summary`. **This works
with no configuration** — when no `auxiliary.lcm_summary` entry exists, Hermes falls
through to its model auto-detection chain.

To *pin* the summarizer's provider, model, and timeout, add an `auxiliary.lcm_summary`
block to `~/.hermes/config.yaml` — note this lives in the top-level `auxiliary:` namespace,
**not** the `lossless_hermes:` plugin namespace:

```yaml
auxiliary:
lcm_summary:
provider: anthropic
model: claude-haiku-4-5
timeout: 60 # seconds
```

This is the operator-facing knob; `summary_model` / `summary_provider` in the
`lossless_hermes:` namespace (above) pin the same chain from LCM's side and take
precedence where both resolve.

## Agent tools

LCM registers **9 agent tools** so the model can search, recall, and self-diagnose:
Expand Down
61 changes: 56 additions & 5 deletions src/lossless_hermes/engine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ class is the single state-creation site.
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Deque, Dict, Final, List, Optional, Set, Tuple
from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Final, List, Optional, Set, Tuple

from lossless_hermes.db.config import LcmConfig
from lossless_hermes.hermes_bridge import ContextEngine
Expand Down Expand Up @@ -101,6 +101,17 @@ class is the single state-creation site.
from .lifecycle import _LifecycleMixin
from .session_locks import SessionLockRegistry

if TYPE_CHECKING: # pragma: no cover — type-only imports
# The summarizer surface (#164 PR-2). These are referenced only in
# ``__init__`` annotations — the actual ``HermesSummarizerDeps`` /
# ``LcmSummarizer`` construction is lazy, inside
# ``on_session_start`` (``lifecycle.py``). ``from __future__ import
# annotations`` makes every annotation a string, so a TYPE_CHECKING
# import is sufficient and avoids any future engine <-> summarize
# import-cycle risk.
from lossless_hermes.compaction import SummarizeFn
from lossless_hermes.summarize import LcmSummarizer, SummarizerDeps

__all__ = [
"APPLE_SYSTEM_PYTHON_MSG",
"CircuitBreaker",
Expand Down Expand Up @@ -348,23 +359,32 @@ class RuntimeContext:
# registrations live here — the documented ``TOOL_DISPATCH`` wiring site,
# mirroring ``lcm_status`` / ``lcm_doctor`` above.
#
# PR-1 wired four tools; #156 PR-2 adds ``lcm_compact`` (the 2-method
# ``CompactContext`` shim — see ``_adapters._CompactCtx``);
# ``lcm_synthesize_around`` ships in PR-3, and ``lcm_expand`` is deferred
# per ADR-037.
# #156 PR-1 wired four tools; #156 PR-2 added ``lcm_compact`` (the
# 2-method ``CompactContext`` shim — see ``_adapters._CompactCtx``).
# The 8th tool, ``lcm_synthesize_around``, was deferred from #156 PR-3
# because its ``build_llm_call`` factory needs a summarizer surface on
# ``LCMEngine`` that did not exist — it is wired here, by #164 PR-2,
# now that ``on_session_start`` constructs ``engine._summarizer``.
# Landing it closes #156: dispatch coverage is 8/8 (the 6 ported tools
# + ``lcm_status`` / ``lcm_doctor``). ``lcm_expand`` stays deferred per
# ADR-037 — it is the only ported tool with no dispatch entry, and it
# is intentionally absent from ``TOOL_SCHEMAS`` too, so coverage of the
# *advertised* surface is total.
from lossless_hermes.tools._adapters import (
_adapt_lcm_compact,
_adapt_lcm_describe,
_adapt_lcm_get_entity,
_adapt_lcm_grep,
_adapt_lcm_search_entities,
_adapt_lcm_synthesize_around,
)

TOOL_DISPATCH["lcm_get_entity"] = _adapt_lcm_get_entity
TOOL_DISPATCH["lcm_search_entities"] = _adapt_lcm_search_entities
TOOL_DISPATCH["lcm_describe"] = _adapt_lcm_describe
TOOL_DISPATCH["lcm_grep"] = _adapt_lcm_grep
TOOL_DISPATCH["lcm_compact"] = _adapt_lcm_compact
TOOL_DISPATCH["lcm_synthesize_around"] = _adapt_lcm_synthesize_around


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -502,6 +522,37 @@ def __init__(
self._telemetry_store: Optional[CompactionTelemetryStore] = None
self._maintenance_store: Optional[CompactionMaintenanceStore] = None

# ------------------------------------------------------------------
# Summarizer surface (#164 PR-2)
# ------------------------------------------------------------------
# The summarizer cascade — :class:`SummarizerDeps` + an
# :class:`LcmSummarizer` built from it — constructed in
# ``on_session_start`` (the config-scoped construction point;
# see ``lifecycle.py``). All three default to ``None`` here and
# are populated once the DB opens.
#
# * ``deps`` — the public :class:`SummarizerDeps` handle. The
# ``getattr(engine, "deps", None)`` probe in ``commands/doctor.py``
# reads this exact name; ``/lcm doctor apply`` activates its real
# summarizer path once it is non-``None``.
# * ``summarize`` — the bound :class:`LcmSummarizer.summarize`
# callable. This is the :data:`~lossless_hermes.compaction.SummarizeFn`
# shape (``(text, aggressive=False, options=None) -> str``)
# that ``CompactionEngine.compact_full_sweep`` and the
# ``lcm_synthesize_around`` ``build_llm_call`` factory consume.
# The ``getattr(engine, "summarize", None)`` probe in
# ``commands/doctor.py`` reads this exact name.
# * ``_summarizer`` — the underlying :class:`LcmSummarizer`
# object. The ``build_llm_call`` factory needs the object (not
# just the bound method) to read ``.candidates`` for the
# Wave-12 F8 audit-honest model name.
#
# Config-scoped (not session-scoped): built once per DB-open and
# nulled on ``on_session_end`` for symmetry with the stores.
self.deps: Optional[SummarizerDeps] = None
self.summarize: Optional[SummarizeFn] = None
self._summarizer: Optional[LcmSummarizer] = None

# ``current_session_id`` — most-recent Hermes session_id seen by
# ``on_session_start``. Used by Epic 08 ``/lcm`` handlers to resolve
# "the current conversation" without a ``PluginCommandContext`` to
Expand Down
61 changes: 60 additions & 1 deletion src/lossless_hermes/engine/lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,12 +298,59 @@ def on_session_start(self, session_id: str, **kwargs: Any) -> None:
self._telemetry_store = CompactionTelemetryStore(conn)
self._maintenance_store = CompactionMaintenanceStore(conn)

# ------------------------------------------------------------------
# Summarizer surface (#164 PR-2)
# ------------------------------------------------------------------
# Construct the summarizer cascade here — the config-scoped
# construction point. ``LcmSummarizer`` depends only on
# ``self.config`` (always set in ``__init__``) and the
# ``HermesSummarizerDeps`` shim (stateless), so it is built
# inside this ``if self._db is None:`` block: it is created
# exactly once per DB-open and persists for the process
# lifetime, matching the stores' lifecycle. A re-entrant
# ``on_session_start`` for a new session on an already-open DB
# returns early above (the ``self._db is not None`` guard) and
# never reaches here — the summarizer is config-scoped, not
# session-scoped, so it must NOT be rebuilt per session.
#
# Deferred import — ``HermesSummarizerDeps.complete`` lazy-imports
# ``agent.auxiliary_client`` (Hermes-less env discipline), but
# the *class* import is cheap and pure-Python; keep it deferred
# anyway to mirror the rest of this method's import posture and
# keep ``import lossless_hermes.engine`` lean.
from lossless_hermes.hermes_llm import HermesSummarizerDeps
from lossless_hermes.summarize import LcmSummarizer

self.deps = HermesSummarizerDeps()
# ``provider_hint`` / ``model_hint`` feed layer 5 of the
# candidate chain (``resolve_summary_candidates``). Layers 1-4
# (env vars, ``config.summary_provider`` / ``summary_model``,
# legacy OpenClaw keys) already resolve from ``self.config`` +
# the environment; the hints are the explicit
# ``config.summary_*`` values passed through so an operator who
# set only those still gets a resolved candidate. Empty strings
# collapse to ``None`` inside the resolver.
summarizer = LcmSummarizer(
deps=self.deps,
config=self.config,
provider_hint=self.config.summary_provider or None,
model_hint=self.config.summary_model or None,
)
self._summarizer = summarizer
# ``self.summarize`` is the bound ``LcmSummarizer.summarize`` —
# the ``SummarizeFn``-shaped callable downstream consumers
# (``CompactionEngine.compact_full_sweep``, the
# ``lcm_synthesize_around`` ``build_llm_call`` factory) expect.
self.summarize = summarizer.summarize

logger.info(
"[lcm] on_session_start session_id=%s: DB open at %s (fts5=%s, vec0=%s)",
"[lcm] on_session_start session_id=%s: DB open at %s "
"(fts5=%s, vec0=%s, summarizer_candidates=%d)",
session_id,
db_path,
features.fts5_available,
features.vec0_available,
len(summarizer.candidates),
)

def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
Expand Down Expand Up @@ -358,6 +405,18 @@ def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> Non
self._telemetry_store = None
self._maintenance_store = None

# Clear the summarizer surface (#164 PR-2) symmetrically with the
# stores. The summarizer holds no DB handle of its own — its
# ``SummarizerDeps`` shim is stateless — but nulling it keeps the
# post-close engine state consistent (``getattr(engine, "deps")``
# / ``"summarize"`` report ``None`` again, matching the
# pre-first-``on_session_start`` shape) so ``/lcm doctor apply``
# post-close renders its "unavailable" arm rather than holding a
# stale summarizer.
self.deps = None
self.summarize = None
self._summarizer = None

# Clear ``current_session_id`` symmetrically with the DB close.
# Epic 08 ``/lcm status`` post-close should report "no active
# conversation" (the same shape pre-first-``on_session_start``),
Expand Down
Loading
Loading