Skip to content
Merged
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
4 changes: 2 additions & 2 deletions mellea/formatters/granite/retrievers/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
import zipfile

# Third Party
import pyarrow as pa
import pyarrow.json as pj
import pyarrow as pa # type: ignore[import-not-found]
import pyarrow.json as pj # type: ignore[import-not-found]


def download_mtrag_corpus(target_dir: str, corpus_name: str) -> pathlib.Path:
Expand Down
41 changes: 25 additions & 16 deletions mellea/plugins/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,23 +134,22 @@ def __init__( # noqa: D107
super().__init__(f"Plugin blocked {hook_type}: {detail}{reason}")


class MelleaBasePayload(PluginPayload):
"""Frozen base — all payloads are immutable by design.

Plugins must use ``model_copy(update={...})`` to propose modifications
and return the copy via ``PluginResult.modified_payload``. The plugin
manager applies the hook's ``HookPayloadPolicy`` to filter changes to
writable fields only.
"""
if _HAS_PLUGIN_FRAMEWORK:

session_id: str | None = None
request_id: str = ""
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
hook: str = ""
user_metadata: dict[str, Any] = Field(default_factory=dict)
class MelleaBasePayload(PluginPayload):
"""Frozen base — all payloads are immutable by design.

Plugins must use ``model_copy(update={...})`` to propose modifications
and return the copy via ``PluginResult.modified_payload``. The plugin
manager applies the hook's ``HookPayloadPolicy`` to filter changes to
writable fields only.
"""

if _HAS_PLUGIN_FRAMEWORK:
session_id: str | None = None
request_id: str = ""
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC))
hook: str = ""
user_metadata: dict[str, Any] = Field(default_factory=dict)

