# Tool-aware node processors

## Overview

A **tool-aware** node processor can discover and invoke the *tools* wired to its
node. This is how an "agent" node (an LLM reasoning step, or any node that picks
and runs actions at runtime) consumes tools.

Tool use is a **capability**, not a node type. Any processor opts in by
implementing `ToolsAwareInterface` (use `ToolsAwareTrait` for the standard
implementation). The runtime then resolves the node's connected tools and
injects them before `process()` runs — the same pattern as
`ExecutionContextAwareInterface`.

> **Supply vs. capability — keep them separate.** A tool *handle/port* is only
> the **dynamic** way to supply tools (per-workflow wiring via
> `tool_availability` edges). Tools may equally come from static node config or
> be hardcoded by the processor. `ToolsAwareInterface` is the *consumption
> capability* and is independent of how tools are supplied. Whether a node
> exposes a tool handle is a deliberate plugin-writer/site-builder choice; it is
> not derived from the interface.

## The contract

```php
namespace Drupal\flowdrop\Plugin\FlowDropNodeProcessor;

interface ToolsAwareInterface {
  public function setTools(ToolCollectionInterface $tools): void;
}
```

`ToolsAwareTrait` stores the injected collection and exposes:

| Method | Returns | Notes |
| ------ | ------- | ----- |
| `getTools()` | `ToolCollectionInterface` | Empty collection if no tools were injected. |
| `getConnectedTools()` | `ToolBindingInterface[]` | Shorthand for `getTools()->all()`. |

Because the trait defaults to an empty collection, a tool-aware node with no
wired tools (e.g. one that sources tools from static config) works without any
special handling.

## The value objects

Everything injected is plain, immutable data plus a way to invoke
(`Drupal\flowdrop\DTO\Tool\*`):

- **`ToolBindingInterface`** — one tool:
  - `getName()` — the **model-facing** name (a slug of the node label); unique
    within the node's tool set.
  - `getNodeId()` — the **internal routing key**; never shown to a model.
  - `getDescription()`, `getInputSchema()` — for building a tool manifest.
  - `toDefinition()` — `['name', 'description', 'input_schema']` in one call.
  - `invoke(array $args): ToolResultInterface` — run the tool.
- **`ToolCollectionInterface`** — the set, keyed by name: `all()`, `get($name)`,
  `has($name)`, `getDefinitions()`, plus `IteratorAggregate`/`Countable`.
- **`ToolResultInterface`** — `isSuccess()`, `getData()`, `getError()`.

## Writing a tool-aware processor

```php
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\AbstractFlowDropNodeProcessor;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ToolsAwareInterface;
use Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ToolsAwareTrait;

class MyAgent extends AbstractFlowDropNodeProcessor implements ToolsAwareInterface {

  use ToolsAwareTrait;

  public function process(ParameterBagInterface $params): array {
    // 1. Build the model's tool manifest from the wired tools.
    $manifest = $this->getTools()->getDefinitions();

    // 2. ... call your model, which returns a tool call by name + args ...

    // 3. Resolve and invoke. An unknown name is recoverable — feed the error
    //    back to the model so it can re-plan; do not throw.
    $binding = $this->getTools()->get($toolName);
    if ($binding === NULL) {
      // tell the model "no such tool" and loop again
    }
    $result = $binding->invoke($args);

    // 4. Use the result.
    return ['answer' => $result->isSuccess() ? $result->getData() : NULL];
  }
}
```

You never touch the workflow graph, and you never execute nodes directly:
`invoke()` routes through the orchestrator.

## Supplying tools dynamically: tool ports

`ToolsAwareInterface` is the *consumption* capability. The most common way to
*supply* tools is the **dynamic** one: the consumer node exposes a **tool input
port**, and a site builder wires tool nodes into it per workflow. This is the
piece that makes `getConnectedTools()` return anything.

### Direction — tools flow *into* the consumer

A tool connection is drawn **`tool.output → consumer.input`**: the tool node is
the edge **source**, the consumer is the edge **target**. This matches the
convention in Langflow, Flowise and n8n — tools are *inputs* an agent consumes
("these tools feed into this agent"), read left-to-right. The runtime collects a
consumer's tools from its **incoming** tool edges.

> If you are wiring this up and `getConnectedTools()` is empty, check the
> direction first: the edge must point **from** the tool node **to** the
> consumer, terminating on the consumer's tool input handle.

### The consuming node — declare a tool *input* port

A consumer declares the port exactly like any other input port — there is no
special tool-port API. Two things, together:

1. **In the processor's `getParameterSchema()`**, add a parameter named
   `ReservedName::PORT_TOOL` with the `tool` data type. The port *name* is the
   reserved constant (it drives runtime classification via the
   `{nodeId}-input-tool` handle); the `type` is the `tool` *data type* (it drives
   the editor's handle colour and connection validation):

   ```php
   use Drupal\flowdrop\Constants\ReservedName;

   public function getParameterSchema(): array {
     return [
       "type" => "object",
       "properties" => [
         ReservedName::PORT_TOOL => [
           "type" => "tool",
           "description" => "Tools wired to this node.",
         ],
         // ... your other parameters ...
       ],
     ];
   }
   ```

2. **In the node-type config entity** (`config/install/flowdrop_node_type.flowdrop_node_type.<id>.yml`),
   mark that parameter **connectable** so it renders as an input port (the entity
   owns port/form behaviour — the plugin only describes the schema). YAML can't
   reference the PHP constant, so use its value, `tool`:

   ```yaml
   parameters:
     tool:                   # ReservedName::PORT_TOOL
       connectable: true     # show as an input port
       configurable: false   # not a config-form field
       required: false
   ```

That is the whole declaration. The node-type API derives an input port with
`dataType: 'tool'`; the editor renders an **amber tool input handle**
automatically (any input port is rendered as a handle, and the `tool` data type
is styled and connection-validated out of the box). A site builder then drags a
tool node's output onto that handle.

> **Use `ReservedName::PORT_TOOL` for the port name.** The runtime classifies a
> tool edge by the consumer's input handle (`{nodeId}-input-tool`) — the same
> handle-pattern mechanism used for `ReservedName::PORT_TRIGGER` and
> `PORT_LOOPBACK`. The constant *is* `'tool'`; using it keeps the port name, the
> handle, and the runtime classifier in sync (and `tool` is reserved, so it can't
> be used as a user-defined dynamic port name).

### The supplying node — expose a tool *output* port

For a node to be wireable as a tool *source* in the editor, it needs a
tool-typed **output** port to drag from. Processors built *as* tools declare
it in code, as below; existing node types can instead be opted in by a site
builder with the `tool_exposed` flag — see
[Exposing existing node types as tools](#exposing-existing-node-types-as-tools-tool_exposed).
This mirrors the consumer, on the output side:

1. **In `getOutputSchema()`**, expose an output named `ReservedName::PORT_TOOL`
   with the `tool` data type:

   ```php
   use Drupal\flowdrop\Constants\ReservedName;

   public function getOutputSchema(): array {
     return [
       "type" => "object",
       "properties" => [
         ReservedName::PORT_TOOL => [
           "type" => "tool",
           "description" => "This node, offered as a callable tool.",
         ],
         // ... your other outputs ...
       ],
     ];
   }
   ```

2. **Mark it exposed** in the node-type config's `outputs`, and set the node's
   **visual type to `tool`** so it renders with the dedicated tool node UI:

   ```yaml
   outputs:
     tool:                   # ReservedName::PORT_TOOL
       exposed: true
   visual_type: tool
   supported_visual_types:
     - tool
   ```

This produces a `{nodeId}-output-tool` handle that connects to a consumer's
`-input-tool` handle (a `tool → tool` connection the editor validates and styles
as a dashed amber edge).

> **Always set `visual_type: tool` on a tool node.** Technically it is not
> required — the engine ignores visual type entirely (it classifies a tool edge
> by the *consumer's* `-input-tool` handle), and a `default`-visual node with a
> tool output port renders a usable handle via `SimpleNode`. But there is no
> reason to omit it: a node whose purpose is to be wired in as a tool should
> *look* like one. `visual_type: tool` gives the purpose-built tool node UI
> (tool handle placement, dashed amber edges) and makes the intent obvious to
> site builders; a tool node rendered as a generic box is just confusing. Treat
> it as the standard, with the strict requirement being only the tool-typed
> **output port**.
>
> The **runtime** requires *nothing* special of the tool node: it is invoked as
> an ordinary node, its `getParameterSchema()` is read as the tool's *input*
> schema (the arguments the model supplies), and its result is returned to the
> consumer. The output port and visual type are purely *authoring* affordances.

### What happens at runtime

When the consumer node is about to run, the orchestrator sees it implements
`ToolsAwareInterface`, resolves its incoming tool edges into a `ToolCollection`,
and injects it via `setTools()` — so `getTools()` / `getConnectedTools()` return
the wired tools, ready to build a manifest and `invoke()`. Tool nodes themselves
are excluded from the execution DAG (they are invoked on demand, not scheduled).

> Tool ports are **static** definitions (declared in the schema + node-type
> config); they are not part of the user-managed *dynamic ports* feature, whose
> data types deliberately exclude `tool`. Supply can also be entirely
> non-dynamic — a processor may source tools from its own config or hardcode
> them — because supply and the consumption capability are orthogonal.

## Sharing tools: the Tool Box

Wiring every tool to every consumer scales as N×M edges. The **Tool Box** node
(`toolbox`, in `flowdrop_node_processor`) bundles tools into a shared set:

```
web_search ──tool──┐
db_query ──────────┼─→ [ToolBox] ──tool──→ agent_1
calculator ──tool──┘         └────tool──→ agent_2
```

Wire any number of tools into the box's tool input, then wire its tool output
into one or more consumers — N+M edges. Each consumer receives the box's whole
set *as if every tool were wired to it directly*: the runtime **flattens**
through the box when projecting a consumer's tools, so consumers need no new
API and the box itself never appears as a tool or executes as a job (it is
tool-only by classification). Boxes nest — a box wired into another box
contributes its flattened contents.

What makes a node flatten is the marker interface
`\Drupal\flowdrop\Plugin\FlowDropNodeProcessor\ToolPassthroughInterface`:

- a plain tool node's output means "I **am** a tool" → bound as one tool;
- a passthrough node's output means "I **carry** tools" → flattened.

In particular, a `ToolsAware` node wired in as a tool (agent-as-tool) is NOT
flattened — it stays a single tool. Only add the marker to a processor whose
whole purpose is forwarding tool wiring.

Rules enforced at compile time (and defensively at projection):

- **Dedupe by node:** the same tool reached via two boxes is one tool.
- **Names are checked on the flattened set:** two *different* tools whose
  labels slug to the same name collide at the consumer, even when they arrive
  through different boxes → `CompilationException`. A box's own label is never
  a tool name.
- **No cycles:** a box that (directly or via other boxes) contains itself →
  `CompilationException`. Nesting deeper than 8 boxes is also rejected.

## Exposing existing node types as tools (`tool_exposed`)

Declaring a tool output port in code (above) is for processors *built* as
tools. But the runtime makes no distinction — **any** node can be invoked as a
tool — so site builders can opt existing node types in without code:

Check **"Available as Tool"** on the node type (admin UI, or `tool_exposed:
true` in the config export). The nodes API then injects a `tool` output port
into the type's metadata (unless the processor already declares one) and the
editor renders a tool source handle on every instance. From there it behaves
exactly like a hand-written tool node: wire it to a consumer's tool input or
into a Tool Box.

The flag is **per node type**, deliberately: one processor class can back many
node types, and "does this make a sensible tool?" is a per-type judgment. The
flag only makes the handle *available* — a tool still does nothing until the
author wires it, so the allow-list stays graph-derived and the per-run prompt
cost scales with what is wired, not with what is checked.

What makes a node type a *good* tool:

- **Parameter-driven** — the model's arguments arrive as the node's runtime
  inputs; a node that mainly consumes piped data from upstream edges gets none
  of that when invoked as a tool.
- **Model-quality descriptions** — the model sees the node *label* (as the
  tool name), the plugin description, and the full parameter schema. Types
  whose schemas read like form help make bad tools.
- **No interrupts** — a node that pauses for human input would pause inside
  the consumer's tool call; leave interrupt-capable types unchecked.

> **The model's arguments override instance configuration.** Tool arguments
> are runtime inputs, and runtime inputs take precedence over configured
> values for any connectable parameter (connection > config > default). If an
> instance is configured with, say, a fixed URL, a model supplying `url` wins.
> Until per-parameter pinning exists, only expose node types whose full
> parameter surface you are comfortable handing to the model, and keep
> sensitive parameters non-connectable.

Side-effecting node types deserve the same thought here as
`HasSideEffectsInterface` implies elsewhere: an exposed email or entity-write
node is an email the model can send, with arguments the model chose. That can
be exactly the point — it just shouldn't be an accident. Note also that a node
wired into the normal flow *and* as a tool runs in both roles: once as a
scheduled job, plus once per tool call.

## How invocation works (and why it's safe)

`ToolBinding::invoke()` delegates to a **per-consumer, orchestrator-owned
invoker** (`ToolInvokerInterface`). This is the security boundary, because the
tool name/args ultimately originate from a model and are therefore
attacker-influenceable (prompt injection). For every call the invoker:

1. **Authorizes** the target node against an allow-list derived from the graph
   (the consumer's `tool_availability` edges). An unauthorized node is a hard
   failure — a processor can never reach a node the author did not wire.
2. **Validates** the arguments against the tool node's parameter schema
   (`validateParams()`); invalid args return a recoverable error result.
3. **Executes** the tool node through the standard node-execution path, so tool
   calls reuse the runtime's logging, status, events and interrupt handling.

The agent's blast radius is therefore exactly what the workflow author wired —
no more.

## Tool names

A tool's model-facing name is a slug of its node label
(`ToolName::fromLabel()`): lower-cased, with every separator collapsed to `_`,
clamped to `^[a-zA-Z0-9_-]{1,64}$`. Names must be **unique within a single
node's tool set** (a model addresses tools by name); uniqueness across different
consumers is fine.

Because names are slugged, visually-distinct labels can still collide
("Web Search" and "web-search" both become `web_search`). Keeping labels unique
is the **site builder's** responsibility, and it is enforced **at compile time**:

- Two wired tools whose names collide → `CompilationException`. With a Tool Box
  in between, the check runs on the **flattened** set each consumer actually
  receives.
- A `tool_availability` edge whose consumer neither consumes tools
  (`ToolsAwareInterface`) nor forwards them (`ToolPassthroughInterface`) →
  `CompilationException` (the tools would otherwise be silently ignored).

## Reference example

`flowdrop_node_processor`'s **Tool Caller** (`tool_caller`) is a minimal,
runnable `ToolsAwareInterface` processor. Given a `tool_name` and `arguments`,
it invokes that wired tool and returns the result; with no name, it just lists
the available tool definitions. A real agent replaces the static inputs with an
LLM loop, but the tool interaction — `getTools()`, `get($name)`, `invoke($args)`
— is identical.

It demonstrates **both halves of the consumer pattern**: the capability
interface *and* the reserved tool input port declared in its parameter schema
(see [Supplying tools dynamically](#supplying-tools-dynamically-tool-ports)) —
so tool nodes can be wired into it directly in the editor.

### Driving Tool Caller from upstream data

Because `tool_name` and `arguments` are connectable, an upstream node can feed
them through the engine's reserved **unified `input` port**: wire a JSON object
shaped `{"tool_name": "...", "arguments": {...}}` into the node's `input` port
and the parameter resolver decomposes it onto the matching params (individually
wired ports win ties). To execute a *list* of LLM tool calls (providers emit an
array — multiple calls per turn is normal), compose existing primitives: a
**ForEach** over the list, a mapper shaping each item to
`{"tool_name", "arguments"}` (carry the provider's call `id` through for the
result round-trip), and a Tool Caller as the per-item worker. Normalizing a
*provider-specific* message shape (OpenAI `tool_calls`, Anthropic `tool_use`)
into that object is business logic for the module that owns the LLM
integration — or a plain mapper node — not for this reference processor.

**Source:**
`modules/flowdrop_node_processor/src/Plugin/FlowDropNodeProcessor/ToolCaller.php`

## Migrating from AgentExecutorInterface

`AgentExecutorInterface` (the old, `@internal` agent side-channel) is
**deprecated** and removed in `flowdrop:2.0.0`. Replace an agent executor with a
normal processor that implements `ToolsAwareInterface`, as above — no separate
executor, and no `DependencyGraph`/`WorkflowDTO` in plugin code.

See the change record: <https://www.drupal.org/node/3593582>.

## See also

- `docs/development/flowdrop-node-processor.md` — the processor plugin attribute.
- `docs/development/testing-node-processors.md` — testing processors.
