# NousResearch/hermes-agent · Hindsight semver comparison and packaging dependency fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 5 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 2 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #40522. Verify it addresses your case.
- **Recommended:** read PR #40522 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-40522`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.40`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #40503: Missing \`packaging\` module in Docker image causes Hindsight version comparison to fail

## Prior & competing PRs

- **#40522: fix(deps): declare packaging as a core dependency so it ships everywhere**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `pyproject.toml`, `tests/test_packaging_metadata.py`
  - fix hunks:
  - `pyproject.toml`

    ```diff
    @@ -61,6 +61,16 @@ dependencies = [
       "prompt_toolkit==3.0.52",
       # Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
       "croniter==6.0.0",
    +  # ``packaging`` is imported directly on three production paths but was never
    +  # declared, so it only reached users transitively (pip/uv pull it for other
    +  # tools). The slim official Docker image ships without it, where the
    +  # try/except-ImportError fallbacks silently degrade: Hindsight's
    +  # ``_meets_minimum_version`` disables update_mode='append' (#40503),
    +  # tools/lazy_deps.py treats every version constraint as satisfied, and
    +  # hermes_cli/main.py drops to naive requirement parsing. Pure-Python
    +  # py3-none-any wheel, no compiled extensions — safe to ship everywhere.
    +  # Pinned to the version already resolved in uv.lock (no resolution churn).
    +  "packaging==26.0",
       # Markdown -> HTML conversion for rich message delivery (Matrix
       # `formatted_body`, and the `send_message` tool's HTML path). Now on the
       # DEFAULT delivery path, not matrix-specific: without it both
    ```
  - `tests/test_packaging_metadata.py`

    ```diff
    @@ -1,4 +1,5 @@
     from pathlib import Path
    +import re
     import tomllib
     
     import pytest
    ```

    ```diff
    @@ -14,6 +15,22 @@
     REPO_ROOT = Path(__file__).resolve().parents[1]
     
     
    +def _distribution_name(requirement: str) -> str:
    +    """Extract the PEP 508 distribution name from a requirement string.
    +
    +    Robust to markers (``; python_version < '3.12'``), direct references
    +    (``name @ https://...``), extras (``name[extra]``) and every version
    +    operator (``==``, ``>=``, ``<=``, ``~=``, ``!=``, ``<``, ``>``), so a
    +    future dep declared with any valid specifier shape doesn't silently
    +    mis-parse here.
    +    """
    +    spec = requirement.split(";", 1)[0]  # drop environment markers
    +    spec = spec.split("@", 1)[0]  # drop direct-reference URLs
    +    spec = spec.split("[", 1)[0]  # drop extras
    +    spec = re.split(r"[=<>!~]", spec, maxsplit=1)[0]  # drop any version operator
    +    return spec.strip().lower()
    +
    +
     def _packages_find_include():
         data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
         return data["tool"]["setuptools"]["packages"]["find"]["include"]
    ```

    ```diff
    @@ -61,6 +78,27 @@ def test_every_on_disk_subpackage_is_covered_by_packages_find():
         )
     
     
    +def test_packaging_declared_as_core_dependency():
    +    """Regression for #40503.
    +
    +    ``packaging`` is imported directly on three production paths
    +    (plugins/memory/hindsight/__init__.py, tools/lazy_deps.py,
    +    hermes_cli/main.py) yet was undeclared, so it only reached users
    +    transitively. The slim Docker image shipped without it, silently
    +    disabling Hindsight append-mode and version-constraint checks. It must
    +    be a declared core dependency so it installs everywhere and the
    +    update-repair step (``_verify_core_dependencies_installed``) guards it.
    +    """
    +    data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
    +    core = data["project"]["dependencies"]
    +    names = {_distribution_name(dep) for dep in core}
    +    assert "packaging" in names, (
    +        "packaging is imported on production paths (hindsight version compare, "
    +        "lazy_deps version constraints, requirement parsing) and must be a "
    +        "declared core dependency, not a transitive — see #40503"
    +    )
    +
    +
     def test_faster_whisper_is_not_a_base_dependency():
         data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
         deps = data["project"]["dependencies"]
    ```
  - `uv.lock`

    ```diff
    @@ -1400,6 +1400,7 @@ dependencies = [
         { name = "jinja2" },
         { name = "markdown" },
         { name = "openai" },
    +    { name = "packaging" },
         { name = "pathspec" },
         { name = "prompt-toolkit" },
         { name = "psutil" },
    ```

    ```diff
    @@ -1652,6 +1653,7 @@ requires-dist = [
         { name = "nemo-relay", marker = "extra == 'nemo-relay'", specifier = "==0.3" },
         { name = "numpy", marker = "extra == 'voice'", specifier = "==2.4.3" },
         { name = "openai", specifier = "==2.24.0" },
    +    { name = "packaging", specifier = "==26.0" },
         { name = "parallel-web", marker = "extra == 'parallel-web'", specifier = "==0.4.2" },
         { name = "pathspec", specifier = "==1.1.1" },
         { name = "pillow", marker = "extra == 'vision'", specifier = "==12.2.0" },
    ```
