Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 60 additions & 15 deletions docs/docs/concepts/plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -658,7 +658,7 @@ async def cap_tokens(payload, ctx):

**Fires:** Before invoking a tool from LLM output.

**Payload fields:** `model_tool_call` (contains `name`, `args`, `callable`)
**Payload fields:** `model_tool_call` (contains `name`, `args`, `callable`), `is_control_flow`

**Writable fields:** `model_tool_call`

Expand All @@ -674,11 +674,15 @@ async def enforce_tool_allowlist(payload, ctx):
return block(f"Tool '{payload.model_tool_call.name}' not permitted", code="TOOL_NOT_ALLOWED")
```

<Note>
The payload includes an `is_control_flow` field that is `True` for framework control-flow tools (e.g. the ReAct loop's `final_answer`). Allowlist plugins should check this field to avoid blocking internal tools. See [Control-flow tools](#control-flow-tools) for the recommended pattern.
</Note>

#### `tool_post_invoke`

**Fires:** After tool execution completes.

**Payload fields:** `model_tool_call`, `tool_output`, `tool_message`, `execution_time_ms`, `success`, `error`
**Payload fields:** `model_tool_call`, `tool_output`, `tool_message`, `execution_time_ms`, `success`, `error`, `is_control_flow`

**Writable fields:** `tool_output`

Expand Down Expand Up @@ -810,13 +814,15 @@ The `tool_pre_invoke` and `tool_post_invoke` hooks give you fine-grained control

### Tool allow-listing

Block any tool not on an explicit approved list:
Block any tool not on an explicit approved list. The `is_control_flow` guard ensures framework tools like `final_answer` are not blocked:

```python
ALLOWED_TOOLS = frozenset({"get_weather", "calculator"})

@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5)
async def enforce_tool_allowlist(payload, ctx):
if payload.is_control_flow:
return # framework control-flow tools are exempt
tool_name = payload.model_tool_call.name
if tool_name not in ALLOWED_TOOLS:
return block(f"Tool '{tool_name}' is not permitted", code="TOOL_NOT_ALLOWED")
Expand Down Expand Up @@ -878,6 +884,43 @@ with start_session(plugins=[tool_security]) as m:

