From d573e7c9e1639d7c98c02f3face6f599464f8758 Mon Sep 17 00:00:00 2001
From: emozilla
Date: Thu, 18 Jun 2026 16:00:26 -0400
Subject: [PATCH 01/28] fix(dashboard): use DS Button prefix/size API instead
of inline icons
@nous-research/ui@0.18.2 Button is grid-based: size=xs is an
aspect-square icon-only box, and icons belong in prefix/suffix.
The dashboard used shadcn-style size=xs + inline text
children, which forced text buttons into broken tall squares
(Configure, Run setup, Select, Save keys) and split icon/label
across grid columns elsewhere (Schedule it, Prune/Delete actions).
Move leading icons to prefix and size text buttons as sm/default.
For the post-setup spinner, drive the spin from a button-level
[&_svg]:animate-spin selector since the prefix slot clones the
icon and overwrites its className.
- ToolsetConfigDrawer: Select, Save keys, Run setup
- SkillsPage: New skill, Configure
- AutomationBlueprints: Schedule it
- SessionsPage: Prune old sessions, Delete empty, Delete selected
---
web/src/components/AutomationBlueprints.tsx | 7 +++--
web/src/components/ToolsetConfigDrawer.tsx | 32 ++++++++++++---------
web/src/pages/SessionsPage.tsx | 7 ++---
web/src/pages/SkillsPage.tsx | 7 ++---
4 files changed, 30 insertions(+), 23 deletions(-)
diff --git a/web/src/components/AutomationBlueprints.tsx b/web/src/components/AutomationBlueprints.tsx
index 10d1270fa059..209c75e0682a 100644
--- a/web/src/components/AutomationBlueprints.tsx
+++ b/web/src/components/AutomationBlueprints.tsx
@@ -149,8 +149,11 @@ function BlueprintCard({
From 81ff916e575f8ccae4d1aacb0c6dc7bf537820a8 Mon Sep 17 00:00:00 2001
From: kyssta-exe
Date: Tue, 16 Jun 2026 12:24:00 +0000
Subject: [PATCH 02/28] fix(agent): flush un-persisted messages before session
rotation (#47202)
compress_context() rotates the session (end_session -> create_session)
mid-turn when auto-compress triggers, but never called
_flush_messages_to_session_db() first. Messages generated during the
current turn that hadn't been persisted to state.db were silently lost.
The same bug existed in cli.py:new_session() (/new command). Both paths
now flush un-persisted messages before ending the old session.
---
agent/conversation_compression.py | 10 ++++++++++
cli.py | 12 ++++++++++++
2 files changed, 22 insertions(+)
diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py
index 318e67d0faf2..5c7d299f0a40 100644
--- a/agent/conversation_compression.py
+++ b/agent/conversation_compression.py
@@ -512,6 +512,16 @@ def _release_lock() -> None:
old_title = agent._session_db.get_session_title(agent.session_id)
# Trigger memory extraction on the old session before it rotates.
agent.commit_memory_session(messages)
+ # Flush any un-persisted messages from the current turn to the
+ # old session *before* rotating. compress_context() can be
+ # called mid-turn (auto-compress when context exceeds threshold)
+ # at a point when _flush_messages_to_session_db() has not yet
+ # run. Without this, messages generated during the current turn
+ # are silently lost on session rotation (#47202).
+ try:
+ agent._flush_messages_to_session_db(messages)
+ except Exception:
+ pass # best-effort — don't block compression on a flush error
agent._session_db.end_session(agent.session_id, "compression")
old_session_id = agent.session_id
agent.session_id = f"{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}"
diff --git a/cli.py b/cli.py
index 07fa2b72513d..ff5db7d01e0a 100644
--- a/cli.py
+++ b/cli.py
@@ -5975,6 +5975,18 @@ def new_session(self, silent=False, title=None):
old_session_id = self.session_id
if self._session_db and old_session_id:
+ # Flush any un-persisted messages from the current turn to the
+ # old session *before* rotating. /new can be called mid-turn
+ # when _flush_messages_to_session_db() has not yet run — without
+ # this, messages generated during the current turn are silently
+ # lost on session rotation (#47202).
+ if self.agent:
+ try:
+ self.agent._flush_messages_to_session_db(
+ self.conversation_history
+ )
+ except Exception:
+ pass # best-effort
try:
self._session_db.end_session(old_session_id, "new_session")
except Exception:
From 0879d5cc8f3a257e2607936ac1e4aebe7be37220 Mon Sep 17 00:00:00 2001
From: teknium1 <127238744+teknium1@users.noreply.github.com>
Date: Thu, 18 Jun 2026 11:35:43 -0700
Subject: [PATCH 03/28] fix(gateway): preserve original transcript when
/compress rotation is skipped
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The manual /compress handler called rewrite_transcript() unconditionally on
the session id returned by _compress_context(). When rotation does not occur
(e.g. _session_db unavailable, or the DB split raised), session_id is unchanged
and rewrite_transcript() DELETEs the original messages and replaces them with
only the compressed summary — permanent data loss (#44794, #39704).
Guard the rewrite on actual rotation: only overwrite when _compress_context
produced a new session id. Otherwise leave the original transcript intact and
log a warning.
---
gateway/slash_commands.py | 19 +++++++++++++++++--
1 file changed, 17 insertions(+), 2 deletions(-)
diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py
index bfcef143f449..04c3f4ca89f2 100644
--- a/gateway/slash_commands.py
+++ b/gateway/slash_commands.py
@@ -2588,14 +2588,29 @@ async def _handle_compress_command(self, event: MessageEvent) -> str:
# session_id for the continuation. Write the compressed messages
# into the NEW session so the original history stays searchable.
new_session_id = tmp_agent.session_id
- if new_session_id != session_entry.session_id:
+ rotated = new_session_id != session_entry.session_id
+ if rotated:
session_entry.session_id = new_session_id
self.session_store._save()
self._sync_telegram_topic_binding(
source, session_entry, reason="compress-command",
)
- self.session_store.rewrite_transcript(new_session_id, compressed)
+ # Only rewrite the transcript when rotation actually produced a
+ # NEW session id. If _compress_context could not rotate (e.g.
+ # _session_db unavailable, or the DB split raised), session_id
+ # is unchanged and rewrite_transcript() would DELETE the
+ # original messages and replace them with only the compressed
+ # summary — permanent data loss (#44794, #39704). In that case
+ # leave the original transcript intact.
+ if rotated:
+ self.session_store.rewrite_transcript(new_session_id, compressed)
+ else:
+ logger.warning(
+ "Manual /compress: session rotation did not occur "
+ "(session_id unchanged) — preserving original transcript "
+ "instead of overwriting it (#44794)."
+ )
# Reset stored token count — transcript changed, old value is stale
self.session_store.update_session(
session_entry.session_key, last_prompt_tokens=0
From 4ed2f3399418f2a2fd1d060878bb4b2f17565a87 Mon Sep 17 00:00:00 2001
From: alelpoan <155192176+alelpoan@users.noreply.github.com>
Date: Thu, 18 Jun 2026 23:44:27 +0300
Subject: [PATCH 04/28] fix(thread): allow scrolling long user messages in chat
history (#48619)
---
apps/desktop/src/components/assistant-ui/thread.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/desktop/src/components/assistant-ui/thread.tsx b/apps/desktop/src/components/assistant-ui/thread.tsx
index 1c8f41d66e63..c5b20cedd3e0 100644
--- a/apps/desktop/src/components/assistant-ui/thread.tsx
+++ b/apps/desktop/src/components/assistant-ui/thread.tsx
@@ -827,7 +827,7 @@ function StickyHumanMessageContainer({ attachments, children }: { attachments?:
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
- 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
+ 'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-y-auto rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
From 9705e7944ae46401ab9cb011ebd9fbcd5667b981 Mon Sep 17 00:00:00 2001
From: islam666
Date: Thu, 18 Jun 2026 07:35:08 +0000
Subject: [PATCH 05/28] fix(picker): remove max_models=50 cap in interactive
model pickers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The interactive model pickers (Desktop REST API, TUI model.options, CLI
/model) were hard-capped at max_models=50, which truncated large provider
catalogs like Kilo Gateway (336 models) to just 50 entries. This made
most models undiscoverable via the picker search box.
Changes:
- Change build_models_payload() default from max_models=50 to None (unlimited)
- Change list_authenticated_providers() default from max_models=8 to None
- Change list_picker_providers() default from max_models=8 to None
- Fix all [:max_models] slicing to handle None as 'no limit'
- Remove max_models=50 from 5 interactive picker callers:
* web_server.py: get_model_options (Desktop /api/model/options)
* web_server.py: get_recommended_default_model
* model_switch.py: prewarm_picker_cache_async
* tui_gateway/server.py: model.options JSON-RPC
* cli.py: HermesCLI model picker
- Telegram/Discord inline keyboard picker (gateway/slash_commands.py)
still passes max_models=50 explicitly — unchanged behavior.
The total_models field was already in the response payload and is now
meaningful since models.length == total_models for interactive pickers.
Fixes #48279
---
cli.py | 2 +-
hermes_cli/inventory.py | 2 +-
hermes_cli/model_switch.py | 13 ++++++-------
hermes_cli/web_server.py | 3 +--
tests/hermes_cli/test_inventory.py | 28 ++++++++++++++++++++++++++++
tui_gateway/server.py | 1 -
6 files changed, 37 insertions(+), 12 deletions(-)
diff --git a/cli.py b/cli.py
index ff5db7d01e0a..b1c9a4bc8ef0 100644
--- a/cli.py
+++ b/cli.py
@@ -6971,7 +6971,7 @@ def _handle_model_switch(self, cmd_original: str):
try:
if ctx is None:
raise RuntimeError("inventory context unavailable")
- providers = build_models_payload(ctx, max_models=50)["providers"]
+ providers = build_models_payload(ctx)["providers"]
except Exception:
providers = []
diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py
index 2c7d9c5bf5ce..7584dd887e03 100644
--- a/hermes_cli/inventory.py
+++ b/hermes_cli/inventory.py
@@ -117,7 +117,7 @@ def build_models_payload(
pricing: bool = False,
capabilities: bool = False,
force_fresh_nous_tier: bool = False,
- max_models: int = 50,
+ max_models: int | None = None,
) -> dict:
"""Build the ``{providers, model, provider}`` shape every consumer
needs from a single substrate call.
diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py
index a27292747bef..4da54beedf6d 100644
--- a/hermes_cli/model_switch.py
+++ b/hermes_cli/model_switch.py
@@ -1188,7 +1188,6 @@ def _warm() -> None:
current_model=ctx.current_model,
user_providers=ctx.user_providers,
custom_providers=ctx.custom_providers,
- max_models=50,
)
except Exception:
# Best-effort warmup — never surface errors into the session.
@@ -1206,7 +1205,7 @@ def list_authenticated_providers(
custom_providers: list | None = None,
*,
force_fresh_nous_tier: bool = False,
- max_models: int = 8,
+ max_models: int | None = None,
current_model: str = "",
) -> List[dict]:
"""Detect which providers have credentials and list their curated models.
@@ -1426,7 +1425,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
if hermes_id in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_id, model_ids)
total = len(model_ids)
- top = model_ids[:max_models]
+ top = model_ids[:max_models] if max_models else model_ids
slug = hermes_id
pinfo = _mdev_pinfo(mdev_id)
@@ -1589,7 +1588,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
if hermes_slug in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
total = len(model_ids)
- top = model_ids[:max_models]
+ top = model_ids[:max_models] if max_models else model_ids
results.append({
"slug": hermes_slug,
@@ -1664,7 +1663,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
if not _cp_model_ids:
_cp_model_ids = curated.get(_cp.slug, [])
_cp_total = len(_cp_model_ids)
- _cp_top = _cp_model_ids[:max_models]
+ _cp_top = _cp_model_ids[:max_models] if max_models else _cp_model_ids
results.append({
"slug": _cp.slug,
@@ -2040,7 +2039,7 @@ def list_picker_providers(
current_base_url: str = "",
user_providers: dict = None,
custom_providers: list | None = None,
- max_models: int = 8,
+ max_models: int | None = None,
current_model: str = "",
) -> List[dict]:
"""Interactive-picker variant of :func:`list_authenticated_providers`.
@@ -2083,7 +2082,7 @@ def list_picker_providers(
except Exception:
live_ids = list(p.get("models", []))
p = dict(p)
- p["models"] = live_ids[:max_models]
+ p["models"] = live_ids[:max_models] if max_models else live_ids
p["total_models"] = len(live_ids)
has_models = bool(p.get("models"))
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index fcda37d6dfed..c8fe020fe151 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -3323,7 +3323,6 @@ def get_model_options(profile: Optional[str] = None):
with _profile_scope(profile):
return build_models_payload(
load_picker_context(),
- max_models=50,
include_unconfigured=True,
picker_hints=True,
canonical_order=True,
@@ -3398,7 +3397,7 @@ def get_recommended_default_model(provider: str = ""):
try:
from hermes_cli.inventory import build_models_payload, load_picker_context
- payload = build_models_payload(load_picker_context(), max_models=50)
+ payload = build_models_payload(load_picker_context())
for row in payload.get("providers", []):
if str(row.get("slug", "")).lower() == slug:
models = row.get("models") or []
diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py
index 6eeb7a535be1..c7d761515b1a 100644
--- a/tests/hermes_cli/test_inventory.py
+++ b/tests/hermes_cli/test_inventory.py
@@ -660,3 +660,31 @@ def test_two_custom_providers_with_overlap_both_survive():
assert a_row["total_models"] == 2
assert b_row["total_models"] == 2
+
+def test_build_models_payload_no_max_models_returns_full_list():
+ """When max_models is not passed (None), build_models_payload must
+ return the full model list — not truncate to the old default of 50.
+ Regression for #48279: Kilo Gateway picker was capped at 50 of 336
+ models, making most models undiscoverable via search."""
+ full_models = [f"model-{i}" for i in range(100)]
+ rows = [
+ {
+ "slug": "kilocode",
+ "name": "Kilo Code",
+ "models": full_models,
+ "total_models": len(full_models),
+ "is_current": False,
+ "is_user_defined": False,
+ "source": "built-in",
+ },
+ ]
+ ctx = _empty_ctx()
+ with _list_auth_returning(rows):
+ # No max_models argument — should return all 100 models
+ payload = build_models_payload(ctx)
+
+ kilo_row = next(r for r in payload["providers"] if r["slug"] == "kilocode")
+ assert kilo_row["models"] == full_models
+ assert kilo_row["total_models"] == 100
+ assert len(kilo_row["models"]) == 100
+
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index d2436b86d082..f13a4c5c7609 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -9496,7 +9496,6 @@ def _(rid, params: dict) -> dict:
canonical_order=True,
pricing=True,
capabilities=True,
- max_models=50,
)
return _ok(rid, payload)
except Exception as e:
From 30420455403cee431f2fb5a9e665649aef0efccd Mon Sep 17 00:00:00 2001
From: teknium1 <127238744+teknium1@users.noreply.github.com>
Date: Thu, 18 Jun 2026 12:41:32 -0700
Subject: [PATCH 06/28] fix(picker): keep max_models=0 distinct from unlimited;
lock cap semantics
Follow-up to the cap-removal salvage. The contributor guarded the new
unlimited default with `[:max_models] if max_models else ...`, which conflates
max_models=0 (used by slug-only callers that want an empty model list) with
None (unlimited). Tighten to `is not None` at all five slicing sites in
list_authenticated_providers / list_picker_providers, and add a regression test
asserting the three-way contract: None=full, 0=empty, N=first N.
---
hermes_cli/model_switch.py | 10 ++--
tests/hermes_cli/test_model_catalog.py | 65 ++++++++++++++++++++++++++
2 files changed, 70 insertions(+), 5 deletions(-)
diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py
index 4da54beedf6d..eae987fbbdfe 100644
--- a/hermes_cli/model_switch.py
+++ b/hermes_cli/model_switch.py
@@ -1425,7 +1425,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
if hermes_id in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_id, model_ids)
total = len(model_ids)
- top = model_ids[:max_models] if max_models else model_ids
+ top = model_ids[:max_models] if max_models is not None else model_ids
slug = hermes_id
pinfo = _mdev_pinfo(mdev_id)
@@ -1588,7 +1588,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
if hermes_slug in _MODELS_DEV_PREFERRED:
model_ids = _merge_with_models_dev(hermes_slug, model_ids)
total = len(model_ids)
- top = model_ids[:max_models] if max_models else model_ids
+ top = model_ids[:max_models] if max_models is not None else model_ids
results.append({
"slug": hermes_slug,
@@ -1663,7 +1663,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
if not _cp_model_ids:
_cp_model_ids = curated.get(_cp.slug, [])
_cp_total = len(_cp_model_ids)
- _cp_top = _cp_model_ids[:max_models] if max_models else _cp_model_ids
+ _cp_top = _cp_model_ids[:max_models] if max_models is not None else _cp_model_ids
results.append({
"slug": _cp.slug,
@@ -1812,7 +1812,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
"name": "Custom endpoint",
"is_current": True,
"is_user_defined": True,
- "models": _models[:max_models] if max_models else _models,
+ "models": _models[:max_models] if max_models is not None else _models,
"total_models": len(_models),
"source": "model-config",
"api_url": str(current_base_url).strip().rstrip("/"),
@@ -2082,7 +2082,7 @@ def list_picker_providers(
except Exception:
live_ids = list(p.get("models", []))
p = dict(p)
- p["models"] = live_ids[:max_models] if max_models else live_ids
+ p["models"] = live_ids[:max_models] if max_models is not None else live_ids
p["total_models"] = len(live_ids)
has_models = bool(p.get("models"))
diff --git a/tests/hermes_cli/test_model_catalog.py b/tests/hermes_cli/test_model_catalog.py
index 7a1cbd54c30d..b464fb046a9a 100644
--- a/tests/hermes_cli/test_model_catalog.py
+++ b/tests/hermes_cli/test_model_catalog.py
@@ -423,6 +423,71 @@ def test_picker_nous_row_uses_curated_list(self, tmp_path, monkeypatch):
assert nous_row is not None, "nous row must appear when authed"
assert nous_row["models"] == expected
+ def test_picker_max_models_cap_semantics(self, tmp_path, monkeypatch):
+ """The cap argument has three distinct meanings on the real slicing
+ path: ``None`` = unlimited (the cap-removal fix, #48297), ``0`` = no
+ models (preserved for slug-only callers), an int N = first N. Guards
+ the ``is not None`` distinction the cap-removal follow-up introduced —
+ a ``if max_models`` (falsy) check would conflate ``0`` with unlimited.
+ """
+ import importlib
+ from hermes_cli import model_catalog
+ from hermes_cli.models import get_curated_nous_model_ids
+ importlib.reload(model_catalog)
+ try:
+ from hermes_cli.model_switch import (
+ list_authenticated_providers,
+ list_picker_providers,
+ )
+
+ active_home = Path(os.environ["HERMES_HOME"])
+ (active_home / "auth.json").write_text(
+ json.dumps(
+ {
+ "providers": {"nous": {"access_token": "fake"}},
+ "credential_pool": {},
+ }
+ )
+ )
+ with patch.object(
+ model_catalog, "_fetch_manifest", return_value=_valid_manifest()
+ ), patch("hermes_cli.models.check_nous_free_tier", return_value=False), patch(
+ "hermes_cli.models.union_with_portal_free_recommendations",
+ side_effect=lambda ids, *a, **k: (ids, {}),
+ ), patch(
+ "hermes_cli.models.union_with_portal_paid_recommendations",
+ side_effect=lambda ids, *a, **k: (ids, {}),
+ ):
+ expected = get_curated_nous_model_ids()
+ full = list_picker_providers(current_provider="nous", max_models=None)
+ one = list_picker_providers(current_provider="nous", max_models=1)
+ # 0 is exercised on list_authenticated_providers (the slug-only
+ # path); the picker variant drops empty-model rows entirely, so
+ # the empty-list contract lives on the auth-providers call.
+ zero = list_authenticated_providers(
+ current_provider="nous", max_models=0
+ )
+ finally:
+ model_catalog.reset_cache()
+
+ def _nous(rows):
+ return next((r for r in rows if r["slug"] == "nous"), None)
+
+ # Only meaningful when the curated list actually exceeds 1 entry.
+ assert len(expected) > 1, "test needs a multi-model curated nous list"
+
+ full_row = _nous(full)
+ assert full_row is not None and full_row["models"] == expected
+
+ one_row = _nous(one)
+ assert one_row is not None and one_row["models"] == expected[:1]
+
+ zero_row = _nous(zero)
+ # 0 means an empty model list — NOT unlimited. total_models still real.
+ assert zero_row is not None
+ assert zero_row["models"] == []
+ assert zero_row["total_models"] == len(expected)
+
# -----------------------------------------------------------------------------
# Drift guard — prevent the in-repo curated lists from going out of sync with
From 49596b70cb2d0d328d68645905febb074e494e77 Mon Sep 17 00:00:00 2001
From: Brooklyn Nicholson
Date: Thu, 18 Jun 2026 15:56:39 -0500
Subject: [PATCH 07/28] fix(gateway): resume follows the compression tip so
post-compression replies render
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Auto-compression ends the live session and forks a continuation child
(linked via parent_session_id). A long-lived parent keeps its own flushed
message rows, so resolve_resume_session_id()'s empty-head walk never
redirected it — resuming the parent id reloaded the pre-compression
transcript and dropped every turn generated after compression, including
the assistant's response. On the desktop this is the recurring "I sent a
message, came back, and the reply isn't there" report on large sessions:
the chat's routed id is the pre-rotation id, and both the gateway
session.resume RPC and the REST /messages read anchored on it.
Fix the resolver at the chokepoint: resolve_resume_session_id() now
follows the compression-continuation chain forward via get_compression_tip()
before its existing empty-head descendant walk. get_compression_tip() only
follows children whose parent ended with end_reason='compression' (created
after the parent was ended), so delegation/branch children never hijack a
resume. This fixes every resume caller at once (REST /messages, CLI
--resume, gateway /resume).
session.resume in tui_gateway was the one resume path that never called the
resolver — it used the raw target id directly. Route it through
resolve_resume_session_id() too (non-lazy only; lazy watch windows must
stay on their exact child branch). Resolving up front also re-anchors the
live-session fast path so a still-live rotated session is reused by its new
key instead of rebuilding a duplicate agent on the stale parent.
Tests:
- resolve_resume_session_id follows the tip even when the parent retains
messages, and is not confused by a delegation child.
- session.resume binds the agent to the continuation tip and returns the
post-compression reply.
---
hermes_state.py | 18 ++++++
.../test_resolve_resume_session_id.py | 40 +++++++++++++
tests/test_tui_gateway_server.py | 59 +++++++++++++++++++
tui_gateway/server.py | 21 +++++++
4 files changed, 138 insertions(+)
diff --git a/hermes_state.py b/hermes_state.py
index 9653eae017f7..19c6a269b99e 100644
--- a/hermes_state.py
+++ b/hermes_state.py
@@ -2820,6 +2820,24 @@ def resolve_resume_session_id(self, session_id: str) -> str:
if not session_id:
return session_id
+ # Follow the compression-continuation chain forward to the live tip
+ # FIRST. Auto-compression ends the current session and forks a
+ # continuation child, but a long-lived parent keeps its own flushed
+ # message rows — so the empty-head walk below never redirects it, and
+ # resuming the parent id reloads the pre-compression transcript while
+ # the turns generated *after* compression (and their responses) sit in
+ # the continuation. ``get_compression_tip`` is lineage-aware: it only
+ # follows children whose parent ended with ``end_reason='compression'``
+ # (created after the parent was ended), so delegation / branch children
+ # never hijack the resume. This is the fix for the desktop "I came back
+ # and the reply isn't there" report on large sessions.
+ try:
+ tip = self.get_compression_tip(session_id)
+ except Exception:
+ tip = session_id
+ if tip and tip != session_id:
+ session_id = tip
+
with self._lock:
# If this session already has messages, nothing to redirect.
try:
diff --git a/tests/hermes_state/test_resolve_resume_session_id.py b/tests/hermes_state/test_resolve_resume_session_id.py
index ec637c6d2052..b4dd8717a2ed 100644
--- a/tests/hermes_state/test_resolve_resume_session_id.py
+++ b/tests/hermes_state/test_resolve_resume_session_id.py
@@ -83,6 +83,46 @@ def test_walks_from_middle_of_chain(db):
assert db.resolve_resume_session_id("c") == "d"
+def test_follows_compression_tip_when_parent_retains_messages(db):
+ # The bug behind the desktop "I came back and the reply isn't there" report
+ # on large sessions: auto-compression ends the live session and forks a
+ # continuation child, but a long parent keeps its own flushed message rows.
+ # The empty-head walk below never redirects a non-empty head, so resuming
+ # the parent id reloaded the pre-compression transcript and the response
+ # generated *after* compression (which lives in the continuation) was
+ # missing. resolve_resume_session_id must follow the compression-tip chain
+ # forward even when the parent still has messages.
+ base = int(time.time()) - 10_000
+ db.create_session("root", source="cli")
+ db.append_message("root", role="user", content="pre-compression turn")
+ db.end_session("root", "compression")
+ db.create_session("cont", source="cli", parent_session_id="root")
+ db.append_message("cont", role="assistant", content="post-compression reply")
+ # Force deterministic ordering so the continuation's started_at is clearly
+ # at/after the parent's ended_at (the get_compression_tip discriminator).
+ db._conn.execute("UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'root'", (base, base + 50))
+ db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont'", (base + 100,))
+ db._conn.commit()
+
+ assert db.resolve_resume_session_id("root") == "cont"
+
+
+def test_compression_tip_not_confused_with_delegation_child(db):
+ # A delegation/branch child is created while the parent is still live (the
+ # parent is NOT ended with end_reason='compression'), so resuming the
+ # parent must stay on the parent, not get hijacked into the subagent branch.
+ base = int(time.time()) - 10_000
+ db.create_session("conv", source="cli")
+ db.append_message("conv", role="user", content="parent turn")
+ db.create_session("subagent", source="cli", parent_session_id="conv")
+ db.append_message("subagent", role="assistant", content="delegated work")
+ db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'conv'", (base,))
+ db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'subagent'", (base + 100,))
+ db._conn.commit()
+
+ assert db.resolve_resume_session_id("conv") == "conv"
+
+
def test_prefers_most_recent_child_when_fork_exists(db):
# If a session was somehow forked (two children), pick the latest one.
# In practice, compression only produces single-chain shape, but the helper
diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py
index e04d07756a3d..6159dab0c166 100644
--- a/tests/test_tui_gateway_server.py
+++ b/tests/test_tui_gateway_server.py
@@ -954,6 +954,65 @@ def get_messages_as_conversation(self, target, include_ancestors=False):
assert captured["history_calls"] == [("tip", False), ("tip", True)]
+def test_session_resume_follows_compression_tip(monkeypatch, tmp_path):
+ """Resuming a rotated-out parent id must load the continuation's messages.
+
+ Regression for the desktop "I came back and the reply isn't there" report:
+ auto-compression ends the live session and forks a continuation child, so a
+ resume on the parent id (the desktop's routed id when the chat was opened
+ before it rotated) used to reload the pre-compression transcript and drop
+ the response generated after compression. session.resume must follow the
+ compression tip via resolve_resume_session_id.
+ """
+ from hermes_state import SessionDB
+
+ db = SessionDB(db_path=tmp_path / "state.db")
+ base = int(time.time()) - 10_000
+ db.create_session("parent_root", source="tui")
+ db.append_message("parent_root", role="user", content="pre-compression turn")
+ db.end_session("parent_root", "compression")
+ db.create_session("cont_tip", source="tui", parent_session_id="parent_root")
+ db.append_message("cont_tip", role="assistant", content="post-compression reply")
+ db._conn.execute(
+ "UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'parent_root'",
+ (base, base + 50),
+ )
+ db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont_tip'", (base + 100,))
+ db._conn.commit()
+
+ captured = {}
+
+ def fake_make_agent(sid, key, session_id=None, session_db=None, **kwargs):
+ captured["agent_session_id"] = session_id
+ return types.SimpleNamespace(model="test", provider="test")
+
+ monkeypatch.setattr(server, "_get_db", lambda: db)
+ monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
+ monkeypatch.setattr(server, "_set_session_context", lambda target: [])
+ monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
+ monkeypatch.setattr(server, "_make_agent", fake_make_agent)
+ monkeypatch.setattr(
+ server, "_session_info", lambda agent, *a: {"model": "test", "tools": {}, "skills": {}}
+ )
+ monkeypatch.setattr(
+ server, "_init_session", lambda sid, key, agent, history, cols=80, **_kwargs: None
+ )
+
+ try:
+ resp = server.handle_request(
+ {"id": "1", "method": "session.resume", "params": {"session_id": "parent_root"}}
+ )
+ finally:
+ db.close()
+
+ # The agent must bind to the continuation tip, and the returned transcript
+ # must include the post-compression reply (which lives only in the tip).
+ assert resp["result"]["session_key"] == "cont_tip"
+ assert captured["agent_session_id"] == "cont_tip"
+ texts = [m.get("text") for m in resp["result"]["messages"]]
+ assert "post-compression reply" in texts
+
+
def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
captured = {}
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index f13a4c5c7609..294e543c230f 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -4419,6 +4419,27 @@ def _(rid, params: dict) -> dict:
found = {}
else:
return _err(rid, 4007, "session not found")
+
+ # Follow the compression-continuation chain to the live tip so a resume on
+ # a rotated-out parent id binds to the descendant that actually holds the
+ # post-compression turns. Auto-compression ends the session and forks a
+ # continuation child; without this, resuming the original id (the desktop's
+ # routed id when the chat was opened before it rotated) reloads the parent
+ # transcript and the response generated after compression is missing — the
+ # "I came back and the reply isn't there" bug on large sessions. Resolving
+ # here also re-anchors the fast path below so a still-live rotated session
+ # is reused (by its new key) instead of rebuilding a duplicate agent on the
+ # stale parent. Skipped for lazy watch windows, which intentionally attach
+ # to the exact child branch they were opened on.
+ if found and not is_truthy_value(params.get("lazy", False)):
+ try:
+ tip = db.resolve_resume_session_id(target)
+ except Exception:
+ tip = target
+ if tip and tip != target:
+ target = tip
+ found = db.get_session(target) or found
+
profile_resume_cwd = str(found.get("cwd") or "").strip() or _profile_configured_cwd(
profile_home
)
From c23c370b8b9832a34b4d5d5c39fcdace08dd8f80 Mon Sep 17 00:00:00 2001
From: Brooklyn Nicholson
Date: Thu, 18 Jun 2026 16:04:58 -0500
Subject: [PATCH 08/28] test: narrow db._conn before raw SQL so ty stops
flagging None-union access
The new compression-tip tests poke started_at/ended_at directly via
db._conn to force deterministic lineage ordering. _conn is typed
Optional[Connection], so ty flagged .execute/.commit as unresolved on
None. Bind a local and assert it's non-None first to narrow the union.
---
.../test_resolve_resume_session_id.py | 16 ++++++++++------
tests/test_tui_gateway_server.py | 8 +++++---
2 files changed, 15 insertions(+), 9 deletions(-)
diff --git a/tests/hermes_state/test_resolve_resume_session_id.py b/tests/hermes_state/test_resolve_resume_session_id.py
index b4dd8717a2ed..ded2b8fdf534 100644
--- a/tests/hermes_state/test_resolve_resume_session_id.py
+++ b/tests/hermes_state/test_resolve_resume_session_id.py
@@ -100,9 +100,11 @@ def test_follows_compression_tip_when_parent_retains_messages(db):
db.append_message("cont", role="assistant", content="post-compression reply")
# Force deterministic ordering so the continuation's started_at is clearly
# at/after the parent's ended_at (the get_compression_tip discriminator).
- db._conn.execute("UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'root'", (base, base + 50))
- db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont'", (base + 100,))
- db._conn.commit()
+ conn = db._conn
+ assert conn is not None
+ conn.execute("UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'root'", (base, base + 50))
+ conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont'", (base + 100,))
+ conn.commit()
assert db.resolve_resume_session_id("root") == "cont"
@@ -116,9 +118,11 @@ def test_compression_tip_not_confused_with_delegation_child(db):
db.append_message("conv", role="user", content="parent turn")
db.create_session("subagent", source="cli", parent_session_id="conv")
db.append_message("subagent", role="assistant", content="delegated work")
- db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'conv'", (base,))
- db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'subagent'", (base + 100,))
- db._conn.commit()
+ conn = db._conn
+ assert conn is not None
+ conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'conv'", (base,))
+ conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'subagent'", (base + 100,))
+ conn.commit()
assert db.resolve_resume_session_id("conv") == "conv"
diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py
index 6159dab0c166..d2057c634cd3 100644
--- a/tests/test_tui_gateway_server.py
+++ b/tests/test_tui_gateway_server.py
@@ -973,12 +973,14 @@ def test_session_resume_follows_compression_tip(monkeypatch, tmp_path):
db.end_session("parent_root", "compression")
db.create_session("cont_tip", source="tui", parent_session_id="parent_root")
db.append_message("cont_tip", role="assistant", content="post-compression reply")
- db._conn.execute(
+ conn = db._conn
+ assert conn is not None
+ conn.execute(
"UPDATE sessions SET started_at = ?, ended_at = ? WHERE id = 'parent_root'",
(base, base + 50),
)
- db._conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont_tip'", (base + 100,))
- db._conn.commit()
+ conn.execute("UPDATE sessions SET started_at = ? WHERE id = 'cont_tip'", (base + 100,))
+ conn.commit()
captured = {}
From f8d8f045facce40351f8a34421764fe514c49c0b Mon Sep 17 00:00:00 2001
From: flooryyyy <67979730+flooryyyy@users.noreply.github.com>
Date: Mon, 15 Jun 2026 17:04:04 +0100
Subject: [PATCH 09/28] feat(kanban): auto-subscribe calling session on
kanban_create
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
When a worker calls kanban_create from inside a session that has a
persistent delivery channel, the originating session is now subscribed
to the new task's completion/block events automatically. The agent
that dispatched the task gets notified instead of having to poll.
- Gateway sessions (telegram/discord/slack): HERMES_SESSION_PLATFORM +
HERMES_SESSION_CHAT_ID ContextVars, set by the messaging gateway.
- TUI / desktop sessions: HERMES_SESSION_KEY in the subprocess env.
The TUI notification poller keys on platform='tui' + chat_id=.
- CLI / cron / test: no persistent channel, no subscription.
Gated by kanban.auto_subscribe_on_create in config.yaml (default True).
Disable to mirror pre-feature behaviour — users who want explicit
kanban_notify-subscribe calls per task can set it to false. This
config gate addresses the design concern that got PR #19718 reverted
upstream (unconditional implicit auto-subscribe on tool-driven
kanban_create was too aggressive for orchestrator users).
HERMES_SESSION_ID is intentionally not a fallback channel — it is
set by ACP/agent subprocess telemetry for every invocation, not just
TUI, so treating it as a notification target would auto-subscribe
every CLI session and re-introduce the over-eager behaviour.
The kanban_create response now includes a 'subscribed' bool so
orchestrators can react if subscription failed (e.g. by falling
back to explicit kanban_notify-subscribe or to polling).
Includes 6 tests covering the gateway / TUI / CLI / partial-context /
gated / add_notify_sub-failure paths. All 90 tests in
test_kanban_tools.py pass; 509 broader kanban tests pass.
---
hermes_cli/config.py | 15 ++
tests/tools/test_kanban_tools.py | 190 ++++++++++++++++++
tools/kanban_tools.py | 99 +++++++++
website/docs/user-guide/features/kanban.md | 1 +
.../current/user-guide/features/kanban.md | 1 +
5 files changed, 306 insertions(+)
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index 6db1130b5cd1..c6975d39fe6b 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -1271,6 +1271,21 @@ def _ensure_hermes_home_managed(home: Path):
# global threshold regardless.
},
+ # Kanban subsystem (orchestrator workers + dispatcher-driven child tasks).
+ # See tools/kanban_tools.py and hermes_cli/kanban_db.py for the actual
+ # implementations. Per-platform notification opt-out is handled by the
+ # kanban dashboard (see ``hermes dashboard`` -> Notifications).
+ "kanban": {
+ # Auto-subscribe the originating gateway/TUI session to task
+ # completion + block events when ``kanban_create`` is called from
+ # inside a session that has a persistent delivery channel. The
+ # agent that dispatched the task will get notified automatically
+ # instead of having to poll. Disable to mirror pre-feature
+ # behaviour — e.g. for a profile that prefers explicit
+ # ``kanban_notify-subscribe`` calls per task.
+ "auto_subscribe_on_create": True,
+ },
+
# Anthropic prompt caching (Claude via OpenRouter or native Anthropic API).
# cache_ttl must be "5m" or "1h" (Anthropic-supported tiers); other values are ignored.
"prompt_caching": {
diff --git a/tests/tools/test_kanban_tools.py b/tests/tools/test_kanban_tools.py
index 2bf89449905a..e9b41f812bb6 100644
--- a/tests/tools/test_kanban_tools.py
+++ b/tests/tools/test_kanban_tools.py
@@ -1812,3 +1812,193 @@ def test_board_param_in_all_schemas():
assert "board" not in schema["parameters"].get("required", []), (
f"{schema['name']} marks board as required; must be optional"
)
+
+
+# ---------------------------------------------------------------------------
+# kanban_create auto-subscribe behaviour
+#
+# When a worker calls kanban_create from inside a session that has a
+# persistent delivery channel, the originating session should be
+# subscribed to the new task's completion/block events automatically.
+# - Gateway sessions: HERMES_SESSION_PLATFORM + HERMES_SESSION_CHAT_ID set.
+# - TUI sessions: HERMES_SESSION_KEY (or HERMES_SESSION_ID) set, with
+# the platform/chat_id ContextVars intentionally empty.
+# - CLI / cron / test sessions: no delivery channel -> no subscription.
+# - Config gate kanban.auto_subscribe_on_create: false -> no subscription
+# even when the session has a delivery channel.
+# ---------------------------------------------------------------------------
+
+def _list_subs_for_task(task_id):
+ from hermes_cli import kanban_db as kb
+ conn = kb.connect()
+ try:
+ return list(kb.list_notify_subs(conn, task_id))
+ finally:
+ conn.close()
+
+
+def _sub_index(subs):
+ """Normalise a list of notify-subs (dicts or objects) into dicts
+ keyed by platform+chat_id, so assertions work regardless of the
+ return shape."""
+ out = []
+ for s in subs:
+ if isinstance(s, dict):
+ out.append(s)
+ else:
+ out.append({
+ "platform": getattr(s, "platform", None),
+ "chat_id": getattr(s, "chat_id", None),
+ "thread_id": getattr(s, "thread_id", None),
+ "user_id": getattr(s, "user_id", None),
+ })
+ return out
+
+
+def test_create_subscribes_gateway_session(monkeypatch, worker_env):
+ """A gateway session (platform + chat_id set) gets auto-subscribed
+ to its own kanban_create result, and the response surfaces the
+ ``subscribed`` flag so the orchestrator can react."""
+ from tools import kanban_tools as kt
+ monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram")
+ monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "chat-42")
+ monkeypatch.setenv("HERMES_SESSION_THREAD_ID", "thread-7")
+ monkeypatch.setenv("HERMES_SESSION_USER_ID", "user-9")
+
+ out = kt._handle_create({
+ "title": "auto-sub gateway",
+ "assignee": "peer",
+ })
+ d = json.loads(out)
+ assert d["ok"] is True
+ new_tid = d["task_id"]
+ assert d["subscribed"] is True, d
+
+ subs = _sub_index(_list_subs_for_task(new_tid))
+ assert len(subs) == 1
+ s = subs[0]
+ assert s["platform"] == "telegram"
+ assert s["chat_id"] == "chat-42"
+ assert s["thread_id"] == "thread-7"
+ assert s["user_id"] == "user-9"
+
+
+def test_create_subscribes_tui_session_via_session_key(monkeypatch, worker_env):
+ """TUI / desktop sessions don't have a platform/chat_id (single
+ local channel), but the parent process exports HERMES_SESSION_KEY.
+ We should still auto-subscribe, with platform='tui' and
+ chat_id=."""
+ from tools import kanban_tools as kt
+ monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
+ monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
+ monkeypatch.delenv("HERMES_SESSION_THREAD_ID", raising=False)
+ monkeypatch.delenv("HERMES_SESSION_USER_ID", raising=False)
+ monkeypatch.setenv("HERMES_SESSION_KEY", "tui-session-abc")
+ monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
+
+ out = kt._handle_create({
+ "title": "auto-sub tui",
+ "assignee": "peer",
+ })
+ d = json.loads(out)
+ assert d["ok"] is True
+ new_tid = d["task_id"]
+ assert d["subscribed"] is True, d
+
+ subs = _sub_index(_list_subs_for_task(new_tid))
+ assert len(subs) == 1
+ assert subs[0]["platform"] == "tui"
+ assert subs[0]["chat_id"] == "tui-session-abc"
+
+
+def test_create_does_not_subscribe_in_cli_session(monkeypatch, worker_env):
+ """CLI / cron / test sessions have no persistent delivery channel.
+ _maybe_auto_subscribe returns False and no row is written."""
+ from tools import kanban_tools as kt
+ monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False)
+ monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
+ monkeypatch.delenv("HERMES_SESSION_KEY", raising=False)
+ monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
+
+ out = kt._handle_create({
+ "title": "no sub cli",
+ "assignee": "peer",
+ })
+ d = json.loads(out)
+ assert d["ok"] is True
+ assert d["subscribed"] is False, d
+
+ assert _list_subs_for_task(d["task_id"]) == []
+
+
+def test_create_respects_auto_subscribe_on_create_false(monkeypatch, worker_env, tmp_path):
+ """The config gate kanban.auto_subscribe_on_create=false must
+ suppress auto-subscription even when the session has a delivery
+ channel. This is the knob that addresses the upstream design
+ concern from PR #19718 (reverted in #19721) — users who want
+ explicit kanban_notify-subscribe calls per task get that."""
+ # worker_env already created /.hermes; use a fresh sibling
+ # home to avoid mkdir() colliding with the worker's directory.
+ home = tmp_path / "gate-home" / ".hermes"
+ home.mkdir(parents=True)
+ (home / "config.yaml").write_text(
+ "kanban:\n auto_subscribe_on_create: false\n"
+ )
+ monkeypatch.setenv("HERMES_HOME", str(home))
+ monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord")
+ monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "channel-1")
+
+ from tools import kanban_tools as kt
+ out = kt._handle_create({
+ "title": "no sub gated",
+ "assignee": "peer",
+ })
+ d = json.loads(out)
+ assert d["ok"] is True
+ assert d["subscribed"] is False, d
+
+ assert _list_subs_for_task(d["task_id"]) == []
+
+
+def test_create_partial_session_context_no_subscribe(monkeypatch, worker_env):
+ """Only one of (platform, chat_id) set -> no implicit subscribe.
+ Either both are set (gateway) or neither (TUI / CLI); partial is
+ ambiguous and the safe default is to skip."""
+ from tools import kanban_tools as kt
+ monkeypatch.setenv("HERMES_SESSION_PLATFORM", "slack")
+ monkeypatch.delenv("HERMES_SESSION_CHAT_ID", raising=False)
+ monkeypatch.delenv("HERMES_SESSION_KEY", raising=False)
+ monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
+
+ out = kt._handle_create({
+ "title": "no sub partial",
+ "assignee": "peer",
+ })
+ d = json.loads(out)
+ assert d["ok"] is True
+ assert d["subscribed"] is False, d
+
+
+def test_maybe_auto_subscribe_swallows_add_notify_sub_failure(monkeypatch, worker_env):
+ """If add_notify_sub itself raises (e.g. DB locked, schema drift),
+ _maybe_auto_subscribe must NOT bubble that up and fail the parent
+ kanban_create. The function returns False and the parent create
+ still succeeds with subscribed=False."""
+ from tools import kanban_tools as kt
+ monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram")
+ monkeypatch.setenv("HERMES_SESSION_CHAT_ID", "chat-42")
+
+ from hermes_cli import kanban_db as kb
+
+ def _boom(*a, **kw):
+ raise RuntimeError("simulated DB failure")
+
+ monkeypatch.setattr(kb, "add_notify_sub", _boom)
+
+ out = kt._handle_create({
+ "title": "auto-sub tolerates add_notify_sub failure",
+ "assignee": "peer",
+ })
+ d = json.loads(out)
+ assert d["ok"] is True, d
+ assert d["subscribed"] is False, d
diff --git a/tools/kanban_tools.py b/tools/kanban_tools.py
index 67157dfc1c62..15988bcba897 100644
--- a/tools/kanban_tools.py
+++ b/tools/kanban_tools.py
@@ -34,6 +34,7 @@
from typing import Any, Optional
from tools.registry import registry, tool_error
+from hermes_cli.config import cfg_get, load_config
logger = logging.getLogger(__name__)
@@ -818,9 +819,11 @@ def _handle_create(args: dict, **kw) -> str:
session_id=session_id,
)
new_task = kb.get_task(conn, new_tid)
+ subscribed = _maybe_auto_subscribe(conn, new_tid)
return _ok(
task_id=new_tid,
status=new_task.status if new_task else None,
+ subscribed=subscribed,
)
finally:
conn.close()
@@ -831,6 +834,102 @@ def _handle_create(args: dict, **kw) -> str:
return tool_error(f"kanban_create: {e}")
+def _maybe_auto_subscribe(conn: Any, task_id: str) -> bool:
+ """Auto-subscribe the calling session to task completion / block events.
+
+ Returns True if a subscription row was written, False otherwise (no
+ session context, config gate disabled, or best-effort failure). The
+ caller surfaces this in the ``subscribed`` field of the kanban_create
+ response so an orchestrator can decide whether to fall back to an
+ explicit ``kanban_notify-subscribe`` or to polling.
+
+ Gated by ``kanban.auto_subscribe_on_create`` in config.yaml (default
+ True). Disable to mirror pre-feature behaviour, e.g. when the
+ originating user/chat opted out via the per-platform notification
+ toggle (see ``hermes dashboard``).
+
+ Subscription paths:
+
+ - **Gateway** (telegram/discord/slack/etc): ``HERMES_SESSION_PLATFORM``
+ and ``HERMES_SESSION_CHAT_ID`` are set in ContextVars by the
+ messaging gateway before agent dispatch. The notification poller
+ already keys off these, so we just register a row.
+
+ - **TUI** (herm desktop / herm TUI): the platform/chat_id ContextVars
+ are intentionally cleared (TUI is a single-channel local UI, not
+ a multi-tenant chat surface), but the agent subprocess inherits
+ ``HERMES_SESSION_KEY`` from the parent session. We subscribe with
+ ``platform="tui"`` and ``chat_id=``; the TUI notification
+ poller (``tui_gateway/server.py``) reads ``kanban_notify_subs``
+ for these rows and posts the completion message into the running
+ session.
+
+ - **CLI / cron / test / unattached**: no persistent delivery channel,
+ no-op.
+
+ Failure mode: any exception inside the function is logged at WARNING
+ with the offending exception + diagnostic env vars and swallowed.
+ We never want a notification bookkeeping failure to fail the
+ kanban_create that the agent is mid-conversation about.
+ """
+ try:
+ cfg = load_config()
+ if not cfg_get(cfg, "kanban", "auto_subscribe_on_create", default=True):
+ return False
+ except Exception:
+ # If config can't load we still default to True — this is the
+ # user-friendly behaviour that mirrors the pre-gate implementation.
+ pass
+
+ platform = ""
+ chat_id = ""
+ try:
+ from gateway.session_context import get_session_env
+ platform = get_session_env("HERMES_SESSION_PLATFORM", "")
+ chat_id = get_session_env("HERMES_SESSION_CHAT_ID", "")
+ if not platform or not chat_id:
+ # TUI / desktop fallback: platform/chat_id ContextVars are
+ # cleared for TUI sessions, but the parent process exports
+ # HERMES_SESSION_KEY into the subprocess env. Treat that
+ # as a "tui" subscription so the TUI notification poller
+ # (tui_gateway/server.py) can pick it up.
+ #
+ # HERMES_SESSION_ID is intentionally NOT a fallback here:
+ # it is set by ACP / the agent subprocess for telemetry
+ # regardless of whether the parent is a TUI or a CLI, so
+ # treating it as a notification target would auto-subscribe
+ # every CLI invocation, which is exactly the over-eager
+ # behaviour that got #19718 reverted upstream. The TUI
+ # poller keys on HERMES_SESSION_KEY.
+ session_key = (
+ get_session_env("HERMES_SESSION_KEY", "")
+ or os.environ.get("HERMES_SESSION_KEY", "")
+ )
+ if not session_key:
+ return False # CLI / cron / test — no persistent channel
+ platform = "tui"
+ chat_id = session_key
+ thread_id = get_session_env("HERMES_SESSION_THREAD_ID", "") or None
+ user_id = get_session_env("HERMES_SESSION_USER_ID", "") or None
+ notifier_profile = os.environ.get("HERMES_PROFILE")
+
+ # Lazy-import to keep the module-level dependency light
+ from hermes_cli import kanban_db as _kb
+ _kb.add_notify_sub(
+ conn, task_id=task_id,
+ platform=platform, chat_id=chat_id,
+ thread_id=thread_id, user_id=user_id,
+ notifier_profile=notifier_profile,
+ )
+ return True
+ except Exception as _exc:
+ logger.warning(
+ "_maybe_auto_subscribe failed: %r (platform=%r key_set=%r)",
+ _exc, platform, bool(chat_id),
+ )
+ return False
+
+
def _handle_unblock(args: dict, **kw) -> str:
"""Transition a blocked task back to ready."""
guard = _require_orchestrator_tool("kanban_unblock")
diff --git a/website/docs/user-guide/features/kanban.md b/website/docs/user-guide/features/kanban.md
index d59438a7171e..66a1ac0be908 100644
--- a/website/docs/user-guide/features/kanban.md
+++ b/website/docs/user-guide/features/kanban.md
@@ -535,6 +535,7 @@ Config knobs (all under `kanban:` in `~/.hermes/config.yaml`):
| `auto_decompose_per_tick` | `3` | Cap on decompositions per dispatcher tick. Excess defers to the next tick. |
| `orchestrator_profile` | `""` | Profile assigned to the root/orchestration task after decomposition. Empty = fall back to active default profile. |
| `default_assignee` | `""` | Where a child task lands when the LLM picks an unknown profile. Empty = fall back to active default. |
+| `auto_subscribe_on_create` | `true` | When a worker calls `kanban_create` from inside a session with a persistent delivery channel (messaging gateway or TUI), the originating session is auto-subscribed to the new task's completion/block events. The dispatcher still drives the delivery — this only changes whether the caller's chat/key shows up in the notify-sub table. Set to `false` to require explicit `kanban_notify-subscribe` calls per task. |
And the two auxiliary LLM slots:
diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md
index 3c5878c089ab..febeb213c7ba 100644
--- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md
+++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/kanban.md
@@ -431,6 +431,7 @@ hermes dashboard # 导航栏中出现 "Kanban" 标签页,位于 "Skills
| `auto_decompose_per_tick` | `3` | 每个调度器 tick 的分解上限。超出部分推迟到下一个 tick。 |
| `orchestrator_profile` | `""` | 拥有分解权的配置文件。空 = 回退到活动默认配置文件。 |
| `default_assignee` | `""` | LLM 选择未知配置文件时子任务的落地位置。空 = 回退到活动默认配置文件。 |
+| `auto_subscribe_on_create` | `true` | 当 worker 在具有持久投递通道的会话(消息网关或 TUI)内调用 `kanban_create` 时,原始会话会自动订阅新任务的完成/阻塞事件。调度器仍负责驱动投递 —— 此设置只决定调用者的聊天/密钥是否出现在通知订阅表中。设为 `false` 则要求对每个任务显式调用 `kanban_notify-subscribe`。 |
以及两个辅助 LLM 槽:
From 2944b3c394e3fe56cadbadd073a7fa54b24f3ba5 Mon Sep 17 00:00:00 2001
From: brooklyn!
Date: Thu, 18 Jun 2026 16:16:06 -0500
Subject: [PATCH 10/28] fix(desktop): make session delete idempotent and
id-resolving (#48641)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
DELETE /api/sessions/{id} was the only session endpoint that didn't
resolve the id (detail, messages, rename, export all call
resolve_session_id) and 404'd when the row was already gone. The desktop
optimistically removes the sidebar row, then RESTORES it and shows the
error on any failure — so deleting a session that had just been reaped
(empty-session hygiene) or removed by a concurrent client resurrected a
ghost row and surfaced "session not found". /goal + auto-compression churn
leaves transient empty rows that race the sidebar snapshot, which is the
exact "I deleted the empty one and got 'session not found'" report.
Resolve exact ids / unique prefixes, and treat an already-absent session
as an idempotent success — DELETE's contract is "ensure it's gone". This
mirrors the bulk-delete endpoint, which already treats ghost ids as
success.
Tests: deleting an absent id is idempotent (200, not 404); delete resolves
a unique prefix; a real session still deletes.
---
hermes_cli/web_server.py | 15 +++++-
tests/hermes_cli/test_web_server.py | 73 +++++++++++++++++++++++++++++
2 files changed, 86 insertions(+), 2 deletions(-)
diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py
index c8fe020fe151..9f451ddfd0ce 100644
--- a/hermes_cli/web_server.py
+++ b/hermes_cli/web_server.py
@@ -6886,8 +6886,19 @@ async def delete_session_endpoint(session_id: str, profile: Optional[str] = None
# desktop routes their DELETE to the remote backend. Omit for current/default.
db = _open_session_db_for_profile(profile)
try:
- if not db.delete_session(session_id):
- raise HTTPException(status_code=404, detail="Session not found")
+ # Resolve exact ids / unique prefixes like every other session endpoint
+ # (detail, messages, rename, export all do). A session that no longer
+ # exists is an idempotent success: DELETE's contract is "ensure it's
+ # gone", and the desktop optimistically removes the row then RESTORES it
+ # on any error — so a 404 on an already-absent row resurrected a ghost
+ # row and surfaced "session not found". /goal + auto-compression churn
+ # leaves transient empty rows (reaped by empty-session hygiene) that
+ # race the sidebar snapshot, which is exactly when this fired. Mirrors
+ # the bulk-delete endpoint, which already treats ghost ids as success.
+ sid = db.resolve_session_id(session_id)
+ if not sid:
+ return {"ok": True, "already_absent": True}
+ db.delete_session(sid)
return {"ok": True}
finally:
db.close()
diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py
index 8f6842b6b503..4312cc08152d 100644
--- a/tests/hermes_cli/test_web_server.py
+++ b/tests/hermes_cli/test_web_server.py
@@ -4334,6 +4334,79 @@ def test_component_styles_accepts_numeric_values(self):
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
+class TestDeleteSessionEndpoint:
+ """Tests for ``DELETE /api/sessions/{session_id}`` — the single-row delete
+ behind the desktop sidebar's per-session delete.
+
+ The desktop optimistically removes the row, then RESTORES it on any error
+ and surfaces the message. So a 404 on a row that is already gone (reaped by
+ empty-session hygiene, or removed by a concurrent client — both common amid
+ /goal + auto-compression churn that leaves transient empty rows) resurrected
+ a ghost row and showed "session not found". DELETE must be idempotent and
+ resolve ids like every other session endpoint.
+ """
+
+ @pytest.fixture(autouse=True)
+ def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
+ try:
+ from starlette.testclient import TestClient
+ except ImportError:
+ pytest.skip("fastapi/starlette not installed")
+
+ import hermes_state
+ from hermes_constants import get_hermes_home
+ from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
+
+ monkeypatch.setattr(
+ hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
+ )
+
+ self.auth_client = TestClient(app)
+ self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
+
+ def _seed(self, ids):
+ from hermes_state import SessionDB
+
+ db = SessionDB()
+ try:
+ for sid in ids:
+ db.create_session(session_id=sid, source="cli")
+ finally:
+ db.close()
+
+ def _exists(self, sid) -> bool:
+ from hermes_state import SessionDB
+
+ db = SessionDB()
+ try:
+ return db.get_session(sid) is not None
+ finally:
+ db.close()
+
+ def test_delete_existing_session(self):
+ self._seed(["real_one"])
+ resp = self.auth_client.delete("/api/sessions/real_one")
+ assert resp.status_code == 200
+ assert resp.json().get("ok") is True
+ assert not self._exists("real_one")
+
+ def test_delete_absent_session_is_idempotent(self):
+ # PREMISE / regression: deleting a row that no longer exists must NOT
+ # 404 — the desktop would resurrect the ghost row and show
+ # "session not found". DELETE's contract is "ensure it's gone".
+ resp = self.auth_client.delete("/api/sessions/never_existed")
+ assert resp.status_code == 200
+ assert resp.json().get("ok") is True
+
+ def test_delete_resolves_unique_prefix(self):
+ # Symmetry with the other session endpoints, which all resolve ids.
+ self._seed(["20260618_abcdef_unique"])
+ resp = self.auth_client.delete("/api/sessions/20260618_abcdef")
+ assert resp.status_code == 200
+ assert resp.json().get("ok") is True
+ assert not self._exists("20260618_abcdef_unique")
+
+
class TestBulkDeleteSessionsEndpoint:
"""Tests for ``POST /api/sessions/bulk-delete`` — backs the
dashboard's "Delete N selected" flow on the sessions page.
From 3ead2bdd0d92083dc11fc49f9260183c2a0d79cd Mon Sep 17 00:00:00 2001
From: Victor Kyriazakos
Date: Thu, 18 Jun 2026 19:19:48 +0300
Subject: [PATCH 11/28] feat(prompt): configurable per-platform system-prompt
hint overrides
Add platform_hints config so an admin can append to or replace Hermes'
built-in platform hint for a single messaging platform (WhatsApp, Slack,
Telegram, ...) without affecting other platforms. Enables enterprise
managed profiles to steer platform-aware skills (e.g. invoke a custom
table-formatting skill on WhatsApp where Markdown tables don't render)
while leaving Telegram/Slack/CLI behavior unchanged.
- hermes_cli/config.py: document platform_hints in DEFAULT_CONFIG
- agent/agent_init.py: load platform_hints -> agent._platform_hint_overrides
- agent/system_prompt.py: _resolve_platform_hint() applies append/replace
(replace wins; bare string = append shorthand); defensive on bad config
- tests: 16 cases covering append/replace/shorthand/isolation/malformed
Override only affects the platform-hint segment of the system prompt;
SOUL/context/memory tiers and general instructions are unchanged.
---
agent/agent_init.py | 17 ++++
agent/system_prompt.py | 60 +++++++++++-
hermes_cli/config.py | 16 ++++
scripts/release.py | 1 +
tests/agent/test_platform_hint_overrides.py | 101 ++++++++++++++++++++
5 files changed, 193 insertions(+), 2 deletions(-)
create mode 100644 tests/agent/test_platform_hint_overrides.py
diff --git a/agent/agent_init.py b/agent/agent_init.py
index 4210b515f65a..555f930f559d 100644
--- a/agent/agent_init.py
+++ b/agent/agent_init.py
@@ -1239,6 +1239,23 @@ def init_agent(
# are noisy.
agent._environment_probe = bool(_agent_section.get("environment_probe", True))
+ # Per-platform prompt-hint overrides (config.yaml → platform_hints).
+ # Lets an enterprise admin append to or replace Hermes' built-in
+ # platform hint for a single messaging platform (e.g. WhatsApp) without
+ # affecting other platforms. Shape:
+ # platform_hints:
+ # whatsapp:
+ # append: "When tabular output would help, invoke the ... skill."
+ # slack:
+ # replace: "Custom Slack hint that fully replaces the default."
+ # Stored verbatim; resolution happens in agent/system_prompt.py against
+ # the active platform. Invalid shapes are ignored defensively so a bad
+ # config entry can never break prompt assembly.
+ _platform_hints_cfg = _agent_cfg.get("platform_hints", {})
+ if not isinstance(_platform_hints_cfg, dict):
+ _platform_hints_cfg = {}
+ agent._platform_hint_overrides = _platform_hints_cfg
+
# App-level API retry count (wraps each model API call). Default 3,
# overridable via agent.api_max_retries in config.yaml. See #11616.
try:
diff --git a/agent/system_prompt.py b/agent/system_prompt.py
index 281f01399b42..d8eaea4e39ef 100644
--- a/agent/system_prompt.py
+++ b/agent/system_prompt.py
@@ -61,6 +61,55 @@ def _ra():
return run_agent
+def _resolve_platform_hint(agent: Any, platform_key: str, default_hint: str) -> str:
+ """Apply a per-platform prompt-hint override to the default hint.
+
+ Reads ``agent._platform_hint_overrides`` (populated from
+ ``config.yaml`` ``platform_hints`` by ``agent_init``) and resolves the
+ effective hint for *platform_key*:
+
+ * ``replace`` — substitute the default hint entirely.
+ * ``append`` — keep the default and append the extra text.
+ * a bare string value — treated as ``append`` (convenience shorthand).
+
+ Precedence: ``replace`` wins over ``append`` if both are present.
+ Override text is added on top of (not instead of) the SOUL/context/
+ memory tiers — it only affects the platform-hint segment, so other
+ platforms are unaffected and general system instructions still apply.
+
+ Defensive: any malformed entry falls back to the unmodified default so
+ a bad config value can never break prompt assembly or leak across
+ platforms.
+ """
+ if not platform_key:
+ return default_hint
+ overrides = getattr(agent, "_platform_hint_overrides", None)
+ if not isinstance(overrides, dict) or not overrides:
+ return default_hint
+ spec = overrides.get(platform_key)
+ if spec is None:
+ return default_hint
+
+ # Shorthand: a bare string is treated as append text.
+ if isinstance(spec, str):
+ extra = spec.strip()
+ return f"{default_hint}\n\n{extra}".strip() if extra else default_hint
+
+ if not isinstance(spec, dict):
+ return default_hint
+
+ replace_text = spec.get("replace")
+ if isinstance(replace_text, str) and replace_text.strip():
+ base = replace_text.strip()
+ else:
+ base = default_hint
+
+ append_text = spec.get("append")
+ if isinstance(append_text, str) and append_text.strip():
+ return f"{base}\n\n{append_text.strip()}".strip()
+ return base
+
+
def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None) -> Dict[str, str]:
"""Assemble the system prompt as three ordered parts.
@@ -331,18 +380,25 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
)
platform_key = (agent.platform or "").lower().strip()
+ # Resolve the built-in/plugin default hint for this platform, then apply
+ # any per-platform override from config (platform_hints.).
+ _default_hint = ""
if platform_key in PLATFORM_HINTS:
- stable_parts.append(PLATFORM_HINTS[platform_key])
+ _default_hint = PLATFORM_HINTS[platform_key]
elif platform_key:
# Check plugin registry for platform-specific LLM guidance
try:
from gateway.platform_registry import platform_registry
_entry = platform_registry.get(platform_key)
if _entry and _entry.platform_hint:
- stable_parts.append(_entry.platform_hint)
+ _default_hint = _entry.platform_hint
except Exception:
pass
+ _effective_hint = _resolve_platform_hint(agent, platform_key, _default_hint)
+ if _effective_hint:
+ stable_parts.append(_effective_hint)
+
# ── Context tier (cwd-dependent, may change between sessions) ─
context_parts: List[str] = []
diff --git a/hermes_cli/config.py b/hermes_cli/config.py
index c6975d39fe6b..f698c11d5ac9 100644
--- a/hermes_cli/config.py
+++ b/hermes_cli/config.py
@@ -2170,6 +2170,22 @@ def _ensure_hermes_home_managed(home: Path):
# User-defined quick commands that bypass the agent loop (type: exec only)
"quick_commands": {},
+ # Per-platform system-prompt hint overrides. Lets an admin append to or
+ # replace Hermes' built-in platform hint for a single messaging platform
+ # (WhatsApp, Slack, Telegram, ...) without affecting other platforms.
+ # Useful for enterprise/managed profiles that ship platform-aware skills.
+ # Each key is a platform name; the value is either:
+ # { "append": "extra text" } — keep the default hint, append text
+ # { "replace": "full text" } — substitute the default hint entirely
+ # "extra text" — shorthand for { "append": ... }
+ # `replace` wins over `append` if both are given. Example:
+ # platform_hints:
+ # whatsapp:
+ # append: >
+ # When tabular output would be useful, invoke the
+ # table_formatting skill instead of emitting a Markdown table.
+ "platform_hints": {},
+
# Shell-script hooks — declarative bridge that invokes shell scripts
# on plugin-hook events (pre_tool_call, post_tool_call, pre_llm_call,
# subagent_stop, etc.). Each entry maps an event name to a list of
diff --git a/scripts/release.py b/scripts/release.py
index 79ecf36382ad..6f56a14154d5 100755
--- a/scripts/release.py
+++ b/scripts/release.py
@@ -45,6 +45,7 @@
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
+ "victor@rocketfueldev.com": "victor-kyriazakos",
"286497132+srojk34@users.noreply.github.com": "srojk34",
"59806492+sitkarev@users.noreply.github.com": "sitkarev",
"zheng@omegasys.eu": "omegazheng",
diff --git a/tests/agent/test_platform_hint_overrides.py b/tests/agent/test_platform_hint_overrides.py
new file mode 100644
index 000000000000..fe34669ee03f
--- /dev/null
+++ b/tests/agent/test_platform_hint_overrides.py
@@ -0,0 +1,101 @@
+"""Tests for per-platform prompt-hint overrides (config.yaml → platform_hints).
+
+Covers agent/system_prompt.py::_resolve_platform_hint — the resolver that
+applies append/replace overrides to a platform's default hint. Feature added
+for enterprise managed profiles (per-platform behavior without affecting other
+platforms). See HA Core ticket: configurable per-platform prompt hints.
+"""
+
+import types
+
+from agent.system_prompt import _resolve_platform_hint
+
+
+def _agent(overrides):
+ """Minimal stand-in carrying just the override attribute the resolver reads."""
+ a = types.SimpleNamespace()
+ a._platform_hint_overrides = overrides
+ return a
+
+
+DEFAULT = "You are on WhatsApp. Do not use markdown."
+EXTRA = "When tabular output would help, invoke the table_formatting skill."
+
+
+class TestResolvePlatformHint:
+ def test_no_overrides_returns_default(self):
+ assert _resolve_platform_hint(_agent({}), "whatsapp", DEFAULT) == DEFAULT
+
+ def test_missing_attr_returns_default(self):
+ a = types.SimpleNamespace() # no _platform_hint_overrides at all
+ assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
+
+ def test_platform_not_in_overrides_returns_default(self):
+ a = _agent({"slack": {"append": "x"}})
+ assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
+
+ def test_append_dict(self):
+ a = _agent({"whatsapp": {"append": EXTRA}})
+ out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
+ assert out == f"{DEFAULT}\n\n{EXTRA}"
+ assert DEFAULT in out and EXTRA in out
+
+ def test_replace_dict(self):
+ a = _agent({"whatsapp": {"replace": EXTRA}})
+ out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
+ assert out == EXTRA
+ assert DEFAULT not in out
+
+ def test_replace_wins_over_append_but_both_applied(self):
+ a = _agent({"whatsapp": {"replace": "BASE", "append": "TAIL"}})
+ out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
+ # replace substitutes the base, append still tacks on
+ assert out == "BASE\n\nTAIL"
+ assert DEFAULT not in out
+
+ def test_bare_string_is_append_shorthand(self):
+ a = _agent({"whatsapp": EXTRA})
+ out = _resolve_platform_hint(a, "whatsapp", DEFAULT)
+ assert out == f"{DEFAULT}\n\n{EXTRA}"
+
+ def test_other_platform_unaffected(self):
+ """An override for whatsapp must not change telegram's hint."""
+ a = _agent({"whatsapp": {"append": EXTRA}})
+ tg_default = "You are on Telegram. Markdown works."
+ assert _resolve_platform_hint(a, "telegram", tg_default) == tg_default
+
+ def test_empty_platform_key_returns_default(self):
+ a = _agent({"whatsapp": {"append": EXTRA}})
+ assert _resolve_platform_hint(a, "", DEFAULT) == DEFAULT
+
+ # --- defensive / malformed input: never break prompt assembly ---
+
+ def test_malformed_spec_list_returns_default(self):
+ a = _agent({"whatsapp": ["not", "valid"]})
+ assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
+
+ def test_overrides_not_a_dict_returns_default(self):
+ a = _agent(["nope"])
+ assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
+
+ def test_empty_append_string_returns_default(self):
+ a = _agent({"whatsapp": {"append": " "}})
+ assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
+
+ def test_empty_replace_falls_back_to_default_base(self):
+ a = _agent({"whatsapp": {"replace": " "}})
+ assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
+
+ def test_non_string_append_ignored(self):
+ a = _agent({"whatsapp": {"append": 123}})
+ assert _resolve_platform_hint(a, "whatsapp", DEFAULT) == DEFAULT
+
+ def test_replace_with_empty_default_hint(self):
+ """replace works even when the platform had no built-in default."""
+ a = _agent({"customplat": {"replace": "Custom hint."}})
+ assert _resolve_platform_hint(a, "customplat", "") == "Custom hint."
+
+ def test_append_with_empty_default_hint(self):
+ """append on a platform with no default just yields the extra text."""
+ a = _agent({"customplat": {"append": "Only this."}})
+ assert _resolve_platform_hint(a, "customplat", "") == "Only this."
From f1ff8459dbc1135f77c1d113607b80a3f75c81b2 Mon Sep 17 00:00:00 2001
From: teknium1 <127238744+teknium1@users.noreply.github.com>
Date: Thu, 18 Jun 2026 13:49:03 -0700
Subject: [PATCH 12/28] docs(prompt): document platform_hints config override
Adds a 'Customizing platform hints' section to the Prompt Assembly
developer guide covering the append/replace/shorthand shapes, the
defensive fallback, and the cache-stable lifecycle (stable tier,
resolved at build time).
---
.../docs/developer-guide/prompt-assembly.md | 37 +++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/website/docs/developer-guide/prompt-assembly.md b/website/docs/developer-guide/prompt-assembly.md
index d255c4a2e936..aabd08562423 100644
--- a/website/docs/developer-guide/prompt-assembly.md
+++ b/website/docs/developer-guide/prompt-assembly.md
@@ -116,6 +116,43 @@ You are a CLI AI Agent. Try not to use markdown but simple text
renderable inside a terminal.
```
+## Customizing platform hints
+
+The platform hint (Layer 10 above) is the per-surface guidance Hermes
+injects for Telegram, WhatsApp, Slack, CLI, and other platforms — for
+example "you are on a terminal, avoid Markdown." The built-in defaults
+live in `PLATFORM_HINTS` (`agent/system_prompt.py`); plugin-provided
+platforms supply theirs through the platform registry.
+
+An administrator can append to or replace a single platform's hint from
+`config.yaml` via the top-level `platform_hints` key, without touching
+any other platform:
+
+```yaml
+platform_hints:
+ whatsapp:
+ append: >
+ When tabular output would be useful, invoke the table_formatting
+ skill instead of emitting a Markdown table.
+ slack:
+ replace: "You are on Slack. Keep responses tight and avoid wide tables."
+ telegram: "Prefer short messages; split long answers." # shorthand = append
+```
+
+- `append` — keep the built-in hint and add the extra text after it.
+- `replace` — substitute the built-in hint entirely.
+- A bare string — shorthand for `append`.
+- `replace` wins over `append` when both are present.
+- A malformed entry is ignored defensively and falls back to the
+ unmodified default, so a bad config value can never break prompt
+ assembly or leak across platforms.
+
+The override is resolved when the system prompt is built (session start,
+and again on compaction since that rebuilds the prompt). It produces a
+byte-stable hint for a fixed config, so it lives in the **stable** tier
+alongside the built-in hint and does not break prompt caching — it is
+not a live mid-session mutation of a frozen prompt.
+
## How SOUL.md appears in the prompt
`SOUL.md` lives at `~/.hermes/SOUL.md` and serves as the agent's identity — the very first section of the system prompt. The loading logic in `prompt_builder.py` works as follows:
From 769f307042d22be2c092249c2d8d78f85fea8e37 Mon Sep 17 00:00:00 2001
From: ethernet
Date: Thu, 18 Jun 2026 17:41:58 -0400
Subject: [PATCH 13/28] fix(npm): lock react-simple-icons to 13.11.1
suppress annoying message about engines that's completely benign but
people seem to complain
---
apps/desktop/package.json | 2 +-
package-lock.json | 24 ++++++++++--------------
2 files changed, 11 insertions(+), 15 deletions(-)
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 70d35fb7bb08..c1d2290e4cb4 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -55,7 +55,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
- "@icons-pack/react-simple-icons": "^13.13.0",
+ "@icons-pack/react-simple-icons": "=13.11.1",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
diff --git a/package-lock.json b/package-lock.json
index 8f95ffeee809..77eafcbaaa15 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -69,7 +69,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hermes/shared": "file:../shared",
- "@icons-pack/react-simple-icons": "^13.13.0",
+ "@icons-pack/react-simple-icons": "=13.11.1",
"@nanostores/react": "^1.1.0",
"@nous-research/ui": "^0.13.0",
"@radix-ui/react-slot": "^1.2.4",
@@ -173,6 +173,15 @@
"global-agent": "^3.0.0"
}
},
+ "apps/desktop/node_modules/@icons-pack/react-simple-icons": {
+ "version": "13.11.1",
+ "resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.11.1.tgz",
+ "integrity": "sha512-WbwN/o7dUHEjDCJh2p3RvDZ4kZ8nhfUSkUSm0bWuPTXIsoKgDJpwD5UkMCG22R/5kZH6lHAZXwuHWsKNtX7fYA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.13 || ^17 || ^18 || ^19"
+ }
+ },
"apps/desktop/node_modules/@nous-research/ui": {
"version": "0.13.2",
"resolved": "https://registry.npmjs.org/@nous-research/ui/-/ui-0.13.2.tgz",
@@ -2285,19 +2294,6 @@
"import-meta-resolve": "^4.2.0"
}
},
- "node_modules/@icons-pack/react-simple-icons": {
- "version": "13.13.0",
- "resolved": "https://registry.npmjs.org/@icons-pack/react-simple-icons/-/react-simple-icons-13.13.0.tgz",
- "integrity": "sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g==",
- "license": "MIT",
- "engines": {
- "node": ">=24",
- "pnpm": ">=10"
- },
- "peerDependencies": {
- "react": "^16.13 || ^17 || ^18 || ^19"
- }
- },
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
From 03d9a95a74b234c2d46e0b59cf6e12281f93fbf5 Mon Sep 17 00:00:00 2001
From: Ben <62250174+benfrank241@users.noreply.github.com>
Date: Thu, 18 Jun 2026 17:48:47 -0400
Subject: [PATCH 14/28] fix(desktop): show Hindsight memory provider (#37546)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(desktop): show Hindsight memory provider
* feat(desktop): configure Hindsight memory provider
* fix(desktop): limit Hindsight modes to supported setup
* refactor(desktop): generic memory-provider config surface
Replace the bespoke Hindsight settings surface with a declarative,
schema-driven path so adding a memory provider is pure declaration —
no per-provider page, conditional, or endpoint.
- memory_providers.py: declarative registry. Each provider lists its
fields {key, label, kind, default, options, secret-vs-plain}. Hindsight's
mode is a select(cloud, local_external), so rejecting local_embedded
falls out of generic enum validation instead of a hand-written check.
- One generic endpoint pair GET/PUT /api/memory/providers/{name}/config.
GET returns declared fields + current values (secrets only as is_set,
never read back); PUT validates selects against their options, writes
plain fields to the provider config file, secrets to the env store,
and flips memory.provider.
- ProviderConfigPanel renders straight from the schema, replacing
hindsight-settings.tsx and the memory.provider === 'hindsight'
conditional in config-settings.tsx — same pattern as
toolset-config-panel.tsx off env_vars.
Scoped to memory providers; storage layout is unchanged so the runtime
Hindsight plugin reads the same config.json / HINDSIGHT_API_KEY / provider
keys as before. Tests cover the registry, endpoint behavior (defaults,
write+secret, select rejection, unknown provider, secret-never-returned),
and the generic panel.
---
.../src/app/settings/config-settings.tsx | 4 +
apps/desktop/src/app/settings/constants.ts | 2 +-
apps/desktop/src/app/settings/helpers.test.ts | 6 +
.../settings/provider-config-panel.test.tsx | 142 ++++++++++++++
.../app/settings/provider-config-panel.tsx | 182 ++++++++++++++++++
apps/desktop/src/hermes.ts | 19 ++
apps/desktop/src/types/hermes.ts | 25 +++
hermes_cli/memory_providers.py | 149 ++++++++++++++
hermes_cli/web_server.py | 163 ++++++++++++++++
tests/hermes_cli/test_memory_providers.py | 46 +++++
tests/hermes_cli/test_web_server.py | 105 +++++++++-
11 files changed, 841 insertions(+), 2 deletions(-)
create mode 100644 apps/desktop/src/app/settings/provider-config-panel.test.tsx
create mode 100644 apps/desktop/src/app/settings/provider-config-panel.tsx
create mode 100644 hermes_cli/memory_providers.py
create mode 100644 tests/hermes_cli/test_memory_providers.py
diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx
index 2d550560764a..771ba2836f4e 100644
--- a/apps/desktop/src/app/settings/config-settings.tsx
+++ b/apps/desktop/src/app/settings/config-settings.tsx
@@ -23,6 +23,7 @@ import { fieldCopyForSchemaKey } from './field-copy'
import { enumOptionsFor, getNested, prettyName, setNested } from './helpers'
import { ModelSettings } from './model-settings'
import { EmptyState, ListRow, LoadingState, SettingsContent } from './primitives'
+import { ProviderConfigPanel } from './provider-config-panel'
function ConfigField({
schemaKey,
@@ -368,6 +369,9 @@ export function ConfigSettings({
schemaKey={key}
value={getNested(config, key)}
/>
+ {key === 'memory.provider' && typeof getNested(config, key) === 'string' && getNested(config, key) ? (
+
+ ) : null}
))}
diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts
index 1cf7cf3ce165..5fc9ba134ccf 100644
--- a/apps/desktop/src/app/settings/constants.ts
+++ b/apps/desktop/src/app/settings/constants.ts
@@ -239,7 +239,7 @@ export const ENUM_OPTIONS: Record = {
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh'],
- 'memory.provider': ['', 'builtin', 'honcho'],
+ 'memory.provider': ['', 'builtin', 'hindsight', 'honcho'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts
index b65d63d3296b..1a8d0eba994f 100644
--- a/apps/desktop/src/app/settings/helpers.test.ts
+++ b/apps/desktop/src/app/settings/helpers.test.ts
@@ -6,6 +6,12 @@ import { defineFieldCopy, fieldCopyForSchemaKey, schemaKeyToFieldCopyKey } from
import { enumOptionsFor, getNested, providerGroup, setNested, stripToolsetLabel, toolsetDisplayLabel } from './helpers'
describe('settings helpers', () => {
+ it('lists Hindsight as a built-in desktop memory provider option', () => {
+ const options = enumOptionsFor('memory.provider', '', {})
+
+ expect(options).toContain('hindsight')
+ })
+
describe('defineFieldCopy', () => {
it('flattens nested field copy paths', () => {
const copy = defineFieldCopy({
diff --git a/apps/desktop/src/app/settings/provider-config-panel.test.tsx b/apps/desktop/src/app/settings/provider-config-panel.test.tsx
new file mode 100644
index 000000000000..3f3d98f1520f
--- /dev/null
+++ b/apps/desktop/src/app/settings/provider-config-panel.test.tsx
@@ -0,0 +1,142 @@
+import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import type { MemoryProviderConfig } from '@/types/hermes'
+
+const getMemoryProviderConfig = vi.fn()
+const saveMemoryProviderConfig = vi.fn()
+
+vi.mock('@/hermes', () => ({
+ getMemoryProviderConfig: (provider: string) => getMemoryProviderConfig(provider),
+ saveMemoryProviderConfig: (provider: string, values: unknown) => saveMemoryProviderConfig(provider, values)
+}))
+
+vi.mock('@/store/notifications', () => ({
+ notify: vi.fn(),
+ notifyError: vi.fn()
+}))
+
+function hindsightSchema(overrides: Partial[] = []): MemoryProviderConfig {
+ const fields: MemoryProviderConfig['fields'] = [
+ {
+ key: 'mode',
+ label: 'Mode',
+ kind: 'select',
+ value: 'cloud',
+ description: 'How Hermes connects to Hindsight.',
+ placeholder: '',
+ is_set: true,
+ options: [
+ { value: 'cloud', label: 'Cloud', description: 'Hindsight Cloud API (lightweight, just needs an API key)' },
+ { value: 'local_external', label: 'Local External', description: 'Connect to an existing Hindsight instance' }
+ ]
+ },
+ {
+ key: 'api_key',
+ label: 'API key',
+ kind: 'secret',
+ value: '',
+ description: 'Used to authenticate with the Hindsight API.',
+ placeholder: 'Enter Hindsight API key',
+ is_set: false,
+ options: []
+ },
+ {
+ key: 'api_url',
+ label: 'API URL',
+ kind: 'text',
+ value: 'https://api.hindsight.vectorize.io',
+ description: '',
+ placeholder: '',
+ is_set: true,
+ options: []
+ },
+ { key: 'bank_id', label: 'Bank ID', kind: 'text', value: 'hermes', description: '', placeholder: '', is_set: true, options: [] },
+ {
+ key: 'recall_budget',
+ label: 'Recall budget',
+ kind: 'select',
+ value: 'mid',
+ description: '',
+ placeholder: '',
+ is_set: true,
+ options: [
+ { value: 'low', label: 'low', description: '' },
+ { value: 'mid', label: 'mid', description: '' },
+ { value: 'high', label: 'high', description: '' }
+ ]
+ }
+ ]
+
+ return {
+ name: 'hindsight',
+ label: 'Hindsight',
+ fields: fields.map((field, index) => ({ ...field, ...overrides[index] }))
+ }
+}
+
+beforeEach(() => {
+ getMemoryProviderConfig.mockResolvedValue(hindsightSchema())
+ saveMemoryProviderConfig.mockResolvedValue({ ok: true })
+})
+
+afterEach(() => {
+ cleanup()
+ vi.clearAllMocks()
+})
+
+async function renderPanel(provider = 'hindsight') {
+ const { ProviderConfigPanel } = await import('./provider-config-panel')
+
+ return render()
+}
+
+describe('ProviderConfigPanel', () => {
+ it('renders the declared provider fields generically', async () => {
+ await renderPanel()
+
+ expect(await screen.findByDisplayValue('https://api.hindsight.vectorize.io')).toBeTruthy()
+ expect(screen.getByDisplayValue('hermes')).toBeTruthy()
+ expect(screen.getByText('Cloud')).toBeTruthy()
+ expect(screen.getAllByText('Hindsight Cloud API (lightweight, just needs an API key)').length).toBeGreaterThan(0)
+ expect(screen.getByText('mid')).toBeTruthy()
+ })
+
+ it('collapses and expands the fields', async () => {
+ await renderPanel()
+
+ expect(await screen.findByLabelText('API URL')).toBeTruthy()
+ fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
+ expect(screen.queryByLabelText('API URL')).toBeNull()
+ fireEvent.click(screen.getByRole('button', { name: /Hindsight settings/ }))
+ expect(await screen.findByLabelText('API URL')).toBeTruthy()
+ })
+
+ it('saves edited values without requiring a secret replacement', async () => {
+ await renderPanel()
+
+ const apiUrl = await screen.findByLabelText('API URL')
+ fireEvent.change(apiUrl, { target: { value: 'http://localhost:8888' } })
+ fireEvent.change(screen.getByLabelText('Bank ID'), { target: { value: 'ben-bank' } })
+ fireEvent.click(screen.getByRole('button', { name: 'Save' }))
+
+ await waitFor(() =>
+ expect(saveMemoryProviderConfig).toHaveBeenCalledWith('hindsight', {
+ mode: 'cloud',
+ api_key: '',
+ api_url: 'http://localhost:8888',
+ bank_id: 'ben-bank',
+ recall_budget: 'mid'
+ })
+ )
+ })
+
+ it('renders nothing for a provider with no declared config surface', async () => {
+ getMemoryProviderConfig.mockResolvedValue({ name: 'builtin', label: 'builtin', fields: [] })
+
+ const { container } = await renderPanel('builtin')
+
+ await waitFor(() => expect(getMemoryProviderConfig).toHaveBeenCalledWith('builtin'))
+ expect(container.querySelector('section')).toBeNull()
+ })
+})
diff --git a/apps/desktop/src/app/settings/provider-config-panel.tsx b/apps/desktop/src/app/settings/provider-config-panel.tsx
new file mode 100644
index 000000000000..d76c0eff2c5f
--- /dev/null
+++ b/apps/desktop/src/app/settings/provider-config-panel.tsx
@@ -0,0 +1,182 @@
+import { useCallback, useEffect, useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import { DisclosureCaret } from '@/components/ui/disclosure-caret'
+import { Input } from '@/components/ui/input'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
+import { Check, Loader2, Save } from '@/lib/icons'
+import { notify, notifyError } from '@/store/notifications'
+import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
+
+import { CONTROL_TEXT } from './constants'
+import { LoadingState, Pill } from './primitives'
+
+/** Seed editable values from the schema: non-secret fields keep their current
+ * value, secret fields start blank (their value is never returned). */
+function seedValues(config: MemoryProviderConfig): Record {
+ return Object.fromEntries(
+ config.fields.map(field => [field.key, field.kind === 'secret' ? '' : field.value])
+ )
+}
+
+function FieldControl({
+ field,
+ value,
+ onChange
+}: {
+ field: MemoryProviderField
+ value: string
+ onChange: (value: string) => void
+}) {
+ if (field.kind === 'select') {
+ const selected = field.options.find(option => option.value === value)
+
+ return (
+ <>
+
+ {(selected?.description || field.description) && (
+ {selected?.description || field.description}
+ )}
+ >
+ )
+ }
+
+ if (field.kind === 'secret') {
+ return (
+