Conversation Threading - Z-M-Huang/openhive GitHub Wiki
Conversation Threading
Topic-based conversation threading allows users to work on multiple concurrent topics with the main assistant. The server classifies each incoming message into a topic and routes it to a dedicated session, enabling parallel processing without waiting for one conversation to finish before starting another.
For message transport and adapter details, see Channel-Adapters. For the request processing pipeline that threading plugs into, see Request-Processing. For the architectural decision behind this feature, see Architecture-Decisions#ADR-27.
Overview
Problem. The current architecture processes one message at a time per WebSocket connection (per-socket serialization). A second message sent while the first is still processing gets rejected. Users cannot work on multiple topics concurrently or reply to a specific ongoing conversation.
Solution. Server-side topic classification routes each message to the correct topic, and each topic gets its own parallel main agent session. The server handles all classification internally — clients send plain messages exactly as before, with no thread IDs or topic metadata required on the client side. This is server-side only.
Key optimization. Most messages need no separate classification step. When there are no topics at all, the message starts a new topic automatically. When there is exactly one active topic, the main agent evaluates during its normal processing whether the message continues the current topic or starts a new one — no separate classifier is invoked, as the evaluation is embedded in the agent's regular inference. Only when two or more topics are active does the server make a lightweight LLM call to classify the message before it reaches the agent. In typical usage patterns, the majority of messages fall into the zero-or-one case.
Topic Storage
A new topics table in the SQLite database stores all topic state:
| Column | Type | Purpose |
|---|---|---|
id |
TEXT PK | Topic identifier, format t-{random} |
channel_id |
TEXT | The channel this topic belongs to |
name |
TEXT | Human-readable topic name (generated by LLM) |
description |
TEXT | Brief description of the topic's subject |
state |
TEXT | Current lifecycle state: active, idle, or done |
created_at |
TEXT | ISO 8601 timestamp of topic creation |
last_activity |
TEXT | ISO 8601 timestamp of most recent message |
Two existing tables gain a topic_id TEXT column:
task_queue— Associates queued tasks with their originating topic. Used for notification routing so that task completion results are delivered back to the correct topic session.channel_interactions— Associates each interaction record with its topic. Used to reconstruct per-topic conversation history when a topic transitions fromidleback toactive.
Topic Lifecycle
Topics follow a state machine driven by message activity and timeouts:
stateDiagram-v2
[*] --> active : New message (no matching topic)
active --> idle : Timeout / session disposed
idle --> active : New message matches\n(fresh session, history rehydrated\nfrom channel_interactions)
active --> done : User or agent closes topic
idle --> done : User or agent closes topic
done --> [*]
Transitions:
- Creation — A new message arrives that does not match any existing topic (or there are no topics). A new
topicsrow is inserted with stateactive, and a fresh main agent session is created. - Active to idle — The session times out after a period of inactivity or the
streamText()session is disposed to free resources. The topic row's state updates toidle. No data is lost — all interactions are already persisted inchannel_interactions. - Idle to active — A new message is classified to an idle topic. A fresh
handleMessage()/streamText()session is created, and the conversation history is rehydrated fromchannel_interactionsrows filtered by thistopic_id. The topic state returns toactive. - Done — The user explicitly closes a topic, or the agent determines the topic is resolved. The state becomes
doneand the topic no longer participates in classification.
Topic Classification
The classification strategy is optimized to avoid LLM calls in the common case:
| Active Topics | Method | Latency |
|---|---|---|
| 0 (no topics exist) | Always create a new topic | None |
| 0 active, idle topics exist | Lightweight LLM checks if message matches an idle topic; rehydrate if match, else create new | ~200-500ms |
| 1 | Main agent evaluates during normal processing whether message continues or starts new topic | None (part of inference) |
| 2+ | Lightweight LLM classification call | ~200-500ms |
For the 1 topic case: the main agent's own reasoning during normal streamText() processing determines whether the message continues the current topic or starts a new one. If the agent decides the message is unrelated, it signals topic creation. No separate classifier component is involved — the evaluation happens as part of the agent's regular inference, adding no latency.
For the 2+ topics case:
- Input: The user's message text plus a compact topic list containing each topic's
id,name, and last message snippet. - Output: Either an existing
topic_id(route to that topic) or"new"(create a fresh topic). - Model: Can use a cheaper or faster model than the main agent. The classification prompt is small and structured, making it suitable for lightweight inference.
Where it runs. The TopicClassifier sits in the request processing pipeline after trust evaluation: messages flow from ChannelRouter through TrustGate then TopicClassifier before reaching handleMessage(). Untrusted messages are rejected by TrustGate before they ever reach the classifier or LLM. See Architecture-Decisions#ADR-30 for trust policy details.
The TopicClassifier inspects the current topic count for the channel, applies the optimization table above, and either creates a new topic, routes to an existing one, or invokes the lightweight LLM to decide.
Per-Topic Sessions
Each active topic gets its own handleMessage() / streamText() session running independently. This means multiple topics can be processing simultaneously for the same user on the same channel.
Relaxed session constraint. The existing "one session per team" rule is relaxed for the main agent only. The main agent may have multiple concurrent sessions (one per active topic). Child teams remain unaware of topics entirely — when the main agent delegates a task, it enters the task_queue with a topic_id, but the child team processes it like any other task without topic awareness.
Shared system prompt, separate histories. All topic sessions for the same channel share the same system prompt (identity, rules, skills). However, each session's conversation history is scoped to its topic, loaded from channel_interactions rows where topic_id matches. This prevents context contamination between topics while keeping the agent's capabilities consistent.
Schedule-triggered sessions. Schedule-triggered internal operations (learning, reflection) run as sessions on their target subagents (per ADR-40). These sessions are not topic-scoped — they follow the standard trigger execution path (see Triggers). The main agent has no learning or reflection triggers — it only routes. See Architecture-Decisions#ADR-37 and Self-Evolution.
Channel Protocol
Topics are internal to OpenHive. All channel adapters send flat messages — no adapter uses external threading features (e.g. Discord threads). The server handles all topic routing internally; clients send plain messages and receive plain responses.
WebSocket
Server-to-client messages include a topic_name field as informational context, allowing the client to display which topic a response relates to. Responses from multiple topics interleave on the wire.
A topic_list message type lets clients query active topics:
| Type | Direction | Purpose |
|---|---|---|
topic_list |
Server → Client | Sends the current list of topics (id, name, state) for the channel |
Client sends messages unchanged. The client continues to send {content: "..."} with no topic metadata. The server classifies and routes.
Discord
The Discord adapter sends flat channel messages. Topics are tracked internally — no Discord threads are created. Responses appear as regular messages in the watched channel.
Interaction with Existing Systems
Task queue. The topic_id column on task_queue tracks which topic spawned each task. When a child team completes a task and a notification is generated, the topic_id ensures the result is delivered to the correct topic session. Child teams themselves are unaware of topics — they process tasks identically regardless of whether a topic_id is present.
Notification routing. The combination of sourceChannelId and topicId provides complete routing information. The sourceChannelId identifies which adapter and channel to deliver to, and the topicId identifies which topic session (and therefore which conversation stream) receives the notification. See Channel-Adapters for the base notification routing mechanism.
Triggers. Keyword and message triggers carry routing context (sourceChannelId and topicId) from the event that fired them. Schedule triggers have no channel or topic context — they are non-notifying by default. If a schedule-triggered task needs attention, it uses escalate() to notify its parent (see Triggers#Notification Routing & Policy).
Prompt cache. Topic threading does not affect prompt caching. The static system prompt prefix (identity, rules, skills) is the same across all topic sessions for a channel. Each session benefits from the cached prefix independently. See Request-Processing for prompt caching details.
Edge Cases
Multi-topic messages. A user message may reference multiple active topics (e.g., "How does the auth feature affect the deployment?"). Future capability. Multi-topic fan-out is not currently implemented — the classifier routes each message to a single topic. The TopicSessionManager ensures same-topic requests are serialized while different topics run in parallel. If the classifier cannot determine a relevant topic, it falls back to creating a new one.
Maximum concurrent topics. A configurable limit (default: 5) caps the number of active topics per channel. When the limit is reached, new topic creation is rejected and the user is informed which topics are active, with a suggestion to close finished topics. The limit prevents unbounded resource consumption from parallel sessions.
All topics idle. When all topics for a channel have transitioned to idle state and a new message arrives, the system uses a lightweight LLM call to check whether the message matches any idle topic. If a match is found, that topic is rehydrated (transitioned back to active with conversation history loaded from channel_interactions). If no match, a new topic is created. This prevents unnecessary topic proliferation when users return to previous conversations after a pause.