{"repo":"NousResearch/hermes-agent","public_id":"40503","display_label":"Hindsight semver comparison and packaging dependency fixes","summary":"These items address a missing `packaging` module in the Docker environment that breaks Hindsight's version comparison, with related fixes exploring either declaring it as a dependency or replacing it with a built-in alternative. One additional item addresses Hindsight append mode sending full session data instead of only new-turn deltas.","subsystem":"agent","counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":2},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40522},"resolution":{"state":"resolved","by":"pr-40522"},"freshness":{"state":"verified","last_verified_at":"2026-06-09T15:11:49+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":23724,"pr_number":40605,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":40503,"pr_number":40522,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":40503,"pr_number":40605,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":40503,"title":"Missing `packaging` module in Docker image causes Hindsight version comparison to fail","url":null,"state":"closed"},{"kind":"pr","number":40522,"title":"fix(deps): declare packaging as a core dependency so it ships everywhere","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["pyproject.toml","tests/test_packaging_metadata.py"],"fix_hunks":[{"file":"pyproject.toml","hunks":["@@ -61,6 +61,16 @@ dependencies = [\n   \"prompt_toolkit==3.0.52\",\n   # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).\n   \"croniter==6.0.0\",\n+  # ``packaging`` is imported directly on three production paths but was never\n+  # declared, so it only reached users transitively (pip/uv pull it for other\n+  # tools). The slim official Docker image ships without it, where the\n+  # try/except-ImportError fallbacks silently degrade: Hindsight's\n+  # ``_meets_minimum_version`` disables update_mode='append' (#40503),\n+  # tools/lazy_deps.py treats every version constraint as satisfied, and\n+  # hermes_cli/main.py drops to naive requirement parsing. Pure-Python\n+  # py3-none-any wheel, no compiled extensions — safe to ship everywhere.\n+  # Pinned to the version already resolved in uv.lock (no resolution churn).\n+  \"packaging==26.0\",\n   # Markdown -> HTML conversion for rich message delivery (Matrix\n   # `formatted_body`, and the `send_message` tool's HTML path). Now on the\n   # DEFAULT delivery path, not matrix-specific: without it both"]},{"file":"tests/test_packaging_metadata.py","hunks":["@@ -1,4 +1,5 @@\n from pathlib import Path\n+import re\n import tomllib\n \n import pytest","@@ -14,6 +15,22 @@\n REPO_ROOT = Path(__file__).resolve().parents[1]\n \n \n+def _distribution_name(requirement: str) -> str:\n+    \"\"\"Extract the PEP 508 distribution name from a requirement string.\n+\n+    Robust to markers (``; python_version < '3.12'``), direct references\n+    (``name @ https://...``), extras (``name[extra]``) and every version\n+    operator (``==``, ``>=``, ``<=``, ``~=``, ``!=``, ``<``, ``>``), so a\n+    future dep declared with any valid specifier shape doesn't silently\n+    mis-parse here.\n+    \"\"\"\n+    spec = requirement.split(\";\", 1)[0]  # drop environment markers\n+    spec = spec.split(\"@\", 1)[0]  # drop direct-reference URLs\n+    spec = spec.split(\"[\", 1)[0]  # drop extras\n+    spec = re.split(r\"[=<>!~]\", spec, maxsplit=1)[0]  # drop any version operator\n+    return spec.strip().lower()\n+\n+\n def _packages_find_include():\n     data = tomllib.loads((REPO_ROOT / \"pyproject.toml\").read_text(encoding=\"utf-8\"))\n     return data[\"tool\"][\"setuptools\"][\"packages\"][\"find\"][\"include\"]","@@ -61,6 +78,27 @@ def test_every_on_disk_subpackage_is_covered_by_packages_find():\n     )\n \n \n+def test_packaging_declared_as_core_dependency():\n+    \"\"\"Regression for #40503.\n+\n+    ``packaging`` is imported directly on three production paths\n+    (plugins/memory/hindsight/__init__.py, tools/lazy_deps.py,\n+    hermes_cli/main.py) yet was undeclared, so it only reached users\n+    transitively. The slim Docker image shipped without it, silently\n+    disabling Hindsight append-mode and version-constraint checks. It must\n+    be a declared core dependency so it installs everywhere and the\n+    update-repair step (``_verify_core_dependencies_installed``) guards it.\n+    \"\"\"\n+    data = tomllib.loads((REPO_ROOT / \"pyproject.toml\").read_text(encoding=\"utf-8\"))\n+    core = data[\"project\"][\"dependencies\"]\n+    names = {_distribution_name(dep) for dep in core}\n+    assert \"packaging\" in names, (\n+        \"packaging is imported on production paths (hindsight version compare, \"\n+        \"lazy_deps version constraints, requirement parsing) and must be a \"\n+        \"declared core dependency, not a transitive — see #40503\"\n+    )\n+\n+\n def test_faster_whisper_is_not_a_base_dependency():\n     data = tomllib.loads((REPO_ROOT / \"pyproject.toml\").read_text(encoding=\"utf-8\"))\n     deps = data[\"project\"][\"dependencies\"]"]},{"file":"uv.lock","hunks":["@@ -1400,6 +1400,7 @@ dependencies = [\n     { name = \"jinja2\" },\n     { name = \"markdown\" },\n     { name = \"openai\" },\n+    { name = \"packaging\" },\n     { name = \"pathspec\" },\n     { name = \"prompt-toolkit\" },\n     { name = \"psutil\" },","@@ -1652,6 +1653,7 @@ requires-dist = [\n     { name = \"nemo-relay\", marker = \"extra == 'nemo-relay'\", specifier = \"==0.3\" },\n     { name = \"numpy\", marker = \"extra == 'voice'\", specifier = \"==2.4.3\" },\n     { name = \"openai\", specifier = \"==2.24.0\" },\n+    { name = \"packaging\", specifier = \"==26.0\" },\n     { name = \"parallel-web\", marker = \"extra == 'parallel-web'\", specifier = \"==0.4.2\" },\n     { name = \"pathspec\", specifier = \"==1.1.1\" },\n     { name = \"pillow\", marker = \"extra == 'vision'\", specifier = \"==12.2.0\" },\n"]}]},{"kind":"pr","number":40524,"title":"fix(hindsight): replace packaging.version with built-in semver comparison","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"b5421f4ba606df706472ac1a4b36a84d9e1973c8","number":0},{"kind":"pr","value":"#40522","number":40522},{"kind":"pr","value":"#40503","number":40503}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":40605,"title":"fix(hindsight): send only new-turn delta on append retains instead of whole session","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/memory/hindsight/__init__.py","tests/plugins/memory/test_hindsight_provider.py"],"fix_hunks":[{"file":"plugins/memory/hindsight/__init__.py","hunks":["@@ -575,6 +575,10 @@ def __init__(self):\n         self._retain_context = \"conversation between Hermes Agent and the User\"\n         self._turn_counter = 0\n         self._session_turns: list[str] = []  # accumulates ALL turns for the session\n+        # How many turns the last append-mode retain already shipped. Used to\n+        # send only the new delta on subsequent retains when the API supports\n+        # update_mode='append' (legacy/overwrite path still sends everything).\n+        self._last_retained_turn_count = 0\n \n         # Recall controls\n         self._auto_recall = True","@@ -1119,6 +1123,7 @@ def initialize(self, session_id: str, **kwargs) -> None:\n         self._agent_workspace = str(kwargs.get(\"agent_workspace\") or \"\").strip()\n         self._turn_index = 0\n         self._session_turns = []\n+        self._last_retained_turn_count = 0\n         self._mode = self._config.get(\"mode\", \"cloud\")\n         # Read timeout from config or env var, fall back to default\n         self._timeout = _parse_int_setting(","@@ -1461,9 +1466,24 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st\n                          self._turn_counter, self._turn_counter + (self._retain_every_n_turns - self._turn_counter % self._retain_every_n_turns))\n             return\n \n-        logger.debug(\"sync_turn: retaining %d turns, total session content %d chars\",\n-                     len(self._session_turns), sum(len(t) for t in self._session_turns))\n-        content = \"[\" + \",\".join(self._session_turns) + \"]\"\n+        document_id, update_mode = self._resolve_retain_target(self._document_id)\n+\n+        # On append-capable APIs each retain only needs to ship the turns\n+        # accumulated since the last retain — the server appends them to the\n+        # existing document. On legacy/overwrite APIs we must resend the whole\n+        # session because each retain replaces the document.\n+        if update_mode == \"append\":\n+            turns_to_retain = self._session_turns[self._last_retained_turn_count:]\n+            if not turns_to_retain:\n+                logger.debug(\"sync_turn: skipped append retain; no new turns since last retain\")\n+                return\n+        else:\n+            turns_to_retain = list(self._session_turns)\n+\n+        logger.debug(\"sync_turn: retaining %d/%d turns, payload %d chars\",\n+                     len(turns_to_retain), len(self._session_turns),\n+                     sum(len(t) for t in turns_to_retain))\n+        content = \"[\" + \",\".join(turns_to_retain) + \"]\"\n \n         lineage_tags: list[str] = []\n         if self._session_id:","@@ -1474,11 +1494,10 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st\n         # Snapshot the state needed for the retain. The writer may run after\n         # _session_turns / _turn_index are mutated by a later sync_turn().\n         metadata_snapshot = self._build_metadata(\n-            message_count=len(self._session_turns) * 2,\n+            message_count=len(turns_to_retain) * 2,\n             turn_index=self._turn_index,\n         )\n-        num_turns = len(self._session_turns)\n-        document_id, update_mode = self._resolve_retain_target(self._document_id)\n+        num_turns = len(turns_to_retain)\n         bank_id = self._bank_id\n         retain_async_flag = self._retain_async\n         retain_context = self._retain_context","@@ -1509,6 +1528,10 @@ def _do_retain() -> None:\n         self._ensure_writer()\n         self._register_atexit()\n         self._retain_queue.put(_do_retain)\n+        # Advance the append watermark only after the delta is queued, so a\n+        # later retain doesn't re-ship turns we've already handed to the writer.\n+        if update_mode == \"append\":\n+            self._last_retained_turn_count = len(self._session_turns)\n \n     def get_tool_schemas(self) -> List[Dict[str, Any]]:\n         if self._memory_mode == \"context\":","@@ -1706,6 +1729,7 @@ def _flush():\n         self._session_turns = []\n         self._turn_counter = 0\n         self._turn_index = 0\n+        self._last_retained_turn_count = 0\n         logger.debug(\n             \"Hindsight on_session_switch: new_session=%s parent=%s reset=%s doc=%s\",\n             self._session_id, self._parent_session_id, reset, self._document_id,"]},{"file":"tests/plugins/memory/test_hindsight_provider.py","hunks":["@@ -780,8 +780,8 @@ def test_sync_turn_every_n_turns(self, provider_with_config):\n         assert item[\"metadata\"][\"turn_index\"] == \"3\"\n         assert item[\"metadata\"][\"message_count\"] == \"6\"\n \n-    def test_sync_turn_accumulates_full_session(self, provider_with_config):\n-        \"\"\"Each retain sends the ENTIRE session, not just the latest batch.\"\"\"\n+    def test_sync_turn_accumulates_full_session_without_append_support(self, provider_with_config):\n+        \"\"\"Legacy/overwrite APIs (no update_mode=append) resend the ENTIRE session each retain.\"\"\"\n         p = provider_with_config(retain_every_n_turns=2)\n \n         p.sync_turn(\"turn1-user\", \"turn1-asst\")","@@ -795,12 +795,59 @@ def test_sync_turn_accumulates_full_session(self, provider_with_config):\n         p._retain_queue.join()\n \n         content = p._client.aretain_batch.call_args.kwargs[\"items\"][0][\"content\"]\n-        # Should contain ALL turns from the session\n+        # Without append support the document is overwritten, so it must\n+        # contain ALL turns from the session.\n         assert \"turn1-user\" in content\n         assert \"turn2-user\" in content\n         assert \"turn3-user\" in content\n         assert \"turn4-user\" in content\n \n+    def test_sync_turn_appends_only_delta_when_append_supported(self, provider_with_config, monkeypatch):\n+        \"\"\"On append-capable APIs each retain ships only the new turns, not the whole session.\"\"\"\n+        monkeypatch.setattr(\n+            \"plugins.memory.hindsight._fetch_hindsight_api_version\",\n+            lambda *a, **kw: \"0.5.6\",\n+        )\n+        from plugins.memory.hindsight import _append_capability_cache, _append_capability_lock\n+        # Clear before AND after: the capability cache is module-global and keyed\n+        # per api_url, so a stale entry would leak into other tests.\n+        with _append_capability_lock:\n+            _append_capability_cache.clear()\n+        try:\n+            p = provider_with_config(retain_every_n_turns=2)\n+\n+            p.sync_turn(\"turn1-user\", \"turn1-asst\")\n+            p.sync_turn(\"turn2-user\", \"turn2-asst\")\n+            p._retain_queue.join()\n+\n+            first = p._client.aretain_batch.call_args.kwargs\n+            first_item = first[\"items\"][0]\n+            assert first[\"document_id\"] == \"test-session\"\n+            assert first_item[\"update_mode\"] == \"append\"\n+            assert \"turn1-user\" in first_item[\"content\"]\n+            assert \"turn2-user\" in first_item[\"content\"]\n+\n+            p._client.aretain_batch.reset_mock()\n+\n+            p.sync_turn(\"turn3-user\", \"turn3-asst\")\n+            p.sync_turn(\"turn4-user\", \"turn4-asst\")\n+            p._retain_queue.join()\n+\n+            second = p._client.aretain_batch.call_args.kwargs\n+            second_item = second[\"items\"][0]\n+            assert second[\"document_id\"] == \"test-session\"\n+            assert second_item[\"update_mode\"] == \"append\"\n+            # Only the delta — the already-retained turns must NOT be resent.\n+            assert \"turn1-user\" not in second_item[\"content\"]\n+            assert \"turn2-user\" not in second_item[\"content\"]\n+            assert \"turn3-user\" in second_item[\"content\"]\n+            assert \"turn4-user\" in second_item[\"content\"]\n+            # message_count reflects only the delta (2 turns -> 4 messages).\n+            assert second_item[\"metadata\"][\"message_count\"] == \"4\"\n+        finally:\n+            with _append_capability_lock:\n+                _append_capability_cache.clear()\n+\n     def test_sync_turn_passes_document_id(self, provider):\n         \"\"\"sync_turn should pass document_id (session_id + per-startup ts).\"\"\"\n         provider.sync_turn(\"hello\", \"hi\")\n"]}]},{"kind":"pr","number":40721,"title":"fix(hindsight): missing packaging breaks semver comparison, disables append mode","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}