Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions agent/conversation_compression.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,12 @@ def _release_lock() -> None:
source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"),
model=agent.model,
model_config=agent._session_init_model_config,
user_id=getattr(agent, "_user_id", None),
user_id_alt=getattr(agent, "_user_id_alt", None),
chat_type=getattr(agent, "_chat_type", None),
chat_id=getattr(agent, "_chat_id", None),
thread_id=getattr(agent, "_thread_id", None),
session_key=getattr(agent, "_gateway_session_key", None),
parent_session_id=old_session_id,
)
agent._session_db_created = True
Expand Down
9 changes: 9 additions & 0 deletions agent/tool_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -717,8 +717,17 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
around_message_id=function_args.get("around_message_id"),
window=function_args.get("window", 5),
sort=function_args.get("sort"),
mode=function_args.get("mode"),
scope=function_args.get("scope"),
db=session_db,
current_session_id=agent.session_id,
current_source=agent.platform or getattr(agent, "_platform", None),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only covers the sequential executor. Current main’s concurrent path calls agent_runtime_helpers.invoke_tool() and its session_search branch still forwards only db and current_session_id; please thread this same scope payload through that path and add a concurrent-dispatch regression.

current_user_id=getattr(agent, "_user_id", None),
current_user_id_alt=getattr(agent, "_user_id_alt", None),
current_chat_type=getattr(agent, "_chat_type", None),
current_chat_id=getattr(agent, "_chat_id", None),
current_thread_id=getattr(agent, "_thread_id", None),
current_session_key=getattr(agent, "_gateway_session_key", None),
)
tool_duration = time.time() - tool_start_time
if agent._should_emit_quiet_tool_messages():
Expand Down
138 changes: 138 additions & 0 deletions docs/plans/2026-05-31-session-search-scope-handoff-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
# Session Search Scope + Previous/Handoff Contract Implementation Plan

> **For Hermes:** This plan is the implementation contract for fixing gateway session-search cross-contamination after `/new`.

**Goal:** Prevent `/new` handoff/previous-session requests from retrieving unrelated sessions, especially across QQ users or adjacent projects.

**Architecture:** Store stable session scope metadata in `sessions`, propagate current gateway scope into `session_search`, and add an explicit `previous`/`handoff` retrieval path that does not rely on keyword search. Default gateway recall is scoped; explicit global search remains available for debug/admin use.

**Tech Stack:** Python, SQLite SessionDB, Hermes gateway `SessionSource`, tool executor, `session_search` tool.

---

## Merged Review Contract

This merges Codex design review and the user's follow-up review.

### 1. Dedicated previous/handoff mode

`session_search(mode="previous" | "handoff", scope="current")`

Behavior:

- Only uses the current platform/chat scope.
- Excludes the current session lineage.
- Picks the most recent ended session first (`ended_at DESC`).
- Falls back to last message activity only after `ended_at` ordering.
- Never performs keyword/global discovery.
- If scoped lookup finds nothing, returns an empty result; it must not silently fall back to global search.

This is the core fix for “刚才那个会话 / 交接信息”.

### 2. Stable scope fields

`session_key` may be persisted and used as auxiliary evidence, but default isolation is based on stable business fields:

- `source` — canonical platform/source (`qqbot`, `telegram`, `cli`, `webui`, `cron`, etc.)
- `chat_type`
- `chat_id`
- `thread_id`
- `user_id`
- `session_key`

QQ DM default scope is `source + chat_type + chat_id`; `user_id` is stored but not the primary QQ DM isolation key.

### 3. Scope propagation chain

The current scope must be available from gateway to tool execution:

- gateway adapter creates `SessionSource`
- gateway `SessionStore` persists scope on new/reset session creation
- `run_agent.AIAgent` receives `platform/user_id/chat_id/chat_type/thread_id/gateway_session_key`
- `agent/tool_executor.py` passes those as hidden current-scope kwargs to `session_search`
- `tools/session_search_tool.py` applies scope defaults and filters

### 4. Legacy compatibility with strict fallback

New sessions write full scope fields. Old sessions may have null scope fields.

Rules:

- `previous`/`handoff`: primary path is scoped new fields. Legacy fallback is allowed only within the same `source`, excluding current lineage, bounded to recent/ended ordering, and marked in the response. No cross-source/global fallback.
- Ordinary search/browse: gateway sessions default to current scope. CLI remains broad/legacy-friendly unless explicit scope is provided.
- Global search must be explicit: `scope="global"`.

### 5. Reliable ended_at

`/new`, auto reset, session switch, and compression split should mark old sessions ended. This change depends on existing `SessionStore.reset_session()` and `SessionDB.end_session()` behavior; tests must cover `/new`-style ended-session selection.

### 6. Behavior-level regression tests

