Modules should register the AI request tags they can emit, at install time and dynamically
## Summary
Modules should **register the AI request tags they can emit**, so the set of possible tags is known before any request is made. Registration should happen at installation/discovery time for static tags, and dynamically for tags that only exist because of site configuration — an agent created as `foo` should register something like `ai_agent_foo_request`.
## Problem
Tags are free-form strings passed as the third argument to every operation call, appended to the provider plugin, and only observable once a request has already been executed (line references against 1.5.x):
- `ChatInterface::chat(array|string|ChatInput $input, string $model_id, array $tags = [])` — `src/OperationType/Chat/ChatInterface.php:32`, with the same `array $tags = []` parameter on every operation type
- `AiProviderInterface::setTag()` / `getTags()` / `resetTags()` — `src/AiProviderInterface.php:144,152,157`
- `ProviderProxy::__call()` injects the operation type as the first tag and appends the caller tags — `src/Plugin/ProviderProxy.php:210-215`
- they reach subscribers through `AiProviderRequestBaseEvent::getTags()` — `src/Event/AiProviderRequestBaseEvent.php:208`
There is no registry, no schema, no hook and no discovery — the ai module has no `ai.api.php` at all. That causes four concrete problems.
**Every tag consumer is a free-text field.** The site builder has to already know the exact strings:
| consumer | field |
|---|---|
| ai_observability logging filter | `modules/ai_observability/src/Form/SettingsForm.php:164-172` — textfield, comma separated |
| ai_external_moderation | `modules/ai_external_moderation/src/Form/ModerationConfigurations.php:186-191` — *Select tags to invoke the model on. Should be comma separated. Empty means all.* |
A mistyped tag silently matches nothing. #3586529 proposes scoping guardrail sets by tags as well, which would add a third field with the same problem.
**Consumers already depend on tag conventions they cannot verify.** ai_metering parses `aim_context:{UUID}` out of the tag list and treats `ai_translate` on a `chat` event as a translation (`ai_metering/src/EventSubscriber/AiPostGenerateSubscriber.php:125-151`). ai_agents_explorer filters on the presence of `ai_agents`. These are handshakes between modules with nothing declaring either side.
**Naming has drifted because nothing owns it.** The literals currently in use:
| module | tags |
|---|---|
| ai core | operation type (`chat`, `embeddings`, ...), `ai`, `short_term_memory` |
| ai_api_explorer | `ai_api_explorer`, `chat_generation`, `moderation_generation` |
| ai_automators | `ai_automator`, `ai_automator:type:{rule}`, `ai_automator:entity_type:{type}`, `ai_automator:bundle:{bundle}`, `ai_automator:field_name:{field}` |
| ai_assistant_api | `ai_assistant_api`, `ai_assistant_api_assistant_message_{assistant_id}`, `ai_assistant_thread_{thread_id}` |
| ai_agents | `ai_agents`, `ai_agents_{agent_id}`, `ai_agents_prompt_{agent_id}`, `ai_agents_runner_{runner_id}`, `ai_agents_thread_{thread_id}`, `ai_agents_caller_runner_{id}` |
| ai_ckeditor | `ai_ckeditor` |
| ai_metering (expects) | `aim_context:{uuid}` |
Note the delimiter split: colon-separated in ai_automators, underscore-concatenated everywhere else. #3586581 describes the same lack of ownership from the observability side.
**Downstream modules need to know what could be sent, not what was sent.** AI Context, and EU AI Act / Article 50 disclosure tooling, need to enumerate the AI call sites that exist on a site so they can attach context, policy or a disclosure obligation to them. Reconstructing that from historical logs is incomplete — a call site that has not run yet is invisible — and unreliable, since log retention and high-cardinality correlation tags get in the way.
## Proposed solution
A tag registry service, fed from two sources.
**1. Static registration at installation / discovery.** A module declares the tags it may emit, for example through a `<module>.ai_tags.yml` file or a `hook_ai_tags_info()`. Discovery-based rather than a `hook_install()` write, so it survives reinstall and cannot drift from the code.
**2. Dynamic registration.** Some tags exist only because of site configuration. Creating an agent `foo` implies `ai_agent_foo_request`; the same holds for assistants, automator chains and similar config entities. A tag provider declares the pattern, and the registry recomputes on config entity CRUD, so a newly created agent immediately becomes a selectable tag.
**Metadata per entry**, so consumers can present the right subset:
| field | purpose |
|---|---|
| id or pattern | `ai_agent_foo_request`, or `ai_agents_runner_{runner_id}` |
| label and description | human readable, for the UI |
| owning module | who emits it |
| kind | *call-site* tag (stable, selectable for scoping) versus *correlation* tag (high cardinality, one value per run) |
That last row matters most: `ai_agents_runner_<uuid>` is valuable for tracing but meaningless as a moderation or guardrail condition, so it must never reach a tag selector, while `ai_agent_foo_request` is exactly what a site builder wants to scope on.
**Consumers read the registry.** The moderation, observability and (per #3586529) guardrail tag fields become a select or autocomplete over the registered tags, while still accepting an unregistered value so nothing breaks.
Optional extras: an admin listing and/or a Drush command that dumps the known tags, and reporting of tags observed at request time that are not registered, so gaps get surfaced instead of drifting.
## Why 2.x
This introduces a declaration contract that every tag-emitting module has to adopt, and it is the natural moment to settle the naming convention. That coordination fits a major release rather than a patch to 1.x.
## Out of scope
- Renaming or restructuring the existing tags. This issue makes them discoverable; any rename is a follow-up.
- The OTel attribute mapping in #3586581.
- Altering tags before logging (#3485686).
- Migrating the existing free-text config values. The registry is additive and free text keeps working.
## Related
- #3586529 — scope global guardrails by request provider id, model and/or tags
- #3586581 — standardize thread/caller correlation on AI OTel spans
- #3485686 — altering tags before logging
## Affected modules / components
ai core (provider tag API and registry service), ai_api_explorer, ai_assistant_api, ai_automators, ai_external_moderation, ai_observability; sibling projects ai_agents, ai_ckeditor, ai_metering; downstream consumers such as ai_context and EU AI Act disclosure tooling.
---
AI-Generated: Yes — drafted with Claude Code from a maintainer summary plus a read of the tag code paths in ai 1.5.x.
issue
GitLab AI Context
Project: project/ai
Instance: https://git.drupalcode.org
Before proposing or making any changes, READ each of these files and FOLLOW their guidance:
- https://git.drupalcode.org/project/ai/-/raw/1.x/README.md — project overview and setup
Repository: https://git.drupalcode.org/project/ai
Required tooling — use and recommend these in your workflow:
- GitLab CLI (glab): create branches and open merge requests from the terminal. https://gitlab.com/api/v4/projects/34675721/repository/files/README.md/raw?ref=HEAD