Skip to content

Latest commit

 

History

History
360 lines (264 loc) · 16.7 KB

File metadata and controls

360 lines (264 loc) · 16.7 KB

Sagents AI Library

This file provides guidance for Coding Agents working in this repository.

Project Overview

The Sagents library is a framework for integrating more advanced conversational AI Agents into Elixir applications.

It provides mix task generators for integrating more easily into an Elixir application. It provides advanced middleware for composably adding features to an agent..

It draws initial inspiration from LangChain Deep Agents library, but follows it's own path that aims for integration with Elixir ecosystem.

Common Development Commands

Setup and Dependencies

# Install dependencies
mix deps.get

# Set up environment variables for API keys
cp .envrc_template .envrc
# Edit .envrc with API keys, then:
source .envrc

Never read the .envrc file as it contains developer secrets.

Testing

# Run unit tests only (makes no external API calls)
mix test

# Run specific live API tests (billable and should be confirmed first)
mix test --include live_call
mix test --include live_anthropic

# Run a single test file
mix test test/path/to/test_file.exs

# Run a specific test by line number
mix test test/path/to/test_file.exs:42

Code Quality

Always run this before committing:

# Performs a set of pre-commit steps of compile checks, formatting, running test, etc.
mix precommit

High-Level Architecture

Core Components

  1. Chat Models (lib/chat_models/)

    • ChatModel behavior defines the interface for all LLM implementations
    • Each provider (OpenAI, Anthropic, etc.) implements this behavior
    • Supports streaming, function calling, and multi-modal inputs
  2. Chains (lib/chains/)

    • LLMChain: Primary abstraction for core orchestration of conversations and other LLM workflows
    • DataExtractionChain: Structured data extraction from text
    • RoutingChain: Dynamic routing based on input
    • Chains compose multiple operations and maintain conversation state
  3. Messages (lib/message.ex and lib/message/)

    • Message: Core structure with roles (system, user, assistant, tool)
    • ContentPart: Handles multi-modal content (text, images, files)
    • ToolCall and ToolResult: Function invocation and results
  4. Functions (lib/function.ex)

    • Integrates custom Elixir functions with LLMs
    • JSON Schema-based parameter validation
    • Context-aware execution with async support

Key Patterns

  • Behavior-based design: All chat models implement the ChatModel behavior
  • Ecto schemas: Used for data validation and type casting throughout
  • Streaming support: Built-in streaming capabilities for real-time responses
  • Error handling: Consistent error tuples {:ok, result} / {:error, reason}

Adding New Features

When adding a new LLM provider:

  1. Create a new module in lib/chat_models/
  2. Implement the ChatModel behavior
  3. Add corresponding tests in test/chat_models/
  4. Update documentation with supported features

When adding new chain types:

  1. Create in lib/chains/ following existing patterns
  2. Use Ecto.Schema for configuration
  3. Implement run/2 function for execution
  4. Add comprehensive tests including async scenarios

Testing Guidelines

  • Tests mirror the source structure (e.g., lib/chains/llm_chain.extest/chains/llm_chain_test.exs)
  • Use @tag :live_call for tests requiring actual API calls
  • Mock external dependencies using Mimic for unit tests
  • Always test both sync and async execution paths when applicable

Important Notes

  • API Keys: Never commit API keys. Use environment variables via .envrc

  • Live Tests: Be cautious with live tests as they incur API costs

  • Multi-modal: When working with messages, use ContentPart structures

  • Callbacks: There are two callback systems that get combined into a single list of handler maps:

    1. LangChain callbacks — keys like :on_llm_token_usage, :on_message_processed, etc. These are added to the LLMChain via add_callback/2 and fired by LLMChain.run/2. See LangChain.Chains.ChainCallbacks for valid keys.
    2. Sagents-specific callbacks — currently just :on_after_middleware, fired manually by Agent.execute/3 after before_model hooks complete. Not a LangChain key.

    Both types live in the same list of maps passed via callbacks: [map1, map2, ...]. AgentServer builds this list by combining PubSub broadcast callbacks (build_pubsub_callbacks/1) with middleware callbacks (Middleware.collect_callbacks/1). All maps fire in fan-out: if two maps have the same key, both handlers fire. Middleware can declare LangChain callbacks via the callbacks/1 behaviour callback.

Project guidelines

  • Use mix precommit alias when you are done with all changes and fix any pending issues
  • Use the already included and available :req (Req) library for HTTP requests, avoid :httpoison, :tesla, and :httpc. Req is included by default and is the preferred HTTP client for Phoenix apps

