diff --git a/README.md b/README.md index 7e9871b..bd88911 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/lossless_hermes/engine/__init__.py b/src/lossless_hermes/engine/__init__.py index 61b586d..d2bfea3 100644 --- a/src/lossless_hermes/engine/__init__.py +++ b/src/lossless_hermes/engine/__init__.py @@ -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 @@ -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", @@ -348,16 +359,24 @@ 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 @@ -365,6 +384,7 @@ class RuntimeContext: 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 # --------------------------------------------------------------------------- @@ -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 diff --git a/src/lossless_hermes/engine/lifecycle.py b/src/lossless_hermes/engine/lifecycle.py index d9d8c05..c0a19f2 100644 --- a/src/lossless_hermes/engine/lifecycle.py +++ b/src/lossless_hermes/engine/lifecycle.py @@ -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: @@ -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``), diff --git a/src/lossless_hermes/tools/_adapters.py b/src/lossless_hermes/tools/_adapters.py index 4a8bb93..b76f35f 100644 --- a/src/lossless_hermes/tools/_adapters.py +++ b/src/lossless_hermes/tools/_adapters.py @@ -32,10 +32,13 @@ 4. calls the real ``handle_lcm_`` with the correct keyword args; 5. returns its JSON string verbatim. -The PR-1 adapters wire four tools: ``lcm_get_entity``, -``lcm_search_entities``, ``lcm_describe``, ``lcm_grep``. #156 PR-2 adds -``lcm_compact``; ``lcm_synthesize_around`` ships in PR-3; ``lcm_expand`` -is deferred per ADR-037. +#156 PR-1 wired four tools: ``lcm_get_entity``, ``lcm_search_entities``, +``lcm_describe``, ``lcm_grep``. #156 PR-2 added ``lcm_compact``. The 8th +tool, ``lcm_synthesize_around``, was blocked on a summarizer surface +``LCMEngine`` did not expose; it is wired by #164 PR-2 (this is the +adapter that closes #156 at 8/8 dispatch coverage). ``lcm_expand`` is +deferred per ADR-037 — absent from both ``TOOL_DISPATCH`` and +``TOOL_SCHEMAS``, so coverage of the advertised surface is total. The ``lcm_compact`` shim (#156 PR-2) ------------------------------------ @@ -115,13 +118,16 @@ import logging import sqlite3 +import time from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final, Optional +from typing import TYPE_CHECKING, Any, Callable, Final, Optional from lossless_hermes.compaction import CompactionResult from lossless_hermes.db.config import LcmConfig from lossless_hermes.store.conversation import ConversationStore from lossless_hermes.store.summary import SummaryStore +from lossless_hermes.summarize import LcmSummarizeOptions +from lossless_hermes.synthesis.dispatch import LlmCall, LlmCallArgs, LlmCallResult from lossless_hermes.tools._common import tool_result from lossless_hermes.tools.compact import ( GateState, @@ -133,6 +139,7 @@ from lossless_hermes.tools.get_entity import handle_lcm_get_entity from lossless_hermes.tools.grep import handle_lcm_grep from lossless_hermes.tools.search_entities import handle_lcm_search_entities +from lossless_hermes.tools.synthesize_around import handle_lcm_synthesize_around from lossless_hermes.voyage.client import VoyageClient if TYPE_CHECKING: # pragma: no cover — import-cycle dodge, type-only @@ -146,6 +153,7 @@ "_adapt_lcm_get_entity", "_adapt_lcm_grep", "_adapt_lcm_search_entities", + "_adapt_lcm_synthesize_around", ) @@ -228,6 +236,32 @@ class _GrepCtx: embeddings_enabled: bool +@dataclass(frozen=True) +class _SynthesizeAroundCtx: + """Frozen :class:`~..synthesize_around.SynthesizeAroundContext` (#164 PR-2). + + ``lcm_synthesize_around`` needs a SQL connection, the conversation + store, a timezone, and — unlike the four PR-1 attribute-bag + contexts — a ``build_llm_call`` *factory* (a :class:`BuildLlmCall`, + ``() -> tuple[LlmCall, str]``). :func:`_adapt_lcm_synthesize_around` + builds that factory over the engine's summarizer surface (the #164 + PR-2 ``engine._summarizer`` handle), so the synthesis dispatcher + runs through the same configured summarizer chain as compaction. + + ``frozen=True`` keeps it an immutable per-dispatch snapshot — the + handler cannot mutate the engine wiring through its ``ctx``. + """ + + conn: sqlite3.Connection + conversation_store: ConversationStore + timezone: str + # ``BuildLlmCall`` is a structural Protocol (``() -> tuple[LlmCall, + # str]``). A plain function value satisfies it; annotate with the + # ``Callable`` shape so ``ty`` structurally verifies the dataclass + # against ``SynthesizeAroundContext`` at the handler call site. + build_llm_call: "Callable[[], tuple[LlmCall, str]]" + + # --------------------------------------------------------------------------- # lcm_compact — the 2-method context shim # --------------------------------------------------------------------------- @@ -935,3 +969,200 @@ def _adapt_lcm_compact(args: dict[str, Any], **kwargs: Any) -> str: # handle_lcm_compact's parameter is ``runtime_context``. runtime_context=runtime_context, ) + + +# --------------------------------------------------------------------------- +# lcm_synthesize_around — Tier 3, build_llm_call factory over the summarizer +# (#164 PR-2 — closes #156, 8/8 dispatch coverage) +# --------------------------------------------------------------------------- +# +# ``lcm_synthesize_around`` is the 8th #156 tool. Its handler +# (``synthesize_around.py:401``) consumes a ``SynthesizeAroundContext`` +# whose ``build_llm_call`` member is a *factory* — ``() -> +# tuple[LlmCall, str]`` — not a plain collaborator. The factory wires +# the synthesis dispatcher's async ``LlmCall`` callable to the engine's +# configured summarizer chain. +# +# This is the deferred #156 PR-3 work, unblocked by #164 PR-2's +# summarizer surface: the factory needs ``engine._summarizer`` (an +# ``LcmSummarizer``), which did not exist on ``LCMEngine`` until PR-2's +# ``on_session_start`` construction. With the surface present, the +# factory is a faithful port of the TS ``buildLlmCallFromSummarizer`` +# helper (``lossless-claw/src/tools/lcm-synthesize-around-tool.ts:608-618`` +# @ ``1f07fbd``) plus the TS handler's own summarizer-resolution at +# ``lcm-synthesize-around-tool.ts:1019-1037``. + + +def _build_synthesizer_llm_call(engine: LCMEngine) -> tuple[LlmCall, str]: + """Build the synthesis dispatcher's ``(LlmCall, model_name)`` pair. + + The :class:`~lossless_hermes.tools.synthesize_around.BuildLlmCall` + factory body — a faithful port of TS ``buildLlmCallFromSummarizer`` + (``lossless-claw/src/tools/lcm-synthesize-around-tool.ts:608-618``) + composed with the TS handler's own summarizer wiring (``:1019-1037``). + + The synthesis dispatcher (:func:`~..synthesis.dispatch.dispatch_synthesis`) + invokes the returned :class:`~..synthesis.dispatch.LlmCall` once per + synthesis pass with a fully-rendered prompt. LCM has no + synthesizer-specific model resolver — the TS source reuses the + configured *summarizer* chain (it already owns provider/model + fallback + auth retries + timeouts), and this port does the same: + the returned callable wraps the engine's bound summarizer. + + Two shape bridges the TS source also makes: + + * **sync → async** — :class:`~..synthesis.dispatch.LlmCall` is + ``async def __call__`` (the TS source returns a ``Promise``), + while :meth:`~..summarize.LcmSummarizer.summarize` is sync (ADR-017). + The wrapper is an ``async def`` that calls the sync summarizer + directly. The summarizer's own LLM call is sync-blocking; a + single call inside the dispatcher's already-isolated event loop + (``synthesize_around.py`` runs ``asyncio.run(dispatch_synthesis(...))`` + on a fresh loop) is acceptable and matches the TS structure — + no thread-pool hop is needed. + * **is_condensed** — the TS handler passes ``{isCondensed: true}`` + to the summarizer (``lcm-synthesize-around-tool.ts:1034``); + synthesis is a condensed-tier rollup, so this port passes + :class:`~..summarize.LcmSummarizeOptions` with ``is_condensed=True``. + + The ``model_name`` (second tuple element) is the Wave-12 F8 + audit-honesty value — the summarizer's resolved *primary* candidate + model, so the synthesis audit row records what actually ran rather + than the dispatcher's ``pick_model`` recommendation + (``lcm-synthesize-around-tool.ts:1029-1036``). The TS caveat holds: + if mid-call provider fallback fires, the recorded model is the + primary candidate, not necessarily the one that succeeded — strictly + better than recording dispatched intent. + + Args: + engine: The :class:`LCMEngine` — its ``_summarizer`` (an + :class:`~..summarize.LcmSummarizer`, built at + ``on_session_start``) is the chain the factory wraps. + + Returns: + A ``(LlmCall, model_name)`` tuple. + + Raises: + RuntimeError: When the engine has no summarizer (``on_session_start`` + not run) or its candidate chain is empty (no + ``summary_model`` / ``summary_provider`` configured and no + ``LCM_SUMMARY_*`` env). The handler catches this and surfaces + the "No summarization model resolved" tool-error + (``synthesize_around.py:748-759``) — the same arm the TS + source's ``if (!summarizerBuilt)`` guard produces. + """ + summarizer = engine._summarizer + if summarizer is None: + # on_session_start has not run. The adapter's engine-readiness + # guard catches this before the factory is built; the factory + # raises defensively so it is self-consistent. The handler's + # try/except around ``ctx.build_llm_call()`` converts the raise + # into the structured "No summarization model resolved" error. + raise RuntimeError( + "LCM summarizer is not initialised (on_session_start not run) — " + "lcm_synthesize_around cannot resolve a synthesis model." + ) + if not summarizer.candidates: + # No (provider, model) candidate resolved. summarizer.summarize() + # would itself raise RuntimeError on the first call; fail here, + # before any cache row is written, so the handler reports the + # clean "No summarization model resolved" tool-error. + raise RuntimeError( + "[lcm] lcm_synthesize_around: no summary model candidates resolved " + "— set summary_model / summary_provider on the LCM config or the " + "LCM_SUMMARY_MODEL / LCM_SUMMARY_PROVIDER env." + ) + + # Wave-12 F8: the primary resolved candidate's model — what the + # audit row records. + model_name = summarizer.candidates[0].model + + async def _llm_call(call_args: LlmCallArgs) -> LlmCallResult: + """Async ``LlmCall`` wrapping the sync summarizer. + + Mirrors the arrow function returned by TS + ``buildLlmCallFromSummarizer`` (``lcm-synthesize-around-tool.ts:613-617``). + """ + started_at = time.monotonic() + # ``is_condensed=True`` — synthesis is a condensed-tier rollup + # (TS ``{isCondensed: true}``). ``aggressive=False`` matches the + # TS ``summarizerBuilt.fn(text, false, ...)`` call. + output = summarizer.summarize( + call_args.prompt, + False, + LcmSummarizeOptions(is_condensed=True), + ) + latency_ms = (time.monotonic() - started_at) * 1000.0 + return LlmCallResult( + output=output, + latency_ms=latency_ms, + actual_model=model_name, + ) + + return _llm_call, model_name + + +def _adapt_lcm_synthesize_around(args: dict[str, Any], **kwargs: Any) -> str: + """Dispatch adapter for ``lcm_synthesize_around`` (#164 PR-2, closes #156). + + Builds a :class:`_SynthesizeAroundCtx` and a :class:`LcmDependencies` + from the engine and calls + :func:`~lossless_hermes.tools.synthesize_around.handle_lcm_synthesize_around`. + This is the 8th and final #156 tool — landing it brings dispatch + coverage to 8/8. + + The one structural difference from the PR-1 attribute-bag adapters: + ``SynthesizeAroundContext.build_llm_call`` is a *factory*, not a + plain collaborator. This adapter constructs that factory as a thin + closure over :func:`_build_synthesizer_llm_call` — so the handler + invokes ``ctx.build_llm_call()`` lazily, only after it has decided + a synthesis pass is actually needed (after the window resolves and + the cache check misses). Building the factory eagerly here is free + (it captures the engine; no LLM call fires until the handler invokes + it), and matches the TS source's structure where the tool factory + closes over the lazily-resolved engine. + + ``build_llm_call`` raising — engine summarizer unset, or an empty + candidate chain — is caught by the handler's own ``try/except`` + around ``ctx.build_llm_call()`` (``synthesize_around.py:748-759``) + and surfaced as the structured "No summarization model resolved" + tool-error. So a mis-configured summarizer degrades to a clean + tool-error, never an exception escape. + + Args: + args: The tool-call ``arguments`` dict (forwarded verbatim). + **kwargs: The uniform dispatch kwargs — see + :func:`_adapt_lcm_get_entity`. + + Returns: + The handler's JSON string, or a structured ``tool_result`` + error if engine state is not initialised. + """ + engine: LCMEngine = kwargs["ctx"] + if engine._db is None: + return _engine_not_ready_error("lcm_synthesize_around", "engine._db") + if engine._conversation_store is None: + return _engine_not_ready_error("lcm_synthesize_around", "engine._conversation_store") + + def _build_llm_call() -> tuple[LlmCall, str]: + # Deferred to handler-invocation time — the handler calls this + # only once it has resolved a non-empty window and missed the + # synthesis cache. Closes over ``engine`` so it reads the live + # ``_summarizer``. + return _build_synthesizer_llm_call(engine) + + ctx = _SynthesizeAroundCtx( + conn=engine._db, + conversation_store=engine._conversation_store, + timezone=engine.config.timezone, + build_llm_call=_build_llm_call, + ) + session_key = _resolve_session_key(engine, kwargs) + return handle_lcm_synthesize_around( + args, + ctx=ctx, + deps=_build_deps(engine), + session_key=session_key, + # The engine is single-session-scoped: session_key == session_id. + session_id=session_key, + ) diff --git a/tests/test_dispatch_registry_coverage.py b/tests/test_dispatch_registry_coverage.py index b572836..c670e23 100644 --- a/tests/test_dispatch_registry_coverage.py +++ b/tests/test_dispatch_registry_coverage.py @@ -11,8 +11,11 @@ carry a ``**_kwargs`` sink and dispatch today. This file is the regression test that *would have caught* that gap, and -the ratchet that keeps it caught as the #156 four-PR sequence -(PR-0 → PR-3) wires the adapter layer incrementally. +the ratchet that kept it caught as the #156 four-PR sequence +(PR-0 → PR-3) plus #164 PR-2 (which finished the 8th adapter) wired the +adapter layer incrementally. As of #164 PR-2 the ratchet is fully +discharged — :data:`_NOT_YET_ADAPTED` is empty and dispatch coverage is +8/8. What it asserts (parametrized over ``get_tool_schemas()``) ---------------------------------------------------------- @@ -34,28 +37,34 @@ Plus a standalone ``test_lcm_expand_deferred`` pinning the ADR-037 deferral. -The xfail ratchet (assertions (a) and (b)) ------------------------------------------- - -PR-0 (this file) wires NO adapters. The six not-yet-adapted ported -tools (``lcm_grep``, ``lcm_describe``, ``lcm_get_entity``, -``lcm_search_entities``, ``lcm_compact``, ``lcm_synthesize_around``) -therefore fail (a) and (b) — they are in ``TOOL_SCHEMAS`` but not in -``TOOL_DISPATCH``, and ``handle_tool_call`` returns the unknown-tool -error for them. Those two assertions are marked ``xfail(strict=True)`` -for those six tools, so: - -* **The suite is GREEN at every intermediate merge.** A known-failing - assertion under ``xfail`` is a pass. -* **Each future adapter PR flips one tool.** When PR-1 wires - ``lcm_grep``, its (a)/(b) assertions start *passing* — and a - ``strict=True`` xfail that passes is an ``XPASS`` that FAILS the - suite. That failure is the signal: the adapter landed, so remove - ``lcm_grep`` from ``_NOT_YET_ADAPTED`` in the same PR. The ratchet - cannot be left half-done. - -``lcm_status`` and ``lcm_doctor`` already dispatch (PR #155) — they are -NOT in ``_NOT_YET_ADAPTED`` and must PASS (a)/(b)/(c) now. +The xfail ratchet (assertions (a) and (b)) — now fully discharged +----------------------------------------------------------------- + +The ratchet worked as designed across the rollout. PR-0 wired no +adapters, so the six not-yet-adapted ported tools (``lcm_grep``, +``lcm_describe``, ``lcm_get_entity``, ``lcm_search_entities``, +``lcm_compact``, ``lcm_synthesize_around``) failed (a) and (b) — in +``TOOL_SCHEMAS`` but not ``TOOL_DISPATCH`` — and were marked +``xfail(strict=True)``. As each adapter landed, its (a)/(b) assertions +started passing, the ``strict=True`` xfail became an ``XPASS`` that +failed the suite, and the same PR removed that tool from +:data:`_NOT_YET_ADAPTED`: + +* **The suite stayed GREEN at every intermediate merge.** A known- + failing assertion under ``xfail`` is a pass. +* **#156 PR-1** flipped ``lcm_get_entity`` / ``lcm_search_entities`` / + ``lcm_describe`` / ``lcm_grep``; **#156 PR-2** flipped ``lcm_compact``; + **#164 PR-2** flipped the 8th tool ``lcm_synthesize_around`` (deferred + from #156 PR-3 — its ``build_llm_call`` factory needed a summarizer + surface ``LCMEngine`` did not expose). + +As of #164 PR-2, :data:`_NOT_YET_ADAPTED` is **empty**: all eight +advertised tools dispatch, so (a)/(b) are hard PASSes for every tool and +#156 is closed. A tool re-appearing in the set would mean the #156 bug +regressed. + +``lcm_status`` and ``lcm_doctor`` dispatch via PR #155 — they were never +in ``_NOT_YET_ADAPTED`` and PASS (a)/(b)/(c). Why assertion (c) is NOT in the ratchet --------------------------------------- @@ -135,47 +144,52 @@ # --------------------------------------------------------------------------- -# The not-yet-adapted ratchet set +# The not-yet-adapted ratchet set — now EMPTY (dispatch coverage is 8/8) # --------------------------------------------------------------------------- # -# The ported ``lcm_*`` tools whose dispatch-adapter has NOT landed yet. -# PR-1..PR-3 of the #156 sequence remove tools from this set as their -# adapters ship: +# The ported ``lcm_*`` tools whose dispatch-adapter has not landed yet. +# The #156 sequence (and #164 PR-2, which finished it) removed tools +# from this set as their adapters shipped: # -# * PR-1 → lcm_get_entity, lcm_search_entities, lcm_describe, lcm_grep -# (DONE — wired in PR-1, removed from this set) -# * PR-2 → lcm_compact (DONE — wired in PR-2, removed from this set) -# * PR-3 → lcm_synthesize_around +# * #156 PR-1 → lcm_get_entity, lcm_search_entities, lcm_describe, +# lcm_grep +# * #156 PR-2 → lcm_compact +# * #164 PR-2 → lcm_synthesize_around — the 8th tool. It was deferred +# from #156 PR-3 because its ``build_llm_call`` factory needs a +# summarizer surface ``LCMEngine`` did not expose; #164 PR-2 built +# that surface and wired the adapter. # -# When an adapter lands, its (a)/(b) ``xfail(strict=True)`` markers turn -# into ``XPASS`` and FAIL the suite — the signal to delete the tool from -# this set in the same PR. ``lcm_expand`` is NOT here: per ADR-037 it is -# deferred and absent from ``get_tool_schemas()`` entirely (see -# ``test_lcm_expand_deferred``). +# This set is now **empty**: every advertised tool has a ``TOOL_DISPATCH`` +# entry, so the (a)/(b) assertions are hard PASSes for all eight tools +# and #156 is closed. ``lcm_expand`` is NOT — and never was — in this +# set: per ADR-037 it is deferred and absent from ``get_tool_schemas()`` +# entirely (see ``test_lcm_expand_deferred``), so it is not part of the +# advertised surface this ratchet covers. # -# #156 PR-1 wired the four Tier-1/2 adapters (``lcm_get_entity``, -# ``lcm_search_entities``, ``lcm_describe``, ``lcm_grep``). #156 PR-2 -# (this PR) wired the Tier-3 ``lcm_compact`` adapter — the 2-method -# ``CompactContext`` shim — so ``lcm_compact`` is removed from this set -# and now PASSes (a)/(b) as hard assertions. Coverage is 7/8. Only -# ``lcm_synthesize_around`` remains here, for PR-3. -_NOT_YET_ADAPTED: frozenset[str] = frozenset({ - "lcm_synthesize_around", -}) +# A non-empty set here again would mean a new ported tool was advertised +# without its adapter — the #156 bug regressing. +_NOT_YET_ADAPTED: frozenset[str] = frozenset() # Minimal schema-valid args per tool, for the (b)/(c) ``handle_tool_call`` -# probes. The six un-adapted tools take the ``handler is None`` branch -# before args are ever inspected, so their args only need to be -# plausible; ``lcm_status`` / ``lcm_doctor`` have empty-parameter schemas -# (ADR-035) so ``{}`` is correct for them. +# probes. All eight tools now dispatch, so each tool's args reach its +# handler — they must therefore be plausible enough that the handler +# takes a *structured-error* path (e.g. "no conversation found", "missing +# prompt") rather than the unknown-tool path. ``lcm_synthesize_around`` +# gets a valid ``window_kind="period"``: on the fresh fixtured engine +# (no conversation, no summary-model config) the handler returns a +# structured "No LCM conversation found" error — a clean (b) PASS (not +# the unknown-tool error) and a clean (c) PASS (a JSON string). ``"recent"`` +# would be an invalid window_kind — still a clean structured error, but +# a valid mode keeps the probe honest. ``lcm_status`` / ``lcm_doctor`` +# have empty-parameter schemas (ADR-035) so ``{}`` is correct for them. _MINIMAL_ARGS: dict[str, dict[str, Any]] = { "lcm_grep": {"pattern": "x"}, "lcm_describe": {"id": "sum_1"}, "lcm_get_entity": {"name": "x"}, "lcm_search_entities": {}, "lcm_compact": {}, - "lcm_synthesize_around": {"window_kind": "recent"}, + "lcm_synthesize_around": {"window_kind": "period", "period": "yesterday"}, "lcm_status": {}, "lcm_doctor": {}, } @@ -193,10 +207,13 @@ def _all_tool_names() -> list[str]: def _xfail_if_unadapted(name: str) -> Any: """Return a ``pytest.param`` for ``name``, xfail-marked if un-adapted. - Assertions (a) and (b) are expected to FAIL for a tool still in - :data:`_NOT_YET_ADAPTED` (no ``TOOL_DISPATCH`` entry → unknown-tool - error). ``strict=True`` makes a landed adapter (assertion starts - passing) an ``XPASS`` that fails the suite — the ratchet signal. + :data:`_NOT_YET_ADAPTED` is now empty (#156 closed by #164 PR-2), so + in practice every tool gets a plain :func:`pytest.param`. The + machinery is retained as a regression guard: if a future ported tool + is advertised in ``get_tool_schemas()`` before its adapter lands, + adding it to :data:`_NOT_YET_ADAPTED` re-arms the ``strict=True`` + xfail ratchet — a landed adapter then ``XPASS``-fails the suite, + forcing the set back to empty. """ if name in _NOT_YET_ADAPTED: return pytest.param( @@ -204,9 +221,9 @@ def _xfail_if_unadapted(name: str) -> Any: marks=pytest.mark.xfail( strict=True, reason=( - f"{name}: dispatch-adapter not yet wired (#156 PR-1..PR-3). " - f"When the adapter lands this XPASSes — remove {name!r} " - f"from _NOT_YET_ADAPTED in the same PR." + f"{name}: dispatch-adapter not yet wired. When the " + f"adapter lands this XPASSes — remove {name!r} from " + f"_NOT_YET_ADAPTED in the same PR." ), ), ) @@ -247,14 +264,15 @@ def test_every_advertised_tool_has_a_dispatch_entry(name: str) -> None: """(a) Every ``get_tool_schemas()`` tool is a key in ``TOOL_DISPATCH``. This is the pure-registry check that pins the #156 invariant: a tool - advertised to the model MUST have a dispatch handler. ``xfail`` for - the six not-yet-adapted ported tools (the #156 sequence wires them - in PR-1..PR-3); a hard pass for ``lcm_status`` / ``lcm_doctor``. + advertised to the model MUST have a dispatch handler. With #156 + closed (#164 PR-2 wired the 8th adapter), this is a hard PASS for + all eight advertised tools — :data:`_NOT_YET_ADAPTED` is empty so no + parameter is xfail-marked. """ assert name in TOOL_DISPATCH, ( f"Tool {name!r} is advertised in get_tool_schemas() but has no " f"TOOL_DISPATCH entry — this is the #156 bug. Wire its dispatch " - f"adapter (see issue #156 PR-1..PR-3)." + f"adapter in tools/_adapters.py." ) @@ -273,15 +291,15 @@ def test_advertised_tool_does_not_return_unknown_tool_error( The behavioural twin of (a): a real fixtured engine dispatches the tool by name and the result must NOT be - ``{"error": "Unknown LCM tool: ..."}``. ``xfail`` for the six - not-yet-adapted ported tools — they hit the ``handler is None`` - branch and return exactly that error. + ``{"error": "Unknown LCM tool: ..."}``. With #156 closed this is a + hard PASS for all eight tools — each dispatches to its adapter, + which (on the fresh fixtured engine) returns a structured + handler-level error, not the unknown-tool error. Note: a fresh fixtured engine has no LLM response yet, so the token-gate degrades to "skip the gate" (``current_token_count`` / ``token_budget`` are ``None``) — the result is the dispatch result, - never a gate refusal. So the un-adapted tools reliably produce the - unknown-tool error (a clean xfail), not an ambiguous refusal. + never a gate refusal. """ result = engine.handle_tool_call(name, _MINIMAL_ARGS[name]) parsed = json.loads(result) @@ -444,15 +462,21 @@ def test_lcm_expand_deferred() -> None: def test_advertised_surface_is_the_expected_eight_tools() -> None: - """``get_tool_schemas()`` advertises exactly the eight expected tools. + """The advertised surface AND ``TOOL_DISPATCH`` are exactly the eight tools. - Pins the post-ADR-037 surface: the six ported tools the #156 - sequence wires (``lcm_grep``, ``lcm_describe``, ``lcm_get_entity``, + Pins the post-ADR-037, post-#156 surface: the six ported tools + (``lcm_grep``, ``lcm_describe``, ``lcm_get_entity``, ``lcm_search_entities``, ``lcm_compact``, ``lcm_synthesize_around``) plus the two ADR-035 diagnostic tools (``lcm_status``, - ``lcm_doctor``). ``lcm_expand`` (ADR-037) is excluded. A drift here - means a tool was added or dropped without updating this test and - ``_NOT_YET_ADAPTED``. + ``lcm_doctor``). ``lcm_expand`` (ADR-037) is excluded. + + With #156 closed by #164 PR-2, this asserts the **dispatch surface + equals the advertised surface** — ``set(TOOL_DISPATCH)`` is exactly + ``{name for s in get_tool_schemas()}``, i.e. 8/8 coverage with no + advertised-but-undispatchable tool and no dispatchable-but-unadvertised + tool. That set-equality is the strongest form of the #156 invariant. + A drift in either set means a tool was added or dropped without + updating this test (and, for a new ported tool, ``_NOT_YET_ADAPTED``). """ expected = { "lcm_grep", @@ -470,3 +494,11 @@ def test_advertised_surface_is_the_expected_eight_tools() -> None: f"actual={sorted(actual)}. If a tool was intentionally added or " f"removed, update this test AND _NOT_YET_ADAPTED." ) + # #156 closed: dispatch coverage is total. Every advertised tool + # dispatches, and nothing dispatches that is not advertised. + assert set(TOOL_DISPATCH) == expected, ( + f"TOOL_DISPATCH drifted from the advertised surface. " + f"expected={sorted(expected)}, actual={sorted(TOOL_DISPATCH)}. " + f"#156 requires dispatch coverage to equal the advertised " + f"surface exactly (8/8)." + ) diff --git a/tests/test_summarizer_surface.py b/tests/test_summarizer_surface.py new file mode 100644 index 0000000..4ca61a4 --- /dev/null +++ b/tests/test_summarizer_surface.py @@ -0,0 +1,426 @@ +"""Direct tests for the summarizer surface on :class:`LCMEngine` (#164 PR-2). + +Issue [#164](https://github.com/electricsheephq/lossless-hermes/issues/164) +PR-2 builds the *summarizer surface* on :class:`~lossless_hermes.engine.LCMEngine`: +``on_session_start`` constructs a +:class:`~lossless_hermes.hermes_llm.HermesSummarizerDeps` (PR-1's shim) +and an :class:`~lossless_hermes.summarize.LcmSummarizer` from it, then +exposes three handles: + +* ``engine.deps`` — the public :class:`~lossless_hermes.summarize.SummarizerDeps`; +* ``engine.summarize`` — the bound :class:`~lossless_hermes.summarize.LcmSummarizer.summarize`, + a :data:`~lossless_hermes.compaction.SummarizeFn`-shaped callable; +* ``engine._summarizer`` — the underlying :class:`LcmSummarizer` object + (the ``build_llm_call`` factory reads ``.candidates`` off it). + +This file is the direct coverage for that surface. What it pins: + +* the three handles are ``None`` on a bare engine and on a post- + ``on_session_end`` engine, populated between ``on_session_start`` and + ``on_session_end``; +* ``engine.deps`` is a real :class:`SummarizerDeps`, ``engine.summarize`` + is callable; +* ``on_session_start`` is idempotent — a second call on an already-open + DB does NOT rebuild the summarizer (the surface is config-scoped, not + session-scoped); +* ``engine.summarize`` round-trips: with a fake ``complete()`` injected + via a :class:`HermesSummarizerDeps` subclass and a configured summary + model, ``engine.summarize("text")`` returns the canned summary; +* ``/lcm doctor apply`` no longer hits its unconditional "unavailable" + arm — ``apply_scoped_doctor_repair`` resolves the engine's ``deps`` + into a real :class:`LcmSummarizer` (the doctor-apply confirmation the + #164 plan's PR-2 §4 calls for). + +Platform note +------------- + +Every test here calls ``on_session_start``, which needs a full +``open_lcm_db`` connection (sqlite-vec loads via +``enable_load_extension``). Apple's system CPython ships without +``--enable-loadable-sqlite-extensions`` and the engine hard-raises at +construction — so the whole module carries the +``enable_load_extension`` skip marker, mirroring +``tests/test_dispatch_registry_coverage.py`` and +``tests/tools/test_compact_adapter.py``. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path +from typing import Any, Iterator, Mapping + +import pytest + +from lossless_hermes.db.config import LcmConfig +from lossless_hermes.engine import LCMEngine +from lossless_hermes.hermes_llm import HermesSummarizerDeps +from lossless_hermes.summarize import LcmSummarizer, SummarizerDeps + +# --------------------------------------------------------------------------- +# Skip marker — Apple system Python lacks enable_load_extension +# --------------------------------------------------------------------------- +pytestmark = pytest.mark.skipif( + not hasattr(sqlite3.Connection, "enable_load_extension"), + reason=( + "actions/setup-python on macOS ships a CPython build without " + "--enable-loadable-sqlite-extensions; sqlite-vec cannot load and " + "the engine hard-raises at construction. Every test here calls " + "on_session_start, so the whole module skips on such a build." + ), +) + + +# =========================================================================== +# Test doubles +# =========================================================================== + + +class _FakeCompleteDeps(HermesSummarizerDeps): + """A :class:`HermesSummarizerDeps` whose ``complete`` is a fake. + + The real :meth:`HermesSummarizerDeps.complete` lazy-imports Hermes's + ``call_llm`` — unavailable in the test/CI env. This subclass + overrides only ``complete`` with a deterministic fake that returns + the cascade-required *block-list* envelope + (``{"content": [{"type": "text", "text": ...}]}``) so + ``engine.summarize`` round-trips without a real LLM. ``get_api_key`` + / ``is_runtime_managed_auth_provider`` are inherited unchanged — the + genuine shim behaviour. + """ + + #: Canned summary the fake ``complete`` returns. + SUMMARY_TEXT = "FAKE-SUMMARY: condensed rollup of the input." + + def complete( + self, + *, + provider: str, + model: str, + api_key: str | None, + system: str, + user_prompt: str, + max_tokens: int, + reasoning: str | None = None, + skip_model_auth: bool = False, + timeout_ms: int, + ) -> Mapping[str, Any]: + """Return a canned block-list envelope (no real LLM call).""" + del ( + provider, + model, + api_key, + system, + user_prompt, + max_tokens, + reasoning, + skip_model_auth, + timeout_ms, + ) + return {"content": [{"type": "text", "text": self.SUMMARY_TEXT}]} + + +class _FakeDepsEngine(LCMEngine): + """An :class:`LCMEngine` whose ``on_session_start`` uses fake deps. + + ``on_session_start`` builds a real :class:`HermesSummarizerDeps`, + whose ``complete`` would call Hermes's ``call_llm``. Tests that need + ``engine.summarize`` to actually *run* override the engine to + substitute :class:`_FakeCompleteDeps` instead — keeping every other + line of the lifecycle body (the ``LcmSummarizer`` construction, the + handle assignment, idempotence) genuine. + + This mirrors the ``_ScriptedCompactEngine`` pattern in + ``tests/tools/test_compact_adapter.py``: subclass the engine to + swap one collaborator, exercise the real surrounding wiring. + """ + + def on_session_start(self, session_id: str, **kwargs: Any) -> None: + super().on_session_start(session_id, **kwargs) + # Only swap the deps when the real surface was just built (the + # non-re-entrant path). A re-entrant call leaves the existing + # fake-backed summarizer in place — which is exactly what the + # idempotence test asserts. + if self.deps is not None and not isinstance(self.deps, _FakeCompleteDeps): + fake_deps = _FakeCompleteDeps() + self.deps = fake_deps + summarizer = LcmSummarizer( + deps=fake_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 = summarizer.summarize + + +# =========================================================================== +# Fixtures +# =========================================================================== + + +@pytest.fixture +def started_engine(tmp_home: Path) -> Iterator[LCMEngine]: + """An :class:`LCMEngine` with ``on_session_start`` run — default config. + + Default :class:`LcmConfig` — no ``summary_model`` configured, so the + summarizer's candidate chain is empty. The summarizer *object* is + still built (``engine.deps`` / ``engine.summarize`` populated); only + a ``summarize()`` *call* would raise. Used by the presence / + idempotence tests, which never call ``summarize``. + """ + eng = LCMEngine(hermes_home=tmp_home / ".hermes", config=LcmConfig()) + eng.on_session_start("surface-test-session") + try: + yield eng + finally: + eng.on_session_end("surface-test-session", []) + + +# =========================================================================== +# Presence — the three handles populate at on_session_start +# =========================================================================== + + +def test_bare_engine_has_no_summarizer_surface() -> None: + """A bare :class:`LCMEngine` (no ``on_session_start``) has the surface ``None``. + + Per ADR-001 the constructor does heavy init nowhere — the summarizer + surface, like the stores, is ``None`` until ``on_session_start``. + """ + eng = LCMEngine(config=LcmConfig()) + assert eng.deps is None + assert eng.summarize is None + assert eng._summarizer is None + + +def test_on_session_start_builds_the_summarizer_surface( + started_engine: LCMEngine, +) -> None: + """After ``on_session_start`` the three handles are populated. + + ``engine.deps`` is a :class:`SummarizerDeps` (concretely a + :class:`HermesSummarizerDeps`), ``engine.summarize`` is callable, and + ``engine._summarizer`` is the :class:`LcmSummarizer` object. + """ + assert started_engine.deps is not None + assert isinstance(started_engine.deps, HermesSummarizerDeps) + assert started_engine.summarize is not None + assert callable(started_engine.summarize) + assert started_engine._summarizer is not None + assert isinstance(started_engine._summarizer, LcmSummarizer) + # engine.summarize is the bound LcmSummarizer.summarize — the + # SummarizeFn-shaped callable downstream consumers expect. + assert started_engine.summarize == started_engine._summarizer.summarize + + +def test_engine_deps_satisfies_summarizer_deps_protocol( + started_engine: LCMEngine, +) -> None: + """``engine.deps`` structurally satisfies the :class:`SummarizerDeps` Protocol. + + The same conformance check ``hermes_llm.py``'s own tests use — a + typed binding to :class:`SummarizerDeps` plus a probe of each + Protocol member. :class:`SummarizerDeps` declares methods, so it is + not ``runtime_checkable``; this asserts the surface directly. + """ + deps = started_engine.deps + assert deps is not None + bound: SummarizerDeps = deps # static + structural: deps IS a SummarizerDeps + assert callable(bound.complete) + assert callable(bound.get_api_key) + assert callable(bound.is_runtime_managed_auth_provider) + + +def test_on_session_end_clears_the_summarizer_surface(tmp_home: Path) -> None: + """After ``on_session_end`` the three handles are ``None`` again. + + Symmetric with the store null-out — a post-close engine reports the + pre-first-``on_session_start`` shape, so ``getattr(engine, "deps")`` + probes (``commands/doctor.py``) see ``None``. + """ + eng = LCMEngine(hermes_home=tmp_home / ".hermes", config=LcmConfig()) + eng.on_session_start("end-test-session") + assert eng.deps is not None # built + eng.on_session_end("end-test-session", []) + assert eng.deps is None + assert eng.summarize is None + assert eng._summarizer is None + + +# =========================================================================== +# Idempotence — the surface is config-scoped, not session-scoped +# =========================================================================== + + +def test_on_session_start_is_idempotent_for_the_summarizer( + started_engine: LCMEngine, +) -> None: + """A re-entrant ``on_session_start`` does NOT rebuild the summarizer. + + The summarizer surface is config-scoped (built once per DB-open), + not session-scoped. ``on_session_start`` for a *new* session on an + already-open DB returns early at the ``self._db is not None`` guard + and must NOT churn the summarizer — the object identity is stable. + """ + deps_before = started_engine.deps + summarizer_before = started_engine._summarizer + summarize_before = started_engine.summarize + + # Second on_session_start — a new session id, same already-open DB. + started_engine.on_session_start("surface-test-session-2") + + # Identity stable — not rebuilt. + assert started_engine.deps is deps_before + assert started_engine._summarizer is summarizer_before + assert started_engine.summarize is summarize_before + # The re-entrant call DID still update current_session_id (the one + # thing the re-entrant path is documented to do). + assert started_engine.current_session_id == "surface-test-session-2" + + +# =========================================================================== +# Round-trip — engine.summarize actually summarizes +# =========================================================================== + + +def test_engine_summarize_round_trips_with_a_fake_complete( + tmp_home: Path, +) -> None: + """``engine.summarize("text")`` returns the canned summary. + + With a configured summary model (so the candidate chain is non-empty) + and a :class:`_FakeCompleteDeps` injected, ``engine.summarize`` runs + the real :class:`LcmSummarizer` cascade end-to-end against the fake + ``complete`` and returns its canned block-list envelope's text. This + proves the surface is wired correctly — the bound method is the live + summarizer, not a stub. + """ + config = LcmConfig(summary_provider="test-provider", summary_model="test-model") + eng = _FakeDepsEngine(hermes_home=tmp_home / ".hermes", config=config) + eng.on_session_start("round-trip-session") + try: + assert eng.summarize is not None + result = eng.summarize("Some input text that needs summarizing.") + assert result == _FakeCompleteDeps.SUMMARY_TEXT + finally: + eng.on_session_end("round-trip-session", []) + + +def test_engine_summarizer_has_resolved_candidates_when_configured( + tmp_home: Path, +) -> None: + """A configured summary model yields a non-empty candidate chain. + + ``engine._summarizer.candidates`` is what the + ``lcm_synthesize_around`` ``build_llm_call`` factory reads for the + Wave-12 F8 audit-honest model name. With ``summary_provider`` / + ``summary_model`` set, the primary candidate's model echoes the + config. + """ + config = LcmConfig(summary_provider="test-provider", summary_model="test-model") + eng = _FakeDepsEngine(hermes_home=tmp_home / ".hermes", config=config) + eng.on_session_start("candidates-session") + try: + summarizer = eng._summarizer + assert summarizer is not None + assert summarizer.candidates, "configured summary model must resolve a candidate" + assert summarizer.candidates[0].model == "test-model" + finally: + eng.on_session_end("candidates-session", []) + + +# =========================================================================== +# /lcm doctor apply — the "unavailable" arm no longer fires (surface side-effect) +# =========================================================================== +# +# ``commands/doctor.py`` (the ``/lcm doctor apply`` handler) probes the +# engine for a summarizer at ``doctor.py:261-262``:: +# +# deps = getattr(engine, "deps", None) +# summarize = getattr(engine, "summarize", None) +# ... +# apply_scoped_doctor_repair(..., deps=deps, +# summarize=summarize if callable(summarize) else None) +# +# and ``apply_scoped_doctor_repair`` reports ``kind="unavailable"`` iff +# ``_resolve_doctor_apply_summarize(...)`` returns ``None``. Before #164 +# PR-2 both probes returned ``None`` (the engine had no ``deps`` / +# ``summarize`` attribute at all), so doctor-apply's "unavailable" arm +# fired *unconditionally*. #164 PR-2's surface populates both — the +# tests below confirm doctor-apply no longer hits that arm, without +# regressing it. + + +def test_doctor_apply_resolver_returns_a_summarizer_from_the_engine_surface( + tmp_home: Path, +) -> None: + """``_resolve_doctor_apply_summarize`` resolves the engine's surface → not ``None``. + + ``_resolve_doctor_apply_summarize`` is the exact function whose + return value decides doctor-apply's "unavailable" arm — a ``None`` + return is "unavailable", a callable return is "the repair runs". + Driven with the engine's real #164 PR-2 handles exactly as + ``commands/doctor.py:261-289`` passes them (``deps=engine.deps``, + ``summarize=engine.summarize`` — which is callable), it returns a + usable callable, NOT ``None``. So doctor-apply no longer reports + "unavailable": the previously-unconditional arm is now skipped. + """ + from lossless_hermes.doctor.apply import _resolve_doctor_apply_summarize + + config = LcmConfig(summary_provider="test-provider", summary_model="test-model") + eng = _FakeDepsEngine(hermes_home=tmp_home / ".hermes", config=config) + eng.on_session_start("doctor-resolver-session") + try: + # Mirror commands/doctor.py:261-262's exact getattr probe. + deps = getattr(eng, "deps", None) + summarize = getattr(eng, "summarize", None) + assert deps is not None, "engine.deps probe must resolve post-PR-2" + assert callable(summarize), "engine.summarize probe must resolve to a callable" + + resolved = _resolve_doctor_apply_summarize( + config=eng.config, + deps=deps, + # commands/doctor.py:287 passes ``summarize if callable(...) else None``. + summarize=summarize if callable(summarize) else None, + runtime_config=None, + ) + assert resolved is not None, ( + "_resolve_doctor_apply_summarize must return a usable summarizer " + "from the engine's surface — a None return is the 'unavailable' arm." + ) + assert callable(resolved) + finally: + eng.on_session_end("doctor-resolver-session", []) + + +def test_doctor_apply_resolver_unavailable_on_a_pre_init_engine() -> None: + """On a bare engine the resolver returns ``None`` — the "unavailable" arm. + + The contrapositive: #164 PR-2 does NOT make doctor-apply always + "available". A bare :class:`LCMEngine` (no ``on_session_start``) has + ``deps`` / ``summarize`` both ``None`` — exactly the pre-init shape + ``commands/doctor.py`` already guards against — and + ``_resolve_doctor_apply_summarize`` correctly returns ``None``. The + arm is genuinely *conditional* on the surface being constructed, not + dead and not always-on. (The doctor-apply command also rejects a + DB-less engine far earlier — this pins the resolver leg itself.) + """ + from lossless_hermes.doctor.apply import _resolve_doctor_apply_summarize + + eng = LCMEngine(config=LcmConfig()) + deps = getattr(eng, "deps", None) + summarize = getattr(eng, "summarize", None) + assert deps is None + assert summarize is None + + resolved = _resolve_doctor_apply_summarize( + config=eng.config, + deps=deps, + summarize=summarize if callable(summarize) else None, + runtime_config=None, + ) + assert resolved is None, ( + "A pre-init engine has no summarizer surface — the resolver must " + "return None (doctor-apply's 'unavailable' arm)." + ) diff --git a/tests/test_tool_dispatch.py b/tests/test_tool_dispatch.py index 861fca9..306f9dd 100644 --- a/tests/test_tool_dispatch.py +++ b/tests/test_tool_dispatch.py @@ -100,11 +100,11 @@ def test_tool_dispatch_is_dict() -> None: assert isinstance(TOOL_DISPATCH, dict) -def test_tool_dispatch_holds_diagnostic_and_pr1_pr2_adapted_tools() -> None: - """The registry holds the ADR-035 diagnostics + the #156 PR-1/PR-2 adapters. +def test_tool_dispatch_holds_all_eight_adapted_tools() -> None: + """The registry holds the full 8-tool dispatch surface (#156 closed). - The dispatch-adapter layer (issue #156) wires the ported ``lcm_*`` - tools incrementally. After #156 PR-2 the registry holds: + The dispatch-adapter layer (issue #156) wired the ported ``lcm_*`` + tools incrementally; #164 PR-2 finished it. The registry holds: * the two read-only model-callable diagnostic tools added by ADR-035 (issue #135): ``lcm_status`` / ``lcm_doctor``; @@ -112,12 +112,17 @@ def test_tool_dispatch_holds_diagnostic_and_pr1_pr2_adapted_tools() -> None: ``lcm_search_entities``, ``lcm_describe``, ``lcm_grep``; * the Tier-3 ``lcm_compact`` wired by #156 PR-2 (its ``CompactContext`` shim implements two methods — - ``get_agent_compaction_gate_state`` + ``compact``). - - ``lcm_synthesize_around`` is still absent (it lands in #156 PR-3) - and ``lcm_expand`` is deferred per ADR-037. Any other entry — or - ``lcm_synthesize_around`` appearing early — means a tool was wired - before its prerequisites; fail fast. + ``get_agent_compaction_gate_state`` + ``compact``); + * ``lcm_synthesize_around``, wired by #164 PR-2 — it was deferred + from #156 PR-3 because its ``build_llm_call`` factory needs a + summarizer surface ``LCMEngine`` did not expose. #164 PR-2 built + that surface (``engine._summarizer`` at ``on_session_start``) and + landed the adapter, closing #156 at 8/8 dispatch coverage. + + ``lcm_expand`` is deferred per ADR-037 — absent from both + ``TOOL_DISPATCH`` and ``TOOL_SCHEMAS``. Any other entry, or a + missing one, means a tool was wired before its prerequisites or + dropped; fail fast. """ assert set(TOOL_DISPATCH) == { "lcm_status", @@ -127,11 +132,13 @@ def test_tool_dispatch_holds_diagnostic_and_pr1_pr2_adapted_tools() -> None: "lcm_describe", "lcm_grep", "lcm_compact", + "lcm_synthesize_around", }, ( "TOOL_DISPATCH should hold the two ADR-035 diagnostic tools plus " - "the four #156 PR-1 adapters (lcm_get_entity, lcm_search_entities, " - "lcm_describe, lcm_grep) plus the #156 PR-2 adapter (lcm_compact); " - f"lcm_synthesize_around lands in #156 PR-3. Got {sorted(TOOL_DISPATCH)}" + "the six #156 dispatch-adapter tools (lcm_get_entity, " + "lcm_search_entities, lcm_describe, lcm_grep, lcm_compact, " + "lcm_synthesize_around) — 8/8 coverage, #156 closed. " + f"Got {sorted(TOOL_DISPATCH)}" ) diff --git a/tests/tools/test_synthesize_around_adapter.py b/tests/tools/test_synthesize_around_adapter.py new file mode 100644 index 0000000..a9aab66 --- /dev/null +++ b/tests/tools/test_synthesize_around_adapter.py @@ -0,0 +1,511 @@ +"""Direct unit tests for the ``lcm_synthesize_around`` dispatch adapter. + +Issue [#164](https://github.com/electricsheephq/lossless-hermes/issues/164) +PR-2 added :func:`~lossless_hermes.tools._adapters._adapt_lcm_synthesize_around` +— the 8th and final #156 dispatch adapter, which brings tool-dispatch +coverage to 8/8 and closes #156. + +Unlike the four PR-1 attribute-bag adapters, ``lcm_synthesize_around``'s +``SynthesizeAroundContext.build_llm_call`` is a *factory* (a +:class:`~..synthesize_around.BuildLlmCall`, ``() -> tuple[LlmCall, str]``). +The adapter builds that factory over the engine's #164 PR-2 summarizer +surface — :func:`~.._adapters._build_synthesizer_llm_call` wires the +synthesis dispatcher's async :class:`~..synthesis.dispatch.LlmCall` to +the engine's :class:`~..summarize.LcmSummarizer`. + +The #156 regression test (``tests/test_dispatch_registry_coverage.py``) +verifies ``lcm_synthesize_around`` *dispatches*, but its (b) probe runs +the handler on a fresh fixtured engine with no conversation — the +handler returns "no conversation found" before reaching +``build_llm_call``. The #161 review established that an adapter must not +ship with zero direct test coverage; this file is that coverage, +mirroring ``tests/tools/test_compact_adapter.py``. + +What this file covers +--------------------- + +* :func:`_build_synthesizer_llm_call`: + - a configured engine yields ``(LlmCall, model_name)`` where the + ``LlmCall`` is awaitable and produces an + :class:`~..synthesis.dispatch.LlmCallResult` whose ``output`` is the + summary and whose ``actual_model`` is the resolved primary candidate + (the Wave-12 F8 audit-honesty contract); + - a bare engine (no ``on_session_start``) raises ``RuntimeError``; + - an engine with an empty candidate chain (no ``summary_model``) + raises ``RuntimeError``. +* :func:`_adapt_lcm_synthesize_around`: + - the engine-state-unset path degrades to a structured + ``tool_result`` error, not an exception; + - builds the :class:`SynthesizeAroundContext` correctly and + dispatches end-to-end against an in-memory DB — a non-error + ``tool_result`` plus a ``lcm_synthesis_cache`` row written; + - the ``build_llm_call`` factory it wires raises (mis-configured + summarizer) → the handler degrades to the structured "No + summarization model resolved" tool-error. +* :class:`_SynthesizeAroundCtx` structurally satisfies + :class:`SynthesizeAroundContext`. + +Platform note +------------- + +The ``_build_synthesizer_llm_call`` factory tests use a **bare** +:class:`LCMEngine` or an ``on_session_start``-fixtured one. The +fixtured / dispatch tests need ``open_lcm_db`` (sqlite-vec loads via +``enable_load_extension``); Apple's system CPython ships without +``--enable-loadable-sqlite-extensions`` and the engine hard-raises at +construction — so those carry the ``enable_load_extension`` skip +marker, mirroring ``tests/tools/test_compact_adapter.py``. The +bare-engine factory-guard tests run on every platform. +""" + +from __future__ import annotations + +import asyncio +import json +import sqlite3 +from pathlib import Path +from typing import Any, Iterator, Mapping + +import pytest + +from lossless_hermes.db.config import LcmConfig +from lossless_hermes.engine import LCMEngine +from lossless_hermes.hermes_llm import HermesSummarizerDeps +from lossless_hermes.summarize import LcmSummarizer +from lossless_hermes.synthesis.prompt_registry import ( + RegisterPromptOptions, + register_prompt, +) +from lossless_hermes.synthesis.dispatch import LlmCallArgs, LlmCallResult +from lossless_hermes.tools._adapters import ( + _adapt_lcm_synthesize_around, + _build_synthesizer_llm_call, + _SynthesizeAroundCtx, +) +from lossless_hermes.tools.synthesize_around import SynthesizeAroundContext + +# --------------------------------------------------------------------------- +# Skip marker — Apple system Python lacks enable_load_extension +# --------------------------------------------------------------------------- +_skip_no_extension_loading = pytest.mark.skipif( + not hasattr(sqlite3.Connection, "enable_load_extension"), + reason=( + "actions/setup-python on macOS ships a CPython build without " + "--enable-loadable-sqlite-extensions; sqlite-vec cannot load and " + "the engine hard-raises at construction. The on_session_start-" + "fixtured / dispatch tests skip here; the bare-engine factory " + "guard tests still run." + ), +) + + +# =========================================================================== +# Test doubles — a HermesSummarizerDeps whose complete() is faked +# =========================================================================== + + +class _FakeCompleteDeps(HermesSummarizerDeps): + """A :class:`HermesSummarizerDeps` with a deterministic fake ``complete``. + + The real ``complete`` lazy-imports Hermes's ``call_llm`` (unavailable + in the test env). This subclass returns the cascade-required + *block-list* envelope so :meth:`LcmSummarizer.summarize` round-trips. + """ + + SUMMARY_TEXT = "ADAPTER-FAKE-SUMMARY: condensed synthesis rollup." + + def complete( + self, + *, + provider: str, + model: str, + api_key: str | None, + system: str, + user_prompt: str, + max_tokens: int, + reasoning: str | None = None, + skip_model_auth: bool = False, + timeout_ms: int, + ) -> Mapping[str, Any]: + del ( + provider, + model, + api_key, + system, + user_prompt, + max_tokens, + reasoning, + skip_model_auth, + timeout_ms, + ) + return {"content": [{"type": "text", "text": self.SUMMARY_TEXT}]} + + +class _FakeDepsEngine(LCMEngine): + """An :class:`LCMEngine` whose ``on_session_start`` uses fake deps. + + Swaps the real :class:`HermesSummarizerDeps` for :class:`_FakeCompleteDeps` + so ``engine.summarize`` / the ``build_llm_call`` factory run without a + real LLM, while keeping every other line of the #164 PR-2 lifecycle + wiring genuine. Mirrors the ``_ScriptedCompactEngine`` pattern in + ``tests/tools/test_compact_adapter.py``. + """ + + def on_session_start(self, session_id: str, **kwargs: Any) -> None: + super().on_session_start(session_id, **kwargs) + if self.deps is not None and not isinstance(self.deps, _FakeCompleteDeps): + fake_deps = _FakeCompleteDeps() + self.deps = fake_deps + summarizer = LcmSummarizer( + deps=fake_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 = summarizer.summarize + + +# =========================================================================== +# Fixtures + helpers +# =========================================================================== + +#: An :class:`LcmConfig` with a configured summary model — so the +#: summarizer's candidate chain resolves a primary candidate and +#: ``build_llm_call`` can produce a model name. +_CONFIGURED = LcmConfig(summary_provider="test-provider", summary_model="test-model") + + +@pytest.fixture +def configured_engine(tmp_home: Path) -> Iterator[LCMEngine]: + """A fake-deps :class:`LCMEngine` with ``on_session_start`` run + a summary model.""" + eng = _FakeDepsEngine(hermes_home=tmp_home / ".hermes", config=_CONFIGURED) + eng.on_session_start("adapter-test-session") + try: + yield eng + finally: + eng.on_session_end("adapter-test-session", []) + + +def _bare_engine(*, config: LcmConfig | None = None) -> LCMEngine: + """A bare :class:`LCMEngine` — no ``on_session_start``, no DB, no summarizer.""" + return LCMEngine(config=config if config is not None else LcmConfig()) + + +def _seed_conversation_and_leaves(engine: LCMEngine, session_id: str) -> int: + """Seed a conversation + two leaf summaries for ``session_id``. + + Returns the ``conversation_id``. The conversation is created with a + non-``NULL`` ``session_key`` (== ``session_id`` — the engine is + single-session-scoped), so the leaves' ``session_key`` (copied from + the conversation row, ``NOT NULL`` on ``summaries``) is populated. + The leaves fall inside the explicit since/before window the e2e + test uses. Mirrors ``tests/tools/test_lcm_synthesize_around.py``'s + ``_insert_leaf``. + """ + store = engine._conversation_store + assert store is not None + # session_key == session_id — the conversation row carries a + # non-NULL session_key so the leaf INSERT's subquery resolves. + conv = store.get_or_create_conversation(session_id, session_key=session_id) + db = engine._db + assert db is not None + for i, content in enumerate(("Leaf one content.", "Leaf two content."), start=1): + db.execute( + "INSERT INTO summaries" + " (summary_id, conversation_id, kind, content, token_count," + " session_key, created_at)" + " VALUES (?, ?, 'leaf', ?, ?," + " (SELECT session_key FROM conversations" + " WHERE conversation_id = ?), ?)", + ( + f"sum_leaf_{i}", + conv.conversation_id, + content, + max(1, (len(content) + 3) // 4), + conv.conversation_id, + f"2026-05-01 1{i}:00:00", + ), + ) + db.commit() + return conv.conversation_id + + +# =========================================================================== +# _build_synthesizer_llm_call — the BuildLlmCall factory body +# =========================================================================== + + +@_skip_no_extension_loading +def test_build_synthesizer_llm_call_returns_callable_and_model( + configured_engine: LCMEngine, +) -> None: + """A configured engine → ``(LlmCall, model_name)``. + + The factory returns an awaitable :class:`~..synthesis.dispatch.LlmCall` + and the resolved primary candidate's model. With ``summary_model="test-model"`` + the model name echoes the config — the Wave-12 F8 audit-honesty + value the synthesis audit row records. + """ + llm_call, model_name = _build_synthesizer_llm_call(configured_engine) + assert callable(llm_call) + assert model_name == "test-model" + + +@_skip_no_extension_loading +def test_build_synthesizer_llm_call_callable_is_awaitable_and_summarizes( + configured_engine: LCMEngine, +) -> None: + """Awaiting the factory's ``LlmCall`` yields the summary as an ``LlmCallResult``. + + The factory bridges the sync :meth:`LcmSummarizer.summarize` to the + async :class:`~..synthesis.dispatch.LlmCall` Protocol. Awaiting it + with an :class:`LlmCallArgs` runs the (fake-backed) summarizer and + returns an :class:`LlmCallResult` whose ``output`` is the canned + summary and whose ``actual_model`` is the resolved primary candidate. + """ + llm_call, model_name = _build_synthesizer_llm_call(configured_engine) + + result = asyncio.run( + llm_call( + LlmCallArgs( + model="ignored-dispatch-model", + prompt="Synthesize these leaves into a rollup.", + pass_kind="single", + max_output_tokens=2_000, + ) + ) + ) + assert isinstance(result, LlmCallResult) + assert result.output == _FakeCompleteDeps.SUMMARY_TEXT + # Wave-12 F8: actual_model is the resolved primary candidate, NOT the + # dispatch-recommended model passed in LlmCallArgs. + assert result.actual_model == model_name == "test-model" + # Latency is measured (>= 0, a real float). + assert isinstance(result.latency_ms, float) + assert result.latency_ms >= 0.0 + + +@_skip_no_extension_loading +def test_build_synthesizer_llm_call_raises_on_empty_candidate_chain( + tmp_home: Path, +) -> None: + """An engine with no ``summary_model`` → the factory raises ``RuntimeError``. + + A default :class:`LcmConfig` resolves an empty summarizer candidate + chain. The factory raises ``RuntimeError`` *before* any cache row is + written — the handler's ``try/except`` around ``ctx.build_llm_call()`` + converts that into the structured "No summarization model resolved" + tool-error (see :func:`test_adapter_degrades_when_summarizer_unconfigured`). + """ + eng = _FakeDepsEngine(hermes_home=tmp_home / ".hermes", config=LcmConfig()) + eng.on_session_start("empty-chain-session") + try: + with pytest.raises(RuntimeError, match="no summary model candidates"): + _build_synthesizer_llm_call(eng) + finally: + eng.on_session_end("empty-chain-session", []) + + +def test_build_synthesizer_llm_call_raises_on_bare_engine() -> None: + """A bare engine (no ``on_session_start``) → the factory raises ``RuntimeError``. + + ``engine._summarizer`` is ``None`` until ``on_session_start``. The + factory raises rather than dereferencing ``None`` — self-consistent + even though the adapter's engine-readiness guard catches the unset + state before the factory is ever built. Bare engine → runs on every + platform. + """ + eng = _bare_engine() + assert eng._summarizer is None + with pytest.raises(RuntimeError, match="summarizer is not initialised"): + _build_synthesizer_llm_call(eng) + + +# =========================================================================== +# Structural conformance — _SynthesizeAroundCtx satisfies SynthesizeAroundContext +# =========================================================================== + + +@_skip_no_extension_loading +def test_synthesize_around_ctx_structurally_satisfies_protocol( + configured_engine: LCMEngine, +) -> None: + """``_SynthesizeAroundCtx`` is usable everywhere a :class:`SynthesizeAroundContext` is. + + :class:`SynthesizeAroundContext` is a runtime-uncheckable structural + Protocol. ``ty`` enforces the match statically at the + ``handle_lcm_synthesize_around(ctx=...)`` call site in ``_adapters.py``; + this pins it at runtime too — the shim exposes ``conn`` / + ``conversation_store`` / ``timezone`` plus a callable ``build_llm_call``. + """ + assert configured_engine._db is not None + assert configured_engine._conversation_store is not None + + def _factory() -> Any: + return _build_synthesizer_llm_call(configured_engine) + + ctx = _SynthesizeAroundCtx( + conn=configured_engine._db, + conversation_store=configured_engine._conversation_store, + timezone="UTC", + build_llm_call=_factory, + ) + bound: SynthesizeAroundContext = ctx # static + runtime conformance + assert isinstance(bound.conn, sqlite3.Connection) + assert bound.conversation_store is configured_engine._conversation_store + assert bound.timezone == "UTC" + assert callable(bound.build_llm_call) + + +# =========================================================================== +# _adapt_lcm_synthesize_around — engine-state-unset graceful degrade +# =========================================================================== + + +def test_adapter_engine_db_unset_returns_structured_error() -> None: + """A bare engine (no ``_db``) → a structured ``tool_result`` error, no raise. + + ``engine._db`` is ``None`` until ``on_session_start``. The adapter's + engine-readiness guard degrades to a structured ``{"error": ...}`` + JSON string rather than raising an :class:`AttributeError`. Bare + engine → runs on every platform. + """ + engine = _bare_engine() + assert engine._db is None + result = _adapt_lcm_synthesize_around( + {"window_kind": "period", "period": "yesterday"}, + ctx=engine, + ) + assert isinstance(result, str) + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert "error" in parsed + assert "not" in parsed["error"] and "initialised" in parsed["error"], ( + f"expected the engine-not-ready structured error, got {result!r}" + ) + # The error names the unset collaborator for the operator. + assert "engine._db" in parsed["error"] + + +# =========================================================================== +# _adapt_lcm_synthesize_around — end-to-end dispatch through the adapter +# =========================================================================== + + +@_skip_no_extension_loading +def test_adapter_dispatches_end_to_end_and_writes_cache_row( + configured_engine: LCMEngine, +) -> None: + """The adapter dispatches synthesis end-to-end and writes a cache row. + + With a seeded conversation + leaves + a registered prompt, dispatching + through :func:`_adapt_lcm_synthesize_around` runs the full path: + ``SynthesizeAroundContext`` construction → window resolution → the + ``build_llm_call`` factory → synthesis dispatch (against the fake + ``complete``) → cache UPDATE. The result is a non-error + ``tool_result`` and a ``lcm_synthesis_cache`` row in ``status='ready'``. + + This is the #164 plan PR-2 §4 "dispatch end-to-end ... assert a + non-error tool_result and a lcm_synthesis_cache row" test — proof the + summarizer surface (#164 PR-2 step 1) drives a real consumer. + """ + session_id = "adapter-test-session" + _seed_conversation_and_leaves(configured_engine, session_id) + db = configured_engine._db + assert db is not None + register_prompt( + db, + RegisterPromptOptions( + memory_type="episodic-condensed", + tier_label="custom", + pass_kind="single", + template="Compact: {{source_text}}", + ), + ) + + result = _adapt_lcm_synthesize_around( + { + "window_kind": "period", + "since": "2026-05-01T00:00:00Z", + "before": "2026-05-02T00:00:00Z", + }, + ctx=configured_engine, + # _dispatch_tool_call forwards the resolved session_key; the + # engine is single-session-scoped, so session_key == session_id. + session_key=session_id, + ) + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert "error" not in parsed, ( + f"adapter dispatch should succeed end-to-end, got error: {parsed.get('error')!r}" + ) + # Success payload shape — markdown text + a cache_id. + assert "text" in parsed + assert _FakeCompleteDeps.SUMMARY_TEXT in parsed["text"] + cache_id = parsed["cache_id"] + assert isinstance(cache_id, str) and cache_id + + # A lcm_synthesis_cache row was written, in the ready state. + cache_row = db.execute( + "SELECT status, content, model_used FROM lcm_synthesis_cache WHERE cache_id = ?", + (cache_id,), + ).fetchone() + assert cache_row is not None, "the synthesis cache row must be written" + status, content, model_used = cache_row[0], cache_row[1], cache_row[2] + assert status == "ready" + assert content is not None and _FakeCompleteDeps.SUMMARY_TEXT in content + # Wave-12 F8: the cache row records the resolved summarizer model. + assert model_used == "test-model" + + +@_skip_no_extension_loading +def test_adapter_degrades_when_summarizer_unconfigured( + tmp_home: Path, +) -> None: + """No ``summary_model`` → the adapter degrades to a clean tool-error. + + With the default :class:`LcmConfig` the summarizer candidate chain is + empty, so the ``build_llm_call`` factory raises ``RuntimeError``. The + handler's ``try/except`` around ``ctx.build_llm_call()`` converts + that into the structured "No summarization model resolved" tool-error + — never an exception escape. This pins the + factory-raises → handler-degrades seam end-to-end through the adapter. + """ + eng = _FakeDepsEngine(hermes_home=tmp_home / ".hermes", config=LcmConfig()) + eng.on_session_start("unconfigured-session") + try: + session_id = "unconfigured-session" + _seed_conversation_and_leaves(eng, session_id) + db = eng._db + assert db is not None + register_prompt( + db, + RegisterPromptOptions( + memory_type="episodic-condensed", + tier_label="custom", + pass_kind="single", + template="Compact: {{source_text}}", + ), + ) + + result = _adapt_lcm_synthesize_around( + { + "window_kind": "period", + "since": "2026-05-01T00:00:00Z", + "before": "2026-05-02T00:00:00Z", + }, + ctx=eng, + session_key=session_id, + ) + # The adapter did not raise — the handler caught the factory's + # RuntimeError and returned a structured tool-error string. + assert isinstance(result, str) + parsed = json.loads(result) + assert isinstance(parsed, dict) + assert "error" in parsed + assert "No summarization model resolved" in parsed["error"], ( + f"expected the structured 'No summarization model resolved' tool-error, got {result!r}" + ) + finally: + eng.on_session_end("unconfigured-session", [])