Skip to content

Commit edfb323

Browse files
committed
refactor: per-plugin control-flow tool filtering
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
1 parent 3a36832 commit edfb323

7 files changed

Lines changed: 127 additions & 147 deletions

File tree

docs/docs/concepts/plugins.mdx

Lines changed: 23 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ async def cap_tokens(payload, ctx):
658658

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

661-
**Payload fields:** `model_tool_call` (contains `name`, `args`, `callable`)
661+
**Payload fields:** `model_tool_call` (contains `name`, `args`, `callable`), `is_control_flow`
662662

663663
**Writable fields:** `model_tool_call`
664664

@@ -675,14 +675,14 @@ async def enforce_tool_allowlist(payload, ctx):
675675
```
676676

677677
<Note>
678-
By default, `tool_pre_invoke` and `tool_post_invoke` hooks are **skipped for framework-internal tools** such as the ReAct loop's `final_answer` tool. This prevents allowlist plugins from accidentally blocking internal control flow. See [Internal tool exemption](#internal-tool-exemption) for details and how to opt out.
678+
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.
679679
</Note>
680680

681681
#### `tool_post_invoke`
682682

683683
**Fires:** After tool execution completes.
684684

685-
**Payload fields:** `model_tool_call`, `tool_output`, `tool_message`, `execution_time_ms`, `success`, `error`
685+
**Payload fields:** `model_tool_call`, `tool_output`, `tool_message`, `execution_time_ms`, `success`, `error`, `is_control_flow`
686686

687687
**Writable fields:** `tool_output`
688688

@@ -814,13 +814,15 @@ The `tool_pre_invoke` and `tool_post_invoke` hooks give you fine-grained control
814814

815815
### Tool allow-listing
816816

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

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

822822
@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5)
823823
async def enforce_tool_allowlist(payload, ctx):
824+
if payload.is_control_flow:
825+
return # framework control-flow tools are exempt
824826
tool_name = payload.model_tool_call.name
825827
if tool_name not in ALLOWED_TOOLS:
826828
return block(f"Tool '{tool_name}' is not permitted", code="TOOL_NOT_ALLOWED")
@@ -882,49 +884,43 @@ with start_session(plugins=[tool_security]) as m:
882884

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

885-
### Internal tool exemption
887+
### Control-flow tools
886888

887-
Mellea's frameworks use internal tools that are invisible to application developers. 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, which means a `tool_pre_invoke` allowlist plugin would block them — breaking the framework.
889+
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.
888890

889-
To prevent this, **tool hooks are skipped for framework-internal tools by default**. Both `tool_pre_invoke` and `tool_post_invoke` are bypassed; the tool itself still executes normally.
890-
891-
This means you can write a tool allowlist plugin that lists only your own tools, without worrying about framework internals:
891+
The recommended pattern for allowlist plugins is to skip control-flow tools explicitly:
892892

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

896896
@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5)
897897
async def enforce_tool_allowlist(payload, ctx):
898+
if payload.is_control_flow:
899+
return # framework control-flow tools are exempt
898900
if payload.model_tool_call.name not in ALLOWED_TOOLS:
899901
return block(f"Tool '{payload.model_tool_call.name}' not permitted")
900-
# final_answer will never reach this hook — it is automatically exempted
901902
```
902903

903-
#### Checking whether a tool is internal
904-
905-
Use `is_internal_tool()` to query the internal tools registry:
904+
Logging and telemetry plugins typically do **not** check this flag — they observe all tool calls including control-flow tools:
906905

907906
```python
908-
from mellea.plugins import is_internal_tool
909-
910-
is_internal_tool("final_answer") # True
911-
is_internal_tool("get_weather") # False
907+
@hook(HookType.TOOL_POST_INVOKE, mode=PluginMode.FIRE_AND_FORGET)
908+
async def log_all_tools(payload, ctx):
909+
logger.info("tool=%s control_flow=%s ms=%d", payload.model_tool_call.name,
910+
payload.is_control_flow, payload.execution_time_ms)
912911
```
913912

914-
#### Opting out
913+
#### Querying the registry
915914

916-
If your plugin genuinely needs to intercept internal tools (e.g., for deep audit logging of every tool invocation including framework tools), disable the exemption:
915+
Use `is_internal_tool()` to check whether a tool name is a known control-flow tool:
917916

918917
```python
919-
from mellea.plugins import set_skip_hooks_for_internal_tools
918+
from mellea.plugins import is_internal_tool
920919

921-
set_skip_hooks_for_internal_tools(False) # all tools now fire hooks
920+
is_internal_tool("final_answer") # True
921+
is_internal_tool("get_weather") # False
922922
```
923923

