Sagents

repository·main·Indexed 42 days ago

https://github.com/sagents-ai/sagents

An orchestration layer for Elixir developers building interactive, production-grade AI applications. Sagents extends the LangChain library to provide supervision, Human-In-The-Loop (HITL) capabilities, real-time event streaming via PubSub, and structured execution modes. It includes built-in middleware for task management (TodoList), virtual filesystems, sub-agent delegation, and conversation summarization.

Tokens
89.7K
Snippets
227
Records
302
Agent score
54%

What's inside Sagents

  1. Understand the difference between agent_status and agent_alive?

    main

    In v0.11.0, Sagents separates the state of the conversation from the state of the underlying process to ensure durability of user prompts during inactivity:

    • agent_status: Represents what the conversation is waiting on. This remains :interrupted as long as a user owes an answer, regardless of whether a process is currently running.
    • agent_alive?: Represents whether a process is backing this session right now.

    This separation allows Sagents.Session.resume/4 to start an agent with a user's answer already in hand if no process was running, preventing the UI from losing the prompt.

  2. How scope works in Sagents

    main

    In Sagents (v0.7.0+), scope is a first-class channel used to manage multi-tenant contexts (e.g., user, organization, or tenant). It is a named field on the Agent struct and is automatically propagated to:

    1. Persistence/Callback Behaviours: Passed as the first positional argument to all @impl functions.
    2. Tool Functions: Injected directly into the context map as context.scope.

    This replaces the previous pattern of manually threading scope through tool_context. Because Sagents injects scope directly into the tool context, it will override any :scope key you manually place in tool_context.

  3. Understand Agent redistribution in clusters

    main

    When using the :horde backend in a cluster, if a node departs, agents may be dropped or redistributed:

    • Dropped Agents: If an agent is placed immediately before a node leaves, it might be dropped cleanly. The next call to Sagents.Session.ensure_running/3 will restart it from its persisted state.
    • Redistributed Agents: Agents that have been running for a few seconds are typically redistributed reliably to surviving nodes.

    Best Practice: Do not rely on Horde to keep an agent alive. Design your application so that the next request is the guarantee of execution. Work that must happen should be driven by a request, a job, or a supervisor you control.

  4. How the FileSystem middleware and FileSystemServer work together

    main

    The FileSystem middleware provides agents with tools to interact with a virtual filesystem (e.g., list_files, read_file, create_file).

    Crucially, the middleware is a thin client that does not hold data itself. Instead, it delegates all operations to a FileSystemServer GenServer. These servers are identified by a scope key (e.g., {:user, 123}).

    Key Lifecycle Rules:

    • Pre-requisite: The FileSystemServer must be started via Sagents.FileSystem.ensure_filesystem/3 before an agent session begins. If the server for a specific scope is not running, the middleware will fail.
    • Persistence: The FileSystemServer is independent of agent lifecycles. It is managed by a FileSystemSupervisor and can outlive individual agent sessions.
    • Sharing: Multiple agents or conversations can share the same filesystem by using the same scope key. Any agent configured with filesystem_scope: {:user, 123} will access the same files.
    Your Application
      └── FileSystemSupervisor (DynamicSupervisor)
            └── FileSystemServer {:user, 1}   ← started by your app, not by the agent
            └── FileSystemServer {:user, 2}
    
      └── AgentsDynamicSupervisor
            └── AgentSupervisor (conversation-101)
                  └── AgentServer   ← uses FileSystemServer {:user, 1} via scope key
  5. Understand the Conversations Dual-View Architecture

    main

    Sagents separates conversation data into three distinct layers to optimize for both intelligence and performance:

    • Conversations Table: The central link between the conversation and your application's owner model (e.g., user_id).
    • Agent State (Internal View): Stored in the agent_states table as JSONB. This contains the complete LLM context required for intelligent responses. It is optimized for the agent's reasoning process.
    • Display Messages (External View): Stored in the display_messages table as individual rows. This is optimized for fast UI queries and rendering. It supports rich content types (like thinking, images, or tool status) that are useful for the user but not necessarily needed by the LLM.

    Key Benefit: This separation allows for fast UI updates and progressive message streaming without the overhead of deserializing large agent state blobs every time a message is displayed.

  6. Understand the three context channels in Sagents tools

    main

    When an LLM calls a tool, the tool function receives two arguments: args (the LLM-provided JSON arguments) and context (runtime data). The context map is composed of three distinct channels, each with a different purpose and lifetime:

    1. scope: The canonical channel for tenancy and authorization. It identifies who the agent is running on behalf of. It is a first-class field on the Agent and is automatically merged into the tool's context under the top-level :scope key.
    2. tool_context: A static, read-only "grab-bag" of caller-supplied data (e.g., feature flags, request IDs). These are merged flat into the context map and are accessible as top-level keys.
    3. state.metadata: A mutable, persistent key-value store. Middleware and tools can read from and write to this during execution. It is nested inside the context.state struct.

    Understanding these differences is critical for writing tools that behave correctly in both main agents and SubAgents.

    fn args, context ->
      # args: the LLM-provided arguments (parsed from JSON)
      # context: runtime data from the agent system
    end
  7. Understand middleware execution order

    main

    Middleware execution follows a specific order depending on the hook being used. This is critical for managing dependencies and human-in-the-loop gates.

    Execution Patterns

    • before_model: Executes in the order provided in the middleware list (Top $\rightarrow$ Bottom).
    • after_model: Executes in reverse order (Bottom $\rightarrow$ Top).
    • handle_resume: Executes in the order provided in the middleware list.

    The Sandwich Pattern

    This ordering creates a 'sandwich' where early middleware can set up context for later middleware, and early middleware also sees the final processed result after the after_model reverse pass.

    Human-In-The-Loop (HITL) Placement

    HumanInTheLoop middleware must be the last item in your middleware list.

    Why? During the handle_resume cycle, HITL executes all tool calls from an interrupted message. If an auto-approved tool produces its own interrupt (e.g., via ask_user), HITL detects it and returns {:cont}. The resume cycle then re-scans the stack so the owning middleware can claim the interrupt. All interrupt-producing middleware (like AskUserQuestion) must be positioned before HITL in the stack.

  8. Use `tool_context` for static, caller-supplied data

    main

    Use tool_context to pass non-identity data that is required by tools but does not change during the agent's execution (e.g., feature flags, correlation IDs, or display names).

    Characteristics:

    • Set once at agent creation.
    • Read-only from the tool's perspective.
    • Merged flat into the context map, meaning you access them as top-level keys.
    • Not persisted or serialized.

    Example Setup:

    {:ok, agent} = Agent.new(%{
      model: model,
      scope: scope,
      tool_context: %{feature_flags: %{new_search: true}, tenant_name: "Acme"}
    })

    Access pattern in tools:

    fn _args, context ->
      context.feature_flags  #=> %{new_search: true}
      context.tenant_name     #=> "Acme"
    end
    fn _args, context ->
      context.feature_flags  #=> %{new_search: true}
      context.tenant_name    #=> "Acme"
    end
  9. How Horde membership works in Sagents

    main

    In Sagents, agent placement is determined entirely by the member set of the Horde instances. A node can only host an agent if it is a member of the Horde cluster.

    Two critical behaviors to understand:

    1. Membership vs. Placement Eligibility: A node must be a member AND report an :alive status to be a valid target for Horde.UniformDistribution. A node that is a member but hasn't started the Horde supervisor remains :uninitialized and is skipped for placement.
    2. CRDT Sync Traffic: The member set drives the DeltaCrdt sync fan-out. Every member (alive or not) is a sync neighbor. An overly broad member set can cause high sync traffic and registration timeouts. Scoping the member set is the most important factor for cluster health.
  10. Understand middleware fan-out and sub-agent inheritance

    main

    Middleware Fan-out

    Multiple middleware can declare the same callback key. When an event occurs, Sagents executes all registered handlers for that event. This allows you to separate concerns, such as having one middleware for metrics and another for audit logging, without interference.

    Sub-agent Inheritance

    By default, an agent's middleware stack is inherited by any sub-agents it spawns. This ensures that observability (token usage, tool execution, errors) is automatically tracked across the entire agent tree without manual reconfiguration for sub-agents.

  11. Understand middleware ordering and `before_model` visibility

    main

    When using the before_model hook to publish metadata, middleware ordering is critical. Hooks run in the order they are listed in the middleware configuration:

    1. Visibility: A middleware can only see metadata set by middleware listed before it in the list.
    2. Tool Independence: Tool functions are not affected by this ordering. Tools execute later and can read metadata set by any middleware in the stack.

    Best Practice: To avoid ordering bugs, use on_server_start for stable configuration data instead of before_model.

  12. The difference between Agent and State

    main

    In Sagents, configuration and runtime data are strictly separated into two distinct structs:

    Agent (Configuration)

    The Agent struct is immutable. It defines how the agent should behave but does not change during execution. It includes:

    • agent_id: Unique identifier.
    • model: The LLM configuration (e.g., %ChatAnthropic{}).
    • base_system_prompt: The core instructions.
    • middleware: A list of middleware configurations.
    • tools: Additional tools beyond those provided by middleware.
    • callbacks: Event callbacks.

    State (Runtime)

    The State struct holds the mutable runtime data that evolves as the agent interacts with users and tools. It includes:

    • agent_id: The ID of the associated agent.
    • messages: The history of user and LLM messages.
    • todos: Middleware-specific task lists.
    • metadata: Arbitrary metadata.
    • interrupt: Data related to Human-In-The-Loop (HITL) pauses.
    # Agent (Immutable Configuration)
    %Agent{
      agent_id: "conversation-123",
      model: %ChatAnthropic{...},
      base_system_prompt: "You are helpful.",
      middleware: [{TodoList, []}, {FileSystem, [enabled_tools: [...]]}, ...],
      tools: [custom_tool],
      callbacks: %{...}
    }
    
    # State (Runtime Data)
    %State{
      agent_id: "conversation-123",
      messages: [%Message{...}, ...],
      todos: [%Todo{...}, ...],
      metadata: %{...},
      interrupt: nil | %InterruptData{...}
    }