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.Zvec and Nous.Memory.Store.Hybrid are deleted. They called an API that no published version of muninn or zvec has 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-level Muninn whose only export is hello/0; the real surface is Muninn.Index / IndexWriter / Searcher, and muninn 0.4.0 has no top-level module at all. zvec's surface is Zvec.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.exs is 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.Store is 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 and Nous.Memory.Search all dispatch on the module handed to them in deps: %{memory_config: %{store: MyApp.MyStore}} — and examples/memory/postgresql_full.exs has been a working out-of-tree implementation (Postgres tsvector + 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/3 is 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.Results is 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 of search_text/3 for any index-plus-entry-table backend.
    • Nous.Memory.Store.Conformance ships in lib/ (moved from test/support/, renamed from Nous.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 the Nous namespace through the plugin and the memory tools, and pins the feature detection in both directions.

Fixed

  • Nous.Eval.Evaluators.FuzzyMatch scored everything wrong. Two off-by-one errors in the hand-rolled Levenshtein fold drove similarity negative; an exact match scored 0.333 and therefore failed the default 0.8 threshold. Rewritten as a two-row DP over graphemes (the old nested Enum.at loop was also ~O(n³)), with calculate_similarity/2 clamped 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 as sqlite-vec — that string appeared exactly once in all of lib/, 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 is list_cosine_similarity in SQL, also a scan. Store.DuckDB's text search was documented as the FTS extension; INSTALL fts / LOAD fts are issued with the result discarded and no query references it, so it is an ILIKE substring 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 internal execute_with_timeout kills the tool process on the deadline and then raises Nous.Errors.ToolTimeout, which the generic rescue clause routed into handle_execution_error/7 — the retry path. With retries defaulting to 1, every timeout ran the tool a second time: measured on the real bash tool, a 120-second command asked for at the then-30-second deadline came back as attempt: 2 after 60 seconds. bash is requires_approval: true and side-effecting, so a human who approved one git push, rm or 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/3 runs once in execute/3 before 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/2 marks a context approval_gated?: true because the runner already ran the approval pipeline for the call it is dispatching — correct for run_code itself, and wrong for every tool the program then calls. Passed through unchanged, one approval of "run this program" silently authorised every Bash, FileWrite and FileEdit the program reached: the handler was never consulted and the tool ran. Approving a run_code call now approves running that program only. Each sub-call to a tool with requires_approval: true consults 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.HumanInTheLoop no longer auto-approves tools outside its :tools list. The handler is only ever invoked for tools already flagged requires_approval: true, so filtering it by the configured :tools list sent every other approval-gated tool down an else -> :approve branch. Configuring HITL with tools: ["send_email"] therefore flipped Bash, FileWrite, and FileEdit from 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; :tools still tags those tools as approval-requiring, but can no longer narrow the gate.

  • Approval enforcement is now structural rather than positional. requires_approval was only checked inside Nous.AgentRunner, so the three other paths to a tool — Nous.LLM's tool loop, Nous.Workflow :tool_step, and any direct Nous.ToolExecutor.execute/3 call — executed Bash / FileWrite / FileEdit with no approval, permission policy, or hooks. In the workflow case, model-authored :agent_step output reached /bin/sh -c unattended. %Nous.RunContext{} gains :approval_handler and :approval_gated?, and ToolExecutor.execute/3 now 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.WebFetch bounds 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 via ctx.deps[:web_fetch_max_bytes] or config :nous, :web_fetch_max_bytes; a model-supplied max_bytes argument may only lower the ceiling, never raise it) and rejects anything that is not text/html, application/xhtml+xml, or text/plain. A missing content-type fails closed. The module previously had zero tests; its redirect re-validation, metadata-IP blocking, redirect cap, and relative-Location handling are now covered.

  • Dependency advisories cleared. mix deps.update req finch mint hpax ecto hackney resolves 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 via WebFetch) 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 in mix.exs changed. The only remaining advisories reach the build through bypass (only: [:dev, :test]) and never ship to consumers.

  • Nous.AgentServer no longer amplifies its own PubSub traffic. init/1 subscribes the server to the topic it publishes on, and Phoenix.PubSub.broadcast/3 does not exclude the sender — so the handle_info clauses that re-broadcast runner notifications received their own message and republished it, forever. Any app that actually set config :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/3 already 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 an AgentServer, which is why this never fired in CI; one does now.

  • OS-level confinement for tool subprocesses: Nous.Sandbox. Nous.Tools.Bash handed the model /bin/sh -c as the OS user, with Nous.Permissions and approval as the only gate — nothing constrained what the shell touched once it was running, and Nous.Tools.PathGuard fences only the file tools. There is now a provider behaviour that wraps argv so the kernel enforces a policy, with Nous.Sandbox.Seatbelt (macOS sandbox-exec) and Nous.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 with config :nous, :sandbox_mode. confine/2 is a pure argv builder; enforcement is data, and Nous.Sandbox.classify/3 distinguishes "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: Bash would 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 with config :nous, :sandbox_confine_command_hooks, true. Nous.Tools.FileGrep is a documented exemption: neither provider restricts reads, so confining a process that only ever reads adds no enforcement.

  • Nous.Tools.PathGuard no longer follows a symlink out of the workspace via ... resolve_real/1 began with Path.expand/1, which collapses .. lexically before any symlink is resolved. With link -> /etc inside 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/2 compounded it by handing the resolver the already-expanded path. The resolver now starts from Path.absname/1 (absolute, .. intact) and applies ./.. to the already-resolved prefix, component by component, as the kernel does; validate/2 passes it the uncollapsed path. The same traversal via link/passwd was already blocked, and benign in-workspace .. still resolves. resolve_real/1 is now public, shared with Nous.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.Bash was silently discarding every byte of stderr. It ran under NetRunner's default stderr: :consume, which reads stderr into an internal buffer with no accessor, so run/2 returned 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 via Nous.Sandbox.merge_stderr/1 (/bin/sh -c 'exec "$@" 2>&1', argv passed positionally, so no quoting surface). Note that NetRunner's documented stderr: :redirect option is not implemented in net_runner 1.0 and is worse than the default — it also disables the :consume drain, leaving an unread stderr pipe that deadlocks a child which writes more than a pipe buffer.

  • Nous.Tools.Bash never actually scrubbed its environment. The tool passed env: Nous.Tools.Env.scrubbed() to NetRunner, which has no :env option: unknown options reach a port layer that ignores them and the shepherd execvps, 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/1 prefixes /usr/bin/env -i plus the allowlisted NAME=VALUE pairs (argv elements, so no shell parses them). Nous.Tools.Env.scrubbed_overrides/0 fixes the sibling bug for System.cmd/3 callers such as Nous.Tools.FileGrep: Erlang's {env, _} merges rather than replaces, so listing the allowlist left OPENAI_API_KEY in place — only {name, nil} removes a variable. Measured: the child's environment went from 73 names (including the secret) to 9.

  • Nous.Tools.Bash rejects a NUL byte in command. 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. So git push origin main\0 --dry-run was 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.Policy rejects a NUL in workspace_root for 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/3 checks runner failure before denial, which is right, but macOS refuses a nested-sandbox escape with sandbox-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, so catting 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.Bash also 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/1 refused legitimate deep paths. The hop budget was spent by ordinary directory components, so a symlink-free 34-deep path returned {:error, :symlink_loop} and validate/2 reported a symlink loop that did not exist. It counts symlink hops now, the way realpath(3) counts them before ELOOP; loop detection is unchanged. This mattered beyond the confusing error: Nous.Sandbox.Policy.canonical/1 swallows the error and falls back to the lexical Path.expand/1 the resolver exists to avoid, so a deep workspace root silently produced a non-canonical SBPL (subpath …) that the kernel never matches — degrading :workspace_write to :read_only.

  • Sandbox hardening from the review pass. Nous.Sandbox.Policy refuses workspace_root: "/", which re-allowed the entire filesystem under :workspace_write while every log line still said "confined" — reachable by accident, since the root defaults to File.cwd!/0. Nous.Sandbox.Bwrap adds --unshare-pid: --proc without 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/bwrap rather than a bare name resolved through an inherited PATH full of user-writable directories. Nous.Sandbox.Seatbelt grants /dev/stdout, /dev/stderr, /dev/tty and /dev/fd — all denied before, so cmd > /dev/stdout and tee /dev/stderr failed on macOS while succeeding under bwrap. Both providers' probe/1 now 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 :deny regardless of fail_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), and Nous.Tools.Bash's cgroup path is flat because the shepherd's mkdir is 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 default empty_values: [""], so content: "" was treated as absent and became nil. Message.assistant/2 builds its struct directly and kept "", while Message.new/1 dropped it — so the same logical message differed by which constructor made it, and Context.deserialize/1 goes through new!/1. Every save/restore therefore rewrote the content: "" that a pure tool-call turn carries into content: 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/1 rejected 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 :assistant in both its forms ("" for OpenAI/Gemini, nil for 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" is nil and "the model sent an empty string" is "", and the two are no longer conflated.

  • Compaction no longer destroys history. Nous.Agent.Context is now backed by an append-only event log (Nous.Session.Log) whose model-visible surface is a pure fold. ctx.messages is materialized from that fold and kept in lockstep, so every existing reader — including result.messages, result.all_messages and result.new_messages — is byte-identical. The log is internal. What it buys immediately: Nous.Plugins.Summarization appends 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.Memory and Plugins.KnowledgeBase carry a source marker so injected context is distinguishable from conversation, and patch_dangling_tool_calls/1's synthetic results are events). Context.serialize/1 is version: 2 and 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 made Context.last_message/1 and output extraction disagree with the log, turning a run whose model replied with empty content — a content filter, a max_tokens cutoff, 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/2 and followup/2 are new public API on top of Nous.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. AgentServer gained an explicit run_state so "is a run in flight" has one answer — it was previously spread across five handlers while an async_nolink task 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_step rejection reuses the existing :pre_request hook rather than adding a second mechanism, since a step is one request.

  • A crashed run no longer loses or invents history. Nous.Session.Recovery repairs an orphaned :turn_start by appending — never deleting or rewriting — synthetic risk-classified :tool_result events plus a :turn_end with reason :interrupted, the one reason no live loop emits, so its presence is unambiguous evidence of a crash. Ambiguity always resolves to :tool_outcome_unknown rather 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 second rm -rf happens. Recovery is idempotent and leaves a clean log untouched. Nous.Session.fork/2 copies 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.PubSub topics are reused; the publish is a no-op when no pubsub is configured, is driven by a count delta through the new Log.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.Invariant checks 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_invariant promotes it to :strict for 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 grep result cost roughly a million tokens of context and was almost never read in full. New Nous.Spill behaviour with a filesystem backend (Nous.Spill.Local): results over max_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 no deps[:spill_config] (or config :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_read is excluded because spilling it creates a read→spill→read loop. Locators are opaque: callers render them with retrieval_hint/1 rather than assuming a path a tool can open. Spilled files are 0o600 inside a 0o700 per-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/2 replaces any tool result over max_result_chars with head 4096 + a marker + tail 1024, with no LLM call at all. Nous.Plugins.Summarization now 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 a tool_call/tool_result pair.

  • One compaction path, not two. Nous.Transcript was public, correct, and entirely dead — nothing in lib/ called it — while Nous.Plugins.Summarization carried a second, independent implementation of the tool-pair boundary rule that all three providers 400 on. Summarization is now the live entry point and calls Transcript for boundary balancing, pruning and estimation; balance_tool_call_boundary/2 is 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 summarization provider, model and 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 :start rather 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_parser buffer 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/2 now accepts and returns a resumable {pos, depth, in_string} scan state that both stream backends thread through their buffer state; parse_buffer/1 is 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 :atomics counter with an 8 MB high-water mark and parks the producer in a receive instead 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-process Process.info/2 call that ran on every chunk, and a Process.sleep/1 busy-wait.

  • Decisions graph traversal is linear again. Both BFS frontiers in Nous.Decisions.Store.ETS used queue ++ [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/1 and related_entries/3 each tab2list'd the entire links table, and related_entries/3 applied its limit only after fetching every neighbour. Over 20,300 links: 7.1x, 6.7x, 5.7x and 3.3x respectively. related_entries/3 still returns up to limit entries that actually exist — it fills lazily rather than truncating before dangling links are rejected, so the dangling-link behaviour is preserved.

  • Default count_tokens/1 no longer inspects every message. It used inspect |> String.length (measured ~13,000x slower than necessary) where the internal estimator already used byte_size. Both now agree.

  • Teams.SharedState reads run in the caller. The table was :private, forcing every read through the GenServer. It is now :protected with read_concurrency: true, and get_discoveries/1 / get_claims/1 select directly. Eight concurrent readers over 1,000 discoveries: 713 ms serialized vs 203 ms concurrent. Discoveries also now expire on the same Process.send_after mechanism claims already used, via a new :discovery_ttl option (default 1 hour, accepts :infinity) — previously they accumulated for the lifetime of the process.

  • AgentServer.save_context/1 no longer blocks the agent process. Serialization and backend IO move to a task, mirroring :load_context which 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.ETS is 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 concurrency parallel_tool_calls exists to enable.

  • Missing read_concurrency / write_concurrency flags 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_code call 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.CodeRuntime is the provider behaviour, and Nous.CodeRuntime.JS is 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_ms enforced by a BEAM timer that really terminates the isolate, :max_heap_mb, and a byte-accurate :max_output_bytes ledger 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: :both is the default and degrades to :native when no runtime is configured, rather than advertising a run_code that can only fail. It is not an unconditional token saving — the SDK is a prompt prefix that can rival the native schemas it replaces — so docs/guides/code_mode.md says to measure your own workload instead of implying a win.

  • Nous.Usage.cost/2 and Nous.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 so gpt-4o-2026-05-13 finds gpt-4o while an unreleased generation stays :unknown rather 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 with config :nous, :model_prices. Cost is derived, not stored: no cost field 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_timeout is 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 :llamacpp already 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. :vllm and :sglang are the same class of host and were left alone because they were not measured. Override per model with receive_timeout: as before.

  • llama_cpp_ex updated to 0.8.44 (from 0.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 llama suite 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, ecto and elixir_make were deliberately not moved with it: mix deps.update llama_cpp_ex pulls them opportunistically, none is required by 0.8.44, and req is the default HTTP backend for every provider.

  • Nous.HTTP.Buffer extracted. Both stream backends reached up into Nous.Providers.HTTP for 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 in Nous.HTTP.Buffer; Nous.Providers.HTTP keeps 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.* and Nous.HTTP.StreamBackend.* are behaviours with a published guide, Nous.Providers.HTTP is injected into every provider by use Nous.Provider, Nous.AgentRunner holds the canonical option docs that Nous.Agent points at, and Nous.AgentServer is used throughout the LiveView guide. Those are now documented as public. Only Nous.Workflow.Engine.{Executor,ParallelExecutor,StateMerger} were genuinely internal; they gain @moduledoc false and leave the docs groups.

  • Nous.Plugins.KnowledgeBase honours a caller-supplied :store_state. init/2 called store_mod.init/1 unconditionally, 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_state reuse guard Nous.Plugins.Memory has always had. Behaviour change: a kb_config[:store_state] that used to be ignored is now used.

  • Nous.Util is @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 and AGENTS.md. Its doctests still run.

  • Nous.Hook's @type event union was incomplete. It omitted :workflow_start, :workflow_end, :pre_node and :post_node, all four of which Nous.Workflow.Engine dispatches. Type-only change.

  • Nous.AgentRegistry.via_tuple/1 and lookup/1 accept any registry key. The specs said String.t(), but Nous.Teams.Coordinator has always registered members under a {:team, team_id, member} tuple. Spec-only change, now expressed as Nous.AgentRegistry.key/0.

Tests

  • Provider request shaping is now asserted. Nous.Providers.Gemini sat at 4.35% coverage and Anthropic at 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. New gemini_test.exs, anthropic_test.exs and openai_test.exs decode 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 inside functionDeclarations, so that specific regression cannot recur. Coverage: Gemini 4.35% → 91.30%, Anthropic 4.76% → 85.71%.

  • Write-tool sandbox escapes are now tested. FileRead had an escape test; FileWrite and FileEdit did not, so deleting their PathGuard.validate/2 call 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 real Nous.Tools.Bash is 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 the lib/ line it defends.

  • The 17 AgentServer cancellation 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 a Process.sleep. Whole suite: 0.1s.

  • Tests no longer reach the public internet. Several tests issued live requests to api.openai.com and aiplatform.googleapis.com and passed only because they asserted on the resulting error — slow, broken offline, and if OPENAI_API_KEY were 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 from async: false to async: true (39 → 30 sync). Files driving Nous.AgentServer stay sync and say why: $callers does not cross GenServer.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 with Tool.from_function/2 passing only name: and description:, so the schema fell back to an empty object. Measured: every one reached the model with properties: [] and required: [], while their descriptions promised parameters in prose ("Parameter: answer (your complete solution)").

    A model that honours the schema therefore called them with {}. final_answer returned the literal string "No answer provided", and note/final_answer — which pattern-match on %{"content" => _} and %{"answer" => _} — raised FunctionClauseError instead. 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 every add_todo create 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.ReActAgent defaults to 25 iterations rather than the generic 10. Its mandated workflow is plan (1) + one add_todo per step + note observations + one complete_todo each + 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 :eval ReAct 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.1 answers "8" to "What is 5 plus 3?" instead of "No answer provided".

  • Nous.Transcript.estimate_messages_tokens/1 was blind to tool-call arguments, so no token budget could see a tool-calling transcript. It summed Message.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 in arguments by definition.

  • Nous.ReActAgent enables 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.Summarization is now on by default with max_context_tokens: 30_000, keep_recent: 8; passing your own plugins: or summarization_config replaces 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 requestoutcome
    before170,732 tokensrefused by the server (32k window) after 775s
    trigger fixed46,809 tokensstill refused, 203s
    + estimator fixed4,866 tokenscompleted, 3 iterations, 19.8s

    The :eval ReAct 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, and enable_thinking: false was 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.Messages doctests re-enabled (7 → 23 doctests total). Dead :mox dependency removed; bypass narrowed to only: :test so a Cowboy server is no longer on the :dev code 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.exs was never run by any job, and was additionally mis-nested: :threshold must sit under :summary or Mix silently keeps its default.

  • Credo thresholds ratcheted to the tightest values the codebase passes today (max_complexity 24 → 23, max_arity 15 → 14) so they can only move down. max_nesting was already at its floor.

Fixed

  • Nous.Plugins.Summarization never bounded the context window. Its trigger read ctx.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 with max_context_tokens: 5_000 came back byte-identical, 13 messages in and 13 out, because nothing had been billed yet. The trigger is now Nous.Transcript.estimate_messages_tokens/1 over ctx.messages, so the threshold means what its name says and matches every other token budget here.

    Found by driving Nous.ReActAgent against 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_recent messages are exempt from pruning, so a few large recent tool results can exceed any budget by themselves, and Nous.ReActAgent ships 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_tokens on a small transcript, which is why the defect survived; a test now drives it from transcript size with usage at zero.

  • A tool could not declare its own deadline, so Nous.Tools.Bash was killed at 30s while documenting and granting 120s. %Nous.Tool{} has always had a :timeout, but the tool/3 macro in Nous.Tool.Schema accepted no such option and Nous.Tool.from_module/2 hardcoded 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).timeout was 30_000 while the tool passes 120_000 to NetRunner and exposes a timeout parameter a model can set to 120_000 — a legitimate 45-second command died at 30 seconds, twice (see Security, above), and the documented 2-minute default was unreachable. tool/3 now takes :timeout, carried through metadata/0 into from_module/2 on exactly the path requires_approval already uses, with an explicit from_module(mod, timeout: …) still winning. Nous.Tools.Bash declares 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; a timeout argument 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/2 emits the OpenAI-nested response_format: %{"type" => "json_schema", "json_schema" => %{"schema" => …}} and resolve_mode(:auto, :gemini) is :json_schema, but Nous.Messages.Gemini only matched the flat %{"type" => …, "schema" => …} shape. Every output_type: agent on gemini: / vertex_ai: therefore sent no responseMimeType and no responseSchema at all, and relied entirely on the model guessing JSON. Both shapes are now accepted.

  • Every Nous.Eval.Optimizer objective except :score and :pass_rate raised. extract_metric/2 and extract_all_metrics/1 used get_in(suite_result, [:metrics_summary, :latency, :p50]); %SuiteResult{} and %Metrics.Summary{} are plain structs with no Access implementation, so that raised UndefinedFunctionError rather than returning nil — and the nested :latency / :tokens / :cost keys never existed on the summary anyway. They now read the real summary fields, and a suite with no metrics summary (every case errored) yields 0.0 instead of crashing the search.

  • Nous.Agent.Context dropped the prompt-cache token counters. add_usage/2's map branch, serialize_usage/1 and deserialize_usage/1 all omitted cache_creation_input_tokens and cache_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/4 skipped optional callbacks on unloaded modules. It used a bare function_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 implement init_context/2 or after_tool/4 silently got the default instead. Now guarded with Code.ensure_loaded?/1.

  • Nous.Memory.Store.Hybrid raised instead of erroring when its optional deps are absent. The deps-unavailable branch defined init/1 and search/3 but not search_vector/3, so the friendly {:error, _} path was an UndefinedFunctionError.

  • Nous.Workflow.run/3's @spec omitted the {:suspended, state, info} return the engine can produce, and its @doc omitted the :hooks, :trace, :scratch, :pause_ref and :on_node_complete options it forwards.

  • Gemini and Vertex AI tool calls from the agent path shipped a malformed payload. Nous.AgentRunner fell through to the OpenAI tool schema for :gemini / :vertex_ai, so the request carried [%{"functionDeclarations" => [%{"type" => "function", "function" => …}]}] — an OpenAI envelope nested inside Gemini's functionDeclarations, which expects the bare declaration. Nous.LLM had 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 share RequestDispatch.convert_tools_for_provider/2, using the Gemini shape. Any agent using tools with Gemini or Vertex was affected.

  • Nous.LLM.generate_text/3 no longer returns "" for multimodal replies. Its private extract_text/2 copy returned "" for any non-binary content; it now uses Nous.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: :infinity with no on_timeout to Task.Supervisor.async_stream_nolink/4, relying on ToolExecutor to enforce per-tool timeouts — but that timer is only armed when tool.timeout is a positive integer, and nil is 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) with on_timeout: :kill_task. A timed-out call returns a per-call tool error and its siblings keep their real results.

  • Nous.Message.ContentPart accepts whitespace-only text under Ecto 3.14. Ecto 3.14 moved trimming out of :empty_values into a separate :trim_values option defaulting to true, so the empty_values: [""] override stopped protecting the Gemini/Vertex "\n\n\n" case. Empty-content rejection is now an explicit check in validate_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.Req matched 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.exs parses every examples/**/*.exs and every Elixir code fence in the docs, resolves each Nous.* remote call and struct literal against the loaded beam (alias-aware, including alias 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} in test/docs/fixtures/doc_snippet_allowlist.exs, and an allowlist entry that has started parsing fails too — and that every relative markdown link and #anchor resolves. A docs CI job runs mix 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 block PromptTemplate deliberately rejects, two LiveView scripts that could not compile, and four memory examples that raised MatchError instead of naming the optional dep to uncomment. 12_pubsub_agent.exs ran on a hand-rolled stub that delivered nothing and cost four 30-second timeouts; it now runs on a real Phoenix.PubSub.

  • 15 misleading examples corrected — most notably the streaming and callback examples, which registered on_llm_new_delta without stream: true and 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/2 vs a nonexistent generate_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 documentedparallel_tool_calls, Nous.Hook's fail_closed, InputGuard's fail_closed / strategy_timeout, the twelve Gemini/Vertex model settings from 0.16.0, and the Nous.Usage prompt-cache token fields. docs/guides/migration_guide.md was 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 under notebooks/ (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.exs and advanced/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_at stamped at build time, [...] placeholders, File.read of a path that does not exist) and were rewritten to actually run.

Removed

  • :inets dropped from extra_applications. :httpc was 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 executionparallel_tool_calls: true on Nous.Agent.new/2 (default false). When a model response contains multiple tool calls, approved executions fan out under Nous.TaskSupervisor while everything order-sensitive stays sequential in call order: pre_tool_use hooks and approval checks run before the fan-out, and post_tool_use hooks, on_tool_response callbacks, behaviour :after_tool, and Context.merge_deps apply after it, in original call order. Result messages keep call order (providers require it). Per-tool timeouts remain ToolExecutor'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 public run_stream/3 (previously uncovered by any live test). Model-agnostic assertions safe for thinking models; verified against LM Studio.

Changed

  • Nous.AgentRunner split into a facade + four submodules. The 2,188-line / 97-function module is now a 926-line facade delegating to internal (@moduledoc false) submodules under Nous.AgentRunner: PromptAssembly (prompt/settings assembly), Streaming (stream wrapping/consumption), RequestDispatch (fallback chains, rate limiting, provider settings), and ToolExecution (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/3 no longer emits a duplicate empty {:complete, _} event. OpenAI-compatible streams yield two {:finish, _} events (the finish_reason chunk 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/1 no longer crashes on content: nil. Thinking models truncated mid-reasoning return assistant messages with only reasoning_content set; extraction now returns "" instead of raising FunctionClauseError and failing the whole run.

  • Optional-dep compile warnings in consumer builds silenced. Nous.Tools.SearchScrape is now gated on Floki (like WebFetch), and :hackney/:hackney_pool are declared no_warn_undefined — apps that depend on nous without the optional floki/hackney packages 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_cache and stripped from Context.serialize/1. Persistence/OTP: agent_server context saves on the response/clear_history paths are now fire-and-forget via Task.Supervisor (off the GenServer mailbox); Teams.RateLimiter uses running-window counters so rate_limited?/2 is O(1); Teams.SharedState uses 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_use hook modified arguments. In AgentRunner, the {:modify, …} hook branch ran the tool through check_tool_approval/3 without first applying enforce_policy_approval/2 (unlike the normal path). A tool gated only by the permission policy (:strict mode, an approval_required entry, or the execute-category gate) — not by its own requires_approval flag — therefore executed UNGATED whenever any pre_tool_use hook 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-:safe verdict to :suspicious (configurable via fail_closed, default true for :any, false for :majority/:all which already count drops against the configured denominator). Drops emit a [:nous, :input_guard, :strategy_dropped] telemetry event + a Logger warning. New :strategy_timeout option (default 30s) bounds the parallel path. Behavior change: an :any guard with a flaky strategy may now warn/block where it previously passed — set fail_closed: false to restore the old behavior.
  • :permissive policy no longer auto-approves execute-class tools. Nous.Permissions.requires_approval?/3 (category-aware) keeps the approval gate on category: :execute tools (e.g. bash) even under :permissive, unless the policy sets allow_unattended_execute: true. Built-in bash was already self-gated via its own requires_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 pass allow_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_fetch fail-closed, atom-exhaustion DoS guard, ReDoS cap, additional UrlGuard ranges. Correctness: get_tool_field fetch, rate-limit TOCTOU fix, async-load reply-on-crash, async_nolink absorb, 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 (deterministic refute_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/2 hardcoded requires_approval: false instead of reading it from the tool's metadata, so Bash/FileWrite registered 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?/1 ensures the module is loaded before checking, and Nous.Agent.new/2 accepts bare behaviour modules in :tools.)
  • FileGrep ripgrep flag injection. LLM-controlled pattern/glob reached rg with no -- option terminator, so values like -f/etc/passwd or --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 (mirrors FileGlob).
  • PathGuard intermediate-directory symlink escape. Only the final path component was lstat'd, so a directory symlink (link -> /etc, accessed as link/passwd) escaped the workspace jail. Nous.Tools.PathGuard now 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-local fe80::/10, and ::, and resolves both A and AAAA records (dual-stack bypass). New validate_pinned/2 returns a validated IP; Nous.Tools.WebFetch pins the connection to it (preserving Host header, SNI, and cert verification) to close the DNS-rebinding TOCTOU. The Req provider backends pass redirect: false.
  • Permission policy is now actually enforced. Nous.Permissions.Policy (via a new :permissions option on Nous.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?/2 now honors allow lists in every mode (deny-by-default), build_policy/1 rejects unknown modes, and both predicates fail closed on an unknown mode.
  • InputGuard no longer bypassed for streaming. Nous.AgentRunner.run_stream/3 runs 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 first VERDICT line, and can fail closed on an unparseable response; the Pattern strategy NFKC-normalizes and strips zero-width/bidi characters; :majority/:all aggregation counts the configured strategies so killing one can't flip the vote.
  • Secret & data-exposure hygiene. Credential-shaped deps keys (api_key/token/secret/…) are no longer written by persistence; Gemini sends its key via the x-goog-api-key header 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/1 returned a list into the :string content field, raising Ecto.InvalidChangesetError on 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_cast and again by AgentRunner.build_context, doubling the prompt sent to the model and corrupting saved history. Now added exactly once.
  • Nous.LLM streaming-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 through Nous.StreamNormalizer.ToolCallAccumulator.
  • OpenAI parse_tool_call/1 crashed on a tool_call missing "function". Map.get(nil, "name") raised BadMapError, 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.Supervisor wires SharedState into agent deps as a registered atom name, but Nous.Plugins.TeamTools gated on is_pid/1 — false for the name — so claim_region/share_discovery no-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 or exits (non-timeout) crashed the whole agent run. Nous.ToolExecutor only caught :exit, {:timeout, _}; it now catches any throw/exit and converts it to a retryable ToolError.
  • Streaming Gemini/Vertex tool calls dropped thought_signature. The accumulator rebuilt the call without metadata, breaking multi-turn thinking parity for 2.5 thinking models. The signature is now carried through.
  • Documented per-run :model_settings override was ignored. AgentRunner now merges opts[:model_settings] over the agent's settings for that run.
  • Nous.Teams.RateLimiter was never invoked, so budget/rpm/tpm had 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.ParallelExecutor now uses zip_input_on_exit so 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_score behaves 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 via Integer.parse (no crash on a bad value) and a partial custom cost_config deep-merges instead of raising KeyError.
  • Nous.Tools.SearchScrape processed only the first concurrency URLs. It now fetches all URLs (capped and throttled by max_concurrency) and clamps LLM-supplied concurrency/timeout.

Changed

  • Nous.Agent.new/2 accepts bare tool modules in :tools (e.g. tools: [Nous.Tools.Bash]), converted via Nous.Tool.from_module/1.
  • Nous.Permissions.blocked?/2 allow-list semantics. A non-empty allow_names/allow_prefixes is now deny-by-default in every mode (was only honored in :strict); build_policy/1 raises on an unknown :mode.
  • Nous.Hook.new/2 accepts :fail_closed so security-gating hooks can opt into fail-closed via the documented constructor (not only a struct literal).
  • License metadata corrected to Apache-2.0 in mix.exs (was MIT) to match the bundled LICENSE and README.
  • Docs: fixed non-compiling/silently-broken examples — README plugin configs now pass :deps to Nous.run/3 (not Nous.new/2, which ignores it); getting-started uses Nous.Errors.ProviderError, the correct AgentDynamicSupervisor.start_agent/3 arity, 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 and Nous.Teams.SharedState's discovery list now prepend (O(1)) instead of ++ [entry] (O(n)).
  • KnowledgeBase ETS store keeps a slug -> id index, so fetch_entry_by_slug/2 is O(1) instead of a full table scan + struct rebuild on every kb_read/kb_backlinks/kb_link call.
  • 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.Gemini was sending the tool call_id (e.g. "gemini_abc123") as the functionResponse.name, but Gemini's API requires the original functionCall.name. Every Gemini tool roundtrip shipped a malformed payload. Message.tool/3 now threads the original name through the :name field; 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/1 now 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_checkpoints table is now owned by a supervised TableOwner under Nous.Application (mirrors the existing Nous.Persistence.ETS pattern). 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_state when present; per-run defaults are still refreshed. Avoids ets_too_many_tables under load and the silent loss of memories across runs.
  • Nous.LLM.stream_text_with_tools silently 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}" while Nous.PubSub.agent_topic/1 returned "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/2 now sets the cancellation atomic and calls Task.shutdown.
  • Research coordinator crashed on task exit. Task.yield returns {:exit, reason} on task crash; the case only matched {:ok, _} / nil and produced CaseClauseError. 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's cache_creation_input_tokens / cache_read_input_tokens and Gemini's cachedContentTokenCount. Nous.Usage gained cache_creation_input_tokens and cache_read_input_tokens fields, which propagate through add/2.
  • Tool validator dropped the enum constraint when type was also declared. Nous.Tool.Validator.validate_types/2 now 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.Hook gains a fail_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. Default false keeps 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. ServingHolder no longer runs Nx.Serving.run/2 inside handle_call. Concurrent embeddings now go through the serving's own batching mechanism instead of serializing through one process.
  • Bare Task.async migrated to supervised Task.Supervisor.async_nolink in research/coordinator.ex, eval/runner.ex, tools/search_scrape.ex, plugins/input_guard.ex, and http/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_len before each send/2; past @backpressure_high_water it pauses until the queue drops below @backpressure_low_water, and after @backpressure_max_wait_ms it emits {:error, %{reason: :backpressure_overflow}} and halts. The M-12 risk called out in mix.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/1 returns immediately, so DynamicSupervisor.start_child (and Teams.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 the Nous.Telemetry moduledoc.

Deprecated

Internal / Hygiene

0.16.1 - 2026-05-15

Changed (breaking)

  • Provider error contracts. Nous.Providers.LMStudio, Nous.Providers.SGLang, Nous.Providers.VLLM, and Nous.Providers.Custom now return {:error, {:invalid_config, reason}} instead of raising ArgumentError when the resolved base_url is missing or fails Nous.Tools.UrlGuard validation. Nous.Providers.LlamaCpp similarly returns {:error, %Nous.Errors.ProviderError{}} instead of raising when the :llamacpp_model option is missing.

    Callers that wrapped these calls in try/rescue ArgumentError should switch to pattern matching on {:error, _}. The high-level Nous.run/2, Nous.generate_text/3, and Nous.Agent.run/3 paths already returned result tuples and are unaffected.

  • Vertex AI token resolution prefers Goth over VERTEX_AI_ACCESS_TOKEN. When a :goth instance is configured (in opts or app config), Nous.Providers.VertexAI now 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, contains previously chained Map.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 into String.replace/3 and 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_config setting maps to generationConfig.thinkingConfig, letting callers set thinking_budget and include_thoughts on Gemini 2.5/3.x. Both Elixir shape (%{thinking_budget: 1024, include_thoughts: true}) and native Vertex shape (%{"thinkingBudget" => 1024, "includeThoughts" => true}) are accepted.
  • thoughtSignature round-trip on tool calls. Nous.Messages.Gemini now preserves Vertex's thoughtSignature on parsed tool calls (under tool_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_response and :json_schema settings wire to responseMimeType / responseSchema in generationConfig. The cross-provider :response_format shape (%{type: :json_schema, schema: ...} and %{type: :json_object}) maps through too.
  • Safety settings. :safety_settings flows to top-level safetySettings, with atom-keyed entries auto-stringified.
  • Tool config / tool choice. :tool_config (raw map) and :tool_choice (friendly form) both flow to top-level toolConfig. Friendly forms: :auto, :any / :required, :none, and {:any, ["fn_a", ...]} for allowedFunctionNames.
  • Function calling on Vertex/Gemini actually works. Function declarations are now serialized in Vertex's tools[].functionDeclarations format via Nous.ToolSchema.to_gemini/1 (which strips OpenAI's strict field and unsupported additionalProperties from the parameters schema). Previously the high-level Nous.LLM path silently dropped tools for these providers.
  • Native Vertex tools. New :native_tools setting accepts :google_search, :url_context, :code_execution atoms (or {tool, config} tuples / raw maps) and adds them as additional entries in the Vertex tools array, alongside any function declarations.
  • Context caching. :cached_content setting maps to top-level cachedContent. Pass-through only — create caches via the Vertex REST API for now.
  • Streaming + tools. Nous.LLM.stream_text/3 now honors :tools. Tool-call deltas are aggregated per turn (preserving any thoughtSignature), 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 generationConfig fields: 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_timeout constants from Nous.Providers.VertexAI (300s) and Nous.Providers.Gemini (120s). Streaming and non-streaming now share the same provider default; the actual timeout used at request time is always model.receive_timeout, which flows through build_provider_opts/1 as :timeout. Override via Model.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 text parts 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_values for cast/3 treats whitespace-only strings as empty, so Nous.Message.ContentPart's changeset dropped the content field entirely and then raised %Ecto.InvalidChangesetError{errors: [content: {"content is required", []}]} from ContentPart.new!/1, taking down the whole Nous.LLM.run_with_tools/6 call. ContentPart now overrides :empty_values to [""] so legitimate whitespace content is preserved, and Nous.Messages.Gemini.parse_content/1 defensively skips whitespace-only text parts to avoid creating useless ContentParts. 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 only name and falls back to %{} for args, matching the behavior of the sibling parse_parts/1 helper.

Added

  • Nous.Errors.RetryInfo parses server-suggested retry hints from provider error responses. Checks error.details[] for google.rpc.RetryInfo (Vertex AI / Gemini) first, then the Retry-After HTTP header. Returns delay in milliseconds, or nil when no hint is available — nil is itself meaningful for Google APIs, since long-term/daily quota exhaustion deliberately omits RetryInfo to discourage retry loops.

  • Nous.Errors.ProviderError gains :retry_after_ms alongside the existing :status_code. Nous.Provider.request/3 and request_stream/3 now 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
      ...
    end
  • Gemini/Vertex finishReason and promptFeedback are surfaced. Nous.Messages.Gemini.from_response/1 now stores both in message.metadata (when present) and emits a Logger.warning when 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, and Nous.HTTP.StreamBackend.Req previously returned {:error, %{status, body}} and dropped headers entirely, which made it impossible to read Retry-After. They now return {:error, %{status, body, headers}} with headers as 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

  • hackney is now an optional dependency. Req (default for both one-shot and streaming) is the primary HTTP backend; hackney is only used when a consumer opts into Nous.HTTP.Backend.Hackney / Nous.HTTP.StreamBackend.Hackney via NOUS_HTTP_BACKEND=hackney (or the streaming variant) or app config. Forcing hackney ~> 4.0 as a hard dep (added in 0.15.x) broke downstream apps with any transitive constraint of hackney ~> 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 own mix.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 one text (or thought) part — common on long gemini-2.5-pro outputs such as multi-thousand-token translations — from_response/1 passed the raw list of ContentPart structs to Nous.Message, whose :content field is :string. Ecto then raised %Ecto.InvalidChangesetError{errors: [content: {"is invalid", [type: :string, validation: :cast]}]}. consolidate_content_parts/1 now joins homogeneous lists of :text or :thinking parts into a single string. Vertex AI is fixed implicitly via the existing :vertex_ai → from_gemini_response/1 delegation in Nous.Messages.from_provider_response/2.

0.15.5 - 2026-05-01

Fixed

  • Both Req-based HTTP backends (Nous.HTTP.Backend.Req and Nous.HTTP.StreamBackend.Req) now actually use the configured Nous.Finch pool. Previously they ignored the :finch_name opt built by Nous.Provider and let Req spin up its own default Finch instance, leaving the supervised Nous.Finch pool (started by Nous.Application with size: 10, count: 1) idle. Both backends now read :finch_name from per-call opts, falling back to Application.get_env(:nous, :finch, Nous.Finch). Net effect: Nous.Finch becomes the live default for both streaming and non-streaming on Req, so pool tuning via app config actually takes effect. (Note: Req disallows passing :finch together with :connect_options; connect timeouts are now pool-level — configure on the Nous.Finch pool 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.Model receive_timeout default → 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_timeout opts 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's proplists resolves bare atom :async as {:async, true}, which puts hackney into push mode; the bare :once atom 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/1 is 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}, ...] per deps/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 any stream_next/1 call; with the tuple form the mailbox holds only 2 messages (status + headers) and body chunks gate on stream_next/1. Reported as part of the same bug that caused observable timeouts against cold/slow SSE backends.

Added

  • Nous.HTTP.StreamBackend behaviour — pluggable streaming HTTP layer mirroring the non-streaming Nous.HTTP.Backend introduced in 0.15.1. Two impls ship:
    • Nous.HTTP.StreamBackend.Req — the new default. Drives Req.post/1 with the :into callback. 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_backend per-call opt on Nous.Providers.HTTP.stream/4.
  • NOUS_HTTP_STREAM_BACKEND env var (req | hackney | My.Custom.Backend). Resolution mirrors NOUS_HTTP_BACKEND: per-call → env → app config → default.

  • config :nous, :http_stream_backend, MyBackend application config knob.

Changed

  • Nous.Providers.HTTP.stream/4 now dispatches to the configured Nous.HTTP.StreamBackend instead 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.Backend resolver is refactored to share its String.to_existing_atom/1 safety logic with the streaming resolver — same C-2 protection on both paths.

Documentation

  • Nous.Providers.HTTP moduledoc rewritten around the dual pluggable-backend model and the streaming backpressure trade-off.
  • Nous.HTTP.StreamBackend and 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.Hackney

or 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

  • :stream option on Nous.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_delta fires for text and the new :on_llm_new_thinking_delta fires for reasoning. Works across all providers (OpenAI-compatible, Anthropic, Gemini, Vertex AI, Mistral) and is compatible with output_type for streaming structured output.
  • :on_llm_new_thinking_delta callback — cleanly-separated reasoning deltas. Pre-existing Nous.Agent.run_stream/3 keeps emitting [thinking] … on :on_llm_new_delta for backward compatibility — the split is opt-in via stream: true.
  • Nous.StreamNormalizer.ToolCallAccumulator — polymorphic across the three provider chunk shapes (OpenAI list with split JSON args, Anthropic _phase-tagged fragments, Gemini already-complete functionCall). Reassembles them into the unified %{"id", "name", "arguments" => decoded_map} shape that Nous.Messages.extract_tool_calls/1 already understands.
  • {:usage, %Nous.Usage{}} stream event — emitted by Nous.StreamNormalizer.OpenAI when chunks carry a usage field (auto-enabled by injecting stream_options.include_usage: true on the OpenAI-compatible streaming request), by Nous.StreamNormalizer.Anthropic from message_start and message_delta chunks, and by Nous.StreamNormalizer.Gemini from usageMetadata. The Nous.Types.stream_event typespec is updated.
  • Mid-stream cancellationctx.cancellation_check is invoked between every streamed chunk; a thrown {:cancelled, reason} halts the run with Errors.ExecutionCancelled and discards partial state. No tool execution happens on cancellation.
  • Nous.Messages.OpenAI.decode_arguments/1 and parse_usage/1 promoted to public helpers (formerly private) so the streaming path and the ToolCallAccumulator reuse the same JSON-decode-with-fallback and usage-parsing logic as the non-streaming path. Anthropic and Gemini's parse_usage/1 are similarly public for the same reason.

Changed

  • Pre-existing Nous.Agent.run_stream/3 semantics are unchanged. The [thinking] … prefix on :on_llm_new_delta is preserved for that legacy path so existing consumers don't break.
  • lib/nous/provider.ex build_request_params allowlist now includes stream_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.md with 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.md Quick 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: and sglang: as first-class named providers (previously only lmstudio: was mentioned; vLLM and SGLang were buried in the custom: section).
  • README "Local Servers" section now recommends the dedicated lmstudio: / vllm: / sglang: / ollama: prefixes over custom: — they default to the right port, validate *_BASE_URL env vars through UrlGuard, and pick up the OpenAI stream normalizer for free.
  • New "HTTP Backend" section in README covering the pluggable Nous.HTTP.Backend behaviour, env-var selection, and shared hackney pool config.
  • Cleaned up mix docs warnings — 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.Backend behaviour with Nous.HTTP.Backend.Req (default) and Nous.HTTP.Backend.Hackney implementations. Configure via:

    • per-call: HTTP.post(url, body, headers, backend: Nous.HTTP.Backend.Hackney)
    • env var: NOUS_HTTP_BACKEND=hackney (also accepts req or 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/1 with rescue (per the project-wide C-2 rule from the 0.15.0 review — never String.to_atom/1 on env input). Benchmark script at bench/http_backend.exs; results in docs/benchmarks/http_backend.md.

  • Hackney :default pool 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_timeout and :pool opts added to both HTTP backends and Nous.Providers.HTTP.stream/4. Default 30_000ms / :default pool. 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_name arg from lmstudio.ex / vllm.ex / sglang.ex chat_stream/2 calls — leftover from the pre-hackney streaming code; HTTP.stream/4 has been ignoring it since 0.15.0.
  • lmstudio: / vllm: / sglang: base_url is now validated through Nous.Tools.UrlGuard with allow_private_hosts: true. Rejects malformed schemes (file://, gopher://, etc.) from *_BASE_URL env 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/1 helper in Nous.Plugins.SubAgent now 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, :all explicitly. Recommended: list specific keys with :sub_agent_shared_deps, [:key1, :key2].
  • Tools with requires_approval: true are now rejected when no :approval_handler is wired (was silently approved). If you use Nous.Tools.Bash, FileWrite, or FileEdit, configure an approval_handler on RunContext or those tools will refuse to run.
  • File tools (FileRead/Write/Edit/Glob/Grep) now enforce a workspace root. Defaults to cwd; override per-agent via deps: %{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/2 rejects template bodies containing <% ... %> blocks other than the simple <%= @ident %> substitution form. Previously bodies were passed through EEx.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 :fallback error 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_iterations exhaustion 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_node hook returning :deny aborts the workflow with {:error, {:hook_denied, hook_name, node_id}}. Previously was silently mapped to {:pause, _} so safety hooks suspended a checkpoint forever.
  • Permissions :strict mode is deny-by-default at the filter layer. New :allow_names / :allow_prefixes opts on Nous.Permissions.build_policy/1. Previously strict_policy() with empty deny lists silently exposed every tool.
  • PromEx plugin event names corrected ([:nous, :model, ...][:nous, :provider, ...]). Anyone using Nous.PromEx.Plugin saw zero data on the model/stream metric panels until now. Metric paths still emit as nous_model_* for dashboard backward compatibility.
  • Nous.Tool.Validator now actually runs. tool.validate_args defaulted to true for months but ToolExecutor never called the validator. Tools whose params declared "required": [...] will now reject calls with missing fields up-front (returning a structured ToolError to the LLM with the field name) instead of crashing inside the tool body and reporting a generic FunctionClauseError. If you have tools that relied on the lack of validation, set validate_args: false on the tool struct.
  • Nous.Teams.RateLimiter.acquire/3 returns {:ok, reservation_ref} instead of :ok. Existing call sites doing assert :ok = RateLimiter.acquire(...) need assert {:ok, _ref} = .... This is the contract change that makes concurrent acquires near the cap race-safe (M-9). Pair with record_usage(reservation: ref, ...) for atomic reconciliation, or release/2 to cancel. Bare record_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 than http/https, blocks RFC1918 / loopback / link-local / CGNAT / IPv6 ULA / cloud-metadata IPs (169.254.169.254). Used by WebFetch (with redirect re-validation) and the Custom provider's base_url. :allow_private_hosts opt-in for local dev.
  • Streaming pipeline rewritten on :hackney 4 :async, :once (pull-based), replacing the prior spawn + Finch.stream + mailbox plumbing. The Stream.resource consumer now drives :hackney.stream_next/1 directly — 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/1 optional Store callback for KB backends. ETS implementation provided. Reduces kb_health_check from 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_nodes validation 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_completed messages from cancelled tasks are discarded. Fixes silent message loss when the user types fast or calls clear_history mid-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 in test/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/1 on untrusted input across 7 modules (Critical). Adopted a project-wide rule — never String.to_atom/1 on 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.TestCase YAML key conversion, and the --tags / --exclude parsers in mix nous.eval / mix nous.optimize.
  • EEx code-execution from template bodies (Critical, see breaking changes above) — PromptTemplate now rejects non-<%= @var %> markers.
  • Nous.Hook :command type now requires a [program | args] list, not a raw string. Previous string handler was passed to NetRunner.run(["sh", "-c", str], ...) — RCE class if handler ever came from config or user input.

  • Bash and FileGrep tools scrub the env before shelling out — whitelists PATH/HOME/LANG/LC_ALL/TZ/USER/SHELL/TERM, drops *_API_KEY, *_TOKEN, *_SECRET, LD_PRELOAD, etc. FileGrep now resolves rg via System.find_executable/1 (no which PATH-shadowing). Bash uses absolute /bin/sh.
  • HumanInTheLoop plugin matches tool names case-insensitively — was raw equality; a tool registered as "Send_Email" bypassed approval if config said "send_email".
  • Nous.Plugins.Memory wraps 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-callable remember tool.
  • extra_body blocked-keys list — drops messages, model, stream, system, tools, tool_choice with a logged warning. Prevents extra_body from being a back-door for rewriting the conversation, model, or safe-tool whitelist.
  • BraveSearch migrated from raw :httpc (no TLS verify by default) to Req with explicit verify: :verify_peer. Previous code path leaked the API key to any MITM on the wire.
  • Custom provider validates base_url through UrlGuard at 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/passwd via a symlink in a skills directory.

Fixed (correctness)

  • Streaming normalizers (OpenAI / LlamaCpp) no longer drop tool_calls or finish_reason when both arrive in the same chunk. Previously the cond returned 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_delta fragments are now tagged with content-block _index and _phase (:start | :partial | :stop) so a stateful consumer can reassemble the full tool call. The non-streaming convert_complete_response/1 path was already correct.

  • Transcript compaction preserves tool_call/tool_result pairs across the compaction boundary. Previously the naive Enum.split could orphan a :tool message 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_ready overwriting a cancelled context, clear_history un-clearing itself, and the wildcard :DOWN handler clearing the wrong task.
  • Workflow scratch ETS leakmaybe_cleanup_scratch/1 now runs on every non-suspended terminal path (was only the :ok arm). 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/1 with "table already exists".
  • Memory backends roll back on NIF errors:ok = NIF.call(...) pattern-matches replaced with with chains; 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 in memories without its memories_fts row, silently invisible to recall but visible to list.
  • SQLite/DuckDB metadata atomize_keys survives unknown keys — was raising ArgumentError on a single new key in user-supplied metadata, breaking recall/list for the entire process.
  • parallel_map handler {:error, _} returns are collected as failuressafely_run_handler/3 previously wrapped any return value in :ok, so user error returns silently landed in successful_results.
  • AgentRunner no longer mutates agent.model mid-run when fallback fires. Active model is tracked on ctx.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.ETS table is owned by a dedicated TableOwner GenServer under the application supervisor — was dying with whichever transient process happened to call save/load first. save/2 now returns {:error, _} on insert failure (was unconditional :ok).
  • Decisions.supersede/5 docstring corrected — flagged as best-effort, not atomic. The Store behaviour has no transaction primitive yet.
  • Coordinator Process.demonitor/2 on agent removal — was leaking monitor refs and could fire spurious {:agent_crashed, name, _} for healthy agents after rapid stop+respawn.
  • Workflow :workflow_end hook payload now reflects failure-time state, not initial state, so post-mortems see the actual state at failure.
  • AgentServer load_context runs in a Task.Supervisor.start_child task with GenServer.reply/2 — slow persistence backends no longer block concurrent get_context / cancel_execution calls.
  • 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.RateLimiter is now race-safe under concurrent acquires (M-9 final). acquire/3 now returns {:ok, reservation_ref} | {:error, _} and atomically reserves the estimated tokens + 1 request slot. record_usage/3 accepts :reservation to reconcile actual vs estimated; missing reconciliations are auto-refunded after :reservation_ttl_ms (default 5 min) with a Logger.warning/1. release/2 cancels a reservation when the call errored before completing. Legacy record_usage/3 without :reservation still works for callers that don't go through acquire. Added :open_reservations to get_status/1.

  • Nous.Memory.Embedding.Bumblebee uses a Registry + DynamicSupervisor (M-7 final). Each model_name is owned by exactly one ServingHolder GenServer registered by name. Replaces the :persistent_term cache (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/1 tolerates nil and non-binary input (some providers emit malformed function-call responses).
  • OpenAI reasoning_model?/1 matches the full o[1-9] family via regex (catches new o4, o3-pro, etc.); also strips presence_penalty and frequency_penalty for reasoning models.
  • Tool.from_function/2 no longer fakes a hardcoded query parameter schema when no @doc is found — falls back to the empty additional-properties schema with a debug log.
  • KB Entry.slugify/1 NFD-normalises and strips combining marks so "Café""cafe" instead of being entirely stripped.
  • kb_health_check coherence_score weighted by issue severity (:high 0.2, :medium 0.1, :low 0.05), clamped to [0.0, 1.0].
  • ParallelExecutor sorts branch results by branch_id before merging — deterministic instead of completion-order-dependent.
  • Transcript summarize/1 redacts :tool message 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_raise constructions).

Known limitations (documented in code, not silently glossed)

  • 9 modules carry @dialyzer :no_opaque for MapSet capture-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) inside Enum.*).

Dependencies

  • Added {:hackney, "~> 4.0"} (production) for pull-based streaming, replacing Finch.stream/5 for the streaming path. Finch / Req are 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_body setting 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's extra_body= argument. Works in default_settings, Nous.LLM calls, and agent model_settings. Atom keys are stringified at request build time; nested values pass through verbatim. extra_body wins 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 checksums
      • Nous.KnowledgeBase.Entry — compiled wiki entries with titles, slugs, [[wiki-links]], summaries, concepts, tags, confidence scores, and optional embeddings
      • Nous.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:

    • 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_generate

    • Nous.Plugins.KnowledgeBase — plugin that auto-injects KB tools and system prompt guidance. Composes with Nous.Plugins.Memory. Configurable via deps[: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 generation

    • 1,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 message
    • maybe_compact/2 — auto-compact based on message count (:every), token budget (:token_budget), or percentage threshold (:threshold)
    • compact_async/2 and compact_async/3 — background compaction via Nous.TaskSupervisor
    • maybe_compact_async/3 — background auto-compact with {:compacted, msgs} / {:unchanged, msgs} callbacks
    • estimate_tokens/1 and estimate_messages_tokens/1 — word-count-based token estimation
  • Built-in Coding Tools — 6 tools implementing Nous.Tool.Behaviour for coding agents:

  • 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_required
    • blocked?/2, requires_approval?/2 — case-insensitive tool name checking
    • filter_tools/2, partition_tools/2 — filter tool lists through policies
  • Nous.Session.Config and Nous.Session.Guardrails — session-level turn limits and token budgets:

    • Config struct with max_turns, max_budget_tokens, compact_after_turns
    • Guardrails.check_limits/4 — returns :ok or {:error, :max_turns_reached | :max_budget_reached}

    • Guardrails.remaining/4, Guardrails.summary/4 — budget tracking and reporting

Fixed

  • Empty stream silent failure: run_stream now emits {:error, :empty_stream} + warning when a provider returns zero events (e.g. minimax), instead of silently yielding {:complete, %{output: ""}}.
  • Memory.Search crash on vector search error: {:ok, results} = store_mod.search_vector(...) pattern match replaced with case — logs warning and returns empty list on error.
  • Atom table exhaustion in skill loader: String.to_atom/1 replaced with String.to_existing_atom/1 + rescue fallback with debug logging.
  • Context deserialization crash on unknown roles: String.to_existing_atom/1 replaced with explicit role whitelist (:system, :user, :assistant, :tool), defaults to :user with warning.
  • Unbounded inspect in stream normalizer: inspect(chunk, limit: :infinity) capped to limit: 500, printable_limit: 1000.
  • SQLite embedding decode crash: JSON.decode!/1 wrapped in rescue, returns nil with 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 with max_concurrency throttling — 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 existing Nous.Hook struct
    • Pause/resume: via hook ({:pause, reason}), :atomics external signal, or :human_checkpoint auto-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: Checkpoint struct + Store behaviour + ETS backend
    • Subworkflows: nested workflow invocation with input_mapper/output_mapper for data isolation
    • Runtime graph mutation: on_node_complete callback, Graph.insert_after/6, Graph.remove_node/2
    • Mermaid visualization: Workflow.to_mermaid/1 generates 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

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/3 in 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.LLMResponses and its generator script (generated Oct 2025, never imported).

Fixed

  • Compiler warning in output_schema.ex: Removed always-truthy conditional around to_json_schema/1 return value.

Changed

  • All JSON encoding/decoding uses built-in JSON module instead of Jason. Jason removed from direct dependencies.
  • Added pretty_encode!/1 helper 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/1 matched on content being a list, but Message.user/2 stores content parts in metadata.content_parts as 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 data field contained the full data URL prefix (data:image/jpeg;base64,...) instead of raw base64; media_type was 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 uses inlineData for base64 images and fileData for HTTP URLs.
  • Anthropic duplicate thinking block: Assistant messages with reasoning content emitted the thinking block twice.

Added

  • ContentPart.parse_data_url/1 — extract MIME type and raw base64 data from a data URL string.
  • ContentPart.data_url?/1 and ContentPart.http_url?/1 — URL type predicates.
  • OpenAI formatter: :image content type support (converts to data URL) and detail option passthrough for image_url parts.
  • 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_timeout silently dropped in Nous.LLM: generate_text/3 and stream_text/3 with a string model only passed [:base_url, :api_key, :llamacpp_model] to Model.parse, so receive_timeout was silently ignored. Now correctly forwarded.

Removed

  • Dead timeout config: Removed unused default_timeout and stream_timeout from config/config.exs. Timeouts are determined by per-provider defaults in Model.default_receive_timeout/1 and each provider module's @default_timeout/@streaming_timeout constants.

Documentation

  • Added "Timeouts" section to README documenting receive_timeout option 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_use and pre_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
  • Skills system: Reusable instruction/capability packages for agents.

    • Module-based skills with use Nous.Skill macro 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 and Nous.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
  • 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, with CUSTOM_API_KEY / CUSTOM_BASE_URL environment-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.
  • Custom Providers guide (docs/guides/custom_providers.md) and examples/providers/custom_providers.exs.

Changed

  • Model.parse/2 accepts the openai_compatible: prefix as a backward-compatible alias for custom: (both route to the :custom provider); ModelDispatcher gained an explicit :custom clause.
  • Expanded documentation across Model, OpenAICompatible, and the README; vllm_sglang.exs now points to custom: 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_history now stays in sync with the persistence backend.

Added

  • {:error, reason} handling in recall/2 and Search.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: and structured_output: as options to Nous.Agent.run/3 and Nous.Agent.run_stream/3 to 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 module
    • OutputSchema.tool_name_for_schema/1 — build synthetic tool name from schema module
    • OutputSchema.find_schema_for_tool_name/2 — reverse-map tool name to schema module
    • OutputSchema.synthetic_tool_name?/1 — predicate for synthetic tool call detection
    • OutputSchema.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_call mode 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 ProviderError or ModelError (rate limit, server error, timeout, auth issue).
    • Nous.Fallback — core fallback logic: eligibility checks, recursive model chain traversal, model string/struct parsing
    • :fallback option on Nous.Agent.new/2 — ordered list of fallback model strings or Model structs
    • :fallback option on Nous.generate_text/3 and Nous.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 ProviderError and ModelError trigger fallback; application-level errors (ValidationError, MaxIterationsExceeded, ExecutionCancelled, ToolError) are returned immediately
    • 52 new tests across test/nous/fallback_test.exs and test/nous/agent_fallback_test.exs

Changed

  • Nous.Agent struct gains fallback: [Model.t()] field (default: [])
  • Nous.LLM now uses injectable dispatcher (get_dispatcher/0) for testability, consistent with AgentRunner

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.

Fixed

  • AgentRunner: before_request plugin hook now short-circuits the LLM call when a plugin sets needs_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") with GOOGLE_CLOUD_PROJECT set was storing a hardcoded v1 URL in model.base_url, causing the provider's v1beta1 selection logic to be bypassed. Preview models now correctly use v1beta1 at 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_LOCATION support: Added as a fallback for GOOGLE_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/3 not including the "model" key in the params map, causing chat/2 and chat_stream/2 to always fall back to "gemini-2.0-flash" regardless of the requested model.
  • Vertex AI 404 on preview models: Use v1beta1 API version for preview and experimental models (e.g., gemini-3.1-pro-preview). The v1 endpoint returns 404 for these models.

Added

0.12.6 - 2026-03-07

Added

  • Auto-update memory: Nous.Plugins.Memory can now automatically reflect on conversations and update memories after each run — no explicit tool calls needed. Enable with auto_update_memory: true in memory_config. Configurable reflection model, frequency, and context limits.
    • New after_run/3 callback in Nous.Plugin behaviour — runs once after the entire agent run completes. Wired into both AgentRunner.run/3 and run_with_context/3.
    • Nous.Plugin.run_after_run/4 helper 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

0.12.5 - 2026-03-06

Added

  • Vertex AI provider: Nous.Providers.VertexAI for 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_key option, VERTEX_AI_ACCESS_TOKEN env 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_PROJECT and GOOGLE_CLOUD_REGION env vars
    • Nous.Providers.VertexAI.endpoint/2 helper to build endpoint URLs
    • Reuses existing Gemini message format, response parsing, and stream normalization
    • Model string: "vertex_ai:gemini-2.0-flash"

0.12.2 - 2026-03-04

Fixed

  • Gemini streaming: Fixed streaming responses returning 0 events. The Gemini streamGenerateContent endpoint returns a JSON array (application/json) by default, not Server-Sent Events. Instead of forcing SSE via alt=sse query parameter, added a pluggable stream parser to Nous.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_parser option on HTTP.stream/4 — accepts any module implementing parse_buffer/1 with 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 search
    • Nous.Memory.Store.SQLite — SQLite + FTS5 backend (requires exqlite)
    • Nous.Memory.Store.DuckDB — DuckDB + FTS + vector backend (requires duckdbex)
    • Nous.Memory.Store.Muninn — Tantivy BM25 text search backend (requires muninn)
    • Nous.Memory.Store.Zvec — HNSW vector search backend (requires zvec)
    • Nous.Memory.Store.Hybrid — combines Muninn + Zvec for maximum retrieval quality
    • Nous.Memory.Scoring — pure functions for Reciprocal Rank Fusion, temporal decay, composite scoring
    • Nous.Memory.Search — hybrid search orchestrator (text + vector → RRF merge → decay → composite score)
    • Nous.Memory.Embedding — embedding provider behaviour with pluggable implementations
    • Nous.Memory.Embedding.Bumblebee — local on-device embeddings via Bumblebee + EXLA (Qwen 0.6B default)
    • Nous.Memory.Embedding.OpenAI — OpenAI text-embedding-3-small provider
    • Nous.Memory.Embedding.Local — generic local endpoint (Ollama, vLLM, LMStudio)
    • Nous.Memory.Tools — agent tools: remember, recall, forget
    • Nous.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.ETS with Jaro matching. The core memory system has zero additional dependencies.

0.11.3 - 2026-02-26

Fixed

Added

  • Nous.StreamNormalizer.Anthropic — normalizes Anthropic SSE events (content_block_delta, message_delta, content_block_start for tool use, thinking deltas, error events)
  • Nous.StreamNormalizer.Gemini — normalizes Gemini SSE events (candidates array with text parts, functionCall, finishReason mapping)
  • 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.OutputSchema core module: JSON schema generation, provider settings dispatch, parsing and validation
    • use Nous.OutputSchema macro with @llm_doc attribute for schema-level LLM documentation
    • validate_changeset/1 optional callback for custom Ecto validation rules
    • Validation retry loop: failed outputs are sent back to the LLM with error details (max_retries option)
    • 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_schemaresponse_format with strict JSON schema (OpenAI, vLLM, SGLang, Gemini)
    • :tool_call — synthetic tool with tool_choice (Anthropic default)
    • :jsonresponse_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, generationConfig now passed through in build_request_params

  • New Files:

    • lib/nous/output_schema.ex — core module
    • lib/nous/output_schema/validator.ex — behaviour definition
    • lib/nous/output_schema/use_macro.exuse Nous.OutputSchema macro
    • docs/guides/structured_output.md — comprehensive guide
    • examples/14_structured_output.exs — example script with 5 patterns
    • test/nous/output_schema_test.exs — 42 unit tests
    • test/nous/structured_output_integration_test.exs — 16 integration tests
    • test/eval/agents/structured_output_test.exs — 3 LLM integration tests

Changed

0.10.1 - 2026-02-14

Changed

  • Sub-Agent plugin unified: Merged ParallelSubAgent into Nous.Plugins.SubAgent

    • Single plugin now provides both delegate_task (single) and spawn_agents (parallel) tools
    • system_prompt/2 callback 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
  • New Example: examples/13_sub_agents.exs

    • Template-based sub-agents using Nous.Agent.new/2 structs
    • Parallel execution with inline model config
    • Direct programmatic invocation bypassing the LLM

0.10.0 - 2026-02-14

Added

  • Plugin System: Composable agent extensions via Nous.Plugin behaviour

    • 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
  • Human-in-the-Loop (HITL): Approval workflows for sensitive tool calls

  • Sub-Agent System: Enable agents to delegate tasks to specialized child agents

    • Nous.Plugins.SubAgent provides delegate_task tool
    • 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.Summarization monitors 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

  • Enhanced Supervision: Production lifecycle management for agents

    • Nous.AgentRegistry for session-based process lookup via Registry
    • Nous.AgentDynamicSupervisor for 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

  • PubSub Abstraction Layer: Unified Nous.PubSub module for all PubSub usage

    • Nous.PubSub wraps 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.Context gains pubsub and pubsub_topic fields (runtime-only, never serialized)
    • Nous.Agent.Callbacks.execute/3 now broadcasts via PubSub as a third channel alongside callbacks and notify_pid
    • AgentServer refactored to use Nous.PubSub — removes ad-hoc setup_pubsub_functions/0 and subscribe_fn/broadcast_fn from state
    • Research Coordinator broadcasts progress via PubSub when :session_id is provided
    • SubAgent plugin propagates parent's PubSub context to child agents
  • Async HITL Approval via PubSub: Nous.PubSub.Approval module

    • handler/1 builds an approval handler compatible with Nous.Plugins.HumanInTheLoop
    • Broadcasts {:approval_required, info} and blocks via receive for response
    • respond/4 sends approval decisions from external processes (e.g., LiveView)
    • Configurable timeout with :reject as default on expiry
    • Enables async approval workflows without synchronous I/O
  • Deep Research Agent: Autonomous multi-step research with citations

  • New Research Tools:

  • New Dependencies:

    • floki ~> 0.36 (optional, for HTML content extraction)
    • phoenix_pubsub ~> 2.1 (test-only, for PubSub integration tests)

Changed

  • Nous.Agent struct now accepts plugins: [module()] option
  • Nous.Tool struct now accepts requires_approval: boolean() option
  • Nous.Agent.Context now includes approval_handler, pubsub, and pubsub_topic fields
  • Nous.AgentServer supports optional :name registration, :persistence backend, and uses Nous.PubSub (removed ad-hoc setup_pubsub_functions/0)
  • Nous.AgentServer :pubsub option now defaults to Nous.PubSub.configured_pubsub() instead of MyApp.PubSub
  • Nous.AgentRunner accepts :pubsub and :pubsub_topic options 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

  • 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.Optimizer with 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 formats
    • mix nous.optimize - Parameter optimization with configurable strategies and metrics
  • New Dependency: yaml_elixir ~> 2.9 for 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 execution
    • 02_yaml_suite.exs - Loading and running YAML suites
    • 03_optimization.exs - Parameter optimization workflows
    • 04_custom_evaluator.exs - Implementing custom evaluators
    • 05_ab_testing.exs - A/B testing configurations

0.8.1 - 2025-12-31

Fixed

  • Fixed Usage struct not implementing Access behaviour for telemetry metrics
  • Fixed Task.shutdown/2 nil return case in AgentServer cancellation
  • 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.Context struct 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.Behaviour for implementing custom agents with lifecycle callbacks (init_context/2, build_messages/2, process_response/3, extract_output/2).

  • Dual Callback System: New Nous.Agent.Callbacks supporting 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.Behaviour for defining tools as modules with metadata/0 and execute/2 callbacks. Use Nous.Tool.from_module/2 to create tools from modules.

  • Tool Context Updates: New Nous.Tool.ContextUpdate struct allowing tools to modify context state:

    def my_tool(ctx, args) do
      {:ok, result, ContextUpdate.new() |> ContextUpdate.set(:key, value)}
    end
  • Tool Testing Helpers: New Nous.Tool.Testing module with mock_tool/2, spy_tool/1, and test_context/1 for testing tool interactions.

  • Tool Validation: New Nous.Tool.Validator for JSON Schema validation of tool arguments.

  • Prompt Templates: New Nous.PromptTemplate for EEx-based prompt templates with variable substitution.

  • Built-in Agent Implementations: Nous.Agents.BasicAgent (default) and Nous.Agents.ReActAgent (reasoning with planning tools).

  • Structured Errors: New Nous.Errors module with MaxIterationsReached, ToolExecutionError, and ExecutionCancelled error types.

  • Enhanced Telemetry: New events for iterations (:iteration), tool timeouts (:tool_timeout), and context updates (:context_update).

Changed

  • Result Structure: Nous.run/3 now returns %{output: _, context: _, usage: _} instead of just output string.

  • Tool Function Signature: Tools now receive (ctx, args) instead of (args). The context provides access to ctx.deps for 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

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 .exs example 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, and gemini_ex are 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 of OpenaiEx.ChatMessage structs. This removes the compile-time dependency on openai_ex for message formatting.

Fixed

  • Fixed "anthropix dependency not available" errors that occurred when using the library in applications without anthropix installed.

  • Fixed compile-time errors that occurred when openai_ex was 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