# Framework Adapters — Plug In Your Favourite AI Stack

> How rlm_code/rlm/frameworks/ lets DSPy, Google ADK, Pydantic-AI, and DeepAgents all plug into the same RLM loop through a shared base class and a framework registry, without changing the core runner.

- Repository: SuperagenticAI/rlm-code
- GitHub: https://github.com/SuperagenticAI/rlm-code
- Human wiki: https://grok-wiki.com/public/wiki/superagenticai-rlm-code-8e144acefc91
- Complete Markdown: https://grok-wiki.com/public/wiki/superagenticai-rlm-code-8e144acefc91/llms-full.txt

## Source Files

- `rlm_code/rlm/frameworks/base.py`
- `rlm_code/rlm/frameworks/registry.py`
- `rlm_code/rlm/frameworks/dspy_rlm_adapter.py`
- `rlm_code/rlm/frameworks/google_adk_adapter.py`
- `rlm_code/rlm/frameworks/pydantic_ai_adapter.py`
- `rlm_code/rlm/frameworks/deepagents_adapter.py`
- `rlm_code/models/providers/registry.py`

---

<details>
<summary>Relevant source files</summary>
The following files were used as context for generating this wiki page:

- [rlm_code/rlm/frameworks/base.py](rlm_code/rlm/frameworks/base.py)
- [rlm_code/rlm/frameworks/registry.py](rlm_code/rlm/frameworks/registry.py)
- [rlm_code/rlm/frameworks/dspy_rlm_adapter.py](rlm_code/rlm/frameworks/dspy_rlm_adapter.py)
- [rlm_code/rlm/frameworks/google_adk_adapter.py](rlm_code/rlm/frameworks/google_adk_adapter.py)
- [rlm_code/rlm/frameworks/pydantic_ai_adapter.py](rlm_code/rlm/frameworks/pydantic_ai_adapter.py)
- [rlm_code/rlm/frameworks/deepagents_adapter.py](rlm_code/rlm/frameworks/deepagents_adapter.py)
- [rlm_code/rlm/frameworks/adk_rlm_adapter.py](rlm_code/rlm/frameworks/adk_rlm_adapter.py)
- [rlm_code/rlm/runner.py](rlm_code/rlm/runner.py)
</details>

# Framework Adapters — Plug In Your Favourite AI Stack

The `rlm_code/rlm/frameworks/` package is a thin plug-in layer that lets the RLM runner drive tasks through DSPy, Google ADK, Pydantic-AI, or DeepAgents — all through a single, uniform interface — without touching the core run loop. Think of it like a universal power adapter: no matter which wall socket your AI framework prefers, the RLM runner only needs to push current through the same two pins (`doctor()` and `run_episode()`).

This page explains the shared contract, the five built-in adapters, how the registry wires them in at runtime, and what happens inside the runner when you choose a framework.

---

## The Shared Contract: `RLMFrameworkAdapter`

Every adapter implements a Python `Protocol` defined in `base.py`. A `Protocol` in Python is like an interface: any class that has the right attributes and methods satisfies it automatically, without inheritance.

```python
# rlm_code/rlm/frameworks/base.py:33-55
class RLMFrameworkAdapter(Protocol):
    framework_id: str

    def doctor(self) -> tuple[bool, str]: ...

    def run_episode(
        self,
        *,
        task: str,
        llm_connector: Any,
        max_steps: int,
        exec_timeout: int,
        workdir: str,
        sub_model: str | None = None,
        sub_provider: str | None = None,
        context: dict[str, Any] | None = None,
    ) -> FrameworkEpisodeResult: ...
```

**`framework_id`** — a unique slug like `"dspy-rlm"` or `"pydantic-ai"`. This is the key the registry and the CLI both use to look up an adapter.

**`doctor()`** — a pre-flight check. Returns `(True, "ok message")` when the adapter's optional dependency is installed and ready, or `(False, "install hint")` when it is not. This lets the runner surface actionable error messages before attempting any work.

