Current section

Files

Jump to
sagents priv templates factory.ex.eex
Raw

priv/templates/factory.ex.eex

defmodule <%= module %> do
@behaviour Sagents.Factory
@moduledoc """
Factory for creating agents with consistent configuration.
Pairs with `<%= factory_config_module %>` — read that module to know
what's required and how to build a config. This factory just consumes
`%<%= factory_config_alias %>{}` and produces a `%Sagents.Agent{}`.
This module centralizes agent creation, ensuring all agents use the
same model, middleware stack, and base configuration. Sagents.Session
calls `create_agent/2` when starting a conversation session.
This Factory is automatically configured for your persistence layer:
- Owner type: :<%= owner_type %>
- Owner field: <%= owner_field %>
- Conversations context: <%= conversations_module %>
## Customization
Each helper takes the `%<%= factory_config_alias %>{}` config (`c`)
so you can branch on per-request fields without re-threading args:
- Change model provider in `build_model/1`
- Configure fallbacks in `get_fallback_models/1`
- Configure title generation model in `get_title_model/1`
- Modify system prompt in `base_system_prompt/1`
- Add/remove middleware in `build_middleware/1`
- Add custom tools in `build_tools/1`
- Configure HITL in `default_interrupt_on/1`
## Understanding the Default Middleware
The middleware stack below replicates `Sagents.Agent.build_default_middleware/3`.
You can call that function in IEx to see the canonical defaults:
middleware = Sagents.Agent.build_default_middleware(model, "test-agent")
## Model Fallback Strategy
The fallback configuration uses the *same model* on a different provider
for resilience without changing behavior:
| Primary Provider | Fallback Provider |
|-----------------------|-------------------------|
| ChatAnthropic (API) | ChatAnthropic (Bedrock) |
| ChatOpenAI (API) | ChatOpenAI (Azure) |
## Filesystem Scoping
Files are scoped to the owner (`{:<%= owner_type %>, <%= owner_field %>}`)
so they persist across all conversations for that owner. Edit
`ensure_filesystem_for/1` to change the scoping strategy.
"""
alias LangChain.ChatModels.ChatAnthropic
# Uncomment for OpenAI:
# alias LangChain.ChatModels.ChatOpenAI
# Uncomment for Bedrock:
# alias LangChain.Utils.BedrockConfig
alias Sagents.Agent
alias Sagents.FileSystem
alias Sagents.FileSystem.FileSystemConfig
alias Sagents.Middleware.ConversationTitle
alias Sagents.Middleware.HumanInTheLoop
alias <%= factory_config_module %>
require Logger
# ---------------------------------------------------------------------------
# Model Configuration (edit these module attributes to change models)
# ---------------------------------------------------------------------------
# Primary model for agent conversations
# See: https://docs.anthropic.com/en/docs/models-overview
@main_model "claude-sonnet-4-6"
# Title generation uses a lighter/faster model for cost efficiency
# Haiku is ~10x cheaper than Sonnet and sufficient for generating titles
@title_model "claude-haiku-4-5"
# Uncomment when using Bedrock fallback:
# @title_fallback_model "us.anthropic.claude-3-5-haiku-20241022-v1:0"
# For OpenAI, uncomment and use:
# @main_model "gpt-4o"
# @title_model "gpt-4o-mini"
@doc """
Builds a `%Sagents.Agent{}` from the supplied config.
- `agent_id` is system-supplied by `Sagents.Session` (derived from
`conversation_id` via the host's `agent_id_fun`).
- `config` is a `%<%= factory_config_alias %>{}` produced by the
paired `FactoryRouter` (typically via
`<%= factory_config_alias %>.from_inputs/1 |> <%= factory_config_alias %>.build/1`).
"""
@impl Sagents.Factory
def create_agent(agent_id, %<%= factory_config_alias %>{} = c) do
Agent.new(
%{
agent_id: agent_id,
scope: c.scope,
model: build_model(c),
base_system_prompt: base_system_prompt(c),
middleware: build_middleware(c),
fallback_models: get_fallback_models(c),
before_fallback: get_before_fallback(c),
# Add any custom tools here (tools not provided by middleware)
tools: build_tools(c),
tool_context: c.tool_context
},
# Since we specify the full middleware stack, don't add defaults
replace_default_middleware: true
)
|> case do
{:ok, agent} -> {:ok, agent, []}
{:error, _reason} = err -> err
end
end
# ---------------------------------------------------------------------------
# Filesystem
# ---------------------------------------------------------------------------
# Ensure the owner-scoped filesystem is running before the agent boots.
# Returns a `scope_key` tuple to pass into the FileSystem middleware, or
# `nil` to fall back to agent-scoped filesystem.
defp ensure_filesystem_for(%<%= factory_config_alias %>{
scope: %{<%= owner_type %>: %{id: <%= owner_field %>}}
})
when not is_nil(<%= owner_field %>) do
scope_key = {:<%= owner_type %>, <%= owner_field %>}
{:ok, fs_config} = FileSystemConfig.new(%{scope_key: scope_key, base_directory: "Memories"})
case FileSystem.ensure_filesystem(scope_key, [fs_config]) do
{:ok, _pid} ->
scope_key
{:error, reason} ->
Logger.warning(
"Factory could not ensure <%= owner_type %> filesystem (<%= owner_field %>=#{<%= owner_field %>}): #{inspect(reason)}. " <>
"Falling back to agent-scoped filesystem."
)
nil
end
end
defp ensure_filesystem_for(_c), do: nil
# ---------------------------------------------------------------------------
# Model Configuration
# ---------------------------------------------------------------------------
# Primary model configuration.
# Modify this function to switch providers or models. Branch on `c` to
# vary the model per request (e.g. agent kind, plan tier).
defp build_model(%<%= factory_config_alias %>{} = _c) do
ChatAnthropic.new!(%{
model: @main_model,
api_key: System.fetch_env!("ANTHROPIC_API_KEY"),
stream: true,
cache_control: %{"type" => "ephemeral"},
thinking: %{
type: "enabled",
budget_tokens: 3_000
}
})
# OpenAI alternative:
# ChatOpenAI.new!(%{
# model: @main_model,
# api_key: System.fetch_env!("OPENAI_API_KEY"),
# stream: true
# })
end
# Fallback models - same model on different infrastructure for resilience.
# These are used when the primary provider is unavailable.
#
# Note: To use Bedrock fallback, configure AWS credentials in config.exs:
#
# config :langchain, :bedrock,
# aws_access_key_id: System.get_env("AWS_ACCESS_KEY_ID"),
# aws_secret_access_key: System.get_env("AWS_SECRET_ACCESS_KEY"),
# aws_region: System.get_env("AWS_REGION", "us-east-1")
#
# Then uncomment the BedrockConfig alias at the top of this file.
defp get_fallback_models(%<%= factory_config_alias %>{} = _c) do
[
# Anthropic via AWS Bedrock (same Claude model, different provider)
# Uncomment when Bedrock is configured:
# ChatAnthropic.new!(%{
# model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
# bedrock: BedrockConfig.from_application_env!(),
# stream: true
# })
# OpenAI via Azure alternative:
# ChatOpenAI.new!(%{
# model: @main_model,
# endpoint: "https://your-resource.openai.azure.com",
# api_key: System.fetch_env!("AZURE_OPENAI_API_KEY"),
# api_version: "2024-02-15-preview"
# })
]
end
# Optional function to modify the chain before each LLM attempt.
# See `Sagents.Agent.before_fallback` docs for the full signature.
defp get_before_fallback(%<%= factory_config_alias %>{} = _c) do
nil
end
# ---------------------------------------------------------------------------
# Title Generation Model
# ---------------------------------------------------------------------------
# Title generation uses a lighter/faster model for cost efficiency.
# Haiku is ~10x cheaper than Sonnet and sufficient for generating titles.
defp get_title_model(%<%= factory_config_alias %>{} = _c) do
ChatAnthropic.new!(%{
model: @title_model,
api_key: System.fetch_env!("ANTHROPIC_API_KEY"),
temperature: 1,
stream: false
})
end
# Fallback models for title generation
defp get_title_fallbacks(%<%= factory_config_alias %>{} = _c) do
[
# Uncomment when Bedrock is configured (also uncomment BedrockConfig alias):
# ChatAnthropic.new!(%{
# model: @title_fallback_model,
# bedrock: BedrockConfig.from_application_env!(),
# temperature: 1,
# stream: false
# })
]
end
# ---------------------------------------------------------------------------
# System Prompt
# ---------------------------------------------------------------------------
# Base system prompt for all agents. Branch on `c` for per-request
# customizations (e.g. inject project context).
defp base_system_prompt(%<%= factory_config_alias %>{} = _c) do
"""
You are a helpful AI assistant.
"""
end
# ---------------------------------------------------------------------------
# Human-in-the-Loop Configuration
# ---------------------------------------------------------------------------
# Default tools that require human approval before execution.
# Return `nil` or `%{}` to disable HITL entirely.
#
# Configuration options:
# - `true` - Enable with default decisions (approve, edit, reject)
# - `false` - No interruption for this tool
# - `%{allowed_decisions: [:approve, :reject]}` - Custom decisions
defp default_interrupt_on(%<%= factory_config_alias %>{} = _c) do
%{
"delete_file" => true
# "write_file" => true,
# "execute_command" => true
}
end
# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------
# Custom (non-middleware-provided) tools. Branch on `c` to enable/disable
# tools per request.
defp build_tools(%<%= factory_config_alias %>{} = _c), do: []
# ---------------------------------------------------------------------------
# Middleware Configuration
# ---------------------------------------------------------------------------
# Build the middleware stack.
#
# This replicates the default stack from `Sagents.Agent.build_default_middleware/3`:
# 1. TodoList - Task management with write_todos tool
# 2. ConversationTitle - Auto-generate conversation titles (async, so positioned early)
# 3. FileSystem - Virtual filesystem (ls, read_file, write_file, etc.)
# 4. SubAgent - Delegate to specialized child agents
# 5. Summarization - Compress long conversations to stay within token limits
# 6. PatchToolCalls - Fix dangling tool calls from interrupted conversations
#
# HumanInTheLoop is conditionally added based on `interrupt_on` configuration.
#
# Order matters! Early middleware sees messages first (before_model) and
# processes responses last (after_model).
defp build_middleware(%<%= factory_config_alias %>{} = c) do
filesystem_scope = ensure_filesystem_for(c)
interrupt_on = default_interrupt_on(c)
[
# Task management - gives the agent a todo list for tracking work
Sagents.Middleware.TodoList,
# ConversationTitle - auto-generate titles after the first exchange.
# Positioned early because it's async and should start as soon as possible.
# Uses a lighter/faster model (Haiku) for cost efficiency.
{ConversationTitle,
[
chat_model: get_title_model(c),
fallbacks: get_title_fallbacks(c)
]},
# Virtual filesystem - file operations with configurable scope
# `filesystem_scope` is derived from `c.scope` in `ensure_filesystem_for/1`.
# If nil, defaults to {:agent, agent_id} (isolated per conversation).
{Sagents.Middleware.FileSystem, [filesystem_scope: filesystem_scope]},
# SubAgent - spawn child agents for complex tasks
# Configure block_middleware to prevent certain middleware from being
# inherited by subagents (e.g., Summarization, ConversationTitle).
# AskUserQuestion is blocked because sub-agents should complete tasks
# autonomously without pausing to quiz the user.
{Sagents.Middleware.SubAgent,
[
block_middleware: [
Sagents.Middleware.Summarization,
Sagents.Middleware.ConversationTitle,
Sagents.Middleware.AskUserQuestion
]
]},
# Summarization - compress long conversations to fit context window
Sagents.Middleware.Summarization,
# PatchToolCalls - fix dangling tool calls from interrupted conversations
Sagents.Middleware.PatchToolCalls,
# AskUserQuestion - structured questions with typed responses
# Gives the agent an `ask_user` tool for presenting single-select,
# multi-select, or freeform questions to the user via the interrupt/resume
# lifecycle. The UI renders appropriate controls based on response_type.
Sagents.Middleware.AskUserQuestion
]
# HumanInTheLoop MUST be last. During resume, HITL executes all tools
# (including auto-approved ones from other middleware). If those tools produce
# interrupts (e.g., ask_user), HITL hands off via {:cont} to the next middleware
# in the resume cycle. Middleware that already ran (earlier in the list) won't
# get a second chance, so interrupt-producing middleware must come before HITL.
|> HumanInTheLoop.maybe_append(interrupt_on)
end
end