Skip to content

Commit 85abec1

Browse files
authored
Merge pull request #165 from alexzhang13/training_harness
Add prime-rl-based RLM training harness and update the local REPL implementation.
2 parents 8467a58 + 1bb4978 commit 85abec1

24 files changed

Lines changed: 8485 additions & 66 deletions

‎README.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ Recursive Language Models (RLMs) are a task-agnostic inference paradigm for lang
3232

3333
This repository provides an extensible inference engine for using RLMs around standard API-based and local LLMs. The initial experiments and idea were proposed in a [blogpost](https://alexzhang13.github.io/blog/2025/rlm/) in 2025, with expanded results in an [arXiv preprint](https://arxiv.org/abs/2512.24601).
3434

35+
We now also include a [verifiers](https://github.com/PrimeIntellect-ai/verifiers) training environment based on Prime Intellect's [prime-rl](https://github.com/PrimeIntellect-ai/prime-rl) in the `training/` folder. Train your own RLMs, which directly can be plugged into our inference engine!
36+
3537
> [!NOTE]
3638
> This repository contains inference code for RLMs with support for various sandbox environments. Open-source contributions are welcome. This repository is maintained by the authors of the paper from the MIT OASYS lab.
3739
@@ -123,6 +125,11 @@ export PRIME_API_KEY=...
123125
### Model Providers
124126
We currently support most major clients (OpenAI, Anthropic), as well as the router platforms (OpenRouter, Portkey). For local models, we recommend using vLLM (which interfaces with the [OpenAI client](https://github.com/alexzhang13/rlm/blob/main/rlm/clients/openai.py)). To view or add support for more clients, start by looking at [`rlm/clients/`](https://github.com/alexzhang13/rlm/tree/main/rlm/clients).
125127

128+
## Training
129+
We provide a simple RL training harness for training RLMs used in this repo (specifically the `local` REPL). The implementation uses no sandboxes for simplicity and slots easily your use case, but an ideal setup would use sandboxes for safety. Training logic is isolated to the [`training/`](https://github.com/alexzhang13/rlm/tree/main/training) folder, which exposes `rlm.RLM` as a [`verifiers`](https://github.com/willccbb/verifiers) `Environment` and plugs straight into [`prime-rl`](https://github.com/PrimeIntellect-ai/prime-rl). See the [training README](https://github.com/alexzhang13/rlm/tree/main/training#readme) for the launch command. The harness uses subprocess-isolated local REPL execution (no cloud sandboxes), matching the `local` environment above.
130+
131+
A worked example with an example `.toml` lives in [`training/environments/oolong/`](https://github.com/alexzhang13/rlm/tree/main/training/environments/oolong) (OOLONG long-context QA). New training environments can be added the same way — author a `verifiers` env that wraps your task (see the [verifiers docs](https://verifiers.readthedocs.io/)), then reference it from a config.
132+
126133
## Relevant Reading
127134
* **[Dec '25]** [Recursive Language Models arXiv](https://arxiv.org/abs/2512.24601)
128135
* **[Oct '25]** [Recursive Language Models Blogpost](https://alexzhang13.github.io/blog/2025/rlm/)

‎rlm/clients/base_lm.py‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,19 @@ class BaseLM(ABC):
1313
does so in a model-agnostic way, so this class provides a base interface for all language models.
1414
"""
1515

16-
def __init__(self, model_name: str, timeout: float = DEFAULT_TIMEOUT, **kwargs):
16+
def __init__(
17+
self,
18+
model_name: str,
19+
timeout: float = DEFAULT_TIMEOUT,
20+
sampling_args: dict[str, Any] | None = None,
21+
**kwargs,
22+
):
1723
self.model_name = model_name
1824
self.timeout = timeout
25+
# Sampling args forwarded to the underlying completion API
26+
# (e.g. temperature, top_p, max_tokens, seed). Forwarded by
27+
# subclasses as **self.sampling_args.
28+
self.sampling_args: dict[str, Any] = dict(sampling_args or {})
1929
self.kwargs = kwargs
2030

2131
@abstractmethod

‎rlm/clients/openai.py‎

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@
1818
DEFAULT_PRIME_INTELLECT_BASE_URL = "https://api.pinference.ai/api/v1/"
1919

2020

21+
def _normalize_sampling_args(sampling_args: dict[str, Any]) -> dict[str, Any]:
22+
"""Match the rename done by verifiers' OpenAIChatCompletionsClient so the
23+
same sampling_args dict produces byte-equivalent chat.completions.create
24+
calls in both harnesses. Pops ``extra_body`` so the caller can merge it
25+
with its own ``extra_body`` rather than passing it twice (TypeError).
26+
"""
27+
args = dict(sampling_args or {})
28+
if "max_tokens" in args:
29+
args["max_completion_tokens"] = args.pop("max_tokens")
30+
args.pop("extra_body", None)
31+
return {k: v for k, v in args.items() if v is not None}
32+
33+
34+
def _merge_extra_body(
35+
hardcoded: dict[str, Any], sampling_args: dict[str, Any] | None
36+
) -> dict[str, Any]:
37+
"""Merge an ``extra_body`` from sampling_args into the hardcoded extra_body."""
38+
merged = dict(hardcoded or {})
39+
user = (sampling_args or {}).get("extra_body")
40+
if user:
41+
merged.update(user)
42+
return merged
43+
44+
2145
class OpenAIClient(BaseLM):
2246
"""
2347
LM Client for running models with the OpenAI API. Works with vLLM as well.
@@ -32,9 +56,10 @@ def __init__(
3256
api_key: str | None = None,
3357
model_name: str | None = None,
3458
base_url: str | None = None,
59+
sampling_args: dict[str, Any] | None = None,
3560
**kwargs,
3661
):
37-
super().__init__(model_name=model_name, **kwargs)
62+
super().__init__(model_name=model_name, sampling_args=sampling_args, **kwargs)
3863

3964
if api_key is None:
4065
if base_url == "https://api.openai.com/v1" or base_url is None:
@@ -78,12 +103,16 @@ def completion(self, prompt: str | list[dict[str, Any]], model: str | None = Non
78103
if not model:
79104
raise ValueError("Model name is required for OpenAI client.")
80105

81-
extra_body = {}
106+
extra_body: dict[str, Any] = {}
82107
if self.client.base_url == DEFAULT_PRIME_INTELLECT_BASE_URL:
83108
extra_body["usage"] = {"include": True}
109+
extra_body = _merge_extra_body(extra_body, self.sampling_args)
84110

85111
response = self.client.chat.completions.create(
86-
model=model, messages=messages, extra_body=extra_body
112+
model=model,
113+
messages=messages,
114+
extra_body=extra_body,
115+
**_normalize_sampling_args(self.sampling_args),
87116
)
88117
self._track_cost(response, model)
89118
return response.choices[0].message.content
@@ -102,12 +131,16 @@ async def acompletion(
102131
if not model:
103132
raise ValueError("Model name is required for OpenAI client.")
104133

105-
extra_body = {}
134+
extra_body: dict[str, Any] = {}
106135
if self.client.base_url == DEFAULT_PRIME_INTELLECT_BASE_URL:
107136
extra_body["usage"] = {"include": True}
137+
extra_body = _merge_extra_body(extra_body, self.sampling_args)
108138

109139
response = await self.async_client.chat.completions.create(
110-
model=model, messages=messages, extra_body=extra_body
140+
model=model,
141+
messages=messages,
142+
extra_body=extra_body,
143+
**_normalize_sampling_args(self.sampling_args),
111144
)
112145
self._track_cost(response, model)
113146
return response.choices[0].message.content

‎rlm/core/rlm.py‎

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ def __init__(
7474
on_subcall_complete: Callable[[int, str, float, str | None], None] | None = None,
7575
on_iteration_start: Callable[[int, int], None] | None = None,
7676
on_iteration_complete: Callable[[int, int, float], None] | None = None,
77+
sampling_args: dict[str, Any] | None = None,
78+
sub_sampling_args: dict[str, Any] | None = None,
79+
orchestrator: bool = True,
80+
user_prologue: str | None = None,
7781
):
7882
"""
7983
Args:
@@ -109,6 +113,30 @@ def __init__(
109113
on_iteration_start: Callback fired when an iteration starts. Args: (depth, iteration_num).
110114
on_iteration_complete: Callback fired when an iteration completes. Args: (depth, iteration_num, duration).
111115
"""
116+
# Sampling args plumbed into backend_kwargs / other_backend_kwargs
117+
# before the clients are constructed, so they reach the chat-completions
118+
# call (e.g. temperature, top_p, max_tokens, seed). ``sampling_args``
119+
# applies to the root model (depth=0); ``sub_sampling_args`` to
120+
# depth=1 sub-LLM calls. If ``sub_sampling_args`` is set without an
121+
# ``other_backends``, we mirror the root backend so depth=1 routes
122+
# through a separate client with its own sampling args.
123+
if sampling_args is not None:
124+
backend_kwargs = dict(backend_kwargs or {})
125+
existing = dict(backend_kwargs.get("sampling_args") or {})
126+
existing.update(sampling_args)
127+
backend_kwargs["sampling_args"] = existing
128+
if sub_sampling_args is not None:
129+
if other_backends is None:
130+
other_backends = [backend]
131+
other_backend_kwargs = [dict(backend_kwargs or {})]
132+
else:
133+
other_backend_kwargs = [dict(kw or {}) for kw in (other_backend_kwargs or [{}])]
134+
first = dict(other_backend_kwargs[0])
135+
existing = dict(first.get("sampling_args") or {})
136+
existing.update(sub_sampling_args)
137+
first["sampling_args"] = existing
138+
other_backend_kwargs[0] = first
139+
112140
# Store config for spawning per-completion
113141
self.backend = backend
114142
self.backend_kwargs = backend_kwargs
@@ -144,6 +172,12 @@ def __init__(
144172
self.max_tokens = max_tokens
145173
self.max_errors = max_errors
146174
self.system_prompt = custom_system_prompt if custom_system_prompt else RLM_SYSTEM_PROMPT
175+
self.orchestrator = orchestrator
176+
# Optional user-prologue message inserted between the metadata user
177+
# message and the iter-0 turn prompt. Mirrors RLMTrainEnv's
178+
# ``user_prologue`` so canonical inference can match envs that
179+
# depend on a task-specific tips message (e.g. BC+).
180+
self.user_prologue = user_prologue
147181
self.logger = logger
148182
self.verbose = VerbosePrinter(enabled=verbose)
149183

@@ -261,7 +295,11 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]):
261295
if not self.persistent and hasattr(environment, "cleanup"):
262296
environment.cleanup()
263297

264-
def _setup_prompt(self, prompt: str | dict[str, Any]) -> list[dict[str, Any]]:
298+
def _setup_prompt(
299+
self,
300+
prompt: str | dict[str, Any],
301+
root_prompt: str | None = None,
302+
) -> list[dict[str, Any]]:
265303
"""
266304
Setup the system prompt for the RLM. Also include metadata about the prompt and build
267305
up the initial message history.
@@ -271,7 +309,11 @@ def _setup_prompt(self, prompt: str | dict[str, Any]) -> list[dict[str, Any]]:
271309
system_prompt=self.system_prompt,
272310
query_metadata=metadata,
273311
custom_tools=self.custom_tools,
312+
root_prompt=root_prompt,
313+
orchestrator=self.orchestrator,
274314
)
315+
if self.user_prologue:
316+
message_history.append({"role": "user", "content": self.user_prologue})
275317
if self.compaction:
276318
message_history[0]["content"] += (
277319
"\n\nThe full conversation history (trajectory segments and any summaries) "
@@ -310,7 +352,7 @@ def completion(
310352
self.logger.clear_iterations()
311353

312354
with self._spawn_completion_context(prompt) as (lm_handler, environment):
313-
message_history = self._setup_prompt(prompt)
355+
message_history = self._setup_prompt(prompt, root_prompt=root_prompt)
314356

315357
compaction_count = 0
316358
try:
@@ -333,7 +375,6 @@ def completion(
333375
lm_handler, environment, message_history, compaction_count
334376
)
335377

336-
# Current prompt = message history + additional prompt suffix
337378
context_count = (
338379
environment.get_context_count()
339380
if isinstance(environment, SupportsPersistence)
@@ -344,12 +385,22 @@ def completion(
344385
if isinstance(environment, SupportsPersistence)
345386
else 0
346387
)
347-
current_prompt = message_history + [
348-
build_user_prompt(root_prompt, i, context_count, history_count)
349-
]
388+
# Fully prefixed trajectory: persist the per-turn user prompt
389+
# into message_history so the model sees a single continuous
390+
# [system, metadata, user_0, assistant_0, repl_0, user_1, ...]
391+
# chain across turns.
392+
message_history.append(
393+
build_user_prompt(
394+
root_prompt,
395+
i,
396+
context_count,
397+
history_count,
398+
max_iterations=self.max_iterations,
399+
)
400+
)
350401

351402
iteration: RLMIteration = self._completion_turn(
352-
prompt=current_prompt,
403+
prompt=message_history,
353404
lm_handler=lm_handler,
354405
environment=environment,
355406
)

‎rlm/utils/parsing.py‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,30 +30,42 @@ def format_iteration(
3030
the prompt of the LM in the next iteration. We also truncate code execution results
3131
that exceed the max_character_length.
3232
33+
Each iteration produces exactly two messages in history: one assistant
34+
turn containing the model's response (with any ```repl``` blocks
35+
embedded), followed by a single user message that concatenates the
36+
outputs of all executed code blocks in that turn. This keeps the
37+
per-turn shape assistant-then-user even when the model emits several
38+
blocks in one response, and avoids redundantly echoing the code
39+
(which is already in the assistant message) back in the user reply.
40+
Each block's output is still individually truncated at
41+
``max_character_length``.
42+
3343
Args:
3444
iteration: The iteration to format
35-
max_character_length: The maximum character length of the result
45+
max_character_length: Per-block cap on the formatted execution
46+
result. Longer outputs are tail-trimmed.
3647
3748
Returns:
38-
A list of messages to add to the next prompt
49+
A list of messages to add to the next prompt — always length 1
50+
(just the assistant) when no code was run, or length 2 (assistant
51+
+ one combined user reply) otherwise.
3952
"""
4053
messages = [{"role": "assistant", "content": iteration.response}]
4154

42-
for code_block in iteration.code_blocks:
43-
code = code_block.code
44-
result = code_block.result
45-
result = format_execution_result(result)
55+
parts = []
56+
multi = len(iteration.code_blocks) > 1
57+
for i, code_block in enumerate(iteration.code_blocks):
58+
result = format_execution_result(code_block.result)
4659
if len(result) > max_character_length:
4760
result = (
4861
result[:max_character_length]
4962
+ f"... + [{len(result) - max_character_length} chars...]"
5063
)
64+
header = f"REPL output (block {i + 1}):" if multi else "REPL output:"
65+
parts.append(f"{header}\n{result}")
5166

52-
execution_message = {
53-
"role": "user",
54-
"content": f"Code executed:\n```python\n{code}\n```\n\nREPL output:\n{result}",
55-
}
56-
messages.append(execution_message)
67+
if parts:
68+
messages.append({"role": "user", "content": "\n\n".join(parts)})
5769
return messages
5870

5971

0 commit comments

Comments
 (0)