See the [full tool hooks example](https://github.com/generative-computing/mellea/blob/main/docs/examples/plugins/tool_hooks.py).

### Control-flow tools

Mellea's frameworks use internal tools for control flow. For example, the [ReAct loop](../reference/glossary#react) uses a `final_answer` tool to signal that the agent has finished reasoning. These tools flow through the same invocation path as user-defined tools — hooks always fire for them — but the payload carries an `is_control_flow` flag so each plugin can decide its own policy.

The recommended pattern for allowlist plugins is to skip control-flow tools explicitly:

```python
ALLOWED_TOOLS = frozenset({"get_weather", "calculator"})

@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5)
async def enforce_tool_allowlist(payload, ctx):
if payload.is_control_flow:
return # framework control-flow tools are exempt
if payload.model_tool_call.name not in ALLOWED_TOOLS:
return block(f"Tool '{payload.model_tool_call.name}' not permitted")
```

Logging and telemetry plugins typically do **not** check this flag — they observe all tool calls including control-flow tools:

```python
@hook(HookType.TOOL_POST_INVOKE, mode=PluginMode.FIRE_AND_FORGET)
async def log_all_tools(payload, ctx):
logger.info("tool=%s control_flow=%s ms=%d", payload.model_tool_call.name,
payload.is_control_flow, payload.execution_time_ms)
```

#### Querying the registry

Use `is_internal_tool()` to check whether a tool name is a known control-flow tool:

```python
from mellea.plugins import is_internal_tool

is_internal_tool("final_answer") # True
is_internal_tool("get_weather") # False
```

---

## Patterns and best practices
Expand Down Expand Up @@ -971,18 +1014,19 @@ All public symbols are available from a single import:

```python
from mellea.plugins import (
HookType, # Enum of all hook types (e.g., GENERATION_PRE_CALL)
Plugin, # Base class for class-based plugins
PluginMode, # Execution mode enum (SEQUENTIAL, TRANSFORM, AUDIT, CONCURRENT, FIRE_AND_FORGET)
PluginResult, # Return type for hooks that modify or block
PluginSet, # Named group of hooks/plugins for composition
PluginViolationError,# Exception raised when a hook blocks execution
block, # Helper to create a blocking PluginResult
hook, # Decorator to register an async function as a hook handler
modify, # Helper to create a modifying PluginResult
plugin_scope, # Context manager for with-block scoped activation
register, # Register hooks/plugins globally or per-session
unregister, # Remove globally-registered hooks/plugins
HookType, # Enum of all hook types (e.g., GENERATION_PRE_CALL)
Plugin, # Base class for class-based plugins
PluginMode, # Execution mode enum (SEQUENTIAL, TRANSFORM, ...)
PluginResult, # Return type for hooks that modify or block
PluginSet, # Named group of hooks/plugins for composition
PluginViolationError, # Exception raised when a hook blocks execution
block, # Helper to create a blocking PluginResult
hook, # Decorator to register an async function as a hook handler
is_internal_tool, # Check if a tool is a framework control-flow tool
modify, # Helper to create a modifying PluginResult
plugin_scope, # Context manager for with-block scoped activation
register, # Register hooks/plugins globally or per-session
unregister, # Remove globally-registered hooks/plugins
)
```

Expand All @@ -996,6 +1040,7 @@ from mellea.plugins import (
| `plugin_scope(*items)` | Context manager that registers on enter, deregisters on exit |
| `block(reason, *, code, details)` | Create a blocking `PluginResult` |
| `modify(payload, **field_updates)` | Create a modifying `PluginResult` via `model_copy` |
| `is_internal_tool(tool_name)` | Returns `True` if the tool is a framework control-flow tool (e.g. `final_answer`) |
| `HookType` | Enum with all 18 hook types |
| `PluginMode` | Enum: `SEQUENTIAL`, `TRANSFORM`, `AUDIT`, `CONCURRENT`, `FIRE_AND_FORGET` |
| `PluginResult` | Typed result with `continue_processing`, `modified_payload`, and `violation` |
Expand Down
2 changes: 2 additions & 0 deletions docs/examples/plugins/tool_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,8 @@ def parse_factor():
@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5)
async def enforce_tool_allowlist(payload, _):
"""Block any tool not on the explicit allow list."""
if payload.is_control_flow:
return # framework control-flow tools (e.g. final_answer) are exempt
tool_name = payload.model_tool_call.name
if tool_name not in ALLOWED_TOOLS:
log.warning(
Expand Down
2 changes: 2 additions & 0 deletions mellea/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from .base import Plugin, PluginResult, PluginViolationError
from .decorators import hook
from .manager import is_internal_tool
from .pluginset import PluginSet
from .registry import block, modify, plugin_scope, register, unregister
from .types import HookType, PluginMode
Expand All @@ -22,6 +23,7 @@
"PluginViolationError",
"block",
"hook",
"is_internal_tool",
"modify",
"plugin_scope",
"register",
Expand Down
8 changes: 8 additions & 0 deletions mellea/plugins/hooks/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,13 @@ class ToolPreInvokePayload(MelleaBasePayload):
Attributes:
model_tool_call: The ``ModelToolCall`` about to be executed (writable —
plugins may modify arguments or swap the tool entirely).
is_control_flow: ``True`` when this tool is used for framework control
flow (e.g. ``final_answer`` in ReAct) rather than data processing.
Plugins should check this field to decide whether to act.
"""

model_tool_call: Any = None
is_control_flow: bool = False


class ToolPostInvokePayload(MelleaBasePayload):
Expand All @@ -29,6 +33,9 @@ class ToolPostInvokePayload(MelleaBasePayload):
execution_time_ms: Wall-clock time of the tool execution in milliseconds.
success: ``True`` if the tool executed without raising an exception.
error: The ``Exception`` raised during execution, or ``None`` on success.
is_control_flow: ``True`` when this tool is used for framework control
flow (e.g. ``final_answer`` in ReAct) rather than data processing.
Plugins should check this field to decide whether to act.
"""

model_tool_call: Any = None
Expand All @@ -37,3 +44,4 @@ class ToolPostInvokePayload(MelleaBasePayload):
execution_time_ms: int = 0
success: bool = True
error: Any = None
is_control_flow: bool = False
16 changes: 16 additions & 0 deletions mellea/plugins/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@
_pending_background_results: list[Any] = []
_collect_background_results: bool = False # opt-in; only tests enable this

# Framework control-flow tool names (e.g. loop terminators).
# These are flagged on the payload so plugins can decide per-tool policy.
_INTERNAL_TOOL_NAMES: frozenset[str] = frozenset({"final_answer"})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we use the constant instead of hardcoding?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same note - MELLEA_FINALIZER_TOOL

ie

from mellea.stdlib.components.react import MELLEA_FINALIZER_TOOL
_INTERNAL_TOOL_NAMES: frozenset[str] = frozenset({MELLEA_FINALIZER_TOOL})

Copy link
Copy Markdown
Contributor Author

@araujof araujof Apr 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good. This doesn't create a circular import, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Circular import confirmed. Despite react.py not directly importing mellea.plugins, the chain goes through mellea.stdlib.components.__init__ which imports from mellea.core, creating: core.backendplugins.managerstdlib.components.reactstdlib.components.__init__core (partially initialized). So the MELLEA_FINALIZER_TOOL import cannot be added to manager.py.. One option would be to refactor constant names to an external shared module, but maybe this is too much for this fix (given we only have one internal tool defined. I added a comment to reference the finalizer tool.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed - I'd skip it here. could optionally openup a followup issue to track


DEFAULT_PLUGIN_TIMEOUT: int = 5 # seconds
DEFAULT_HOOK_POLICY: Literal["allow"] | Literal["deny"] = "deny"

Expand Down Expand Up @@ -88,6 +92,18 @@ def has_plugins(hook_type: HookType | None = None) -> bool:
return True


def is_internal_tool(tool_name: str) -> bool:
"""Return whether the given tool name is a framework-internal tool.

Args:
tool_name: Name of the tool to check.

Returns:
``True`` if the tool is in the internal tools registry.
"""
return tool_name in _INTERNAL_TOOL_NAMES


def get_plugin_manager() -> Any | None:
"""Return the initialized PluginManager, or ``None`` if plugins are not configured.

Expand Down
9 changes: 7 additions & 2 deletions mellea/stdlib/functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
)
from ..helpers import _run_async_in_thread
from ..plugins.hooks.tool import ToolPostInvokePayload, ToolPreInvokePayload
from ..plugins.manager import has_plugins, invoke_hook
from ..plugins.manager import has_plugins, invoke_hook, is_internal_tool
from ..plugins.types import HookType
from ..telemetry import set_span_attribute, trace_application
from .components import (
Expand Down Expand Up @@ -1287,9 +1287,13 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM
return outputs

for name, tool in tool_calls.items():
control_flow = is_internal_tool(name)

# --- tool_pre_invoke ---
if has_plugins(HookType.TOOL_PRE_INVOKE):
pre_payload = ToolPreInvokePayload(model_tool_call=tool)
pre_payload = ToolPreInvokePayload(
model_tool_call=tool, is_control_flow=control_flow
)
_, pre_payload = await invoke_hook(
HookType.TOOL_PRE_INVOKE, pre_payload, backend=backend
)
Expand Down Expand Up @@ -1335,6 +1339,7 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM
execution_time_ms=latency_ms,
success=success,
error=error,
is_control_flow=control_flow,
)
_, post_payload = await invoke_hook(
HookType.TOOL_POST_INVOKE, post_payload, backend=backend
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ switch = [
backends = ["mellea[watsonx,hf,litellm]"]

hooks = [
"cpex>=0.1.0.dev12; python_version >= '3.11'",
"cpex>=0.1.0rc1",
"grpcio>=1.78.0",
]

Expand Down
Loading
Loading