← Query Engine

Conversation

query-engine · workzone

A user query is not a standalone question but a message in a conversation, and the conversation belongs to Query Engine: it remembers history, carries context between messages, and holds the session. Yet the turn is driven by the user's chat model — it holds the knowledge-base search tool and decides, on each message, whether to answer on its own or reach into the store. Every surface — Web App, Slack, Telegram, Mattermost, MCP, extension — is just a thin window onto one conversation model; the state lives here, in its own tables (conversations · messages), not on the client.

The conversation belongs to Query Engine

A conversation is stateful: the session groups the steps of one exchange, and history accumulates message by message. That state has to live somewhere — and it lives with Query Engine, the only module that sees both the query and the assembled answer. Its data home is its own tables: sessions and messages with their answers.

Where a conversation begins

A conversation is a durable thread, not an ephemeral login session: it lives until deleted and does not expire on idle (a timeout is about signing in, not about the conversation). The line between a “new” conversation and a “continuation” is drawn not by a separate command but by the presence or absence of a reference to the conversation (conversation_id) in the request: a message with the reference appends to the existing thread, a message without it opens a new one.

A conversation is created lazily — on the first message: we never spin up empty threads; the conversations row is born when the first message is sent, and its identifier is returned to the client for subsequent turns. The “New chat” button opens nothing on the server — it merely clears the active reference on the client, and the new conversation is born by the first message actually sent.

Hence a single lever across all channels: “new chat”, “clear”, and “resume” are not separate API operations but expressions of the same “which reference the client sends”. What counts as a new conversation is up to the surface:

  • Web App — the “New chat” button and the conversation list; clear = resetting the active reference.
  • Slack — native threads: a new thread or DM is a new conversation.
  • Telegram — DMs without threads: the /new command resets the active reference, and the next message starts a new conversation (intercepted on the surface side, never recorded in history).
  • Mattermost — Slack's rule: a new thread or DM is a new conversation.
  • Browser Extension — ephemeral, scoped to the page or tab.

Conversation management — listing, renaming, deleting, retention — lives in the Web App shell and is designed alongside it; the data model is ready for it (the cascade runs off conversations). Here we set only the rule for where the boundary of one exchange falls.

The model decides: answer or search

Not every message is a query to the base. “Thanks”, “rephrase this paragraph”, “what even is an OKR” — ordinary conversation that needs no store. But “what's the deadline for project Ajax” is a question for company knowledge. So the turn is driven by the chat model: it holds a toolset — search_knowledge (when the base is non-empty) and, if the admin enabled it, web_search — and it decides, on each message, whether to answer on its own or reach for them. Conversation → it answers without search and without citations. A question for knowledge → it calls the tools — several at once if needed, in a single round — and builds the answer on what they return.

the chat model drives the turn AI holds search_knowledge · web_search
⟨ need tools? ⟩
conversation answers on its own chit-chat · meta-request · general question — no search, no citations
to the tools calls one round (search_knowledge ± web_search) answer strictly from what was found · with a citation to the source

Bias toward searching. On the edge between “is this chit-chat or a query”, the model leans toward searching: an empty search is cheap, while a made-up company fact is expensive — it breaks trust. This holds when there is something to search: with an empty base, search_knowledge is not offered to the model at all (derived from is_empty), the bias does not apply, and the model answers from general knowledge like an ordinary assistant. What grounding means in each mode and how an honest “not found” sounds is held by grounding. Which tools the chat gets at all is up to the admin: web_search is off by default, and a disabled tool is not offered to the model (tool catalog). Where a single round of tools ends and a full agent loop begins is at the boundary with Agent Engine.

Context carry and a self-contained query

Within a session, messages are linked: “and what about his timeline?” only makes sense against the previous message. So, having decided to search, the model formulates a self-contained query — understandable without the history — and passes it as an argument to search_knowledge. There is no separate rewriting step: the model already has the conversation history in view, so it fills in the omitted subject itself, in the same move that decides to call search. Without this, “and more?” would go to retrieval as “more” — the vector would catch the phrase, not the topic of the conversation.

What happens on a tool call — embed, search under access rights, packing, grounding generation — is held by the search route. How many past turns reach the model in a long exchange is a matter of the window budget.

Context-window budget

The chat model's window is not boundless: everything it sees on one turn shares a common token cap. On a grounding turn, the system instructions, the conversation history, the current message, and the retrieved context all land in it at once — and room must still be left for the answer itself. So overflow is not “history too long” but competition for a single budget: two consumers grow independently, yet are trimmed under one cap.

Context window · one turn
System prompt platform prompt (safety + organization) + grounding instructions + the search_knowledge description fixed
Conversation history past turns · trimmed by Query Engine (token-window) grows over turns ▲
Current message the turn's message always in full
Retrieved context top-K fragments from KS · trimmed by augment to fit the remainder large per turn ▲
Reserve for the answer room for generation — kept free protected

Trimming the window is not losing memory. The verbatim transcript lives in messages, retrieval snapshots in retrieval_trace, and the answer's facts are re-retrieved by a fresh retrieval from Knowledge Store on every grounding turn. The window is a working buffer for one call, not storage: what falls out of it stays in the base. That is why history here can be trimmed more boldly than in a pure chatbot, where it is the only memory.

Shared cap, token-based trimming. History and retrieved context share one window budget rather than being trimmed each under its own limit: augment fits fragments into the remainder left by the trimmed history, keeping the reserve for the answer. History is trimmed from the front by tokens — the earliest turns go first, the current message always in full. This does not break coherent follow-up phrasing: as long as the subject is in the window, the model builds a self-contained query as before.

v2 — a running summary of old turns: when even the trimmed history stops fitting, the earliest turns are collapsed by a separate call into a brief summary, and the window carries “summary + the last verbatim window”. A layer on top of the token-window, not a replacement: it adds memory depth at the cost of an extra call and the risk of losing a low-frequency but important detail across repeated compressions.

One conversation across all surfaces

There are several entry points into the conversation — Web App Slack Telegram Mattermost Browser Extension — and all of them are thin clients: they render messages and stream the answer, but carry no conversation logic. They share one conversation model through a single internal Query Engine API. Sessions, history, context carry, the decision to search — all on the Query Engine side; the surface only opens a session, sends a message, and displays the answer.

This keeps the different channels from diverging in behavior: the conversation rule is implemented once, not copied into each client. A new surface plugs into the same API and gets the same conversation memory for free — including a customer's custom surface through the public contract. → Public API

MCP does not belong here. It is a surface of a different kind — not a conversation client but a supplier of a search tool to an external model: it holds not a conversation but a tool-call, and returns findings, not a finished answer. → MCP

Synchronous answer streaming

The answer is delivered synchronously, as a stream: the tokens of the grounding answer flow to the surface as they are generated, without queuing a task.

Slack is the exception. The messenger does not hold the connection and expects an answer within 3 seconds: the stream is assembled on the server and returned as a post in the thread — receipt is acknowledged immediately, processing runs asynchronously through a queue. → Slack

Feedback on the answer

Every answer carries a lightweight rating: thumbs up / down. This is not an action on the conversation but a quality signal: was the answer good or off the mark. The rating sits next to the message itself in the conversation store — we collect it for the future, to gauge answer quality and tune ranking.