Sagents.State (Sagents v0.8.0-rc.6)

Copy Markdown

Agent state structure for managing agent execution context.

The state holds the complete context for an agent execution including:

Note: Files are managed separately by FileSystemServer and are not part of the agent's internal state. The FileSystemServer provides persistent storage with ETS and optional backend persistence (disk, database, S3, etc.).

agent_id Management (Automatic)

The agent_id field is a runtime identifier used for process registration and coordination. You don't need to set it when creating states—the library automatically injects it when you call Sagents.Agent.execute/2, Sagents.Agent.resume/3, or start an AgentServer.

Why it's automatic

The agent_id flows from the Agent struct (which is configuration) to the State (which is data). Making you synchronize them manually could be error-prone.

For middleware developers

When middleware receives state in hooks (before_model, after_model), the agent_id will already be set. If you create new state structs in middleware, copy the agent_id from the incoming state:

def after_model(state, config) do
  updated_state = State.new!(%{
    agent_id: state.agent_id,  # Copy from incoming state
    messages: new_messages
  })
  {:ok, updated_state}
end

State Merging

State merging follows specific rules:

  • messages: Appends new messages to existing list
  • todos: Replaces with new todos (merge handled by TodoList middleware)
  • metadata: Deep merges metadata maps
  • agent_id: Uses right if present, otherwise left (runtime identifier, not data)

Summary

Functions

Add a message to the state.

Add multiple messages to the state.

Demote every is_interrupt: true tool result in the message log to an error result, signaling to the LLM that the user abandoned the pending interrupt and that a new user message follows.

Replace stale interrupt placeholder tool results with error messages, consulting the agent's middleware to decide which interrupts can be restored from cold start.

Remove a TODO by ID.

Deserializes state data from export_state/1.

Get a TODO by ID.

Get all TODOs with a specific status.

Load an agent's persisted state via an Sagents.AgentPersistence implementation, or return a fresh empty state if nothing is saved or the saved data is unusable.

Merge two states together.

Create a new agent state.

Create a new agent state, raising on error.

Set metadata value.

Add or update a TODO item.

Replace a tool result in the state's messages by tool_call_id.

Reset the state to a clean slate.

Replace all messages.

Replace all TODOs.

Types

t()

@type t() :: %Sagents.State{
  agent_id: term(),
  interrupt_data: term(),
  messages: term(),
  metadata: term(),
  runtime: term(),
  todos: term()
}

Functions

add_message(state, message)

Add a message to the state.

Message must be a LangChain.Message struct.

add_messages(state, messages)

Add multiple messages to the state.

Messages must be LangChain.Message structs.

cancel_pending_interrupts(state)

@spec cancel_pending_interrupts(t()) :: t()

Demote every is_interrupt: true tool result in the message log to an error result, signaling to the LLM that the user abandoned the pending interrupt and that a new user message follows.

Used when a user sends a free-text message instead of resuming an interrupted tool call (e.g. ignoring an ask_user question and asking something different). Without this demotion the trailing interrupt placeholder would remain in the conversation, causing the next LLM call to be malformed (Anthropic rejects with "must end with a user message").

Also clears state.interrupt_data (the virtual field).

Idempotent: a state with no interrupts is unchanged.

clean_stale_interrupts(state, middleware_entries \\ [])

@spec clean_stale_interrupts(t(), [Sagents.MiddlewareEntry.t()]) :: t()

Replace stale interrupt placeholder tool results with error messages, consulting the agent's middleware to decide which interrupts can be restored from cold start.

Called after loading state from the database. For each interrupted tool result the decision is:

  • interrupt_data == nil (decode failed, never persisted, or written by older code) → demote to error result.
  • interrupt_data shape is :multiple_interrupts → demote unless every sub-interrupt is itself claimed by some middleware. Partial restore is a footgun: a resumed agent would dispatch responses that don't all have valid targets.
  • No middleware in middleware_entries says restorable_interrupt? for the data → demote.
  • Otherwise → keep is_interrupt: true and interrupt_data intact.

Demoted results carry a clear message so the LLM can see the call failed on the next turn and recover gracefully. Two messages are used to distinguish the failure modes (process-bound vs incompatible data) for debugging. Both are equivalent to the model.

An empty middleware_entries list demotes every interrupt — preserving pre-restorable behaviour for callers that don't have an agent in scope (e.g. from_serialized/2).

Idempotent: a state with no interrupted tool results is unchanged.

delete_todo(state, todo_id)

Remove a TODO by ID.

from_serialized(agent_id, data)

Deserializes state data from export_state/1.

This is a convenience wrapper around StateSerializer.deserialize_state/2.

