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():
- Validates the question (rejects empty/whitespace-only input).
- Loads the conversation record from
keyvalue.expirable, collectionai_answers.conversations. Follow-up turns are pinned to the record's agent. - Gates:
AgentSettingsRegistry::getSettings()must show the agent enabled for answers;RagSettingsResolver::resolve()must return a non-null result.RagSettingsResolveritself never throws. It returns?array,NULLwhen the agent has noai_search:rag_searchtool configured or no forced index.AnswerService::answer()is what turns aNULLresolve into a thrownDomainException. 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 throwOutOfBoundsException.generate()runs the agent throughAiAgentEntityWrapper(plugin.manager.ai_agents):setChatInput()with the threaded history, an explicitsetRunnerId()(a UUID generated byAnswerServiceitself, since the wrapper's own tagging has no other injection point; see ADR 0007),determineSolvability(), thensolve().- 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 (see ADR 0006). - The stream is consumed inside the same
try/finallyblock that ownsAgentRunContext;finallyclears 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 (see ADR 0003).
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.
AnswerSystemPromptSubscriberlistens toBuildSystemPromptEvent(ai_agents.pre_system_prompt, defined in theai_agentsmodule) 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 becauseai_context's equivalent subscriber runs at priority 0 and must layer on top of this module's additions, not be overwritten by them.AnswerToolResultSubscriberlistens toAgentToolFinishedExecutionEvent(ai_agents.tool_finished_executed, also defined inai_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 idai_search:rag_searchand aStructuredExecutableFunctionCallInterfaceinstance, and readsgetStructuredOutput()['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.
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, falling back to the untouched list if the text cites
nothing at all (see ADR 0011). 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
(see ADR 0007). 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 (see
ADR 0009). 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 (see ADR 0009).
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.