From 0a934000927f987e4a4aacce37620978dc89efb5 Mon Sep 17 00:00:00 2001 From: lkz-de <149545632+lkz-de@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:09:23 +0200 Subject: [PATCH] fix(session-search): scope recall by chat origin --- agent/agent_runtime_helpers.py | 1 + agent/prompt_builder.py | 8 +- agent/tool_executor.py | 1 + gateway/run.py | 1 + hermes_state.py | 42 ++- hermes_state_search.py | 99 ++++- tests/agent/test_prompt_builder.py | 6 + tests/gateway/test_background_command.py | 1 + tests/test_hermes_state.py | 43 +++ tests/tools/test_session_search.py | 353 ++++++++++++++++++ tools/session_search_tool.py | 339 ++++++++++++++++- website/docs/user-guide/sessions.md | 19 +- .../current/user-guide/sessions.md | 7 +- 13 files changed, 873 insertions(+), 47 deletions(-) diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index e64fd9ad7230..decb031d76d5 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2932,6 +2932,7 @@ def _execute(next_args: dict) -> Any: around_message_id=next_args.get("around_message_id"), window=next_args.get("window", 5), sort=next_args.get("sort"), + scope=next_args.get("scope"), db=session_db, current_session_id=agent.session_id, ), diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index dc583c752958..3505e1d63b63 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -188,7 +188,13 @@ def _strip_yaml_frontmatter(content: str) -> str: SESSION_SEARCH_GUIDANCE = ( "When the user references something from a past conversation or you suspect " "relevant cross-session context exists, use session_search to recall it before " - "asking them to repeat themselves." + "asking them to repeat themselves. Treat session_search as cross-session " + "history lookup, not as the source of truth for ambiguous continuations in " + "the current active session/thread. First re-anchor on the active session/thread " + "when a request is ambiguous. session_search results may include origin and " + "same_origin metadata; same_origin=false means the result belongs to another " + "conversation. In group chats, do not relay cross-chat history as this chat's " + "history without confirmation." ) SKILLS_GUIDANCE = ( diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 9645199ef012..67e0779258bb 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -1761,6 +1761,7 @@ def _execute(next_args: dict) -> Any: around_message_id=next_args.get("around_message_id"), window=next_args.get("window", 5), sort=next_args.get("sort"), + scope=next_args.get("scope"), db=session_db, current_session_id=agent.session_id, ) diff --git a/gateway/run.py b/gateway/run.py index ef626504dc4b..dc07cdb28d4a 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -20134,6 +20134,7 @@ def run_sync(): provider_require_parameters=pr.get("require_parameters", False), provider_data_collection=pr.get("data_collection"), session_id=task_id, + gateway_session_key=self._session_key_for_source(source), platform=platform_key, user_id=source.user_id, user_id_alt=source.user_id_alt, diff --git a/hermes_state.py b/hermes_state.py index 27aaee7a4b31..582ba9748f9d 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -6751,33 +6751,41 @@ def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]: row = cursor.fetchone() return self._session_row_dict(row) if row else None - def resolve_session_by_title(self, title: str) -> Optional[str]: + def resolve_session_by_title(self, title: str, session_key: str = None, chat_id: str = None) -> Optional[str]: """Resolve a title to a session ID, preferring the latest in a lineage. If the exact title exists, returns that session's ID. If not, searches for "title #N" variants and returns the latest one. If the exact title exists AND numbered variants exist, returns the - latest numbered variant (the most recent continuation). + latest numbered variant (the most recent continuation). When + ``session_key`` is provided, restrict resolution to that gateway-origin + boundary so a same-titled session in another chat cannot shadow the + current chat's match. When only ``chat_id`` is available, restrict to + that legacy chat boundary. """ - # First try exact match - exact = self.get_session_by_title(title) - - # Also search for numbered variants: "title #2", "title #3", etc. - # Escape SQL LIKE wildcards (%, _) in the title to prevent false matches escaped = _escape_like(title) + where = "WHERE (title = ? OR title LIKE ? ESCAPE '\\')" + params: list = [title, f"{escaped} #%"] + if session_key: + where += " AND session_key = ?" + params.append(session_key) + elif chat_id: + where += " AND chat_id = ?" + params.append(chat_id) + with self._read_ctx() as conn: cursor = conn.execute( "SELECT id, title, started_at FROM sessions " - "WHERE title LIKE ? ESCAPE '\\' ORDER BY started_at DESC", - (f"{escaped} #%",), + f"{where} ORDER BY CASE WHEN title = ? THEN 1 ELSE 0 END, started_at DESC", + (*params, title), ) - numbered = cursor.fetchall() + rows = cursor.fetchall() + numbered = [row for row in rows if row["title"] != title] if numbered: - # Return the most recent numbered variant return numbered[0]["id"] - elif exact: - return exact["id"] + if rows: + return rows[0]["id"] return None def get_next_title_in_lineage(self, base_title: str) -> str: @@ -6890,6 +6898,7 @@ def list_sessions_rich( self, source: str = None, sources: List[str] = None, + chat_id: str = None, exclude_sources: List[str] = None, cwd_prefix: str = None, limit: int = 20, @@ -6956,7 +6965,9 @@ def list_sessions_rich( Pass ``session_key`` to restrict results to one stable gateway conversation scope (DM, group, channel, or thread, including the - configured per-user isolation policy). + configured per-user isolation policy). Pass ``chat_id`` to restrict + results to one platform chat while preserving the broader source and + session-key filters. """ # Rows carry token/cost totals — drain queued deltas first so # listings (sidebar, /resume, dashboards) show exact counters. @@ -6990,6 +7001,9 @@ def list_sessions_rich( if session_key: where_clauses.append("s.session_key = ?") params.append(session_key) + if chat_id: + where_clauses.append("s.chat_id = ?") + params.append(chat_id) if exclude_sources: placeholders = ",".join("?" for _ in exclude_sources) where_clauses.append(f"s.source NOT IN ({placeholders})") diff --git a/hermes_state_search.py b/hermes_state_search.py index 643c7f739886..085cb7bc1f67 100644 --- a/hermes_state_search.py +++ b/hermes_state_search.py @@ -59,6 +59,10 @@ class SessionSearchMixin: "source", "model", "session_started", + "session_key", + "chat_id", + "chat_type", + "display_name", "context", ) @@ -1338,6 +1342,8 @@ def _run_trigram_search( source_filter: List[str] = None, exclude_sources: List[str] = None, role_filter: List[str] = None, + chat_id: str = None, + session_key: str = None, limit: int = 20, offset: int = 0, ) -> Optional[List[Dict[str, Any]]]: @@ -1379,6 +1385,12 @@ def _run_trigram_search( if role_filter: tri_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})") tri_params.extend(role_filter) + if session_key: + tri_where.append("s.session_key = ?") + tri_params.append(session_key) + elif chat_id: + tri_where.append("s.chat_id = ?") + tri_params.append(chat_id) tri_sql = f""" SELECT m.id, @@ -1390,7 +1402,11 @@ def _run_trigram_search( m.tool_name, s.source, s.model, - s.started_at AS session_started + s.started_at AS session_started, + s.session_key, + s.chat_id, + s.chat_type, + s.display_name FROM {table} JOIN messages m ON m.id = {table}.rowid JOIN sessions s ON s.id = m.session_id @@ -1413,6 +1429,8 @@ def search_messages( source_filter: List[str] = None, exclude_sources: List[str] = None, role_filter: List[str] = None, + chat_id: str = None, + session_key: str = None, limit: int = 20, offset: int = 0, sort: str = None, @@ -1435,6 +1453,8 @@ def search_messages( source_filter=source_filter, exclude_sources=exclude_sources, role_filter=role_filter, + chat_id=chat_id, + session_key=session_key, limit=limit, offset=offset, sort=sort, @@ -1545,6 +1565,8 @@ def _search_messages_like_fallback( source_filter: Optional[List[str]], exclude_sources: Optional[List[str]], role_filter: Optional[List[str]], + chat_id: str = None, + session_key: str = None, limit: int, offset: int, sort: Optional[str], @@ -1569,6 +1591,12 @@ def _search_messages_like_fallback( if role_filter: where.append(f"m.role IN ({','.join('?' for _ in role_filter)})") params.extend(role_filter) + if session_key: + where.append("s.session_key = ?") + params.append(session_key) + elif chat_id: + where.append("s.chat_id = ?") + params.append(chat_id) order = ( "ASC" @@ -1579,7 +1607,8 @@ def _search_messages_like_fallback( SELECT m.id, m.session_id, m.role, substr(m.content, max(1, instr(m.content, ?) - 40), 120) AS snippet, m.content, m.timestamp, m.tool_name, - s.source, s.model, s.started_at AS session_started + s.source, s.model, s.started_at AS session_started, + s.session_key, s.chat_id, s.chat_type, s.display_name FROM messages m JOIN sessions s ON s.id = m.session_id WHERE {' AND '.join(where)} @@ -1702,6 +1731,8 @@ def _search_messages_impl( source_filter: List[str] = None, exclude_sources: List[str] = None, role_filter: List[str] = None, + chat_id: str = None, + session_key: str = None, limit: int = 20, offset: int = 0, sort: str = None, @@ -1755,6 +1786,8 @@ def _search_messages_impl( source_filter=source_filter, exclude_sources=exclude_sources, role_filter=role_filter, + chat_id=chat_id, + session_key=session_key, limit=limit, offset=offset, sort=sort, @@ -1808,6 +1841,12 @@ def _search_messages_impl( role_placeholders = ",".join("?" for _ in role_filter) where_clauses.append(f"m.role IN ({role_placeholders})") params.extend(role_filter) + if session_key: + where_clauses.append("s.session_key = ?") + params.append(session_key) + elif chat_id: + where_clauses.append("s.chat_id = ?") + params.append(chat_id) where_sql = " AND ".join(where_clauses) params.extend([limit, offset]) @@ -1823,7 +1862,11 @@ def _search_messages_impl( m.tool_name, s.source, s.model, - s.started_at AS session_started + s.started_at AS session_started, + s.session_key, + s.chat_id, + s.chat_type, + s.display_name FROM messages_fts JOIN messages m ON m.id = messages_fts.rowid JOIN sessions s ON s.id = m.session_id @@ -1902,6 +1945,12 @@ def _search_messages_impl( if role_filter: cjk_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})") cjk_params.extend(role_filter) + if session_key: + cjk_where.append("s.session_key = ?") + cjk_params.append(session_key) + elif chat_id: + cjk_where.append("s.chat_id = ?") + cjk_params.append(chat_id) cjk_sql = f""" SELECT m.id, @@ -1913,7 +1962,11 @@ def _search_messages_impl( m.tool_name, s.source, s.model, - s.started_at AS session_started + s.started_at AS session_started, + s.session_key, + s.chat_id, + s.chat_type, + s.display_name FROM messages_fts_cjk JOIN messages m ON m.id = messages_fts_cjk.rowid JOIN sessions s ON s.id = m.session_id @@ -1991,6 +2044,12 @@ def _search_messages_impl( if role_filter: tri_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})") tri_params.extend(role_filter) + if session_key: + tri_where.append("s.session_key = ?") + tri_params.append(session_key) + elif chat_id: + tri_where.append("s.chat_id = ?") + tri_params.append(chat_id) tri_sql = f""" SELECT m.id, @@ -2002,7 +2061,11 @@ def _search_messages_impl( m.tool_name, s.source, s.model, - s.started_at AS session_started + s.started_at AS session_started, + s.session_key, + s.chat_id, + s.chat_type, + s.display_name FROM messages_fts_trigram JOIN messages m ON m.id = messages_fts_trigram.rowid JOIN sessions s ON s.id = m.session_id @@ -2085,13 +2148,20 @@ def _search_messages_impl( if role_filter: like_where.append(f"m.role IN ({','.join('?' for _ in role_filter)})") like_params.extend(role_filter) + if session_key: + like_where.append("s.session_key = ?") + like_params.append(session_key) + elif chat_id: + like_where.append("s.chat_id = ?") + like_params.append(chat_id) like_sql = f""" SELECT m.id, m.session_id, m.role, substr(m.content, max(1, instr(m.content, ?) - 40), 120) AS snippet, m.content, m.timestamp, m.tool_name, - s.source, s.model, s.started_at AS session_started + s.source, s.model, s.started_at AS session_started, + s.session_key, s.chat_id, s.chat_type, s.display_name FROM messages m JOIN sessions s ON s.id = m.session_id WHERE {' AND '.join(like_where)} @@ -2144,6 +2214,8 @@ def _search_messages_impl( source_filter=source_filter, exclude_sources=exclude_sources, role_filter=role_filter, + chat_id=chat_id, + session_key=session_key, ) seen_ids = {m["id"] for m in matches} matches.extend(m for m in gap_matches if m["id"] not in seen_ids) @@ -2182,6 +2254,8 @@ def _search_messages_impl( source_filter=source_filter, exclude_sources=exclude_sources, role_filter=role_filter, + chat_id=chat_id, + session_key=session_key, limit=limit, offset=offset, ) @@ -2199,6 +2273,8 @@ def _search_messages_impl( source_filter=source_filter, exclude_sources=exclude_sources, role_filter=role_filter, + chat_id=chat_id, + session_key=session_key, limit=limit, offset=offset, ) @@ -2216,6 +2292,8 @@ def _search_unindexed_gap( source_filter: Optional[List[str]] = None, exclude_sources: Optional[List[str]] = None, role_filter: Optional[List[str]] = None, + chat_id: str = None, + session_key: str = None, ) -> List[Dict[str, Any]]: """LIKE-scan the rows the deferred rebuild hasn't indexed yet. @@ -2261,6 +2339,12 @@ def _search_unindexed_gap( if role_filter: where.append(f"m.role IN ({','.join('?' for _ in role_filter)})") params.extend(role_filter) + if session_key: + where.append("s.session_key = ?") + params.append(session_key) + elif chat_id: + where.append("s.chat_id = ?") + params.append(chat_id) sql = f""" SELECT m.id, m.session_id, m.role, @@ -2268,7 +2352,8 @@ def _search_unindexed_gap( max(1, instr(m.content, ?) - 40), 120) AS snippet, m.content, m.timestamp, m.tool_name, - s.source, s.model, s.started_at AS session_started + s.source, s.model, s.started_at AS session_started, + s.session_key, s.chat_id, s.chat_type, s.display_name FROM messages m JOIN sessions s ON s.id = m.session_id WHERE {' AND '.join(where)} diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index d02871db13f7..6c22de542d9b 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -55,6 +55,12 @@ def test_session_search_guidance_is_simple_cross_session_recall(self): assert "relevant cross-session context exists" in SESSION_SEARCH_GUIDANCE assert "recent turns of the current session" not in SESSION_SEARCH_GUIDANCE + def test_session_search_guidance_mentions_origin_and_group_confirmation(self): + assert "same_origin" in SESSION_SEARCH_GUIDANCE + assert "another conversation" in SESSION_SEARCH_GUIDANCE + assert "group chats" in SESSION_SEARCH_GUIDANCE + assert "without confirmation" in SESSION_SEARCH_GUIDANCE + # ========================================================================= # Context injection scanning diff --git a/tests/gateway/test_background_command.py b/tests/gateway/test_background_command.py index ccf7833b1991..5d969788784a 100644 --- a/tests/gateway/test_background_command.py +++ b/tests/gateway/test_background_command.py @@ -160,6 +160,7 @@ async def test_successful_task_sends_result(self): assert "Hello from background!" in content agent_kwargs = MockAgent.call_args.kwargs assert agent_kwargs["checkpoints_enabled"] is True + assert agent_kwargs["gateway_session_key"] == runner._session_key_for_source(source) assert agent_kwargs["checkpoint_max_snapshots"] == 8 assert agent_kwargs["checkpoint_max_total_size_mb"] == 222 assert agent_kwargs["checkpoint_max_file_size_mb"] == 3 diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 725cfde8d0a9..ac0bc1a7be53 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -658,6 +658,49 @@ def test_search_finds_content(self, db): snippets = [r.get("snippet", "") for r in results] assert any("docker" in s.lower() or "Docker" in s for s in snippets) + def test_search_returns_session_origin_metadata(self, db): + db.create_session(session_id="s1", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "signal:chat-a", "s1"), + ) + db.append_message("s1", role="user", content="Origin tagged Python question") + db._conn.commit() + + results = db.search_messages("Python") + + assert results[0]["chat_id"] == "chat-a" + assert results[0]["chat_type"] == "group" + assert results[0]["display_name"] == "Group A" + assert results[0]["session_key"] == "signal:chat-a" + + def test_search_can_filter_by_chat_id(self, db): + for sid, chat_id in (("same", "chat-a"), ("foreign", "chat-b")): + db.create_session(session_id=sid, source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + (chat_id, "group", chat_id, sid), + ) + db.append_message(sid, role="user", content="Needle context isolation") + db._conn.commit() + + results = db.search_messages("Needle", chat_id="chat-a") + + assert [r["session_id"] for r in results] == ["same"] + + def test_list_sessions_rich_can_filter_by_chat_id(self, db): + for sid, chat_id in (("same", "chat-a"), ("foreign", "chat-b")): + db.create_session(session_id=sid, source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + (chat_id, "group", chat_id, sid, sid), + ) + db._conn.commit() + + results = db.list_sessions_rich(source="signal", chat_id="chat-a") + + assert [r["id"] for r in results] == ["same"] + diff --git a/tests/tools/test_session_search.py b/tests/tools/test_session_search.py index c5c64635de37..0bd8c95f9e88 100644 --- a/tests/tools/test_session_search.py +++ b/tests/tools/test_session_search.py @@ -22,6 +22,7 @@ _session_link, session_search, ) +from tools.registry import registry @pytest.fixture @@ -78,6 +79,8 @@ def test_schema_params_cover_every_shape(self): assert "window" in params # Shared assert "role_filter" in params + assert "scope" in params + assert params["scope"]["enum"] == ["chat", "all"] # Mode is inferred from which args are set — no explicit mode param assert "mode" not in params @@ -107,6 +110,57 @@ def test_browse_excludes_current_session(self, db): sids = [r["session_id"] for r in result["results"]] assert "s_newest" not in sids + def test_group_context_browse_defaults_to_current_chat(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-a", "group", "Group A", "current"), + ) + db.create_session("same", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("chat-a", "group", "Group A", "Same chat", "same"), + ) + db.create_session("foreign", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("chat-b", "group", "Group B", "Foreign chat", "foreign"), + ) + db._conn.commit() + + result = json.loads(session_search(db=db, current_session_id="current")) + + sids = [r["session_id"] for r in result["results"]] + assert result["scope"] == "chat" + assert "same" in sids + assert "foreign" not in sids + assert result["results"][0]["same_origin"] is True + + def test_group_context_browse_filters_beyond_recent_window(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-a", "group", "Group A", "current"), + ) + for idx in range(12): + sid = f"foreign_{idx}" + db.create_session(sid, source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, started_at=? WHERE id=?", + ("chat-b", "group", "Group B", f"Foreign {idx}", 1000 + idx, sid), + ) + db.create_session("same_old", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, started_at=? WHERE id=?", + ("chat-a", "group", "Group A", "Same old", 1, "same_old"), + ) + db._conn.commit() + + result = json.loads(session_search(limit=3, db=db, current_session_id="current")) + + assert result["scope"] == "chat" + assert [r["session_id"] for r in result["results"]] == ["same_old"] + # ========================================================================= # Discovery shape (with query) @@ -158,6 +212,283 @@ def test_current_session_filtered_out(self, db): sids = [r["session_id"] for r in result["results"]] assert "s_newest" not in sids + def test_group_context_search_defaults_to_current_session_key(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "signal:group:chat-a:user-a", "current"), + ) + db.create_session("same_user_old", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "Group A user A links", "signal:group:chat-a:user-a", "same_user_old"), + ) + db.append_message("same_user_old", role="user", content="ambiguous local links") + db.create_session("same_chat_other_user", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "Group A user B links", "signal:group:chat-a:user-b", "same_chat_other_user"), + ) + db.append_message("same_chat_other_user", role="user", content="ambiguous other user links ambiguous ambiguous ambiguous") + db._conn.commit() + + result = json.loads(session_search(query="ambiguous", db=db, current_session_id="current")) + + assert result["scope"] == "chat" + assert [r["session_id"] for r in result["results"]] == ["same_user_old"] + assert result["results"][0]["same_origin"] is True + + def test_group_context_search_defaults_to_current_session_key_for_trigram_cjk(self, db): + if not db._trigram_available: + pytest.skip("trigram tokenizer unavailable in this build") + db._fts_cjk_available = False + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "signal:group:chat-a:user-a", "current"), + ) + db.create_session("same_user_old", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "Group A CJK", "signal:group:chat-a:user-a", "same_user_old"), + ) + db.append_message("same_user_old", role="user", content="关于大别山项目的本地记录") + db.create_session("foreign", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=? WHERE id=?", + ("chat-b", "group", "Group B", "Group B CJK", "signal:group:chat-b:user-a", "foreign"), + ) + db.append_message("foreign", role="user", content="关于大别山项目的外部记录") + db._conn.commit() + + result = json.loads(session_search(query="大别山项目", db=db, current_session_id="current")) + + assert result["scope"] == "chat" + assert [r["session_id"] for r in result["results"]] == ["same_user_old"] + assert result["results"][0]["same_origin"] is True + + def test_group_context_search_defaults_to_current_chat_when_no_session_key(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-a", "group", "Group A", "current"), + ) + db.create_session("same_old", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("chat-a", "group", "Group A", "Group A links", "same_old"), + ) + db.append_message("same_old", role="user", content="ambiguous local links") + db.create_session("foreign", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("foreign", "group", "Group B", "Group B links", "foreign"), + ) + db.append_message("foreign", role="user", content="ambiguous foreign links ambiguous ambiguous ambiguous") + db._conn.commit() + + result = json.loads(session_search(query="ambiguous", db=db, current_session_id="current")) + + assert result["scope"] == "chat" + assert [r["session_id"] for r in result["results"]] == ["same_old"] + assert result["results"][0]["origin"]["display_name"] == "Group A" + assert result["results"][0]["same_origin"] is True + + def test_dm_context_search_defaults_to_current_chat(self, db): + db.create_session("current", source="telegram") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, session_key=? WHERE id=?", + ("dm-a", "dm", "DM A", "telegram:dm:dm-a", "current"), + ) + db.create_session("same_dm", source="telegram") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=? WHERE id=?", + ("dm-a", "dm", "DM A", "DM scoped", "telegram:dm:dm-a", "same_dm"), + ) + db.append_message("same_dm", role="user", content="dm private recall marker") + db.create_session("other_dm", source="telegram") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=? WHERE id=?", + ("dm-b", "dm", "DM B", "Other DM", "telegram:dm:dm-b", "other_dm"), + ) + db.append_message("other_dm", role="user", content="dm private recall marker foreign") + db._conn.commit() + + result = json.loads(session_search(query="private recall", db=db, current_session_id="current")) + + assert result["scope"] == "chat" + assert [r["session_id"] for r in result["results"]] == ["same_dm"] + + def test_scoped_empty_result_reports_outside_scope_count(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-a", "group", "Group A", "current"), + ) + db.create_session("foreign", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("chat-b", "group", "Group B", "Foreign", "foreign"), + ) + db.append_message("foreign", role="user", content="outside-scope needle") + db._conn.commit() + + result = json.loads(session_search(query="outside-scope", db=db, current_session_id="current")) + + assert result["results"] == [] + assert result["recall_scope"]["scope_level"] == "chat" + assert result["recall_scope"]["matches_before_scope"] == 1 + assert result["recall_scope"]["matches_after_scope"] == 0 + + def test_legacy_null_origin_rows_excluded_from_chat_scope_and_counted(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-a", "group", "Group A", "current"), + ) + db.create_session("legacy", source="signal") + db._conn.execute( + "UPDATE sessions SET title=? WHERE id=?", + ("Legacy", "legacy"), + ) + db.append_message("legacy", role="user", content="legacy null-origin marker") + db._conn.commit() + + scoped = json.loads(session_search(query="null-origin", db=db, current_session_id="current")) + global_result = json.loads(session_search(query="null-origin", scope="all", db=db, current_session_id="current")) + + assert scoped["results"] == [] + assert scoped["recall_scope"]["matches_before_scope"] == 1 + assert scoped["recall_scope"]["matches_after_scope"] == 0 + assert global_result["results"][0]["session_id"] == "legacy" + + def test_scope_all_prefers_same_origin_before_foreign(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-a", "group", "Group A", "current"), + ) + db.create_session("same", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("chat-a", "group", "Group A", "Group A links", "same"), + ) + db.append_message("same", role="user", content="ambiguous same links") + db.create_session("foreign", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("foreign", "group", "Group B", "Group B links", "foreign"), + ) + db.append_message("foreign", role="user", content="ambiguous foreign links") + db._conn.commit() + + result = json.loads(session_search(query="ambiguous", scope="all", db=db, current_session_id="current")) + + assert result["scope"] == "all" + assert [r["session_id"] for r in result["results"][:2]] == ["same", "foreign"] + assert result["results"][0]["same_origin"] is True + assert result["results"][1]["same_origin"] is False + assert result["results"][1]["origin"]["display_name"] == "Group B" + assert "different conversation" in result["notice"] + + def test_same_chat_id_on_different_source_is_foreign(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("shared-id", "group", "Signal Group", "current"), + ) + db.create_session("other_platform", source="telegram") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("shared-id", "group", "Telegram Group", "Collision", "other_platform"), + ) + db.append_message("other_platform", role="user", content="collision marker") + db._conn.commit() + + scoped = json.loads(session_search(query="collision", db=db, current_session_id="current")) + global_result = json.loads(session_search(query="collision", scope="all", db=db, current_session_id="current")) + + assert scoped["results"] == [] + assert global_result["results"][0]["same_origin"] is False + + def test_title_match_uses_in_chat_session_when_foreign_title_is_newer(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "signal:group:chat-a:user-a", "current"), + ) + db.create_session("same_title", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=?, started_at=? WHERE id=?", + ("chat-a", "group", "Group A", "Shared Title #2", "signal:group:chat-a:user-a", 1, "same_title"), + ) + db.append_message("same_title", role="user", content="same chat title content") + db.create_session("foreign_title", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=?, started_at=? WHERE id=?", + ("chat-b", "group", "Group B", "Shared Title", "signal:group:chat-b:user-a", 2, "foreign_title"), + ) + db.append_message("foreign_title", role="user", content="foreign title content") + db._conn.commit() + + result = json.loads(session_search(query="Shared Title", db=db, current_session_id="current")) + + assert result["results"][0]["session_id"] == "same_title" + + def test_stale_fts_like_fallback_honors_chat_scope(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "signal:group:chat-a:user-a", "current"), + ) + db.create_session("same", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=? WHERE id=?", + ("chat-a", "group", "Group A", "Same", "signal:group:chat-a:user-a", "same"), + ) + db.append_message("same", role="user", content="stale fallback scoped needle") + db.create_session("foreign", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=?, session_key=? WHERE id=?", + ("chat-b", "group", "Group B", "Foreign", "signal:group:chat-b:user-a", "foreign"), + ) + db.append_message("foreign", role="user", content="stale fallback scoped needle foreign") + db._conn.commit() + db._fts_stale = True + db._fts_enabled = False + + result = json.loads(session_search(query="stale fallback", db=db, current_session_id="current")) + + assert [r["session_id"] for r in result["results"]] == ["same"] + + def test_registered_handler_honors_scope(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-a", "group", "Group A", "current"), + ) + db.create_session("foreign", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=?, title=? WHERE id=?", + ("chat-b", "group", "Group B", "Group B result", "foreign"), + ) + db.append_message("foreign", role="user", content="registry scope marker") + db._conn.commit() + + entry = registry.get_entry("session_search") + assert entry is not None + result = json.loads( + entry.handler( + {"query": "registry", "scope": "all"}, + db=db, + current_session_id="current", + ) + ) + + assert result["scope"] == "all" + assert result["results"][0]["session_id"] == "foreign" + assert result["results"][0]["same_origin"] is False + class TestDiscoverySort: def test_sort_newest_orders_by_recency(self, db): @@ -308,6 +639,28 @@ def test_read_strips_ansi_sequences_from_messages(self, db): assert any(text == "red text and more" for text in rendered) assert all("\u001b" not in text for text in rendered) + def test_read_and_scroll_warn_on_foreign_origin(self, db): + db.create_session("current", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-a", "group", "Group A", "current"), + ) + db.create_session("foreign", source="signal") + db._conn.execute( + "UPDATE sessions SET chat_id=?, chat_type=?, display_name=? WHERE id=?", + ("chat-b", "group", "Group B", "foreign"), + ) + msg_id = db.append_message("foreign", role="user", content="foreign scroll content") + db._conn.commit() + + read_result = json.loads(session_search(session_id="foreign", db=db, current_session_id="current")) + scroll_result = json.loads(session_search(session_id="foreign", around_message_id=msg_id, db=db, current_session_id="current")) + + assert read_result["same_origin"] is False + assert "different conversation" in read_result["cross_context_warning"] + assert scroll_result["same_origin"] is False + assert "different conversation" in scroll_result["cross_context_warning"] + def test_read_truncates_large_session(self, db): db.create_session("s_big", source="cli") for i in range(50): diff --git a/tools/session_search_tool.py b/tools/session_search_tool.py index 1c15aca5bf4d..ffd3e07e5d47 100644 --- a/tools/session_search_tool.py +++ b/tools/session_search_tool.py @@ -65,6 +65,10 @@ "source", "model", "session_started", + "session_key", + "chat_id", + "chat_type", + "display_name", ) # Prefixes that identify generated context-compaction handoff summaries. @@ -230,12 +234,129 @@ def _annotate_rebuild_status(db, payload: Dict[str, Any]) -> None: } -def _order_for_recall(raw_results: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + +def _origin_from_meta(meta: Dict[str, Any]) -> Dict[str, Any]: + """Return compact, non-secret session-origin metadata for tool output.""" + return { + "display_name": meta.get("display_name") or None, + "chat_type": meta.get("chat_type") or None, + "source": meta.get("source") or None, + } + + +def _current_origin(db, current_session_id: Optional[str]) -> Optional[Dict[str, Any]]: + """Look up the active gateway chat origin, if this turn has one.""" + meta: Dict[str, Any] = {} + if current_session_id: + try: + meta = db.get_session(current_session_id) or {} + except Exception: + logging.debug("current origin lookup failed for %s", current_session_id, exc_info=True) + meta = {} + chat_id = meta.get("chat_id") + session_key = meta.get("session_key") + if not chat_id or not session_key: + try: + from gateway.session_context import get_session_env + chat_id = chat_id or get_session_env("HERMES_SESSION_CHAT_ID", "") or None + session_key = session_key or get_session_env("HERMES_SESSION_KEY", "") or None + meta = { + **meta, + "chat_id": chat_id, + "session_key": session_key, + "chat_type": meta.get("chat_type") or get_session_env("HERMES_SESSION_CHAT_TYPE", "") or None, + "display_name": meta.get("display_name") or get_session_env("HERMES_SESSION_CHAT_NAME", "") or None, + "source": meta.get("source") or get_session_env("HERMES_SESSION_PLATFORM", "") or None, + } + except Exception: + pass + if not chat_id and not session_key: + return None + return { + "chat_id": chat_id, + "session_key": session_key, + "chat_type": meta.get("chat_type"), + "display_name": meta.get("display_name"), + "source": meta.get("source"), + } + + +def _is_groupish_origin(origin: Optional[Dict[str, Any]]) -> bool: + return (origin or {}).get("chat_type") in {"group", "forum", "channel"} + + +def _has_chat_origin(origin: Optional[Dict[str, Any]]) -> bool: + """Return true when a gateway turn has a usable chat/thread recall origin.""" + return bool(origin and (origin.get("session_key") or origin.get("chat_id"))) + + +def _recall_scope_payload( + scope: str, + current_origin: Optional[Dict[str, Any]], + *, + matches_before_scope: Optional[int] = None, + matches_after_scope: Optional[int] = None, + title_match_dropped_by_scope: Optional[bool] = None, +) -> Dict[str, Any]: + payload: Dict[str, Any] = {"scope_level": scope} + if current_origin: + for key in ("source", "chat_id", "chat_type", "display_name"): + if current_origin.get(key): + payload[key] = current_origin[key] + if matches_before_scope is not None: + payload["matches_before_scope"] = matches_before_scope + if matches_after_scope is not None: + payload["matches_after_scope"] = matches_after_scope + if title_match_dropped_by_scope is not None: + payload["title_match_dropped_by_scope"] = title_match_dropped_by_scope + return payload + + +def _dedup_lineage_count(db, rows: List[Dict[str, Any]], current_lineage_root: Optional[str]) -> int: + seen: set[str] = set() + for row in rows: + sid = row.get("session_id") + if not sid: + continue + lineage = _resolve_lineage(db, sid) + if current_lineage_root and lineage == current_lineage_root: + continue + seen.add(lineage or sid) + return len(seen) + + +def _origin_payload(meta: Dict[str, Any], current_origin: Optional[Dict[str, Any]]) -> tuple[Dict[str, Any], Optional[bool]]: + origin = _origin_from_meta(meta) + same_origin = None + if current_origin and (current_origin.get("session_key") or current_origin.get("chat_id")): + current_source = current_origin.get("source") + meta_source = meta.get("source") + same_source = bool(meta_source and meta_source == current_source) if current_source else True + if current_origin.get("session_key"): + same_scope = bool(meta.get("session_key") and meta.get("session_key") == current_origin.get("session_key")) + else: + same_scope = bool(meta.get("chat_id") and meta.get("chat_id") == current_origin.get("chat_id")) + same_origin = same_scope and same_source + return origin, same_origin + + +def _add_cross_context_notice(payload: Dict[str, Any], current_origin: Optional[Dict[str, Any]]) -> None: + if not current_origin: + return + entries = payload.get("results") or [] + if any(e.get("same_origin") is False for e in entries): + payload["notice"] = ( + "Some results are from a different conversation. Treat same_origin=false " + "content as cross-chat history; in group contexts, ask before relaying it." + ) + +def _order_for_recall(raw_results: List[Dict[str, Any]], current_origin: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """Stable-sort FTS rows so interactive sessions rank above automation. - Within each class (interactive vs demoted) the original BM25 ``rank`` - order is preserved — Python's sort is stable, and rows arrive already - ranked by relevance. This only changes cross-class ordering: a cron hit + When current-origin metadata is available, same-origin rows rank before + foreign-origin rows. Within each class (same-origin vs foreign, interactive + vs demoted) the original BM25 ``rank`` order is preserved — Python's sort is + stable, and rows arrive already ranked by relevance. This only changes cross-class ordering: a cron hit never displaces an interactive hit during lineage dedup, so the user's own conversations surface first even when cron rows out-rank them under bare BM25 (#19434). Demoted rows still appear when they're the only @@ -243,7 +364,10 @@ def _order_for_recall(raw_results: List[Dict[str, Any]]) -> List[Dict[str, Any]] """ return sorted( raw_results, - key=lambda r: 1 if (r.get("source") or "") in _DEMOTED_SESSION_SOURCES else 0, + key=lambda r: ( + 1 if (current_origin and (r.get("chat_id") != current_origin.get("chat_id") or (current_origin.get("source") and r.get("source") != current_origin.get("source")))) else 0, + 1 if (r.get("source") or "") in _DEMOTED_SESSION_SOURCES else 0, + ), ) @@ -384,7 +508,14 @@ def _locate_session_db(session_id: str): return None, None -def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_profile: str = None) -> str: +def _read_session( + db, + session_id: str, + head: int = 20, + tail: int = 10, + link_profile: str = None, + current_origin: Optional[Dict[str, Any]] = None, +) -> str: """Read shape: dump a whole session by id (head + tail when large). Serves the linked-session case — the user dropped an @session reference and @@ -421,11 +552,21 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_prof "source": meta.get("source"), "model": meta.get("model"), "title": meta.get("title"), + "origin": _origin_from_meta(meta), }, "message_count": total, "truncated": truncated, "messages": window, } + origin, same_origin = _origin_payload(meta, current_origin) + response["session_meta"]["origin"] = origin + if same_origin is not None: + response["same_origin"] = same_origin + if same_origin is False: + response["cross_context_warning"] = ( + "This session belongs to a different conversation from the active chat. " + "Do not present it as this chat's history without confirmation." + ) if truncated: response["message"] = ( f"Session has {total} messages; showing first {head} + last {tail}. " @@ -434,10 +575,24 @@ def _read_session(db, session_id: str, head: int = 20, tail: int = 10, link_prof return json.dumps(response, ensure_ascii=False) -def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_profile: str = None) -> str: +def _list_recent_sessions( + db, + limit: int, + current_session_id: str = None, + link_profile: str = None, + current_origin: Optional[Dict[str, Any]] = None, + scope: str = "all", +) -> str: """Return metadata for the most recent sessions (no LLM calls, no FTS5).""" try: sessions = db.list_sessions_rich( + source=current_origin.get("source") if (scope == "chat" and current_origin) else None, + session_key=current_origin.get("session_key") if (scope == "chat" and current_origin) else None, + chat_id=( + current_origin.get("chat_id") + if (scope == "chat" and current_origin and not current_origin.get("session_key")) + else None + ), limit=limit + 5, exclude_sources=list(_HIDDEN_SESSION_SOURCES), order_by_last_active=True, @@ -453,7 +608,10 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_p # Skip child / delegation sessions if s.get("parent_session_id"): continue - results.append({ + origin, same_origin = _origin_payload(s, current_origin) + if scope == "chat" and same_origin is False: + continue + entry = { "session_id": sid, "link": _session_link(sid, link_profile), "title": s.get("title") or None, @@ -462,17 +620,27 @@ def _list_recent_sessions(db, limit: int, current_session_id: str = None, link_p "last_active": s.get("last_active", ""), "message_count": s.get("message_count", 0), "preview": s.get("preview", ""), - }) + "origin": origin, + } + if same_origin is not None: + entry["same_origin"] = same_origin + results.append(entry) if len(results) >= limit: break - return json.dumps({ + payload = { "success": True, "mode": "browse", + "scope": scope, + "recall_scope": _recall_scope_payload(scope, current_origin), "results": results, "count": len(results), "message": f"Showing {len(results)} most recent sessions. Pass a query= to search, or session_id+around_message_id to scroll.", - }, ensure_ascii=False) + } + if scope == "chat" and not results: + payload["message"] = "No recent sessions found in this chat. Pass scope='all' to browse other conversations." + _add_cross_context_notice(payload, current_origin) + return json.dumps(payload, ensure_ascii=False) except Exception as e: logging.error("Error listing recent sessions: %s", e, exc_info=True) return tool_error(f"Failed to list recent sessions: {e}", success=False) @@ -484,6 +652,7 @@ def _scroll( around_message_id: int, window: int = 5, current_session_id: str = None, + current_origin: Optional[Dict[str, Any]] = None, ) -> str: """Scroll shape: return a window of messages centered on an anchor. @@ -604,12 +773,21 @@ def _scroll( "source": session_meta.get("source"), "model": session_meta.get("model"), "title": session_meta.get("title"), + "origin": _origin_from_meta(session_meta), }, "window": window, "messages": [_shape_message(m, anchor_id=around_message_id) for m in messages], "messages_before": view.get("messages_before", 0), "messages_after": view.get("messages_after", 0), } + _origin, same_origin = _origin_payload(session_meta, current_origin) + if same_origin is not None: + response["same_origin"] = same_origin + if same_origin is False: + response["cross_context_warning"] = ( + "This scroll window belongs to a different conversation from the active chat. " + "Do not present it as this chat's history without confirmation." + ) if rebind_warning: response["warning"] = rebind_warning return json.dumps(response, ensure_ascii=False) @@ -624,6 +802,8 @@ def _title_match_result( db, query: str, current_lineage_root: Optional[str], + current_origin: Optional[Dict[str, Any]] = None, + scope: str = "all", ) -> Optional[Dict[str, Any]]: """Return a discovery-shaped result when the query matches a session title.""" title_query = _normalize_title_query(query) @@ -631,7 +811,15 @@ def _title_match_result( return None try: - session_id = db.resolve_session_by_title(title_query) + session_id = db.resolve_session_by_title( + title_query, + session_key=current_origin.get("session_key") if (scope == "chat" and current_origin) else None, + chat_id=( + current_origin.get("chat_id") + if (scope == "chat" and current_origin and not current_origin.get("session_key")) + else None + ), + ) except Exception: logging.debug("resolve_session_by_title failed for %r", title_query, exc_info=True) return None @@ -649,6 +837,9 @@ def _title_match_result( session_meta = {} if session_meta.get("source") in _HIDDEN_SESSION_SOURCES: return None + origin, same_origin = _origin_payload(session_meta, current_origin) + if scope == "chat" and same_origin is False: + return None try: messages = db.get_messages(session_id) @@ -680,8 +871,11 @@ def _title_match_result( "bookend_end": [_shape_message(m) for m in (view.get("bookend_end") or messages[-3:])], "messages_before": view.get("messages_before", 0), "messages_after": view.get("messages_after", max(len(messages) - 5, 0)), + "origin": origin, "_lineage_root": lineage_root, } + if same_origin is not None: + entry["same_origin"] = same_origin if lineage_root and lineage_root != session_id: entry["parent_session_id"] = lineage_root return entry @@ -695,16 +889,31 @@ def _discover( sort: Optional[str], current_session_id: str = None, link_profile: str = None, + current_origin: Optional[Dict[str, Any]] = None, + scope: str = "all", ) -> str: """Discovery shape: FTS5 + anchored window + bookends per hit. Single call.""" role_list = role_filter if role_filter else ["user", "assistant"] current_lineage_root = _resolve_lineage(db, current_session_id) if current_session_id else None - title_result = _title_match_result(db, query, current_lineage_root) + title_result = _title_match_result(db, query, current_lineage_root, current_origin, scope) + title_match_dropped_by_scope = False + if scope == "chat" and title_result is None: + global_title_result = _title_match_result(db, query, current_lineage_root, current_origin, "all") + if global_title_result and global_title_result.get("same_origin") is False: + title_match_dropped_by_scope = True + scoped_session_key = current_origin.get("session_key") if (scope == "chat" and current_origin) else None + scoped_chat_id = ( + current_origin.get("chat_id") + if (scope == "chat" and current_origin and not scoped_session_key) + else None + ) + scoped_source_filter = [current_origin.get("source")] if (scope == "chat" and current_origin and current_origin.get("source")) else None try: raw_results = db.search_messages( query=query, role_filter=role_list, + source_filter=scoped_source_filter, exclude_sources=list(_HIDDEN_SESSION_SOURCES), limit=_DISCOVER_SCAN_LIMIT, # widen so dedup-by-lineage can find # distinct sessions AND so interactive matches buried under a wall @@ -712,16 +921,35 @@ def _discover( offset=0, sort=sort, fields=_DISCOVER_SEARCH_FIELDS, + chat_id=scoped_chat_id, + session_key=scoped_session_key, ) except Exception as e: logging.error("FTS5 search failed: %s", e, exc_info=True) return tool_error(f"Search failed: {e}", success=False) + matches_after_scope = _dedup_lineage_count(db, raw_results, current_lineage_root) + matches_before_scope = matches_after_scope + if scope == "chat": + try: + unscoped_results = db.search_messages( + query=query, + role_filter=role_list, + exclude_sources=list(_HIDDEN_SESSION_SOURCES), + limit=_DISCOVER_SCAN_LIMIT, + offset=0, + sort=sort, + fields=_DISCOVER_SEARCH_FIELDS, + ) + matches_before_scope = _dedup_lineage_count(db, unscoped_results, current_lineage_root) + except Exception: + logging.debug("unscoped discovery count failed", exc_info=True) + # Demote automation (cron) rows below interactive ones before dedup, so a # high-volume cron corpus can't starve the user's own sessions out of the # top `limit` results (#19434). Stable — preserves BM25/recency order # within each class. - raw_results = _order_for_recall(raw_results) + raw_results = _order_for_recall(raw_results, current_origin) if not raw_results and not title_result: _empty_payload = { @@ -730,7 +958,15 @@ def _discover( "query": query, "results": [], "count": 0, - "message": "No matching sessions found.", + "scope": scope, + "recall_scope": _recall_scope_payload( + scope, + current_origin, + matches_before_scope=matches_before_scope, + matches_after_scope=matches_after_scope, + title_match_dropped_by_scope=title_match_dropped_by_scope, + ), + "message": "No matching sessions found." if scope != "chat" else "No matching sessions found in this chat. Pass scope='all' to search other conversations.", } _annotate_rebuild_status(db, _empty_payload) return json.dumps(_empty_payload, ensure_ascii=False) @@ -825,7 +1061,11 @@ def _discover( ], "messages_before": view.get("messages_before", 0), "messages_after": view.get("messages_after", 0), + "origin": _origin_from_meta(session_meta or match_info), } + _origin, same_origin = _origin_payload({**match_info, **session_meta}, current_origin) + if same_origin is not None: + entry["same_origin"] = same_origin if lineage_root and lineage_root != hit_sid: entry["parent_session_id"] = lineage_root results.append(entry) @@ -837,10 +1077,19 @@ def _discover( "success": True, "mode": "discover", "query": query, + "scope": scope, + "recall_scope": _recall_scope_payload( + scope, + current_origin, + matches_before_scope=matches_before_scope, + matches_after_scope=matches_after_scope, + title_match_dropped_by_scope=title_match_dropped_by_scope, + ), "results": results, "count": len(results), "sessions_searched": len(seen_sessions), } + _add_cross_context_notice(_final_payload, current_origin) _annotate_rebuild_status(db, _final_payload) return json.dumps(_final_payload, ensure_ascii=False) @@ -857,6 +1106,7 @@ def session_search( window: int = 5, # Discovery shape sort: str = None, + scope: Optional[str] = None, # Cross-profile (any shape) profile: str = None, ) -> str: @@ -895,6 +1145,7 @@ def session_search( # Cross-profile read: swap in the named profile's DB (read-only) for every # shape below. The current-session-lineage guards no longer apply across # profiles, but they key off ids that won't collide, so they stay inert. + cross_profile = False if profile is not None and str(profile).strip(): try: profile_db = _resolve_profile_db(profile) @@ -903,6 +1154,16 @@ def session_search( if profile_db is not None: db = profile_db current_session_id = None + cross_profile = True + + current_origin = _current_origin(db, current_session_id) + if cross_profile: + # `profile=` is explicit cross-profile recall. Do not accidentally apply + # the current gateway room's ContextVars to another profile's DB. + current_origin = None + scope_norm = str(scope).strip().lower() if isinstance(scope, str) else "" + if scope_norm not in {"chat", "all"}: + scope_norm = "chat" if _has_chat_origin(current_origin) else "all" # Scroll shape takes precedence — explicit anchor beats any query. if (isinstance(session_id, str) and session_id.strip()) and around_message_id is not None: @@ -912,12 +1173,18 @@ def session_search( around_message_id=around_message_id, window=window, current_session_id=current_session_id, + current_origin=current_origin, ) # Read shape: a session_id with no anchor → dump the whole session. if isinstance(session_id, str) and session_id.strip(): sid = session_id.strip() - result = _read_session(db, sid, link_profile=profile) + result = _read_session( + db, + sid, + link_profile=profile, + current_origin=current_origin, + ) if json.loads(result).get("success"): return result @@ -927,7 +1194,14 @@ def session_search( located, owner = _locate_session_db(sid) if located is not None: try: - found = json.loads(_read_session(located, sid, link_profile=owner)) + found = json.loads( + _read_session( + located, + sid, + link_profile=owner, + current_origin=current_origin, + ) + ) finally: located.close() if found.get("success"): @@ -943,9 +1217,28 @@ def session_search( limit = 3 limit = max(1, min(limit, 10)) + if scope_norm == "chat" and current_origin is None: + mode = "browse" if (not query or not isinstance(query, str) or not query.strip()) else "discover" + return json.dumps({ + "success": True, + "mode": mode, + "scope": scope_norm, + "query": query.strip() if isinstance(query, str) and query.strip() else None, + "results": [], + "count": 0, + "message": "No active chat origin is available for scope='chat'. Pass scope='all' to search other conversations.", + }, ensure_ascii=False) + # Browse shape: no query → recent sessions. if not query or not isinstance(query, str) or not query.strip(): - return _list_recent_sessions(db, limit, current_session_id, link_profile=profile) + return _list_recent_sessions( + db, + limit, + current_session_id, + link_profile=profile, + current_origin=current_origin, + scope=scope_norm, + ) # Parse role_filter role_list: Optional[List[str]] = None @@ -967,6 +1260,8 @@ def session_search( sort=sort_norm, current_session_id=current_session_id, link_profile=profile, + current_origin=current_origin, + scope=scope_norm, ) @@ -1053,7 +1348,7 @@ def check_session_search_requirements() -> bool: "session where Z\". If the user provided a direct source identifier, inspect " "that source first when accessible; session_search can then supply historical " "context. The session DB carries what was said when; external tools show " - "current source/world state." + "current source/world state. In gateway contexts with a chat/session origin, discovery and browse default to the current chat's history; pass scope='all' to search other conversations, and treat same_origin=false results as cross-chat history that needs confirmation before sharing. Legacy rows with no chat_id/session_key are excluded from scope='chat' results and remain reachable via scope='all'." ), "parameters": { "type": "object", @@ -1088,6 +1383,11 @@ def check_session_search_requirements() -> bool: "and browse shapes." ), }, + "scope": { + "type": "string", + "enum": ["chat", "all"], + "description": "Optional recall scope. In gateway contexts with a chat/session origin, defaults to 'chat' to avoid cross-chat leaks. Pass 'all' only for explicit cross-conversation recall; foreign results are labeled same_origin=false.", + }, "session_id": { "type": "string", "description": ( @@ -1152,6 +1452,7 @@ def check_session_search_requirements() -> bool: around_message_id=args.get("around_message_id"), window=args.get("window", 5), sort=args.get("sort"), + scope=args.get("scope"), profile=args.get("profile"), db=kw.get("db"), current_session_id=kw.get("current_session_id"), diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index 3f02dd2be20b..7ae37f57702f 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -612,9 +612,11 @@ routing is the only thing the repair changes. Back up first ## Session Search Tool -The agent has a built-in `session_search` tool that performs full-text search across all past conversations using SQLite's FTS5 engine — and lets the agent scroll through any session it finds. No LLM calls, no summarization, no truncation. Every shape returns actual messages from the DB. +The agent has a built-in `session_search` tool that performs full-text search across saved conversations using SQLite's FTS5 engine — and lets the agent scroll through any session it finds. No LLM calls, no summarization, no truncation. Every shape returns actual messages from the DB. -### Three calling shapes +In gateway contexts with a chat/session origin, discovery and browse default to the current chat's history to avoid leaking cross-chat recall into another room or DM. Pass `scope="all"` only when you explicitly want cross-conversation recall. Results outside the current origin are labeled with `same_origin=false`; treat them as cross-chat history that needs confirmation before sharing. Scoped empty results include `recall_scope` counts so the agent can distinguish "nothing in this chat" from "matches exist only outside this chat." + +### Four calling shapes The tool infers what you want from which arguments you set. There's no `mode` parameter. @@ -650,13 +652,21 @@ Returns a window of ±`window` messages centered on the anchor. No FTS5, no book Typical wall time: 1–2ms per scroll call. -**3. Browse — no args:** +**3. Read — pass `session_id` only:** + +```python +session_search(session_id="20260510_174648_805cc2") +``` + +Dumps that session directly. If the user gives an `@session:/` link, split the value into `profile` and `session_id` and pass both fields. + +**4. Browse — no args:** ```python session_search() ``` -Returns recent sessions chronologically (titles, previews, timestamps). Useful when the user asks "what was I working on" without naming a topic. +Returns recent sessions chronologically (titles, previews, timestamps). In gateway contexts with a chat/session origin this defaults to the current chat unless you pass `scope="all"`. Useful when the user asks "what was I working on" without naming a topic. ### FTS5 query syntax @@ -670,6 +680,7 @@ The keyword mode supports standard FTS5 query syntax: ### Optional parameters - `sort` — `newest` or `oldest`, on top of FTS5 ranking. Omit for relevance-only ordering (the default; suitable for exploratory recall). Use `newest` for "where did we leave X" questions, `oldest` for "how did X start" questions. +- `scope` — `chat` or `all`. In gateway contexts with a chat/session origin, discovery and browse default to `chat`; direct reads by `session_id` are explicit and are not narrowed. Use `scope="all"` only when the user asks for broader cross-conversation recall. Scoped empty results include `recall_scope.matches_before_scope` / `matches_after_scope` counts. - `role_filter` — comma-separated roles to include. Discovery defaults to `user,assistant` (tool output is usually noise). Pass `user,assistant,tool` to include tool output (debugging tool behaviour) or `tool` to search tool output only. ### When It's Used diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md index 19c7b710ebf7..112094c8efab 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/sessions.md @@ -436,7 +436,9 @@ Database size: 12.4 MB Agent 内置了 `session_search` 工具,使用 SQLite 的 FTS5 引擎对所有历史对话进行全文搜索,并允许 agent 滚动浏览找到的任何 session。无需 LLM 调用、无需摘要、无截断。每种调用形式都从数据库返回实际消息。 -### 三种调用形式 +在带有聊天/session 来源的 gateway 上下文中,发现和浏览默认限定在当前聊天历史中,避免把其他房间或私信的召回内容泄漏进当前对话。只有明确需要跨对话召回时才传入 `scope="all"`。当前来源之外的结果会标记 `same_origin=false`;分享前应把它当作跨聊天历史并先确认。范围内无结果时会包含 `recall_scope` 计数,用于区分“本聊天没有结果”和“只有其他聊天有匹配”。 + +### 调用形式 工具根据你设置的参数推断意图,没有 `mode` 参数。 @@ -478,7 +480,7 @@ session_search(session_id="20260510_174648_805cc2", around_message_id=590803, wi session_search() ``` -按时间顺序返回最近的 session(标题、预览、时间戳)。当用户询问"我在做什么"而未指定主题时很有用。 +按时间顺序返回最近的 session(标题、预览、时间戳)。在带有聊天/session 来源的 gateway 上下文中,默认限定在当前聊天,除非传入 `scope="all"`。当用户询问"我在做什么"而未指定主题时很有用。 ### FTS5 查询语法 @@ -492,6 +494,7 @@ session_search() ### 可选参数 - `sort` — `newest` 或 `oldest`,在 FTS5 排名之上排序。省略则仅按相关性排序(默认;适合探索性召回)。对于"我们在哪里停下了 X"的问题使用 `newest`,对于"X 是怎么开始的"的问题使用 `oldest`。 +- `scope` — `chat` 或 `all`。在带有聊天/session 来源的 gateway 上下文中,发现和浏览默认使用 `chat`;通过 `session_id` 显式读取不受此范围限制。只有用户要求更广泛的跨对话召回时才使用 `scope="all"`。范围内无结果时会返回 `recall_scope.matches_before_scope` / `matches_after_scope` 计数。 - `role_filter` — 逗号分隔的角色列表。发现模式默认为 `user,assistant`(工具输出通常是噪音)。传入 `user,assistant,tool` 以包含工具输出(调试工具行为),或传入 `tool` 仅搜索工具输出。 ### 使用时机