From 859a91cad4429e400da2996d7d26541d8f78e36c Mon Sep 17 00:00:00 2001 From: Larissa Schiavo Date: Wed, 16 Sep 2026 18:25:17 -0700 Subject: [PATCH] Preserve native reasoning across ReAct tool history --- CHANGELOG.md | 6 + lib/imp/adapter/chat.ex | 20 +- lib/imp/clients/req_llm.ex | 78 ++++++++ lib/imp/predict/react_v2.ex | 9 + test/reasoning_continuity_test.exs | 310 +++++++++++++++++++++++++++++ 5 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 test/reasoning_continuity_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index 965a8fac..19fd770d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/imp/adapter/chat.ex b/lib/imp/adapter/chat.ex index 365392a6..8f891610 100644 --- a/lib/imp/adapter/chat.ex +++ b/lib/imp/adapter/chat.ex @@ -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 -> diff --git a/lib/imp/clients/req_llm.ex b/lib/imp/clients/req_llm.ex index 3107c508..8cc2a7da 100644 --- a/lib/imp/clients/req_llm.ex +++ b/lib/imp/clients/req_llm.ex @@ -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 @@ -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 -> diff --git a/lib/imp/predict/react_v2.ex b/lib/imp/predict/react_v2.ex index 3cbc90b3..22b5f7b5 100644 --- a/lib/imp/predict/react_v2.ex +++ b/lib/imp/predict/react_v2.ex @@ -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 @@ -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 diff --git a/test/reasoning_continuity_test.exs b/test/reasoning_continuity_test.exs new file mode 100644 index 00000000..32fc5dbb --- /dev/null +++ b/test/reasoning_continuity_test.exs @@ -0,0 +1,310 @@ +defmodule ReasoningContinuityTest do + use ExUnit.Case, async: true + + @continuation_token "sk-protocolabcdefghijklmnopqrstuvwx1234567890" + @input_token "sk-inputcredentialabcdefghijklmnop1234567890" + + defmodule CaptureClient do + def generate_text(model, messages, opts) do + send(Keyword.fetch!(opts, :test_pid), {:native_messages, messages}) + + {:ok, + %ReqLLM.Response{ + id: "capture-response", + model: to_string(model), + message: ReqLLM.Context.assistant("done"), + context: ReqLLM.Context.new(messages) + }} + end + end + + test "OpenRouter reasoning blocks survive tool continuation and a JSON history reload" do + details = [ + %{ + "type" => "reasoning.encrypted", + "data" => @continuation_token, + "id" => "reasoning-1", + "format" => "fixture-v1", + "index" => 0, + "provider_extension" => %{"token" => @continuation_token, "order" => [2, 1]} + }, + %{ + "type" => "reasoning.text", + "text" => "Use the lookup result.", + "signature" => @continuation_token, + "format" => "fixture-v1", + "index" => 1 + } + ] + + assert_continuity(:openrouter, "reasoning_details", details) + end + + test "DeepSeek native reasoning text survives tool continuation and a JSON history reload" do + assert_continuity( + :deepseek, + "reasoning_content", + " Native plan: #{@continuation_token}\nuse lookup.\n" + ) + end + + test "atom and string keyed assistant messages retain native reasoning without duplicating it" do + detail = %ReqLLM.Message.ReasoningDetails{ + provider: :openrouter, + text: "native plan", + signature: @continuation_token, + provider_data: %{"opaque" => [@continuation_token]} + } + + lm = + Imp.req_llm("openrouter:fixture/model", + req_module: CaptureClient, + test_pid: self(), + cache: false + ) + + for stored <- [detail, Map.from_struct(detail)], keys <- [:atom, :string] do + assistant = %{ + role: :assistant, + content: [%Imp.Adapter.Types.Reasoning{text: "native plan"}, "visible answer"], + reasoning_content: "native plan", + reasoning_details: [stored] + } + + assistant = + if keys == :string, + do: Map.new(assistant, fn {key, value} -> {Atom.to_string(key), value} end), + else: assistant + + assert {:ok, _} = Imp.Clients.ReqLLM.generate(lm, [assistant], []) + assert_received {:native_messages, [%ReqLLM.Message{} = message]} + assert message.reasoning_details == [detail] + + assert [ + %ReqLLM.Message.ContentPart{type: :thinking, text: "native plan"}, + %ReqLLM.Message.ContentPart{type: :text, text: "visible answer"} + ] = message.content + end + end + + test "Anthropic tool history replays signed and redacted blocks once after nested JSON decoding" do + details = [ + %ReqLLM.Message.ReasoningDetails{ + provider: :anthropic, + text: "native plan", + signature: @continuation_token, + index: 0 + }, + %ReqLLM.Message.ReasoningDetails{ + provider: :anthropic, + encrypted?: true, + provider_data: %{"data" => @continuation_token}, + index: 1 + } + ] + + lm = + Imp.req_llm("anthropic:claude-sonnet-4-6", + req_module: CaptureClient, + test_pid: self(), + cache: false + ) + + for stored <- [details, details |> Jason.encode!() |> Jason.decode!()] do + assistant = %{ + role: :assistant, + content: [%Imp.Adapter.Types.Reasoning{text: "native plan"}, "visible answer"], + reasoning_content: "native plan", + reasoning_details: stored, + tool_calls: [%{id: "signed-call", name: "lookup", arguments: %{query: "fixture"}}] + } + + assert {:ok, _} = Imp.Clients.ReqLLM.generate(lm, [assistant], []) + assert_received {:native_messages, [%ReqLLM.Message{} = message]} + assert message.reasoning_details == details + + wire = + ReqLLM.Providers.Anthropic.Context.encode_request( + ReqLLM.Context.new([message]), + "claude-fixture" + ) + + assert [%{role: "assistant", content: blocks}] = wire.messages + + assert blocks == [ + %{type: "thinking", thinking: "native plan", signature: @continuation_token}, + %{type: "redacted_thinking", data: @continuation_token}, + %{type: "text", text: "visible answer"}, + %{ + type: "tool_use", + id: "signed-call", + name: "lookup", + input: %{"query" => "fixture"} + } + ] + end + end + + test "raw provider details survive message JSON decoding and ordinary messages stay ordinary" do + raw = %{"type" => "reasoning.encrypted", "data" => @continuation_token} + + lm = + Imp.req_llm("openrouter:fixture/model", + req_module: CaptureClient, + test_pid: self(), + cache: false + ) + + messages = + [ + %{role: :assistant, content: "", reasoning_details: [raw]}, + %{role: :assistant, content: "ordinary answer"}, + %{role: :user, content: "question", reasoning_content: "not assistant reasoning"} + ] + |> Jason.encode!() + |> Jason.decode!() + + assert {:ok, _} = Imp.Clients.ReqLLM.generate(lm, messages, []) + assert_received {:native_messages, [reasoning, ordinary, user]} + assert reasoning.reasoning_details == [raw] + assert ordinary.reasoning_details == nil + assert [%ReqLLM.Message.ContentPart{type: :text, text: "ordinary answer"}] = ordinary.content + assert [%ReqLLM.Message.ContentPart{type: :text, text: "question"}] = user.content + end + + test "normalized JSON details restore known providers without atomizing opaque extension keys" do + extension_key = "unknown-reasoning-extension-#{System.unique_integer([:positive])}" + unknown_provider = "unknown-reasoning-provider-#{System.unique_integer([:positive])}" + + details = + for provider <- [:anthropic, :google, :openai, :openrouter] do + %ReqLLM.Message.ReasoningDetails{ + provider: provider, + text: "native plan", + signature: @continuation_token, + provider_data: %{extension_key => @continuation_token} + } + end + + raw_unknown = %{"provider" => unknown_provider, "type" => "reasoning.encrypted"} + + lm = + Imp.req_llm("openrouter:fixture/model", + req_module: CaptureClient, + test_pid: self(), + cache: false + ) + + stored = (details ++ [raw_unknown]) |> Jason.encode!() |> Jason.decode!() + messages = [%{"role" => "assistant", "content" => "", "reasoning_details" => stored}] + assert {:ok, _} = Imp.Clients.ReqLLM.generate(lm, messages, []) + assert_received {:native_messages, [message]} + assert message.reasoning_details == details ++ [raw_unknown] + assert_raise ArgumentError, fn -> String.to_existing_atom(extension_key) end + assert_raise ArgumentError, fn -> String.to_existing_atom(unknown_provider) end + end + + defp assert_continuity(provider, field, value) do + owner = self() + counter = start_supervised!({Agent, fn -> 0 end}) + + base_url = + Imp.Test.LocalHTTP.start(fn request -> + count = Agent.get_and_update(counter, fn value -> {value + 1, value + 1} end) + body = Jason.decode!(request.body) + send(owner, {:wire_request, count, body}) + + {name, arguments} = + if count == 1, + do: {"lookup", %{"query" => "fixture"}}, + else: {"submit", %{"answer" => "done"}} + + {200, + %{ + "id" => "completion-#{count}", + "object" => "chat.completion", + "model" => body["model"], + "choices" => [ + %{ + "index" => 0, + "finish_reason" => "tool_calls", + "message" => %{ + "role" => "assistant", + "content" => "", + field => value, + "tool_calls" => [ + %{ + "id" => "original-call-#{count}", + "type" => "function", + "function" => %{"name" => name, "arguments" => Jason.encode!(arguments)} + } + ] + } + } + ], + "usage" => %{"prompt_tokens" => 2, "completion_tokens" => 3, "total_tokens" => 5} + }} + end) + + lm = + Imp.req_llm( + %{provider: provider, id: "fixture/model", model: "fixture/model", base_url: base_url}, + api_key: "local-test-key", + cache: false, + req_http_options: [retry: false, max_retries: 0] + ) + + lookup = Imp.tool(:lookup, "Read an immutable fixture", fn %{query: "fixture"} -> "found" end) + program = Imp.react_v2("question -> answer", [lookup], lm: lm, max_iters: 4) + + assert {:ok, run} = + Imp.Run.start(program, %{question: "Look up the fixture. #{@input_token}"}) + + assert {:ok, first} = Task.await(run.task) + events = Imp.Run.events(run) + assert :ok = Imp.Run.stop(run) + assert Imp.get(first, :termination_reason) == :submit + assert Imp.get(first, :answer) == "done" + assert_received {:wire_request, 1, _initial} + assert_received {:wire_request, 2, continuation} + + history = Imp.get(first, :history) + refute inspect(history, limit: :infinity) =~ @input_token + + restored = + history |> Imp.History.dump() |> Jason.encode!() |> Jason.decode!() |> Imp.History.load() + + restarted = Imp.react_v2("question -> answer", [lookup], lm: lm, max_iters: 4) + + assert {:ok, resumed} = Imp.call(restarted, %{question: "Continue.", history: restored}) + assert Imp.get(resumed, :answer) == "done" + assert Agent.get(counter, & &1) == 3 + assert_received {:wire_request, 3, after_reload} + + assert_replayed(continuation, "original-call-1", field, value) + assert_replayed(after_reload, "original-call-1", field, value) + assert_replayed(after_reload, "original-call-2", field, value) + + # Operational continuation data is lossless; diagnostic copies still use + # the ordinary credential redactor, including inside provider extensions. + refute inspect(Imp.History.redact(history), limit: :infinity) =~ @continuation_token + assert Enum.any?(events, &(&1.kind == :model_response)) + assert Enum.any?(events, &(&1.kind == :run_finished)) + refute inspect(events, limit: :infinity) =~ @continuation_token + end + + defp assert_replayed(request, id, field, value) do + assistant = + Enum.find(request["messages"], fn message -> + message["role"] == "assistant" and + Enum.any?(message["tool_calls"] || [], &(&1["id"] == id)) + end) + + assert assistant, "missing original assistant tool call #{id}" + assert assistant[field] == value + + assert Enum.any?(request["messages"], fn message -> + message["role"] == "tool" and message["tool_call_id"] == id + end) + end +end