Skip to content

feat(rlm): add support for structured history in RLM - #10387

Open
mannuch wants to merge 44 commits into
stanfordnlp:mainfrom
mannuch:rlm-add-history
Open

mannuch wants to merge 44 commits into
stanfordnlp:mainfrom
mannuch:rlm-add-history

Conversation

@mannuch

@mannuch mannuch commented Sep 12, 2026 •

Copy link
Copy Markdown

This adds support for structured dspy.History inside the RLM module.

Currently, RLM only supports a REPLHistory that records the reasoning, code, and REPL output for each iteration. At the end of each call, the REPL history can be inspected by accessing the history field on the resulting Prediction. While this works well for debugging purposes, it doesn't support recording structured inputs and/or outputs and there is no way to pass history from prior calls into subsequent calls. This makes the RLM module incapable of continuing previous interactions.

Structured history support

RLM can adopt a similar approach to history as the ReActV2 module. As a refresher, the ReActV2 module contains a dspy.History object whose messages list contains one structured event per loop turn. The event contains the original signature's input fields (only at the start of a new turn), next_thought reasoning content, a tool_calls field of type dspy.ToolCalls containing structured tool calls and their results, and the original signature's output fields when the turn successfully calls the submit tool.

We take a very similar approach here for RLM. Specifically, each loop turn is structured the following way:

# query -> answer: int
{
    "query": "Double twenty",  # input fields, included only at the start of a new turn
    "variables_info": ..., # The variables that are available in the REPL
    "iteration": "1/5",
    "repl_entry": dspy.REPLEntry(
        reasoning: "First explore the data", 
        code: "x = 20\nprint(f'x = {x}')",  # code to execute
        output: "x = 20"  # the executed code's REPL output
    ), 
}
...
{
    "variables_info": ...,
    "iteration": "3/5",
    "repl_entry": dspy.REPLEntry(
        reasoning: "Now compute and return", 
        code: "y = x * 2\nSUBMIT(y)",
        output: "FINAL:  {'answer': 40}" 
    ),
    "answer": "40"  # output fields, included when the `SUBMIT` tool is successfully called
}

