Autonomous self-chaining research agent -- one GenAgent that
drives itself through sub-question generation, per-question
answering, and final synthesis without any further input from the
manager.
When to reach for this
The work can be decomposed into a fixed sequence of phases, each
phase is a separate LLM call, and the output of one phase is the
input to the next. You want to kick it off and walk away. A
manager polling status/1 can observe progress but does not need
to drive the agent turn-by-turn.
This is the smallest and most idiomatic use of gen_agent's self-chaining primitive. If you can express your work as a linear state machine where each state produces the prompt for the next state, this is the shape.
What it exercises in gen_agent
- Self-chaining via
{:prompt, text, state}fromhandle_response/3-- the whole pattern hinges on this return shape. - Phase-based
handle_responsedispatch -- one clause per phase, each transitioning to the next phase or halting. handle_error/3to halt gracefully on any failure with the reason attached to state, so a manager pollingstatus/1seesphase: :failedwith context rather than a dead process.{:halt, state}as the terminal transition from the final synthesis phase.
The pattern
One callback module. No manager-facing facade needed -- the
manager just calls GenAgent.start_agent/2 and GenAgent.status/1
directly.
defmodule Research.Agent do
@moduledoc """
Autonomous research GenAgent.
Phases:
1. :listing -- model lists N sub-questions about the topic
2. :answering -- agent feeds each sub-question back one turn
at a time, accumulating answers
3. :synthesizing -- final turn asking the model to pull
everything into a report
4. :done -- halt; final report is on state
"""
use GenAgent
defmodule State do
defstruct [
:topic,
:max_sub_questions,
:last_error,
phase: :listing,
sub_questions: [],
answered: [],
final_report: nil,
turns: 0
]
end
@impl true
def init_agent(opts) do
state = %State{
topic: Keyword.fetch!(opts, :topic),
max_sub_questions: Keyword.get(opts, :max_sub_questions, 3)
}
backend_opts = [
system: system_prompt(),
max_tokens: Keyword.get(opts, :max_tokens, 512)
]
{:ok, backend_opts, state}
end
# Phase 1 -> Phase 2: parse questions, dispatch first answer.
@impl true
def handle_response(_ref, response, %State{phase: :listing} = state) do
questions =
response.text
|> parse_questions()
|> Enum.take(state.max_sub_questions)
new_state = %{state | sub_questions: questions, phase: :answering, turns: state.turns + 1}
case questions do
[] ->
{:halt, %{new_state | phase: :failed, last_error: :no_questions}}
[first | _] ->
{:prompt, answer_prompt(first), new_state}
end
end
# Phase 2 loop: answer each question in turn, then transition to synthesis.
def handle_response(_ref, response, %State{phase: :answering} = state) do
answered_count = length(state.answered)
current_question = Enum.at(state.sub_questions, answered_count)
answered = state.answered ++ [{current_question, String.trim(response.text)}]
new_state = %{state | answered: answered, turns: state.turns + 1}
if length(answered) < length(state.sub_questions) do
next_question = Enum.at(state.sub_questions, length(answered))
{:prompt, answer_prompt(next_question), new_state}
else
{:prompt, synthesis_prompt(new_state), %{new_state | phase: :synthesizing}}
end
end
# Phase 3 -> terminal halt with the synthesized report on state.
def handle_response(_ref, response, %State{phase: :synthesizing} = state) do
{:halt,
%{
state
| final_report: String.trim(response.text),
phase: :done,
turns: state.turns + 1
}}
end
# A resumed terminal agent preserves its report and halts again.
def handle_response(_ref, _response, %State{phase: phase} = state)
when phase in [:done, :failed], do: {:halt, state}
# Errors after resume preserve the terminal phase too.
@impl true
def handle_error(_ref, reason, %State{phase: phase} = state)
when phase in [:done, :failed] do
{:halt, %{state | last_error: reason}}
end
def handle_error(_ref, reason, %State{} = state) do
{:halt, %{state | last_error: reason, phase: :failed}}
end
# --- Prompts & parsing ---
defp answer_prompt(question) do
"Answer this sub-question in 2-3 concise sentences: #{question}"
end
defp synthesis_prompt(%State{} = state) do
answers =
state.answered
|> Enum.map_join("\n\n", fn {q, a} -> "Q: #{q}\nA: #{a}" end)
"""
You have now answered all the sub-questions for the topic:
#{state.topic}.
Here are your sub-questions and answers:
#{answers}
Synthesize these findings into a concise 3-paragraph report.
"""
end
defp system_prompt do
"""
You are a concise research assistant.
When asked to list sub-questions: output them one per line,
plain text questions ending in ?, no preamble, numbering or markup.
When asked to answer a sub-question: 2-3 short sentences, no
preamble.
When asked to synthesize a report: exactly 3 paragraphs
separated by blank lines, no headings.
"""
end
defp parse_questions(text) do
text
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.replace(&1, ~r/^\d+[.)]\s+/, ""))
|> Enum.filter(&String.ends_with?(&1, "?"))
end
# Bound both polling and each status call; halted processes retain final state.
def await_completion(name, timeout \\ 30_000) do
wait([name], System.monotonic_time(:millisecond) + timeout)
end
defp wait(names, deadline) do
remaining = deadline - System.monotonic_time(:millisecond)
if remaining <= 0 do
{:error, :timeout}
else
try do
statuses =
Enum.map(names, fn name ->
call_timeout = deadline - System.monotonic_time(:millisecond)
if call_timeout > 0,
do: GenAgent.status(name, call_timeout),
else: {:error, :timeout}
end)
case Enum.find(statuses, &match?({:error, _}, &1)) do
{:error, reason} ->
{:error, reason}
nil ->
if Enum.all?(statuses, & &1.halted) do
states = Enum.map(statuses, & &1.agent_state)
case Enum.find(states, fn state -> state.phase == :failed end) do
nil -> {:ok, hd(states)}
state -> {:error, state.last_error}
end
else
Process.sleep(min(10, max(0, deadline - System.monotonic_time(:millisecond))))
wait(names, deadline)
end
end
catch
:exit, {:timeout, _} -> {:error, :timeout}
:exit, reason -> {:error, {:status_unavailable, reason}}
end
end
end
endResuming a terminal agent and sending another ask/tell preserves its report and terminal phase, then halts again.
Questions must occupy one line each and end in ?. The parser strips numeric
prefixes such as 1. and 2), and ignores blank lines and preambles without a
trailing question mark before applying the question limit. A preamble ending
in ? counts as a question; wrapped questions and other list markup are outside
this simple format. A listing with no valid questions fails with :no_questions.
Using it
# Start the agent. It self-drives from here.
{:ok, _pid} = GenAgent.start_agent(Research.Agent,
name: "octopus-research",
backend: GenAgent.Backends.Anthropic,
topic: "why do octopuses have three hearts?",
max_sub_questions: 3
)
# The first turn needs a kick.
{:ok, _ref} = GenAgent.tell("octopus-research",
"List 3 sub-questions about: why do octopuses have three hearts?")
try do
case Research.Agent.await_completion("octopus-research", 30_000) do
{:ok, %{final_report: report}} -> IO.inspect(report, label: "report")
{:error, reason} -> IO.inspect(reason, label: "research failed or timed out")
end
after
GenAgent.stop("octopus-research")
endVariations
- Dynamic sub-question count. Instead of fixing
max_sub_questions, let the listing turn output as many as it wants and take all of them. - Parallel answering. Replace the sequential answer loop with
a
Supervisor-shaped fan-out: spawn one worker agent per sub-question, collect answers via notify. See the Supervisor pattern. - Per-phase model selection. Different phases might deserve different models -- cheap model for listing, expensive model for synthesis. Swap sessions mid-run, or have the backend take per-call overrides.
- Mid-run inspection. Because phase lives on state, a manager
can inspect partial results via
GenAgent.status/1at any point. Useful for long-running research where you want to bail early if answers aren't looking good.