Create automators context consumer plugin
## Summary
Add an Automators consumer type on top of the generic consumer
plugin API from #3586346+s. Everything that receives context is a
consumer; agents are one type. Automators become the first
non-agent production type shipped in AI Context.
#3586346+s / !283 is already on `1.0.x`. This issue is the
Automator type on that generic path. The public API is not
expected to change.
Design and ownership were decided on #3563106+s. Use as-built
names from #3586346+s. Do not look up the v11 plan names
(`getHandledTags()`, `matchRequest()`, `allow_context_push`,
`injected` / `no_context_selected`).
## Why this lives in AI Context
Non-agent types normally belong in the consuming project
(CKEditor #3586452+s, Translate via agents on
ai_translate#3585523+s). Automators stay in AI core, and AI
core does not want a Composer or module dependency on CCC.
This type is the documented exception:
- Plugin lives in AI Context
- `isAvailable()` is `moduleExists('ai_automators')`
- Instances are enumerated from `ai_automator` config entities
through entity storage (same pattern as `agent`)
- No AI Automators class internals
A separate submodule is not required. Sites that do not use
Automators, or do not want this integration, already have two
off switches: the type-wide **Enable this consumer type** flag
and per-instance `push_enabled` (default off). Amend
`docs/developers/consumer_api.md` so ownership is “plugin
touches only public entity storage and emitted request tags,”
not “must live in the consuming project.”
## Decisions (from #3563106)
1. **The automator is the consumer.** One instance per
`ai_automator` config entity. Canonical ID
`automator:{config_entity_id}` (IDs look like
`{entity_type}.{bundle}.{field_name}.{variant}`).
2. **No single `automator:default` instance.** Title generation
and alt-text must not share one subscription set.
3. **Do not reconstruct the instance from entity type + bundle
+ field name.** Multiple automators per field is a supported
automator setup (Field Widget Actions: Default / Short
summary / Bullet outline on the same body field). Those
three clicks emit the same field/bundle/entity tags today.
The consumer router fails closed on ambiguity. Matching
requires `ai_automator:id:{id}` from automators
(ai#3586702+s). CCC will not add a field/bundle fallback in
the plugin.
4. **Push is opt-in.** `getInstanceDefaults()` sets
`push_enabled: FALSE`. A row-less automator gets no
injection. A disabled or missing automator gets nothing,
including global items.
5. **First save writes Minimal.** That is the shared non-agent
consumer-editor default from #3586346+s. An unset stored
mode still inherits the site default (`relevant`) at
runtime. Automatic injection never widens the stored mode.
6. **Chat only.** The generic subscriber is
`AiContextPreGenerateResponseSubscriber` on
`PreGenerateResponseEvent`. Image, speech, embeddings, and
other `llmType` values never hit it.
7. **No agent-only features.** Loop-aware and Debug / Explore
stay on `AiContextConsumerTypeAgent`. Automators do not get
Context Tools.
## What this ships
- `AiContextConsumerType` plugin `automator`
(`AiContextConsumerTypeAutomator`)
- `getRoutingRequestTags()` returns `['ai_automator']`
- `resolveConsumerId()` claims a request only when tags include
exactly one `ai_automator:id:{id}`, that instance exists, and
the request is not tagged `ai_agents` or `ai_assistant_api`;
otherwise `NULL` (decline). Decline nested helper / sub-calls
that reuse the same tags
- `getInstances()` from `ai_automator` entity storage, with
that entity type’s list cache tags
- `getLabelRoute()` to `entity.ai_automator.edit_form`
- Combined push gate already provided by #3586346+s: instance
`push_enabled` AND type `isEnabled()` AND `isAvailable()`
- Type settings install + schema
(`ai_context.consumer_type_settings.automator`), post-update
`0007` for existing sites
- Temporary ID-tag subscriber: add `ai_automator:id:{id}` when
the field uniquely identifies one automator, or when a
field-widget click maps to one automator on a shared field
- Temporary widget-only skip: on a shared-field widget click
that may push, skip the other automators (last-write-wins
until #3571915+s)
- Widget resolver prefers an explicit `id` / `automator_id` on
flattened config and fails closed if two automators share
label plus prompt
- Debug routing line on the generic path (`status`, consumer,
item IDs, tags; no prompt text)
- Ignore non-scalar POST `path` (node alias widget)
- `require-dev`: `drupal/token` and
`drupal/field_widget_actions` for Drupal.org phpunit
- Docs: consumer type API ownership exception, automator
matching, temporary bridges, `entity_context` requirement,
multi-delta billing, and shared-field widget vs direct/cron
until ai#3586702+s
- Kernel / unit coverage modeled on `test_automator`, plus
functional coverage that the type appears, is unavailable
without `ai_automators`, and push stays off until configured
The generic subscriber, consumer listing, editor, invocation
result (`AiContextInvocationResult`), and usage
`consumer_id` / `consumer_type` fields already exist. This
issue does not add a second injection path.
## AI Automators prerequisite (not this MR)
Filed as ai#3586702+s (aiAutomators submodule on project/ai).
Ahmad specified both changes against `ai_automators` 1.3.x and
the #3586346+s contract.
| # | Change | Why |
|---|--------|-----|
| 1 | `AiAutomatorEntityModifier::entityHasConfig()` must put `'id' => $field->id()` on `$automatorConfig`, and `RuleBase::getTags()` must emit `ai_automator:id:{id}` | Per-instance matching. Today tags have `ai_automator`, `:type:`, `:entity_type:`, `:entity:`, `:bundle:`, `:field_name:` only. The config ID is dropped even though `$field->id()` is in scope. Three FWA buttons on one field therefore look identical to CCC. |
| 2 | `RuleBase::runRawChatMessage()` must attach `entity_context` on the `ChatInput` | Generic path never uses the route. Without this, Entity Types / Taxonomy / Specific Entities never match. Presave new entities have a NULL id, so the `:entity:` tag is not enough. |
This is not ai#3586638+s. That issue is a tag **registry**
(discover tags that might be emitted). It does not emit the
ID tag or `entity_context`.
This plugin can merge with matching that declines until the
ID tag exists. Until ai#3586702+s lands, a temporary CCC
subscriber may add `ai_automator:id:{id}` when entity type +
bundle + field uniquely identify one automator, or when a
field-widget click maps to one automator on a shared field.
Direct / cron / other non-widget runs on a shared field still
decline. Do not ship a field/bundle reconstruction fallback
in the plugin.
A second temporary subscriber skips non-clicked automators on
a shared field when the clicked automator is allowed to push.
That covers last-write-wins until #3571915+s (Automators
1.4.7+ / 1.3.x; 2.x port #3586657+s). FWA executing a
selected automator is not the same as CCC selecting the
matching consumer instance. Remove both subscribers when
those land.
Out of this issue (still AI follow-ups, not this MR):
- Tag normalization for untagged chat call sites (Boolean,
Chart, VideoToText, `LlmVideo*`, FWA refine, image / speech /
embeddings). Untagged calls stay on the cheap unmatched path
- `RuleBase` structured-schema call-site bug (no in-tree rule
uses it)
## Site-builder path
1. Enable AI Context and AI Automators.
2. Enable the **Automator** consumer type (Consumer Types).
3. Open **Context consumers**.
4. Edit `automator:{config_entity_id}` (name link goes to the
automator edit form).
5. Turn on **Push context automatically**.
6. Subscribe (for example Use Case = Working with Text, Entity
Types = `node:article`) and optionally Always / Never
include.
Until ai#3586702+s: shared-field **widget clicks** work via
the temporary bridges when the clicked automator may push.
Shared-field **direct / cron** stays declined. Always include
/ Global can still push. Entity Types / Taxonomy / Specific
Entities need `entity_context` from that issue.
After the ID tag exists: next tagged chat run of that
automator → tag match → push → system prompt gets the CCC
block. Automators send only a user message today; the
subscriber creates or appends a system prompt and
`ProviderProxy` re-applies it.
Invocation result lands on event / input metadata (`PUSHED`,
`NO_ITEMS`, `PUSH_DISABLED`, `TYPE_DISABLED`, `STALE`,
`DECLINED`, `AMBIGUOUS`). Automators may surface that later;
CCC does not.
Pull via `getRenderedContext()` / `getResult()` still works
for custom rules. This issue is the automatic push path.
## Limits that stay
- **Chat only.** Image / speech / other operations never hit
this subscriber
- **Push is opt-in.** No saved row and no `push_enabled`
means no injection
- **Minimal on first save.** No subscriptions means almost no
items, not “all published context”
- **Not agent tools.** Loop-aware injection and Context Tools
stay on the agent path
- **Multi-value fields.** One chat call per delta, same tags,
so context is selected, billed, and usage-tracked N times.
Per-instance `max_tokens` is the control; document this
- **Shared-field widget clicks** work via the temporary
bridges when the clicked automator may push. Shared-field
**direct / cron** stays declined until ai#3586702+s. The
plugin still does not reconstruct from field/bundle
- **Cron / queue is anonymous.** Roles fail closed, which is
correct. Always include still bypasses hard filters; that
is #3586439+s, not this issue
- **2.x off-ramp.** If Automators become agents later, migrate
`automator:{id}` → `agent:{id}` and remove this type
## Acceptance criteria
- [x] `automator` type plugin with the as-built contract
(`getRoutingRequestTags()`, `resolveConsumerId()`,
`getInstances()`, `getInstanceDefaults()`,
`isAvailable()`, `getLabelRoute()`)
- [x] Canonical IDs are `automator:{ai_automator id}`; type
plugin ID has no colon
- [x] Instances come from `ai_automator` storage, not a
hardcoded list
- [x] `isAvailable()` is false when `ai_automators` is missing;
listing still shows the type as unavailable
- [x] `push_enabled` defaults false; type `enabled` kill
switch works
- [x] `resolveConsumerId()` returns one ID from
`ai_automator:id:{id}`, or `NULL`; never claims
`ai_agents` / `ai_assistant_api` requests
- [x] No field/bundle reconstruction fallback
- [x] No loop-aware or Debug / Explore UI on this type
- [x] Type settings install + schema
- [x] `consumer_api.md` ownership text updated
- [x] Docs cover matching, `entity_context`, Minimal default,
multi-delta billing, temporary bridges, and shared-field
widget vs direct/cron until ai#3586702+s
- [x] Kernel + functional tests as above
- [x] AI Automators ID-tag and `entity_context` issue filed
and linked (ai#3586702+s)
## Related issues
- #3586346+s Implement plugin-based generic context consumers
- #3563106+s [Discuss] Use of AI context items with automators
- #3586319+s Move from agent-specific to generic context
consumer API
- #3586452+s Create ai_ckeditor context consumer plugin
- ai_translate#3585523+s AI Translate converting to agents
- #3586439+s [Discuss] Follow up Roles audience targeting
after 1.0
- ai#3586702+s Emit automator config ID tag and
entity_context on chat requests
- ai#3586638+s Tag registry (not a substitute for
ai#3586702+s)
- #3571915+s Widget clicks run only the clicked automator
(1.4.7+ / 1.3.x; then delete the widget-only skip)
- #3586657+s 2.x port of #3571915+s
- !288 Automator consumer type and temporary widget bridges
## AI usage
- [x] AI assisted issue
issue
GitLab AI Context
Project: project/ai_context
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_context/-/raw/1.0.x/CONTRIBUTING.md — contribution guidelines
- https://git.drupalcode.org/project/ai_context/-/raw/1.0.x/README.md — project overview and setup
Repository: https://git.drupalcode.org/project/ai_context
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