Developing with modes¶
This page is for developers integrating AI Agent Modes, shipping modes with a module or recipe, or adding the selector to a chat surface of their own.
The mode entity¶
A mode is the ai_agent_mode configuration entity, stored as
ai_agent_modes.ai_agent_mode.<id>. Full shape, with every exported property:
langcode: en
status: true
dependencies:
config:
- ai_agents.ai_agent.canvas_ai_orchestrator
id: page_builder_only
label: 'Page Builder Only'
description: 'Scopes the Canvas orchestrator to page building.'
weight: 0
agent: canvas_ai_orchestrator
sub_agents:
- canvas_page_builder_agent
- canvas_metadata_generation_agent
system_prompt_addition: 'Work on page structure only.'
scope_strength: guide
assistants: { }
surfaces: { }
| Property | Type | Meaning |
|---|---|---|
id |
machine name | The mode ID, used as mode:<id> in a selection |
label |
string | Shown in the dropdown |
description |
text | Administrative note, not shown in the chat |
weight |
integer | Order in the listing and the dropdown |
status |
boolean | Disabled modes are never offered |
agent |
string | Parent agent plugin ID. An empty string makes the mode generic and it is then offered for every agent |
sub_agents |
list of strings | The sub-agent plugin IDs this mode names |
system_prompt_addition |
text | Extra instruction added when the mode is active. Drupal tokens in it are replaced |
scope_strength |
string | guide (default) steers with the prompt only; restrict also withholds the sub-agent tools the mode does not name |
assistants |
list of strings | ai_assistant entity IDs. An empty list means every assistant; a non-empty list means those assistants only, and nowhere an assistant is absent |
surfaces |
list of strings | Surface IDs. An empty list means all surfaces |
The entity calculates a configuration dependency on the parent agent it names, so a mode is removed with its agent and an exported mode declares that relationship for you.
It deliberately does not depend on the sub-agents or the assistants it names. The module already tolerates their absence at run time: an unavailable sub-agent name is dropped when the scope is resolved, and an assistant that no longer exists never matches. A hard dependency there would delete a whole mode because one unrelated sub-agent was removed.
Shipping modes in a module or a recipe¶
The module ships no modes of its own, by design: what a useful mode is depends entirely on the agents a site has.
In a module, put the file in config/install/ when the mode should always be
created, or in config/optional/ when it should only be created if the agent it
depends on is present. Optional configuration is installed only once its
declared dependencies are satisfied, which is why the dependencies block
matters.
my_module/config/optional/ai_agent_modes.ai_agent_mode.page_builder_only.yml
In a recipe, install the module and put the mode alongside it:
name: 'My AI modes'
type: 'Site'
install:
- ai
- ai_agents
- ai_agent_modes
my_recipe/config/ai_agent_modes.ai_agent_mode.page_builder_only.yml
There is a working example in the repository:
tests/recipes/ai_agent_modes_test/, which seeds a parent agent, two
sub-agents, an assistant, a saved mode and the selector block, with no AI
provider needed.
How a sub-agent list is discovered¶
ModeManagerInterface::listSubAgents($parent_agent_id) reads the parent agent
entity's enabled tools, keeps the tool plugins whose group is agent_tools
(falling back to the ai_agents::ai_agent:: ID prefix when the definition is
not available), and resolves each one to the child agent it wraps. So the list
follows the agent's current configuration, and there is no hardcoded set
anywhere in the module.
This is also why a mode can name a sub-agent that is not offered: if the parent agent later loses that tool, the name stays in configuration and is ignored at runtime.
How the steering is applied¶
AgentScopeSubscriber listens on three ai_agents events, all at priority 100,
in the order the agent fires them:
| Order | Event | What the module does |
|---|---|---|
| 1 | ai_agents.started_execution |
Withholds sub-agent tools, for a restrict mode only |
| 2 | ai_agents.pre_system_prompt |
Prepends the directive to the system prompt |
| 3 | ai_agents.request |
Fallback only: prepends the directive if step 2 never ran |
The order matters twice over. The agent assembles its tool set right after the
started-execution event, so that is the only point at which a tool can still be
withheld before the provider is told about it. And the agent replaces tokens
after the prompt event, which is why a mode's system_prompt_addition may
contain tokens: they are resolved with the agent's own token context. The request
event runs after both, so it is guarded by the directive marker and never injects
twice.
For each run the module reads the stored selection, resolves it with
ModeManagerInterface::resolve() into a ScopePayload, and then applies it.
Withholding tools¶
With scope_strength: guide, the default, tools are never touched: the assistant
keeps its full capability and is steered by text alone, which is safe on any agent
including one never written with this module in mind.
With scope_strength: restrict, the module builds a complete tools map with
ModeManagerInterface::restrictedTools() and applies it through the agent
wrapper's own overrideFunctions(['tools' => ...]). Rules, all enforced in that
method:
- Only tools in upstream's
agent_toolsfunction group are ever withheld. The agent's own tools are copied through untouched. - A tool already disabled on the agent is never re-enabled.
- The baseline is the override-applied agent (
getAiAgentEntity()->get('tools')), so a mode can never re-enable something anai_agent_overrideremoved. - If nothing would be withheld, or the map would leave the agent with no tool at all, the mode falls back to steering and logs a warning.
- A generic mode (no parent agent) is downgraded to steering, because its sub-agent names mean nothing for whichever agent is running.
- A nested sub-agent run is never narrowed: the handler returns as soon as the event carries a caller ID.
resetFunctions() runs on every top-level run of an agent whenever the site has
any mode at all, before anything else. That is deliberate: a functions override
survives between turns, and the AI Assistant API restores it onto the next turn's
wrapper, so a restriction has to be undone when the user clears or changes the
mode. On a site with at least one mode, this module owns functions_override on
the agents it runs on. Nothing in ai or ai_agents writes that property, but a
third-party module that does should not be combined with a withholding mode.
Two limits worth knowing. Withholding relies on AiAgentEntityWrapper, the config
entity agent: a legacy code-plugin agent gets prompt steering only. And it is a
context-window narrowing, not an authorisation boundary, because a tool call the
model returns is resolved by function name against the global plugin manager, and
each tool still authorises itself when it runs.
The directive, when the mode names sub-agents:
MODE (AI Agent Modes): For this request, use the following sub-agent(s): a, b.
Route the task to them and prefer them over other sub-agents for this conversation.
And when it steers by instruction alone:
MODE (AI Agent Modes): Follow this working mode for the conversation.
The mode's system_prompt_addition is appended to whichever of the two applies,
then the whole block is placed in front of the agent's own system prompt.
Resolution rules¶
resolve($parent_agent_id, $selected_sub_agents, $mode_id):
- A saved mode wins over an ad-hoc sub-agent selection.
- A mode that is missing or disabled resolves to nothing, so the request is left untouched.
- A generic mode (empty
agent) is resolved against the agent that is making the request. - The mode's
sub_agentsare intersected with the parent agent's live sub-agents. Names that are not currently available are dropped. - When the site-wide Enforce tool scope switch is off, a withholding mode logs that fact and steers instead, so the log says which of the two was the reason nothing was withheld.
- If that intersection is empty and the mode has no
system_prompt_addition, the mode resolves to nothing. This is whatScopePayload::isRestrictive()decides, and it is the same test the listing uses to show a mode as Prompt only rather than None.
Every applied scope is logged at info level to the ai_agent_modes channel,
naming the mode, the agent and the sub-agents it steered to, which is the
quickest way to confirm a mode really took effect.
Scoping an AI Assistant that has no agent¶
An ai_assistant with no ai_agent never starts an agent run, so none of the
events above fire for it. AssistantScopeSubscriber covers that case through the
AI Assistant API's own events, and stays optional: it is registered with
'@?ai_assistant_api.runner' and subscribes by literal event name, because
getSubscribedEvents() runs while the container is compiled and a class constant
fetch on an absent module would fatal every cache rebuild.
ai_assistant.pass_context_to_agentnotes which assistant an agent run belongs to, keyed by the agent runner ID, which is the same value the agent events later report as their thread ID. Nothing in the event is mutated.AgentScopeSubscriberreads it back to refuse a mode limited to a different assistant.ai_assistant.change_assistant_messageprepends the directive to the assistant's own system prompt. This is only reachable when the assistant has no agent: with an agent, upstream hands off and returns before that prompt is built, so the two seams can never both fire for one turn.
Such a selection is stored under assistant:<assistant_id> instead of an agent ID,
and only generic modes (empty agent) are ever offered or applied, revalidated at
run time rather than trusted from the store.
Where a selection is stored¶
SelectionStore writes to the private tempstore, collection ai_agent_modes,
under the key <agent_id>:<conversation_id>, or <agent_id>:session when no
conversation ID is given. So a selection belongs to one user, is scoped to a
conversation when the surface supplies an ID, and is never a permanent setting.
Reads fall back from the conversation key to the session key, so the first turn of a new conversation is still covered by a selection made before it started.
A selection is one of three values everywhere in the module:
''clears the selection.mode:<mode_id>selects a saved mode.agent:<sub_agent_id>selects a single sub-agent ad hoc.
AI Assistants¶
assistants limits a mode to the AI Assistants it names, which is how two
assistants sharing one parent agent can offer different sets of modes.
ModeManagerInterface::listModes($agent, $surface, $assistant_id) takes the
assistant as its third argument, and AiAgentModeInterface::appliesToAssistant()
decides each mode:
- An empty
assistantslist applies to every assistant, and to a surface with no assistant at all. This is the default, so existing modes are unaffected. - A non-empty list applies only when
$assistant_idis one of its entries. PassingNULL(or an empty string) therefore withholds the mode: a mode that names assistants belongs to them.
Every surface passes the assistant along:
- The AI Assistant chat form reads the assistant from the runner and sets
#assistanton the render element. - The chatbot block puts the assistant ID in
drupalSettings.aiAgentModesChatbot.assistant, and the script sends it to the options endpoint as?assistant=<id>. - The selector block passes its configured AI Assistant setting on. With only a Parent agent set there is no assistant, so assistant-limited modes are not offered.
- The Drupal Canvas AI panel has no assistant: it is driven by the
canvas_ai_orchestratoragent, so it never sends the parameter and never offers an assistant-limited mode.
Selecting a mode is unaffected: resolve() looks a chosen mode up by ID, so a
selection already stored stays valid even if the restriction changes later.
Like the agent it names, a mode does not calculate a config dependency on the assistants it names, so add one yourself when you ship a mode that is limited to an assistant.
Surfaces¶
surfaces is a free list of IDs, and the module uses one of its own:
ai_assistant. Which surfaces actually filter on it is worth being precise
about:
- The AI Assistant chat form filters. It builds the render element with
#surfaceset toai_assistant, so a mode restricted to another surface is not offered there. - The selector block and the render element filter only when you give them a surface, and both leave it empty by default.
- The chatbot block uses
ai_assistantonly to decide whether to attach its script at all. The list it then shows comes from the JSON options endpoint, which does not take a surface. - The Drupal Canvas AI panel does not filter either, for the same reason. It
lists every enabled mode for
canvas_ai_orchestrator, whatever thesurfacesfield says.
So use surfaces to keep a mode out of the assistant chat form and out of your
own surfaces, not as an access control. To withdraw a mode everywhere, disable
it.
Adding the selector to your own surface¶
The block¶
The AI Agent Mode selector block (plugin ID ai_agent_mode_selector,
category AI) can go anywhere. Its settings:
- AI Assistant. The agent is read from the assistant, the same way the chatbot block is configured, and the assistant is passed on so modes limited to it are offered. Takes precedence over the field below.
- Parent agent. The agent plugin ID, used when no assistant is selected.
- Surface. Optional surface ID used to filter the modes listed.
The block renders a small form with an Apply mode button and reports the result with a status message.
The render element¶
For your own form, use the ai_agent_mode_select element:
$form['mode'] = [
'#type' => 'ai_agent_mode_select',
'#parent_agent' => 'canvas_ai_orchestrator',
'#surface' => 'canvas',
// Optional: the assistant this chat is backed by, when there is one.
'#assistant' => 'drupal_cms_assistant',
];
It is a select whose options are built for you: a free-form default, then a
Modes group, then a Sub-agents group listing the parent agent's live
sub-agents so a single one can be picked ad hoc. Each group is added only when
it has something in it. The element adds the config:ai_agent_mode_list and
config:ai_agent_list cache tags.
Note the difference from the client-rendered surfaces: the JSON endpoint below deliberately offers modes only, because raw sub-agent names are meaningful to developers and not to the people building pages.
To persist a change made in your own form, attach the ai_agent_modes/chat
library, give the select the ai-agent-modes-mode class, and set
drupalSettings.aiAgentModes with agent and conversation. That is how the
AI Assistant chat form integration works.
The JSON endpoints¶
GET /ai-agent-modes/options/{agent} returns the option list for a client-side
dropdown. It requires a logged-in user and applies no further access check.
Add ?assistant=<id> to name the AI Assistant the chat is backed by, which is
what includes the modes limited to that assistant. It returns:
{
"agent": "canvas_ai_orchestrator",
"assistant": "",
"value": "mode:page_builder_only",
"options": [
{ "value": "", "label": "All, let the assistant decide", "group": "" },
{ "value": "mode:page_builder_only", "label": "Page Builder Only", "group": "" }
],
"position": "toolbar"
}
position is the ai_agent_modes.settings:canvas_position value, carried on the
response so the client does not need a second request to know where to put the
control.
POST /ai-agent-modes/selection stores a selection. It requires a logged-in
user and a CSRF request header token, obtained from /session/token, with a
JSON body:
{ "agent": "canvas_ai_orchestrator", "value": "mode:page_builder_only", "conversation": "" }
It answers {"status": "applied"}, or {"status": "cleared"} for an empty
value, and returns 400 for a missing agent or a value that is neither empty nor
prefixed with mode: or agent:.
How the two chat integrations attach¶
Both are soft: they do nothing when the host module is absent.
- Drupal Canvas AI.
hook_library_info_alter()addsai_agent_modes/canvas_aias a dependency of the Canvas editor bundle (canvas/canvas-ui), and only whencanvas_aiis installed. The Canvas AI panel is a React island whose chat is a web component, so the dropdown is injected client-side rather than through the render pipeline. The script polls for the panel, fetches the options endpoint, and places the control according toposition. - AI Chatbot.
hook_library_info_alter()addsai_agent_modes/chatbot_deepchatto theai_chatbot/deepchatlibrary, andhook_block_view_ai_deepchat_block_alter()resolves the assistant's agent and passes three things to the script indrupalSettings.aiAgentModesChatbot:agent,assistantandposition. The position is the assistant's own override when it has one, andai_agent_modes.settings:chatbot_positionotherwise (above_chat,below_inputorheader). The override is a third-party setting on theai_assistantentity:
yaml
# ai_assistant_api.ai_assistant.drupal_cms_assistant.yml
third_party_settings:
ai_agent_modes:
chatbot_position: header
It is added by AssistantSettingsHooks through
hook_form_ai_assistant_form_alter() and an entity builder, and an assistant
left on the site setting stores nothing at all. An unknown stored value falls
back to the site setting rather than breaking the panel. The script anchors on the panel's own markup
(.ai-deepchat--header and .chat-element) and retries for ten seconds while
a privacy gate holds the chat back, rather than appending the dropdown wherever
it can. It
bails out when ai_assistant_api is absent, when the block has no assistant,
when the assistant has no agent, or when that agent has neither modes nor
sub-agents.
Both scripts stop before injecting anything when the endpoint returns a single option, since a dropdown with one choice is not a choice.
Services¶
| Service | Interface | Use it for |
|---|---|---|
ai_agent_modes.manager |
ModeManagerInterface |
Listing sub-agents and modes, resolving a selection, applying a scope |
ai_agent_modes.selection_store |
SelectionStoreInterface |
Reading, setting and clearing a selection |
Both interfaces are aliased to their service, so they can be autowired by type.