Required tests:

- QQ user A/B both mention “新增功能”; A scoped search does not see B.
- A `/new` then `mode="handoff"` returns A's just-ended admissions session.
- Adjacent admissions/tutoring sessions: `mode="handoff"` returns admissions and not tutoring/OCR/PDF/学生档案.
- Current lineage is excluded.
- Scoped no-result does not global fallback unless `scope="global"`.
- Legacy null-scope sessions are not lost, but fallback is source-bounded and flagged.
- CLI search is not accidentally constrained by QQ scope rules.

## Implementation Tasks

### Task 1: Add scope columns and write paths

Modify `hermes_state.py`:

- Add nullable columns to `sessions`: `chat_type`, `chat_id`, `thread_id`, `session_key`, `user_id_alt`.
- Keep indexes referencing new columns after `_reconcile_columns()`.
- Extend `_insert_session_row()` / `create_session()` to accept those kwargs.

Modify `gateway/session.py` and `run_agent.py`:

- Pass scope metadata when creating DB sessions.

### Task 2: Add scope helper + scoped filtering

Modify `tools/session_search_tool.py`:

- Add helper to resolve current scope from hidden kwargs.
- Add helper to decide default `scope`:
- gateway source (`qqbot`, `telegram`, `discord`, `slack`, etc.) + chat scope => current
- CLI/local with no chat scope => legacy/global-ish
- explicit `scope="global"` bypasses scope filters
- Add shared session scope matcher.

### Task 3: Add previous/handoff mode

Modify `tools/session_search_tool.py`:

- Add `mode` schema enum: `previous`, `handoff`.
- Implement previous/handoff selection by current scope, excluding current lineage.
- Return recent session metadata + bookend start/end + messages; no FTS keyword search.
- Return empty when scoped none.

### Task 4: Preserve search behavior with scoped default

Modify discovery/browse paths:

- Apply scope filters when default/current scoped.
- Keep explicit global mode.
- Preserve CLI broad search by default.

### Task 5: Verify

Run focused tests:

```bash
python -m pytest tests/tools/test_session_search.py -o addopts='' -q
python -m pytest tests/gateway/test_session*.py tests/test_hermes_state*.py -o addopts='' -q
```

Then inspect:

```bash
git status --short --branch --untracked-files=all
git diff --stat
git diff --check
```
10 changes: 10 additions & 0 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -937,6 +937,11 @@ def get_or_create_session(
"session_id": session_id,
"source": source.platform.value,
"user_id": source.user_id,
"user_id_alt": source.user_id_alt,
"chat_type": source.chat_type,
"chat_id": str(source.chat_id) if source.chat_id is not None else None,
"thread_id": str(source.thread_id) if source.thread_id is not None else None,
"session_key": session_key,
}

# SQLite operations outside the lock
Expand Down Expand Up @@ -1163,6 +1168,11 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) ->
"session_id": session_id,
"source": old_entry.platform.value if old_entry.platform else "unknown",
"user_id": old_entry.origin.user_id if old_entry.origin else None,
"user_id_alt": old_entry.origin.user_id_alt if old_entry.origin else None,
"chat_type": old_entry.origin.chat_type if old_entry.origin else old_entry.chat_type,
"chat_id": str(old_entry.origin.chat_id) if old_entry.origin and old_entry.origin.chat_id is not None else None,
"thread_id": str(old_entry.origin.thread_id) if old_entry.origin and old_entry.origin.thread_id is not None else None,
"session_key": session_key,
}

if self._db and db_end_session_id:
Expand Down
37 changes: 34 additions & 3 deletions hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

DEFAULT_DB_PATH = get_hermes_home() / "state.db"

SCHEMA_VERSION = 14
SCHEMA_VERSION = 15

# ---------------------------------------------------------------------------
# WAL-compatibility fallback
Expand Down Expand Up @@ -235,6 +235,11 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
user_id_alt TEXT,
chat_type TEXT,
chat_id TEXT,
thread_id TEXT,
session_key TEXT,
model TEXT,
model_config TEXT,
system_prompt TEXT,
Expand Down Expand Up @@ -754,6 +759,21 @@ def _init_schema(self):
# recreates them.
self._drop_fts_triggers(cursor)

try:
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sessions_scope_recent "
"ON sessions(source, chat_type, chat_id, thread_id, ended_at, started_at)"
)
except sqlite3.OperationalError as exc:
logger.debug("idx_sessions_scope_recent create skipped: %s", exc)
try:
cursor.execute(
"CREATE INDEX IF NOT EXISTS idx_sessions_session_key "
"ON sessions(session_key) WHERE session_key IS NOT NULL"
)
except sqlite3.OperationalError as exc:
logger.debug("idx_sessions_session_key create skipped: %s", exc)