- **#40605: fix(hindsight): send only new-turn delta on append retains instead of whole session**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/memory/hindsight/__init__.py`, `tests/plugins/memory/test_hindsight_provider.py`
  - fix hunks:
  - `plugins/memory/hindsight/__init__.py`

    ```diff
    @@ -575,6 +575,10 @@ def __init__(self):
             self._retain_context = "conversation between Hermes Agent and the User"
             self._turn_counter = 0
             self._session_turns: list[str] = []  # accumulates ALL turns for the session
    +        # How many turns the last append-mode retain already shipped. Used to
    +        # send only the new delta on subsequent retains when the API supports
    +        # update_mode='append' (legacy/overwrite path still sends everything).
    +        self._last_retained_turn_count = 0
     
             # Recall controls
             self._auto_recall = True
    ```

    ```diff
    @@ -1119,6 +1123,7 @@ def initialize(self, session_id: str, **kwargs) -> None:
             self._agent_workspace = str(kwargs.get("agent_workspace") or "").strip()
             self._turn_index = 0
             self._session_turns = []
    +        self._last_retained_turn_count = 0
             self._mode = self._config.get("mode", "cloud")
             # Read timeout from config or env var, fall back to default
             self._timeout = _parse_int_setting(
    ```

    ```diff
    @@ -1461,9 +1466,24 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
                              self._turn_counter, self._turn_counter + (self._retain_every_n_turns - self._turn_counter % self._retain_every_n_turns))
                 return
     
    -        logger.debug("sync_turn: retaining %d turns, total session content %d chars",
    -                     len(self._session_turns), sum(len(t) for t in self._session_turns))
    -        content = "[" + ",".join(self._session_turns) + "]"
    +        document_id, update_mode = self._resolve_retain_target(self._document_id)
    +
    +        # On append-capable APIs each retain only needs to ship the turns
    +        # accumulated since the last retain — the server appends them to the
    +        # existing document. On legacy/overwrite APIs we must resend the whole
    +        # session because each retain replaces the document.
    +        if update_mode == "append":
    +            turns_to_retain = self._session_turns[self._last_retained_turn_count:]
    +            if not turns_to_retain:
    +                logger.debug("sync_turn: skipped append retain; no new turns since last retain")
    +                return
    +        else:
    +            turns_to_retain = list(self._session_turns)
    +
    +        logger.debug("sync_turn: retaining %d/%d turns, payload %d chars",
    +                     len(turns_to_retain), len(self._session_turns),
    +                     sum(len(t) for t in turns_to_retain))
    +        content = "[" + ",".join(turns_to_retain) + "]"
     
             lineage_tags: list[str] = []
             if self._session_id:
    ```

    ```diff
    @@ -1474,11 +1494,10 @@ def sync_turn(self, user_content: str, assistant_content: str, *, session_id: st
             # Snapshot the state needed for the retain. The writer may run after
             # _session_turns / _turn_index are mutated by a later sync_turn().
             metadata_snapshot = self._build_metadata(
    -            message_count=len(self._session_turns) * 2,
    +            message_count=len(turns_to_retain) * 2,
                 turn_index=self._turn_index,
             )
    -        num_turns = len(self._session_turns)
    -        document_id, update_mode = self._resolve_retain_target(self._document_id)
    +        num_turns = len(turns_to_retain)
             bank_id = self._bank_id
             retain_async_flag = self._retain_async
             retain_context = self._retain_context
    ```

    ```diff
    @@ -1509,6 +1528,10 @@ def _do_retain() -> None:
             self._ensure_writer()
             self._register_atexit()
             self._retain_queue.put(_do_retain)
    +        # Advance the append watermark only after the delta is queued, so a
    +        # later retain doesn't re-ship turns we've already handed to the writer.
    +        if update_mode == "append":
    +            self._last_retained_turn_count = len(self._session_turns)
     
         def get_tool_schemas(self) -> List[Dict[str, Any]]:
             if self._memory_mode == "context":
    ```

    ```diff
    @@ -1706,6 +1729,7 @@ def _flush():
             self._session_turns = []
             self._turn_counter = 0
             self._turn_index = 0
    +        self._last_retained_turn_count = 0
             logger.debug(
                 "Hindsight on_session_switch: new_session=%s parent=%s reset=%s doc=%s",
                 self._session_id, self._parent_session_id, reset, self._document_id,
    ```
  - `tests/plugins/memory/test_hindsight_provider.py`

    ```diff
    @@ -780,8 +780,8 @@ def test_sync_turn_every_n_turns(self, provider_with_config):
             assert item["metadata"]["turn_index"] == "3"
             assert item["metadata"]["message_count"] == "6"
     
    -    def test_sync_turn_accumulates_full_session(self, provider_with_config):
    -        """Each retain sends the ENTIRE session, not just the latest batch."""
    +    def test_sync_turn_accumulates_full_session_without_append_support(self, provider_with_config):
    +        """Legacy/overwrite APIs (no update_mode=append) resend the ENTIRE session each retain."""
             p = provider_with_config(retain_every_n_turns=2)
     
             p.sync_turn("turn1-user", "turn1-asst")
    ```

    ```diff
    @@ -795,12 +795,59 @@ def test_sync_turn_accumulates_full_session(self, provider_with_config):
             p._retain_queue.join()
     
             content = p._client.aretain_batch.call_args.kwargs["items"][0]["content"]
    -        # Should contain ALL turns from the session
    +        # Without append support the document is overwritten, so it must
    +        # contain ALL turns from the session.
             assert "turn1-user" in content
             assert "turn2-user" in content
             assert "turn3-user" in content
             assert "turn4-user" in content
     
    +    def test_sync_turn_appends_only_delta_when_append_supported(self, provider_with_config, monkeypatch):
    +        """On append-capable APIs each retain ships only the new turns, not the whole session."""
    +        monkeypatch.setattr(
    +            "plugins.memory.hindsight._fetch_hindsight_api_version",
    +            lambda *a, **kw: "0.5.6",
    +        )
    +        from plugins.memory.hindsight import _append_capability_cache, _append_capability_lock
    +        # Clear before AND after: the capability cache is module-global and keyed
    +        # per api_url, so a stale entry would leak into other tests.
    +        with _append_capability_lock:
    +            _append_capability_cache.clear()
    +        try:
    +            p = provider_with_config(retain_every_n_turns=2)
    +
    +            p.sync_turn("turn1-user", "turn1-asst")
    +            p.sync_turn("turn2-user", "turn2-asst")
    +            p._retain_queue.join()
    +
    +            first = p._client.aretain_batch.call_args.kwargs
    +            first_item = first["items"][0]
    +            assert first["document_id"] == "test-session"
    +            assert first_item["update_mode"] == "append"
    +            assert "turn1-user" in first_item["content"]
    +            assert "turn2-user" in first_item["content"]
    +
    +            p._client.aretain_batch.reset_mock()
    +
    +            p.sync_turn("turn3-user", "turn3-asst")
    +            p.sync_turn("turn4-user", "turn4-asst")
    +            p._retain_queue.join()
    +
    +            second = p._client.aretain_batch.call_args.kwargs
    +            second_item = second["items"][0]
    +            assert second["document_id"] == "test-session"
    +            assert second_item["update_mode"] == "append"
    +            # Only the delta — the already-retained turns must NOT be resent.
    +            assert "turn1-user" not in second_item["content"]
    +            assert "turn2-user" not in second_item["content"]
    +            assert "turn3-user" in second_item["content"]
    +            assert "turn4-user" in second_item["content"]
    +            # message_count reflects only the delta (2 turns -> 4 messages).
    +            assert second_item["metadata"]["message_count"] == "4"
    +        finally:
    +            with _append_capability_lock:
    +                _append_capability_cache.clear()
    +
         def test_sync_turn_passes_document_id(self, provider):
             """sync_turn should pass document_id (session_id + per-startup ts)."""
             provider.sync_turn("hello", "hi")
    ```
- **#40524: fix(hindsight): replace packaging.version with built-in semver comparison**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `b5421f4ba606df706472ac1a4b36a84d9e1973c8`, `#40522`, `#40503`
- **#40721: fix(hindsight): missing packaging breaks semver comparison, disables append mode**. closed without merge
  - maintainer direction: category `silent_close`; implication `active`

## Fix status

- Merged to main in PR #40522 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-09`.
- Subsystem: `agent`
