diff --git a/CHANGELOG.md b/CHANGELOG.md index e0187faa..93adef33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ 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 `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 ### Added 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 19537e6c..30dbad33 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,10 @@ defmodule Imp.Adapter.Chat do end defp render_history_turns(signature, turns, renderers) do + render_history_turn_messages(turns, signature, renderers) + end + + defp render_history_turn_messages(turns, signature, renderers) do turns |> Enum.zip(Enum.drop(turns, 1) ++ [nil]) |> Enum.flat_map(fn {turn, next} -> @@ -1422,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 @@ -1431,9 +1448,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 +1459,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/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 new file mode 100644 index 00000000..bdab74b0 --- /dev/null +++ b/test/react_v2_ending_switch_test.exs @@ -0,0 +1,128 @@ +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 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."}}]} + ] + + {: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."}, + {: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