Skip to content

ReActV2 finish: :submit: keep the thought as the model's message and say it was not sent - #347

Merged
deepfates merged 2 commits into
mainfrom
react-v2-unsent-note
Oct 16, 2026
Merged

deepfates merged 2 commits into
mainfrom
react-v2-unsent-note

Conversation

@deepfates

@deepfates deepfates commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

User story

A host runs a program with finish: :submit. After the model writes a thought and calls no tool, #346 continues the loop with an ordinary step. That step currently shows the thought back to the model as the request's last user message, under [[ ## next_thought ## ]].

In a host rehearsal, some models misread that message:

  • as an echo, or a pipeline artifact ("the environment is echoing my last output back as if it were the next input");
  • or as their own message, already sent.

They then ended with an empty submit. That lost 4 of 74 replies that were owed.

Change

The continued step's request (only under finish: :submit):

  • The thought stays the model's own assistant message.
  • One user message follows it, naming submit and the signature's outputs:
    Your message above was not sent. To reply, call `submit` with `answer`; to keep working, call a tool.
    
    With several outputs it reads "with answer and confidence".
  • The request still ends on a user turn, which Mistral and Gemini require.

The line follows every thought-only step:

  • in the continued request;
  • in every later request of the turn;
  • on replay.

So each request is still the previous one plus the newest exchange, which is what a provider's prompt cache reuses. The line states a fact about the step (only submit is sent) and the two ways to go on, so it stays true when the history is replayed.

One exception: a thought the turn's extracted outputs directly follow. That thought was answered, so it gets no line on replay. The extracted outputs are still not rendered, because those are words the model did not write.

The forced submit is unchanged. Probe 4 measured that its shape preserves replies. The forced request's own last thought is still shown as its last user message, with no line after it. Earlier thoughts in the same history keep their line.

  • The line is dropped only when thought_as_request actually moves the thought to the user side.
  • A forced request that carries new inputs (a resume with max_iters: 0) keeps the line after the held thought, before the inputs' user message.
  • Imp tells the adapter which request is the forced one through a forced_submit key in the loop's guidance.

finish: :auto is byte-for-byte unchanged. The line exists only under finish: :submit.

This qualifies an existing doc statement. The docs said Imp adds nothing to the last request except a host's last_request_note. That is no longer quite true: under finish: :submit, the earlier thought-only steps in the history keep their unsent line. The moduledoc, the option doc, react.md section 3 and decisions.md now say so.

Written tool calls: the step's fields, then the line, and the request ends on the tool listing as every written-mode request does.

Evidence

Measured on 2026-10-16: 21 real final steps, 2 samples each, across 11 model families, after a plain-text step.

  • Thought as a user message (current): 5 samples wrote text again.
  • Thought kept, plus this line: no sample wrote text again, and replies were submitted intact (similarity about 1.0).
    • Meta's models accepted it with tool_choice: auto.
    • No confusion was seen.
    • One family once submitted a closing note as its answer.
  • Thought kept, plus only "Your message above was not sent.": models probed their status, and some took extra steps.

The measurement is recorded in a new decisions.md row. The earlier continuation row is amended to point to it. react.md section 2 shows the line.

Empty submit({}): what the host saw

This is already the behaviour on an ordinary step, the continued one included. A submit with missing outputs records the error as that call's result, and the loop goes on. I probed this with a scripted model (submit({}), then a valid submit): the turn ended :submit, both in a first step and in the continued step.

In the forced submit it is different. That request is the turn's last, so there is no loop to go on. An invalid submit there ends the turn :incomplete. The termination_cause names the interruption that led to the forced request (:empty_tool_calls after two thought-only steps in a row), and the history records the error.

So the host's incomplete turns came from an empty submit in the forced request. They did not come from the continued step. This PR does not change that. Two options for review:

  • send an invalid forced submit to the extractor, as an answer without submit already is;
  • leave the turn :incomplete.

Tests

These fail on main:

  • test/react_v2_unsent_note_test.exs:
    • The line names every output. It stays after the thought in the next request, which extends the continued request.
    • The forced submit shows its last thought on the user side, with no line after it; the earlier thought keeps its line.
    • With written tool calls, the request is the step, the line, then the tool listing.
    • On replay of a held history, the line follows each unsent thought, and not the thought the extracted answer followed.
    • A forced request with new inputs keeps the line after the held thought.
  • test/react_v2_finish_submit_test.exs: the continued request ends with the thought (assistant), then the line (user).

The deadline tests from #346 also get wider timing margins. They could fail on a loaded machine: the deadline sometimes passed before the request meant to be slow had been sent. Each still asserts that no request is sent after the deadline.

mix check and mix quality.check pass.

…say it was not sent

On the step that follows a thought-only step, the thought now stays the model's own (assistant) message and one user message follows it: "Your message above was not sent. To reply, call `submit` with <the outputs>; to keep working, call a tool." It follows every thought-only step, in later requests and on replay, so each request extends the previous one. The forced submit keeps its shape: its last thought is shown as the request's last user message, with no note after it.

Measured on 21 real final steps x 2 samples across 11 model families: the thought shown back as a user message had 5 samples write text again, and some models took it for an echo and submitted nothing; with the note, none wrote text again and replies were submitted intact.

Also widens the margins of the deadline tests from #346, which could fail on a loaded machine.
…ced request only when the thought moves

A thought the turn's extracted outputs directly follow was answered, so on replay it gets no "Your message above was not sent" line; the extracted outputs are still not rendered. The forced submit drops the line after its last thought only when thought_as_request moves that thought to the user side, so a forced request with new inputs keeps it. Docs that said Imp adds nothing to the last request now name the unsent line as the exception.
@deepfates
deepfates enabled auto-merge (squash) October 16, 2026 06:30
@deepfates
deepfates merged commit d90291a into main Oct 16, 2026
13 checks passed
@deepfates
deepfates deleted the react-v2-unsent-note branch October 16, 2026 06:35
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