Packages

fnord

0.9.38

AI code archaeology

Current section

Files

Jump to
fnord lib ai agent composite.ex
Raw

lib/ai/agent/composite.ex

defmodule AI.Agent.Composite do
@moduledoc """
Behaviour and execution engine for composite agents - agents that orchestrate
work across multiple completion turns, optionally with tool use, structured
output, and sub-agent delegation.
## Steps as state
A composite agent's work is defined as a dequeue of steps. Each step is either
a **completion** (a turn in this agent's conversation) or a **delegation**
(spawning a sub-agent). Steps can be grouped in a list for parallel execution.
Example step queues:
# Reviewer: fixed pipeline with parallel specialist fan-out
[formulate, [pedantic, acceptance, state_flow], incorporate]
# Coder planner: fixed sequential pipeline
[research, visualize, plan]
# Coder orchestrator: dynamic - validate can push tasks back
[task_1, task_2, task_3, validate]
## Step types
A `completion` step runs a prompt against this agent's conversation, accumulating
the response into the message history:
AI.Agent.Composite.completion(:research, "Investigate the code...",
response_format: %{...}, keep_prompt?: false)
A `delegate` step spawns a sub-agent. The sub-agent runs its own independent
conversation; its response is injected into the parent's message history as a
user message with a header identifying the source:
AI.Agent.Composite.delegate(:pedantic, AI.Agent.Review.Pedantic,
fn state -> %{prompt: ..., scope: state.request} end)
## Parallel execution
When a step in the queue is a list, all steps in that list run concurrently.
Results are collected and injected into the conversation in list order before
the next sequential step begins.
## Lifecycle
The execution engine calls implementation callbacks at each stage:
1. `init/1` - Build the initial state and step queue.
2. Pop the next item from the step queue.
3. `on_step_start/2` - Pre-execution hook (logging, UI).
4. Execute the step (completion or delegation).
5. `on_step_complete/2` - Post-execution hook (parse response, update state).
6. `get_next_steps/2` - Return steps to prepend to the queue, enabling
dynamic control flow (retry, task generation, validation loops).
7. Go to 2.
"""
# ---------------------------------------------------------------------------
# Step types
# ---------------------------------------------------------------------------
@type step_name :: atom
@type completion_step :: %{
type: :completion,
name: step_name,
prompt: binary,
model: AI.Model.t() | nil,
toolbox: AI.Tools.toolbox() | nil,
response_format: map | nil,
keep_prompt?: boolean
}
@type delegate_step :: %{
type: :delegate,
name: step_name,
agent: module,
args_builder: (t -> map)
}
@type step :: completion_step | delegate_step
@type step_queue :: [step | [step]]
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
defstruct [
:agent,
:model,
:toolbox,
:request,
:response,
:error,
:messages,
:internal,
:steps
]
@type t :: %__MODULE__{
agent: AI.Agent.t(),
model: AI.Model.t(),
toolbox: AI.Tools.toolbox(),
request: binary,
response: binary | nil,
error: any,
messages: AI.Util.msg_list(),
internal: map,
steps: step_queue
}
# ---------------------------------------------------------------------------
# Behaviour callbacks
# ---------------------------------------------------------------------------
@doc """
Initialize the composite agent from the caller-provided args map (which
includes `:agent` injected by `AI.Agent.get_response/2`). Must return a
fully populated `%AI.Agent.Composite{}` with the initial step queue.
"""
@callback init(args :: map) :: {:ok, t} | {:error, any}
@doc """
Called immediately before a step executes. Typically used for UI reporting
(`UI.report_from/2`). Must return the (possibly modified) state.
"""
@callback on_step_start(step :: step, state :: t) :: t
@doc """
Called after a step completes successfully. The step's response is in
`state.response` and has been appended to `state.messages`. Use this to
parse structured output and update `state.internal`.
Must return the updated state.
"""
@callback on_step_complete(step :: step, state :: t) :: t
@doc """
Called after `on_step_complete/2`. Returns a list of steps to prepend to the
front of the queue. Return `[]` to continue with the existing queue.
This is the primary mechanism for dynamic control flow:
- Retry: return `[the_same_step]`
- Task generation: return `[task_1, task_2, ..., validate]`
- Conditional branching: inspect `state.internal` and return different steps
For the reviewer, this always returns `[]` since the pipeline is fixed.
For the coder, the plan step returns task steps, and the validate step can
return more task steps on failure.
"""
@callback get_next_steps(step :: step, state :: t) :: [step | [step]]
@doc """
Called when a step fails. `state.error` contains the error. Return one of:
- `{:retry, state}` - re-execute the same step
- `{:skip, state}` - clear the error and continue to the next step
- `{:halt, state}` - stop execution with the error
"""
@callback on_error(step :: step, error :: any, state :: t) ::
{:retry, t} | {:skip, t} | {:halt, t}
# ---------------------------------------------------------------------------
# Step constructors
# ---------------------------------------------------------------------------
@doc """
Creates a completion step - a turn in this agent's conversation.
Options:
- `:model` - override the agent's default model for this step
- `:toolbox` - override the agent's default toolbox for this step
- `:response_format` - JSON schema to constrain output
- `:keep_prompt?` - if true, the prompt remains in message history (default false)
"""
@spec completion(step_name, binary, keyword) :: completion_step
def completion(name, prompt, opts \\ []) do
%{
type: :completion,
name: name,
prompt: prompt,
model: Keyword.get(opts, :model),
toolbox: Keyword.get(opts, :toolbox),
response_format: Keyword.get(opts, :response_format),
keep_prompt?: Keyword.get(opts, :keep_prompt?, false)
}
end
@doc """
Creates a delegate step - spawns a sub-agent and injects its response into
the parent conversation. The `args_builder` function receives the current
state and must return the args map passed to the sub-agent's `get_response/1`.
"""
@spec delegate(step_name, module, (t -> map)) :: delegate_step
def delegate(name, agent_module, args_builder) do
%{
type: :delegate,
name: name,
agent: agent_module,
args_builder: args_builder
}
end
# ---------------------------------------------------------------------------
# State accessors
# ---------------------------------------------------------------------------
@doc """
Sets a value in the `internal` map. `key` may be a single atom or a list of
atoms (nested path per `put_in/3` semantics). When passing a list, all
intermediate keys must already exist.
"""
@spec put_state(state :: t, key :: atom | list, value :: any) :: t
def put_state(state, key, value) when is_atom(key) do
%{state | internal: Map.put(state.internal, key, value)}
end
def put_state(state, keys, value) when is_list(keys) do
%{state | internal: put_in(state.internal, keys, value)}
end
@doc """
Retrieves a value from the `internal` map. `key` may be a single atom or a
list of atoms (nested path per `get_in/2` semantics). Returns
`{:error, :not_found}` when any key in the path is missing.
"""
@spec get_state(state :: t, key :: atom | list) :: {:ok, any} | {:error, :not_found}
def get_state(state, key) when is_atom(key) do
case Map.fetch(state.internal, key) do
{:ok, value} -> {:ok, value}
:error -> {:error, :not_found}
end
end
def get_state(state, keys) when is_list(keys) do
case get_in(state.internal, keys) do
nil -> {:error, :not_found}
value -> {:ok, value}
end
end
# ---------------------------------------------------------------------------
# Step queue manipulation - available for use in callbacks
# ---------------------------------------------------------------------------
@doc "Prepend steps to the front of the queue (next to execute)."
@spec push_steps(t, [step | [step]]) :: t
def push_steps(state, new_steps) do
%{state | steps: new_steps ++ state.steps}
end
@doc "Append steps to the end of the queue."
@spec append_steps(t, [step | [step]]) :: t
def append_steps(state, new_steps) do
%{state | steps: state.steps ++ new_steps}
end
# ---------------------------------------------------------------------------
# Execution engine
# ---------------------------------------------------------------------------
@doc """
Runs the composite agent to completion. Calls `init/1` on the implementation
module, then processes steps from the queue until it's empty or an
unrecoverable error occurs.
Returns `{:ok, final_response}` or `{:error, reason}`.
"""
@spec run(module, map) :: {:ok, binary} | {:error, any}
def run(impl, args) do
case impl.init(args) do
{:ok, state} -> execute_loop(impl, state)
{:error, _} = error -> error
end
end
defp execute_loop(_impl, %{steps: []} = state) do
{:ok, state.response}
end
defp execute_loop(impl, %{steps: [next | rest]} = state) do
state = %{state | steps: rest}
execute_step(impl, state, next)
end
# ---------------------------------------------------------------------------
# Parallel group - a list of steps to run concurrently
# ---------------------------------------------------------------------------
defp execute_step(impl, state, steps) when is_list(steps) do
state = Enum.reduce(steps, state, fn step, acc -> impl.on_step_start(step, acc) end)
parent_pool = HttpPool.get()
tasks =
Enum.map(steps, fn step ->
Services.Globals.Spawn.async(fn ->
HttpPool.set(parent_pool)
run_single_step(state, step)
end)
end)
results =
try do
Task.await_many(tasks, :infinity)
rescue
e ->
# If any parallel task crashes, map all results to errors so the
# on_error callback gets a chance to handle it gracefully.
Enum.map(steps, fn _ -> {:error, Exception.message(e)} end)
end
# Collect results and inject into the conversation. Each parallel step's
# response becomes a user message with a header so the agent can identify
# which specialist produced it. Next-steps from all parallel results are
# collected and applied after the full reduction to avoid interleaving.
{state, errors, pending_next_steps} =
Enum.zip(steps, results)
|> Enum.reduce({state, [], []}, fn
{step, {:ok, response, messages}}, {acc, errs, nexts} ->
# Completion step in parallel - append only messages beyond what was
# in the shared state before this step ran, so parallel completions
# don't clobber each other. Length-based slicing avoids the fragility
# of list subtraction on structurally similar messages.
new_msgs = Enum.drop(messages, length(acc.messages))
acc = %{acc | messages: acc.messages ++ new_msgs, response: response}
acc = impl.on_step_complete(step, acc)
case acc.error do
nil ->
next = impl.get_next_steps(step, acc)
{acc, errs, nexts ++ next}
reason ->
{acc, [{step, reason} | errs], nexts}
end
{step, {:ok, response}}, {acc, errs, nexts} ->
# Delegate step - inject response as a labeled user message
label = step_label(step)
msg = AI.Util.user_msg("## #{label}\n\n#{response}")
acc = %{acc | messages: acc.messages ++ [msg], response: response}
acc = impl.on_step_complete(step, acc)
case acc.error do
nil ->
next = impl.get_next_steps(step, acc)
{acc, errs, nexts ++ next}
reason ->
{acc, [{step, reason} | errs], nexts}
end
{step, {:error, reason}}, {acc, errs, nexts} ->
label = step_label(step)
msg = AI.Util.user_msg("## #{label}\n\n**FAILED**: #{inspect(reason)}")
acc = %{acc | messages: acc.messages ++ [msg]}
{acc, [{step, reason} | errs], nexts}
end)
# Apply all collected next-steps at once after reduction
state = push_steps(state, pending_next_steps)
case errors do
[] ->
execute_loop(impl, state)
[{step, reason} | _] ->
state = %{state | error: reason}
case impl.on_error(step, reason, state) do
{:retry, state} -> execute_step(impl, state, steps)
{:skip, state} -> execute_loop(impl, %{state | error: nil})
{:halt, state} -> {:error, state.error}
end
end
end
# ---------------------------------------------------------------------------
# Single sequential step
# ---------------------------------------------------------------------------
defp execute_step(impl, state, step) do
state = impl.on_step_start(step, state)
case run_single_step(state, step) do
{:ok, response, messages} ->
state = %{state | response: response, messages: messages, error: nil}
state = impl.on_step_complete(step, state)
case state.error do
nil ->
next = impl.get_next_steps(step, state)
state = push_steps(state, next)
execute_loop(impl, state)
reason ->
case impl.on_error(step, reason, state) do
{:retry, state} -> execute_step(impl, state, step)
{:skip, state} -> execute_loop(impl, %{state | error: nil})
{:halt, state} -> {:error, state.error}
end
end
{:ok, response} ->
state = %{state | response: response, error: nil}
state = impl.on_step_complete(step, state)
case state.error do
nil ->
next = impl.get_next_steps(step, state)
state = push_steps(state, next)
execute_loop(impl, state)
reason ->
case impl.on_error(step, reason, state) do
{:retry, state} -> execute_step(impl, state, step)
{:skip, state} -> execute_loop(impl, %{state | error: nil})
{:halt, state} -> {:error, state.error}
end
end
{:error, reason} ->
state = %{state | error: reason}
case impl.on_error(step, reason, state) do
{:retry, state} -> execute_step(impl, state, step)
{:skip, state} -> execute_loop(impl, %{state | error: nil})
{:halt, state} -> {:error, state.error}
end
end
end
# ---------------------------------------------------------------------------
# Step execution primitives
# ---------------------------------------------------------------------------
# Completion step - a turn in this agent's conversation. Runs the prompt,
# manages message history (prompt injection/removal), and returns the
# response along with the updated message list (which includes any tool
# call messages generated during the completion).
defp run_single_step(state, %{type: :completion} = step) do
model = step.model || state.model
toolbox = step.toolbox || state.toolbox
state.agent
|> AI.Agent.get_completion(
model: model,
toolbox: toolbox,
messages: state.messages ++ [AI.Util.system_msg(step.prompt)],
response_format: step.response_format,
log_tool_calls: true
)
|> case do
{:ok, %{response: response, messages: messages}} ->
messages =
if step.keep_prompt? do
messages
else
Enum.reject(messages, fn msg ->
Map.get(msg, :role) == "system" and Map.get(msg, :content, "") == step.prompt
end)
end
|> Enum.concat([AI.Util.assistant_msg(response)])
{:ok, response, messages}
{:error, %{response: response}} ->
{:error, response}
{:error, reason} ->
{:error, reason}
end
end
# Delegate step - spawn a sub-agent with its own conversation.
defp run_single_step(state, %{type: :delegate} = step) do
args = step.args_builder.(state)
step.agent
|> AI.Agent.new()
|> AI.Agent.get_response(args)
|> case do
{:ok, response} -> {:ok, response}
{:error, reason} -> {:error, reason}
end
end
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
defp step_label(%{name: name}) do
name
|> Atom.to_string()
|> String.replace("_", " ")
|> String.split()
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
end
end