feat: session topic segmentation — lightweight sub-contexts within sessions - #72149
feat: session topic segmentation — lightweight sub-contexts within sessions#72149nuffin wants to merge 35 commits into
Conversation
1040539 to
2d777b3
Compare
teknium1
left a comment
There was a problem hiding this comment.
Thanks for tackling the long-session context problem. The current implementation needs substantial rework before its storage and routing model is safe.
Problems
hermes_state.py:5586writesmessages.topic_id, but the schema-owninghermes_state_common.py:192is not changed; the PR contains neither asession_topicstable nor amessages.topic_idmigration.hermes_state.py:7156addsget_topic_messages, butget_messages()still selects every active message for the session athermes_state.py:6159, and no PR call site consumes the topic-specific helper. Topic switching therefore does not isolate model context.run_agent.py:1731/1744invalidate the system prompt on transitions, whileagent/system_prompt.py:602-604adds mutable topic state to it. This conflicts with the byte-stable prompt-cache invariant inAGENTS.md:19-23andAGENTS.md:88-91.set_active_topic()archives the current topic before validating its target (hermes_state.py:7125-7138), so/topic switch <bad-id>can leave no active topic.- The shared
/topicregistry change conflicts with the existing Telegram handler atgateway/run.py:14304-14305and exposes the command to the TUI catalog.
Suggested changes
- Add schema/migration coverage first, then route active-topic selection through actual context construction without rebuilding the system prompt.
- Preserve the existing Telegram
/topicsurface or separate the command routing by platform.
Automated hermes-sweeper review.
|
Thanks for the thorough review. All five issues addressed:
|
nuffin
left a comment
There was a problem hiding this comment.
All 5 inline review comments resolved:
- 3682829433 (schema/migration): fixed in 0b32964c8
- 3682829440 (system prompt invalidation): fixed in 0b32964c8
- 3682829448 (validate before archive): fixed in 0b32964c8
- 3682829454 (get_messages context isolation): fixed in 0b32964c8
- 3682829460 (command name conflict): fixed in 0b32964c8
test_set_active_topic_nonexistent updated to match validate-then-archive behavior. 182/182 tests pass.
4beef45 to
6bd5d8e
Compare
3e8e9a6 to
f7787f6
Compare
f7787f6 to
7a67de0
Compare
Phase 2: topic CRUD operations. - create_topic(session_id, title, summary) → topic_id - get_topics(session_id) → list of topic dicts (ordered by last_active_at) - get_active_topic(session_id) → currently active topic or None - set_active_topic(session_id, topic_id) → archive current + activate target - update_topic_message_count(topic_id, count_delta) - get_topic_messages(session_id, topic_id) → filtered message list All read methods use self._lock for thread safety (matching existing patterns). _write methods delegate to _execute_write().
… prompt Phase 3: system prompt injection. - _build_topic_detection_block(): generates topic index table + detection instruction, placed at the end of the volatile system prompt tier - Topic index: shows up to 5 most recent topics with id/title/msgs/state - Detection instruction: teaches LLM to emit TOPIC_SHIFT and TOPIC_MATCH lines after each response (parsed and stripped before user sees output) - Graceful fallback: returns empty string when no DB/session_id available
Phase 4: response parsing and topic detection state machine. - _parse_topic_signals(): regex-based parser extracts and strips TOPIC_SHIFT and TOPIC_MATCH lines from assistant content - AIAgent._process_topic_signals(): dual counter state machine with configurable thresholds (score>=6, consecutive>=3) - _create_topic_from_shift(): archives current topic, creates new one - _switch_to_topic(): switches active topic to existing one - Counter state initialized in AIAgent.__init__ Integration into response pipeline (Phase 5) follows.
…tence Phase 5a: strip TOPIC_SHIFT/MATCH lines from assistant content before storing to state.db. Content is cleaned in _flush_messages_to_session_db just before append_message() is called for assistant-role messages. Signals are stripped from DB-stored content. User-visible streaming content still carries the lines (integration into streaming pipeline is a future optimization).
Phase 7: prevent compression from archiving messages belonging to other topics. archive_and_compact() now accepts optional topic_id; when set, only messages matching that topic_id are soft-archived. Caller in conversation_compression.py passes agent._active_topic_id. Backward compatible: topic_id=None preserves old behavior (archive all).
Phase 9: CLI /topic command implementation. - commands.py: repurpose /topic CommandDef for session topic management (was gateway-only Telegram DM topics). Now available in CLI. - cli.py: dispatch 'topic' canonical → _handle_topic_command - cli_commands_mixin.py: _handle_topic_command with three subcommands: list (default) — show all topics with active marker switch N — switch active topic to ID N new name — create a new topic
When first user message is persisted and no topic exists, derive a topic name from the first 40 chars of the message and create it. Also passes topic_id to append_message() so new messages are associated with their topic from the start. Mirrors the session title auto-generation pattern.
_add_ensure_topic_for_session() called in _persist_session() BEFORE the DB write lock is acquired, avoiding deadlock when _auto_create_ first_topic calls get_topics() which also needs the lock. Also adds _ensure_topic_for_session() as a simpler path that creates a 'new session' topic when no topics exist.
…uteError AIAgent._active_topic_id may not be initialized in all code paths. Use getattr(..., None) as a defensive guard.
…ssion after it The previous patch accidentally closed the docstring early, causing remaining docstring text (with em-dashes) to become bare code that Python 3.12 rejects as syntax errors.
_build_topic_detection_block was looking for agent.db but SessionDB is stored as agent._session_db. This caused the topic detection block to never be injected into the system prompt.
… /topic new - Instruction now REQUIRES TOPIC_SHIFT on every response (score 1-2 for same topic) - /topic new now archives previous active topic before creating new one
Agent was emitting TOPIC_MATCH instead of TOPIC_SHIFT with inverted argument order. Simplified instruction with explicit format: 'TOPIC_SHIFT: <score> | <name>' and concrete examples.
When agent doesn't emit TOPIC_SHIFT at all (shift is None), treat as staying on same topic (counter=0) instead of resetting. This distinguishes 'no signal' from 'explicit low score'.
…: format Agent now responds with 'TOPIC: <id>' for existing topics or 'TOPIC: new <name>' for new topics. This makes topic classification a natural part of the response rather than hidden metadata. Removed dual-counter state machine — single signal triggers immediately. Simplified _create_topic_from_shift to accept name directly.
…struction - _create_topic_from_shift now archives the current active topic first - System prompt tells agent to only use TOPIC: new when subject has CLEARLY changed; follow-ups on same subject are NOT new topics
… first Agent emits TOPIC: git-merge-strategies (no 'new' keyword). Updated regex to accept both 'TOPIC: <name>' and 'TOPIC: new <name>'. Check MATCH (numeric) before SHIFT (named) to avoid ambiguity.
One regex matches everything. Agent emits TOPIC: <name>. Local code matches name against existing topics (case-insensitive): - Match → switch to that topic - No match → create new topic Removed SHIFT/MATCH distinction, counters, 'new' prefix.
Appends '(Append exactly: TOPIC: <name>)' to the current turn's user message before sending to API. This ensures the instruction is seen every turn, avoiding system prompt decay in long conversations.
- Fuzzy match: 'ramen-noodle' matches existing 'ramen' topic - Instruction now says: 'Use short names like git, cooking, docker' - Both injected into user message for every-turn visibility
…picManager - _parse_topic: 9 tests (extraction, hyphenation, whitespace, edge cases) - TopicManager CRUD: 7 tests (create, get, set_active, message_count, title) - _process_topic_signals: 7 tests (exact match, fuzzy match, case-insensitive, same-topic no-op, no-topic passthrough)
…raint Foreign key session_topics.session_id -> sessions.id blocks session deletion when session_topics rows exist. Fix applies to all five delete paths: _delete_delegate_children, delete_session, delete_session_if_empty, delete_sessions, prune_empty_ghost_sessions.
Two-layer improvement for session topic auto-detection: Layer 1 — Smarter agent instruction (conversation_loop.py + system_prompt.py): Teach the agent to distinguish brief asides from sustained topic shifts. "Stay on the current topic name for brief asides. Only introduce a new name when the conversation shifts to a sustained new subject." Layer 2 — _TopicDriftTracker hysteresis (run_agent.py): A new topic name must appear in 2 consecutive responses before the system switches. A single stray TOPIC signal no longer creates a topic or triggers a switch. Interleaved signals reset the counter each time. Tests: 31 tests, including 4 new hysteresis tests + 4 _TopicDriftTracker unit tests. Existing non-hysteresis tests preserved via explicit _topic_drift=None on the mock agent.
…, validation, naming 1. hermes_state_common.py: add topic_id to messages SCHEMA_SQL, add session_topics table. Reconciliation adds column on existing DBs. 2. run_agent.py: remove _invalidate_system_prompt() calls from _auto_create_first_topic and _switch_to_topic — topic state is in volatile prompt section, no cache rebuild needed. 3. hermes_state.py: validate target topic exists in set_active_topic BEFORE archiving the current active topic. 4. cli.py: rename /topic → /session-topic to avoid Telegram conflict.
Optional topic_id parameter filters messages to a single topic. When None (default), all messages returned — backward compatible.
…n-archive set_active_topic now validates the target topic exists BEFORE archiving the current one (sweeper fix NousResearch#4). When the target doesn't exist, the current active topic stays active — no archive occurs. Update the test assertion from 'warm' to 'active' and the comment to explain the validate-then-archive flow.
Pure-function library for detecting topic shifts between consecutive user messages. Used by both skill-graph (delta injection) and session-topics (system prompt hint). Detection strategy: AND gate combining two signals: - LLM topic_continuation (piggy-backed on intent-split, zero extra cost) - S6 cosine similarity (first-100-char embedding, 88.9% solo accuracy) Both signals must agree on 'same topic' to classify as continuation. Calibrated on 18 real session transitions: 94.4% accuracy (vs 83% LLM-only, 89% S6-only). When either signal is unavailable, falls back to the other. No hermes-agent imports — importable from both core and plugins.
When topic_detection independently confirms a topic shift (pre-LLM, via S6 cosine on the last two user messages), the _TopicDriftTracker threshold is lowered from 2 to 1 for that turn — a single TOPIC signal from the model is enough to switch. This bridges the two detection layers: - Pre-LLM: topic_detection.detect_topic_shift (S6 cosine, fast) - Post-LLM: model's TOPIC: <name> signal (semantic, but unreliable) When both agree on a shift, switch immediately. When only the model says shift, keep the original 2-confirmation hysteresis. When only S6 says shift but the model doesn't emit TOPIC, no switch (model didn't agree). Add set_threshold/reset_threshold to _TopicDriftTracker and _check_topic_shift_from_history to AIAgent (S6-only, no LLM call in the response path to avoid latency).
Tests for the new dynamic threshold behavior: - _TopicDriftTracker.set_threshold/reset_threshold preserve base value - Threshold=1 allows single-signal confirmation (for topic_detection hint) - Threshold floors at 1 (no zero/negative) - Default threshold=2 still requires two consecutive signals - _check_topic_shift_from_history extracts last two user messages - Handles insufficient history, empty history, non-string content, exceptions Also fixes feed() to check threshold on first occurrence (else branch), so threshold=1 actually enables single-signal confirmation.
When topic_detection detects a shift between the last two user messages, inject a hint into the user message's api_content sidecar: [Topic shift detected — this message appears to start a new topic. Emit a new TOPIC: <english-kebab-case-name> at the end of your response.] This guides the LLM to reliably emit a new TOPIC: line without relying on the model's own topic detection, which is inconsistent (skill documents the known problem: 'Agent confirms TOPIC instruction when asked, skips it in normal responses'). The hint uses S6 cosine only (no LLM call in this path — latency-free). Also strengthen _build_topic_detection_block format rules: - TOPIC prefix must be literal English 'TOPIC:' — never '主题:' or '话题:' - Topic name must be English kebab-case - One line, no trailing punctuation, at end of response
_build_topic_shift_hint called detect_topic_shift without embed_fn, so S6 cosine was always None and the function fell through to the 'fallback' branch (no signals) — meaning no hint was ever generated. Add _get_embed_fn() that resolves the TEI endpoint from skills.config.skill-graph.embedding_api_url config, health-checks it, and returns a callable. Cached per-process.
7a67de0 to
b317ae6
Compare
What does this PR do?
Adds session topic segmentation — lightweight, named sub-contexts within a single session. The agent auto-detects topic shifts via
TOPIC: <name>in responses and groups messages by topic. Users can manually switch with/topic list|new|switch.Problem: Sessions run for hours and span many subjects. While debugging code, the user briefly asks about a git command, a cooking recipe, then back to debugging. All these questions pollute each other's context for the LLM.
/newis too heavy — kills the process, loses terminal state.Solution: Topics are instant-switch, sharing terminal / cwd / memory. Agent emits
TOPIC: <name>per response. Fuzzy matching merges "docker-compose" into "docker".archive_and_compactscoped to active topic.Example: debugging (topic 1) → git stash tips (topic 2) → pizza dough recipe (topic 3) → back to debugging (topic 1, auto-matched) → kubernetes pods (topic 4).
Related Issue
N/A — new feature
Type of Change
Changes Made
hermes_state.py—session_topicstable +messages.topic_idcolumn + TopicManager CRUD methodsagent/system_prompt.py— topic index table injection at end of system promptagent/conversation_loop.py—TOPIC: <name>instruction injected per user message (avoids system prompt decay)run_agent.py— parser, fuzzy matcher, auto-create first topicagent/conversation_compression.py—archive_and_compactscoped to active topiccli.py/hermes_cli/cli_commands_mixin.py—/topic list|new|switchtests/run_agent/test_session_topic_segmentation.py— 23 unit testsHow to Test
TOPIC: <name>at end of each response/topic listshows topics grouped correctly with proper countsChecklist
Code
Documentation