Global LLM-based guardrails cause infinite recursive LLM calls (no re-entrancy guard)
### Problem/Motivation When an LLM-based ("non-deterministic") guardrail — for example **Restrict to Topic** — is configured as a **global guardrail** (the global guardrails feature added in 1.4.x, [#3584851](https://www.drupal.org/project/ai/issues/3584851)), every AI chat request triggers **unbounded recursive LLM calls**. The root cause is a missing re-entrancy guard. The chain is: 1. `ProviderProxy` dispatches a `PreGenerateResponseEvent` for **every** provider `chat()` call — `src/Plugin/ProviderProxy.php:244`. 2. `GlobalGuardrailsEventSubscriber::attachGlobalGuardrails()` runs on that event at priority `100` and **unconditionally attaches the configured `ai.settings.global_guardrails` sets to every input** — `src/EventSubscriber/GlobalGuardrailsEventSubscriber.php:79`. 3. `GuardrailsEventSubscriber` then evaluates the attached set and, for an LLM-based guardrail, calls `processInput()` — `src/EventSubscriber/GuardrailsEventSubscriber.php:89`. 4. `RestrictToTopic::processInput()` makes its **own** LLM call to classify the text — `$ai_provider->chat($input, $model, ['ai'])` at `src/Plugin/AiGuardrail/RestrictToTopic.php:294`. 5. That inner `chat()` goes back through `ProviderProxy` → a new `PreGenerateResponseEvent` → `GlobalGuardrailsEventSubscriber` attaches the **same** global set to the guardrail's own internal request → the guardrail runs again → another inner `chat()` → **infinite recursion**. Nothing marks or excludes a guardrail's own LLM requests from guardrail processing. The `['ai']` tag passed at `RestrictToTopic.php:294` is never inspected for this purpose, and the guardrail subscribers ignore tags. > **Why it surfaces now:** before global guardrails, this was masked only incidentally — `RestrictToTopic::processInput()` builds a fresh `ChatInput` (`RestrictToTopic.php:271`) **without** a guardrail set, so the internal call hit `getGuardrailSets() === []` and returned early. The global subscriber defeats that safeguard by re-attaching the global set to _every_ input, including the guardrail's internal one. So the defect is the absent re-entrancy guard, not the input construction. ### Steps to reproduce 1. On 1.4.x, create a **Restrict to Topic** (LLM-based) guardrail with a chat provider/model, inside a guardrail set. 2. Add that set under **Global guardrails** (Global guardrail settings form → `ai.settings.global_guardrails`). 3. Make any chat request (e.g. via the AI API Explorer). 4. Observe recursive LLM calls: the guardrail's own topic-classification request is itself globally guarded, which fires another classification request, and so on. 5. Eventually you will run out of memory. ### Impact - Infinite recursion → runaway LLM API requests and uncontrolled token cost/billing, unbounded call-stack growth, and an eventual fatal error (request timeout / memory exhaustion). - Makes the global-guardrails feature unusable with **any** LLM-based guardrail, which is one of its most useful applications. ### Proposed resolution Add a re-entrancy guard so a guardrail's own LLM calls are not themselves guarded. Options: 1. **Tag-based skip (preferred).** Reserve a tag for guardrail-issued calls (the internal call already passes `['ai']`; a dedicated `guardrail_internal` tag is clearer) and have both `GlobalGuardrailsEventSubscriber` and `GuardrailsEventSubscriber` early-return when the `PreGenerateResponseEvent` carries it. The tags are already available on the event. 2. **Request-scoped depth/flag.** Set a flag (or depth counter) while a guardrail is executing and skip guardrail attachment/evaluation for any nested call until it unwinds. 3. **Explicit opt-out.** Have inputs created inside guardrails explicitly opt out of global attachment, and make that contract enforced rather than incidental. A test should cover: a global LLM-based guardrail must result in exactly one guardrail evaluation per user request (the guardrail's internal LLM call must not be re-guarded). ### API changes None expected — this is an internal behaviour change. --- _Filed with AI assistance (Claude Code), based on analysis of the 1.4.x source._
issue