Current section

Files

Jump to
erlang_adk src adk_agent.erl
Raw

src/adk_agent.erl

-module(adk_agent).
-behaviour(gen_server).
-export([start_link/3, stop/1, prompt/2, delegate/2, delegate/3, delegate/4,
run_with_events/3, get_tools/1, get_runtime/1, format_result/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
-record(state, {
name :: string(),
llm_config :: map(),
tools :: [module()],
session_id :: term(),
session_store :: module(),
memory :: list(),
sub_agents :: map()
}).
-define(DEFAULT_CALL_TIMEOUT, 60000).
%% API
start_link(Name, LLMConfig, Tools) ->
gen_server:start_link({via, adk_agent_registry, Name}, ?MODULE,
[Name, LLMConfig, Tools], []).
stop(Pid) ->
gen_server:stop(Pid, normal, 5000).
prompt(Pid, Message) ->
gen_server:call(Pid, {prompt, Message}, agent_call_timeout()).
delegate(Pid, Message) ->
gen_server:cast(Pid, {delegate, Message, undefined}).
delegate(Pid, Message, ReplyToPid) ->
gen_server:cast(Pid, {delegate, Message, ReplyToPid}).
%% @doc Delegate with an explicit caller-supplied correlation reference.
delegate(Pid, Message, ReplyToPid, Ref) ->
gen_server:cast(Pid, {delegate, Message, {ReplyToPid, Ref}}).
%% @doc Run the agent using the new Event-based architecture.
run_with_events(Pid, HistoryEvents, InvocationId) ->
gen_server:call(Pid, {run_with_events, HistoryEvents, InvocationId},
agent_call_timeout()).
%% @doc Get the tools and sub-agents registered with this agent.
-spec get_tools(Pid :: pid()) -> {ok, [module()], map()}.
get_tools(Pid) ->
gen_server:call(Pid, get_tools, 5000).
%% @doc Return the immutable runtime configuration needed by Runner lifecycle
%% hooks. Kept separate from get_tools/1 for backward compatibility.
get_runtime(Pid) ->
gen_server:call(Pid, get_runtime, 5000).
agent_call_timeout() ->
case application:get_env(erlang_adk, agent_call_timeout,
?DEFAULT_CALL_TIMEOUT) of
infinity -> infinity;
Timeout when is_integer(Timeout), Timeout >= 0 -> Timeout;
Invalid -> erlang:error({invalid_agent_call_timeout, Invalid})
end.
%% Gen Server Callbacks
init([Name, LLMConfig, Tools]) ->
SessionId = maps:get(session_id, LLMConfig, undefined),
SessionStore = maps:get(session_store, LLMConfig, erlang_adk_session),
ok = ensure_session_store(SessionStore),
Memory = case SessionId of
undefined -> adk_memory:new();
Id ->
case SessionStore:load(Id) of
[] -> adk_memory:new();
Loaded -> Loaded
end
end,
%% A restored legacy session may predate system-message persistence. Always
%% make sure configured instructions are present, even when history exists.
Memory1 = ensure_system_instruction(Memory, LLMConfig),
if SessionId =/= undefined -> SessionStore:save(SessionId, Memory1); true -> ok end,
SubAgents = maps:get(sub_agents, LLMConfig, #{}),
{ok, #state{name = Name, llm_config = LLMConfig, tools = Tools, session_id = SessionId, session_store = SessionStore, memory = Memory1, sub_agents = SubAgents}}.
handle_call({prompt, Message}, _From, State) ->
telemetry:execute([erlang_adk, agent, prompt, start], #{}, #{agent => State#state.name}),
StartTime = erlang:monotonic_time(millisecond),
Memory1 = adk_memory:add_message(State#state.memory, user, Message),
RunResult = run_agent_invocation(State, Message, Memory1),
{Reply, Memory2} = case RunResult of
{ok, Response, UpdatedMemory} ->
{{ok, Response}, UpdatedMemory};
{error, Reason, UpdatedMemory} ->
{{error, Reason}, UpdatedMemory}
end,
Duration = erlang:monotonic_time(millisecond) - StartTime,
telemetry:execute([erlang_adk, agent, prompt, stop], #{duration => Duration}, #{agent => State#state.name}),
if State#state.session_id =/= undefined ->
Store = State#state.session_store,
Store:save(State#state.session_id, Memory2);
true -> ok end,
{reply, Reply, State#state{memory = Memory2}};
handle_call({run_with_events, HistoryEvents, InvocationId}, _From, State) ->
telemetry:execute([erlang_adk, agent, run_with_events, start], #{}, #{agent => State#state.name}),
StartTime = erlang:monotonic_time(millisecond),
%% Convert incoming events to legacy history format for the LLM
LegacyHistory0 = adk_memory:from_events(HistoryEvents),
LegacyHistory = ensure_system_instruction(LegacyHistory0,
State#state.llm_config),
ModelTools = model_tools(State#state.tools, State#state.sub_agents),
Result = case generate_with_callbacks(State#state.llm_config,
LegacyHistory,
ModelTools) of
{ok, Text} ->
ResponseText = to_binary(Text),
FinalEvent = adk_event:new(to_binary(State#state.name), ResponseText, #{
invocation_id => InvocationId,
is_final => true
}),
{ok, FinalEvent};
{tool_calls, Calls} ->
AgentEvent = adk_event:new(to_binary(State#state.name), {tool_calls, Calls}, #{
invocation_id => InvocationId
}),
{tool_calls, AgentEvent, Calls};
{error, Reason} ->
{error, Reason}
end,
Duration = erlang:monotonic_time(millisecond) - StartTime,
telemetry:execute([erlang_adk, agent, run_with_events, stop], #{duration => Duration}, #{agent => State#state.name}),
{reply, Result, State};
handle_call(get_tools, _From, State) ->
{reply, {ok, State#state.tools, State#state.sub_agents}, State};
handle_call(get_runtime, _From, State) ->
{reply, {ok, State#state.name, State#state.llm_config,
State#state.tools, State#state.sub_agents}, State};
handle_call(_Request, _From, State) ->
{reply, ok, State}.
handle_cast({delegate, Message, ReplyToPid}, State) ->
telemetry:execute([erlang_adk, agent, delegate, start], #{}, #{agent => State#state.name}),
StartTime = erlang:monotonic_time(millisecond),
Memory1 = adk_memory:add_message(State#state.memory, user, Message),
RunResult = run_agent_invocation(State, Message, Memory1),
{Response, Memory2} = case RunResult of
{ok, Value, UpdatedMemory} -> {{ok, Value}, UpdatedMemory};
{error, Reason, UpdatedMemory} -> {{error, Reason}, UpdatedMemory}
end,
Duration = erlang:monotonic_time(millisecond) - StartTime,
telemetry:execute([erlang_adk, agent, delegate, stop], #{duration => Duration}, #{agent => State#state.name}),
if State#state.session_id =/= undefined ->
Store = State#state.session_store,
Store:save(State#state.session_id, Memory2);
true -> ok end,
%% Notify caller if ReplyToPid is provided. The four-argument API carries
%% an explicit Ref so concurrent delegations can be correlated.
case ReplyToPid of
undefined -> ok;
{TargetPid, Ref} ->
TargetPid ! {agent_response, Ref, self(), Response};
TargetPid when is_pid(TargetPid) ->
TargetPid ! {agent_response, self(), unwrap_response(Response)}
end,
{noreply, State#state{memory = Memory2}};
handle_cast(_Msg, State) ->
{noreply, State}.
handle_info(stop, State) ->
{stop, normal, State};
handle_info(_Info, State) ->
{noreply, State}.
terminate(_Reason, _State) ->
ok.
code_change(_OldVsn, State, _Extra) ->
{ok, State}.
%% Internal Functions
run_agent_loop(Config, Memory, Tools, State, Round) ->
MaxRounds = maps:get(max_tool_rounds, Config, 16),
case Round >= MaxRounds of
true ->
{error, {max_tool_rounds_exceeded, MaxRounds}, Memory};
false ->
ModelTools = model_tools(Tools, State#state.sub_agents),
ModelResult = generate_with_callbacks(Config, Memory, ModelTools),
case ModelResult of
{ok, Text} ->
ResponseText = to_binary(Text),
Memory1 = adk_memory:add_message(Memory, agent, ResponseText),
{ok, ResponseText, Memory1};
{tool_calls, Calls} ->
Memory1 = adk_memory:add_message(Memory, agent,
{tool_calls, Calls}),
Memory2 = execute_tools(Calls, Tools, Memory1, State),
run_agent_loop(Config, Memory2, Tools, State, Round + 1);
{error, Reason} ->
{error, Reason, Memory};
Other ->
{error, {invalid_model_result, Other}, Memory}
end
end.
execute_tools([], _ToolsList, MemoryAcc, _State) ->
MemoryAcc;
execute_tools([{NameBin, ArgsMap} | Rest], ToolsList, MemoryAcc, State) ->
execute_tools_inner(NameBin, ArgsMap, undefined, undefined, Rest,
ToolsList, MemoryAcc, State);
execute_tools([{NameBin, ArgsMap, Sig} | Rest], ToolsList, MemoryAcc, State) ->
execute_tools_inner(NameBin, ArgsMap, Sig, undefined, Rest,
ToolsList, MemoryAcc, State);
execute_tools([{NameBin, ArgsMap, Sig, CallId} | Rest], ToolsList,
MemoryAcc, State) ->
execute_tools_inner(NameBin, ArgsMap, Sig, CallId, Rest,
ToolsList, MemoryAcc, State).
execute_tools_inner(NameBin, ArgsMap, Sig, CallId, Rest, ToolsList,
MemoryAcc, State) ->
FoundTool = lists:search(
fun(Mod) when is_atom(Mod) ->
Schema = Mod:schema(),
maps:get(<<"name">>, Schema, atom_to_binary(Mod, utf8)) == NameBin;
(_) -> false
end, ToolsList),
Context = #{app_name => maps:get(app_name, State#state.llm_config,
undefined),
session_id => State#state.session_id,
user_id => maps:get(user_id, State#state.llm_config,
undefined),
invocation_id => undefined,
call_id => CallId,
state_ref => State#state.session_store},
Handlers = maps:get(callbacks, State#state.llm_config, []),
Result = case FoundTool of
{value, Mod} ->
execute_tool_with_callbacks(
Mod, NameBin, ArgsMap, Context, Handlers);
false ->
execute_sub_agent_with_callbacks(
NameBin, ArgsMap, Context, State#state.sub_agents, Handlers)
end,
Memory1 = adk_memory:add_message(
MemoryAcc, tool,
tool_response(NameBin, Result, Sig, CallId)),
execute_tools(Rest, ToolsList, Memory1, State).
tool_response(Name, Result, Sig, undefined) ->
{tool_response, Name, Result, Sig};
tool_response(Name, Result, Sig, CallId) ->
{tool_response, Name, Result, Sig, CallId}.
%% @doc Format a tool execution result into a standard map.
-spec format_result(Res :: term()) -> map().
format_result(Res) when is_map(Res) -> Res;
format_result(Res) when is_binary(Res) -> #{<<"result">> => Res};
format_result(Res) ->
#{<<"result">> => unicode:characters_to_binary(io_lib:format("~p", [Res]))}.
execute_tool_with_callbacks(Mod, NameBin, ArgsMap, Context, Handlers) ->
adk_callbacks:execute(Handlers, on_tool_start, [NameBin, ArgsMap]),
RawResult = case adk_callbacks:run(Handlers, before_tool,
[NameBin, ArgsMap, Context]) of
{halt, Replacement} -> {ok, Replacement};
{replace, Replacement} -> {ok, Replacement};
_ ->
try Mod:execute(ArgsMap, Context) of
ToolResult -> ToolResult
catch
Class:ToolFailure:Stack ->
logger:error("Tool ~p failed: ~p:~p~n~p",
[NameBin, Class, ToolFailure, Stack]),
{error, {Class, ToolFailure}}
end
end,
FinalResult = case adk_callbacks:run(Handlers, after_tool,
[NameBin, ArgsMap, Context, RawResult]) of
{replace, ReplacementResult} -> {ok, ReplacementResult};
{halt, ReplacementResult} -> {ok, ReplacementResult};
_ -> RawResult
end,
adk_callbacks:execute(Handlers, on_tool_end, [NameBin, FinalResult]),
case FinalResult of
{ok, Res} -> #{<<"success">> => true, <<"result">> => format_result(Res)};
{error, ToolError} ->
#{<<"success">> => false, <<"error">> => format_result(ToolError)};
Other ->
#{<<"success">> => false,
<<"error">> => format_result({invalid_tool_result, Other})}
end.
execute_sub_agent_with_callbacks(NameBin, ArgsMap, Context,
SubAgents, Handlers) ->
adk_callbacks:execute(Handlers, on_tool_start, [NameBin, ArgsMap]),
RawResult = case adk_callbacks:run(Handlers, before_tool,
[NameBin, ArgsMap, Context]) of
{halt, Replacement} -> {ok, Replacement};
{replace, Replacement} -> {ok, Replacement};
_ -> execute_sub_agent(NameBin, ArgsMap, SubAgents)
end,
FinalResult = case adk_callbacks:run(
Handlers, after_tool,
[NameBin, ArgsMap, Context, RawResult]) of
{replace, ReplacementResult} -> {ok, ReplacementResult};
{halt, ReplacementResult} -> {ok, ReplacementResult};
_ -> RawResult
end,
adk_callbacks:execute(Handlers, on_tool_end,
[NameBin, FinalResult]),
case FinalResult of
{ok, SubResult} ->
#{<<"success">> => true,
<<"result">> => format_result(SubResult)};
{error, SubError} ->
#{<<"success">> => false,
<<"error">> => format_result(SubError)};
Other ->
#{<<"success">> => false,
<<"error">> => format_result({invalid_tool_result, Other})}
end.
execute_sub_agent(NameBin, ArgsMap, SubAgents) ->
case maps:find(NameBin, SubAgents) of
{ok, SubSpec} ->
SubPrompt = maps:get(<<"prompt">>, ArgsMap, <<>>),
case sub_agent_pid(NameBin, SubSpec) of
{ok, SubPid} -> safe_sub_agent_prompt(SubPid, SubPrompt);
error -> {error, invalid_sub_agent_configuration}
end;
error ->
{error, tool_not_found}
end.
safe_generate(Config, History, Tools) ->
try adk_llm:generate(Config, History, Tools) of
Result -> Result
catch
Class:Reason:Stack ->
logger:error("LLM provider failed: ~p:~p~n~p", [Class, Reason, Stack]),
{error, {Class, Reason}}
end.
generate_with_callbacks(Config, Memory, ModelTools) ->
Handlers = maps:get(callbacks, Config, []),
RawResult = case adk_callbacks:run(Handlers, before_model,
[Config, Memory, ModelTools]) of
{halt, Replacement} -> Replacement;
{replace, Replacement} -> Replacement;
_ -> safe_generate(Config, adk_memory:get_history(Memory), ModelTools)
end,
case adk_callbacks:run(Handlers, after_model, [Config, RawResult]) of
{replace, ReplacementResult} -> ReplacementResult;
{halt, ReplacementResult} -> ReplacementResult;
_ -> RawResult
end.
run_agent_invocation(State, Message, Memory) ->
Config = State#state.llm_config,
Handlers = maps:get(callbacks, Config, []),
AgentName = State#state.name,
adk_callbacks:execute(Handlers, on_agent_start,
[to_binary(AgentName), Message]),
Result0 = case adk_callbacks:run(Handlers, before_agent,
[AgentName, Message]) of
{halt, Value} -> {ok, Value, Memory};
{replace, Value} -> {ok, Value, Memory};
_ -> run_agent_loop(Config, Memory, State#state.tools, State, 0)
end,
case Result0 of
{ok, Response0, UpdatedMemory} ->
Response0a = case adk_callbacks:run(Handlers, after_agent,
[AgentName, Response0]) of
{replace, Replacement} -> Replacement;
{halt, Replacement} -> Replacement;
_ -> Response0
end,
%% Text returned by the public agent APIs is always a UTF-8
%% binary. Erlang charlists are lists of integer code points and
%% are often rendered as [69,114,...] by shells and loggers.
Response = normalize_response(Response0a),
FinalMemory = persist_agent_response(
Memory, UpdatedMemory, Response0, Response),
adk_callbacks:execute(Handlers, on_agent_end,
[to_binary(AgentName), Response]),
{ok, Response, FinalMemory};
{error, Reason, UpdatedMemory} ->
adk_callbacks:execute(Handlers, on_error, [Reason]),
{error, Reason, UpdatedMemory}
end.
%% Keep the conversation visible to the next model turn identical to the
%% response returned to the caller. A before_agent short-circuit has not yet
%% added an agent message; an after_agent replacement updates the final one.
persist_agent_response(InputMemory, InputMemory, _Original, Response) ->
adk_memory:add_message(InputMemory, agent, Response);
persist_agent_response(_InputMemory, UpdatedMemory, Response, Response) ->
UpdatedMemory;
persist_agent_response(_InputMemory, UpdatedMemory, _Original, Response) ->
replace_latest_agent_response(UpdatedMemory, Response).
replace_latest_agent_response([#{role := agent} = Message | Rest], Response) ->
[Message#{content => Response} | Rest];
replace_latest_agent_response([Message | Rest], Response) ->
[Message | replace_latest_agent_response(Rest, Response)];
replace_latest_agent_response([], Response) ->
adk_memory:add_message([], agent, Response).
ensure_system_instruction(Memory, Config) ->
case lists:any(fun(#{role := system}) -> true; (_) -> false end, Memory) of
true -> Memory;
false ->
Instructions = maps:get(instructions, Config,
"You are a helpful assistant."),
SystemMessage = #{role => system, content => Instructions,
timestamp => erlang:system_time(millisecond)},
%% Memory is stored newest-first, so append the oldest system item.
Memory ++ [SystemMessage]
end.
model_tools(Tools, SubAgents) ->
Tools ++ [sub_agent_schema(Name, Spec)
|| {Name, Spec} <- maps:to_list(SubAgents)].
sub_agent_schema(Name, Spec) ->
Description = case Spec of
#{description := Desc} -> Desc;
_ -> <<"Delegate a task to this specialist agent.">>
end,
adk_agent_tool:schema(#{name => to_binary(Name),
description => to_binary(Description)}).
sub_agent_pid(Name, #{pid := Pid}) ->
resolve_sub_agent(Name, Pid);
sub_agent_pid(Name, Pid) ->
resolve_sub_agent(Name, Pid).
resolve_sub_agent(Name, Pid) when is_pid(Pid) ->
case is_process_alive(Pid) of
true -> {ok, Pid};
false -> lookup_sub_agent(Name)
end;
resolve_sub_agent(Name, _StaleRef) ->
lookup_sub_agent(Name).
lookup_sub_agent(Name) ->
case adk_agent_registry:lookup(Name) of
{ok, Pid} -> {ok, Pid};
{error, not_found} -> error
end.
safe_sub_agent_prompt(SubPid, SubPrompt) ->
try prompt(SubPid, SubPrompt) of
Result -> Result
catch
Class:Reason -> {error, {sub_agent_unavailable, Class, Reason}}
end.
unwrap_response({ok, Value}) -> Value;
unwrap_response({error, Reason}) -> {error, Reason}.
to_binary(Value) when is_binary(Value) -> Value;
to_binary(Value) when is_list(Value) -> unicode:characters_to_binary(Value);
to_binary(Value) when is_atom(Value) -> atom_to_binary(Value, utf8).
normalize_response(Value) when is_list(Value) ->
unicode:characters_to_binary(Value);
normalize_response(Value) ->
Value.
ensure_session_store(Store) ->
case code:ensure_loaded(Store) of
{module, Store} ->
case erlang:function_exported(Store, init, 0) of
true ->
case Store:init() of
ok -> ok;
{atomic, ok} -> ok;
{error, Reason} -> exit({session_store_init_failed, Reason});
_ -> ok
end;
false -> ok
end;
{error, Reason} -> exit({session_store_unavailable, Store, Reason})
end.