Important: The agent_id is NOT serialized (it's a runtime identifier), so you MUST provide it when deserializing. This ensures the state can properly interact with AgentServer and middleware that rely on the agent_id.

Examples

# Load from database
{:ok, state_data} = load_from_db(conversation_id)

# Deserialize with agent_id
{:ok, state} = State.from_serialized("my-agent-123", state_data["state"])

Parameters

  • agent_id - The agent_id to use for this state (required)
  • data - The serialized state map (the "state" field from export_state)

Returns

  • {:ok, state} - Successfully deserialized with agent_id set
  • {:error, reason} - Deserialization failed

get_metadata(state, key, default \\ nil)

Get metadata value.

get_todo(state, todo_id)

Get a TODO by ID.

get_todos_by_status(state, status)

Get all TODOs with a specific status.

load_or_new(persistence_module, scope, context, opts \\ [])

@spec load_or_new(
  module(),
  term() | nil,
  %{:agent_id => String.t(), optional(:conversation_id) => term()},
  keyword()
) :: {:ok, t()}

Load an agent's persisted state via an Sagents.AgentPersistence implementation, or return a fresh empty state if nothing is saved or the saved data is unusable.

Always returns {:ok, state} so callers can pattern-match without fallback branches. Failure modes (missing envelope key, malformed serialized data) are logged at warning level and degrade gracefully to a fresh state — restoring a partial conversation is worse than starting fresh, since the user can re-prompt but cannot recover from a server that won't boot.

Parameters

  • persistence_module — module implementing Sagents.AgentPersistence
  • scope — integrator-defined scope struct (forwarded to load_state/2)
  • context — map with :agent_id (required) and :conversation_id (optional; useful for log lines and is included in the load context)
  • opts — optional keyword list:
    • :fresh_state_attrs — map seeded into new!/1 only when no persisted state is found (ignored on resume).

Examples

Sagents.State.load_or_new(MyApp.AgentPersistence, scope, %{
  agent_id: "conversation-123",
  conversation_id: 123
})
# => {:ok, %Sagents.State{...}}

Sagents.State.load_or_new(
  MyApp.AgentPersistence,
  scope,
  %{agent_id: "conversation-123", conversation_id: 123},
  fresh_state_attrs: %{todos: seed_todos}
)

merge_states(left, right)

Merge two states together.

This is used when combining state updates from tools, middleware, or subagents.

Merge Rules

  • messages: Concatenates lists (left + right)
  • todos: Uses right if present, otherwise left
  • metadata: Deep merges maps

Examples

left = State.new!(%{messages: [%{role: "user", content: "hi"}]})
right = State.new!(%{messages: [%{role: "assistant", content: "hello"}]})
merged = State.merge_states(left, right)
# merged now has both messages

new(attrs \\ %{})

Create a new agent state.

Note: The agent_id field is optional when creating a state. The library automatically injects it when the state is passed to Agent.execute or AgentServer. This eliminates the need for manual agent_id synchronization.

Examples

# Create state without agent_id (recommended)
state = State.new!(%{messages: [message]})

# Library injects agent_id automatically
{:ok, result_state} = Agent.execute(agent, state)

new!(attrs \\ %{})

Create a new agent state, raising on error.

put_metadata(state, key, value)

Set metadata value.

put_todo(state, todo)

Add or update a TODO item.

If a TODO with the same ID exists, it will be replaced at its current position. If the TODO ID doesn't exist, it will be appended to the end of the list.

replace_tool_result(state, tool_call_id, new_result)

Replace a tool result in the state's messages by tool_call_id.

Delegates to LangChain.Message.replace_tool_result/3.

reset(state)

@spec reset(t()) :: t()

Reset the state to a clean slate.

Clears:

  • All messages
  • All TODOs
  • All metadata

Note: This function only resets the Agent's state structure. File state is managed separately by FileSystemServer and must be reset through AgentServer.reset/1 which coordinates the full reset process.

Examples

state = State.new!(%{
  messages: [msg1, msg2],
  todos: [todo1],
  metadata: %{config: "value"}
})

reset_state = State.reset(state)
# reset_state has:
# - messages: []
# - todos: []
# - metadata: %{} (cleared)

set_messages(state, messages)

Replace all messages.

Useful for:

  • Thread restoration (restoring persisted messages)
  • Testing scenarios (setting sample messages)
  • Bulk message updates

Parameters

  • state - The current State struct
  • messages - List of Message structs

Examples

messages = [
  Message.new_user!("Hello")
]
state = State.set_messages(state, messages)

set_todos(state, todos)

Replace all TODOs.