As seen above, each event contains a repl_entry field of type REPLEntry with reasoning, the code output by the LM to be executed, and the REPL output of the code. Each event also carries variables_info outlining the variables available in the REPL (the RLM signature's inputs are represented here) as well as the current iteration of the turn. The start of each new turn contains the signature's input fields (query), while the final message in a turn contains the signature's output fields (answer). Intermediate turns will only contain the repl_entry, variables_info, and iteration fields.

The extract fallback case

When the RLM has reached max_iters iterations, but hasn't yet SUBMITted a final answer successfully, the module falls back to extracting the answer from the REPL trajectory so far. In this case, the history would record an event that contains only the final outputs and repl_entry set to ExtractFallbackMarker:

# query -> answer: int, max_iters = 2
{
    "query": "Double ten, then add five",
    "variables_info": ...,
    "iteration": "1/2",
    "repl_entry": dspy.REPLEntry(
        reasoning: "First explore the data", 
        code: "x = 10\nprint(f'x = {x}')", 
        output: "x = 10"
    ), 
}
{
    "variables_info": ...,
    "iteration": "2/2", # <-- max iterations reached!
    "repl_entry": dspy.REPLEntry(
        reasoning: "Compute double 10", 
        code: "y = x * 2\nprint(f'y = {y}')", 
        output: "y = 20"
    ), 
}
{
    "repl_entry": ExtractFallbackMarker(), # Marker type that tells us this history event was from the extract fallback
    "answer": "25"  # output fields extracted
}

Note: repl_entry is always set to either REPLEntry or ExtractFallbackMarker. This key is how readers of the history messages know it's an RLM history message.

Formatting the conversation history for the LM

An event's REPLEntry is formatted into conversation history as follows:

  • The reasoning and code are formatted into a "role": "assistant" message's content
  • The output is formatted into a "role": "user" message's content

With the ChatAdapter, history is formatted like:

{
    "role": "user",
    "content": "[[ ## variables_info ## ]]\nVariable: `query` (access it in your code)\nType: str\nTotal length:  10 characters\nPreview:\n```\nDouble ten\n```\n\n[[ ## iteration ## ]]\n1/5\n\n[[ ## completed ## ]]\n"
}
{
    "role": "assistant",
    "content": "[[ ## reasoning ## ]]\nFirst explore the data\n\n[[ ## code ## ]]\nx = 10\nprint(f'x = {x}')\n\n[[ ## completed ## ]]\n"
}
{
    "role": "user",
    "content": "[[ ## repl_output ## ]]\nx = 10"
}
{
    "role": "assistant",
    "content": "[[ ## reasoning ## ]]\nNow compute and return\n\n[[ ## code ## ]]\ny = x * 2\nSUBMIT(y)\n\n[[ ## completed ## ]]\n"
}
{
    "role": "user",
    "content": "[[ ## repl_output ## ]]\nFINAL:  {'answer': 40}"
}

A replayed history where an extract fallback occurred would be like:

...
{
    "role": "user",
    "content": "[[ ## repl_output ## ]]\ny=20"  # Last REPL output before max iterations reached
}
{
    "role": "assistant",
    "content": "[[ ## answer ## ]]\n5" # Extracted final answer
}

This happens inside Adapter.format_conversation_history — it looks for history messages containing a repl_entry key containing REPLEntry | ExtractFallbackMarker and handles them accordingly.

Output Truncation

When formatting the REPLEntry's output, we truncate it according to a provided max_output_chars argument. The format as implemented in this PR as follows:

x = 10
... (100 characters omitted) ...
z = 20

This PR removes the output header (Output (500 chars)...) that specifies the total characters in the output. This was done because it felt redundant with the input field marker ([[ ## repl_output ## ]]) in the user message content inside the conversation history.

History processing hook

There is also a history_processor hook added to the module. This enables users to run e.g. compaction strategies. The hook is a Callable[[dspy.History], dspy.History | None]. A user can provide this during the instantiation of the RLM module passing RLM("query -> answer", max_iters=5, history_processor=my_processor) or when calling it my_rlm(interpreter, my_processor, ...). The per-call processor overrides any processor set at instantiation.

This design is intentionally very similar to what's proposed in #10217.
However, a key difference is that this implementation does not treat a return of None as a mistake/error. It allows for callbacks that simply mutate the passed in history in-place. It does degrade gracefully though on exceptions thrown from the processor hook, enabling the run to continue with the unprocessed history.

Passed-in History is Input Only

The RLM module treats the user's passed-in history as read only. It doesn't modify it. It makes its own copy of the passed-in history. The returned history on the resulting Prediction is the continuation of the user's passed-in history, but is a separate object.

This implies a kind of usage pattern where the caller manages history between RLM invocations, like so:

def process_history(history: dspy.History):
    # ...process / compact / etc.
    return history

rlm = RLM("query -> answer", max_iters=5, history_processor=process_history)

result_one = rlm(query="Double ten")
result_two = rlm(query="Double twenty, then add 5", history=result_one.history)
full_history = result_two.history # result_two's history is continuation of result_one's history

REPL History is no longer an input

Now that the prior interaction history and context is now properly replayed to the LM, this PR no longer has the REPL trajectory repl_history: REPLHistory as an input into the inner generate_action or extract Predict signatures.

REPL History is still returned to the caller

The REPL history is still returned on the resulting Predicition, but as repl_trajectory. It contains a dictionary representation of the REPLHistory.

rlm = RLM("query -> answer")
result = rlm(query="Double ten")
print(result.repl_trajectory) 
# [ {"reasoning": "First explore the data", "code": "x = 10\nprint(f'x = {x}')", "output":  "x = 10"} ... ]

Happy to discuss / modify any of the design decisions proposed here!


This PR addresses issue #10334

…dapter.format(). Removed history input field from signature (when present) when formatting system message inside Adapter.format().
…y event update inside extract fallback methods
…r-provided history in-place. Improved history processor hook application to allow for in-place mutation processor callbacks.
@greptile-apps

greptile-apps Bot commented Sep 12, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] No outstanding blocking findings remain; the reviewed changes address both numbered findings.

Summary

Adds structured dspy.History support to RLM so callers can carry prior turns into later calls.

  • RLM calls can continue from saved conversation history.
  • Adapters replay RLM steps as conversation messages.
  • Test support changes leave product behavior unchanged.

Reviews (13) · Last reviewed commit: "Reserve RLM runtime options and history-..." · Reviewed by Greptile

Comment thread dspy/adapters/base.py Outdated
Comment thread dspy/adapters/base.py Outdated
Comment thread dspy/adapters/base.py Outdated
Comment thread dspy/adapters/base.py Outdated
…ter field names and inner signature field names. Reserved 'repl_entry' for RLM input / output names. Made sure all repl history events have 'repl_entry' set to either REPLEntry or None
Comment thread dspy/primitives/repl_types.py Outdated
Comment thread tests/predict/test_rlm.py Outdated
@mannuch
mannuch marked this pull request as draft September 16, 2026 01:51
…dded variables_info and iteration to history event. Now variables_info and iteration are replayed on prior turns of conversation history.
@mannuch
mannuch marked this pull request as ready for review September 16, 2026 21:14
mannuch and others added 3 commits October 5, 2026 15:47
Pulls in 49 upstream commits, three of which touch the RLM/adapter code this
branch changes:

- stanfordnlp#10554 (reminders in History replay): adopted for the REPL-event branch too,
  so a replayed step's user message is now byte-identical to the one sent at
  that step. Pinned by test_replayed_step_message_matches_the_live_one.
- stanfordnlp#10493 (interpreter factories at call time): the positional `interpreter`
  argument is gone. `history_processor` follows upstream's convention for
  runtime configuration and is now keyword-only on forward/aforward; the
  "second positional argument" TypeError is removed. RLM tests move from the
  pooled-interpreter fixture to `interpreter_factory=PythonInterpreter`.
- stanfordnlp#10478 (sub-dspy interpreters): `_LLMCallBudget`, `_setup_facade` and
  `sub_agent_rules` thread through the action loop alongside the history
  plumbing.

Conflict resolutions of note:
- Reserved-name validation keeps upstream's `interpreter_factory` guard and
  `is_reserved()` (now covering sandbox-facade names) plus this branch's
  `_RESERVED_INPUT_NAMES` check, each with its own error message.
- Upstream's new facade tests read `result.trajectory`; renamed to
  `repl_trajectory` to match this branch.
- Adapter exact-message tests take upstream's expectations (ours were
  formatting-only); the RLM request snapshot is regenerated so it carries both
  upstream's sub-agent instructions and this branch's signature fields.

Full suite: 2616 passed, 365 skipped, 2 xfailed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This branch's uv.lock carried marker churn from a local `uv lock` run (every
diff line just adds a `python_full_version`/`sys_platform` marker to an existing
dependency entry; regenerating on this host reproduces it even with CI's pinned
uv 0.11.2, so it cannot be matched locally).

The churn is unrelated to the history feature and it is the only reason the
Dependency Range workflow runs on this PR. That workflow resolves every
dependency to its latest version and is already failing on upstream main's
scheduled run with the same 5 failures and 10 errors (newer httpx rejects the
Mock in tests/clients/test_lm.py, the litellm test server then fails to boot,
and newer mcp reformats tool-call error text).

Note that `uv lock --check` fails against this lockfile because upstream's own
lock still says dspy 3.4.0b1 while pyproject says 3.4.0; that is upstream's
state, not something this branch should fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`configure_dspy_loggers` sets `propagate = False` on the `dspy` logger. Up to
pytest 8.2 the capture handler lives only on the root logger, so that flag stops
`caplog` from seeing any dspy record; pytest 9 also attaches it to the logger
named in `caplog.at_level`, which is why these assertions pass locally and under
the latest dependency profile but not under the minimum one.

Tests asserting on dspy log output were therefore relying on an earlier test
having leaked `propagate = True` (tests/predict/test_predict.py sets it and never
restores it), which is order-dependent under `-n auto --dist worksteal`. Adding
history tests to tests/predict/test_rlm.py reshuffled that distribution and
dropped the leak from the worker running
TestRLMSubAgents::test_interpreter_in_the_host_memory_runs_without_sub_agents,
so it saw an empty caplog and failed on minimum-direct while passing on main.

An autouse fixture now enables propagation for the duration of each test and
restores it afterwards, which covers the 63 caplog assertions across six files
and stops the leak from carrying between tests. The history-processor test drops
its own copy of the save/set/restore dance.

Verified by reproducing the CI failure with `--with pytest==8.2.0` (fails without
the fixture, passes with it); 636 passed across the caplog-dependent suites on
pytest 8.2.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

@isaacbmiller

Copy link
Copy Markdown
Collaborator

Hey Matt — thanks for raising this, and sorry for the slow response!

We’ve since merged #10643, which makes RLM use dspy.Reasoning: native reasoning when supported, otherwise a short textual plan. The old str field requested ordinary text; the fallback plan still replays through History.

The newer XMLAdapter is in #10402 — typed tags with literal leaves, on branch isaac/xml-adapter-literal-tags. It’s still open and im actively working on it, so rebasing onto main alone won’t include it.

You’re right that the native path currently omits prior reasoning from ordinary History replay, even when the provider exposes it. We’re tracking exposed-thinking replay and provider-native continuation separately (#10675), so you don’t need to solve those in this PR.

I tested 30 paired cases per benchmark/model. Before → PR compares newer main + fixed XMLAdapter against the same integration + your History PR, with JSON fallback:

Model BrowseComp+ before → PR OOLONG before → PR Failures / 60 before → PR
Luna 67.07 → 75.69 82.60 → 85.04 0 → 0
Haiku 4.5 59.11 → 66.86 52.69 → 68.50 0 → 0
Kimi K2.6 69.82 → 54.57 73.24 → 64.92 9 → 16

The fixed XML + newer reasoning stack substantially helps: Kimi’s PR failures fell from 40/60 on the older stack to 16/60.

Luna BrowseComp cached-input share also improved 8.43% → 78.36%, with SDK-estimated cost falling $1.410 → $0.655 despite more input tokens.

Three remaining issues:

  1. Kimi extraction: 13 PR failures return another action (reasoning/code) instead of the final answer/response, even after JSON retry. This is the wrong output schema, not merely malformed XML. Three other failures are unexplained HTTP 400s. From some testing I did, Across three replay cases, supplying history as reference text rather than assistant turns improved schema success 4/9 → 8/9.

  2. Ownership: returning an external History from history_processor lets RLM mutate it. Editing the supplied private copy works correctly.

  3. Saving/loading History loses the REPL transcript: restored entries become plain dictionaries, so Chat/JSON/XML replay omits their code and outputs:

    saved = history.model_dump_json()
    restored = dspy.History.model_validate_json(saved)

The repro bundle includes offline contract checks and five captured extraction fixtures, with controls, exact revisions, and setup instructions. After setup:

python check_history.py                              # offline contracts
python replay_extraction.py --live --case browsecomp-05 \
  --variant both --repeats 3 --out runs/before         # paid calls

rlm-pr-review.zip

mannuch and others added 5 commits October 25, 2026 17:04
Pulls in 45 upstream commits. Three needed more than conflict resolution:

- stanfordnlp#10643 (native reasoning with a plan fallback): RLM's `reasoning` field keeps
  this branch's `dspy.Reasoning` type and adopts upstream's description and
  `__dspy_reasoning_fallback="plan"`. Without native reasoning the adapter now
  renames the field to `plan` before the call, which the REPL replay misread as
  native reasoning and so silently dropped the plan from every replayed action.
  Replay now resolves the live name from `__dspy_reasoning_field` (the approach
  used in the maintainer's tested integration), via
  `_repl_reasoning_field_name`. Covered by
  test_replayed_actions_use_the_live_reasoning_field_name, which fails without
  the fix. DummyLM action responses in this branch's tests move to the `plan`
  key (38 sites); `make_mock_predictor` responses bypass the adapter and keep
  `reasoning`.
- GEPA trace compaction lowers `REPLHistory.max_output_chars`, but this branch
  moved the limit onto `REPLEntry`, so compaction stopped truncating.
  `REPLEntry.format` takes an optional override again (upstream's signature) and
  `REPLHistory.format` passes its limit; standalone entries in History events
  still use their own.
- test_replayed_step_message_matches_the_live_one now checks what a second turn
  actually sends. Replaying through the unprocessed signature named the
  reminder's field `reasoning`, while real calls format history with the
  processed signature, where it is `plan`.

Also: new upstream tests read `result.trajectory`, renamed to
`repl_trajectory`; the request snapshot is regenerated from upstream's file so
only its two text fields differ.

Full suite (xdist): 3125 passed; the only failures are 10 tests needing pandas,
which this local venv lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`_apply_history_processor` gave the processor a private deep copy, but
returned whatever the processor handed back as-is. A processor returning a
History its caller still holds (e.g. `lambda _: external`) therefore had RLM
append every iteration's events to the caller's object.

Anything other than the private copy is now deep-copied before RLM adopts it;
the private copy itself is still adopted without an extra copy. Reported in
review of stanfordnlp#10387.

The ratchet-adoption tests asserted the old behaviour (`result.history is
returned[1]`); they now check that each call still builds on the previous
result while RLM's events never land in the processor's objects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`dspy.History.messages` is `list[dict[str, Any]]`, so saving and loading a
history (`model_dump_json` / `model_validate_json`, or the python-mode
equivalents) turned each `REPLEntry` into a plain dict and the extract-fallback
marker into `{}`. `is_repl_event` deliberately requires a typed value, so that
an application field that happens to be named `repl_entry` is not mistaken for
an RLM event, which meant restored events fell through to the generic path and
Chat/JSON/XML replay lost their code and REPL output.

Both values now serialize with a `__dspy_repl__` tag ("entry" /
"extract_fallback"), following dspy's `__dspy_*` naming. Readers accept a live
instance or a dict carrying the tag, and `split_repl_event` returns the typed
value again; untagged dicts remain ordinary application data.
`restore_repl_events` retypes a message list, and RLM applies it to the history
it is given, so history processors and `result.history` see `REPLEntry`
objects. `repl_trajectory` is built with `to_trajectory_dict()`, so it keeps
its previous shape without the tag.

Reported in review of stanfordnlp#10387. Before this change a restored history's replay
differed from the original under all three adapters; it is now identical for
both the action and extract signatures, including after an extract fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the extraction-schema failures reported in review of stanfordnlp#10387, where
Kimi K2.6's extract call returned another `reasoning/code` action instead of the
final outputs. On main the extract call sees the trajectory as `repl_history`
text; this PR replaced that with replay as assistant turns, so the request
ended in a run of the model's own actions.

The extract signature regains main's `repl_history` input ("Your REPL
interactions so far"), built from the current turn's entries as main does, so
those steps sit as text beside the request for the final outputs. `history` is
kept, so prior turns (and this turn's steps) are still replayed as
conversation turns. If this alone does not restore schema success, the next step
is to drop `history` from the extract signature.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tory text

Replayed reasoning/code turns in the extract request led Kimi K2.6 to answer
with another action instead of the final outputs (10/18 schema-valid on the
review fixtures). Extract now matches main's signature: variables_info plus the
current turn's steps as repl_history (9/9). history_processor no longer runs
for the fallback, since nothing there reads history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment thread dspy/predict/rlm.py
Comment thread dspy/predict/rlm.py
… names

A signature input named history_processor could be declared but never
passed, since forward() takes that keyword as the runtime option; it is now
rejected at construction like interpreter_factory.

Outer fields named variables_info or iteration collided with the step
metadata on history events: _repl_entry_event overwrote matching inputs, and
a successful SUBMIT replaced the metadata in place, so replay treated the
answer as a step input. Both names are now reserved for inputs and outputs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mannuch

mannuch commented Oct 26, 2026 •

Copy link
Copy Markdown
Author

Hey Isaac, thanks for the feedback! Glad to see the positive results in the benchmarking.

I've addressed the issues you pointed out:

  • dspy.History Ownership: now, a dspy.Historyreturned by a history_processor is deep-copied before the RLM mutates it internally.
  • RLM history survives JSON / plain dict roundtrips: REPLEntry and ExtractFallbackMarker now serialize with a __dspy_repl__ tag.
  • Extract Signature uses the current turn's repl_history only: The extraction signature now doesn't pass history as one of its inputs. Only the current turn's repl_history is passed, along with variables_info. I tried keeping both history and repl_history as inputs on the signature, but only got 6/9 valid answers. Using repl_history only gave 9/9 valid answers. It seems that having repl_history only prevents Kimi from sending back {"reasoning", "code"} instead of the actual answer.

Thanks for providing the repro bundle! It didn't initially work with the fix that removeshistory — so I used the following script to evaluate: replay_turn_extraction.py

This branch has not been deployed

No deployments
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.

2 participants