Current section
Files
Jump to
Current section
Files
lib/agent_executor.ex
defmodule Magus.AgentExecutor do
@doc """
The AgentExecutor is a GenServer for executing an agent and managing/storing its
state. This includes agent state as well as state about the execution (current node, status, errors, etc)
For each node in the agent graph, a new `Task` will be started to run the function for the current node.
If the node function succeeds, the current agent state will be updated and the executor
traverses the agent's graph to find the next node to execute.
If the node function fails, it is retrieved 3 times with an expotential backoff.
Once created, the AgentExecutor process can be subscribed to in order to get to state and log updates.
"""
use GenServer
use Retry
alias LangChain.MessageDelta
alias Magus.AgentExecutor
alias Magus.AgentExecutor.Step
alias Magus.GraphAgent
alias Magus.AgentChain
defstruct [
:id,
:agent,
:cur_node,
:cur_agent_state,
:created_at,
:pid,
:error_message,
steps: [],
status: :notstarted
]
@type t() :: %AgentExecutor{}
@doc """
Create a new AgentExecutor process responsible for running an agent.
## Options
- `:agent` - The `Magus.GraphAgent` definition of the agent for the executor.
- `:id` - A string to use for the id of the execution.
"""
@spec new(agent: GraphAgent.t(), id: String.t()) :: {:ok, pid()} | {:error, any()}
def new(opts) do
GenServer.start(__MODULE__, opts)
end
@doc """
Returns the execution state of the given AgentExecutor process
"""
@spec get_state(atom() | pid() | {atom(), any()} | {:via, atom(), any()}) :: any()
def get_state(pid) do
GenServer.call(pid, :get_state)
end
@doc """
Starts the agent execution for the given AgentExecutor process.
"""
@spec run(atom() | pid() | {atom(), any()} | {:via, atom(), any()}) :: :ok
def run(pid) do
GenServer.cast(pid, :start)
:ok
end
@doc """
Subscribe to new agents being started.
"""
@spec subscribe_to_new_agents() :: :ok | {:error, {:already_registered, pid()}}
def subscribe_to_new_agents() do
Phoenix.PubSub.subscribe(Magus.PubSub, "agent_starting")
end
@doc """
Subscribe to agent execution state changes.
"""
@spec subscribe_to_state(atom() | pid() | {atom(), any()} | {:via, atom(), any()}) ::
:ok | {:error, {:already_registered, pid()}}
def subscribe_to_state(pid) do
%{id: id} = AgentExecutor.get_state(pid)
Phoenix.PubSub.subscribe(Magus.PubSub, get_agent_state_topic(id))
end
@doc """
Subscribe to logs coming from the agent execution. This includes info about steps and
the streamed content from the LLM.
"""
@spec subscribe_to_logs(atom() | pid() | {atom(), any()} | {:via, atom(), any()}) ::
:ok | {:error, {:already_registered, pid()}}
def subscribe_to_logs(pid) do
%{id: id} = AgentExecutor.get_state(pid)
Phoenix.PubSub.subscribe(Magus.PubSub, get_agent_log_topic(id))
end
@impl true
@spec init(keyword()) :: {:ok, t()}
def init(opts) do
# TODO: autogenerate id if one is not provided
{:ok,
%AgentExecutor{
id: Keyword.fetch!(opts, :id),
agent: Keyword.fetch!(opts, :agent),
status: :notstarted,
cur_node: nil,
cur_agent_state: nil,
steps: [],
created_at: DateTime.now!("Etc/UTC"),
pid: self(),
error_message: nil
}}
end
@impl true
def handle_call(:get_state, _reply, state) do
{:reply, state, state}
end
@impl true
def handle_cast(:start, %{agent: agent} = state) do
cur_node = agent.entry_point_node
state = %AgentExecutor{
state
| cur_node: cur_node,
status: :running,
cur_agent_state: agent.initial_state
}
Phoenix.PubSub.broadcast!(Magus.PubSub, "agent_starting", {:agent_starting, state.id})
# TODO: Check if the end of the graph is reachable
# Throw error if not
GenServer.cast(self(), :step)
{:noreply, state}
end
@impl true
def handle_cast(
:step,
%{agent: agent, cur_node: cur_node, cur_agent_state: cur_agent_state, steps: steps} =
state
) do
step = %Step{
node: cur_node,
status: :notstarted,
input_state: cur_agent_state
}
cur_node_fn = agent.node_to_fn[cur_node]
# Execute function for current node
pid = self()
notify_with_log(state.id, "\n--- BEGINNING STEP #{cur_node} ---\n")
Task.start_link(fn ->
log_handler = %{
on_llm_new_delta: fn _model, %MessageDelta{} = data ->
# We receive a piece of data
AgentExecutor.notify_with_log(state.id, data.content)
end
}
chain = AgentChain.new!(stream_handler: log_handler)
# Retry up to 3 times if we get an error
# TODO: Make this more configurable
# and be more thoughtful about how we want to handle errors
# thrown from nodes
retry with: exponential_backoff() |> cap(1_000) |> Stream.take(3),
rescue_only: [MatchError] do
cur_node_fn.(chain, cur_agent_state)
after
new_state -> GenServer.cast(pid, {:step_done, new_state})
else
error -> GenServer.cast(pid, {:error, error})
end
end)
# Update the state with the step and notify subscribers
step = %Step{step | pid: pid, status: :running}
steps = steps ++ [step]
state = %AgentExecutor{state | steps: steps}
notify_with_state(state.id, state)
{:noreply, state}
end
def handle_cast(
{:step_done, new_agent_state},
%{agent: agent, cur_node: cur_node, steps: steps} = state
) do
# Set the new agent state
state = %AgentExecutor{state | cur_agent_state: new_agent_state}
# Find next nodes to go to
neighbors = agent.graph |> Graph.out_neighbors(cur_node)
next_node =
if length(neighbors) > 1 do
# We need to call a conditional function to figure out what node to move to
conditional_fn = agent.node_to_conditional_fn[cur_node]
conditional_fn.(new_agent_state)
else
neighbors |> Enum.at(0)
end
if !Enum.member?(neighbors, next_node) do
raise "Next node is not a possible edge -- check the agent definition"
end
# Gets the latest state (assuming that's what we're workin on)
# and sets the output state
last_step =
steps
|> List.last()
|> Map.put(:status, :done)
|> Map.put(:output_state, new_agent_state)
steps = steps |> List.replace_at(-1, last_step)
state = %AgentExecutor{state | steps: steps}
notify_with_state(state.id, state)
notify_with_log(state.id, "\n--- FINISHED STEP #{cur_node} ---\n")
# If we're not done, continue to step through the graph
status =
if next_node != :end do
GenServer.cast(self(), :step)
:running
else
# Clean up the agent
GraphAgent.cleanup(state.agent, state.cur_agent_state)
# We're done
:done
end
# TODO: have an update state func that automatically notifies subscribers
state = %AgentExecutor{
state
| agent: agent,
cur_node: next_node,
steps: steps,
status: status
}
notify_with_state(state.id, state)
{:noreply, state}
end
def handle_cast({:error, msg}, state) do
# Gets the latest state (assuming that's what we're workin on)
last_step =
state.steps
|> List.last()
|> Map.put(:status, :failed)
msg_string = inspect(msg)
steps = state.steps |> List.replace_at(-1, last_step)
state = %{state | steps: steps, error_message: msg_string, status: :failed}
# Clean up the agent
GraphAgent.cleanup(state.agent, state.cur_agent_state)
notify_with_state(state.id, state)
notify_with_log(state.id, "#{msg_string}\n")
notify_with_log(state.id, "\n--- STEP FAILED #{state.cur_node} ---\n")
{:noreply, state}
end
defp notify_with_state(id, state) do
Phoenix.PubSub.broadcast!(
Magus.PubSub,
get_agent_state_topic(id),
{:agent_state, id, state}
)
end
def notify_with_log(id, msg) do
Phoenix.PubSub.broadcast!(Magus.PubSub, get_agent_log_topic(id), {:agent_log, id, msg})
end
def get_agent_state_topic(id) do
"agent:#{id}"
end
def get_agent_log_topic(id) do
"agent_log:#{id}"
end
end