Packages
fnord
0.9.34
0.9.40
0.9.39
0.9.38
0.9.37
0.9.36
0.9.35
0.9.34
0.9.33
0.9.32
0.9.31
0.9.30
0.9.29
0.9.28
0.9.27
0.9.26
0.9.25
0.9.24
0.9.23
0.9.22
0.9.21
0.9.20
0.9.19
0.9.18
0.9.17
0.9.16
0.9.15
0.9.14
0.9.13
0.9.12
0.9.11
0.9.10
0.9.9
0.9.8
0.9.7
0.9.6
0.9.5
0.9.4
0.9.3
0.9.2
0.9.1
0.9.0
0.8.99
0.8.98
0.8.97
0.8.96
0.8.95
0.8.94
0.8.93
0.8.92
0.8.91
0.8.90
0.8.89
0.8.88
0.8.87
0.8.86
0.8.85
0.8.84
0.8.83
0.8.82
0.8.81
0.8.80
0.8.79
0.8.78
0.8.77
0.8.76
0.8.75
0.8.74
0.8.73
0.8.72
0.8.71
0.8.70
0.8.69
0.8.68
0.8.67
0.8.66
0.8.65
0.8.64
0.8.63
0.8.62
0.8.61
0.8.60
0.8.59
0.8.58
0.8.57
0.8.56
0.8.55
0.8.54
0.8.53
0.8.52
0.8.51
0.8.50
0.8.49
0.8.48
0.8.47
0.8.46
0.8.45
0.8.44
0.8.43
0.8.42
0.8.41
0.8.40
0.8.39
0.8.38
0.8.37
0.8.36
0.8.35
0.8.34
0.8.33
0.8.32
0.8.31
0.8.30
0.8.29
0.8.27
0.8.26
0.8.25
0.8.24
0.8.23
0.8.22
0.8.21
0.8.20
0.8.19
0.8.18
0.8.17
0.8.16
0.8.15
0.8.14
0.8.13
0.8.12
0.8.11
0.8.1
0.8.0
0.7.24
0.7.23
0.7.22
0.7.21
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.3
0.7.2
0.7.1
0.7.0
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.1
0.6.0
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.44
0.4.43
0.4.42
0.4.41
0.4.40
0.4.39
0.4.38
0.4.37
0.4.36
0.4.35
0.4.34
0.4.33
0.4.32
0.4.30
0.4.29
0.4.28
0.4.27
0.4.26
0.4.25
0.4.24
0.4.23
0.4.22
0.4.21
0.4.20
0.4.19
0.4.18
0.4.17
0.4.16
0.4.15
0.4.14
0.4.13
0.4.12
0.4.11
0.4.10
0.4.9
0.4.8
0.4.7
0.4.6
0.4.5
0.4.4
0.4.3
0.4.2
0.4.1
0.4.0
0.3.0
0.2.0
0.1.0
AI code archaeology
Current section
Files
Jump to
Current section
Files
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