fix(honcho): don't let auto-generated session titles override sessionStrategy

Auto-generated display titles (LLM or derived) were passed to Honcho's
resolve_session_name() as authoritative, so a titled per-repo,
per-directory, or global session silently remapped onto a second Honcho
session named after the generated title. Only explicit /title commands
(user provenance) should act as an intentional session-name override.

Thread session_title_source from the session DB through
agent_init into the Honcho provider, and skip title-based remapping
when the source is 'derived' or 'llm'. Missing provenance keeps the
legacy explicit-title behavior for callers that predate source
threading. Gateway per-chat keys and per-session identity safeguards
are unchanged.

Adds regressions for titled per-repo, per-directory, and global
sessions at both the resolver and provider level.

Fixes #24740

(cherry picked from commit e7ba26ee15821baa382a397ce9ce9cd57a260188)
This commit is contained in:
outpoints
2026-08-23 19:04:30 -07:00
committed by Teknium
parent 3abeca16e6
commit 3cbdc32565
7 changed files with 188 additions and 14 deletions
+3
View File
@@ -1215,6 +1215,9 @@ def _memory_provider_init_kwargs(agent, platform) -> Dict[str, Any]:
_st = agent._session_db.get_session_title(agent.session_id)
if _st:
kwargs["session_title"] = _st
_source = agent._session_db.get_session_title_source(agent.session_id)
if _source:
kwargs["session_title_source"] = _source
# Gateway user/chat identity for per-user scoping (gateway_session_key: stable per-chat
# Honcho session isolation).
for _ident in _GATEWAY_IDENTITY_PARAMS:
+14 -8
View File
@@ -273,15 +273,21 @@ The Honcho session name determines which conversation bucket memory lands in. Re
| Priority | Source | Example session name |
|----------|--------|---------------------|
| 1 | Manual map (`sessions` config) | `"myproject-main"` |
| 2 | `/title` command (mid-session rename) | `"refactor-auth"` |
| 3 | Gateway session key (Telegram, Discord, etc.) | `"agent-main-telegram-dm-8439114563"` |
| 4 | `per-session` strategy | Hermes session ID (`20260415_a3f2b1`) |
| 1 | Gateway session key (Telegram, Discord, etc.) | `"agent-main-telegram-dm-8439114563"` |
| 2 | `per-session` strategy | Hermes session ID (`20260415_a3f2b1`) |
| 3 | Manual map (`sessions` config) | `"myproject-main"` |
| 4 | Explicit `/title` command (non-automatic title) | `"refactor-auth"` |
| 5 | `per-repo` strategy | Git root directory name (`hermes-agent`) |
| 6 | `per-directory` strategy | Current directory basename (`src`) |
| 7 | `global` strategy | Workspace name (`hermes`) |
| 6 | `per-directory` strategy | Directory basename (`my-project`) |
| 7 | `global` strategy | Workspace name |
Gateway platforms always resolve via priority 3 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting only affects CLI sessions.
Messaging gateway platforms always resolve via priority 1 (per-chat isolation) regardless of `sessionStrategy`. The strategy setting controls non-gateway sessions such as CLI and Desktop.
Directory strategies and manual mappings use the logical session workspace, not the backend process's launch directory. Desktop/TUI and ACP pass the workspace during agent construction; deferred Desktop/TUI builds use the same session cwd. With no non-empty construction cwd, Honcho uses the runtime resolver: session cwd context, scoped `terminal.cwd`, then the launch directory. No process-wide `chdir` is needed.
Automatically generated Hermes titles (`derived` or `llm`) are display metadata and do not override `sessionStrategy`. An explicit user title remains an intentional session-name override for non-gateway, non-`per-session` sessions.
Sessions created before title provenance was recorded retain legacy behavior: because an old automatic title cannot be distinguished from an old user title, a title with no source is treated as an explicit override.
If `sessionPeerPrefix` is `true`, the user peer name is prepended: `alice-hermes-agent`.
@@ -293,7 +299,7 @@ In bot mode another Hermes profile can DM this agent. The relay marks that turn
#### What each strategy produces
- **`per-directory`** — basename of `$PWD`. Opening hermes in `~/code/myapp` and `~/code/other` gives two separate sessions. Same directory = same session across runs.
- **`per-directory`** — basename of the logical session working directory. Opening Hermes in `~/code/myapp` and `~/code/other` gives two separate sessions. Same directory = same session across runs.
- **`per-repo`** — git root directory name. All subdirectories within a repo share one session. Falls back to `per-directory` if not inside a git repo.
- **`per-session`** — Hermes session ID (timestamp + hex). Every `hermes` invocation starts a fresh Honcho session. Falls back to `per-directory` if no session ID is available.
- **`global`** — workspace name. One session for everything. Memory accumulates across all directories and runs.
+1
View File
@@ -277,6 +277,7 @@ class HonchoMemoryProvider(DialecticMixin, MemoryProvider):
"""Resolve the Honcho session key without touching the network."""
return cfg.resolve_session_name(
session_title=kwargs.get("session_title"), session_id=session_id,
session_title_source=kwargs.get("session_title_source"),
gateway_session_key=kwargs.get("gateway_session_key"),
) or session_id or "hermes-default"
+4 -1
View File
@@ -488,6 +488,7 @@ class HonchoClientConfig:
def resolve_session_name(
self, cwd: str | None = None, session_title: str | None = None,
session_id: str | None = None, gateway_session_key: str | None = None,
session_title_source: str | None = None,
) -> str | None:
"""Resolve the Honcho session name; with ``session_ai_peer_prefix`` the result is prefixed
``{ai_peer}-`` on every path, including the AI-peer-agnostic gateway session key."""
@@ -523,7 +524,9 @@ class HonchoClientConfig:
manual = self.sessions.get(cwd)
if manual:
return manual
if session_title and _slug(session_title):
# Absent provenance retains the legacy explicit-title override. Generated
# display titles must not change a strategy-selected memory identity.
if session_title and session_title_source not in {"derived", "llm"} and _slug(session_title):
return self._with_peer_prefix(_slug(session_title))
if self.session_strategy == "per-repo":
return self._with_peer_prefix(self._git_repo_name(cwd) or Path(cwd).name)
+36
View File
@@ -99,6 +99,42 @@ class TestMemoryManagerUserIdThreading:
assert p2._init_kwargs.get("user_id") == "slack_U12345"
assert p2._init_kwargs.get("platform") == "slack"
def test_session_title_provenance_reaches_provider(self):
from run_agent import AIAgent
provider = RecordingProvider()
session_db = MagicMock()
session_db.get_session_title.return_value = "Generated title"
session_db.get_session_title_source.return_value = "llm"
with patch(
"run_agent.get_tool_definitions",
return_value=[],
), patch(
"run_agent.check_toolset_requirements",
return_value={},
), patch(
"run_agent.OpenAI",
), patch(
"hermes_cli.config.load_config_readonly",
return_value={"memory": {"provider": "recording"}},
), patch(
"plugins.memory.load_memory_provider",
return_value=provider,
):
agent = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
session_id="session-with-title",
session_db=session_db,
)
assert provider._init_kwargs["session_title"] == "Generated title"
assert provider._init_kwargs["session_title_source"] == "llm"
agent.close()
# ---------------------------------------------------------------------------
# Mem0 provider user_id tests
+128 -5
View File
@@ -13,6 +13,7 @@ import json
import logging
import threading
import time
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
@@ -129,11 +130,122 @@ class TestResolveSessionNameTitle:
result = cfg.resolve_session_name("/my/project", session_title="the-title")
assert result == "manual-name"
def test_title_beats_dirname(self):
cfg = HonchoClientConfig()
result = cfg.resolve_session_name("/some/dir", session_title="my-project")
@pytest.mark.parametrize("session_strategy", ["per-directory", "per-repo", "global"])
def test_explicit_title_beats_non_session_strategy(self, session_strategy):
cfg = HonchoClientConfig(session_strategy=session_strategy)
result = cfg.resolve_session_name(
"/some/dir",
session_title="my-project",
session_title_source="user",
)
assert result == "my-project"
def test_missing_title_source_preserves_legacy_title_override(self):
cfg = HonchoClientConfig(session_strategy="per-repo")
result = cfg.resolve_session_name(
"/some/dir",
session_title="legacy-title",
)
assert result == "legacy-title"
def test_title_source_parameter_preserves_legacy_positional_arguments(self):
cfg = HonchoClientConfig(session_strategy="per-session")
result = cfg.resolve_session_name(
"/some/dir",
None,
"session-id",
"agent:main:telegram:dm:42",
)
assert result == "agent-main-telegram-dm-42"
@pytest.mark.parametrize(
("session_strategy", "title_source", "expected"),
[
("per-directory", "llm", "dir"),
("per-directory", "derived", "dir"),
("per-repo", "llm", "repo-name"),
("per-repo", "derived", "repo-name"),
("global", "llm", "my-workspace"),
("global", "derived", "my-workspace"),
],
)
def test_automatic_title_does_not_override_strategy(
self,
session_strategy,
title_source,
expected,
):
cfg = HonchoClientConfig(
session_strategy=session_strategy,
workspace_id="my-workspace",
)
with patch.object(HonchoClientConfig, "_git_repo_name", return_value="repo-name"):
result = cfg.resolve_session_name(
"/some/dir",
session_title="generated-title",
session_title_source=title_source,
)
assert result == expected
def test_provider_preserves_strategy_for_automatic_title(self):
from plugins.memory.honcho import HonchoMemoryProvider
cfg = HonchoClientConfig(session_strategy="per-repo")
provider = HonchoMemoryProvider()
with patch.object(HonchoClientConfig, "_git_repo_name", return_value="repo-name"):
result = provider._resolve_session_key(
cfg,
"session-id",
session_title="generated-title",
session_title_source="llm",
)
assert result == "repo-name"
def test_provider_resolves_session_cwd_not_process_cwd(
self,
monkeypatch,
tmp_path,
):
"""Desktop/gateway backends launch from $HOME; Honcho routing must use
the agent's logical workspace, not the process cwd."""
import agent.runtime_cwd as runtime_cwd
from agent.runtime_cwd import set_session_cwd
from plugins.memory.honcho import HonchoMemoryProvider
process_cwd = tmp_path / "home"
terminal_cwd = tmp_path / "terminal"
project = tmp_path / "projects" / "myapp"
process_cwd.mkdir()
terminal_cwd.mkdir()
project.mkdir(parents=True)
monkeypatch.chdir(process_cwd)
monkeypatch.setenv("TERMINAL_CWD", str(terminal_cwd))
cfg = HonchoClientConfig(
session_strategy="per-repo",
sessions={
str(process_cwd): "process-cwd-fallback",
str(terminal_cwd): "terminal-cwd-fallback",
},
)
provider = HonchoMemoryProvider()
token = set_session_cwd(str(project))
try:
with patch.object(
HonchoClientConfig,
"_git_repo_name",
side_effect=lambda cwd: Path(cwd).name,
) as repo_name:
result = provider._resolve_session_key(cfg, "session-id")
finally:
runtime_cwd._SESSION_CWD.reset(token)
repo_name.assert_called_once_with(str(project))
assert result == project.name
def test_title_sanitized(self):
cfg = HonchoClientConfig()
@@ -154,14 +266,25 @@ class TestResolveSessionNameTitle:
def test_per_session_uses_session_id(self):
cfg = HonchoClientConfig(session_strategy="per-session")
result = cfg.resolve_session_name("/some/dir", session_id="20260309_175514_9797dd")
result = cfg.resolve_session_name(
"/some/dir",
session_title="generated-title",
session_title_source="llm",
session_id="20260309_175514_9797dd",
)
assert result == "20260309_175514_9797dd"
def test_gateway_key_beats_per_session_id(self):
# Gateways keep per-chat isolation even in per-session.
cfg = HonchoClientConfig(session_strategy="per-session")
result = cfg.resolve_session_name("/some/dir", gateway_session_key="agent:main:telegram:dm:42", session_id="20260309_175514_9797dd")
result = cfg.resolve_session_name(
"/some/dir",
session_title="explicit-title",
session_title_source="user",
gateway_session_key="agent:main:telegram:dm:42",
session_id="20260309_175514_9797dd",
)
assert result == "agent-main-telegram-dm-42"
def test_global_strategy_returns_workspace(self):
@@ -140,6 +140,8 @@ When pointing Hermes at a self-hosted Honcho server, `hermes honcho setup` (and
- `per-repo` — one session per git repository.
- `global` — single session across all directories.
Automatically generated Hermes titles are display metadata and do not override `sessionStrategy`. An explicit user `/title` remains an intentional session-name override for non-`per-session` CLI sessions.
**Recall mode** controls how memory flows into conversations:
- `hybrid` — context auto-injected into system prompt AND tools available (model decides when to query).
- `context` — auto-injection only, tools hidden.