924-
<Warning>
925-
Disabling the exemption means your allowlist plugin must explicitly permit internal tools like `final_answer`, or the ReAct loop will fail with a `PluginViolationError`.
926-
</Warning>
927-
928924
---
929925

930926
## Patterns and best practices
@@ -1026,12 +1022,10 @@ from mellea.plugins import (
10261022
PluginViolationError, # Exception raised when a hook blocks execution
10271023
block, # Helper to create a blocking PluginResult
10281024
hook, # Decorator to register an async function as a hook handler
1029-
is_internal_tool, # Check if a tool name is framework-internal
1025+
is_internal_tool, # Check if a tool is a framework control-flow tool
10301026
modify, # Helper to create a modifying PluginResult
10311027
plugin_scope, # Context manager for with-block scoped activation
10321028
register, # Register hooks/plugins globally or per-session
1033-
set_skip_hooks_for_internal_tools, # Enable/disable tool hook exemption for internal tools
1034-
skip_hooks_for_internal_tools, # Query current exemption state
10351029
unregister, # Remove globally-registered hooks/plugins
10361030
)
10371031
```
@@ -1046,9 +1040,7 @@ from mellea.plugins import (
10461040
| `plugin_scope(*items)` | Context manager that registers on enter, deregisters on exit |
10471041
| `block(reason, *, code, details)` | Create a blocking `PluginResult` |
10481042
| `modify(payload, **field_updates)` | Create a modifying `PluginResult` via `model_copy` |
1049-
| `is_internal_tool(tool_name)` | Returns `True` if the tool is framework-internal (e.g. `final_answer`) |
1050-
| `skip_hooks_for_internal_tools()` | Returns `True` if tool hooks are currently skipped for internal tools |
1051-
| `set_skip_hooks_for_internal_tools(enabled)` | Enable or disable tool hook exemption for internal tools |
1043+
| `is_internal_tool(tool_name)` | Returns `True` if the tool is a framework control-flow tool (e.g. `final_answer`) |
10521044
| `HookType` | Enum with all 18 hook types |
10531045
| `PluginMode` | Enum: `SEQUENTIAL`, `TRANSFORM`, `AUDIT`, `CONCURRENT`, `FIRE_AND_FORGET` |
10541046
| `PluginResult` | Typed result with `continue_processing`, `modified_payload`, and `violation` |

docs/examples/plugins/tool_hooks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,8 @@ def parse_factor():
150150
@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5)
151151
async def enforce_tool_allowlist(payload, _):
152152
"""Block any tool not on the explicit allow list."""
153+
if payload.is_control_flow:
154+
return # framework control-flow tools (e.g. final_answer) are exempt
153155
tool_name = payload.model_tool_call.name
154156
if tool_name not in ALLOWED_TOOLS:
155157
log.warning(

mellea/plugins/__init__.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@
99

1010
from .base import Plugin, PluginResult, PluginViolationError
1111
from .decorators import hook
12-
from .manager import (
13-
is_internal_tool,
14-
set_skip_hooks_for_internal_tools,
15-
skip_hooks_for_internal_tools,
16-
)
12+
from .manager import is_internal_tool
1713
from .pluginset import PluginSet
1814
from .registry import block, modify, plugin_scope, register, unregister
1915
from .types import HookType, PluginMode
@@ -31,7 +27,5 @@
3127
"modify",
3228
"plugin_scope",
3329
"register",
34-
"set_skip_hooks_for_internal_tools",
35-
"skip_hooks_for_internal_tools",
3630
"unregister",
3731
]

mellea/plugins/hooks/tool.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ class ToolPreInvokePayload(MelleaBasePayload):
1313
Attributes:
1414
model_tool_call: The ``ModelToolCall`` about to be executed (writable —
1515
plugins may modify arguments or swap the tool entirely).
16+
is_control_flow: ``True`` when this tool is used for framework control
17+
flow (e.g. ``final_answer`` in ReAct) rather than data processing.
18+
Plugins should check this field to decide whether to act.
1619
"""
1720

1821
model_tool_call: Any = None
22+
is_control_flow: bool = False
1923

2024

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

3441
model_tool_call: Any = None
@@ -37,3 +44,4 @@ class ToolPostInvokePayload(MelleaBasePayload):
3744
execution_time_ms: int = 0
3845
success: bool = True
3946
error: Any = None
47+
is_control_flow: bool = False

mellea/plugins/manager.py

Lines changed: 3 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,9 @@
2727
_plugins_enabled: bool = False
2828
_session_tags: dict[str, set[str]] = {} # session_id -> set of plugin names
2929

30-
# Framework-internal tool names that bypass plugin hooks by default.
31-
# See mellea.stdlib.components.react.MELLEA_FINALIZER_TOOL
30+
# Framework control-flow tool names (e.g. loop terminators).
31+
# These are flagged on the payload so plugins can decide per-tool policy.
3232
_INTERNAL_TOOL_NAMES: frozenset[str] = frozenset({"final_answer"})
33-
_skip_hooks_for_internal_tools: bool = True
3433

3534
DEFAULT_PLUGIN_TIMEOUT: int = 5 # seconds
3635
DEFAULT_HOOK_POLICY: Literal["allow"] | Literal["deny"] = "deny"
@@ -57,29 +56,6 @@ def has_plugins(hook_type: HookType | None = None) -> bool:
5756
return True
5857

5958

60-
def skip_hooks_for_internal_tools() -> bool:
61-
"""Return whether tool hooks are skipped for framework-internal tools.
62-
63-
Returns:
64-
``True`` if hooks are bypassed for internal tools like ``final_answer``.
65-
"""
66-
return _skip_hooks_for_internal_tools
67-
68-
69-
def set_skip_hooks_for_internal_tools(enabled: bool) -> None:
70-
"""Control whether tool hooks are skipped for framework-internal tools.
71-
72-
When *enabled* (the default), ``tool_pre_invoke`` and ``tool_post_invoke``
73-
hooks will not fire for tools in the internal registry (e.g. ``final_answer``).
74-
Set to ``False`` if your plugin intentionally needs to intercept internal tools.
75-
76-
Args:
77-
enabled: ``True`` to skip hooks for internal tools, ``False`` to invoke them.
78-
"""
79-
global _skip_hooks_for_internal_tools
80-
_skip_hooks_for_internal_tools = enabled
81-
82-
8359
def is_internal_tool(tool_name: str) -> bool:
8460
"""Return whether the given tool name is a framework-internal tool.
8561
@@ -176,18 +152,13 @@ async def initialize_plugins(
176152

177153
async def shutdown_plugins() -> None:
178154
"""Shut down the PluginManager and reset all state."""
179-
global \
180-
_plugin_manager, \
181-
_plugins_enabled, \
182-
_session_tags, \
183-
_skip_hooks_for_internal_tools
155+
global _plugin_manager, _plugins_enabled, _session_tags
184156

185157
if _plugin_manager is not None:
186158
await _plugin_manager.shutdown()
187159
_plugin_manager = None
188160
_plugins_enabled = False
189161
_session_tags.clear()
190-
_skip_hooks_for_internal_tools = True
191162

192163

193164
def track_session_plugin(session_id: str, plugin_name: str) -> None:

mellea/stdlib/functional.py

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,7 @@
3030
)
3131
from ..helpers import _run_async_in_thread
3232
from ..plugins.hooks.tool import ToolPostInvokePayload, ToolPreInvokePayload
33-
from ..plugins.manager import (
34-
has_plugins,
35-
invoke_hook,
36-
is_internal_tool,
37-
skip_hooks_for_internal_tools,
38-
)
33+
from ..plugins.manager import has_plugins, invoke_hook, is_internal_tool
3934
from ..plugins.types import HookType
4035
from ..telemetry import set_span_attribute, trace_application
4136
from .components import Instruction, Message, MObjectProtocol, ToolMessage, mify
@@ -1275,11 +1270,13 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM
12751270
return outputs
12761271

12771272
for name, tool in tool_calls.items():
1278-
run_hooks = not (skip_hooks_for_internal_tools() and is_internal_tool(name))
1273+
control_flow = is_internal_tool(name)
12791274

12801275
# --- tool_pre_invoke ---
1281-
if run_hooks and has_plugins(HookType.TOOL_PRE_INVOKE):
1282-
pre_payload = ToolPreInvokePayload(model_tool_call=tool)
1276+
if has_plugins(HookType.TOOL_PRE_INVOKE):
1277+
pre_payload = ToolPreInvokePayload(
1278+
model_tool_call=tool, is_control_flow=control_flow
1279+
)
12831280
_, pre_payload = await invoke_hook(
12841281
HookType.TOOL_PRE_INVOKE, pre_payload, backend=backend
12851282
)
@@ -1317,14 +1314,15 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM
13171314
)
13181315

13191316
# --- tool_post_invoke ---
1320-
if run_hooks and has_plugins(HookType.TOOL_POST_INVOKE):
1317+
if has_plugins(HookType.TOOL_POST_INVOKE):
13211318
post_payload = ToolPostInvokePayload(
13221319
model_tool_call=tool,
13231320
tool_output=output,
13241321
tool_message=tool_msg,
13251322
execution_time_ms=latency_ms,
13261323
success=success,
13271324
error=error,
1325+
is_control_flow=control_flow,
13281326
)
13291327
_, post_payload = await invoke_hook(
13301328
HookType.TOOL_POST_INVOKE, post_payload, backend=backend

0 commit comments

Comments
 (0)