User-facing documentation: Plugins & Hooks covers usage, registration, execution modes, hook types reference, and patterns. This file retains only internal design rationale and decisions for contributors.
- Consistent interface: All hooks follow the same async pattern with payload and context parameters
- Composable: Multiple plugins can register for the same hook, executing in priority order
- Fail-safe: Hook failures can be handled gracefully without breaking core execution
- Minimal intrusion: Plugins are opt-in; default Mellea behavior remains unchanged without plugins. Plugins work identically whether invoked through a session (
m.instruct(...)) or via the functional API (instruct(backend, context, ...)) - Architecturally aligned: Hook categories reflect Mellea's true abstraction boundaries — Session lifecycle, Component lifecycle, and the (Backend, Context) generation pipeline
- Code-first: Plugins are defined and composed in Python. The
@hookdecorator andPluginbase class are the primary registration mechanisms; YAML configuration is a secondary option for deployment-time overrides - Functions-first: The simplest plugin is a plain async function decorated with
@hook. Class-based plugins (via thePluginbase class) exist for stateful, multi-hook scenarios but are not required
Hooks use Python's async/await cooperative multitasking. Because Python's event loop only switches execution at await points, hook code won't be interrupted mid-logic. This means:
- Sequential when awaited: Calling
await hook(...)keeps control flow deterministic — the hook completes before the caller continues. - Race conditions only at
awaitpoints: Shared state is safe to read and write betweenawaitcalls within a single hook. Races only arise if multiple hooks modify the same shared state and are dispatched concurrently. - No preemptive interruption: Unlike threads, a hook handler runs uninterrupted until it yields control via
await.
Hooks are called from Mellea's base classes (Component.aact(), Backend.generate(), SamplingStrategy.run(), etc.). This means hook invocation is a framework-level concern, and authors of new backends, sampling strategies, or components do not need to manually insert hook calls.
The caller (the base class method) is responsible for both invoking the hook and processing the result. Processing means checking the result for one of three possible outcomes:
- Continue with original payload —
PluginResult(continue_processing=True)with nomodified_payload. The caller proceeds unchanged. - Continue with modified payload —
PluginResult(continue_processing=True, modified_payload=...). The plugin manager applies the hook's payload policy, accepting only changes to writable fields and discarding unauthorized modifications. The caller uses the policy-filtered payload in place of the original. - Block execution —
PluginResult(continue_processing=False, violation=...). The caller raises or returns early with structured error information.
Hooks cannot redirect control flow, jump to arbitrary code, or alter the calling method's logic beyond these outcomes. This is enforced by the PluginResult type.
- Strongly typed — Each hook has a dedicated payload dataclass (not a generic dict). This enables IDE autocompletion, static analysis, and clear documentation of what each hook receives.
- Sufficient (maximize-at-boundary) — Each payload includes everything available at that point in time. Post-hooks include the pre-hook fields plus results. This avoids forcing plugins to maintain their own state across pre/post pairs.
- Frozen (immutable) — Payloads are frozen Pydantic models (
model_config = ConfigDict(frozen=True)). Plugins cannot mutate payload attributes in place. To propose changes, plugins must callpayload.model_copy(update={...})and return the copy viaPluginResult.modified_payload. This ensures every modification is explicit and flows through the policy system. - Policy-controlled — Each hook type declares a
HookPayloadPolicyspecifying which fields are writable. The plugin manager applies the policy after each plugin returns, accepting only changes to writable fields and silently discarding unauthorized modifications. This separates "what the plugin can observe" from "what the plugin can change" — and enforces it at the framework level. - Serializable — Payloads should be serializable for external (MCP-based) plugins that run out-of-process. All payload fields use types that can round-trip through JSON or similar formats.
- Versioned — Payload schemas carry a
payload_versionso plugins can detect incompatible changes at registration time rather than at runtime. - Isolation — Each plugin receives a copy-on-write (CoW) snapshot of the payload. Mutable containers (dicts, lists) are wrapped so mutations in one plugin do not affect others. Plugins should not cache payloads beyond the hook invocation — payload fields reference live framework objects (
Context,Component,MelleaSession) whose lifecycle is managed by the framework.
The GlobalContext passed to hooks carries lightweight, cross-cutting ambient metadata that is useful to every hook regardless of type. Hook-specific data (context, session, action, etc.) belongs on the typed payload, not on the global context.
# GlobalContext.state — same for all hook types
backend_name: str # Derived from backend.model_id (when backend is passed)The backend_name is a lightweight string extracted from backend.model_id. The full backend and session objects are not stored in GlobalContext — this avoids giving plugins unchecked mutable access to core framework objects.
Previously, context, session, and backend were passed both on payloads and in GlobalContext.state, creating duplication. The same mutable object accessible via two paths was a footgun — plugins could be confused about which to read/modify. The refactored design:
- Payloads are the primary API surface — typed, documented, policy-controlled
- GlobalContext holds only truly ambient metadata (
backend_name) that doesn't belong on any specific payload - No mutable framework objects (
Backend,MelleaSession,Context) are stored in GlobalContext
component_post_success and component_post_error are separate hooks rather than a single component_post with a sum type over success/failure. The reasons are:
- Registration granularity — Plugins subscribe to only what they need. An audit logger may only care about errors; a metrics collector may only care about successes.
- Distinct payload shapes — Success payloads carry
result,generate_log, andsampling_results; error payloads carryexception,error_type, andstack_trace. A sum type would force nullable fields or tagged unions, adding complexity for every consumer. - Different execution modes — Error hooks may be fire-and-forget (for alerting); success hooks may be blocking (for output transformation). Separate hooks allow per-hook execution timing configuration.
component_pre_create and component_post_create are not implemented. Component is currently a Protocol, not an abstract base class. This means Mellea has no ownership over component initialization: there are no guarantees about when or how subclass __init__ methods run, and there is no single interception point that covers all Component implementations.
Placing hook calls inside Instruction.__init__ and Message.__init__ works for those specific classes, but it is fragile (any user-defined Component subclass is invisible to the hooks) and architecturally wrong (the hook system should not need to be threaded manually into every __init__).
If Component were refactored to an abstract base class, Mellea could wrap __init__ at the ABC level and fire these hooks generically for all subclasses. Until then, use component_pre_execute for pre-execution policy enforcement.
The following hooks are designed but not yet implemented. They are included in the design for completeness and may be implemented as demand arises.
| Hook Point | Category | Notes |
|---|---|---|
component_pre_create |
Component Lifecycle | Blocked on Component-as-ABC refactoring (see above) |
component_post_create |
Component Lifecycle | Blocked on Component-as-ABC refactoring (see above) |
generation_stream_chunk |
Generation Pipeline | Per-chunk interception during streaming |
adapter_pre_load |
Backend Adapter Ops | Before backend.load_adapter() |
adapter_post_load |
Backend Adapter Ops | After adapter loaded |
adapter_pre_unload |
Backend Adapter Ops | Before backend.unload_adapter() |
adapter_post_unload |
Backend Adapter Ops | After adapter unloaded |
context_update |
Context Operations | When context changes (append/reset) |
context_prune |
Context Operations | When context is trimmed for token budget |
error_occurred |
Error Handling | Cross-cutting hook for unrecoverable errors |
A single PluginManager instance manages all plugins. Plugins are tagged with an optional session_id. At dispatch time, the manager filters: global plugins (no session tag) always run; session-tagged plugins run only when the dispatch context matches their session ID.
With-block scopes use the same session_id tagging mechanism. Each with block gets a unique UUID scope ID; the plugin manager filters plugins by scope ID at dispatch time and deregisters them by scope ID on exit.
For deployment-time configuration, plugins can be loaded from YAML. This is useful for enabling/disabling plugins or changing priorities without code changes. The disabled mode (PluginMode.DISABLED) is available in YAML configuration for deployment-time control but is not exposed in Mellea's public PluginMode enum.
The plugin framework supports custom hook types for domain-specific extension points beyond the built-in lifecycle hooks. This is particularly relevant for agentic patterns (ReAct, tool-use loops, etc.) where the execution flow is application-defined. Custom hooks use the same @hook decorator and follow the same calling convention, payload chaining, and result semantics. As agentic patterns stabilize in Mellea, frequently-used custom hooks may be promoted to built-in hooks.
The functional API (instruct(backend, context, ...)) does not require a session. Hooks still fire at the same execution points. If global plugins are registered, they execute. If no plugins are registered, hooks are no-ops with zero overhead. Session-scoped plugins do not apply because there is no session.