Skip to content

Render ReActV2's last_request_note as a user message with no invented reply - #260

Merged
deepfates merged 2 commits into
mainfrom
claude/note-render
Sep 29, 2026
Merged

deepfates merged 2 commits into
mainfrom
claude/note-render

Conversation

@deepfates

@deepfates deepfates commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Closes #259

What I found

  • ReActV2 keeps one Imp.History for the whole conversation. The caller's :history input 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 in metadata.history. Dwell passes that back as the next call's :history.
  • append_note/3 stored the note as %{first_input => note}. When the first step failed, note_after_inputs/3 also stored the unspent inputs as %{intent: ...}. Both entries have inputs and no outputs.
  • Imp.Adapter.Chat.render_history_turns/3 has three branches:
    • An entry without tool_calls is rendered as a finished History exchange: 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's ChatAdapter.format renders a missing history output as None (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.
    • A native tool turn (render_native_tool_history_turn/3) already drops an assistant message that has no text and no calls.
    • A written tool turn (render_written_tool_history_turn/3, used in prompt mode) always rendered the assistant, with the filler in empty fields.

The fix

  • ReActV2 now records its own user-only turns (the note, and unspent inputs before it) as step events that called nothing: the fields plus tool_calls: %ToolCalls{tool_calls: []} and tool_call_results: []. This is the same shape as every other step event, through the new append_user_turn/2.
  • In written mode, the chat adapter now renders a turn with no calls and no other recorded output as its user message alone. That matches what native mode already did. A turn that recorded any output other than the tool-calls field (a generic Imp.Predict step whose signature sets tool_calls_field and stored an answer, say) keeps its assistant message. This also means a ReActV2 step that answered with nothing replays in prompt mode with no filler assistant message.
  • History entries a host writes without tool_calls keep 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: []} and tool_call_results: []. A host that recognises the note by shape (Dwell does) must match the first input's key with an empty tool_calls, or the text. The CHANGELOG entry is under Changed, marked Breaking, with that migration.

Result:

  • Native mode: the last request ends on the note as a user message.
  • Prompt mode: the note is a user message followed only by the [[ ## tools ## ]] listing. Every prompt-mode step request already ends on that listing, also a user message. There is no assistant message between them.
  • Replay: a returned history, dumped and loaded, renders the note as a user message followed by the reply the model actually gave (the submit call).

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 reply
  • prompt tools: the note ends the forced request as a user message, with no invented reply
  • native tools: a returned history replays the note as the user turn the model answered
  • prompt tools: a returned history replays the note as the user turn the model answered
  • a 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.ex restored to main, adapter change kept: 5 fail (the 4 note tests and the last-text test).
  • chat.ex restored 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.ex at 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

  • A note stored by an earlier Imp is an input-only entry, and it still renders with the filler. It can't be told apart from a host's own input-only entry. Dwell's stored histories from before this change keep that until they age out, unless GroveResearch/dwell#148's narrow renderer stays for them.
  • mix differential.check (:dspy_parity) could not run in this worktree. Its pinned DSPy and GEPA sources (tmp/gepa-v0.1.4/src etc.) aren't provisioned, and 19 of 60 tests flunk on that. None of those tests touch ReActV2 or history rendering.

… 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.
@deepfates
deepfates merged commit 99ef8cd into main Sep 29, 2026
10 checks passed
@deepfates
deepfates deleted the claude/note-render branch September 29, 2026 02:38
@deepfates deepfates mentioned this pull request Sep 29, 2026
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.

ReActV2's last_request_note renders with an invented assistant reply

1 participant