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: :done}}
[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
# Any error at any phase halts with the reason visible on state.
@impl true
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, no 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.reject(&(&1 == ""))
end
endUsing 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?")
# Poll progress any time.
GenAgent.status("octopus-research")
# => %{agent_state: %Research.Agent.State{phase: :answering, ...}, ...}
# Wait for :done (in practice, a small poll loop in the manager).
# Then read the final report:
%{agent_state: %{final_report: report}} = GenAgent.status("octopus-research")
IO.puts(report)
GenAgent.stop("octopus-research")Variations
- 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.