Skip to content

Tags: nyo16/nous

Tags

v0.17.0

Toggle v0.17.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
refactor: split Nous.AgentRunner god module into facade + 4 submodules (

#70)

* refactor: extract AgentRunner.PromptAssembly

Move-only extraction of prompt/settings assembly helpers out of the
Nous.AgentRunner god module into Nous.AgentRunner.PromptAssembly
(@moduledoc false, internal): inject_todos_into_prompt,
format_todos_for_prompt, priority_icon, apply_plugin_system_prompts,
inject_structured_output_settings, merge_structured_output_settings,
convert_synthetic_tool_anthropic. No behavior change; public API
untouched.

* refactor: extract AgentRunner.Streaming

Move-only extraction of stream handling out of Nous.AgentRunner into
Nous.AgentRunner.Streaming (@moduledoc false, internal):
wrap_stream_with_callbacks, wrap_stream_with_result, build_stream_result,
consume_stream_into_message, handle_stream_event, build_streamed_message,
check_cancellation_inline. maybe_inject_include_usage stays behind for the
RequestDispatch extraction. No behavior change; public API untouched.

* refactor: extract AgentRunner.RequestDispatch

Move-only extraction of request dispatch out of Nous.AgentRunner into
Nous.AgentRunner.RequestDispatch (@moduledoc false, internal):
request_with_fallback, stream_with_fallback, stream_request_with_fallback,
acquire_and_request, safe_acquire, record_or_release_rate_limit,
resolve_rate_limiter, resolve_alive_process, estimate_request_tokens,
rebuild_settings_for_model, get_dispatcher, maybe_inject_include_usage,
convert_tools_for_provider. converted_tool_schemas stays in the facade
(mutates ctx.tool_schema_cache) and delegates conversion. No behavior
change; public API untouched.

* refactor: extract AgentRunner.ToolExecution

Move-only extraction of the tool-call execution pipeline out of
Nous.AgentRunner into Nous.AgentRunner.ToolExecution (@moduledoc false,
internal): handle_tool_calls, sequential/parallel execution, pre-stage
decisions, approval + permission-policy enforcement, hook integration,
result recording, error formatting, and tool-call field helpers.
Task.Supervisor.async_stream_nolink under Nous.TaskSupervisor and the
ctx/run_ctx threading are preserved exactly. Telemetry events all remain
in the facade (none lived in moved code). No behavior change; public API
untouched.

* fix: run_stream emitted a duplicate empty {:complete, _} event

OpenAI-compatible providers yield two {:finish, _} events per stream (the
finish_reason chunk plus the end-of-stream marker); wrap_stream_with_result
emitted a {:complete, _} for each, the second with empty output. Swallow
the duplicate once completed. Found by the new LM Studio live smoke suite;
reproduced deterministically in the added regression test.

* fix: Message.extract_text/1 crashed on nil content

Thinking models truncated mid-reasoning return assistant messages with only
reasoning_content set; extract_text had no nil clause, so
BasicAgent.extract_with_output_type raised FunctionClauseError and failed
the whole run. Return "" for nil content.

* test: add LM Studio live smoke suite

One live test per extracted AgentRunner submodule path: plain run,
sequential tool loop, parallel_tool_calls, and the public run_stream/3
(previously uncovered live). Tagged :llm (excluded by default);
model-agnostic assertions safe for thinking models.

* fix: silence optional-dep compile warnings in consumer builds

Gate SearchScrape on Floki like WebFetch — compiling it against the
floki-less WebFetch stub warned that the {:ok, page} clause can never
match. Add no_warn_undefined for :hackney/:hackney_pool so apps without
the optional hackney backend compile nous without warnings.

* chore: changelog entries for AgentRunner split + fixes; bump to 0.17.0

0.16.6 is already published on hex.pm (2026-06-27) while mix.exs still
carried 0.16.6; Unreleased includes the parallel_tool_calls feature, so
the next release is a minor bump.

* test: fix flaky setup race and real-network calls in unit tests

StructuredOutputStreamingTest: the scripted-dispatcher Agent was started
with a bare start_link, so the named process could outlive a test long
enough to race the next setup into {:error, {:already_started, _}} —
use start_supervised! for synchronous teardown.

SummarizationTest: the failure-path tests relied on 'no API key' to make
the summary LLM call fail, firing real requests at api.openai.com (and
with OPENAI_API_KEY exported they would make a paid call and then fail).
Swap in a failing mock dispatcher; async: false since it mutates the
global app env.

v0.16.6

Toggle v0.16.6's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
refactor: consolidate OpenAI-compatible provider chat into Nous.Provi…

…der macro (#65)

Six providers (vLLM, SGLang, LM Studio, Mistral, OpenAICompatible, Custom)
carried near-identical chat/2 + chat_stream/2 implementations — the local
trio (vLLM/SGLang/LM Studio) were byte-for-byte structurally identical
except for an id, base URL, env prefix, and display name.

Extend `use Nous.Provider` with an opt-in `chat:` config that injects the
shared chat/2, chat_stream/2, base-URL resolver, and header helpers. The
duplication reduced to three variation axes:

  * base_url: :plain | :local | :required
  * headers:  :bearer | :bearer_org
  * timeout: / stream_timeout:  (replaces duplicated module-attr constants)

Each provider collapses to a moduledoc + a single `use` block.

Behavior-preserving:
  * Error message strings reproduced exactly (generated from id/env where
    they were provider-specific).
  * Local trio now forwards finch_name in chat_stream, matching the
    convention already used by the other 7 providers (they were the
    outliers that dropped it).

Verified: provider tests 197 passed, full suite 1896 passed,
credo --strict 0 issues, compile --warnings-as-errors clean, format clean.

v0.16.5

Toggle v0.16.5's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Audit-driven hardening: security fixes, dep hygiene, perf, +75 tests (#…

…61)

* Audit-driven hardening: security fixes, dep hygiene, perf, +75 tests

Implements the fix plan derived from a full-codebase health audit, plus a
streaming/tool-call hardening review. Full suite green (1881 passed, 0
failures); format + credo --strict clean.

Security
- Fix HIGH approval-gate bypass: the pre_tool_use `{:modify}` hook branch in
  AgentRunner skipped `enforce_policy_approval`, so a tool gated only by the
  permission policy (strict / approval_required / execute-category) ran ungated
  whenever a hook rewrote its arguments. Now applies policy approval on that
  path too. (+regression test)
- InputGuard fails closed on dropped strategies: a timed-out/errored strategy
  under the default `:any` aggregation no longer passes as `:safe`; it upgrades
  to `:suspicious` (configurable `fail_closed`, telemetry + log, new
  `strategy_timeout`).
- Permissions: `:permissive` mode no longer auto-approves `category: :execute`
  tools unless `allow_unattended_execute: true` (new `requires_approval?/3`).
- Memory stores: `field_to_column/1` is now a strict column allowlist in the
  SQLite/DuckDB backends (removes a SQL-identifier injection primitive).
- mix nous.optimize: `--params` accepts safe YAML/JSON data; `Code.eval_file`
  is now an explicit, warned `.exs` fallback only.

Dependencies
- Purge 36 stale mix.lock entries; add `mix deps.unlock --check-unused` to CI.
- Loosen net_runner (`~> 1.0`) and req (`~> 0.5 or ~> 0.6`); make phoenix_pubsub
  `optional: true`.
- Guard the hackney optional-dep crash paths (backend selection + pool config).

Performance (BEAM hot paths)
- Context.add_messages/2: single concat, O(n+m) instead of O(n*m).
- PubSub: memoize `Code.ensure_loaded?(Phoenix.PubSub)` in :persistent_term
  (was per-broadcast / per-streamed-token).
- Hybrid memory search: run the embedding round-trip concurrently with the text
  scan (store access stays single-process).
- SSE parsing: `:binary.split` instead of a per-chunk regex split.

Tests (+75)
- New: OpenAI request marshalling, InputGuard fail-closed, permissions
  category-gate, optimizer data params, Context.add_messages equivalence,
  output_schema one_of error path, semantic input-guard strategy,
  ParallelExecutor, teams Supervisor, research Planner + Synthesizer, and a
  reusable Nous.MemoryStoreConformance harness (wired to the ETS backend;
  native backends adopt it behind a tag in CI).
- Remove flaky/redundant sleeps in rate_limiter, workflow state, and phase3
  tests; fix the self-contradicting one_of test.

See CHANGELOG.md for the user-facing security entries.

* Wire up llama_cpp_ex 0.8.22 + add a local llama.cpp smoke test

Enables the LlamaCpp NIF provider and adds an end-to-end smoke test against
real GGUF models. Picks up the previously-deferred P4-T4 (llamacpp coverage).

- mix.exs: add {:llama_cpp_ex, "~> 0.8", optional: true} (optional so it stays
  out of downstream builds unless opted in, but available for Nous's dev/test).
  mix.lock adds only llama_cpp_ex 0.8.22 + fine; unrelated transitive bumps
  (ecto 3.14, req 0.6, telemetry 1.4) that `deps.update` tried to drag in were
  isolated out — ecto 3.14 trims whitespace in cast :empty_values and breaks the
  ContentPart "\n\n\n" regression test, so that update belongs in the deliberate
  dependency-bump pass.

- providers/llamacpp.ex: fix two latent warnings exposed now that the module
  actually compiles (it was compiled-out without the NIF):
  * do_request_stream/3 had dead {:ok,_}/{:error,_} clauses on
    stream_chat_completion, which is spec'd `:: Enumerable.t()` (raw stream,
    errors surface during enumeration). Simplified to match the real contract.
  * dropped the unused hand-written build_request_params/3 stub — the
    macro-generated default fills the overridable slot and is exempt from the
    unused-function warning. Compiles clean under --warnings-as-errors.

- test/nous/providers/llamacpp_smoke_test.exs (@moduletag :llama, excluded by
  default; test_helper excludes it): chat completion (generate_text + agent
  loop), enable_thinking:false suppresses <think>, json_schema structured output
  (tolerant JSON extraction — small models wrap output in fences), an
  agent-with-tool completes (documents that llama_cpp_ex has no native tools
  API; grammar/json_schema is its structured mechanism), and embeddings via
  embed/3. Reads NOUS_LLAMACPP_TEST_MODEL / NOUS_LLAMACPP_TEST_EMBED_MODEL;
  skips cleanly (single placeholder) when the model/NIF is absent so the default
  suite and CI never fail. Verified 7/7 passing across repeated runs against
  Qwen3.5-0.8B + Qwen3-Embedding-0.6B with Metal.

* Fix dialyzer on the LlamaCpp provider (build_request_params)

`use Nous.Provider` injects `@dialyzer {:nowarn_function, build_request_params: 3}`,
but the provider overrides request/3 + request_stream/3, so the macro's default
build_request_params/3 is dead-code-eliminated when unused — leaving the dialyzer
directive dangling ("Unknown function build_request_params/3"). Defining it as a
private stub instead trips the compiler's unused-function warning (and @compile
nowarn_unused_function doesn't cover Elixir's own check). Resolve the catch-22
with a `@doc false` public stub: public functions aren't unused-warned and stay
in the BEAM, so the @dialyzer directive resolves.

`mix dialyzer` now passes (0 errors). Note: the local PLT under priv/plts is
gitignored and was rebuilt for OTP 29 (the committed-era PLT was OTP-incompatible
and raised "Old PLT file"); no repo artifact changes.

v0.16.4

Toggle v0.16.4's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Audit-pass follow-up: security/OTP/test hardening (#60)

* Phase 1: security hardening (PathGuard canonical path, web_fetch fail-closed, atom-DoS, ReDoS cap, UrlGuard ranges, docs)

* Phase 2: correctness fixes (get_tool_field fetch, rate-limit TOCTOU, async-load reply-on-crash, async_nolink absorb, claims O(1), iodata flat accumulation, drop needless ets rescue)

* Phase 4: test quality (remove redundant sleeps, deterministic refute_receive, start_supervised!, non-tautological glob assertion, unique telemetry handler IDs+detach, membership asserts, encoded-IP SSRF cases, async path_guard)

* Phase 3: document intentional run-scoped ETS ownership model (KB/Decisions stores), safer slug-index write order

* Review fixes: observable rate-limiter fail-open (log + telemetry), correct async_nolink completion-clause comment

* Capture audit-pass-followup solutions + mark plan complete

* chore: untrack local .claude workflow artifacts

v0.16.3

Toggle v0.16.3's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Security, bug, performance & docs audit pass (RCE gate, SSRF, sandbox…

… + 90 more findings) (#59)

* security: fix RCE approval gate, sandbox escape & SSRF holes

Critical/high security findings from the audit:

- Tool.from_module/2 dropped requires_approval from metadata, silently
  disabling the agent_runner approval gate for Bash/FileWrite (one prompt
  injection from RCE). Fall back to metadata like name/description do.
- Tool.Behaviour.implements?/1 used function_exported?/3 without loading the
  module first, so from_module spuriously rejected built-in tools by load
  order. Guard with Code.ensure_loaded?.
- Agent.parse_tools/1 now accepts bare behaviour modules (tools: [Tools.Bash]),
  routing through from_module (preserves metadata flags; matches the docs).
- FileGrep passed LLM-controlled pattern/glob into ripgrep with no '--'
  terminator -> rg flag injection (-f/--pre) escaping the workspace. Use
  --regexp/--glob and terminate options before the positional path. Also
  re-validate each matched file in the Elixir fallback (mirrors FileGlob).
- PathGuard only lstat'd the final path component, so an intermediate
  directory symlink escaped the jail. Resolve symlinks across every existing
  component (realpath) and compare canonical path vs canonical root.
- UrlGuard: normalize IPv4-mapped IPv6 + NAT64 to the v4 blocklist, block
  fe80::/10 and ::, and resolve BOTH A and AAAA (dual-stack bypass). Add
  validate_pinned/2 returning a validated IP; web_fetch now pins the
  connection to that IP (original hostname kept for Host/SNI/cert), closing
  the DNS-rebinding TOCTOU.
- Req provider backends (one-shot + streaming) set redirect: false so a
  compromised upstream can't bounce requests to internal/metadata addresses.

Regression tests added for every fix.

* security: enforce permissions policy & input guards; secret hygiene

Authorization / guard enforcement (audit medium findings):

- Nous.Permissions was dead code. Add an optional :permissions Policy to Agent,
  filter blocked tools out of the model's tool set in agent_runner, and force
  the approval gate for policy-approval-required tools. Fix blocked?/2 to honor
  allow lists in ALL modes (deny-by-default), validate Policy.mode, and fail
  closed (block / require-approval) on unknown modes.
- InputGuard never ran on the streaming path: run_stream now executes the
  plugin init + before_request pipeline and short-circuits to a terminal
  'blocked' stream without calling the model.
- LLMJudge: fence untrusted input in a random boundary, parse only the first
  VERDICT line, and honor on_error (fail-closed) on unparseable responses;
  truncate the stored raw_response.
- InputGuard :majority/:all aggregation now uses the configured strategy count
  as the denominator so killing strategies can't flip the vote.
- Pattern strategy NFKC-normalizes and strips zero-width/bidi chars before
  matching (defeats homoglyph/zero-width evasion).
- Policy :warn/:block sanitize and fence the (possibly LLM-derived) reason
  before embedding it in a system/assistant message.

Secret & data-exposure hygiene:

- Context.serialize_deps redacts credential-shaped keys (api_key/token/secret/
  password/authorization/...) so persistence never writes them.
- Gemini sends the API key via the x-goog-api-key header, not the URL query
  string (URLs leak into logs/proxies/spans).
- Default telemetry handler logs a bounded status+body summary, not the raw
  error term (full upstream body/headers).
- Streaming Req backend caps non-2xx error-body buffering at max_buffer_size.
- Hook.new/2 accepts :fail_closed so security-gating hooks can opt in.
- Persistence.ETS + Workflow.Checkpoint.ETS tables are now :protected (owner
  writes via GenServer, any process reads) instead of :public; add ETS.clear/0.
- Summarization omits raw tool-result content from the durable summary.

Regression tests added across these paths.

* fix: correctness bugs across providers, streaming, agents, teams & workflow

Crash / wrong-behavior fixes (audit high/medium):

- Anthropic from_response: join multiple text/thinking blocks instead of
  returning a list into the :string content field (was Ecto.InvalidChangesetError
  on common multi-block responses). Mirrors the Gemini fix.
- OpenAI parse_tool_call: default a missing "function" wrapper to %{} (was
  BadMapError aborting the whole response parse on non-conformant backends).
- AgentServer no longer adds the user message to context twice per turn.
- Nous.LLM streaming-with-tools reassembles fragments via ToolCallAccumulator
  (was: Access crash on OpenAI list shape, nil-arg tool calls on Anthropic).
- Streaming accumulator carries Gemini/Vertex thought_signature metadata.
- ToolExecutor catches throw/non-timeout exit -> retryable ToolError instead of
  crashing the whole agent run.
- TeamTools resolves shared_state/coordinator passed as a registered NAME to a
  live pid (region locking & discovery sharing were silently disabled).
- SQLite memory search_text scope placeholder off-by-one fixed.
- Hybrid store over-fetches a larger candidate pool when scoped.
- Memory search normalizes RRF scores to 0-1 (consistent min_score behavior).
- Per-run :model_settings override is now applied.
- RateLimiter wired into the agent request path (rpm/tpm/request enforced).
- Parallel executor preserves branch_id/index on crash/timeout.

Lower-severity robustness: SearchScrape processes all URLs (capped/throttled) +
clamps opts; eval/config env_integer via Integer.parse and estimate_cost deep
merge; tool validator recurses into nested objects/arrays.

Regression tests added across all of the above.

* perf: remove O(n^2) accumulation and per-node full-table scans

- RateLimiter.apply_delta prepends to the sliding window instead of `++ [entry]`
  (O(1) vs O(n); window is order-independent).
- SharedState.share_discovery prepends discoveries (get_discoveries reverses to
  keep insertion order), removing the O(n^2) tail-append.
- KnowledgeBase ETS store keeps a slug->id index so fetch_entry_by_slug is O(1)
  instead of a full tab2list scan + struct rebuild on every kb_read/backlinks/
  link tool call. Index maintained on store/update/delete.
- 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).

Regression tests added for slug-index consistency on update/delete.

* docs: fix non-compiling examples, wrong APIs, and stale claims

- mix.exs: licenses ["MIT"] -> ["Apache-2.0"] to match the bundled LICENSE and
  README badge.
- README + Memory plugin moduledoc: move plugin :deps from Nous.new/2 (where it
  is silently dropped — the Agent struct has no :deps field) to Nous.run/3, for
  the Memory, Knowledge Base, and Sub-Agent examples.
- README: scope the streaming-backpressure claim (Req default; Hackney opt-in).
- docs/getting-started.md:
  - Nous.ProviderError/ModelError -> Nous.Errors.* (the bare names don't exist
    and the retry example would not compile).
  - AgentDynamicSupervisor.start_agent("id", agent_config_map, opts) — correct
    arity/types; drop the ignored :name option.
  - restore via Nous.Agent.Context.deserialize/1 (load returns a serialized map).
  - ChatBot GenServer example uses %Nous.Message{} structs + the :messages key
    (a bare list of role/content maps returns {:error, :invalid_input}).
- AGENTS.md:
  - custom-tool example uses @behaviour + metadata/0 + execute(ctx, args)
    (the documented `use Nous.Tool` / reversed args don't compile); bare tool
    modules in tools: now work (parse_tools converts them).
  - correct the "streaming always uses hackney pull mode / not configurable"
    claim (Req is the default since 0.15.4; Hackney is opt-in).

* chore: dialyzer clean — guard nil/list message content in rate-limit & blocked stream

Message.extract_text/1 has no nil-content clause (it would raise on a
tool-call-only assistant message) and is spec'd String.t(), so the `|| ""`
fallbacks were both dead code (dialyzer guard_fail) and unsafe. Match binary
content directly in blocked_stream/1 and estimate_request_tokens/1.

* docs: changelog entries for the security/bug/perf/docs audit pass

v0.16.2

Toggle v0.16.2's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
Audit-driven fixes: provider marshalling, ETS lifecycle, OTP hygiene,…

… telemetry (#58)

* fix: provider message marshalling — Gemini tool name, OpenAI bad-JSON, usage parsing

- Gemini functionResponse.name must equal the original functionCall.name;
  was leaking tool_call_id (e.g. "gemini_abc123") and breaking every
  Gemini/Vertex tool roundtrip. Now uses Message.name with tool_call_id
  fallback; agent_runner + llm + context all thread name through
  Message.tool/3.
- OpenAI tool-call arguments JSON-decode failure no longer injects a fake
  args map (%{"error" => ..., "raw" => ...}) into the tool. decode_arguments/1
  now returns {:ok, map()} | {:error, {:invalid_json, raw}}; parsers tag the
  call with "_invalid_arguments" and agent_runner short-circuits with a
  proper tool-error result so the LLM can retry.
- Streaming Gemini tool calls now synthesize a "gemini_<base64>" id matching
  the non-streaming parser instead of always emitting id: nil.
- Anthropic + Gemini usage parsing set requests: 1 (was 0) so final usage
  metrics aren't undercounted. Anthropic captures cache_creation_input_tokens
  and cache_read_input_tokens; Gemini captures cachedContentTokenCount. New
  Usage struct fields propagate through add/2.
- Comment why two Gemini parsers exist (parse_content vs parse_parts) to
  prevent future "consolidate these" refactors that would break callers.

1743 tests passing (was 1737, +6 new tests).

* fix: ETS store lifecycle — supervised Checkpoint owner + Memory plugin re-init

- Nous.Workflow.Checkpoint.ETS gains a supervised TableOwner GenServer
  started under Nous.Application. Previously the :nous_workflow_checkpoints
  table was owned by whichever caller first invoked save/load — when that
  process died, the table died and init/0 silently recreated an empty one,
  losing every suspended workflow that depended on resume. Added a regression
  test that saves from a transient Task and asserts the data survives.
- Nous.Plugins.Memory.init/2 is called on every agent run by
  AgentRunner.run_init. Previously it called store_mod.init/1 unconditionally,
  creating a fresh ETS table per run and silently discarding the prior one
  (under load: ets_too_many_tables). Now reuses store_state when already set;
  extracted apply_defaults/1 so the per-run defaults still get refreshed.
  Added a regression test asserting the second init returns the same store_state.
- Added a regression test that runs a workflow with scratch: true where a
  node raises, and asserts the :nous_scratch_* table is cleaned up. The
  executor's existing exception catch already handles this, but the test
  pins the contract.

1746 tests passing (was 1743, +3 new tests).

* fix: supervise Task.async sites + clear compile warnings

Migrate bare Task.async / Task.async_stream callsites to the application's
Nous.TaskSupervisor with the *_nolink variants — so a research/eval/scrape
crash no longer crashes the calling process (and vice versa), and the
supervisor can send graceful exits on app shutdown.

- research/coordinator.ex: top-level research task + parallel search stream
- eval/runner.ex: parallel suite run + per-test-case timeout task
- tools/search_scrape.ex: URL fan-out
- plugins/input_guard.ex: parallel strategy run
- http/stream_backend/req.ex: SSE producer task (the default streaming
  backend)

Task.yield now handles {:exit, reason} as {:error, {:task_exit, reason}}
instead of crashing the caller with CaseClauseError (coordinator.ex,
eval/runner.ex).

Compile warnings cleared:
- providers/vertex_ai.ex: drop unreachable validate_project_id(nil)
  clause (caller already guards with `if project`).
- research/planner.ex: tighten @SPEC to {:ok, plan()}; the LLM-error
  branch falls back to a single-step plan and never returns {:error, _}.
- research/coordinator.ex: drop dead {:error, _} clause in research_loop/1
  now that plan_phase/1 only returns {:ok, _, _}.

mix compile --warnings-as-errors is clean in both dev and test envs.
1746 tests passing.

* fix: AgentServer PubSub topic mismatch + terminate cancellation

- AgentServer.init/1 subscribed to "agent:#{session_id}" while the public
  helper Nous.PubSub.agent_topic/1 returns "nous:agent:#{session_id}".
  Anyone publishing via the helper never reached the server. Use the
  helper.
- AgentServer.terminate/2 now cancels state.current_task on shutdown
  (set the cancelled atomic + Task.shutdown). Previously the in-flight
  LLM stream would keep consuming tokens and HTTP connections after the
  server was already gone, until the runner hit max_iterations.
- Replace `_ -> :ok` swallow on Task.shutdown with explicit clauses;
  log {:exit, reason} crashes during reset instead of silently
  discarding them.

* fix: security/validation gaps — hook fail_closed opt-in + tool validator constraint composition

- 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 (raised
  exceptions, timeouts, non-0/2 command exit codes) now :deny instead of
  silently failing open. Default remains false for backward compatibility;
  set fail_closed: true on hooks that gate security-sensitive operations.
- Nous.Tool.Validator.validate_types/2 previously matched the property
  schema's "type" clause before "enum", silently dropping any declared
  enum constraint when both keys were present (e.g. %{"type" => "string",
  "enum" => ["a","b"]} accepted any string). Now every constraint runs
  via maybe_check_type/4 + maybe_check_enum/4 so a value that violates
  both produces two errors and a value that violates only one still
  fails.

1753 tests passing (was 1746, +7 new tests).

* fix: llm.ex streaming with tools surfaces dispatcher errors

Nous.LLM.stream_text_with_tools/6 silently :halt'd its Stream.resource when
Fallback.with_fallback returned {:error, _}. Consumers iterating the stream
saw a clean empty stream with no signal that the LLM call had failed,
making error handling impossible for the streaming + tools path.

Now emits an {:error, reason} event before halting, matching the contract
consumers expect from any Stream-based provider. Regression test added.

* fix: telemetry — emit documented events, document existing ones, remove stale doc

Documented but never emitted (now emitted):
- [:nous, :agent, :iteration, :start/:stop] — fires around each do_iteration
  in AgentRunner with iteration, max_iterations, tool_calls, needs_response.
- [:nous, :context, :update] — fires from Tool.ContextUpdate.apply/2 with
  keys_updated count and the list of keys that changed.
- [:nous, :callback, :execute] — fires from Nous.Agent.Callbacks.execute/3
  with the callback_type and agent_name.

Emitted but undocumented (now documented under their own sections):
- [:nous, :agent, :fallback, :used], [:nous, :fallback, :activated]
- [:nous, :hook, :execute, :start/:stop], [:nous, :hook, :denied]
- [:nous, :skill, :activate/:deactivate]
- [:nous, :workflow, :run, :*], [:nous, :workflow, :node, :*]

Dropped [:nous, :provider, :stream, :chunk] from docs and attach_default_handler
— it was never emitted, and a per-chunk telemetry call would be high-overhead
on the hot streaming path. Use [:nous, :provider, :stream, :start]/:connected/
:exception for stream lifecycle.

1754 tests passing.

* fix: OTP design — Bumblebee parallelism, partitioned registry, async context load

- memory/embedding/bumblebee.ex: stop serializing every embedding through
  the one ServingHolder GenServer. handle_call/3 no longer runs
  Nx.Serving.run/2; it returns the serving struct via :get_serving, and
  callers run inference themselves. Nx.Serving is designed to batch
  concurrent calls, so this restores the parallelism the prior design
  bottlenecked.
- agent_registry.ex: bump Registry partitions to System.schedulers_online()
  (was the default :1). High-concurrency LiveView fan-ins called
  lookup/1 from many sockets and serialized on a single partition.
- agent_server.ex: defer maybe_load_context to a handle_continue so init/1
  returns immediately. Persistence I/O no longer blocks
  DynamicSupervisor.start_child — which means Teams.Coordinator's
  spawn_agent handle_call no longer wedges the team coordinator for
  seconds when the persistence backend is slow (S3, Postgres).

1754 tests passing, clean compile.

* fix: Req streaming backend gains backpressure guard

Req's :into callback pushed chunks to the consumer via send/2, so a fast
LLM + slow consumer (LiveView fan-out, persistence-per-chunk, slow IO)
grew the consumer's mailbox without bound — the M-12 risk called out in
mix.exs.

The producing Task now polls the consumer's message_queue_len before each
send:
- below @backpressure_high_water (1_000): forward chunk normally
- above: busy-wait in 5ms increments until queue drops below
  @backpressure_low_water (100), then resume
- still backed up after @backpressure_max_wait_ms (30s): surface
  {:error, %{reason: :backpressure_overflow, queue_len: n}} and halt
  rather than wedging forever

This pauses Req's :into callback while we wait, which transitively pauses
the producing socket — natural pull-based backpressure on top of the
push-based default. Users with reliably slow consumers can still opt
into Hackney's strict :async-once mode.

1754 tests passing.

* chore: deprecate orphaned public API + SQLite FTS5 escape fix

- Mark @deprecated on public functions with no internal callers, kept for
  backward compat per user direction (not removed since this is the 0.x
  hex line and downstream consumers may rely on them):
    Nous.ToolSchema.to_openai/1 — use Nous.Tool.to_openai_schema/1
    Nous.Agent.tool/3 — use Agent.new/2 with :tools or build %Tool{} directly
    Nous.Eval.run!/2 — match Nous.Eval.run/2's {:ok, _} | {:error, _}
    Nous.Decisions.path_between/4, descendants/3, ancestors/3 — call
      store_mod.query directly
- memory/store/sqlite.ex: FTS5 query escaping now doubles embedded `"`
  per FTS5 inside-quotes rules. A search term like `say "hi"` previously
  produced invalid FTS5 syntax and errored.

1754 tests passing, --warnings-as-errors clean.

* docs(CHANGELOG): document audit-driven fixes under Unreleased

v0.16.1

Toggle v0.16.1's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
chore: code review fixes + README reorganization (#57)

* fix(providers): return {:error, {:invalid_config, _}} instead of raising

The LMStudio, SGLang, VLLM, Custom, and LlamaCpp providers used to raise
ArgumentError when base_url validation failed or required options were
missing. That broke the documented `chat/2 :: {:ok, _} | {:error, _}`
contract and forced callers to rescue exceptions instead of pattern
matching.

Normalize the error path: bad URLs and missing config now flow back as
`{:error, {:invalid_config, reason}}` (or `%Nous.Errors.ProviderError{}`
for the llamacpp missing-model case). The high-level `Nous.run/2`,
`Nous.generate_text/3`, and `Nous.Agent.run/3` paths are unaffected
since they already returned result tuples.

Breaking change documented in CHANGELOG.

* refactor(providers): use shared header helpers from Nous.Providers.HTTP

Every provider was rebuilding the same content-type header and the same
"add Bearer auth if api_key is non-empty" conditional inline. The HTTP
module already exposed `bearer_auth_header/1` and `api_key_header/2`
helpers (and handles the nil/empty/"not-needed" cases correctly), but
nobody was using them.

Centralize the duplication on three new HTTP helpers — `json_headers/0`,
`organization_header/1`, `openai_project_header/1` — and rewire every
provider's `build_headers` to compose them. The OpenAI / OpenAICompatible
/ Custom org+project chain becomes a flat list concatenation; the four
local providers (LMStudio, SGLang, vLLM, Mistral) collapse to one line.

Pure refactor — no header values changed. Verified against the existing
provider Bypass tests (LMStudio/SGLang/vLLM/HTTP) plus new unit coverage
for the three new helpers.

* fix(vertex_ai,tools): surface Goth errors and type-guard StringTools args

Two unrelated reliability fixes that share a theme: prefer explicit
errors over silent fallthrough.

**Vertex AI: prefer Goth over the env-var fallback.**
`resolve_token/1` used to call the macro-injected `api_key/1` (which
walks opts → env var → app config) BEFORE checking for a configured
Goth instance. If a user had Goth set up but also a stale
`VERTEX_AI_ACCESS_TOKEN` env var lying around, a Goth misconfig would
silently fall through to the env var and produce confusing 401s. Now,
when `:goth` is named, we use it exclusively for that request — Goth
failures surface as `{:error, %{reason: :goth_error, ...}}`. The env
var / app config remains as the last-resort fallback for non-Goth users.

**StringTools: type-guard arg extraction.**
`replace_text`, `split_text`, `count_occurrences`, and `contains`
chained `Map.get(args, "k1") || Map.get(args, "k2") || ""` to support
aliased argument names. When the LLM handed back a non-string value
(e.g. `"pattern" => 123`), it flowed straight into `String.replace/3`
and crashed the tool call with `FunctionClauseError`. Extracted to a
single `fetch_arg/3` helper that falls back to the default when the
value isn't a binary, eliminating the nil-pun chains and the crash.

Documented in CHANGELOG.

* test(prompt_template): use @tag :tmp_dir for EEx-injection canary

The security test for `Nous.PromptTemplate.from_template/1` rejection
asserted that a hostile `<%= File.write!("/tmp/nous_pwn_test", ...) %>`
template never produced the side-effect file. The hardcoded `/tmp/`
path was async-unsafe — and worse, would have left a sentinel under
`/tmp/` on any future test machine if the rejection ever regressed.

Switch to ExUnit's `@tag :tmp_dir`, which gives a per-test unique
directory that's cleaned automatically. The test stays parallel-safe
and the canary path is now scoped to the test's own temp dir.

Note: provider @SPEC coverage was inventoried as part of this PR.
Public callbacks (`chat/2`, `chat_stream/2`, `request/3`,
`request_stream/3`, `count_tokens/1`, etc.) are already typed by the
`@callback` declarations in `Nous.Provider`, and the macro-injected
helpers (`api_key/1`, `base_url/1`, `count_tokens/1`) carry @SPEC in
the macro body. `mix dialyzer` runs clean with 0 errors and
`mix credo --strict` reports no issues. No spec changes needed.

* docs: reorganize README and extract guides (PR 5)

Cut README from 1454 to 791 lines by extracting Vertex AI setup and
HTTP backend internals to dedicated guides, splitting contributor docs
into CONTRIBUTING.md, and reordering for a new-user flow: positioning
block, AI-agent callout, tool-calling Quick Start, features overview
linking to deep dives, then condensed deep dives.

- Add docs/guides/vertex_ai_setup.md (verbatim extract, with back-link)
- Add docs/guides/http_backends.md (verbatim extract, with back-link)
- Add CONTRIBUTING.md (moves dev/test/lint sections out of README; fixes
  stale "OTP 26+/Elixir 1.15+" prereqs to OTP 27+/Elixir 1.18+; adds
  Security subsection pointing at AGENTS.md critical-rules)
- Repurpose docs/getting-started.md: fix {:nous, "~> 0.9.0"} to ~> 0.16.0,
  drop broken examples/tutorials and examples/quickstart links, strip
  duplicate Install/Quick Setup, add multi-tool agent / error handling /
  persistence / observability sections, keep error-handling and
  GenServer conversation patterns
- Fix docs/README.md link typos: tool-development.md, best-practices.md,
  migration.md to use underscores; also fix broken examples/tutorials and
  examples/quickstart references
- Preserve H2 anchors: Quick Start, Supported Providers, Features,
  Examples, Architecture, Contributing, License (the [Status](#features)
  badge still resolves)

* chore: bump version to 0.16.1

v0.16.0

Toggle v0.16.0's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
feat: expand Vertex/Gemini support — thinking, structured output, too…

…ls, caching (0.16.0) (#56)

Bundle of Gemini-on-Vertex improvements. Most surface lands as helpers in
Nous.Messages.Gemini wired into both Nous.Providers.VertexAI and
Nous.Providers.Gemini, so anything new works against either entry point.

- Thinking config (request-side) via :thinking_config (snake_case or
  Vertex camelCase), and thoughtSignature round-trip on tool calls so
  multi-turn thinking + tool loops keep working on Gemini 2.5/3.x.
- Structured output: :json_response and :json_schema map to
  responseMimeType / responseSchema; :response_format also flows through.
- :safety_settings → top-level safetySettings.
- :tool_config / :tool_choice (with :auto / :any / :required / :none /
  {:any, names} friendly forms) → top-level toolConfig.
- Function calling on Vertex/Gemini now works through the high-level
  Nous.LLM path: ToolSchema.to_gemini/1 emits proper functionDeclarations
  (strips OpenAI's "strict" + unsupported "additionalProperties").
- :native_tools accepts :google_search, :url_context, :code_execution
  (plus {tool, config} tuples / raw maps).
- :cached_content passes through as top-level cachedContent.
- Nous.LLM.stream_text/3 now honors :tools — tool_call_deltas (incl.
  thoughtSignature) aggregate per turn, tools execute, conversation
  continues until the model stops calling tools or hits max iterations.
- More generationConfig fields: topK, seed, candidateCount,
  presencePenalty, frequencyPenalty, responseModalities.
- Single timeout source of truth: removed the separate streaming-only
  defaults in both providers; receive_timeout flows uniformly through
  build_provider_opts/1.

v0.15.8

Toggle v0.15.8's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: Vertex/Gemini whitespace crash + surface retry-after hints (0.15…

….8) (#55)

* fix: Vertex/Gemini whitespace crash + surface retry-after hints

- ContentPart.new/1: Ecto's default :empty_values trimmed whitespace,
  which crashed ContentPart.text/1 on legitimate Gemini text parts
  containing only newlines. Override empty_values to [""] so "\n\n\n"
  is preserved.
- Messages.Gemini.parse_content/1: skip whitespace-only text parts
  defensively, capture finishReason and promptFeedback in metadata,
  log a warning when content is empty for non-STOP reasons (SAFETY,
  RECITATION, MAX_TOKENS) so blocked generations stop being silent.
  Also handle functionCall without args, and unify tool-call ID
  generation to a single 64-bit-entropy helper.
- Add Nous.Errors.RetryInfo: parses google.rpc.RetryInfo from error
  body and Retry-After header into milliseconds.
- ProviderError gains :retry_after_ms; Provider.request/3 now populates
  :status_code and :retry_after_ms from HTTP error tuples.
- HTTP backends (Req, Hackney, stream Req) surface response headers in
  error tuples so RetryInfo can extract Retry-After.

* chore: bump to 0.15.8 with changelog entry

* fix(dialyzer): drop unreachable header-normalize clauses

Req returns headers as %{binary() => [binary()]} unconditionally and
hackney returns them as a list — the is_list / catch-all fallbacks I
added were flagged as pattern_match_cov.

v0.15.7

Toggle v0.15.7's commit message

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
fix: make hackney an optional dependency (0.15.7) (#54)

Req is the default backend for both one-shot and streaming HTTP. Hackney
is only used when a consumer opts into Nous.HTTP.Backend.Hackney /
Nous.HTTP.StreamBackend.Hackney via NOUS_HTTP_BACKEND or app config.

Forcing {:hackney, "~> 4.0"} as a hard dep in 0.15.x broke downstream
apps that pulled in any transitive optional constraint of hackney ~> 1.x
(e.g. aws ~> 1.0's optional hackney dep). Once hackney 4 entered the
graph, Mix activated the optional ~> 1.20 constraint and version
resolution failed:

  Because "the lock" depends on "aws 1.0.10" which depends on
  "hackney ~> 1.20", "the lock" requires "hackney ~> 1.20".
  And because "nous >= 0.15.0" depends on "hackney ~> 4.0",
  "the lock" is incompatible with "nous >= 0.15.0".

Apps using the hackney backend now declare {:hackney, "~> 4.0"} in
their own mix.exs.