Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
04b5d0b
feat(backends): implement LocalFileBinding verbs and from_catalog()
planetf1 Jul 28, 2026
5b2c1a5
fix(backends): classify schema mismatches in adapter_scope; correct s…
planetf1 Aug 11, 2026
b771658
test(backends): correct the rationale in the hook-capture test helpers
planetf1 Aug 11, 2026
c0a364c
fix(backends): address review findings on release(), phase hooks and …
planetf1 Aug 11, 2026
9dda170
test(backends): make test_adapters a package, matching test/telemetry
planetf1 Aug 11, 2026
0c05582
docs(backends): fix RST cross-reference roles and stale-status docstr…
planetf1 Aug 12, 2026
9719f7d
fix(backends): fix leaked coroutine at the _run_async_in_thread helpe…
planetf1 Aug 13, 2026
46a789d
fix(backends): narrow event_loop_helper's close-on-failure catch to E…
planetf1 Aug 13, 2026
52e24f6
fix(backends): guarantee deactivate() runs after a successful activat…
planetf1 Aug 14, 2026
9b84a29
fix(backends): name the conflict when a LocalFileBinding blocks resol…
planetf1 Aug 14, 2026
afba145
test(backends): pin adapter_scope's new raise on shim-backed adapters
planetf1 Aug 14, 2026
ec3411f
fix(backends): close the internal wrapper coroutine on scheduling fai…
planetf1 Aug 14, 2026
d3dc793
fix(backends): make prepare() retryable after a load failure, enforce…
planetf1 Aug 14, 2026
6615ba5
fix(backends): lock PEFT load/unload in prepare()/release(); document…
planetf1 Aug 14, 2026
53843e8
fix(backends): isolate phase hook failures
planetf1 Aug 17, 2026
94b2b7c
fix(backends): preserve adapter lifecycle failures
planetf1 Aug 17, 2026
9df8503
fix(backends): guard binding lifecycle transitions
planetf1 Aug 17, 2026
39091e3
fix(backends): serialise binding lifecycle state
planetf1 Aug 17, 2026
0ceccc8
fix(backends): serialise binding lifecycle transitions
planetf1 Aug 17, 2026
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
390 changes: 373 additions & 17 deletions mellea/backends/adapters/_core.py

Large diffs are not rendered by default.

327 changes: 316 additions & 11 deletions mellea/backends/adapters/adapter.py

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions mellea/backends/adapters/catalog.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,7 @@ class IntrinsicsCatalogEntry(pydantic.BaseModel):
revision (str): Hugging Face revision — branch name, tag, or commit SHA.
Catalogue entries pin to commit SHAs by convention so loads are
reproducible; the validator itself only requires a non-empty string.
Note: this field is stored in the catalogue but not yet forwarded to
the Hugging Face download call; wiring it through is deferred to a
subsequent phase of the adapter-lifecycle epic (#929).
The revision is forwarded to Hugging Face download calls.
adapter_types (tuple[AdapterType, ...]): Adapter types known to be
available for this adapter function; defaults to
`(AdapterType.LORA, AdapterType.ALORA)`.
Expand Down
102 changes: 82 additions & 20 deletions mellea/backends/huggingface.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from __future__ import annotations

import asyncio
import contextlib
import dataclasses
import datetime
import functools
Expand Down Expand Up @@ -76,6 +77,7 @@
from ..telemetry.context import generate_request_id, with_context
from ._options import resolve_model_options
from .adapters import AdapterMixin, IntrinsicAdapter, LocalHFAdapter
from .adapters._core import LocalFileBinding
from .adapters.adapter import AdapterInput
from .backend import FormatterBackend
from .cache import Cache, SimpleLRUCache
Expand Down Expand Up @@ -445,8 +447,8 @@ def __init__(
)

# Adapters can be made known to the backend (added) and loaded.
self._added_adapters: dict[str, LocalHFAdapter] = {}
self._loaded_adapters: dict[str, LocalHFAdapter] = {}
self._added_adapters: dict[str, LocalHFAdapter | LocalFileBinding] = {}
self._loaded_adapters: dict[str, LocalHFAdapter | LocalFileBinding] = {}

self._generation_lock = threading.Lock()
"""Used to force generation requests to be non-concurrent. Necessary for preventing issues with adapters."""
Expand Down Expand Up @@ -565,19 +567,9 @@ def _generate_with_adapter_lock(
with self._generation_lock:
if adapter_name != "":
self.load_peft_adapter(adapter_name)
self._model.set_adapter(adapter_name)
self.activate_peft_adapter(adapter_name)
Comment thread
planetf1 marked this conversation as resolved.
else:
try:
# `._model.disable_adapters()` doesn't seem to actually disable them or
# remove them from the model's list of `.active_adapters()`.
self._model.set_adapter([])
except ValueError as e:
# If no weights have been loaded, the model will raise a ValueError:
# `ValueError("No adapter loaded. Please load an adapter first.")`
if "No adapter loaded" in str(e):
pass
else:
raise e
self.deactivate_peft_adapter(adapter_name)
Comment thread
jakelorocco marked this conversation as resolved.

_assert_correct_adapters(adapter_name, self._model)
out = generate_func(*args, **kwargs)
Expand Down Expand Up @@ -2005,15 +1997,16 @@ def add_adapter(self, adapter: AdapterInput) -> None:

Args:
adapter (AdapterInput): The adapter to register. Must be a
`LocalHFAdapter`; other adapter realities are rejected.
`LocalHFAdapter` or `LocalFileBinding`; other adapter realities
are rejected.

Raises:
TypeError: If `adapter` is not a `LocalHFAdapter`.
TypeError: If `adapter` is not a `LocalHFAdapter` or `LocalFileBinding`.
Exception: If `adapter` has already been added to a different backend.
"""
if not isinstance(adapter, LocalHFAdapter):
if not isinstance(adapter, (LocalHFAdapter, LocalFileBinding)):
raise TypeError(
f"LocalHFBackend requires a LocalHFAdapter; got "
f"LocalHFBackend requires a LocalHFAdapter or LocalFileBinding; got "
f"{type(adapter).__name__}."
)
if adapter.backend is not None:
Expand All @@ -2027,9 +2020,15 @@ def add_adapter(self, adapter: AdapterInput) -> None:
f"adapter {adapter.name} with type {adapter.adapter_type} has already been added to backend {adapter.backend}"
)

if self._added_adapters.get(adapter.qualified_name) is not None:
existing = self._added_adapters.get(adapter.qualified_name)
if existing is not None:
MelleaLogger.get_logger().warning(
f"Client code attempted to add {adapter.name} with type {adapter.adapter_type} but {adapter.name} was already added to {self.__class__}. The backend is refusing to do this, because adapter loading is not idempotent."
f"Client code attempted to add {adapter.name} with type {adapter.adapter_type} "
f"but {adapter.qualified_name!r} is already registered as a "
f"{type(existing).__name__} on {self.__class__.__name__}. The backend is "
"refusing to do this, because adapter loading is not idempotent. "
"LocalFileBinding and IntrinsicAdapter/resolve_adapter() registrations "
"share this qualified-name key space and cannot both claim the same name."
)
return None

Expand Down Expand Up @@ -2103,6 +2102,69 @@ def unload_peft_adapter(self, adapter_qualified_name: str) -> None:
# Remove the adapter from the list of loaded adapters.
del self._loaded_adapters[adapter.qualified_name]

def activate_peft_adapter(self, adapter_qualified_name: str) -> None:
"""Switch a previously loaded PEFT adapter on for subsequent generation.

Must be called while holding `_generation_lock`.

Args:
adapter_qualified_name (str): The `adapter.qualified_name` of the adapter
to activate.
"""
self._model.set_adapter(adapter_qualified_name)

def deactivate_peft_adapter(self, adapter_qualified_name: str) -> None:
"""Switch off any active PEFT adapter so generation uses the base model.

Must be called while holding `_generation_lock`.

Args:
adapter_qualified_name (str): The `adapter.qualified_name` of the adapter
to deactivate. Accepted for symmetry with `activate_peft_adapter`; the
underlying primitive clears all active PEFT adapters regardless of
name.

Raises:
ValueError: If the underlying PEFT model raises `ValueError` for a
reason other than "no adapter loaded" (which is treated as a
no-op, since deactivating is already a no-op in that case).
"""
try:
# `._model.disable_adapters()` doesn't seem to actually disable them or
# remove them from the model's list of `.active_adapters()`.
self._model.set_adapter([])
except ValueError as e:
# If no weights have been loaded, the model will raise a ValueError:
# `ValueError("No adapter loaded. Please load an adapter first.")`
if "No adapter loaded" not in str(e):
raise e

def _adapter_activation_lock(
Comment thread
planetf1 marked this conversation as resolved.
self,
) -> contextlib.AbstractContextManager[bool | None]:
"""Reuse `_generation_lock` for exclusivity around adapter activation.

`activate_peft_adapter`/`deactivate_peft_adapter` document "must be called
while holding `_generation_lock`" as a precondition, and they have two
callers:

1. `_generate_with_adapter_lock`, which takes `_generation_lock` itself.
2. `LocalFileBinding.activate()`/`.deactivate()` (driven by
`AdapterMixin.adapter_scope()`), which holds no lock of its own.

This exists for caller 2 — so it is not a duplicate of the lock caller 1
takes, it is the only thing satisfying the precondition on that path.

TODO(#1465): `_generation_lock` is a plain non-reentrant `threading.Lock`.
Nothing nests these two callers today, because generation still runs
outside `adapter_scope`. When #1465 moves the model call inside the scope,
`activate()` will re-acquire this lock while `_generate_with_adapter_lock`
already holds it and deadlock. #1465 owns the fix (make it reentrant, or
restructure so the scope takes the lock once); it must not be resolved by
dropping the lock here, which would leave caller 2's precondition unmet.
"""
return self._generation_lock

def list_adapters(self) -> list[str]:
"""List the qualified names of all adapters registered with this backend.

Expand Down
62 changes: 49 additions & 13 deletions mellea/helpers/event_loop_helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,20 +78,56 @@ def __call__(self, co: Coroutine[Any, Any, R]) -> R:
The caller's `contextvars` snapshot is applied inside the new Task so
contextvar-backed state is visible to the coroutine. One-way:
mutations inside the Task don't leak back.
"""
self._reinit_if_forked()
if self._event_loop == get_current_event_loop():
# If this gets called from the same event loop, launch in a separate thread to prevent blocking.
return _EventLoopHandler()(co)

parent_ctx = contextvars.copy_context()

async def _wrapped() -> R:
for var, value in parent_ctx.items():
var.set(value)
return await co

return asyncio.run_coroutine_threadsafe(_wrapped(), self._event_loop).result()
`_reinit_if_forked`/`get_current_event_loop` run before the coroutine
is scheduled, so an exception there (or a failure to schedule) would
otherwise leave `co` unawaited and emit a "coroutine was never
awaited" warning on top of whatever raised. `co.close()` covers that:
if scheduling never succeeded, `co` hasn't started and closing it is
a clean no-op; if `.result()` raised the task's own exception instead,
the future is only marked done after the scheduled task has fully
finished running `co`, so closing an already-completed coroutine is
also a no-op.

Deliberately `except Exception`, not `BaseException`: a `Future.result()`
with no timeout blocks on a `threading.Condition`, and a `KeyboardInterrupt`
delivered to this (calling) thread can unblock that wait before the task
on the event-loop thread has actually finished — `co` may still be running
there. Closing it from here at that point would run `co`'s `GeneratorExit`
handling in this thread while the event loop thread is concurrently
stepping the same frame, which is not safe. Excluding
`KeyboardInterrupt`/`SystemExit` means that rare case leaks the
unawaited-coroutine warning instead of risking that race.

`_wrapped()`'s own coroutine object needs the same treatment as `co`:
if scheduling fails before `run_coroutine_threadsafe` hands it to the
loop, `_wrapped()` never starts and never gets to `await co` — leaking
both `_wrapped()`'s and (via the outer `except`) `co`'s "coroutine was
never awaited" warnings otherwise.
"""
wrapped_co: Coroutine[Any, Any, R] | None = None
try:
self._reinit_if_forked()
if self._event_loop == get_current_event_loop():
# If this gets called from the same event loop, launch in a separate thread to prevent blocking.
return _EventLoopHandler()(co)

parent_ctx = contextvars.copy_context()

async def _wrapped() -> R:
for var, value in parent_ctx.items():
var.set(value)
return await co

wrapped_co = _wrapped()
return asyncio.run_coroutine_threadsafe(
wrapped_co, self._event_loop
).result()
except Exception:
co.close()
if wrapped_co is not None:
wrapped_co.close()
raise


# Instantiate this class once. It will not be re-instantiated.
Expand Down
6 changes: 2 additions & 4 deletions mellea/telemetry/metrics_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -476,10 +476,8 @@ class AdapterFunctionMetricsPlugin(
"""Records adapter function invocation and phase-duration metrics.

Hooks into `adapter_function_invocation_complete` and
`adapter_function_phase_complete`. No production call site fires these
hooks yet — real `prepare`/`activate`/`generate`/`parse`/`deactivate`
wiring lands with the LocalFileBinding and EmbeddedBinding lifecycle work
(Epic #929 Phase 2 follow-ups).
`adapter_function_phase_complete`. `phase` is one of the values in
`AdapterFunctionPhaseCompletePayload`'s `phase` field.
"""

@hook("adapter_function_invocation_complete", mode=PluginMode.FIRE_AND_FORGET)
Expand Down
2 changes: 2 additions & 0 deletions test/backends/test_adapters/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Copyright IBM Corp. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0
90 changes: 90 additions & 0 deletions test/backends/test_adapters/_hook_capture.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Copyright IBM Corp. All Rights Reserved.
# SPDX-License-Identifier: Apache-2.0

"""Shared helper for asserting on the adapter-function hooks (Epic #929).

Single home for the hook-capture idiom, so the patching contract below is stated
once. Not a `conftest.py` fixture: callers need to wrap a *specific* block inside
a test (the integration tests capture only the `adapter_scope` section, not the
whole test), which a fixture cannot express.

Assertions here are on **hooks, not spans**. `adapter_scope` fires hooks and never
opens a span — #1464 documents that rule, #1466 adds the spans from a plugin.
"""

import contextlib
from collections.abc import Iterator
from unittest.mock import MagicMock, patch

_TARGET = "mellea.backends.adapters.adapter"


@contextlib.contextmanager
def capture_adapter_hooks() -> Iterator[MagicMock]:
"""Capture the hook payloads fired inside the block.

Patches three things, each for a distinct reason:

- **`has_plugins` pinned `True`.** It is already `True` under pytest —
`test/conftest.py`'s `auto_register_acceptance_sets` is `autouse`,
session-scoped, and registers a plugin for every `HookType`
(`test/plugins/_acceptance_sets.py`). Pinning it removes the dependency on
that ambient registration.
- **`invoke_hook` replaced with `new_callable=MagicMock`.** Load-bearing:
`invoke_hook` is an `async def`, so a bare `patch()` auto-creates an
`AsyncMock`. Calling an `AsyncMock` returns a coroutine, and if a
`side_effect` returns a coroutine of its own, *that* inner coroutine becomes
the outer one's result and is never awaited — surfacing as
`PytestUnraisableExceptionWarning: coroutine ... was never awaited`. Note
`-W error::RuntimeWarning` does **not** catch it; use
`-W error::pytest.PytestUnraisableExceptionWarning`. Forcing a sync
`MagicMock` means no coroutine exists to leak.
- **`_run_async_in_thread` patched out.** Real dispatch works fine; it is
simply not needed to read the payloads, and skipping it keeps these tests
off the shared event loop.

Yields:
The `invoke_hook` mock. Use `hook_payloads()` to read what it recorded.
"""
with (
patch(f"{_TARGET}.has_plugins", return_value=True),
patch(f"{_TARGET}.invoke_hook", new_callable=MagicMock) as mock_invoke,
patch(f"{_TARGET}._run_async_in_thread"),
):
yield mock_invoke


def hook_payloads(mock_invoke: MagicMock) -> list:
"""Returns the payload argument of every recorded `invoke_hook` call, in order.

Args:
mock_invoke: The mock yielded by `capture_adapter_hooks`.

Returns:
Each call's payload, ordered as fired.
"""
return [call.args[1] for call in mock_invoke.call_args_list]


def phase_payloads(mock_invoke: MagicMock) -> list:
"""Returns only the phase-complete payloads.

Args:
mock_invoke: The mock yielded by `capture_adapter_hooks`.

Returns:
The recorded `AdapterFunctionPhaseCompletePayload`s, ordered as fired.
"""
return [p for p in hook_payloads(mock_invoke) if hasattr(p, "phase")]


def invocation_payloads(mock_invoke: MagicMock) -> list:
"""Returns only the invocation-complete payloads.

Args:
mock_invoke: The mock yielded by `capture_adapter_hooks`.

Returns:
The recorded `AdapterFunctionInvocationCompletePayload`s, ordered as fired.
"""
return [p for p in hook_payloads(mock_invoke) if hasattr(p, "outcome")]
Loading
Loading