Skip to content

fix: skip tool hooks for framework-internal tools#939

Open
araujof wants to merge 6 commits intogenerative-computing:mainfrom
araujof:fix/938_plugins
Open

fix: skip tool hooks for framework-internal tools#939
araujof wants to merge 6 commits intogenerative-computing:mainfrom
araujof:fix/938_plugins

Conversation

@araujof
Copy link
Copy Markdown
Contributor

@araujof araujof commented Apr 26, 2026

Misc PR

Type of PR

  • Bug Fix
  • New Feature
  • Documentation
  • Other

Description

Summary

  • Add an internal tools registry and a configurable bypass so tool_pre_invoke / tool_post_invoke hooks are automatically skipped for framework-internal tools like final_answer
  • Default: on (internal tools exempt). Users can opt out with set_skip_hooks_for_internal_tools(False)
  • Document the new capability in the Plugins & Hooks docs page

Changes

mellea/plugins/manager.py

  • Added _INTERNAL_TOOL_NAMES frozenset ({"final_answer"}) and _skip_hooks_for_internal_tools boolean (default True)
  • Added public API: skip_hooks_for_internal_tools(), set_skip_hooks_for_internal_tools(), is_internal_tool()
  • shutdown_plugins() resets the flag to True for test isolation

mellea/stdlib/functional.py

  • In _acall_tools(), gate both TOOL_PRE_INVOKE and TOOL_POST_INVOKE blocks with a _run_hooks check that skips internal tools when the flag is enabled

mellea/plugins/__init__.py

  • Exported the three new functions

docs/docs/concepts/plugins.mdx

  • Added a note on tool_pre_invoke referencing the exemption
  • Added "Internal tool exemption" subsection with usage examples and opt-out instructions
  • Added new functions to the API reference table

test/plugins/test_internal_tool_hook_skip.py (new)

  • 8 tests: skip pre/post hooks for internal tools, hooks fire when disabled, user tools unaffected, mixed batches, is_internal_tool unit test, shutdown resets flag

Testing

  • Tests added to the respective file if code was changed
  • New code has 100% coverage if code as added
  • Ensure existing tests and github automation passes (a maintainer will kick off the github automation when the rest of the PR is populated)

Test plan

  • uv run pytest test/plugins/test_internal_tool_hook_skip.py -v — all new tests pass
  • uv run pytest test/plugins/test_tool_hooks_redaction.py -v — no regression
  • uv run pytest test/stdlib/frameworks/test_react_framework.py -v — no regression
  • uv run ruff check / ruff format — clean
  • uv run mypy mellea/plugins/manager.py mellea/stdlib/functional.py — no issues

Attribution

  • AI coding assistants used

Related

Part of #920.

@araujof araujof requested a review from a team as a code owner April 26, 2026 13:11
@araujof araujof requested review from nrfulton and psschwei April 26, 2026 13:11
@github-actions github-actions Bot added the bug Something isn't working label Apr 26, 2026
@github-actions
Copy link
Copy Markdown
Contributor

The PR description has been updated. Please fill out the template for your PR to be reviewed.

@araujof
Copy link
Copy Markdown
Contributor Author

araujof commented Apr 26, 2026

cc: @nrfulton @jakelorocco

Copy link
Copy Markdown
Member

@psschwei psschwei left a comment

Choose a reason for hiding this comment

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

couple of minor nits, but otherwise looks ok to me

Comment thread test/plugins/test_internal_tool_hook_skip.py Outdated
Comment thread mellea/plugins/manager.py

# 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"})
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

Comment thread mellea/stdlib/functional.py Outdated
@jakelorocco
Copy link
Copy Markdown
Contributor

I will defer to Nathan's comment here: #921 (comment)

At the very least, I don't think we should have these be a part of Mellea core:

| `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 |

Even if we set an internal flag on some mellea tools, we should let the plugin decide explicitly whether to skip them. I also have some additional comments here that I think are applicable: #921 (review)

araujof and others added 5 commits April 30, 2026 22:12
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>

Co-authored-by: Paul Schweigert <paul@paulschweigert.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>

Co-authored-by: Paul Schweigert <paul@paulschweigert.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof araujof force-pushed the fix/938_plugins branch from edfb323 to 76d502c Compare May 1, 2026 02:14
Signed-off-by: Frederico Araujo <frederico.araujo@ibm.com>
@araujof
Copy link
Copy Markdown
Contributor Author

araujof commented May 1, 2026

I will defer to Nathan's comment here: #921 (comment)

At the very least, I don't think we should have these be a part of Mellea core:

| `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 |

Even if we set an internal flag on some mellea tools, we should let the plugin decide explicitly whether to skip them. I also have some additional comments here that I think are applicable: #921 (review)

Thanks @jakelorocco ! Great suggestion. I refactored the PR to enable per-plugin control-flow tool filtering. I also updated the examples and docs to show how it works.

What changed

Replaced the global skip_hooks_for_internal_tools flag with a is_control_flow field on the tool hook payloads. Hooks now always fire for all tools. Each plugin decides whether to act on control-flow tools.

Why

The global flag hid tool calls from every plugin (including logging/telemetry plugins that need to see them). Nathan and Jake's feedback: the plugin should decide, not the framework.

Key changes

  1. mellea/plugins/hooks/tool.py - Added is_control_flow: bool = False to both ToolPreInvokePayload and ToolPostInvokePayload.

  2. mellea/stdlib/functional.py - Removed the run_hooks gate. Always fires hooks. Sets is_control_flow=True when the tool is in the internal registry.

  3. mellea/plugins/manager.py - Removed skip_hooks_for_internal_tools(), set_skip_hooks_for_internal_tools(), and the backing _skip_hooks_for_internal_tools bool. Kept is_internal_tool() (used internally to populate the payload field).

  4. mellea/plugins/__init__.py - Removed the two deleted functions from exports.

  5. test/plugins/test_internal_tool_hook_skip.py - Rewritten. Tests verify hooks always fire, is_control_flow is set correctly, and the allowlist pattern works.

  6. docs/docs/concepts/plugins.mdx - Replaced "Internal tool exemption" section with "Control-flow tools" section showing the per-plugin pattern.

  7. docs/examples/plugins/tool_hooks.py - Added is_control_flow guard to the allowlist plugin example.

Recommended plugin pattern

@hook(HookType.TOOL_PRE_INVOKE, mode=PluginMode.CONCURRENT, priority=5)
async def enforce_tool_allowlist(payload, _):
    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 plugins that want to observe all tools simply ignore the flag.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(plugins): Tool allowlist plugins block ReAct's internal final_answer tool

4 participants