README.md

![Nous AI](images/header.jpeg)

# Nous AI

> *"Nous (νοῦς) — the ancient Greek concept of mind, reason, and intellect; the faculty of understanding that grasps truth directly."*

AI agent framework for Elixir with multi-provider LLM support.

[![Elixir](https://img.shields.io/badge/elixir-~%3E%201.15-purple.svg)](https://elixir-lang.org)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/nyo16/nous/blob/master/LICENSE)
[![Status](https://img.shields.io/badge/status-active-brightgreen.svg)](#features)

## Installation

Add to your `mix.exs`:

```elixir
def deps do
  [
    {:nous, "~> 0.12.0"}
  ]
end
```

Then run:
```bash
mix deps.get
```

## Quick Start

### Simple Text Generation

For quick LLM calls without agents:

```elixir
# One-liner
{:ok, text} = Nous.generate_text("lmstudio:qwen3", "What is Elixir?")
IO.puts(text)

# With options
{:ok, text} = Nous.generate_text("openai:gpt-4", "Explain monads",
  system: "You are a functional programming expert",
  temperature: 0.7,
  max_tokens: 500
)

# Streaming
{:ok, stream} = Nous.stream_text("lmstudio:qwen3", "Write a haiku")
stream |> Stream.each(&IO.write/1) |> Stream.run()

# With prompt templates
alias Nous.PromptTemplate

template = PromptTemplate.from_template("""
Summarize the following text in <%= @style %> style:

<text>
<%= @content %>
</text>
""")

prompt = PromptTemplate.format(template, %{
  style: "bullet points",
  content: "Elixir is a dynamic, functional language for building scalable applications..."
})

{:ok, summary} = Nous.generate_text("openai:gpt-4", prompt)
```

### With Agents

For multi-turn conversations, tools, and complex workflows:

```elixir
# Create an agent
agent = Nous.new("lmstudio:qwen3",
  instructions: "Be helpful and concise."
)

# Run it
{:ok, result} = Nous.run(agent, "What is Elixir?")

IO.puts(result.output)
IO.puts("Tokens: #{result.usage.total_tokens}")
```

## Supported Providers

| Provider | Model String | Streaming |
|----------|-------------|-----------|
| LM Studio | `lmstudio:qwen3` | ✅ |
| OpenAI | `openai:gpt-4` | ✅ |
| Anthropic | `anthropic:claude-sonnet-4-5-20250929` | ✅ |
| Google Gemini | `gemini:gemini-2.0-flash` | ✅ |
| Google Vertex AI | `vertex_ai:gemini-2.0-flash` | ✅ |
| Groq | `groq:llama-3.1-70b-versatile` | ✅ |
| Ollama | `ollama:llama2` | ✅ |
| OpenRouter | `openrouter:anthropic/claude-3.5-sonnet` | ✅ |
| Together AI | `together:meta-llama/Llama-3-70b-chat-hf` | ✅ |
| LlamaCpp | `llamacpp:local` + `:llamacpp_model` | ✅ |
| Custom | `openai_compatible:model` + `:base_url` | ✅ |

All HTTP providers use pure Elixir HTTP clients (Req + Finch). LlamaCpp runs in-process via NIFs.

```elixir
# Switch providers with one line change
agent = Nous.new("lmstudio:qwen3")                  # Local (free)
agent = Nous.new("openai:gpt-4")                    # OpenAI
agent = Nous.new("anthropic:claude-sonnet-4-5-20250929")   # Anthropic
agent = Nous.new("vertex_ai:gemini-2.0-flash")      # Google Vertex AI
agent = Nous.new("llamacpp:local", llamacpp_model: llm)  # Local NIF
```

### Google Vertex AI Setup

Vertex AI provides enterprise access to Gemini models. To use it with a service account:

**1. Create a service account:**

```bash
export PROJECT_ID="your-project-id"

# Enable Vertex AI API
gcloud services enable aiplatform.googleapis.com --project=$PROJECT_ID

# Create service account
gcloud iam service-accounts create nous-vertex-ai \
  --display-name="Nous Vertex AI" \
  --project=$PROJECT_ID

# Grant permission
gcloud projects add-iam-policy-binding $PROJECT_ID \
  --member="serviceAccount:nous-vertex-ai@${PROJECT_ID}.iam.gserviceaccount.com" \
  --role="roles/aiplatform.user"

# Download key and store as env var
gcloud iam service-accounts keys create /tmp/sa.json \
  --iam-account="nous-vertex-ai@${PROJECT_ID}.iam.gserviceaccount.com"

# Set the env vars
export GOOGLE_CREDENTIALS="$(cat /tmp/sa.json)"
export GOOGLE_CLOUD_PROJECT="$PROJECT_ID"
export GOOGLE_CLOUD_REGION="us-central1"
```

**2. Add Goth to your deps** (handles token refresh from the service account):

```elixir
{:goth, "~> 1.4"}
```

**3. Start Goth in your supervision tree:**

```elixir
credentials = System.get_env("GOOGLE_CREDENTIALS") |> Jason.decode!()

children = [
  {Goth, name: MyApp.Goth, source: {:service_account, credentials}}
]
```

**4. Configure Nous to use Goth:**

```elixir
# Option A: Via app config (recommended for production)
# config/config.exs
config :nous, :vertex_ai, goth: MyApp.Goth

# Then just use it — no extra options needed:
agent = Nous.new("vertex_ai:gemini-2.0-flash")
{:ok, result} = Nous.run(agent, "Hello from Vertex AI!")
```

```elixir
# Option B: Per-model (useful for multiple projects/regions)
agent = Nous.new("vertex_ai:gemini-2.0-flash",
  default_settings: %{goth: MyApp.Goth}
)
```

```elixir
# Option C: Direct access token (no Goth needed, e.g. for quick testing)
export VERTEX_AI_ACCESS_TOKEN="$(gcloud auth print-access-token)"

agent = Nous.new("vertex_ai:gemini-2.0-flash")
```

See [`examples/providers/vertex_ai_goth_test.exs`](examples/providers/vertex_ai_goth_test.exs) for a runnable example.

## Features

### Tool Calling

Define Elixir functions as tools. The AI calls them automatically when needed.

```elixir
get_weather = fn _ctx, %{"city" => city} ->
  %{city: city, temperature: 72, conditions: "sunny"}
end

agent = Nous.new("openai:gpt-4",
  instructions: "You can check the weather.",
  tools: [get_weather]
)

{:ok, result} = Nous.run(agent, "What's the weather in Tokyo?")
```

### Tools with Context

Pass dependencies (user, database, API keys) via context:

```elixir
get_balance = fn ctx, _args ->
  user = ctx.deps[:user]
  %{balance: user.balance}
end

agent = Nous.new("openai:gpt-4", tools: [get_balance])

{:ok, result} = Nous.run(agent, "What's my balance?",
  deps: %{user: %{id: 123, balance: 1000}}
)
```

### Context Continuation

Continue conversations with full context preservation:

```elixir
{:ok, result1} = Nous.run(agent, "My name is Alice")
{:ok, result2} = Nous.run(agent, "What's my name?", context: result1.context)
# => "Your name is Alice"
```

### Streaming

```elixir
{:ok, stream} = Nous.run_stream(agent, "Write a haiku")

stream
|> Enum.each(fn
  {:text_delta, text} -> IO.write(text)
  {:finish, _} -> IO.puts("")
  _ -> :ok
end)
```

### Callbacks

Monitor execution with callbacks or process messages:

```elixir
# Map-based callbacks
{:ok, result} = Nous.run(agent, "Hello",
  callbacks: %{
    on_llm_new_delta: fn _event, delta -> IO.write(delta) end,
    on_tool_call: fn _event, call -> IO.puts("Tool: #{call.name}") end
  }
)

# Process messages (for LiveView)
{:ok, result} = Nous.run(agent, "Hello", notify_pid: self())
# Receives: {:agent_delta, text}, {:tool_call, call}, {:agent_complete, result}
```

### Module-Based Tools

Define tools as modules for better organization and testability:

```elixir
defmodule MyTools.Search do
  @behaviour Nous.Tool.Behaviour

  @impl true
  def metadata do
    %{
      name: "search",
      description: "Search the web",
      parameters: %{
        "type" => "object",
        "properties" => %{
          "query" => %{"type" => "string"}
        },
        "required" => ["query"]
      }
    }
  end

  @impl true
  def execute(ctx, %{"query" => query}) do
    http = ctx.deps[:http_client] || MyApp.HTTP
    {:ok, http.search(query)}
  end
end

agent = Nous.new("openai:gpt-4",
  tools: [Nous.Tool.from_module(MyTools.Search)]
)
```

### Tool Context Updates

Tools can modify context state for subsequent calls:

```elixir
alias Nous.Tool.ContextUpdate

add_item = fn ctx, %{"item" => item} ->
  items = ctx.deps[:cart] || []
  {:ok, %{added: item}, ContextUpdate.set(ContextUpdate.new(), :cart, items ++ [item])}
end
```

### Prompt Templates

Build prompts with EEx variable substitution:

```elixir
alias Nous.PromptTemplate

template = PromptTemplate.from_template(
  "You are a <%= @role %> who speaks <%= @language %>.",
  role: :system
)

message = PromptTemplate.to_message(template, %{role: "teacher", language: "Spanish"})
{:ok, result} = Nous.run(agent, messages: [message, Message.user("Hello")])
```

### ReActAgent

For complex multi-step reasoning with planning:

```elixir
agent = Nous.ReActAgent.new("openai:gpt-4",
  tools: [&search/2, &calculate/2]
)

{:ok, result} = Nous.run(agent,
  "Research the population of Tokyo and calculate its density"
)
```

### Plugin System

Extend agents with composable plugins for cross-cutting concerns:

```elixir
agent = Nous.new("openai:gpt-4",
  instructions: "You are an assistant.",
  plugins: [Nous.Plugins.Summarization, Nous.Plugins.HumanInTheLoop],
  tools: [&MyTools.send_email/2]
)

{:ok, result} = Nous.run(agent, "Send a welcome email to alice@example.com")
```

### Human-in-the-Loop

Add approval workflows for sensitive tool calls:

```elixir
agent = Nous.new("openai:gpt-4",
  plugins: [Nous.Plugins.HumanInTheLoop],
  tools: [&MyTools.delete_record/2]
)

{:ok, result} = Nous.run(agent, "Delete user 42",
  approval_handler: fn tool_call ->
    IO.puts("Approve #{tool_call.name}? [y/n]")
    if IO.gets("") |> String.trim() == "y", do: :approve, else: :reject
  end
)
```

#### Async Approval via PubSub

For LiveView or other async approval workflows:

```elixir
# Configure PubSub once in config/config.exs
config :nous, pubsub: MyApp.PubSub

# Use async approval handler
deps = %{hitl_config: %{
  tools: ["send_email"],
  handler: Nous.PubSub.Approval.handler(session_id: session_id, timeout: :timer.minutes(5))
}}

# In LiveView: handle {:approval_required, info} and call
# Nous.PubSub.Approval.respond(MyApp.PubSub, session_id, tool_call_id, :approve)
```

### Sub-Agent Delegation

Enable agents to delegate tasks to specialized child agents:

```elixir
agent = Nous.new("openai:gpt-4",
  plugins: [Nous.Plugins.SubAgent],
  deps: %{sub_agent_templates: %{
    "researcher" => Agent.new("openai:gpt-4o-mini",
      instructions: "Research topics thoroughly"
    ),
    "coder" => Agent.new("openai:gpt-4",
      instructions: "Write clean Elixir code"
    )
  }}
)

# delegate_task — single sub-agent for focused work
{:ok, result} = Nous.run(agent, "Research Elixir GenServers, then write an example")

# spawn_agents — multiple sub-agents in parallel
{:ok, result} = Nous.run(agent,
  "Compare GenServer vs Agent vs ETS for caching. Research each in parallel."
)
```

The `SubAgent` plugin provides two tools:
- `delegate_task` — run a single sub-agent for sequential delegation
- `spawn_agents` — run multiple sub-agents concurrently via `Task.Supervisor`

Each sub-agent runs in its own isolated context. Configure concurrency
limits and timeouts via deps:

```elixir
deps: %{
  sub_agent_templates: templates,
  parallel_max_concurrency: 3,  # Max concurrent sub-agents (default: 5)
  parallel_timeout: 60_000      # Per-task timeout in ms (default: 120_000)
}
```

### Agent Memory

Persistent memory across conversations with hybrid text + vector search:

```elixir
# Minimal setup — ETS store, keyword-only search, zero deps
agent = Nous.new("openai:gpt-4",
  plugins: [Nous.Plugins.Memory],
  deps: %{memory_config: %{store: Nous.Memory.Store.ETS}}
)

# Agent can now use remember/recall/forget tools
{:ok, r1} = Nous.run(agent, "Remember that my favorite color is blue")
{:ok, r2} = Nous.run(agent, "What is my favorite color?", context: r1.context)
# => Recalls "blue" from memory
```

Add semantic search with embeddings:

```elixir
agent = Nous.new("openai:gpt-4",
  plugins: [Nous.Plugins.Memory],
  deps: %{
    memory_config: %{
      store: Nous.Memory.Store.ETS,
      embedding: Nous.Memory.Embedding.OpenAI,
      embedding_opts: %{api_key: System.get_env("OPENAI_API_KEY")},
      auto_inject: true  # Auto-retrieves relevant memories before each request
    }
  }
)
```

**Store backends:** ETS (zero deps), SQLite (FTS5), DuckDB (FTS + vector), Muninn (Tantivy BM25), Zvec (HNSW), Hybrid (Muninn + Zvec).

**Embedding providers:** Bumblebee (local, offline), OpenAI, Local (Ollama/vLLM).

**Features:** Memory scoping (agent/user/session/global), temporal decay, importance weighting, RRF scoring, configurable auto-injection.

See the [Memory Examples](#memory-examples) section below for complete examples.

### Deep Research

Autonomous multi-step research with citations:

```elixir
{:ok, report} = Nous.Research.run(
  "Best practices for Elixir deployment",
  model: "openai:gpt-4o",
  search_tool: &Nous.Tools.TavilySearch.search/2
)

IO.puts(report.content)  # Markdown report with inline citations
```

### Agent Supervision & Persistence

Production lifecycle management with state persistence:

```elixir
# Start a supervised agent with persistence
{:ok, pid} = Nous.AgentDynamicSupervisor.start_agent(
  agent, session_id: "user-123",
  persistence: Nous.Persistence.ETS,
  name: {:via, Registry, {Nous.AgentRegistry, "user-123"}}
)

# Agent state auto-saves; restore later
{:ok, context} = Nous.Persistence.ETS.load("user-123")
{:ok, result} = Nous.run(agent, "Continue our conversation", context: context)
```

### LiveView Integration

```elixir
defmodule MyAppWeb.ChatLive do
  use MyAppWeb, :live_view

  def mount(_params, _session, socket) do
    agent = Nous.new("lmstudio:qwen3", instructions: "Be helpful.")
    {:ok, assign(socket, agent: agent, messages: [], streaming: false)}
  end

  def handle_event("send", %{"message" => msg}, socket) do
    Task.start(fn ->
      Nous.run(socket.assigns.agent, msg, notify_pid: socket.root_pid)
    end)
    {:noreply, assign(socket, streaming: true)}
  end

  def handle_info({:agent_delta, text}, socket) do
    {:noreply, update(socket, :current, &(&1 <> text))}
  end

  def handle_info({:agent_complete, result}, socket) do
    messages = socket.assigns.messages ++ [%{role: :assistant, content: result.output}]
    {:noreply, assign(socket, messages: messages, streaming: false)}
  end
end
```

See [examples/advanced/liveview_integration.exs](examples/advanced/liveview_integration.exs) for complete patterns.

## Examples

**[Full Examples Collection](examples/README.md)** - Focused examples from basics to production.

### Core Examples (01-10)

| Example | Description |
|---------|-------------|
| [01_hello_world.exs](examples/01_hello_world.exs) | Minimal example |
| [02_with_tools.exs](examples/02_with_tools.exs) | Tool calling |
| [03_streaming.exs](examples/03_streaming.exs) | Streaming responses |
| [04_conversation.exs](examples/04_conversation.exs) | Multi-turn with context |
| [05_callbacks.exs](examples/05_callbacks.exs) | Callbacks + LiveView |
| [06_prompt_templates.exs](examples/06_prompt_templates.exs) | EEx templates |
| [07_module_tools.exs](examples/07_module_tools.exs) | Module-based tools |
| [08_tool_testing.exs](examples/08_tool_testing.exs) | Test helpers |
| [09_agent_server.exs](examples/09_agent_server.exs) | GenServer agent |
| [10_react_agent.exs](examples/10_react_agent.exs) | ReAct pattern |
| [13_sub_agents.exs](examples/13_sub_agents.exs) | Sub-agents (single + parallel) |

### Provider Examples

- [providers/anthropic.exs](examples/providers/anthropic.exs) - Claude, extended thinking
- [providers/openai.exs](examples/providers/openai.exs) - GPT models
- [providers/lmstudio.exs](examples/providers/lmstudio.exs) - Local AI
- [providers/llamacpp.exs](examples/providers/llamacpp.exs) - Local NIF-based inference
- [providers/switching_providers.exs](examples/providers/switching_providers.exs) - Provider comparison

### Memory Examples

- [memory/basic_ets.exs](examples/memory/basic_ets.exs) - Simplest setup, ETS + keyword search
- [memory/local_bumblebee.exs](examples/memory/local_bumblebee.exs) - Local semantic search, no API keys
- [memory/sqlite_full.exs](examples/memory/sqlite_full.exs) - SQLite + FTS5 production setup
- [memory/duckdb_full.exs](examples/memory/duckdb_full.exs) - DuckDB analytics-friendly setup
- [memory/hybrid_full.exs](examples/memory/hybrid_full.exs) - Muninn + Zvec maximum quality
- [memory/cross_agent.exs](examples/memory/cross_agent.exs) - Multi-agent shared memory with scoping

### Advanced Examples

- [advanced/context_updates.exs](examples/advanced/context_updates.exs) - Tool state management
- [advanced/error_handling.exs](examples/advanced/error_handling.exs) - Retries, fallbacks
- [advanced/telemetry.exs](examples/advanced/telemetry.exs) - Metrics, cost tracking
- [advanced/cancellation.exs](examples/advanced/cancellation.exs) - Task cancellation
- [advanced/liveview_integration.exs](examples/advanced/liveview_integration.exs) - LiveView patterns

## Telemetry

Attach handlers for monitoring:

```elixir
Nous.Telemetry.attach_default_handler()
```

**Events:**
- `[:nous, :agent, :run, :start/stop/exception]`
- `[:nous, :agent, :iteration, :start/stop]`
- `[:nous, :provider, :request, :start/stop/exception]`
- `[:nous, :tool, :execute, :start/stop/exception]`
- `[:nous, :tool, :timeout]`
- `[:nous, :context, :update]`

## Evaluation Framework

Test, benchmark, and optimize your agents:

```elixir
# Define tests
suite = Nous.Eval.Suite.new(
  name: "my_tests",
  default_model: "lmstudio:qwen3",
  test_cases: [
    Nous.Eval.TestCase.new(
      id: "greeting",
      input: "Say hello",
      expected: %{contains: ["hello"]},
      eval_type: :contains
    )
  ]
)

# Run evaluation
{:ok, result} = Nous.Eval.run(suite)
Nous.Eval.Reporter.print(result)
```

**Features:**
- Six built-in evaluators (exact_match, fuzzy_match, contains, tool_usage, schema, llm_judge)
- Metrics collection (latency, tokens, cost)
- A/B testing with `Nous.Eval.run_ab/2`
- Parameter optimization with Bayesian, grid, or random search
- YAML test suite definitions

**CLI:**
```bash
mix nous.eval --suite test/eval/suites/basic.yaml
mix nous.optimize --suite suite.yaml --strategy bayesian --trials 20
```

See [Evaluation Guide](docs/guides/evaluation.md) for complete documentation.

## Architecture

```
Nous.new/2 → Agent struct
    ↓
Nous.run/3 → AgentRunner
    ↓
├─→ Context (messages, deps, callbacks, pubsub)
├─→ Behaviour (BasicAgent | ReActAgent | custom)
├─→ Plugins (HITL, Summarization, SubAgent, Memory, ...)
├─→ Memory (Store → Search → Scoring → Embedding)
├─→ ModelDispatcher → Provider → HTTP
├─→ ToolExecutor (timeout, validation, approval)
├─→ Callbacks (map | notify_pid | PubSub)
├─→ PubSub (Nous.PubSub → Phoenix.PubSub, optional)
├─→ Persistence (ETS | custom backend)
└─→ Research (Planner → Searcher → Synthesizer → Reporter)
```

## Development

### Prerequisites

- Erlang/OTP 26+
- Elixir 1.15+

### Setup

```bash
git clone https://github.com/nyo16/nous.git
cd nous
mix deps.get
mix compile
```

### Running Tests

```bash
# Run all tests
mix test

# Run a specific test file
mix test test/nous/decisions_test.exs

# Run tests with verbose output
mix test --trace
```

### Code Quality

```bash
# Check formatting
mix format --check-formatted

# Run credo linter
mix credo --strict

# Run dialyzer (first run builds PLT, takes a few minutes)
mix dialyzer

# All checks at once
mix compile --warnings-as-errors && mix format --check-formatted && mix credo --strict && mix test
```

### Configuration

API keys are configured via environment variables:

```bash
export OPENAI_API_KEY="sk-..."
export ANTHROPIC_API_KEY="sk-ant-..."
export GROQ_API_KEY="gsk_..."
# See config/config.exs for all supported providers
```

For local models (no API key needed):

```bash
# LM Studio — start the server, then:
agent = Nous.new("lmstudio:qwen3")

# Ollama — start the server, then:
agent = Nous.new("ollama:llama2")

# LlamaCpp — load a GGUF model directly (requires llama_cpp_ex dep):
:ok = LlamaCppEx.init()
{:ok, llm} = LlamaCppEx.load_model("model.gguf", n_gpu_layers: -1)
agent = Nous.new("llamacpp:local", llamacpp_model: llm)

# For thinking models (Qwen3, DeepSeek, etc.), disable <think> tags:
agent = Nous.new("llamacpp:local",
  llamacpp_model: llm,
  model_settings: %{enable_thinking: false}
)
```

### Running Examples

```bash
# Run any example script
mix run examples/01_hello_world.exs

# Run with a specific provider
OPENAI_API_KEY=sk-... mix run examples/02_with_tools.exs
```

### Generating Docs

```bash
mix docs
open doc/index.html
```

### Project Structure

```
lib/nous/
├── agent.ex              # Agent struct and builder
├── agent_runner.ex       # Core execution loop
├── agent_server.ex       # GenServer wrapper for supervised agents
├── decisions/            # Decision graph (goals, decisions, outcomes)
│   ├── store/            # Store backends (ETS, DuckDB)
│   ├── node.ex           # Node struct
│   ├── edge.ex           # Edge struct
│   ├── tools.ex          # LLM-callable decision tools
│   └── context_builder.ex
├── memory/               # Persistent memory with hybrid search
│   ├── store/            # Store backends (ETS, SQLite, DuckDB, etc.)
│   ├── embedding/        # Embedding providers
│   └── tools.ex          # LLM-callable memory tools
├── plugins/              # Agent plugins
│   ├── decisions.ex      # Decision graph plugin
│   ├── memory.ex         # Memory plugin
│   ├── team_tools.ex     # Team communication plugin
│   ├── sub_agent.ex      # Sub-agent delegation
│   └── human_in_the_loop.ex
├── providers/            # LLM provider adapters
├── teams/                # Multi-agent team orchestration
│   ├── coordinator.ex    # Team lifecycle management
│   ├── shared_state.ex   # Per-team shared state (ETS)
│   ├── rate_limiter.ex   # Budget and rate limiting
│   ├── role.ex           # Role-based tool scoping
│   └── comms.ex          # PubSub topic helpers
├── tool/                 # Tool system
│   ├── behaviour.ex      # Tool behaviour
│   ├── schema.ex         # Declarative tool DSL
│   └── registry.ex       # Tool collection and filtering
├── research/             # Deep research system
└── eval/                 # Evaluation framework
```

## Contributing

Contributions welcome! See [CHANGELOG.md](CHANGELOG.md) for recent changes.

```bash
# Fork, clone, then:
mix deps.get
mix test                     # Make sure tests pass
mix format                   # Format your code
mix credo --strict           # Check for issues
# Open a PR against master
```

## License

Apache 2.0 - see [LICENSE](https://github.com/nyo16/nous/blob/master/LICENSE)

## Credits

- Inspired by [Pydantic AI](https://ai.pydantic.dev/)
- HTTP: [Req](https://github.com/wojtekmach/req) + [Finch](https://github.com/sneako/finch)