diff --git a/agent/agent_init.py b/agent/agent_init.py index bd9b9478a6..890f25b405 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -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: diff --git a/plugins/memory/honcho/README.md b/plugins/memory/honcho/README.md index 341540dd60..1115b2f11c 100644 --- a/plugins/memory/honcho/README.md +++ b/plugins/memory/honcho/README.md @@ -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. diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 0b966be1c7..62fe8ce52f 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -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" diff --git a/plugins/memory/honcho/client.py b/plugins/memory/honcho/client.py index 5765425451..401b7ed18b 100644 --- a/plugins/memory/honcho/client.py +++ b/plugins/memory/honcho/client.py @@ -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) diff --git a/tests/agent/test_memory_user_id.py b/tests/agent/test_memory_user_id.py index b7ea2d63d1..b688a08f14 100644 --- a/tests/agent/test_memory_user_id.py +++ b/tests/agent/test_memory_user_id.py @@ -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 diff --git a/tests/honcho_plugin/test_async_memory.py b/tests/honcho_plugin/test_async_memory.py index e779166573..cb7129a164 100644 --- a/tests/honcho_plugin/test_async_memory.py +++ b/tests/honcho_plugin/test_async_memory.py @@ -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): diff --git a/website/docs/user-guide/features/honcho.md b/website/docs/user-guide/features/honcho.md index eeaa2e99e9..cc64246c2e 100644 --- a/website/docs/user-guide/features/honcho.md +++ b/website/docs/user-guide/features/honcho.md @@ -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.