diff --git a/CHANGELOG.md b/CHANGELOG.md index ef921d8..95a65b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.11.1 - 2026-10-04 + +- Fixed the Hermes live-compression adapter's missing `_coerce_threshold_tokens_cap` + method and lazy context-window/threshold cache invalidation. +- Reused installed Hermes route metadata and per-model threshold handling; current + `select_context` budgets refresh the window without resetting retry/usage state. +- Preserved independent cloned-engine configuration, exact recovery, internal-call + isolation and the existing strict tokenizer acceptance gate. +- Added nine real-Hermes regression cases and corrected their Ruff formatting. +- Updated release/install references and corrected the README's stale context-engine + FAQ. Rust companion version aligned; its runtime behavior is unchanged. +- Unknown-model tokenizers and the host's plugin-discovery race remain separate + limitations; publication does not install or restart a running Hermes profile. + ## 0.11.0 - 2026-09-29 **Context, learning & observability.** Includes the unshipped 0.10.0 milestone; diff --git a/Cargo.toml b/Cargo.toml index ffb9760..8f98d97 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "token-terminator" -version = "0.11.0" +version = "0.11.1" edition = "2021" rust-version = "1.78" description = "Rust interoperability helpers for Token Terminator artifact identities and reduction invariants" diff --git a/MIGRATION.md b/MIGRATION.md index 50a1f31..86e2a72 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,3 +1,27 @@ +# Migration to Token Terminator 0.11.1 + +## 0.11.0 to 0.11.1 + +This is the PR #20 Hermes live-compression compatibility repair. No new key, +database migration, tokenizer override or engine-selection change is required. +Upgrade the environment actually used by the managed Hermes launcher, not an +unrelated Python environment or stale venv copy: + +```bash +python -m pip install --upgrade 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1' +python -m rtk_hermes_plus.cli install-context-engine +``` + +Confirm the imported `rtk_hermes_plus` path in that environment. When the relevant +backend is idle, restart it once through its normal supervisor; do not start a +competing gateway or delete histories/vaults. Verify the loaded version and +current-model request metrics. A healthy restart or a lower UI percentage alone +does not prove reduction. Unknown target tokenizers still bypass the engine. +See [the compatibility guide](docs/LIVE_COMPRESSION_COMPAT.md). These instructions +do not imply publication performs a live installation or restart. + +--- + # Migration to Token Terminator 0.11.0 ## Optional learned policy diff --git a/README.md b/README.md index a6d1b9a..ac82ffa 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,10 @@

CI - Release v0.11.0 + Release v0.11.1 JEV-powered GitHub Wiki - crates.io + crates.io docs.rs Python 3.10–3.13 MIT License @@ -27,6 +27,19 @@ Token Terminator is an agent-runtime optimization layer. It removes token bloat Documentation: see the [GitHub Wiki](https://github.com/AronAxe/Token-Terminator/wiki) for quick start, architecture, configuration, recovery, security, troubleshooting, migration, and release notes. +## Patch v0.11.1: Hermes live-compression compatibility + +Repairs the missing `_coerce_threshold_tokens_cap` adapter method and live +window/threshold invalidation. The selected TT engine now follows Hermes' +current route budget and isolates cloned engine settings. No new settings, +generative summarizer or relaxed tokenizer gate are introduced. + +For managed/multiplex installs, upgrade the environment that actually imports +TT, then restart the appropriate idle backend through its normal supervisor. +This patch does not add unknown-model tokenizer mappings or fix Hermes' separate +concurrent plugin-discovery race. [Patch notes](docs/releases/v0.11.1.md) · +[Compatibility details](docs/LIVE_COMPRESSION_COMPAT.md). + ## New in v0.11.0 **Context, learning & observability.** This release includes the unshipped 0.10.0 @@ -146,12 +159,12 @@ See [JEV roles and implemented scope](docs/JEV_ROLES.md) and ## Select Token Terminator as the Hermes ContextEngine -Install **v0.11.0** in the Python environment that runs Hermes, then install its +Install **v0.11.1** in the Python environment that runs Hermes, then install its managed adapter. Replace `python` below with that environment's interpreter: ```bash python -m pip install --upgrade \ - 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.0' + 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1' python -m rtk_hermes_plus.cli install-context-engine ``` @@ -309,9 +322,9 @@ The optional working-state block defaults to zero characters, even in `balanced` ## Install: Hermes Agent (turnkey) -Token Terminator 0.11.0 builds on v0.9.0 and replaces the earlier `rtk-hermes-plus` distribution. `token-terminator` and `rtk-hermes-plus` must not coexist because both own the `rtk_hermes_plus` Python import package. +Token Terminator 0.11.1 builds on v0.9.0 and replaces the earlier `rtk-hermes-plus` distribution. `token-terminator` and `rtk-hermes-plus` must not coexist because both own the `rtk_hermes_plus` Python import package. -This is the supported zero-glue installation: the repository already contains the Hermes hooks, slash command, recovery tool, and lifecycle accounting. The commands below pin the `v0.11.0` release tag. +This is the supported zero-glue installation: the repository already contains the Hermes hooks, slash command, recovery tool, and lifecycle accounting. The commands below pin the `v0.11.1` release tag. ### 1. Install RTK when using terminal rewriting @@ -333,7 +346,7 @@ HERMES_PY="$HOME/.hermes/hermes-agent/venv/bin/python" hermes plugins disable rtk-plus "$HERMES_PY" -m pip uninstall -y rtk-hermes-plus token-terminator "$HERMES_PY" -m pip install \ - 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.0' + 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1' ``` Windows example: @@ -342,7 +355,7 @@ Windows example: $HermesPy = "$env:LOCALAPPDATA\hermes\hermes-agent\venv\Scripts\python.exe" hermes plugins disable rtk-plus & $HermesPy -m pip uninstall -y rtk-hermes-plus token-terminator -& $HermesPy -m pip install "git+https://github.com/AronAxe/Token-Terminator.git@v0.11.0" +& $HermesPy -m pip install "git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1" ``` `tiktoken` now ships with Token Terminator and is used automatically for supported OpenAI-family models, including common provider-qualified model IDs. Hugging Face `tokenizers` remains optional when pointing Token Terminator at a local `tokenizer.json`: @@ -374,7 +387,7 @@ Installation and enablement are separate operations. Disabling affects subsequen Rust agent hosts can use the supported `token-terminator` companion crate for the stable cross-language pieces of the Token Terminator contract: ```bash -cargo add token-terminator@0.11.0 +cargo add token-terminator@0.11.1 ``` ```rust @@ -396,7 +409,7 @@ Install the same distribution in the environment that owns your agent loop: ```bash python -m pip install \ - 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.0' + 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1' ``` Then connect your runtime's tool-result and final-request hooks to `Runtime`. The adapter must map equivalent tools to Token Terminator's canonical names (`search_files`, `process`, and optionally `read_file`) and expose `Runtime.tool` to the model for exact recovery. @@ -649,7 +662,7 @@ No. Provider-visible content may be compacted, and `artifact_peek` is deliberate ### Does it replace the host's memory or context engine? -No. Token Terminator operates after or alongside normal context assembly. It does not own the transcript, alter persisted conversation history, or require a particular memory engine. +It can replace the selected **Hermes context engine**, including LCM, when explicitly configured with `context.engine: token-terminator`. Middleware-only mode keeps the host engine upstream. Neither mode replaces the memory system or transcript store, or destructively rewrites persisted conversation history. ### Does enabling Jev turn off the normal Token Terminator pipeline? diff --git a/docs/LIVE_COMPRESSION_COMPAT.md b/docs/LIVE_COMPRESSION_COMPAT.md new file mode 100644 index 0000000..77c9a17 --- /dev/null +++ b/docs/LIVE_COMPRESSION_COMPAT.md @@ -0,0 +1,36 @@ +# Live-compression adapter repair + +The v0.11.0 adapter omitted the compatibility surface used by current Hermes +`_apply_live_compression_config`. That host function updates the selected +`context_compressor` even when it is a plugin ContextEngine, including calling +`_coerce_threshold_tokens_cap` and clearing lazy window/threshold caches. + +This patch supplies positive-int/None cap normalization, lazy threshold and window +invalidation, current-route metadata resolution when an explicit pin is removed, +per-model threshold handling and isolated clone state. The current host request's +`budget_tokens` refreshes the engine's window without resetting response counters. +No hardcoded 272k or assumed replacement window is introduced. Host-side metadata +is reused; the underlying request tokenizer and reduction acceptance gate are not +relaxed. Internal service calls remain excluded. No generative compressor is built. + +Nine offline regression tests passed against the installed Hermes checkout +`188a1a5efd098f067638032fea6e24c42d06178f` on Python 3.14. The tests call the real +live-config function with mocked model metadata and disposable stores. They do not +claim paid-provider validation or a reduction of an existing live session. + +Release scope: v0.11.1 publishes PR #20's adapter and tests. Earlier incident +deployment notes are historical; a separately deployed adapter snapshot is not +proof that this exact release has loaded in a running process. Installation, +engine discovery and actual current-model request reduction remain separate +checks. This release performs no live installation or backend restart. + +The observed host directory loader can expose a partially initialized cached module +to simultaneous discovery. This adapter patch does not modify that upstream loader +or claim to resolve its race. Deployment must use the host's managed plugin update +path and a controlled backend restart, then check loaded engine identity and actual +provider-request metrics. Do not kill unrelated relay/messaging/dashboard processes, +delete conversation/vault data, or claim a reset UI percentage proves reduction. + +Unknown target tokenizers still cause ContextEngine/IR to pass through. This +patch does not supply new tokenizer mappings, guess a replacement model window, +or guarantee a reduction in a live session. diff --git a/docs/releases/v0.11.1.md b/docs/releases/v0.11.1.md new file mode 100644 index 0000000..4c1b939 --- /dev/null +++ b/docs/releases/v0.11.1.md @@ -0,0 +1,57 @@ +# Token Terminator v0.11.1 — Hermes live-compression compatibility + +A focused patch release of PR #20. The v0.11.0 ContextEngine, learned JEV policy, +Context IR, exact-evidence vault and Observatory remain in place. + +## Fixed + +- Supply `_coerce_threshold_tokens_cap` for Hermes' live-compression configuration. +- Honor invalidated context-window and threshold caches, explicit pin removal, + current-route metadata and per-model threshold handling. +- Refresh the engine window from the host's current `select_context` budget + without resetting retry bindings or usage counters. +- Keep cloned engines' settings independent. No generative compressor, destructive + history truncation, new key or weakened tokenizer gate is introduced. +- Include nine real-Hermes regression cases and the formatting-only CI correction. + +## Upgrade + +Use the Python environment actually loaded by your managed Hermes launcher: + +```bash +python -m pip install --upgrade 'git+https://github.com/AronAxe/Token-Terminator.git@v0.11.1' +python -m rtk_hermes_plus.cli install-context-engine +``` + +Keep your existing plugin/engine selection and JEV settings. Once idle, restart +only the relevant backend through its normal supervisor. Do not delete the +conversation history or evidence vault. Verify the imported package path, +loaded version and current-model request metrics, not only a UI percentage. + +## Validation and scope + +The formatted PR candidate passed Python 3.10–3.13, Windows, packaging, Rust, +learned-policy, ContextEngine, dashboard and Context IR workflows before release +preparation. The learning artifact reported 611 passed and three skips; one +skip is the whole optional real-Hermes test module. Its nine cases were previously +run locally against installed Hermes `188a1a5` on Python 3.14, not counted as +executed in that learning job. Final release-head CI status is recorded on PR #20. +No paid inference or new performance/answer-quality claims accompany this patch. + +**Not fixed by this patch:** unknown-model tokenizer support and the separate +host plugin-discovery race. Without an exact target tokenizer, ContextEngine/IR +still pass through. This is not a guarantee that an affected live session has +shrunk, and it does not merge the separate deployed-adapter branch. + +README changes are limited to a compatibility note, current install/version +references and correcting the stale FAQ: explicitly selected TT can replace LCM +as Hermes' ContextEngine. Artwork and schematics are unchanged. + +The Rust interoperability companion is version-aligned to 0.11.1 without a Rust +behavior change; it is not the Python runtime. GitHub assets include the wheel, +source distribution and SHA256SUMS. Existing releases remain unchanged. +Publication does not install, configure or restart your running agents. + +[PR #20](https://github.com/AronAxe/Token-Terminator/pull/20) · +[Compatibility guide](https://github.com/AronAxe/Token-Terminator/blob/v0.11.1/docs/LIVE_COMPRESSION_COMPAT.md) · +[Migration](https://github.com/AronAxe/Token-Terminator/blob/v0.11.1/MIGRATION.md) diff --git a/pyproject.toml b/pyproject.toml index 82bd8a7..fa38af8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "token-terminator" -version = "0.11.0" +version = "0.11.1" description = "Fail-open token reduction, exact recovery, and request compilation for Hermes Agent" readme = "README.md" requires-python = ">=3.10" diff --git a/src/rtk_hermes_plus/_version.py b/src/rtk_hermes_plus/_version.py index ae6db5f..fee46bd 100644 --- a/src/rtk_hermes_plus/_version.py +++ b/src/rtk_hermes_plus/_version.py @@ -1 +1 @@ -__version__ = "0.11.0" +__version__ = "0.11.1" diff --git a/src/rtk_hermes_plus/hermes_engine.py b/src/rtk_hermes_plus/hermes_engine.py index 5c28f1b..7159183 100644 --- a/src/rtk_hermes_plus/hermes_engine.py +++ b/src/rtk_hermes_plus/hermes_engine.py @@ -1,4 +1,4 @@ -"""Optional Hermes adapter. Normal TT imports never depend on Hermes being installed.""" +"""Hermes adapter with live-config and route-local model-window compatibility.""" from agent.context_engine import ContextEngine @@ -6,8 +6,127 @@ class TokenTerminatorContextEngine(HistoryContextEngine, ContextEngine): - """Selectable through Hermes' supported user ContextEngine directory mechanism.""" + """Hermes updates its selected engine through context_compressor as well.""" + + def __init__(self, **kwargs): + self.threshold_tokens_cap = None + self._config_context_length = None + self._model_metadata_kwargs = {} + self._resolved_context_length = 0 + self._threshold_tokens = self._tail_token_budget = None + super().__init__(**kwargs) @property def name(self) -> str: return "token-terminator" + + @staticmethod + def _coerce_threshold_tokens_cap(value) -> int | None: + """Positive integer or None, as required by Hermes' live-config hook.""" + try: + cap = int(value) if value is not None else 0 + except (TypeError, ValueError, OverflowError): + return None + return cap if cap > 0 else None + + @staticmethod + def _effective_threshold_percent(context_length, threshold_percent): + from agent.context_compressor import ContextCompressor + + return ContextCompressor._effective_threshold_percent( + context_length, threshold_percent + ) + + @property + def context_length(self) -> int: + if self._resolved_context_length is None: + from agent.model_metadata import get_model_context_length + + self._resolved_context_length = get_model_context_length( + self.model, + config_context_length=self._config_context_length, + **self._model_metadata_kwargs, + ) + return self._resolved_context_length + + @context_length.setter + def context_length(self, value: int) -> None: + self._resolved_context_length = max(0, int(value)) + self._threshold_tokens = self._tail_token_budget = None + + def _effective_threshold_cap(self, context_length: int) -> int | None: + cap = self._coerce_threshold_tokens_cap(self.threshold_tokens_cap) + return min(cap, context_length) if cap is not None else None + + @property + def threshold_tokens(self) -> int: + if self._threshold_tokens is None: + window = self.context_length + base = getattr(self, "_base_threshold_percent", self.threshold_percent) + self.threshold_percent = self._effective_threshold_percent(window, base) + threshold = int(window * self.threshold_percent) + cap = self._effective_threshold_cap(window) + self._threshold_tokens = ( + min(threshold, cap) if cap is not None else threshold + ) + return self._threshold_tokens + + @threshold_tokens.setter + def threshold_tokens(self, value: int) -> None: + self._threshold_tokens = value + + def update_model(self, model: str, context_length: int, **kwargs) -> None: + from agent.context_compressor import resolve_model_threshold + + with self._lock: + self._model_metadata_kwargs = { + key: kwargs[key] + for key in ("base_url", "api_key", "provider", "custom_providers") + if key in kwargs + } + if not hasattr(self, "_config_threshold_percent"): + self._config_threshold_percent = self.threshold_percent + self._base_threshold_percent = resolve_model_threshold( + model, + getattr(self, "model_thresholds", {}), + self._config_threshold_percent, + kwargs.get("provider", ""), + ) + self.threshold_percent = self._effective_threshold_percent( + context_length, self._base_threshold_percent + ) + super().update_model(model, context_length, **kwargs) + self._threshold_tokens = self._tail_token_budget = None + + def select_context(self, request_messages, **kwargs): + from .call_scope import internal_operation + + if internal_operation(): + return None + with self._lock: + # The hook carries the host's current route window, not a fixed TT cap. + # Do not use update_model here: that would reset retry/usage state. + budget = kwargs.get("budget_tokens", 0) + if type(budget) is int and budget > 0: + self.context_length = budget + return super().select_context(request_messages, **kwargs) + + def clone_for_agent(self): + with self._lock: + clone = type(self)(config=self.config, limits=self.limits) + clone.threshold_percent = self.threshold_percent + clone.threshold_tokens_cap = self.threshold_tokens_cap + clone._config_context_length = self._config_context_length + for key in ("_config_threshold_percent", "_base_threshold_percent"): + if hasattr(self, key): + setattr(clone, key, getattr(self, key)) + clone.model_thresholds = dict(getattr(self, "model_thresholds", {})) + clone.update_model( + self.model, self.context_length, **self._model_metadata_kwargs + ) + return clone + + def get_status(self): + result = super().get_status() + result["adapter_compat"] = "hermes-live-compression-v1" + return result diff --git a/tests/test_hermes_live_compression.py b/tests/test_hermes_live_compression.py new file mode 100644 index 0000000..7387aba --- /dev/null +++ b/tests/test_hermes_live_compression.py @@ -0,0 +1,124 @@ +"""Regression against the real optional Hermes live-config implementation.""" + +import copy +import tempfile +import types +import unittest +from pathlib import Path +from unittest.mock import patch + +try: + from tui_gateway.session_compression import _apply_live_compression_config +except ImportError as exc: + raise unittest.SkipTest("requires a current Hermes checkout") from exc + +from rtk_hermes_plus.config import Config +from rtk_hermes_plus.hermes_engine import TokenTerminatorContextEngine as Engine +from rtk_hermes_plus.history_engine import HistoryContextEngine + + +class LiveConfigTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.engine = Engine( + config=Config( + db_path=Path(self.tmp.name) / "vault.db", ledger_enabled=False + ) + ) + self.engine.update_model("fixture-main", 272000, provider="fixture") + self.agent = types.SimpleNamespace( + context_compressor=self.engine, model="fixture-main", provider="fixture" + ) + + def apply(self, cfg, pin=None): + with patch( + "agent.agent_init.config_context_length_for_runtime", return_value=pin + ): + _apply_live_compression_config(self.agent, cfg) + + def test_cap_values(self): + cases = [ + (None, None), + (0, None), + (-1, None), + ("bad", None), + ("", None), + (180000, 180000), + ("180000", 180000), + (float("inf"), None), + ] + for value, expected in cases: + with self.subTest(value=value): + self.assertEqual( + self.engine._coerce_threshold_tokens_cap(value), expected + ) + + def test_actual_live_hook_updates_pin_and_cap(self): + self.apply( + {"compression": {"threshold": 0.7, "threshold_tokens": 180000}}, + pin=1000000, + ) + self.assertEqual(self.engine.context_length, 1000000) + self.assertEqual(self.engine.threshold_tokens, 180000) + self.assertEqual(self.engine._config_context_length, 1000000) + + def test_removed_pin_reresolves_current_route(self): + self.apply( + {"compression": {"threshold": 0.7, "threshold_tokens": 180000}}, + pin=1000000, + ) + with patch( + "agent.model_metadata.get_model_context_length", return_value=600000 + ) as resolver: + self.apply({"compression": {"threshold": 0.7, "threshold_tokens": None}}) + self.assertIsNone(self.engine._config_context_length) + self.assertEqual(self.engine.context_length, 600000) + self.assertEqual(self.engine.threshold_tokens, 420000) + self.assertEqual(resolver.call_args.args[0], "fixture-main") + self.assertEqual(resolver.call_args.kwargs["provider"], "fixture") + + def test_live_small_window_threshold_floor(self): + self.apply({"compression": {"threshold": 0.1, "threshold_tokens": None}}) + self.assertEqual(self.engine.threshold_tokens, 204000) + + def test_model_switch_preserves_cap_and_changes_route(self): + self.engine.threshold_tokens_cap = 180000 + self.engine._threshold_tokens = None + self.engine.update_model("fixture-small", 32000, provider="other") + self.assertEqual(self.engine.threshold_tokens, 24000) + self.assertEqual(self.engine._model_metadata_kwargs, {"provider": "other"}) + + def test_host_request_window_replaces_stale_272k(self): + self.engine.last_prompt_tokens = 270000 + messages = [{"role": "user", "content": "Keep this exact."}] + original = copy.deepcopy(messages) + with patch.object( + HistoryContextEngine, "select_context", return_value=None + ) as select: + self.engine.select_context(messages, budget_tokens=1000000) + select.assert_called_once() + self.assertEqual(self.engine.context_length, 1000000) + self.assertEqual(self.engine.last_prompt_tokens, 270000) + self.assertEqual(messages, original) + + def test_unknown_window_does_not_erase_known_window(self): + with patch.object(HistoryContextEngine, "select_context", return_value=None): + self.engine.select_context([], budget_tokens=0) + self.assertEqual(self.engine.context_length, 272000) + + def test_clones_do_not_share_mutable_budget_state(self): + self.engine.threshold_tokens_cap = 100000 + self.engine.model_thresholds = {"fixture": 0.6} + other = self.engine.clone_for_agent() + other.threshold_tokens_cap = 50000 + other.model_thresholds["fixture"] = 0.9 + other.context_length = 1000000 + self.assertEqual(self.engine.threshold_tokens_cap, 100000) + self.assertEqual(self.engine.model_thresholds, {"fixture": 0.6}) + self.assertEqual(self.engine.context_length, 272000) + + def test_no_summarizer_or_destructive_compression(self): + self.assertFalse(self.engine.should_compress(9999999)) + self.assertFalse(self.engine.should_compress_preflight([])) + self.assertIsNone(self.engine._catalog)