Canvas AI: Add the place_components AI tool (+ kernel test)
## Summary
Add a new AI tool `canvas_ai:place_components` (class `PlaceComponents`) to `canvas_ai`, ported from the POC (MR !1214). It places components into the current layout at a given region/slot target and returns the assigned UUIDs plus the predicted layout tree. It lands **inert** — no agent lists it yet — so it changes nothing until a later issue wires it to the dev agent. Add a kernel test adapted from the existing `set_component_structure` test.
## Why (this sprint)
`place_components` is the core mutation tool of the client-side hop loop: the agent places one section per turn and references the returned UUID when placing the next. It is deterministic PHP — fully testable on its own, with the agent only *selecting* it — so it can land now, tested, in parallel with the module/wizard work. It is a **new** tool rather than an edit to `set_component_structure` because the live `canvas_page_builder_agent` already lists `canvas_ai:set_component_structure` in its tools; changing that tool's logic would alter behavior for the existing agent. The old tool is removed at graduation once the new agents replace it.
- Roadmap: https://git.drupalcode.org/project/canvas/-/work_items/3591777
- Reference implementation (POC, no test): https://git.drupalcode.org/project/canvas/-/merge_requests/1214
## What to build
Port `PlaceComponents` from the POC to `canvas_ai` under the final ID `canvas_ai:place_components`. It mirrors the existing `SetAIGeneratedComponentStructure` (`canvas_ai:set_component_structure`) tool: same operations YAML input, same validation, same shared-layout services. Port the POC class largely as-is (apart from any small optimizations) rather than re-deriving it.
- **Argument.** A single argument holding the operations YAML string. Name it **`component_structure_yaml`** (the POC calls it `component_structure`; use the `_yaml` name here to make the format explicit).
- **Interfaces.** `final class PlaceComponents extends FunctionCallBase implements ExecutableFunctionCallInterface, AiAgentContextInterface, BuilderResponseFunctionCallInterface` — the `BuilderResponseFunctionCallInterface` marker is what makes `getStructuredOutput()` merge into the hop response.
- **Validation.** Calls `AiResponseValidator::validateComponentStructure()` (`canvas_ai.response_validator`) on each operation's `components`, exactly as `set_component_structure` does.
- **Layout mapping / shared state.** Calls `CanvasAiPageBuilderHelper::customYamlToArrayMapper()` (`canvas_ai.page_builder_helper`), which reads the running layout from `CanvasAiTempStore::CURRENT_LAYOUT_KEY` (`canvas_ai.tempstore`), merges the placement in, computes each component's `nodePath`, and predicts the resulting tree. This tempstore-backed layout is what lets successive `place_components` calls build on each other.
- **Output.** `getStructuredOutput()` returns the `{ operations: [...] }` payload plus a message built from `CanvasAiPlacementDto` (`canvas_ai.placement_dto`): `getComponentStructureWithUuids()` (the placed components with their assigned UUIDs) and `getPredictedLayout()` (the predicted UUID tree), with the POC's "continuation point" wording. The frontend applies this to the Redux layout.
### Services
`canvas_ai.response_validator`, `canvas_ai.page_builder_helper`, and `canvas_ai.tempstore` already exist in `canvas_ai`. **`canvas_ai.placement_dto` (`CanvasAiPlacementDto`) does not — create it**: port the class from the POC and register it in `canvas_ai.services.yml`. If the additive `CanvasAiPageBuilderHelper` methods this tool relies on (`customYamlToArrayMapper()` and the DTO wiring) are not yet present in live, port them from the POC alongside — they are additive.
### Inert on landing
Do **not** add `place_components` to any agent's tool list in this issue. It ships as an available plugin only; wiring it to the dev agent is a later issue. This keeps the change additive and invisible to the live editor.
## Tests
New kernel test `modules/canvas_ai/tests/src/Kernel/Plugin/AiFunctionCall/PlaceComponentsTest.php` extending `CanvasKernelTestBase`, adapted from `SetAIGeneratedComponentStructureTest` (same fixtures, `getCurrentLayout()` helper, and `normalizeErrorString()`). Cover:
- **Happy path.** Valid `operations` → assert `getStructuredOutput()` equals the expected payload, including the predicted-layout message.
- **Permission.** Without the permission, running the tool throws.
- **Invalid YAML** (data provider).
- **Component validation errors**, top-level and nested: non-existent component id, missing required prop, invalid slot name, non-existent prop — copied from the existing test.
- **Placement/region targeting** (place_components-specific, not in the structure test): unknown `target` region, unknown `reference_uuid`, and each `placement` value (`inside` / `above` / `below`) resolving to the correct `nodePath`.
Assert the exact error strings, as the existing test does.
## Explicitly NOT doing
- Not modifying or deleting `set_component_structure` (removed at graduation).
- Not wiring `place_components` to any agent.
- Not building `edit_components` / `set_page_value` / `get_component_details` — separate issues sharing the same validator/helper.
---
_Issue generated with AI assistance._
issue