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