defmodule <%= module %> do @moduledoc """ Factory for creating agents with consistent configuration. This module centralizes agent creation, ensuring all agents use the same model, middleware stack, and base configuration. The Coordinator calls `create_agent/1` 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 - Change model provider in `get_model_config/0` - Configure fallbacks in `get_fallback_models/0` - Configure title generation model in `get_title_model/0` - Modify system prompt in `base_system_prompt/0` - Add/remove middleware in `build_middleware/2` - Add custom tools in `create_agent/1` under the `:tools` key - Configure HITL in `default_interrupt_on/0` ## 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 The FileSystem middleware supports flexible scoping to control file isolation. **For user-facing interactive agents (recommended):** - **User-scoped**: `filesystem_scope: {:user, user_id}` - Files persist across all conversations for the same user - Enables true long-term memory and file accumulation - This is the typical pattern for chat applications **Other scoping options:** - **Project-scoped**: `filesystem_scope: {:project, project_id}` - Files shared within a project across multiple users and conversations - **Agent-scoped**: `filesystem_scope: nil` (defaults to `{:agent, agent_id}`) - Files isolated per conversation - typically too limiting for user-facing agents - May be appropriate for non-interactive batch processing or isolated task execution - **Custom scoping**: Use any tuple like `{:team, team_id}` or `{:session, session_id}` **Important:** The Coordinator should pass the appropriate scope when calling `create_agent/1`. """ alias LangChain.ChatModels.ChatAnthropic # Uncomment for OpenAI: # alias LangChain.ChatModels.ChatOpenAI # Uncomment for Bedrock: # alias LangChain.Utils.BedrockConfig alias Sagents.Agent alias Sagents.Middleware.ConversationTitle alias Sagents.Middleware.HumanInTheLoop # --------------------------------------------------------------------------- # 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 """ Creates an agent with the standard configuration. ## Options - `:agent_id` - Required. Unique identifier for this agent. - `:filesystem_scope` - Required. Scope tuple for filesystem isolation. Examples: `{:<%= owner_type %>, <%= owner_field %>}`, `{:project, 456}`, `{:team, 789}`. Pass `nil` for agent-scoped (isolated per conversation). - `:interrupt_on` - Optional. Map of tool names requiring approval. Pass `nil` to disable HITL entirely. - `:tool_context` - Optional. Map of caller-supplied data passed to tool functions via `LLMChain.custom_context`. Defaults to `%{}`. Example: `%{user_id: 42}`. ## Examples # Standard usage with <%= owner_type %> scope {:ok, agent} = Factory.create_agent( agent_id: "conv-123", filesystem_scope: {:<%= owner_type %>, <%= owner_field %>} ) # Project-scoped filesystem {:ok, agent} = Factory.create_agent( agent_id: "conv-123", filesystem_scope: {:project, project_id} ) # Agent-scoped (isolated per conversation) {:ok, agent} = Factory.create_agent( agent_id: "conv-123", filesystem_scope: nil ) # With custom HITL configuration {:ok, agent} = Factory.create_agent( agent_id: "conv-123", filesystem_scope: {:<%= owner_type %>, <%= owner_field %>}, interrupt_on: %{ "write_file" => true, "delete_file" => true, "execute_command" => true } ) """ def create_agent(opts \\ []) do agent_id = Keyword.fetch!(opts, :agent_id) filesystem_scope = Keyword.fetch!(opts, :filesystem_scope) interrupt_on = Keyword.get(opts, :interrupt_on, default_interrupt_on()) tool_context = Keyword.get(opts, :tool_context, %{}) Agent.new( %{ agent_id: agent_id, model: get_model_config(), base_system_prompt: base_system_prompt(), middleware: build_middleware(filesystem_scope, interrupt_on), fallback_models: get_fallback_models(), before_fallback: get_before_fallback(), # Add any custom tools here (tools not provided by middleware) tools: [], tool_context: tool_context }, # Since we specify the full middleware stack, don't add defaults replace_default_middleware: true ) end # --------------------------------------------------------------------------- # Model Configuration # --------------------------------------------------------------------------- # Primary model configuration. # Modify this function to switch providers or models. defp get_model_config do ChatAnthropic.new!(%{ model: @main_model, api_key: System.fetch_env!("ANTHROPIC_API_KEY"), stream: true, 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. # # This function is called by create_agent/1 and passed to the Agent struct. # # 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. # @doc """ Returns the list of fallback models to use when the primary model fails. This is called automatically by `create_agent/1`. The fallback models are used in order when the primary model encounters an error that should be retried (rate limits, service unavailable, etc.). **Cost Considerations:** - Fallbacks are only used when the primary model fails - Each fallback attempt incurs API costs - Monitor your primary model's error rate to assess fallback usage - Consider using cheaper models as fallbacks (e.g., Haiku instead of Sonnet) See the implementation for examples of configuring Bedrock and Azure fallbacks. """ def get_fallback_models 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 @doc """ Returns an optional function to modify the chain before each LLM attempt. This function is called before EVERY attempt, including the first. This makes it useful for provider-specific modifications like: - Swapping system prompts for different providers (Anthropic vs OpenAI formatting) - Adjusting context or parameters based on the model - Logging or telemetry per attempt ## Function Signature The function receives an `LLMChain.t()` and returns a modified `LLMChain.t()`: fn %LLMChain{} = chain -> modified_chain end ## Example: Provider-Specific System Prompts def get_before_fallback do fn chain -> case chain.llm do %ChatAnthropic{} -> # Anthropic-specific system prompt new_prompt = Message.new_system!("Anthropic-optimized prompt") %LLMChain{chain | messages: LangChain.Utils.replace_system_message!(chain.messages, new_prompt)} %ChatOpenAI{} -> # OpenAI-specific system prompt new_prompt = Message.new_system!("OpenAI-optimized prompt") %LLMChain{chain | messages: LangChain.Utils.replace_system_message!(chain.messages, new_prompt)} _ -> chain end end end Returns a function `(LLMChain.t() -> LLMChain.t())` or `nil` to skip. """ def get_before_fallback do nil # Example implementation (commented out): # fn chain -> # # Example: Log which model is being attempted # Logger.debug("Attempting LLM call with \#{inspect(chain.llm)}") # chain # end 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 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 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. # Customize this for your agent's purpose and personality. defp base_system_prompt 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 do %{ "delete_file" => true # "write_file" => true, # "execute_command" => true } end # --------------------------------------------------------------------------- # 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(filesystem_scope, interrupt_on) do [ # 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(), fallbacks: get_title_fallbacks() ]}, # Virtual filesystem - file operations with configurable scope # For user-facing agents, pass {:user, user_id} from the Coordinator # 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). {Sagents.Middleware.SubAgent, [ block_middleware: [ Sagents.Middleware.Summarization, Sagents.Middleware.ConversationTitle ] ]}, # Summarization - compress long conversations to fit context window Sagents.Middleware.Summarization, # PatchToolCalls - fix dangling tool calls from interrupted conversations Sagents.Middleware.PatchToolCalls ] # Conditionally add HumanInTheLoop if interrupt_on is configured. |> HumanInTheLoop.maybe_append(interrupt_on) end end