v0.3.0 (2026-08-11)
Changed
Embedding task vocabularies now belong to each provider. There was a single normalized set of eight atoms translated per provider, plus a
:task_mapto retarget the strings and a verbatim-string escape hatch. That model does not survive contact with real endpoints: Gemini'staskTypeis a closed enum of eight, Jina v5 has four task names plus a separateprompt_name, and OpenAI has no task field at all. Translating between them meant either dropping distinctions a model makes or inventing ones it does not - and the built-in map was already wrong, mapping:retrieval_documentto Jina v3's"retrieval.passage", which v5 removed.Each provider now declares its own atoms and rejects anything outside them.
ExAgent.embedding_tasks/1lists them, backed by a new optionalExAgent.Provider.embedding_tasks/1callback.Removed from
ExAgent.Embeddings:tasks/0,valid_task?/1, thetask_inputtype, the:task_mapoption, and verbatim string tasks. A string is now rejected everywhere - an endpoint that does not recognize a task string answers 200 and leaves quietly wrong vectors in an index, so there is no safe version of "send it and hope".Gemini's own atoms are unchanged, so Gemini callers are unaffected.
ExAgent.Providers.OpenAICompatibleno longer supports embeddings. "Any endpoint speaking the OpenAI dialect" cannot have a task vocabulary, which is exactly what the removed:task_mapwas trying to paper over. theOpenAICompatibleEmbedServicemodule is gone; the provider is chat-only. UseExAgent.Providers.JinaV5, or a provider of your own, for embeddings against a self-hosted model.
Fixed
Found in a pre-release audit of the code added for this version.
ExAgent.Reranking.above/2kept unscored results. In Elixir's term ordering every atom sorts above every number, sonil >= 0.5istrueand a result with no score survived any relevance floor.above/2now requires a numeric score, and the reranker service rejects the whole response if a result has no numericrelevance_score, so the situation cannot arise from a server that omits one.Reranking.take/2returnednilfor an out-of-range index, which would put the string "nil" into a prompt. It now raises, naming the mismatch, and the service rejects a response whose indexes fall outside the documents that were sent.A bad
:max_historyor:max_tool_iterationscrashed the agent mid-turn.max_history: 0was accepted bystart_link/1and then raised aFunctionClauseErrorat the end of the first turn, pointing at the wrong line entirely. Both are validated when the agent starts.MapReducereported{:error, :all_sections_failed}with no reason. The failures now travel with it, since "everything failed" alone cannot be debugged.Consensusreported{:error, :no_answers}when nobody had been asked. An empty:voterslist or a non-positive:samplesis now{:error, :no_voters}, distinct from every voter having been asked and failed, which returns the failures alongside.MapReduceandConsensusaccumulated results with++per item, which is quadratic in the number of sections or voters. They prepend and reverse once.
Changed
Pattern API is uniform. Every workflow entry point is now
run/2orrun/3, and every builder that hands tools to an agent istools/1. Three conventions had grown up side by side, and thebuild_prefix said nothing that the return type did not.Before Now Subagents.build_orchestrator_tools/1Subagents.tools/1Subagents.invoke_subagents_parallel/2Subagents.run/2Handoff.build_handoff_tool/3Handoff.tools/1Handoff.execute_handoff/2Handoff.run/2Router.route/2Router.run/2Skills.evaluate_skills/2Skills.evaluate/2Handoff.tools/1now takes a list of%{name:, agent:, description:}specs and returns a list, matchingSubagents.tools/1exactly, so building several handoff targets is one call.ExAgent.route/2andExAgent.handoff/2are unchanged.The README is now a tutorial, not a feature tour. Eleven steps from "ask one question" to a composed support pipeline, each a complete program you can paste into
iex -S mix. Every block was executed against a live API before publishing, and the outputs shown are from real runs with a note that models vary.The step order is the teaching order: tool, then skill, then subagent, then handoff, followed by a table answering the question people actually have - who answers the next message? A subagent is a phone call you make while the customer waits; a handoff is passing the customer to a colleague.
The handoff step now explains why
ExAgent.handoff/2has to be called before talking to the target: it is what delivers the conversation, and without it the target agent is a stranger. Includes the before/after of what actually reaches the model, why the returned tuple is a proposal rather than a transfer, and why the async cast is not a race (Erlang orders messages between a pair of processes; measured 0 late arrivals in 200 runs) along with the case where that guarantee does not hold.Em dashes removed from all documentation and source comments.
Added
Four workflow patterns, filling the gaps against the commonly documented catalogue (Anthropic's prompt chaining / routing / parallelization / orchestrator-workers / evaluator-optimizer, plus the sequential-workflow and reflection patterns that show up in every 2026 survey). ExAgent already had routing, orchestrator-workers, peer transfer, progressive disclosure, and the ReAct-style tool loop; these are the rest:
ExAgent.Patterns.Chain- a fixed sequence of steps, each working on the last one's output. Steps are plain functions, so validation, parsing, and database lookups sit in the line beside the LLM calls;Chain.llm/2builds an LLM step. A step returning{:halt, value}stops the line without it being a failure, which is how you decline to spend the remaining calls - and where a human approval gate belongs. Errors carry the failing step's index.ExAgent.Patterns.Reflection- the evaluator-optimizer loop: draft, critique, revise, until the critic accepts ormax_rounds(default 3) runs out. Exhausting the ceiling returns{:max_rounds, result}, not{:ok, result}: the last draft is there, but using unreviewed work has to be a choice rather than something handed over as if a reviewer had passed it. An LLM critic can always find something to complain about, so the ceiling is the difference between a workflow and a runaway bill.ExAgent.Patterns.MapReduce- parallelization by sectioning: split an oversized input, process the pieces concurrently, combine them with either a function or another model (reduce: {target, prompt_builder}). One failing section does not fail the run; the reducer sees what survived and:failuresreports the rest, because a summary of 38 of 40 interviews is worth having but not worth mistaking for all 40.ExAgent.Patterns.Consensus- parallelization by voting: ask several times (or several models) and go with the answer that recurs.:agreementis the winner's share, which is the actual product - a low number is the signal to escalate rather than proceed. Ties resolve to the earliest answer deterministically, which needs care becauseEnum.frequencies/1returns a map and a map has no insertion order to fall back on.
All four accept either a provider struct (stateless, no process) or a running agent (remembers the conversation) wherever they take a target - previously
Routertook only agents andSubagentsonly provider structs, and neither could be handed the other.Deliberately not added: plan-and-execute, which needs an LLM-authored plan parsed into executable steps and is brittle in exactly the way the rest of this library tries not to be - compose it from
ChainandSubagentsinstead; and blackboard/swarm topologies, which the production write-ups consistently report losing to hierarchical and graph shapes.
Changed
The README's pattern section is now a guide to choosing one, not a feature list: analogies for all eight, a table keyed on when to reach for each, and worked comparisons of the pairs people conflate - Handoff vs Subagents (a lookup versus a transfer, settled by "who is the user talking to now?"), Skills vs Subagents (continuity versus isolation), and Reflection vs Consensus (sloppy work versus wrong work).
Reranking.
ExAgent.rerank/4andExAgent.rerank_with/4, backed by a new optionalExAgent.Provider.rerank/4callback, returning anExAgent.Rerankingstruct. Retrieval's second stage: embeddings compare independently computed vectors, which is what makes searching a corpus feasible, while a cross-encoder reads the query and one document together - more accurate, and far too slow to run over everything.:indexis the contract, pointing back into the list you passed, so results map onto your own records without the server echoing text back.ExAgent.Reranking.take/2reorders a list;above/2applies a relevance floor, because ranking always returns something - the best of an irrelevant set still sorts first, and a floor is how you decline to answer. Scores are model-scoped: higher is more relevant and that is the only guarantee.Providers without a reranking endpoint return
{:error, %ExAgent.Error{type: :unsupported}}. Emits[:ex_agent, :rerank, :start | :stop | :exception]telemetry.ExAgent.Providers.JinaRerankerM0- a reranking-only provider for a self-hostedjina-reranker-m0server.chat/3returns:unsupported, and there is noembed/3: a reranker scores query/document pairs and has no single-text vector to give.:base_urlis required and takes bearer auth plus arbitrary:headers, so Modal's proxy auth works. Empty document lists, non-string documents, batches over 512, a blank query, a non-positive:top_n, and unrecognized options are all rejected before the request - the server rejects unknown body fields outright, so a typo has to be caught client-side or it comes back as a validation blob.The wire contract -
POST {base_url}/v1/rerankwithquery/documents/top_n/return_documents, answeringresultswithrelevance_scoreanddocument.text- was verified against a live deployment.return_documentsdefaults totruethere andfalsehere, since:indexalready identifies each document. This is not the shape of Jina's hostedapi.jina.ai/v1/rerank, whosedocumentstake{"text": ...}/{"image": ...}objects.ExAgent.Providers.JinaV5- an embeddings-only provider for a self-hosted Jina embeddings v5 server, with v5's own tasks::retrieval,:text_matching,:clustering,:classification. v5 moved the query/document distinction out of the task and into a separateprompt_name, which is why the module is named for the version: v3 and v4 spelled the same thing as a single"retrieval.query"/"retrieval.passage"task, so one module covering both would have to lie about one of them.prompt_nameis required for:retrievaland rejected for the other tasks; Matryoshka truncation is validated against the trained widths (32, 64, 128, 256, 512, 768, 1024); batches are capped at 512 inputs. All three are rules the server enforces, checked client-side so the failure names the fix instead of arriving as a 400. The server owns normalization, so vectors are returned untouched -args: [normalize: false]really does give you a non-unit vector.chat/3returns{:error, %ExAgent.Error{type: :unsupported}}pointing at a chat provider.:base_urlis required and takes bearer auth plus arbitrary:headers, so Modal's proxy auth works.The wire contract -
POST {base_url}/embedwithtexts/task/prompt_name/dimensions/normalize, answeringembeddings- was verified against a live deployment, not inferred from a model card. It is not the shape of Jina's hostedapi.jina.aiservice, which speaks an OpenAI-style/v1/embeddings.:argsonembed/3- extra request-body parameters as a keyword list or map, for what this library does not model:ExAgent.embed(jina, chunks, task: :retrieval, args: [prompt_name: :document]) ExAgent.embed(openai, chunks, args: [encoding_format: "base64"])Each provider validates keys and values against what its own endpoint accepts and rejects the rest, so
prompt_nane:fails naming the accepted keys instead of being ignored by the server. Atoms are accepted where the endpoint wants one of a fixed set of strings. Gemini accepts no extra args and says so; OpenAI acceptsencoding_formatanduser; Jina v5 acceptsprompt_nameandnormalize, the only extra fields its server permits.ExAgent.Embeddings.normalize_args/1, for providers implementing the same option.
Fixed
An end-to-end audit of the library found the following. Every one of them shipped with a
green suite: the tests covered the shape of each code path but not the behaviour a user
would observe. test/ex_agent/regressions_test.exs now covers each one.
Streaming stole messages from the caller's mailbox.
chat_stream/3runs in the calling process, and the SSE transport used a barereceivethat matched anything;Req.parse_message/2answering:unknownthen discarded it. Streaming inside a LiveView or GenServer silently ate that process's own messages, and the matchinghandle_infosimply never fired. The receive is now selective on the response ref.Gemini ignored
:max_tokensentirely. The option was merged under one key and read under another, so neither the provider setting nor a per-call override ever reachedgenerationConfigand every response used the API default. Google's own:max_output_tokensspelling is accepted as an alias.A Gemini reasoning part was returned as the answer. Only the first content part was read, and a
thoughtpart matched the text clause - so the model's scratchpad became:contentand the real answer was discarded. Text split across parts was truncated to the first piece for the same reason. Reasoning now lands in:thinking, as it already did when streaming.Parallel tool calls were silently dropped. Both dialects returned only the first call, so the model believed tools had run that never did.
ExAgent.Provider.chat/3now answers{:tool_calls, calls}with every call;{:tool_call, name, args}is still accepted from providers written against the older contract.Tool call ids were fabricated. The assistant message was rebuilt with
id = name, discarding the id the API issued - so calling one tool twice in a turn produced two colliding ids. The provider's id is now carried through, and a tool result correlates back by it (Gemini correlates by function name, which travels alongside).A tool returning anything but a string crashed the turn.
to_string/1on a map raisedProtocol.UndefinedError, killing the task and surfacing as an opaque:servererror - for the most natural tool shape there is. Non-string results are now JSON encoded.A skill never deactivated. Applying one overwrote the provider's
system_promptpermanently, so the first activation repainted the agent for the rest of its life - a "SQL expert" answering jokes. Skills are re-evaluated every turn and now restore the agent's own prompt when they stop matching.One failing agent took down a whole Router run. Only
{:exit, :timeout}was handled, so any other crash raised aCaseClauseErrorin the caller, discarding the routes that had already answered. BothRouterandSubagentsnow report a failure per route, named. A handoff result no longer falls through unmatched.parse_response/2raised on a message with no"content"key. A bare refusal gave aCaseClauseErrorinstead of a normalized{:error, %ExAgent.Error{}}.OpenAI could never reference an uploaded image. Attachments over the inline ceiling were uploaded and then referenced as an
image_filecontent part - which is the Assistants API's shape and which chat completions rejects outright. Verified against the live API: no file-id shape works for images there. Oversized images and imagefile_refsnow return{:error, %ExAgent.Error{type: :unsupported}}with the fix in the message, instead of spending an upload on a request that would always fail.API keys were printed by
inspect/1. No provider redacted its credential, so every crash report,dbg, and Logger metadata dump leaked it. All three providers now deriveInspectexcluding:api_key,:req, and (forOpenAICompatible):headers, where gateway credentials live.Streaming with tools billed twice. The tool loop ran non-streamed to completion and then discarded the finished answer to regenerate it as a stream - two full completions per streamed turn. Every turn is now streamed once, with tools run between turns; the consumer still sees exactly one terminal
:donechunk.SSE multi-line
data:fields were concatenated without a separator. The spec joins them with a newline. JSON payloads survived either way; a plain-text stream did not.Skills, subagents, and the streaming tool loop assumed every provider struct carries
:toolsand:system_prompt, reintroducing theKeyErroralready fixed on the chat path. All of them now populate a field only when the provider declares it.ExAgent.Agent.chat/3's@specstill promised{:ok, Message.t()}after the switch toExAgent.Response.
Changed
:max_tokensand:temperaturenow default tonilon every provider and are omitted from the request, so the model's own defaults apply. The previousmax_tokens: 512truncated most real answers atfinish_reason: :length, andtemperature: 0.6broke models that reject the parameter outright (o-series, search-preview). Set them explicitly if you relied on the old values. Both now also accept an integer, wheretemperature: 1used to raise.OpenAI embeddings reject a batch over 2048 inputs up front, naming the limit, rather than letting the API 400.
Added
ExAgent.Telemetry.[:ex_agent, :chat | :embed | :tool, :start | :stop | :exception]events carrying duration, token counts, model, and - on failure -:error_typeand:retryable?. A library that calls billed APIs has to be measurable; nothing is logged on your behalf. Adds a:telemetrydependency.:max_historyonstart_agent/1andExAgent.Context.trim/2. History was unbounded, so every turn resent the whole transcript until the model returned:context_length. Opt-in, because silently forgetting what a user said is the caller's decision. Leading system messages survive the window, and a tool result is never orphaned from the assistant message that requested it.:max_tool_iterationsonstart_agent/1, replacing the hard-coded ceiling of 10.
Added
Provider roles.
config :ex_agent, :roles, chat: {Module, opts}maps a purpose to a provider, andExAgent.provider!(:chat)returns an ordinary provider struct usable anywhere a hand-built one is -start_agent/1,ExAgent.Provider.chat/3, subagent specs, every pattern. Role names are arbitrary atoms.start_agent(role: :vision)is shorthand;:roleand:providerare mutually exclusive.Purely additive: every existing entry point still takes a struct, unchanged.
ExAgent.chat_with/3,stream_with/3andembed_with/3are stateless one-shot wrappers that bypass the agent GenServer. They do not run the tool loop - a tool-configured provider returns the raw{:tool_call, name, args}.Roles resolve once at application start and cache in
:persistent_term, so lookups cost nothing on the request path; per-call overrides (provider!/2) build a fresh struct rather than writing to the cache. A module that is missing, lacksnew/1, does not implementExAgent.Provider, or whosenew/1raises fails the boot with the role name in the message, so a missing credential crashes at deploy time instead of on the first request. Option values may be a zero-arity function or{m, f, a}, resolved once at boot, for vault-backed credentials.Note that this is the first thing in
lib/to readApplication.get_env- the library was otherwise configured entirely through explicit structs, and still can be.ExAgent.Error. A normalized error struct returned by every provider operation, carrying:type,:message,:status,:provider,:rawand:retryable?. HTTP statuses are classified into one vocabulary (:auth,:rate_limit,:context_length,:invalid_request,:not_found,:timeout,:server,:transport,:unsupported), so retry logic is written once rather than per provider.ExAgent.Error.from_result/2does the classification for custom providers. The struct is also an exception, so it can be raised where no return value exists.URL file sources.
files: [%{url: "https://..."}]hands the URL straight to the provider - Gemini asfile_data.file_uri, OpenAI asimage_url/file_url. ExAgent never fetches the URL, so no bytes cross your application.Optional
:mime_type. Inferred from the file extension for:pathand:url(query strings ignored) and from magic bytes for:data(PNG, JPEG, GIF, WebP, WAV, MP3, MP4/QuickTime/M4A, PDF). An explicit:mime_typestill wins. When the type cannot be determined the call fails with an error naming:mime_typerather than guessing.ExAgent.Source. Pure MIME-inference and modality helpers, with no provider knowledge.ExAgent.Attachment. Attachments normalize into a struct carrying:kind,:mime_type,:modality,:byte_sizeand:provider_opts(video:fps/:max_framesare lifted into the latter).Modality gating. The optional
ExAgent.Provider.supported_modalities/1callback declares which attachment modalities a provider accepts (:image,:document,:video,:audio).ExAgent.Provider.chat/3andstream/3check every attachment against it before building a request. Providers that omit the callback are text-only, so an unsupported attachment fails loudly instead of being dropped.Every built-in provider takes a
:modalitiesoption, because modality support is a property of the model, not the vendor -o1-minireads no images. Defaults are[:text, :image, :document]for OpenAI, those plus:videoand:audiofor Gemini, and[:text]forOpenAICompatible(one container serves one model). Narrowing makes the gate fire locally instead of letting the provider 400 later:OpenAI.new(api_key: key, model: "o1-mini", modalities: [:text])ExAgent keeps no model-to-modality table on purpose: it would go stale silently, and whoever picked the model already knows.
Embeddings.
ExAgent.embed(provider, inputs, opts)returns an%ExAgent.Embeddings{}carryingvectors,model,provider,dimensions,task, andusage. It takes a provider struct rather than an agent pid - embedding is stateless. Backed by a new optionalExAgent.Provider.embed/3callback; providers without an embeddings endpoint return{:error, %ExAgent.Error{type: :unsupported}}.One normalized task vocabulary (
:retrieval_query,:retrieval_document,:similarity,:classification,:clustering,:question_answering,:fact_verification,:code_query) is translated per provider:gemini-embedding-001takes ataskTypeenum,gemini-embedding-2has no such field and takes a text prefix (with an optional:titleper input), OpenAI has no task support and errors rather than dropping it, and OpenAI-compatible endpoints take ataskbody field whose strings are overridable via:task_map.Notable correctness details:
:modelis always resolved to an embedding model and never the provider's chat model; Gemini requests always usebatchEmbedContentswith oneContentper input, because a flat list returns a single aggregated vector ongemini-embedding-2; OpenAI responses are re-sorted byindex, which the API does not guarantee; and truncatedgemini-embedding-001vectors are L2-normalized client-side, which that model does not do for you. An unknown Gemini embedding model errors rather than guessing a family - pass:embedding_familyto adopt a newer one.On
OpenAICompatibleonly,:taskalso accepts a raw string, sent verbatim with no translation and no validation - a self-hosted endpoint serves whatever model you deployed, and those vocabularies change between versions (Jina v3's"retrieval.passage"became a"retrieval"task plus a prompt in v5, which also added"text-matching"). Gemini and OpenAI take atoms only:taskTypeis a closed enum and OpenAI has no task field, so a string there is a typo far more often than a new value and is rejected naming the valid atoms. Atoms stay validated everywhere, and the result carries back exactly what was passed.ExAgent.Embeddingsalso exposestasks/0,valid_task?/1,l2_normalize/1, andcosine_similarity/2. Persistmodel,dimensions, andtaskalongside every vector - embedding spaces are model-scoped and mixing them degrades retrieval silently.ExAgent.Providers.OpenAICompatible. One provider for any endpoint speaking the OpenAI chat-completions dialect - self-hosted vLLM (including behind Modal), OpenRouter, Together, Groq. Takes arbitrary:headers(so Modal'sModal-Key/Modal-Secretproxy auth works, whereExAgent.Providers.OpenAIhad no way to set them), with:api_keyas sugar for a bearer header that explicit headers override.:modalitiesis declared per deployment and defaults to[:text]. Media is carried inimage_url/video_url/audio_urlcontent parts whose URL may be adata:URI. There is no Files API, so an attachment past:max_inline_bytes(32 MB default) returns{:error, %ExAgent.Error{type: :unsupported}}rather than being truncated.probe/1checks that the endpoint actually serves the configured model.Shared OpenAI dialect helper (
lib/ex_agent/services/openai_dialect.ex, internal). Holds the request/response shaping shared by every dialect speaker, so the OpenAI and OpenAI-compatible services no longer duplicate it.Adaptive inline-vs-upload. Both services now choose how each attachment is delivered rather than leaving it to the caller: URLs and existing
FileRefs are referenced as-is, bytes under the inline ceiling are base64-encoded, and anything larger is uploaded through the provider's Files API and referenced. Gemini inlines up to 20 MB (50 MB forapplication/pdf) and references by URI; OpenAI inlines up to 20 MB and references byfile_id. Uploads reuse the provider'sReqclient and are deduplicated throughExAgent.UploadCache;upload_cache: falseon the provider opts out.Video options.
:fpsmaps to Gemini'svideo_metadata, and string-keyed:provider_optsentries are merged into the media part verbatim. TheOpenAICompatiblevideo_urlcontent part and:fpspassthrough are verified against a live vLLM (Qwen3-VL) deployment, from both an http URL and a base64 data URI.Gemini upload polling is configurable.
:poll_interval_msand:max_poll_attemptsare now options, defaulting to2_000/60(~2 minutes, up from ~10 seconds) - large files and video need it.ExAgent.UploadCache. An ETS-backed cache that lets the same bytes reuse an existingExAgent.FileRefinstead of re-uploading. Entries are keyed by{scope, sha256(bytes)}where the scope digests the provider module, base URL, and API key - so two accounts never share a file reference, and the key itself is never stored. An expiredFileRefis treated as a miss and evicted. Added to the supervision tree ahead of the agent supervisor;clear/0empties it.
Fixed
A failed turn poisoned the agent. The user message was committed to context even when the turn failed, so a message the provider had already refused - a rejected attachment, say - was resent on every later turn and every one of them failed. "Fails loudly" became "fails forever". A failed turn now leaves no trace, which also stops a retry after a transient 429 from duplicating the question in history.
chat_stream/3raised on a rejected attachment. The modality gate raises insideExAgent.Provider.stream/3because a lazy enumerable has nowhere to carry an error at construction time, and that escaped to the consumer - contradicting the documented promise that streaming never raises, and leaving the agent stuck in:processing. It now arrives as the terminal:donechunk like every other stream failure, and the agent is released.The agent required a
:toolsfield on every provider struct.run_tool_loop/3and the streaming path both did%{provider | tools: ...}, so a provider without tool support crashed with aKeyErrorthat surfaced as an opaque{:error, %Error{type: :server}}. The field is now populated only when the provider declares one.ExAgent.FileRefrejected custom providers.:providerwas validated against a hardcoded[:openai, :gemini], so a third-party provider implementing the optionalExAgent.Provider.upload/4callback could not build the reference its own callback has to return. The built-in services construct%FileRef{}structs directly and never callednew/1, so the closed list protected nothing - it only walled out everyone else. Any atom is now accepted, and a reference need only carry a:file_idor a:file_uri; OpenAI's and Gemini's specific field requirements still apply to them, since their services pattern-match on those fields.OpenAICompatibleshaped documents as images.:documentwas declarable through:modalitiesbutformat_attachment/1fell through toimage_url, so a PDF was sent as an image part and the gateway either rejected it or read nothing. Documents now use the dialect'sfilepart -file_datafor bytes (with the requiredfilename) andfile_urlfor a URL - which is what a gateway fronting a document-reading model expects. The moduledoc previously claimed documents were unsupported; a model behind OpenRouter or Modal may well read them, so it is a deployment property like every other modality.Gemini streaming produced no text at all. Gemini terminates SSE events with CRLF, but
ExAgent.SSE.take_events/1split only on"\n\n". A CRLF stream contains no such boundary, so every frame stayed buffered, no frame was ever decoded, and the stream ended with its terminal chunk and empty content - silently, with no error. Framing now accepts CRLF, LF, and bare CR per the SSE spec, on both event and line boundaries. Found by running against the live API; every mocked test hand-wrote LF bodies.Streams ended with two
:donechunks whenever the model reported a finish reason. Provider mappers turn a finish-reason frame into a:donechunk and the transport appends the terminal one. Since real responses always finish, the documented "exactly one:donechunk" invariant was broken in practice for every provider. The transport now consumes the mapper's:donefor its finish reason and emits the single terminal chunk itself.A tool returning a bare value crashed the tool loop.
ExAgent.Tool's:functionis typed(map() -> any())and its own doctest returns a bare:ok, but the agent matched only{:ok, _}/{:error, _}/{:handoff, _, _}- anything else raisedCaseClauseErrorinside the supervised task, surfacing as an opaque:servererror. An unwrapped return is now taken as the result.Documented that OpenAI's
:web_searchneedstemperature: nil.web_search_optionsis only accepted by a*-search-previewmodel, and those rejecttemperature- so the provider's owntemperature: 0.6default made the shipped example return HTTP 400. ExAgent still forwardstemperatureas configured rather than dropping it when a model objects; a silently ignored sampling parameter is worse than a 400 naming the field.Retired Gemini default model.
gemini-2.0-flashis no longer served and returns HTTP 429 with a zero free-tier quota rather than a clear 404. The default is nowgemini-3.6-flash. Pass:modelexplicitly to pin a different one.A malformed attachment no longer crashes the agent.
ExAgent.chat/3andExAgent.chat_stream/3matched on{:ok, msg} = Message.new(...), so an unreadable path turned into aMatchErrorinsidehandle_calland took the agent process down. Both now return{:error, %ExAgent.Error{type: :invalid_request}}.Normalized stream chunks.
ExAgent.chat_stream/3andExAgent.Provider.stream/3now yield%ExAgent.Chunk{}structs instead of bare strings, surfacing what streaming previously discarded: reasoning traces (:thinking_delta), tool-call deltas, token usage, and finish reasons. Every stream ends with exactly one:donechunk.ExAgent.collect/1. Folds a chunk stream into the sameExAgent.Responsethatchat/3returns, reassembling fragmented tool-call arguments by index, so streaming and non-streaming share one downstream code path.ExAgent.SSE. Server-Sent Events framing extracted from the streaming transport and made public, withdecode/1returning complete frames plus the unconsumed remainder. A frame split across TCP reads is reassembled correctly - now covered by a test that splits a body at every byte boundary.
Removed (breaking)
ExAgent.Providers.DeepSeek. DeepSeek speaks the OpenAI chat-completions dialect, soExAgent.Providers.OpenAICompatiblecovers it with no loss of capability:# before ExAgent.Providers.DeepSeek.new(api_key: key, model: "deepseek-reasoner") # after ExAgent.Providers.OpenAICompatible.new( base_url: "https://api.deepseek.com/v1", api_key: key, model: "deepseek-reasoner" )Reasoning traces still arrive as
:thinking_deltachunks - thereasoning_contentfield is handled by the shared dialect, not by the removed module. The built-in:thinkingtool went with it; pick the reasoner model instead.
Changed (breaking)
chat/3returns%ExAgent.Response{}. Previously{:ok, %ExAgent.Message{}}. The response carries:content,:usage,:finish_reason,:tool_calls,:thinking, and the:messageappended to conversation history:# before {:ok, %ExAgent.Message{content: content}} = ExAgent.chat(agent, "Hi") # after {:ok, %ExAgent.Response{content: content}} = ExAgent.chat(agent, "Hi") # ...or response.message for the Message struct itselfThis cascades through the
ExAgent.Provider.chat/3callback, so custom providers must return aResponse- build one withExAgent.Response.new/2.Streams yield
%ExAgent.Chunk{}instead ofString.t().# before agent |> ExAgent.chat_stream("Hi") |> Enum.each(&IO.write/1) # after agent |> ExAgent.chat_stream("Hi") |> Enum.each(fn %ExAgent.Chunk{type: :text_delta, text: text} -> IO.write(text) _chunk -> :ok end) # ...or collect it into a single response {:ok, response} = agent |> ExAgent.chat_stream("Hi") |> ExAgent.collect()ExAgent.StreamErroris removed, and streaming never raises. A non-200 response, a transport failure, a busy agent, and an idle timeout all arrive as a terminal:donechunk carrying anExAgent.Error. Previouslychat_stream/3raised eagerly for a busy agent but lazily for an HTTP error, forcing consumers to wrap both the call site and the consumption site intry. Partial output already emitted stays valid.A stream idling for 5 minutes now reports a timeout instead of halting silently, which was indistinguishable from clean completion.
DeepSeek attachments. Previously
raisedArgumentErrorfrom inside the service (and the README claimed they were silently ignored - neither was right). Attaching a file to DeepSeek now returns{:error, %ExAgent.Error{type: :unsupported}}before the request is built.:pathattachments are read lazily.Message.new/1now records only the file's size; the bytes are read when the request is built. This keeps a large file out of conversation history, where it would otherwise be re-encoded on every turn. The behaviour change: a file deleted between attaching and sending now fails at send time rather than at attach time.Attachment element type.
Message.attachmentsnow holds%ExAgent.Attachment{}structs rather than bare maps. Since a struct is a map, code matching on%{data: data, mime_type: mime_type}or%{file_ref: %ExAgent.FileRef{}}keeps working; code usingMap.keys/1, exact-map patterns, orMap.get/3defaults on optional keys (a struct key is present-but-nil, so the default never applies) needs updating.Error shape. All providers, services and upload services now return
{:error, %ExAgent.Error{}}instead of{:error, {status, body}}. The original body is preserved in:raw, so the migration is mechanical:# before {:error, {status, body}} -> handle(status, body) # after {:error, %ExAgent.Error{status: status, raw: body}} -> handle(status, body)Replaced along with it:
{:error, {:unexpected_response, body}}and{:error, {:unexpected_parts, parts}}are now%ExAgent.Error{type: :server}with the offending payload in:raw;{:error, {:unsupported, :upload, module}}is now%ExAgent.Error{type: :unsupported}; the Gemini upload atoms:file_processing_timeoutand:file_processing_failedare now%ExAgent.Error{type: :timeout}and%ExAgent.Error{type: :server}; andExAgent.upload_file/4no longer leaks a bare posix atom for an unreadable path.ExAgent.Provider.stream/3raisesExAgent.Errorwithtype: :unsupportedinstead ofArgumentErrorwhen a provider does not implementstream/3.
v0.2.0 (2026-07-20)
Added
- Streaming.
ExAgent.chat_stream/3(agent-level) andExAgent.Provider.stream/3(provider-level) return a lazyStreamof text chunks. Tool-call turns are resolved non-streamed; only the final assistant turn is streamed. All three providers implement the optionalstream/3callback. RaisesExAgent.StreamErroron non-200 responses / when the agent is busy.
Changed
- Providers are now a behaviour instead of protocols. The
ExAgent.LlmProviderandExAgent.FileUploaderprotocols were removed and replaced by a singleExAgent.Providerbehaviour (chat/3required,upload/4optional). Custom providers now declare@behaviour ExAgent.Providerand implementchat/3(and optionallyupload/4) as public functions instead of usingdefimpl. - Non-blocking agent.
ExAgent.Agentnow runs the tool loop off the GenServer (via a supervised task), so an agent stays responsive to reads (get_context) and casts while a request is in flight. A concurrentchat/3on a busy agent now returns{:error, :busy}instead of serializing behind the mailbox.
v0.1.0 (2026-03-30)
First release!