Skip to content

Events

Selector pipeline events

AI Context exposes two stable selector events for contrib modules that need to inspect or alter selected context without coupling to AiContextSelector internals. Event name constants live on Drupal\ai_context\Event\AiContextSelectionEvents. The ai_context.selection.* event name prefix is reserved for selector pipeline events.

The module intentionally exposes only the later selector stages:

  • items_selected and text_rendered provide useful extension points while keeping candidate filtering and scoring internals private.
  • A fuller pipeline with start and candidates_gathered would offer more flexibility, but it would also make intermediate selector stages part of the public API before there are concrete contrib requirements.
  • Observer-only events would be safer for refactors, but would not support the ticket goal of allowing supported selection alteration.
  • Plugin-based filters or boosters may be useful later, but would add more architecture than this stabilization issue needs.

Potential future start use cases include adding consumer-specific scope subscriptions, lowering maxTokens, adding always/never include IDs from route, role, feature flag, or workflow state, or skipping selection entirely (return an empty result without loading, scoring, or rendering — for example feature flags, maintenance mode, or route/agent policy).

Potential future candidates_gathered use cases include removing candidates based on external policy, taxonomy, workspace, tenant, or site-section rules; moving items between internal candidate groups; or inspecting how many items survived filtering. These use cases are deferred because candidate groups are more likely to change during selector refactors.

The selector pipeline names form one lifecycle family. The two current events ship now; the earlier-stage names are reserved so the namespace stays consistent when they are added:

Stage Class Constant Event name
Start (reserved) AiContextSelectionStartEvent START ai_context.selection.start
Candidates gathered (reserved) AiContextSelectionCandidatesGatheredEvent CANDIDATES_GATHERED ai_context.selection.candidates_gathered
Items selected AiContextSelectionItemsSelectedEvent ITEMS_SELECTED ai_context.selection.items_selected
Text rendered AiContextSelectionTextRenderedEvent TEXT_RENDERED ai_context.selection.text_rendered

Supported extension model

Contrib and site-specific integrations should extend selection through the documented selector pipeline events and AiContextSelection / selection factory APIs — not by decorating or replacing ai_context.selector.

Supported:

  • Subscribe to ai_context.selection.items_selected and ai_context.selection.text_rendered (see below).
  • Build selections via AiContextSelectionFactory or AiContextSelection directly.
  • Skip calling select() entirely when your integration controls the call site (for example loop-aware agent injection).

Not supported:

  • Decorating, extending, or replacing ai_context.selector in services.yml.
  • Depending on AiContextSelector internals such as candidate loading, scoring, merge order, or subcontext resolution.

The selector pipeline may change without deprecation. Events define the stable public contract; the selector implementation is internal. For the module-wide public/internal boundary, see API stability.

When the selector finds no candidates, ITEMS_SELECTED still fires so subscribers can inject items — but that is not a skip: the full pipeline has already run. Skipping work before any database queries requires a future start event (see above).

Subscriber constraints

Configured max_tokens limits are enforced by the selector and renderer before events fire. Event subscribers may override those outcomes (for example by injecting additional items or replacing rendered text). When subscribers alter selection, they are responsible for staying within any budget or policy their integration requires.

Cache metadata: Subscribers that alter the selected item list or rendered text based on runtime state (route, role, workspace, config, feature flags, and so on) must ensure that state is reflected in the AiContextSelectionResult cache metadata. Whatever you consulted to make the decision should be represented in the result's cache tags, contexts, or max-age. AiContextSelectionResult is immutable; during selection, merge dependencies via addCacheableDependency() on the event where you make the decision — ITEMS_SELECTED when you alter the item list, TEXT_RENDERED when you alter rendered text. Both flow into the result the selector builds. Entity cache tags for selected items are added automatically during normal result building.

Items added or replaced via setSelectedItems() are not re-run through scope matching, scoring, or request-level neverInclude filtering. They must still pass a view access check, but contrib should not assume injected items went through the normal candidate pipeline.

When the selector finds no candidates, it still dispatches ITEMS_SELECTED with an empty item list so subscribers can inject items before rendering. Injected items on that path skip resolveSubcontextItems(), so parent subcontexts will not expand automatically.

Subscribers on the same event run in Symfony registration order unless a priority is set on the service tag or getSubscribedEvents() return value. ITEMS_SELECTED always runs before TEXT_RENDERED.

Items selected

Event name: ai_context.selection.items_selected

Class: Drupal\ai_context\Event\AiContextSelectionItemsSelectedEvent

Fires after priority merge and subcontext resolution, before selected items are rendered. Subscribers may filter or reorder the final selected item array via setSelectedItems(). Injected items bypass scope matching and scoring; only view access is validated.

setSelectedItems() throws \InvalidArgumentException when any value is not an AiContextItem, and \Drupal\Core\Access\AccessException when any item fails a view access check (the whole batch is rejected).

Event API: getSelection(), getSelectedItems(), setSelectedItems(), getCacheableMetadata() (clone), addCacheableDependency()

Supported use cases:

  • Filter, remove, or reorder the final selected item list before rendering.
  • Remove selected items that conflict with other selected items.
  • Add a mandatory context or disclaimer item after normal selection.
  • Enforce per-consumer rules, such as limiting items from a category.

Cache metadata: Injected or retained items receive entity cache tags when the selector builds the result. When your items-selected changes depend on runtime context beyond those entities (for example route or workspace), call addCacheableDependency() on this event so that state is represented on AiContextSelectionResult — you no longer need to also subscribe to TEXT_RENDERED for cache metadata. getCacheableMetadata() returns a clone; mutating it does not affect the result. See Subscriber constraints.

Text rendered

Event name: ai_context.selection.text_rendered

Class: Drupal\ai_context\Event\AiContextSelectionTextRenderedEvent

Fires after selected items are rendered and before the AiContextSelectionResult is constructed. Subscribers may alter the rendered text and merge cache metadata into the result via addCacheableDependency(). The selected items and the IDs of items truncated during rendering are read-only — exposed for observation and telemetry, not modification.

After subscribers run, the selector recalculates tokensUsed from the final rendered text so AiContextSelectionResult stays consistent. During the event, getTokensUsed() still reflects the renderer estimate; the value on AiContextSelectionResult reflects post-event text. Do not pass untrusted input into setRenderedText(); subscriber output is injected into agent prompts without further sanitization.

getCacheableMetadata() returns a clone. Mutating it does not affect the result; use addCacheableDependency() to merge tags, contexts, or max-age.

When a subscriber exceeds configured max_tokens, the selector logs a warning during rendering but does not reject the result.

Event API: getSelection(), getSelectedItems() (read-only), getRenderedText(), setRenderedText(), getTokensUsed(), getMaxTokens(), getTruncatedItems() (read-only), getCacheableMetadata() (clone), addCacheableDependency()

Supported use cases:

  • Observe final rendered text for debugging, telemetry, audit trails, or developer tools.
  • Add a wrapper, delimiter, provenance note, or prompt-cache hint.
  • Redact sensitive patterns from final rendered text.
  • Add cache metadata when subscriber output depends on route, user role, workspace, or other context.

Subscribers must preserve access-checked entities, avoid logging sensitive context content unless it is explicitly safe, add cache metadata whenever their changes depend on additional runtime state, and treat setRenderedText() as trusted output only.

Example subscriber:

<?php

declare(strict_types=1);

namespace Drupal\example\EventSubscriber;

use Drupal\ai_context\Event\AiContextSelectionTextRenderedEvent;
use Drupal\ai_context\Event\AiContextSelectionItemsSelectedEvent;
use Drupal\ai_context\Event\AiContextSelectionEvents;
use Drupal\Core\Cache\CacheableMetadata;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class ExampleAiContextSelectionSubscriber implements EventSubscriberInterface {

  public static function getSubscribedEvents(): array {
    return [
      AiContextSelectionEvents::ITEMS_SELECTED => 'onItemsSelected',
      AiContextSelectionEvents::TEXT_RENDERED => 'onTextRendered',
    ];
  }

  public function onItemsSelected(AiContextSelectionItemsSelectedEvent $event): void {
    $items = $event->getSelectedItems();
    $event->setSelectedItems(array_slice($items, 0, 3, TRUE));
  }

  public function onTextRendered(AiContextSelectionTextRenderedEvent $event): void {
    $event->setRenderedText($event->getRenderedText() . "\n<!-- checked -->\n");

    // Cache metadata can only be merged, never replaced.
    $metadata = new CacheableMetadata();
    $metadata->addCacheContexts(['user.roles']);
    $event->addCacheableDependency($metadata);
  }

}

AI Agents integration events

The AI Context module uses two event subscribers to integrate with the AI Agents module.

AiContextBuildSystemPromptSubscriber

Service ID: ai_context.build_system_prompt_subscriber

Class: Drupal\ai_context\EventSubscriber\AiContextBuildSystemPromptSubscriber

This is the primary integration point between AI Context and AI Agents. It listens for two events:

AgentStartedExecutionEvent (priority 100)

Captures the runner ID when an agent starts execution. The runner ID is stored temporarily and used later to associate usage records with the correct agent run.

BuildSystemPromptEvent

Fires when the agent's system prompt is being assembled. The subscriber:

  1. Checks the agent's push_enabled flag via AiContextSelectionFactory::isPushAllowed(); if disabled, returns immediately and none of the following steps run
  2. Passes BuildSystemPromptEvent payload values to AiContextRequestInfoResolver::applyRequestInfo() so the resolver can resolve the entity and page path
  3. Builds an AiContextSelection via AiContextSelectionFactory::fromConsumer()
  4. Runs the selection through AiContextSelectorInterface::select() to get an AiContextSelectionResult
  5. If relevant context is found, appends it to the system prompt with a configurable prefix
  6. Records usage via AiContextUsageTracker if a runner ID is available
  7. Reads the resolved entity and path from the resolver for usage tracking. The subscriber always has the agent ID from this event; the tool path does not. See Page and entity context.

The context is appended in a context block:

[configurable prefix text]
-----------------------------------------------
[rendered context items]
-----------------------------------------------

The prefix text is configurable at /admin/config/ai/context/settings/general. Default: "The following site-specific context applies to this task. Use it strictly when relevant; do not override user intent."

The prefix is translatable via Configuration translation (/admin/config/regional/config-translation → AI Context settings).

Allow context injection

Before anything else, the subscriber checks the agent's push_enabled flag (configured on the consumer editor, default enabled) and the Agent type kill switch. When either is disabled, the subscriber returns immediately: no selection is built or run, and no usage is recorded for that call. This fully suppresses push-based injection -- global items, always-include overrides, and scope-subscribed matches alike -- since none of the pipeline that would produce them executes.

This does not affect pull-based context retrieval: scope_subscriptions, always_include, and never_include are dual-duty settings that AiContextSelectionFactory::fromParameters() still merges into tool-call selections that carry the agent's ID, regardless of this flag. GetRelevantAiContextItems and ListAiContextItems inherit the running agent ID when agent_id is omitted, so those defaults apply without a prompt instruction naming the agent. Disable injection for agents that should retrieve context on demand via tool plugins (GetRelevantAiContextItems, ListAiContextItems) instead of receiving it automatically.

See Consumer configuration.

Loop-aware context injection

Before building the selection, the subscriber checks settings.loop_aware (configured on the consumer editor for the Agent type). When loop-aware is enabled and the current loop count is greater than zero, context injection and usage recording for that iteration are skipped entirely.

Use loop-aware injection when first-iteration context is sufficient (for example tone or style guidance). Leave it disabled when agents need context re-injected on every loop (for example factual references or output format rules).

See Consumer configuration.

AiContextPreGenerateResponseSubscriber

Service ID: ai_context.pre_generate_response_subscriber

Class: Drupal\ai_context\EventSubscriber\AiContextPreGenerateResponseSubscriber

Pushes context into generic chat calls on PreGenerateResponseEvent. Agents keep using BuildSystemPromptEvent; this subscriber is the non-agent path.

The subscriber runs at priority -100 so GlobalGuardrailsEventSubscriber (priority 100) and the guardrail/moderation subscribers (priority 0) run first. Requests they reject never receive context, and guardrails never evaluate injected site context as if it were user input.

AiContextAutomatorIdTagSubscriber is a temporary internal bridge at priority -50. AI Automators do not yet emit ai_automator:id:{id}. When the request has ai_automator plus exactly one entity type, bundle, and field tag, and exactly one ai_automator config entity matches those properties, this subscriber adds the ID tag so the injector can claim the request. It does not guess .default. When a field has more than one automator, it uses the clicked field-widget-action UUID from the current POST if that action maps to one matching automator. Direct, cron, or other non-widget runs on a shared field still leave the tags unchanged. An ID tag already present is left alone. Remove this subscriber when AI core adds the ID in RuleBase::getTags().

AiContextAutomatorWidgetOnlySubscriber is a second temporary bridge. A field-widget click still asks AI Automators to process the whole field, so every automator on that field would run and the last write would win. When the POST identifies one widget action and that automator is allowed to push, this subscriber force-skips the other automators. Direct or cron runs, and clicks whose automator cannot push, are left alone. Remove it when AI Automators run only the clicked instance.

