Pull out 'handle' behavior into own service
> [!note] Migrated issue
>
> <!--Drupal.org comment-->
>
> <!--Migrated from issue #3550815.-->
>
> Reported by: [michaellander](https://www.drupal.org/user/636494)
Added a handle system for passing complex values (entities, objects) between tool calls without serializing them to the invoker.
**The problem:** When an LLM chains tools like `entity_load` → `field_set_value` → `entity_save`, the entity can't be serialized over the wire between calls. The previous approach in `tool_ai_connector` manually managed tempstore artifacts with inline string manipulation and regex parsing.
**The solution:** A `ToolHandleStore` service in the base tool module provides generic value storage with opaque handle strings. An `EntityHandleTransformer` service provides reusable entity-specific logic. Connector modules wire these into the invoker event system via thin subscriber classes.
**How it works:**
1. Tool outputs an entity → `EntityHandleTransformer::transformOutput()` stores it, returns a `HandleTransformResult` (handle string + hint)
2. LLM receives `` handle:uuid `` and a hint like: `` `handle:uuid` (output `loaded_entity`) is a node entity (bundle: article) (id: 42) (language: en). Pass this handle to subsequent tool calls that accept entity inputs. ``
3. LLM passes `handle:uuid` to next tool → `EntityHandleTransformer::resolveInput()` resolves it back to the entity and tags it with `_tool_handle` for reuse
4. Tool mutates entity, outputs it → transformer sees `_tool_handle`, reuses the same handle, updates the stored value
**Architecture:**
```
tool module (shared infrastructure):
src/Handle/
ToolHandleStore.php — generic store (store/resolve/has/delete/isHandle)
EntityHandleTransformer.php — pure methods, no event dependency
HandleTransformResult.php — DTO (value + hint)
tool_ai_connector (AI-specific wiring):
src/EventSubscriber/EntityArtifactSubscriber.php
— checks AiAgentsInvoker::ID, delegates to transformer
mcp_server_tool_bridge (MCP-specific wiring):
src/EventSubscriber/EntityHandleSubscriber.php
— checks ToolApi::INVOKER_ID, delegates to same transformer
```
**Key design decisions:**
- `EntityHandleTransformer` is pure — takes values, returns values/DTOs. Never touches events directly. Subscribers are the glue between events and the handler.
- `ToolHandleStore` is value-agnostic — stores anything (entities, arrays, strings). Entity-specific logic lives in the transformer, not the store.
- Handle format is `handle:uuid` — prefixed for detection via `ToolHandleStore::isHandle()`, UUID for uniqueness.
- Backed by `PrivateTempStore` (user-scoped, 7-day expiry, cleaned on cron).
- For custom non-entity handles (e.g., accumulating a long string across tool calls), tools use `tool.handle_store` directly — no event needed.
- Handle reuse across serialization boundaries works because `resolveInput()` tags the deserialized object with `_tool_handle`, so subsequent output transforms reuse the same handle.
**What this replaces in tool_ai_connector:**
<span dir="">The old `ToolPluginBase` had \~80 lines of inline artifact management (`handleArtifactConversion()`, `createHandleTokenForEntity()`, `getEntityMetadata()`, `replaceHandleTokenWithValue()`, manual tempstore/regex). </span>`mcp` and `mcp_server` also use a duplicated approach, rather than a unified solution. Separate issues will be used to implement the handle across these modules (including `tool_ai_connector`).
**Definition normalization:**
The transformer also handles schema — when normalizing for an invoker, entity-typed definitions are swapped to `string` with descriptions like "pass a handle from a previous tool call". This ensures the JSON Schema exposed to MCP/AI describes what the invoker actually provides (a handle string), not what the tool internally expects (an entity object).
issue