Imp.Predict.ReActV2 (Imp v0.16.1)

Copy Markdown View Source

Native-tool-aware ReAct loop with structured history and typed completion.

ReActV2 preserves parallel tool call IDs and results in Imp.History and keeps unknown and failed tool calls as observations. Each recorded result carries returned_at, when it came back (ISO 8601, UTC), which the Chat adapter passes to a :tool_result_renderer with the call. Nothing shows it by default; a host that wants the model to know the time during a turn renders it there.

Which signatures get submit

A task signature with exactly one output of type :string and no constraints has an answer that the model can write as text, so its loop offers no submit tool. Every other signature (several outputs, one output that is not text, or one text output with constraints such as an enum, whose allowed values reach the model in submit's schema) gets the reserved submit tool, whose parameters are the signature's outputs, exactly as DSPy's ReActV2 has it. The name submit is reserved for every signature, so a user tool cannot take it.

finish: :submit gives the one-text-output signature submit too, and its turn then ends only through submit: a step that writes text and calls no tool is a thought, not the answer. It is for a program whose model writes text it does not mean as the answer, such as a sentence about what it will do next, or a report of what it did. Under finish: :submit a thought-only step is followed by an ordinary step (every tool, tool_choice: "auto"), in which the thought stays the model's own message and a user message after it says "Your message above was not sent. To reply, call submit with answer; to keep working, call a tool." (naming the signature's outputs), so the model can do what it announced or submit what it wrote; a second thought-only step in a row, or a step that says nothing, is the :empty_tool_calls interruption and the forced submit follows. Every other signature with submit keeps DSPy's ending: its first thought-only step is that interruption. The default, finish: :auto, leaves the choice to the signature.

finish: :tools is for a program that acts only through tools: its turn ends only through a finish_on tool. There is no submit, and the outputs come from the finish_on functions. A thought-only step is followed by one ordinary step, as under :submit, with a line naming the ending tools (unsent_note: replaces it). Nothing is forced and no last request is made. A step whose request fails gets one more ordinary step, once per turn (there is no last request to absorb a transient failure). A second thought-only step in a row, a step that says nothing, the step limit or a second failure (or a failure on the last allowed step) ends the turn :incomplete with that cause and the request's error in termination_error, or :deadline_exceeded when the deadline has passed. A program with finish: :tools needs a finish_on tool, and refuses last_request_note and last_request_tools, which it would never use. Its context recovery (a full window) has no answer steps to mark episodes, so it can only drop the whole history.

history_replay: :actions shows the steps of earlier turns without the text and reasoning written beside their calls, and leaves out steps that only wrote text; a step that carries the task's outputs (an answer) is shown whole. The current turn is shown whole: it starts with the call's new inputs or its own last_request_note, or, on a call with neither (a resume mid-turn), at the last step that carries inputs. DeepSeek accepts earlier turns without reasoning_content; Anthropic and Gemini do not validate reasoning on earlier turns. Some thinking APIs reject an earlier tool-call message without its reasoning content (Kimi-style thinking modes); Kimi K3 through OpenRouter accepted one in a probe (2026-10-16), but check the provider before relying on it.

The prediction

The prediction's fields are the signature's outputs and nothing else, so an output may be called anything, history included. How the turn went is in its metadata:

  • :history — the turn's full Imp.History, to pass back as the :history input of the next call.
  • :termination_reason — how the turn ended (below).
  • :termination_cause — present whenever the turn was interrupted: for :last_text, :forced_submit and :extracted, and for :finished_by_tool when a finish_on hook ended the turn in the last request, the interruption that led to the last request. For :incomplete, the same interruption, unless the turn's context window was full or its Imp.Deadline had passed, which would refuse any further request too and so are named instead; termination_error holds the errors of the requests that failed. One of :max_iters, :parse_error, :prediction_error, :empty_tool_calls, :context_window_exceeded and :deadline_exceeded.
  • :termination_error — for :incomplete, the redacted errors of the requests that failed.
  • :finished_by_tool — the terminal tool that ended the turn.
  • :unexecuted_tool_calls — tool calls the last request made that were not run.
  • :context_projection — how many prior episodes were left out of a request (see below).
  • :finish_reason — for a turn whose answer is a reply's text (:answered, :last_text), why the provider stopped that reply, as the LM client reports it in the result's provider metadata (Imp.Clients.ReqLLM: :stop, :length, ...). :length means the provider's output limit cut the answer short: the text is what was written before it. Absent when the client reports none.

Imp.Prediction.complete?/1 is false exactly when the reason is :incomplete.

How a turn ends

  • :answered (one unconstrained text output, without finish: :submit). A step that says something and calls no tool is the answer, in that one request.
  • :submit (every other signature, and every program with finish: :submit). The model calls submit with the signature's outputs.
  • :finished_by_tool. finish_on maps a tool name to fn arguments, result, inputs -> {:finish, outputs} | :continue end. It runs after that tool's call executes, with the string-keyed arguments the tool received; {:finish, outputs} validates outputs against the signature exactly as a submit would and ends the turn, with finished_by_tool naming the tool. :continue leaves the loop running. When one step calls several terminal tools, the first in call order finishes the run; the rest still execute and are recorded, and a submit in the same step still wins. Outputs that fail validation are recorded as that call's result, the same error a bad submit records, and the loop continues.
  • :last_text, :forced_submit, :extracted. The turn was interrupted and its last request answered (below).
  • :incomplete. The turn was interrupted and has no answer: its last request was answered with no valid answer in it, or the turn ran out of time or context window, or, under finish: :tools, it ended without a finish_on tool.

A turn that could not get a model response returns {:error, %Imp.Predict.ReActV2.StepError{}} rather than a prediction: its last request failed with an Imp.LMError, a step's request was stopped by the provider's content filter (Imp.LMError with content_filtered: true; sending it again would be filtered and billed again, so no last request is made, as DSPy makes none after a step's LM error), its LM client raised ({:lm_failed, client, exception}), or a renderer raised ({:adapter_format_failed, adapter, exception}). The error's :reason is that request's error as Imp.Predict returns it, which Imp.Errors classifies through the struct, and its :history is the turn's history as far as it got, with every tool call that ran and its result. A step that fails with an Imp.OperationalSafetyError (a route, cost, transport or budget guard) ends the turn the same way at once, with no last request. A tool call whose outcome is unknown is not such a failure: it is an observation, and the turn goes on.

When a turn is interrupted

A turn is interrupted when it reaches max_iters, when a step's request fails (:prediction_error, :parse_error) other than by the content filter, or when a step of a signature with submit calls no tool (:empty_tool_calls; under finish: :submit, the second such step in a row, see above). An enum output is such a signature: text that is not a submit call is :empty_tool_calls, never an answer, and a turn with no valid submit, the forced one and the extraction after it included, ends :incomplete.

With one unconstrained text output, a step that calls no tool and says nothing is not an interruption: it is an empty answer, and the turn ends there (:answered). Saying nothing is how a model declines to answer, and asking it again would make declining cost a second request.

With one unconstrained text output, every interruption takes the same path: one more request, and its text is the answer (:last_text). The request is a step like any other: the same tools and the same tool_choice: "auto". A provider may refuse a history of tool calls when no tools are declared (Anthropic does), and a changed roster changes the prompt prefix a provider caches. It does not say tool_choice: "none": a model told that while it wants a tool can write the call as text in its own tool markup (seen from inkling through OpenRouter, and through DeepInfra), and that text would become the answer. If the model calls a tool, the call is not run unless :last_request_tools names that tool (below); the completion's text, if any, is the answer, and the calls not run are kept in unexecuted_tool_calls. A completion that says nothing is an empty answer rather than an error. If the process's Imp.Deadline has already passed, no request is made and the turn is :incomplete with termination_cause: :deadline_exceeded.

:last_request_tools names tools whose calls that request does run. It is for a program that acts through a tool (it publishes, sends or records something) and whose text only reports what it did: an interruption, a single provider error among them, would otherwise cost it the action it was about to take. Calls to those tools run as a step's calls do, under the same tool policy and run authorization, with the same :tool_call and :tool_result events, and the request is recorded in the history with their results. The turn then ends with the request's text as the answer (:last_text); calls to any other tool are not run and are listed in unexecuted_tool_calls. If a listed call meets a finish_on hook that returns {:finish, outputs}, the turn ends with those outputs (:finished_by_tool), as it would after any step. DSPy's ReActV2 makes the same allowance for its terminal tool: the forced last request may call submit, and that call runs. The default, [], runs nothing.

With submit, an interruption forces one more request with tool_choice naming submit (:forced_submit), as DSPy does, unless the process's Imp.Deadline has already passed (:incomplete, :deadline_exceeded). If a provider cannot honor that tool contract, or the forced request calls no submit, or calls it with arguments that fail validation (some models, made to call a tool by name, write its arguments in their reasoning and call it empty), a tools-disabled typed extractor derives the task outputs from the original inputs and accumulated history (:extracted), and the outputs are appended to the history as a step that called no tool. An invalid forced call stays in the history before them, with its error as its result. A forced submit the tool policy refuses is not sent to the extractor, which no policy checks: the turn ends :incomplete.

The last request says nothing about why it is being made unless :last_request_note is given: one line of host text put in front of it as a user message and kept in the returned history like any other turn. It is recorded as a step that said nothing and called nothing, so it renders as that user message alone, with no assistant reply after it, in the request and whenever the history is passed back. Imp writes no sentence of its own there, except that under finish: :submit each earlier thought-only step in the history keeps the unsent note after it (see "Which signatures get submit").

Per-call options

A call may give max_iters, last_request_note, last_request_tools, config and tools beside its inputs, under atom or string keys. Each applies to that call only and is not an input field. max_iters, last_request_note and last_request_tools replace the program's own; max_iters is a non-negative integer, last_request_note a string or nil, and last_request_tools a list of tools the program holds, checked as the program's own list is. config is a keyword list of request options merged over the program's :config for every request of the call, such as max_tokens: 4_000; it may not name :tools or :tool_choice, which the loop chooses on each step. tools is a list of Imp.Tools that the program holds, each the program's own in name, description and schema, whose runners the call uses in place of the program's: the request is unchanged, and only what a call to those tools does is the call's. finish (:auto, :submit or :tools) replaces the program's ending for the call, as new/3 would build it; it is an option only when the task has no input named finish, which is then that input. Any other value is refused before a model is called.

Together they continue a conversation the caller holds. A call with a :history, max_iters: 0 and a note makes one request, the last request: the loop's own system message, tools and history, then the note, with tool_choice: "auto". No tool runs, except one named in :last_request_tools, and the reply's text is the answer (termination_reason: :last_text). This is how a host asks a model about a conversation it has had, as that model saw it, for instance for a summary of it before the history is shortened. Codex CLI and Anthropic's SDK compaction build that request the same way: the conversation's own messages, then one instruction. The call's config gives that request its own options, an output budget for instance, and the prediction's :finish_reason says whether the reply was cut short by it.

That request is a last request, so a program's last_request_tools run in it. A summary is not the place for a program's actions: a host asking for one gives last_request_tools: [] with the call, and a call to such a tool in that request is not run but listed in unexecuted_tool_calls.

A request refused because the context window is full is not an interruption of this kind: a further request would be refused the same way, so the turn ends at once as :incomplete with termination_cause: :context_window_exceeded (see below).

For a single unconstrained text output, a step describes that task field directly, including its description. Other signatures use next_thought alongside the typed submit tool. The other step output is tool_calls (agent_tool_calls when the task text output itself is named tool_calls). The provider holds the tool roster natively. Plain text with no tool call fills the declared text field through the step signature's :text_field metadata. It completes the text task in one LM call and is appended to the history as its own step. A tool call the model writes as JSON rather than calling natively is accepted with tool for name and args or parameters for arguments (Imp.Adapter.Types.ToolCall); a map that names no tool at all is kept as a malformed-call observation.

So a step asks for one thing, whichever adapter formats it. The step has a tools input, the roster as text in DSPy's shape ("name, whose description is <desc>...</desc>. It takes arguments {...}.", the arguments being the schema's properties, required and $defs as JSON, the properties in the schema's order when it keeps one and by name otherwise), and a tool_calls output whose description shows how to write a call. When the LM calls tools natively (every LM unless its client says otherwise; Imp.Clients.ReqLLM asks the model registry), the tools are sent natively and Imp.Predict formats the step without both fields, in every adapter and in the JSON fallback: the model answers with tool calls or with text. When the LM cannot (the registry says the model has no tool calling, or the LM is Imp.Clients.TRLLM), no tools are sent: the step lists them in tools, the model writes its calls in tool_calls, and earlier steps are replayed as text (the step's fields, then the results), never as native tool messages. This is DSPy's structure. The guidance says where a text answer goes: in the task text field, with tool_calls left empty when that field is described. With native tool calls the Chat adapter asks for no field markers at all: its system message lays out the inputs alone and says the message without a tool call is the answer. A reply in plain text, or in markers, is read as that field by Imp.Adapter.Chat. Durable step history keeps next_thought and tool_calls for compatibility with saved conversations; adapters project them into the declared fields when rendering. A stored turn that carries a one-text-output task's answer and no step outputs is replayed as a step that answered in text. The name tools is reserved: a task signature cannot have a field of that name.

On a recognized context-window refusal, up to eight smaller requests omit oldest prior episodes from the prompt, preserving their full durable history. Completed signature outputs delimit episodes; a trailing unfinished prior group is kept together. Current-call tool observations are never omitted or replayed. Omission counts appear in :context_projection and native :context_projected events. If the current call and instructions alone exceed the window, an incomplete prediction retains history and context diagnostics. This is lossy prompt selection, not summarization or deletion of memory.

Tool history is the conversation the model reads: it keeps each input, tool call and tool result as it was, and provider-native reasoning text and opaque reasoning details for continuation, including after Imp.History.dump/1 and Imp.History.load!/1. Store history privately; use redacted events or Imp.History.redact/1 for diagnostic copies.

Summary

Functions

Builds a ReAct loop over tools for signature.

Types

t()

@type t() :: %Imp.Predict.ReActV2{
  finish: term(),
  finish_on: term(),
  history_replay: term(),
  last_request_note: term(),
  last_request_tools: term(),
  max_iters: term(),
  react: term(),
  signature: term(),
  tool_order: term(),
  tool_policy: term(),
  tools: term(),
  unsent_note: term()
}

Functions

new(signature, tools, opts \\ [])

@spec new(Imp.Signature.t() | String.t(), [Imp.Tool.t()], keyword()) :: t()

Builds a ReAct loop over tools for signature.

tools is a list of Imp.Tool values; the name submit is reserved. The signature decides whether the loop has a submit tool (see the module documentation).

Options

  • :lm - The model each step calls. When absent, each call uses Imp.Settings' :lm.

  • :adapter - The adapter that renders each step's request and parses its reply. When absent, each call uses Imp.Settings' :adapter.

  • :demos (list of term/0) - Worked examples for the step predictor, as for Imp.Predict. The default value is [].

  • :config (keyword/0) - Request options for every step (temperature, max tokens, ...). The tool roster is added here, so do not pass :tools. The default value is [].

  • :adapter_opts (keyword/0) - Options handed to the adapter beside the loop's own guidance; a host injects its renderers here, such as Imp.Adapter.Chat's :system_renderer, and its own :agent_line (the guidance's "You are an Agent..." line; a string replaces it, nil leaves it out). The default value is [].

  • :metadata (map of term/0 keys and term/0 values) - Free-form metadata kept on the step predictor. The default value is %{}.

  • :max_iters (non_neg_integer/0) - Steps before the turn is interrupted. An interrupted turn ends with the forced submit, or, for a signature with one unconstrained text output, one last request whose text is the answer. The default value is 20.

  • :tool_policy - Which tool calls may run; see Imp.ToolPolicy. The default value is :allow.

  • :last_request_note - One line of host text put in front of the last request of an interrupted turn, as a user message, and kept in the history: the last request for a signature with one unconstrained text output, the forced submit for every other. nil says nothing; Imp writes no sentence of its own, except the unsent note after earlier thought-only steps under finish: :submit. The default value is nil.

  • :finish_on - Tools whose call ends the turn: a map from tool name to fn arguments, result, inputs -> {:finish, outputs} | :continue end. See "How a turn ends" above. The default value is %{}.

  • :finish - How the model ends its turn. :auto lets the signature decide: one unconstrained text output ends on plain text, every other signature through submit. :submit ends every turn through submit, as DSPy's ReActV2 does, so a step that writes text and calls no tool is a thought, not the answer; the next step continues with every tool, and a second such step in a row forces submit. :tools ends a turn only through a finish_on tool: text without a tool call is a thought as under :submit, there is no submit, and a failed request gets one more step, once per turn; a second such step in a row, the step limit or a second failure ends the turn :incomplete, with no further request. See "Which signatures get submit" above. The default value is :auto.

  • :unsent_note - The line shown after each thought-only step, in place of Imp's: under finish: :submit and :tools, and on a plain-text loop's replay of a history written under :submit. A blank line is refused. The default value is nil.

  • :history_replay - How the steps of earlier turns in a history are shown to the model. :trajectory shows them whole, as DSPy's ReActV2 does. :actions leaves out the text and reasoning each earlier step wrote beside its calls, and drops a step that only wrote text: earlier turns read as their inputs, the calls made with their results, and the outputs. The current turn is always whole, and the history the prediction returns keeps everything. The default value is :trajectory.

  • :last_request_tools - Tools whose calls run in the last request of an interrupted turn, for a signature with one unconstrained text output (:last_text). Calls to them are executed and recorded as any step's calls are, and the turn then ends with that request's text as the answer; calls to any other tool are not run and are listed in unexecuted_tool_calls. For a program that acts through a tool and whose text only reports what it did. Each name must be a tool the program holds. See "When a turn is interrupted" above. The default value is [].