**`run_episode()`** — the main hook. Executes one complete task run inside the target framework and returns a `FrameworkEpisodeResult`.

Sources: [rlm_code/rlm/frameworks/base.py:33-55]()

### Data Shapes Crossing the Boundary

Two dataclasses carry all data between an adapter and the runner:

```python
# rlm_code/rlm/frameworks/base.py:11-29
@dataclass(slots=True)
class FrameworkStepRecord:
    action: str
    observation: dict[str, Any] = field(default_factory=dict)
    reward: float = 0.0
    done: bool = False

@dataclass(slots=True)
class FrameworkEpisodeResult:
    completed: bool
    final_response: str
    steps: list[FrameworkStepRecord] = field(default_factory=list)
    total_reward: float = 0.0
    usage_summary: dict[str, int] | None = None
    metadata: dict[str, Any] = field(default_factory=dict)
```

`FrameworkStepRecord` is RLM's unit of trajectory data: one action, its outcome (observation), and a scalar reward signal. `FrameworkEpisodeResult` wraps the whole run: the final text answer, the full list of steps, a clipped total reward, optional token-usage totals, and a metadata dict the adapter can fill with whatever the framework exposes.

Sources: [rlm_code/rlm/frameworks/base.py:11-29]()

---

## The Registry: One Call to Bind Them All

`FrameworkAdapterRegistry` is a plain dictionary wrapper. Adapters are registered by their `framework_id` (lowercased, stripped). The `default()` classmethod constructs and registers all five built-in adapters in a single call:

```python
# rlm_code/rlm/frameworks/registry.py:32-46
@classmethod
def default(cls, *, workdir: str) -> "FrameworkAdapterRegistry":
    registry = cls()
    from .adk_rlm_adapter import ADKRLMFrameworkAdapter
    from .deepagents_adapter import DeepAgentsFrameworkAdapter
    from .dspy_rlm_adapter import DSPyRLMFrameworkAdapter
    from .google_adk_adapter import GoogleADKFrameworkAdapter
    from .pydantic_ai_adapter import PydanticAIFrameworkAdapter

    registry.register(DSPyRLMFrameworkAdapter(workdir=workdir))
    registry.register(ADKRLMFrameworkAdapter(workdir=workdir))
    registry.register(PydanticAIFrameworkAdapter(workdir=workdir))
    registry.register(GoogleADKFrameworkAdapter(workdir=workdir))
    registry.register(DeepAgentsFrameworkAdapter(workdir=workdir))
    return registry
```

The registry exposes three methods:

| Method | Purpose |
|--------|---------|
| `register(adapter)` | Add an adapter; raises `ValueError` if `framework_id` is empty |
| `get(framework_id)` | Look up by slug; returns `None` for unknown ids |
| `list_ids()` | Sorted list of all registered slugs |
| `doctor()` | Run `doctor()` on every adapter and collect results |

The registry is constructed once when the `RLMRunner` initialises, via `self.framework_registry = FrameworkAdapterRegistry.default(workdir=...)` (runner.py:210). From that point on, the runner knows nothing about individual frameworks — it only talks to the registry.

Sources: [rlm_code/rlm/frameworks/registry.py:12-61]()

---

## Class Structure

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│  RLMFrameworkAdapter  (Protocol — base.py)                                      │
│  ─────────────────────────────────────────                                      │
│  framework_id: str                                                              │
│  doctor() -> (bool, str)                                                        │
│  run_episode(...) -> FrameworkEpisodeResult                                     │
└─────────────────────────────┬──────────────────────────────────────────────────┘
                              │ satisfied by
        ┌─────────────────────┼──────────────────────────┐
        │                     │                          │
