Packages
nous
0.16.0
0.17.0
0.16.6
0.16.5
0.16.4
0.16.3
0.16.2
0.16.1
0.16.0
0.15.8
0.15.7
0.15.6
0.15.5
0.15.4
0.15.3
0.15.2
0.15.1
0.15.0
0.14.3
0.14.2
0.14.1
0.14.0
0.13.3
0.13.2
0.13.1
0.13.0
0.12.17
0.12.16
0.12.15
0.12.14
0.12.13
0.12.12
0.12.11
0.12.9
0.12.7
0.12.6
0.12.5
0.12.3
0.12.2
0.12.0
0.11.3
0.11.0
0.10.1
0.10.0
0.9.0
0.8.1
0.8.0
0.7.2
0.7.1
0.7.0
0.5.0
AI agent framework for Elixir with multi-provider LLM support
Current section
Files
Jump to
Current section
Files
lib/nous/prompt_template.ex
defmodule Nous.PromptTemplate do
# validate_bindings/2 uses MapSet.difference/2 on MapSets built from
# heterogeneous keys (`bindings` may key on atoms or strings, and
# `extract_variables/1` returns `[atom() | String.t()]` per C-2). That
# heterogeneity confuses dialyzer's opaque tracking even though the
# operation is well-typed.
@dialyzer :no_opaque
@moduledoc """
Safe prompt templates for building messages.
Templates use a tightly-constrained `<%= @var %>` substitution syntax;
the template body is **not** passed through `EEx.eval_string/2`, so
template content from LLM output, tool results, or other untrusted
sources cannot trigger arbitrary Elixir evaluation. Templates that
contain `<%` markers other than the simple variable form are rejected
by `from_template/2`.
## Simple Usage
template = PromptTemplate.from_template(
"You are a <%= @role %> assistant that speaks <%= @language %>.",
role: :system
)
message = PromptTemplate.to_message(template, %{role: "helpful", language: "Spanish"})
# => %Message{role: :system, content: "You are a helpful assistant that speaks Spanish."}
## Building Message Lists
messages = PromptTemplate.to_messages([
PromptTemplate.from_template("You are <%= @persona %>", role: :system),
PromptTemplate.from_template("Tell me about <%= @topic %>", role: :user)
], %{persona: "a historian", topic: "ancient Rome"})
# Use with Agent.run
Agent.run(agent, messages: messages)
## Default Values
template = PromptTemplate.from_template(
"Search for <%= @query %> with limit <%= @limit %>",
inputs: %{limit: 10}
)
# Only need to provide query, limit has a default
formatted = PromptTemplate.format(template, %{query: "elixir"})
# => "Search for elixir with limit 10"
## Conditional Content
This module deliberately does not support `<%= if ... do %>` blocks or
arbitrary Elixir expressions. Build conditional structure by composing
multiple smaller templates with `compose/2` or `to_messages/2`.
## String Templates
For simple string templates without Message conversion:
text = PromptTemplate.format_string(
"Hello, <%= @name %>!",
%{name: "World"}
)
# => "Hello, World!"
"""
alias Nous.Message
@type t :: %__MODULE__{
text: String.t(),
role: :system | :user | :assistant,
inputs: map()
}
@enforce_keys [:text]
defstruct [:text, role: :user, inputs: %{}]
@doc """
Create a template from a string with `<%= @var %>` placeholders.
## Options
- `:role` - Message role (`:system`, `:user`, `:assistant`). Default: `:user`
- `:inputs` - Default variable values. Default: `%{}`
## Example
template = PromptTemplate.from_template(
"You are a <%= @role %> assistant.",
role: :system,
inputs: %{role: "helpful"}
)
"""
@spec from_template(String.t(), keyword()) :: t()
def from_template(text, opts \\ []) when is_binary(text) do
case validate_template_safety(text) do
:ok ->
%__MODULE__{
text: text,
role: Keyword.get(opts, :role, :user),
inputs: Keyword.get(opts, :inputs, %{})
}
{:error, reason} ->
raise ArgumentError,
"PromptTemplate: " <>
reason <>
". This module only supports `<%= @var %>` substitution; arbitrary " <>
"EEx expressions are rejected because template bodies that flow " <>
"from LLM output / tool results / untrusted strings could otherwise " <>
"execute arbitrary Elixir."
end
end
# Reject any `<%` marker that isn't a simple `<%= @ident %>` substitution.
# Compose multiple templates if you need conditional structure.
defp validate_template_safety(text) do
# Strip every valid `<%= @var %>` (with optional whitespace) and check
# that no other `<%` remains. This rejects `<% if ... do %>`, `<% end %>`,
# `<%= System.cmd(...) %>`, and any other expression form.
stripped = String.replace(text, ~r/<%=\s*@\w+\s*%>/, "")
if String.contains?(stripped, "<%") do
{:error, "template contains unsupported <% ... %> expression"}
else
:ok
end
end
@doc """
Create a system message template.
Shorthand for `from_template(text, role: :system)`.
## Example
template = PromptTemplate.system("You are a <%= @persona %>")
"""
@spec system(String.t(), keyword()) :: t()
def system(text, opts \\ []) when is_binary(text) do
from_template(text, Keyword.put(opts, :role, :system))
end
@doc """
Create a user message template.
Shorthand for `from_template(text, role: :user)`.
## Example
template = PromptTemplate.user("Tell me about <%= @topic %>")
"""
@spec user(String.t(), keyword()) :: t()
def user(text, opts \\ []) when is_binary(text) do
from_template(text, Keyword.put(opts, :role, :user))
end
@doc """
Create an assistant message template.
Shorthand for `from_template(text, role: :assistant)`.
## Example
template = PromptTemplate.assistant("I understand you want <%= @action %>")
"""
@spec assistant(String.t(), keyword()) :: t()
def assistant(text, opts \\ []) when is_binary(text) do
from_template(text, Keyword.put(opts, :role, :assistant))
end
@doc """
Format template with variable substitution.
Merges provided bindings with default inputs (bindings take precedence).
## Example
template = from_template("Hello, <%= @name %>!", inputs: %{name: "World"})
format(template, %{}) # => "Hello, World!"
format(template, %{name: "Elixir"}) # => "Hello, Elixir!"
"""
@spec format(t(), map()) :: String.t()
def format(%__MODULE__{text: text, inputs: default_inputs}, bindings \\ %{}) do
merged = Map.merge(default_inputs, bindings)
do_format(text, merged)
end
@doc """
Format a string template directly without creating a PromptTemplate struct.
## Example
PromptTemplate.format_string("Hello, <%= @name %>!", %{name: "World"})
# => "Hello, World!"
"""
@spec format_string(String.t(), map()) :: String.t()
def format_string(text, bindings) when is_binary(text) and is_map(bindings) do
do_format(text, bindings)
end
@doc """
Convert template to Message with variable substitution.
## Example
template = from_template("You are a <%= @role %> assistant", role: :system)
message = to_message(template, %{role: "helpful"})
# => %Message{role: :system, content: "You are a helpful assistant"}
"""
@spec to_message(t(), map()) :: Message.t()
def to_message(%__MODULE__{role: role} = template, bindings \\ %{}) do
content = format(template, bindings)
case role do
:system -> Message.system(content)
:user -> Message.user(content)
:assistant -> Message.assistant(content)
end
end
@doc """
Convert list of templates and/or messages to messages.
Items can be:
- `%PromptTemplate{}` - Will be formatted with bindings
- `%Message{}` - Passed through unchanged
## Example
messages = to_messages([
PromptTemplate.system("You are <%= @persona %>"),
Message.user("Hello"),
PromptTemplate.user("Tell me about <%= @topic %>")
], %{persona: "helpful", topic: "Elixir"})
"""
@spec to_messages([t() | Message.t()], map()) :: [Message.t()]
def to_messages(items, bindings \\ %{}) when is_list(items) do
Enum.map(items, fn
%__MODULE__{} = template -> to_message(template, bindings)
%Message{} = message -> message
end)
end
@doc """
Create messages from a list of role/content tuples with shared bindings.
Convenient for building message lists inline.
## Example
messages = PromptTemplate.build_messages([
{:system, "You are a <%= @role %> assistant"},
{:user, "Hello, my name is <%= @name %>"}
], %{role: "helpful", name: "Alice"})
"""
@spec build_messages([{atom(), String.t()}], map()) :: [Message.t()]
def build_messages(items, bindings \\ %{}) when is_list(items) do
Enum.map(items, fn {role, text} ->
template = from_template(text, role: role)
to_message(template, bindings)
end)
end
@doc """
Extract all variable names from a template.
Useful for validation or documentation.
## Example
template = from_template("Hello <%= @name %>, you are <%= @age %> years old")
variables(template)
# => [:name, :age]
"""
@spec variables(t()) :: [atom()]
def variables(%__MODULE__{text: text}) do
extract_variables(text)
end
@doc """
Extract variable names from a template string.
## Example
PromptTemplate.extract_variables("Hello <%= @name %>!")
# => [:name]
Variables that match an existing atom are returned as atoms (matching what
callers pass in `bindings`). Unknown variables are returned as strings to
avoid uncontrolled atom-table growth from attacker-controlled template bodies.
"""
@spec extract_variables(String.t()) :: [atom() | String.t()]
def extract_variables(text) when is_binary(text) do
Regex.scan(~r/@(\w+)/, text)
|> Enum.map(fn [_, var] -> safe_to_existing_atom(var) end)
|> Enum.uniq()
end
defp safe_to_existing_atom(binary) do
String.to_existing_atom(binary)
rescue
ArgumentError -> binary
end
@doc """
Check if all required variables are present in bindings.
Returns `{:ok, bindings}` if all variables are present,
or `{:error, missing_vars}` if some are missing.
## Example
template = from_template("Hello <%= @name %>, age <%= @age %>")
validate_bindings(template, %{name: "Alice", age: 30})
# => {:ok, %{name: "Alice", age: 30}}
validate_bindings(template, %{name: "Alice"})
# => {:error, [:age]}
"""
@spec validate_bindings(t(), map()) :: {:ok, map()} | {:error, [atom()]}
def validate_bindings(%__MODULE__{} = template, bindings) do
required = variables(template)
available = Map.merge(template.inputs, bindings)
provided = MapSet.new(Map.keys(available))
required_set = MapSet.new(required)
missing = MapSet.difference(required_set, provided) |> MapSet.to_list()
if Enum.empty?(missing) do
{:ok, available}
else
{:error, missing}
end
end
@doc """
Compose multiple templates into a single template.
Joins templates with the specified separator.
## Example
intro = system("You are a helpful assistant.")
rules = system("Follow these rules: <%= @rules %>")
combined = compose([intro, rules], "\\n\\n")
# Creates a single :system template with both texts joined
"""
@spec compose([t()], String.t()) :: t()
def compose(templates, separator \\ "\n") when is_list(templates) do
# Use the role of the first template
role =
case templates do
[first | _] -> first.role
[] -> :user
end
# Merge all inputs
merged_inputs =
Enum.reduce(templates, %{}, fn template, acc ->
Map.merge(acc, template.inputs)
end)
# Join all texts
combined_text =
templates
|> Enum.map(& &1.text)
|> Enum.join(separator)
%__MODULE__{
text: combined_text,
role: role,
inputs: merged_inputs
}
end
# Private functions
# Substitute `<%= @var %>` with the value from `bindings`. Missing keys
# are left as the original placeholder so callers see clearly which var
# wasn't bound. Values are converted via `to_string/1`. The bindings map
# may key on either atom or string; both are tried (atoms preferred).
defp do_format(text, bindings) when is_map(bindings) do
Regex.replace(~r/<%=\s*@(\w+)\s*%>/, text, fn full_match, var_name ->
case lookup_binding(bindings, var_name) do
{:ok, value} -> to_string(value)
:error -> full_match
end
end)
end
defp lookup_binding(bindings, var_name) do
atom_key =
try do
String.to_existing_atom(var_name)
rescue
ArgumentError -> nil
end
cond do
not is_nil(atom_key) and Map.has_key?(bindings, atom_key) ->
Map.fetch(bindings, atom_key)
Map.has_key?(bindings, var_name) ->
Map.fetch(bindings, var_name)
true ->
:error
end
end
end