# ── Schema version bookkeeping ─────────────────────────────────
# Bump to current so future data migrations (if any) can gate on
# version. No version-gated column additions remain.
Expand Down Expand Up @@ -889,18 +909,29 @@ def _insert_session_row(
model_config: Dict[str, Any] = None,
system_prompt: str = None,
user_id: str = None,
user_id_alt: str = None,
chat_type: str = None,
chat_id: str = None,
thread_id: str = None,
session_key: str = None,
parent_session_id: str = None,
) -> None:
"""Shared INSERT OR IGNORE for session rows."""
def _do(conn):
conn.execute(
"""INSERT OR IGNORE INTO sessions (id, source, user_id, model, model_config,
"""INSERT OR IGNORE INTO sessions (id, source, user_id, user_id_alt,
chat_type, chat_id, thread_id, session_key, model, model_config,
system_prompt, parent_session_id, started_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
session_id,
source,
user_id,
user_id_alt,
chat_type,
chat_id,
thread_id,
session_key,
model,
json.dumps(model_config) if model_config else None,
system_prompt,
Expand Down
7 changes: 6 additions & 1 deletion run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,12 @@ def _ensure_db_session(self) -> None:
model=self.model,
model_config=self._session_init_model_config,
system_prompt=self._cached_system_prompt,
user_id=None,
user_id=self._user_id,
user_id_alt=self._user_id_alt,
chat_type=self._chat_type,
chat_id=self._chat_id,
thread_id=self._thread_id,
session_key=self._gateway_session_key,
parent_session_id=self._parent_session_id,
)
self._session_db_created = True
Expand Down
44 changes: 43 additions & 1 deletion tests/agent/test_compression_concurrent_fork.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@
from hermes_state import SessionDB


def _build_agent_with_db(db: SessionDB, session_id: str):
def _build_agent_with_db(db: SessionDB, session_id: str, **agent_kwargs):
"""Build an AIAgent that's wired to ``db`` and pinned to ``session_id``."""
with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}):
from run_agent import AIAgent
Expand All @@ -53,6 +53,7 @@ def _build_agent_with_db(db: SessionDB, session_id: str):
session_id=session_id,
skip_context_files=True,
skip_memory=True,
**agent_kwargs,
)

# Stub the compressor so it returns deterministic output and DOESN'T make
Expand Down Expand Up @@ -173,6 +174,47 @@ def test_skipped_compression_returns_messages_unchanged(tmp_path: Path) -> None:
agent.context_compressor.compress.assert_not_called()


def test_compression_child_inherits_gateway_scope(tmp_path: Path) -> None:
"""Compression-created child sessions must stay in the same gateway scope."""
db = SessionDB(db_path=tmp_path / "state.db")
parent_sid = "SCOPED_PARENT"
db.create_session(
parent_sid,
source="discord",
user_id="user-a",
user_id_alt="alt-a",
chat_type="group",
chat_id="chat-1",
thread_id="thread-1",
session_key="agent:main:discord:group:chat-1:user-a",
)

agent = _build_agent_with_db(
db,
parent_sid,
platform="discord",
user_id="user-a",
user_id_alt="alt-a",
chat_type="group",
chat_id="chat-1",
thread_id="thread-1",
gateway_session_key="agent:main:discord:group:chat-1:user-a",
)
messages = [{"role": "user", "content": f"m{i}"} for i in range(20)]

agent._compress_context(messages, "sys", approx_tokens=120_000)

child = db.get_session(agent.session_id)
assert child["parent_session_id"] == parent_sid
assert child["source"] == "discord"
assert child["user_id"] == "user-a"
assert child["user_id_alt"] == "alt-a"
assert child["chat_type"] == "group"
assert child["chat_id"] == "chat-1"
assert child["thread_id"] == "thread-1"
assert child["session_key"] == "agent:main:discord:group:chat-1:user-a"


class _NoLockSubsystemDB:
"""Wraps a real SessionDB but simulates a pre-#34351 version skew.

Expand Down
10 changes: 7 additions & 3 deletions tests/test_hermes_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -1791,9 +1791,13 @@ def test_topic_mode_schema_is_not_auto_migrated_on_open(self, tmp_path):
conn.close()

db = SessionDB(db_path=old_db)
cursor = db._conn.execute("PRAGMA table_info(sessions)")
columns = {row[1] for row in cursor.fetchall()}
assert {"chat_id", "chat_type", "thread_id", "session_key"}.isdisjoint(columns)
tables = {
row[0]
for row in db._conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table'"
).fetchall()
}
assert "telegram_dm_topic_bindings" not in tables
db.close()

def test_apply_telegram_topic_migration_creates_topic_tables_explicitly(self, tmp_path):
Expand Down
Loading