From e6c8363b8839d304e5299d00979b7ce6f863981a Mon Sep 17 00:00:00 2001 From: deepfates Date: Thu, 15 Oct 2026 23:15:16 -0700 Subject: [PATCH 1/2] ReActV2 finish: :submit: keep the thought as the model's message and 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 ; 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. --- CHANGELOG.md | 9 +- decisions.md | 3 +- docs/diving-deeper/react.md | 29 ++++-- lib/imp/adapter/chat.ex | 42 ++++++++- lib/imp/predict/react_v2.ex | 55 +++++++++--- test/react_v2_finish_submit_test.exs | 18 ++-- test/react_v2_thought_continues_test.exs | 8 +- test/react_v2_unsent_note_test.exs | 110 +++++++++++++++++++++++ 8 files changed, 238 insertions(+), 36 deletions(-) create mode 100644 test/react_v2_unsent_note_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e019b0..f2dd2315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,9 +36,12 @@ User-visible changes to Imp are recorded here. - Under ReActV2's `finish: :submit`, a step that writes a thought and calls no tool is now followed by an ordinary step (every tool, - `tool_choice: "auto"`, the thought shown as the request's last message when - the tools are held natively) - instead of the forced `submit`. A second thought-only step in a row, or a + `tool_choice: "auto"`), in which the thought stays the model's own message + and a user message after it says "Your message above was not sent. To + reply, call `submit` with `answer`; to keep working, call a tool." (naming + the signature's outputs), instead of the forced `submit`. The line follows + every thought-only step, in later requests and on replay too. With written + tool calls the request ends on the tool listing after it. A second thought-only step in a row, or a step that says nothing or only whitespace, still forces `submit` (`:empty_tool_calls`), as does `max_iters`. Once the deadline has passed, no further request is sent, continued or forced. A model that announced an action can take diff --git a/decisions.md b/decisions.md index c433377d..5c4d8dff 100644 --- a/decisions.md +++ b/decisions.md @@ -8,7 +8,8 @@ not necessarily when it was made. | Date | Decision | Source and reason | Status | Retires when | | --- | --- | --- | --- | --- | -| 2026-10-16 | Under `finish: :submit`, a ReActV2 step that writes a thought and calls no tool is followed by an ordinary step (every tool, `tool_choice: "auto"`, the thought as the request's last user message when the tools are held natively), not at once by the forced `submit`; a second thought-only step in a row, or a step that says nothing or only whitespace, is the `:empty_tool_calls` interruption. `max_iters` ends the turn with the forced `submit` as before; once the deadline has passed no further request is sent and the turn is `:incomplete` (`:deadline_exceeded`). With written tool calls the request ends on the tool listing, after the thought. Signatures with `submit` under `finish: :auto` keep DSPy's ending. | `lib/imp/predict/react_v2.ex` `continue_after_thought?/3`, `test/react_v2_thought_continues_test.exs`. Forcing at once took every other tool away from a model that had just announced its next action, and added a request whose failure lost the reply the thought held. Measured 2026-10-16 on 42 real final steps from 11 models through OpenRouter, sending that ordinary step: most samples called `submit` with the thought's reply, copied or paraphrased (Meta's models 6 of 6, unforced; Kimi, GPT and Gemini models likewise); a thought that was a closing note was submitted as an empty answer; a DeepSeek model went on to fetch what it had said it would read; 5 of 42 wrote text again. DSPy's ReActV2 forces `submit` after any step that calls no tool. | In force. | A measurement shows a different bound, or forcing at once, loses fewer answers. | +| 2026-10-16 | Under `finish: :submit`, every thought-only ReActV2 step is followed, in every request and on replay, by one user message: "Your message above was not sent. To reply, call `submit` with ; to keep working, call a tool." The thought stays the model's own (assistant) message. The forced `submit`'s own last thought is the exception: it is shown as the request's last user message (the row on the forced request), with no note after it. | `lib/imp/predict/react_v2.ex` `unsent_note/2`, `lib/imp/adapter/chat.ex` `unsent_note_messages/3`, `test/react_v2_unsent_note_test.exs`. With the thought shown back as a user message under `[[ ## next_thought ## ]]`, some models read it as an echo or a pipeline artifact, or as their own message already sent, and ended with an empty `submit`: 4 of 74 owed replies lost in a rehearsal. Measured 2026-10-16 on 21 real final steps, 2 samples each, across 11 model families, after a plain-text step: thought as a user message, 5 samples wrote text again; thought kept with this note, none did, replies were submitted intact (similarity about 1.0), Meta's models accepted it with `tool_choice: auto`, and one family once submitted a closing note as its answer; with only the note's first sentence, models probed their status and took extra steps. The note is a sentence Imp writes, which the last request does not; it states a fact about the step (only `submit` is sent) and the two ways on. | In force. | A measurement finds a wording or shape that loses fewer replies. | +| 2026-10-16 | Under `finish: :submit`, a ReActV2 step that writes a thought and calls no tool is followed by an ordinary step (every tool, `tool_choice: "auto"`; amended the same day: the thought stays the model's own message, followed by the unsent note, the row on that note), not at once by the forced `submit`; a second thought-only step in a row, or a step that says nothing or only whitespace, is the `:empty_tool_calls` interruption. `max_iters` ends the turn with the forced `submit` as before; once the deadline has passed no further request is sent and the turn is `:incomplete` (`:deadline_exceeded`). With written tool calls the request ends on the tool listing, after the thought. Signatures with `submit` under `finish: :auto` keep DSPy's ending. | `lib/imp/predict/react_v2.ex` `continue_after_thought?/3`, `test/react_v2_thought_continues_test.exs`. Forcing at once took every other tool away from a model that had just announced its next action, and added a request whose failure lost the reply the thought held. Measured 2026-10-16 on 42 real final steps from 11 models through OpenRouter, sending that ordinary step: most samples called `submit` with the thought's reply, copied or paraphrased (Meta's models 6 of 6, unforced; Kimi, GPT and Gemini models likewise); a thought that was a closing note was submitted as an empty answer; a DeepSeek model went on to fetch what it had said it would read; 5 of 42 wrote text again. DSPy's ReActV2 forces `submit` after any step that calls no tool. | In force. | A measurement shows a different bound, or forcing at once, loses fewer answers. | | 2026-10-16 | ReActV2's forced `submit` request, when the step before it wrote a thought and called nothing, shows that thought as the request's last message, a user message under its field's marker (`[[ ## next_thought ## ]]`), not as the assistant turn the history records it as. DSPy's ReActV2 leaves the thought out of the forced request. | `lib/imp/adapter/chat.ex` `thought_as_request/4`, `test/react_v2_finish_submit_test.exs`. As a trailing assistant turn the request is refused by providers that do not take an assistant prefix: Mistral ("Cannot set an assistant prefix with function calling") and Gemini ("Requests ending with a model turn are not supported"), probed through OpenRouter 2026-10-16. Dropping it, as DSPy does, made models regenerate finished answers with reasoning off, 2 of 16 to the output limit (deepfates/imp#336). Measured 2026-10-16 on real final steps re-sent through OpenRouter: with the thought as this user message, the submitted answer kept the plain-text reply (text similarity 0.8 to 1.0 across Kimi, GPT, DeepSeek and Muse models), a model whose plain text was a closing note submitted an empty answer as it intended, Meta's models (which accept only `tool_choice: auto`) copied the reply into `submit` 3 of 3, and Gemini accepted every request; with the thought dropped, the models wrote a different answer (similarity about 0.05), one shrank to 30 characters, and the closing-note model submitted a 170-character answer. | In force. | A measurement shows the thought is better dropped, or DSPy keeps it in a form providers accept. | | 2026-10-12 | In that plain-text step (row of 2026-10-07; a program with `finish: :auto`, the default), the text output is not presented as a field: the system message lists no output fields, the default agent line names no output ("You are an Agent. Use the supplied tools to gather information and take actions."), there is no "The outputs to produce are:" list, and the finish line is "When the final answer is ready, write it as plain text without calling a tool." followed by the field's description, once. Amended 2026-10-15: the finish line was "... reply without calling a tool."; "reply" is a common tool name, so for a program holding a `reply` tool it read as an instruction about that tool, and it now uses the sentence the other tool-less text answer already used. A host's `agent_line` still replaces the agent line. | `lib/imp/adapter/chat.ex` `default_system/2`, `with_guidance/5`; `test/react_v2_plain_text_answer_test.exs`. The answer is the whole message, so a named, typed output field described a place in the message that does not exist; models on several providers wrote the name into the message as a label (`answer:`, `**answer**`, `[[ ## answer ## ]]`), after notes about the turn, and the whole message, label and notes included, was the answer. The description was listed twice. DSPy has no plain-text finish to follow (row of 2026-10-07). | In force. | ReActV2 gives that signature a `submit` tool, or DSPy defines a plain-text finish whose prompt differs. | | 2026-10-08 | A stored history turn shows the outputs it recorded and nothing for the rest: no filler, and not DSPy 3.2.1's `None`. A turn with no recorded output is its user message alone. A predictor that names a tool-calls output and is not a tool loop keeps its recorded outputs as the assistant text on native replay. The system message of every format leaves out a history input, since history arrives as turns: a field typed `history`, or one the call supplies as an `Imp.History`. | deepfates/imp#261, deepfates/imp#258. `lib/imp/adapter/chat.ex` `recorded_reply/3`, `system_signature/2`, `test/history_test.exs`. A filler or `None` puts words in the model's mouth that it never wrote, and a `[[ ## history ## ]]` section in the structure is one no message in the request contains. DSPy 3.2.1 does both (`ChatAdapter.format_assistant_message_content`, `Adapter.format`). | In force. | DSPy stops writing missing history outputs or listing the history input, and Imp follows it. | diff --git a/docs/diving-deeper/react.md b/docs/diving-deeper/react.md index 26881145..24697a3d 100644 --- a/docs/diving-deeper/react.md +++ b/docs/diving-deeper/react.md @@ -58,18 +58,29 @@ not mean as the answer: a sentence about what it will do next, or a report of what it just did. The text then stays in the history as the step's thought, and only `submit`'s argument is the answer. -After such a step the loop goes on with an ordinary step: every tool, -`tool_choice: "auto"`, and the thought shown as the request's last message -when the tools are held natively (as in section 3; with written tool calls -the request ends on the tool listing, after the thought). The model can -then do what it said it would, or submit what it wrote; a thought that was +After such a step the loop goes on with an ordinary step: every tool and +`tool_choice: "auto"`. The thought stays the model's own message, and one +user message follows it, naming the signature's outputs: + +```text +Your message above was not sent. To reply, call `submit` with `answer`; to keep working, call a tool. +``` + +That line follows every thought-only step, in later requests and on replay +too, so each request is the previous one plus the newest exchange. With +written tool calls the request then ends, as every such request does, on +the tool listing. The model can then do what it said it would, or submit +what it wrote; a thought that was only a closing note can be submitted as an empty answer. A second thought-only step in a row, or a step that says nothing, interrupts the turn (`:empty_tool_calls`) and the forced `submit` follows. Once the process's -`Imp.Deadline` has passed, no further request is sent. Measured on real final steps from 11 models, most submitted the -thought's reply on that ordinary step, unforced, Meta's models included, -whose provider cannot force a tool; some went on with the work they had -announced; about one in eight wrote text again. Forcing at once, as DSPy +`Imp.Deadline` has passed, no further request is sent. Measured on real +final steps from 11 model families, with that line after the thought no +sample wrote text again, and replies were submitted intact, Meta's models +included, whose provider cannot force a tool. Shown the thought back as a +user message instead, some models took it for an echo of their own output +and submitted nothing; given only "Your message above was not sent.", they +asked about their status and took extra steps. Forcing at once, as DSPy does, took the announced action away and added a request whose failure lost the reply. It costs one more request whenever the model ends in prose. The default, `finish: :auto`, lets the signature decide as above. The diff --git a/lib/imp/adapter/chat.ex b/lib/imp/adapter/chat.ex index 945f100f..d3668e55 100644 --- a/lib/imp/adapter/chat.ex +++ b/lib/imp/adapter/chat.ex @@ -106,7 +106,8 @@ defmodule Imp.Adapter.Chat do history_note_renderer: [type: {:fun, 2}], # Loop guidance a program passes as data rather than writing into # `signature.instructions`: `%{submit_tool:, input_names:, output_names:, - # tool_names:}`. + # tool_names:}`, and from ReActV2 `unsent_note:` (a user message after + # each thought-only step) and, on its forced `submit`, `forced_submit:`. guidance: [type: {:or, [:map, nil]}], # The guidance line that tells the model it is an agent. Absent, it is # DSPy ReActV2's "You are an Agent. Use the supplied tools to produce @@ -163,7 +164,9 @@ defmodule Imp.Adapter.Chat do tool_result: tool_result_renderer, history_note: Keyword.get(opts, :history_note_renderer) || (&no_history_note/2), submit_is_text?: submit_is_text?(Keyword.get(opts, :guidance)), - loop?: not is_nil(Keyword.get(opts, :guidance)) + loop?: not is_nil(Keyword.get(opts, :guidance)), + unsent_note: guidance_value(Keyword.get(opts, :guidance), :unsent_note), + forced_submit?: guidance_value(Keyword.get(opts, :guidance), :forced_submit) == true } {history_messages, history_fields} = extract_history(signature, inputs, renderers) @@ -1363,8 +1366,11 @@ defmodule Imp.Adapter.Chat do end defp render_history_turns(signature, turns, renderers) do + last_shown = last_shown_index(turns) + turns - |> Enum.flat_map(fn turn -> + |> Enum.with_index() + |> Enum.flat_map(fn {turn, index} -> turn = Imp.Example.new(turn) |> Imp.Example.to_map() messages = @@ -1390,10 +1396,38 @@ defmodule Imp.Adapter.Chat do |> Enum.reject(&(is_nil(&1) or blank_message?(&1))) end - messages ++ history_note_messages(signature, turn, renderers.history_note) + messages ++ + unsent_note_messages(turn, index == last_shown, renderers) ++ + history_note_messages(signature, turn, renderers.history_note) end) end + # A loop that continues after a thought-only step (ReActV2 under + # `finish: :submit`) says, after each such step, that its message was not + # sent and how to go on, as a user message: in every request and on replay, + # natively and with written tool calls alike. The one exception is the + # forced `submit`'s own last thought, which `thought_as_request/4` shows on + # the user side instead. + defp unsent_note_messages(turn, last?, renderers) do + note = renderers.unsent_note + + if is_binary(note) and thought_only_step?(turn) and not (last? and renderers.forced_submit?), + do: [%{role: :user, content: note}], + else: [] + end + + # The index of the last turn that is not a silent step (see + # `last_history_turn/2`). + defp last_shown_index(turns) do + turns + |> Enum.with_index() + |> Enum.reverse() + |> Enum.find_value(fn {turn, index} -> if not silent_step?(turn), do: index end) + end + + defp guidance_value(%{} = guidance, key), do: Map.get(guidance, key) + defp guidance_value(_guidance, _key), do: nil + # The note is what the model reads next after the turn it is about, so it is # a user message directly behind that turn's own messages. A renderer that # returns nothing adds nothing. diff --git a/lib/imp/predict/react_v2.ex b/lib/imp/predict/react_v2.ex index 0cbc78dd..64a893a8 100644 --- a/lib/imp/predict/react_v2.ex +++ b/lib/imp/predict/react_v2.ex @@ -25,10 +25,11 @@ defmodule Imp.Predict.ReActV2 do tool is a thought, not the answer. It is for a program whose model writes text it does not mean as the answer, such as a sentence about what it will do next, or a report of what it did. Under `finish: :submit` a thought-only - step is followed by an ordinary step (every tool, `tool_choice: "auto"`, - the thought shown as the request's last message when the tools are held - natively), so the model can do what - it announced or submit what it wrote; a second thought-only step in a row, + step is followed by an ordinary step (every tool, `tool_choice: "auto"`), + in which the thought stays the model's own message and a user message + after it says "Your message above was not sent. To reply, call `submit` + with `answer`; to keep working, call a tool." (naming the signature's + outputs), so the model can do what it announced or submit what it wrote; a second thought-only step in a row, or a step that says nothing, is the `:empty_tool_calls` interruption and the forced `submit` follows. Every other signature with `submit` keeps DSPy's ending: its first thought-only step is that interruption. The @@ -818,10 +819,9 @@ defmodule Imp.Predict.ReActV2 do # Under `finish: :submit`, a step that wrote a thought and called nothing is # followed by an ordinary step, not at once by the forced `submit`: the - # same tools and `tool_choice: "auto"`, with the thought as the request's - # last message when the tools are held natively (`Imp.Adapter.Chat`'s - # `thought_as_request`; with written tool calls the request ends on the - # tool listing, after the thought). Forcing at once + # same tools and `tool_choice: "auto"`, with the thought as the model's own + # message and the unsent note after it (`guidance/3`; with written tool + # calls the request then ends on the tool listing). Forcing at once # took every other tool away from a model that had just said what it would # do next, and made a request that, if it failed, lost the reply the thought # held. Measured on real final steps across 11 models (decisions.md, row of @@ -1195,10 +1195,18 @@ defmodule Imp.Predict.ReActV2 do end end + # The forced request's guidance says so: its last thought, if the history + # ends on one, is shown as the request's last user message (probe 4, + # decisions.md row of 2026-10-16), not followed by the unsent note. defp forced_submit_program(react, tool_choice) do %{ react.react - | config: + | adapter_opts: + Keyword.update(react.react.adapter_opts, :guidance, nil, fn + %{} = guidance -> Map.put(guidance, :forced_submit, true) + other -> other + end), + config: Keyword.merge(react.react.config, tools: react.tools @@ -2127,10 +2135,37 @@ defmodule Imp.Predict.ReActV2 do input_names: Imp.Signature.input_names(signature), output_names: Imp.Signature.output_names(signature), outputs: signature.outputs, - tool_names: tools |> Map.keys() |> Enum.sort() + tool_names: tools |> Map.keys() |> Enum.sort(), + unsent_note: unsent_note(signature, finish) } end + # Under `finish: :submit` a thought-only step is followed by an ordinary + # step, and the adapter tells the model, after that step's message, what + # became of it and what its two ways on are. This is a fact about the step + # (nothing a step writes is sent; only `submit` is), so it is rendered + # after every thought-only step, the same in every request and on replay. + # Measured on real final steps across 11 model families (decisions.md, row + # of 2026-10-16 on the unsent note): with this line after the thought, no sample wrote text + # again and replies were submitted intact; with the thought shown back as a + # user message, some models took it for an echo of their own output and + # submitted nothing; with the first sentence alone, models probed their + # status and took extra steps. + defp unsent_note(signature, :submit) do + fields = signature |> Imp.Signature.output_names() |> Enum.map(&"`#{&1}`") + + with_fields = + case fields do + [one] -> one + several -> Enum.join(Enum.drop(several, -1), ", ") <> " and " <> List.last(several) + end + + "Your message above was not sent. To reply, call `submit` with #{with_fields}; " <> + "to keep working, call a tool." + end + + defp unsent_note(_signature, _finish), do: nil + defp provider_tool_config(tools, order, signature) do [ tools: tools |> ordered_tools(order) |> Enum.map(&tool_description(&1, signature)), diff --git a/test/react_v2_finish_submit_test.exs b/test/react_v2_finish_submit_test.exs index f1300d6b..ec6c263e 100644 --- a/test/react_v2_finish_submit_test.exs +++ b/test/react_v2_finish_submit_test.exs @@ -57,15 +57,21 @@ defmodule ReActV2FinishSubmitTest do [{_step, step_opts}, {continued, continued_opts}] = requests() assert Enum.map(step_opts[:tools], & &1.function.name) == ["look", "submit"] - # The next request is an ordinary step: every tool, nothing forced, and - # the thought as its last message. + # The next request is an ordinary step: every tool, nothing forced, the + # thought as the model's own message, then the line saying it was not + # sent. assert Enum.map(continued_opts[:tools], & &1.function.name) == ["look", "submit"] assert continued_opts[:tool_choice] == "auto" - assert List.last(continued) == %{ - role: :user, - content: "[[ ## next_thought ## ]]\nNow let me look at the box." - } + assert continued |> Enum.take(-2) |> Enum.map(&Map.take(&1, [:role, :content])) == [ + %{role: :assistant, content: "Now let me look at the box."}, + %{ + role: :user, + content: + "Your message above was not sent. To reply, call `submit` with `answer`; " <> + "to keep working, call a tool." + } + ] # The thought stays in the history as the model's step. [thought_step, _submit_step] = prediction.metadata.history.messages diff --git a/test/react_v2_thought_continues_test.exs b/test/react_v2_thought_continues_test.exs index 18ca0e33..0401c76f 100644 --- a/test/react_v2_thought_continues_test.exs +++ b/test/react_v2_thought_continues_test.exs @@ -142,7 +142,7 @@ defmodule ReActV2ThoughtContinuesTest do # The process's deadline passes during the request named first; no request # after it is sent, and the turn ends without an answer. - @slow 20 + @slow 150 @refusal {:error, %Imp.LMError{ status: 400, @@ -190,13 +190,15 @@ defmodule ReActV2ThoughtContinuesTest do program = Imp.react(@signature, [look()], lm: lm, finish: @finish) assert {:ok, prediction} = - Imp.Deadline.with_deadline(10, fn -> + Imp.Deadline.with_deadline(100, fn -> Imp.call(program, %{ticket: "What is in the box?"}) end) assert prediction.metadata.termination_reason == :incomplete assert prediction.metadata.termination_cause == :deadline_exceeded - assert length(tool_choices()) == length(@replies) + # Nothing after the slow request. (On a loaded machine the deadline + # can pass even earlier, which sends fewer, never more.) + assert length(tool_choices()) <= length(@replies) end end end diff --git a/test/react_v2_unsent_note_test.exs b/test/react_v2_unsent_note_test.exs new file mode 100644 index 00000000..41f49238 --- /dev/null +++ b/test/react_v2_unsent_note_test.exs @@ -0,0 +1,110 @@ +defmodule ReActV2UnsentNoteTest do + use ExUnit.Case, async: true + + # Under `finish: :submit`, after a step that wrote a thought and called no + # tool, the model is told that its message was not sent and how to go on. + # The line follows every such step, in every request and on replay; only + # the forced `submit`'s own last thought is shown on the user side instead. + + defp look, do: Imp.tool(:look, "Look at a thing", fn _arguments -> "a cat" end) + + @thought %{next_thought: "Let me look in the box first.", tool_calls: []} + @look %{tool_calls: [%{id: "c1", name: "look", arguments: %{}}]} + @note "Your message above was not sent. To reply, call `submit` with `answer` and " <> + "`confidence`; to keep working, call a tool." + + defmodule TextOnlyLM do + @behaviour Imp.LM + defstruct [:handler] + + @impl true + def generate(%__MODULE__{handler: handler}, messages, opts), + do: {:ok, handler.(messages, opts)} + + def tool_calling_capability(%__MODULE__{}), do: false + end + + defp scripted(owner, replies, module \\ Imp.LM.Static) do + {:ok, agent} = Agent.start_link(fn -> replies end) + + handler = fn messages, opts -> + send(owner, {:request, messages, opts}) + + Agent.get_and_update(agent, fn + [reply | rest] -> {reply, rest} + [] -> {%{tool_calls: []}, []} + end) + end + + if module == TextOnlyLM, do: %TextOnlyLM{handler: handler}, else: module.new(handler: handler) + end + + defp requests(acc \\ []) do + receive do + {:request, messages, _opts} -> requests([messages | acc]) + after + 0 -> Enum.reverse(acc) + end + end + + defp submit(arguments), + do: %{tool_calls: [%{id: "s1", name: "submit", arguments: arguments}]} + + defp call(replies, lm_module \\ Imp.LM.Static) do + Imp.react("ticket -> answer, confidence: float", [look()], + lm: scripted(self(), replies, lm_module), + finish: :submit + ) + |> Imp.call(%{ticket: "What is in the box?"}) + end + + test "the line names every output, and stays after the thought in later requests" do + assert {:ok, prediction} = + call([@thought, @look, submit(%{"answer" => "A cat.", "confidence" => 1.0})]) + + assert prediction.metadata.termination_reason == :submit + + [_first, continued, after_look] = requests() + + assert continued |> Enum.take(-2) |> Enum.map(&Map.take(&1, [:role, :content])) == [ + %{role: :assistant, content: "Let me look in the box first."}, + %{role: :user, content: @note} + ] + + # The next request is the continued one plus the newest exchange. + assert Enum.take(after_look, length(continued)) == continued + end + + test "the forced submit shows its last thought on the user side, with no line after it" do + assert {:ok, prediction} = + call([@thought, @thought, submit(%{"answer" => "A cat.", "confidence" => 1.0})]) + + assert prediction.metadata.termination_reason == :forced_submit + + [_first, _continued, forced] = requests() + + assert forced |> Enum.take(-3) |> Enum.map(&Map.take(&1, [:role, :content])) == [ + %{role: :assistant, content: "Let me look in the box first."}, + %{role: :user, content: @note}, + %{role: :user, content: "[[ ## next_thought ## ]]\nLet me look in the box first."} + ] + end + + test "with written tool calls the line follows the thought, and the tool listing ends the request" do + thought = + "[[ ## next_thought ## ]]\nLet me look in the box first.\n\n[[ ## tool_calls ## ]]\n[]" + + submitted = + ~s([[ ## next_thought ## ]]\nDone.\n\n[[ ## tool_calls ## ]]\n) <> + ~s([{"name": "submit", "arguments": {"answer": "A cat.", "confidence": 1.0}}]) + + assert {:ok, prediction} = call([thought, submitted], TextOnlyLM) + assert prediction.metadata.termination_reason == :submit + + [_first, continued] = requests() + [step, note, listing] = Enum.take(continued, -3) + assert step.role == :assistant and step.content =~ "Let me look in the box first." + assert note == %{role: :user, content: @note} + assert listing.role == :user and listing.content =~ "[[ ## tools ## ]]" + end +end From 1ceeefffc7f68efad809cceddf46fccd4de1a06a Mon Sep 17 00:00:00 2001 From: deepfates Date: Thu, 15 Oct 2026 23:30:20 -0700 Subject: [PATCH 2/2] ReActV2: no unsent note after an answered thought; drop it on the forced 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. --- CHANGELOG.md | 17 +++++--- decisions.md | 2 +- docs/diving-deeper/react.md | 9 +++- lib/imp/adapter/chat.ex | 70 +++++++++++++++++++----------- lib/imp/predict/react_v2.ex | 23 +++++++--- test/react_v2_unsent_note_test.exs | 68 +++++++++++++++++++++++++++++ 6 files changed, 148 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2dd2315..6b20628f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,13 +40,16 @@ User-visible changes to Imp are recorded here. and a user message after it says "Your message above was not sent. To reply, call `submit` with `answer`; to keep working, call a tool." (naming the signature's outputs), instead of the forced `submit`. The line follows - every thought-only step, in later requests and on replay too. With written - tool calls the request ends on the tool listing after it. A second thought-only step in a row, or a - step that says nothing or only whitespace, still forces `submit` - (`:empty_tool_calls`), as does `max_iters`. Once the deadline has passed, - no further request is sent, continued or forced. A model that announced an action can take - it, and a reply in the thought is usually submitted unforced, also on - providers that cannot force a tool. Other signatures with `submit` are + every thought-only step, in later requests and on replay too, except a + thought the turn's extracted answer follows, and the forced `submit`'s own + last thought, which is shown as the request's last user message instead. + With written tool calls the request ends on the tool listing after it. A + second thought-only step in a row, or a step that says nothing or only + whitespace, still forces `submit` (`:empty_tool_calls`), as does + `max_iters`. Once the deadline has passed, no further request is sent, + continued or forced. A model that announced an action can take it, and a + reply in the thought is usually submitted unforced, also on providers that + cannot force a tool. Other signatures with `submit` are unchanged. - Once the process's `Imp.Deadline` has passed, ReActV2 sends no forced `submit`, no `"required"` retry and no extraction request (and, under diff --git a/decisions.md b/decisions.md index 5c4d8dff..feb8ab71 100644 --- a/decisions.md +++ b/decisions.md @@ -8,7 +8,7 @@ not necessarily when it was made. | Date | Decision | Source and reason | Status | Retires when | | --- | --- | --- | --- | --- | -| 2026-10-16 | Under `finish: :submit`, every thought-only ReActV2 step is followed, in every request and on replay, by one user message: "Your message above was not sent. To reply, call `submit` with ; to keep working, call a tool." The thought stays the model's own (assistant) message. The forced `submit`'s own last thought is the exception: it is shown as the request's last user message (the row on the forced request), with no note after it. | `lib/imp/predict/react_v2.ex` `unsent_note/2`, `lib/imp/adapter/chat.ex` `unsent_note_messages/3`, `test/react_v2_unsent_note_test.exs`. With the thought shown back as a user message under `[[ ## next_thought ## ]]`, some models read it as an echo or a pipeline artifact, or as their own message already sent, and ended with an empty `submit`: 4 of 74 owed replies lost in a rehearsal. Measured 2026-10-16 on 21 real final steps, 2 samples each, across 11 model families, after a plain-text step: thought as a user message, 5 samples wrote text again; thought kept with this note, none did, replies were submitted intact (similarity about 1.0), Meta's models accepted it with `tool_choice: auto`, and one family once submitted a closing note as its answer; with only the note's first sentence, models probed their status and took extra steps. The note is a sentence Imp writes, which the last request does not; it states a fact about the step (only `submit` is sent) and the two ways on. | In force. | A measurement finds a wording or shape that loses fewer replies. | +| 2026-10-16 | Under `finish: :submit`, every thought-only ReActV2 step is followed, in every request and on replay, by one user message: "Your message above was not sent. To reply, call `submit` with ; to keep working, call a tool." The thought stays the model's own (assistant) message. Two exceptions: the forced `submit`'s own last thought, when that request has nothing new to ask, is shown as the request's last user message (the row on the forced request), with no note after it; and a thought that the turn's extracted outputs directly follow was answered and gets no note. This is a sentence Imp writes in a request, where otherwise it adds nothing but a host's `last_request_note`. | `lib/imp/predict/react_v2.ex` `unsent_note/2`, `lib/imp/adapter/chat.ex` `unsent_note_messages/3`, `test/react_v2_unsent_note_test.exs`. With the thought shown back as a user message under `[[ ## next_thought ## ]]`, some models read it as an echo or a pipeline artifact, or as their own message already sent, and ended with an empty `submit`: 4 of 74 owed replies lost in a rehearsal. Measured 2026-10-16 on 21 real final steps, 2 samples each, across 11 model families, after a plain-text step: thought as a user message, 5 samples wrote text again; thought kept with this note, none did, replies were submitted intact (similarity about 1.0), Meta's models accepted it with `tool_choice: auto`, and one family once submitted a closing note as its answer; with only the note's first sentence, models probed their status and took extra steps. The note is a sentence Imp writes, which the last request does not; it states a fact about the step (only `submit` is sent) and the two ways on. | In force. | A measurement finds a wording or shape that loses fewer replies. | | 2026-10-16 | Under `finish: :submit`, a ReActV2 step that writes a thought and calls no tool is followed by an ordinary step (every tool, `tool_choice: "auto"`; amended the same day: the thought stays the model's own message, followed by the unsent note, the row on that note), not at once by the forced `submit`; a second thought-only step in a row, or a step that says nothing or only whitespace, is the `:empty_tool_calls` interruption. `max_iters` ends the turn with the forced `submit` as before; once the deadline has passed no further request is sent and the turn is `:incomplete` (`:deadline_exceeded`). With written tool calls the request ends on the tool listing, after the thought. Signatures with `submit` under `finish: :auto` keep DSPy's ending. | `lib/imp/predict/react_v2.ex` `continue_after_thought?/3`, `test/react_v2_thought_continues_test.exs`. Forcing at once took every other tool away from a model that had just announced its next action, and added a request whose failure lost the reply the thought held. Measured 2026-10-16 on 42 real final steps from 11 models through OpenRouter, sending that ordinary step: most samples called `submit` with the thought's reply, copied or paraphrased (Meta's models 6 of 6, unforced; Kimi, GPT and Gemini models likewise); a thought that was a closing note was submitted as an empty answer; a DeepSeek model went on to fetch what it had said it would read; 5 of 42 wrote text again. DSPy's ReActV2 forces `submit` after any step that calls no tool. | In force. | A measurement shows a different bound, or forcing at once, loses fewer answers. | | 2026-10-16 | ReActV2's forced `submit` request, when the step before it wrote a thought and called nothing, shows that thought as the request's last message, a user message under its field's marker (`[[ ## next_thought ## ]]`), not as the assistant turn the history records it as. DSPy's ReActV2 leaves the thought out of the forced request. | `lib/imp/adapter/chat.ex` `thought_as_request/4`, `test/react_v2_finish_submit_test.exs`. As a trailing assistant turn the request is refused by providers that do not take an assistant prefix: Mistral ("Cannot set an assistant prefix with function calling") and Gemini ("Requests ending with a model turn are not supported"), probed through OpenRouter 2026-10-16. Dropping it, as DSPy does, made models regenerate finished answers with reasoning off, 2 of 16 to the output limit (deepfates/imp#336). Measured 2026-10-16 on real final steps re-sent through OpenRouter: with the thought as this user message, the submitted answer kept the plain-text reply (text similarity 0.8 to 1.0 across Kimi, GPT, DeepSeek and Muse models), a model whose plain text was a closing note submitted an empty answer as it intended, Meta's models (which accept only `tool_choice: auto`) copied the reply into `submit` 3 of 3, and Gemini accepted every request; with the thought dropped, the models wrote a different answer (similarity about 0.05), one shrank to 30 characters, and the closing-note model submitted a 170-character answer. | In force. | A measurement shows the thought is better dropped, or DSPy keeps it in a form providers accept. | | 2026-10-12 | In that plain-text step (row of 2026-10-07; a program with `finish: :auto`, the default), the text output is not presented as a field: the system message lists no output fields, the default agent line names no output ("You are an Agent. Use the supplied tools to gather information and take actions."), there is no "The outputs to produce are:" list, and the finish line is "When the final answer is ready, write it as plain text without calling a tool." followed by the field's description, once. Amended 2026-10-15: the finish line was "... reply without calling a tool."; "reply" is a common tool name, so for a program holding a `reply` tool it read as an instruction about that tool, and it now uses the sentence the other tool-less text answer already used. A host's `agent_line` still replaces the agent line. | `lib/imp/adapter/chat.ex` `default_system/2`, `with_guidance/5`; `test/react_v2_plain_text_answer_test.exs`. The answer is the whole message, so a named, typed output field described a place in the message that does not exist; models on several providers wrote the name into the message as a label (`answer:`, `**answer**`, `[[ ## answer ## ]]`), after notes about the turn, and the whole message, label and notes included, was the answer. The description was listed twice. DSPy has no plain-text finish to follow (row of 2026-10-07). | In force. | ReActV2 gives that signature a `submit` tool, or DSPy defines a plain-text finish whose prompt differs. | diff --git a/docs/diving-deeper/react.md b/docs/diving-deeper/react.md index 24697a3d..4788183a 100644 --- a/docs/diving-deeper/react.md +++ b/docs/diving-deeper/react.md @@ -67,7 +67,10 @@ Your message above was not sent. To reply, call `submit` with `answer`; to keep ``` That line follows every thought-only step, in later requests and on replay -too, so each request is the previous one plus the newest exchange. With +too, so each request is the previous one plus the newest exchange. It is +left out after a thought the turn's extracted answer follows (that thought +was answered), and the forced `submit`'s own last thought is shown on the +user side instead (section 3). With written tool calls the request then ends, as every such request does, on the tool listing. The model can then do what it said it would, or submit what it wrote; a thought that was @@ -156,7 +159,9 @@ If the last request does not produce an answer either, the turn ends `termination_cause`, and `Imp.Prediction.complete?/1` is false for it; a turn without an answer is never reported complete. -Imp adds nothing to that request unless you pass `last_request_note:`, one +Imp adds nothing to that request (beyond the unsent line that follows each +earlier thought-only step under `finish: :submit`, section 2) unless you +pass `last_request_note:`, one line of your own text, sent as a user message and kept in the history. Some programs act through a tool: they publish, send or record something by diff --git a/lib/imp/adapter/chat.ex b/lib/imp/adapter/chat.ex index d3668e55..19537e6c 100644 --- a/lib/imp/adapter/chat.ex +++ b/lib/imp/adapter/chat.ex @@ -166,7 +166,8 @@ defmodule Imp.Adapter.Chat do submit_is_text?: submit_is_text?(Keyword.get(opts, :guidance)), loop?: not is_nil(Keyword.get(opts, :guidance)), unsent_note: guidance_value(Keyword.get(opts, :guidance), :unsent_note), - forced_submit?: guidance_value(Keyword.get(opts, :guidance), :forced_submit) == true + forced_submit?: guidance_value(Keyword.get(opts, :guidance), :forced_submit) == true, + output_names: guidance_value(Keyword.get(opts, :guidance), :output_names) || [] } {history_messages, history_fields} = extract_history(signature, inputs, renderers) @@ -184,11 +185,14 @@ defmodule Imp.Adapter.Chat do } # With nothing new to ask, the request ends on the history. If that ends - # on a thought-only step, the thought becomes the last user message (see - # `thought_as_request/4`): before ReActV2's forced `submit`, and also when - # a host resumes a program with `submit` on a held history that ends that - # way and gives no new inputs. Either way the request must not end on the - # model's own message. + # on a thought-only step shown as the model's own message, the thought + # becomes the last user message (see `thought_as_request/4`): before + # ReActV2's forced `submit` (which drops the unsent note after it), and + # when a host resumes a program with `submit`, but no unsent note, on a + # held history that ends that way and gives no new inputs. Under + # `finish: :submit` an ordinary request already ends on the unsent note, + # a user message. Either way the request does not end on the model's own + # message. {history_messages, trailing} = if opts[:omit_empty_request] and blank_message?(request), do: {thought_as_request(history_messages, signature, inputs, renderers), []}, @@ -1295,14 +1299,16 @@ defmodule Imp.Adapter.Chat do # history keeps the step as the model's turn. A loop whose answer is plain # text has no such step, and its replies are not fields. defp thought_as_request(messages, signature, inputs, renderers) do + candidate = without_forced_note(messages, renderers) + with false <- renderers.submit_is_text?, %{role: :assistant, content: thought} = last when is_binary(thought) <- - List.last(messages), + List.last(candidate), false <- Map.has_key?(last, :tool_calls), true <- thought_only_step?(last_history_turn(signature, inputs)) do field = Imp.Signature.Field.new(meta_field(signature, :text_field) || :next_thought, :input) - List.replace_at(messages, -1, %{ + List.replace_at(candidate, -1, %{ role: :user, content: renderers.input_section.(field, thought) }) @@ -1311,6 +1317,20 @@ defmodule Imp.Adapter.Chat do end end + # The forced `submit` shows its last thought on the user side (probe 4, + # decisions.md), so the unsent note after it goes. It goes only when the + # thought is then moved, which the caller decides from the result: any + # other request keeps the note. + defp without_forced_note(messages, %{forced_submit?: true, unsent_note: note}) + when is_binary(note) do + case List.last(messages) do + %{role: :user, content: ^note} -> Enum.drop(messages, -1) + _other -> messages + end + end + + defp without_forced_note(messages, _renderers), do: messages + # The last turn that is a step with something to show. A step that called # nothing and wrote nothing renders no assistant message (ReActV2 records # its extracted outputs that way), so the message before it is the thought @@ -1366,11 +1386,9 @@ defmodule Imp.Adapter.Chat do end defp render_history_turns(signature, turns, renderers) do - last_shown = last_shown_index(turns) - turns - |> Enum.with_index() - |> Enum.flat_map(fn {turn, index} -> + |> Enum.zip(Enum.drop(turns, 1) ++ [nil]) + |> Enum.flat_map(fn {turn, next} -> turn = Imp.Example.new(turn) |> Imp.Example.to_map() messages = @@ -1397,7 +1415,7 @@ defmodule Imp.Adapter.Chat do end messages ++ - unsent_note_messages(turn, index == last_shown, renderers) ++ + unsent_note_messages(turn, next, renderers) ++ history_note_messages(signature, turn, renderers.history_note) end) end @@ -1405,24 +1423,26 @@ defmodule Imp.Adapter.Chat do # A loop that continues after a thought-only step (ReActV2 under # `finish: :submit`) says, after each such step, that its message was not # sent and how to go on, as a user message: in every request and on replay, - # natively and with written tool calls alike. The one exception is the - # forced `submit`'s own last thought, which `thought_as_request/4` shows on - # the user side instead. - defp unsent_note_messages(turn, last?, renderers) do + # natively and with written tool calls alike. Two exceptions. A thought + # that the turn's outputs follow, with no step between (ReActV2's extracted + # answer, recorded as a silent step), was answered, so the line would be + # false. And the forced `submit`'s own last thought is shown on the user + # side instead (`thought_as_request/4` drops the line there). + defp unsent_note_messages(turn, next, renderers) do note = renderers.unsent_note - if is_binary(note) and thought_only_step?(turn) and not (last? and renderers.forced_submit?), + if is_binary(note) and thought_only_step?(turn) and not answered_next?(next, renderers), do: [%{role: :user, content: note}], else: [] end - # The index of the last turn that is not a silent step (see - # `last_history_turn/2`). - defp last_shown_index(turns) do - turns - |> Enum.with_index() - |> Enum.reverse() - |> Enum.find_value(fn {turn, index} -> if not silent_step?(turn), do: index end) + defp answered_next?(nil, _renderers), do: false + + defp answered_next?(next, renderers) do + next = Imp.Example.new(next) |> Imp.Example.to_map() + + silent_step?(next) and + Enum.any?(renderers.output_names, &(not is_nil(fetch_field(next, &1)))) end defp guidance_value(%{} = guidance, key), do: Map.get(guidance, key) diff --git a/lib/imp/predict/react_v2.ex b/lib/imp/predict/react_v2.ex index 64a893a8..6106243e 100644 --- a/lib/imp/predict/react_v2.ex +++ b/lib/imp/predict/react_v2.ex @@ -170,7 +170,10 @@ defmodule Imp.Predict.ReActV2 do user message and kept in the returned history like any other turn. It is recorded as a step that said nothing and called nothing, so it renders as that user message alone, with no assistant reply after it, in the request and - whenever the history is passed back. Imp writes no sentence of its own. + whenever the history is passed back. Imp writes no sentence of its own + there, except that under `finish: :submit` each earlier thought-only step + in the history keeps the unsent note after it (see "Which signatures get + `submit`"). ## Per-call options @@ -345,7 +348,8 @@ defmodule Imp.Predict.ReActV2 do "One line of host text put in front of the last request of an interrupted " <> "turn, as a user message, and kept in the history: the last " <> "request for a signature with one unconstrained text output, the forced `submit` for " <> - "every other. `nil` says nothing; Imp writes no sentence of its own." + "every other. `nil` says nothing; Imp writes no sentence of its own, " <> + "except the unsent note after earlier thought-only steps under `finish: :submit`." ], finish_on: [ type: {:custom, __MODULE__, :validate_finish_on, []}, @@ -1202,10 +1206,17 @@ defmodule Imp.Predict.ReActV2 do %{ react.react | adapter_opts: - Keyword.update(react.react.adapter_opts, :guidance, nil, fn - %{} = guidance -> Map.put(guidance, :forced_submit, true) - other -> other - end), + case Keyword.fetch(react.react.adapter_opts, :guidance) do + {:ok, %{} = guidance} -> + Keyword.put( + react.react.adapter_opts, + :guidance, + Map.put(guidance, :forced_submit, true) + ) + + _none -> + react.react.adapter_opts + end, config: Keyword.merge(react.react.config, tools: diff --git a/test/react_v2_unsent_note_test.exs b/test/react_v2_unsent_note_test.exs index 41f49238..27514c99 100644 --- a/test/react_v2_unsent_note_test.exs +++ b/test/react_v2_unsent_note_test.exs @@ -107,4 +107,72 @@ defmodule ReActV2UnsentNoteTest do assert note == %{role: :user, content: @note} assert listing.role == :user and listing.content =~ "[[ ## tools ## ]]" end + + defp shape(messages), do: Enum.map(messages, &{&1.role, to_string(&1.content)}) + + defp thought_step(fields, thought), + do: + Map.merge(fields, %{ + next_thought: thought, + tool_calls: %Imp.Adapter.Types.ToolCalls{tool_calls: []}, + tool_call_results: [] + }) + + test "on replay, the line follows each unsent thought, and not one the extracted answer followed" do + # A held turn: a thought, a second one (the forced request's), the forced + # reply's thought, then the extracted answer, recorded as a silent step. + history = + Imp.History.new([ + thought_step(%{ticket: "What is in the box?"}, "Let me look."), + thought_step(%{}, "Still looking."), + thought_step(%{}, "A cat, I think."), + %{ + answer: "A cat.", + confidence: 0.5, + tool_calls: %Imp.Adapter.Types.ToolCalls{tool_calls: []}, + tool_call_results: [] + } + ]) + + program = + Imp.react("ticket -> answer, confidence: float", [look()], + lm: scripted(self(), [submit(%{"answer" => "A dog.", "confidence" => 1.0})]), + finish: :submit + ) + + assert {:ok, _} = Imp.call(program, %{ticket: "And now?", history: history}) + [replayed] = requests() + + assert replayed |> tl() |> shape() == [ + {:user, "[[ ## ticket ## ]]\nWhat is in the box?"}, + {:assistant, "Let me look."}, + {:user, @note}, + {:assistant, "Still looking."}, + {:user, @note}, + {:assistant, "A cat, I think."}, + {:user, "[[ ## ticket ## ]]\nAnd now?"} + ] + end + + test "a forced request with new inputs keeps the line after the held thought" do + history = Imp.History.new([thought_step(%{ticket: "What is in the box?"}, "Let me look.")]) + + program = + Imp.react("ticket -> answer, confidence: float", [look()], + lm: scripted(self(), [submit(%{"answer" => "A cat.", "confidence" => 1.0})]), + finish: :submit + ) + + assert {:ok, prediction} = + Imp.call(program, %{ticket: "And now?", history: history, max_iters: 0}) + + assert prediction.metadata.termination_reason == :forced_submit + [forced] = requests() + + assert forced |> Enum.take(-3) |> shape() == [ + {:assistant, "Let me look."}, + {:user, @note}, + {:user, "[[ ## ticket ## ]]\nAnd now?"} + ] + end end