From 45035d443024b31eef439e5b3ed735148a6309c9 Mon Sep 17 00:00:00 2001 From: deepfates Date: Sat, 17 Oct 2026 13:10:30 -0700 Subject: [PATCH 1/2] ReActV2: replay a stored answer turn under finish: :submit and :tools A turn a host stores with the task's inputs and its one output, and no step of the loop's own (a recap that stands in for the earlier conversation, say), is read as a step that answered in text before any adapter formats it. Since #343 that reading was gated on the program ending in plain text (`finish: :auto`), so under `finish: :submit` and `finish: :tools` the turn replayed only its inputs, under either history replay: the model read the event and never the answer stored with it. Read it the same way under every ending. The turn carries the task's output, so it is an answer, never a thought, and gets no unsent line, exactly as a plain-text loop's answer step replayed under submit does. Only the request changes; the history the caller holds does not. --- CHANGELOG.md | 13 ++++ lib/imp/predict/react_v2.ex | 22 +++--- test/react_v2_host_answer_turn_test.exs | 96 +++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 test/react_v2_host_answer_turn_test.exs diff --git a/CHANGELOG.md b/CHANGELOG.md index be419d5b..12116089 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ User-visible changes to Imp are recorded here. ## Unreleased +### Fixed + +- ReActV2 under `finish: :submit` or `finish: :tools` replays a stored turn + that carries the task's inputs and its one output, and no step of the + loop's own (a turn a host wrote, such as a recap that stands in for the + earlier conversation), as the model's message holding that output, as + `finish: :auto` always has. Since 0.14.0 it replayed only the turn's + inputs, under either history replay, so the model read the event and not + the answer stored with it. The history the caller holds is unchanged, and + no other turn's replay changes. A one-output signature whose output is + constrained or not a string under `finish: :auto` gets the same replay + when the stored value is a string. + ## 0.16.0 — 2026-10-17 ### Added diff --git a/lib/imp/predict/react_v2.ex b/lib/imp/predict/react_v2.ex index 2fc95b1c..2ef58841 100644 --- a/lib/imp/predict/react_v2.ex +++ b/lib/imp/predict/react_v2.ex @@ -2245,15 +2245,19 @@ defmodule Imp.Predict.ReActV2 do end end - # A stored turn that carries the answer of a one-text-output task but no - # step outputs (a host wrote it, or it predates the loop's own events) is, - # in the step's terms, a step that answered in text: `next_thought` is the - # answer and it called nothing. Read that way, every adapter replays it as - # the answer the model gave, not as step fields it never filled. Only the - # prompt's view changes; the history the caller holds does not. A binary - # task output named tool_calls is text, not the stored call collection. - defp step_turn(turn, %__MODULE__{signature: signature, finish: finish}) when is_map(turn) do - with {:text, [%Imp.Signature.Field{name: name}]} <- text_output(signature, finish), + # A stored turn that carries the answer of a one-output task but no step + # outputs (a host wrote it, or it predates the loop's own events) is, in the + # step's terms, a step that answered in text: `next_thought` is the answer + # and it called nothing. Read that way, every adapter replays it as the + # answer the model gave, not as step fields it never filled. So it is under + # every ending: under `finish: :submit` or `:tools` the turn carries the + # task's output, so it is an answer, never a thought, as a plain-text + # loop's answer step replayed there is. Without this it showed only its + # inputs. Only the prompt's view changes; the history the caller holds + # does not. A binary task output named tool_calls is text, not the stored + # call collection. + defp step_turn(turn, %__MODULE__{signature: signature}) when is_map(turn) do + with %Imp.Signature{outputs: [%Imp.Signature.Field{name: name}]} <- signature, false <- Imp.FieldMap.has_key?(turn, :next_thought), false <- Imp.FieldMap.has_key?(turn, :tool_calls) and diff --git a/test/react_v2_host_answer_turn_test.exs b/test/react_v2_host_answer_turn_test.exs new file mode 100644 index 00000000..532a6144 --- /dev/null +++ b/test/react_v2_host_answer_turn_test.exs @@ -0,0 +1,96 @@ +defmodule ReActV2HostAnswerTurnTest do + use ExUnit.Case, async: true + + # A turn a host stores with the task's inputs and its answer, and no step of + # the loop's own (a conversation's recap, say), is replayed as that answer + # under every ending and either history replay: the model reads the reply + # it is told it gave. Under `finish: :submit` and `:tools` it showed only + # its inputs. + + defp look, do: Imp.tool(:look, "Look at a thing", fn _arguments -> "a cat" end) + defp send_tool, do: Imp.tool(:send, "Send the reply", fn _arguments -> "sent" end) + + defp scripted(owner, replies) do + {:ok, agent} = Agent.start_link(fn -> replies end) + + Imp.LM.Static.new( + handler: fn messages, _opts -> + send(owner, {:request, messages}) + + Agent.get_and_update(agent, fn + [reply | rest] -> {reply, rest} + [] -> {%{tool_calls: []}, []} + end) + end + ) + end + + defp requests(acc \\ []) do + receive do + {:request, messages} -> requests([messages | acc]) + after + 0 -> Enum.reverse(acc) + end + end + + defp shape(messages), do: Enum.map(tl(messages), &{&1.role, to_string(&1.content)}) + + defp program(:auto, replay), + do: + Imp.react("ticket -> reply", [look()], + lm: scripted(self(), [%{reply: "Yes.", tool_calls: []}]), + finish: :auto, + history_replay: replay + ) + + defp program(:submit, replay), + do: + Imp.react("ticket -> reply", [look()], + lm: + scripted(self(), [ + %{tool_calls: [%{id: "s1", name: "submit", arguments: %{"reply" => "Yes."}}]} + ]), + finish: :submit, + history_replay: replay + ) + + defp program(:tools, replay), + do: + Imp.react("ticket -> reply", [look(), send_tool()], + lm: scripted(self(), [%{tool_calls: [%{id: "s1", name: "send", arguments: %{}}]}]), + finish: :tools, + finish_on: %{send: fn _arguments, _result, _inputs -> {:finish, %{reply: "sent"}} end}, + history_replay: replay + ) + + @recap Imp.History.new([ + %{ + ticket: "The earlier part of this conversation is summarised in your recap of it.", + reply: "We talked about a cat in a box." + } + ]) + + for finish <- [:auto, :submit, :tools], replay <- [:trajectory, :actions] do + test "finish: #{finish}, history_replay: #{replay}" do + program = program(unquote(finish), unquote(replay)) + assert {:ok, _prediction} = Imp.call(program, %{ticket: "Is it awake?", history: @recap}) + + [request] = requests() + + assert shape(request) == [ + {:user, + "[[ ## ticket ## ]]\nThe earlier part of this conversation is summarised in your recap of it."}, + {:assistant, "We talked about a cat in a box."}, + {:user, "[[ ## ticket ## ]]\nIs it awake?"} + ] + end + end + + # The history the caller passed keeps the turn as it was written. + test "the stored turn is unchanged in the history returned" do + assert {:ok, prediction} = + Imp.call(program(:submit, :trajectory), %{ticket: "Is it awake?", history: @recap}) + + assert hd(prediction.metadata.history.messages) == hd(@recap.messages) + end +end From be4b23fd769a513d148c80b578bfdcfcd82ce4d1 Mon Sep 17 00:00:00 2001 From: deepfates Date: Sat, 17 Oct 2026 13:15:59 -0700 Subject: [PATCH 2/2] Prepare Imp 0.16.1 release Version 0.16.1 with tag links for v0.16.1; install lines stay ~> 0.16.0, as for earlier patch releases. The changelog's Unreleased section goes under 0.16.1, and the release notes describe #356. A patch: #356 adds no option and no public API, and fixes how ReActV2 replays a stored answer turn under finish: :submit and :tools. --- CHANGELOG.md | 2 + README.md | 2 +- RELEASE_NOTES.md | 71 ++++++++----------- docs/coming-from-dspy.md | 2 +- docs/diving-deeper/modules-and-composition.md | 2 +- docs/diving-deeper/saving-and-artifacts.md | 2 +- .../running-in-your-application.md | 2 +- docs/getting-started/where-to-go-next.md | 2 +- docs/production.md | 4 +- livebooks/05_operating_imp.livemd | 2 +- mix.exs | 2 +- priv/public_api.json | 2 +- test/package_contract_test.exs | 2 +- 13 files changed, 42 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12116089..833086c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ User-visible changes to Imp are recorded here. ## Unreleased +## 0.16.1 — 2026-10-17 + ### Fixed - ReActV2 under `finish: :submit` or `finish: :tools` replays a stored turn diff --git a/README.md b/README.md index 70571e93..c030afa1 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ large-scale benchmarking. Bug reports and pull requests are welcome. - [Getting started](docs/getting-started/index.md) builds one program step by step, from the first call to a supervised server, with real scores. - [Coming from DSPy](docs/coming-from-dspy.md) maps DSPy's names to Imp's. -- [Tutorials](https://github.com/deepfates/imp/tree/v0.16.0/livebooks) are Livebook notebooks +- [Tutorials](https://github.com/deepfates/imp/tree/v0.16.1/livebooks) are Livebook notebooks you can run offline or with a key. - The [cheatsheet](docs/cheatsheet.cheatmd) has the common calls on one page. diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e0a2d4f1..f9d336e2 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,13 +1,13 @@ -# Imp v0.16.0 +# Imp v0.16.1 Imp is a framework for typed, optimizable language-model programs on the BEAM. -This release adds `request_retry:` to `Imp.Run.start/3`, which sends a model -request the provider failed in a way that may pass again in the same run, after -a wait. It also changes two things a caller may rely on: an LM client other -than ReqLLM reports its charge as metadata `:cost`, because `Imp.Core` no longer -reads it from the usage map; and a ReActV2 forced `submit` with invalid -arguments goes to the extractor. A new option and changed behaviour make this -a minor release under Imp's versioning rule. +This release fixes how ReActV2 replays a history turn that a host stored with +the task's inputs and its answer, and no step of the loop's own. Under +`finish: :submit` and `finish: :tools`, since 0.14.0, such a turn replayed only +its inputs: the model read the event and not the answer stored with it. It now +replays as the model's message holding that answer, as it always has under +`finish: :auto`. It adds no options and changes no public API, so under Imp's +versioning rule this is a patch release. ## Install @@ -29,44 +29,29 @@ in its release definition; see [releases that use MCP or ACP](docs/production.md#releases-that-use-mcp-or-acp). Ordinary Imp startup starts no protocol endpoint. -## Changes and upgrading from 0.15 +## Changes and upgrading from 0.16.0 -Change the Imp dependency to `{:imp, "~> 0.16.0"}`, run `mix deps.update imp`, -and commit `mix.lock`. No data migration is needed. Check the second item if -you use an LM client of your own, or build response envelopes by hand. +Under `{:imp, "~> 0.16.0"}`, run `mix deps.update imp` and commit `mix.lock`. +No code or data migration is needed. -- **Added: `request_retry:` on `Imp.Run.start/3`.** `request_retry: [waits: - [ms, ...], reserve: ms]` sends a model request that failed with an error - `Imp.Errors.retryable?/1` accepts (408, 425, 429, 5xx, a failed connection) - again inside `Imp.LM.request/2`, after each wait in turn. Under a deadline - the last wait repeats while it leaves `reserve` (60 s by default) of the - deadline; without one, the list is the bound. A provider's `retry-after` - longer than the wait is waited instead. Each attempt is its own - `:model_request` and `:model_response`, and a failed response followed by - another attempt carries `retry_in_ms`. Only the outermost request in a - process retries, so an LM that wraps an LM does not multiply attempts. - Streamed requests are not sent again. `waits` must not be empty. Off unless - given. A timed-out attempt may have been finished and billed by the - provider without a response to record, so a retry can be billed twice; - under a deadline each attempt is capped to the time left. -- **Changed: where a call's charge is read.** OpenRouter bring-your-own-key - billing moved into `Imp.Clients.ReqLLM`, which records the charge it - resolves as `:charge` in its `req_llm` response metadata. `Imp.Core` reads - `LMResponse.cost` from that `:charge` (final when present, `nil` included), - and otherwise from metadata `:cost`; it no longer reads usage `"cost"` or - `"is_byok"`. An LM client other than ReqLLM, or a hand-built response - envelope, must report its charge as `:cost` in its metadata (or - `req_llm.charge`), or its cost is `nil`. -- **Fixed: a forced `submit` with invalid arguments is extracted.** A ReActV2 - forced `submit` whose arguments fail validation now goes to the typed - extractor and ends `:extracted`, where it ended `:incomplete` with no - outputs. If that extraction request fails with an LM error, the call - returns `{:error, %Imp.Predict.ReActV2.StepError{}}`, where it returned - `{:ok, prediction}` ending `:incomplete`. A forced `submit` the tool policy - refuses still ends `:incomplete`. +- **Fixed: a stored answer turn replays its answer under every ending.** + ReActV2 reads a stored turn that carries the inputs and the one output of + the task, and neither `next_thought` nor `tool_calls`, as a step that + answered in text. Since 0.14.0 it did so only when the program ended in + plain text (`finish: :auto`); under `finish: :submit` and `:tools`, with + either `history_replay`, the request held the turn's inputs as a user + message and no assistant message after them. A host that writes such a + turn, for example a recap standing in for the earlier conversation, had + its content left out of every request. The turn now replays as one + assistant message holding the answer, with no "not sent" line after it, + since it carries the task's output. Turns the loop writes itself are + unchanged, and the history the caller holds is unchanged. Under + `finish: :auto`, a one-output signature whose output is constrained or not + typed string gets the same replay when the stored value is a string. -Rollback means restoring `0.15.0` in the lock and removing `request_retry:`. -See the [changelog](CHANGELOG.md) for every change. +Rollback means restoring `0.16.0` in the lock; such turns then replay their +inputs alone again under `finish: :submit` and `:tools`. See the +[changelog](CHANGELOG.md) for every change. ## Known limits diff --git a/docs/coming-from-dspy.md b/docs/coming-from-dspy.md index ad4307e9..1de95752 100644 --- a/docs/coming-from-dspy.md +++ b/docs/coming-from-dspy.md @@ -87,7 +87,7 @@ error row too. Each model request has a receive timeout, and has no time limit of its own (an MCP tool call does, 30 seconds by default), so to bound an agent, start it with `Imp.start_run/3` and cancel it when you choose. The -[deployment example](https://github.com/deepfates/imp/blob/v0.16.0/examples/deployment/README.md) +[deployment example](https://github.com/deepfates/imp/blob/v0.16.1/examples/deployment/README.md) is a complete OTP application. **Optimizers see what you name.** DSPy finds predictors by walking a module's diff --git a/docs/diving-deeper/modules-and-composition.md b/docs/diving-deeper/modules-and-composition.md index 4521f171..d1b8c95b 100644 --- a/docs/diving-deeper/modules-and-composition.md +++ b/docs/diving-deeper/modules-and-composition.md @@ -149,7 +149,7 @@ A custom module is your code, so it is not saved as a whole. What an optimizer chose for it is data: `Imp.ProgramParameters.values/1` reads it, `Imp.ProgramParameters.apply_values/2` puts it on a freshly built program, and `Imp.Optimizer.Artifact` writes it to a checksummed file. The -[deployment example](https://github.com/deepfates/imp/blob/v0.16.0/examples/deployment/README.md) +[deployment example](https://github.com/deepfates/imp/blob/v0.16.1/examples/deployment/README.md) does this in a supervised application. ### Built-in variants diff --git a/docs/diving-deeper/saving-and-artifacts.md b/docs/diving-deeper/saving-and-artifacts.md index ebd689d3..1d9c2968 100644 --- a/docs/diving-deeper/saving-and-artifacts.md +++ b/docs/diving-deeper/saving-and-artifacts.md @@ -21,7 +21,7 @@ Save the whole program when it is made of Imp's modules: `Imp.predict/2`, when the program is your own struct implementing `Imp.Module`, or when you want the program's code to live in your release and only its tuned text to cross the persistence boundary. The -[deployment example](https://github.com/deepfates/imp/tree/v0.16.0/examples/deployment) +[deployment example](https://github.com/deepfates/imp/tree/v0.16.1/examples/deployment) does the second. ### 2. JSON with a checksum diff --git a/docs/getting-started/running-in-your-application.md b/docs/getting-started/running-in-your-application.md index e2807cdc..ad28fb08 100644 --- a/docs/getting-started/running-in-your-application.md +++ b/docs/getting-started/running-in-your-application.md @@ -121,7 +121,7 @@ Changing models is then a configuration change, and rotating a key is a restart. The -[deployment example](https://github.com/deepfates/imp/blob/v0.16.0/examples/deployment/README.md) +[deployment example](https://github.com/deepfates/imp/blob/v0.16.1/examples/deployment/README.md) is the complete version of this page: a two-stage program like `TicketTriage`, learned parameters loaded and reloaded as an `Imp.Optimizer.Artifact`, crashed and timed-out calls contained, and the whole thing restarted in a fresh OS diff --git a/docs/getting-started/where-to-go-next.md b/docs/getting-started/where-to-go-next.md index 62fa74aa..c00b466f 100644 --- a/docs/getting-started/where-to-go-next.md +++ b/docs/getting-started/where-to-go-next.md @@ -51,7 +51,7 @@ The module documentation is the reference for every function. [Running Imp in production](../production.md) covers what the last page began: supervision, provider failures, runs you can observe and cancel, and telemetry. The -[deployment example](https://github.com/deepfates/imp/blob/v0.16.0/examples/deployment/README.md) +[deployment example](https://github.com/deepfates/imp/blob/v0.16.1/examples/deployment/README.md) is a complete application to copy from. ## Try it in a notebook diff --git a/docs/production.md b/docs/production.md index 441d9f41..8b5c1356 100644 --- a/docs/production.md +++ b/docs/production.md @@ -5,7 +5,7 @@ where its credentials come from, how long a call may take and what it may cost. This page covers those decisions in the order you meet them: starting Imp, credentials, concurrency, timeouts, cost and caching, persistence and telemetry. The -[deployment example](https://github.com/deepfates/imp/tree/v0.16.0/examples/deployment) +[deployment example](https://github.com/deepfates/imp/tree/v0.16.1/examples/deployment) is a complete OTP application that puts them together. ## Imp in your supervision tree @@ -263,7 +263,7 @@ standard error. ## The reference application -[`examples/deployment`](https://github.com/deepfates/imp/tree/v0.16.0/examples/deployment) +[`examples/deployment`](https://github.com/deepfates/imp/tree/v0.16.1/examples/deployment) is a small OTP application that loads a checksummed artifact at startup, reads its key from the environment, serves calls from bounded supervised tasks, answers overload and timeouts with errors instead of blocking, and reloads diff --git a/livebooks/05_operating_imp.livemd b/livebooks/05_operating_imp.livemd index 58f189a5..6fd60705 100644 --- a/livebooks/05_operating_imp.livemd +++ b/livebooks/05_operating_imp.livemd @@ -103,7 +103,7 @@ Enum.each(busy, &Task.await/1) refused ``` -The [deployment example](https://github.com/deepfates/imp/tree/v0.16.0/examples/deployment) +The [deployment example](https://github.com/deepfates/imp/tree/v0.16.1/examples/deployment) wraps the same pattern in a `ProgramServer` that also reloads parameters while serving. diff --git a/mix.exs b/mix.exs index a0f291cc..19aa7c82 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule Imp.MixProject do use Mix.Project - @version "0.16.0" + @version "0.16.1" def project do [ diff --git a/priv/public_api.json b/priv/public_api.json index 65ccb546..80c3d221 100644 --- a/priv/public_api.json +++ b/priv/public_api.json @@ -10359,7 +10359,7 @@ "types": [] } ], - "package_version": "0.16.0", + "package_version": "0.16.1", "schema_version": 3, "scope": "Curated Imp API compiled from package-shipped sources. Exports come from non-hidden documentation entries; callbacks, types, and struct fields are category- and policy-gated." } diff --git a/test/package_contract_test.exs b/test/package_contract_test.exs index d5833ffe..316db6f2 100644 --- a/test/package_contract_test.exs +++ b/test/package_contract_test.exs @@ -114,7 +114,7 @@ defmodule PackageContractTest do version = Mix.Project.config()[:version] dependency = hex_dependency(version) - assert version == "0.16.0" + assert version == "0.16.1" assert File.read!("RELEASE_NOTES.md") =~ "# Imp v#{version}" assert File.read!("CHANGELOG.md") =~ "## #{version}" assert File.read!("examples/deployment/mix.exs") =~ dependency