User-visible changes to Imp are recorded here.
0.7.0 — 2026-09-28
Changed
- Breaking:
Imp.Core.LMResponse.cost, and the:coston a:model_responseevent, is what the provider reported charging, as its docs said, andnilwhen the provider reported no charge. It was ReqLLM's catalog estimate for every model in ReqLLM's catalog, so every spend total built on it counted an estimate as money spent, and OpenRouter's own charge, which OpenRouter responses carry unasked, was overridden by it. OpenRouter calls now report OpenRouter's charge; one made with the caller's own provider key reports OpenRouter's fee plus the upstream charge, ornilwhen the upstream charge is missing. A call to any catalog-priced provider that reports no charge (Anthropic, OpenAI, Google, Groq, xAI and others) now has anilcost where it had the estimate. The estimate is the newestimated_costfield onImp.Core.LMResponseand:estimated_coston the:model_responseevent,nilfor a streamed call;billingis the breakdown behind that estimate, as it always was, and its docs now say so. Migration: a host that sumscosttreatsnilas an unknown charge, not a free one; a host that wants the old number for calls with no reported charge readsestimated_costfor them, knowing it is an estimate. - Breaking: a stream from
Imp.Clients.ReqLLMthat did not complete ends in{:error, %Imp.LMError{}}, not in a completion of whatever text arrived first: one that carries a provider error, finishes with reason:erroror:cancelled, or is incomplete, its body ending with no finish and no[DONE](reason{:stream_finished, :incomplete}). The error event carries the usage and other metadata that arrived before it. Migration: a caller ofImp.stream/3withprovider_stream: truehandles that error as any failed call; the request reached the provider, so it may have been charged. - Breaking: a call streamed through
Imp.Clients.ReqLLMrecords the configured model id without its provider prefix (gpt-test, where it recorded the wholeopenai:gpt-test) in thereq_llmmetadata of its:model_responseevent's:response, and itsImp.Usageentry is keyed"openai/gpt-test". In 0.6.0 a real streamed call had noImp.Usageentry at all. A call to a client built from a string-keyed spec map or a{provider, opts}or{provider, model, opts}tuple, streamed or not, records its provider, where it recorded none, so itsImp.Usagekey gains the"provider/"prefix. Migration: a host that matched the provider-prefixed model of a streamed call matches the model id, and one that readsImp.Usageby key for such a client uses the prefixed key. - Breaking:
Imp.Predict.ReActV2's:last_request_notereaches the model as a user message with no assistant reply after it, as documented, in the last request and whenever the returned history is passed back. It was stored as a history entry with inputs and no outputs. The chat adapter renders that as a finished exchange, so the note was followed by an assistant message the model never gave, each field reading "Not supplied for this conversation history message." Using that filler for history is Imp's; DSPy 3.2.1 renders a missing history output asNone. The note, and the entry for inputs no step spent that comes before it when the first step failed, now carrytool_calls: %Imp.Adapter.Types.ToolCalls{tool_calls: []}andtool_call_results: [], like every other step the loop records. Migration: a host that recognises the note inmetadata.historyby its shape (only the first input's key) matches it by that key with an emptytool_callslist, or by its text. A note recorded by an earlier version keeps the old shape and still renders with the filler; to render it as this release does, addtool_calls: %Imp.Adapter.Types.ToolCalls{tool_calls: []}andtool_call_results: []to that entry.Historyentries a host writes keep Imp's rendering. - In written tool mode (an LM that cannot call tools natively, where earlier steps replay as text), a stored step that recorded no call and no other output replays as its user message alone, as native replay already did. A ReActV2 step that answered with nothing no longer replays as an assistant message of filler. A turn that recorded any output, an answer included, keeps its assistant message.
Fixed
- A call streamed through
Imp.Clients.ReqLLMrecords the usage and the cost the provider reported at the end of the stream. ReqLLM's stream is aStream.resource, which reports its end as halted rather than done, and Imp ended such a stream without its terminal event, so every streamed call was recorded with no usage and no cost. The stream now ends with exactly one terminal event,done: truewith the provider's usage (including its"cost"), model and finish reason, taking the finish reason, and usage no chunk reported, from ReqLLM's metadata handle. - A streamed call that fails after the provider reported usage records that
usage and cost on its failed
:model_responseevent, since the provider may have charged for it. The caller still receives{:error, reason}. - A streamed call to a client built from an inline spec map, with atom or
string keys, or a
{provider, opts}or{provider, model, opts}tuple no longer fails with{:lm_stream_failed, "protocol String.Chars not implemented ..."}. :reasoning_effortacceptsmax, when an LM is built, on a call and in a saved program. Imp's accepted efforts are read from ReqLLM's ownreasoning_effortoption, so they are every effort ReqLLM accepts, on every provider. ReqLLM's provider translates it where it has a translation (Anthropic and Google turn it into a thinking budget); OpenAI, OpenRouter, Groq and xAI receive the effort as written, and it is the provider's to accept. OpenRouter receives"max"in either wire field.- A string effort such as
"high"reaches ReqLLM as its atom. ReqLLM checks the effort against its atom list before any provider sees it, so in 0.6.0 a string effort, including every effort loaded from a saved program, failed every call on most providers: OpenRouter withoutopenrouter_reasoning_wire: :nested, Anthropic, Google and Groq among them. OpenAI and xAI were not affected.
0.6.0 — 2026-09-28
Security
- Imp's lock file and the example projects' keep
mint1.10.1, which has three advisories thatmint1.11.0 fixes: EEF-CVE-2026-91043 and EEF-CVE-2026-92103 are in Mint's HTTP/2 client, and EEF-CVE-2026-94194 is in HTTP/1 chunked framing and needs an intermediary between the client and a malicious server.mint1.11.0 leaves an HTTP/1 connection open after a receive timeout, and Finch 0.23.0 reuses it, so requests after a timeout fail (see Known limits in the release notes)..audit_ignorelists the three advisories with the reason and the condition that removes them: a Finch release that includes https://github.com/sneako/finch/pull/397, which is open and unreleased. Imp.Redactionredacts a string that holds a PEM private key of any type, a PGP private key block or a PuTTY key file; a Stripe (sk_live_,rk_live_), GitHub, GitLab (glpat-), Hugging Face (hf_), Slack (xox?-), SendGrid (SG.), npm (npm_), PyPI (pypi-AgE), Google OAuth (ya29.) or Vault (hvs.) token, or a JSON Web Token; an AWS access key id longer than 20 characters; a URL with a password in its user info; or a signed URL'sX-Amz-Signature,X-Amz-Security-Token,X-Goog-Signatureor Azuresig. It passed these unchanged into run events, traces, trajectories and saved programs. As before, the whole string is replaced, and every shape it caught before is still caught. It tries 22 patterns where 0.5.0 tried 7, so it is slower on text that holds no credential.- A
Bearertoken of 16 or more characters with a digit in it is found wherever it ends: mid-line (Bearer <token> https://…) or before a newline ("Authorization: Bearer <token>\nmachine …"). Both were missed. A word without a digit afterBearer, followed by more text, is still read as prose and left alone. - Near misses for
BearerorBasicin one letter case (BEARER xlines) are redacted in linear time; they took quadratic time. Imp.ExternalCommandredacts captured output with the same rules, in place of its ownsk-andBearerpatterns: output that holds a credential is"[REDACTED]"whole, so the credentials printed beside a recognized one (env | grep AWS, a credentials file) go with it. ABearertoken of 12 to 15 characters, or one with no digit, followed by more text or a newline is shown, where 0.5.0's ownBearerpattern hid any such token of 12 or more characters. The optimizer's pricing URL check uses the same rules instead of its own.- Writers redact a term before converting it: optimizer reports
(
Imp.Optimizer.Report.dump/1,json_safe/1,json_projection/1), experiment results,Imp.Evaluate.Result.save_as_json/2andsave_as_csv/2, BetterTogether's bootstrap diagnostics, optimizer artifact candidates, GRPO session checkpoints, Optimize Anything results, ACP session records and saved programs (a Predict's demos and metadata, KNN examples, memory retriever documents). They converted first, so a client, retriever or MCP OAuth store in them was written with its header values, URL query secrets or store key, and a secret in a map key that is a tuple, a list or a struct was written as it was.Imp.Redaction.drop_credentials/1redacts such structs too. The SIMBA, MIPROv2, InferRules and random-search checkpoints redact their failure reasons; the instructions, demos and scores a resumed run continues from are kept as they are. Output that holds no secret is unchanged. Imp.Redaction.redact/2hides a connection struct's header values and the query, fragment and user info of its URLs, asinspect/1already did:Imp.Clients.ReqLLM,Imp.Retrievers.HTTP,Imp.Tracking.MLflow,Imp.Tracking.WandB,Imp.Optimize.Anything.Config.Tracking,ExMCP.ClientandExMCP.Transport.HTTP. A retriever in a tool result kept anX-Subscription-Tokenheader, a cookie and a?key=URL inrender_inspection/2, run-event JSON and ATIF.Imp.Redaction.redact/2redacts the secrets inImp.MCP.OAuth.Store(the derived key),Imp.MCP.OAuth.Pending(the authorization URL andstate) andImp.MCP.OAuth.Flow(the authorization URL, PKCE transaction and registered client), keeping each struct and its other fields. They were walked as plain maps, so the store's key reached any redacted output that held a store.code_verifieris a credential name, so a PKCE transaction map is redacted on its own too.- An error about a devset, evaluation row, field entry or demo that is not an example names its type and key names, never its values.
- A map key that is a string shaped like a credential is redacted.
Imp.Optimizer.Trajectorynames a key it refuses (one that is not an atom or a string) by its type; it printed the key, credential included. - The
ReqLLM.Error.API.Requestinside anImp.LMErrorfromImp.Clients.ReqLLMcarries at most one response header,retry-after. Any other header ReqLLM left on the error is removed, so cookies, account identifiers and request ids no longer reach logs, checkpoints or run events throughinspect/1of the error. - An ACP session store redacts the history and transcript it writes, except
the provider's
reasoning_contentandreasoning_details. A resumed session therefore replays a user message or tool result that held a credential as"[REDACTED]", both to the client and in the model's history. The session's_metais stored as sent; Imp never reads it. - A two-element list with a name first is a key and its value only when it is
an element of a list, as JSON writes config, headers and tool results
(
[["api_key", key], ["model", "gpt"]]); held directly as a field or map value, it is data. Saving andImp.Redaction.drop_credentials/1read every such list as a pair, so a saved program whose example had an input named like a credential (with_inputs([:api_key, :question])), or whose metadata held such a list, did not load back as it was;Imp.Redaction.redact/1rewrote an Avatar tool schema's"required" => ["api_key", "query"]; andImp.Optimizer.Parameterrefused input keys["api_key", "question"]. One divergence from 0.5.0 follows: a flat two-element name-first list that is not itself an element of a list (at the top level, in a tuple, or under a key that is not a credential name), such as["password", "hunter2"], is not redacted, since it has the shape of a list of two names.
Changed
- Breaking:
Imp.Observability.Statushas a new state,:succeeded_with_errors, andImp.Observability.status/1gives it, where it gave:failed, for every optimizer report whoseerrorsis not empty: an optimizer that returned a report returned a program. Migration: code that matches everyStatusstate adds the new one; code that treated:failedas "the report has errors" matches:succeeded_with_errors. - Breaking:
Imp.collect/3returns{:ok, prediction}or{:error, reason}, asImp.call/2does, instead of a string that joined every output field's value with no separator (question -> reasoning, answercollected as"Because.Paris"). Migration:{:ok, prediction} = Imp.collect(program, inputs), thenImp.get(prediction, :answer). - Breaking: an
Imp.Predict.ReActV2turn whose last request (made after a step failed or the turn was interrupted) also fails returns{:error, %Imp.Predict.ReActV2.StepError{}}, where it returned{:ok, prediction}with no outputs andtermination_reason: :incomplete.:reasonis that request's error unchanged: anImp.LMError,{:lm_failed, client, exception}or{:adapter_format_failed, adapter, exception}, whichImp.Errors.retryable?/1andcontext_window_exceeded?/1read through the struct.retryable?/1says whether the last model request may be sent again, not the turn: its tools have run.:historyis the turn'sImp.Historyas far as it got, with every tool call that ran and its result; inspecting the error shows the history's size, not the history. A turn whose last request is answered still ends:last_text,:forced_submitor:extracted, and:incompleteis kept for one answered without a valid answer or out of time or context window.Imp.ACPsaves the history to its session before failing the turn. Migration: where you checkedImp.Prediction.complete?/1after a model failure, match{:error, %Imp.Predict.ReActV2.StepError{reason: reason, history: history}}and storehistoryas you store a finished turn's. - Breaking: an
Imp.Predict.ReActV2step refused by anImp.OperationalSafetyError(a route, cost, transport, budget or cancellation guard) ends the turn at once with aStepErrorwhose:reasonis that error. The turn made its last request instead, and an answer to it passed the guard by.Imp.Evaluateand the optimizers find the guard inside theStepErrorand raise it. Migration: where you call the program yourself, match{:error, %Imp.Predict.ReActV2.StepError{reason: %Imp.OperationalSafetyError{}}}. - Breaking: an
Imp.reacttask signature cannot have a field namedtools, which is now the step's tool list, and loading a program saved with one raises the sameArgumentError. Migration: rebuild the program with the field renamed and save it; to keep a saved program's optimized instructions and demos, rename the field in the saved file, or optimize again. - Breaking:
Imp.Clients.ReqLLMhas a:tool_callingfield: whether the model calls tools natively, read from the registry once when the client is built or loaded, and kept with the model it was read for. A client whose model is swapped looks the new model up. A client built byImp.req_llm/2, or loaded, therefore no longer equals a bare%Imp.Clients.ReqLLM{}for the same model. Likewise%Imp.Predict.ReActV2{}has a:tool_orderfield, the tools' declared order. Migration: compare clients bymodel, not by the whole struct. - Breaking: in
Imp.Adapter.Chat,Imp.Adapter.JSONandImp.Adapter.XML, a reply that answers none of the requested outputs (no[[ ## field ## ]]section, no output key, no output tag) is anImp.AdapterParseErrorof kind:missing_fieldsnaming every output, so the JSON fallback or a retry runs. When every output was optional or defaulted, as a ReActV2 step's are, such a reply parsed as defaults andnils, so an XML agent given prose finished withanswer: nil. DSPy 3.3.1 fills defaults there; Imp does not. For a signature that names an output inmetadata[:text_field], Chat and XML read prose as that field (so a ReActV2 step's prose under XML is itsnext_thought), and a blank completion, the one exemption, as a step that said nothing. A reply that writes fields in some format (a JSON object with an output's key, a[[ ## field ## ]]line, an output's tag) is not prose: Chat and XML parse it or report it, so a step that spelled out a tool call as JSON runs that tool instead of answering with the JSON text. So a step whose prose quotes its own field names goes to the JSON fallback, which usually costs one more call; beside native tool calls such a thought becomesnil, and the calls still run. A JSON{}for a ReActV2 step is:missing_fields, where it was a step withnilfields.Imp.Predict.ProgramOfThought, whose outputs are all optional, sends prose to the JSON fallback instead of regenerating with a missing-program error. Migration: match%Imp.AdapterParseError{kind: :missing_fields}where you relied on a prediction of defaults, or on ProgramOfThought's:missing_program, for a reply that answered nothing. - Breaking:
Imp.Clients.ReqLLMBatchnever sends a request again when it may already have run. A dispatch that timed out, crashed, threw or exited was retried as transient, so one request could run and be billed several times; it is now:ambiguousand final, and a dispatcher may return{:ambiguous, reason}itself.req_llm_dispatcher/2makes every call withmax_retries: 0(ReqLLM's own retry step could send a request four times in one batch attempt); retries only a request that never reached the provider (connection refused, or no pooled connection free, as Req reports it) or that the provider answered with 408, 425, 429, 503 or 529; treats any other 4xx as terminal; and treats any other 5xx, and a timeout or closed connection with no response, as:ambiguous, where a 5xx was retried. On resume, a checkpoint written by 0.5.0 is rewritten at schema version 2 and its:transient_failurerequests become:ambiguous, since 0.5.0 recorded timeouts and dispatcher crashes that way, and aschema_migrationevent records each. Migration: check an:ambiguousrequest with the provider before sending it again. - Breaking: an MCP call the server answers with HTTP 503 or 529 is
:refused, like a 429, where it was:unknown: RFC 9110 defines 503 as the server being unable to handle the request, and providers answer overload with 503 or 529. MCP calls andImp.Clients.ReqLLMBatchread a status the same way. Migration: code that treated a 503 or 529 as possibly run matches:refusedfor them. - Breaking:
Imp.Datasets.csv/3parses RFC 4180 CSV with NimbleCSV, a new dependency (nimble_csv ~> 1.3); it raisedFunctionClauseErroron any quoted field and could not read a quoted line break. A line break may be CRLF, LF or a bare CR, and a leading byte order mark is dropped, where it was read into the first column's name. A blank line is skipped, but a""line is a row with one empty value, so a file with more than one column refuses it (invalid CSV row at <path>:<line>: expected N fields, got 1). A malformed file raisesImp.Datasets.Errornaming the line its record starts on, with that record's first 200 characters asrecord. Some files 0.5.0 loaded are refused:- a quote inside an unquoted field, such as an inch mark (
12" pipe,1):invalid CSV at <path>:<line>: a quote opened on this line is never closed, or, when the line holds two (12" by 3" board,1),invalid CSV at <path>:<line>: unexpected escape character " in "...". - a space between a comma and a quoted field (
x, "y"):invalid CSV at <path>:<line>: unexpected escape character " in "...". Migration: quote the whole field and double the quotes inside it ("12"" pipe",1), or remove the space.
- a quote inside an unquoted field, such as an inch mark (
- Breaking:
Imp.Example.inputs/1andlabels/1raiseArgumentErrorwhen the example never declared its inputs, as DSPy raisesValueError. They returned every field as inputs, labels included, so a program was given the answer and scored on it.Imp.evaluate/4,Imp.Evaluate.run/2,Imp.Experiment.Data.new/1and every optimizer that runs a program on examples check each dataset before any model call, and the error names the function, the dataset and the row. To check it,Imp.Evaluate, MIPROv2,Imp.Optimizer.InstructionSearchandImp.Optimizer.SignatureOptimizerread a lazy dataset once and hold it in memory, so a one-shot stream is no longer found empty on a second pass (every InstructionSearch candidate scored 0.0 on one). Migration: callImp.with_inputs/2on every example you evaluate or optimize on. - Breaking:
Imp.EvaluateandImp.Experiment.Datarefuse a row that is a plain map or a field pair list, which cannot declare its inputs; Evaluate turned it into an example whose labels reached the program. Migration: build the row withImp.example/1 |> Imp.with_inputs(...). - Breaking:
Imp.Example.new/1andImp.Prediction.new/2raise when a field is given twice, as an atom and a string or as a repeated key; one value was silently dropped. Migration: give each field once, under one spelling. - Breaking:
Imp.Signature.new/2(andImp.signature/2) applies new instructions to an existing signature; it returned the signature unchanged. Migration: to keep a signature's instructions, pass it without instructions. - Breaking: a GEPA run that continued past failed proposals reports them, so
it reports
status: :with_errorswhere it reported:okwith no errors. Each failure is an entry inreport.errorswith itsiteration, itsdiagnosticsand itscandidate_id(the rejected candidate incandidates, ornilwhen the failure left none there);metadata.failed_proposalscounts them. Withraise_on_exception: falsethat is any failure: a reflection call, reflection strategy, evaluation or validation that raised, threw or exited, or an iteration that did. With the default,true, it is the failures GEPA already recorded and went on from: a reflection that returned no usable instruction, a failed reflective dataset in a parallel slot, and a reflection interrupted before a resume. The program returned is still the best candidate found, the baseline when every proposal failed, as DSPy's GEPA returns it. A slot cancelled because a sibling failed first is rejected, not counted as failed. Migration: code that checkedstatus == :okreadsreport.errors. - Breaking: under GEPA's
execution_profile: :beam_nativewithraise_on_exception: false, an iteration that raised is recorded as a rejection with a{:proposal_error, reason}reason, soStopper.consecutive_outcome/2counts it as:proposal_error, as the default profile already did, where it counted:none. When evaluating the first two proposals raises and the third solves the task,consecutive_outcome(:proposal_error, 2)now stops after two iterations with the baseline, where the run went on and returned the solving candidate, andconsecutive_outcome(:none, 2)no longer stops it after the first. An iteration that threw or exited, which crashed the run, is recorded the same way. Migration: review stoppers built onconsecutive_outcome(:none, _)or(:proposal_error, _)for:beam_nativeruns. Imp.Clients.ReqLLMmarkscontext_window_exceededon more providers' length refusals, streamed or not: Anthropic's "prompt is too long: N tokensM maximum" and "input length and
max_tokensexceed context limit", Google Gemini's "The input token count (N) exceeds the maximum number of tokens allowed" (including astreamGenerateContenterror sent as a JSON array) and Vertex AI's "the input token count is N but model only supports up to M", Mistral's "Prompt contains N tokens, too large for model with M maximum context length", and OpenRouter'serror_type: "context_length_exceeded"from an OpenRouter model. Only OpenAI's non-streamedcontext_length_exceededwas marked, so the others failed the call instead of lettingImp.Predict.ReActV2leave out older episodes or end the turn:incomplete.- Imp 0.5.0 cannot read some files this release writes:
- a trajectory with a map that has an atom key, which GEPA checkpoints,
Playbook checkpoints and
Imp.dump/1write throughImp.Optimizer.Trajectory; - an optimizer report (
Imp.Optimizer.Report.encode_term/1,dump/1) that holds anImp.History, now written under a"history"tag where it was a plain map; - an
Imp.Clients.ReqLLMBatchcheckpoint, now at schema version 2.
- a trajectory with a map that has an atom key, which GEPA checkpoints,
Playbook checkpoints and
- Imp declares the dependencies whose modules it names, which reached it only
through its other dependencies:
finch~> 0.21(Imp.Clients.ReqLLMmatches its error structs) anddecimal~> 2.0 or ~> 3.0as an optional dependency (Imp.Corereads a reported cost given as aDecimal). It declaresplugandplug_cowboywithruntime: falsefor the demo MCP servers, which the package leaves out: Imp starts neither, and no Imp code that ships uses them. It declaresmint~> 1.8, since it matches Mint's error structs to tell a request that was never sent from one that may have run. Every one of these requirements is one Finch, Req or ExMCP already set (ExMCP requiresdecimal~> 3.0), so none moves an application's lock. - Imp no longer depends on
jsv, which nothing in Imp uses. ReqLLM still requires it.
Fixed
Imp.inspect_history/2renders any history. A turn holding a term JSON has no encoding for, such as the{:error, {:unknown_tool, name}}result a ReActV2 history keeps for a call to a tool that does not exist, raisedProtocol.UndefinedError. Turns render through the conversionImp.Observability.render_inspection/2uses: tuples become lists, structs become maps, and pids, references and functions become theirinspect/1text. Redaction runs first, as before.- A binary that is not valid UTF-8 renders as
%{"__imp_type__" => "binary", "bytes" => size}, never as its content or a digest of it; a map key that is not valid UTF-8 takes the same form inside the map'sentries. This applies toImp.inspect_history/2andImp.Observability.render_inspection/2, which raisedJason.EncodeErroron such a binary, and toImp.Run.Event.to_map/1andImp.Trajectory.to_atif/2, which returned the raw bytes, so encoding their result raised. Output for values without such binaries is unchanged, and a request'stools_hashis identical. - A call streamed with
Imp.stream(program, inputs, provider_stream: true)is recorded in its run like any other model call: a:model_requestevent with the request's:purpose, and a:model_responseevent with the usage and cost the provider reported. It recorded neither, so a streamed turn left no model record, no cost and no ATIF model step. Astream/3that raises before returning a stream is recorded too and returns{:error, {:lm_failed, lm, error}}, as a raisinggenerate/3does. - A model turn that says something and calls tools keeps both. Through
Imp.req_llm/2the text was dropped whenever the reply had tool calls, so a ReActV2 step'snext_thoughtwas empty; streamed withprovider_stream: true, the text was kept but the tool calls were lost, all of them when text arrived and all but the last otherwise. The raw LM output for such a reply is%{text: text, tool_calls: calls}(:textonly when it is not blank), unstreamed and streamed. Keeping the text is what DSPy does; the adapter then reads it as it reads a text reply, intonext_thoughtfor ReActV2, where DSPy's Chat adapter leaves the field empty. So when a ReActV2 run reachesmax_itersand its last reply has text beside tool calls it did not run, that text is now the answer, where the answer wasnil; the calls are still listed as unexecuted. The text also appears in history turns and in the ATIF model step. - An
Imp.reactstep asks for one thing, whichever adapter formats it, as DSPy's does. With an LM that calls tools natively, the tools are sent natively and the prompt (Chat, JSON or XML, their demos and the JSON fallback) no longer also describestool_callsas a field to write; a model that followed that description wrote a JSON object, which for a signature with one text output became the answer. An LM whose client says it cannot call tools (a ReqLLM model the registry lists without tool calling,Imp.Clients.TRLLM) is sent no tools. Its step has atoolsinput listing every tool,submitincluded, in DSPy's words: name, description, and arguments as JSON (the schema's properties, in its order when it is aJason.OrderedObjectand by name otherwise, itsrequiredlist and its$defs). It writes its calls intool_calls, whose description shows a call's shape with an example, and its earlier steps are replayed as text, not as tool blocks a provider without declared tools may refuse. The guidance says the same in every format: a text answer goes innext_thought, withtool_callsleft empty when the model writes its calls. It no longer asks for plain text beside a structure that asks for fields; a plain-text reply to a Chat step is still read as the answer. A stored turn that carries only the task's answer is replayed as that answer, not as step fields marked "Not supplied", by the adapter itself, so a host:output_rendereris not consulted for it.Imp.LM.Budgetedand BootstrapFewShot's rollout LM answer for the LM they wrap, for tool calling as for reasoning and response-format support. Imp.reactsends its tool roster in declared order, thensubmit, and a saved agent keeps that order. It was the order of the tool names' atoms, which can differ between runs of the VM and changed the prompt a provider caches.- The JSON fallback after an unparseable Chat or XML reply sends the same
request in JSON: the program's
adapter_optsrenderers (:system_renderer,:output_renderer), an agent loop's guidance and the demos go with it. It sent the stock JSON prompt, so a host that shapes its prompt with renderers got a different prompt on every fallback, and anImp.reactstep lost its tool guidance.Imp.Adapter.JSONandImp.Adapter.XMLhonor:system_renderer,:output_rendererand:guidancewhenever they are used, and end a request whose inputs are all in the history with the output requirements as a user message of their own instead of appending them to the last tool result. A renderer receives the formatting adapter's own rendering in its options, as:default_systemand, for an:output_rendererthat takes the options as a fourth argument,:default_outputs, so one that builds on the default builds on the format of the request. A renderer now shapes the JSON fallback and every JSON or XML request; one that ignores its options sends a Chat-shaped prompt in the fallback. - The
[:imp, :adapter, :parse, :json_fallback]event names the adapter whose reply failed as:adapter(it always saidImp.Adapter.Chat, also for XML) and the adapter that retried as:fallback_adapter. - Repairing a Python-style completion keeps its text and gives the value
DSPy parses:
{'answer': 'caf\u00e9'}reads ascafé, notcafu00e9. Escapes are read the wayjson_repair, which DSPy runs first, reads them:\t,\n,\r,\b,\\, escaped quotes,\uwith four hex digits and\xwith two decode, and every other escape (\d,C:\path,\a,\0,\U,\N{…}, a backslash before a newline) keeps its backslash. A\usurrogate pair reads as the character it spells; a lone surrogate, which an Elixir string cannot hold, makes the parse anImp.AdapterParseError. This reaches the Chat, JSON and XML adapters and GEPA's instruction proposal. - Before a retry,
Imp.Clients.ReqLLMBatchwaits for the provider'sretry-after(seconds, or an IMF-fixdate, RFC 850 or asctime date) when it sent one, otherwise with exponential backoff and jitter capped by the new:max_retry_waitoption (default 60 s).Imp.Clients.ReqLLMkeepsretry-afteron its errors for this; ReqLLM's own decoding dropped it. A waiting request takes no dispatch slot from the others. When the provider asks for longer than:max_retry_wait, or the wait would pass theImp.Deadlinein force, the request is not retried in that run: it stays:transient_failure, the summary is notcomplete?, andresume/3retries it. The checkpoint keeps the time each such request may be sent again (not_before, UTC) and records aretry_stoppedevent, andresume/3waits for it under the same rules or stops the retry again without sending. A process that traps exits and is stopped by its parent during the wait exits at once with the parent's reason; a dispatch wave in progress is still waited for, up to:timeout, and a process started with barespawnhas no parent to listen for and sleeps uninterrupted. - An Avatar tool ends with its caller. Its task kept running after the caller
was killed, after
Imp.Run.cancel/3and after the run's owner died. It still runs unlinked, so a crash is an observation, and takes no place in the task pool. It sees the caller'sImp.context/2settings, run context and deadline, which it did not, and parallel work it starts runs on the caller's place in the pool when the caller has one. A tool that timed out, or whose task exited, reads as:unknowninImp.Tool.outcome/1rather than:result, since it may have acted. Inside a run, Avatar records each tool call as:tool_calland:tool_resultevents withmetadata.outcome, and arguments that fail the tool's schema are refused before the tool starts. - An RLM call made outside a run no longer leaves its model or tool call
running, holding a place in the task pool, when the calling process is
killed. An RLM call no longer leaves the pool place of one of its own tasks
recorded in the calling process, which made parallel work that process
started afterwards (
Imp.Predict.Parallel.map/3,Imp.Evaluate.run/2) run one item at a time. - The package ships the TRL worker that
Imp.Clients.TRLTrainerstarts by default:priv/trl_worker/worker.py, itspyproject.tomlanduv.lock, and the default contract. In 0.5.0 the defaults named files the package did not contain, so GRPO training from Hex needed a source checkout. The default contract pins transformers 5.10.1, the version the lockfile installs; it named 5.5.0, which the worker refused at startup. Imp.Optimizer.Report.load!/1andImp.Optimizer.GRPO.Checkpoint.load!/1read the"[REDACTED]"atom marker in a VM that has not loadedImp.Redaction, and a MIPROv2 checkpoint resumes in a fresh VM: its loader loads the modules whose atoms it decodes. They failed with "not an already existing atom".Imp.Evaluate.Result.save_as_csv/2writes with the same CSV module thatImp.Datasets.csv/3reads with, so what it writes reads back unchanged. The bytes it writes are the same as before.- A signature field written as
nil,trueorfalsekeeps that name as text, so"nil: string -> a"saves and loads with a field named"nil". The name became the atomnil, which saved as"". A field given the atomnil,trueorfalseas its name raisesArgumentError. - An instruction an optimizer sets on
Imp.Predict.ProgramOfThoughtorImp.Predict.CodeActreaches the extraction step, which kept the old instructions when GEPA, MIPROv2, COPRO, SIMBA or InferRules set it, and such a program saves and loads:Imp.Saving.load!/1raised "saved ProgramOfThought planner instructions must match task instructions".Imp.Optimizer.InstructionSearchsets instructions the same way. examples/deployment/agent_optimization.exswrites the optimizer's rejections and history withImp.Optimizer.Report.json_safe/1. It passed them toJason.encode!/1, which raised on a failure reason such as{:incomplete_evaluation, 1}, so a finished run lost its record.
GEPA
- A GEPA report names real failures only: a row whose program call or metric
failed, a row whose metric returned a value
Imp.Metricscannot read, and a proposal error. It treated the metric's feedback as a failure, so a run whose metric returned feedback reportederrors,status: :with_errorsand candidates named "Program call failed: …" when nothing had failed. A candidate rejected because its proposal failed is named "Proposal failed: …". - A GEPA report no longer crashes when a metric throws or exits: the failure
is shown as
{:throw, reason}or{:exit, reason}, where building the report raisedProtocol.UndefinedError. Every diagnostic in the report has its credential values redacted. - GEPA ends the run on an
Imp.OperationalSafetyError(a budget, cost, route or transport guard) whateverraise_on_exceptionsays. Withraise_on_exception: false, it recorded the refusal as a failed proposal and went on spending. - GEPA redacts a failure reason when it records it in a rejection, the
history or a pending proposal slot. An exception is recorded as
{:proposal_exception, "Module.Name", message}(the tag names the stage that failed) and a throw or exit as{:proposal_throw, term}or{:proposal_exit, term}, so a checkpoint holding it reads back in a VM that has not loaded the module that raised; the parallel and sequential paths record the same shape. A checkpoint otherwise holds the resume state as it is (candidates, the evaluation cache, proposed instructions and reflection data), so a:checkpoint_fnconsumer treats it as sensitive. - GEPA with
raise_on_exception: falseno longer records an exit that asks the process running it to stop (:normal,:shutdown,{:shutdown, _},:kill) as a failed proposal when a module selector, a reflection strategy, or the adapter's evaluation or reflective dataset raises it in that process: the exit goes on up, as it must from a process that traps exits and turns its owner's shutdown into an exit. Imp.Optimizer.Trajectory.dump/1andload!/1round-trip a trajectory: a map with an atom key is written as its entries, each key tagged as an atom, so a prediction's metadata, a trace step (%{predictor: :main}) and metric metadata such as Optimize Anything'sobjective_scoresload with the keys they had. Every map key was written as a string, so a trajectory whose prediction had metadata failed to load (Imp.Prediction.new/2: invalid map in :metadata … got: "trace"), and resuming GEPA from a checkpoint whose pending proposal batch held a result from anImp.predictprogram raised that error. Loading never creates an atom: a key written as an atom the loading VM does not have loads as its name, a string. A prediction's string metadata keys become their atoms when those exist, soImp.Prediction.get_lm_usage/1,complete?/1and readers of:tracefind them. A map that holds a key as both an atom and a string is refused on load, as on dump. A trajectory written by 0.5.0 still loads, including one whose prediction has metadata; its other map keys load as the strings they were written as.- GEPA checkpoints an agent. A trajectory, and the report codec GEPA uses for
a pending batch's reflective dataset and a result's outputs, write an
Imp.HistorywithImp.History.dump/1and read it withImp.History.load!/1; the trajectory codec also carriesImp.Adapter.Types.ToolCallsandToolCallResults. The first checkpoint that held anImp.reactprogram's trajectories raisedTrajectory.DecodeError: trajectory contains an unsupported struct: Imp.History, under either profile. - A GEPA checkpoint resumes in a fresh VM: the loader loads the GEPA modules
whose atoms a checkpoint holds before decoding it.
Imp.Optimizer.GEPA.compile_with_report/5given a checkpoint in a VM that had not yet run GEPA raisednot an already existing atomon names like:cache_hitsand:no_strict_improvement. - The checkpoint GEPA writes after a sequential reflection holds the proposed candidate, as the parallel path's does, so a resume from it, or from a checkpoint taken while that candidate was being evaluated, evaluates that candidate on the same minibatch. It held no proposal, so the resume started the iteration over on the next minibatch and asked for a new reflection.
- Under
:proposal_concurrencyabove 1, GEPA evaluates a proposed candidate on its minibatch without capturing traces, as the sequential path and DSPy's GEPA do: the metric is called without a trace, an:acceptance_policyseesafter.trajectories == %{}, and the child'son_evaluation_endcarries no trajectories. Imp.Optimizer.GEPA.compile_with_report/5no longer raisesKeyErrorwhen it resumes from a checkpoint taken during a full validation; the interrupted validation's rejected candidate is in the report.
Documentation
- The install instructions ask for a C and a C++ compiler: jaxon builds native code from C and erlexec from C++. They asked for a C++ compiler only. They also say the first compile needs network access, for erlexec's rebar3 plugins.
- The package's Changelog link opens the changelog on HexDocs, and the 0.4.0
entry links the benchmark pages as they were at v0.4.0, not on
main. - Each Livebook's setup cell only installs Imp: from Hex, or from the
checkout
IMP_PATHnames. It no longer searches for a source checkout. - The MCP example on the Tools and agents page defines its server and its
imported tools, where it used an undefined
imported, and closes the server when the call fails.
0.5.0 — 2026-09-26
Security
- An LM client, the HTTP, Databricks and Weaviate retrievers and the MLflow
and W&B trackers print with their credentials redacted, and so does a
program holding one.
Imp.req_llm(model, api_key: key)printed the key in IEx, in log lines and in crash reports, and a RAG program printed its retriever's bearer token. Refusing to save a retriever that is not portable names its module instead of printing it. - A client, retriever or tracker prints every header value as
[REDACTED], whatever the header is called (X-Subscription-Token,Cookie), and the query, fragment and user info of every URL, given as a string or a%URI{}. A saved program holds no header at all, so an LM with custom headers has to be rebound after loading, andImp.save!refuses an LM whosebase_urlhas a query, fragment or user info. Option errors, including those for the options passed to a call, name a credential-bearing option without printing its value. The ExMCP client's state, which a crash report prints, is redacted the same way. Before, a header was hidden only when its name looked like a credential, andImp.save!wrote other headers' values to disk. - RLM controller code cannot make a struct. A map literal that named
__struct__was dispatched by every protocol as that struct: a map shaped like aFile.StreamsentEnum.joininto theEnumerableimplementation forFile.Stream, which read the named file. A map literal,submit/1and the result of a library call may no longer carry the key, and a library call takes no struct but a range or MapSet and no Elixir module named as a value. A module is not a value at all (m = Filefails with{:module_value_not_allowed, "File"}); a sorter is:asc,:descor a function, since an Erlang module given as a sorter had itscompare/2called, andMap.from_struct/1takes no module; and a library call that hands back a function it was given (Map.get(m, k, f)) fails the turn instead of leaving a native closure in a variable.
Installing
- Imp is a Hex package:
{:imp, "~> 0.5"}. Every dependency comes from Hex, including ExMCP (~> 1.5, unpatched); thedeepfates/ex_mcpGit fork, the bundledvendor/ex_mcppath and theEX_MCP_PATHoverride are gone. - What 0.4.0 got from the fork now lives in Imp: stdio MCP servers in their
own process group, a
PATHwithout the release's own directories, and the HTTP options public servers need (noOriginheader, a written/path kept, one retry with the standardinitializeafter a non-401 4xx on the era probe). What an upgrader meets:- erlexec is a direct dependency. It builds a C++ port program, and an OTP
release that uses
Imp.MCPorImp.ACPlistsapplications: [ex_mcp: :load, erlexec: :load]. - Imp sets
SHELL=/bin/shin a VM started withoutSHELL, because erlexec's port program does not start without it. - Closing a stdio connection, or the death of the process that owns it, sends the server's group SIGTERM and SIGKILL one second later; 0.4.0 stopped the group without a SIGTERM a server could handle.
- When a stdio server exits on its own, what is left of its group gets SIGKILL half a second later.
- Trust for an authorized remote server is VM-wide: while a connection to it
is open, its exact origin is in ExMCP's
trusted_origins, so any ExMCP client in the VM may send credentials to that origin. In 0.4.0 the trust belonged to the one connection. The origin is removed when the last connection to it closes, and origins the host configured are left alone.
- erlexec is a direct dependency. It builds a C++ port program, and an OTP
release that uses
MCP
- An MCP tool call that got no answer from its tool returns
{:error, %Imp.MCP.CallFailure{}}instead of{:mcp_tool_call_failed, server, reason}or{:mcp_connection_unavailable, server, reason}. Itsoutcomesays whether the call was refused before anything ran, its credential was refused (an HTTP 401 or a failed OAuth flow), it was never sent, or it was sent with no trustworthy answer (:refused,:auth_refused,:not_sent,:unknown);reasonkeeps ExMCP's error unchanged, and an exit is kept as{:exit, reason}. Imp.Tool.outcome/1gives the outcome of a tool call's value::resultwhen the tool answered, MCP error results included, unless the error result declaresstructuredContent.outcomeas"refused","auth_refused"or"unknown", which it then is (a server marks a write that may have been applied"unknown"). A value alone never reads as:refused, because a tool can return any term: ReActV2 and RLM decide a refusal where they refuse the call (an unknown tool, a malformed call, arguments that fail the schema, a tool policy, a host's authorization, submit outputs that do not fit) and record every call's outcome on its:tool_resultevent asmetadata.outcome.Imp.Tool.outcomes/0lists the five.- A failed tool call reaches the model as plain text instead of an Elixir
term. An MCP error result is the text of its content, the tool's own words,
after
Error:unless the text already begins with "error"; a JSON-RPC error is the server's message; a call that got no answer says why in one sentence, and unless the request was never sent, that it may have been carried out (a timeout, a closed or failed connection, a broken stream, a server that stopped waiting for its tool or a tool that crashed). An unknown tool, missing or invalid arguments, a denied tool and a tool that exits or throws are sentences too; a tool that exits may have been carried out.Imp.Adapter.Chat.format_tool_result/1renders these,Imp.Adapter.Chat.tool_error_text/1gives the words withoutError:, andImp.MCP.failure_text/1gives the MCP ones. The recorded error term is unchanged.Imp.Predict.ReAct's:dspyobservations use the same words afterExecution error in <tool>:, where they showedinspect/1of the reason. Imp.MCP.connect/2takespool_size:(1 by default): that many connections are opened to eachhttporsseserver, and each tool call borrows an idle one for the length of the call, so up topool_sizecalls to one server run at once. One ExMCP client sends one HTTP request at a time, so without it a quick call waits behind a slow one to the same server. A call that finds every connection busy until its:timeoutfails as:not_sent(reason: :no_idle_connection), and a call on a closed HTTP import fails as:not_sentwithreason: :not_connectedrather than a process exit. Astdioserver keeps one connection whateverpool_sizesays: ExMCP already sends it several calls at once, and another connection would be another server process. The extra connections are dialed at once, so a server costs the import at most about three:timeouts. A connection whose call timed out, or whose caller died during the call, is replaced in the background rather than lent again while ExMCP still waits on that request, and is closed once that request is done, so the server finishes what it was doing; a replacement that cannot be dialed leaves the server a connection fewer.- A call to an HTTP MCP server that asks for progress (
call_metawith aprogressToken) no longer loses its server-side work when the caller's:timeoutpasses. ExMCP ended such a request's stream a second after the call's timeout, and its server ended the tool with it: a 3 s write under a 300 ms timeout never finished. The caller is answered at its:timeout,:unknownwithreason: :timeout, and the request runs on to the connection's limit.:call_metais still called in the process that makes the call. - An HTTP MCP call can take as long as the import's
:timeoutallows. ExMCP ended every HTTP request at its own 30 s default whatever:timeoutsaid, so a call to a tool that takes 33 s failed at about 30 s undertimeout: 45_000. ExMCP'srequest_timeoutis now the import's:timeout, and never less than 30 s. "type" => "sse"now means MCP's deprecated HTTP+SSE transport (2024-11-05): the descriptor's"url"is the event stream's (https://host/sse), and requests go to the URL the server names on it. Before,ssewas Streamable HTTP with a standing GET stream, so a server that speaks only the old transport answered its first request with 405. It works with servers that name the sessionsessionId(the TypeScript SDK's, ExMCP's), not with the Python SDK's SSE servers (session_id): their dial fails with:sse_endpoint_without_session_idand a message saying to use"http". A server that speaks Streamable HTTP is reached as"http". Anssedescriptor with"headers"or"auth"is refused (:mcp_sse_credentials_refused): the server names where requests go, and its credentials would go there whatever origin it named. Underon_failure: :droponly that server is left out, with a warning, and so is ansseURL with a query string (:mcp_sse_url_refused), which ExMCP would dial without it. Ansseconnection whose event stream ends (ExMCP ends it after a stretch with nothing on it, and does not reopen it) is replaced, rather than kept and lent; the stretch is at least 60 s and longer than a request can take.- A caller of
Imp.MCP.connect/2that dies while its import is connecting no longer leaves the connections already made open. They close with the import's:owner, which is the caller unless another process was named. Imp.MCP.OAuth.begin/3runs its own browser flow on ExMCP's public OAuth functions. Its:flowoption is replaced by:client_registration(:auto,{:pre_registered, client_id, client_secret}or{:cimd, url}); a pre-registered client also names its:client_issuer, and the flow refuses to begin when the server names a different authorization server. A server with neither protected-resource nor authorization-server metadata is refused rather than given guessed/authorizeand/tokenendpoints.- A tool's name keeps the type it was given.
Imp.Tool.new/4no longer turns a string into an atom that happens to exist, and tools imported from an MCP server are always named by the server's string. Lookups (resolve_name/2, tool policies) already compare names by text; code that matched an imported tool's name against an atom matches the string now. Imp.ACP.start_link/1,Imp.ACP.run/1andImp.ACP.Local.start_link/1raiseArgumentErrorfor an option they do not know, before anything starts;Imp.ACP.Localchecks its:agent_optionsbefore it listens. In 0.4.0 a key Imp did not know went to ExMCP, which handed it to the transport, so a misspelled:permission_policyor:session_storewas silently ignored. The options are declared once andImp.ACP.start_link/1documents them. A transport's own options go in:transport_options(Imp.ACP.Localpasses its socket there), and:capabilitiesis no longer accepted beside:agent_capabilities.
Runs
- A run no longer outlives its control process, and a cancellation that
never returns no longer holds a run. When the control ends (its event
sink's process died, or
Imp.Run.stop/1was called while the run was still going), every cancellation registered by work in flight (an authorization decision being waited on, an RLM budget, an ACP terminal command) is called with{:run_control_ended, reason}, and then the task is killed; its monitor reports:killed. In 0.4.0 the task ran on until its next event and those effects were never cancelled.Imp.Run.cancel/3andcancel_with_events/3give the registered cancellations the cancel'stimeout(5 s by default) and then end the task; the control ending, its owner going down, and work registered after a cancel give them 5 s. The cancellations are called at once, each in its own process, and one still running at the end of that time is abandoned. In 0.4.0 they were called one after another inside the control, so one that never returned held the run and made the cancel exit after 30 s.Imp.Run.cancel/2on a run whose control has already ended still exits (:noproc). Imp.Run.start/3takesadmission: {pool, limit}: the run holds a place in the host's named pool instead of the machine-wide:async_max_workerspool, at mostlimitruns hold places in that pool at once, and a full pool returns{:error, :busy}without waiting. Runs started without it wait for the machine-wide pool as before.- An
Imp.Deadlinereaches the work Imp starts in other processes. EveryImp.Taskstask, and soImp.parallel/3,Imp.best_of_n/3attempts andImp.Run, runs under the deadline of the process that started it, so requests made throughImp.Clients.ReqLLMinside are cut to the time left. In 0.4.0 only a model call made in the process that bound the deadline was cut; the same call throughImp.parallel/3or a run kept its full receive timeout.Imp.Run.start/3takesdeadline:(milliseconds,:infinityor{:deadline, absolute}), capped by the caller's own, and returns{:error, :deadline_exceeded}without starting the run when the deadline passed while it waited for a place in the pool. A deadline does not bound that wait. - An
Imp.Runevent sink that raises, throws or exits is no longer ignored. The run's owner is sent{:imp_run_event_sink_failed, run_id, %{sequence: _, kind: _, reason: _}}, and delivery goes on with the next event. Stopping or cancelling a run reports the event the sink was holding the same way (reason: :in_sink_when_stopped, since it may have been stored), and each event after it, which the sink never received, as{:imp_run_event_undelivered, run_id, %{sequence: _, kind: _}}. Run owners receive these messages where they received nothing before; an owner with a stricthandle_info/2needs clauses for them. Imp.Run.start/3raisesArgumentErrorfor an option it does not know. In 0.4.0 it ignored one, so a misspelled:authorizestarted a run whose tool calls nobody was asked about. Its options are declared once, and its documentation lists them; an invalid capture bound now raises fromstart/3instead of returning{:error, {%ArgumentError{}, stacktrace}}.Imp.Run.Event.kinds/0lists every event kind Imp emits. ReActV2 no longer emits a:finalevent: it carried the same prediction as the:run_finishedevent after it. A host may still emit kinds of its own withImp.Run.emit/2.Imp.Trajectory.to_atif/2names the event that ended the runextra.terminal_event(it wasextra.outcome), and a tool result'sextra.outcomeis theImp.Tool.outcome/1its loop recorded ("result","refused","auth_refused","not_sent","unknown") rather than"returned"or"error"read from whether the event carried an error. A stored event whose kind Imp does not know keeps its kind string instead of becoming:other.
ReActV2, adapters and models
- Prompts name types in plain words instead of Python annotations, in every
adapter and in ReAct:
`team` (one of: atlas, harbor, beacon, quill)where 0.4.0 wrote`team` (Literal['atlas', 'harbor', 'beacon', 'quill']), and "string", "integer", "number", "true or false", "list of strings", "object", "code in python" forstr,int,float,bool,list[str],dict[str, Any]andCode_python. "(must be formatted as a valid Python int)" is "(must be formatted as an integer)", and ReAct's:dspymode lists tool arguments as JSON. Fields, order, constraints and parsing are unchanged. A saved program's prompt text changes with this, so an optimized program may be worth re-evaluating. - Values in prompts take their JSON spelling:
null,trueandfalsewhere 0.4.0 wroteNone,TrueandFalse, in inputs, demos, ReAct observations and field constraints. The structured-output schema sent to a provider is namedoutputs(titleOutputs) instead ofDSPyProgramOutputs. The MIPROv2 proposer's dataset summary shows each example as{"inputs": {...}, "outputs": {...}}instead ofExample({...}) (input_keys={...}). GEPA's reflective dataset and SIMBA's program listing use the same words, so an enum shows its allowed values. - A non-string answer for a string field is kept as its JSON text (
"true","[\"x\", \"y\"]") where 0.4.0 gave Python's"True"and"['x', 'y']"; anullis no value, so a required field reports it missing and an optional one isnil, where 0.4.0 gave the string"None". - A
nullanswer for any output field with a declared default takes the default, as an omitted one does; 0.4.0 kept the null and failed "is required". A present non-null value,""and[]included, still wins over the default. - InferRules shows the rule model each example's values as JSON text
(
null,{"k": 1},1500000.0) instead of Elixir'sinspectoutput, and aJason.OrderedObject(a ReAct observation, for one) renders as JSON in its own order instead of as the struct. - Each ReActV2 step lists the task's output fields with their types and
descriptions ("The outputs to produce are: ..."). Before, a model saw an
output's description only inside
submit's parameter schema. Imp.Adapter.SingleFieldnames an untyped input's type (- ticket (string)), where it wrote empty parentheses.- The names follow the glossary: a step answered in text ends as
:answered, the last request of an interrupted turn as:last_textwithlast_request_note, the step signature declaresmetadata[:text_field], and the tool list sent to a provider is the:tools_sentevent. - A ReActV2 or ReAct prediction's fields are the signature's outputs and
nothing else.
history,termination_reason,termination_cause,termination_error,finished_by_tool,unexecuted_tool_callsandcontext_projectionare inprediction.metadata; in 0.4.0 they were fields, so an output namedhistorycollided with the loop's own. Readprediction.metadata.historywhereImp.get(prediction, :history)was read. termination_reasonsays only how the turn ended::answered,:submit,:finished_by_tool,:last_text,:forced_submit,:extracted(the tools-disabled extractor wrote the outputs; it was:forced_submitwithcompletion_mode: :typed_extraction, andcompletion_modeis gone), or:incomplete(no answer; it was the failure itself, such as:max_itersor:context_window_exceeded).termination_causeis set whenever the turn was interrupted, including a forced submit that answered, where 0.4.0 left it out: the interruption that led to the last request. An:incompleteturn names the same interruption, unless its context window was full or itsImp.Deadlinehad passed, which are named instead, so a turn that hitmax_itersand then ran out of time is:deadline_exceeded.Imp.Predict.ReActfollows the same rule::dspymode's extraction aftermax_iters, a parse failure or an empty step is:extractedwith that cause, spelled:parse_error(it was:parse_failure), and:provider_nativemode's:directis:answeredwithtermination_cause: :empty_tool_calls.Imp.Prediction.complete?/1is false exactly for:incomplete, andImp.Observabilityreads it.ReActV2offerssubmitonly to a signature that needs it. A task signature with exactly one output of type:stringand no constraints gets nosubmittool: a step that comes back as text with no tool call is the answer, in that one request, withtermination_reason: :answered, which is how Anthropic's tool runner, the OpenAI Agents SDK, LangGraph's ReAct and Pydantic AI end a turn. Its history event carries the output, as asubmit's does, and the answer is not also emitted as a:reasoningevent. A signature with several outputs, one non-text output, or one constrained text output (anenum, a pattern, an answer shape) keeps DSPy'ssubmitunchanged, so the allowed values reach the model in its schema; a step of such a signature that writes text instead of callingsubmitis the:empty_tool_callsinterruption, and a turn that ends without a validsubmitis:incomplete.- A step of a one-text-output signature that calls nothing and says nothing
is an empty answer: the turn ends there with
termination_reason: :answeredand no further request, because saying nothing is how a model declines to answer. - An interrupted turn of a one-text-output signature (the step limit, a
failed request) makes one more
request with the same tools as every step and
tool_choice: "auto", and its text is the answer, withtermination_reason: :last_textandtermination_causenaming the interruption (:max_iters,:prediction_error,:parse_error). A completion that says nothing is an empty answer rather than an error. A tool call the model makes on that request anyway is not run; the text is the answer and the calls are listed inunexecuted_tool_calls. If the process'sImp.Deadlinehas already passed, no request is made and the turn ends:incompletewithtermination_cause: :deadline_exceeded. last_request_note, a string, puts one line of host text in front of the last request of an interrupted turn, the text-only request or the forcedsubmit, as a user message, keeps it in the returned history, and is saved with the program. Imp writes no sentence of its own.ReActV2gainsfinish_on, a map from tool name tofn arguments, result, inputs -> {:finish, outputs} | :continue end. A tool named there ends the turn with the outputs the function returns, which are validated against the signature exactly as asubmit's are, withtermination_reason: :finished_by_toolandfinished_by_tool(in the prediction's metadata) naming the tool. This is the shape Pydantic AI calls an output tool: one call both does the work and carries the answer.:continueleaves the loop running. When a step calls several terminal tools, the first in call order finishes the run and the rest still execute and are recorded; asubmitin the same step still wins. The functions persist by registry name, like a tool runner.- A
ReActV2step answered in plain text with no tool call is now a thought that called nothing, not a parse failure. It used to fail the chat parse and re-ask the whole prompt throughImp.Adapter.JSON, which doubled the cost of the step and broke the provider's prefix cache; the text is nownext_thoughtandtool_callsis empty, which is what the turn-ending rule above then reads. The text is recorded as that turn's thought in the history and shown back to the model as a plain assistant turn in any next request.Imp.Adapter.Chatreads a marker-free completion this way only for a signature that declaresmetadata[:text_field]; every other signature parses exactly as before, JSON fallback included. - 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
:model_requestevent now records the whole request, not only its messages. Its metadata carries:options, the request options with the tool definitions removed, and:tools_hash, a SHA-256 of the canonical JSON of those definitions ornilwhen the request offered none. The definitions themselves are emitted once per run per distinct hash, as a new:tools_sentevent whose input is the tool list as sent. A recorded run can now be reproduced call for call, without repeating an unchanging roster on every one. Both payloads are redacted like every other event. Imp.LM.generate/3takespurpose:, a name for what kind of call this is. It is recorded on the:model_requestevent's metadata as:purposeand is never sent to the provider, so a caller that makes more than one kind of model call can tell them apart on the record.- The chat adapter's format options gain the
:history_note_rendererseam,fn signature, turn -> nil | String.t(). It is consulted for every stored history turn, native tool turns included, after that turn's own messages, and its text becomes one user message immediately behind them. Before it, a host had no way to say anything about a turn that carried tool calls: those turns route through the native replay path, which consults neither:output_renderernor:input_section_renderer. A note is data about the turn — the answer was never delivered, the account's allowance ran out — so the model reads it as the next thing after the turn, and the record the loop keeps is untouched. - A signature field's description now reaches the provider in the JSON schema
Imp builds for it (
Imp.Schema.json_schema/1), soReActV2'ssubmittool declares each output field's own words about itself in its parameter schema. A field without a description still emits nodescriptionkey. This is the only place a field description reaches a host that replaces the chat adapter's rendered system section. Imp.Adapter.Types.ToolCall.from_map/1acceptstoolas a spelling of the tool name, besidenameandrecipient_name(the arguments already acceptedarguments,argsandparameters).%{"tool" => ..., "arguments" => ...}is what a model emits when it writes a tool call as JSON instead of calling natively, andImp.Predict.ReActV2now executes such a call instead of recording a malformed-call observation and spending another iteration on it.Imp.Clients.ReqLLMreturns a response whose body carries a provider error as a failed request (Imp.LMError, below). OpenRouter relays an upstream provider's refusal as a successful HTTP response with an error object and no choices, which ReqLLM decodes to an empty message; read as a completion, a refused request was a model that said nothing.Imp.Predict.ReActV2.new/3documents its options.Imp.predict/2,Imp.chain_of_thought/2andImp.configure/1raiseArgumentErrorfor an option or setting they do not know, and document the ones they take. In 0.4.0Imp.predict(sig, temperature: 0)built a program that ignored the temperature; a request option given at the top level (such as:temperature,:max_tokensor:n) is now refused with a message that says to put it underconfig:. Settings of a caller's own, such as a request id, go throughImp.context/2. Both check Imp's own settings the same way, so:track_usageand:warn_on_type_mismatchmust now be booleans. ReAct, ReActV2, Avatar, CodeAct, ProgramOfThought, MultiChainComparison andImp.Optimizer.Avatargive the sameconfig:message.:max_errorsand:retrieverare no longer settings. Nothing read:retriever; give a retriever to the program (Imp.rag/3).:max_errorswas read only by BootstrapFewShot, RandomSearch and COPRO, as the fallback for their own:max_errors; given none, they now use 10, the setting's old default, and their reports saymax_errors_source: :defaultwhere they said:settingsor:teacher_settings.Imp.configure/1,Imp.context/2and an optimizer's:teacher_settingsrefuse either key with a message naming where it belongs.ReActV2'ssubmitand afinish_oncallback's outputs find a field by the text of its name. A string signature keeps a field name as a string when its atom did not exist yet, so outputs keyed by that atom were never found and the turn ended:incompletewithmissing_output_fields.- RLM controller code may call every function of
Enum,Keyword,List,MapandStringexceptString.to_atom,List.to_atom,String.splitter,Enum.random,Enum.shuffleandEnum.take_random, and may pass them anonymous functions and captures, which the interpreter runs under the cell's step and value budgets. Registered tools,llm_queryandsubmitstay outside such functions; a function's patterns may pin a variable (fn ^target -> ... end). Without a module, the Kernel data functionselem/2,to_string/1, theis_*type checks,length/1,map_size/1,tuple_size/1,byte_size/1,abs/1,round/1,trunc/1,div/2,rem/2,max/2andmin/2are available. Other calls return{:function_not_allowed, ...}. Assignment takes the same patterns as a function clause ({a, b} = pair,[first | rest] = lines),[x | acc]builds a list, and aforgenerator takes a pattern and a map (for {key, n} <- counts) and theinto:anduniq:options.into:anduniq:were ignored, andreduce:is refused with{:unsupported_for_option, :reduce, ...}. A function held in a variable or written in place can be called directly (f.(x),(fn x -> ... end).(x)), under the same rules as one a library call runs. - RLM controller code that calls a value that is not a function (
g = 1; g.(1)) fails that turn with{:not_a_function, "g", 1}, which the controller reads and repairs. It ended the whole call with{:module_call_failed, Imp.Predict.RLM, ...}whenever the variable's name was not already an atom. An unexpected error inside the interpreter now fails the turn the same way, as{:interpreter_error, message}. - The RLM controller prompt names one reply shape,
{"reasoning", "code"}, for every turn including the last, which callssubmit/1from code. It also offered a bare JSON object of the outputs as a final answer, which a text reply never in fact got. The controller's first reply almost always failed to parse: gpt-5.4 sends several JSON objects in one reply (its action twice, or several actions written ahead of their outputs). Such a reply now runs the first object that carries code, as a REPL would. max_preview_charsbounds every RLM variable preview in characters. A list was previewed as its firstmax_preview_charsitems, so afterlines = String.split(log, "\n")on a 20,000-line log the controller's turn message grew from 2,351 to 89,312 bytes. A list, map, tuple or other term is now previewed as the firstmax_preview_charscharacters of its printed form, withlengthorsizebeside it, as upstream previewsstr(value); a map's preview shows its values, not only its keys. A variable holding a tuple, which could not be encoded into the turn message and ended the call, is previewed the same way, and so is an integer too long for the preview.- RLM
max_recursion_depthis one rule: the number of levels of child RLMs below the root.recurse/2already allowed a child at depthmax_recursion_depth, butrlm_query*started one only below it, so the default of 1 gaverecurse/2a child andrlm_querynone. Both now allow one level by default;max_recursion_depth: 0makesrlm_query*a one-shot sub-LM query, as the standalone runtime'smax_depth=1does.
Signatures
- Numeric bounds are
minimumandmaximum, JSON Schema's names, in validation, in the JSON schema and in the rendered prompt, and a validation error's rule is:minimumor:maximum.minandmaxraise anArgumentErrornaming the key to use; before, the documentedminimum/maximumwere ignored. Pydantic'sgeandlestill mean the same bounds. - Constraint keys are read by one normalizer for validation, the JSON schema
and the rendered prompt, so
"min_length"/"max_length"given as strings now constrain validation and the schema too, as they already did the prompt. - An
enumconstraint with atom members raisesArgumentErrorwhen the field is built, showing the string members to write instead. An answer arrives as text and a signature saves as JSON, soenum: [:atlas]failed every parse ("must be one of [:atlas, :harbor]"), and returning the atom would have made a program answer differently afterImp.save!/Imp.read!. - A signature refuses a repeated field name, on one side or across the arrow,
and names it: the string form raises a signature parse error, the map
form and
Imp.Signature.extend/3raiseArgumentError, and:aand"a"are the same name. Before, only a name on both sides of the arrow raised, and"q -> a, a"built two fields calleda. json_retries: nmakes up to n retries of a parse failure, each the original request plus the latest failure's message, and stops at the first reply that parses. Before, any n above 0 made one.- An arity-3 metric receives
nilas its trace fromImp.evaluate/4and the optimizers' validation scoring, and the program's trace while an optimizer bootstraps demos, as DSPy'strace=Nonehas it. Before, evaluation passed the trace too, so a ported metric that scores continuously when evaluated and passes or fails when compiling gave its compile-time answer. Imp.read!/2andImp.load!/2load a saved program in a VM that never created the atoms it names: a demo's field names, a RAG program'squery_fieldandcontext_field, a memory retriever's document keys, tool names and tool policies, and any atom tag in saved metadata load as strings when the atom does not exist, and no atom is created.Imp.Example,Imp.Predictionand the tool index look them up by text; a caller reading saved metadata directly may findmetadata["my_flag"]where it expectedmetadata[:my_flag], which isnilin a VM where:my_flagdoes not exist yet. It raisedArgumentError("not an already existing atom"), so a router saved by one process failed to load in a fresh one.
Errors and shapes
Every change here is breaking for code that matches on the old shape.
- A failed request from
Imp.Clients.ReqLLMis{:error, %Imp.LMError{}}, whatever failed: an HTTP error status, an error relayed inside a successful response, a connection that failed, or an exception raised inside ReqLLM, or a response or stream of a shape ReqLLM never returns. It carriesstatus,retryableandcontext_window_exceeded, with ReqLLM's own error unchanged underreason.retryableistruefor a 408, 425, 429 or 5xx status, for a request that never reached the provider (connection refused, no free pooled connection, closed or timed out before sending), for any other status whose ReqLLM error saysretryable: true, and for a timeout while waiting for the answer or a stream that failed after it started; those last two may have run and been billed, and a retried stream repeats chunks the caller already has. A 409 is never retryable. In 0.4.0 these reached the caller as ReqLLM's structs, as{:req_llm_generate_failed, text},{:req_llm_stream_failed, text},{:invalid_req_llm_response, text}or{:invalid_req_llm_stream, text}, or, for a context-length refusal, asImp.ContextWindowExceededError, which is gone. An option the client refuses raisesArgumentErrorbefore the request, as every other option error does. Imp.Errors.retryable?/1readsImp.LMError'sretryable, and the newImp.Errors.context_window_exceeded?/1itscontext_window_exceeded, each through{:error, _}and{:lm_failed, _, _}. In 0.4.0retryable?/1was true only for anImp.LMErrorthat nothing built.- An LM client that raises is
{:lm_failed, client, exception}with the exception struct, and one that throws or exits is{:lm_failed, client, {kind, value}}. In 0.4.0 both were the message text. The same holds for{:module_call_failed, module, reason}fromImp.call/2,{:retriever_failed, _, reason},{:tool_error, tool, reason},{:tool_policy_error, tool, reason},{:parallel_program_failed, reason},{:ensemble_program_failed, reason},{:optimizer_failed, optimizer, reason}and{:optimizer_capabilities_failed, optimizer, reason}, the MCP import's:mcp_connection_failedand:mcp_tool_import_failed, andImp.ACP's:program_factory_failed,:input_mapper_failed,:output_renderer_failed,:before_turn_failed,:permission_policy_failedand:host_request_failed, and for{:http_transport_failed, transport, reason}fromImp.HTTP(and so the training clients':training_transport_failed,:training_refresh_failedand:training_cancel_failed, andImp.Retrievers.HTTP's{:transport, reason}),{:embedding_provider_failed, provider, reason}fromImp.Embeddings, andImp.Predict's{:adapter_format_failed, adapter, reason}and{:adapter_lm_opts_failed, adapter, reason}, and{:program_runtime_error, reason}fromImp.Predict.ProgramOfThoughtandImp.Predict.CodeAct, whose model still reads the exception's message. Several of these carried text on one path and a term on another. - A completion that cannot be read as the outputs is always
%Imp.AdapterParseError{}, with akind::malformed,:missing_fields(reasonis the missing names),:invalid_fields,:unsupported_outputor:other;kindis required. In 0.4.0 an adapter could return the struct,{:missing_output_fields, names}or{:unsupported_lm_output, raw}, andImp.Adapter.TwoStep{:two_step_extraction_failed, reason, completion}.Imp.Predict.ReAct's final outputs follow the same rule: in 0.4.0 a missing output was{:missing_output_fields, names}and one of the wrong type an%Imp.AdapterParseError{}with nokind. Imp.Predictreturns that struct, withtrace(the redacted messages, the raw completion, and which output fields were read) and, forn > 1,completion_index. In 0.4.0 it returned%{reason: {:error, reason}, trace: trace}, and ann > 1failure{:completion_parse_failed, index, reason}inside it. Because of that map, aReActV2step that could not be parsed was recorded astermination_cause: :prediction_error; it is now:parse_error.- When the chat or XML adapter's JSON fallback makes its request and that
request fails,
Imp.Predictreturns the LM's error. In 0.4.0 it returned the original parse failure with the LM error inside it.Imp.Adapter.TwoStep's extraction request does the same: when it fails, the call returns thatImp.LMError(or{:lm_failed, _, _}), so a 429 from the extraction model reads as retryable. In 0.4.0 it was{:two_step_extraction_failed, reason, completion}. - A failure reason that holds a pid, port or reference, such as a
GenServer.calltimeout, is written to JSON (optimizer checkpoints and reports,Imp.History.dump/1) as its inspected text. Before, the write raisedProtocol.UndefinedError. Imp.Predict.RefineandImp.Predict.Assertionsreturn{:error, reason}like everyImp.Module::no_attemptswithn: 0(max_attempts: 0forAssertions),{:refine_fail_count_exceeded, reason}, or the last attempt's reason. In 0.4.0 they returned{:error, reason, history}, whichImp.call/2reported as{:invalid_module_result, module, text}.- A tool a tool policy does not allow is
{:tool_denied, tool, :tool_policy}, and a tool a run's:authorizecallback refuses is{:tool_denied, tool, reason}with the callback's reason. In 0.4.0 they were{:tool_denied, tool}and{:tool_authorization_denied, tool, reason}. - MCP import refusals: a tool named after one in
:reserved_tool_namesis{:mcp_tool_name_reserved, tool, servers}(it shared:mcp_tool_name_collisionwith two servers offering one name); an unauthorized descriptor is{:mcp_server_not_authorized, server, answer}, whereansweris what:authorizereturned or:not_trusted; a descriptor that cannot be addressed is{:invalid_mcp_server, index, %ArgumentError{}}(it was:mcp_connection_failedwith the message and no name); and a server left out underon_failure: :dropcarries the same reason term the import would have refused with, where 0.4.0 shortened its detail to text. Imp.HTTP.request/6andImp.Retrievers.HTTPrefuse a method they cannot send as{:http_method_not_supported, refuser, method}; one of them said{:unsupported_http_method, method}.Imp.stream/3withoutprovider_stream: trueends a failed call with%Imp.Streaming.Messages.StreamResponse{chunk: {:error, reason}, done: true}, as a provider stream does; it yielded a bare{:error, reason}.- An
Imp.Telemetryspan's[:exception]event carries:kind,:reasonand:stacktrace, as:telemetry.span/3does; it carried:erroras text.Imp.Redactionkeeps an exception's type while redacting its fields. Imp.optimize!raisesImp.Errorwith the reasonImp.optimizewould have returned when optimization fails, andArgumentErroronly for a call that could never run (including options the optimizer refused). In 0.4.0 every failure was anArgumentErrorcarrying text.Imp.Erroris now that exception; nothing raised it before.Imp.ExampleandImp.Predictionkeep each key as the atom or string it was given, and look keys up by their text. In 0.4.0 a string key became an atom whenever that atom already existed in the VM, so the same data could come back keyed either way. Data read from JSON now keeps its string keys: a history turn loaded withImp.History.load!/1, or a demo field a signature does not declare in a saved optimizer artifact.- A tool receives its arguments as a map with string keys, from every runtime
(ReActV2 and its
finish_oncallbacks, ReAct, Avatar, CodeAct, RLM) and fromImp.Tool.call/2, which turns atom keys into strings at every depth. In 0.4.0 a key became an atom when that atom already existed in the VM, so the same tool could see either shape. Match on strings:fn %{query: q}becomesfn %{"query" => q}. Tool policies andunexecuted_tool_callssee the same string keys. The built-in tools follow:Imp.ACP.Host'sfetchtool andImp.Optimizer.Playbook.EquationSearch.solve_tool/0read string keys only.
Public surface: what is exported
- Gone, with what to use instead:
Imp.MCP.Client,Imp.MCP.HTTPClient,Imp.MCP.StreamableHTTPClient,Imp.MCP.StdioClient,Imp.MCP.CatalogandImp.MCP.import_tools:Imp.MCP.connect/2, which returns the tools and onecleanup. A tool schema spelled:input_schemais no longer accepted; MCP's"inputSchema"is.Imp.ACP.MCPandImp.ACP.MCP.Import:Imp.MCP.connect/2, andImp.ACP.ToolKind.derive_all(import.annotations)for:tool_kinds.Imp.Core.ToolCallandImp.Core.ToolResult, which nothing built, andImp.MCP.json_rpc_resultandImp.MCP.initialize_params, which nothing called.
- No longer documented, because they are Imp's own machinery:
Imp.Clients.TRLProtocol,Imp.Optimizer.Utils,Imp.Telemetry.executeandspan,Imp.Prediction.set_lm_usage,Imp.MCP.Connections.import_tools(useImp.MCP.connect/2), the transition functions ofImp.Training.FastSlow.State, and thevalidate_*option validators.compile/Non an optimizer thatImp.optimize/3,4,5orImp.train/4runs is no longer documented either: call those, which take the same datasets and invocation options with one argument order.KNNFewShot,Ensemble,PlaybookandInstructionSearchkeepcompile, because the facade does not run constructors or workflows. - Now documented:
Imp.Deadline,Imp.Observability.Inspection(whatImp.Observability.inspect_artifact/2returns, withjson_safe/1),Imp.Run.barrier/3,Imp.Run.new_event_id/1,Imp.Run.register_cancellable/1andunregister_cancellable/1,Imp.Tasks.async/1andasync_nolink/1(tasks that carry the caller's settings, run and telemetry context), and the modules public functions return or call:Imp.Predict.RLM.SandboxSerializable,Imp.Optimize.Anything.Result,Imp.Optimizer.Parameter.SetandImp.Optimizer.Parameter.Change, andImp.Optimizer.GEPA.Callback. Imp.MCP,Imp.MCP.Connections,Imp.MCP.Import,Imp.MCP.CallFailure,Imp.ACPandImp.ACP.ToolKindare stable, and so are the LabeledFewShot, BootstrapFewShot, random search, KNNFewShot, COPRO, MIPROv2, GEPA, SIMBA, InferRules and Ensemble optimizers,Imp.Optimizer.ArtifactandImp.Optimizer.Report: stable means they do not break within 0.x without a deprecation.Imp.ExternalCommandandImp.Tasksare experimental. The documentation lists the MCP and ACP modules in a group of their own instead of under experimental optimizers.- An ACP agent started without
:agent_infointroduces itself asimpat Imp's version, not asimp-acp0.1.0. - Plug is no longer a dependency of Imp. The demo MCP servers, its only user, are not in the package.
Public surface: facade and behaviours
Imp.load/2takes the mapImp.dump/1returns and gives{:ok, program}or{:error, %ArgumentError{}};Imp.load!/2is its raising form. Reading a fileImp.save!/2wrote isImp.read!/2, which wasImp.load!/1.Imp.Savinghas the same four functions.Imp.react/3buildsImp.Predict.ReActV2, andImp.react_v2is gone.Imp.Predict.ReAct, the earlier loop, has no facade name; its port of DSPy'sdspy.ReActismode: :dspy, which was:dspy_3_2_1. A program saved with"dspy_3_2_1"still loads.Imp.Predict.PredictisImp.Predict. It,Imp.Predict.ChainOfThoughtandImp.Predict.ReActV2, whichImp.react/3builds, are stable.Imp.Signature.ParseError, which every signature constructor raises, is documented, with its:inputand:positionfields.Imp.Retrievers.KNNis deleted. It did not implementImp.Retrieve, andImp.Retrieve.Memoryis the token-overlap retriever.- The
compilefunction ofImp.Optimizer.BootstrapFewShotWithRandomSearchis hidden, like the other optimizers';Imp.optimize/4runs it. Imp.Adapter.Types.Document,History,CitationandType, andImp.Datasets.Error, are documented.priv/public_api.jsonnow lists every packaged module, the@moduledoc falseones amongexcluded_modules.Imp.LMdeclaresgenerate(lm, messages, opts), wherelmis the struct or the module it was given;request/2andstream/3stay optional. An LM is a struct or module implementing it. The%{module: module, opts: opts}map and a bare two-argument function, both deprecated in 0.4.0, are refused: at construction with the:lmoption's error, and byImp.LM.generate/3as{:error, {:not_an_lm, lm}}. The clients'generate/2is gone; call a client throughImp.LM.generate/3, asImp.LM.generate(Imp.Clients.ReqLLM.new(model), messages).Imp.LM.Static.new/1takes:model, the model a static client stands in for.Imp.Retrievedeclaresretrieve(retriever, query, opts); a module retriever receives itself first, as an LM does. A two-argument function is still a retriever.
Public surface: one name per idea
- MCP names a server descriptor a descriptor and a server's name
server_name.Imp.MCP.CallFailurehasserver_name,tool_nameand the descriptor'sindexwhere it hadserverandtool; anunavailableentry hasserver_namewhere it hadserver;tool.metadata.mcpgainsindex;:authorize's two-argument context is%{cwd:, descriptor:}. - One allow/deny vocabulary:
:allowor{:deny, reason}, asImp.Run's:authorizeandImp.ACP's:permission_policyalready answer.Imp.MCP.connect/2's:authorizereturned:okortrue; a refused descriptor is now{:mcp_server_not_authorized, server_name, reason}, with:not_trustedfor one outside:trusted_servers. A:tool_policyfunction returnedtrue,:okorfalse; a refused call is now{:tool_denied, name, reason}, wherereasonnames what denied it::tool_policyfor a name or list policy, or the function's own reason. Any other answer refuses with{:invalid_decision, answer}.Imp.ToolPolicyis documented. - DSPy's names for DSPy's ideas.
num_threadsis the concurrency option ofImp.Evaluate,Imp.evaluate/4,Imp.Predict.Parallel,Imp.Predict.Search,Imp.Experiment's evaluation options,Imp.Clients.ReqLLMBatch, and the GEPA, MIPROv2, SIMBA, BetterTogether and BootstrapFinetune optimizers, where it wasmax_concurrency; COPRO, InferRules and random search already saidnum_threads. COPRO'sproposal_max_concurrencyisproposal_concurrency, as GEPA's is.Imp.Predict.Refinecounts its attempts inn, asImp.Predict.BestOfNand DSPy'sRefinedo, where it hadmax_attempts; a Refine saved with"max_attempts"still loads.Imp.Predict.RLMtakesmax_iterationsonly, DSPy RLM's name;max_iterswas a second spelling. Imp.Optimizer.RandomSearch,Imp.Optimizer.BootstrapRSandImp.Optimizer.BootstrapFewShotWithRandomSearchwere three names for one optimizer; it isImp.Optimizer.BootstrapFewShotWithRandomSearch, DSPy's class name. Itscandidatesanddemos_per_candidateoptions, second names fornum_candidate_programsandmax_bootstrapped_demos, are gone.- The ReActV2 loop's guidance key
finish_toolissubmit_tool, the tool it names. The step signature'smetadata[:text_step]ismetadata[:text_field]: it names the output field a text reply fills. - A ReActV2
:reasoningevent saysforced: truewhere it saidforced?, and names its step asstep:where it saidturn:: a turn is one call of the agent, and a step is one request inside it. Imp.MCP.connect/2's:credentialsoption is:credential_store, theImp.MCP.OAuth.Storea descriptor's"credential"is looked up in.Imp.MCP.OAuth.Pendinghasserver_urlwhere it hadresource_url, the namebegin/3andauthorization_header/3give the same URL. Credentials already on disk load unchanged.- An
Imp.Clients.MLXLMTrainer:runneris called withcwd:where it was called withcd:, the name ACP, MCP andImp.MCP.connect/2use. Imp.Optimizer.GEPA.Callbackhooks take(event, context)only; a bare callback module's context isnil. A bare module's one-argument hooks were called and its two-argument ones were not.- A ReActV2 agent loaded from a saved program asks the model what the agent it
was saved from asked. Loading lost the step predictor's loop guidance, its
request shape (
response_instruction: false,omit_empty_request: true) and its tool roster's atom keys; they are rebuilt from the signature and tools. A host's own:adapter_opts, such as renderers, are functions and are not saved; pass them again. Imp.load/2given a string says to read a file withImp.read!/1.- One
loadcontract:loadreturns{:ok, value}or{:error, reason},load!raises, andread!reads a file. TheloadofImp.Signature,Imp.HistoryandImp.Optimizer.Reportraised, so each is nowload!/1.Imp.Clients.TrainingJob'sloadisload!/2, and its oldload!, which read a checkpoint file, isread!/2. The dataset loaders that read a file areread!:Imp.Datasets.GSM8K.read!/1,Imp.Datasets.HotPotQA.read!/1,Imp.Datasets.MATH.read!/1andImp.Datasets.DataLoader.read!/3. TheloadofImp.Datasets.Colors, which builds examples from records and raises, isload!/1.
GEPA
Imp.Optimizer.GEPAis DSPy's GEPA unless told otherwise: with no:execution_profileit runs:gepa_v0_1_4_merge(merge on, perfect minibatches skipped, no evaluation cache, the pinned RNG and budget), or:gepa_v0_1_4withuse_merge: false. The old default isexecution_profile: :beam_native, which a run using ComBee, parallel proposals,:feedback_fn,:reflection_strategy, a module selector other than:round_robin, a parent selector other than:pareto, or another option the DSPy profile fixes now has to name; without it, those options raise.- Under the DSPy profiles,
:max_full_evaluationsis converted to a metric budget as DSPy convertsmax_full_evals(times the training plus validation set sizes) instead of being counted beside it, and giving it together with:max_metric_callsraises.:max_reflection_callsraises innew/2under the default profile, whose reflection limit is derived from the budget. - GEPA optimizes agents. For
Imp.react/3the reflection model reads the whole run of each example asContext(every thought, tool call and tool result, and the outputs, except whenfinish_onor the extractor ended the turn) and the loop's tools by name, description and arguments. It used to read only the first step, with an empty history. - A predictor called several times in a run is reflected on at one call drawn at random with the optimizer's seed, not always its first call.
- Under the DSPy profiles, a metric that returns only a score gives the
reflection model "This trajectory got a score of X." with the row's score,
as DSPy does, instead of
improveorsuccessful. - An iteration whose chosen component has no reflection records ends without a reflection call.
- A program with an
Imp.Historyinput, or any struct among its inputs, no longer crashes the reflection prompt underreflection_record_mode: :beam_native; the history is shown as its turns. - A predictor with two
Imp.Historyinputs raises when GEPA builds its reflection records, as DSPy's GEPA asserts there is one.
Retrieval
Imp.Retrieve.Memory(Imp.memory/2) returns only documents that share a word with the query, up tok. It returnedkdocuments whatever they scored, so a document with no word in common came back withscore: 0because it came first in the list.
0.4.0 — 2026-09-17
Imp.ACPandImp.MCP.connect/2are part of Imp. The separateimp_acppackage is retired:Imp.ACP.start_link/1andImp.ACP.run/1expose an ordinary Imp program to an ACP host, andImp.MCP.connect/2imports authorized MCP servers through ExMCP as ordinary tools with explicit connection cleanup. There is no compatibility shim; a consumer that depended onimp_acpdepends onimpalone and changes the module prefix. ExMCP is declaredruntime: false, so an OTP release that uses either must listapplications: [ex_mcp: :load]in its release definition. Ordinary Imp startup still starts no protocol endpoint.- 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 wasinspect/1at its default limit, which cut any structured value past fifty elements to an ellipsis the model could not count and no host bound could measure. A term JSON cannot carry renders as a completeinspect. - ReActV2 sends the tool roster once, natively, and never renders it as text;
it no longer declares a
toolsinput field or writes "You are an Agent..." intosignature.instructions. The loop's guidance (finish_tool,input_names,output_names,tool_names) travels to the adapter as data through the new:adapter_optsonImp.Predict.PredictandImp.Predict.ReActV2. Each step's request is now the previous step's request plus the newest exchange, which is what a provider's prompt cache is keyed on; before, the first user message changed shape between steps one and two and the roster was re-sent after the history on every call. - The chat adapter's format options (
Imp.Adapter.Chat) gain:system_renderer(a function of the signature and the format options, default the DSPy system message),:guidance(rendered by the default system renderer the way ReAct's instructions used to read), and:omit_empty_request(end the request on the newest history message rather than an empty user message; off by default, so the JSON and XML adapters keep DSPy's shape). :reasoning_effortis the one reasoning option onImp.Clients.ReqLLM, at construction or per call, and:openrouter_reasoningis gone. A call namingreasoning_effort: nilspends no reasoning on that call, which is what ReAct's forced submit asks for; previously that nil, combined with a configured OpenRouter effort, raised and ended the turn without an answer. Which OpenRouter wire field carries the effort is a separate switch,openrouter_reasoning_wire: :top_level | :nested(default top-level, as ReqLLM sends it); it names an encoding, never a value. Saved programs allowlist both in place ofopenrouter_reasoning.- Changed the cost a host reads off a model call to a plain number. The
:model_responseevent'smetadata.costis now the provider's reported total in USD as a non-negative float, ornilwhen the provider reported nothing Imp can read as a number; it was whatever the provider library put there, most recently ReqLLM's private billing breakdown map. When the provider reported a breakdown, the whole map is on the event asmetadata.billing, untouched; when it reported none, there is no:billingkey.Imp.Core.LMResponsecarries the same pair as:costand the new:billingfield, and reads a total given as a number, a string, aDecimal, or a breakdown map. A host summing spend reads the number and no longer has to know a provider library's internal shape. - Added
Imp.MCP.OAuth, a credential store for remote HTTP MCP servers that require a browser-authorized OAuth grant. It begins the flow, listens on127.0.0.1for the redirect, stores the grant encrypted as one file per credential under a directory the host names, and refreshes it without the person. A grant is bound to the URL it was authorized for, so a descriptor cannot point another server's credential at itself. A refresh the authorization server answers withinvalid_grantis reported as{:mcp_oauth_reauthorization_required, credential}rather than as a retryable failure. - Added two server descriptor auth forms that
Imp.MCP.connect/2resolves to headers at connect time:%{"type" => "oauth", "credential" => ref}, which reads the store passed as the new:credentialsoption, and%{"type" => "bearer_env", "variable" => name}, which reads the host's environment and connects with noAuthorizationheader (logging one warning) when the variable is unset, unless"required" => true. Static"headers"are unchanged. - Added
on_failure: :droptoImp.MCP.connect/2. Under it a server whose transport orinitializefails, which accepts the connection and never answers, or which cannot answertools/list, is left out with its client closed instead of failing the whole import: the tools of the servers that did connect are returned, and the newunavailablefield ofImp.MCP.Importnames each dropped server with a short reason and with theindexof its descriptor in the list that was passed in. The defaulton_failure: :refusekeeps the previous all-or-nothing behaviour, except that a transport that refuses the connection is now reported as{:mcp_connection_failed, reason}rather than as the import helper's exit. A descriptor:authorizerefused, one whose declaredauthcannot produce a header, a malformed one, and anything raised by the caller's own:tool_filterrefuse the import under both settings. - Each MCP dial is now bounded by
:timeouton its own rather than sharing one budget with the whole list. A host that accepts the connection and answers nothing returns within neither:handshake_timeoutnor:era_probe_timeout, so two of them used to exhaust the shared budget and refuse the import as{:error, :mcp_import_timeout}whatever:on_failuresaid; now each costs its own timeout and is reported as{:mcp_connection_failed, :timeout}. - An abandoned dial no longer leaks its client.
ExMCP.Clienttraps exits and ignores theEXITfrom the process that started it, so killing the import helper at a timeout left every half-open client alive, holding its socket, for the life of the node. - A
tools/listbody that is not a catalog is now reported as{:mcp_tools_list_failed, server, {:invalid_mcp_tools_response, shape}}rather than as a bare{:invalid_mcp_tools_response, shape}that named no server. - The repository is public. Installing at a tag needs no credentials, and the README, docs, and livebooks no longer describe a private source release.
- Removed the evidence-certification bookkeeping from the source checkout. It never shipped in the package, so a consumer sees no change; the benchmark harness it wrapped is unchanged.
- Added Benchmarks and its results table. Every number this repository publishes is one row in that table, carrying the dataset and its license, the model, the provider, the date, the commit, and the command that produced it; prose elsewhere cites a row rather than restating a number. The benchmarks page says what each command needs from you — key, Python environment, time, rough cost — and separates a row a stranger can re-measure with an API key from one that only recomputes statistics from committed rows, and from the claims that cannot be re-measured at all. Neither page publishes an aggregate or a release score. The tutorial's rows were re-measured live for this release, a month after the first run, and both runs are recorded. They live in the repository, not in the installed package.
0.3.2 — 2026-09-01
- Made the RLM controller prompt describe the restricted language actually accepted by its interpreter, including supported repair paths and explicit unsupported forms.
- Preserved explicit no-retry provider policy across Req function, module, and
{module, function, args}adapters.
0.3.1 — 2026-08-23
- Clarified the supported center versus pre-1.0 advanced workflows, the exact meaning of typed inputs and outputs, and the restricted-interpreter security boundary.
- Completed the structured signature field reference and corrected cold-reader prerequisites and cross-references.
0.3.0 — 2026-08-23
Private Git source release. Imp is not published to Hex.
Added
- Typed code fields, richer structured signatures, and consistent Chat, JSON, and XML adapter validation.
- General program parameters and checksummed parameter artifacts for named predictors, tools, playbooks, and custom optimizable components.
- Full MIPROv2 TPE search, expanded GEPA/SIMBA/COPRO behavior, persistent playbook optimization, optimizer checkpoints, and Optimize Anything program artifacts.
- Addressable ReActV2 and RLM runs with ordered events, cancellation, explicit per-effect authorization, and owner-death cleanup.
- MCP stdio process ownership that reaps server process groups when a call or run is cancelled.
- Provider streaming for composed programs, including named-predictor field selection and a typed terminal prediction.
- A complete deployment example with disjoint evaluation, parameter artifacts, fresh-process loading, concurrent serving, hot reload, and failure containment.
Changed
Imp.optimize/3,/4, and/5now return{:ok, program}or{:error, reason}. The correspondingoptimize!functions raise.- Adapter types moved from
Imp.Adapters.TypestoImp.Adapter.Types. - Saved programs and parameter artifacts use atomic private writes and require live providers, callbacks, tools, and policies to be rebound from trusted application code.
- ReActV2 uses a validated
submittool, falls back to tools-disabled typed extraction when a provider cannot honor forced tool choice, and grounds that extraction only in successful retained observations. - Evaluation timeouts, provider failures, metric failures, budget exhaustion, and cancelled work propagate as observable failures instead of silently becoming ordinary scores.
- The private release installs consistently from the immutable
v0.3.0Git tag in the README, Livebooks, and packaged examples.
Fixed
- Cached ReqLLM responses no longer double-count token usage.
- ReAct retains a single tool-call trajectory when context truncation cannot safely remove a complete turn.
- ReActV2 converts malformed provider tool calls into non-executable error observations so a model can recover without bypassing policy or authorization.
- Whole-program and optimizer-report identities survive artifact round trips.
- Composed streaming cancels provider work on early halt or consumer death.
- Batch messages retain their structured conversations after JSON transport.
- Optimizer progress subscribers receive real trial events.
Removed
Imp.AgentandImp.Agent.Runtime. Use ReActV2 or RLM as the program and ordinary Elixir supervision as the runtime.Imp.Streaming.Messages.StatusMessageProvider, which did not implement an execution-stage status contract. Use stream listeners and:telemetry.
0.2.1 — 2026-07-18
Added
- Configurable teacher and rollout timeouts for BootstrapFewShot, GRPO, and BootstrapFinetune.
Fixed
- Saved Predict programs retain JSON retry and fallback configuration.
- GEPA reflection no longer sends an invalid provider connection option.
- Reasoning-model token-limit normalization is wire-neutral.
- Signature errors suggest
array[...]when given DSPy'slist[...]spelling. - Global test configuration and async synchronization no longer leak across tests.
0.2.0 — 2026-07-17
Added
Imp.stream/3andImp.collect/3, with provider streaming and local post-call chunking modes.- Public optimizer progress subscriptions and evaluation timeout reporting.
- Deterministic LabeledFewShot selection and seeded data splitting.
Fixed
- Batch requests preserve structured messages.
- Optimizer trial telemetry is emitted as documented.
- Timed-out evaluation rows are reported explicitly.
0.1.0 — 2026-07-16
Initial private Git source release of Imp's signature, program, provider, evaluation, optimization, tool, retrieval, persistence, and Livebook APIs.