%% @doc Integrated command-line tooling for local Erlang ADK development. %% %% The CLI deliberately consumes JSON configuration with a small, checked key %% set. It never accepts model API keys in files; providers read their normal %% environment-backed credentials. `inspect' talks to the authenticated local %% developer API, while `run' and `evaluate' execute in the current VM. -module(adk_cli). -export([main/1, command/1, command/2, usage/0]). -define(DEFAULT_MODEL, <<"gemini-3.1-flash-lite">>). -define(DEFAULT_BASE_URL, <<"http://127.0.0.1:8080">>). -define(DEFAULT_TIMEOUT, 120000). -define(DEFAULT_EVAL_CASE_TIMEOUT, 30000). -define(MAX_CONFIG_BYTES, 1048576). -define(MAX_EVAL_FILE_BYTES, 16777216). -define(MAX_DEVELOPER_RESPONSE_BYTES, 1048576). -define(DEVELOPER_HTTP_TIMEOUT, 5000). -spec main([string()]) -> no_return() | ok. main(Args) -> case command(Args) of {ok, #{command := serve} = Result} -> write_result(Result), wait_for_shutdown(); {ok, #{command := eval_run} = Result} -> write_eval_run_result(Result), case maps:get(ci_exit_code, Result) of 0 -> ok; ExitCode -> erlang:halt(ExitCode) end; {ok, Result} -> write_result(Result), ok; {error, Reason} -> write_error(Reason), erlang:halt(1) end. -spec command([string()]) -> {ok, term()} | {error, term()}. command(Args) -> command(Args, default_console_io()). %% @doc Execute a command with an injectable interactive IO adapter. The %% second form makes the console deterministic in tests and embeddable in an %% Erlang shell without replacing the caller's group leader. -spec command([string()], map()) -> {ok, term()} | {error, term()}. command(["console" | Args], Io) -> with_options( Args, #{"--config" => config, "--app" => app_name, "--user" => user_id, "--session" => session_id, "--timeout" => timeout}, fun(Opts) -> console_command(Opts, Io) end); command(Args, _Io) -> command_noninteractive(Args). command_noninteractive([]) -> {ok, usage()}; command_noninteractive(["help"]) -> {ok, usage()}; command_noninteractive(["--help"]) -> {ok, usage()}; command_noninteractive(["doctor"]) -> doctor(); command_noninteractive(["config", "validate", Path]) -> validate_config_command(Path); command_noninteractive(["run" | Args]) -> with_options( Args, #{"--config" => config, "--message" => message, "--app" => app_name, "--user" => user_id, "--session" => session_id, "--timeout" => timeout}, fun run_command/1); command_noninteractive(["evaluate" | Args]) -> with_options( Args, #{"--config" => config, "--dataset" => dataset, "--concurrency" => concurrency, "--timeout" => timeout}, fun evaluate_command/1); command_noninteractive(["eval", "run" | Args]) -> with_options( Args, #{"--config" => config, "--eval-set" => eval_set, "--criteria" => criteria, "--baseline" => baseline, "--format" => format, "--output" => output, "--samples" => samples, "--concurrency" => concurrency, "--sample-concurrency" => sample_concurrency, "--timeout" => timeout, "--case-timeout" => case_timeout, "--pass-rate-threshold" => pass_rate_threshold, "--sample-pass-rate-threshold" => sample_pass_rate_threshold, "--min-successful-samples" => min_successful_samples, "--empty-criteria" => empty_criteria, "--capture-events" => capture_events, "--capture-tool-content" => capture_tool_content, "--max-heap-words" => max_heap_words, "--max-report-bytes" => max_report_bytes, "--max-pass-rate-drop" => max_pass_rate_drop, "--metric-tolerances" => metric_tolerances}, fun eval_run_command/1); command_noninteractive(["serve" | Args]) -> with_options( Args, #{"--config" => config, "--port" => port, "--ip" => ip}, fun serve_command/1); command_noninteractive(["inspect", "agents" | Args]) -> with_options( Args, #{"--url" => base_url}, fun inspect_agents/1); command_noninteractive(["inspect", "diagnostics" | Args]) -> with_options( Args, #{"--url" => base_url}, fun inspect_diagnostics/1); command_noninteractive(["inspect", "observability" | Args]) -> with_options( Args, #{"--url" => base_url}, fun inspect_observability/1); command_noninteractive(["inspect", "live" | Args]) -> with_options( Args, #{"--url" => base_url}, fun inspect_live_sessions/1); command_noninteractive(["live", "send", SessionId | Args]) -> with_options( Args, #{"--url" => base_url, "--text" => text}, fun(Opts) -> send_remote_live_text(SessionId, Opts) end); command_noninteractive(["inspect", "run", RunId | Args]) -> with_options( Args, #{"--url" => base_url}, fun(Opts) -> inspect_run(RunId, Opts) end); command_noninteractive(["inspect", "sessions", App, User | Args]) -> with_options( Args, #{"--url" => base_url}, fun(Opts) -> inspect_sessions(App, User, Opts) end); command_noninteractive(["inspect", "session", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url}, fun(Opts) -> inspect_session(App, User, Session, Opts) end); command_noninteractive(["inspect", "context", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url}, fun(Opts) -> inspect_context(App, User, Session, Opts) end); command_noninteractive( ["inspect", "context-lifecycle", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url, "--model" => model}, fun(Opts) -> inspect_context_lifecycle( App, User, Session, Opts) end); command_noninteractive( ["context-cache", "invalidate", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url, "--model" => model, "--confirm-json" => confirm_json}, fun(Opts) -> invalidate_remote_context_cache( App, User, Session, Opts) end); command_noninteractive(["inspect", "artifacts", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url, "--limit" => limit, "--cursor" => cursor}, fun(Opts) -> inspect_artifacts(App, User, Session, Opts) end); command_noninteractive( ["inspect", "artifact", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url, "--name" => name, "--limit" => limit, "--cursor" => cursor}, fun(Opts) -> inspect_artifact_versions( App, User, Session, Opts) end); command_noninteractive(["inspect", "memory", App, User | Args]) -> with_options( Args, #{"--url" => base_url}, fun(Opts) -> inspect_memory(App, User, Opts) end); command_noninteractive(["memory", "search", App, User | Args]) -> with_options( Args, #{"--url" => base_url, "--query" => query, "--filter-json" => filter_json, "--limit" => limit}, fun(Opts) -> search_remote_memory(App, User, Opts) end); command_noninteractive( ["artifact", "delete", App, User, Session, Name, Selector | Args]) -> with_options( Args, #{"--url" => base_url, "--confirm-json" => confirm_json}, fun(Opts) -> delete_remote_artifact( App, User, Session, Name, Selector, Opts) end); command_noninteractive( ["memory", "erase", App, User, "entry", Id | Args]) -> with_options( Args, #{"--url" => base_url, "--confirm-json" => confirm_json}, fun(Opts) -> erase_remote_memory( App, User, entry, Id, Opts) end); command_noninteractive( ["memory", "erase", App, User, "session", Session | Args]) -> with_options( Args, #{"--url" => base_url, "--confirm-json" => confirm_json}, fun(Opts) -> erase_remote_memory( App, User, session, Session, Opts) end); command_noninteractive(["memory", "erase", App, User, "user" | Args]) -> with_options( Args, #{"--url" => base_url, "--confirm-json" => confirm_json}, fun(Opts) -> erase_remote_memory(App, User, user, User, Opts) end); command_noninteractive(["session", "create", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url}, fun(Opts) -> create_remote_session(App, User, Session, Opts) end); command_noninteractive(["session", "delete", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url}, fun(Opts) -> delete_remote_session(App, User, Session, Opts) end); command_noninteractive(["session", "state", App, User, Session | Args]) -> with_options( Args, #{"--url" => base_url, "--delta-json" => delta_json}, fun(Opts) -> update_remote_session_state( App, User, Session, Opts) end); command_noninteractive(["cancel", RunId | Args]) -> with_options( Args, #{"--url" => base_url}, fun(Opts) -> cancel_remote_run(RunId, Opts) end); command_noninteractive(["resume", RunId | Args]) -> with_options( Args, #{"--url" => base_url, "--response-json" => response_json}, fun(Opts) -> resume_remote_run(RunId, Opts) end); command_noninteractive(_Args) -> {error, invalid_command}. -spec usage() -> binary(). usage() -> <<"Erlang ADK developer CLI\n\n" " adk doctor\n" " adk config validate AGENT.json\n" " adk run --config AGENT.json --message TEXT [--user ID --session ID]\n" " adk console --config AGENT.json [--user ID --session ID]\n" " adk evaluate --config AGENT.json --dataset DATASET.json\n" " adk eval run --config AGENT.json --eval-set SET.json [--criteria CRITERIA.json]\n" " adk serve [--config AGENT.json] [--port 8080 --ip 127.0.0.1]\n" " adk inspect agents [--url URL]\n" " adk inspect diagnostics [--url URL]\n" " adk inspect observability [--url URL]\n" " adk inspect live [--url URL]\n" " adk live send SESSION_ID --text TEXT [--url URL]\n" " adk inspect run RUN_ID [--url http://127.0.0.1:8080]\n" " adk inspect sessions APP USER [--url URL]\n" " adk inspect session APP USER SESSION [--url URL]\n" " adk inspect context APP USER SESSION [--url URL]\n" " adk inspect context-lifecycle APP USER SESSION --model MODEL [--url URL]\n" " adk context-cache invalidate APP USER SESSION --model MODEL --confirm-json JSON\n" " adk inspect artifacts APP USER SESSION [--limit N --cursor NAME]\n" " adk inspect artifact APP USER SESSION --name NAME [--limit N --cursor VERSION]\n" " adk inspect memory APP USER [--url URL]\n" " adk memory search APP USER --query TEXT [--filter-json JSON --limit N]\n" " adk artifact delete APP USER SESSION NAME SELECTOR --confirm-json JSON\n" " adk memory erase APP USER entry ID --confirm-json JSON\n" " adk memory erase APP USER session SESSION --confirm-json JSON\n" " adk memory erase APP USER user --confirm-json JSON\n" " adk session create APP USER SESSION [--url URL]\n" " adk session delete APP USER SESSION [--url URL]\n" " adk session state APP USER SESSION --delta-json JSON [--url URL]\n" " adk cancel RUN_ID [--url URL]\n" " adk resume RUN_ID --response-json JSON [--url URL]\n\n" "Model credentials stay in provider environment variables. The local\n" "developer API uses ERLANG_ADK_DEV_TOKEN.\n">>. with_options(Args, Allowed, Fun) -> case parse_options(Args, Allowed, #{}) of {ok, Opts} -> Fun(Opts); {error, _} = Error -> Error end. parse_options([], _Allowed, Acc) -> {ok, Acc}; parse_options([Flag, Value | Rest], Allowed, Acc) -> case maps:find(Flag, Allowed) of {ok, Key} -> case maps:is_key(Key, Acc) of true -> {error, {duplicate_option, Flag}}; false -> parse_options(Rest, Allowed, Acc#{Key => Value}) end; error -> {error, {unknown_option, Flag}} end; parse_options([Flag], _Allowed, _Acc) -> {error, {missing_option_value, Flag}}. doctor() -> _ = application:load(erlang_adk), ProviderStatus = module_status(adk_llm_gemini), ProviderStatuses = #{gemini => ProviderStatus, openai => module_status(adk_llm_openai), anthropic => module_status(adk_llm_anthropic), compatible => module_status(adk_llm_compatible)}, DependencyModules = [cowboy, gun, jsx, telemetry, oidcc, jose], Dependencies = maps:from_list( [{Module, module_status(Module)} || Module <- DependencyModules]), DependenciesAvailable = lists:all( fun(available) -> true; (_) -> false end, maps:values(Dependencies)), ApiKeyConfigured = valid_environment_secret("GEMINI_API_KEY"), OpenAiKeyConfigured = valid_environment_secret("OPENAI_API_KEY"), AnthropicKeyConfigured = valid_environment_secret("ANTHROPIC_API_KEY"), DevTokenConfigured = valid_environment_secret( "ERLANG_ADK_DEV_TOKEN"), Warnings0 = case ApiKeyConfigured of true -> []; false -> [<<"GEMINI_API_KEY is not configured; live model calls will fail">>] end, Warnings = case application:get_env(erlang_adk, dev_enabled, false) of true when not DevTokenConfigured -> [<<"dev_enabled is true but ERLANG_ADK_DEV_TOKEN is missing">> | Warnings0]; _ -> Warnings0 end, {ok, #{command => doctor, status => case {ProviderStatus, DependenciesAvailable} of {available, true} -> ok; _ -> degraded end, otp_release => unicode:characters_to_binary( erlang:system_info(otp_release)), erlang_adk_version => application_version(), default_model => ?DEFAULT_MODEL, gemini_provider => ProviderStatus, providers => ProviderStatuses, dependencies => Dependencies, gemini_api_key_configured => ApiKeyConfigured, openai_api_key_configured => OpenAiKeyConfigured, anthropic_api_key_configured => AnthropicKeyConfigured, developer_token_configured => DevTokenConfigured, warnings => lists:reverse(Warnings)}}. validate_config_command(Path) -> case load_agent_file(Path) of {ok, Agent} -> {ok, #{command => config_validate, status => valid, name => maps:get(name, Agent), provider => maps:get(provider_name, Agent), model => maps:get(model, Agent), tool_count => length(maps:get(tools, Agent))}}; {error, _} = Error -> Error end. run_command(Opts) -> with_required(Opts, [config, message], fun() -> case parse_positive_integer( maps:get(timeout, Opts, integer_to_list(?DEFAULT_TIMEOUT)), timeout) of {ok, Timeout} -> run_loaded_agent(Opts, Timeout); {error, _} = Error -> Error end end). run_loaded_agent(Opts, Timeout) -> case load_agent_file(maps:get(config, Opts)) of {error, _} = Error -> Error; {ok, Agent} -> case ensure_application_started() of ok -> execute_cli_run(Agent, Opts, Timeout); {error, _} = Error -> Error end end. execute_cli_run(Agent, Opts, Timeout) -> Name = maps:get(name, Agent), Config = maps:get(config, Agent), Tools = maps:get(tools, Agent), case erlang_adk:spawn_agent(Name, Config, Tools) of {error, Reason} -> {error, {agent_start_failed, public_reason(Reason)}}; {ok, AgentPid} -> try AppName = option_binary(Opts, app_name, <<"adk-cli">>), UserId = option_binary(Opts, user_id, <<"local-user">>), SessionId = option_binary( Opts, session_id, generate_id(<<"session">>)), RunnerOptions0 = maps:get(runner_options, Agent, #{}), RunnerOptions = RunnerOptions0#{run_timeout => Timeout}, Runner = adk_runner:new( AgentPid, AppName, erlang_adk_session, RunnerOptions), Message = unicode:characters_to_binary( maps:get(message, Opts)), case adk_run:start( Runner, UserId, SessionId, Message, #{retention_ms => erlang:max(60000, Timeout + 1000), max_buffered_events => 256}) of {ok, RunId} -> Outcome = adk_run:await(RunId, Timeout + 1000), cli_run_result(RunId, SessionId, Outcome); {error, Reason} -> {error, {run_start_failed, public_reason(Reason)}} end after _ = catch erlang_adk:stop_agent(AgentPid) end end. cli_run_result(RunId, SessionId, {completed, Text}) -> {ok, #{command => run, run_id => RunId, session_id => SessionId, outcome => completed, text => safe_binary(Text)}}; cli_run_result(RunId, SessionId, {paused, Event}) -> EventMap = case adk_event:encode(Event) of {ok, Encoded} -> Encoded; {error, _} -> #{<<"encoding_error">> => true} end, {ok, #{command => run, run_id => RunId, session_id => SessionId, outcome => paused, event => EventMap, note => <<"Use the persistent serve/UI mode for cross-command resume">>}}; cli_run_result(RunId, SessionId, {cancelled, Reason}) -> {ok, #{command => run, run_id => RunId, session_id => SessionId, outcome => cancelled, reason => public_reason(Reason)}}; cli_run_result(_RunId, _SessionId, {failed, Reason}) -> {error, {run_failed, public_reason(Reason)}}; cli_run_result(RunId, _SessionId, {error, timeout}) -> _ = adk_run:cancel(RunId, cli_timeout), {error, timeout}; cli_run_result(_RunId, _SessionId, Other) -> {error, {invalid_run_outcome, public_reason(Other)}}. console_command(Opts, Io) -> with_required(Opts, [config], fun() -> case {validate_console_io(Io), parse_positive_integer( maps:get(timeout, Opts, integer_to_list(?DEFAULT_TIMEOUT)), timeout)} of {ok, {ok, Timeout}} -> start_console(Opts, Timeout, Io); {{error, _} = Error, _} -> Error; {_, {error, _} = Error} -> Error end end). start_console(Opts, Timeout, Io) -> case load_agent_file(maps:get(config, Opts)) of {error, _} = Error -> Error; {ok, Agent} -> case ensure_application_started() of ok -> run_console(Agent, Opts, Timeout, Io); {error, _} = Error -> Error end end. run_console(Agent, Opts, Timeout, Io) -> case erlang_adk:spawn_agent( maps:get(name, Agent), maps:get(config, Agent), maps:get(tools, Agent)) of {error, Reason} -> {error, {agent_start_failed, public_reason(Reason)}}; {ok, AgentPid} -> try AppName = option_binary(Opts, app_name, <<"adk-cli">>), UserId = option_binary(Opts, user_id, <<"local-user">>), SessionId = option_binary( Opts, session_id, generate_id(<<"session">>)), RunnerOptions = (maps:get(runner_options, Agent, #{}))#{ run_timeout => Timeout}, Runner = adk_runner:new( AgentPid, AppName, erlang_adk_session, RunnerOptions), console_write( Io, <<"Erlang ADK console. /help lists commands; /exit closes.\n">>), console_loop( #{runner => Runner, app_name => AppName, user_id => UserId, session_id => SessionId, timeout => Timeout, io => Io, turns => 0, paused_run_id => undefined}) after _ = catch erlang_adk:stop_agent(AgentPid) end end. console_loop(State = #{io := Io, session_id := SessionId}) -> Prompt = <<"adk[", SessionId/binary, "]> ">>, case console_read(Io, Prompt) of eof -> console_summary(State, eof); {error, Reason} -> {error, {console_io_failed, public_reason(Reason)}}; {ok, Line0} -> Line = trim_binary(Line0), case console_action(Line) of exit -> console_summary(State, closed); help -> console_write(Io, console_help()), console_loop(State); inspect -> console_inspect(State), console_loop(State); {switch_session, NewSession} -> console_write( Io, <<"session: ", NewSession/binary, "\n">>), console_loop(State#{session_id => NewSession, paused_run_id => undefined}); new_session -> NewSession = generate_id(<<"session">>), console_write( Io, <<"session: ", NewSession/binary, "\n">>), console_loop(State#{session_id => NewSession, paused_run_id => undefined}); {resume, ResponseJson} -> console_resume(ResponseJson, State); empty -> console_loop(State); {message, Message} -> console_turn(Message, State); {error, Reason} -> console_write_json( Io, #{status => error, reason => Reason}), console_loop(State) end end. console_turn(Message, State = #{runner := Runner, user_id := UserId, session_id := SessionId, timeout := Timeout, io := Io, turns := Turns}) -> RunOptions = #{retention_ms => erlang:max(60000, Timeout + 1000), max_buffered_events => 256}, case adk_run:start(Runner, UserId, SessionId, Message, RunOptions) of {ok, RunId} -> console_outcome( RunId, adk_run:await(RunId, Timeout + 1000), State#{turns => Turns + 1}); {error, Reason} -> console_write_json( Io, #{status => error, reason => {run_start_failed, public_reason(Reason)}}), console_loop(State) end. console_resume(_ResponseJson, State = #{paused_run_id := undefined, io := Io}) -> console_write_json(Io, #{status => error, reason => no_paused_run}), console_loop(State); console_resume(ResponseJson, State = #{paused_run_id := RunId, timeout := Timeout, io := Io}) -> case decode_json_binary(ResponseJson) of {ok, Response} -> case adk_run:resume(RunId, Response) of {ok, ResumedRunId} -> console_outcome( ResumedRunId, adk_run:await(ResumedRunId, Timeout + 1000), State); {error, Reason} -> console_write_json( Io, #{status => error, reason => {resume_failed, public_reason(Reason)}}), console_loop(State) end; {error, _} -> console_write_json(Io, #{status => error, reason => invalid_resume_json}), console_loop(State) end. console_outcome(_RunId, {completed, Text}, State = #{io := Io}) -> console_write(Io, <<(safe_binary(Text))/binary, "\n">>), console_loop(State#{paused_run_id => undefined}); console_outcome(RunId, {paused, Event}, State = #{io := Io}) -> Encoded = case adk_event:encode(Event) of {ok, Value} -> Value; {error, _} -> #{<<"encoding_error">> => true} end, console_write_json( Io, #{status => paused, run_id => RunId, event => Encoded, next => <<"/resume JSON">>}), console_loop(State#{paused_run_id => RunId}); console_outcome(_RunId, {cancelled, Reason}, State = #{io := Io}) -> console_write_json(Io, #{status => cancelled, reason => public_reason(Reason)}), console_loop(State#{paused_run_id => undefined}); console_outcome(_RunId, {failed, Reason}, State = #{io := Io}) -> console_write_json(Io, #{status => failed, reason => public_reason(Reason)}), console_loop(State#{paused_run_id => undefined}); console_outcome(RunId, {error, timeout}, State = #{io := Io}) -> _ = adk_run:cancel(RunId, cli_timeout), console_write_json(Io, #{status => error, reason => timeout}), console_loop(State#{paused_run_id => undefined}); console_outcome(_RunId, Other, State = #{io := Io}) -> console_write_json(Io, #{status => error, reason => public_reason(Other)}), console_loop(State#{paused_run_id => undefined}). console_inspect(#{app_name := App, user_id := User, session_id := Session, io := Io}) -> case erlang_adk_session:get_session(App, User, Session) of {ok, Stored} -> console_write_json(Io, Stored); {error, not_found} -> console_write_json(Io, #{status => not_found, session_id => Session}); {error, Reason} -> console_write_json(Io, #{status => error, reason => public_reason(Reason)}) end. console_action(<<>>) -> empty; console_action(<<"/exit">>) -> exit; console_action(<<"/quit">>) -> exit; console_action(<<"/help">>) -> help; console_action(<<"/inspect">>) -> inspect; console_action(<<"/new">>) -> new_session; console_action(<<"/session ", Session0/binary>>) -> case trim_binary(Session0) of <<>> -> {error, invalid_session_id}; Session when byte_size(Session) =< 4096 -> {switch_session, Session}; _ -> {error, invalid_session_id} end; console_action(<<"/resume ", Response0/binary>>) -> case trim_binary(Response0) of <<>> -> {error, missing_resume_json}; Response -> {resume, Response} end; console_action(<<"/", _/binary>>) -> {error, unknown_console_command}; console_action(Message) when byte_size(Message) =< ?MAX_CONFIG_BYTES -> {message, Message}; console_action(_Message) -> {error, message_too_large}. console_help() -> <<"/help show commands\n" "/inspect print the current session\n" "/new switch to a generated session\n" "/session SESSION_ID switch sessions\n" "/resume JSON resume the last paused run\n" "/exit close the console\n">>. console_summary(State, Outcome) -> {ok, #{command => console, outcome => Outcome, app_name => maps:get(app_name, State), user_id => maps:get(user_id, State), session_id => maps:get(session_id, State), turns => maps:get(turns, State)}}. default_console_io() -> #{read => fun(Prompt) -> case io:get_line(binary_to_list(Prompt)) of eof -> eof; {error, _} = Error -> Error; Line -> {ok, unicode:characters_to_binary(Line)} end end, write => fun(Text) -> io:put_chars(Text), ok end}. validate_console_io(#{read := Read, write := Write}) when is_function(Read, 1), is_function(Write, 1) -> ok; validate_console_io(_Io) -> {error, invalid_console_io}. console_read(#{read := Read}, Prompt) -> try Read(Prompt) of eof -> eof; {ok, Value} when is_binary(Value); is_list(Value) -> {ok, safe_binary(Value)}; {error, _} = Error -> Error; _ -> {error, invalid_console_input} catch Class:Reason -> {error, {Class, public_reason(Reason)}} end. console_write(#{write := Write}, Text0) -> Text = safe_binary(Text0), try Write(Text) of _ -> ok catch _:_ -> ok end. console_write_json(Io, Value) -> case adk_json:normalize(adk_secret_redactor:redact(Value)) of {ok, Json} -> console_write(Io, <<(jsx:encode(Json))/binary, "\n">>); {error, _} -> console_write(Io, <<"{\"status\":\"error\"}\n">>) end. trim_binary(Value) -> try string:trim(safe_binary(Value)) of Trimmed when is_binary(Trimmed) -> Trimmed; Trimmed -> safe_binary(Trimmed) catch _:_ -> <<>> end. evaluate_command(Opts) -> with_required(Opts, [config, dataset], fun() -> case {parse_positive_integer( maps:get(timeout, Opts, integer_to_list(?DEFAULT_TIMEOUT)), timeout), parse_positive_integer( maps:get(concurrency, Opts, "1"), concurrency)} of {{ok, Timeout}, {ok, Concurrency}} -> evaluate_loaded(Opts, Timeout, Concurrency); {{error, _} = Error, _} -> Error; {_, {error, _} = Error} -> Error end end). %% Versioned evaluation is a separate command so the historical evaluate %% dataset contract and its output remain unchanged. eval_run_command(Opts) -> with_required( Opts, [config, eval_set], fun() -> case prepare_eval_run(Opts) of {ok, Prepared} -> execute_eval_run(Prepared); {error, _} = Error -> Error end end). prepare_eval_run(Opts) -> case {parse_eval_format(maps:get(format, Opts, "json")), parse_eval_output(maps:get(output, Opts, "-")), parse_eval_option_overrides(Opts), parse_comparison_overrides(Opts)} of {{ok, Format}, {ok, Output}, {ok, EvalOptions}, {ok, ComparisonOptions}} -> case validate_comparison_usage(Opts, ComparisonOptions) of ok -> load_eval_run_inputs( Opts, Format, Output, EvalOptions, ComparisonOptions); {error, _} = Error -> Error end; {{error, _} = Error, _, _, _} -> Error; {_, {error, _} = Error, _, _} -> Error; {_, _, {error, _} = Error, _} -> Error; {_, _, _, {error, _} = Error} -> Error end. load_eval_run_inputs(Opts, Format, Output, EvalOptions, ComparisonOptions) -> case load_agent_file(maps:get(config, Opts)) of {error, _} = Error -> Error; {ok, Agent} -> case load_versioned_eval_set(maps:get(eval_set, Opts)) of {error, _} = Error -> Error; {ok, EvalSet} -> case load_cli_eval_criteria(Opts) of {error, _} = Error -> Error; {ok, Criteria} -> case load_optional_baseline(Opts) of {error, _} = Error -> Error; {ok, Baseline} -> {ok, #{agent => Agent, eval_set => EvalSet, criteria => Criteria, eval_options => EvalOptions, comparison_options => ComparisonOptions, baseline => Baseline, format => Format, output => Output}} end end end end. execute_eval_run(Prepared) -> case ensure_application_started() of {error, _} = Error -> Error; ok -> Agent = maps:get(agent, Prepared), EvalOptions = maps:get(eval_options, Prepared), CaseTimeout = maps:get( case_timeout_ms, EvalOptions, ?DEFAULT_EVAL_CASE_TIMEOUT), Adapter = #{ module => adk_eval_agent_adapter, target => maps:with( [name, config, tools, runner_options], Agent), config => #{run_timeout_ms => CaseTimeout} }, case adk_eval_set:run( Adapter, maps:get(eval_set, Prepared), maps:get(criteria, Prepared), EvalOptions) of {error, Reason} -> {error, {evaluation_failed, Reason}}; {ok, Current} -> finish_eval_run(Prepared, Current) end end. finish_eval_run(Prepared, Current) -> Baseline = maps:get(baseline, Prepared), ComparisonOptions = maps:get(comparison_options, Prepared), case eval_gate_report(Baseline, Current, ComparisonOptions) of {error, _} = Error -> Error; {ok, ReportValue, Comparison, Passed} -> Format = maps:get(format, Prepared), case adk_eval_set:report(ReportValue, Format) of {error, Reason} -> {error, {report_render_failed, Reason}}; {ok, Rendered0} -> Rendered = trailing_newline(Rendered0), case deliver_eval_report( maps:get(output, Prepared), Rendered) of {error, _} = Error -> Error; {ok, Delivery, OutputPath} -> ExitCode = case Passed of true -> 0; false -> 2 end, Base = #{command => eval_run, delivery => Delivery, format => Format, report => Rendered, evaluation => Current, passed => Passed, ci_exit_code => ExitCode}, WithComparison = case Comparison of undefined -> Base; _ -> Base#{comparison => Comparison} end, Result = case OutputPath of undefined -> WithComparison; _ -> WithComparison#{ output_path => OutputPath} end, {ok, Result} end end end. eval_gate_report(undefined, Current, _ComparisonOptions) -> Passed = maps:get(<<"passed">>, Current), {ok, Current, undefined, Passed}; eval_gate_report(Baseline, Current, ComparisonOptions) -> case adk_eval_set:compare(Baseline, Current, ComparisonOptions) of {error, Reason} -> {error, {baseline_comparison_failed, Reason}}; {ok, Comparison} -> EvaluationPassed = maps:get(<<"passed">>, Current), ComparisonPassed = maps:get(<<"passed">>, Comparison), Passed = EvaluationPassed andalso ComparisonPassed, %% Keep the baseline comparison's validated public schema intact. %% The command result carries the combined CI outcome separately. {ok, Comparison, Comparison, Passed} end. parse_eval_format("json") -> {ok, json}; parse_eval_format("markdown") -> {ok, markdown}; parse_eval_format(_) -> {error, invalid_eval_report_format}. parse_eval_output("-") -> {ok, stdout}; parse_eval_output(Path0) -> try unicode:characters_to_list(Path0) of Path when is_list(Path), Path =/= [] -> case lists:member(0, Path) of true -> {error, invalid_eval_output_path}; false -> {ok, {file, Path}} end; _ -> {error, invalid_eval_output_path} catch _:_ -> {error, invalid_eval_output_path} end. parse_eval_option_overrides(Opts) -> Specs = [ {samples, sample_count, positive}, {concurrency, concurrency, positive}, {sample_concurrency, sample_concurrency, positive}, {timeout, timeout_ms, positive}, {case_timeout, case_timeout_ms, positive}, {pass_rate_threshold, pass_rate_threshold, fraction}, {sample_pass_rate_threshold, sample_pass_rate_threshold, fraction}, {min_successful_samples, min_successful_samples, positive}, {empty_criteria, empty_criteria, empty_policy}, {capture_events, capture_events, boolean}, {capture_tool_content, capture_tool_content, boolean}, {max_heap_words, max_heap_words, positive}, {max_report_bytes, max_report_bytes, positive} ], parse_override_specs(Specs, Opts, #{}). parse_comparison_overrides(Opts) -> case parse_override_specs( [{max_pass_rate_drop, max_pass_rate_drop, fraction}], Opts, #{}) of {error, _} = Error -> Error; {ok, Acc} -> case maps:find(metric_tolerances, Opts) of error -> {ok, Acc}; {ok, Path} -> case load_metric_tolerances(Path) of {ok, Tolerances} -> {ok, Acc#{metric_tolerances => Tolerances}}; {error, _} = Error -> Error end end end. parse_override_specs([], _Opts, Acc) -> {ok, Acc}; parse_override_specs([{CliKey, EvalKey, Type} | Rest], Opts, Acc) -> case maps:find(CliKey, Opts) of error -> parse_override_specs(Rest, Opts, Acc); {ok, Value} -> case parse_override_value(Value, CliKey, Type) of {ok, Parsed} -> parse_override_specs( Rest, Opts, Acc#{EvalKey => Parsed}); {error, _} = Error -> Error end end. parse_override_value(Value, Name, positive) -> parse_positive_integer(Value, Name); parse_override_value(Value, Name, fraction) -> parse_fraction(Value, Name); parse_override_value("pass", _Name, empty_policy) -> {ok, pass}; parse_override_value("error", _Name, empty_policy) -> {ok, error}; parse_override_value(_Value, Name, empty_policy) -> {error, {invalid_empty_criteria_policy, Name}}; parse_override_value("true", _Name, boolean) -> {ok, true}; parse_override_value("false", _Name, boolean) -> {ok, false}; parse_override_value(_Value, Name, boolean) -> {error, {invalid_boolean, Name}}. parse_fraction(Value, Name) -> try unicode:characters_to_binary(Value) of Binary -> case fraction_number(Binary) of Number when is_number(Number), Number >= 0, Number =< 1 -> {ok, Number}; _ -> {error, {invalid_fraction, Name}} end catch _:_ -> {error, {invalid_fraction, Name}} end. fraction_number(Binary) -> try binary_to_integer(Binary) of Integer -> Integer catch _:_ -> try binary_to_float(Binary) of Float -> Float catch _:_ -> invalid end end. validate_comparison_usage(Opts, ComparisonOptions) -> case {maps:is_key(baseline, Opts), map_size(ComparisonOptions)} of {false, Size} when Size > 0 -> {error, baseline_required_for_comparison_options}; _ -> ok end. load_versioned_eval_set(Path) -> case read_eval_json_file(Path) of {ok, Value} when is_map(Value) -> case adk_eval_set:decode(Value) of {ok, EvalSet} -> {ok, EvalSet}; {error, Reason} -> {error, {invalid_eval_set, Reason}} end; {ok, _} -> {error, eval_set_must_be_object}; {error, _} = Error -> Error end. load_cli_eval_criteria(Opts) -> case maps:find(criteria, Opts) of error -> {ok, [#{id => <<"response">>, criterion => <<"exact_response">>, threshold => 1.0}]}; {ok, Path} -> case read_json_file(Path) of {ok, Criteria} when is_list(Criteria) -> normalize_cli_criteria(Criteria, 0, []); {ok, #{<<"criteria">> := Criteria} = Wrapper} when is_list(Criteria) -> case maps:keys( maps:without([<<"criteria">>], Wrapper)) of [] -> normalize_cli_criteria(Criteria, 0, []); Unknown -> {error, {unknown_eval_criteria_keys, Unknown}} end; {ok, _} -> {error, eval_criteria_must_be_array}; {error, _} = Error -> Error end end. normalize_cli_criteria([], _Index, Acc) -> {ok, lists:reverse(Acc)}; normalize_cli_criteria([Criterion | Rest], Index, Acc) when is_map(Criterion) -> Allowed = [<<"id">>, <<"criterion">>, <<"threshold">>, <<"kind">>, <<"config">>], Unknown = maps:keys(maps:without(Allowed, Criterion)), Id = maps:get(<<"id">>, Criterion, undefined), Name = maps:get(<<"criterion">>, Criterion, undefined), Threshold = maps:get(<<"threshold">>, Criterion, 1.0), Kind0 = maps:get(<<"kind">>, Criterion, <<"metric">>), Config = maps:get(<<"config">>, Criterion, #{}), case {Unknown, valid_nonempty_binary(Id), builtin_criterion(Name), metric_kind(Kind0), valid_cli_score(Threshold), criterion_config(Config)} of {[], true, true, {ok, Kind}, true, ok} -> Descriptor = #{id => Id, criterion => Name, threshold => Threshold, kind => Kind, config => Config}, normalize_cli_criteria( Rest, Index + 1, [Descriptor | Acc]); {[_ | _], _, _, _, _, _} -> {error, {unknown_eval_criterion_keys, Index, Unknown}}; _ -> {error, {invalid_eval_criterion, Index}} end; normalize_cli_criteria([_ | _], Index, _Acc) -> {error, {invalid_eval_criterion, Index}}. builtin_criterion(<<"exact_response">>) -> true; builtin_criterion(<<"trajectory_exact">>) -> true; builtin_criterion(<<"trajectory_in_order">>) -> true; builtin_criterion(<<"trajectory_any_order">>) -> true; builtin_criterion(<<"trajectory_subset">>) -> true; builtin_criterion(<<"tool_trajectory">>) -> true; builtin_criterion(_) -> false. metric_kind(<<"metric">>) -> {ok, metric}; metric_kind(<<"judge">>) -> {ok, judge}; metric_kind(_) -> error. criterion_config(Config) when is_map(Config) -> Allowed = [<<"normalization">>, <<"args">>, <<"match">>], case maps:keys(maps:without(Allowed, Config)) of [] -> ok; _ -> error end; criterion_config(_) -> error. valid_cli_score(Value) when is_integer(Value) -> Value >= 0 andalso Value =< 1; valid_cli_score(Value) when is_float(Value) -> Value =:= Value andalso Value >= 0.0 andalso Value =< 1.0; valid_cli_score(_) -> false. load_optional_baseline(Opts) -> case maps:find(baseline, Opts) of error -> {ok, undefined}; {ok, Path} -> case read_eval_json_file(Path) of {ok, Value} when is_map(Value) -> case adk_eval_set:decode_result(Value) of {ok, Baseline} -> {ok, Baseline}; {error, Reason} -> {error, {invalid_eval_baseline, Reason}} end; {ok, _} -> {error, eval_baseline_must_be_object}; {error, _} = Error -> Error end end. load_metric_tolerances(Path) -> case read_json_file(Path) of {ok, Value} when is_map(Value) -> case lists:all( fun({Id, Tolerance}) -> valid_nonempty_binary(Id) andalso valid_cli_score(Tolerance) end, maps:to_list(Value)) of true -> {ok, Value}; false -> {error, invalid_metric_tolerances} end; {ok, _} -> {error, metric_tolerances_must_be_object}; {error, _} = Error -> Error end. read_eval_json_file(Path0) -> Path = unicode:characters_to_list(Path0), case file:read_file(Path) of {ok, Binary} when byte_size(Binary) =< ?MAX_EVAL_FILE_BYTES -> decode_json_binary(Binary); {ok, _Binary} -> {error, eval_file_too_large}; {error, Reason} -> {error, {file_read_failed, Reason}} end. deliver_eval_report(stdout, _Rendered) -> {ok, stdout, undefined}; deliver_eval_report({file, Path}, Rendered) -> case atomic_write_eval_report(Path, Rendered) of ok -> {ok, file, unicode:characters_to_binary(Path)}; {error, Reason} -> {error, {report_write_failed, Reason}} end. atomic_write_eval_report(Path, Rendered) -> Suffix = integer_to_list( erlang:unique_integer([positive, monotonic])), Temporary = Path ++ ".tmp-" ++ Suffix, case file:write_file(Temporary, Rendered, [binary, exclusive]) of ok -> case file:rename(Temporary, Path) of ok -> ok; {error, Reason} -> _ = file:delete(Temporary), {error, Reason} end; {error, Reason} -> {error, Reason} end. trailing_newline(<<>>) -> <<"\n">>; trailing_newline(Binary) -> case binary:last(Binary) of $\n -> Binary; _ -> <> end. evaluate_loaded(Opts, Timeout, Concurrency) -> case {load_agent_file(maps:get(config, Opts)), load_dataset(maps:get(dataset, Opts))} of {{ok, Agent}, {ok, Dataset}} -> case ensure_application_started() of ok -> execute_evaluation(Agent, Dataset, Timeout, Concurrency); {error, _} = Error -> Error end; {{error, _} = Error, _} -> Error; {_, {error, _} = Error} -> Error end. execute_evaluation(Agent, Dataset, Timeout, Concurrency) -> case erlang_adk:spawn_agent( maps:get(name, Agent), maps:get(config, Agent), maps:get(tools, Agent)) of {error, Reason} -> {error, {agent_start_failed, public_reason(Reason)}}; {ok, AgentPid} -> try Metric = fun(Expected, Actual) -> case safe_binary(Expected) =:= safe_binary(Actual) of true -> 1.0; false -> 0.0 end end, case adk_eval:run( AgentPid, Dataset, Metric, #{timeout => Timeout, concurrency => Concurrency}) of {ok, Report} -> {ok, #{command => evaluate, report => Report}}; {error, Reason} -> {error, {evaluation_failed, public_reason(Reason)}} end after _ = catch erlang_adk:stop_agent(AgentPid) end end. serve_command(Opts) -> case {parse_port(maps:get(port, Opts, "8080")), parse_loopback_ip(maps:get(ip, Opts, "127.0.0.1")), developer_token()} of {{ok, Port}, {ok, Ip}, ok} -> case prepare_developer_application(Port, Ip) of ok -> maybe_start_served_agent(Opts, Port, Ip); {error, _} = Error -> Error end; {{error, _} = Error, _, _} -> Error; {_, {error, _} = Error, _} -> Error; {_, _, {error, _} = Error} -> Error end. maybe_start_served_agent(Opts, Port, Ip) -> case maps:find(config, Opts) of error -> {ok, serve_result(Port, Ip, undefined)}; {ok, Path} -> case load_agent_file(Path) of {error, _} = Error -> Error; {ok, Agent} -> case erlang_adk:spawn_agent( maps:get(name, Agent), maps:get(config, Agent), maps:get(tools, Agent)) of {ok, _Pid} -> {ok, serve_result( Port, Ip, maps:get(name, Agent))}; {error, Reason} -> {error, {agent_start_failed, public_reason(Reason)}} end end end. serve_result(Port, Ip, AgentName) -> IpText = unicode:characters_to_binary(inet:ntoa(Ip)), Base = <<"http://", IpText/binary, ":", (integer_to_binary(Port))/binary>>, #{command => serve, status => listening, url => <>, api_url => <>, agent_name => AgentName, note => <<"Runs survive browser disconnects; Ctrl-C stops this VM">>}. inspect_run(RunId0, Opts) -> RunId = safe_binary(RunId0), remote_json(get, <<"/dev/v1/runs/", (quote(RunId))/binary>>, undefined, Opts). inspect_agents(Opts) -> remote_json(get, <<"/dev/v1/agents">>, undefined, Opts). inspect_diagnostics(Opts) -> remote_json(get, <<"/dev/v1/diagnostics">>, undefined, Opts). inspect_observability(Opts) -> remote_json(get, <<"/dev/v1/observability">>, undefined, Opts). inspect_live_sessions(Opts) -> remote_json(get, <<"/dev/v1/live/sessions">>, undefined, Opts). send_remote_live_text(SessionId0, Opts) -> with_required( Opts, [text], fun() -> SessionId = safe_binary(SessionId0), Text = option_binary(Opts, text, <<>>), case valid_nonempty_binary(SessionId) andalso valid_nonempty_binary(Text) of true -> remote_json( post, <<"/dev/v1/live/sessions/", (quote(SessionId))/binary, "/text">>, jsx:encode(#{<<"text">> => Text}), Opts); false -> {error, invalid_live_text_command} end end). inspect_sessions(App0, User0, Opts) -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), remote_json( get, <<"/dev/v1/sessions/", App/binary, "/", User/binary>>, undefined, Opts). inspect_session(App0, User0, Session0, Opts) -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Session = quote(safe_binary(Session0)), remote_json( get, <<"/dev/v1/sessions/", App/binary, "/", User/binary, "/", Session/binary>>, undefined, Opts). inspect_context(App0, User0, Session0, Opts) -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Session = quote(safe_binary(Session0)), remote_json( get, <<"/dev/v1/context/", App/binary, "/", User/binary, "/", Session/binary>>, undefined, Opts). inspect_context_lifecycle(App0, User0, Session0, Opts) -> with_required(Opts, [model], fun() -> Model = option_binary(Opts, model, <<>>), case valid_nonempty_binary(Model) of false -> {error, invalid_context_cache_model}; true -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Session = quote(safe_binary(Session0)), remote_json( get, <<"/dev/v1/context/", App/binary, "/", User/binary, "/", Session/binary, "/lifecycle?model=", (quote(Model))/binary>>, undefined, Opts) end end). invalidate_remote_context_cache(App0, User0, Session0, Opts) -> with_required(Opts, [model, confirm_json], fun() -> AppRaw = safe_binary(App0), UserRaw = safe_binary(User0), SessionRaw = safe_binary(Session0), Model = option_binary(Opts, model, <<>>), case valid_nonempty_binary(Model) of false -> {error, invalid_context_cache_model}; true -> case inspect_context_lifecycle( AppRaw, UserRaw, SessionRaw, Opts) of {ok, Lifecycle} -> checked_remote_context_cache_invalidation( AppRaw, UserRaw, SessionRaw, Model, Lifecycle, Opts); {error, _} = Error -> Error end end end). checked_remote_context_cache_invalidation(App, User, Session, Model, Lifecycle, Opts) -> Cache = maps:get(<<"cache">>, Lifecycle, #{}), Fingerprint = maps:get(<<"scope_fingerprint">>, Cache, undefined), Expected = #{<<"app_name">> => App, <<"user_id">> => User, <<"session_id">> => Session, <<"model">> => Model, <<"scope_fingerprint">> => Fingerprint}, case is_binary(Fingerprint) andalso checked_confirmation(Opts, Expected) =:= ok of false -> {error, confirmation_does_not_match_target}; true -> AppPath = quote(App), UserPath = quote(User), SessionPath = quote(Session), Payload = #{<<"model">> => Model, <<"confirm">> => Expected}, remote_json( post, <<"/dev/v1/context/", AppPath/binary, "/", UserPath/binary, "/", SessionPath/binary, "/cache/invalidate">>, jsx:encode(Payload), Opts) end. inspect_artifacts(App0, User0, Session0, Opts) -> case resource_page_query(Opts, name) of {error, _} = Error -> Error; {ok, Query} -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Session = quote(safe_binary(Session0)), remote_json( get, <<"/dev/v1/artifacts/", App/binary, "/", User/binary, "/", Session/binary, Query/binary>>, undefined, Opts) end. inspect_artifact_versions(App0, User0, Session0, Opts) -> with_required(Opts, [name], fun() -> Name = option_binary(Opts, name, <<>>), case {valid_nonempty_binary(Name), resource_page_query(Opts, version)} of {true, {ok, PageQuery}} -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Session = quote(safe_binary(Session0)), NameQuery = <<"?name=", (quote(Name))/binary>>, Query = append_query(NameQuery, PageQuery), remote_json( get, <<"/dev/v1/artifacts/", App/binary, "/", User/binary, "/", Session/binary, "/versions", Query/binary>>, undefined, Opts); {false, _} -> {error, invalid_artifact_name}; {_, {error, _} = Error} -> Error end end). inspect_memory(App0, User0, Opts) -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), remote_json( get, <<"/dev/v1/memory/", App/binary, "/", User/binary>>, undefined, Opts). search_remote_memory(App0, User0, Opts) -> with_required(Opts, [query], fun() -> Query = option_binary(Opts, query, <<>>), case {valid_nonempty_binary(Query), remote_memory_options(Opts)} of {true, {ok, SearchOptions}} -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Payload = SearchOptions#{<<"query">> => Query}, remote_json( post, <<"/dev/v1/memory/", App/binary, "/", User/binary, "/search">>, jsx:encode(Payload), Opts); {false, _} -> {error, invalid_memory_query}; {_, {error, _} = Error} -> Error end end). delete_remote_artifact(App0, User0, Session0, Name0, Selector0, Opts) -> with_required(Opts, [confirm_json], fun() -> AppRaw = safe_binary(App0), UserRaw = safe_binary(User0), SessionRaw = safe_binary(Session0), Name = safe_binary(Name0), case cli_artifact_selector(Selector0) of {error, _} = Error -> Error; {ok, Selector} -> Expected = #{<<"app_name">> => AppRaw, <<"user_id">> => UserRaw, <<"session_id">> => SessionRaw, <<"name">> => Name, <<"selector">> => Selector}, case checked_confirmation(Opts, Expected) of ok -> App = quote(AppRaw), User = quote(UserRaw), Session = quote(SessionRaw), Payload = #{<<"name">> => Name, <<"selector">> => Selector, <<"confirm">> => Expected}, remote_json( post, <<"/dev/v1/artifacts/", App/binary, "/", User/binary, "/", Session/binary, "/delete">>, jsx:encode(Payload), Opts); {error, _} = Error -> Error end end end). erase_remote_memory(App0, User0, Target, Identifier0, Opts) -> with_required(Opts, [confirm_json], fun() -> AppRaw = safe_binary(App0), UserRaw = safe_binary(User0), Identifier = safe_binary(Identifier0), TargetBin = atom_to_binary(Target, utf8), Expected = #{<<"app_name">> => AppRaw, <<"user_id">> => UserRaw, <<"target">> => TargetBin, <<"identifier">> => Identifier}, case checked_confirmation(Opts, Expected) of ok -> App = quote(AppRaw), User = quote(UserRaw), Payload0 = #{<<"target">> => TargetBin, <<"confirm">> => Expected}, Payload = case Target of entry -> Payload0#{<<"id">> => Identifier}; session -> Payload0#{<<"session_id">> => Identifier}; user -> Payload0 end, remote_json( post, <<"/dev/v1/memory/", App/binary, "/", User/binary, "/erase">>, jsx:encode(Payload), Opts); {error, _} = Error -> Error end end). resource_page_query(Opts, CursorType) -> case cli_page_limit(Opts) of {error, _} = Error -> Error; {ok, LimitPart} -> case maps:find(cursor, Opts) of error -> {ok, query_parts(LimitPart)}; {ok, Cursor0} -> Cursor = safe_binary(Cursor0), case valid_cli_cursor(CursorType, Cursor) of true -> {ok, query_parts( LimitPart ++ [{<<"cursor">>, quote(Cursor)}])}; false -> {error, invalid_resource_cursor} end end end. cli_page_limit(Opts) -> case maps:find(limit, Opts) of error -> {ok, []}; {ok, Value} -> case parse_positive_integer(Value, limit) of {ok, Integer} when Integer =< 1000 -> {ok, [{<<"limit">>, integer_to_binary(Integer)}]}; _ -> {error, invalid_resource_limit} end end. valid_cli_cursor(name, Cursor) -> valid_nonempty_binary(Cursor); valid_cli_cursor(version, Cursor) -> case positive_binary_integer(Cursor) of {ok, _} -> true; _ -> false end. query_parts([]) -> <<>>; query_parts(Parts) -> Encoded = [<>, Encoded)))/binary>>. append_query(NameQuery, <<>>) -> NameQuery; append_query(NameQuery, <<"?", Rest/binary>>) -> <>. remote_memory_options(Opts) -> case {remote_memory_filter(Opts), cli_page_limit(Opts)} of {{ok, Filter}, {ok, LimitParts}} -> Base = case map_size(Filter) of 0 -> #{}; _ -> #{<<"filter">> => Filter} end, case LimitParts of [] -> {ok, Base}; [{<<"limit">>, LimitBin}] -> {ok, Limit} = positive_binary_integer(LimitBin), {ok, Base#{<<"limit">> => Limit}} end; {{error, _} = Error, _} -> Error; {_, {error, _} = Error} -> Error end. remote_memory_filter(Opts) -> case maps:find(filter_json, Opts) of error -> {ok, #{}}; {ok, Json0} -> case decode_json_binary(safe_binary(Json0)) of {ok, Filter} when is_map(Filter) -> {ok, Filter}; _ -> {error, memory_filter_must_be_object} end end. cli_artifact_selector("all") -> {ok, <<"all">>}; cli_artifact_selector("latest") -> {ok, <<"latest">>}; cli_artifact_selector(Value) -> case parse_positive_integer(Value, selector) of {ok, Version} -> {ok, Version}; _ -> {error, invalid_artifact_selector} end. positive_binary_integer(Value) when is_binary(Value) -> try binary_to_integer(Value) of Integer when Integer > 0 -> {ok, Integer}; _ -> {error, invalid_positive_integer} catch _:_ -> {error, invalid_positive_integer} end. checked_confirmation(Opts, Expected) -> Json = option_binary(Opts, confirm_json, <<>>), case decode_json_binary(Json) of {ok, Expected} -> ok; _ -> {error, confirmation_does_not_match_target} end. create_remote_session(App0, User0, Session0, Opts) -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Session = safe_binary(Session0), remote_json( post, <<"/dev/v1/sessions/", App/binary, "/", User/binary>>, jsx:encode(#{<<"session_id">> => Session}), Opts). delete_remote_session(App0, User0, Session0, Opts) -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Session = quote(safe_binary(Session0)), remote_json( delete, <<"/dev/v1/sessions/", App/binary, "/", User/binary, "/", Session/binary>>, undefined, Opts). update_remote_session_state(App0, User0, Session0, Opts) -> with_required(Opts, [delta_json], fun() -> DeltaJson = unicode:characters_to_binary( maps:get(delta_json, Opts)), case decode_json_binary(DeltaJson) of {ok, Delta} when is_map(Delta), map_size(Delta) > 0 -> App = quote(safe_binary(App0)), User = quote(safe_binary(User0)), Session = quote(safe_binary(Session0)), remote_json( post, <<"/dev/v1/sessions/", App/binary, "/", User/binary, "/", Session/binary, "/state">>, jsx:encode(#{<<"state_delta">> => Delta}), Opts); _ -> {error, state_delta_must_be_nonempty_object} end end). cancel_remote_run(RunId0, Opts) -> RunId = quote(safe_binary(RunId0)), remote_json(delete, <<"/dev/v1/runs/", RunId/binary>>, undefined, Opts). resume_remote_run(RunId0, Opts) -> with_required(Opts, [response_json], fun() -> case decode_json_binary( unicode:characters_to_binary( maps:get(response_json, Opts))) of {ok, Response} -> RunId = quote(safe_binary(RunId0)), remote_json( post, <<"/dev/v1/runs/", RunId/binary, "/resume">>, jsx:encode(#{<<"tool_response">> => Response}), Opts); {error, _} = Error -> Error end end). remote_json(Method, Path, Body, Opts) -> Base0 = option_binary(Opts, base_url, ?DEFAULT_BASE_URL), case validate_base_url(Base0) of {error, _} = Error -> Error; {ok, Base} -> case developer_token_value() of {error, _} = Error -> Error; {ok, Token} -> case safe_developer_request( Method, Base, Path, Body, Token) of {ok, Status, ResponseBody} when Status >= 200, Status < 300 -> case decode_json_binary(ResponseBody) of {ok, Json} -> {ok, Json}; {error, _} -> {error, invalid_developer_api_response} end; {ok, Status, ResponseBody} -> {error, {developer_api_http_error, Status, public_http_error(ResponseBody)}}; {error, {developer_response_too_large, Status}} when Status >= 200, Status < 300 -> {error, invalid_developer_api_response}; {error, {developer_response_too_large, Status}} -> {error, {developer_api_http_error, Status, <<"developer_api_request_failed">>}}; {error, Reason} -> {error, developer_api_transport_error(Reason)} end end end. %% `httpc' only streams 200 and 206 responses; all other status bodies are %% accumulated internally before the caller can enforce a limit. Gun's %% per-stream flow credit lets the CLI apply the same hard cap to success and %% error responses while retaining verified TLS for HTTPS endpoints. safe_developer_request(Method, Base, Path, Body, Token) -> try bounded_developer_request(Method, Base, Path, Body, Token) of Result -> Result catch Class:Reason:_Stacktrace -> {error, {http_client_exception, Class, Reason}} end. bounded_developer_request(Method, Base, Path, Body, Token) -> case application:ensure_all_started(gun) of {ok, _} -> case developer_endpoint(Base, Path) of {ok, Endpoint} -> Deadline = developer_deadline(), open_developer_connection( Endpoint, Method, Body, Token, Deadline); {error, _} = Error -> Error end; {error, Reason} -> {error, {gun_start_failed, Reason}} end. developer_endpoint(Base, Path) -> try uri_string:parse(Base) of #{scheme := Scheme0, host := Host0} = Parsed -> Scheme = safe_binary(Scheme0), Host = safe_binary(Host0), Port = maps:get( port, Parsed, case Scheme of <<"https">> -> 443; <<"http">> -> 80 end), BasePath = safe_binary(maps:get(path, Parsed, <<>>)), {ok, #{scheme => Scheme, host => Host, port => Port, path => <>}}; _ -> {error, invalid_base_url} catch _:_ -> {error, invalid_base_url} end. open_developer_connection(Endpoint, Method, Body, Token, Deadline) -> case developer_gun_options(Endpoint, Deadline) of {error, _} = Error -> Error; {ok, GunOptions} -> Host = binary_to_list(maps:get(host, Endpoint)), case gun:open(Host, maps:get(port, Endpoint), GunOptions) of {ok, Connection} -> try await_developer_connection( Connection, Endpoint, Method, Body, Token, Deadline) after _ = catch gun:close(Connection) end; {error, Reason} -> {error, Reason} end end. await_developer_connection(Connection, Endpoint, Method, Body, Token, Deadline) -> case gun:await_up(Connection, developer_remaining(Deadline)) of {ok, http} -> Headers0 = [{<<"authorization">>, <<"Bearer ", Token/binary>>}, {<<"accept">>, <<"application/json">>}], Headers = case Method of post -> [{<<"content-type">>, <<"application/json">>} | Headers0]; _ -> Headers0 end, RequestBody = case Body of undefined -> <<>>; _ -> Body end, Stream = gun:request( Connection, developer_method(Method), maps:get(path, Endpoint), Headers, RequestBody, #{flow => 1}), await_developer_response(Connection, Stream, Deadline); {ok, _OtherProtocol} -> {error, unsupported_protocol}; {error, timeout} -> {error, timeout}; {error, Reason} -> {error, Reason} end. developer_method(get) -> <<"GET">>; developer_method(delete) -> <<"DELETE">>; developer_method(post) -> <<"POST">>. await_developer_response(Connection, Stream, Deadline) -> case gun:await(Connection, Stream, developer_remaining(Deadline)) of {inform, _Status, _Headers} -> await_developer_response(Connection, Stream, Deadline); {response, fin, Status, _Headers} -> {ok, Status, <<>>}; {response, nofin, Status, Headers} -> case declared_body_too_large(Headers) of true -> cancel_developer_stream(Connection, Stream), {error, {developer_response_too_large, Status}}; false -> collect_developer_body( Connection, Stream, Status, Deadline, [], 0) end; {error, timeout} -> cancel_developer_stream(Connection, Stream), {error, timeout}; {error, Reason} -> {error, Reason}; _ -> {error, invalid_http_response} end. collect_developer_body(Connection, Stream, Status, Deadline, Acc, Size) -> case gun:await(Connection, Stream, developer_remaining(Deadline)) of {data, Fin, Chunk} when is_binary(Chunk) -> NewSize = Size + byte_size(Chunk), case NewSize =< ?MAX_DEVELOPER_RESPONSE_BYTES of false -> cancel_developer_stream(Connection, Stream), {error, {developer_response_too_large, Status}}; true when Fin =:= fin -> {ok, Status, iolist_to_binary(lists:reverse([Chunk | Acc]))}; true -> ok = gun:update_flow(Connection, Stream, 1), collect_developer_body( Connection, Stream, Status, Deadline, [Chunk | Acc], NewSize) end; {trailers, _Headers} -> {ok, Status, iolist_to_binary(lists:reverse(Acc))}; {error, timeout} -> cancel_developer_stream(Connection, Stream), {error, timeout}; {error, Reason} -> {error, Reason}; _ -> {error, invalid_http_response} end. declared_body_too_large(Headers) -> lists:any( fun({Name0, Value0}) -> Name = string:lowercase(safe_binary(Name0)), case Name of <<"content-length">> -> case parse_content_length(safe_binary(Value0)) of {ok, Length} -> Length > ?MAX_DEVELOPER_RESPONSE_BYTES; error -> false end; _ -> false end; (_) -> false end, Headers). parse_content_length(Value) -> try binary_to_integer(string:trim(Value)) of Length when Length >= 0 -> {ok, Length}; _ -> error catch _:_ -> error end. cancel_developer_stream(Connection, Stream) -> _ = catch gun:cancel(Connection, Stream), ok. developer_gun_options(#{scheme := <<"http">>}, Deadline) -> {ok, #{transport => tcp, protocols => [http], retry => 0, connect_timeout => developer_remaining(Deadline)}}; developer_gun_options(#{scheme := <<"https">>, host := Host}, Deadline) -> case developer_ca_options() of {ok, CaOptions} -> HostString = binary_to_list(Host), {ok, #{transport => tls, protocols => [http], retry => 0, connect_timeout => developer_remaining(Deadline), tls_handshake_timeout => developer_remaining(Deadline), tls_opts => [{verify, verify_peer} | CaOptions] ++ [{server_name_indication, HostString}, {customize_hostname_check, [{match_fun, public_key: pkix_verify_hostname_match_fun(https)}]}]}}; {error, _} = Error -> Error end. developer_ca_options() -> try public_key:cacerts_get() of Certs when is_list(Certs), Certs =/= [] -> {ok, [{cacerts, Certs}]}; _ -> developer_fallback_ca_file() catch _:_ -> developer_fallback_ca_file() end. developer_fallback_ca_file() -> Environment = case os:getenv("SSL_CERT_FILE") of false -> []; Value -> [Value] end, Candidates = Environment ++ ["/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt", "/opt/homebrew/etc/ca-certificates/cert.pem", "/usr/local/etc/openssl@3/cert.pem", "/usr/local/etc/openssl/cert.pem"], case lists:dropwhile( fun(File) -> not filelib:is_regular(File) end, Candidates) of [File | _] -> {ok, [{cacertfile, File}]}; [] -> {error, ca_certificates_unavailable} end. developer_deadline() -> erlang:monotonic_time(millisecond) + ?DEVELOPER_HTTP_TIMEOUT. developer_remaining(Deadline) -> max(0, Deadline - erlang:monotonic_time(millisecond)). load_agent_file(Path) -> case read_json_file(Path) of {ok, Value} when is_map(Value) -> build_agent_config(Value); {ok, _} -> {error, agent_config_must_be_object}; {error, _} = Error -> Error end. build_agent_config(Json) -> Allowed = [<<"name">>, <<"provider">>, <<"model">>, <<"instructions">>, <<"global_instruction">>, <<"input_schema">>, <<"output_schema">>, <<"output_key">>, <<"include_contents">>, <<"history_policy">>, <<"generation_config">>, <<"temperature">>, <<"top_p">>, <<"top_k">>, <<"max_tokens">>, <<"candidate_count">>, <<"max_output_tokens">>, <<"seed">>, <<"presence_penalty">>, <<"frequency_penalty">>, <<"stop_sequences">>, <<"response_mime_type">>, <<"response_schema">>, <<"thinking_config">>, <<"safety_settings">>, <<"builtin_tools">>, <<"required_capabilities">>, <<"instruction_timeout_ms">>, <<"artifact_timeout_ms">>, <<"max_instruction_bytes">>, <<"request_timeout">>, <<"response">>, <<"tools">>, <<"runner_options">>], Unknown = maps:keys(maps:without(Allowed, Json)), case {Unknown, contains_forbidden_secret(Json)} of {_, true} -> {error, secret_in_config_file}; {[_ | _], false} -> {error, {unknown_agent_config_keys, Unknown}}; {[], false} -> build_checked_agent(Json) end. build_checked_agent(Json) -> Name = maps:get(<<"name">>, Json, <<"CliAgent">>), ProviderName = maps:get(<<"provider">>, Json, <<"gemini">>), Model = maps:get(<<"model">>, Json, ?DEFAULT_MODEL), case {valid_nonempty_binary(Name), provider_module(ProviderName), tools_from_json(maps:get(<<"tools">>, Json, [])), runner_options_from_json( maps:get(<<"runner_options">>, Json, #{}))} of {true, {ok, Provider}, {ok, Tools}, {ok, RunnerOptions}} -> Config0 = maps:fold( fun agent_config_field/3, #{provider => Provider, model => Model}, Json), case adk_llm:validate_config(Config0) of ok -> {ok, #{name => Name, provider_name => ProviderName, provider => Provider, model => Model, config => Config0, tools => Tools, runner_options => RunnerOptions}}; {error, Reason} -> {error, {invalid_provider_config, public_reason(Reason)}} end; {false, _, _, _} -> {error, invalid_agent_name}; {_, {error, _} = Error, _, _} -> Error; {_, _, {error, _} = Error, _} -> Error; {_, _, _, {error, _} = Error} -> Error end. agent_config_field(<<"name">>, _Value, Acc) -> Acc; agent_config_field(<<"provider">>, _Value, Acc) -> Acc; agent_config_field(<<"tools">>, _Value, Acc) -> Acc; agent_config_field(<<"runner_options">>, _Value, Acc) -> Acc; agent_config_field(<<"include_contents">>, Value, Acc) -> Acc#{include_contents => cli_history_value(Value)}; agent_config_field(<<"history_policy">>, Value, Acc) -> Acc#{history_policy => cli_history_value(Value)}; agent_config_field(<<"generation_config">>, Value, Acc) -> Acc#{generation_config => cli_generation_config(Value)}; agent_config_field(<<"thinking_config">>, Value, Acc) -> Acc#{thinking_config => cli_thinking_config(Value)}; agent_config_field(<<"safety_settings">>, Value, Acc) -> Acc#{safety_settings => cli_safety_settings(Value)}; agent_config_field(<<"builtin_tools">>, Value, Acc) -> Acc#{builtin_tools => cli_builtin_tools(Value)}; agent_config_field(<<"required_capabilities">>, Value, Acc) -> Acc#{required_capabilities => cli_capabilities(Value)}; agent_config_field(Key, Value, Acc) -> case config_atom(Key) of undefined -> Acc; AtomKey -> Acc#{AtomKey => Value} end. config_atom(<<"model">>) -> model; config_atom(<<"instructions">>) -> instructions; config_atom(<<"global_instruction">>) -> global_instruction; config_atom(<<"input_schema">>) -> input_schema; config_atom(<<"output_schema">>) -> output_schema; config_atom(<<"output_key">>) -> output_key; config_atom(<<"temperature">>) -> temperature; config_atom(<<"top_p">>) -> top_p; config_atom(<<"top_k">>) -> top_k; config_atom(<<"max_tokens">>) -> max_tokens; config_atom(<<"max_output_tokens">>) -> max_output_tokens; config_atom(<<"candidate_count">>) -> candidate_count; config_atom(<<"seed">>) -> seed; config_atom(<<"presence_penalty">>) -> presence_penalty; config_atom(<<"frequency_penalty">>) -> frequency_penalty; config_atom(<<"stop_sequences">>) -> stop_sequences; config_atom(<<"response_mime_type">>) -> response_mime_type; config_atom(<<"response_schema">>) -> response_schema; config_atom(<<"instruction_timeout_ms">>) -> instruction_timeout_ms; config_atom(<<"artifact_timeout_ms">>) -> artifact_timeout_ms; config_atom(<<"max_instruction_bytes">>) -> max_instruction_bytes; config_atom(<<"request_timeout">>) -> request_timeout; config_atom(<<"response">>) -> response; config_atom(_) -> undefined. cli_history_value(<<"default">>) -> default; cli_history_value(<<"none">>) -> none; cli_history_value(<<"include">>) -> include; cli_history_value(<<"exclude">>) -> exclude; cli_history_value(Value) -> Value. cli_generation_config(Value) when is_map(Value) -> maps:fold( fun(Key, NestedValue, Acc) -> case cli_generation_key(Key) of undefined -> Acc#{Key => NestedValue}; thinking_config -> Acc#{thinking_config => cli_thinking_config(NestedValue)}; safety_settings -> Acc#{safety_settings => cli_safety_settings(NestedValue)}; Atom -> Acc#{Atom => NestedValue} end end, #{}, Value); cli_generation_config(Value) -> Value. cli_generation_key(<<"temperature">>) -> temperature; cli_generation_key(<<"top_p">>) -> top_p; cli_generation_key(<<"top_k">>) -> top_k; cli_generation_key(<<"max_tokens">>) -> max_tokens; cli_generation_key(<<"max_output_tokens">>) -> max_output_tokens; cli_generation_key(<<"candidate_count">>) -> candidate_count; cli_generation_key(<<"seed">>) -> seed; cli_generation_key(<<"presence_penalty">>) -> presence_penalty; cli_generation_key(<<"frequency_penalty">>) -> frequency_penalty; cli_generation_key(<<"stop_sequences">>) -> stop_sequences; cli_generation_key(<<"response_mime_type">>) -> response_mime_type; cli_generation_key(<<"thinking_config">>) -> thinking_config; cli_generation_key(<<"safety_settings">>) -> safety_settings; cli_generation_key(_) -> undefined. cli_thinking_config(Value) when is_map(Value) -> maps:fold( fun(<<"thinking_level">>, Nested, Acc) -> Acc#{thinking_level => Nested}; (<<"thinking_budget">>, Nested, Acc) -> Acc#{thinking_budget => Nested}; (<<"include_thoughts">>, Nested, Acc) -> Acc#{include_thoughts => Nested}; (Key, Nested, Acc) -> Acc#{Key => Nested} end, #{}, Value); cli_thinking_config(Value) -> Value. cli_safety_settings(Settings) when is_list(Settings) -> [cli_safety_setting(Setting) || Setting <- Settings]; cli_safety_settings(Value) -> Value. cli_safety_setting(Setting) when is_map(Setting) -> maps:fold( fun(<<"category">>, Value, Acc) -> Acc#{category => Value}; (<<"threshold">>, Value, Acc) -> Acc#{threshold => Value}; (Key, Value, Acc) -> Acc#{Key => Value} end, #{}, Setting); cli_safety_setting(Value) -> Value. cli_builtin_tools(Values) when is_list(Values) -> [cli_builtin_tool(Value) || Value <- Values]; cli_builtin_tools(Value) -> Value. cli_builtin_tool(<<"google_search">>) -> google_search; cli_builtin_tool(Value) -> Value. cli_capabilities(Values) when is_list(Values) -> [cli_capability(Value) || Value <- Values]; cli_capabilities(Value) -> Value. cli_capability(<<"streaming">>) -> streaming; cli_capability(<<"function_calling">>) -> function_calling; cli_capability(<<"structured_output">>) -> structured_output; cli_capability(<<"generation_config">>) -> generation_config; cli_capability(<<"multimodal">>) -> multimodal; cli_capability(<<"thinking">>) -> thinking; cli_capability(<<"safety_settings">>) -> safety_settings; cli_capability(<<"content_streaming">>) -> content_streaming; cli_capability(<<"google_search_grounding">>) -> google_search_grounding; cli_capability(Value) -> Value. provider_module(Name) when is_binary(Name) -> %% An operator-owned profile deliberately takes precedence over the four %% convenient built-in CLI aliases. This lets a JSON config select the %% documented `compatible' profile without accepting a caller-controlled %% URL, credential, or header in that file. With no matching profile the %% legacy aliases retain their existing fixed-adapter behavior. case configured_provider_profile(Name) of true -> profile_provider_module(Name); false -> fallback_provider_module(Name) end; provider_module(_Name) -> {error, invalid_provider_name}. configured_provider_profile(Name) -> case application:get_env(erlang_adk, provider_profiles, #{}) of Profiles when is_map(Profiles) -> maps:is_key(Name, Profiles); _InvalidProfiles -> false end. profile_provider_module(Name) -> case adk_provider_registry:lookup(Name) of {ok, #{request_adapter := _}} -> {ok, Name}; {ok, _LiveOnlyProfile} -> {error, {invalid_provider, Name, request_capability_unavailable}}; {error, unknown_provider_profile} -> legacy_provider_module(Name); {error, _} -> {error, {unknown_provider, Name}} end. fallback_provider_module(<<"gemini">>) -> {ok, adk_llm_gemini}; fallback_provider_module(<<"openai">>) -> {ok, adk_llm_openai}; fallback_provider_module(<<"anthropic">>) -> {ok, adk_llm_anthropic}; fallback_provider_module(<<"compatible">>) -> {ok, adk_llm_compatible}; fallback_provider_module(<<"adk_llm_probe">> = Name) -> checked_provider(adk_llm_probe, Name); fallback_provider_module(Name) -> legacy_provider_module(Name). legacy_provider_module(<<"adk_llm_", _/binary>> = Name) -> try binary_to_existing_atom(Name, utf8) of Module -> checked_provider(Module, Name) catch error:badarg -> {error, {unknown_provider, Name}} end; legacy_provider_module(Name) -> {error, {unknown_provider, Name}}. checked_provider(Module, Name) -> case adk_llm:capabilities(Module) of {ok, _} -> {ok, Module}; {error, Reason} -> {error, {invalid_provider, Name, public_reason(Reason)}} end. tools_from_json(Tools) when is_list(Tools) -> tools_from_json(Tools, []); tools_from_json(_Tools) -> {error, tools_must_be_array}. tools_from_json([], Acc) -> {ok, lists:reverse(Acc)}; tools_from_json([Name | Rest], Acc) when is_binary(Name) -> try binary_to_existing_atom(Name, utf8) of Module -> case code:ensure_loaded(Module) of {module, Module} -> case erlang:function_exported(Module, schema, 0) andalso erlang:function_exported(Module, execute, 2) of true -> tools_from_json(Rest, [Module | Acc]); false -> {error, {invalid_tool_module, Name}} end; _ -> {error, {unknown_tool_module, Name}} end catch error:badarg -> {error, {unknown_tool_module, Name}} end; tools_from_json([_ | _], _Acc) -> {error, invalid_tool_name}. runner_options_from_json(Options) when is_map(Options) -> Allowed = [<<"run_timeout">>, <<"max_llm_calls">>, <<"max_tool_rounds">>, <<"service_timeout">>, <<"tool_execution">>], case maps:keys(maps:without(Allowed, Options)) of [] -> convert_runner_options(maps:to_list(Options), #{}); Unknown -> {error, {unknown_runner_options, Unknown}} end; runner_options_from_json(_Options) -> {error, runner_options_must_be_object}. convert_runner_options([], Acc) -> {ok, Acc}; convert_runner_options([{<<"run_timeout">>, Value} | Rest], Acc) when is_integer(Value), Value > 0 -> convert_runner_options(Rest, Acc#{run_timeout => Value}); convert_runner_options([{<<"max_llm_calls">>, Value} | Rest], Acc) when is_integer(Value), Value > 0 -> convert_runner_options(Rest, Acc#{max_llm_calls => Value}); convert_runner_options([{<<"max_tool_rounds">>, Value} | Rest], Acc) when is_integer(Value), Value > 0 -> convert_runner_options(Rest, Acc#{max_tool_rounds => Value}); convert_runner_options([{<<"service_timeout">>, Value} | Rest], Acc) when is_integer(Value), Value > 0 -> convert_runner_options(Rest, Acc#{service_timeout => Value}); convert_runner_options([{<<"tool_execution">>, <<"serial">>} | Rest], Acc) -> convert_runner_options(Rest, Acc#{tool_execution => serial}); convert_runner_options([{<<"tool_execution">>, Value} | Rest], Acc) when is_map(Value) -> case parallel_tool_policy(Value) of {ok, Policy} -> convert_runner_options(Rest, Acc#{tool_execution => Policy}); {error, _} = Error -> Error end; convert_runner_options([{Key, _Value} | _], _Acc) -> {error, {invalid_runner_option, Key}}. parallel_tool_policy(Value) -> Allowed = [<<"mode">>, <<"max_concurrency">>, <<"tool_timeout">>], case {maps:keys(maps:without(Allowed, Value)), maps:get(<<"mode">>, Value, undefined), maps:get(<<"max_concurrency">>, Value, undefined), maps:get(<<"tool_timeout">>, Value, undefined)} of {[], <<"parallel">>, Max, Timeout} when is_integer(Max), Max > 0, is_integer(Timeout), Timeout > 0 -> {ok, #{mode => parallel, max_concurrency => Max, tool_timeout => Timeout}}; _ -> {error, invalid_parallel_tool_policy} end. load_dataset(Path) -> case read_json_file(Path) of {ok, Rows} when is_list(Rows) -> dataset_rows(Rows, 1, []); {ok, _} -> {error, dataset_must_be_array}; {error, _} = Error -> Error end. dataset_rows([], _Index, Acc) -> {ok, lists:reverse(Acc)}; dataset_rows([#{<<"input">> := Input, <<"expected">> := Expected} = Row | Rest], Index, Acc) -> Allowed = [<<"input">>, <<"expected">>, <<"metadata">>], Metadata = maps:get(<<"metadata">>, Row, #{}), case maps:keys(maps:without(Allowed, Row)) =:= [] andalso is_map(Metadata) of true -> dataset_rows( Rest, Index + 1, [#{input => Input, expected => Expected, metadata => Metadata} | Acc]); false -> {error, {invalid_dataset_row, Index}} end; dataset_rows([_ | _], Index, _Acc) -> {error, {invalid_dataset_row, Index}}. read_json_file(Path0) -> Path = unicode:characters_to_list(Path0), case file:read_file(Path) of {ok, Binary} when byte_size(Binary) =< ?MAX_CONFIG_BYTES -> decode_json_binary(Binary); {ok, _Binary} -> {error, file_too_large}; {error, Reason} -> {error, {file_read_failed, Reason}} end. decode_json_binary(Binary) -> try jsx:decode(Binary, [return_maps]) of Json -> {ok, Json} catch _:_ -> {error, invalid_json} end. contains_forbidden_secret(Map) when is_map(Map) -> lists:any( fun({Key, Value}) -> forbidden_secret_key(Key) orelse contains_forbidden_secret(Value) end, maps:to_list(Map)); contains_forbidden_secret(List) when is_list(List) -> lists:any(fun contains_forbidden_secret/1, List); contains_forbidden_secret(_Value) -> false. forbidden_secret_key(Key) when is_binary(Key) -> Normalized0 = string:lowercase(Key), Normalized = binary:replace( Normalized0, <<"-">>, <<"_">>, [global]), lists:member( Normalized, [<<"api_key">>, <<"apikey">>, <<"authorization">>, <<"access_token">>, <<"refresh_token">>, <<"id_token">>, <<"password">>, <<"client_secret">>, <<"secret">>, <<"credential">>, <<"credentials">>, <<"private_key">>]); forbidden_secret_key(_Key) -> false. with_required(Opts, Keys, Fun) -> case [Key || Key <- Keys, not maps:is_key(Key, Opts)] of [] -> Fun(); Missing -> {error, {missing_required_options, Missing}} end. ensure_application_started() -> case application:ensure_all_started(erlang_adk) of {ok, _} -> ok; {error, Reason} -> {error, {application_start_failed, public_reason(Reason)}} end. %% A packaged escript may enter main/1 before the main application has been %% loaded. application:set_env/3 accepts that state, but a later load of the %% .app file replaces the temporary values with its defaults. Load first so the %% developer listener settings survive the subsequent application start. prepare_developer_application(Port, Ip) -> stop_application(), case application:load(erlang_adk) of ok -> configure_and_start_developer_application(Port, Ip); {error, {already_loaded, erlang_adk}} -> configure_and_start_developer_application(Port, Ip); {error, Reason} -> {error, #{code => application_load_failed, reason => public_reason(Reason)}} end. configure_and_start_developer_application(Port, Ip) -> ok = application:set_env(erlang_adk, dev_enabled, true), ok = application:set_env(erlang_adk, a2a_ip, Ip), ok = application:set_env(erlang_adk, a2a_port, Port), ensure_application_started(). stop_application() -> case application:stop(erlang_adk) of ok -> ok; {error, {not_started, erlang_adk}} -> ok end. wait_for_shutdown() -> receive stop -> ok after infinity -> ok end. module_status(Module) -> case code:ensure_loaded(Module) of {module, Module} -> available; {error, Reason} -> {unavailable, public_reason(Reason)} end. application_version() -> case application:get_key(erlang_adk, vsn) of {ok, Version} -> safe_binary(Version); undefined -> <<"unknown">> end. valid_environment_secret(Name) -> case os:getenv(Name) of Value when is_list(Value), Value =/= [] -> true; _ -> false end. developer_token() -> case developer_token_value() of {ok, Token} when byte_size(Token) >= 16 -> ok; {ok, _} -> {error, developer_token_too_short}; {error, _} = Error -> Error end. developer_token_value() -> case os:getenv("ERLANG_ADK_DEV_TOKEN") of Value when is_list(Value), Value =/= [] -> {ok, unicode:characters_to_binary(Value)}; _ -> {error, missing_developer_token} end. parse_positive_integer(Value, Name) -> try list_to_integer(Value) of Integer when Integer > 0 -> {ok, Integer}; _ -> {error, {invalid_positive_integer, Name}} catch _:_ -> {error, {invalid_positive_integer, Name}} end. parse_port(Value) -> case parse_positive_integer(Value, port) of {ok, Port} when Port =< 65535 -> {ok, Port}; _ -> {error, invalid_port} end. parse_loopback_ip("127.0.0.1") -> {ok, {127, 0, 0, 1}}; parse_loopback_ip("::1") -> {ok, {0, 0, 0, 0, 0, 0, 0, 1}}; parse_loopback_ip(_) -> {error, developer_server_must_bind_loopback}. validate_base_url(Base) -> try uri_string:parse(Base) of #{scheme := Scheme, host := _Host} = Parsed when Scheme =:= <<"https">>; Scheme =:= "https" -> case maps:is_key(query, Parsed) orelse maps:is_key(fragment, Parsed) of true -> {error, invalid_base_url}; false -> {ok, trim_trailing_slash(safe_binary(Base))} end; #{scheme := Scheme, host := Host} = Parsed when Scheme =:= <<"http">>; Scheme =:= "http" -> HostBin = safe_binary(Host), case is_loopback_host(HostBin) andalso not maps:is_key(query, Parsed) andalso not maps:is_key(fragment, Parsed) of true -> {ok, trim_trailing_slash(safe_binary(Base))}; false -> {error, insecure_non_loopback_base_url} end; _ -> {error, invalid_base_url} catch _:_ -> {error, invalid_base_url} end. is_loopback_host(<<"127.0.0.1">>) -> true; is_loopback_host(<<"localhost">>) -> true; is_loopback_host(<<"::1">>) -> true; is_loopback_host(_) -> false. trim_trailing_slash(Value) when byte_size(Value) > 0 -> case binary:last(Value) of $/ -> binary:part(Value, 0, byte_size(Value) - 1); _ -> Value end; trim_trailing_slash(Value) -> Value. quote(Value) -> safe_binary(uri_string:quote(Value)). option_binary(Opts, Key, Default) -> case maps:find(Key, Opts) of {ok, Value} -> unicode:characters_to_binary(Value); error -> Default end. valid_nonempty_binary(Value) -> is_binary(Value) andalso byte_size(Value) > 0. generate_id(Prefix) -> Suffix = integer_to_binary( erlang:unique_integer([positive, monotonic])), <>. safe_binary(Value) when is_binary(Value) -> Value; safe_binary(Value) when is_list(Value) -> try unicode:characters_to_binary(Value) catch _:_ -> <<>> end; safe_binary(Value) when is_atom(Value) -> atom_to_binary(Value, utf8); safe_binary(Value) -> unicode:characters_to_binary(io_lib:format("~p", [Value])). public_reason(Reason) -> Redacted = adk_secret_redactor:redact(Reason), case adk_json:normalize(Redacted) of {ok, Json} -> Json; {error, _} -> <<"operation_failed">> end. public_http_error(Body) -> case decode_json_binary(Body) of {ok, Json} -> adk_secret_redactor:redact(Json); {error, _} -> <<"developer_api_request_failed">> end. developer_api_transport_error(Reason) -> #{code => developer_api_unavailable, reason => transport_failure_reason(Reason)}. transport_failure_reason(Reason) -> case {term_contains_atom(Reason, econnrefused), term_contains_atom(Reason, timeout), term_contains_atom(Reason, nxdomain)} of {true, _, _} -> connection_refused; {_, true, _} -> timeout; {_, _, true} -> name_resolution_failed; _ -> connection_failed end. term_contains_atom(Value, Value) when is_atom(Value) -> true; term_contains_atom(Tuple, Atom) when is_tuple(Tuple) -> term_contains_atom(tuple_to_list(Tuple), Atom); term_contains_atom([Head | Tail], Atom) -> term_contains_atom(Head, Atom) orelse term_contains_atom(Tail, Atom); term_contains_atom([], _Atom) -> false; term_contains_atom(_Value, _Atom) -> false. write_result(Binary) when is_binary(Binary) -> io:put_chars([Binary, "\n"]); write_result(Result) -> case adk_json:normalize(Result) of {ok, Json} -> io:put_chars([jsx:encode(Json), "\n"]); {error, _} -> io:put_chars(["{\"status\":\"ok\"}\n"]) end. write_eval_run_result(#{delivery := stdout, report := Report}) -> io:put_chars(Report); write_eval_run_result(#{delivery := file}) -> ok. write_error(Reason) -> Error = #{<<"status">> => <<"error">>, <<"reason">> => public_reason(Reason)}, io:put_chars(standard_error, [jsx:encode(Error), "\n"]).