Skip to content

fix(rlm): preserve input mutations across iterations - #67

Draft
isaacbmiller wants to merge 1 commit into
review-base/stanford-main-10014from
isaac/rlm-persistent-input-state
Draft

fix(rlm): preserve input mutations across iterations#67
isaacbmiller wants to merge 1 commit into
review-base/stanford-main-10014from
isaac/rlm-persistent-input-state

Conversation

@isaacbmiller

Copy link
Copy Markdown

1. Issue / repro

RLM tells the model that interpreter state persists between iterations, but ordinary signature inputs are re-injected before every generated snippet.

# input
numbers = [1, 2, 3]

# iteration 1
numbers.append(4)

# iteration 2
SUBMIT(sum(numbers))

On main, this returns 6, not 10. Before iteration 2 runs, RLM silently restores numbers to [1, 2, 3].

2. Why this is the root cause

Every generated snippet currently crosses this boundary:

repl.execute(code, variables=dict(input_args))

variables= means “inject these values into the interpreter namespace before this execution.” Repeating it overwrites mutations from earlier iterations. That conflicts with both contracts already visible to implementers and models:

  • CodeInterpreter documents that state persists across execute() calls.
  • The RLM prompt says: “State persists between iterations.”

SandboxSerializable inputs already follow the correct lifecycle: setup and assignment happen before the loop. Only ordinary inputs were coupled to per-iteration code execution.

3. How we know the fix addresses the root cause

The regression test uses the real Deno/Pyodide interpreter, not a mock:

  1. Start with numbers=[1, 2, 3].
  2. Append 4 in one model iteration.
  3. Sum numbers in the next iteration.

The exact test fails on untouched main at 24ec85de4:

E       assert 6 == 10

It passes here with 10. Unit tests also assert that ordinary inputs are injected exactly once.

4. Why this is the concise fix

This moves input injection to the existing session-initialization boundary and removes input_args from the per-iteration call chain:

self._initialize_inputs(input_args, repl)

# Each model iteration now uses the namespace already owned by the session.
repl.execute(code)

The patch does not add state flags, first-iteration branches, retry paths, or a second namespace abstraction. It uses the persistence guarantee the interpreter already provides.

The existing SandboxSerializable transport is intentionally unchanged: each serializable still runs the same setup/assignment call with the same payload variables. Ordinary inputs get one additional no-op initialization call:

repl.execute("pass", variables=regular_args)

5. Context needed to validate the change

There are two distinct objects named “variables” in this module:

  • REPLVariable metadata is formatted into the model prompt.
  • execute(..., variables=...) mutates the live interpreter namespace.

This PR only changes the second. Prompt metadata, tools, output coercion, extraction after max_iters, and LM behavior are untouched.

6. What the fix does in the code

  • Renames the private serializable-preparation helper to _initialize_inputs because it now initializes both input forms.
  • Preserves the existing SandboxSerializable setup path.
  • Injects ordinary inputs once, before the iteration loop.
  • Stops threading input_args through sync and async iteration helpers.
  • Executes generated snippets against the persistent session namespace.

Compatibility boundaries and downsides

  • Intentional behavior change: code that mutates an input now observes that mutation in later iterations. A caller relying on inputs being reset each turn will see different results, but reset-on-every-turn contradicts the documented interpreter and RLM contracts.
  • Custom interpreters: an RLM run with ordinary inputs now makes one explicit execute("pass", variables=...) call before model-generated code. CodeInterpreter.execute already requires support for code plus variable injection and persistent state. Test doubles that script one response per call must include this initialization call.
  • Initialization failures: if a custom interpreter cannot inject host inputs, that error now happens before the first model iteration instead of being repeated on every iteration. Generated model code cannot repair failed host-side initialization.
  • No public API change: RLM constructor/forward signatures and the CodeInterpreter protocol are unchanged.
  • No provider-specific path: this uses only the interpreter protocol; it does not depend on an LM provider or adapter.
  • No serializable transport change: byte encoding, large-payload handling, setup code, and assignment behavior stay as they were.

Validation

  • uv run --frozen pytest -q tests/predict/test_rlm.py --deno110 passed, 2 skipped
  • uv run --frozen pytest -q tests/primitives/test_python_interpreter.py tests/primitives/test_sandbox_serializable.py --deno66 passed
  • repository pre-commit hooks on both changed files — passed
  • new regression against untouched main — fails with 6; passes here with 10

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant