Canvas AI: Client-side orchestration loop roadmap
## Development strategy Breaking changes and new features ship behind a dev module, the way Canvas already does it for conflict detection with `canvas_dev_cd`. We create `canvas_dev_ai` and hide the revamped flow behind it. - **New tools and services merge directly into `canvas_ai`**, not the dev module: `place_components`, `edit_components`, `set_page_value`, `get_component_details`, and their supporting services. - **The controller is cloned into `canvas_dev_ai`.** Copy `canvas_ai`'s existing `CanvasBuilder` into the dev module with the hop-loop `render()`, served at `/admin/api/canvas/ai-dev`. The `/ai-dev` route validates the same CSRF token the `/ai` route uses, and the dev wizard sends it. - **`canvas_dev_ai` ships two new agents**, under new IDs so they never collide with `canvas_ai`'s: `canvas_dev_page_builder_agent` and `canvas_agent` (the Q&A agent). - **`canvas_dev_ai` overrides the component-metadata output.** It provides its own tool that narrows `get_component_context` to id/name/description; the existing `canvas_ai` tool keeps its full output because the old agents still consume it. - **The wizard is cloned, not modified.** `ui/src/components/aiExtension/AiWizard.tsx` → `AiWizardDev.tsx` (the hop loop, posting to `/ai-dev`); `AiWizard.tsx` is untouched. - **A flag drives the render.** When `canvas_dev_ai` is installed it sets a flag on `drupalSettings.canvas`; the two wizard mount sites render `AiWizardDev` or `AiWizard` based on that flag. - **Tests stay in `canvas_ai`.** Tests that exercise the changed files (e.g. `CanvasBuilder`) live in `canvas_ai`'s suite but install `canvas_dev_ai` to run against the dev flow. - **Deprecation.** Once the flow is stable it graduates into `canvas_ai` and `canvas_dev_ai` is deprecated. Breaking changes are acceptable (`canvas_ai` is beta). The upgrade path is a `hook_update_N` that deletes stale `ai_agent` config entities plus system-prompt/config updates; no data migration. ### Where each item lands During the dev phase nothing in `canvas_ai` is deleted or rewritten in place: items framed below as "extend the existing X" or "remove the old Y" land as parallel `canvas_dev_ai` additions under new IDs, and the graduation MR performs every in-place rewrite and deletion at once. | Item | Lands in | |---|---| | 1. `addNewComponentToLayout` UUID | Canvas core `1.x`, ungated (backward compatible) | | 2. Settings: main agent + tools | New `canvas_dev_ai` settings form + `canvas_dev_ai.settings` | | 3. Chat tools picker | `AiWizardDev.tsx`, gated by the `canvas_dev_ai` flag | | 4. New/changed tools | `canvas_ai` itself, final IDs, kernel-tested | | 5. Resolved props in `current_layout` | Backend helpers in `canvas_ai`; frontend in `AiWizardDev.tsx` | | 6. Hop flow | Cloned controller in `canvas_dev_ai` at `/admin/api/canvas/ai-dev`; the old progress endpoint stays until graduation | | 7. Page Builder prompt + wiring | `canvas_dev_page_builder_agent` in `canvas_dev_ai` | | 8. Delete consolidated agents/tools | Deferred to graduation — nothing is deleted while the old flow is live | | 9. Q&A agent | `canvas_agent` in `canvas_dev_ai` | | 10. Update hook | Written as the graduation update hook (see above) | ## Key changes | Agent deleted | Replaced with | |---------------|---------------| | Drupal Canvas Template Builder Agent and its tools | Drupal Canvas Page Builder Agent with new tools | | Drupal Canvas Title Generation Agent and its tools | Drupal Canvas Page Builder Agent with new tools | | Drupal Canvas Metadata Generation Agent and its tools | Drupal Canvas Page Builder Agent with new tools | | Drupal Canvas AI Orchestrator and its tools | Drupal Canvas Agent (new) — answers questions about Drupal Canvas, possibly with a tool that reads existing documentation in future | ## Prototype [Drupal Canvas AI V2.html](/uploads/c314deceeee5e425d1c8a61550b496c4/Drupal_Canvas_AI_V2.html) ## **Try it out — step by step** 1. **Open the HTML file on a browser. You land on the config screen.** Just look at the two things that matter: the **Main agent** dropdown and the **Tools** below it. Don't change anything yet. 2. **Click "Save configuration."** You'll see it confirm. (This applies your setup and starts a clean session.) 3. **Click through to the Canvas UI** (the editor with the chat panel). 4. **Type anything in the chat and send.** Notice: **the main agent is the one answering.** Out of the box that's the **Drupal Canvas Agent** — its job is answering questions _about_ Drupal Canvas, so it responds by explaining what it can do rather than building anything. 5. **Now click the wrench and pick the Page Builder tool**, type any prompt, and send → **watch it build the page** live in the canvas. 6. **Send again** → it takes the next step (documents the page / makes its edit), so you see the flow continue. 7. **Now go back and change the main agent and tools** (e.g. set Component Agent as main), save, and return to the chat → **notice the assistant UI changes to match** — the agent answering and the available tools update. That's the whole point: config drives the chat, and it's direct and easy to follow. ## How Canvas AI would work after the changes **Picking which agent you talk to (config-driven, no AI decides this):** - A site admin picks one **main agent** from a settings screen dropdown — this is who answers by default when you open the chat. - The admin can also check off other agents to appear as **tools** in the chat's tool picker (a wrench icon in the chat UI). - When you chat normally (no tool picked), the main agent answers. When you pick a tool from that picker, _that_ agent answers instead — but only for that one message. - This choice is always made by a person — either the admin (setting the default) or the user (picking a tool for one turn) — never by an AI deciding which agent should take over. There's no "router" agent guessing who should handle a request; exactly one agent runs per message, and it's always known in advance which one. ### The loop in action — building a cookie shop homepage The agent stops after every decision and reports back, so each request does one step. A tool call the agent makes is _saved, not run_ — it runs at the start of the next request. This means what the agent says it is doing shows one step before it appears on the canvas. ```mermaid flowchart TD Start["User: 'Create a homepage for a cookie shop'"] --> R1[Request 1 sent] R1 --> R1Act["Agent narrates 'Let me start by placing the hero banner'<br/>and calls the place tool"] R1Act --> R1Save["Place tool NOT executed — state saved back to tempstore"] R1Save --> R1Reply["Reply: should_continue = true"] R1Reply --> R1UI["Client shows narration: 'Let me place the hero banner'<br/>Canvas still empty"] R1UI --> R2[Request 2 sent] R2 --> R2Load["Agent loaded from saved state"] R2Load --> R2Run["Place tool executes (adds the hero banner)<br/>Result sent back to the agent"] R2Run --> R2Act["Agent narrates 'I'll place cards below the hero banner'<br/>and calls the place tool again"] R2Act --> R2Save["Place tool NOT executed — state saved back to tempstore"] R2Save --> R2Reply["Reply: should_continue = true"] R2Reply --> R2UI["Hero banner appears on canvas AND<br/>narration 'Adding cards' shows — at the same time"] R2UI --> Loop["Next request repeats the cycle<br/>until should_continue = false"] ``` ## Roadmap ### 1. Canvas core: let `addNewComponentToLayout` accept a caller-supplied UUID Update the core layout action `addNewComponentToLayout` (`ui/src/features/layout/layoutModelSlice.ts`) so a caller can pass the UUID the new component should get. When no UUID is passed, it keeps generating one itself, exactly as today — so drag-and-drop and the component list are unaffected. **Why this comes first:** As the flowchart above shows, a component is placed on the canvas one request *after* the agent decides to place it. This one-step delay creates a problem for placing components relative to each other: - To put the cards **below the hero banner**, the agent has to name the hero banner — it needs the hero banner's UUID to say "place below this one." - But if the front end invents its own UUID when it finally draws the hero banner, the agent never knows what that UUID turned out to be. The two sides disagree on the ID, and "place below the hero banner" can't be resolved. The fix is to make the **back end own the UUID**: the back end assigns a component's UUID when the agent decides to place it, sends that UUID back to the agent (so the agent can reference it for the *next* component), and also sends it to the front end. For that to work, the front end must place the component using the **UUID it was given**, instead of generating a fresh one — which is exactly what this change enables. Because this touches canvas core (not `canvas_ai`), it is a standalone contribution with its own tests and review, and every later AI change depends on it. That is why it is item 1. ### 2. Canvas AI settings: choose the main agent and the tools Extend the existing Canvas AI settings form so a site admin can compose the chat from the AI agents installed on the site: - **Main agent** — the single-select dropdown that already exists. Today it lists *every* AI agent on the site; change it to list only Canvas's own agents. - **Tools** — a new set of checkboxes. Each checked agent becomes a tool the user can pick from the chat's tool picker for one message. Both lists are drawn from the `ai_agent` config entities on the site, but **filtered to Canvas's own agents** — third-party AI agents are hidden, because they cannot drive the canvas. For now this filter is a **hardcoded allow-list** of Canvas agent IDs (a cleaner, entity-flagged approach can come later). An agent chosen as the main agent cannot also be checked as a tool, and vice versa — the two never overlap. On save, the choices are written to the `canvas_ai.settings` config (the existing `main_agent`, plus a new `tools` list) and exposed to the front end through `drupalSettings.canvas` so the chat UI can read them. **Why:** this is the control panel for the "one agent per message, chosen by a person" model described above. It decides who answers by default and which agents the user can switch to — with no AI making that choice. ### 3. Chat UI: show a tools picker driven by the settings Update the chat UI to render a **tools picker** (the wrench) when one or more tools are configured. The UI reads the configured main agent and tools from `drupalSettings.canvas` and shows the picker accordingly; when no tools are configured, no picker appears. During development the UI can be built against mock settings values, then switched to the real `drupalSettings.canvas` values once item 2 exposes them. **This step needs new designs** for the tool picker — its placement, states, and how the selected tool is shown for a message — before implementation. **Why:** this is the user-facing half of the same model — it turns the admin's configured tools into something the user can actually pick per message. The picker sends the selected tool's agent id with each request; the back-end controller then runs that agent for the turn instead of the default main agent. ### 4. New and changed tools: `place_components`, `edit_components`, `set_page_value`, and `get_component_details` Add the tools the agent calls to change the page: - **`place_components`** — adds new components to the page. This is the current `set_component_structure` tool **renamed** — the name states plainly what the tool does, so the agent understands it more directly. It accepts the **same YAML** as before (the `operations:` format), so the input shape is unchanged. When validation passes, it returns a **UUID tree of the predicted layout** alongside the tool result — because the components are actually placed on the canvas one step later, this tells the agent the resulting structure and each component's UUID up front, so it can reference them when placing the next ones. - **`edit_components`** — changes the props of components already on the page. It accepts a single top-level `component_edits` map, keyed by component UUID, listing only the props being changed: ```yaml component_edits: 550e8400-e29b-41d4-a716-446655440000: heading_level: h3 align: left 6ba7b810-9dad-11d1-80b4-00c04fd430c8: text: "Drupal Canvas is cool" ``` - **`set_page_value`** — sets a value on the page, keyed by field name. Today it handles the page title and the SEO meta description; because it is keyed by field, it can be extended to any future Canvas page field without a new tool. It both sets and updates these values. This single tool replaces all the tools the separate Title Generation and Metadata Generation agents used. The `place_components` and `edit_components` tools validate their input before any change is applied. The old `set_component_structure` tool is removed as part of this — `place_components` is its rename, not a second tool alongside it. **Read tools — the catalog/details split:** - **`get_component_details`** (new) — returns the **props and slots** for the component ids passed to it. The agent shortlists candidates from the catalog, then calls this to learn their props and slots before placing or editing — it cannot set props it has not seen. - **`get_component_context`** (changed) — narrowed to a lightweight **catalog**: it returns only each component's **id, name, and description**, no props or slots. The props and slots move to `get_component_details`, fetched on demand only for shortlisted components. This keeps the initial context small even on sites with many components. It is auto-injected once per turn, so the agent always has the catalog without calling it. **Why the split:** sending every component's full props and slots up front does not scale as the component set grows. A lean catalog plus on-demand details lets the agent see everything that exists cheaply, then pay for detail only on the few components it actually considers. **Why:** these are the tools the page builder agent uses to build and modify a page — placing new components, editing existing ones, and setting page fields like the title and meta description. ### 5. Send each component's prop values in `current_layout` Extend the `current_layout` the chat UI sends so each component carries its **resolved prop values** (its actual content), not just its UUID and name. The layout is rebuilt from the latest editor state and sent on **every request**, so the agent sees the current content of the page on each hop. **Why:** to edit or place components sensibly, the agent has to know what is already on the page — not just that a component exists, but what it says and how it is configured. Sending only UUIDs tells the agent a component is there but nothing about its content; sending the resolved props lets the agent read the real page. Rebuilding it every request keeps the agent in step with edits made between hops. ### 6. [FOUNDATION] Build the hop flow in `CanvasBuilder` (one decision per request) Change the controller so the agent stops after **each decision** instead of running to completion in one request: - Set the agent to **not** loop internally (`setLooped(FALSE)`), so it parks its tool call and returns after a single decision rather than running the whole build inside one request. - Save the agent's state to tempstore between requests and return a `should_continue` flag; the front end re-sends the request to resume, and the saved state is loaded back so the agent picks up where it left off. - This is what makes each request short and bounded, and what lets each row appear as it is built (the flow shown in the diagram above). Delete the **separate AI progress-tracking channel**. Progress no longer comes from its own polling request — instead, **every hop response carries the AI's narration directly**, so the one response stream is the only source of both the live progress text and the final answer. **Why:** this is the core mechanic of the whole revamp — the pause/resume loop that removes request timeouts and drives incremental placement. Folding progress into the hop response removes a second channel that used to race the loop. ### 7. Rewrite the Page Builder agent prompt and wire the new tools Update the `canvas_page_builder_agent` config so it drives the new tools: - **Wire the tools** from items 4–5 into the agent — `place_components`, `edit_components`, `set_page_value`, and the read tools it needs to inspect components before acting — and remove the old tools the prompt used to call. - **Rewrite the system prompt** to describe the agent's job in terms of these tools: fetch component details, place components, edit components, set the page title and meta description. **Crucial: instruct the agent to place one row at a time.** This prompt instruction is what actually makes incremental placement happen — it is as load-bearing as the hop flow itself (item 6). The hop flow provides the *mechanism* (one decision per request), but only the prompt makes the agent **choose** to place a page as a sequence of separate `place_components` calls — one row, then the next — instead of emitting the whole page in a single call. - **With this instruction:** each row is its own tool call, so each row is its own hop, so each row appears in the editor as it is built. This is the live, growing-page experience. - **Without it:** the agent would place the entire page in one `place_components` call — one hop — and the whole page would pop in at once, even though the hop flow is in place. So the row-by-row behavior depends on **both** pieces: the hop flow (item 6) and this prompt instruction. Neither alone is enough. **Why:** the prompt is what turns the wired tools and the hop flow into actual behavior. Without the one-row-at-a-time instruction, the agent has every tool and the whole loop, yet still builds the page in one shot. ### 8. Delete the consolidated agents and their tools Remove the agents now superseded by the Page Builder agent (items 4, 7): - `canvas_template_builder_agent` - `canvas_title_generation_agent` - `canvas_metadata_generation_agent` Delete the tools exclusive to these agents; retain any shared with the Page Builder or Component agents. Existing sites are migrated by the `hook_update_N` in the migration item. ### 9. Create the Drupal Canvas agent (Q&A), replacing the orchestrator Add a Drupal Canvas agent that answers questions about the platform rather than building pages. - No tools initially — it answers from context, with no ability to modify the page. - Its context includes the current Canvas documentation, so answers reflect current behavior. A documentation-reading tool may follow. - Replaces the orchestrator, which is removed. Ships as the default main agent (`canvas_ai.settings:main_agent`). **Why:** users still need a default agent for questions about Canvas. This is that default — read-only, and safe against unintended page changes. ### 10. Update hook for existing sites Add a `hook_update_N` that brings sites installed before the revamp to the new agent set: - **Delete** the removed agents and their exclusive tools (items 8, 9). - **Update** the changed agents' config — the Page Builder agent's prompt and tool list (items 4, 7). - **Import** the new Drupal Canvas agent and set it as the default main agent (item 9). No content or data migration is required — only `ai_agent` and `canvas_ai.settings` config. Follows the existing agent-reimport pattern in `canvas_ai.post_update.php`. **Why:** config changes in items 4–9 apply only to fresh installs. Existing sites keep their old agents until an update hook reconciles their config with the module's. ## Cross project dependencies Two changes land in the `ai_agents` module, not Canvas. Tracked as blockers. ### 1. Test coverage for `setLooped(FALSE)` in ai_agents The hop flow (item 6) depends on `setLooped(FALSE)` behaving as it does today. This is internal behavior, not a documented API. An ai_agents release could change it and break every Canvas AI build with no error. Current ai_agents tests do not cover this. `setLooped` and `toArray` have zero test references. Existing tests load agent state from fixtures via `fromArray()` one way only, and cover status-event dispatch, not the pause/resume mechanic. Add cases to the existing `AiAgentEntityWrapperDetermineSolvabilityTest` and `AiAgentEntityWrapperTest` (no new files) pinning: - With `setLooped(FALSE)`, `determineSolvability()` stops after one tool decision. The tool call is parked, not executed. The agent is not finished. - A parked tool call executes at the start of the next `determineSolvability()`. - `toArray()` then `fromArray()` round-trips the agent so a parked call survives serialize and restore. - `isFinished()` is FALSE while a parked call is pending, TRUE only after a final text answer with no tool call. - `setDetailedProgressTracking()` still captures narration: the `Started` event initializes the progress store, so dropping it must not discard `TextGenerated` writes. ### 2. "Disable parallel tool calling" in ai_agents Add a per-tool setting that forbids the model from calling the same tool more than once in one response, with a configurable error message. When enabled and the response contains that tool more than once, ai_agents does not execute the duplicate calls. It returns the configured message to the model as the tool result, so the model retries with one consolidated call. Canvas enables it on `place_components` via `tool_settings`. `place_components` is meant to be called once per decision, with all components for that step in one YAML payload. Today several `place_components` calls in one response all use the `operations` key and are merged with `array_merge` in `CanvasBuilder`, so one clobbers the others and placements are lost. Fixing this in ai_agents solves it at the source and benefits every ai_agents consumer. ### 3. Merge gate `1.x-ai-revamp` does not merge to `1.x` until the ai_agents releases carrying items 1 and 2 are tagged. _Generated with AI assistance._
issue