Current section

Files

Jump to
nous lib nous tool.ex
Raw

lib/nous/tool.ex

defmodule Nous.Tool do
@moduledoc """
Tool definition for agent function calling.
A tool represents a function that an AI agent can call to retrieve
information or perform actions. Tools are automatically converted to
OpenAI function calling schemas.
## Example
defmodule MyTools do
@doc "Search the database for users"
def search_users(ctx, query) do
ctx.deps.database
|> Database.search(query)
|> format_results()
end
end
# Create tool from function
tool = Tool.from_function(&MyTools.search_users/2,
name: "search_users",
description: "Search for users in the database"
)
# Convert to OpenAI schema
schema = Tool.to_openai_schema(tool)
"""
@type category :: :read | :write | :execute | :communicate | :search | nil
@type t :: %__MODULE__{
name: String.t(),
description: String.t(),
parameters: map(),
function: function(),
takes_ctx: boolean(),
retries: non_neg_integer(),
timeout: non_neg_integer() | nil,
validate_args: boolean(),
module: module() | nil,
requires_approval: boolean(),
category: category(),
tags: [atom()]
}
@enforce_keys [:name, :function]
defstruct [
:name,
:description,
:parameters,
:function,
:module,
takes_ctx: true,
retries: 1,
timeout: 30_000,
validate_args: true,
requires_approval: false,
category: nil,
tags: []
]
@doc """
Create a tool from a function.
Automatically extracts function metadata including documentation
and generates a JSON schema for the parameters.
## Options
* `:name` - Custom tool name (default: function name)
* `:description` - Custom description (default: from @doc)
* `:parameters` - Custom parameter schema (default: auto-generated)
* `:retries` - Number of retries on failure (default: 1)
* `:timeout` - Timeout in milliseconds (default: 30000)
* `:validate_args` - Whether to validate arguments against schema (default: true)
* `:requires_approval` - Whether tool needs human approval before execution (default: false)
* `:category` - Tool category: `:read`, `:write`, `:execute`, `:communicate`, `:search`, or `nil`
* `:tags` - Arbitrary tags for filtering (default: `[]`)
## Examples
# Simple tool
tool = Tool.from_function(&MyTools.calculate/2)
# With custom options
tool = Tool.from_function(&MyTools.search/2,
name: "search_database",
description: "Search for records",
retries: 3,
timeout: 60_000
)
"""
@spec from_function(function(), keyword()) :: t()
def from_function(fun, opts \\ []) when is_function(fun) do
# Get function info
info = Function.info(fun)
arity = info[:arity]
# Determine if function takes context (2 args) or just arguments (1 arg)
takes_ctx = arity == 2
# Extract function name
function_name = get_function_name(fun)
# Try to extract documentation
{description, param_schema} =
extract_function_docs(fun) ||
{Keyword.get(opts, :description, ""), default_schema()}
%__MODULE__{
name: Keyword.get(opts, :name, function_name),
description: Keyword.get(opts, :description, description),
parameters: Keyword.get(opts, :parameters, param_schema),
function: fun,
takes_ctx: takes_ctx,
retries: Keyword.get(opts, :retries, 1),
timeout: Keyword.get(opts, :timeout, 30_000),
validate_args: Keyword.get(opts, :validate_args, true),
requires_approval: Keyword.get(opts, :requires_approval, false),
module: nil,
category: Keyword.get(opts, :category),
tags: Keyword.get(opts, :tags, [])
}
end
@doc """
Create a tool from a module implementing `Nous.Tool.Behaviour`.
Module-based tools are easier to test because dependencies can be
injected through the context.
## Options
* `:name` - Override tool name from metadata
* `:description` - Override description from metadata
* `:parameters` - Override parameters from metadata
* `:retries` - Number of retries on failure (default: 1)
* `:timeout` - Timeout in milliseconds (default: 30000)
* `:validate_args` - Whether to validate arguments (default: true)
* `:requires_approval` - Whether tool needs human approval before execution (default: false)
* `:category` - Override category from metadata
* `:tags` - Override tags from metadata
## Example
defmodule MyApp.Tools.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
# Create tool from module
tool = Tool.from_module(MyApp.Tools.Search)
# Override options
tool = Tool.from_module(MyApp.Tools.Search, timeout: 60_000, retries: 3)
"""
@spec from_module(module(), keyword()) :: t()
def from_module(module, opts \\ []) when is_atom(module) do
alias Nous.Tool.Behaviour
unless Behaviour.implements?(module) do
raise ArgumentError, """
Module #{inspect(module)} does not implement Nous.Tool.Behaviour.
Ensure the module has:
@behaviour Nous.Tool.Behaviour
@impl true
def execute(ctx, args) do
# ...
end
"""
end
metadata = Behaviour.get_metadata(module)
%__MODULE__{
name: Keyword.get(opts, :name, metadata.name),
description: Keyword.get(opts, :description, metadata.description),
parameters: Keyword.get(opts, :parameters, metadata.parameters),
function: &module.execute/2,
takes_ctx: true,
retries: Keyword.get(opts, :retries, 1),
timeout: Keyword.get(opts, :timeout, 30_000),
validate_args: Keyword.get(opts, :validate_args, true),
requires_approval: Keyword.get(opts, :requires_approval, false),
module: module,
category: Keyword.get(opts, :category, Map.get(metadata, :category)),
tags: Keyword.get(opts, :tags, Map.get(metadata, :tags, []))
}
end
@doc """
Convert tool to OpenAI function calling schema.
## Example
schema = Tool.to_openai_schema(tool)
# %{
# "type" => "function",
# "function" => %{
# "name" => "search_users",
# "description" => "Search for users",
# "parameters" => %{...}
# }
# }
"""
@spec to_openai_schema(t()) :: map()
def to_openai_schema(%__MODULE__{} = tool) do
%{
"type" => "function",
"function" => %{
"name" => tool.name,
"description" => tool.description || "No description provided",
"parameters" => tool.parameters || default_schema()
}
}
end
# Private functions
@spec get_function_name(fun()) :: String.t()
defp get_function_name(fun) do
info = Function.info(fun)
case info[:name] do
name when is_atom(name) ->
Atom.to_string(name)
_ ->
"anonymous_function"
end
end
@spec extract_function_docs(fun()) :: String.t() | nil
defp extract_function_docs(fun) do
info = Function.info(fun)
case info do
[module: module, name: name, arity: arity] when not is_nil(module) ->
case Code.fetch_docs(module) do
{:docs_v1, _, _, _, _, _, docs} ->
find_function_doc(docs, name, arity)
_ ->
nil
end
_ ->
nil
end
end
@spec find_function_doc(list(), atom(), non_neg_integer()) :: String.t() | nil
defp find_function_doc(docs, function_name, arity) do
Enum.find_value(docs, fn
{{:function, ^function_name, ^arity}, _, _, doc, _} when is_map(doc) ->
# Extract description from doc
description = Map.get(doc, "en", "")
# Parse simple parameter schema from description
param_schema = parse_param_schema(description)
{description, param_schema}
_ ->
nil
end)
end
# L-8: previously returned a hardcoded `query` schema even when the
# function had no `query` parameter, which advertised a misleading
# contract to the LLM. Until real markdown parsing is implemented, fall
# back to the empty additionalProperties: true schema and log a warning
# so the developer knows to pass `:parameters` explicitly.
defp parse_param_schema(doc_string) when is_binary(doc_string) do
require Logger
Logger.debug(
"Tool.from_function/2: no automatic schema inference yet; pass :parameters explicitly " <>
"for accurate validation. Defaulting to additionalProperties: true."
)
default_schema()
end
defp default_schema do
%{
"type" => "object",
"properties" => %{},
"required" => []
}
end
end