Resolve "Canvas AI: Add a dev page builder agent and wire the placement tools"
For testing steps, please refer: #3591921 (comment 1940926)
What this MR does
Adds the dev page builder agent (canvas_dev_page_builder_agent) and wires the new placement tools end-to-end through canvas_dev_ai's client-side hop loop. One agent now covers what the four live agents (canvas_page_builder_agent, canvas_template_builder_agent, canvas_title_generation_agent, canvas_metadata_generation_agent) split between them: building pages, extending them, editing components already on the page, and setting page title/description. The four live agents are untouched — consolidation and the later rename to canvas_page_builder_agent stay out of scope per the issue.
The agent
- New config entity
modules/canvas_dev_ai/config/install/ai_agents.ai_agent.canvas_dev_page_builder_agent.yml, shipped bycanvas_dev_ai(dev-only lifecycle: installed with the dev module, removed on its uninstall), based on the POC prompt with the POC-only artifacts removed (rag_tool, the[canvas_ai:execution_strategy]token). - Tools wired:
canvas_ai:place_components,canvas_ai:edit_components,canvas_ai:get_component_details,canvas_ai:set_page_value.place_componentshas Restrict multiple calls enabled with the message from the issue.max_loops: 50. - Information tools:
canvas_ai:get_component_contextwithcatalog_only: trueon the first hop only (available_on_loop: [1]),canvas_ai:get_current_layouton every hop. canvas_ai:get_component_contextgained an optional booleancatalog_onlycontext: when set, it returns the lean id/name/description catalog fromCanvasAiComponentContextHelper::getComponentCatalog(). Absent/false keeps the full output, so the live agents are unaffected.- Prompt behavior per the follow-up comment on the issue:
- Plan gate anchored to the agreed rule: placing or editing more than one component, or a distinct section, requires describing the plan and asking first; a single unambiguous action acts directly. A "Gate replies" rule classifies the user's answer (approval / adjustment / unrelated) — an adjustment produces the full revised plan and stops at the gate again, in a text-only turn.
- In-progress narration: placements/edits are narrated present-continuous ("Adding the hero row now — it'll appear shortly."); past tense only once the work is done.
- No execution-strategy toggle: row-by-row is the only behavior; the prompt's "Build sequence" section states there is no batch mode, and nothing configurable was added.
Backend wiring
CanvasDevAiBuildernow instantiates the agent fromcanvas_dev_ai.settings:main_agent(previously hardcoded tocanvas_component_agent), with a fallback for pre-existing settings. The shipped default cannot name the new agent directly — core installs simple config before config entities, so theConfigExistsconstraint would fail mid-install; insteadcanvas_dev_ai_install()flipsmain_agentafter config install, andcanvas_dev_ai_post_update_0001covers existing installs (creates the agent preservinguuid/_core, flipsmain_agentonly if it still has the old shipped default).- The new agent is selectable in the Agents & Tools form.
PlaceComponents/EditComponentsnow catch YAMLParseExceptionand return an instructive error (quote string values; unquoted date-like values such as2233-33-33are parsed as invalid dates). Raw parse errors previously gave the model nothing to act on and caused identical-retry loops; instructive errors make it self-correct, matching how it already recovers from validation errors.
Frontend (dev wizard only — AiWizard.tsx untouched)
Three bugs found during live testing, all in how hop responses were applied:
- Race:
receiveMessage()fired the operations handler viasetTimeout(0)and dropped the promise, so the next hop'scurrent_layoutwas built before placements were applied. Handlers are extracted tomessageHandlers.ts(processAiMessage()), and every handler — operations included — is awaited before the hop resolves. - UUID chaining: the operations handler generated a fresh client-side UUID for each placed component, while
place_components' tool result tells the model to chainreference_uuidon the backend-assigned UUID — every follow-up placement failed with "Component with UUID … not found in layout". The handler now passespredefinedUUIDthroughaddNewComponentToLayout. - Edits were dropped:
edit_components' structured output (component_updates) had no frontend handler — edits validated server-side and reported success, but the canvas never changed. A new handler applies them via the existinglayoutUtils.updateExistingComponentValues.
Requirements
composer.json require-dev: drupal/ai ^1.4, drupal/ai_agents ^1.3 (needed for available_on_loop and per-tool restrict_multiple_calls); README version text updated.
Test coverage
CanvasDevPageBuilderAgentEndToEndTest— kernel E2E driving three real HTTP hops through/admin/api/canvas/ai-devagainst recorded echoai fixtures: hop 1 parks the hero placement, hop 2 executes it and chains the headingbelowthe hero's backend UUID from the tool result, hop 3 sends the layout containing the hero and closes the turn. UUIDs deterministic via a stubbed uuid service.CanvasDevPageBuilderAgentConfigTest— agent created via real module install (exerciseshook_install), tool registration, explicit config-schema validation, restrict-multiple-calls settings, information-tool hop config, post-update create/flip/preserve semantics.- Extended:
PlaceComponentsTest/EditComponentsTest(instructive YAML-parse errors),GetComponentContextTest(catalog_onlyon/off),CanvasDevAiAgentSelectionFormTest. messageHandlers.test.tsx— vitest:processAiMessage()resolves only after the operations handler finishes (fake timers, dispatch ordering, navigate-before-resolve), backend-UUID preservation,component_updatesapplication, page-data application, error handling.
Testing instructions
Setup
-
Check out the branch, then build the UI and clear caches — mandatory, the branch ships frontend fixes:
cd <canvas>; npm ci && npm run --workspace=@drupal-canvas/ui build drush cr -
Fresh install:
drush pm:enable canvas canvas_ai canvas_dev_ai. Pre-existingcanvas_dev_aiinstall: rundrush updbinstead and confirmcanvas_dev_ai_post_update_0001_add_dev_page_builder_agentappears in the log. -
Configure an AI provider with function calling and set the default Chat model to a current-generation model (gpt-5.2 / Claude Sonnet class). Older models (verified with gpt-4o) do not follow the plan-gate instructions reliably — that is a model limitation, not this branch.
-
Log in with
use Drupal Canvas AI.
Config checks
-
/admin/config/ai/tools-automation/agentslists Drupal Canvas Dev Page Builder Agent; its form shows exactly the four new tools, "Restrict multiple calls" onplace_componentswith the message from the issue, max loops 50. - On the Canvas AI settings Agents & Tools tab, Main agent is preselected to the dev page builder agent; selecting it as a Tool too is rejected.
Frontend (dev wizard only — AiWizard.tsx untouched)
Three bugs found during live testing, all in how hop responses were applied:
- Race:
receiveMessage()fired the operations handler viasetTimeout(0)and dropped the promise, so the next hop'scurrent_layoutwas built before placements were applied. Handlers are extracted tomessageHandlers.ts(processAiMessage()), and every handler — operations included — is awaited before the hop resolves.
- Single edit applies in place: "Change that heading to say 'Hello there' and make it an H2" → the heading visibly changes on the canvas (no duplicate), and its props form shows the new values.
- Bulk edit gates, then applies: "Make all the headings on this page title case" → plan + approval, then every listed heading changes.
- Metadata gate: after a build on a page with an empty title/description, the agent proposes both, stops for approval, and the page's Title/Description fields update after "yes".
Network verification (DevTools → Network, filter /admin/api/canvas)
- Every turn POSTs to
/admin/api/canvas/ai-dev(never/admin/api/canvas/ai), with anX-CSRF-Tokenheader; the build turn is a burst sharing onerequest_id,should_continue: trueuntil the final hop,progressgrowing per hop. - Placement hops carry
operations(components withid/uuid/nodePath/fieldValues); edit turns carrycomponent_updates. - The race/UUID fixes: take a hop whose response contains
operations, note a component'suuid, and confirm the next request'scurrent_layout.regions.content.componentscontains that same uuid at the matching index.
Automated
composer run phpunit -- modules/canvas_ai/tests/src/Kernel/
composer run phpunit -- modules/canvas_ai/tests/src/Functional/Form/CanvasDevAiAgentSelectionFormTest.php
npm run --workspace=@drupal-canvas/ui test -- src/components/aiExtension/(Latest local run: 161 kernel tests / 2218 assertions, 3 functional, 8 vitest — all passing; phpcs/phpstan/eslint/prettier/cspell clean on all changed files.)
Known limitations and follow-ups
- Mid-build stall (rare): a model can end a completion without a tool call mid-build; the client loop has no automatic re-prompt, so the turn ends. Recovery is replying "continue" (the agent resumes from the current layout, no data loss). The prompt hardens against this; the mechanical fix (re-prompting on unfinished work)
Frontend (dev wizard only — AiWizard.tsx untouched)
Three bugs found during live testing, all in how hop responses were applied:
- Race:
receiveMessage()fired the operations handler viasetTimeout(0)and dropped the promise, so the next hop'scurrent_layoutwas built before placements were applied. Handlers are extracted tomessageHandlers.ts(processAiMessage()), and every handler — operations included — is awaited before the hop resolves. - UUID chaining: the operations handler generated a fresh client-side UUID for each placed component, while
place_components' tool result tells the model to chainreference_uuidon the backend-assigned UUID — every follow-up placement failed with "Component with UUID … not found in layout". The handler now passespredefinedUUIDthroughaddNewComponentToLayout. - Edits were dropped:
edit_components' structured output (component_updates) had no frontend handler — edits validated server-side and reported success, but the canvas never changed. A new handler applies them via the existinglayoutUtils.updateExistingComponentValues.
Requirements
composer.json require-dev: drupal/ai ^1.4, drupal/ai_agents ^1.3 (needed for available_on_loop and per-tool restrict_multiple_calls); README version text updated.
Test coverage
CanvasDevPageBuilderAgentEndToEndTest— kernel E2E driving three real HTTP hops through/admin/api/canvas/ai-devagainst recorded echoai fixtures: hop 1 parks the hero placement, hop 2 executes it and chains the headingbelowthe hero's backend UUID from the tool result, hop 3 sends the layout containing the hero and closes the turn. UUIDs deterministic via a stubbed uuid service.CanvasDevPageBuilderAgentConfigTest— agent created via real module install (exerciseshook_install), tool registration, explicit config-schema validation, restrict-multiple-calls settings, information-tool hop config, post-update create/flip/preserve semantics.- Extended:
PlaceComponentsTest/EditComponentsTest(instructive YAML-parse errors),GetComponentContextTest(catalog_onlyon/off),CanvasDevAiAgentSelectionFormTest. messageHandlers.test.tsx— vitest:processAiMessage()resolves only after the operations handler finishes (fake timers, dispatch ordering, navigate-before-resolve), backend-UUID preservation,component_updatesapplication, page-data application, error handling.
Testing instructions
Setup
-
Check out the branch, then build the UI and clear caches — mandatory, the branch ships frontend fixes:
cd <canvas>; npm ci && npm run --workspace=@drupal-canvas/ui build drush cr -
Fresh install:
drush pm:enable canvas canvas_ai canvas_dev_ai. Pre-existingcanvas_dev_aiinstall: rundrush updbinstead and confirmcanvas_dev_ai_post_update_0001_add_dev_page_builder_agentappears in the log. -
Configure an AI provider with function calling and set the default Chat model to a current-generation model (gpt-5.2 / Claude Sonnet class). Older models (verified with gpt-4o) do not follow the plan-gate instructions reliably — that is a model limitation, not this branch.
-
Log in with
use Drupal Canvas AI.
Config checks
-
/admin/config/ai/tools-automation/agentslists Drupal Canvas Dev Page Builder Agent; its form shows exactly the four new tools, "Restrict multiple calls" onplace_componentswith the message from the issue, max loops 50. - On the Canvas AI settings Agents & Tools tab, Main agent is preselected to the dev page builder agent; selecting it as a Tool too is rejected.
-
drush config:get canvas_dev_ai.settings main_agent→canvas_dev_page_builder_agent(a deliberately different choice must be preserved acrossdrush updb).
Chat scenarios (clean Canvas page, AI panel in the editor)
- Plan gate: "Build me a landing page for a coffee subscription service" → plain-text plan + approval question, nothing placed.
- Adjustment loops back: "swap the testimonials for a FAQ section" → complete revised plan + asks again, text-only turn, still nothing placed.
- Approve: "proceed" → the whole revised plan builds, sections appearing one at a time top-to-bottom (~1 s apart), narration present-continuous during the build, past tense only in the closing line. Occasional single-shot corrective retries ("wrong slot name — correcting…") are designed behavior.
- Section-sized request gates: "Add a pricing section" → brief plan first.
- Single action doesn't gate: "Add a heading that says 'Welcome' at the bottom of the page" → places directly.
- Single edit applies in place: "Change that heading to say 'Hello there' and make it an H2" → the heading visibly changes on the canvas (no duplicate), and its props form shows the new values.
- Bulk edit gates, then applies: "Make all the headings on this page title case" → plan + approval, then every listed heading changes.
- Metadata gate: after a build on a page with an empty title/description, the agent proposes both, stops for approval, and the page's Title/Description fields update after "yes".
Network verification (DevTools → Network, filter /admin/api/canvas)
- Every turn POSTs to
/admin/api/canvas/ai-dev(never/admin/api/canvas/ai), with anX-CSRF-Tokenheader; the build turn is a burst sharing onerequest_id,should_continue: trueuntil the final hop,progressgrowing per hop. - Placement hops carry
operations(components withid/uuid/nodePath/fieldValues); edit turns carrycomponent_updates. - The race/UUID fixes: take a hop whose response contains
operations, note a component'suuid, and confirm the next request'scurrent_layout.regions.content.componentscontains that same uuid at the matching index.
Automated
composer run phpunit -- modules/canvas_ai/tests/src/Kernel/
composer run phpunit -- modules/canvas_ai/tests/src/Functional/Form/CanvasDevAiAgentSelectionFormTest.php
npm run --workspace=@drupal-canvas/ui test -- src/components/aiExtension/(Latest local run: 161 kernel tests / 2218 assertions, 3 functional, 8 vitest — all passing; phpcs/phpstan/eslint/prettier/cspell clean on all changed files.)
Known limitations and follow-ups
- Mid-build stall (rare): a model can end a completion without a tool call mid-build; the client loop has no automatic re-prompt, so the turn ends. Recovery is replying "continue" (the agent resumes from the current layout, no data loss). The prompt hardens against this; the mechanical fix (re-prompting on unfinished work) belongs to the hop-loop work in #3591777.
AiWizard.tsxdedup: the live wizard still carries its own copy of the message handlers; switching it to the sharedmessageHandlers.tsis a follow-up so this MR does not change production-wizard timing.- Live-LLM eval group: the
canvas_ai_agents_testgroup yaml still asserts the oldset_component_structuretool; updating it is deferred to the consolidation issue. - Testing on Byte-theme sites:
card-pricing/hero-blogcomponents error with[byte_theme:heading/url] Invalid URL format … "No URL"wherever SDC validation runs — an upstream theme bug, filed with a fix at byte_theme#3591007. Not caused by this branch; ignore during review. - Observation for a possible canvas_ai follow-up: placement-time validation does not assert SDC prop
formatconstraints (render-time does), and the enum-violation message doesn't distinguish48from"48"— both occasionally cost the model an extra retry.