Hindsight semver comparison and packaging dependency fixes
Cluster 40503 · agent. Data as of
Nothing open on GitHub. Fix merged in #40522. Check it covers your case.
The short answer
- Known?
- Yes. 5 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #40522. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #40522. That is separate from the fix event. - Still open
- 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.
Open issues0
Open PRs0
Fixes on record
-
#40522 fix(deps): declare packaging as a core dependency so it ships everywhere
files:
pyproject.toml,tests/test_packaging_metadata.pyShow the diff
-
pyproject.toml@@ -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@@ -1,4 +1,5 @@ from pathlib import Path +import re import tomllib import pytest @@ -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"] @@ -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@@ -1400,6 +1400,7 @@ dependencies = [ { name = "jinja2" }, { name = "markdown" }, { name = "openai" }, + { name = "packaging" }, { name = "pathspec" }, { name = "prompt-toolkit" }, { name = "psutil" }, @@ -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
files:
plugins/memory/hindsight/__init__.py,tests/plugins/memory/test_hindsight_provider.pyShow the diff
-
plugins/memory/hindsight/__init__.py@@ -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 @@ -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( @@ -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: @@ -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 @@ -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": @@ -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@@ -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") @@ -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")
-
2 PRs closed without merging
From the close comments: closed without comment 1 · done elsewhere 1
Closed issues · 1
- Closed#40503Missing `packaging` module in Docker image causes Hindsight version comparison to fail
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.40: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab