feat: #3613643 Offer a mode for selected AI Assistants

Issue: https://www.drupal.org/project/ai_agent_modes/issues/3613643

What this does

Commit 1 — offer a mode for selected AI Assistants

A mode (ai_agent_mode) is keyed to a parent agent, so two AI Assistants backed by the same agent were always offered the same modes. This adds an optional assistants list to the mode: the AI Assistants it is offered for.

  • Empty (the default) means every assistant, so existing modes behave exactly as before. No update hook needed.
  • A non-empty list means those assistants only, and the mode is withheld where there is no assistant at all — the Drupal Canvas AI panel is driven by the canvas_ai_orchestrator agent, not by an assistant.
  • Unlike the existing surfaces field, every surface passes the assistant along, so this is real filtering rather than tidying.

Entity and API, all additive:

  • assistants in config_export and the config schema
  • AiAgentModeInterface::getAssistants() and ::appliesToAssistant()
  • a third optional $assistant_id argument on ModeManagerInterface::listModes()
  • a new #assistant property on the ai_agent_mode_select element
  • an optional assistant query parameter plus an assistant response key on GET /ai-agent-modes/options/{agent}

UI: an AI Assistants checkboxes field on the mode add/edit form (rendered only when the site has assistants), and an AI Assistants column on the Modes listing that reads "Any assistant" when the mode names none.

Surfaces that pass the assistant: the AI Assistant chat form, the AI Chatbot DeepChat block (drupalSettings.aiAgentModesChatbot.assistant, and the script sends ?assistant=<id>), the selector block (its configured AI Assistant), and the options endpoint.

Commit 2 — use the actual ai_agents / AI Assistant API extension points

The module used a single event before. This moves it onto the extension points those two APIs actually provide.

1. Directive timing. The mode directive moved from ai_agents.request to ai_agents.pre_system_prompt. The agent replaces tokens after that event, so a Drupal token written into a mode's System prompt addition is now resolved instead of sent to the model literally. ai_agents.request is kept as a fallback, guarded by a shared directive marker so nothing is ever injected twice.

2. Scope strength — a mode can now withhold sub-agent tools, not just steer. New optional per-mode Scope strength:

  • guide (default) — exactly current behaviour: steer through the prompt, every tool stays.
  • restrict — also withholds the sub-agent tools the mode does not name, applied on ai_agents.started_execution through the agent wrapper's own overrideFunctions(['tools' => ...]) — the last point the tool set changes, so a withheld tool is never instantiated and never reaches the provider.

Enforced and tested: only tools in upstream's agent_tools function group are ever withheld (so the agent keeps its own tools); a tool already disabled on the agent is never re-enabled; the baseline is the override-applied agent, so a mode can never re-enable something an ai_agent_override removed; a nested sub-agent run is never narrowed; a restriction is undone when the mode is cleared or changed, including across turns; a site-wide Enforce tool scope setting turns the whole behaviour off without editing any mode. A mode naming no parent agent, or no sub-agent, falls back to steering and logs a warning.

Withholding narrows the context window; it is not an authorisation boundary — 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.

Known upstream defect, which is why restrict is opt-in with a kill switch: if the model calls a withheld sub-agent anyway (can happen when the mode changes mid-conversation), drupal/ai builds a ToolsFunctionOutput with an uninitialised $name, and the run raises a PHP Error outside any catch instead of answering gracefully. Advise picking the mode before starting a conversation.

3. AI Assistant API integration, two of its own events, both soft:

  • ai_assistant.pass_context_to_agent binds the assistant identity to the agent run, so a mode limited to other assistants is refused at run time, not only hidden in the dropdown. Nothing in the event is mutated.
  • ai_assistant.change_assistant_message steers an AI Assistant with no agent behind it, which no agent event can ever reach. Such an assistant is now offered the generic modes, stored under assistant:<id>.

The subscriber is registered with '@?ai_assistant_api.runner' and subscribes by literal event name — getSubscribedEvents() runs while the container compiles, and a class-constant fetch on an absent module would fatal every cache rebuild. A unit test fails if that's ever "tidied" into a class reference.

4. Two smaller things.

  • ai_agents dependency floor raised to 1.3 (composer.json and ai_agent_modes.info.yml) — the new event classes are referenced at container-compile time. Maintainer: please confirm the exact floor if you know which 1.x release introduced BuildSystemPromptEvent, AgentStartedExecutionEvent and overrideFunctions().
  • The test fixture recipe no longer places an AI Agent Mode selector block (it used to put a Mode dropdown on every admin page of any site the fixture was applied to). The suite now asserts through the JSON options endpoint the client-rendered chats actually read, closer to real usage and harmless to the host site.

A post_update hook writes the new site setting on existing sites and re-saves every mode so the explicit default lands in exported config. No mode changes behaviour on update.

Commit 3 — CSpell dictionary and the live-provider lane

Follow-up to the fixture change in commit 2: with the selector block no longer placed, the CI dictionary and the live-provider test lane needed the corresponding adjustment so CSpell and the live functional job keep passing against the updated fixture.

Commits 4 to 6 — the AI Chatbot dropdown placement is configurable, per site and per assistant

The problem. In the AI Chatbot panel the Mode dropdown rendered under the message box, contradicting the documentation, which said above the chat. The script inserted before .chat-element and appended at the end of the panel when that anchor was missing, which is what happens while a privacy gate (Klaro) holds the chat back.

The fix, and the new capability.

  • New chatbot_position setting on the module's settings form: above_chat (the default, and what the documentation always promised), below_input, header. The Drupal Canvas AI placement keeps its own separate setting, since the two panels are different surfaces.
  • The script now anchors on the panel's real markup, .ai-deepchat--header and .chat-element, per position, and waits up to ten seconds for the chat rather than appending the dropdown wherever it can.
  • A single AI Assistant can override the site setting on its own edit form. The choice is stored as a third-party setting on the ai_assistant config entity, so it travels with that assistant's exported configuration, 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.
  • Two post-update hooks write the new settings on existing sites. No site's behaviour changes on update.
  • The placement scenarios were reworked to pick their radio group by element ID rather than position, and the chatbot group gained its own dedicated scenarios alongside the existing Drupal Canvas AI ones.

Verified live on Drupal CMS across all four cases: no override (site setting applies), override honoured, an unknown stored value falls back to the site setting, and the override removed leaving empty third-party settings on the assistant.

Commit 7 — calculate the parent agent dependency, and log when tool scope is off

Four follow-ups from more local testing and a review of where the module could lean harder on Drupal's own APIs and the AI stack's own APIs.

  1. The mode entity now calculates its configuration dependencies, the way ai_agents' own AiAgent entity does. A mode declares a dependency on its parent agent only, so Drupal removes the mode with the agent. It deliberately does not depend on the sub-agents or the AI Assistants it names, because the module already tolerates their absence at run time and a hard dependency would delete a whole mode because one unrelated sub-agent was removed. The developer documentation previously told people to add that dependency by hand; it no longer does.
  2. The site-wide tool-scope switch is no longer silent. When Enforce tool scope is off, a withholding mode logs that this is why nothing was withheld, so a reader of the log can tell the switch apart from a misconfigured mode.
  3. One vocabulary for the scope strength. The Modes listing said Steer; the mode form said Steer only. Both now say Steer only.
  4. The browser suite is repeatable. The mode-creation scenario created a mode and never deleted it, so a second run against the same site failed on a duplicate machine name. It now deletes what it creates. CI never saw this because CI builds a fresh site for every run; it only showed up in local repeat runs.

Two new kernel tests came with it: dependencies name only the parent agent (and a generic mode names nothing), and the enforcement-off message is asserted through a spy attached to a real logger channel.

Verification

  • PHPUnit: 48 tests, 312 assertions, unit + kernel + functional, no failures.
  • webship-js browser suite on Drupal CMS: 42 scenarios, 365 steps, all passing, and the previously non-repeatable mode-creation scenario now passes on two consecutive runs against the same site.
  • PHPCS Drupal and DrupalPractice: clean. CSpell: clean.
  • Live checks on Varbase 11 against the real canvas_ai_orchestrator: a withholding mode kept the single sub-agent it named, withheld the other four, and kept all 23 of the agent's own tools; [site:name] inside a mode's text resolved to the site name, where the old code sent it literally; and all eight of that site's existing modes stayed on steer-only after the update.
  • An AI Assistant with no agent behind it was verified live on Drupal CMS: the directive reaches its own system prompt, an agent-bound mode is refused there, the text never accumulates across turns, and another assistant does not pick up that assistant's selection.
  • Pipeline 905452 for the current head (ec5be9ca) is green: 10 of 10 jobs passing (composer, composer-lint, phpcs, phpstan, cspell, eslint, eslint (step definitions), phpunit, functional-testing (Drupal CMS), functional-testing (Drupal CMS - AI live agent - amazee.ai)). Remaining pipeline entries are the manual core-version matrix or rules-skipped.

AI-Generated: Yes

Checkpoints

  • File an issue about this project
  • Addition/Change/Update/Fix to this project
  • Testing to ensure no regression
  • Automated unit/functional testing coverage
  • Developer Documentation support on feature change/addition
  • User Guide Documentation support on feature change/addition
  • UX/UI designer responsibilities
  • Accessibility and Readability
  • Reviewed by a human
  • Code review by maintainers
  • Full testing and approval
  • Credit contributors
  • Review with the product owner
  • Update Release Notes
  • Release
Edited by Rajab Natshah

Merge request reports

Loading