From c7241808e62ffe857af6fa9ef141d267e03add3d Mon Sep 17 00:00:00 2001 From: deepfates Date: Fri, 16 Oct 2026 01:00:50 -0700 Subject: [PATCH 1/2] ReActV2: a plain-text answer step is never a thought; plain-text replay keeps roles alternating Under finish: :submit, a step recorded under finish: :auto as the turn's plain-text answer (text plus the signature's outputs) was treated as a thought-only step: the unsent line followed each delivered answer, and models re-sent them. A step that carries the outputs is an answer: no line, never moved to the user side. In a plain-text loop, a submit-era thought-only step followed by its submit step replayed as two assistant messages in a row; they are now one message, the thought then the answer, as a step that wrote both renders. --- CHANGELOG.md | 16 +++++ lib/imp/adapter/chat.ex | 67 +++++++++++++++++--- test/react_v2_ending_switch_test.exs | 92 ++++++++++++++++++++++++++++ test/react_v2_test.exs | 4 +- 4 files changed, 168 insertions(+), 11 deletions(-) create mode 100644 test/react_v2_ending_switch_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index e0187faa..001cb413 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ User-visible changes to Imp are recorded here. ## Unreleased +### Fixed + +- Under ReActV2's `finish: :submit`, a step that answered in plain text + (recorded under `finish: :auto`, text and outputs both) was taken for a + thought when its history was passed on: the "Your message above was not + sent." line followed each answer that had been delivered, and models sent + those answers again. A step that carries the signature's outputs is an + answer: it gets no line, and the forced `submit` never moves it to the + user side. Only a step with text, no calls and no outputs is a thought. +- In a plain-text ReActV2 loop, a history written under `submit` that holds + a thought-only step followed by the `submit` that answered it no longer + replays as two assistant messages in a row: they are one message, the + thought then the answer, as a step that wrote both renders. Some APIs + require roles to alternate. Messages that carry calls or provider + reasoning are not joined. + ## 0.14.0 — 2026-10-16 ### Added diff --git a/lib/imp/adapter/chat.ex b/lib/imp/adapter/chat.ex index 19537e6c..385380a3 100644 --- a/lib/imp/adapter/chat.ex +++ b/lib/imp/adapter/chat.ex @@ -1305,7 +1305,8 @@ defmodule Imp.Adapter.Chat do %{role: :assistant, content: thought} = last when is_binary(thought) <- List.last(candidate), false <- Map.has_key?(last, :tool_calls), - true <- thought_only_step?(last_history_turn(signature, inputs)) do + true <- + thought_only_step?(last_history_turn(signature, inputs), renderers.output_names) do field = Imp.Signature.Field.new(meta_field(signature, :text_field) || :next_thought, :input) List.replace_at(candidate, -1, %{ @@ -1360,16 +1361,26 @@ defmodule Imp.Adapter.Chat do String.trim(blank_to_empty(fetch_field(turn, :next_thought))) == "" end - defp thought_only_step?(nil), do: false + # A step that wrote text, called nothing and carries none of the task's + # outputs. A step that carries them was the answer: a plain-text loop + # (ReActV2 under `finish: :auto`) records its answer step that way, text + # and outputs both, and a history keeps it when the program's ending later + # changes. An answer is never a thought, so it is never followed by the + # unsent note nor moved to the user side. + defp thought_only_step?(nil, _output_names), do: false - defp thought_only_step?(turn) do + defp thought_only_step?(turn, output_names) do turn = Imp.Example.new(turn) |> Imp.Example.to_map() native_tool_history_turn?(turn) and normalize_history_tool_calls(fetch_field(turn, :tool_calls)) == [] and - String.trim(blank_to_empty(fetch_field(turn, :next_thought))) != "" + String.trim(blank_to_empty(fetch_field(turn, :next_thought))) != "" and + not carries_outputs?(turn, output_names) end + defp carries_outputs?(turn, output_names), + do: Enum.any?(output_names, &(not is_nil(fetch_field(turn, &1)))) + defp extract_history(signature, inputs, renderers) do signature.inputs |> Enum.reduce({[], MapSet.new()}, fn field, {messages, fields} -> @@ -1386,6 +1397,44 @@ defmodule Imp.Adapter.Chat do end defp render_history_turns(signature, turns, renderers) do + turns + |> render_history_turn_messages(signature, renderers) + |> join_plain_text_replies(renderers) + end + + # In a plain-text loop a history written under `submit` (ReActV2 with + # `finish: :submit`, or before a program's ending changed) can hold a + # thought-only step followed by the `submit` step that answered it, which + # renders as the answer's text: two assistant messages in a row. Some APIs + # want roles to alternate, so they are one message, the thought then the + # answer, as a step that wrote both renders. Messages that carry calls or + # provider reasoning are left as they are. + defp join_plain_text_replies(messages, %{submit_is_text?: true}) do + messages + |> Enum.reduce([], fn + message, [previous | rest] = acc -> + if plain_assistant_text?(message) and plain_assistant_text?(previous), + do: [%{previous | content: join_text(previous.content, message.content)} | rest], + else: [message | acc] + + message, [] -> + [message] + end) + |> Enum.reverse() + end + + defp join_plain_text_replies(messages, _renderers), do: messages + + defp plain_assistant_text?(%{role: :assistant, content: content} = message) + when is_binary(content) do + Map.get(message, :tool_calls) in [nil, []] and + not Map.has_key?(message, :reasoning_content) and + not Map.has_key?(message, :reasoning_details) + end + + defp plain_assistant_text?(_message), do: false + + defp render_history_turn_messages(turns, signature, renderers) do turns |> Enum.zip(Enum.drop(turns, 1) ++ [nil]) |> Enum.flat_map(fn {turn, next} -> @@ -1431,9 +1480,10 @@ defmodule Imp.Adapter.Chat do defp unsent_note_messages(turn, next, renderers) do note = renderers.unsent_note - if is_binary(note) and thought_only_step?(turn) and not answered_next?(next, renderers), - do: [%{role: :user, content: note}], - else: [] + if is_binary(note) and thought_only_step?(turn, renderers.output_names) and + not answered_next?(next, renderers), + do: [%{role: :user, content: note}], + else: [] end defp answered_next?(nil, _renderers), do: false @@ -1441,8 +1491,7 @@ defmodule Imp.Adapter.Chat do 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)))) + silent_step?(next) and carries_outputs?(next, renderers.output_names) end defp guidance_value(%{} = guidance, key), do: Map.get(guidance, key) diff --git a/test/react_v2_ending_switch_test.exs b/test/react_v2_ending_switch_test.exs new file mode 100644 index 00000000..dc90eb11 --- /dev/null +++ b/test/react_v2_ending_switch_test.exs @@ -0,0 +1,92 @@ +defmodule ReActV2EndingSwitchTest do + use ExUnit.Case, async: true + + # A host can change a program's ending mid-conversation and pass the same + # history on. A step that answered in plain text (`finish: :auto`) was the + # answer, not a thought, under either ending; a `submit`-era history + # replayed in a plain-text loop keeps roles alternating. + + defp look, do: Imp.tool(:look, "Look at a thing", fn _arguments -> "a cat" end) + + @note_start "Your message above was not sent." + + defp scripted(owner, replies) do + {:ok, agent} = Agent.start_link(fn -> replies end) + + Imp.LM.Static.new( + handler: fn messages, _opts -> + send(owner, {:request, messages}) + + Agent.get_and_update(agent, fn + [reply | rest] -> {reply, rest} + [] -> {%{tool_calls: []}, []} + end) + end + ) + end + + defp requests(acc \\ []) do + receive do + {:request, messages} -> requests([messages | acc]) + after + 0 -> Enum.reverse(acc) + end + end + + defp program(replies, finish), + do: Imp.react("ticket -> reply", [look()], lm: scripted(self(), replies), finish: finish) + + defp shape(messages), do: Enum.map(tl(messages), &{&1.role, to_string(&1.content)}) + + test "plain-text answers replayed under finish: :submit are answers, with no unsent line" do + {:ok, first} = + program([%{reply: "It is a cat.", tool_calls: []}], :auto) + |> Imp.call(%{ticket: "What is in the box?"}) + + {:ok, second} = + program([%{reply: "It is asleep.", tool_calls: []}], :auto) + |> Imp.call(%{ticket: "Is it awake?", history: first.metadata.history}) + + _plain = requests() + + submit = %{tool_calls: [%{id: "s1", name: "submit", arguments: %{"reply" => "Yes."}}]} + + assert {:ok, _} = + program([submit], :submit) + |> Imp.call(%{ticket: "Can I pet it?", history: second.metadata.history}) + + [replayed] = requests() + + assert shape(replayed) == [ + {:user, "[[ ## ticket ## ]]\nWhat is in the box?"}, + {:assistant, "It is a cat."}, + {:user, "[[ ## ticket ## ]]\nIs it awake?"}, + {:assistant, "It is asleep."}, + {:user, "[[ ## ticket ## ]]\nCan I pet it?"} + ] + + refute Enum.any?(replayed, &(to_string(&1.content) =~ @note_start)) + end + + test "a submit-era thought and answer replayed in a plain-text loop are one assistant message" do + replies = [ + %{next_thought: "Let me think.", tool_calls: []}, + %{tool_calls: [%{id: "s1", name: "submit", arguments: %{"reply" => "It is a cat."}}]} + ] + + {:ok, held} = program(replies, :submit) |> Imp.call(%{ticket: "What is in the box?"}) + _submit_era = requests() + + assert {:ok, _} = + program([%{reply: "Yes.", tool_calls: []}], :auto) + |> Imp.call(%{ticket: "Is it awake?", history: held.metadata.history}) + + [replayed] = requests() + + assert shape(replayed) == [ + {:user, "[[ ## ticket ## ]]\nWhat is in the box?"}, + {:assistant, "Let me think.\n\nIt is a cat."}, + {:user, "[[ ## ticket ## ]]\nIs it awake?"} + ] + end +end diff --git a/test/react_v2_test.exs b/test/react_v2_test.exs index be50bb91..51410f7f 100644 --- a/test/react_v2_test.exs +++ b/test/react_v2_test.exs @@ -901,11 +901,11 @@ defmodule ReActV2Test do messages = Imp.Adapter.Chat.format(signature, %{history: history}, no_submit) + # The two steps' texts are one assistant message, so roles alternate. assert [ %{role: :system}, %{role: :user}, - %{role: :assistant, content: "trying"}, - %{role: :assistant, content: "again\n\nRight key."}, + %{role: :assistant, content: "trying\n\nagain\n\nRight key."}, %{role: :user} ] = messages From 784832d69700bfdc56eec858d1060229e9dd56c3 Mon Sep 17 00:00:00 2001 From: deepfates Date: Fri, 16 Oct 2026 01:06:34 -0700 Subject: [PATCH 2/2] ReActV2: in a plain-text loop, say a submit-era thought was not sent instead of joining it to the answer Joining a thought-only step to the answer that followed presented an unsent thought as part of a sent answer. A plain-text loop now follows each thought-only step with a user message, "Your message above was not sent." (no submit to name there), so roles alternate and nothing is merged. A history written only in a plain-text loop has no thought-only step and replays byte-for-byte as before; the existing rejected-submit test is restored as it was. --- CHANGELOG.md | 18 ++++++++----- docs/diving-deeper/react.md | 13 ++++++++- lib/imp/adapter/chat.ex | 40 +++------------------------- lib/imp/predict/react_v2.ex | 9 ++++++- test/react_v2_ending_switch_test.exs | 40 ++++++++++++++++++++++++++-- test/react_v2_test.exs | 4 +-- 6 files changed, 76 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 001cb413..93adef33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,12 +13,18 @@ User-visible changes to Imp are recorded here. those answers again. A step that carries the signature's outputs is an answer: it gets no line, and the forced `submit` never moves it to the user side. Only a step with text, no calls and no outputs is a thought. -- In a plain-text ReActV2 loop, a history written under `submit` that holds - a thought-only step followed by the `submit` that answered it no longer - replays as two assistant messages in a row: they are one message, the - thought then the answer, as a step that wrote both renders. Some APIs - require roles to alternate. Messages that carry calls or provider - reasoning are not joined. +- In a plain-text ReActV2 loop, a history written under `finish: :submit` + replayed each thought-only step as an assistant message, which there reads + as a message that was sent, often right before the answer's: two + assistant messages in a row. Each such step is now followed by a user + message, "Your message above was not sent." (the line's factual half; a + plain-text loop has no `submit` to name), so the history stays true and + roles alternate. A history written in a plain-text loop has no + thought-only step, so its requests are byte-for-byte unchanged. +- Known gap: under `finish: :submit`, a forced request with nothing new to + ask on a history that ends with a plain-text answer step still ends on + that assistant message, since an answer is not moved. Resume with new + inputs or a `last_request_note`. ## 0.14.0 — 2026-10-16 diff --git a/docs/diving-deeper/react.md b/docs/diving-deeper/react.md index 4788183a..f3841e28 100644 --- a/docs/diving-deeper/react.md +++ b/docs/diving-deeper/react.md @@ -70,7 +70,13 @@ 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. 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 +user side instead (section 3). A step that answered in plain text, written +while the program ended with `finish: :auto`, is an answer, not a thought, +and gets no line. The other way round, a plain-text loop replaying a history +written under `finish: :submit` follows each thought-only step with only +"Your message above was not sent.", since it has no `submit` to name; a +history written in a plain-text loop has no such step, so its requests do +not change. 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 @@ -149,6 +155,11 @@ than return nothing, Imp makes one more request: thought as its last user message, under its field's marker, as DSPy's ReAct shows its trajectory; the history keeps it as the model's step. DSPy's ReActV2 leaves the thought out of the forced request instead. + + An answer is not moved. So a forced request with nothing new to ask (no + inputs, no `last_request_note`) on a history that ends with a step that + answered in plain text, written under `finish: :auto`, still ends on that + assistant message. Resume such a history with new inputs or a note. - With one text output, the request is an ordinary step, same tools, and its text is the answer (`:last_text`). Tool calls in it are not run; they are listed in `unexecuted_tool_calls`, unless the tool is named in diff --git a/lib/imp/adapter/chat.ex b/lib/imp/adapter/chat.ex index 385380a3..30dbad33 100644 --- a/lib/imp/adapter/chat.ex +++ b/lib/imp/adapter/chat.ex @@ -1397,43 +1397,9 @@ defmodule Imp.Adapter.Chat do end defp render_history_turns(signature, turns, renderers) do - turns - |> render_history_turn_messages(signature, renderers) - |> join_plain_text_replies(renderers) - end - - # In a plain-text loop a history written under `submit` (ReActV2 with - # `finish: :submit`, or before a program's ending changed) can hold a - # thought-only step followed by the `submit` step that answered it, which - # renders as the answer's text: two assistant messages in a row. Some APIs - # want roles to alternate, so they are one message, the thought then the - # answer, as a step that wrote both renders. Messages that carry calls or - # provider reasoning are left as they are. - defp join_plain_text_replies(messages, %{submit_is_text?: true}) do - messages - |> Enum.reduce([], fn - message, [previous | rest] = acc -> - if plain_assistant_text?(message) and plain_assistant_text?(previous), - do: [%{previous | content: join_text(previous.content, message.content)} | rest], - else: [message | acc] - - message, [] -> - [message] - end) - |> Enum.reverse() + render_history_turn_messages(turns, signature, renderers) end - defp join_plain_text_replies(messages, _renderers), do: messages - - defp plain_assistant_text?(%{role: :assistant, content: content} = message) - when is_binary(content) do - Map.get(message, :tool_calls) in [nil, []] and - not Map.has_key?(message, :reasoning_content) and - not Map.has_key?(message, :reasoning_details) - end - - defp plain_assistant_text?(_message), do: false - defp render_history_turn_messages(turns, signature, renderers) do turns |> Enum.zip(Enum.drop(turns, 1) ++ [nil]) @@ -1471,7 +1437,9 @@ 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, + # sent and how to go on, as a user message; a plain-text loop replaying a + # history written under `submit` says only that it was not sent, since it + # has no `submit` to name. Either way: in every request and on replay, # 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 diff --git a/lib/imp/predict/react_v2.ex b/lib/imp/predict/react_v2.ex index 6106243e..c5939ee4 100644 --- a/lib/imp/predict/react_v2.ex +++ b/lib/imp/predict/react_v2.ex @@ -2175,7 +2175,14 @@ defmodule Imp.Predict.ReActV2 do "to keep working, call a tool." end - defp unsent_note(_signature, _finish), do: nil + # A plain-text loop has no `submit` to name, but a history written under + # `finish: :submit` can hold thought-only steps; after each, the factual + # half of the note, so the thought does not read as a message that was + # sent. A history written in a plain-text loop has no such step (its text + # steps carry the answer), so its requests are unchanged. + defp unsent_note(signature, :auto) do + if single_text_output?(signature), do: "Your message above was not sent." + end defp provider_tool_config(tools, order, signature) do [ diff --git a/test/react_v2_ending_switch_test.exs b/test/react_v2_ending_switch_test.exs index dc90eb11..bdab74b0 100644 --- a/test/react_v2_ending_switch_test.exs +++ b/test/react_v2_ending_switch_test.exs @@ -68,7 +68,7 @@ defmodule ReActV2EndingSwitchTest do refute Enum.any?(replayed, &(to_string(&1.content) =~ @note_start)) end - test "a submit-era thought and answer replayed in a plain-text loop are one assistant message" do + test "a submit-era thought replayed in a plain-text loop is followed by the line that it was not sent" do replies = [ %{next_thought: "Let me think.", tool_calls: []}, %{tool_calls: [%{id: "s1", name: "submit", arguments: %{"reply" => "It is a cat."}}]} @@ -85,8 +85,44 @@ defmodule ReActV2EndingSwitchTest do assert shape(replayed) == [ {:user, "[[ ## ticket ## ]]\nWhat is in the box?"}, - {:assistant, "Let me think.\n\nIt is a cat."}, + {:assistant, "Let me think."}, + {:user, "Your message above was not sent."}, + {:assistant, "It is a cat."}, {:user, "[[ ## ticket ## ]]\nIs it awake?"} ] end + + # The requests of a conversation held only in a plain-text loop are those + # 0.14.0 sent: its text steps are answers, so no line is added. + test "a plain-text-only history replays exactly as before" do + {:ok, first} = + program([%{reply: "It is a cat.", tool_calls: []}], :auto) + |> Imp.call(%{ticket: "What is in the box?"}) + + look = %{tool_calls: [%{id: "c1", name: "look", arguments: %{}}]} + + {:ok, second} = + program([look, %{reply: "It is asleep.", tool_calls: []}], :auto) + |> Imp.call(%{ticket: "Is it awake?", history: first.metadata.history}) + + _held = requests() + + assert {:ok, _} = + program([%{reply: "Yes.", tool_calls: []}], :auto) + |> Imp.call(%{ticket: "Can I pet it?", history: second.metadata.history}) + + [replayed] = requests() + + assert shape(replayed) == [ + {:user, "[[ ## ticket ## ]]\nWhat is in the box?"}, + {:assistant, "It is a cat."}, + {:user, "[[ ## ticket ## ]]\nIs it awake?"}, + {:assistant, ""}, + {:tool, "a cat"}, + {:assistant, "It is asleep."}, + {:user, "[[ ## ticket ## ]]\nCan I pet it?"} + ] + + refute Enum.any?(replayed, &(to_string(&1.content) =~ "not sent")) + end end diff --git a/test/react_v2_test.exs b/test/react_v2_test.exs index 51410f7f..be50bb91 100644 --- a/test/react_v2_test.exs +++ b/test/react_v2_test.exs @@ -901,11 +901,11 @@ defmodule ReActV2Test do messages = Imp.Adapter.Chat.format(signature, %{history: history}, no_submit) - # The two steps' texts are one assistant message, so roles alternate. assert [ %{role: :system}, %{role: :user}, - %{role: :assistant, content: "trying\n\nagain\n\nRight key."}, + %{role: :assistant, content: "trying"}, + %{role: :assistant, content: "again\n\nRight key."}, %{role: :user} ] = messages