Current section
Files
Jump to
Current section
Files
docs/diving-deeper/react.md
# ReAct
## Intent
`Imp.react(signature, tools, opts)` is Imp's tool-using loop. The model reads
the task, calls tools, reads their results, and repeats until it can give the
signature's outputs. It is DSPy's ReActV2: the history is structured, tools
are called natively through the provider, one step may call several tools,
and the answer arrives directly rather than through a separate extraction
call.
`Imp.Predict.ReActV2`, which `Imp.react/3` builds, is stable. The older
trajectory-based `Imp.Predict.ReAct` is experimental.
Read this when a task needs the model to choose actions, when you want to
know why a turn ended the way it did, or when you are moving an agent from
DSPy's `ReAct` or `ReActV2`. For defining tools and importing them from MCP,
see [Tools and MCP](tools-and-mcp.md).
## Design decisions
### 1. The history is structured, and it is the prompt
Every step is recorded as one entry in an `Imp.History`: the inputs (on the
first step), the model's thought if it wrote one, its tool calls with their
IDs, and each call's result. The adapter replays that history as real
messages: an assistant message with native tool calls, then one tool message
per result, matched by ID. Nothing earlier is reformatted, so each request is
the previous one plus the newest exchange, which is the prefix a provider's
prompt cache can reuse.
The history comes back as `prediction.metadata.history`. Pass it as the
`history` input of the next call to continue the conversation.
### 2. How a turn ends depends on the outputs
A signature with **one unconstrained text output** (`ticket -> reply`) ends
the way most tool loops end: when the model stops calling tools and writes
text, that text is the answer. No `submit` tool is offered. This costs one
request fewer and matches what models are trained to do.
**Every other signature** (several outputs, one that is not text, or one text
output with a constraint such as an enum, a pattern or an answer shape) gets a
`submit` tool whose parameters are the outputs, as in DSPy. Calling it with
valid values ends the turn; values that do not fit the signature are recorded
as that call's error, and the loop goes on. The name `submit` is reserved.
The one-text-output rule has a consequence to design for: any step that
writes text and calls no tool has answered. A model that says what it is
about to do, instead of doing it, has given that sentence as its answer.
A constrained output never takes that path: its allowed values are in
`submit`'s schema, and text is not an answer to it.
**`finish: :submit`** gives the one-text-output signature `submit` as well,
so its turn ends only through `submit`: a step that writes text and calls no
tool is a thought, not the answer. Use it when the model writes text it does
not mean as the answer: a sentence about what it will do next, or a report
of what it just did. The text then stays in the history as the step's
thought, and only `submit`'s argument is the answer.
After such a step the loop goes on with an ordinary step: every tool and
`tool_choice: "auto"`. The thought stays the model's own message, and one
user message follows it, naming the signature's outputs:
```text
Your message above was not sent. To reply, call `submit` with `answer`; to keep working, call a tool.
```
That line follows every thought-only step, in later requests and on replay
too, so each request is the previous one plus the newest exchange. It is
left out after a thought the turn's extracted answer follows (that thought
was answered), and the forced `submit`'s own last thought is shown on the
user side instead (section 3). A step that answered in plain text, written
while the program ended with `finish: :auto`, is an answer, not a thought,
and gets no line. The other way round, a plain-text loop replaying a history
written under `finish: :submit` follows each thought-only step with only
"Your message above was not sent.", since it has no `submit` to name; a
history written in a plain-text loop has no such step, so its requests do
not change. With
written tool calls the request then ends, as every such request does, on
the tool listing. The model can then do what it said it would, or submit
what it wrote; a thought that was
only a closing note can be submitted as an empty answer. A second
thought-only step in a row, or a step that says nothing, interrupts the turn
(`:empty_tool_calls`) and the forced `submit` follows. Once the process's
`Imp.Deadline` has passed, no further request is sent. Measured on real
final steps from 11 model families, with that line after the thought no
sample wrote text again, and replies were submitted intact, Meta's models
included, whose provider cannot force a tool. Shown the thought back as a
user message instead, some models took it for an echo of their own output
and submitted nothing; given only "Your message above was not sent.", they
asked about their status and took extra steps. Forcing at once, as DSPy
does, took the announced action away and added a request whose failure
lost the reply. It costs one more request whenever the model ends in prose.
The default, `finish: :auto`, lets the signature decide as above. The
option is saved with the program.
The prompt says the same. When the provider holds the tools natively, the
Chat adapter's system message for `Imp.react("intent -> answer", [look])`
shows the input layout, no output markers to write and no output fields,
because the answer is the plain text of the message that calls no tool. The
answer's description, when it has one, follows the sentence that says so:
```text
--- system
Your input fields are:
1. `intent` (string):
Inputs will be structured in the following way, with the appropriate values filled in.
[[ ## intent ## ]]
{intent}
In adhering to this structure, your objective is:
Given the fields `intent`, produce the fields `answer`.
You are an Agent. Use the supplied tools to gather information and take actions.
Call tools when more information is needed.
When the final answer is ready, write it as plain text without calling a tool.
The available tools are: `look`.
--- user
[[ ## intent ## ]]
What is in the box?
```
The tool names are listed in the text as DSPy lists them; the tools
themselves go to the provider. The "You are an Agent..." line is yours to
change: pass `adapter_opts: [agent_line: "..."]` for your own text, or
`agent_line: nil` to leave it out. A reply that still writes
`[[ ## answer ## ]]` sections, as replies did before, is read as before.
When the model writes its tool calls as text instead (an LM without native
tool calling), and for every signature with `submit`, the step keeps the
`[[ ## field ## ]]` layout and its `[[ ## completed ## ]]` marker.
### 3. An interrupted turn gets one last request
A turn is interrupted when it reaches `max_iters`, when a step's request or
parse fails, or when a step gives neither a tool call nor an answer. Rather
than return nothing, Imp makes one more request:
- With `submit`, the request names `submit` as the required tool, as DSPy
does (`:forced_submit`). If the process's `Imp.Deadline` has already
passed, no request is made and the turn is `:incomplete`
(`:deadline_exceeded`). A provider that refuses a named `tool_choice`
(its error names `tool_choice` and says what it supports, as Meta's
"only `auto` is supported" does through OpenRouter) is asked again with
`"required"` and `submit` as the only tool; if it refuses
that too, or answers without `submit`, a typed extractor with no tools
reads the history and fills the outputs (`:extracted`). The history then
ends with those outputs, recorded as a step that called no tool.
When the step before it wrote a thought and called nothing, that thought
is the newest thing in the history, and the request would end on the
model's own message. Providers refuse that: Mistral reads it as an
assistant prefix it will not combine with function calling, and Gemini
refuses any request that ends on a model turn. So the request shows the
thought as its last user message, under its field's marker, as DSPy's
ReAct shows its trajectory; the history keeps it as the model's step.
DSPy's ReActV2 leaves the thought out of the forced request instead.
An answer is not moved. So a forced request with nothing new to ask (no
inputs, no `last_request_note`) on a history that ends with a step that
answered in plain text, written under `finish: :auto`, still ends on that
assistant message. Resume such a history with new inputs or a note.
- With one text output, the request is an ordinary step, same tools, and its
text is the answer (`:last_text`). Tool calls in it are not run; they are
listed in `unexecuted_tool_calls`, unless the tool is named in
`last_request_tools:` (below).
If the last request does not produce an answer either, the turn ends
`:incomplete`. That is still `{:ok, prediction}`, carrying the history and a
`termination_cause`, and `Imp.Prediction.complete?/1` is false for it; a turn
without an answer is never reported complete.
Imp adds nothing to that request (beyond the unsent line that follows each
earlier thought-only step under `finish: :submit`, section 2) unless you
pass `last_request_note:`, one
line of your own text, sent as a user message and kept in the history.
Some programs act through a tool: they publish, send or record something by
calling it, and their text only reports what they did. For them, a turn
interrupted by a single provider error would lose the action it was about to
take. `last_request_tools:` names the tools whose calls the last request
does run, as any step's calls run: under the same tool policy and
authorization, with the same events, and recorded in the history with their
results. The turn still ends with the request's text as the answer, and calls
to other tools still go unrun. DSPy's ReActV2 makes the same allowance for
its terminal tool, whose call in the forced last request runs; this lets the
last request take the program's own final action, and nothing further.
A request refused because the context window is full is different. Imp first
retries with older episodes left out of the prompt (see below); if the request
still does not fit, another would be refused the same way, so the turn ends at
once, without a last request.
### 4. How it ended is metadata, never an output
The prediction's fields are exactly the signature's outputs, so an output can
be called anything, `history` included. The loop's own account lives in
`prediction.metadata`: `history`, `termination_reason`, and when the turn was
interrupted, `termination_cause`. `Imp.Prediction.complete?/1` is false
exactly when the turn has no answer.
## API walkthrough
### The loop
The escalation program routes a ticket and finds who is on call for it:
```elixir
on_call =
Imp.tool(
:on_call,
"Look up the on-call engineer for a squad.",
fn %{"team" => team} ->
%{"atlas" => "Maya", "harbor" => "Tom", "beacon" => "Ines", "quill" => "Raj"}[team]
end,
schema: %{
"type" => "object",
"properties" => %{"team" => %{"type" => "string", "enum" => ["atlas", "harbor", "beacon", "quill"]}},
"required" => ["team"]
}
)
escalation =
Imp.signature(
"ticket -> team: enum[atlas,harbor,beacon,quill], contact: string",
"Find the squad that owns the ticket and its on-call engineer. " <>
"atlas owns money, harbor the platform, beacon identity, quill the product."
)
```
With `gpt-5.4-mini`, in six runs of six the model called `on_call` for atlas
and then `submit`:
```elixir
lm = Imp.req_llm("openai:gpt-5.4-mini", api_key: System.fetch_env!("OPENAI_API_KEY"))
escalate = Imp.react(escalation, [on_call], lm: lm, max_iters: 5)
{:ok, prediction} = Imp.call(escalate, %{ticket: "We were charged twice this month."})
{Imp.get(prediction, :team), Imp.get(prediction, :contact), prediction.metadata.termination_reason}
#=> {"atlas", "Maya", :submit}
```
Each step shows the model the task's instructions, the output fields with
their types and descriptions, the tools, and the history. What the model
needs to decide, such as what the squads own, can go in the instructions or
in a field's description: with the squad meanings in the `team` field's
description instead, the same model did the same in six runs of six.
A scripted model shows the loop without a provider. Each step is either tool
calls or text:
```elixir
script = fn steps ->
{:ok, agent} = Agent.start_link(fn -> steps end)
Imp.LM.Static.new(
handler: fn _messages, _opts ->
Agent.get_and_update(agent, fn
[step | rest] -> {step, rest}
[] -> {%{tool_calls: []}, []}
end)
end
)
end
lm =
script.([
%{next_thought: "A double charge is money, so atlas.", tool_calls: [%{name: "on_call", arguments: %{"team" => "atlas"}}]},
%{tool_calls: [%{name: "submit", arguments: %{"team" => "atlas", "contact" => "Maya"}}]}
])
{:ok, prediction} = Imp.call(Imp.react(escalation, [on_call], lm: lm), %{ticket: "We were charged twice this month."})
{Imp.get(prediction, :team), Imp.get(prediction, :contact), prediction.metadata.termination_reason}
#=> {"atlas", "Maya", :submit}
```
### The history
Each entry holds the step's `next_thought`, its `tool_calls`, and its
`tool_call_results`, each result paired with its call's ID; the final entry
also holds the submitted outputs.
```elixir
for step <- prediction.metadata.history.messages,
call <- step.tool_calls.tool_calls,
do: call.name
#=> ["on_call", "submit"]
```
To continue the conversation, pass the history back:
~~~elixir
Imp.call(escalate, %{ticket: "It happened again today.", history: prediction.metadata.history})
~~~
The history keeps provider reasoning data needed to continue a turn, and
tool results in full, so store it privately. `Imp.History.redact/1` returns a
copy safe for logs.
### A text answer
A signature with one unconstrained text output asks for that field directly
and ends when the model writes text without a tool call:
```elixir
lm =
script.([
%{tool_calls: [%{name: "on_call", arguments: %{"team" => "atlas"}}]},
%{reply: "Maya from atlas is looking at the duplicate charge.", tool_calls: []}
])
reply = Imp.react(Imp.signature("ticket -> reply", "Tell the customer who is handling their ticket."), [on_call], lm: lm)
{:ok, prediction} = Imp.call(reply, %{ticket: "We were charged twice this month."})
{Imp.get(prediction, :reply), prediction.metadata.termination_reason}
#=> {"Maya from atlas is looking at the duplicate charge.", :answered}
```
That is also how a turn ends when the model writes text instead of acting.
Given "I can't log in after resetting my password." and instructions to look
up who is on call before replying, `gpt-5.4-mini` called the tool and named
Ines in five runs of six. In the sixth it wrote the tool call out as JSON
text instead of making it, and that text was the answer. When an
answer has to follow a tool call, give the signature a second output, so the
turn ends only through `submit`, or end it from the tool with `finish_on:`.
Or keep the one output and pass `finish: :submit`. Text without a tool call
is then a thought, not the answer. Here the model announces a lookup and
forgets to make it; the next step sees what it wrote, makes the lookup, and
ends the turn through `submit`:
```elixir
lm =
script.([
%{next_thought: "Let me check who is on call for atlas.", tool_calls: []},
%{tool_calls: [%{name: "on_call", arguments: %{"team" => "atlas"}}]},
%{tool_calls: [%{name: "submit", arguments: %{"reply" => "Maya from atlas is on it."}}]}
])
reply = Imp.react(Imp.signature("ticket -> reply"), [on_call], lm: lm, finish: :submit)
{:ok, prediction} = Imp.call(reply, %{ticket: "We were charged twice this month."})
{Imp.get(prediction, :reply), prediction.metadata.termination_reason}
#=> {"Maya from atlas is on it.", :submit}
```
### One more request on a history you hold
A call can give its own `max_iters`, `last_request_note` and
`last_request_tools` beside its inputs. With a history, `max_iters: 0` and a note, the call makes one
request, the last one: the loop's system message, its tools and the history
as the model saw them, then the note. No tool runs unless `last_request_tools`
names it (see "Acting in the last request" below; a call for a summary gives
`last_request_tools: []`), and the text the model writes is the answer. Codex CLI and Anthropic's SDK build a request to
summarise a conversation the same way: the conversation's own messages, then
one instruction.
```elixir
lm = script.([%{reply: "Maya from atlas has the duplicate charge.", tool_calls: []}])
reply = Imp.react(Imp.signature("ticket -> reply"), [on_call], lm: lm)
{:ok, summary} =
Imp.call(reply, %{
history: prediction.metadata.history,
max_iters: 0,
last_request_note: "Summarise this ticket for the next shift."
})
{Imp.get(summary, :reply), summary.metadata.termination_reason}
#=> {"Maya from atlas has the duplicate charge.", :last_text}
```
The call can also give its own request options as `config`, merged over the
program's for that call only, such as an output budget for the summary:
`config: [max_tokens: 4_000]`. A text answer's `:finish_reason` metadata says
why the provider stopped the reply, as the LM client reports it; `:length`
means the budget cut it short, so a host keeping the summary can tell a whole
one from a cut one.
### One call's own tool runners
A call can also give `tools`: tools the program holds, each the same in
name, description and schema, whose runners that call uses instead of the
program's. The model is shown exactly what the program shows it, so the
request and its cacheable prefix are unchanged; only what a call to those
tools does belongs to the call. A host that keeps state for one call -- what
the call has done so far, whom it runs for -- builds that call's runners over
it, so copies of one program value, called at once by an evaluation or
`Imp.parallel`, never share it.
```elixir
paging = %{on_call | run: fn %{"team" => team} -> "paged #{team}" end}
lm =
script.([
%{tool_calls: [%{name: "on_call", arguments: %{"team" => "harbor"}}]},
%{reply: "Paged.", tool_calls: []}
])
reply = Imp.react(Imp.signature("ticket -> reply"), [on_call], lm: lm)
{:ok, prediction} = Imp.call(reply, %{ticket: "The deploy is stuck.", tools: [paging]})
for step <- prediction.metadata.history.messages, result <- step.tool_call_results,
do: result.result
#=> ["paged harbor"]
```
A tool shown any differently, or one the program does not hold, is refused
before a request with `{:error, {:invalid_react_v2_tools, message}}`. The
program value is not changed. Updating a program's tools for good is an
optimizer's path, and it still refuses a changed runner: an optimizer may
rewrite what the model reads of a tool, never what the tool does.
### Acting in the last request
A program whose real output is an action names the tools that take it in
`last_request_tools:`. Here the step limit interrupts the turn, and the last
request pages Maya and asks for another lookup; the page runs, the lookup
does not:
```elixir
page = Imp.tool(:page, "Page an engineer.", fn %{"name" => name} -> "paged #{name}" end)
lm =
script.([
%{tool_calls: [%{name: "on_call", arguments: %{"team" => "atlas"}}]},
%{
reply: "Paged Maya.",
tool_calls: [
%{name: "page", arguments: %{"name" => "Maya"}},
%{name: "on_call", arguments: %{"team" => "harbor"}}
]
}
])
pager =
Imp.react(Imp.signature("ticket -> reply"), [on_call, page],
lm: lm,
max_iters: 1,
last_request_tools: [:page]
)
{:ok, prediction} = Imp.call(pager, %{ticket: "We were charged twice this month."})
{Imp.get(prediction, :reply), prediction.metadata.termination_reason,
for(step <- prediction.metadata.history.messages, result <- step.tool_call_results, do: result.result),
for(call <- prediction.metadata.unexecuted_tool_calls, do: call.name)}
#=> {"Paged Maya.", :last_text, ["Maya", "paged Maya"], ["on_call"]}
```
The option applies to a signature with one text output; a signature with
`submit` ends an interrupted turn with the forced `submit`, and naming tools
for it is refused. A listed tool also runs in a one-request call made with
`max_iters: 0`, since that request is the last one, so a call that asks for
a summary gives its own `last_request_tools: []`, which replaces the
program's for that call, as its own `max_iters` and `last_request_note` do:
```elixir
lm = script.([%{reply: "Maya was paged about the double charge.", tool_calls: [%{name: "page", arguments: %{"name" => "Maya"}}]}])
pager = Imp.react(Imp.signature("ticket -> reply"), [on_call, page], lm: lm, last_request_tools: [:page])
{:ok, summary} =
Imp.call(pager, %{
history: prediction.metadata.history,
max_iters: 0,
last_request_note: "Summarise this ticket for the next shift.",
last_request_tools: []
})
{Imp.get(summary, :reply), for(call <- summary.metadata.unexecuted_tool_calls, do: call.name)}
#=> {"Maya was paged about the double charge.", ["page"]}
```
### Ending the turn from a tool
`finish_on:` maps a tool name to a function of the call's arguments, its
result and the turn's inputs. Returning `{:finish, outputs}` ends the turn
with those outputs, checked against the signature as a `submit` would be;
`:continue` lets the loop go on.
```elixir
lm = script.([%{tool_calls: [%{name: "on_call", arguments: %{"team" => "beacon"}}]}])
page_and_stop =
Imp.react(escalation, [on_call],
lm: lm,
finish_on: %{
on_call: fn %{"team" => team}, contact, _inputs ->
{:finish, %{team: team, contact: contact}}
end
}
)
{:ok, prediction} = Imp.call(page_and_stop, %{ticket: "I can't log in."})
{Imp.get(prediction, :contact), prediction.metadata.termination_reason, prediction.metadata.finished_by_tool}
#=> {"Ines", :finished_by_tool, "on_call"}
```
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:
| Reason | Meaning |
| --- | --- |
| `:answered` | one text output, `finish: :auto`: a step wrote the answer |
| `:submit` | the model called `submit` with valid outputs |
| `:finished_by_tool` | a `finish_on` function finished the turn; `finished_by_tool` names the tool |
| `:forced_submit` | interrupted; the last request, with `submit` required, answered |
| `:extracted` | interrupted; the provider could not require `submit`, and the extractor answered; the history ends with its outputs |
| `:last_text` | interrupted; one text output, and the last request's text is the answer |
| `:incomplete` | interrupted, and there is no answer |
When the turn was interrupted, `termination_cause` says why:
| Cause | Meaning |
| --- | --- |
| `:max_iters` | the step budget ran out |
| `:parse_error` | a step's reply could not be read |
| `:prediction_error` | a step's request failed |
| `:empty_tool_calls` | with `submit` (every signature but one unconstrained text output under `finish: :auto`): a step called no tool; under `finish: :submit`, a second such step in a row, or one that said nothing |
| `:context_window_exceeded` | the prompt no longer fits, even with old episodes left out |
| `:deadline_exceeded` | the process's `Imp.Deadline` passed |
For `:incomplete`, `termination_error` holds the redacted errors of the
requests that failed.
```elixir
lm =
script.([
%{tool_calls: [%{name: "on_call", arguments: %{"team" => "atlas"}}]},
%{tool_calls: [%{name: "submit", arguments: %{"team" => "atlas", "contact" => "Maya"}}]}
])
{:ok, prediction} =
Imp.call(Imp.react(escalation, [on_call], lm: lm, max_iters: 1), %{ticket: "We were charged twice this month."})
{prediction.metadata.termination_reason, prediction.metadata.termination_cause, Imp.get(prediction, :contact)}
#=> {:forced_submit, :max_iters, "Maya"}
```
An enum output keeps `submit`, so text alone does not end the turn. A model
that only ever writes text is interrupted, the forced `submit` gets text too,
and the turn has no answer:
```elixir
lm =
script.([
%{next_thought: "team: atlas", tool_calls: []},
%{next_thought: "team: atlas", tool_calls: []}
])
router = Imp.react(Imp.signature("ticket -> team: enum[atlas,harbor,beacon,quill]"), [on_call], lm: lm)
{:ok, prediction} = Imp.call(router, %{ticket: "We were charged twice this month."})
{prediction.metadata.termination_reason, prediction.metadata.termination_cause, Imp.Prediction.complete?(prediction)}
#=> {:incomplete, :empty_tool_calls, false}
```
`max_iters` is 20 by default; a call can override it with a `max_iters`
input.
### Tools, policies and authorization
`tool_policy:` limits which tools the model may call; a refused call is
recorded as an error and the model reads that it was not allowed. `submit` is
subject to the policy too, so a list policy must include `:submit`, or the
turn cannot finish with it. To ask a person or a service before each call,
run the program under `Imp.start_run/3` with `authorize:`, or serve it over
ACP. See [Tools and MCP](tools-and-mcp.md).
Unknown tools, calls that fail the schema, and tools that raise all become
error results in the history, and the model can respond to them on the next
step.
### A full context window
When the provider refuses a request because the context window is full, Imp
retries with the oldest earlier episodes of the history left out of the
prompt, up to eight times. The history itself keeps everything; only the
request is smaller. `context_projection` in the metadata counts what was left
out. The current turn's own tool results are never dropped. If the current
turn and the instructions alone do not fit, the turn ends `:incomplete` with
`termination_cause: :context_window_exceeded`.
### Optimizing the loop
`Imp.Optimizer.GEPA` optimizes an agent as DSPy's GEPA optimizes a ReActV2:
it rewrites the step predictor's instruction and leaves the tool descriptions
as they are. For each example it reflects on one step of the run, drawn with
the optimizer's seed, and shows the reflection model the run's finished
history (`metadata.history`): every step's thought, tool calls and results,
and the outputs, which the history holds unless `finish_on` or the extractor
ended the turn. It also shows the
tools the loop offered, by name, description and arguments, and the metric's
feedback.
### Coming from DSPy's ReAct and ReActV2
DSPy is replacing its trajectory-based `ReAct` with the structured-history
`ReActV2`, which becomes `dspy.ReAct` in DSPy 3.5. `Imp.react/3` is already
that design. The trajectory-based loop remains as `Imp.Predict.ReAct` for
programs that need it.
What matches DSPy's ReActV2: structured history replayed as native messages,
several tool calls per step, a `submit` tool generated from the outputs,
the forced `submit` when a turn is interrupted, and passing `history` back to
continue.
What differs:
- A signature with one text output has no `submit`; the model's text is the
answer (`:answered`, and `:last_text` when interrupted). `finish: :submit`
gives it `submit`, as DSPy does, but a thought-only step is followed by an
ordinary step rather than at once by the forced `submit`, which DSPy sends
after every step that calls no tool. A plain-text turn's last request
runs no tool by default; `last_request_tools:` names the ones it may run,
the counterpart of DSPy executing `submit` in its forced last request.
- `finish_on:` lets a tool end the turn (`:finished_by_tool`).
- When the provider cannot require `submit`, an extractor fills the outputs
(`:extracted`); a turn with no answer is `:incomplete`, with a cause.
- History, termination reason and cause are in `prediction.metadata`, not
among the output fields.
- On a full context window, old episodes are left out of the prompt; DSPy's
ReActV2 does not truncate.
- Tool policies and per-run authorization decide which calls run.
## Cross-links
- [Tools and MCP](tools-and-mcp.md): tools, policies, MCP servers, ACP.
- [Adapters](adapters.md): the Chat adapter renders the history and the
step's instructions.
- [Signatures](signatures.md): the outputs that become `submit`'s
parameters.
- `Imp.Predict.ReActV2`, `Imp.History`, `Imp.Prediction`: the reference.