All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased
Removed — BREAKING
Nous.Memory.Store.Muninn,Nous.Memory.Store.ZvecandNous.Memory.Store.Hybridare deleted. They called an API that no published version ofmuninnorzvechas ever exported, so they never worked and could not be fixed by pinning a version. Installing{:muninn, "~> 0.4"}— the requirement those modules' own docs gave — yields a top-levelMuninnwhose only export ishello/0; the real surface isMuninn.Index/IndexWriter/Searcher, and muninn 0.4.0 has no top-level module at all.zvec's surface isZvec.Collection, and its collection API is list-oriented where the calls assumed per-item. 28 undefined call sites across the three modules, none of which could ever have resolved at runtime.No working code can have depended on them: every entry point either failed to resolve or returned an error, and both stores were absent from the compiler and from the coverage denominator.
examples/memory/hybrid_full.exsis removed with them.If you want Tantivy BM25 or HNSW/IVF vector search, it now belongs in your application rather than in Nous — see the extension point below, which is where a backend with a heavy native dependency should have lived all along.
Added
Nous.Memory.Storeis a documented extension point, and a backend you write yourself is a first-class citizen. This was already true and never written down: nothing in the memory system knows a backend's name — the plugin, the tools andNous.Memory.Searchall dispatch on the module handed to them indeps: %{memory_config: %{store: MyApp.MyStore}}— andexamples/memory/postgresql_full.exshas been a working out-of-tree implementation (Postgrestsvector+pgvector) the whole time. Now supported deliberately:- The behaviour's moduledoc is an implementer's guide: the state contract,
which callbacks are required, that
search_vector/3is optional and feature-detected (function_exported?/3— a text-only backend omits it rather than defining it to return an error), and the warning that scores are compared across backends so a distance where a similarity is expected ranks results backwards while every callback still looks correct. Nous.Memory.Store.Resultsis now public (was@moduledoc false). It is the shared retrieval tail — hydrate hit ids from an entry table, scope filter,min_score, sort, truncate — i.e. the whole back half ofsearch_text/3for any index-plus-entry-table backend.Nous.Memory.Store.Conformanceships inlib/(moved fromtest/support/, renamed fromNous.MemoryStoreConformance), so an out-of-tree backend can hold itself to the same contract battery Nous runs against its own:use Nous.Memory.Store.Conformance, store: MyApp.MyStore. A new test drives a store defined entirely outside theNousnamespace through the plugin and the memory tools, and pins the feature detection in both directions.
- The behaviour's moduledoc is an implementer's guide: the state contract,
which callbacks are required, that
Fixed
Nous.Eval.Evaluators.FuzzyMatchscored everything wrong. Two off-by-one errors in the hand-rolled Levenshtein fold drove similarity negative; an exact match scored0.333and therefore failed the default0.8threshold. Rewritten as a two-row DP over graphemes (the old nestedEnum.atloop was also ~O(n³)), withcalculate_similarity/2clamped to the documented 0.0–1.0 range. ⚠️ This changes eval results — previously-failing fuzzy cases will start passing; review your thresholds.The memory backend table claimed capabilities the code does not have. Three of the surviving rows overstated, in the table an operator picks a backend from (
lib/nous/memory.ex,docs/guides/memory.md,README.md):Store.SQLite's vector search was documented assqlite-vec— that string appeared exactly once in all oflib/, in the table itself; it is an in-Elixir cosine scan over JSON-decoded blobs, and no extension is ever loaded.Store.DuckDB's was documented as VSS; it islist_cosine_similarityin SQL, also a scan.Store.DuckDB's text search was documented as the FTS extension;INSTALL fts/LOAD ftsare issued with the result discarded and no query references it, so it is anILIKEsubstring match. Net, now stated plainly: no shipped backend performs indexed (ANN) vector search — which is usually the right trade at the corpus sizes agent memory reaches, but is a scan.
Security
A tool timeout is no longer retried, so one approval no longer bought two executions.
Nous.ToolExecutor's internalexecute_with_timeoutkills the tool process on the deadline and then raisesNous.Errors.ToolTimeout, which the generic rescue clause routed intohandle_execution_error/7— the retry path. Withretriesdefaulting to 1, every timeout ran the tool a second time: measured on the realbashtool, a 120-second command asked for at the then-30-second deadline came back asattempt: 2after 60 seconds.bashisrequires_approval: trueand side-effecting, so a human who approved onegit push,rmor payment POST got two, the first killed part-way through. A timeout is now terminal on both timeout paths: the executor cannot know how much of the work already landed, and wrongly repeating side effects costs far more than the one visible error a caller can retry deliberately. Retries are untouched for ordinary failures. Making retry-on-timeout opt-in per tool was considered and rejected: nothing can make the second run safe, so there is no configuration worth offering. Note that the retry path never re-consulted the approval handler —check_approval/3runs once inexecute/3before the retry loop — so the second execution was also unprompted.Code Mode sub-calls no longer inherit the runner's approval gate.
Nous.Agent.Context.to_run_context/2marks a contextapproval_gated?: truebecause the runner already ran the approval pipeline for the call it is dispatching — correct forrun_codeitself, and wrong for every tool the program then calls. Passed through unchanged, one approval of "run this program" silently authorised everyBash,FileWriteandFileEditthe program reached: the handler was never consulted and the tool ran. Approving arun_codecall now approves running that program only. Each sub-call to a tool withrequires_approval: trueconsults the handler on its own, with the real tool name and the real arguments — which is what an operator needs, since a program computes its arguments at runtime and the approved program text does not show them. With no handler in the context such a tool is refused rather than run, matching the default-deny every other entry point already applies. Found by writing the integration test the plan asked for; the test that had asserted the old behaviour is corrected with a comment recording why.Nous.Plugins.HumanInTheLoopno longer auto-approves tools outside its:toolslist. The handler is only ever invoked for tools already flaggedrequires_approval: true, so filtering it by the configured:toolslist sent every other approval-gated tool down anelse -> :approvebranch. Configuring HITL withtools: ["send_email"]therefore flippedBash,FileWrite, andFileEditfrom default-deny to silent auto-approve — installing the approval plugin made an agent strictly less safe than omitting it, and left unattended command execution one prompt injection away. The handler is now passed through unchanged;:toolsstill tags those tools as approval-requiring, but can no longer narrow the gate.Approval enforcement is now structural rather than positional.
requires_approvalwas only checked insideNous.AgentRunner, so the three other paths to a tool —Nous.LLM's tool loop,Nous.Workflow:tool_step, and any directNous.ToolExecutor.execute/3call — executedBash/FileWrite/FileEditwith no approval, permission policy, or hooks. In the workflow case, model-authored:agent_stepoutput reached/bin/sh -cunattended.%Nous.RunContext{}gains:approval_handlerand:approval_gated?, andToolExecutor.execute/3now default-denies an approval-gated tool unless the context supplies a handler that approves it or is flagged as already gated. The agent runner marks its context gated, so operators are not prompted twice and its behaviour is unchanged.Nous.Tools.WebFetchbounds its responses. The model-supplied-URL egress point had no size or content-type limit and fed whole bodies to Floki. It now streams into a capped collector (default 5 MB, overridable viactx.deps[:web_fetch_max_bytes]orconfig :nous, :web_fetch_max_bytes; a model-suppliedmax_bytesargument may only lower the ceiling, never raise it) and rejects anything that is nottext/html,application/xhtml+xml, ortext/plain. A missingcontent-typefails closed. The module previously had zero tests; its redirect re-validation, metadata-IP blocking, redirect cap, and relative-Locationhandling are now covered.Dependency advisories cleared.
mix deps.update req finch mint hpax ecto hackneyresolves req 0.6.3, finch 0.23.0, mint 1.9.3, hpax 1.0.4, hackney 4.6.0, quic 1.7.1, ecto 3.14.1, decimal 3.1.1. This clears the two advisories reachable from production code — CVE-2026-49755 (Req decompression bomb, HIGH, reachable viaWebFetch) and CVE-2026-56810 / CVE-2026-58229 (Mint HTTP/1 memory exhaustion, HIGH, on every provider call) — plus the hackney and QUIC advisories. No dependency requirement inmix.exschanged. The only remaining advisories reach the build throughbypass(only: [:dev, :test]) and never ship to consumers.Nous.AgentServerno longer amplifies its own PubSub traffic.init/1subscribes the server to the topic it publishes on, andPhoenix.PubSub.broadcast/3does not exclude the sender — so thehandle_infoclauses that re-broadcast runner notifications received their own message and republished it, forever. Any app that actually setconfig :nous, pubsub:had one busy-looping process per agent (measured: ~1.2e8 reductions/s on an idle server) and unbounded duplicate events on every subscriber.Nous.Agent.Callbacks.execute/3already broadcasts every one of those events via the run context, so the five clauses (:agent_delta,:tool_call,:tool_result,:agent_complete,:agent_error) are now drains rather than publishers. No test configured a real PubSub with anAgentServer, which is why this never fired in CI; one does now.OS-level confinement for tool subprocesses:
Nous.Sandbox.Nous.Tools.Bashhanded the model/bin/sh -cas the OS user, withNous.Permissionsand approval as the only gate — nothing constrained what the shell touched once it was running, andNous.Tools.PathGuardfences only the file tools. There is now a provider behaviour that wraps argv so the kernel enforces a policy, withNous.Sandbox.Seatbelt(macOSsandbox-exec) andNous.Sandbox.Bwrap(Linux bubblewrap) in tree. Three modes —:read_only,:workspace_write,:danger_full_access— set per agent (Nous.new(..., sandbox: :workspace_write)) or per run (Nous.run(agent, prompt, sandbox: :read_only)), or globally withconfig :nous, :sandbox_mode.confine/2is a pure argv builder; enforcement is data, andNous.Sandbox.classify/3distinguishes "the OS denied a write" from "the sandbox runner broke and the command never ran" — the latter must never read as working confinement. With no usable provider the tool refuses to run rather than running unconfined. The default is still:danger_full_access, with a one-time warning. Fail-closed confinement is a behaviour change even though it is not an API change:Bashwould stop working on any host without bubblewrap installed. The default will flip in a later release; opt in now with one line of config. Command hooks are deliberately not confined (they are operator-authored, and a hook that cannot write is not a hook) — opt in withconfig :nous, :sandbox_confine_command_hooks, true.Nous.Tools.FileGrepis a documented exemption: neither provider restricts reads, so confining a process that only ever reads adds no enforcement.Nous.Tools.PathGuardno longer follows a symlink out of the workspace via...resolve_real/1began withPath.expand/1, which collapses..lexically before any symlink is resolved. Withlink -> /etcinside the workspace,validate("link/../passwd", ctx)expanded to<root>/passwd, passed the containment check, and was accepted — the resolver never saw the..it exists to catch.validate/2compounded it by handing the resolver the already-expanded path. The resolver now starts fromPath.absname/1(absolute,..intact) and applies./..to the already-resolved prefix, component by component, as the kernel does;validate/2passes it the uncollapsed path. The same traversal vialink/passwdwas already blocked, and benign in-workspace..still resolves.resolve_real/1is now public, shared withNous.Sandbox.writable_roots/1— canonicalisation is load-bearing there too, since an SBPL(subpath "/tmp")clause never matches a write the macOS kernel sees as/private/tmp/....Nous.Tools.Bashwas silently discarding every byte of stderr. It ran underNetRunner's defaultstderr: :consume, which reads stderr into an internal buffer with no accessor, sorun/2returned stdout only: compiler errors, stack traces and permission failures never reached the model, which saw an exit code with no explanation. Output is now merged viaNous.Sandbox.merge_stderr/1(/bin/sh -c 'exec "$@" 2>&1', argv passed positionally, so no quoting surface). Note thatNetRunner's documentedstderr: :redirectoption is not implemented in net_runner 1.0 and is worse than the default — it also disables the:consumedrain, leaving an unread stderr pipe that deadlocks a child which writes more than a pipe buffer.Nous.Tools.Bashnever actually scrubbed its environment. The tool passedenv: Nous.Tools.Env.scrubbed()toNetRunner, which has no:envoption: unknown options reach a port layer that ignores them and the shepherdexecvps, so the child inherited the BEAM's entire environment. For this tool's whole existence, one tool call —{"command": "printenv"}— returned every provider API key, OAuth token and vault credential in the VM, while the moduledoc claimed the opposite. Confinement could not have mitigated it: both sandbox providers are write fences and do not restrict reads or env. The environment now travels in argv, where it cannot be ignored:Nous.Tools.Env.with_scrubbed_env/1prefixes/usr/bin/env -iplus the allowlistedNAME=VALUEpairs (argv elements, so no shell parses them).Nous.Tools.Env.scrubbed_overrides/0fixes the sibling bug forSystem.cmd/3callers such asNous.Tools.FileGrep: Erlang's{env, _}merges rather than replaces, so listing the allowlist leftOPENAI_API_KEYin place — only{name, nil}removes a variable. Measured: the child's environment went from 73 names (including the secret) to 9.Nous.Tools.Bashrejects a NUL byte incommand. The port layer truncates argv at a NUL rather than rejecting it, and a NUL renders as nothing in an approval prompt, an audit log or a terminal. Sogit push origin main\0 --dry-runwas approved as a dry run and executed as a push — a bypass of the approval gate that AGENTS.md makes mandatory for this tool.Nous.Sandbox.Policyrejects a NUL inworkspace_rootfor the same reason (it previously failed closed only by luck, by truncating the SBPL profile mid-string).A real sandbox denial could be reported as a broken sandbox.
Nous.Sandbox.classify/3checks runner failure before denial, which is right, but macOS refuses a nested-sandbox escape withsandbox-exec: sandbox_apply: Operation not permitted— a line that satisfies the runner-failure signature and the denial signature. The escape was prevented, and the tool told the model "this is a broken sandbox … the command's effects did not happen and were not prevented". Both halves false. Three constraints now bound the classifier: exit 0 is always:ok(a denial fails the command, socatting a file that merely mentions a signature is no longer a denial — that was prompt-injectable); fatal signatures match only at the start of a trimmed line (a runner prefixes its own name; a mid-line mention is the command talking about the runner); and a line matching both kinds of signature is a denial.Nous.Tools.Bashalso now appends verdicts to output instead of replacing it with an error — the classified stream is the command's own output, so replacing it let a forged verdict launder real side effects out of the transcript.Nous.Tools.PathGuard.resolve_real/1refused legitimate deep paths. The hop budget was spent by ordinary directory components, so a symlink-free 34-deep path returned{:error, :symlink_loop}andvalidate/2reported a symlink loop that did not exist. It counts symlink hops now, the wayrealpath(3)counts them beforeELOOP; loop detection is unchanged. This mattered beyond the confusing error:Nous.Sandbox.Policy.canonical/1swallows the error and falls back to the lexicalPath.expand/1the resolver exists to avoid, so a deep workspace root silently produced a non-canonical SBPL(subpath …)that the kernel never matches — degrading:workspace_writeto:read_only.Sandbox hardening from the review pass.
Nous.Sandbox.Policyrefusesworkspace_root: "/", which re-allowed the entire filesystem under:workspace_writewhile every log line still said "confined" — reachable by accident, since the root defaults toFile.cwd!/0.Nous.Sandbox.Bwrapadds--unshare-pid:--procwithout it leaves the host PID namespace, so/proc/<other-pid>/root/…resolves in a namespace where/is read-write, which is a write escape. Its unprobed executable default is now the absolute/usr/bin/bwraprather than a bare name resolved through an inheritedPATHfull of user-writable directories.Nous.Sandbox.Seatbeltgrants/dev/stdout,/dev/stderr,/dev/ttyand/dev/fd— all denied before, socmd > /dev/stdoutandtee /dev/stderrfailed on macOS while succeeding under bwrap. Both providers'probe/1now assert that a write outside every root is actually refused, instead of only proving the profile parses, and both denial-signature lists cover EACCES as well as EPERM/EROFS. Confined command hooks no longer fail open: with stdout-only capture the classification branches were structurally dead, so any nonzero exit under confinement is now:denyregardless offail_closed— a security hook that never ran was silently permitting the event. A failed provider probe is no longer memoized (a 2s timeout on a busy host used to fail closed for the rest of the VM's life), andNous.Tools.Bash's cgroup path is flat because the shepherd'smkdiris not recursive, so the nested path it used could never be created and the cgroup containment was a silent no-op.A saved session silently rewrote every tool-calling assistant message. Found by the plan-03 regression gate before any refactor, in three layers that hid each other:
Nous.Message's changeset used Ecto's defaultempty_values: [""], socontent: ""was treated as absent and becamenil.Message.assistant/2builds its struct directly and kept"", whileMessage.new/1dropped it — so the same logical message differed by which constructor made it, andContext.deserialize/1goes throughnew!/1. Every save/restore therefore rewrote thecontent: ""that a pure tool-call turn carries intocontent: nil, and providers distinguish the two, so a resumed session sent a different request shape than the one that was saved. Underneath that,validate_content/1rejected empty content outright, so once the coercion was removed, deserializing any transcript containing a tool call failed instead of merely corrupting it. Empty content is now valid for:assistantin both its forms (""for OpenAI/Gemini,nilfor Anthropic), which is what a pure tool-call turn looks like and what streaming produces before the first delta. Underneath that, the Anthropic and Gemini response parsers manufactured""for content that was simply absent — masked until now by the very coercion above. They set the key only when it carries something, as the OpenAI parser already did, so "the model sent no content" isniland "the model sent an empty string" is"", and the two are no longer conflated.Compaction no longer destroys history.
Nous.Agent.Contextis now backed by an append-only event log (Nous.Session.Log) whose model-visible surface is a pure fold.ctx.messagesis materialized from that fold and kept in lockstep, so every existing reader — includingresult.messages,result.all_messagesandresult.new_messages— is byte-identical. The log is internal. What it buys immediately:Nous.Plugins.Summarizationappends a{:replace, start, stop}event instead of rewriting the message list, so a summary shadows the range it replaces and every original event stays in the log. Its in-place tool-result pruning became a replace too — previously the next append re-materialized and silently resurrected the oversized results, undoing the pruning it had just done. Six sites wrote%{ctx | messages: ...}directly, which is what made "model-visible implies logged" decorative; all six now go through the log (Plugins.MemoryandPlugins.KnowledgeBasecarry asourcemarker so injected context is distinguishable from conversation, andpatch_dangling_tool_calls/1's synthetic results are events).Context.serialize/1isversion: 2and persists events; a v1 blob still loads and seeds a log that folds back to its original messages. The plan's rule that an assistant event with empty content should be skipped in derivation was dropped: skipping it madeContext.last_message/1and output extraction disagree with the log, turning a run whose model replied with empty content — a content filter, amax_tokenscutoff, a provider hiccup — from{:ok, ""}into{:error, :no_output}. "Providers reject an empty assistant turn" is a fact about what a request may contain; the fold is history and filters nothing.The plugin system prompt no longer compounds across runs. The per-request system-prompt rewrite is assembly-time state, applied as an idempotent overlay during materialization rather than written over the message list. Continuing one context across three runs used to append the plugin fragment to the system message three times.
You can talk to an agent mid-run.
Nous.AgentServer.steer/2,inject/2andfollowup/2are new public API on top ofNous.Session.Inbox, which has two ordered queues and one primitive with three presets:followup= next turn and wake,steer= next step and wake,inject= next step and no wake. That last distinction is the point: injected context waits for the next admitted request rather than starting one, so you can enrich an idle agent without provoking it. A message sent mid-run is claimed by the next step, not the one already in flight.AgentServergained an explicitrun_stateso "is a run in flight" has one answer — it was previously spread across five handlers while anasync_nolinktask announces its end three different ways, which is too thin a basis for a wake decision. Cancellation behaviour is unchanged.Turns and steps are durable events. A step is one model request plus the tools it calls; a turn is zero or more steps. Both are logged, so a run is reconstructable after the fact: which turn a tool call belonged to, which step produced a request, where a crash landed. A zero-step turn is legal and is what a rejected input leaves behind.
pre_steprejection reuses the existing:pre_requesthook rather than adding a second mechanism, since a step is one request.A crashed run no longer loses or invents history.
Nous.Session.Recoveryrepairs an orphaned:turn_startby appending — never deleting or rewriting — synthetic risk-classified:tool_resultevents plus a:turn_endwith reason:interrupted, the one reason no live loop emits, so its presence is unambiguous evidence of a crash. Ambiguity always resolves to:tool_outcome_unknownrather than:tool_not_started: wrongly saying "may have run" costs a human one check, wrongly saying "did not run" is how a duplicate charge or a secondrm -rfhappens. Recovery is idempotent and leaves a clean log untouched.Nous.Session.fork/2copies an event prefix and records its parent, and refuses a boundary inside an open turn rather than clipping it.Every committed event is broadcast, so a LiveView can render from the log instead of from ad-hoc callbacks. Existing
Nous.PubSubtopics are reused; the publish is a no-op when no pubsub is configured, is driven by a count delta through the newLog.since/2(O(new), not O(log) — otherwise publishing would be quadratic over a session), and a broadcast failure cannot break an append.Nous.Session.Invariantchecks that every model-visible request is reconstructable from the log, including an orphaned-tool-result pass for the provider-400 class that an unbalanced compaction range can still produce. It warns and emits telemetry, never raises (config :nous, :session_invariantpromotes it to:strictfor our own suite, or:off), because a legacy append path stays alive for at least one release and taking down a production run over a bookkeeping discrepancy would be the wrong trade.
Performance
Oversized tool results can spill to a store instead of the context window. A multi-megabyte
grepresult cost roughly a million tokens of context and was almost never read in full. NewNous.Spillbehaviour with a filesystem backend (Nous.Spill.Local): results overmax_inline_bytes(default 64 KB) are written out and replaced with a head+tail preview plus an opaque locator and the backend's own retrieval hint.Nous.Tools.Bash's 1 MB truncation now keeps the bytes it captured instead of discarding them. Opt-in and best-effort by construction: with nodeps[:spill_config](orconfig :nous, :spill) behaviour is byte-for-byte unchanged, and a store error logs and keeps the result inline — spilling must never turn a successful tool call into a failure.file_readis excluded because spilling it creates a read→spill→read loop. Locators are opaque: callers render them withretrieval_hint/1rather than assuming a path a tool can open. Spilled files are0o600inside a0o700per-session directory, created exclusively so a planted symlink cannot redirect the write, and they persist until the operator deletes them — there is no reaper, by design.Compaction prunes before it pays for a summary.
Nous.Transcript.prune_tool_results/2replaces any tool result overmax_result_charswith head 4096 + a marker + tail 1024, with no LLM call at all.Nous.Plugins.Summarizationnow prunes first, re-measures, and skips the summarization request entirely when pressure has cleared — measured at a 90% estimated-token cut on a 50 KB tool result, which is the single largest saving in this release. Pruning only ever rewrites content in place, so it cannot reorder, drop, or split atool_call/tool_resultpair.One compaction path, not two.
Nous.Transcriptwas public, correct, and entirely dead — nothing inlib/called it — whileNous.Plugins.Summarizationcarried a second, independent implementation of the tool-pair boundary rule that all three providers 400 on.Summarizationis now the live entry point and callsTranscriptfor boundary balancing, pruning and estimation;balance_tool_call_boundary/2is public and is the only implementation left.Compaction is observable and crash-detectable.
[:nous, :compaction, :start | :stop | :exception]telemetry carries message counts, byte counts (pruning never changes the count, so counts alone make a prune-only compaction look like a no-op), whether the LLM was called, and the summarizationprovider,modeland usage — enough to reconstruct a compaction after the fact. The in-progress marker is cleared only after:stop, so a crash mid-compaction leaves a detectable orphaned:startrather than a false success.Summarization reuses the provider's KV prefix cache. It built a brand-new agent with different instructions and no tools, guaranteeing a cache miss on every compaction. It now replays the conversation's own system messages and tools verbatim, and keeps only the returned text — tool calls and reasoning are discarded, so a compaction can no longer produce an orphaned tool call, and a tool-call-only response is an error rather than an empty summary overwriting history.
Gemini/Vertex JSON-array streaming is no longer O(n²). The
:stream_parserbuffer was re-walked byte-by-byte from position 0 on every arriving chunk, so one large object spread across many chunks cost quadratic time.parse_buffer/2now accepts and returns a resumable{pos, depth, in_string}scan state that both stream backends thread through their buffer state;parse_buffer/1is unchanged for the SSE default and any third-party parser. Measured over the report's shape (one object, 1400-byte chunks): 3/14/59/243 ms at 60/120/240/480 KB becomes 0/0/1/8 ms — 27-30x at the larger sizes, and linear rather than quadratic. The median path (many small objects) is unchanged. Resume is byte-identical to a full rescan, pinned by a test that splits 14 adversarial inputs at every byte boundary, including a lone trailing backslash inside a string — the one case where a naive resume diverges.The Req stream backend now bounds buffered bytes, not message count. The guard capped the consumer mailbox at 1000 messages while never inspecting chunk size, so resident memory was roughly 1000 x chunk size. It now tracks bytes through a shared
:atomicscounter with an 8 MB high-water mark and parks the producer in areceiveinstead of polling. A/B measurement streaming 100 MB to a deliberately slow consumer: peak binary memory 23.9 MB bounded vs 108.5 MB unbounded, and the bounded peak is flat in stream size where the unbounded one grows linearly. This also removes a cross-processProcess.info/2call that ran on every chunk, and aProcess.sleep/1busy-wait.Decisions graph traversal is linear again. Both BFS frontiers in
Nous.Decisions.Store.ETSusedqueue ++ [node], which silently defeated the adjacency index built directly above them. Now:queue. Star graph: 20/74/284 ms at V=4000/8000/16000 becomes 4/10/19 ms (14.8x at V=16000), scaling ~2x per doubling instead of ~4x. Reachable set and emission order are unchanged.Knowledge-base link queries push filters into the match spec.
backlinks/2,outlinks/2,link_counts_by_source/1andrelated_entries/3eachtab2list'd the entire links table, andrelated_entries/3applied its limit only after fetching every neighbour. Over 20,300 links: 7.1x, 6.7x, 5.7x and 3.3x respectively.related_entries/3still returns up tolimitentries that actually exist — it fills lazily rather than truncating before dangling links are rejected, so the dangling-link behaviour is preserved.Default
count_tokens/1no longer inspects every message. It usedinspect |> String.length(measured ~13,000x slower than necessary) where the internal estimator already usedbyte_size. Both now agree.Teams.SharedStatereads run in the caller. The table was:private, forcing every read through the GenServer. It is now:protectedwithread_concurrency: true, andget_discoveries/1/get_claims/1select directly. Eight concurrent readers over 1,000 discoveries: 713 ms serialized vs 203 ms concurrent. Discoveries also now expire on the sameProcess.send_aftermechanism claims already used, via a new:discovery_ttloption (default 1 hour, accepts:infinity) — previously they accumulated for the lifetime of the process.AgentServer.save_context/1no longer blocks the agent process. Serialization and backend IO move to a task, mirroring:load_contextwhich was already offloaded. The call remains synchronous for the caller — the reply is sent after the backend write returns — so the "the save has landed when this returns" guarantee is unchanged; only the server stops blocking.Persistence.ETSis bounded rather than growing without eviction, and the global Finch pool is configurable instead of hard-capping the node at 10 connections per provider — which directly throttled the concurrencyparallel_tool_callsexists to enable.Missing
read_concurrency/write_concurrencyflags added to the ETS tables whose access pattern warrants them (not blanket-applied — the flags cost memory and hurt single-writer tables).
Added
Code Mode: the model can write a program that calls tools, instead of a chain of individual tool calls. One
run_codecall carries a generated typed SDK declaring every tool in scope; the program loops, branches and fans out in a single round trip, and only what it logs or returns re-enters the conversation. A 10-sub-call fan-out completes in 244ms where a serial chain of the same work needs 400ms plus ten model round trips.Nous.CodeRuntimeis the provider behaviour, andNous.CodeRuntime.JSis the shipped provider: an embedded V8 isolate (Deno via Rustler NIFs) behind the optional{:tyrex, "~> 0.4"}dependency, one fresh isolate per run so no state carries over. Budgets are provider configuration, never per request, so a program cannot negotiate its own deadline::timeout_msenforced by a BEAM timer that really terminates the isolate,:max_heap_mb, and a byte-accurate:max_output_bytesledger that keeps the fitting prefix.Isolation is stated exactly rather than marketed: it is in-process, so a V8 escape is an escape into the BEAM. What it does enforce is no filesystem, network, env or subprocess access, and no route into Elixir except the tools you granted — the runtime's arbitrary-module bridge is narrowed to one function and then removed from the isolate before any model-authored code runs. There is deliberately no instruction budget, because this substrate has no fuel metering; the wall-clock kill is the only bound on a compute-bound program and it is a real one.
mode: :bothis the default and degrades to:nativewhen no runtime is configured, rather than advertising arun_codethat can only fail. It is not an unconditional token saving — the SDK is a prompt prefix that can rival the native schemas it replaces — sodocs/guides/code_mode.mdsays to measure your own workload instead of implying a win.Nous.Usage.cost/2andNous.Usage.Pricing.%Usage{}counted tokens and priced nothing, so no caller could answer what a run cost. Prices are per 1M tokens with separate input, output, cache-read and cache-write rates, keyed by{provider, model}, with a longest-family-prefix fallback on a-boundary sogpt-4o-2026-05-13findsgpt-4owhile an unreleased generation stays:unknownrather than inheriting a stale rate. Unknown models return{:error, :unknown_model}— never a guess. Local providers (ollama, lmstudio, vllm, sglang, llamacpp) are explicitly zero. Override or extend the table withconfig :nous, :model_prices. Cost is derived, not stored: nocostfield on%Usage{}, because a price table changes independently of the run and a stale number persisted in the struct would be worse than no number. Prices are a snapshot recorded 2026-08-14 and will go stale; the override config is the fix.
Changed
LM Studio's default
receive_timeoutis 5 minutes, up from 2. LM Studio JIT-loads a model on the first request that names it, so "slow first token on cold weights" — the reason:llamacppalready had 5 minutes — is the default behaviour there, not an edge case: loading an 18GB 27B took 21s before a single token appeared. Generation is slow too; one tool-calling step with three tools measured 36.7s for 515 completion tokens, and a loop's later steps carry bigger contexts than its first. At 2 minutes that surfaced mid-run as a bare%Req.TransportError{reason: :timeout}, which reads like a broken server rather than a budget the caller can raise.:vllmand:sglangare the same class of host and were left alone because they were not measured. Override per model withreceive_timeout:as before.llama_cpp_exupdated to0.8.44(from0.8.22) and verified against real GGUF models: the four functions this library calls —init/0,load_model/2,chat_completion/3,stream_chat_completion/3— are unchanged, and the tagged--only llamasuite passes on two different local models, covering chat, streaming,enable_thinking: false, grammar-constrained JSON and embeddings. Tool calling is still absent upstream, so the provider's "not supported by this backend" behaviour is unchanged.req,ectoandelixir_makewere deliberately not moved with it:mix deps.update llama_cpp_expulls them opportunistically, none is required by 0.8.44, and req is the default HTTP backend for every provider.Nous.HTTP.Bufferextracted. Both stream backends reached up intoNous.Providers.HTTPfor buffer helpers, making the transport layer depend on the provider layer — the one genuine (non-benign) runtime cycle in the graph. The helpers now live inNous.HTTP.Buffer;Nous.Providers.HTTPkeeps delegating wrappers, so nothing external breaks. Runtime cycles drop from 7 to 6; compile-time cycles remain 0.Nous.AgentRunner's 199-line orchestration loop moved out of the facade into a new internal Nous.AgentRunner.IterationLoop, alongside the four submodules added in 0.17.0. Pure move: the public API and every telemetry event are unchanged.AGENTS.md's "What NOT to use" list corrected. It declared several modules private that are in fact documented plug-in points —Nous.HTTP.Backend.*andNous.HTTP.StreamBackend.*are behaviours with a published guide,Nous.Providers.HTTPis injected into every provider byuse Nous.Provider,Nous.AgentRunnerholds the canonical option docs thatNous.Agentpoints at, andNous.AgentServeris used throughout the LiveView guide. Those are now documented as public. OnlyNous.Workflow.Engine.{Executor,ParallelExecutor,StateMerger}were genuinely internal; they gain@moduledoc falseand leave the docs groups.Nous.Plugins.KnowledgeBasehonours a caller-supplied:store_state.init/2calledstore_mod.init/1unconditionally, discarding any store passed in config, so an agent configured against a pre-populated knowledge base searched an empty one. It now mirrors the:store_statereuse guardNous.Plugins.Memoryhas always had. Behaviour change: akb_config[:store_state]that used to be ignored is now used.Nous.Utilis@moduledoc false. The module described itself as "internal" while carrying a visible@moduledoc, which under this project's own mechanical rule (@moduledoc false== private, everything else is semver-covered API) made it public. It is now hidden, matching both its own description andAGENTS.md. Its doctests still run.Nous.Hook's@type eventunion was incomplete. It omitted:workflow_start,:workflow_end,:pre_nodeand:post_node, all four of whichNous.Workflow.Enginedispatches. Type-only change.Nous.AgentRegistry.via_tuple/1andlookup/1accept any registry key. The specs saidString.t(), butNous.Teams.Coordinatorhas always registered members under a{:team, team_id, member}tuple. Spec-only change, now expressed asNous.AgentRegistry.key/0.
Tests
Provider request shaping is now asserted.
Nous.Providers.Geminisat at 4.35% coverage andAnthropicat 4.76% — message translation was well covered, but nothing checked the URL, auth headers, or body of an outgoing request. That is exactly how the malformed Gemini tool payload above shipped green. Newgemini_test.exs,anthropic_test.exsandopenai_test.exsdecode the real request inside a Bypass plug and assert path, method, auth header, system-prompt placement, and tool schema per dialect. The Gemini file explicitly refutes the OpenAI"type"/"function"envelope keys insidefunctionDeclarations, so that specific regression cannot recur. Coverage: Gemini 4.35% → 91.30%, Anthropic 4.76% → 85.71%.Write-tool sandbox escapes are now tested.
FileReadhad an escape test;FileWriteandFileEditdid not, so deleting theirPathGuard.validate/2call would not have failed anything — and a write escape is strictly worse than a read escape. Both now have absolute-path and../../traversal tests that also assert the target file was not created or modified, and the realNous.Tools.Bashis tested for approval refusal via a filesystem side effect that must not happen. Each new protection test was verified to fail under a targeted mutation of thelib/line it defends.The 17
AgentServercancellation tests now run in CI. They were@moduletag :llm-excluded, so the only cancellation coverage was a trivial{:ok, :no_execution}assertion — cancel-while-running, double-cancel, cancel-then-restart and multi-agent isolation were all unverified. They now use stub dispatchers that signal readiness, so cancellation is triggered at a provably-parked point instead of after aProcess.sleep. Whole suite: 0.1s.Tests no longer reach the public internet. Several tests issued live requests to
api.openai.comandaiplatform.googleapis.comand passed only because they asserted on the resulting error — slow, broken offline, and ifOPENAI_API_KEYwere ever set in CI they would have made real billed calls with different behaviour. The two Vertex region tests additionally never checked the thing they were named for; they now assert the resolved URL directly. Full-suite wall time dropped from ~9s to ~6.4s.A process-scoped dispatcher seam (
Nous.ModelDispatcher.put_dispatcher/1, resolved through$callers) lets tests inject a stub without mutating application environment. Precedence is explicit option → process override → app env → default, pinned by a test. 12 files moved fromasync: falsetoasync: true(39 → 30 sync). Files drivingNous.AgentServerstay sync and say why:$callersdoes not crossGenServer.start_link.Nous.ReActAgent's own tools declared no parameters, so the agent could not work. All six —plan,note,add_todo,complete_todo,list_todos,final_answer— were built withTool.from_function/2passing onlyname:anddescription:, so the schema fell back to an empty object. Measured: every one reached the model withproperties: []andrequired: [], while their descriptions promised parameters in prose ("Parameter: answer (your complete solution)").A model that honours the schema therefore called them with
{}.final_answerreturned the literal string"No answer provided", andnote/final_answer— which pattern-match on%{"content" => _}and%{"answer" => _}— raisedFunctionClauseErrorinstead. The loop retried calls that could never succeed until it ran out of iterations, which is what made ReAct look like a model-capability problem. All six now carry real schemas, and those two functions answer a schema-violating call with a sentence naming the missing parameter rather than raising, because models do ignore schemas and a crash teaches them nothing.The ReAct prompt contained an obligation that could never be discharged. "Complete all pending todos before calling
final_answer" makes everyadd_todocreate a new prerequisite for finishing, so a task whose deliverable is a todo list can never be answered — measured as{:error, %MaxIterationsExceeded{}}on "make a todo list for learning Elixir, then answer with the list", having done the work and never being allowed to report it. Completing todos is now advised rather than required, and the prompt states plainly that an answer with pending todos beats running out of steps.Nous.ReActAgentdefaults to 25 iterations rather than the generic 10. Its mandated workflow is plan (1) + oneadd_todoper step +noteobservations + onecomplete_todoeach +final_answer(1), so a four-step task needs 11 iterations before it is permitted to answer. The agent could not follow its own instructions inside the default budget.Together these take the
:evalReAct suite from 1 of 11 to 11 of 11 on two different local models — a 4B in 155s and a 27B in 793s — where before the fixes the suite spent 25 minutes mostly timing out.7.1answers"8"to "What is 5 plus 3?" instead of"No answer provided".Nous.Transcript.estimate_messages_tokens/1was blind to tool-call arguments, so no token budget could see a tool-calling transcript. It summedMessage.extract_text/1, which returns content only. Measured exactly: a 102,000-byte payload counted as 25,500 tokens when carried as message content and as 0 tokens when carried as tool-call arguments — the same payload serialises to 102,137 bytes on the wire either way. Arguments are now counted as encoded JSON, which brings the estimate to 25,503 against that 25,534-token wire size.This is the root cause behind the ReAct blow-ups below, and it silently weakened every consumer of the estimate: compaction thresholds, spill decisions and
should_compact?/2. It bit hardest on the agents that need a budget most, because a tool-using agent keeps its payload inargumentsby definition.Nous.ReActAgentenables context management by default. ReAct's defining feature is looping, which makes it the one agent shape that must not be handed an unbounded transcript.Nous.Plugins.Summarizationis now on by default withmax_context_tokens: 30_000, keep_recent: 8; passing your ownplugins:orsummarization_configreplaces it entirely.Enabling it costs nothing on the common path — the plugin prunes oversized tool results for free and only pays for a summarization if still over budget. On one "plan the area of a rectangle" task against a local model, measured end to end:
peak request outcome before 170,732 tokens refused by the server (32k window) after 775s trigger fixed 46,809 tokens still refused, 203s + estimator fixed 4,866 tokens completed, 3 iterations, 19.8s The
:evalReAct suite went from ~1 to 7 of 11 passing on a 4B local model, with zero context-size rejections. The remainder is throughput, not capability or context: the same test measured 27s and >180s minutes apart because the model loops a variable number of times, and a larger model is worse rather than better — a 27B Q8 generates at ~13 tokens/sec, so one ReAct-shaped request took 72.7s and a request near the 30,000-token ceiling exceeded even a 5-minute per-request budget. 648 of its 956 completion tokens were reasoning, andenable_thinking: falsewas ignored by that model, so two thirds of the generation is invisible overhead for an agent already being told to reason.Race-hiding sleeps replaced with real synchronisation, wall-clock concurrency assertions replaced with a structural in-flight counter asserting the maximum is exactly the expected concurrency (a
<=bound also passes for a fully sequential implementation), and several tests that could not fail for their stated reason were fixed or deleted.Nous.Messagesdoctests re-enabled (7 → 23 doctests total). Dead:moxdependency removed;bypassnarrowed toonly: :testso a Cowboy server is no longer on the:devcode path.CI now enforces test coverage. Total went 56.80% → ~60%, and the gate is a ratchet at 59 rather than an aspiration — the 90% threshold configured in
mix.exswas never run by any job, and was additionally mis-nested::thresholdmust sit under:summaryor Mix silently keeps its default.Credo thresholds ratcheted to the tightest values the codebase passes today (
max_complexity24 → 23,max_arity15 → 14) so they can only move down.max_nestingwas already at its floor.
Fixed
Nous.Plugins.Summarizationnever bounded the context window. Its trigger readctx.usage.total_tokens— the cumulative bill for the run, every input and output token of every request summed — instead of the size of the transcript about to be sent. That measured the wrong thing in both directions: a long conversation of small requests crossed the threshold while its context was still tiny and then compacted on every subsequent request forever, because a bill never decreases; while a run whose context genuinely exploded was not compacted at all. Reproduced with no LLM involved: a transcript of ~300,000 estimated tokens configured withmax_context_tokens: 5_000came back byte-identical, 13 messages in and 13 out, because nothing had been billed yet. The trigger is nowNous.Transcript.estimate_messages_tokens/1overctx.messages, so the threshold means what its name says and matches every other token budget here.Found by driving
Nous.ReActAgentagainst a local model: with no context management it grew one task to a 170,732-token request against a 32,000-token window before the server refused it with a 400, and the earlier symptom was a stream of%Req.TransportError{reason: :timeout}as each request got slower. Enabling the plugin now cuts the peak on that task to 46,809 tokens. It still does not fit the window::keep_recentmessages are exempt from pruning, so a few large recent tool results can exceed any budget by themselves, andNous.ReActAgentships with no context management of its own — a task it cannot converge on will still outgrow the context.Every existing test in this plugin's suite triggered compaction by supplying a large fake
usage.total_tokenson a small transcript, which is why the defect survived; a test now drives it from transcript size withusageat zero.A tool could not declare its own deadline, so
Nous.Tools.Bashwas killed at 30s while documenting and granting 120s.%Nous.Tool{}has always had a:timeout, but thetool/3macro inNous.Tool.Schemaaccepted no such option andNous.Tool.from_module/2hardcoded the 30-second struct default, so a schema-defined tool's own budget could never reach the executor. Measured:Nous.Tool.from_module(Nous.Tools.Bash).timeoutwas30_000while the tool passes120_000toNetRunnerand exposes atimeoutparameter a model can set to120_000— a legitimate 45-second command died at 30 seconds, twice (see Security, above), and the documented 2-minute default was unreachable.tool/3now takes:timeout, carried throughmetadata/0intofrom_module/2on exactly the pathrequires_approvalalready uses, with an explicitfrom_module(mod, timeout: …)still winning.Nous.Tools.Bashdeclares a deadline five seconds above the command budget it grants, so its own timeout fires first and reports "Command timed out after 120000ms" instead of an opaque outer kill; atimeoutargument may only lower that budget, never raise it past the deadline. The other schema-defined built-ins keep the 30-second default, which their work cannot plausibly exceed.Structured output silently did nothing on Gemini and Vertex AI.
Nous.OutputSchema.to_provider_settings/2emits the OpenAI-nestedresponse_format: %{"type" => "json_schema", "json_schema" => %{"schema" => …}}andresolve_mode(:auto, :gemini)is:json_schema, butNous.Messages.Geminionly matched the flat%{"type" => …, "schema" => …}shape. Everyoutput_type:agent ongemini:/vertex_ai:therefore sent noresponseMimeTypeand noresponseSchemaat all, and relied entirely on the model guessing JSON. Both shapes are now accepted.Every
Nous.Eval.Optimizerobjective except:scoreand:pass_rateraised.extract_metric/2andextract_all_metrics/1usedget_in(suite_result, [:metrics_summary, :latency, :p50]);%SuiteResult{}and%Metrics.Summary{}are plain structs with noAccessimplementation, so that raisedUndefinedFunctionErrorrather than returning nil — and the nested:latency/:tokens/:costkeys never existed on the summary anyway. They now read the real summary fields, and a suite with no metrics summary (every case errored) yields0.0instead of crashing the search.Nous.Agent.Contextdropped the prompt-cache token counters.add_usage/2's map branch,serialize_usage/1anddeserialize_usage/1all omittedcache_creation_input_tokensandcache_read_input_tokens, so persisting and resuming a context zeroed them and any cache-aware cost calculation under-reported after a restore.Nous.Agent.Behaviour.call/4skipped optional callbacks on unloaded modules. It used a barefunction_exported?/3, which answers false for a module that has not been loaded yet — routine under interactive code loading. A behaviour module that really did implementinit_context/2orafter_tool/4silently got the default instead. Now guarded withCode.ensure_loaded?/1.Nous.Memory.Store.Hybridraised instead of erroring when its optional deps are absent. The deps-unavailable branch definedinit/1andsearch/3but notsearch_vector/3, so the friendly{:error, _}path was anUndefinedFunctionError.Nous.Workflow.run/3's@specomitted the{:suspended, state, info}return the engine can produce, and its@docomitted the:hooks,:trace,:scratch,:pause_refand:on_node_completeoptions it forwards.Gemini and Vertex AI tool calls from the agent path shipped a malformed payload.
Nous.AgentRunnerfell through to the OpenAI tool schema for:gemini/:vertex_ai, so the request carried[%{"functionDeclarations" => [%{"type" => "function", "function" => …}]}]— an OpenAI envelope nested inside Gemini'sfunctionDeclarations, which expects the bare declaration.Nous.LLMhad a second, correct copy of the same conversion, which is why one-shot calls worked while agent runs did not. The duplicate is deleted and both paths now shareRequestDispatch.convert_tools_for_provider/2, using the Gemini shape. Any agent using tools with Gemini or Vertex was affected.Nous.LLM.generate_text/3no longer returns""for multimodal replies. Its privateextract_text/2copy returned""for any non-binary content; it now usesNous.Message.extract_text/1, which walks list content.A hung tool can no longer wedge an entire agent run. The parallel tool-call path passed
timeout: :infinitywith noon_timeouttoTask.Supervisor.async_stream_nolink/4, relying onToolExecutorto enforce per-tool timeouts — but that timer is only armed whentool.timeoutis a positive integer, andnilis permitted. The stream now uses a finite ceiling derived from the batch (each tool's own timeout times its retry budget, plus headroom; five minutes when a tool declares none) withon_timeout: :kill_task. A timed-out call returns a per-call tool error and its siblings keep their real results.Nous.Message.ContentPartaccepts whitespace-only text under Ecto 3.14. Ecto 3.14 moved trimming out of:empty_valuesinto a separate:trim_valuesoption defaulting to true, so theempty_values: [""]override stopped protecting the Gemini/Vertex"\n\n\n"case. Empty-content rejection is now an explicit check invalidate_content/1, giving identical behaviour across Ecto 3.11-3.14.Transport errors are logged again under Req 0.6. The error clause in
Nous.HTTP.Backend.Reqmatched only%Mint.TransportError{}; Req 0.6 surfaces%Req.TransportError{}, so the clause went dead and transport failures fell through to the generic handler. Both structs are handled.
Documentation
A full pass over docs/, examples/, README.md, AGENTS.md and
CONTRIBUTING.md. The rot was semantic, not structural: mix docs built with
zero warnings the whole time, because nothing in CI read examples/ or the
code fences in the guides.
New: a regression guard.
test/docs/api_reference_test.exsparses everyexamples/**/*.exsand every Elixir code fence in the docs, resolves eachNous.*remote call and struct literal against the loaded beam (alias-aware, includingalias Nous.{A, B}, pipes, captures and default arities), and fails on an unknown module, function, arity or struct field. It also asserts every fence parses — deliberate fragments are allowlisted by{file, line, reason}intest/docs/fixtures/doc_snippet_allowlist.exs, and an allowlist entry that has started parsing fails too — and that every relative markdown link and#anchorresolves. AdocsCI job runsmix docs --warnings-as-errors.14 broken examples fixed — calls to functions that do not exist (
AgentServer.subscribe/1), fields that do not exist (usage.iterations,SuiteResult.test_results,Result.test_case.id), an unsupported"provider:model@base_url"model string, an EEx blockPromptTemplatedeliberately rejects, two LiveView scripts that could not compile, and four memory examples that raisedMatchErrorinstead of naming the optional dep to uncomment.12_pubsub_agent.exsran on a hand-rolled stub that delivered nothing and cost four 30-second timeouts; it now runs on a realPhoenix.PubSub.15 misleading examples corrected — most notably the streaming and callback examples, which registered
on_llm_new_deltawithoutstream: trueand therefore never streamed, and every bare anonymous function passed as a tool (which the model sees under a compiler-mangled name with an empty parameter schema).Wrong facts corrected across the guides — README receive-timeouts (60s/ 120s claimed; 180s cloud, 120s local, 300s llamacpp actual), the vLLM base_url contract, the
nous:-prefixed AgentServer topic,generate/2vs a nonexistentgenerate_output/2, and several snippets that were outright syntax errors — including the custom-memory-store template, whose five callbacks each had a comment where their body should be.Features that shipped undocumented are now documented —
parallel_tool_calls,Nous.Hook'sfail_closed, InputGuard'sfail_closed/strategy_timeout, the twelve Gemini/Vertex model settings from 0.16.0, and theNous.Usageprompt-cache token fields.docs/guides/migration_guide.mdwas a rewrite: it described 0.1.x–0.4.x of a different library and was ~40% Kubernetes boilerplate.New:
docs/guides/transcript.md, three Livebook notebooks undernotebooks/(linked from the README with "Run in Livebook" badges and published to hexdocs), and five examples —advanced/distributed_agents.exs(agents across two nodes, one killed mid-run, supervisor restart, persisted context recovered),20_sql_generation.exs,advanced/cost_aware_routing.exs,advanced/rag_documents.exsandadvanced/streaming_backpressure.exs. All five run offline and exit 0 with no API key.Doctests went from 3 wired modules to 32. 210 doctests now execute; they previously read well and ran never. Several were pseudo-code that could not evaluate (whole-struct literals compared against a
created_atstamped at build time,[...]placeholders,File.readof a path that does not exist) and were rewritten to actually run.
Removed
:inetsdropped fromextra_applications.:httpcwas replaced by Req; the entry only forced inets to boot in every downstream release.
0.17.0 - 2026-07-18
Added
Opt-in parallel tool-call execution —
parallel_tool_calls: trueonNous.Agent.new/2(defaultfalse). When a model response contains multiple tool calls, approved executions fan out underNous.TaskSupervisorwhile everything order-sensitive stays sequential in call order:pre_tool_usehooks and approval checks run before the fan-out, andpost_tool_usehooks,on_tool_responsecallbacks, behaviour:after_tool, andContext.merge_depsapply after it, in original call order. Result messages keep call order (providers require it). Per-tool timeouts remainToolExecutor's job (no second outer timeout); a crashed task surfaces as a per-call tool error instead of sinking the turn. Off by default because tools may rely on sequential external side effects within one turn — note that tools already cannot observe each other's context updates within a turn (the run context is snapshotted before the tool loop).LM Studio live smoke suite (
test/nous/lmstudio_smoke_test.exs,:llm-tagged, excluded by default) — one live test per runner path: plain run, sequential tool loop,parallel_tool_calls, and the publicrun_stream/3(previously uncovered by any live test). Model-agnostic assertions safe for thinking models; verified against LM Studio.
Changed
Nous.AgentRunnersplit into a facade + four submodules. The 2,188-line / 97-function module is now a 926-line facade delegating to internal (@moduledoc false) submodules underNous.AgentRunner:PromptAssembly(prompt/settings assembly),Streaming(stream wrapping/consumption),RequestDispatch(fallback chains, rate limiting, provider settings), andToolExecution(sequential/parallel tool execution, hooks, approval/policy enforcement). Move-only: the public API (run/2,3,run_with_context/2,3,run_stream/2,3) and all telemetry events are unchanged.Internal dedup/refactor sweeps (#66, #67): repeated logic across providers, tools, and errors single-sourced; struct references adopt
alias __MODULE__. No behavior change.
Fixed
run_stream/3no longer emits a duplicate empty{:complete, _}event. OpenAI-compatible streams yield two{:finish, _}events (thefinish_reasonchunk plus the end-of-stream marker) and the result wrapper emitted a{:complete, _}for each — the second with empty output. Consumers now get exactly one, carrying the accumulated output.Nous.Message.extract_text/1no longer crashes oncontent: nil. Thinking models truncated mid-reasoning return assistant messages with onlyreasoning_contentset; extraction now returns""instead of raisingFunctionClauseErrorand failing the whole run.Optional-dep compile warnings in consumer builds silenced.
Nous.Tools.SearchScrapeis now gated on Floki (likeWebFetch), and:hackney/:hackney_poolare declaredno_warn_undefined— apps that depend on nous without the optionalfloki/hackneypackages compile without warnings.Audit follow-ups (#68): atom leaks, secret redaction, and O(n²) knowledge-base stats.
0.16.6 - 2026-06-27
Changed
- Agent-runtime hot-path hardening (behavior-preserving) (#62). Eliminates
confirmed super-linear and serialization hot paths in the agent runtime,
measured with Benchee first; all changes preserve observable behavior. Core
loop: tool-schema conversion is memoized once per run via a runtime-only
Context.tool_schema_cacheand stripped fromContext.serialize/1. Persistence/OTP:agent_servercontext saves on the response/clear_historypaths are now fire-and-forget viaTask.Supervisor(off the GenServer mailbox);Teams.RateLimiteruses running-window counters sorate_limited?/2is O(1);Teams.SharedStateuses ETS row-per-entry for discoveries/claims. Context updates replace O(n²)++ [item]appends with prepend + per-key reverse. Memory/search: scope/kb_id/type filters are pushed into ETS via matchspecs, search is single-pass, and the SQLite cosine L2 norm is hoisted out of the loop.
Documentation
- Documentation overhaul (#63). ExDoc structure reorganized after months of feature growth: 67 previously-orphaned modules are now grouped, with new module groups (Multi-Agent/Teams, Decision Graph, Messages & Streaming, HTTP Backends, Structured Output, Utility Tools, Mix Tasks), a completed Providers group, and an expanded Evaluation subtree; 0 broken-link warnings. Seven new source-grounded subsystem guides (teams, decisions, research, fallback, permissions, observability, providers). README/getting-started/indexes updated and stale doc indexes regenerated. Numerous broken examples fixed and four new advanced examples added (teams, decisions, deep_research, fallback).
0.16.5 - 2026-06-12
Security
- Permission-policy approval gate was bypassed when a
pre_tool_usehook modified arguments. InAgentRunner, the{:modify, …}hook branch ran the tool throughcheck_tool_approval/3without first applyingenforce_policy_approval/2(unlike the normal path). A tool gated only by the permission policy (:strictmode, anapproval_requiredentry, or the execute-category gate) — not by its ownrequires_approvalflag — therefore executed UNGATED whenever anypre_tool_usehook rewrote its arguments. The modify branch now applies policy approval identically to the allow branch. - InputGuard now fails closed on dropped strategies. Under the default
aggregation: :any, a strategy that errored or timed out was silently dropped; if it was the only real detector, flagged input passed as:safe. Dropped strategies now upgrade an otherwise-:safeverdict to:suspicious(configurable viafail_closed, defaulttruefor:any,falsefor:majority/:allwhich already count drops against the configured denominator). Drops emit a[:nous, :input_guard, :strategy_dropped]telemetry event + aLoggerwarning. New:strategy_timeoutoption (default 30s) bounds the parallel path. Behavior change: an:anyguard with a flaky strategy may now warn/block where it previously passed — setfail_closed: falseto restore the old behavior. :permissivepolicy no longer auto-approves execute-class tools.Nous.Permissions.requires_approval?/3(category-aware) keeps the approval gate oncategory: :executetools (e.g.bash) even under:permissive, unless the policy setsallow_unattended_execute: true. Built-inbashwas already self-gated via its ownrequires_approval: true; this closes the gap for custom execute-class tools that relied on the policy. Behavior change:build_policy(mode: :permissive)users who want unattended shell execution must now passallow_unattended_execute: true.
0.16.4 - 2026-06-05
Changed
- Audit-pass follow-up: security/OTP/test hardening (#60). Security
hardening: PathGuard canonical-path resolution,
web_fetchfail-closed, atom-exhaustion DoS guard, ReDoS cap, additional UrlGuard ranges. Correctness:get_tool_fieldfetch, rate-limit TOCTOU fix, async-load reply-on-crash,async_nolinkabsorb, O(1) claims, iodata flat accumulation. Documents the intentional run-scoped ETS ownership model (KnowledgeBase/Decisions stores) and a safer slug-index write order, makes the rate-limiter fail-open observable (log + telemetry), and improves test quality (deterministicrefute_receive,start_supervised!, unique telemetry handler IDs, encoded-IP SSRF cases).
0.16.3 - 2026-05-29
Security
- RCE approval gate could be silently bypassed.
Nous.Tool.from_module/2hardcodedrequires_approval: falseinstead of reading it from the tool's metadata, soBash/FileWriteregistered via the standard path ran without the human-approval gate — one prompt-injected document from RCE. It now falls back to metadata like name/description/parameters. (Also fixed:Nous.Tool.Behaviour.implements?/1ensures the module is loaded before checking, andNous.Agent.new/2accepts bare behaviour modules in:tools.) - FileGrep ripgrep flag injection. LLM-controlled
pattern/globreachedrgwith no--option terminator, so values like-f/etc/passwdor--pre=…read files (or ran a preprocessor) outside the workspace. Pattern is now passed via--regexp, glob via--glob, with--before the positional path; the pure-Elixir fallback re-validates every matched file (mirrorsFileGlob). - PathGuard intermediate-directory symlink escape. Only the final path
component was
lstat'd, so a directory symlink (link -> /etc, accessed aslink/passwd) escaped the workspace jail.Nous.Tools.PathGuardnow resolves symlinks across every existing component (realpath) and compares the canonical path against the canonical root (also robust to symlinked roots like macOS/tmp). - SSRF hardening in
Nous.Tools.UrlGuard. Now blocks IPv4-mapped IPv6 (::ffff:169.254.169.254), NAT64 (64:ff9b::/96), link-localfe80::/10, and::, and resolves both A and AAAA records (dual-stack bypass). Newvalidate_pinned/2returns a validated IP;Nous.Tools.WebFetchpins the connection to it (preserving Host header, SNI, and cert verification) to close the DNS-rebinding TOCTOU. The Req provider backends passredirect: false. - Permission policy is now actually enforced.
Nous.Permissions.Policy(via a new:permissionsoption onNous.Agent.new/2) filters blocked tools out of the tool list the model sees and forces the approval gate for approval-required tools — previously the engine was never consulted at runtime.blocked?/2now honors allow lists in every mode (deny-by-default),build_policy/1rejects unknown modes, and both predicates fail closed on an unknown mode. - InputGuard no longer bypassed for streaming.
Nous.AgentRunner.run_stream/3runs the plugin pipeline and short-circuits to a terminal blocked stream before any LLM call. The LLMJudge strategy fences untrusted input in a random boundary, parses only the firstVERDICTline, and can fail closed on an unparseable response; the Pattern strategy NFKC-normalizes and strips zero-width/bidi characters;:majority/:allaggregation counts the configured strategies so killing one can't flip the vote. - Secret & data-exposure hygiene. Credential-shaped
depskeys (api_key/token/secret/…) are no longer written by persistence; Gemini sends its key via thex-goog-api-keyheader instead of the URL query string; the default telemetry handler logs a bounded status+body summary instead of the raw upstream error term; the persistence and workflow-checkpoint ETS tables are:protected(owner writes, any process reads) instead of:public.
Fixed (critical)
- Anthropic responses with 2+ text or thinking blocks crashed the turn.
consolidate_content_parts/1returned a list into the:stringcontent field, raisingEcto.InvalidChangesetErroron common multi-block responses (text around a tool_use, multi-paragraph answers). Now joins homogeneous blocks into a string (mirrors the Gemini path). - AgentServer added the user message to the context twice per turn. The
message was added in
handle_castand again byAgentRunner.build_context, doubling the prompt sent to the model and corrupting saved history. Now added exactly once. Nous.LLMstreaming-with-tools never reassembled tool-call fragments. It treated each{:tool_call_delta, _}as complete — crashing for OpenAI (Access on a list) and invoking tools with nil args for Anthropic. Now feeds fragments throughNous.StreamNormalizer.ToolCallAccumulator.- OpenAI
parse_tool_call/1crashed on a tool_call missing"function".Map.get(nil, "name")raisedBadMapError, aborting the whole response parse on non-conformant OpenAI-compatible backends. Defaults to%{}now.
Fixed (important)
- Team region locking and discovery sharing were silently inert.
Nous.Teams.SupervisorwiresSharedStateinto agent deps as a registered atom name, butNous.Plugins.TeamToolsgated onis_pid/1— false for the name — soclaim_region/share_discoveryno-op'd for every team built via the public API. The guards now resolve a registered name to a live pid. - A tool that
throws orexits (non-timeout) crashed the whole agent run.Nous.ToolExecutoronly caught:exit, {:timeout, _}; it now catches anythrow/exitand converts it to a retryableToolError. - Streaming Gemini/Vertex tool calls dropped
thought_signature. The accumulator rebuilt the call withoutmetadata, breaking multi-turn thinking parity for 2.5 thinking models. The signature is now carried through. - Documented per-run
:model_settingsoverride was ignored.AgentRunnernow mergesopts[:model_settings]over the agent's settings for that run. Nous.Teams.RateLimiterwas never invoked, sobudget/rpm/tpmhad no effect. It is now wired into the agent request path (reserve → reconcile → release) when a limiter is in deps; rpm/tpm/request limits are enforced (the cost budget is reconciled post-hoc — see the moduledoc).- Crashed/timed-out parallel workflow branches were attributed to
"unknown".Nous.Workflow.Engine.ParallelExecutornow useszip_input_on_exitso failures keep their branch id / item index. - SQLite memory scoped FTS recall was silently broken (a parameter
off-by-one bound the scope filter to the wrong columns); the Hybrid store
now over-fetches a larger candidate pool when a scope is applied (so in-scope
results aren't crowded out); and memory search normalizes RRF scores to
0–1 so
min_scorebehaves consistently between text-only and hybrid modes. - Tool argument validator now recurses into nested object properties and array items (was top-level types only).
- Eval config robustness.
NOUS_EVAL_*integer env vars parse viaInteger.parse(no crash on a bad value) and a partial customcost_configdeep-merges instead of raisingKeyError. Nous.Tools.SearchScrapeprocessed only the firstconcurrencyURLs. It now fetches all URLs (capped and throttled bymax_concurrency) and clamps LLM-suppliedconcurrency/timeout.
Changed
Nous.Agent.new/2accepts bare tool modules in:tools(e.g.tools: [Nous.Tools.Bash]), converted viaNous.Tool.from_module/1.Nous.Permissions.blocked?/2allow-list semantics. A non-emptyallow_names/allow_prefixesis now deny-by-default in every mode (was only honored in:strict);build_policy/1raises on an unknown:mode.Nous.Hook.new/2accepts:fail_closedso security-gating hooks can opt into fail-closed via the documented constructor (not only a struct literal).- License metadata corrected to
Apache-2.0inmix.exs(wasMIT) to match the bundledLICENSEand README. - Docs: fixed non-compiling/silently-broken examples — README plugin
configs now pass
:depstoNous.run/3(notNous.new/2, which ignores it); getting-started usesNous.Errors.ProviderError, the correctAgentDynamicSupervisor.start_agent/3arity,Context.deserialize/1, and%Nous.Message{}for the chatbot example; AGENTS.md custom-tool example uses@behaviour/metadata/0/execute(ctx, args)and corrects the streaming backpressure claim (Req is the default; Hackney is the opt-in pull-based backend).
Performance
- Removed O(n²) list accumulation on hot loops.
Nous.Teams.RateLimiter's sliding window andNous.Teams.SharedState's discovery list now prepend (O(1)) instead of++ [entry](O(n)). - KnowledgeBase ETS store keeps a
slug -> idindex, sofetch_entry_by_slug/2is O(1) instead of a full table scan + struct rebuild on everykb_read/kb_backlinks/kb_linkcall. - Decisions ETS store builds an edge adjacency index once per BFS traversal
(
descendants/ancestors/path_between) instead of scanning the whole edge table per visited node — O(V+E) instead of O(V·E).
0.16.2 - 2026-05-16
Fixed (critical)
- Gemini/Vertex tool-result roundtrip was broken.
Nous.Messages.Geminiwas sending the tool call_id (e.g."gemini_abc123") as thefunctionResponse.name, but Gemini's API requires the originalfunctionCall.name. Every Gemini tool roundtrip shipped a malformed payload.Message.tool/3now threads the original name through the:namefield; agent_runner and llm pass it at every call site. - OpenAI tool-call malformed JSON used to be passed to the tool as bogus
args.
Nous.Messages.OpenAI.decode_arguments/1now returns{:ok, map()} | {:error, {:invalid_json, raw}}. Parsers tag the tool_call with"_invalid_arguments"; AgentRunner short-circuits with a proper tool-error result so the LLM can retry. - Workflow checkpoint ETS table lost its data when the saving process
exited. The
:nous_workflow_checkpointstable is now owned by a supervisedTableOwnerunder Nous.Application (mirrors the existingNous.Persistence.ETSpattern). Every suspended workflow relying on resume is now durable to caller exits. - Memory plugin re-initialized its ETS store on every agent run.
Nous.Plugins.Memory.init/2 now reuses the existing
store_statewhen present; per-run defaults are still refreshed. Avoidsets_too_many_tablesunder load and the silent loss of memories across runs. Nous.LLM.stream_text_with_toolssilently halted on dispatcher error. Now emits an{:error, reason}event before halting so consumers can detect LLM failures on the streaming + tools path.- AgentServer subscribed to the wrong PubSub topic. It used
"agent:#{session_id}"whileNous.PubSub.agent_topic/1returned"nous:agent:#{session_id}"— anyone publishing via the helper never reached the server. Now uses the helper. - AgentServer didn't cancel its in-flight task on shutdown. Streaming
LLM calls kept consuming tokens and HTTP connections after the server
was already gone.
terminate/2now sets the cancellation atomic and callsTask.shutdown. - Research coordinator crashed on task exit.
Task.yieldreturns{:exit, reason}on task crash; thecaseonly matched{:ok, _}/niland producedCaseClauseError. Now handles{:exit, _}with{:error, {:task_exit, _}}.
Fixed (important)
- Anthropic + Gemini usage parsing dropped requests count and cache
tokens. Now sets
requests: 1(was always 0) and captures Anthropic'scache_creation_input_tokens/cache_read_input_tokensand Gemini'scachedContentTokenCount.Nous.Usagegainedcache_creation_input_tokensandcache_read_input_tokensfields, which propagate throughadd/2. - Tool validator dropped the
enumconstraint whentypewas also declared.Nous.Tool.Validator.validate_types/2now runs every constraint independently — a schema%{"type" => "string", "enum" => ["a","b"]}properly rejects values outside the enum. - Hooks can now opt into fail-closed semantics.
Nous.Hookgains afail_closed: boolean()field. When set on a hook bound to a blocking event (:pre_tool_use,:pre_request), runtime errors deny the action instead of silently failing open — so a broken security-gating hook can't be bypassed. Defaultfalsekeeps existing behavior. - Streaming Gemini tool calls had
id: nil. Stream and non-stream paths now both synthesize a"gemini_<base64>"id. - Bumblebee embedding serialization removed.
ServingHolderno longer runsNx.Serving.run/2insidehandle_call. Concurrent embeddings now go through the serving's own batching mechanism instead of serializing through one process. - Bare
Task.asyncmigrated to supervisedTask.Supervisor.async_nolinkinresearch/coordinator.ex,eval/runner.ex,tools/search_scrape.ex,plugins/input_guard.ex, andhttp/stream_backend/req.ex— so a crashed sub-task no longer takes down its caller (and vice-versa), and graceful shutdown can signal in-flight work. - Default Req streaming backend gains backpressure. The producing
Task watches the consumer's
message_queue_lenbefore eachsend/2; past@backpressure_high_waterit pauses until the queue drops below@backpressure_low_water, and after@backpressure_max_wait_msit emits{:error, %{reason: :backpressure_overflow}}and halts. The M-12 risk called out inmix.exs. - AgentRegistry partitioned across schedulers (was
partitions: 1). High-concurrency LiveView lookups no longer serialize on a single partition. - AgentServer's persistence load moved to
handle_continue.init/1returns immediately, soDynamicSupervisor.start_child(andTeams.Coordinator.spawn_agent) no longer wedge waiting for slow persistence backends.
Changed
- Telemetry events reconciled. The documented-but-never-emitted events
[:nous, :agent, :iteration, :start/:stop],[:nous, :context, :update], and[:nous, :callback, :execute]are now actually emitted. The unreached[:nous, :provider, :stream, :chunk]was removed from the docs and default handler (per-chunk telemetry is too hot for the streaming path). Existing-but-undocumented events (fallback,hook,skill,workflow) are now documented in theNous.Telemetrymoduledoc.
Deprecated
Nous.ToolSchema.to_openai/1— useNous.Tool.to_openai_schema/1.Nous.Agent.tool/3— useNous.Agent.new/2with:tools, or build a%Nous.Tool{}directly.Nous.Eval.run!/2— matchNous.Eval.run/2's{:ok, _} | {:error, _}result.Nous.Decisions.path_between/4,descendants/3,ancestors/3— callstore_mod.query(state, ..., ...)directly.
Internal / Hygiene
mix compile --warnings-as-errorsis clean. Removed unreachableVertex AI validate_project_id(nil)clause; tightenedNous.Research.Planner.plan/2spec to{:ok, plan()}and dropped the unreachable{:error, _}clause inresearch/coordinator.ex.Nous.Workflow.Checkpoint.ETSandNous.Plugins.Memorynow expose proper supervised / reusable lifecycle (see Fixed).Nous.Memory.Store.SQLiteFTS5 escape now doubles embedded"characters per FTS5 syntax — queries containing"no longer error.
0.16.1 - 2026-05-15
Changed (breaking)
Provider error contracts.
Nous.Providers.LMStudio,Nous.Providers.SGLang,Nous.Providers.VLLM, andNous.Providers.Customnow return{:error, {:invalid_config, reason}}instead of raisingArgumentErrorwhen the resolvedbase_urlis missing or failsNous.Tools.UrlGuardvalidation.Nous.Providers.LlamaCppsimilarly returns{:error, %Nous.Errors.ProviderError{}}instead of raising when the:llamacpp_modeloption is missing.Callers that wrapped these calls in
try/rescue ArgumentErrorshould switch to pattern matching on{:error, _}. The high-levelNous.run/2,Nous.generate_text/3, andNous.Agent.run/3paths already returned result tuples and are unaffected.Vertex AI token resolution prefers Goth over
VERTEX_AI_ACCESS_TOKEN. When a:gothinstance is configured (in opts or app config),Nous.Providers.VertexAInow uses Goth exclusively for that request, and surfaces Goth failures as{:error, %{reason: :goth_error, ...}}. Previously, a Goth failure would silently fall through to the env var, producing confusing 401s when the env var was stale or missing. If you relied on env-var fallback while Goth was misconfigured, you will now see the Goth error directly — that's the intended behavior.
Fixed
- Tool args of the wrong type no longer crash
Nous.Tools.StringTools.replace_text,split_text,count_occurrences,containspreviously chainedMap.get(args, "k1") || Map.get(args, "k2") || ""to support aliased keys. When the LLM handed back a non-string value (e.g."pattern" => 123), the value flowed straight intoString.replace/3and crashed the tool call. Args are now extracted via a typed helper that falls back to the default when the value isn't a binary.
0.16.0 - 2026-05-10
A significant Gemini-on-Vertex upgrade. Most of the new surface lands as
Nous.Messages.Gemini helpers + small build_request_params/3 wiring on
both Nous.Providers.VertexAI and Nous.Providers.Gemini, so anything new
works against either entry point.
Added
- Thinking config (request-side). New
:thinking_configsetting maps togenerationConfig.thinkingConfig, letting callers setthinking_budgetandinclude_thoughtson Gemini 2.5/3.x. Both Elixir shape (%{thinking_budget: 1024, include_thoughts: true}) and native Vertex shape (%{"thinkingBudget" => 1024, "includeThoughts" => true}) are accepted. thoughtSignatureround-trip on tool calls.Nous.Messages.Gemininow preserves Vertex'sthoughtSignatureon parsed tool calls (undertool_call["metadata"]["thought_signature"]) and echoes it back when serializing assistant turns. Without this, multi-turn thinking + tool loops on Gemini 2.5/3.x degrade or fail because the next turn lacks the required signature. The streaming normalizer also propagates the signature on{:tool_call_delta, ...}events.- Structured output (JSON schema). New
:json_responseand:json_schemasettings wire toresponseMimeType/responseSchemaingenerationConfig. The cross-provider:response_formatshape (%{type: :json_schema, schema: ...}and%{type: :json_object}) maps through too. - Safety settings.
:safety_settingsflows to top-levelsafetySettings, with atom-keyed entries auto-stringified. - Tool config / tool choice.
:tool_config(raw map) and:tool_choice(friendly form) both flow to top-leveltoolConfig. Friendly forms::auto,:any/:required,:none, and{:any, ["fn_a", ...]}forallowedFunctionNames. - Function calling on Vertex/Gemini actually works. Function
declarations are now serialized in Vertex's
tools[].functionDeclarationsformat viaNous.ToolSchema.to_gemini/1(which strips OpenAI'sstrictfield and unsupportedadditionalPropertiesfrom the parameters schema). Previously the high-levelNous.LLMpath silently dropped tools for these providers. - Native Vertex tools. New
:native_toolssetting accepts:google_search,:url_context,:code_executionatoms (or{tool, config}tuples / raw maps) and adds them as additional entries in the Vertextoolsarray, alongside any function declarations. - Context caching.
:cached_contentsetting maps to top-levelcachedContent. Pass-through only — create caches via the Vertex REST API for now. - Streaming + tools.
Nous.LLM.stream_text/3now honors:tools. Tool-call deltas are aggregated per turn (preserving anythoughtSignature), tools execute between turns, and the conversation continues until the model stops calling tools or hits@max_tool_iterations. Text deltas are still yielded to the caller as they were produced. - More
generationConfigfields:topK←:top_k,seed←:seed,candidateCount←:candidate_count,presencePenalty←:presence_penalty,frequencyPenalty←:frequency_penalty,responseModalities←:response_modalities.
Changed
- Single timeout source of truth. Removed the separate
@streaming_timeoutconstants fromNous.Providers.VertexAI(300s) andNous.Providers.Gemini(120s). Streaming and non-streaming now share the same provider default; the actual timeout used at request time is alwaysmodel.receive_timeout, which flows throughbuild_provider_opts/1as:timeout. Override viaModel.parse(..., receive_timeout: ms).
0.15.8 - 2026-05-06
Fixed
- Vertex AI / Gemini whitespace text parts no longer crash the
request pipeline. Gemini occasionally returns
textparts whose content is only newlines (e.g."\n\n\n") — typically between tool calls or as filler when the model is blocked. Ecto's default:empty_valuesforcast/3treats whitespace-only strings as empty, soNous.Message.ContentPart's changeset dropped thecontentfield entirely and then raised%Ecto.InvalidChangesetError{errors: [content: {"content is required", []}]}fromContentPart.new!/1, taking down the whole Nous.LLM.run_with_tools/6 call.ContentPartnow overrides:empty_valuesto[""]so legitimate whitespace content is preserved, and Nous.Messages.Gemini.parse_content/1 defensively skips whitespace-only text parts to avoid creating uselessContentParts. The streaming normalizer (Nous.StreamNormalizer.Gemini) already had this guard; the non-streaming path is now consistent. - Nous.Messages.Gemini.parse_content/1 no longer silently drops
function calls without
args. Nullary tool calls (%{"functionCall" => %{"name" => "get_time"}}) were falling into the catch-all clause and disappearing. Pattern now requires onlynameand falls back to%{}forargs, matching the behavior of the siblingparse_parts/1helper.
Added
Nous.Errors.RetryInfoparses server-suggested retry hints from provider error responses. Checkserror.details[]forgoogle.rpc.RetryInfo(Vertex AI / Gemini) first, then theRetry-AfterHTTP header. Returns delay in milliseconds, ornilwhen no hint is available —nilis itself meaningful for Google APIs, since long-term/daily quota exhaustion deliberately omitsRetryInfoto discourage retry loops.Nous.Errors.ProviderErrorgains:retry_after_msalongside the existing:status_code. Nous.Provider.request/3 andrequest_stream/3now populate both fields automatically when the underlying HTTP layer returns an error tuple, so callers can branch on rate-limit hints without parsing provider-specific bodies:case Nous.LLM.run_with_tools(...) do {:error, %Nous.Errors.ProviderError{retry_after_ms: ms}} when is_integer(ms) -> {:snooze, ms} # use server-suggested delay {:error, %Nous.Errors.ProviderError{status_code: 429}} -> {:snooze, exp_backoff(attempt)} # rate-limited, no hint ... endGemini/Vertex
finishReasonandpromptFeedbackare surfaced.Nous.Messages.Gemini.from_response/1now stores both inmessage.metadata(when present) and emits aLogger.warningwhen the candidate produced empty content for a non-STOP reason (SAFETY,RECITATION,MAX_TOKENS, etc.) or when the prompt was blocked. Previously these signals were discarded, so blocked generations manifested as silent empty messages with no diagnostic.
Changed
- HTTP error tuples now carry response headers.
Nous.HTTP.Backend.Req,Nous.HTTP.Backend.Hackney, andNous.HTTP.StreamBackend.Reqpreviously returned{:error, %{status, body}}and dropped headers entirely, which made it impossible to readRetry-After. They now return{:error, %{status, body, headers}}withheadersas a list of{name, value}tuples (lowercased per HTTP spec, both string). Existing pattern matches on%{status: _, body: _}continue to work since map matching is non-exhaustive. - Gemini tool-call ID generation unified.
Nous.Messages.Gemini.parsecontent/1 previously used
`"gemini#{:rand.uniform(10000)}"
(~50% birthday-paradox collision at ~118 calls) whileparse_parts/1used"call#{:rand.uniform(1000_000)}"— two formats, two ranges. Both now share agenerate_tool_call_id/0helper using 64 bits of:crypto.strong_rand_bytes/1, base64url-encoded with thegemini` prefix preserved.
0.15.7 - 2026-05-05
Changed
hackneyis now an optional dependency. Req (default for both one-shot and streaming) is the primary HTTP backend;hackneyis only used when a consumer opts intoNous.HTTP.Backend.Hackney/Nous.HTTP.StreamBackend.HackneyviaNOUS_HTTP_BACKEND=hackney(or the streaming variant) or app config. Forcinghackney ~> 4.0as a hard dep (added in 0.15.x) broke downstream apps with any transitive constraint ofhackney ~> 1.20(e.g.aws ~> 1.0's optional dep), since the resolver activated the optional constraint once hackney 4 entered the graph. Apps that use the hackney backend now declare{:hackney, "~> 4.0"}in their ownmix.exs.
0.15.6 - 2026-05-05
Fixed
- Gemini / Vertex AI multi-part responses no longer crash
Message.new!/1. When a Gemini candidate contained more than onetext(orthought) part — common on longgemini-2.5-prooutputs such as multi-thousand-token translations —from_response/1passed the raw list ofContentPartstructs toNous.Message, whose:contentfield is:string. Ecto then raised%Ecto.InvalidChangesetError{errors: [content: {"is invalid", [type: :string, validation: :cast]}]}.consolidate_content_parts/1now joins homogeneous lists of:textor:thinkingparts into a single string. Vertex AI is fixed implicitly via the existing:vertex_ai → from_gemini_response/1delegation inNous.Messages.from_provider_response/2.
0.15.5 - 2026-05-01
Fixed
- Both Req-based HTTP backends (
Nous.HTTP.Backend.ReqandNous.HTTP.StreamBackend.Req) now actually use the configuredNous.Finchpool. Previously they ignored the:finch_nameopt built byNous.Providerand let Req spin up its own default Finch instance, leaving the supervisedNous.Finchpool (started by Nous.Application withsize: 10, count: 1) idle. Both backends now read:finch_namefrom per-call opts, falling back toApplication.get_env(:nous, :finch, Nous.Finch). Net effect:Nous.Finchbecomes the live default for both streaming and non-streaming on Req, so pool tuning via app config actually takes effect. (Note: Req disallows passing:finchtogether with:connect_options; connect timeouts are now pool-level — configure on theNous.Finchpool itself if a non-default is needed.)
Changed
Default timeouts increased to 3 minutes (180_000 ms) across the board. The previous 60s default routinely tripped on reasoning models and longer completions. Affected:
Nous.Modelreceive_timeoutdefault → 180_000- Nous.Model.default_receive_timeout/1 per-provider: cloud/custom → 180_000, llamacpp → 300_000 (up from 120_000)
- Provider
@default_timeout(OpenAI, Anthropic, Mistral, VertexAI, OpenAICompatible) → 180_000 - Provider
@streaming_timeout(Anthropic, Mistral, VertexAI, OpenAICompatible) → 300_000 (up from 120_000) - HTTP backend defaults (Req + Hackney, both streaming and non-streaming) → 180_000
Per-call
:timeout/:receive_timeoutopts continue to override.
0.15.4 - 2026-05-01
Pluggable streaming HTTP backends + hackney 4 pull-mode bug fix.
Fixed
- Hackney 4 streaming was silently in push mode, not pull mode.
lib/nous/providers/http.ex:463-470(in 0.15.0–0.15.3) passed[:async, :once, ...]as separate atoms to:hackney.request/5. Erlang'sproplistsresolves bare atom:asyncas{:async, true}, which puts hackney into push mode; the bare:onceatom is silently ignored. The architectural intent of M-12 (strict pull-based backpressure so a slow consumer cannot grow its mailbox) was forfeited —:hackney.stream_next/1is a no-op in push mode, so the receive loop appeared to work in many cases (chunks arrive in the same shape) but the pacing came from the producer, not the consumer. The fix is the tuple form[{:async, :once}, ...]perdeps/hackney/NEWS.md:269-272. Empirical confirmation: with the broken form a benign Bypass server delivers 97 messages to the caller's mailbox in 2 s without anystream_next/1call; with the tuple form the mailbox holds only 2 messages (status + headers) and body chunks gate onstream_next/1. Reported as part of the same bug that caused observable timeouts against cold/slow SSE backends.
Added
Nous.HTTP.StreamBackendbehaviour — pluggable streaming HTTP layer mirroring the non-streamingNous.HTTP.Backendintroduced in 0.15.1. Two impls ship:Nous.HTTP.StreamBackend.Req— the new default. DrivesReq.post/1with the:intocallback. Simpler stack (Req/Finch/Mint), marginally faster TTFB than hackney in benchmarks against LMStudio (~130 ms vs ~133 ms mean).Nous.HTTP.StreamBackend.Hackney— opt-in. Strict pull-based backpressure via:hackney's[{:async, :once}]mode (the bug above is fixed here). Pick this when downstream consumers can block per chunk (LiveView fan-out under load, persistence-on-every-chunk, slow IO).
:stream_backendper-call opt onNous.Providers.HTTP.stream/4.NOUS_HTTP_STREAM_BACKENDenv var (req|hackney|My.Custom.Backend). Resolution mirrorsNOUS_HTTP_BACKEND: per-call → env → app config → default.config :nous, :http_stream_backend, MyBackendapplication config knob.
Changed
Nous.Providers.HTTP.stream/4now dispatches to the configuredNous.HTTP.StreamBackendinstead of inlining hackney plumbing. The public API surface (return shape, event types, error tuples) is unchanged. Provider stream normalizers (Nous.StreamNormalizer.*) consume normalized events and need no changes.- The non-streaming pluggable
Nous.HTTP.Backendresolver is refactored to share itsString.to_existing_atom/1safety logic with the streaming resolver — same C-2 protection on both paths.
Documentation
Nous.Providers.HTTPmoduledoc rewritten around the dual pluggable-backend model and the streaming backpressure trade-off.Nous.HTTP.StreamBackendand the two impl modules carry full moduledocs explaining when to pick each.
Migration
No code changes required for callers — the default behavior is restored to "streaming works against any healthy SSE backend." Apps that depend on strict pull-based backpressure should set:
config :nous, :http_stream_backend, Nous.HTTP.StreamBackend.Hackneyor pass stream_backend: Nous.HTTP.StreamBackend.Hackney per call.
0.15.3 - 2026-05-01
Streaming + tool execution. The Nous.Agent.run/3 loop now has a
stream: true opt that combines per-token deltas with the regular
tool-call loop. Behavior is identical to non-streaming run/3 except
for the additional streaming events: same final result, same callbacks,
same fallback chain, same hook/plugin pipeline.
Added
:streamoption onNous.Agent.run/3— runs the iteration loop with the LLM call streamed. Per-iteration assembly produces a%Nous.Message{}structurally identical to what the non-streaming path returns, so:on_llm_new_message,process_response,handle_tool_calls, and the loop continuation are all unchanged. Per-token:on_llm_new_deltafires for text and the new:on_llm_new_thinking_deltafires for reasoning. Works across all providers (OpenAI-compatible, Anthropic, Gemini, Vertex AI, Mistral) and is compatible withoutput_typefor streaming structured output.:on_llm_new_thinking_deltacallback — cleanly-separated reasoning deltas. Pre-existingNous.Agent.run_stream/3keeps emitting[thinking] …on:on_llm_new_deltafor backward compatibility — the split is opt-in viastream: true.Nous.StreamNormalizer.ToolCallAccumulator— polymorphic across the three provider chunk shapes (OpenAI list with split JSON args, Anthropic_phase-tagged fragments, Gemini already-completefunctionCall). Reassembles them into the unified%{"id", "name", "arguments" => decoded_map}shape thatNous.Messages.extract_tool_calls/1already understands.{:usage, %Nous.Usage{}}stream event — emitted byNous.StreamNormalizer.OpenAIwhen chunks carry ausagefield (auto-enabled by injectingstream_options.include_usage: trueon the OpenAI-compatible streaming request), byNous.StreamNormalizer.Anthropicfrommessage_startandmessage_deltachunks, and byNous.StreamNormalizer.GeminifromusageMetadata. TheNous.Types.stream_eventtypespec is updated.- Mid-stream cancellation —
ctx.cancellation_checkis invoked between every streamed chunk; a thrown{:cancelled, reason}halts the run withErrors.ExecutionCancelledand discards partial state. No tool execution happens on cancellation. Nous.Messages.OpenAI.decode_arguments/1andparse_usage/1promoted to public helpers (formerly private) so the streaming path and theToolCallAccumulatorreuse the same JSON-decode-with-fallback and usage-parsing logic as the non-streaming path. Anthropic and Gemini'sparse_usage/1are similarly public for the same reason.
Changed
- Pre-existing
Nous.Agent.run_stream/3semantics are unchanged. The[thinking] …prefix on:on_llm_new_deltais preserved for that legacy path so existing consumers don't break. lib/nous/provider.exbuild_request_paramsallowlist now includesstream_options(no-op for non-OpenAI providers — silently ignored).
Documentation
- New "Streaming with Tool Execution" section in
README.md. - New "Streaming with Tool Execution (Recommended)" section in
docs/guides/liveview-integration.mdwith a complete LiveView example wiring:agent_delta,:agent_thinking,:tool_call,:tool_result,:agent_message, and:agent_complete. - New "Streaming Structured Output" section in
docs/guides/structured_output.md. - 0.15.2 → 0.15.3 entry in
docs/guides/migration_guide.md. AGENTS.mdQuick Start example updated.
0.15.2 - 2026-04-27
Documentation-only release. No code changes.
Added
AGENTS.md— quick-reference for AI coding agents (Claude, Cursor, Copilot, Codex, etc.) consuming the library. Covers the minimal API, provider quick-pick, key opts, custom tools, HTTP backend, security rules, common workflows, and what's public vs internal. Conforms to https://agents.md.
Changed
- README "Supported Providers" table now lists
vllm:andsglang:as first-class named providers (previously onlylmstudio:was mentioned; vLLM and SGLang were buried in thecustom:section). - README "Local Servers" section now recommends the dedicated
lmstudio:/vllm:/sglang:/ollama:prefixes overcustom:— they default to the right port, validate*_BASE_URLenv vars throughUrlGuard, and pick up the OpenAI stream normalizer for free. - New "HTTP Backend" section in README covering the pluggable
Nous.HTTP.Backendbehaviour, env-var selection, and shared hackney pool config. - Cleaned up
mix docswarnings — replaced backticks around hidden module references in CHANGELOG so ExDoc no longer tries to auto-link them.
0.15.1 - 2026-04-26
Follow-up to 0.15.0. No behavioral changes for existing users — the default HTTP backend stays Req. Two themes: making the HTTP backend pluggable, and bringing the local-server providers (LM Studio, vLLM, SGLang) up to date with the post-0.15.0 hackney streaming rewrite.
Added
Pluggable HTTP backend for non-streaming requests. New
Nous.HTTP.Backendbehaviour withNous.HTTP.Backend.Req(default) andNous.HTTP.Backend.Hackneyimplementations. Configure via:- per-call:
HTTP.post(url, body, headers, backend: Nous.HTTP.Backend.Hackney) - env var:
NOUS_HTTP_BACKEND=hackney(also acceptsreqor any fully-qualified custom backend module name) - app config:
config :nous, :http_backend, Nous.HTTP.Backend.Hackney
Precedence: per-call > env > app config > default. Custom backends are resolved via
String.to_existing_atom/1with rescue (per the project-wide C-2 rule from the 0.15.0 review — neverString.to_atom/1on env input). Benchmark script atbench/http_backend.exs; results indocs/benchmarks/http_backend.md.- per-call:
Hackney
:defaultpool is now configurable from app config:config :nous, :hackney_pool, max_connections: 200, timeout: 1_500. Applied at app boot. Used by both the Hackney HTTP backend and the streaming pipeline. (Hackney 4 caps the idle keepalive timeout at 2_000 ms — values above that silently cap.)Per-call
:connect_timeoutand:poolopts added to both HTTP backends andNous.Providers.HTTP.stream/4. Default 30_000ms /:defaultpool. Lets a single app run different timeouts per provider without mutating shared state.Test coverage for
lmstudio:,vllm:,sglang:providers (12 new tests) plus 14 backend contract tests run twice (once per backend) and 9 backend-resolution tests.
Fixed
- Removed dead
finch_namearg fromlmstudio.ex/vllm.ex/sglang.exchat_stream/2calls — leftover from the pre-hackney streaming code;HTTP.stream/4has been ignoring it since 0.15.0. lmstudio:/vllm:/sglang:base_urlis now validated throughNous.Tools.UrlGuardwithallow_private_hosts: true. Rejects malformed schemes (file://,gopher://, etc.) from*_BASE_URLenv vars while keeping localhost defaults.
0.15.0 - 2026-04-26
Comprehensive security & correctness pass driven by a multi-agent code review of every subsystem. 57 fixes across 10 Critical, 19 High, 16 Medium, and 12 Low severity findings, plus a streaming pipeline rewrite. The full review report is at docs/reviews/2026-04-26-comprehensive-review.md.
Minor version bump (not patch) because of the 9 behavioral changes called out below — most are security defaults moving from open to deny, which existing callers may need to opt back into.
⚠ Behavioral / breaking changes
Read these before upgrading.
- Sub-agent deps no longer auto-forward to children. The
compute_sub_deps/1helper inNous.Plugins.SubAgentnow defaults to[]. The previous default forwarded every parent dep (minus a 6-key denylist) — secrets, repo handles, signed URLs all leaked into LLM-controlled sub-agent contexts. To restore the old behaviour, set:sub_agent_shared_deps, :allexplicitly. Recommended: list specific keys with:sub_agent_shared_deps, [:key1, :key2]. - Tools with
requires_approval: trueare now rejected when no:approval_handleris wired (was silently approved). If you useNous.Tools.Bash,FileWrite, orFileEdit, configure anapproval_handleronRunContextor those tools will refuse to run. - File tools (
FileRead/Write/Edit/Glob/Grep) now enforce a workspace root. Defaults tocwd; override per-agent viadeps: %{workspace_root: "/path"}. Paths that escape the root (absolute paths outside,..traversal, symlink-escape) are rejected with a clear error to the LLM. PromptTemplate.from_template/2rejects template bodies containing<% ... %>blocks other than the simple<%= @ident %>substitution form. Previously bodies were passed throughEEx.eval_string/2, which executes arbitrary Elixir — an RCE vector for any caller piping LLM output into a template. Conditionals must now be expressed by composing multiple smaller templates.- Workflow
:fallbackerror strategy now actually executes the fallback node (was a silent no-op that returned{:fallback, id}as if the primary had succeeded). Workflows that relied on the broken behaviour will now see real fallback execution. - Workflow
max_iterationsexhaustion returns{:error, {:max_iterations_exceeded, node_id, max}}instead of silently{:ok, state}. Quality-gate loops that saturate now surface as failures rather than passing-looking results. - Workflow
:pre_nodehook returning:denyaborts the workflow with{:error, {:hook_denied, hook_name, node_id}}. Previously was silently mapped to{:pause, _}so safety hooks suspended a checkpoint forever. - Permissions
:strictmode is deny-by-default at the filter layer. New:allow_names/:allow_prefixesopts onNous.Permissions.build_policy/1. Previouslystrict_policy()with empty deny lists silently exposed every tool. PromExplugin event names corrected ([:nous, :model, ...]→[:nous, :provider, ...]). Anyone usingNous.PromEx.Pluginsaw zero data on the model/stream metric panels until now. Metric paths still emit asnous_model_*for dashboard backward compatibility.Nous.Tool.Validatornow actually runs.tool.validate_argsdefaulted totruefor months butToolExecutornever called the validator. Tools whose params declared"required": [...]will now reject calls with missing fields up-front (returning a structuredToolErrorto the LLM with the field name) instead of crashing inside the tool body and reporting a genericFunctionClauseError. If you have tools that relied on the lack of validation, setvalidate_args: falseon the tool struct.Nous.Teams.RateLimiter.acquire/3returns{:ok, reservation_ref}instead of:ok. Existing call sites doingassert :ok = RateLimiter.acquire(...)needassert {:ok, _ref} = .... This is the contract change that makes concurrent acquires near the cap race-safe (M-9). Pair withrecord_usage(reservation: ref, ...)for atomic reconciliation, orrelease/2to cancel. Barerecord_usage/3(no:reservation) still works for legacy post-hoc callers.
Added
Nous.Tools.PathGuard— workspace-root sandbox for file tools. Rejects path traversal, NUL-byte injection, and symlink escapes. Used by all five built-in file tools.Nous.Tools.UrlGuard— SSRF protection for outbound HTTP. Rejects schemes other thanhttp/https, blocks RFC1918 / loopback / link-local / CGNAT / IPv6 ULA / cloud-metadata IPs (169.254.169.254). Used byWebFetch(with redirect re-validation) and the Custom provider'sbase_url.:allow_private_hostsopt-in for local dev.- Streaming pipeline rewritten on
:hackney 4:async, :once(pull-based), replacing the prior spawn +Finch.stream+ mailbox plumbing. TheStream.resourceconsumer now drives:hackney.stream_next/1directly — backpressure is structural, no consumer mailbox can grow unboundedly. Same path picks up hackney 4's HTTP/3 + Alt-Svc auto-upgrade for free. New:bypass-driven integration tests exercise the streaming path end-to-end. link_counts_by_source/1optional Store callback for KB backends. ETS implementation provided. Reduceskb_health_checkfrom O(E·L) to O(L) — health checks on a 1k-entry / 5k-link KB drop from millions of comparisons to thousands.- Workflow fallback validation in
Nous.Workflow.Compiler— fallback target nodes are reachable for the purposes of:unreachable_nodesvalidation but excluded from the topo order so they don't double-execute. - AgentServer task generation refs — every spawned agent task carries a monotonic ref; stale
:agent_response_ready/:agent_task_completedmessages from cancelled tasks are discarded. Fixes silent message loss when the user types fast or callsclear_historymid-stream. - Seven new test files:
test/nous/json_test.exs,test/nous/prompt_template_test.exs,test/nous/tools/path_guard_test.exs,test/nous/tools/url_guard_test.exs, plus expanded coverage intest/nous/workflow/phase2_test.exs,test/nous/workflow/phase3_test.exs,test/nous/transcript_test.exs. Test suite: 1539 → 1543 passing (mix test), plus 0 dialyzer errors and 0 credo issues at--strict.
Fixed (security)
- Atom-table DoS via
String.to_atom/1on untrusted input across 7 modules (Critical). Adopted a project-wide rule — neverString.to_atom/1on data that didn't originate from a literal in this repo. Audited and fixed:Agent.Context.safe_to_atom, skill loader frontmatter parser, LlamaCpp provider message-key conversion,PromptTemplate.extract_variables,Eval.TestCaseYAML key conversion, and the--tags/--excludeparsers inmix nous.eval/mix nous.optimize. - EEx code-execution from template bodies (Critical, see breaking changes above) —
PromptTemplatenow rejects non-<%= @var %>markers. Nous.Hook:commandtype now requires a[program | args]list, not a raw string. Previous string handler was passed toNetRunner.run(["sh", "-c", str], ...)— RCE class ifhandlerever came from config or user input.BashandFileGreptools scrub the env before shelling out — whitelistsPATH/HOME/LANG/LC_ALL/TZ/USER/SHELL/TERM, drops*_API_KEY,*_TOKEN,*_SECRET,LD_PRELOAD, etc.FileGrepnow resolvesrgviaSystem.find_executable/1(nowhichPATH-shadowing).Bashuses absolute/bin/sh.HumanInTheLoopplugin matches tool names case-insensitively — was raw equality; a tool registered as"Send_Email"bypassed approval if config said"send_email".Nous.Plugins.Memorywraps auto-injected memories in<retrieved_memory>tags with provenance metadata and an explicit "USER-SUPPLIED DATA, not instructions" framing — defense-in-depth against stored prompt injection through the LLM-callableremembertool.extra_bodyblocked-keys list — dropsmessages,model,stream,system,tools,tool_choicewith a logged warning. Preventsextra_bodyfrom being a back-door for rewriting the conversation, model, or safe-tool whitelist.BraveSearchmigrated from raw:httpc(no TLS verify by default) toReqwith explicitverify: :verify_peer. Previous code path leaked the API key to any MITM on the wire.Customprovider validatesbase_urlthroughUrlGuardat startup — SSRF prevention for the user-supplied endpoint URL.- Skill loader caps file count (1000) and individual file size (5MB), and skips symlinks — prevents loading
/etc/passwdvia a symlink in a skills directory.
Fixed (correctness)
- Streaming normalizers (OpenAI / LlamaCpp) no longer drop
tool_callsorfinish_reasonwhen both arrive in the same chunk. Previously thecondreturned a single event and silently dropped the others; tool-calling agents misclassified termination and the OpenAI complete-response path lost tool calls entirely. Anthropic streaming
input_json_deltafragments are now tagged with content-block_indexand_phase(:start | :partial | :stop) so a stateful consumer can reassemble the full tool call. The non-streamingconvert_complete_response/1path was already correct.- Transcript compaction preserves
tool_call/tool_resultpairs across the compaction boundary. Previously the naiveEnum.splitcould orphan a:toolmessage from its assistant prelude — Anthropic and OpenAI 400 in that shape. - AgentServer task generation refs (C-5/H-16/L-7) prevent silent message loss in three races: stale
:agent_response_readyoverwriting a cancelled context,clear_historyun-clearing itself, and the wildcard:DOWNhandler clearing the wrong task. - Workflow scratch ETS leak —
maybe_cleanup_scratch/1now runs on every non-suspended terminal path (was only the:okarm). Failed workflows under retry no longer accumulate orphan ETS tables. - Memory backends (Hybrid/Muninn/Zvec) use unnamed ETS tables — named tables are global per BEAM, so a second concurrent agent crashed
init/1with "table already exists". - Memory backends roll back on NIF errors —
:ok = NIF.call(...)pattern-matches replaced withwithchains; ETS insert/delete only happens after the index op succeeds, leaving consistent (entry-absent) state on failure. - SQLite memory store wraps multi-statement ops in
BEGIN ... COMMIT— a crash mid-write would have left a row inmemorieswithout itsmemories_ftsrow, silently invisible torecallbut visible tolist. - SQLite/DuckDB metadata
atomize_keyssurvives unknown keys — was raisingArgumentErroron a single new key in user-supplied metadata, breakingrecall/listfor the entire process. parallel_maphandler{:error, _}returns are collected as failures —safely_run_handler/3previously wrapped any return value in:ok, so user error returns silently landed insuccessful_results.AgentRunnerno longer mutatesagent.modelmid-run when fallback fires. Active model is tracked onctx.deps[:active_model]and surfaced in stop telemetry as:active_model_provider/:active_model_name/:fallback_used. Sticky-fallback is preserved across iterations. New[:nous, :agent, :fallback, :used]event when the chain advances.Persistence.ETStable is owned by a dedicatedTableOwnerGenServer under the application supervisor — was dying with whichever transient process happened to callsave/loadfirst.save/2now returns{:error, _}on insert failure (was unconditional:ok).Decisions.supersede/5docstring corrected — flagged as best-effort, not atomic. The Store behaviour has no transaction primitive yet.- Coordinator
Process.demonitor/2on agent removal — was leaking monitor refs and could fire spurious{:agent_crashed, name, _}for healthy agents after rapid stop+respawn. - Workflow
:workflow_endhook payload now reflects failure-time state, not initial state, so post-mortems see the actual state at failure. - AgentServer
load_contextruns in aTask.Supervisor.start_childtask withGenServer.reply/2— slow persistence backends no longer block concurrentget_context/cancel_executioncalls. - AgentDynamicSupervisor + Application supervisor restart limits tuned to
max_restarts: 100, max_seconds: 10(was the default 3-in-5) so one bad user's crash loop doesn't take down every other tenant. Nous.Teams.RateLimiteris now race-safe under concurrent acquires (M-9 final).acquire/3now returns{:ok, reservation_ref} | {:error, _}and atomically reserves the estimated tokens + 1 request slot.record_usage/3accepts:reservationto reconcile actual vs estimated; missing reconciliations are auto-refunded after:reservation_ttl_ms(default 5 min) with aLogger.warning/1.release/2cancels a reservation when the call errored before completing. Legacyrecord_usage/3without:reservationstill works for callers that don't go throughacquire. Added:open_reservationstoget_status/1.Nous.Memory.Embedding.Bumblebeeuses a Registry + DynamicSupervisor (M-7 final). Each model_name is owned by exactly oneServingHolderGenServer registered by name. Replaces the:persistent_termcache (which forced a node-wide GC pause per new model). The application supervisor conditionally adds the Registry + ServingSupervisor children when Bumblebee is loaded.
Fixed (UX / minor)
clean_tool_name/1toleratesniland non-binary input (some providers emit malformed function-call responses).- OpenAI
reasoning_model?/1matches the fullo[1-9]family via regex (catches newo4,o3-pro, etc.); also stripspresence_penaltyandfrequency_penaltyfor reasoning models. Tool.from_function/2no longer fakes a hardcodedqueryparameter schema when no@docis found — falls back to the empty additional-properties schema with a debug log.- KB
Entry.slugify/1NFD-normalises and strips combining marks so"Café"→"cafe"instead of being entirely stripped. kb_health_checkcoherence_scoreweighted by issue severity (:high 0.2, :medium 0.1, :low 0.05), clamped to[0.0, 1.0].- ParallelExecutor sorts branch results by
branch_idbefore merging — deterministic instead of completion-order-dependent. - Transcript
summarize/1redacts:toolmessage content (replaced with a structural marker) so secrets / PII pulled from MCP don't bake into the permanent summary. - All compile warnings cleared (unused aliases, unused vars, dialyzer "clause never matches" on test stubs, "incompatible types" on intentional
assert_raiseconstructions).
Known limitations (documented in code, not silently glossed)
- 9 modules carry
@dialyzer :no_opaqueforMapSetcapture-syntax false positives — Elixir community standard, each suppression has a one-line justification at the top of its module. Specs were tried first and verified not to help; this isn't a code bug, it's a known dialyzer/Elixir interaction with opaque types and capture syntax (&MapSet.member?(set, &1)insideEnum.*).
Dependencies
- Added
{:hackney, "~> 4.0"}(production) for pull-based streaming, replacingFinch.stream/5for the streaming path.Finch/Reqare still used for non-streaming requests. - Added
{:bypass, "~> 2.1", only: :test}for in-test HTTP server fixtures driving the new streaming integration tests.
0.14.3 - 2026-04-25
Added
:extra_bodysetting for arbitrary request body params — pass vendor-specific top-level JSON keys (e.g.top_k,chat_template_kwargs,repetition_penalty,min_p,best_of,ignore_eos) to OpenAI-compatible providers (vllm:,sglang:,custom:,lmstudio:,ollama:). Mirrors the OpenAI Python SDK'sextra_body=argument. Works indefault_settings,Nous.LLMcalls, and agentmodel_settings. Atom keys are stringified at request build time; nested values pass through verbatim.extra_bodywins on collision with whitelisted keys (escape-hatch semantics). Also forwarded by Gemini and Vertex AI overrides.Example — disable Qwen3 thinking and tune sampling on a vLLM endpoint:
Nous.new("custom:qwen3-vl", base_url: "http://localhost:8000/v1", default_settings: %{ extra_body: %{ top_k: 20, chat_template_kwargs: %{enable_thinking: false} } })Example — interleaved thinking (preserve thinking blocks across turns):
Nous.new("custom:qwen3-vl", base_url: "http://localhost:8000/v1", default_settings: %{ extra_body: %{ chat_template_kwargs: %{preserve_thinking: true} } })
0.14.2 - 2026-04-13
Fixed
- SubAgent deps propagation — parent deps now flow to sub-agents by default (excluding plugin-internal keys like templates, PubSub, concurrency config). Use
sub_agent_shared_deps: [:key1, :key2]in deps to restrict which keys are shared.
0.14.0 - 2026-04-11
Added
Nous.KnowledgeBase— LLM-compiled personal knowledge base system inspired by Karpathy's vision. Raw documents are ingested and compiled by an LLM into a structured markdown wiki with summaries, backlinks, cross-references, and semantic search.Core data types:
Nous.KnowledgeBase.Document— raw ingested source material (markdown, text, URL, PDF, HTML) with status tracking and checksumsNous.KnowledgeBase.Entry— compiled wiki entries with titles, slugs,[[wiki-links]], summaries, concepts, tags, confidence scores, and optional embeddingsNous.KnowledgeBase.Link— typed directional links between entries (related, subtopic, prerequisite, contradicts, extends, references)Nous.KnowledgeBase.HealthReport— audit results with statistics, coverage/freshness/coherence scores, and categorized issues
Storage:
Nous.KnowledgeBase.Store— behaviour with 15 callbacks for document, entry, and link CRUD plus search and graph traversalNous.KnowledgeBase.Store.ETS— zero-dependency in-memory backend with Jaro-distance text search and optional embedding vector search
9 agent tools via
Nous.KnowledgeBase.Tools:kb_search,kb_read,kb_list,kb_ingest,kb_add_entry,kb_link,kb_backlinks,kb_health_check,kb_generateNous.Plugins.KnowledgeBase— plugin that auto-injects KB tools and system prompt guidance. Composes withNous.Plugins.Memory. Configurable viadeps[:kb_config]with optional embedding support for semantic search.Nous.Agents.KnowledgeBaseAgent— specialized agent behaviour for KB curation. Adds 4 reasoning tools on top of standard KB tools:kb_plan_compilation,kb_verify_entry,kb_suggest_links,kb_summarize_topic. Tracks KB operations for reporting.Nous.KnowledgeBase.Workflows— pre-built DAG pipelines using the workflow engine:- Ingest pipeline: raw documents → concept extraction → entry compilation → link generation → embedding → persistence
- Incremental update: detect changes via checksums and recompile affected entries
- Health check: audit for stale, orphan, inconsistent, and duplicate entries
- Output generation: produce reports, summaries, or slides from KB content
Nous.KnowledgeBase.Prompts— LLM prompt templates for extraction, compilation, linking, auditing, and output generation1,159 lines of test coverage across 6 test files (document, entry, link, ETS store, tools, plugin)
0.13.1 - 2026-04-03
Added
Nous.Transcript— Lightweight conversation compaction without LLM calls.compact/2— keep last N messages, summarize older ones into a system messagemaybe_compact/2— auto-compact based on message count (:every), token budget (:token_budget), or percentage threshold (:threshold)compact_async/2andcompact_async/3— background compaction viaNous.TaskSupervisormaybe_compact_async/3— background auto-compact with{:compacted, msgs}/{:unchanged, msgs}callbacksestimate_tokens/1andestimate_messages_tokens/1— word-count-based token estimation
Built-in Coding Tools — 6 tools implementing
Nous.Tool.Behaviourfor coding agents:Nous.Tools.Bash— shell execution via NetRunner with timeout and output limitsNous.Tools.FileRead— file reading with line numbers, offset, and limitNous.Tools.FileWrite— file writing with auto parent directory creationNous.Tools.FileEdit— string replacement with uniqueness check andreplace_allNous.Tools.FileGlob— file pattern matching sorted by modification timeNous.Tools.FileGrep— content search with ripgrep fallback to pure Elixir regex
Nous.Permissions— Tool-level permission policy engine complementing InputGuard:- Three presets:
default_policy/0,permissive_policy/0,strict_policy/0 build_policy/1— custom policies with:deny,:deny_prefixes,:approval_requiredblocked?/2,requires_approval?/2— case-insensitive tool name checkingfilter_tools/2,partition_tools/2— filter tool lists through policies
- Three presets:
Nous.Session.ConfigandNous.Session.Guardrails— session-level turn limits and token budgets:Configstruct withmax_turns,max_budget_tokens,compact_after_turnsGuardrails.check_limits/4— returns:okor{:error, :max_turns_reached | :max_budget_reached}Guardrails.remaining/4,Guardrails.summary/4— budget tracking and reporting
Fixed
- Empty stream silent failure:
run_streamnow emits{:error, :empty_stream}+ warning when a provider returns zero events (e.g. minimax), instead of silently yielding{:complete, %{output: ""}}. Memory.Searchcrash on vector search error:{:ok, results} = store_mod.search_vector(...)pattern match replaced withcase— logs warning and returns empty list on error.- Atom table exhaustion in skill loader:
String.to_atom/1replaced withString.to_existing_atom/1+ rescue fallback with debug logging. - Context deserialization crash on unknown roles:
String.to_existing_atom/1replaced with explicit role whitelist (:system,:user,:assistant,:tool), defaults to:userwith warning. - Unbounded inspect in stream normalizer:
inspect(chunk, limit: :infinity)capped tolimit: 500, printable_limit: 1000. - SQLite embedding decode crash:
JSON.decode!/1wrapped in rescue, returnsnilwith warning on malformed data. - Muninn bare rescue:
rescue _ ->replaced with specific exception types (MatchError,File.Error,ErlangError,RuntimeError).
Documentation
- Memory System Guide (
docs/guides/memory.md) — 630+ line walkthrough covering all 6 store backends, search/scoring, BM25, agent integration, and cross-agent memory sharing. - Context & Dependencies Guide (
docs/guides/context.md) — RunContext, ContextUpdate operations, stateful agent walkthrough, multi-user patterns. - Skills Guide enhanced — added 400+ lines: module-based and file-based skill walkthroughs, skill groups, activation modes, plugin configuration.
- LiveView examples — chat interface (
liveview_chat.exs) and multi-agent dashboard (liveview_multi_agent.exs) reference implementations. - PostgreSQL memory example (
postgresql_full.exs) — end-to-end Store implementation with tsvector + pgvector, BM25 search, hybrid RRF search. - Coding agent example (
19_coding_agent.exs) — permissions, tools, guardrails, and transcript compaction. - Tool permissions example (
tool_permissions.exs) — policy presets, custom deny lists, tool filtering.
0.13.0 - 2026-03-28
Added
Nous.Workflow— DAG/graph-based workflow engine for orchestrating agents, tools, and control flow as executable directed graphs. Complements Decisions (reasoning tracking) and Teams (persistent agent groups).- Builder API:
Ecto.Multi-style pipes —Workflow.new/1 |> add_node/4 |> connect/3 |> chain/2 |> run/2 - 8 node types:
:agent_step,:tool_step,:transform,:branch,:parallel,:parallel_map,:human_checkpoint,:subworkflow - Hand-rolled graph: dual adjacency maps, Kahn's algorithm for topological sort + cycle detection + parallel execution levels in one O(V+E) pass
- Static parallel: named branches fan-out concurrently via
Task.Supervisor - Dynamic
parallel_map: runtime fan-out over data lists withmax_concurrencythrottling — the scatter-gather pattern - Cycle support: edge-following execution with per-node max-iteration guards for retry/quality-gate loops
- Workflow hooks:
:pre_node,:post_node,:workflow_start,:workflow_end— integrates with existingNous.Hookstruct - Pause/resume: via hook (
{:pause, reason}),:atomicsexternal signal, or:human_checkpointauto-suspend - Error strategies:
:fail_fast,:skip,{:retry, max, delay},{:fallback, node_id}per node - Telemetry:
[:nous, :workflow, :run|:node, :start|:stop|:exception]events - Execution tracing: opt-in per-node timing and status recording (
trace: true) - Checkpointing:
Checkpointstruct +Storebehaviour + ETS backend - Subworkflows: nested workflow invocation with
input_mapper/output_mapperfor data isolation - Runtime graph mutation:
on_node_completecallback,Graph.insert_after/6,Graph.remove_node/2 - Mermaid visualization:
Workflow.to_mermaid/1generates flowchart diagrams with type-specific node shapes - Scratch ETS: optional per-workflow ETS table for large/binary data exchange between steps
- 113 new tests covering all workflow features
- Builder API:
0.12.17 - 2026-03-28
Removed
- Dead module
Nous.Decisions.Tools: 4 tool functions never used by any plugin or code path. - Dead module
Nous.StreamNormalizer.Mistral: Mistral provider uses the default OpenAI-compatible normalizer. - Dead function
emit_fallback_exhausted/3in Fallback module: Defined but never called. - Dead config
enable_telemetry: Set in config files but never read — telemetry is always on. - Dead config
log_level: Set in dev/test configs but never read by Nous. - Unused test fixtures:
NousTest.Fixtures.LLMResponsesand its generator script (generated Oct 2025, never imported).
Fixed
- Compiler warning in
output_schema.ex: Removed always-truthy conditional aroundto_json_schema/1return value.
Changed
- All JSON encoding/decoding uses built-in
JSONmodule instead ofJason. Jason removed from direct dependencies. - Added
pretty_encode!/1helper to internal JSON module for pretty-printed JSON output (used in LLM prompts and eval reports). - Updated README with Elixir 1.18+ / OTP 27+ requirements.
0.12.16 - 2026-03-28
Fixed
- Anthropic multimodal messages silently lost image data:
message_to_anthropic/1matched oncontentbeing a list, butMessage.user/2stores content parts inmetadata.content_partsas a string. Multimodal messages were sent as plain text, losing all image data. Now reads from metadata like the OpenAI formatter. - Gemini multimodal messages had the same issue: Same pattern match bug caused all image content to be dropped.
- Anthropic image format incorrect: The
datafield contained the full data URL prefix (data:image/jpeg;base64,...) instead of raw base64;media_typewas hardcoded to"image/jpeg"regardless of actual format; HTTP URLs were incorrectly wrapped as base64 source instead of"type": "url". - Gemini had no image support: All non-text content parts fell through to a
[Image: ...]text representation. Now usesinlineDatafor base64 images andfileDatafor HTTP URLs. - Anthropic duplicate thinking block: Assistant messages with reasoning content emitted the
thinkingblock twice.
Added
ContentPart.parse_data_url/1— extract MIME type and raw base64 data from a data URL string.ContentPart.data_url?/1andContentPart.http_url?/1— URL type predicates.- OpenAI formatter:
:imagecontent type support (converts to data URL) anddetailoption passthrough forimage_urlparts. - Comprehensive vision test pipeline (
test/nous/vision_pipeline_test.exs) with 19 unit tests covering format conversion across all providers and 4 LLM integration tests. - Test fixture images:
test_square.png(100x100 red),test_tiny.webp(minimal WebP).
0.12.15 - 2026-03-26
Fixed
receive_timeoutsilently dropped inNous.LLM:generate_text/3andstream_text/3with a string model only passed[:base_url, :api_key, :llamacpp_model]toModel.parse, soreceive_timeoutwas silently ignored. Now correctly forwarded.
Removed
- Dead timeout config: Removed unused
default_timeoutandstream_timeoutfromconfig/config.exs. Timeouts are determined by per-provider defaults inModel.default_receive_timeout/1and each provider module's@default_timeout/@streaming_timeoutconstants.
Documentation
- Added "Timeouts" section to README documenting
receive_timeoutoption and default timeouts per provider.
0.12.14 - 2026-03-21
Added
Hooks system: Granular lifecycle interceptors for tool execution and request/response flow.
- 6 lifecycle events:
pre_tool_use,post_tool_use,pre_request,post_response,session_start,session_end - 3 handler types:
:function(inline),:module(behaviour),:command(shell via NetRunner) - Matcher-based dispatch: string (exact tool name), regex, or predicate function
- Blocking semantics for
pre_tool_useandpre_request— hooks can deny or modify tool calls - Priority-based execution ordering (lower = earlier)
Telemetry events:
[:nous, :hook, :execute, :start | :stop],[:nous, :hook, :denied]Nous.Hook,Nous.Hook.Registry,Nous.Hook.Runner- New option on
Nous.Agent.new/2::hooks - New example:
examples/16_hooks.exs
- 6 lifecycle events:
Skills system: Reusable instruction/capability packages for agents.
- Module-based skills with
use Nous.Skillmacro and behaviour callbacks - File-based skills: markdown files with YAML frontmatter, loaded from directories
- 5 activation modes:
:manual,:auto,{:on_match, fn},{:on_tag, tags},{:on_glob, patterns} - Skill groups:
:coding,:review,:testing,:debug,:git,:docs,:planning - Registry with load/unload, activate/deactivate, group operations, and input matching
Nous.Plugins.Skills— auto-included plugin bridging skills into the agent lifecycle- Directory scanning:
skill_dirs:option andNous.Skill.Registry.register_directory/2 Telemetry events:
[:nous, :skill, :activate | :deactivate | :load | :match]- New options on
Nous.Agent.new/2::skills,:skill_dirs - New example:
examples/17_skills.exs - New guides:
docs/guides/skills.md,docs/guides/hooks.md
- Module-based skills with
21 built-in skills:
- Language-agnostic (10): CodeReview, TestGen, Debug, Refactor, ExplainCode, CommitMessage, DocGen, SecurityScan, Architect, TaskBreakdown
- Elixir-specific (5): PhoenixLiveView, EctoPatterns, OtpPatterns, ElixirTesting, ElixirIdioms
- Python-specific (6): PythonFastAPI, PythonTesting, PythonTyping, PythonDataScience, PythonSecurity, PythonUv
NetRunner dependency (
~> 1.0.4): Zero-zombie-process OS command execution for command hooks with SIGTERM→SIGKILL timeout escalation.76 new tests for hooks and skills systems.
0.12.13 - 2026-03-20
Added
custom:provider (Nous.Providers.Custom): first-class prefix for any OpenAI-compatible endpoint, withCUSTOM_API_KEY/CUSTOM_BASE_URLenvironment-variable support. This is now the documented/recommended approach for custom endpoints.- Configuration precedence (highest to lowest): direct options to
Nous.new/2→ environment variables → application config (config :nous, :custom, ...) → defaults.
- Configuration precedence (highest to lowest): direct options to
- Custom Providers guide (
docs/guides/custom_providers.md) andexamples/providers/custom_providers.exs.
Changed
Model.parse/2accepts theopenai_compatible:prefix as a backward-compatible alias forcustom:(both route to the:customprovider);ModelDispatchergained an explicit:customclause.- Expanded documentation across
Model,OpenAICompatible, and the README;vllm_sglang.exsnow points tocustom:as the recommended approach.
0.12.12 - 2026-03-19
Fixed
- Unbounded atom creation in
atomize_keys/1(security): untrusted keys no longer create atoms dynamically. - ETS table race condition in
Persistence.ETS.ensure_table/0. - Double recency penalization in memory search scoring.
clear_historynow stays in sync with the persistence backend.
Added
{:error, reason}handling inrecall/2andSearch.search.Nous.Memory.Scope— shared scope logic extracted from the memory modules.- AgentServer tests (16) and Summarization plugin tests (8).
Removed
- Dead code in
do_memory_reflection.
0.12.11 - 2026-03-19
Added
- Per-run structured output override: Pass
output_type:andstructured_output:as options toNous.Agent.run/3andNous.Agent.run_stream/3to override the agent's defaults per call. The same agent can return raw text or structured data depending on the request. - Multi-schema selection (
{:one_of, [SchemaA, SchemaB]}): New output_type variant where the LLM dynamically chooses which schema to use per response. Each schema becomes a synthetic tool — the LLM's tool choice acts as schema selection. Includes automatic retry and validation against the selected schema.OutputSchema.schema_name/1— public helper to get snake_case name for a schema moduleOutputSchema.tool_name_for_schema/1— build synthetic tool name from schema moduleOutputSchema.find_schema_for_tool_name/2— reverse-map tool name to schema moduleOutputSchema.synthetic_tool_name?/1— predicate for synthetic tool call detectionOutputSchema.extract_response_for_one_of/2— extract text and identify matched schema from tool call- New example: Example 6 (per-run override) and Example 7 (multi-schema) in
examples/14_structured_output.exs - New sections in
docs/guides/structured_output.md
Fixed
- Synthetic tool call handling: Structured output tool calls (
__structured_output__) in:tool_callmode are now correctly filtered from the tool execution loop. Previously, these synthetic calls would produce "Tool not found" errors and cause an unnecessary extra LLM round-trip. Now they terminate the loop immediately and the structured output is extracted directly.
0.12.10 - 2026-03-19
Added
- Fallback model/provider support: Automatic failover to alternative models when the primary model fails with a
ProviderErrororModelError(rate limit, server error, timeout, auth issue).Nous.Fallback— core fallback logic: eligibility checks, recursive model chain traversal, model string/struct parsing:fallbackoption onNous.Agent.new/2— ordered list of fallback model strings orModelstructs:fallbackoption onNous.generate_text/3andNous.stream_text/3- Tool schemas are automatically re-converted when falling back across providers (e.g., OpenAI → Anthropic)
- Structured output settings are re-injected for the target provider on cross-provider fallback
- Agent model is swapped on successful fallback so remaining iterations use the working model
- Streaming fallback retries stream initialization only, not mid-stream failures
- New telemetry events:
[:nous, :fallback, :activated]and[:nous, :fallback, :exhausted] - Only
ProviderErrorandModelErrortrigger fallback; application-level errors (ValidationError,MaxIterationsExceeded,ExecutionCancelled,ToolError) are returned immediately - 52 new tests across
test/nous/fallback_test.exsandtest/nous/agent_fallback_test.exs
Changed
Nous.Agentstruct gainsfallback: [Model.t()]field (default:[])Nous.LLMnow uses injectable dispatcher (get_dispatcher/0) for testability, consistent withAgentRunner
0.12.9 - 2026-03-12
Added
- InputGuard plugin: Modular malicious input classifier with pluggable strategy pattern. Detects prompt injection, jailbreak attempts, and other malicious inputs before they reach the LLM.
Nous.Plugins.InputGuard— Main plugin with configurable aggregation (:any/:majority/:all), short-circuit mode, and violation callbacksNous.Plugins.InputGuard.Strategy— Behaviour for custom detection strategiesNous.Plugins.InputGuard.Strategies.Pattern— Built-in regex patterns for instruction override, role reassignment, DAN jailbreaks, prompt extraction, and encoding evasion. Supports:extra_patterns(additive) and:patterns(full override)Nous.Plugins.InputGuard.Strategies.LLMJudge— Secondary LLM classification with fail-open/fail-closed modesNous.Plugins.InputGuard.Strategies.Semantic— Embedding cosine similarity against pre-computed attack vectorsNous.Plugins.InputGuard.Policy— Severity-to-action resolution (:block,:warn,:log,:callback, customfun/2)- Tracks checked message index to prevent re-triggering on tool-call loop iterations
- New example:
examples/15_input_guard.exs
Fixed
- AgentRunner:
before_requestplugin hook now short-circuits the LLM call when a plugin setsneeds_response: false(e.g., InputGuard blocking). Previously the current iteration would still call the LLM before the block took effect on the next iteration.
0.12.8 - 2026-03-12
Fixed
- Vertex AI v1/v1beta1 bug:
Model.parse("vertex_ai:gemini-2.5-pro-preview-06-05")withGOOGLE_CLOUD_PROJECTset was storing a hardcodedv1URL inmodel.base_url, causing the provider'sv1beta1selection logic to be bypassed. Preview models now correctly usev1beta1at request time.
Added
- Vertex AI input validation: Project ID and region from environment variables are now validated with helpful error messages instead of producing opaque DNS/HTTP errors.
GOOGLE_CLOUD_LOCATIONsupport: Added as a fallback forGOOGLE_CLOUD_REGION, consistent with other Google Cloud libraries and tooling.- Multi-region example script:
examples/providers/vertex_ai_multi_region.exs
0.12.7 - 2026-03-10
Fixed
- Vertex AI model routing: Fixed
build_request_params/3not including the"model"key in the params map, causingchat/2andchat_stream/2to always fall back to"gemini-2.0-flash"regardless of the requested model. - Vertex AI 404 on preview models: Use
v1beta1API version for preview and experimental models (e.g.,gemini-3.1-pro-preview). Thev1endpoint returns 404 for these models.
Added
Nous.Providers.VertexAI.api_version_for_model/1— returns"v1beta1"for preview/experimental models,"v1"for stable models.Nous.Providers.VertexAI.endpoint/3now accepts an optional model name to select the correct API version.- Debug logging for Vertex AI request URLs.
0.12.6 - 2026-03-07
Added
- Auto-update memory:
Nous.Plugins.Memorycan now automatically reflect on conversations and update memories after each run — no explicit tool calls needed. Enable withauto_update_memory: trueinmemory_config. Configurable reflection model, frequency, and context limits.- New
after_run/3callback inNous.Pluginbehaviour — runs once after the entire agent run completes. Wired into bothAgentRunner.run/3andrun_with_context/3. Nous.Plugin.run_after_run/4helper for executing the hook across all plugins- New config options:
:auto_update_memory,:auto_update_every,:reflection_model,:reflection_max_tokens,:reflection_max_messages,:reflection_max_memories - New example:
examples/memory/auto_update.exs
- New
0.12.5 - 2026-03-06
Added
- Vertex AI provider:
Nous.Providers.VertexAIfor accessing Gemini models through Google Cloud Vertex AI. Supports enterprise features (VPC-SC, CMEK, regional endpoints, IAM).- Three auth modes: app config Goth (
config :nous, :vertex_ai, goth: MyApp.Goth), per-model Goth (default_settings: %{goth: MyApp.Goth}), or direct access token (api_key/VERTEX_AI_ACCESS_TOKEN) - Bearer token auth via
api_keyoption,VERTEX_AI_ACCESS_TOKENenv var, or Goth integration - Goth integration (
{:goth, "~> 1.4", optional: true}) for automatic service account token management — reuse existing Goth processes from PubSub, etc. - URL auto-construction from
GOOGLE_CLOUD_PROJECTandGOOGLE_CLOUD_REGIONenv vars Nous.Providers.VertexAI.endpoint/2helper to build endpoint URLs- Reuses existing Gemini message format, response parsing, and stream normalization
- Model string:
"vertex_ai:gemini-2.0-flash"
- Three auth modes: app config Goth (
0.12.2 - 2026-03-04
Fixed
- Gemini streaming: Fixed streaming responses returning 0 events. The Gemini
streamGenerateContentendpoint returns a JSON array (application/json) by default, not Server-Sent Events. Instead of forcing SSE viaalt=ssequery parameter, added a pluggable stream parser toNous.Providers.HTTP.
Added
Nous.Providers.HTTP.JSONArrayParser— stream buffer parser for JSON array responses. Extracts complete JSON objects from a streaming[{...},{...},...]response by tracking{}nesting depth while respecting string literals and escape sequences.:stream_parseroption onHTTP.stream/4— accepts any module implementingparse_buffer/1with the same{events, remaining_buffer}contract as SSE parsing. Defaults to the existing SSE parser. Enables any provider with a non-SSE streaming format to plug in a custom parser.
0.12.0 - 2026-02-28
Added
Memory System: Persistent memory for agents with hybrid text + vector search, temporal decay, importance weighting, and flexible scoping.
Nous.Memory.Entry— memory entry struct with type (semantic/episodic/procedural), importance, evergreen flag, and scoping fields (agent_id, session_id, user_id, namespace)Nous.Memory.Store— storage behaviour with 8 callbacks (init, store, fetch, delete, update, search_text, search_vector, list)Nous.Memory.Store.ETS— zero-dep in-memory backend with Jaro-distance text searchNous.Memory.Store.SQLite— SQLite + FTS5 backend (requiresexqlite)Nous.Memory.Store.DuckDB— DuckDB + FTS + vector backend (requiresduckdbex)Nous.Memory.Store.Muninn— Tantivy BM25 text search backend (requiresmuninn)Nous.Memory.Store.Zvec— HNSW vector search backend (requireszvec)Nous.Memory.Store.Hybrid— combines Muninn + Zvec for maximum retrieval qualityNous.Memory.Scoring— pure functions for Reciprocal Rank Fusion, temporal decay, composite scoringNous.Memory.Search— hybrid search orchestrator (text + vector → RRF merge → decay → composite score)Nous.Memory.Embedding— embedding provider behaviour with pluggable implementationsNous.Memory.Embedding.Bumblebee— local on-device embeddings via Bumblebee + EXLA (Qwen 0.6B default)Nous.Memory.Embedding.OpenAI— OpenAI text-embedding-3-small providerNous.Memory.Embedding.Local— generic local endpoint (Ollama, vLLM, LMStudio)Nous.Memory.Tools— agent tools:remember,recall,forgetNous.Plugins.Memory— plugin with auto-injection of relevant memories, configurable search scope and injection strategy- 6 example scripts in
examples/memory/(basic ETS, Bumblebee, SQLite, DuckDB, Hybrid, cross-agent) - 62 new tests across 6 test files
Graceful degradation: No embedding provider = keyword-only search. No optional deps =
Store.ETSwith Jaro matching. The core memory system has zero additional dependencies.
0.11.3 - 2026-02-26
Fixed
- Anthropic and Gemini streaming: Added missing
Nous.StreamNormalizer.AnthropicandNous.StreamNormalizer.Geminimodules. These were referenced inProvider.default_stream_normalizer/0but never created, causing runtime crashes when streaming with Anthropic or Gemini providers.
Added
Nous.StreamNormalizer.Anthropic— normalizes Anthropic SSE events (content_block_delta,message_delta,content_block_startfor tool use, thinking deltas, error events)Nous.StreamNormalizer.Gemini— normalizes Gemini SSE events (candidatesarray with text parts,functionCall,finishReasonmapping)- 42 tests for both new stream normalizers
0.11.0 - 2026-02-20
Added
Structured Output Mode: Agents return validated, typed data instead of raw strings. Inspired by instructor_ex.
Nous.OutputSchemacore module: JSON schema generation, provider settings dispatch, parsing and validationuse Nous.OutputSchemamacro with@llm_docattribute for schema-level LLM documentationvalidate_changeset/1optional callback for custom Ecto validation rules- Validation retry loop: failed outputs are sent back to the LLM with error details (
max_retriesoption) - System prompt augmentation with schema instructions
Output Type Variants:
- Ecto schema modules — full JSON schema + changeset validation
- Schemaless Ecto types (
%{name: :string, age: :integer}) — lightweight, no module needed - Raw JSON schema maps (string keys) — passed through as-is
{:regex, pattern}— regex-constrained output (vLLM/SGLang){:grammar, ebnf}— EBNF grammar-constrained output (vLLM){:choice, choices}— choice-constrained output (vLLM/SGLang)
Provider Modes: Controls how structured output is enforced per-provider
:auto(default) — picks best mode for the provider:json_schema—response_formatwith strict JSON schema (OpenAI, vLLM, SGLang, Gemini):tool_call— synthetic tool with tool_choice (Anthropic default):json—response_format: json_object(OpenAI-compatible):md_json— prompt-only enforcement with markdown fence + stop token (all providers)
Provider Passthrough:
response_format,guided_json,guided_regex,guided_grammar,guided_choice,json_schema,regex,generationConfignow passed through inbuild_request_paramsNew Files:
lib/nous/output_schema.ex— core modulelib/nous/output_schema/validator.ex— behaviour definitionlib/nous/output_schema/use_macro.ex—use Nous.OutputSchemamacrodocs/guides/structured_output.md— comprehensive guideexamples/14_structured_output.exs— example script with 5 patternstest/nous/output_schema_test.exs— 42 unit teststest/nous/structured_output_integration_test.exs— 16 integration teststest/eval/agents/structured_output_test.exs— 3 LLM integration tests
Changed
Nous.Agentstruct gainsstructured_outputkeyword list field (mode, max_retries)Nous.Types.output_typeexpanded with schemaless, raw JSON schema, and guided mode tuplesNous.AgentRunnerinjects structured output settings, augments system prompt, handles validation retriesNous.Agents.BasicAgent.extract_output/2routes throughOutputSchema.parse_and_validate/2Nous.Agents.ReActAgent.extract_output/2validatesfinal_answeragainst output_type- Provider
build_request_params/3passes through structured output parameters
0.10.1 - 2026-02-14
Changed
Sub-Agent plugin unified: Merged
ParallelSubAgentintoNous.Plugins.SubAgent- Single plugin now provides both
delegate_task(single) andspawn_agents(parallel) tools system_prompt/2callback injects orchestration guidance including available templates- Templates accept
%Nous.Agent{}structs (recommended) or config maps (legacy) - Parallel execution via
Task.Supervisor.async_stream_nolink - Configurable concurrency (
parallel_max_concurrency, default: 5) and timeout (parallel_timeout, default: 120s) - Graceful partial failure: crashed/timed-out sub-agents don't block others
- Single plugin now provides both
New Example:
examples/13_sub_agents.exs- Template-based sub-agents using
Nous.Agent.new/2structs - Parallel execution with inline model config
- Direct programmatic invocation bypassing the LLM
- Template-based sub-agents using
0.10.0 - 2026-02-14
Added
Plugin System: Composable agent extensions via
Nous.Pluginbehaviour- Callbacks:
init/2,tools/2,system_prompt/2,before_request/3,after_response/3 - Add
plugins: [MyPlugin]to any agent for cross-cutting concerns - AgentRunner iterates plugins at each stage of the execution loop
- Callbacks:
Human-in-the-Loop (HITL): Approval workflows for sensitive tool calls
requires_approval: trueonNous.Toolstructapproval_handleronNous.Agent.Contextfor approve/edit/reject decisionsNous.Plugins.HumanInTheLoopfor per-tool configuration via deps
Sub-Agent System: Enable agents to delegate tasks to specialized child agents
Nous.Plugins.SubAgentprovidesdelegate_tasktool- Pre-configured agent templates via
deps[:sub_agent_templates] - Isolated context per sub-agent with shared deps support
Conversation Summarization: Automatic context window management
Nous.Plugins.Summarizationmonitors token usage against configurable threshold- LLM-powered summarization with safe split points (never separates tool_call/tool_result pairs)
- Error-resilient: keeps all messages if summarization fails
State Persistence: Save and restore agent conversation state
Nous.Agent.Context.serialize/1anddeserialize/1for JSON-safe round-tripsNous.Persistencebehaviour withsave/load/delete/listcallbacksNous.Persistence.ETSreference implementation- Auto-save hooks on
Nous.AgentServer
Enhanced Supervision: Production lifecycle management for agents
Nous.AgentRegistryfor session-based process lookup via RegistryNous.AgentDynamicSupervisorfor on-demand agent creation/destruction- Configurable inactivity timeout on
AgentServer(default: 5 minutes) - Added to application supervision tree
Dangling Tool Call Recovery: Resilient session resumption
Nous.Agent.Context.patch_dangling_tool_calls/1injects synthetic results for interrupted tool calls- Called automatically when continuing from an existing context
PubSub Abstraction Layer: Unified
Nous.PubSubmodule for all PubSub usageNous.PubSubwraps Phoenix.PubSub with graceful no-op fallback when unavailable- Application-level configuration via
config :nous, pubsub: MyApp.PubSub - Topic builders:
agent_topic/1,research_topic/1,approval_topic/1 Nous.Agent.Contextgainspubsubandpubsub_topicfields (runtime-only, never serialized)Nous.Agent.Callbacks.execute/3now broadcasts via PubSub as a third channel alongside callbacks andnotify_pidAgentServerrefactored to useNous.PubSub— removes ad-hocsetup_pubsub_functions/0andsubscribe_fn/broadcast_fnfrom state- Research Coordinator broadcasts progress via PubSub when
:session_idis provided - SubAgent plugin propagates parent's PubSub context to child agents
Async HITL Approval via PubSub:
Nous.PubSub.Approvalmodulehandler/1builds an approval handler compatible withNous.Plugins.HumanInTheLoop- Broadcasts
{:approval_required, info}and blocks viareceivefor response respond/4sends approval decisions from external processes (e.g., LiveView)- Configurable timeout with
:rejectas default on expiry - Enables async approval workflows without synchronous I/O
Deep Research Agent: Autonomous multi-step research with citations
Nous.Research.run/2public API with HITL checkpoints between iterations- Five-phase loop: plan → search → synthesize → evaluate → report
Nous.Research.Plannerdecomposes queries into searchable sub-questionsNous.Research.Searcherruns parallel search agents per sub-questionNous.Research.Synthesizerfor deduplication, contradiction detection, gap analysisNous.Research.Reportergenerates markdown reports with inline citations- Progress broadcasting via callbacks,
notify_pid, and PubSub
New Research Tools:
Nous.Tools.WebFetch— URL content extraction with Floki HTML parsingNous.Tools.Summarize— LLM-powered text summarization focused on research queriesNous.Tools.SearchScrape— Parallel fetch + summarize for multiple URLsNous.Tools.TavilySearch— Tavily AI search API integrationNous.Tools.ResearchNotes— Structured finding/gap/contradiction tracking via ContextUpdate
New Dependencies:
floki ~> 0.36(optional, for HTML content extraction)phoenix_pubsub ~> 2.1(test-only, for PubSub integration tests)
Changed
Nous.Agentstruct now acceptsplugins: [module()]optionNous.Toolstruct now acceptsrequires_approval: boolean()optionNous.Agent.Contextnow includesapproval_handler,pubsub, andpubsub_topicfieldsNous.AgentServersupports optional:nameregistration,:persistencebackend, and usesNous.PubSub(removed ad-hocsetup_pubsub_functions/0)Nous.AgentServer:pubsuboption now defaults toNous.PubSub.configured_pubsub()instead ofMyApp.PubSubNous.AgentRunneraccepts:pubsuband:pubsub_topicoptions when building context- Application supervision tree includes AgentRegistry and AgentDynamicSupervisor
0.9.0 - 2026-01-04
Added
Evaluation Framework: Production-grade testing and benchmarking for AI agents
Nous.Evalmodule for defining and running test suitesNous.Eval.Suitefor test suite management with YAML supportNous.Eval.TestCasefor individual test case definitionsNous.Eval.Runnerfor sequential and parallel test executionNous.Eval.Metricsfor collecting latency, token usage, and cost metricsNous.Eval.Reporterfor console and JSON result reporting- A/B testing support with
Nous.Eval.run_ab/2
Six Built-in Evaluators:
:exact_match- Strict string equality matching:fuzzy_match- Jaro-Winkler similarity with configurable thresholds:contains- Substring and regex pattern matching:tool_usage- Tool call verification with argument validation:schema- Ecto schema validation for structured outputs:llm_judge- LLM-based quality assessment with custom rubrics
Optimization Engine: Automated parameter tuning for agents
Nous.Eval.Optimizerwith three strategies: grid search, random search, Bayesian optimization- Support for float, integer, choice, and boolean parameter types
- Early stopping on threshold achievement
- Detailed trial history and best configuration reporting
New Mix Tasks:
mix nous.eval- Run evaluation suites with filtering, parallelism, and multiple output formatsmix nous.optimize- Parameter optimization with configurable strategies and metrics
New Dependency:
yaml_elixir ~> 2.9for YAML test suite parsing
Documentation
- New comprehensive evaluation framework guide (
docs/guides/evaluation.md) - Five new example scripts in
examples/eval/:01_basic_evaluation.exs- Simple test execution02_yaml_suite.exs- Loading and running YAML suites03_optimization.exs- Parameter optimization workflows04_custom_evaluator.exs- Implementing custom evaluators05_ab_testing.exs- A/B testing configurations
0.8.1 - 2025-12-31
Fixed
- Fixed
Usagestruct not implementing Access behaviour for telemetry metrics - Fixed
Task.shutdown/2nil return case inAgentServercancellation - Fixed tool call field access for OpenAI-compatible APIs (string vs atom keys)
Added
- Vision/multimodal test suite with image fixtures (
test/nous/vision_test.exs) - ContentPart test suite for image conversion utilities (
test/nous/content_part_test.exs) - Multimodal message examples in conversation demo (
examples/04_conversation.exs)
Changed
- Updated docs to link examples to GitHub source files
- Improved sidebar grouping in hexdocs
0.8.0 - 2025-12-31
Added
Context Management: New
Nous.Agent.Contextstruct for immutable conversation state, message history, and dependency injection. Supports context continuation between runs:{:ok, result1} = Nous.run(agent, "My name is Alice") {:ok, result2} = Nous.run(agent, "What's my name?", context: result1.context)Agent Behaviour: New
Nous.Agent.Behaviourfor implementing custom agents with lifecycle callbacks (init_context/2,build_messages/2,process_response/3,extract_output/2).Dual Callback System: New
Nous.Agent.Callbackssupporting both map-based callbacks and process messages:# Map callbacks Nous.run(agent, "Hello", callbacks: %{ on_llm_new_delta: fn _event, delta -> IO.write(delta) end }) # Process messages (for LiveView) Nous.run(agent, "Hello", notify_pid: self())Module-Based Tools: New
Nous.Tool.Behaviourfor defining tools as modules withmetadata/0andexecute/2callbacks. UseNous.Tool.from_module/2to create tools from modules.Tool Context Updates: New
Nous.Tool.ContextUpdatestruct allowing tools to modify context state:def my_tool(ctx, args) do {:ok, result, ContextUpdate.new() |> ContextUpdate.set(:key, value)} endTool Testing Helpers: New
Nous.Tool.Testingmodule withmock_tool/2,spy_tool/1, andtest_context/1for testing tool interactions.Tool Validation: New
Nous.Tool.Validatorfor JSON Schema validation of tool arguments.Prompt Templates: New
Nous.PromptTemplatefor EEx-based prompt templates with variable substitution.Built-in Agent Implementations:
Nous.Agents.BasicAgent(default) andNous.Agents.ReActAgent(reasoning with planning tools).Structured Errors: New
Nous.Errorsmodule withMaxIterationsReached,ToolExecutionError, andExecutionCancellederror types.Enhanced Telemetry: New events for iterations (
:iteration), tool timeouts (:tool_timeout), and context updates (:context_update).
Changed
Result Structure:
Nous.run/3now returns%{output: _, context: _, usage: _}instead of just output string.Tool Function Signature: Tools now receive
(ctx, args)instead of(args). The context provides access toctx.depsfor dependency injection.Examples Modernized: Reduced from ~95 files to 21 files. Flattened directory structure from 4 levels to 2 levels. All examples updated to v0.8.0 API.
Removed
Removed deprecated provider modules:
Nous.Providers.Gemini,Nous.Providers.Mistral,Nous.Providers.VLLM,Nous.Providers.SGLang.Removed built-in tools:
Nous.Tools.BraveSearch,Nous.Tools.DateTimeTools,Nous.Tools.StringTools,Nous.Tools.TodoTools. These can be implemented as custom tools.Removed
Nous.RunContext(replaced byNous.Agent.Context).Removed
Nous.PromEx.Plugin(users can implement custom Prometheus metrics using telemetry events).
0.7.2 - 2025-12-29
Fixed
Stream completion events: The
[DONE]SSE event now properly emits a{:finish, "stop"}event instead of being silently discarded. This ensures stream consumers always receive a completion signal.Documentation links: Fixed broken links in hexdocs documentation. Relative links to
.exsexample files now use absolute GitHub URLs so they work correctly on hexdocs.pm.
0.7.1 - 2025-12-29
Changed
Make all provider dependencies optional:
openai_ex,anthropix, andgemini_exare now truly optional dependencies. Users only need to install the dependencies for the providers they use.Runtime dependency checks: Provider modules now check for dependency availability at runtime instead of compile-time, allowing the library to compile without any provider-specific dependencies.
OpenAI message format: Messages are now returned as plain maps with string keys (
%{"role" => "user", "content" => "Hi"}) instead ofOpenaiEx.ChatMessagestructs. This removes the compile-time dependency onopenai_exfor message formatting.
Fixed
Fixed "anthropix dependency not available" errors that occurred when using the library in applications without
anthropixinstalled.Fixed compile-time errors that occurred when
openai_exwas not present in the consuming application.
0.7.0 - 2025-12-27
Initial public release with multi-provider LLM support:
- OpenAI-compatible providers (OpenAI, Groq, OpenRouter, Ollama, LM Studio, vLLM)
- Native Anthropic Claude support with extended thinking
- Google Gemini support
- Mistral AI support
- Tool/function calling
- Streaming support
- ReAct agent implementation