Current section

Files

Jump to
erlang_adk src adk_graph.erl
Raw

src/adk_graph.erl

%% @doc adk_graph - Graph-based workflow engine for Erlang ADK.
%%
%% This module allows constructing stateful execution graphs where nodes can be
%% agents, functions, or sub-graphs, and edges define the conditional routing.
-module(adk_graph).
-export([new/0, add_node/3, add_edge/3, add_conditional_edge/3, set_entry_point/2]).
-export([compile/1, run/2, run/3]).
-type node_name() :: atom() | binary().
-type node_fn() :: fun((map()) -> map()).
-type edge_condition() :: fun((map()) -> node_name() | end_node).
-record(graph, {
nodes = #{} :: #{node_name() => node_fn()},
edges = #{} :: #{node_name() => node_name() | edge_condition()},
entry_point :: node_name() | undefined
}).
-record(compiled_graph, {
nodes :: #{node_name() => node_fn()},
edges :: #{node_name() => node_name() | edge_condition()},
entry_point :: node_name()
}).
-type graph() :: #graph{}.
-type compiled_graph() :: #compiled_graph{}.
%% @doc Initialize a new, empty graph.
-spec new() -> graph().
new() ->
#graph{}.
%% @doc Add a computation node to the graph.
-spec add_node(Graph :: graph(), Name :: node_name(), NodeFn :: node_fn()) -> graph().
add_node(Graph, Name, NodeFn) ->
Nodes = Graph#graph.nodes,
Graph#graph{nodes = Nodes#{Name => NodeFn}}.
%% @doc Add a deterministic edge from one node to another.
-spec add_edge(Graph :: graph(), From :: node_name(), To :: node_name() | end_node) -> graph().
add_edge(Graph, From, To) ->
Edges = Graph#graph.edges,
Graph#graph{edges = Edges#{From => To}}.
%% @doc Add a conditional edge that decides the next node based on state.
-spec add_conditional_edge(Graph :: graph(), From :: node_name(), ConditionFn :: edge_condition()) -> graph().
add_conditional_edge(Graph, From, ConditionFn) ->
Edges = Graph#graph.edges,
Graph#graph{edges = Edges#{From => ConditionFn}}.
%% @doc Set the entry point (starting node) of the graph.
-spec set_entry_point(Graph :: graph(), Entry :: node_name()) -> graph().
set_entry_point(Graph, Entry) ->
Graph#graph{entry_point = Entry}.
%% @doc Compile the graph, verifying it is well-formed.
-spec compile(Graph :: graph()) -> {ok, compiled_graph()} | {error, term()}.
compile(Graph) ->
case Graph#graph.entry_point of
undefined -> {error, missing_entry_point};
Entry ->
case validate_graph(Entry, Graph#graph.nodes, Graph#graph.edges) of
ok ->
{ok, #compiled_graph{
nodes = Graph#graph.nodes,
edges = Graph#graph.edges,
entry_point = Entry
}};
Error -> Error
end
end.
%% @doc Execute the compiled graph with an initial state.
-spec run(CompiledGraph :: compiled_graph(), InitialState :: map()) -> {ok, map()} | {error, term()}.
run(CompiledGraph, InitialState) ->
run(CompiledGraph, InitialState, #{}).
%% @doc Execute with a configurable safety bound for cyclic graphs.
-spec run(CompiledGraph :: compiled_graph(), InitialState :: map(), Opts :: map()) ->
{ok, map()} | {error, term()}.
run(CompiledGraph, InitialState, Opts) ->
Entry = CompiledGraph#compiled_graph.entry_point,
MaxSteps = maps:get(max_steps, Opts, 10000),
case is_integer(MaxSteps) andalso MaxSteps > 0 of
true -> execute_node(CompiledGraph, Entry, InitialState, 0, MaxSteps);
false -> {error, {invalid_max_steps, MaxSteps}}
end.
%% Internal Functions
execute_node(_CompiledGraph, end_node, State, _Steps, _MaxSteps) ->
{ok, State};
execute_node(_CompiledGraph, _NodeName, _State, Steps, MaxSteps)
when Steps >= MaxSteps ->
{error, {max_steps_exceeded, MaxSteps}};
execute_node(CompiledGraph, NodeName, State, Steps, MaxSteps) ->
case maps:find(NodeName, CompiledGraph#compiled_graph.nodes) of
{ok, NodeFn} ->
try
%% 1. Execute the node function, merging state
NewState = maps:merge(State, NodeFn(State)),
%% 2. Determine the next node based on edges
NextNode = determine_next_node(CompiledGraph, NodeName, NewState),
%% 3. Recurse
execute_node(CompiledGraph, NextNode, NewState,
Steps + 1, MaxSteps)
catch
E:R:S ->
logger:error("Graph execution error at ~p: ~p:~p~n~p", [NodeName, E, R, S]),
{error, {node_execution_failed, NodeName, R}}
end;
error ->
{error, {unknown_node, NodeName}}
end.
determine_next_node(CompiledGraph, NodeName, State) ->
case maps:find(NodeName, CompiledGraph#compiled_graph.edges) of
{ok, Target} when is_atom(Target) orelse is_binary(Target) ->
Target;
{ok, ConditionFn} when is_function(ConditionFn) ->
ConditionFn(State);
error ->
%% If no edge is defined, implicitly end
end_node
end.
validate_graph(Entry, Nodes, Edges) ->
case maps:is_key(Entry, Nodes) of
false -> {error, {unknown_entry_point, Entry}};
true ->
maps:fold(fun(From, Target, Acc) ->
case Acc of
ok -> validate_edge(From, Target, Nodes);
Error -> Error
end
end, ok, Edges)
end.
validate_edge(From, _Target, Nodes) when not is_map_key(From, Nodes) ->
{error, {unknown_edge_source, From}};
validate_edge(_From, Target, _Nodes) when is_function(Target, 1) ->
ok;
validate_edge(_From, end_node, _Nodes) ->
ok;
validate_edge(_From, Target, Nodes) ->
case maps:is_key(Target, Nodes) of
true -> ok;
false -> {error, {unknown_edge_target, Target}}
end.