Skip to content

ReActV2: finish: :tools, and history_replay: :actions - #351

Merged
deepfates merged 4 commits into
mainfrom
react-v2-finish-tools
Oct 16, 2026
Merged

deepfates merged 4 commits into
mainfrom
react-v2-finish-tools

Conversation

@deepfates

@deepfates deepfates commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

User story

Some programs act only through tools. What they say goes out through a tool, and their text is read by nobody. Two problems follow from that.

  • A text step ends the turn. Under finish: :auto, a step that writes text and calls no tool ends the turn, so these programs end on notes to themselves. The notes are stored as answers, and the model reads them back on every later turn as its own last word.
  • Narration replays. Even when the turn ends through a tool, the text the model wrote beside its calls replays whole in every later request.

Change

finish: :tools

  • Only a finish_on tool ends a turn. A program built with it needs at least one finish_on tool.

  • There is no submit. The outputs come from the finish_on functions.

  • The guidance says "The turn ends only when you call a or b. Text you write is not sent."

  • A step with text and no call is a thought, as under finish: :submit. One ordinary step follows: every tool, tool_choice: "auto", the thought kept as the model's own message, then one line.

    • The default line names the ending tools: "Your message above was not sent. To end the turn, call decline or send; to keep working, call a tool."
    • unsent_note: replaces it with the host's own words.
  • Nothing is forced, and there is no last request. A step whose request fails gets one more ordinary step, once per turn. Each of these ends the turn :incomplete, with its termination_cause and no further request:

    • a second thought in a row;
    • a step that says nothing;
    • the step limit;
    • a second failed request.

    The cause is :deadline_exceeded when the deadline has passed.

  • Refused, each with a reason: last_request_note and last_request_tools (never used under :tools), and a blank unsent_note. Loading a saved program applies the same checks.

  • Forcing an ending tool would make the program act without having decided to, so the host decides what an unfinished turn means.

  • A call may give its own finish:, like its own max_iters. It is an option only when the task has no input named finish; otherwise it stays the input, as on main. A call's :tools without finish_on returns {:error, ...}. A :tools program that needs one text answer for one call, such as a summary, calls with finish: :auto, max_iters: 0 and a note.

  • A history recorded under :auto replays unchanged: an answer step is an answer, as in 0.14.1.

history_replay: :actions (opt-in; the default :trajectory is unchanged)

  • Earlier turns are shown as their inputs, the calls with their results, and their outputs.
  • The text and reasoning written beside calls are left out, and so is a step that only wrote text.
  • The current turn is shown whole. It starts with the call's new inputs or its own last_request_note (a summary request, say, reads every passed turn as earlier). On a resume with neither, it starts at the last step that carries inputs. Reasoning within the turn (DeepSeek reasoning_content, Anthropic thinking, Gemini signatures) is therefore kept.
  • A step that carries outputs (an answer) is shown whole.
  • The returned history keeps every step. Only the prompt changes.
  • Some thinking APIs reject an earlier tool-call message without its reasoning. In a probe on 2026-10-17, Kimi K3 through OpenRouter accepted one, both on an earlier turn and mid-turn. Hosts should still check their provider.
  • This diverges from DSPy's ReActV2, which appends every step's next_thought and calls to the history and renders them all. That is why it is opt-in; decisions.md records it.

Saving. Both options, and unsent_note, are saved. A program that uses neither saves exactly as before.

Tests

test/react_v2_finish_tools_test.exs and test/react_v2_history_replay_test.exs. Each fails on main, where the options do not exist. They cover:

  • a thought continues, and only a finish_on tool ends the turn: the request shape, the guidance line, the default note;
  • two thoughts end :incomplete after 2 requests;
  • the step limit ends :incomplete with nothing forced;
  • the host's note replaces the default;
  • :tools without finish_on is refused;
  • a per-call finish works both ways;
  • an :auto answer replays without a note;
  • save and load;
  • :actions drops earlier narration and thoughts and keeps the current turn and the record;
  • :trajectory replays whole.

:auto and :submit requests are unchanged: the full suite passes, including the exact-replay test from #349. mix check passes. priv/public_api.json is regenerated for the new options.

Review fixes (second commit)

  • A task input named finish stays an input.
  • :actions keeps the current turn whole on a resume mid-turn.
  • :tools:
    • a failed request gets one retry per turn;
    • a passed deadline ends the turn :deadline_exceeded;
    • meaningless settings are refused.
  • Saving: loading checks the same rules, and a bad saved unsent_note now gets an error naming unsent_note.
  • With written tool calls, the :tools agent line no longer says the tools produce the outputs.
  • Docs updated.
  • Tests now discriminate between endings and replay modes:
    • distinct tool results and reasoning fields;
    • a mid-turn resume;
    • a retry once, then incomplete;
    • the deadline;
    • a finish input name;
    • the refusals.
      Mutating the projection or the turn boundary now fails them.

mix check and mix quality.check pass.

finish: :tools ends a turn only through a finish_on tool. Text without a
tool call is a thought followed by one ordinary step and a line naming the
ending tools (unsent_note: replaces it); a second thought, an empty step, the
step limit or a failed step ends the turn :incomplete with nothing forced.
A call may give its own finish:.

history_replay: :actions shows earlier turns as inputs, calls with results,
and outputs, without the text and reasoning beside the calls. Opt-in; the
default :trajectory replays whole, as DSPy's ReActV2 does.
- A task input named finish is the input, not the per-call option.
- :actions keeps the current turn whole on a resume without new inputs: it
  starts at the last step that carries inputs, so in-turn reasoning stays.
- Under :tools, a failed request gets one more ordinary step, once per turn;
  a passed deadline ends the turn :deadline_exceeded.
- :tools refuses last_request_note/last_request_tools and a blank
  unsent_note; a call's :tools without finish_on returns an error; loading
  validates the same; the unsent_note load error names unsent_note.
- The written-tool agent line under :tools no longer says the tools produce
  the outputs.
- Docs: per-call finish, :tools in how a turn ends, the retry, :actions'
  current-turn rule and output steps, reasoning on earlier turns (Kimi K3
  through OpenRouter accepted it in a probe), :tools context recovery.
- Tests discriminate: distinct results, reasoning fields, a mid-turn resume.
@deepfates
deepfates enabled auto-merge (squash) October 16, 2026 21:09
@deepfates
deepfates merged commit 7b0a712 into main Oct 16, 2026
13 checks passed
@deepfates
deepfates deleted the react-v2-finish-tools branch October 16, 2026 21:11
deepfates added a commit that referenced this pull request Oct 16, 2026
Version 0.15.0 with install lines ~> 0.15.0 and tag links for v0.15.0. The
changelog's Unreleased section goes under 0.15.0, and the release notes
describe #351. A minor release: finish: :tools, history_replay: :actions and
unsent_note: are new options; programs that use none of them send and save
exactly as in 0.14.1.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant