User-visible changes to Imp are recorded here.
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.