Render ReActV2's last_request_note as a user message with no invented reply - #260
Merged
Merged
Conversation
… reply The note, and inputs no step spent before it, are recorded as step events that called nothing, and the chat adapter renders a step with no thought and no calls as its user message alone in written tool mode, as it already did natively. History entries a host writes keep DSPy's rendering.
A turn with no calls replays with no assistant message only when no other output was recorded, so a generic Predict step with a tool-calls field keeps its stored answer. The CHANGELOG entry moves to Changed as breaking: the note's history entry now carries empty tool_calls and tool_call_results. The history filler is Imp's; DSPy renders a missing history output as None.
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #259
What I found
Imp.Historyfor the whole conversation. The caller's:historyinput is the start of it, each step adds an event (history_event/5: the step's pending inputs,next_thought,tool_calls,tool_call_results), and the prediction returns all of it inmetadata.history. Dwell passes that back as the next call's:history.append_note/3stored the note as%{first_input => note}. When the first step failed,note_after_inputs/3also stored the unspent inputs as%{intent: ...}. Both entries have inputs and no outputs.Imp.Adapter.Chat.render_history_turns/3has three branches:tool_callsis rendered as a finishedHistoryexchange: a user message, then an assistant message where every missing output reads "Not supplied for this conversation history message." That filler is Imp's own, not DSPy's: DSPy 3.2.1'sChatAdapter.formatrenders a missing history output asNone(checked in the pinned venv; "Not supplied" is DSPy's demo filler). This PR leaves that older divergence alone. For the note, it put a reply in the model's mouth that the model never gave. This happened in native and prompt tool modes, in the last request, and again whenever the history was replayed.render_native_tool_history_turn/3) already drops an assistant message that has no text and no calls.render_written_tool_history_turn/3, used in prompt mode) always rendered the assistant, with the filler in empty fields.The fix
tool_calls: %ToolCalls{tool_calls: []}andtool_call_results: []. This is the same shape as every other step event, through the newappend_user_turn/2.Imp.Predictstep whose signature setstool_calls_fieldand stored ananswer, say) keeps its assistant message. This also means a ReActV2 step that answered with nothing replays in prompt mode with no filler assistant message.Historyentries a host writes withouttool_callskeep Imp's rendering, filler included. A new test pins that.Breaking, for a host that reads the history's shape: the note entry, and the unspent-inputs entry before it, now carry
tool_calls: %ToolCalls{tool_calls: []}andtool_call_results: []. A host that recognises the note by shape (Dwell does) must match the first input's key with an emptytool_calls, or the text. The CHANGELOG entry is under Changed, marked Breaking, with that migration.Result:
[[ ## tools ## ]]listing. Every prompt-mode step request already ends on that listing, also a user message. There is no assistant message between them.submitcall).There is one knock-on change.
extract_final's extractor also renders these entries through the native path, so the note no longer carries a filler there either.Docs and records: I changed one sentence in the ReActV2 moduledoc, added two CHANGELOG entries under Unreleased > Changed (one marked Breaking), and added one line to
decisions.md.Tests and falsification
New tests in
test/react_v2_last_request_note_test.exs:native tools: the note ends the forced request as a user message, with no invented replyprompt tools: the note ends the forced request as a user message, with no invented replynative tools: a returned history replays the note as the user turn the model answeredprompt tools: a returned history replays the note as the user turn the model answereda host's input-only history entry keeps Imp's rendering: this one guards the unchanged behaviour, so it passes with or without the fix.New tests in
test/adapter_chat_written_history_test.exs:a Predict step with a tool-calls field keeps an answered turn's assistant message: fails with the first commit's adapter condition (thought-only), passes with the current one and on main.a ReActV2 step that answered with nothing replays with no assistant message: fails on main's adapter.I also added a role assertion to
ReActV2LastTextTest"a failed step takes the same last request, and the cause is recorded". It covers the unspent-inputs-then-note path.Falsification, run on the three test files together (25 tests), each time restoring one file to origin/main:
react_v2.exrestored to main, adapter change kept: 5 fail (the 4 note tests and the last-text test).chat.exrestored to main, ReActV2 change kept: 3 fail (the 2 prompt-mode note tests and the empty-answer replay test); native passes, since native replay already dropped an empty assistant.chat.exat this PR's first commit: 1 fails (the generic Predict answered-turn test).So each half is needed, and each is covered. (A review note said the first two results were swapped in the earlier body; I re-ran them and the counts above are what they give.)
Other checks:
mix check:59 doctests, 9 properties, 3562 tests, 0 failures, 13 skipped (221 excluded)mix dialyzer.check: passed.mix docs --warnings-as-errors: passed.mix parity.check(golden trace against the pinned DSPy 3.2.1 venv): 1 test, 0 failures, so the golden trace is unchanged.Unsure / open
mix differential.check(:dspy_parity) could not run in this worktree. Its pinned DSPy and GEPA sources (tmp/gepa-v0.1.4/srcetc.) aren't provisioned, and 19 of 60 tests flunk on that. None of those tests touch ReActV2 or history rendering.