DSPyRLMFrameworkAdapter  GoogleADKFrameworkAdapter  PydanticAIFrameworkAdapter
  framework_id="dspy-rlm"  framework_id="google-adk"  framework_id="pydantic-ai"
  adapter_mode="native_rlm" adapter_mode="agent_loop"  adapter_mode="agent_loop"

ADKRLMFrameworkAdapter    DeepAgentsFrameworkAdapter
  framework_id="adk-rlm"   framework_id="deepagents"
  adapter_mode="native_rlm" adapter_mode="agent_loop"
```

The `adapter_mode` field (not part of the Protocol, but present on every adapter) lets the runner's `doctor()` report distinguish between frameworks that use their own native RLM loop (`"native_rlm"`) from those that run a generic agent loop (`"agent_loop"`). Sources: [rlm_code/rlm/frameworks/registry.py:55-61]()

---

## Built-in Adapters

### DSPy RLM (`dspy-rlm`)

**Install:** `pip install dspy`

The DSPy adapter is in `native_rlm` mode: it delegates directly to `dspy.RLM`, DSPy's own reinforcement-learning module. The adapter checks for `hasattr(dspy, "RLM")` in `doctor()` because older DSPy versions do not expose this attribute.

```python
# rlm_code/rlm/frameworks/dspy_rlm_adapter.py:78-88
rlm = dspy.RLM(
    "context, query -> answer",
    max_iterations=max(2, int(max_steps)),
    sub_lm=lm,
)
with dspy.context(lm=lm):
    prediction = rlm(context=context_payload, query=task)
```

Model resolution maps RLM provider strings to DSPy's `provider/model` format. For example, `provider="google"` becomes `"gemini/model-name"`, and `provider="openai-compatible"` is normalised to `"openai/model-name"`. If the global `dspy.settings.lm` is already configured, the adapter reuses it rather than constructing a new `dspy.LM`.

Sources: [rlm_code/rlm/frameworks/dspy_rlm_adapter.py:22-190]()

---

### Google ADK (`google-adk`)

**Install:** `pip install 'rlm-code[adk]'`

The Google ADK adapter wraps the `google.adk` package's `LlmAgent` and `InMemoryRunner`. Because ADK's runner is fully async, the adapter uses a helper `_run_coro_sync()` that spins up a new thread with its own event loop when an existing loop is already running (avoiding the "cannot run nested event loop" problem):

```python
# rlm_code/rlm/frameworks/google_adk_adapter.py:193-215
def _run_coro_sync(coro: Any) -> Any:
    try:
        asyncio.get_running_loop()
    except RuntimeError:
        return asyncio.run(coro)
    # Already in a running loop — run in a daemon thread
    import threading
    thread = threading.Thread(target=_runner, daemon=True)
    thread.start()
    thread.join()
