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 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, and for:finished_by_toolwhen afinish_onhook 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 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).: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, ...).:lengthmeans 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, withoutfinish: :submit). A step that says something and calls no tool is the answer, in that one request.:submit(every other signature, and every program withfinish: :submit). 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: its last request was answered with no valid answer in it, or the turn ran out of time or context window, or, underfinish: :tools, it ended without afinish_ontool.
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
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, 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 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 request whose text is the answer. 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 request for a signature with one unconstrained text output, the forcedsubmitfor every other.nilsays nothing; Imp writes no sentence of its own, except the unsent note after earlier thought-only steps underfinish: :submit. 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%{}.:finish- How the model ends its turn.:autolets the signature decide: one unconstrained text output ends on plain text, every other signature throughsubmit.:submitends every turn throughsubmit, 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 forcessubmit.:toolsends a turn only through afinish_ontool: text without a tool call is a thought as under:submit, there is nosubmit, 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 getsubmit" above. The default value is:auto.:unsent_note- The line shown after each thought-only step, in place of Imp's: underfinish: :submitand:tools, and on a plain-text loop's replay of a history written under:submit. A blank line is refused. The default value isnil.:history_replay- How the steps of earlier turns in ahistoryare shown to the model.:trajectoryshows them whole, as DSPy's ReActV2 does.:actionsleaves 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 inunexecuted_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[].