Nous.Decisions (nous v0.16.0)

View Source

Top-level module for the Nous Decision Graph system.

Provides a directed graph for tracking agent goals, decisions, options, actions, and outcomes. The graph persists the reasoning process and enables agents to revisit, supersede, or build on prior decisions.

Quick Start

# Minimal setup (ETS store)
agent = Agent.new("openai:gpt-4",
  plugins: [Nous.Plugins.Decisions],
  deps: %{decisions_config: %{store: Nous.Decisions.Store.ETS}}
)

Architecture

Three layers, all plain modules and structs (no GenServer):

  • Data Layer -- Node (struct), Edge (struct), Store (behaviour + backends)
  • Query Layer -- Graph traversal via store query/3 callbacks
  • Integration -- Plugins.Decisions (plugin), decision tools, ContextBuilder

Store Backends

BackendGraph QueriesDeps
Store.ETSBFS traversalNone
Store.DuckDBDuckPGQ path matchingduckdbex

Summary

Functions

Get all active goal nodes.

Add an edge connecting two nodes.

Add a node to the decision graph.

Get all ancestor nodes that can reach a given node.

Get all descendant nodes reachable from a given node.

Fetch a single node by ID.

Find a path between two nodes.

Get recent decision nodes, sorted by creation time descending.

Update fields on an existing node.

Validate a decisions configuration map.

Functions

active_goals(store_mod, state)

@spec active_goals(module(), term()) :: {:ok, [Nous.Decisions.Node.t()]}

Get all active goal nodes.

Examples

{:ok, goals} = Nous.Decisions.active_goals(Store.ETS, state)

add_edge(store_mod, state, edge)

@spec add_edge(module(), term(), Nous.Decisions.Edge.t()) ::
  {:ok, term()} | {:error, term()}

Add an edge connecting two nodes.

Examples

edge = Edge.new(%{from_id: goal.id, to_id: decision.id, edge_type: :leads_to})
{:ok, state} = Nous.Decisions.add_edge(Store.ETS, state, edge)

add_node(store_mod, state, node)

@spec add_node(module(), term(), Nous.Decisions.Node.t()) ::
  {:ok, term()} | {:error, term()}

Add a node to the decision graph.

Examples

node = Node.new(%{type: :goal, label: "Implement auth"})
{:ok, state} = Nous.Decisions.add_node(Store.ETS, state, node)

ancestors(store_mod, state, node_id)

@spec ancestors(module(), term(), String.t()) :: {:ok, [Nous.Decisions.Node.t()]}

Get all ancestor nodes that can reach a given node.

Examples

{:ok, ancestors} = Nous.Decisions.ancestors(Store.ETS, state, node_id)

descendants(store_mod, state, node_id)

@spec descendants(module(), term(), String.t()) :: {:ok, [Nous.Decisions.Node.t()]}

Get all descendant nodes reachable from a given node.

Examples

{:ok, descendants} = Nous.Decisions.descendants(Store.ETS, state, node_id)

get_node(store_mod, state, id)

@spec get_node(module(), term(), String.t()) ::
  {:ok, Nous.Decisions.Node.t()} | {:error, :not_found}

Fetch a single node by ID.

Examples

{:ok, node} = Nous.Decisions.get_node(Store.ETS, state, node_id)

path_between(store_mod, state, from_id, to_id)

@spec path_between(module(), term(), String.t(), String.t()) ::
  {:ok, [Nous.Decisions.Node.t()]}

Find a path between two nodes.

Returns the nodes along the shortest path, or an empty list if no path exists.

Examples

{:ok, path} = Nous.Decisions.path_between(Store.ETS, state, from_id, to_id)

recent_decisions(store_mod, state, opts \\ [])

@spec recent_decisions(module(), term(), keyword()) ::
  {:ok, [Nous.Decisions.Node.t()]}

Get recent decision nodes, sorted by creation time descending.

Options

  • :limit - maximum number of decisions (default: 10)

Examples

{:ok, decisions} = Nous.Decisions.recent_decisions(Store.ETS, state, limit: 5)

supersede(store_mod, state, old_id, new_id, rationale \\ nil)

@spec supersede(module(), term(), String.t(), String.t(), String.t() | nil) ::
  {:ok, term()} | {:error, term()}

Supersede a node with a new one.

Marks the old node as :superseded and adds a :supersedes edge from the new node to the old node.

Best-effort, not atomic

This function performs two backend writes (update_node then add_edge). If update_node succeeds but add_edge fails (network blip, lock contention, NIF failure), the old node is left marked :superseded with no edge connecting the new and old. There is no automatic rollback. The Store behaviour does not currently expose a transaction primitive; once it does, this should be wrapped in one.

Options

  • rationale - reason for superseding (stored on the old node)

Examples

{:ok, state} = Nous.Decisions.supersede(Store.ETS, state, old_id, new_id, "Better approach found")

update_node(store_mod, state, id, updates)

@spec update_node(module(), term(), String.t(), map()) ::
  {:ok, term()} | {:error, term()}

Update fields on an existing node.

Examples

{:ok, state} = Nous.Decisions.update_node(Store.ETS, state, node_id, %{status: :completed})

validate_config(config)

@spec validate_config(map()) :: {:ok, map()} | {:error, String.t()}

Validate a decisions configuration map.

Returns {:ok, config} with defaults applied, or {:error, reason}.

Required Keys

Optional Keys

  • :store_opts - Options passed to store.init/1 (default: [])
  • :decision_limit - Max recent decisions in context (default: 5)
  • :auto_inject - Inject decision context into system prompt (default: true)
  • :inject_strategy - :first_only (default) or :every_iteration

Examples

{:ok, config} = Nous.Decisions.validate_config(%{store: Nous.Decisions.Store.ETS})
{:error, reason} = Nous.Decisions.validate_config(%{})