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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,21 @@ User-visible changes to Imp are recorded here.

## Unreleased

### Added

- ReActV2 `finish: :tools`: only a `finish_on` tool ends a turn. Text without
a tool call is a thought, followed by one ordinary step and a line naming
the ending tools. A failed request gets one more ordinary step, once per
turn (not on the last allowed step). A second thought in a row, an empty
step, the step limit or a second failure ends the turn `:incomplete`, with
no further request and nothing forced; a passed deadline ends it
`:deadline_exceeded`. `unsent_note:` replaces that line. A call may give its own
`finish:`.
- ReActV2 `history_replay: :actions`: earlier turns are shown as their
inputs, calls with results, and outputs, without the text and reasoning
written beside the calls. Opt-in; the default `:trajectory` is unchanged,
and the returned history keeps every step.

## 0.14.1 — 2026-10-16

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ not necessarily when it was made.

| Date | Decision | Source and reason | Status | Retires when |
| --- | --- | --- | --- | --- |
| 2026-10-16 | ReActV2 `history_replay: :actions` (opt-in) shows earlier turns without the text and reasoning their steps wrote beside their calls, and without steps that only wrote text. Default `:trajectory` replays whole, as DSPy's ReActV2 does (its history events carry `next_thought` and the calls with results, and the adapter renders every event). | A host whose loop does not speak for itself (its text is read by nobody) found the model rebuilding a persona from its own earlier narration on every turn. The divergence is opt-in; the record keeps every step, only the prompt changes. | settled | DSPy offers a comparable history view. |
| 2026-10-16 | ReActV2 `finish: :tools` ends a turn only through `finish_on` and never forces: past the one continued step after a thought, the step limit, or a second failed request (a failed request gets one more step, once per turn, not on the last allowed step), the turn ends `:incomplete`. | Forcing a call from a loop whose ending tools act in the world would make it act without having decided to; the host knows what an unfinished turn means. | settled | A host needs a forced ending tool. |
| 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 <the outputs>; 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. |
Expand Down
37 changes: 37 additions & 0 deletions docs/diving-deeper/react.md
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,43 @@ When one step calls several finishing tools, the first in call order ends
the turn; the others still run and are recorded, and a `submit` in the same
step wins.

**Only a tool ends the turn: `finish: :tools`.** Some programs act only
through tools: what they say goes out through a tool, and their text is
for nobody. With `finish: :tools`, nothing but a `finish_on` tool ends the
turn. There is no `submit`, and the signature's outputs come from the
`finish_on` functions. A step that writes text and calls no tool is a
thought, as under `finish: :submit`: the next request is an ordinary step
in which the thought stays the model's own message, followed by one line,
by default "Your message above was not sent. To end the turn, call
`decline` or `send`; to keep working, call a tool." Pass `unsent_note:` to
say what ending the turn means in your program. Nothing is ever forced. A
step whose request fails gets one more ordinary step, once per turn. A
second thought in a row, a step that says nothing, the step limit or a
second failure ends the turn `:incomplete`, with its `termination_cause`
(`:deadline_exceeded` when the deadline has passed) and no further request,
and the host decides what that means. A program built
with `finish: :tools` needs at least one `finish_on` tool.

A call may give its own `finish:`, as it gives its own `max_iters`. A
program with `finish: :tools` that needs one text answer for one call, a
summary of the conversation say, calls with `finish: :auto, max_iters: 0`
and a note.

**Earlier turns as actions: `history_replay: :actions`.** By default a
history replays every earlier step whole, as DSPy's ReActV2 does: the text
each step wrote beside its calls, its reasoning, the calls and their
results. With `history_replay: :actions`, the steps of earlier turns lose
the text and reasoning written beside their calls, and a step that only
wrote text is left out; earlier turns read as their inputs, the calls with
their results, and the outputs. The current turn is always shown whole, and
the history the prediction returns keeps every step. Use it when the
model's own narration should not become something it reads back on every
later turn. A step that carries the task's outputs (an answer) is shown
whole. A call with new inputs or its own `last_request_note` starts a turn;
on a call with neither (a resume mid-turn), the current turn starts at the
last step that carries inputs. Some thinking APIs reject an
earlier tool-call message without its reasoning content; check yours.

### Why a turn ended

`termination_reason` says how the turn ended:
Expand Down
30 changes: 25 additions & 5 deletions lib/imp/adapter/chat.ex
Original file line number Diff line number Diff line change
Expand Up @@ -777,21 +777,28 @@ defmodule Imp.Adapter.Chat do
defp with_guidance(instructions, %{} = guidance, signature, plain?, agent_line) do
names = fn key -> guidance |> Map.get(key, []) |> Enum.map_join(", ", &"`#{&1}`") end
plain_finish? = plain? and Map.get(guidance, :submit_tool, :submit) == nil
finish_tools = Map.get(guidance, :finish_tools)

finish =
case Map.get(guidance, :submit_tool, :submit) do
nil when plain? ->
case {Map.get(guidance, :finish_tools), Map.get(guidance, :submit_tool, :submit)} do
# `finish: :tools`: only these tools end the turn, and what the model
# writes without one is not sent (`Imp.Predict.ReActV2`).
{[_ | _] = tools, _submit} ->
"The turn ends only when you call " <>
or_names(tools) <> ". Text you write is not sent."

{_tools, nil} when plain? ->
[
"When the final answer is ready, write it as plain text without calling a tool.",
field_description(output_field(signature, meta_field(signature, :text_field)))
]
|> Enum.reject(&(&1 == ""))
|> Enum.join(" ")

nil ->
{_tools, nil} ->
text_answer(signature)

tool ->
{_tools, tool} ->
"When the final answer is ready, call `#{tool}` with #{names.(:output_names)}."
end

Expand All @@ -804,6 +811,9 @@ defmodule Imp.Adapter.Chat do
outputs =
case Map.get(guidance, :outputs, []) do
_fields when plain_finish? -> nil
# Under `finish: :tools` the tools that end the turn give its outputs;
# the model writes none.
_fields when is_list(finish_tools) -> nil
[] -> nil
fields -> "The outputs to produce are:\n" <> render_field_list(fields)
end
Expand All @@ -825,8 +835,18 @@ defmodule Imp.Adapter.Chat do
# without a tool call, so the opening line does not say the tools
# produce it: they gather information and act. Nor does it name the
# answer as an output to produce (see `with_guidance/5`).
defp or_names([one]), do: "`#{one}`"

defp or_names(names) do
quoted = Enum.map(names, &"`#{&1}`")
Enum.join(Enum.drop(quoted, -1), ", ") <> " or " <> List.last(quoted)
end

defp default_agent_line(guidance, plain?, names) do
if plain? and Map.get(guidance, :submit_tool, :submit) == nil do
# Under `finish: :tools` the ending tools give the outputs, so the tools do
# not "produce" them from the inputs, with native or written calls alike.
if is_list(Map.get(guidance, :finish_tools)) or
(plain? and Map.get(guidance, :submit_tool, :submit) == nil) do
"You are an Agent. Use the supplied tools to gather information and take actions."
else
"You are an Agent. Use the supplied tools to produce #{names.(:output_names)} " <>
Expand Down
Loading
Loading