Skip to content

Architecture

Request flow

POST /ai-answers/question requires the use ai answers permission and a valid X-CSRF-Token request header, both enforced at the routing layer (ai_answers.routing.yml), not in the controller. AiAnswersController::question() decodes the JSON body, then content-negotiates on the Accept header: a request for text/event-stream streams Server-Sent Events via streamAnswer(); anything else returns a single JSON Answer via jsonAnswer(). Both paths call the same AnswerService::answer(), differing only in whether streaming callbacks are passed. Before the SSE stream starts, the controller explicitly releases the session write lock ($session->save()) so it doesn't block other concurrent requests in the same session.

Exceptions from answer() map to HTTP status in the controller: DomainException → 409, InvalidArgumentException → 400, OutOfBoundsException → 404, anything else → 500 (logged, generic message returned).

Answer pipeline

AnswerService::answer():

  1. Validates the question (rejects empty/whitespace-only input).
  2. Loads the conversation record from keyvalue.expirable, collection ai_answers.conversations. Follow-up turns are pinned to the record's agent.
  3. Gates: AgentSettingsRegistry::getSettings() must show the agent enabled for answers; RagSettingsResolver::resolve() must return a non-null result. RagSettingsResolver itself never throws. It returns ?array, NULL when the agent has no ai_search:rag_search tool configured or no forced index. AnswerService::answer() is what turns a NULL resolve into a thrown DomainException.
  4. prepareConversation() authorizes the caller against the conversation's owner uid. Anonymous-owned conversations are bearer-by-id (anyone holding the conversation id can resume them); authenticated-owned conversations only resume for the same uid. Unknown or mismatched-owner conversations throw OutOfBoundsException.
  5. generate() runs the agent through AiAgentEntityWrapper (plugin.manager.ai_agents): setChatInput() with the threaded history, an explicit setRunnerId() (a UUID generated by AnswerService itself, since the wrapper's own tagging has no other injection point), determineSolvability(), then solve().
  6. On a brand-new conversation (no prior turns), the service forces retrieval directly via forceRagSearch() rather than leaving the choice to the agent. On a follow-up turn, retrieval is entirely the agent's own tool-call decision: if it doesn't call the tool, the previous turn's held sources are reused.
  7. The stream is consumed inside the same try/finally block that owns AgentRunContext; finally clears the context. This matters because a tool-calling agent's second round only executes lazily, while the caller iterates the stream. Clearing the context any earlier silently drops the sources block before round 2 runs.
  8. After generate() returns, the no-answer message is substituted for $text whenever nothing has actually reached the client yet: either the non-streaming path found no usable sources, or generation produced no text at all on either path. $onToken is only ever invoked with the same non-empty pieces $text accumulates, so $text === '' means no token frame was sent either, making the substitution safe even when streaming. A streamed answer that already reached the client with some text is left alone, even with zero usable sources, since it may already be visible; the citation contract handles that case instead.

Subscriber bridge

AgentRunContext is a single-slot service (not keyed by runner id) storing the current run's agent id, base system prompt, an optional sources block, and a tool-results callback.

  • AnswerSystemPromptSubscriber listens to BuildSystemPromptEvent (ai_agents.pre_system_prompt, defined in the ai_agents module) at priority 10. It guards on the event's agent id matching the run context's agent id: if they don't match, it returns without touching the prompt, protecting against event bleed across unrelated concurrent runs. When they match, it appends the citation contract, guidance, and sources block to the agent's own system prompt; it never replaces it. Priority 10 is chosen because ai_context's equivalent subscriber runs at priority 0 and must layer on top of this module's additions, not be overwritten by them.
  • AnswerToolResultSubscriber listens to AgentToolFinishedExecutionEvent (ai_agents.tool_finished_executed, also defined in ai_agents) at the default priority. Like the subscriber above, it first guards on the event's agent id matching the run context's agent id, returning early on a mismatch. It then filters for plugin id ai_search:rag_search and a StructuredExecutableFunctionCallInterface instance, and reads getStructuredOutput()['results'] back into the run context.

Source processing

sourcesFromToolResults() applies the score gate, then maybeRerank() reorders using the site-default rerank provider if one is configured, a stopgap pending ai_reranker (see the rerank known gap). applyEntityCap() caps by distinct entity, skipping rather than breaking so extra chunks of already-included entities aren't lost. renderReferences() dedupes per entity, does a translation-aware load with an access re-check, and renders each in the agent's configured view mode. On the SSE path the references event fires before generation completes, so it can only list every retrieved source at that point.

A tool-calling turn can call rag_search more than once if the model judges one round's results insufficient. resolveRetrievedSources() takes an $existingSources parameter for exactly this: each round's own candidates are merged onto whatever the turn already gathered, ahead of renderReferences()'s own dedupe, so an entity already assigned a citation number keeps that same position (and therefore that same number) no matter how many further rounds run. Without this, a later round would replace $sourcesUsed outright and a citation the model wrote against an earlier round's numbering would resolve against a completely different round's sources by the time the answer finishes.

Once generation finishes, finalizeCitations() drops any source that never got an inline [n] marker in the text and renumbers the survivors in first-citation order, returning an empty sources array if the text cites nothing at all. On the JSON path this is the only Sources list the client ever sees. On the SSE path the done event separately carries this corrected text/references pair, and ai_answers.answer.js swaps to it once done arrives. The earlier references event's list is provisional.

Persistence & feedback

Each turn stores log and trace ids captured once, via a single query for the newest ai_agents_runner_<runnerId> ai_log entry. FeedbackLogger authorizes against the same conversation record and owner rule as AnswerService, requires the agent's feedback_enabled setting, annotates the stored ai_log entry (deduping any prior feedback:* tag rather than accumulating them, and writing an ai_answers_feedback extra-data payload), and optionally scores a Langfuse trace when a trace_id was captured.

Front end

Both blocks render only configuration and drupalSettings into a cacheable static shell. Answer content always arrives afterward via the API, never baked into cached markup. The Answer block's DOM id is Html::getUniqueId('ai-answers-answer-' . $instanceUuid), which deduplicates a colliding id within the same request rather than emitting it verbatim. The Question block doesn't recompute this formula at render time; its admin form enumerates placed Answer blocks and stores the already-computed id string as the target setting, which its JS resolves at runtime with document.getElementById().

Wire protocol: { agent, question, conversation_id? } in; SSE events references / token / done / error out. Drupal.aiAnswers.answer.ask() is the sole function that actually issues the request. There is no parallel implementation of the fetch itself. Chip click and same-page form submit (ai_answers.question.js's submit()) go through dispatchAsk() first, which calls ask() directly when the target Answer block is on the same page, or falls back to an ai-answers:ask CustomEvent that the Answer block's own listener turns back into an ask() call. The cross-page fragment handoff and the same-block follow-up form call ask() directly, bypassing dispatchAsk() entirely, since both already run on the Answer block's own page. CSS :not([hidden]) scoping is required only on elements with a non-none display override that would otherwise fight the [hidden] attribute: currently the feedback and follow-up containers; the references list has no such override and needs no guard.

A references section (local or a paired Sources block) is shared across every turn in a conversation, not rebuilt per question: ask() assigns each call its own turnId (a client-side counter, independent of the backend's own turn/conversationId, which only arrive on the done frame — too late for the references frame's own render) and every citation anchor, reference <li> id, and pending-placeholder element carries that turn as a data-ref-turn attribute. renderReferences() and renderReferencesPending() only ever add or remove elements tagged with the current turn, so an earlier turn's already-rendered references, and a still-open earlier turn's citation links, survive later turns starting to stream, erroring, or re-rendering their own references twice (once on the references frame, again on done). The one exception is a genuinely new (non-follow-up) question, where every section is fully cleared: there is no earlier turn's state left to preserve. Only the <ol>'s own running count is shown per item now; the reference <li> no longer renders its own [n] marker (removed after the shared list across turns made a turn-restarting index collide visibly with the list's own numbering), though data-ref-index/data-ref-turn still carry the real per-turn identity for anchoring.

The Sources block follows the same targeting pattern as the Question block. Its admin form stores the target Answer block's DOM id as the target setting, rendered into the data-ai-answers-sources-target attribute on its own section (see SourcesBlock::build()). There's no shared registry linking the two. ai_answers.answer.js's referenceSections() finds every matching Sources-block section by DOM query ([data-ai-answers-sources-target="<answer-root-id>"]) at render time, alongside the Answer block's own inline references list if it has one, and fills all of them with the same reference data. A Question/Answer pair can have zero, one, or several standalone Sources blocks placed anywhere on the page.