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