class MelleaPlugin(_CpexPlugin):
"""Base class for Mellea plugins with lifecycle hooks and typed accessors.
Expand Down Expand Up @@ -229,14 +228,24 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
PluginResult: TypeAlias = _CFPluginResult # type: ignore[misc]

else:
# Provide a stub when the plugin framework is not installed.
class MelleaBasePayload: # type: ignore[no-redef]
"""Stub — install ``"mellea[hooks]"`` for full plugin support."""

def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D107
raise ImportError(
"MelleaPlugin requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[hooks]'"
)

# Provide a stub when the plugin framework is not installed.
class MelleaPlugin: # type: ignore[no-redef]
"""Stub — install ``mcp-contextforge-gateway`` for full plugin support."""
"""Stub — install ``"mellea[hooks]"`` for full plugin support."""

def __init__(self, *args: Any, **kwargs: Any) -> None: # noqa: D107
raise ImportError(
"MelleaPlugin requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[contextforge]'"
"Install it with: pip install 'mellea[hooks]'"
)

# Provide an alias when the plugin framework is not installed.
Expand Down
4 changes: 2 additions & 2 deletions mellea/plugins/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def ensure_plugin_manager() -> Any:
if not _HAS_PLUGIN_FRAMEWORK:
raise ImportError(
"Plugin system requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[contextforge]'"
"Install it with: pip install 'mellea[hooks]'"
)

if _plugin_manager is None:
Expand Down Expand Up @@ -93,7 +93,7 @@ async def initialize_plugins(
if not _HAS_PLUGIN_FRAMEWORK:
raise ImportError(
"Plugin system requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[contextforge]'"
"Install it with: pip install 'mellea[hooks]'"
)

register_mellea_hooks()
Expand Down
4 changes: 3 additions & 1 deletion mellea/plugins/policies.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
from typing import Any

try:
from cpex.framework.hooks.policies import HookPayloadPolicy
from cpex.framework.hooks.policies import (
HookPayloadPolicy, # type: ignore[import-not-found]
)

_HAS_PLUGIN_FRAMEWORK = True
except ImportError:
Expand Down
236 changes: 129 additions & 107 deletions mellea/plugins/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ def modify(payload: Any, **field_updates: Any) -> Any:
if not _HAS_PLUGIN_FRAMEWORK:
raise ImportError(
"modify() requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[contextforge]'"
"Install it with: pip install 'mellea[hooks]'"
)
return PluginResult(
continue_processing=True,
Expand All @@ -94,7 +94,7 @@ def block(
if not _HAS_PLUGIN_FRAMEWORK:
raise ImportError(
"block() requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[contextforge]'"
"Install it with: pip install 'mellea[hooks]'"
)
return PluginResult(
continue_processing=False,
Expand Down Expand Up @@ -123,7 +123,7 @@ def register(
if not _HAS_PLUGIN_FRAMEWORK:
raise ImportError(
"register() requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[contextforge]'"
"Install it with: pip install 'mellea[hooks]'"
)

from mellea.plugins.manager import ensure_plugin_manager
Expand Down Expand Up @@ -232,115 +232,137 @@ def _register_single(
)


class _FunctionHookAdapter(Plugin):
"""Adapts a standalone ``@hook``-decorated function into a ContextForge Plugin."""

def __init__(
self,
fn: Callable,
session_id: str | None = None,
priority_override: int | None = None,
):
meta: HookMeta = fn._mellea_hook_meta # type: ignore[attr-defined]
priority = (
priority_override
if priority_override is not None
else (meta.priority if meta.priority is not None else 50)
)
config = PluginConfig(
name=f"{fn.__module__}.{fn.__qualname__}",
kind=f"{fn.__module__}.{fn.__qualname__}",
hooks=[meta.hook_type],
mode=_map_mode(meta.mode),
priority=priority,
on_error=_CFOnError.IGNORE,
)
super().__init__(config)
self._fn = fn
self._session_id = session_id

async def initialize(self) -> None:
pass

async def shutdown(self) -> None:
pass

# The hook method is discovered by convention: method name == hook_type.
# We dynamically add it so ContextForge's HookRef can find it.
def __getattr__(self, name: str) -> Any:
meta: HookMeta | None = getattr(self._fn, "_mellea_hook_meta", None)
if meta and name == meta.hook_type:
return self._invoke
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)

async def _invoke(self, payload: Any, context: Any) -> Any:
result = await self._fn(payload, context)
if result is None:
return PluginResult(continue_processing=True)
return result
if _HAS_PLUGIN_FRAMEWORK:

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.

Lets do the stubs with import errors for these two classes as well.


class _FunctionHookAdapter(Plugin):
"""Adapts a standalone ``@hook``-decorated function into a ContextForge Plugin."""

class _MethodHookAdapter(Plugin):
"""Adapts a single ``@hook``-decorated bound method from a ``Plugin`` class.
def __init__(
self,
fn: Callable,
session_id: str | None = None,
priority_override: int | None = None,
):
meta: HookMeta = fn._mellea_hook_meta # type: ignore[attr-defined]
priority = (
priority_override
if priority_override is not None
else (meta.priority if meta.priority is not None else 50)
)
config = PluginConfig(
name=f"{fn.__module__}.{fn.__qualname__}",
kind=f"{fn.__module__}.{fn.__qualname__}",
hooks=[meta.hook_type],
mode=_map_mode(meta.mode),
priority=priority,
on_error=_CFOnError.IGNORE,
)
super().__init__(config)
self._fn = fn
self._session_id = session_id

async def initialize(self) -> None:
pass

async def shutdown(self) -> None:
pass

# The hook method is discovered by convention: method name == hook_type.
# We dynamically add it so ContextForge's HookRef can find it.
def __getattr__(self, name: str) -> Any:
meta: HookMeta | None = getattr(self._fn, "_mellea_hook_meta", None)
if meta and name == meta.hook_type:
return self._invoke
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)

Each ``@hook`` method on a ``@plugin``-decorated class gets its own adapter
so that per-method execution modes (``SEQUENTIAL``, ``FIRE_AND_FORGET``, etc.)
are respected. The adapter name is ``"<plugin_name>.<hook_type>"``.
async def _invoke(self, payload: Any, context: Any) -> Any:
result = await self._fn(payload, context)
if result is None:
return PluginResult(continue_processing=True)
return result

class _MethodHookAdapter(Plugin):
"""Adapts a single ``@hook``-decorated bound method from a ``Plugin`` class.

Each ``@hook`` method on a ``@plugin``-decorated class gets its own adapter
so that per-method execution modes (``SEQUENTIAL``, ``FIRE_AND_FORGET``, etc.)
are respected. The adapter name is ``"<plugin_name>.<hook_type>"``.

Note: ``initialize()`` and ``shutdown()`` delegate to the underlying class
instance and may be called once per registered hook method. Make them
idempotent when using the ``Plugin`` base class with multiple hook methods.
"""

