Agent loop
Since version 0.6.3 the backend AI Chat does not run its own tool loop.
ChatService delegates the whole chat turn to nr-llm's AgentRuntime
(nr-llm ADR-101), which drives the model over nr-llm's builtin tool
registry and returns the settled result synchronously.
Processing a turn
ChatService::processConversation() performs the following steps:
- If no nr-llm Task is configured (
llmTaskUidis0), the conversation is set tofailedwith a descriptive message. - Resolve the
LlmConfigurationthe chat should use from the configured Task (llmTaskUid->Task->getConfiguration()). A missing Task or Configuration fails loudly rather than silently degrading to a no-tools chat. - Keep the conversation
locked:ai-chat:processandai-chat:workerclaimed it aslockedbefore calling the service, and the turn start writeslockedagain (refreshing the timestampai-chat:cleanupmeasures). Aprocessingrow is one waiting for a consumer, so writingprocessinghere would let a second consumer take the running turn. - Build the message transcript: a
systemmessage carrying the identity/behaviour contract and the resolved Task/Configuration prompts (see Architecture > System prompt priority), followed by the stored conversation messages. File attachments are expanded to the multimodal wire shape and forwarded as array messages. - Call
AgentRuntimeInterface::run()with anAgentRunRequestbuilt from the configuration, the messages and the initiating backend user uid.allowedToolNamesis left atnullso the run is offered the whole globally-enabled tool set; nr-llm's own tool gate (RBAC, global enable cascade, per-configuration groups) stays authoritative. The request carries aToolOptionsobject with the caller source and the site of the conversation's page (see below). - Map the returned
AgentRunResultonto the conversation.
Caller-source attribution
Every run started here is tagged with withCallerSource() so nr-llm's
Analytics module lists this extension's usage and cost under
nr_mcp_agent instead of grouping it as Unattributed. The operation
names the turn:
chatTurn-- a turn on a queued conversation.resumeChatTurn-- the same turn re-run over an existing transcript byresumeConversation().
The tag is call metadata persisted on nr-llm's telemetry row; it is never
sent to the provider, and no provider option is overridden by it. nr-llm
stores it beside the run's suspended state and puts it back when the run
continues after an approval or an answer
(AgentRuntimeInterface::approve(), submitInput()).
The site of the page
A turn about a page states the identifier of that page's site with
withSite(). nr-llm then checks the run against that site's term lists
and the lists bound to no site, and leaves the lists of other sites out
(nr-llm ADR-238, ADR-244). The site is stored with the suspended run, so a
continuation after an approval or an answer is checked the same way. A turn
about no page, or about a page that belongs to no site, states no site, and
every forbidden term of every site applies.
The same site decides the configuration (ADR-033: A site's own configuration runs the chat). Where it names
its own in the site setting nrllm.configuration (nr-llm ADR-244), the
turn runs on that configuration instead of the Task's; the Task still adds
its prompt template. SiteConfigurationSelector reads the setting
through nr-llm's ConfigurationResolver::resolveForSite() with the
turn's actor and refuses, before any model call, a configuration that
cannot be used, one without a fixed model, and one whose model lacks the
capability tools or whose provider adapter is not tool-capable. The
turn then fails with a code from SiteConfigurationRefusal; it never
falls back to the Task's configuration. A site that names none, a turn
about no page and a page in no site keep the Task's configuration.
A run waiting for an approval or an answer keeps the configuration it
started with. Before approve() or submitInput() the chat asks the
site again: when it now names another configuration than the run's, or one
the chat refuses, the decision or answer is handed back with
handBack.siteConfig / handBack.inputSiteConfig and nr-llm is not
called. A new message starts a turn on the site's configuration.
Outcome mapping
AgentRuntime::run() never throws for a run outcome; it returns a
settled AgentRunResult. ChatService maps it as follows:
COMPLETED-- append the final assistant answer (ToolLoopResult::$finalContent), set statusidleand clear the error message. The clearing matters because the row is written whole: without it a message an earlier state of the same turn wrote -- the reason a refused decision wrote back, above all -- survives the run that resolved it, and a finished conversation goes on looking failed.AWAITING_APPROVAL-- set statusawaiting_approval, store the run uuid for the link, and clear the error-message field. Not a failure: the run stopped before a write and is waiting for a human. No sentence is stored for the pause: the chat renders the notice from the status, as the labelchat.approvalPending(or, for a reader who may not decide approvals,chat.approvalPendingElsewhere) in the reader's language -- a stored sentence is frozen in the language of the moment it was written (NEXT-159). The field is not always empty in this state: when nr-llm refuses a recorded decision and hands the run back still pending, the reason is stored as an error code (ApprovalHandBackReason) and shown after the label as the chat's own sentence for it in the reader's language. nr-llm's exception message goes to the log and is never shown. The notice says what is pending, not where to grant it -- the decision is offered on the card directly beneath it, and pointing past that into the AI Tasks module is what led to the same write being approved twice.AWAITING_INPUT-- a tool asks the user for an answer (nr-llm ADR-105). Set statusawaiting_inputand store the run uuid; the question, when the input schema states one, is appended as the assistant's message. The chat offers the answers as reply buttons above the input; the answer is recorded byrecordInput()and handed toAgentRuntimeInterface::submitInput()by the worker. A refusal that leaves the run waiting restoresawaiting_inputwith its reason as a code (InputHandBackReason) (ADR-018).- a refusal for a guided process (nr-llm ADR-214 item 6;
ProcessRefusal). When the process verdict could not be read, or only the person who started the process may decide, the run still waits: the card or the question comes back as for the other hand-backs, with its own code. When the run's configuration requires a second approver, nr-llm stopped the run: the conversation becomesidleand usable again, with the reason as a code (ProcessStopReason), and offers no Retry. - any other outcome (
FAILED,GUARDRAIL_BLOCKED, …) -- set statusfailedwith a sanitized reason taken fromAgentRunResult::$erroror derived from the outcome. The mapping keeps a default arm becauseAgentRunOutcomegains cases in nr-llm minor releases. An error that offersstorableMessage()is stored in that form (ErrorMessageSanitizer::sanitizeException()): nr-llm's refusal of the rule-set judge quotes the passage of the refused text in its message and leaves it out of the storable form (nr-llm ADR-288). The same applies to an exception the runtime throws and to the CLI commands' error output.
Hints of the rule-set judge
In report mode nr-llm's rule-set judge lets a proposed write through and
reports hints: a rule the text of the write may break, or that the text was
not checked (nr-llm ADR-288). The runtime's result carries no hints, so
ChatService runs run(), approve() and submitInput() inside
nr-llm's GuardrailHintCollector (GuardrailHintCapture), the shared
service the guardrail middleware records into, resolved through
GeneralUtility::makeInstance() in the process that runs the turn. An
nr-llm release without the collector runs the calls as they are.
The hints of one call become one assistant line with the notice
ruleHints and the hints under data.hints (kind and message of each),
after what that call added: the answer, the text beside a proposal, or the
question. A call that reports nothing adds no line, and a failed call adds
none, as it is retried over the transcript. The chat renders the label
chat.ruleHints and each hint as text; the model is not sent the line.
A violation hint quotes up to the judge's quote length of the proposed
text, so the line stores that passage; the reasons are in
ChatService::noteRuleHints().
Text the model writes in the same round as a tool call -- the overview of
a guided process beside chat_set_progress, the explanation beside a
proposal, a status line beside a read -- is not in $finalContent,
which holds only the last round's text. For COMPLETED,
AWAITING_APPROVAL and AWAITING_INPUT, ChatService reads it from
the run's llm steps that requested a tool and appends each non-blank
text as an assistant message of its own, in the order of the rounds and
before the answer, the card or the question. A failed run adds none: Retry
runs the turn again over the transcript. Each such message is marked
(Conversation::KEY_BESIDE_TOOL_CALL); on later turns the model reads
it joined with the assistant message that follows, so one assistant turn
stays one message.
The tools the model can call, their execution, retry/back-off on transient provider errors, budget enforcement and the iteration cap all live inside nr-llm now.
Synchronous execution and resume
AgentRuntime::run() is synchronous and drives the entire tool loop in
one call, so a turn never leaves persisted "pending tool calls" in the
conversation. The CLI worker (ai-chat:process / ai-chat:worker)
therefore always calls processConversation().
resumeConversation() re-runs the turn over the existing transcript
for a resumable conversation (processing, tool_loop or
failed) and for the locked conversation a consumer hands it.
Retry from the chat recovers a conversation left processing because
no consumer ever claimed it. A conversation whose consumer crashed
mid-turn stays locked; Retry refuses it with 409 like any busy
conversation, and it is released only when ai-chat:cleanup marks it
failed after five minutes.
It refuses with 409 while an approval decision recorded by
recordDecision(), or an answer recorded by recordInput(), has not
been carried out yet (Conversation::hasPendingApprovalDecision()). Resuming clears the
decision and starts the turn again, so a retry arriving in that window
would run a second turn over the same transcript while the first is still
performing the approved write -- which is how a page and its content
element came to exist twice. A decision no worker ever picked up is not
stranded by the refusal: reconcile() reads the run, sees it still
waiting, clears the decision and hands the card back.
MCP servers
External MCP servers are configured in nr-llm (module MCP Servers), which imports their tools into the same registry and agent loop the chat runs on. The stdio MCP client this extension once shipped was removed in 0.12.0; it had not been used by the chat turn since 0.11.