Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion docs/diving-deeper/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 27 additions & 10 deletions lib/imp/adapter/chat.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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, %{
Expand Down Expand Up @@ -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} ->
Expand All @@ -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} ->
Expand Down Expand Up @@ -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
Expand All @@ -1431,18 +1448,18 @@ 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

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)
Expand Down
9 changes: 8 additions & 1 deletion lib/imp/predict/react_v2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
[
Expand Down
128 changes: 128 additions & 0 deletions test/react_v2_ending_switch_test.exs
Original file line number Diff line number Diff line change
@@ -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
Loading