def __init__(
self,
instance: Any,
bound_method: Callable,
hook_meta: HookMeta,
plugin_name: str,
plugin_module: str,
priority: int,
session_id: str | None = None,
):
hook_val = getattr(hook_meta.hook_type, "value", hook_meta.hook_type)
adapter_name = f"{plugin_name}.{hook_val}"
config = PluginConfig(
name=adapter_name,
kind=f"{plugin_module}.{hook_val}",
hooks=[hook_meta.hook_type],
mode=_map_mode(hook_meta.mode),
priority=priority,
on_error=_CFOnError.IGNORE,
)
super().__init__(config)
self._instance = instance
self._bound_method = bound_method
self._session_id = session_id

async def initialize(self) -> None:
init = getattr(self._instance, "initialize", None)
if init and callable(init):
await init()

async def shutdown(self) -> None:
shut = getattr(self._instance, "shutdown", None)
if shut and callable(shut):
await shut()

def __getattr__(self, name: str) -> Any:
if self._config.hooks and name == self._config.hooks[0]:
return self._invoke
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)

Note: ``initialize()`` and ``shutdown()`` delegate to the underlying class
instance and may be called once per registered hook method. Make them
idempotent when using the ``Plugin`` base class with multiple hook methods.
"""
async def _invoke(self, payload: Any, context: Any) -> Any:
result = await self._bound_method(payload, context)
if result is None:
return PluginResult(continue_processing=True)
return result

else:
# Provide a stub when the plugin framework is not installed.
class _FunctionHookAdapter: # type: ignore[no-redef]
"""Stub — install ``"mellea[hooks]"`` for full plugin support."""

def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"MelleaPlugin requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[hooks]'"
)

def __init__(
self,
instance: Any,
bound_method: Callable,
hook_meta: HookMeta,
plugin_name: str,
plugin_module: str,
priority: int,
session_id: str | None = None,
):
hook_val = getattr(hook_meta.hook_type, "value", hook_meta.hook_type)
adapter_name = f"{plugin_name}.{hook_val}"
config = PluginConfig(
name=adapter_name,
kind=f"{plugin_module}.{hook_val}",
hooks=[hook_meta.hook_type],
mode=_map_mode(hook_meta.mode),
priority=priority,
on_error=_CFOnError.IGNORE,
)
super().__init__(config)
self._instance = instance
self._bound_method = bound_method
self._session_id = session_id

async def initialize(self) -> None:
init = getattr(self._instance, "initialize", None)
if init and callable(init):
await init()

async def shutdown(self) -> None:
shut = getattr(self._instance, "shutdown", None)
if shut and callable(shut):
await shut()

def __getattr__(self, name: str) -> Any:
if self._config.hooks and name == self._config.hooks[0]:
return self._invoke
raise AttributeError(
f"'{type(self).__name__}' object has no attribute '{name}'"
)
# Provide a stub when the plugin framework is not installed.
class _MethodHookAdapter: # type: ignore[no-redef]
"""Stub — install ``"mellea[hooks]"`` for full plugin support."""

async def _invoke(self, payload: Any, context: Any) -> Any:
result = await self._bound_method(payload, context)
if result is None:
return PluginResult(continue_processing=True)
return result
def __init__(self, *args: Any, **kwargs: Any) -> None:
raise ImportError(
"MelleaPlugin requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[hooks]'"
)


class _PluginScope:
Expand Down Expand Up @@ -440,7 +462,7 @@ def unregister(
if not _HAS_PLUGIN_FRAMEWORK:
raise ImportError(
"unregister() requires the ContextForge plugin framework. "
"Install it with: pip install 'mellea[contextforge]'"
"Install it with: pip install 'mellea[hooks]'"
)

from mellea.plugins.manager import get_plugin_manager
Expand Down
2 changes: 1 addition & 1 deletion test/backends/test_mellea_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ def test_from_smolagents_basic():
4. The tool can be executed with arguments
"""
try:
from smolagents import Tool
from smolagents import Tool # type: ignore[import-not-found]
except ImportError:
pytest.skip(
"smolagents not installed - install with: uv pip install 'mellea[smolagents]'"
Expand Down
Loading
Loading