Sagents.Agent (Sagents v0.12.0)
Copy MarkdownMain entry point for creating Agents.
A Agent is an AI agent with composable middleware that provides capabilities like TODO management, filesystem operations, and task delegation.
Basic Usage
# Create agent with default middleware
{:ok, agent} = Agent.new(%{
agent_id: "my-agent-1",
model: ChatAnthropic.new!(%{model: "claude-sonnet-4-6"}),
base_system_prompt: "You are a helpful assistant."
})
# Execute with messages
state = State.new!(%{messages: [%{role: "user", content: "Hello!"}]})
{:ok, result_state} = Agent.execute(agent, state)Middleware Composition
# Append custom middleware to defaults
{:ok, agent} = Agent.new(%{
middleware: [MyCustomMiddleware]
})
# Customize default middleware
{:ok, agent} = Agent.new(%{
filesystem_opts: [long_term_memory: true]
})
# Provide complete middleware stack
{:ok, agent} = Agent.new(%{
replace_default_middleware: true,
middleware: [{MyMiddleware, []}]
})
Summary
Functions
Build the default middleware stack.
Execute the agent with the given state.
Create a new Agent.
Create a new Agent, raising on error.
Merge additional OpenTelemetry span attributes into the agent.
Resume agent execution after an interrupt.
Types
@type execute_result() :: {:ok, Sagents.State.t()} | {:ok, Sagents.State.t(), LangChain.Message.ToolResult.t()} | {:interrupt, Sagents.State.t(), any()} | {:pause, Sagents.State.t()} | {:error, any()}
Result of execute/3 (and resume/4).
The {:ok, state, tool_result} shape is returned for :until_tool /
:until_tool_success completions; the third element is the matching
LangChain.Message.ToolResult.
@type t() :: %Sagents.Agent{ agent_id: term(), assembled_system_prompt: term(), async_tool_timeout: term(), base_system_prompt: term(), before_fallback: term(), fallback_models: term(), filesystem_scope: term(), max_runs: term(), middleware: term(), mode: term(), model: term(), name: term(), otel_attributes: term(), scope: term(), tool_context: term(), tools: term() }
Functions
Build the default middleware stack.
This is a utility function that can be used to build the default middleware stack with custom options. Useful when you want to customize middleware configuration or when building subagents.
Parameters
model- The LangChain ChatModel structagent_id- The agent's unique identifieropts- Keyword list of middleware options
Options
:todo_opts- Options for TodoList middleware:filesystem_opts- Options for Filesystem middleware:summarization_opts- Options for Summarization middleware:subagent_opts- Options for SubAgent middleware:interrupt_on- Map of tool names to interrupt configuration
Examples
middleware = Agent.build_default_middleware(
model,
"agent-123",
filesystem_opts: [long_term_memory: true],
interrupt_on: %{"write_file" => true}
)
@spec execute(t(), Sagents.State.t(), keyword()) :: execute_result()
Execute the agent with the given state.
Applies middleware hooks in order:
- before_model hooks (in order)
- LLM execution
- after_model hooks (in reverse order)
Options
:callbacks- A list of callback handler maps. Each map may contain LangChain callback keys (seeLangChain.Chains.ChainCallbacks) and/or the Sagents-specific:on_after_middlewarekey. All maps are added to the LLMChain, and matching handlers fire in fan-out (all maps checked).LangChain callback keys (e.g.,
:on_llm_token_usage,:on_message_processed) are fired byLLMChain.run/2during execution. The:on_after_middlewarekey is fired by the agent directly afterbefore_modelhooks complete, before the LLM call — it receives the prepared state as its single argument.This agent's own middleware callbacks (from
Middleware.collect_callbacks/1) are always collected and run — supplying:callbacksadds to them rather than replacing them, so an ad-hoc handler never silently disables middleware callbacks. When running viaAgentServer, the only callbacks passed here are the PubSub broadcasting callbacks; middleware collection happens internally.:until_tool- Tool name (string) or list of tool names. When set, the run completes (returning{:ok, state, tool_result}) as soon as the target tool is called, regardless of whether it succeeded or returned an error. Errors withuntil_tool_not_calledif the LLM stops without calling it.:until_tool_success- Tool name (string) or list of tool names. Like:until_tool, but the run completes only when the target tool returns a successful (non-error) result. An error result keeps the loop running so the LLM can correct its arguments, up to:max_runsattempts. Use this instead of:until_toolwhen the tool validates its input and you want the model to retry. Passing both:until_tooland:until_tool_successis an error (they are mutually exclusive).On success the run returns
{:ok, state, %LangChain.Message.ToolResult{}}. The tool can hand a processed result back on thatToolResultby returning{:ok, "text for LLM", processed_term}— the 3rd element becomes the result'sprocessed_contentand is not sent to the model. Read it withSagents.AgentResult.processed_content/1. This is the building blockSagents.Extractis built on.# A submit tool that validates + persists, returning the inserted record: submit = LangChain.Function.new!(%{ name: "submit", parameters_schema: schema, function: fn args, _ctx -> case MyApp.create_person(args) do {:ok, person} -> {:ok, "Saved #{person.id}.", person} # an error result loops so the LLM can correct the call: {:error, reason} -> {:error, "Could not save: #{reason}"} end end }) {:ok, _state, %LangChain.Message.ToolResult{}} = result = Agent.execute(agent, state, until_tool_success: "submit", max_runs: 5) {:ok, %MyApp.Person{} = person} = Sagents.AgentResult.processed_content(result)Plain
:until_toolwould instead terminate on the first call even when the tool returns{:error, ...}, so the validate-and-retry loop above needs:until_tool_success. Noteprocessed_contentis a virtual field. Read it from theexecute/3return value; it is not persisted across a state serialize/reload.
Returns
{:ok, state}- Normal completion{:ok, state, tool_result}-:until_tool/:until_tool_successcompletion; the third element is the matching%LangChain.Message.ToolResult{}(see those options andSagents.AgentResult){:interrupt, state, interrupt_data}- Execution paused for human approval{:pause, state}- Infrastructure pause;state.pause_reasoncarries the cause the mode attached (nil when it attached none){:error, reason}- Execution failed
Examples
state = State.new!(%{messages: [%{role: "user", content: "Hello"}]})
case Agent.execute(agent, state) do
{:ok, final_state} ->
# Normal completion
handle_response(final_state)
{:interrupt, interrupted_state, interrupt_data} ->
# Human approval needed
decisions = get_human_decisions(interrupt_data)
{:ok, final_state} = Agent.resume(agent, interrupted_state, decisions)
handle_response(final_state)
{:error, err} ->
# Handle error
Logger.error("Agent execution failed: #{inspect(err)}")
end
# With custom callbacks
callbacks = [
%{
on_llm_token_usage: fn _chain, usage ->
IO.inspect(usage, label: "tokens")
end
}
]
Agent.execute(agent, state, callbacks: callbacks)
Create a new Agent.
Attributes
:agent_id- Unique identifier for the agent (optional, auto-generated if not provided):model- LangChain ChatModel struct (required):base_system_prompt- Base system instructions:tools- Additional tools beyond middleware (default: []):middleware- List of middleware modules/configs (default: []):name- Agent name for identification (default: nil):filesystem_scope- Optional scope key for referencing an independently-running filesystem (e.g.,{:user, 123},{:project, 456}):scope- Integrator-defined scope struct (e.g.,%MyApp.Accounts.Scope{}). Opaque to Sagents — propagated as the first positional argument to persistence callbacks (AgentPersistence,DisplayMessagePersistence,FileSystemCallbacks) and auto-merged into tool-callcustom_contextunder the canonical:scopekey. Default:nil. Note: not serialized — scope is session/runtime state belonging to the caller starting the agent, not to persisted conversations. On restore, scope comes from the fresh Coordinator invocation that starts the agent.:tool_context- Map of caller-supplied data merged intoLLMChain.custom_contextso every tool function receives it as part of its second argument. Internal keys (:state,:parent_middleware,:parent_tools,:scope) always take precedence on collision. (default:%{}):otel_attributes- Flat map of OpenTelemetry span attributes describing this agent's context, e.g.%{"user.id" => user.id, "organization.id" => org.id}. Applied to every span the agent produces: theinvoke_agentchain span, eachchatspan, and eachexecute_toolspan, including tools running in their own process. Keys may be strings or atoms; namespace application-specific ones (myapp.*). Seeput_otel_attributes/2to add to them after construction. Not serialized. (default:%{}):async_tool_timeout- Timeout for parallel tool execution. Integer (milliseconds) or:infinity. Overrides application-level config. See LLMChain module docs for details. (default: uses application config or:infinity):fallback_models- List of ChatModel structs to try if primary model fails (default: []):before_fallback- Optional function to modify chain before each attempt (default: nil). Signature:fn chain -> modified_chain end. Useful for provider-specific system prompts or modifications:max_runs- Maximum number of LLM calls per execution (default: 50 for AgentExecution mode). Agents with many tools or complex middleware may need higher values. Can also be overridden per-invocation viaexecute/3opts:execute(agent, state, max_runs: 100).
Options
:replace_default_middleware- If true, use only provided middleware (default: false):todo_opts- Options for TodoList middleware:filesystem_opts- Options for Filesystem middleware:summarization_opts- Options for Summarization middleware (e.g.,[max_tokens_before_summary: 150_000, messages_to_keep: 8]):subagent_opts- Options for SubAgent middleware:interrupt_on- Map of tool names to interrupt configuration (default: nil)
Human-in-the-loop configuration
The :interrupt_on option enables human oversight for specific tools:
# Simple boolean configuration
interrupt_on: %{
"write_file" => true, # Require approval
"delete_file" => true,
"read_file" => false # No approval needed
}
# Advanced configuration
interrupt_on: %{
"write_file" => %{
allowed_decisions: [:approve, :edit, :reject]
}
}Examples
# Basic agent
{:ok, agent} = Agent.new(%{
agent_id: "basic-agent",
model: ChatAnthropic.new!(%{model: "claude-sonnet-4-6"}),
base_system_prompt: "You are helpful."
})
# With custom tools
{:ok, agent} = Agent.new(%{
agent_id: "tool-agent",
model: model,
tools: [write_file_tool, search_tool]
})
# With human-in-the-loop for file operations
{:ok, agent} = Agent.new(
%{
agent_id: "hitl-agent",
model: model,
tools: [write_file_tool, delete_file_tool]
},
interrupt_on: %{
"write_file" => true, # Require approval for writes
"delete_file" => %{allowed_decisions: [:approve, :reject]} # No edit for deletes
}
)
# Execute and handle interrupts
case Agent.execute(agent, state) do
{:ok, final_state} ->
IO.puts("Agent completed successfully")
{:interrupt, interrupted_state, interrupt_data} ->
# Present interrupt_data.action_requests to user
# Get their decisions
decisions = UI.get_decisions(interrupt_data)
{:ok, final_state} = Agent.resume(agent, interrupted_state, decisions)
{:error, reason} ->
Logger.error("Agent failed: #{inspect(reason)}")
end
# With custom middleware configuration
{:ok, agent} = Agent.new(
%{
agent_id: "custom-middleware-agent",
model: model
},
filesystem_opts: [long_term_memory: true]
)
# With caller-supplied context for tool functions
{:ok, agent} = Agent.new(%{
agent_id: "context-agent",
model: model,
tool_context: %{user_id: 42, tenant: "acme"}
})
# Tool functions receive the context as their second argument:
# fn args, context ->
# context.user_id #=> 42
# context.tenant #=> "acme"
# context.state #=> %State{} (always present)
# end
Create a new Agent, raising on error.
Merge additional OpenTelemetry span attributes into the agent.
New values win on key collision. Use this when some of an agent's tracing context is only known after construction — a workspace resolved during setup, a feature flag read after the middleware stack was assembled.
The attributes reach every span the agent produces (the invoke_agent chain span,
each chat span, and each execute_tool span) by way of the :otel_attributes key
LangChain reserves in LLMChain.custom_context.
Examples
agent = Sagents.Agent.put_otel_attributes(agent, %{"myapp.workspace" => ws.id})
# Values that are not strings, numbers or booleans are JSON-encoded by
# LangChain rather than rejected.
agent = Sagents.Agent.put_otel_attributes(agent, %{"myapp.retries" => 3})
@spec resume(t(), Sagents.State.t(), any(), keyword()) :: execute_result()
Resume agent execution after an interrupt.
Cycles through the middleware stack, giving each middleware a chance to claim
and handle the interrupt via handle_resume/4. The first middleware that returns
{:ok, state} or {:interrupt, ...} or {:error, ...} wins. If no middleware
claims the interrupt, returns an error.
Parameters
agent- The agent instancestate- The state at the point of interruption (withinterrupt_dataset)resume_data- Data provided by the caller to resume (polymorphic per middleware)opts- Options (same asexecute/3, including:callbacks)
Examples
# HITL resume with decisions
{:interrupt, state, interrupt_data} = Agent.execute(agent, initial_state)
decisions = [%{type: :approve}, %{type: :reject}]
{:ok, final_state} = Agent.resume(agent, state, decisions)
# AskUserQuestion resume with response
{:interrupt, state, %{type: :ask_user_question}} = Agent.execute(agent, initial_state)
response = %{type: :answer, selected: ["PostgreSQL"]}
{:ok, final_state} = Agent.resume(agent, state, response)