Repository navigation
Conversation
…ser message to conversation history
…dapter.format(). Removed history input field from signature (when present) when formatting system message inside Adapter.format().
…pen as separate PR).
…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.
…d() in RLM module
[Medium impact] No outstanding blocking findings remain; the reviewed changes address both numbered findings. SummaryAdds structured
Reviews (13) · Last reviewed commit: "Reserve RLM runtime options and history-..." · Reviewed by Greptile |
…ut/output fields when reusing history
…stead of a user turn
…ignature field names
…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
Signed-off-by: Matt Mannucci <mannuch31@gmail.com>
…dded variables_info and iteration to history event. Now variables_info and iteration are replayed on prior turns of conversation history.
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>
|
Want your agent to iterate on Greptile's feedback? Try greploops. |
|
Hey Matt — thanks for raising this, and sorry for the slow response! We’ve since merged #10643, which makes RLM use The newer XMLAdapter is in #10402 — typed tags with literal leaves, on branch 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:
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:
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 |
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>
… 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>
|
Hey Isaac, thanks for the feedback! Glad to see the positive results in the benchmarking. I've addressed the issues you pointed out:
Thanks for providing the repro bundle! It didn't initially work with the fix that removes |
This adds support for structured
dspy.Historyinside the RLM module.Currently, RLM only supports a
REPLHistorythat records the reasoning, code, and REPL output for each iteration. At the end of each call, the REPL history can be inspected by accessing thehistoryfield on the resultingPrediction. 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.Historyobject whosemessageslist 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_thoughtreasoning content, atool_callsfield of typedspy.ToolCallscontaining structured tool calls and their results, and the original signature's output fields when the turn successfully calls thesubmittool.We take a very similar approach here for RLM. Specifically, each loop turn is structured the following way:
As seen above, each event contains a
repl_entryfield of typeREPLEntrywith reasoning, the code output by the LM to be executed, and the REPL output of the code. Each event also carriesvariables_infooutlining the variables available in the REPL (the RLM signature's inputs are represented here) as well as the currentiterationof 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 therepl_entry,variables_info, anditerationfields.The extract fallback case
When the RLM has reached
max_itersiterations, but hasn't yetSUBMITted 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 andrepl_entryset toExtractFallbackMarker:Note:
repl_entryis always set to eitherREPLEntryorExtractFallbackMarker. 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
REPLEntryis formatted into conversation history as follows:reasoningandcodeare formatted into a"role": "assistant"message's contentoutputis formatted into a"role": "user"message's contentWith 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 arepl_entrykey containingREPLEntry | ExtractFallbackMarkerand handles them accordingly.Output Truncation
When formatting the
REPLEntry's output, we truncate it according to a providedmax_output_charsargument. The format as implemented in this PR as follows: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_processorhook added to the module. This enables users to run e.g. compaction strategies. The hook is aCallable[[dspy.History], dspy.History | None]. A user can provide this during the instantiation of the RLM module passingRLM("query -> answer", max_iters=5, history_processor=my_processor)or when calling itmy_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
Noneas 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
historyon the resultingPredictionis 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:
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: REPLHistoryas an input into the innergenerate_actionorextractPredict signatures.REPL History is still returned to the caller
The REPL history is still returned on the resulting
Predicition, but asrepl_trajectory. It contains a dictionary representation of theREPLHistory.Happy to discuss / modify any of the design decisions proposed here!
This PR addresses issue #10334