Current section

Files

Jump to
nous lib nous decisions tools.ex
Raw

lib/nous/decisions/tools.ex

defmodule Nous.Decisions.Tools do
@moduledoc """
Agent tools for decision graph operations.
Provides tools for agents to record goals, decisions, outcomes, and
query the decision graph. Each tool receives `ctx` via `takes_ctx: true`
and returns `{:ok, result, ContextUpdate.new()}`.
## Tools
- `add_goal` -- create a goal node
- `record_decision` -- create a decision node with edge from a parent
- `record_outcome` -- create an outcome node with edge from a parent
- `query_decisions` -- query the graph (active_goals, recent_decisions, path)
"""
alias Nous.Decisions.{Node, Edge}
alias Nous.Tool
alias Nous.Tool.ContextUpdate
@doc """
Returns all decision tools as a list.
"""
@spec all_tools() :: [Tool.t()]
def all_tools do
[add_goal_tool(), record_decision_tool(), record_outcome_tool(), query_decisions_tool()]
end
# ---------------------------------------------------------------------------
# Tool definitions
# ---------------------------------------------------------------------------
defp add_goal_tool do
%Tool{
name: "add_goal",
description:
"Add a goal to the decision graph. Goals are top-level objectives that drive decisions and actions.",
parameters: %{
"type" => "object",
"properties" => %{
"label" => %{
"type" => "string",
"description" => "Description of the goal"
},
"confidence" => %{
"type" => "number",
"description" => "Confidence level 0.0-1.0 that this goal is achievable (optional)"
},
"rationale" => %{
"type" => "string",
"description" => "Why this goal was chosen (optional)"
}
},
"required" => ["label"]
},
function: &__MODULE__.add_goal/2,
takes_ctx: true
}
end
defp record_decision_tool do
%Tool{
name: "record_decision",
description:
"Record a decision in the graph. Optionally link it to a parent node (e.g., a goal or another decision).",
parameters: %{
"type" => "object",
"properties" => %{
"label" => %{
"type" => "string",
"description" => "Description of the decision"
},
"parent_id" => %{
"type" => "string",
"description" => "ID of the parent node to link from (optional)"
},
"edge_type" => %{
"type" => "string",
"enum" => ["leads_to", "chosen", "rejected", "requires", "enables"],
"description" => "Type of relationship to parent (default: leads_to)"
},
"confidence" => %{
"type" => "number",
"description" => "Confidence level 0.0-1.0 (optional)"
},
"rationale" => %{
"type" => "string",
"description" => "Why this decision was made (optional)"
}
},
"required" => ["label"]
},
function: &__MODULE__.record_decision/2,
takes_ctx: true
}
end
defp record_outcome_tool do
%Tool{
name: "record_outcome",
description:
"Record an outcome of a decision or action. Links the outcome to its parent node.",
parameters: %{
"type" => "object",
"properties" => %{
"label" => %{
"type" => "string",
"description" => "Description of the outcome"
},
"parent_id" => %{
"type" => "string",
"description" => "ID of the parent decision or action node"
},
"status" => %{
"type" => "string",
"enum" => ["active", "completed", "rejected"],
"description" => "Status of the outcome (default: completed)"
},
"rationale" => %{
"type" => "string",
"description" => "Notes about the outcome (optional)"
}
},
"required" => ["label", "parent_id"]
},
function: &__MODULE__.record_outcome/2,
takes_ctx: true
}
end
defp query_decisions_tool do
%Tool{
name: "query_decisions",
description:
"Query the decision graph. Supports: active_goals, recent_decisions, descendants, ancestors, path_between.",
parameters: %{
"type" => "object",
"properties" => %{
"query_type" => %{
"type" => "string",
"enum" => [
"active_goals",
"recent_decisions",
"descendants",
"ancestors",
"path_between"
],
"description" => "Type of query to run"
},
"node_id" => %{
"type" => "string",
"description" => "Node ID for descendants/ancestors queries"
},
"from_id" => %{
"type" => "string",
"description" => "Source node ID for path_between query"
},
"to_id" => %{
"type" => "string",
"description" => "Destination node ID for path_between query"
},
"limit" => %{
"type" => "integer",
"description" => "Maximum results for recent_decisions (default: 5)"
}
},
"required" => ["query_type"]
},
function: &__MODULE__.query_decisions/2,
takes_ctx: true
}
end
# ---------------------------------------------------------------------------
# Tool implementations
# ---------------------------------------------------------------------------
@doc """
Add a goal node to the decision graph.
"""
@spec add_goal(Nous.Agent.Context.t(), map()) :: {:ok, map(), ContextUpdate.t()}
def add_goal(ctx, args) do
with {:ok, store_mod, state} <- get_store(ctx) do
node =
Node.new(%{
type: :goal,
label: Map.fetch!(args, "label"),
confidence: Map.get(args, "confidence"),
rationale: Map.get(args, "rationale")
})
case store_mod.add_node(state, node) do
{:ok, new_state} ->
{:ok, %{status: "added", id: node.id, type: "goal", label: node.label},
config_update(ctx, new_state)}
{:error, reason} ->
{:ok, %{status: "error", message: "Failed to add goal: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} ->
{:ok, %{status: "error", message: "Decision system not initialized"}, ContextUpdate.new()}
end
end
@doc """
Record a decision node, optionally linking it to a parent.
"""
@spec record_decision(Nous.Agent.Context.t(), map()) :: {:ok, map(), ContextUpdate.t()}
def record_decision(ctx, args) do
with {:ok, store_mod, state} <- get_store(ctx) do
node =
Node.new(%{
type: :decision,
label: Map.fetch!(args, "label"),
confidence: Map.get(args, "confidence"),
rationale: Map.get(args, "rationale")
})
case store_mod.add_node(state, node) do
{:ok, new_state} ->
# Optionally add edge from parent
new_state =
case Map.get(args, "parent_id") do
nil ->
new_state
parent_id ->
edge_type = parse_edge_type(Map.get(args, "edge_type", "leads_to"))
edge =
Edge.new(%{
from_id: parent_id,
to_id: node.id,
edge_type: edge_type
})
case store_mod.add_edge(new_state, edge) do
{:ok, s} -> s
{:error, _} -> new_state
end
end
{:ok, %{status: "recorded", id: node.id, type: "decision", label: node.label},
config_update(ctx, new_state)}
{:error, reason} ->
{:ok, %{status: "error", message: "Failed to record decision: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} ->
{:ok, %{status: "error", message: "Decision system not initialized"}, ContextUpdate.new()}
end
end
@doc """
Record an outcome node linked to a parent decision or action.
"""
@spec record_outcome(Nous.Agent.Context.t(), map()) :: {:ok, map(), ContextUpdate.t()}
def record_outcome(ctx, args) do
with {:ok, store_mod, state} <- get_store(ctx) do
status = parse_status(Map.get(args, "status", "completed"))
node =
Node.new(%{
type: :outcome,
label: Map.fetch!(args, "label"),
status: status,
rationale: Map.get(args, "rationale")
})
case store_mod.add_node(state, node) do
{:ok, new_state} ->
parent_id = Map.fetch!(args, "parent_id")
edge =
Edge.new(%{
from_id: parent_id,
to_id: node.id,
edge_type: :leads_to
})
new_state =
case store_mod.add_edge(new_state, edge) do
{:ok, s} -> s
{:error, _} -> new_state
end
{:ok, %{status: "recorded", id: node.id, type: "outcome", label: node.label},
config_update(ctx, new_state)}
{:error, reason} ->
{:ok, %{status: "error", message: "Failed to record outcome: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} ->
{:ok, %{status: "error", message: "Decision system not initialized"}, ContextUpdate.new()}
end
end
@doc """
Query the decision graph.
"""
@spec query_decisions(Nous.Agent.Context.t(), map()) :: {:ok, map(), ContextUpdate.t()}
def query_decisions(ctx, args) do
with {:ok, store_mod, state} <- get_store(ctx) do
query_type = String.to_existing_atom(Map.fetch!(args, "query_type"))
opts =
case query_type do
:recent_decisions ->
[limit: Map.get(args, "limit", 5)]
:descendants ->
[node_id: Map.fetch!(args, "node_id")]
:ancestors ->
[node_id: Map.fetch!(args, "node_id")]
:path_between ->
[from_id: Map.fetch!(args, "from_id"), to_id: Map.fetch!(args, "to_id")]
_ ->
[]
end
case store_mod.query(state, query_type, opts) do
{:ok, nodes} ->
formatted =
Enum.map(nodes, fn node ->
%{
id: node.id,
type: to_string(node.type),
label: node.label,
status: to_string(node.status),
confidence: node.confidence
}
end)
{:ok, %{status: "found", count: length(formatted), nodes: formatted},
ContextUpdate.new()}
{:error, reason} ->
{:ok, %{status: "error", message: "Query failed: #{inspect(reason)}"},
ContextUpdate.new()}
end
else
{:error, :not_initialized} ->
{:ok, %{status: "error", message: "Decision system not initialized"}, ContextUpdate.new()}
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp get_store(ctx) do
config = ctx.deps[:decisions_config] || %{}
store_mod = config[:store]
store_state = config[:store_state]
if store_mod && store_state do
{:ok, store_mod, store_state}
else
{:error, :not_initialized}
end
end
defp config_update(ctx, new_state) do
config = ctx.deps[:decisions_config]
updated_config = Map.put(config, :store_state, new_state)
ContextUpdate.new() |> ContextUpdate.set(:decisions_config, updated_config)
end
defp parse_edge_type("leads_to"), do: :leads_to
defp parse_edge_type("chosen"), do: :chosen
defp parse_edge_type("rejected"), do: :rejected
defp parse_edge_type("requires"), do: :requires
defp parse_edge_type("enables"), do: :enables
defp parse_edge_type("blocks"), do: :blocks
defp parse_edge_type("supersedes"), do: :supersedes
defp parse_edge_type(_), do: :leads_to
defp parse_status("active"), do: :active
defp parse_status("completed"), do: :completed
defp parse_status("superseded"), do: :superseded
defp parse_status("rejected"), do: :rejected
defp parse_status(_), do: :completed
end