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:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user