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.
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 plain 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.
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 fullImp.History, to pass back as the:historyinput of the next call.:termination_reason— how the turn ended (below).:termination_cause— present whenever the turn was interrupted: for:last_text,:forced_submitand:extracted, the interruption that led to the last request. For:incomplete, the same interruption, unless the turn's context window was full or itsImp.Deadlinehad passed, which would refuse any further request too and so are named instead;termination_errorholds the errors of the requests that failed. One of:max_iters,:parse_error,:prediction_error,:empty_tool_calls,:context_window_exceededand: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).
Imp.Prediction.complete?/1 is false exactly when the reason is
:incomplete.
How a turn ends
:answered(one unconstrained text output). A step that says something and calls no tool is the answer, in that one request.:submit(every other signature). The model callssubmitwith the signature's outputs.:finished_by_tool.finish_onmaps a tool name tofn 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}validatesoutputsagainst the signature exactly as asubmitwould and ends the turn, withfinished_by_toolnaming the tool.:continueleaves 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 asubmitin the same step still wins. Outputs that fail validation are recorded as that call's result, the same error a badsubmitrecords, 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.
When a turn is interrupted
A turn is interrupted when it reaches max_iters, when a step's request
fails (:prediction_error, :parse_error), or when a step of a signature
with submit calls no tool (:empty_tool_calls). 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 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; the completion's text, if any, is the answer, and the calls 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.
With submit, an interruption forces one more request with tool_choice
naming submit (:forced_submit), as DSPy does. If a provider cannot honor
that tool contract, a tools-disabled typed extractor derives the task
outputs from the original inputs and accumulated history (:extracted).
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. Imp
writes no sentence of its own.
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).
A step's outputs are next_thought and tool_calls. The provider holds the
tool roster natively, so a step normally comes back as native tool calls. A
step that comes back as plain text with no tool call is read as that text
being next_thought and no tool calls, by the :text_field metadata on the
internal step signature that Imp.Adapter.Chat honors: it is a thought that
called nothing, not a parse failure, so it costs one LM call rather than two
and keeps the provider's prefix cache. That thought 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.
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 retains provider-native reasoning text and opaque reasoning
details for continuation, including after Imp.History.dump/1 and Imp.History.load!/1.
These are operational protocol data and must remain unmodified. 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
Functions
@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 usesImp.Settings':lm.:adapter- The adapter that renders each step's request and parses its reply. When absent, each call usesImp.Settings':adapter.:demos(list ofterm/0) - Worked examples for the step predictor, as forImp.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 asImp.Adapter.Chat's:system_renderer. The default value is[].:metadata(map ofterm/0keys andterm/0values) - 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 forcedsubmit, or, for a signature with one unconstrained text output, one last text-only request. The default value is20.:tool_policy- Which tool calls may run; seeImp.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 text-only request for a signature with one unconstrained text output, the forcedsubmitfor every other.nilsays nothing; Imp writes no sentence of its own. The default value isnil.:finish_on- Tools whose call ends the turn: a map from tool name tofn arguments, result, inputs -> {:finish, outputs} | :continue end. See "How a turn ends" above. The default value is%{}.