```

Events are streamed with `runner.run_async(...)` and serialised into `FrameworkStepRecord` objects with actions `"model_text"`, `"tool_call"`, or `"tool_result"`. For Gemini models, the adapter strips the `provider:` prefix from the model name because Google ADK expects a bare model name like `gemini-2.0-flash`.

Sources: [rlm_code/rlm/frameworks/google_adk_adapter.py:1-215]()

---

### ADK RLM (`adk-rlm`)

**Install:** `pip install 'rlm-code[adk]'`

A second ADK-flavoured adapter in `native_rlm` mode. While `google-adk` uses the public `LlmAgent`/`InMemoryRunner` API, `adk-rlm` calls a vendored `adk_rlm.completion()` function — a sample RLM implementation that adds depth-limited recursive search on top of ADK. It exposes `max_iterations` and `max_depth` directly.

```python
# rlm_code/rlm/frameworks/adk_rlm_adapter.py:94-102
completion_result = completion(
    context=context_payload,
    prompt=task,
    model=resolved_model,
    sub_model=sub_model or resolved_model,
    max_iterations=max(2, int(max_steps)),
    max_depth=max(1, min(8, int(max_steps))),
    verbose=False,
)
```

Sources: [rlm_code/rlm/frameworks/adk_rlm_adapter.py:60-174]()

---

### Pydantic AI (`pydantic-ai`)

**Install:** `pip install 'rlm-code[pydantic]'`

Uses `pydantic_ai.Agent` in synchronous mode (`agent.run_sync(task)`). The adapter converts every message part from Pydantic-AI's message history into a `FrameworkStepRecord`, assigning reward values by part type:

| Part type | Action label | Reward |
|-----------|-------------|--------|
| `ToolCallPart` | `tool_call` | +0.02 |
| `ToolReturnPart` | `tool_result` | +0.06 |
| `RetryPromptPart` | `retry_prompt` | −0.05 |
| Any text part | `model_part` | +0.05 |

Model resolution maps several local-inference providers (LM Studio, vLLM, SGLang, TGI) to `openai:model-name` and sets `OPENAI_BASE_URL` from the connector's `base_url` field, keeping local inference working without code changes. Ollama is mapped to `ollama:model-name`.

Sources: [rlm_code/rlm/frameworks/pydantic_ai_adapter.py:109-143](), [rlm_code/rlm/frameworks/pydantic_ai_adapter.py:144-190]()

---

### DeepAgents / LangGraph (`deepagents`)

**Install:** `pip install 'rlm-code[deepagents]'`

Wraps `deepagents.create_deep_agent()` which is itself built on LangGraph. The adapter converts LangChain's message types (`AIMessage`, `ToolMessage`, `HumanMessage`) into step records:

| LangChain message type | Produces |
|------------------------|---------|
| `AIMessage` with tool calls | `tool_call` step (+0.02, or +0.03 for `write_todos`/`read_todos`) |
| `AIMessage` with text content | `model_text` step (+0.05) |
| `ToolMessage` (success) | `tool_result` step (+0.06) |
| `ToolMessage` (error) | `tool_result` step (−0.05) |

DeepAgents additionally supports multiple execution backends, selectable via the `deepagents_backend` key in the `context` dict:

```python
# rlm_code/rlm/frameworks/deepagents_adapter.py:151-167
if backend_name == "filesystem":
    return FilesystemBackend(root=workdir)
if backend_name == "local_shell":
    return LocalShellBackend(cwd=workdir)
return StateBackend
```

Sources: [rlm_code/rlm/frameworks/deepagents_adapter.py:169-245](), [rlm_code/rlm/frameworks/deepagents_adapter.py:151-167]()

---

## How the Runner Uses Adapters

The sequence below shows the full call chain from a user task to a logged episode:

```
sequenceDiagram
    participant User
    participant Runner (runner.py)
    participant Registry
    participant Adapter
    participant Framework

    User->>Runner: run_task(task, framework="dspy-rlm")
    Runner->>Runner: _resolve_framework_id("dspy-rlm")
    Runner->>Registry: get("dspy-rlm")
    Registry-->>Runner: DSPyRLMFrameworkAdapter
    Runner->>Adapter: doctor()
    Adapter-->>Runner: (True, "dspy RLM available")
    Runner->>Adapter: run_episode(task, llm_connector, ...)
    Adapter->>Framework: dspy.RLM(...)(context, query)
    Framework-->>Adapter: prediction
    Adapter-->>Runner: FrameworkEpisodeResult(steps=[...], ...)
    Runner->>Runner: write step events to .jsonl
    Runner->>Runner: emit run_end event
    Runner-->>User: RLMRunResult
