Tags: nyo16/nous
Tags
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.
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.
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.
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
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
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
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
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.
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.
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.
PreviousNext