How to Build Agentic Loop
View SourceStep-by-step guide to building autonomous agents with feedback loops.
Overview
An agentic loop allows the LLM to:
- Plan the next action based on current state
- Execute that action
- Update state with results
- Repeat until goal achieved or max iterations
Step 1: Define State and Action Types
Create baml_src/agent.baml:
enum AgentAction {
Search
Analyze
Complete
}
class AgentStep {
action AgentAction
reasoning string
query string?
final_answer string?
}
class AgentState {
goal string
steps_taken string[]
information_gathered string[]
iteration int
}
function PlanNextStep(state: AgentState) -> AgentStep {
client GPT4
prompt #"
You are a research agent. Goal: {{ state.goal }}
Current iteration: {{ state.iteration }}
Steps taken: {{ state.steps_taken }}
Information: {{ state.information_gathered }}
Decide next action:
- Search: Need more information
- Analyze: Process gathered information
- Complete: Have enough to answer
{{ ctx.output_format }}
"#
}Step 2: Generate Types
mix ash_baml.gen.types MyApp.BamlClient
Step 3: Create Agent Resource
defmodule MyApp.Agent do
use Ash.Resource,
domain: MyApp.Domain,
extensions: [AshBaml.Resource]
baml do
client :default
import_functions [:PlanNextStep]
end
actions do
action :run_agent, :map do
argument :goal, :string, allow_nil?: false
argument :max_iterations, :integer, default: 5
run MyApp.Agent.AgentLoop
end
end
endStep 4: Implement Agent Loop
defmodule MyApp.Agent.AgentLoop do
use Ash.Resource.Actions.Implementation
alias MyApp.BamlClient.Types.{AgentState, AgentStep}
@impl true
def run(input, _opts, _context) do
initial_state = %AgentState{
goal: input.arguments.goal,
steps_taken: [],
information_gathered: [],
iteration: 0
}
run_loop(initial_state, input.arguments.max_iterations)
end
defp run_loop(state, max_iterations) do
if state.iteration >= max_iterations do
{:ok, %{
status: :max_iterations,
iterations: state.iteration,
steps: state.steps_taken
}}
else
# Plan next step
case plan_step(state) do
{:ok, step} ->
# Execute step
case execute_step(step, state) do
{:ok, :complete, answer} ->
{:ok, %{
status: :completed,
answer: answer,
iterations: state.iteration + 1,
steps: state.steps_taken ++ [step.reasoning]
}}
{:ok, :continue, updated_state} ->
run_loop(updated_state, max_iterations)
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
end
defp plan_step(state) do
MyApp.Agent
|> Ash.ActionInput.for_action(:plan_next_step, %{state: state})
|> Ash.run_action()
end
defp execute_step(%AgentStep{action: :complete, final_answer: answer}, _state) do
{:ok, :complete, answer}
end
defp execute_step(%AgentStep{action: :search, query: query, reasoning: reasoning}, state) do
# Perform search
results = search(query)
updated_state = %AgentState{
state
| iteration: state.iteration + 1,
steps_taken: state.steps_taken ++ ["Search: #{reasoning}"],
information_gathered: state.information_gathered ++ [results]
}
{:ok, :continue, updated_state}
end
defp execute_step(%AgentStep{action: :analyze, reasoning: reasoning}, state) do
# Analyze gathered information
analysis = analyze(state.information_gathered, state.goal)
updated_state = %AgentState{
state
| iteration: state.iteration + 1,
steps_taken: state.steps_taken ++ ["Analyze: #{reasoning}"],
information_gathered: state.information_gathered ++ [analysis]
}
{:ok, :continue, updated_state}
end
# Placeholder functions - replace with real implementations
defp search(query), do: "Results for: #{query}"
defp analyze(info, goal), do: "Analysis of #{length(info)} items for #{goal}"
endStep 5: Use the Agent
{:ok, result} = MyApp.Agent
|> Ash.ActionInput.for_action(:run_agent, %{
goal: "What are the benefits of Elixir?",
max_iterations: 5
})
|> Ash.run_action()
IO.inspect(result)
# %{
# status: :completed,
# answer: "Elixir provides...",
# iterations: 3,
# steps: ["Search: ...", "Analyze: ...", "Search: ..."]
# }Adding Streaming Progress Updates
defmodule MyApp.Agent.AgentLoopStream do
use Ash.Resource.Actions.Implementation
@impl true
def run(input, _opts, _context) do
initial_state = %AgentState{
goal: input.arguments.goal,
steps_taken: [],
information_gathered: [],
iteration: 0
}
stream = Stream.resource(
fn -> initial_state end,
fn state ->
if state.iteration >= input.arguments.max_iterations do
{[%{type: :max_iterations, state: state}], :halt}
else
case run_iteration(state) do
{:ok, :complete, answer} ->
{[%{type: :complete, answer: answer, state: state}], :halt}
{:ok, :continue, step, updated_state} ->
{[%{type: :step, step: step, state: updated_state}], updated_state}
{:error, reason} ->
{[%{type: :error, reason: reason}], :halt}
end
end
end,
fn _ -> :ok end
)
{:ok, stream}
end
defp run_iteration(state) do
# Similar logic but returns per-iteration
# ...
end
endAdd streaming action:
action :run_agent_stream, AshBaml.Type.Stream do
argument :goal, :string
argument :max_iterations, :integer, default: 5
run MyApp.Agent.AgentLoopStream
endState Persistence
For long-running agents, persist state:
defmodule MyApp.PersistentAgent do
use Ash.Resource,
data_layer: AshPostgres.DataLayer
attributes do
uuid_primary_key :id
attribute :goal, :string
attribute :state, :map
attribute :status, :atom
attribute :result, :map
end
actions do
create :start do
accept [:goal]
change fn changeset, _context ->
Ash.Changeset.change_attribute(changeset, :state, %{
iteration: 0,
steps_taken: [],
information_gathered: []
})
|> Ash.Changeset.change_attribute(:status, :running)
end
end
action :continue, :map do
argument :agent_id, :uuid
run fn input, _ctx ->
agent = MyApp.PersistentAgent |> Ash.get!(input.arguments.agent_id)
case run_next_iteration(agent.state, agent.goal) do
{:ok, :complete, answer} ->
agent
|> Ash.Changeset.for_update(:update, %{
status: :completed,
result: %{answer: answer}
})
|> Ash.update!()
{:ok, %{status: :completed, answer: answer}}
{:ok, :continue, new_state} ->
agent
|> Ash.Changeset.for_update(:update, %{state: new_state})
|> Ash.update!()
{:ok, %{status: :running, state: new_state}}
end
end
end
end
endError Recovery
Add retry and fallback logic:
defp execute_step_with_retry(step, state, retries \\ 3) do
case execute_step(step, state) do
{:ok, result} ->
{:ok, result}
{:error, _reason} when retries > 0 ->
Process.sleep(1000)
execute_step_with_retry(step, state, retries - 1)
{:error, _reason} ->
# Fallback: try analysis instead
fallback_step = %AgentStep{
action: :analyze,
reasoning: "Original action failed, analyzing available data"
}
execute_step(fallback_step, state)
end
endTesting
defmodule MyApp.AgentTest do
use ExUnit.Case
test "agent completes within max iterations" do
{:ok, result} = MyApp.Agent
|> Ash.ActionInput.for_action(:run_agent, %{
goal: "Test goal",
max_iterations: 5
})
|> Ash.run_action()
assert result.status in [:completed, :max_iterations]
assert result.iterations <= 5
end
test "agent state progresses" do
# Mock plan_next_step to return predictable steps
expect(MyApp.BamlClientMock, :plan_next_step, fn %{state: state} ->
step = case state.iteration do
0 -> %AgentStep{action: :search, query: "test", reasoning: "Need info"}
1 -> %AgentStep{action: :analyze, reasoning: "Process results"}
_ -> %AgentStep{action: :complete, final_answer: "Done"}
end
{:ok, step}
end)
{:ok, result} = MyApp.Agent
|> Ash.ActionInput.for_action(:run_agent, %{goal: "Test"})
|> Ash.run_action()
assert result.status == :completed
assert length(result.steps) == 3
end
endNext Steps
- Tutorial: Building an Agent - Complete tutorial
- Topic: Patterns - Agent patterns
- How to: Customize Actions - Advanced customization
Related
- Tutorial: Building an Agent - Full walkthrough
- Topic: Actions - Custom action implementations