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