Packages

Elixir port of Google's Agent Development Kit (ADK) — agent orchestration, sessions, tools, LLM abstraction, plugins, and telemetry. Transport-agnostic.

Current section

Files

Jump to
adk_ex lib adk flow.ex
Raw

lib/adk/flow.ex

defmodule ADK.Flow do
@moduledoc """
Core execution engine for LLM agents.
Implements the request->model->response->tool loop. Each iteration:
1. Build an LlmRequest via request processors
2. Run before_model callbacks (may short-circuit)
3. Call the model's generate_content
4. Run after_model callbacks
5. If the response contains function calls, execute tools
6. Yield events; loop if tool responses were generated
"""
alias ADK.Agent.{CallbackContext, InvocationContext}
alias ADK.Agent.Tree
alias ADK.Event
alias ADK.Event.Actions
alias ADK.Model
alias ADK.Model.{LlmRequest, LlmResponse}
alias ADK.Plugin.Manager, as: PluginManager
alias ADK.Tool
alias ADK.Tool.Context, as: ToolContext
alias ADK.Types
alias ADK.Types.{Content, FunctionCall, FunctionResponse, Part}
@type before_model_callback ::
(CallbackContext.t(), LlmRequest.t() -> {LlmResponse.t() | nil, CallbackContext.t()})
@type after_model_callback ::
(CallbackContext.t(), LlmResponse.t() -> {LlmResponse.t() | nil, CallbackContext.t()})
@type before_tool_callback ::
(ToolContext.t(), struct(), map() -> {map() | nil, ToolContext.t()})
@type after_tool_callback ::
(ToolContext.t(), struct(), map(), map() -> {map() | nil, ToolContext.t()})
@type request_processor ::
(InvocationContext.t(), LlmRequest.t(), map() -> {:ok, LlmRequest.t()})
@type t :: %__MODULE__{
model: struct() | nil,
tools: [struct()],
toolsets: [struct()],
request_processors: [request_processor()],
response_processors: [term()],
before_model_callbacks: [before_model_callback()],
after_model_callbacks: [after_model_callback()],
on_model_error_callbacks: [term()],
before_tool_callbacks: [before_tool_callback()],
after_tool_callbacks: [after_tool_callback()],
on_tool_error_callbacks: [term()]
}
defstruct [
:model,
tools: [],
toolsets: [],
request_processors: [],
response_processors: [],
before_model_callbacks: [],
after_model_callbacks: [],
on_model_error_callbacks: [],
before_tool_callbacks: [],
after_tool_callbacks: [],
on_tool_error_callbacks: []
]
@max_iterations 25
@doc """
Runs the flow loop, returning a stream of events.
"""
@spec run(t(), InvocationContext.t()) :: Enumerable.t()
def run(%__MODULE__{} = flow, %InvocationContext{} = ctx) do
Stream.resource(
fn -> {:step, flow, ctx, 0} end,
&flow_next/1,
fn _ -> :ok end
)
end
defp flow_next(:done), do: {:halt, :done}
defp flow_next({:step, _flow, ctx, iteration}) when iteration >= @max_iterations do
event =
Event.new(
invocation_id: ctx.invocation_id,
branch: ctx.branch,
author: agent_name(ctx),
error_code: "max_iterations",
error_message: "Flow exceeded maximum iterations (#{@max_iterations})"
)
{[event], :done}
end
defp flow_next({:step, flow, ctx, iteration}) do
case run_one_step(flow, ctx) do
{:ok, events, updated_ctx} ->
last = List.last(events)
if last != nil and Event.final_response?(last) do
{events, :done}
else
{events, {:step, flow, updated_ctx, iteration + 1}}
end
{:error, reason} ->
event =
Event.new(
invocation_id: ctx.invocation_id,
branch: ctx.branch,
author: agent_name(ctx),
error_code: "flow_error",
error_message: to_string(reason)
)
{[event], :done}
end
end
defp run_one_step(flow, ctx) do
resolved_tools = resolve_toolsets(flow.toolsets, ctx)
flow_state = %{tools: flow.tools ++ resolved_tools}
with {:ok, request} <- build_request(flow, ctx, flow_state),
{:ok, response, cb_ctx} <- call_model_with_callbacks(flow, ctx, request),
model_event <- build_model_event(ctx, response, cb_ctx) do
function_calls = extract_function_calls(response)
if function_calls == [] do
{:ok, [model_event], update_ctx_from_event(ctx, model_event)}
else
{:ok, tool_event} = execute_tools(flow, ctx, request, function_calls)
updated_ctx =
ctx
|> update_ctx_from_event(model_event)
|> update_ctx_from_event(tool_event)
transfer_events = maybe_run_transfer(tool_event, updated_ctx)
{:ok, [model_event, tool_event] ++ transfer_events, updated_ctx}
end
end
end
defp resolve_toolsets([], _ctx), do: []
defp resolve_toolsets(toolsets, ctx) do
Enum.flat_map(toolsets, fn ts ->
case ts.__struct__.tools(ts, ctx) do
{:ok, tools} -> tools
{:error, _reason} -> []
end
end)
end
defp build_request(flow, ctx, flow_state) do
request = %LlmRequest{model: model_name(flow)}
Enum.reduce_while(flow.request_processors, {:ok, request}, fn processor, {:ok, req} ->
case processor.(ctx, req, flow_state) do
{:ok, updated} -> {:cont, {:ok, updated}}
{:error, _} = err -> {:halt, err}
end
end)
end
defp call_model_with_callbacks(flow, ctx, request) do
cb_ctx = CallbackContext.new(ctx)
pm = ctx.plugin_manager
# Plugin before_model runs first
case PluginManager.run_before_model(pm, cb_ctx, request) do
{%LlmResponse{} = response, updated_cb_ctx} ->
{:ok, response, updated_cb_ctx}
{nil, plugin_cb_ctx} ->
# Then agent before_model callbacks
case run_before_model_callbacks(flow.before_model_callbacks, plugin_cb_ctx, request) do
{:short_circuit, response, updated_cb_ctx} ->
{:ok, response, updated_cb_ctx}
{:continue, updated_cb_ctx} ->
call_and_finalize_model(flow, ctx, request, updated_cb_ctx)
end
end
end
defp call_and_finalize_model(flow, ctx, request, cb_ctx) do
pm = ctx.plugin_manager
stream? = ctx.run_config.streaming_mode != :none
llm_meta = %{
model_name: model_name(flow),
invocation_id: ctx.invocation_id,
session_id: if(ctx.session, do: ctx.session.id, else: nil)
}
responses =
ADK.Telemetry.span_llm_call(llm_meta, fn ->
flow.model |> Model.generate_content(request, stream?) |> Enum.to_list()
end)
final_response = find_final_response(responses)
# Plugin after_model runs first
case PluginManager.run_after_model(pm, cb_ctx, final_response) do
{%LlmResponse{} = replaced, updated_cb_ctx} ->
{:ok, replaced, updated_cb_ctx}
{nil, plugin_cb_ctx} ->
# Then agent after_model callbacks
case run_after_model_callbacks(flow.after_model_callbacks, plugin_cb_ctx, final_response) do
{:replaced, replaced_response, after_cb_ctx} ->
{:ok, replaced_response, after_cb_ctx}
{:continue, after_cb_ctx} ->
{:ok, final_response, after_cb_ctx}
end
end
end
defp run_before_model_callbacks([], cb_ctx, _request), do: {:continue, cb_ctx}
defp run_before_model_callbacks([callback | rest], cb_ctx, request) do
case callback.(cb_ctx, request) do
{%LlmResponse{} = response, updated_ctx} -> {:short_circuit, response, updated_ctx}
{nil, updated_ctx} -> run_before_model_callbacks(rest, updated_ctx, request)
end
end
defp run_after_model_callbacks([], cb_ctx, _response), do: {:continue, cb_ctx}
defp run_after_model_callbacks([callback | rest], cb_ctx, response) do
case callback.(cb_ctx, response) do
{%LlmResponse{} = replaced, updated_ctx} -> {:replaced, replaced, updated_ctx}
{nil, updated_ctx} -> run_after_model_callbacks(rest, updated_ctx, response)
end
end
defp find_final_response([]), do: %LlmResponse{turn_complete: true}
defp find_final_response(responses) do
Enum.reduce(responses, %LlmResponse{}, fn resp, acc ->
merge_response(acc, resp)
end)
end
defp merge_response(acc, new) do
content = if new.content != nil, do: new.content, else: acc.content
%LlmResponse{
content: content,
error_code: new.error_code || acc.error_code,
error_message: new.error_message || acc.error_message,
finish_reason: new.finish_reason || acc.finish_reason,
usage_metadata: new.usage_metadata || acc.usage_metadata,
turn_complete: new.turn_complete || acc.turn_complete,
partial: false,
interrupted: new.interrupted || acc.interrupted
}
end
defp build_model_event(ctx, response, cb_ctx) do
Event.new(
invocation_id: ctx.invocation_id,
branch: ctx.branch,
author: agent_name(ctx),
content: response.content,
partial: response.partial,
turn_complete: response.turn_complete,
interrupted: response.interrupted,
error_code: response.error_code,
error_message: response.error_message,
finish_reason: response.finish_reason,
usage_metadata: response.usage_metadata,
actions: cb_ctx.actions
)
end
defp extract_function_calls(%LlmResponse{content: nil}), do: []
defp extract_function_calls(%LlmResponse{content: content}) do
Types.function_calls(content)
end
defp execute_tools(flow, ctx, request, function_calls) do
cb_ctx = CallbackContext.new(ctx)
results =
Enum.map(function_calls, fn fc ->
call_tool(flow, ctx, cb_ctx, request, fc)
end)
parts =
Enum.map(results, fn {_tool_ctx, part} -> part end)
merged_actions =
Enum.reduce(results, %Actions{}, fn {tool_ctx, _part}, acc ->
merge_actions(acc, tool_ctx.actions)
end)
event =
Event.new(
invocation_id: ctx.invocation_id,
branch: ctx.branch,
author: agent_name(ctx),
content: %Content{role: Types.role_user(), parts: parts},
actions: merged_actions
)
ADK.Telemetry.span_merged_tools(%{event_id: event.id})
{:ok, event}
end
defp call_tool(flow, ctx, cb_ctx, request, %FunctionCall{} = fc) do
tool_ctx = ToolContext.new(cb_ctx, fc.id)
tool = Map.get(request.tools, fc.name)
if tool == nil do
error_part = %Part{
function_response: %FunctionResponse{
name: fc.name,
id: fc.id,
response: %{"error" => "Tool not found: #{fc.name}"}
}
}
{tool_ctx, error_part}
else
do_call_tool(flow, ctx, tool_ctx, tool, fc)
end
end
defp do_call_tool(flow, ctx, tool_ctx, tool, fc) do
pm = ctx.plugin_manager
# Plugin before_tool runs first
case PluginManager.run_before_tool(pm, tool_ctx, tool, fc.args) do
{%{} = result, updated_ctx} when not is_struct(result) ->
{updated_ctx, make_response_part(fc, result)}
{nil, plugin_ctx} ->
# Then agent before_tool callbacks
case run_before_tool_callbacks(flow.before_tool_callbacks, plugin_ctx, tool, fc.args) do
{:short_circuit, result, updated_ctx} ->
{updated_ctx, make_response_part(fc, result)}
{:continue, updated_ctx} ->
execute_and_finalize(flow, ctx, updated_ctx, tool, fc)
end
end
end
defp execute_and_finalize(flow, ctx, tool_ctx, tool, fc) do
tool_meta = %{
tool_name: Tool.name(tool),
function_call_id: fc.id
}
result =
ADK.Telemetry.span_tool_call(tool_meta, fn ->
Tool.run(tool, tool_ctx, fc.args)
end)
case result do
{:ok, result} ->
finalize_tool_success(flow, ctx, tool_ctx, tool, fc, result)
{:error, reason} ->
finalize_tool_error(flow, ctx, tool_ctx, tool, fc, reason)
end
end
defp finalize_tool_success(flow, ctx, tool_ctx, tool, fc, result) do
pm = ctx.plugin_manager
tool_ctx = maybe_set_transfer(tool_ctx, result)
# Plugin after_tool runs first
case PluginManager.run_after_tool(pm, tool_ctx, tool, fc.args, result) do
{%{} = replaced, updated_ctx} when not is_struct(replaced) ->
{updated_ctx, make_response_part(fc, replaced)}
{nil, plugin_ctx} ->
# Then agent after_tool callbacks
case run_after_tool_callbacks(flow.after_tool_callbacks, plugin_ctx, tool, fc.args, result) do
{:replaced, replaced_result, after_ctx} ->
{after_ctx, make_response_part(fc, replaced_result)}
{:continue, after_ctx} ->
{after_ctx, make_response_part(fc, result)}
end
end
end
defp maybe_set_transfer(tool_ctx, %{"transfer_to_agent" => name}) when is_binary(name) do
%{tool_ctx | actions: %{tool_ctx.actions | transfer_to_agent: name}}
end
defp maybe_set_transfer(tool_ctx, _), do: tool_ctx
defp finalize_tool_error(flow, ctx, tool_ctx, tool, fc, reason) do
pm = ctx.plugin_manager
error_result = %{"error" => to_string(reason)}
# Plugin on_tool_error runs first
case PluginManager.run_on_tool_error(pm, tool_ctx, tool, error_result) do
{%{} = recovered, updated_ctx} when not is_struct(recovered) ->
{updated_ctx, make_response_part(fc, recovered)}
{nil, plugin_ctx} ->
# Then agent on_tool_error callbacks
case run_tool_error_callbacks(flow.on_tool_error_callbacks, plugin_ctx, tool, error_result) do
{:recovered, recovered_result, err_ctx} ->
{err_ctx, make_response_part(fc, recovered_result)}
{:continue, err_ctx} ->
{err_ctx, make_response_part(fc, error_result)}
end
end
end
defp run_before_tool_callbacks([], tool_ctx, _tool, _args), do: {:continue, tool_ctx}
defp run_before_tool_callbacks([callback | rest], tool_ctx, tool, args) do
case callback.(tool_ctx, tool, args) do
{%{} = result, updated_ctx} when not is_struct(result) -> {:short_circuit, result, updated_ctx}
{nil, updated_ctx} -> run_before_tool_callbacks(rest, updated_ctx, tool, args)
end
end
defp run_after_tool_callbacks([], tool_ctx, _tool, _args, _result), do: {:continue, tool_ctx}
defp run_after_tool_callbacks([callback | rest], tool_ctx, tool, args, result) do
case callback.(tool_ctx, tool, args, result) do
{%{} = replaced, updated_ctx} when not is_struct(replaced) ->
{:replaced, replaced, updated_ctx}
{nil, updated_ctx} ->
run_after_tool_callbacks(rest, updated_ctx, tool, args, result)
end
end
defp run_tool_error_callbacks([], tool_ctx, _tool, _error), do: {:continue, tool_ctx}
defp run_tool_error_callbacks([callback | rest], tool_ctx, tool, error) do
case callback.(tool_ctx, tool, error) do
{%{} = recovered, updated_ctx} when not is_struct(recovered) ->
{:recovered, recovered, updated_ctx}
{nil, updated_ctx} ->
run_tool_error_callbacks(rest, updated_ctx, tool, error)
end
end
defp make_response_part(%FunctionCall{} = fc, result) do
%Part{
function_response: %FunctionResponse{
name: fc.name,
id: fc.id,
response: result
}
}
end
defp merge_actions(acc, new) do
%Actions{
state_delta: Map.merge(acc.state_delta, new.state_delta),
artifact_delta: Map.merge(acc.artifact_delta, new.artifact_delta),
transfer_to_agent: new.transfer_to_agent || acc.transfer_to_agent,
escalate: acc.escalate or new.escalate,
skip_summarization: acc.skip_summarization or new.skip_summarization
}
end
defp agent_name(%InvocationContext{agent: nil}), do: nil
defp agent_name(%InvocationContext{agent: agent}) do
agent.__struct__.name(agent)
end
defp model_name(%__MODULE__{model: nil}), do: nil
defp model_name(%__MODULE__{model: model}), do: Model.name(model)
defp maybe_run_transfer(%Event{actions: %{transfer_to_agent: name}}, ctx)
when is_binary(name) do
case Tree.find_agent(ctx.root_agent, name) do
{:ok, target_agent} ->
transfer_ctx = InvocationContext.with_agent(ctx, target_agent)
target_agent.__struct__.run(target_agent, transfer_ctx) |> Enum.to_list()
:error ->
[]
end
end
defp maybe_run_transfer(_event, _ctx), do: []
defp update_ctx_from_event(ctx, %Event{actions: actions}) do
if ctx.session && map_size(actions.state_delta) > 0 do
updated_state = Map.merge(ctx.session.state, actions.state_delta)
updated_session = %{ctx.session | state: updated_state}
%{ctx | session: updated_session}
else
ctx
end
end
end