From 1a571c60c8e673dbe540425a5553ff0dbb4b9fff Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sun, 26 Apr 2026 09:06:45 -0400 Subject: [PATCH 1/7] fix: skip tool hooks for framework-internal tools Signed-off-by: Frederico Araujo --- docs/docs/concepts/plugins.mdx | 77 ++++++-- mellea/plugins/__init__.py | 8 + mellea/plugins/manager.py | 46 ++++- mellea/stdlib/functional.py | 13 +- test/plugins/test_internal_tool_hook_skip.py | 189 +++++++++++++++++++ 5 files changed, 317 insertions(+), 16 deletions(-) create mode 100644 test/plugins/test_internal_tool_hook_skip.py diff --git a/docs/docs/concepts/plugins.mdx b/docs/docs/concepts/plugins.mdx index 192d7bd1f..0b299c695 100644 --- a/docs/docs/concepts/plugins.mdx +++ b/docs/docs/concepts/plugins.mdx @@ -675,6 +675,10 @@ async def enforce_tool_allowlist(payload, ctx): return block(f"Tool '{payload.model_tool_call.name}' not permitted", code="TOOL_NOT_ALLOWED") ``` + +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. + + #### `tool_post_invoke` **Fires:** After tool execution completes. @@ -879,6 +883,49 @@ 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). +### Internal tool exemption + +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. + +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. + +This means you can write a tool allowlist plugin that lists only your own tools, without worrying about framework internals: + +```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.model_tool_call.name not in ALLOWED_TOOLS: + return block(f"Tool '{payload.model_tool_call.name}' not permitted") + # final_answer will never reach this hook — it is automatically exempted +``` + +#### Checking whether a tool is internal + +Use `is_internal_tool()` to query the internal tools registry: + +```python +from mellea.plugins import is_internal_tool + +is_internal_tool("final_answer") # True +is_internal_tool("get_weather") # False +``` + +#### Opting out + +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: + +```python +from mellea.plugins import set_skip_hooks_for_internal_tools + +set_skip_hooks_for_internal_tools(False) # all tools now fire hooks +``` + + +Disabling the exemption means your allowlist plugin must explicitly permit internal tools like `final_answer`, or the ReAct loop will fail with a `PluginViolationError`. + + --- ## Patterns and best practices @@ -972,18 +1019,21 @@ 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 name is framework-internal + modify, # Helper to create a modifying PluginResult + plugin_scope, # Context manager for with-block scoped activation + register, # Register hooks/plugins globally or per-session + set_skip_hooks_for_internal_tools, # Enable/disable tool hook exemption for internal tools + skip_hooks_for_internal_tools, # Query current exemption state + unregister, # Remove globally-registered hooks/plugins ) ``` @@ -997,6 +1047,9 @@ 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 framework-internal (e.g. `final_answer`) | +| `skip_hooks_for_internal_tools()` | Returns `True` if tool hooks are currently skipped for internal tools | +| `set_skip_hooks_for_internal_tools(enabled)` | Enable or disable tool hook exemption for internal tools | | `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` | diff --git a/mellea/plugins/__init__.py b/mellea/plugins/__init__.py index f5433fdd8..3dd4cc059 100644 --- a/mellea/plugins/__init__.py +++ b/mellea/plugins/__init__.py @@ -9,6 +9,11 @@ from .base import Plugin, PluginResult, PluginViolationError from .decorators import hook +from .manager import ( + is_internal_tool, + set_skip_hooks_for_internal_tools, + skip_hooks_for_internal_tools, +) from .pluginset import PluginSet from .registry import block, modify, plugin_scope, register, unregister from .types import HookType, PluginMode @@ -22,8 +27,11 @@ "PluginViolationError", "block", "hook", + "is_internal_tool", "modify", "plugin_scope", "register", + "set_skip_hooks_for_internal_tools", + "skip_hooks_for_internal_tools", "unregister", ] diff --git a/mellea/plugins/manager.py b/mellea/plugins/manager.py index e196a0a25..4093a8cff 100644 --- a/mellea/plugins/manager.py +++ b/mellea/plugins/manager.py @@ -29,6 +29,11 @@ _pending_background_results: list[Any] = [] _collect_background_results: bool = False # opt-in; only tests enable this +# Framework-internal tool names that bypass plugin hooks by default. +# See mellea.stdlib.components.react.MELLEA_FINALIZER_TOOL +_INTERNAL_TOOL_NAMES: frozenset[str] = frozenset({"final_answer"}) +_skip_hooks_for_internal_tools: bool = True + DEFAULT_PLUGIN_TIMEOUT: int = 5 # seconds DEFAULT_HOOK_POLICY: Literal["allow"] | Literal["deny"] = "deny" @@ -88,6 +93,41 @@ def has_plugins(hook_type: HookType | None = None) -> bool: return True +def skip_hooks_for_internal_tools() -> bool: + """Return whether tool hooks are skipped for framework-internal tools. + + Returns: + ``True`` if hooks are bypassed for internal tools like ``final_answer``. + """ + return _skip_hooks_for_internal_tools + + +def set_skip_hooks_for_internal_tools(enabled: bool) -> None: + """Control whether tool hooks are skipped for framework-internal tools. + + When *enabled* (the default), ``tool_pre_invoke`` and ``tool_post_invoke`` + hooks will not fire for tools in the internal registry (e.g. ``final_answer``). + Set to ``False`` if your plugin intentionally needs to intercept internal tools. + + Args: + enabled: ``True`` to skip hooks for internal tools, ``False`` to invoke them. + """ + global _skip_hooks_for_internal_tools + _skip_hooks_for_internal_tools = enabled + + +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. @@ -172,7 +212,11 @@ async def initialize_plugins( async def shutdown_plugins() -> None: """Shut down the PluginManager and reset all state.""" - global _plugin_manager, _plugins_enabled, _session_tags + global \ + _plugin_manager, \ + _plugins_enabled, \ + _session_tags, \ + _skip_hooks_for_internal_tools if _plugin_manager is not None: await _plugin_manager.shutdown() diff --git a/mellea/stdlib/functional.py b/mellea/stdlib/functional.py index 849a72cf8..a467fc30f 100644 --- a/mellea/stdlib/functional.py +++ b/mellea/stdlib/functional.py @@ -30,7 +30,12 @@ ) 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, + skip_hooks_for_internal_tools, +) from ..plugins.types import HookType from ..telemetry import set_span_attribute, trace_application from .components import ( @@ -1287,8 +1292,10 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM return outputs for name, tool in tool_calls.items(): + _run_hooks = not (skip_hooks_for_internal_tools() and is_internal_tool(name)) + # --- tool_pre_invoke --- - if has_plugins(HookType.TOOL_PRE_INVOKE): + if _run_hooks and has_plugins(HookType.TOOL_PRE_INVOKE): pre_payload = ToolPreInvokePayload(model_tool_call=tool) _, pre_payload = await invoke_hook( HookType.TOOL_PRE_INVOKE, pre_payload, backend=backend @@ -1327,7 +1334,7 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM ) # --- tool_post_invoke --- - if has_plugins(HookType.TOOL_POST_INVOKE): + if _run_hooks and has_plugins(HookType.TOOL_POST_INVOKE): post_payload = ToolPostInvokePayload( model_tool_call=tool, tool_output=output, diff --git a/test/plugins/test_internal_tool_hook_skip.py b/test/plugins/test_internal_tool_hook_skip.py new file mode 100644 index 000000000..bb8b1dc62 --- /dev/null +++ b/test/plugins/test_internal_tool_hook_skip.py @@ -0,0 +1,189 @@ +"""Tests for skipping tool hooks on framework-internal tools (e.g. final_answer). + +Verifies that TOOL_PRE_INVOKE and TOOL_POST_INVOKE hooks are bypassed for +internal tools when the skip flag is enabled (default), and that user tools +are always subject to hooks regardless of the flag. +""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock + +import pytest + +pytest.importorskip("cpex.framework") + +from mellea.core.base import AbstractMelleaTool, ModelOutputThunk, ModelToolCall +from mellea.plugins import PluginResult, hook, register +from mellea.plugins.manager import ( + is_internal_tool, + set_skip_hooks_for_internal_tools, + shutdown_plugins, + skip_hooks_for_internal_tools, +) +from mellea.plugins.types import HookType +from mellea.stdlib.functional import _acall_tools + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _RecordingTool(AbstractMelleaTool): + """A tool that records invocations.""" + + def __init__(self, name: str = "test_tool") -> None: + self.name = name + self.calls: list[dict[str, Any]] = [] + + def run(self, **kwargs: Any) -> str: + self.calls.append(dict(kwargs)) + return f"result from {self.name}" + + @property + def as_json_tool(self) -> dict[str, Any]: + return {"name": self.name, "description": "recording tool", "parameters": {}} + + +def _make_result(*tool_calls: ModelToolCall) -> ModelOutputThunk: + """Wrap one or more ModelToolCalls in a minimal ModelOutputThunk.""" + mot = MagicMock(spec=ModelOutputThunk) + mot.tool_calls = {tc.name: tc for tc in tool_calls} + return mot + + +# --------------------------------------------------------------------------- +# Tests — is_internal_tool +# --------------------------------------------------------------------------- + + +class TestIsInternalTool: + def test_recognizes_final_answer(self) -> None: + assert is_internal_tool("final_answer") is True + + def test_rejects_user_tool(self) -> None: + assert is_internal_tool("search") is False + assert is_internal_tool("get_weather") is False + + +# --------------------------------------------------------------------------- +# Tests — hook skip behaviour +# --------------------------------------------------------------------------- + + +class TestInternalToolHookSkip: + async def test_internal_tool_skips_pre_hook(self) -> None: + """TOOL_PRE_INVOKE does not fire for final_answer when skip is enabled.""" + tool = _RecordingTool("final_answer") + tc = ModelToolCall(name="final_answer", func=tool, args={"answer": "42"}) + result = _make_result(tc) + + fired: list[str] = [] + + @hook(HookType.TOOL_PRE_INVOKE) + async def spy(payload, *_): + fired.append(payload.model_tool_call.name) + + register(spy) + + msgs = await _acall_tools(result, MagicMock()) + + assert fired == [] + assert len(msgs) == 1 + assert "final_answer" in msgs[0].content or "result from" in msgs[0].content + + async def test_internal_tool_skips_post_hook(self) -> None: + """TOOL_POST_INVOKE does not fire for final_answer when skip is enabled.""" + tool = _RecordingTool("final_answer") + tc = ModelToolCall(name="final_answer", func=tool, args={"answer": "42"}) + result = _make_result(tc) + + fired: list[str] = [] + + @hook(HookType.TOOL_POST_INVOKE) + async def spy(payload, *_): + fired.append(payload.model_tool_call.name) + + register(spy) + + await _acall_tools(result, MagicMock()) + + assert fired == [] + + async def test_internal_tool_hooks_fire_when_disabled(self) -> None: + """Hooks fire for final_answer when skip is explicitly disabled.""" + set_skip_hooks_for_internal_tools(False) + + tool = _RecordingTool("final_answer") + tc = ModelToolCall(name="final_answer", func=tool, args={"answer": "42"}) + result = _make_result(tc) + + fired: list[str] = [] + + @hook(HookType.TOOL_PRE_INVOKE) + async def spy(payload, *_): + fired.append(payload.model_tool_call.name) + + register(spy) + + await _acall_tools(result, MagicMock()) + + assert fired == ["final_answer"] + + async def test_user_tool_always_runs_hooks(self) -> None: + """A non-internal tool always fires hooks regardless of skip config.""" + tool = _RecordingTool("search") + tc = ModelToolCall(name="search", func=tool, args={}) + result = _make_result(tc) + + fired: list[str] = [] + + @hook(HookType.TOOL_PRE_INVOKE) + async def spy(payload, *_): + fired.append(payload.model_tool_call.name) + + register(spy) + + await _acall_tools(result, MagicMock()) + + assert fired == ["search"] + + async def test_mixed_calls_only_skip_internal(self) -> None: + """In a batch with both internal and user tools, only user tool triggers hooks.""" + internal_tool = _RecordingTool("final_answer") + user_tool = _RecordingTool("search") + tc_internal = ModelToolCall( + name="final_answer", func=internal_tool, args={"answer": "done"} + ) + tc_user = ModelToolCall(name="search", func=user_tool, args={}) + result = _make_result(tc_internal, tc_user) + + fired: list[str] = [] + + @hook(HookType.TOOL_PRE_INVOKE) + async def spy(payload, *_): + fired.append(payload.model_tool_call.name) + + register(spy) + + msgs = await _acall_tools(result, MagicMock()) + + assert "search" in fired + assert "final_answer" not in fired + assert len(msgs) == 2 + + +# --------------------------------------------------------------------------- +# Tests — shutdown reset +# --------------------------------------------------------------------------- + + +class TestShutdownResetsSkipFlag: + async def test_shutdown_resets_skip_flag(self) -> None: + set_skip_hooks_for_internal_tools(False) + assert skip_hooks_for_internal_tools() is False + + await shutdown_plugins() + + assert skip_hooks_for_internal_tools() is True From 7bc0147002d1a77caae9ecbc0307b182e9075adf Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 27 Apr 2026 12:35:27 -0400 Subject: [PATCH 2/7] chore: removed unused import in unit test Signed-off-by: Frederico Araujo Co-authored-by: Paul Schweigert --- test/plugins/test_internal_tool_hook_skip.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/plugins/test_internal_tool_hook_skip.py b/test/plugins/test_internal_tool_hook_skip.py index bb8b1dc62..4b49d7f17 100644 --- a/test/plugins/test_internal_tool_hook_skip.py +++ b/test/plugins/test_internal_tool_hook_skip.py @@ -15,7 +15,7 @@ pytest.importorskip("cpex.framework") from mellea.core.base import AbstractMelleaTool, ModelOutputThunk, ModelToolCall -from mellea.plugins import PluginResult, hook, register +from mellea.plugins import hook, register from mellea.plugins.manager import ( is_internal_tool, set_skip_hooks_for_internal_tools, From 90e039fceaf6aa2d74c5dfe09ce1bc6800537e2d Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 27 Apr 2026 12:36:20 -0400 Subject: [PATCH 3/7] refactor: rename local `run_hooks` variable Signed-off-by: Frederico Araujo Co-authored-by: Paul Schweigert --- mellea/stdlib/functional.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mellea/stdlib/functional.py b/mellea/stdlib/functional.py index a467fc30f..910a0a06d 100644 --- a/mellea/stdlib/functional.py +++ b/mellea/stdlib/functional.py @@ -1292,7 +1292,7 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM return outputs for name, tool in tool_calls.items(): - _run_hooks = not (skip_hooks_for_internal_tools() and is_internal_tool(name)) + run_hooks = not (skip_hooks_for_internal_tools() and is_internal_tool(name)) # --- tool_pre_invoke --- if _run_hooks and has_plugins(HookType.TOOL_PRE_INVOKE): From dce9450f6247bd1a71e62cc33c7a540fa0314cbe Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Mon, 27 Apr 2026 13:00:27 -0400 Subject: [PATCH 4/7] fix: minor issue introduced by variable renaming Signed-off-by: Frederico Araujo --- mellea/stdlib/functional.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mellea/stdlib/functional.py b/mellea/stdlib/functional.py index 910a0a06d..d33a1605e 100644 --- a/mellea/stdlib/functional.py +++ b/mellea/stdlib/functional.py @@ -1295,7 +1295,7 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM run_hooks = not (skip_hooks_for_internal_tools() and is_internal_tool(name)) # --- tool_pre_invoke --- - if _run_hooks and has_plugins(HookType.TOOL_PRE_INVOKE): + if run_hooks and has_plugins(HookType.TOOL_PRE_INVOKE): pre_payload = ToolPreInvokePayload(model_tool_call=tool) _, pre_payload = await invoke_hook( HookType.TOOL_PRE_INVOKE, pre_payload, backend=backend @@ -1334,7 +1334,7 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM ) # --- tool_post_invoke --- - if _run_hooks and has_plugins(HookType.TOOL_POST_INVOKE): + if run_hooks and has_plugins(HookType.TOOL_POST_INVOKE): post_payload = ToolPostInvokePayload( model_tool_call=tool, tool_output=output, From 0f1c3596afbe5e0a14f3ee04de07383b8c378939 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 30 Apr 2026 22:03:39 -0400 Subject: [PATCH 5/7] refactor: per-plugin control-flow tool filtering Signed-off-by: Frederico Araujo --- docs/docs/concepts/plugins.mdx | 54 +++---- docs/examples/plugins/tool_hooks.py | 2 + mellea/plugins/__init__.py | 8 +- mellea/plugins/hooks/tool.py | 8 + mellea/plugins/manager.py | 34 +---- mellea/stdlib/functional.py | 18 +-- test/plugins/test_internal_tool_hook_skip.py | 149 ++++++++++--------- 7 files changed, 127 insertions(+), 146 deletions(-) diff --git a/docs/docs/concepts/plugins.mdx b/docs/docs/concepts/plugins.mdx index 0b299c695..608904f01 100644 --- a/docs/docs/concepts/plugins.mdx +++ b/docs/docs/concepts/plugins.mdx @@ -659,7 +659,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` @@ -676,14 +676,14 @@ async def enforce_tool_allowlist(payload, ctx): ``` -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. +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. #### `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` @@ -815,13 +815,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") @@ -883,49 +885,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). -### Internal tool exemption +### Control-flow tools -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. +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. -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. - -This means you can write a tool allowlist plugin that lists only your own tools, without worrying about framework internals: +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") - # final_answer will never reach this hook — it is automatically exempted ``` -#### Checking whether a tool is internal - -Use `is_internal_tool()` to query the internal tools registry: +Logging and telemetry plugins typically do **not** check this flag — they observe all tool calls including control-flow tools: ```python -from mellea.plugins import is_internal_tool - -is_internal_tool("final_answer") # True -is_internal_tool("get_weather") # False +@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) ``` -#### Opting out +#### Querying the registry -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: +Use `is_internal_tool()` to check whether a tool name is a known control-flow tool: ```python -from mellea.plugins import set_skip_hooks_for_internal_tools +from mellea.plugins import is_internal_tool -set_skip_hooks_for_internal_tools(False) # all tools now fire hooks +is_internal_tool("final_answer") # True +is_internal_tool("get_weather") # False ``` - -Disabling the exemption means your allowlist plugin must explicitly permit internal tools like `final_answer`, or the ReAct loop will fail with a `PluginViolationError`. - - --- ## Patterns and best practices @@ -1027,12 +1023,10 @@ from mellea.plugins import ( 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 name is framework-internal + 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 - set_skip_hooks_for_internal_tools, # Enable/disable tool hook exemption for internal tools - skip_hooks_for_internal_tools, # Query current exemption state unregister, # Remove globally-registered hooks/plugins ) ``` @@ -1047,9 +1041,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 framework-internal (e.g. `final_answer`) | -| `skip_hooks_for_internal_tools()` | Returns `True` if tool hooks are currently skipped for internal tools | -| `set_skip_hooks_for_internal_tools(enabled)` | Enable or disable tool hook exemption for internal tools | +| `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` | diff --git a/docs/examples/plugins/tool_hooks.py b/docs/examples/plugins/tool_hooks.py index 3b574ba75..96ec518ba 100644 --- a/docs/examples/plugins/tool_hooks.py +++ b/docs/examples/plugins/tool_hooks.py @@ -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( diff --git a/mellea/plugins/__init__.py b/mellea/plugins/__init__.py index 3dd4cc059..c4a7aa6aa 100644 --- a/mellea/plugins/__init__.py +++ b/mellea/plugins/__init__.py @@ -9,11 +9,7 @@ from .base import Plugin, PluginResult, PluginViolationError from .decorators import hook -from .manager import ( - is_internal_tool, - set_skip_hooks_for_internal_tools, - skip_hooks_for_internal_tools, -) +from .manager import is_internal_tool from .pluginset import PluginSet from .registry import block, modify, plugin_scope, register, unregister from .types import HookType, PluginMode @@ -31,7 +27,5 @@ "modify", "plugin_scope", "register", - "set_skip_hooks_for_internal_tools", - "skip_hooks_for_internal_tools", "unregister", ] diff --git a/mellea/plugins/hooks/tool.py b/mellea/plugins/hooks/tool.py index bc92d430e..8bba52ad3 100644 --- a/mellea/plugins/hooks/tool.py +++ b/mellea/plugins/hooks/tool.py @@ -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): @@ -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 @@ -37,3 +44,4 @@ class ToolPostInvokePayload(MelleaBasePayload): execution_time_ms: int = 0 success: bool = True error: Any = None + is_control_flow: bool = False diff --git a/mellea/plugins/manager.py b/mellea/plugins/manager.py index 4093a8cff..ce2859d38 100644 --- a/mellea/plugins/manager.py +++ b/mellea/plugins/manager.py @@ -29,10 +29,9 @@ _pending_background_results: list[Any] = [] _collect_background_results: bool = False # opt-in; only tests enable this -# Framework-internal tool names that bypass plugin hooks by default. -# See mellea.stdlib.components.react.MELLEA_FINALIZER_TOOL +# 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"}) -_skip_hooks_for_internal_tools: bool = True DEFAULT_PLUGIN_TIMEOUT: int = 5 # seconds DEFAULT_HOOK_POLICY: Literal["allow"] | Literal["deny"] = "deny" @@ -93,29 +92,6 @@ def has_plugins(hook_type: HookType | None = None) -> bool: return True -def skip_hooks_for_internal_tools() -> bool: - """Return whether tool hooks are skipped for framework-internal tools. - - Returns: - ``True`` if hooks are bypassed for internal tools like ``final_answer``. - """ - return _skip_hooks_for_internal_tools - - -def set_skip_hooks_for_internal_tools(enabled: bool) -> None: - """Control whether tool hooks are skipped for framework-internal tools. - - When *enabled* (the default), ``tool_pre_invoke`` and ``tool_post_invoke`` - hooks will not fire for tools in the internal registry (e.g. ``final_answer``). - Set to ``False`` if your plugin intentionally needs to intercept internal tools. - - Args: - enabled: ``True`` to skip hooks for internal tools, ``False`` to invoke them. - """ - global _skip_hooks_for_internal_tools - _skip_hooks_for_internal_tools = enabled - - def is_internal_tool(tool_name: str) -> bool: """Return whether the given tool name is a framework-internal tool. @@ -212,11 +188,7 @@ async def initialize_plugins( async def shutdown_plugins() -> None: """Shut down the PluginManager and reset all state.""" - global \ - _plugin_manager, \ - _plugins_enabled, \ - _session_tags, \ - _skip_hooks_for_internal_tools + global _plugin_manager, _plugins_enabled, _session_tags if _plugin_manager is not None: await _plugin_manager.shutdown() diff --git a/mellea/stdlib/functional.py b/mellea/stdlib/functional.py index d33a1605e..a281e7c3f 100644 --- a/mellea/stdlib/functional.py +++ b/mellea/stdlib/functional.py @@ -30,12 +30,7 @@ ) from ..helpers import _run_async_in_thread from ..plugins.hooks.tool import ToolPostInvokePayload, ToolPreInvokePayload -from ..plugins.manager import ( - has_plugins, - invoke_hook, - is_internal_tool, - skip_hooks_for_internal_tools, -) +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 ( @@ -1292,11 +1287,13 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM return outputs for name, tool in tool_calls.items(): - run_hooks = not (skip_hooks_for_internal_tools() and is_internal_tool(name)) + control_flow = is_internal_tool(name) # --- tool_pre_invoke --- - if run_hooks and has_plugins(HookType.TOOL_PRE_INVOKE): - pre_payload = ToolPreInvokePayload(model_tool_call=tool) + if has_plugins(HookType.TOOL_PRE_INVOKE): + 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 ) @@ -1334,7 +1331,7 @@ async def _acall_tools(result: ModelOutputThunk, backend: Backend) -> list[ToolM ) # --- tool_post_invoke --- - if run_hooks and has_plugins(HookType.TOOL_POST_INVOKE): + if has_plugins(HookType.TOOL_POST_INVOKE): post_payload = ToolPostInvokePayload( model_tool_call=tool, tool_output=output, @@ -1342,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 diff --git a/test/plugins/test_internal_tool_hook_skip.py b/test/plugins/test_internal_tool_hook_skip.py index 4b49d7f17..6f7836d4e 100644 --- a/test/plugins/test_internal_tool_hook_skip.py +++ b/test/plugins/test_internal_tool_hook_skip.py @@ -1,8 +1,9 @@ -"""Tests for skipping tool hooks on framework-internal tools (e.g. final_answer). +"""Tests for control-flow tool signalling on tool hook payloads. -Verifies that TOOL_PRE_INVOKE and TOOL_POST_INVOKE hooks are bypassed for -internal tools when the skip flag is enabled (default), and that user tools -are always subject to hooks regardless of the flag. +Verifies that TOOL_PRE_INVOKE and TOOL_POST_INVOKE hooks always fire for all +tools (including framework-internal ones like ``final_answer``), and that the +``is_control_flow`` field is correctly populated so plugins can decide their +own policy. """ from __future__ import annotations @@ -15,14 +16,9 @@ pytest.importorskip("cpex.framework") from mellea.core.base import AbstractMelleaTool, ModelOutputThunk, ModelToolCall -from mellea.plugins import hook, register -from mellea.plugins.manager import ( - is_internal_tool, - set_skip_hooks_for_internal_tools, - shutdown_plugins, - skip_hooks_for_internal_tools, -) -from mellea.plugins.types import HookType +from mellea.plugins import block, hook, is_internal_tool, register +from mellea.plugins.manager import shutdown_plugins +from mellea.plugins.types import HookType, PluginMode from mellea.stdlib.functional import _acall_tools # --------------------------------------------------------------------------- @@ -68,89 +64,71 @@ def test_rejects_user_tool(self) -> None: # --------------------------------------------------------------------------- -# Tests — hook skip behaviour +# Tests — hooks always fire, payload carries is_control_flow # --------------------------------------------------------------------------- -class TestInternalToolHookSkip: - async def test_internal_tool_skips_pre_hook(self) -> None: - """TOOL_PRE_INVOKE does not fire for final_answer when skip is enabled.""" +class TestControlFlowPayloadField: + async def test_pre_hook_fires_for_internal_tool(self) -> None: + """TOOL_PRE_INVOKE fires for final_answer with is_control_flow=True.""" tool = _RecordingTool("final_answer") tc = ModelToolCall(name="final_answer", func=tool, args={"answer": "42"}) result = _make_result(tc) - fired: list[str] = [] + captured: list[Any] = [] @hook(HookType.TOOL_PRE_INVOKE) async def spy(payload, *_): - fired.append(payload.model_tool_call.name) - - register(spy) - - msgs = await _acall_tools(result, MagicMock()) - - assert fired == [] - assert len(msgs) == 1 - assert "final_answer" in msgs[0].content or "result from" in msgs[0].content - - async def test_internal_tool_skips_post_hook(self) -> None: - """TOOL_POST_INVOKE does not fire for final_answer when skip is enabled.""" - tool = _RecordingTool("final_answer") - tc = ModelToolCall(name="final_answer", func=tool, args={"answer": "42"}) - result = _make_result(tc) - - fired: list[str] = [] - - @hook(HookType.TOOL_POST_INVOKE) - async def spy(payload, *_): - fired.append(payload.model_tool_call.name) + captured.append(payload) register(spy) await _acall_tools(result, MagicMock()) - assert fired == [] - - async def test_internal_tool_hooks_fire_when_disabled(self) -> None: - """Hooks fire for final_answer when skip is explicitly disabled.""" - set_skip_hooks_for_internal_tools(False) + assert len(captured) == 1 + assert captured[0].model_tool_call.name == "final_answer" + assert captured[0].is_control_flow is True + async def test_post_hook_fires_for_internal_tool(self) -> None: + """TOOL_POST_INVOKE fires for final_answer with is_control_flow=True.""" tool = _RecordingTool("final_answer") tc = ModelToolCall(name="final_answer", func=tool, args={"answer": "42"}) result = _make_result(tc) - fired: list[str] = [] + captured: list[Any] = [] - @hook(HookType.TOOL_PRE_INVOKE) + @hook(HookType.TOOL_POST_INVOKE) async def spy(payload, *_): - fired.append(payload.model_tool_call.name) + captured.append(payload) register(spy) await _acall_tools(result, MagicMock()) - assert fired == ["final_answer"] + assert len(captured) == 1 + assert captured[0].is_control_flow is True - async def test_user_tool_always_runs_hooks(self) -> None: - """A non-internal tool always fires hooks regardless of skip config.""" + async def test_user_tool_has_control_flow_false(self) -> None: + """User tools get is_control_flow=False.""" tool = _RecordingTool("search") tc = ModelToolCall(name="search", func=tool, args={}) result = _make_result(tc) - fired: list[str] = [] + captured: list[Any] = [] @hook(HookType.TOOL_PRE_INVOKE) async def spy(payload, *_): - fired.append(payload.model_tool_call.name) + captured.append(payload) register(spy) await _acall_tools(result, MagicMock()) - assert fired == ["search"] + assert len(captured) == 1 + assert captured[0].is_control_flow is False - async def test_mixed_calls_only_skip_internal(self) -> None: - """In a batch with both internal and user tools, only user tool triggers hooks.""" + async def test_mixed_batch_sets_flag_correctly(self) -> None: + """In a batch, each tool gets the correct is_control_flow value.""" internal_tool = _RecordingTool("final_answer") user_tool = _RecordingTool("search") tc_internal = ModelToolCall( @@ -159,31 +137,68 @@ async def test_mixed_calls_only_skip_internal(self) -> None: tc_user = ModelToolCall(name="search", func=user_tool, args={}) result = _make_result(tc_internal, tc_user) - fired: list[str] = [] + captured: list[Any] = [] @hook(HookType.TOOL_PRE_INVOKE) async def spy(payload, *_): - fired.append(payload.model_tool_call.name) + captured.append(payload) register(spy) - msgs = await _acall_tools(result, MagicMock()) + await _acall_tools(result, MagicMock()) - assert "search" in fired - assert "final_answer" not in fired - assert len(msgs) == 2 + assert len(captured) == 2 + by_name = {p.model_tool_call.name: p for p in captured} + assert by_name["final_answer"].is_control_flow is True + assert by_name["search"].is_control_flow is False # --------------------------------------------------------------------------- -# Tests — shutdown reset +# Tests — plugin pattern: allowlist that skips control-flow tools # --------------------------------------------------------------------------- -class TestShutdownResetsSkipFlag: - async def test_shutdown_resets_skip_flag(self) -> None: - set_skip_hooks_for_internal_tools(False) - assert skip_hooks_for_internal_tools() is False +class TestAllowlistPluginPattern: + async def test_allowlist_does_not_block_control_flow_tool(self) -> None: + """An allowlist plugin using is_control_flow guard does not block final_answer.""" + allowed_tools = frozenset({"search"}) + + @hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5) + async def enforce_allowlist(payload, _): + if payload.is_control_flow: + return + if payload.model_tool_call.name not in allowed_tools: + return block(f"Tool '{payload.model_tool_call.name}' not permitted") + + register(enforce_allowlist) + + internal_tool = _RecordingTool("final_answer") + tc = ModelToolCall( + name="final_answer", func=internal_tool, args={"answer": "ok"} + ) + result = _make_result(tc) + + msgs = await _acall_tools(result, MagicMock()) + assert len(msgs) == 1 + + async def test_allowlist_still_blocks_unknown_user_tools(self) -> None: + """The allowlist pattern still blocks non-allowed user tools.""" + from mellea.plugins.base import PluginViolationError + + allowed_tools = frozenset({"search"}) + + @hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5) + async def enforce_allowlist(payload, _): + if payload.is_control_flow: + return + if payload.model_tool_call.name not in allowed_tools: + return block(f"Tool '{payload.model_tool_call.name}' not permitted") - await shutdown_plugins() + register(enforce_allowlist) + + unknown_tool = _RecordingTool("hack_system") + tc = ModelToolCall(name="hack_system", func=unknown_tool, args={}) + result = _make_result(tc) - assert skip_hooks_for_internal_tools() is True + with pytest.raises(PluginViolationError, match="not permitted"): + await _acall_tools(result, MagicMock()) From e996ea86d7086ec0277236769565907a4ce5b783 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 30 Apr 2026 23:12:00 -0400 Subject: [PATCH 6/7] chore: update CPEX to 0.1.0rc1 Signed-off-by: Frederico Araujo --- pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 159bb61ab..578036179 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", ] diff --git a/uv.lock b/uv.lock index 0e051f419..d7d4b6aed 100644 --- a/uv.lock +++ b/uv.lock @@ -883,7 +883,7 @@ toml = [ [[package]] name = "cpex" -version = "0.1.0.dev12" +version = "0.1.0rc1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, @@ -899,9 +899,9 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/f6/d5a194b338b3d55b1b9b8619baafa504ae8146168cf4b91fcefa95811a16/cpex-0.1.0.dev12.tar.gz", hash = "sha256:9fb08e0fa27236747c26c841260951a83252029c0e55a7550c65a060473f200c", size = 3475629, upload-time = "2026-04-23T17:34:14.434Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/f1/608f5295bcd77a62ce520c91c76ba9fe07c50378e2600aeb7edbf80298c2/cpex-0.1.0rc1.tar.gz", hash = "sha256:36c8c85395073f5a8e828ab972b7a3eedc9f68066e6665473090947481319915", size = 1211891, upload-time = "2026-05-01T03:04:54.602Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/ed/f70537bd8adbf1f847a703e02c3abd2cdc53dfa87e44977aa25dd163774b/cpex-0.1.0.dev12-py3-none-any.whl", hash = "sha256:5c10688b6f7ca8c3673fce9dfd94d0b3a348e0e63566546ced5068574a38403e", size = 236654, upload-time = "2026-04-23T17:34:12.592Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e5/a9f07008443e42eceedb433dfd683e480f648c2db4072190aa7ac4209c9d/cpex-0.1.0rc1-py3-none-any.whl", hash = "sha256:6aa9395af7792653dfa18815f11f9c34ebc5ac91284a47f40834ba3f507a7f21", size = 241048, upload-time = "2026-05-01T03:04:52.996Z" }, ] [[package]] @@ -3558,7 +3558,7 @@ typecheck = [ requires-dist = [ { name = "accelerate", marker = "extra == 'hf'", specifier = ">=1.9.0" }, { name = "boto3", marker = "extra == 'litellm'" }, - { name = "cpex", marker = "python_full_version >= '3.11' and extra == 'hooks'", specifier = ">=0.1.0.dev12" }, + { name = "cpex", marker = "extra == 'hooks'", specifier = ">=0.1.0rc1" }, { name = "datasets", marker = "extra == 'hf'", specifier = ">=4.0.0" }, { name = "docling", marker = "extra == 'docling'", specifier = ">=2.45.0" }, { name = "elasticsearch", marker = "extra == 'granite-retriever'", specifier = ">=8.0.0,<9.0.0" }, From 764da3a5449c203c08939b20c7cac47cfb9d104e Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Wed, 6 May 2026 12:41:16 -0400 Subject: [PATCH 7/7] chore: update CPEX to >=0.1.0 Signed-off-by: Frederico Araujo --- pyproject.toml | 2 +- uv.lock | 18 ++++++++++++++---- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 578036179..4e5f877d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ switch = [ backends = ["mellea[watsonx,hf,litellm]"] hooks = [ - "cpex>=0.1.0rc1", + "cpex>=0.1.0", "grpcio>=1.78.0", ] diff --git a/uv.lock b/uv.lock index d7d4b6aed..f851e3b18 100644 --- a/uv.lock +++ b/uv.lock @@ -883,7 +883,7 @@ toml = [ [[package]] name = "cpex" -version = "0.1.0rc1" +version = "0.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fastapi" }, @@ -899,9 +899,9 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f0/f1/608f5295bcd77a62ce520c91c76ba9fe07c50378e2600aeb7edbf80298c2/cpex-0.1.0rc1.tar.gz", hash = "sha256:36c8c85395073f5a8e828ab972b7a3eedc9f68066e6665473090947481319915", size = 1211891, upload-time = "2026-05-01T03:04:54.602Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/73/98eb3322c63fe8654e3db87b13557aff75eaeecf35aabb129901e5c7704e/cpex-0.1.0.tar.gz", hash = "sha256:d67fdb2892bb9c683d42c716be46d926fbb01b9f4c3d85d2cdf0869d7a0f2d0e", size = 1211837, upload-time = "2026-05-05T19:17:15.387Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6a/e5/a9f07008443e42eceedb433dfd683e480f648c2db4072190aa7ac4209c9d/cpex-0.1.0rc1-py3-none-any.whl", hash = "sha256:6aa9395af7792653dfa18815f11f9c34ebc5ac91284a47f40834ba3f507a7f21", size = 241048, upload-time = "2026-05-01T03:04:52.996Z" }, + { url = "https://files.pythonhosted.org/packages/e2/15/c1a98af9dca602846373a07dd09b3944f6e2fceee132df58772acc057d66/cpex-0.1.0-py3-none-any.whl", hash = "sha256:890db8077e05aedf8236b1ff140f39b677944a751afe57de6538faf884d64d4a", size = 241015, upload-time = "2026-05-05T19:17:13.614Z" }, ] [[package]] @@ -1688,7 +1688,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, + { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, @@ -1696,7 +1698,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, @@ -1704,7 +1708,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/58/fc576f99037ce19c5aa16628e4c3226b6d1419f72a62c79f5f40576e6eb3/greenlet-3.5.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:5a5ed18de6a0f6cc7087f1563f6bd93fc7df1c19165ca01e9bde5a5dc281d106", size = 285066, upload-time = "2026-04-27T12:23:05.033Z" }, { url = "https://files.pythonhosted.org/packages/4a/ba/b28ddbe6bfad6a8ac196ef0e8cff37bc65b79735995b9e410923fffeeb70/greenlet-3.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a717fbc46d8a354fa675f7c1e813485b6ba3885f9bef0cd56e5ba27d758ff5b", size = 604414, upload-time = "2026-04-27T12:52:42.358Z" }, { url = "https://files.pythonhosted.org/packages/09/06/4b69f8f0b67603a8be2790e55107a190b376f2627fe0eaf5695d85ffb3cd/greenlet-3.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ddc090c5c1792b10246a78e8c2163ebbe04cf877f9d785c230a7b27b39ad038e", size = 617349, upload-time = "2026-04-27T12:59:43.32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/15/a643b4ecd09969e30b8a150d5919960caae0abe4f5af75ab040b1ab85e78/greenlet-3.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4964101b8585c144cbda5532b1aa644255126c08a265dae90c16e7a0e63aaa9d", size = 623234, upload-time = "2026-04-27T13:02:40.611Z" }, { url = "https://files.pythonhosted.org/packages/8a/17/a3918541fd0ddefe024a69de6d16aa7b46d36ac19562adaa63c7fa180eff/greenlet-3.5.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2094acd54b272cb6eae8c03dd87b3fa1820a4cef18d6889c378d503500a1dc13", size = 613927, upload-time = "2026-04-27T12:25:30.28Z" }, + { url = "https://files.pythonhosted.org/packages/77/18/3b13d5ef1275b0ffaf933b05efa21408ac4ca95823c7411d79682e4fdcff/greenlet-3.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7022615368890680e67b9965d33f5773aade330d5343bbe25560135aaa849eae", size = 425243, upload-time = "2026-04-27T13:05:15.689Z" }, { url = "https://files.pythonhosted.org/packages/ee/e1/bd0af6213c7dd33175d8a462d4c1fe1175124ebed4855bc1475a5b5242c2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5e05ba267789ea87b5a155cf0e810b1ab88bf18e9e8740813945ceb8ee4350ba", size = 1570893, upload-time = "2026-04-27T12:53:29.483Z" }, { url = "https://files.pythonhosted.org/packages/9b/2a/0789702f864f5382cb476b93d7a9c823c10472658102ccd65f415747d2e2/greenlet-3.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0ecec963079cd58cbd14723582384f11f166fd58883c15dcbfb342e0bc9b5846", size = 1636060, upload-time = "2026-04-27T12:25:28.845Z" }, { url = "https://files.pythonhosted.org/packages/b2/8f/22bf9df92bbff0eb07842b60f7e63bf7675a9742df628437a9f02d09137f/greenlet-3.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:728d9667d8f2f586644b748dbd9bb67e50d6a9381767d1357714ea6825bb3bf5", size = 238740, upload-time = "2026-04-27T12:24:01.341Z" }, @@ -1712,7 +1718,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/5e/a70f31e3e8d961c4ce589c15b28e4225d63704e431a23932a3808cbcc867/greenlet-3.5.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:f35807464c4c58c55f0d31dfa83c541a5615d825c2fe3d2b95360cf7c4e3c0a8", size = 285564, upload-time = "2026-04-27T12:23:08.555Z" }, { url = "https://files.pythonhosted.org/packages/af/a6/046c0a28e21833e4086918218cfb3d8bed51c075a1b700f20b9d7861c0f4/greenlet-3.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55fa7ea52771be44af0de27d8b80c02cd18c2c3cddde6c847ecebdf72418b6a1", size = 651166, upload-time = "2026-04-27T12:52:43.644Z" }, { url = "https://files.pythonhosted.org/packages/47/f8/4af27f71c5ff32a7fbc516adb46370d9c4ae2bc7bd3dc7d066ac542b4b15/greenlet-3.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a97e4821aa710603f94de0da25f25096454d78ffdace5dc77f3a006bc01abba3", size = 663792, upload-time = "2026-04-27T12:59:44.93Z" }, + { url = "https://files.pythonhosted.org/packages/fb/89/2dadb89793c37ee8b4c237857188293e9060dc085f19845c292e00f8e091/greenlet-3.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf2d8a80bec89ab46221ae45c5373d5ba0bd36c19aa8508e85c6cd7e5106cd37", size = 668086, upload-time = "2026-04-27T13:02:42.314Z" }, { url = "https://files.pythonhosted.org/packages/a3/59/1bd6d7428d6ed9106efbb8c52310c60fd04f6672490f452aeaa3829aa436/greenlet-3.5.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f52a464e4ed91780bdfbbdd2b97197f3accaa629b98c200f4dffada759f3ae7", size = 660933, upload-time = "2026-04-27T12:25:33.276Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/75722be7e26a2af4cbd2dc35b0ed382dacf9394b7e75551f76ed1abe87f2/greenlet-3.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:1bae92a1dd94c5f9d9493c3a212dd874c202442047cf96446412c862feca83a2", size = 470799, upload-time = "2026-04-27T13:05:17.094Z" }, { url = "https://files.pythonhosted.org/packages/83/e4/b903e5a5fae1e8a28cdd32a0cfbfd560b668c25b692f67768822ddc5f40f/greenlet-3.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:762612baf1161ccb8437c0161c668a688223cba28e1bf038f4eb47b13e39ccdf", size = 1618401, upload-time = "2026-04-27T12:53:31.062Z" }, { url = "https://files.pythonhosted.org/packages/0e/e3/5ec408a329acb854fb607a122e1ee5fb3ff649f9a97952948a90803c0d8e/greenlet-3.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:57a43c6079a89713522bc4bcb9f75070ecf5d3dbad7792bfe42239362cbf2a16", size = 1682038, upload-time = "2026-04-27T12:25:31.838Z" }, { url = "https://files.pythonhosted.org/packages/91/20/6b165108058767ee643c55c5c4904d591a830ee2b3c7dbd359828fbc829f/greenlet-3.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bc59be3945ae9750b9e7d45067d01ae3fe90ea5f9ade99239dabdd6e28a5033", size = 239835, upload-time = "2026-04-27T12:24:54.136Z" }, @@ -1720,7 +1728,9 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/a8/4522939255bb5409af4e87132f915446bf3622c2c292d14d3c38d128ae82/greenlet-3.5.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:a10a732421ab4fec934783ce3e54763470d0181db6e3468f9103a275c3ed1853", size = 293614, upload-time = "2026-04-27T12:24:12.874Z" }, { url = "https://files.pythonhosted.org/packages/15/5e/8744c52e2c027b5a8772a01561934c8835f869733e101f62075c60430340/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fc391b1566f2907d17aaebe78f8855dc45675159a775fcf9e61f8ee0078e87f", size = 650723, upload-time = "2026-04-27T12:52:45.412Z" }, { url = "https://files.pythonhosted.org/packages/00/ef/7b4c39c03cf46ceca512c5d3f914afd85aa30b2cc9a93015b0dd73e4be6c/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:680bd0e7ad5e8daa8a4aa89f68fd6adc834b8a8036dc256533f7e08f4a4b01f7", size = 656529, upload-time = "2026-04-27T12:59:46.295Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/0602239503b124b70e39355cbdb39361ecfe65b87a5f2f63752c32f5286f/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1aa4ce8debcd4ea7fb2e150f3036588c41493d1d52c43538924ae1819003f4ce", size = 657015, upload-time = "2026-04-27T13:02:43.973Z" }, { url = "https://files.pythonhosted.org/packages/0b/b5/c7768f352f5c010f92064d0063f987e7dc0cd290a6d92a34109015ce4aa1/greenlet-3.5.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddb36c7d6c9c0a65f18c7258634e0c416c6ab59caac8c987b96f80c2ebda0112", size = 654364, upload-time = "2026-04-27T12:25:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/38/51/8699f865f125dc952384cb432b0f7138aa4d8f2969a7d12d0df5b94d054d/greenlet-3.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:728a73687e39ae9ca34e4694cbf2f049d3fbc7174639468d0f67200a97d8f9e2", size = 488275, upload-time = "2026-04-27T13:05:18.28Z" }, { url = "https://files.pythonhosted.org/packages/ef/d0/079ebe12e4b1fc758857ce5be1a5e73f06870f2101e52611d1e71925ce54/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e5ddf316ced87539144621453c3aef229575825fe60c604e62bedc4003f372b2", size = 1614204, upload-time = "2026-04-27T12:53:32.618Z" }, { url = "https://files.pythonhosted.org/packages/6d/89/6c2fb63df3596552d20e58fb4d96669243388cf680cff222758812c7bfaa/greenlet-3.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4a448128607be0de65342dc9b31be7f948ef4cc0bc8832069350abefd310a8f2", size = 1675480, upload-time = "2026-04-27T12:25:34.168Z" }, { url = "https://files.pythonhosted.org/packages/15/32/77ee8a6c1564fc345a491a4e85b3bf360e4cf26eac98c4532d2fdb96e01f/greenlet-3.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d60097128cb0a1cab9ea541186ea13cd7b847b8449a7787c2e2350da0cb82d86", size = 245324, upload-time = "2026-04-27T12:24:40.295Z" }, @@ -3558,7 +3568,7 @@ typecheck = [ requires-dist = [ { name = "accelerate", marker = "extra == 'hf'", specifier = ">=1.9.0" }, { name = "boto3", marker = "extra == 'litellm'" }, - { name = "cpex", marker = "extra == 'hooks'", specifier = ">=0.1.0rc1" }, + { name = "cpex", marker = "extra == 'hooks'", specifier = ">=0.1.0" }, { name = "datasets", marker = "extra == 'hf'", specifier = ">=4.0.0" }, { name = "docling", marker = "extra == 'docling'", specifier = ">=2.45.0" }, { name = "elasticsearch", marker = "extra == 'granite-retriever'", specifier = ">=8.0.0,<9.0.0" },