```

The runner's `_run_task_with_framework_adapter()` method (runner.py:1240-1388) handles the adapter path:

1. Looks up the adapter from the registry by `framework_id`.
2. Calls `adapter.doctor()` — fails fast with the adapter's human-readable install hint if the dependency is missing.
3. Calls `adapter.run_episode(...)`, passing the shared `llm_connector`, `workdir`, and RLM run parameters.
4. Iterates `episode.steps`, applies the global reward scale, and writes each step as a JSON event to a `.jsonl` file.
5. Writes a `"final"` event capturing `completed`, `total_reward`, `final_response`, token usage, and the adapter's `metadata` dict.

Sources: [rlm_code/rlm/runner.py:1240-1388]()

---

## Adding a Custom Adapter

To plug in a new framework, implement the `RLMFrameworkAdapter` Protocol and register it before the runner starts:

```python
from rlm_code.rlm.frameworks.base import FrameworkEpisodeResult, FrameworkStepRecord
from dataclasses import dataclass
from typing import Any

@dataclass(slots=True)
class MyFrameworkAdapter:
    workdir: str
    framework_id: str = "my-framework"
    adapter_mode: str = "agent_loop"
    reference_impl: str = "my_framework (installed package)"

    def doctor(self) -> tuple[bool, str]:
        try:
            import my_framework  # noqa: F401
            return (True, "my-framework available")
        except ImportError:
            return (False, "pip install my-framework")

    def run_episode(self, *, task, llm_connector, max_steps, exec_timeout,
                    workdir, sub_model=None, sub_provider=None, context=None):
        import my_framework
        result = my_framework.run(task)
        return FrameworkEpisodeResult(
            completed=True,
            final_response=result.text,
            steps=[FrameworkStepRecord(action="answer", observation={"text": result.text}, reward=0.5)],
            total_reward=0.5,
        )

# Register it alongside the built-ins
runner.framework_registry.register(MyFrameworkAdapter(workdir=runner.workdir))
```

Because `FrameworkAdapterRegistry.register()` only checks `framework_id` (registry.py:18-21), no changes to the core runner are needed.

Sources: [rlm_code/rlm/frameworks/registry.py:18-21]()

---

## Health Check: `rlm frameworks doctor`

The registry's `doctor()` method runs every adapter's `doctor()` in one pass and returns a list of rows, including the `adapter_mode` and `reference_impl` fields the runner surfaces to operators:

```python
# rlm_code/rlm/frameworks/registry.py:48-61
def doctor(self) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for framework_id, adapter in sorted(self._adapters.items()):
        ok, detail = adapter.doctor()
        rows.append({
            "framework": framework_id,
            "ok": bool(ok),
            "detail": str(detail),
            "mode": str(getattr(adapter, "adapter_mode", "adapter")),
            "reference": str(getattr(adapter, "reference_impl", "")),
        })
    return rows
```

A typical healthy output might look like:

| framework | ok | mode | reference |
|-----------|-----|------|-----------|
| adk-rlm | ✓ | native_rlm | adk_rlm/main.py (vendored sample package) |
| deepagents | ✓ | agent_loop | deepagents (installed package) |
| dspy-rlm | ✓ | native_rlm | dspy.RLM (installed package) |
| google-adk | ✗ | agent_loop | google.adk (installed package) |
| pydantic-ai | ✓ | agent_loop | pydantic_ai.Agent (installed package) |

Sources: [rlm_code/rlm/frameworks/registry.py:48-61]()

---

## Summary

The `rlm_code/rlm/frameworks/` package achieves clean framework extensibility through three cooperating pieces: the `RLMFrameworkAdapter` Protocol that defines a two-method contract, the `FrameworkAdapterRegistry` that maps string slugs to adapter instances, and the shared `FrameworkEpisodeResult` / `FrameworkStepRecord` dataclasses that carry every framework's output back into the same RLM trajectory machinery. The five built-in adapters (`dspy-rlm`, `adk-rlm`, `google-adk`, `pydantic-ai`, `deepagents`) each live in their own file, import their optional dependency lazily, and express readiness through `doctor()` — so a missing package surfaces a clear install hint rather than a cryptic import error. The runner itself remains unchanged regardless of which adapter is selected; it only calls `registry.get(framework_id)` followed by `adapter.run_episode(...)`, as shown in [rlm_code/rlm/runner.py:1240-1293]().