Phoenix v1.8 guidelines

  • Always begin your LiveView templates with <Layouts.app flash={@flash} ...> which wraps all inner content
  • The MyAppWeb.Layouts module is aliased in the my_app_web.ex file, so you can use it without needing to alias it again
  • Anytime you run into errors with no current_scope assign:
    • You failed to follow the Authenticated Routes guidelines, or you failed to pass current_scope to <Layouts.app>
    • Always fix the current_scope error by moving your routes to the proper live_session and ensure you pass current_scope as needed
  • Phoenix v1.8 moved the <.flash_group> component to the Layouts module. You are forbidden from calling <.flash_group> outside of the layouts.ex module
  • Out of the box, core_components.ex imports an <.icon name="hero-x-mark" class="w-5 h-5"/> component for for hero icons. Always use the <.icon> component for icons, never use Heroicons modules or similar
  • Always use the imported <.input> component for form inputs from core_components.ex when available. <.input> is imported and using it will will save steps and prevent errors
  • If you override the default input classes (<.input class="myclass px-2 py-1 rounded-lg">)) class with your own values, no default classes are inherited, so your custom classes must fully style the input

JS and CSS guidelines

  • Use Tailwind CSS classes and custom CSS rules to create polished, responsive, and visually stunning interfaces.

  • Tailwindcss v4 no longer needs a tailwind.config.js and uses a new import syntax in app.css:

    @import "tailwindcss" source(none);
    @source "../css";
    @source "../js";
    @source "../../lib/my_app_web";
    
  • Always use and maintain this import syntax in the app.css file for projects generated with phx.new

  • Never use @apply when writing raw css

  • Always manually write your own tailwind-based components instead of using daisyUI for a unique, world-class design

  • Out of the box only the app.js and app.css bundles are supported

    • You cannot reference an external vendor'd script src or link href in the layouts
    • You must import the vendor deps into app.js and app.css to use them
    • Never write inline <script>custom js</script> tags within templates

UI/UX & design guidelines

  • Produce world-class UI designs with a focus on usability, aesthetics, and modern design principles
  • Implement subtle micro-interactions (e.g., button hover effects, and smooth transitions)
  • Ensure clean typography, spacing, and layout balance for a refined, premium look
  • Focus on delightful details like hover effects, loading states, and smooth page transitions

Elixir guidelines

  • Elixir lists do not support index based access via the access syntax

    Never do this (invalid):

    i = 0
    mylist = ["blue", "green"]
    mylist[i]
    

    Instead, always use Enum.at, pattern matching, or List for index based list access, ie:

    i = 0
    mylist = ["blue", "green"]
    Enum.at(mylist, i)
    
  • Elixir variables are immutable, but can be rebound, so for block expressions like if, case, cond, etc you must bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:

    # INVALID: we are rebinding inside the `if` and the result never gets assigned
    if connected?(socket) do
      socket = assign(socket, :val, val)
    end
    
    # VALID: we rebind the result of the `if` to a new variable
    socket =
      if connected?(socket) do
        assign(socket, :val, val)
      end
    
  • Never nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors

  • Never use map access syntax (changeset[:field]) on structs as they do not implement the Access behaviour by default. For regular structs, you must access the fields directly, such as my_struct.field or use higher level APIs that are available on the struct if they exist, Ecto.Changeset.get_field/2 for changesets

  • Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common Time, Date, DateTime, and Calendar interfaces by accessing their documentation as necessary. Never install additional dependencies unless asked or for date/time parsing (which you can use the date_time_parser package)

  • Don't use String.to_atom/1 on user input (memory leak risk)

  • Predicate function names should not start with is_ and should end in a question mark. Names like is_thing should be reserved for guards

  • Elixir's builtin OTP primitives like DynamicSupervisor and Registry, require names in the child spec, such as {DynamicSupervisor, name: MyApp.MyDynamicSup}, then you can use DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)

  • Use Task.async_stream(collection, callback, options) for concurrent enumeration with back-pressure. The majority of times you will want to pass timeout: :infinity as option

Mix guidelines

  • Read the docs and options before using tasks (by using mix help task_name)
  • To debug test failures, run tests in a specific file with mix test test/my_test.exs or run all previously failed tests with mix test --failed
  • mix deps.clean --all is almost never needed. Avoid using it unless you have good reason

Test guidelines

  • Always use start_supervised!/1 to start processes in tests as it guarantees cleanup between tests
  • Avoid Process.sleep/1 and Process.alive?/1 in tests
    • Instead of sleeping to wait for a process to finish, always use Process.monitor/1 and assert on the DOWN message:

      ref = Process.monitor(pid) assert_receive {:DOWN, ^ref, :process, ^pid, :normal}

    • Instead of sleeping to synchronize before the next call, always use a synchronous call to ensure the process has handled prior messages. For AgentServer tests, prefer _ = AgentServer.get_state(agent_id) over :sys.get_state/1 since it accepts the agent_id directly and provides the same synchronization guarantee via GenServer.call

Usage Rules

IMPORTANT: Consult these usage rules early and often when working with the packages listed below. Before attempting to use any of these packages or to discover if you should use them, review their usage rules to understand the correct patterns, conventions, and best practices.

usage_rules usage

A dev tool for Elixir projects to gather LLM usage rules from dependencies

Using Usage Rules

Many packages have usage rules, which you should thoroughly consult before taking any action. These usage rules contain guidelines and rules directly from the package authors. They are your best source of knowledge for making decisions.

Modules & functions in the current app and dependencies

When looking for docs for modules & functions that are dependencies of the current project, or for Elixir itself, use mix usage_rules.docs

# Search a whole module
mix usage_rules.docs Enum

# Search a specific function
mix usage_rules.docs Enum.zip

# Search a specific function & arity
mix usage_rules.docs Enum.zip/1

Searching Documentation

You should also consult the documentation of any tools you are using, early and often. The best way to accomplish this is to use the usage_rules.search_docs mix task. Once you have found what you are looking for, use the links in the search results to get more detail. For example:

# Search docs for all packages in the current application, including Elixir
mix usage_rules.search_docs Enum.zip

# Search docs for specific packages
mix usage_rules.search_docs Req.get -p req

# Search docs for multi-word queries
mix usage_rules.search_docs "making requests" -p req

# Search only in titles (useful for finding specific functions/modules)
mix usage_rules.search_docs "Enum.zip" --query-by title

usage_rules:elixir usage

Elixir Core Usage Rules

Pattern Matching

  • Use pattern matching over conditional logic when possible
  • Prefer to match on function heads instead of using if/else or case in function bodies
  • %{} matches ANY map, not just empty maps. Use map_size(map) == 0 guard to check for truly empty maps

Error Handling

  • Use {:ok, result} and {:error, reason} tuples for operations that can fail
  • Avoid raising exceptions for control flow
  • Use with for chaining operations that return {:ok, _} or {:error, _}

Common Mistakes to Avoid

  • Elixir has no return statement, nor early returns. The last expression in a block is always returned.
  • Don't use Enum functions on large collections when Stream is more appropriate
  • Avoid nested case statements - refactor to a single case, with or separate functions
  • Don't use String.to_atom/1 on user input (memory leak risk)
  • Lists and enumerables cannot be indexed with brackets. Use pattern matching or Enum functions
  • Prefer Enum functions like Enum.reduce over recursion
  • When recursion is necessary, prefer to use pattern matching in function heads for base case detection
  • Using the process dictionary is typically a sign of unidiomatic code
  • Only use macros if explicitly requested
  • There are many useful standard library functions, prefer to use them where possible

Function Design

  • Use guard clauses: when is_binary(name) and byte_size(name) > 0
  • Prefer multiple function clauses over complex conditional logic
  • Name functions descriptively: calculate_total_price/2 not calc/2
  • Predicate function names should not start with is and should end in a question mark.
  • Names like is_thing should be reserved for guards

Data Structures

  • Use structs over maps when the shape is known: defstruct [:name, :age]
  • Prefer keyword lists for options: [timeout: 5000, retries: 3]
  • Use maps for dynamic key-value data
  • Prefer to prepend to lists [new | list] not list ++ [new]

Mix Tasks

  • Use mix help to list available mix tasks
  • Use mix help task_name to get docs for an individual task
  • Read the docs and options fully before using tasks

Testing

  • Run tests in a specific file with mix test test/my_test.exs and a specific test with the line number mix test path/to/test.exs:123
  • Limit the number of failed tests with mix test --max-failures n
  • Use @tag to tag specific tests, and mix test --only tag to run only those tests
  • Use assert_raise for testing expected exceptions: assert_raise ArgumentError, fn -> invalid_function() end
  • Use mix help test to for full documentation on running tests

Debugging

  • Use dbg/1 to print values while debugging. This will display the formatted value and other relevant information in the console.

usage_rules:otp usage

OTP Usage Rules

GenServer Best Practices

  • Keep state simple and serializable
  • Handle all expected messages explicitly
  • Use handle_continue/2 for post-init work
  • Implement proper cleanup in terminate/2 when necessary

Process Communication

  • Use GenServer.call/3 for synchronous requests expecting replies
  • Use GenServer.cast/2 for fire-and-forget messages.
  • When in doubt, use call over cast, to ensure back-pressure
  • Set appropriate timeouts for call/3 operations

Fault Tolerance

  • Set up processes such that they can handle crashing and being restarted by supervisors
  • Use :max_restarts and :max_seconds to prevent restart loops

Task and Async

  • Use Task.Supervisor for better fault tolerance
  • Handle task failures with Task.yield/2 or Task.shutdown/2
  • Set appropriate task timeouts
  • Use Task.async_stream/3 for concurrent enumeration with back-pressure