PreGenerateResponseEvent

The subscriber:

  1. Exits unless the operation type is chat and the input is a ChatInput
  2. Re-publishes the stored result and exits when the ChatInput already carries an ai_context debug-data key (a tool-call inner request reusing the outer input); no second context block is appended
  3. Exits without writing metadata when request tags include ai_agents or ai_assistant_api, or when no consumer type routes for the tags
  4. Asks routing types for a consumer ID or a decline
  5. Skips push-disabled, type-disabled (including unavailable), stale, or ambiguous consumers
  6. Builds a selection from saved subscriptions, using the latest user message as the task and entity_context metadata as the entity (never the route)
  7. Appends the same context block used on the agent path
  8. Records usage with a NULL runner and the entity_context entity when present
  9. Writes an ai_context invocation result to event metadata and the chat input debug data. When debug logging is on, also writes one privacy-safe watchdog line with status, consumer ID, item IDs, and request tags. See Debugging.

An absent ai_context key means the request was not handled (non-chat, unrouted tags, excluded agent/assistant tags, or a higher-priority subscriber stopped the event). Assistants that are not backed by an agent receive no push in 1.x: the generic path excludes ai_agents and ai_assistant_api, and no assistant consumer type exists. Agent-backed assistants use the agent path. Distinguished skip reasons use the AiContextInvocationResult status constants. The payload contains consumer_id, status, item_count, and item_ids only — never prompt text or rendered context.

Both channels are best-effort. A subscriber that returns a forced output, stops propagation, or replaces the event input can leave the caller without the result. See the AI core ProviderProxy behavior for those edge cases.

AI Context tests this path with the test module's consumer types and the shipped automator type. AI CKEditor plugins are owned by that project. Automator chat calls are claimed only when they carry ai_automator:id:{id} and that automator exists. Field, bundle, and entity tags are not a plugin fallback. A temporary subscriber may add the ID tag when those properties uniquely identify one automator, or when a widget-action click identifies one of several automators on the same field. Attach entity_context on the chat input so Entity Types, Taxonomy, and Specific Entities can match. Image, speech, and other non-chat operations never hit this subscriber. Multi-value fields generate one chat call per delta, so context is selected and usage-tracked N times; per-instance max_tokens is the budget control.

AiContextAgentToolSubscriber

Service ID: ai_context.agent_tool_subscriber

Class: Drupal\ai_context\EventSubscriber\AiContextAgentToolSubscriber

Listens for tool execution events to inherit the running agent ID on context tools and to track what tools agents use.

AgentToolPreExecuteEvent

Fires immediately before $tool->execute(). Tools opt in by implementing AiContextInheritsAgentIdInterface and declaring an optional agent_id context definition. When agent_id is omitted, the subscriber sets it from $event->getAgentId(). An explicit agent_id argument is left unchanged. Non-agent callers never dispatch this event.

GetRelevantAiContextItems and ListAiContextItems implement the interface. LoadAiContextItemById does not, because it has no agent_id parameter. Custom or third-party tools may opt in the same way.

If the resolved ID has no saved CCC configuration, the existing "no context config found for agent" warning from AiContextSelectionFactory::fromParameters() still applies.

AgentToolFinishedExecutionEvent

Fires when an agent tool completes execution. The subscriber:

  1. Records the tool plugin ID via AiContextUsageTracker::recordToolUsed()
  2. Attempts to extract entity information from the tool's output (e.g., "Entity of type node created with id: 123")
  3. If entity info is found, attaches it to the usage record via AiContextUsageTracker::attachEntity()

This provides visibility into what agents actually do with the context they receive.

Tool IDs are buffered in memory during the run and are not written to the database on each tool call. When an entity is attached via attachEntity(), any buffered tools are merged onto the in-memory usage records before they are saved.

AiContextAgentFinishedSubscriber

Service ID: ai_context.agent_finished_subscriber

Class: Drupal\ai_context\EventSubscriber\AiContextAgentFinishedSubscriber

Listens for agent completion so buffered tool usage can be flushed once per run instead of on every tool event.

AgentFinishedExecutionEvent

Fires when an agent run completes. The subscriber calls AiContextUsageTracker::flushToolsUsed() with the runner ID so any tool IDs buffered by recordToolUsed() are persisted in a single load-and-save cycle.