Skip to content

feat: session topic segmentation — lightweight sub-contexts within sessions - #72149

Open
nuffin wants to merge 35 commits into
NousResearch:mainfrom
nuffin:feat/session-topic-segmentation
Open

feat: session topic segmentation — lightweight sub-contexts within sessions#72149
nuffin wants to merge 35 commits into
NousResearch:mainfrom
nuffin:feat/session-topic-segmentation

Conversation

@nuffin

@nuffin nuffin commented Jul 26, 2026

Copy link
Copy Markdown

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. /new is 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_compact scoped 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

  • ✨ New feature (non-breaking change that adds functionality)
  • ✅ Tests (adding or improving test coverage)

Changes Made

  • hermes_state.pysession_topics table + messages.topic_id column + TopicManager CRUD methods
  • agent/system_prompt.py — topic index table injection at end of system prompt
  • agent/conversation_loop.pyTOPIC: <name> instruction injected per user message (avoids system prompt decay)
  • run_agent.py — parser, fuzzy matcher, auto-create first topic
  • agent/conversation_compression.pyarchive_and_compact scoped to active topic
  • cli.py / hermes_cli/cli_commands_mixin.py/topic list|new|switch
  • tests/run_agent/test_session_topic_segmentation.py — 23 unit tests

How to Test

  1. Start hermes, send messages spanning multiple topics (e.g. docker questions, then cooking, then back to docker)
  2. Agent emits TOPIC: <name> at end of each response
  3. /topic list shows topics grouped correctly with proper counts
  4. Switching back to a previous topic auto-matches via fuzzy name matching

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature
  • I've added tests for my changes (23 unit tests)
  • I've tested on my platform: WSL2 (Ubuntu 24.04)

Documentation

  • N/A — no new config keys or docs needed

@alt-glitch alt-glitch added type/feature New feature or request comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state area/sessions Session lifecycle, resume, persistence, history area/compression Context compression and continuation sessions labels Jul 26, 2026
@nuffin
nuffin force-pushed the feat/session-topic-segmentation branch 3 times, most recently from 1040539 to 2d777b3 Compare July 30, 2026 12:45

@teknium1 teknium1 left a comment

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.

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:5586 writes messages.topic_id, but the schema-owning hermes_state_common.py:192 is not changed; the PR contains neither a session_topics table nor a messages.topic_id migration.
  • hermes_state.py:7156 adds get_topic_messages, but get_messages() still selects every active message for the session at hermes_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/1744 invalidate the system prompt on transitions, while agent/system_prompt.py:602-604 adds mutable topic state to it. This conflicts with the byte-stable prompt-cache invariant in AGENTS.md:19-23 and AGENTS.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 /topic registry change conflicts with the existing Telegram handler at gateway/run.py:14304-14305 and 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 /topic surface or separate the command routing by platform.

Automated hermes-sweeper review.

Comment thread hermes_state.py
Comment thread run_agent.py Outdated
Comment thread hermes_state.py
Comment thread hermes_state.py
Comment thread hermes_cli/commands.py
@nuffin

nuffin commented Jul 30, 2026

Copy link
Copy Markdown
Author

Thanks for the thorough review. All five issues addressed:

  1. Schema/migrationmessages.topic_id column and session_topics table added to SCHEMA_SQL in hermes_state_common.py. The declarative column reconciliation in _init_schema handles existing databases.

  2. Context isolationget_messages() now accepts an optional topic_id parameter that filters to a single topic. Backward compatible: None returns all messages.

  3. Cache invalidation_invalidate_system_prompt() calls removed from _auto_create_first_topic and _switch_to_topic. Topic state lives in the volatile prompt section that changes per-call; no cache rebuild needed.

  4. Validation orderset_active_topic() now checks the target topic exists before archiving the current active topic.

  5. Command conflict/topic renamed to /session-topic to avoid Telegram handler collision.

@nuffin
nuffin requested a review from teknium1 July 30, 2026 13:30
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:blast-massive Sweeper blast radius: massive — everyone, every turn (invariant surface) labels Jul 30, 2026

@nuffin nuffin left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

nuffin added 5 commits August 17, 2026 21:10
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).
nuffin added 29 commits August 17, 2026 21:12
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.
@nuffin
nuffin force-pushed the feat/session-topic-segmentation branch from 7a67de0 to b317ae6 Compare August 17, 2026 13:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions area/sessions Session lifecycle, resume, persistence, history comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:blast-massive Sweeper blast radius: massive — everyone, every turn (invariant surface) sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants