Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ User-visible changes to Imp are recorded here.

## Unreleased

- ReActV2 preserves provider-native reasoning text and opaque reasoning details
across tool calls and saved-history reloads. ReqLLM receives the original
continuation data, including provider extension fields and signatures, instead
of losing it while rebuilding assistant messages. Operational history must be
stored privately; run events and explicit diagnostic redaction still redact
credential-shaped values.
- A map or list value in a prompt, including a structured tool result, now
renders the way DSPy renders a dict: `json.dumps(..., ensure_ascii=False)`
with Python's default separators, complete. It was `inspect/1` at its
Expand Down
20 changes: 15 additions & 5 deletions lib/imp/adapter/chat.ex
Original file line number Diff line number Diff line change
Expand Up @@ -1044,11 +1044,21 @@ defmodule Imp.Adapter.Chat do
content: render_inputs(signature, turn, skip: history_input_fields(signature))
}

assistant = %{
role: :assistant,
content: fetch_field(turn, :next_thought) |> blank_to_empty(),
tool_calls: calls
}
assistant =
Enum.reduce(
[:reasoning_content, :reasoning_details],
%{
role: :assistant,
content: fetch_field(turn, :next_thought) |> blank_to_empty(),
tool_calls: calls
},
fn field, message ->
case fetch_field(turn, field) do
nil -> message
value -> Map.put(message, field, value)
end
end
)

tool_messages =
Enum.map(results, fn result ->
Expand Down
78 changes: 78 additions & 0 deletions lib/imp/clients/req_llm.ex
Original file line number Diff line number Diff line change
Expand Up @@ -801,6 +801,7 @@ defmodule Imp.Clients.ReqLLM do
content,
Map.get(message, :tool_calls) || Map.get(message, "tool_calls")
)
|> preserve_reasoning(message)

# Messages that went through a JSON round trip (ReqLLMBatch checkpoints,
# anything decoded from disk or the wire) arrive with string keys and
Expand All @@ -812,12 +813,89 @@ defmodule Imp.Clients.ReqLLM do
content,
Map.get(message, "tool_calls") || Map.get(message, :tool_calls)
)
|> preserve_reasoning(message)

other ->
ReqLLM.Context.user(inspect(other))
end)
end

defp preserve_reasoning(%ReqLLM.Message{role: :assistant} = message, source) do
message =
case map_value(source, :reasoning_details) do
details when is_list(details) ->
%{message | reasoning_details: Enum.map(details, &restore_reasoning_detail/1)}

_ ->
message
end

text = map_value(source, :reasoning_content)

cond do
Enum.any?(message.reasoning_details || [], fn detail ->
match?(%ReqLLM.Message.ReasoningDetails{provider: :anthropic}, detail)
end) ->
# ReqLLM encodes Anthropic's signed/redacted blocks from the details.
# Adding their text as a thinking content part duplicates the block on
# tool turns and produces an unsigned continuation.
%{message | content: without_thinking(message.content)}

is_binary(text) and text != "" ->
%{
message
| content: [
ReqLLM.Message.ContentPart.thinking(text) | without_thinking(message.content)
]
}

true ->
message
end
end

defp preserve_reasoning(message, _source), do: message

defp without_thinking(content), do: Enum.reject(content, &match?(%{type: :thinking}, &1))

defp restore_reasoning_detail(%ReqLLM.Message.ReasoningDetails{} = detail), do: detail
defp restore_reasoning_detail(%{"type" => _type} = raw_detail), do: raw_detail

# History's codec drops unknown struct types and may decode atoms as strings
# before their owning module is loaded. Restore only known struct fields and
# provider names; never atomize provider data or unrecognized wire maps.
defp restore_reasoning_detail(detail) when is_map(detail) do
provider = map_value(detail, :provider)

provider =
if is_binary(provider),
do:
Enum.find(
[:anthropic, :google, :openai, :openrouter],
&(Atom.to_string(&1) == provider)
),
else: provider

if is_atom(provider) and not is_nil(provider) do
fields =
Enum.reduce(
[:text, :signature, :encrypted?, :format, :index, :provider_data],
%{provider: provider},
fn key, fields ->
if Map.has_key?(detail, key) or Map.has_key?(detail, Atom.to_string(key)),
do: Map.put(fields, key, map_value(detail, key)),
else: fields
end
)

struct(ReqLLM.Message.ReasoningDetails, fields)
else
detail
end
end

defp restore_reasoning_detail(detail), do: detail

defp build_message(role, content, tool_calls) do
case normalize_role(role) do
:system ->
Expand Down
9 changes: 9 additions & 0 deletions lib/imp/predict/react_v2.ex
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ defmodule Imp.Predict.ReActV2 do
`: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.
"""

@behaviour Imp.Module
Expand Down Expand Up @@ -650,6 +655,10 @@ defmodule Imp.Predict.ReActV2 do
|> Map.put(:tool_call_results, results)
|> then(fn event -> if final, do: Map.merge(event, final), else: event end)
|> Imp.Redaction.redact()
# Opaque signatures and reasoning blocks may resemble credentials. Preserve
# the provider's continuation state exactly; Run events redact their copies.
|> maybe_put(:reasoning_content, Map.get(prediction.metadata, :native_reasoning))
|> maybe_put(:reasoning_details, Map.get(prediction.metadata, :reasoning_details))
end

defp final_prediction(final, history, reason) do
Expand Down
Loading
Loading