Current section
Files
Jump to
Current section
Files
src/tools/core/adk_toolset.erl
%% @doc Generic immutable/dynamic toolset descriptor.
%%
%% Toolsets let one provider-backed object advertise multiple model-visible
%% schemas while resolving execution out of band. The descriptor contains no
%% model arguments and is never sent to an LLM. OpenAPI and MCP implement the
%% same two callbacks without being special-cased by adk_agent or Runner.
%% A descriptor owns an immutable, compiled schema snapshot; `refresh/1'
%% deliberately replaces that snapshot for a mutable backend. Plain module
%% tools are immutable for the lifetime of their loaded BEAM code version.
-module(adk_toolset).
-export([new/2, refresh/1, is_descriptor/1, schemas/1, expand_tools/1,
preflight/3, materialize/4, resolve/4,
validate_arguments/2, invalid_arguments_response/1]).
-type descriptor() :: {adk_toolset, module(), term()}.
-export_type([descriptor/0]).
-define(CATALOG_TAG, '$adk_toolset_catalog').
-define(CATALOG_VERSION, 1).
-define(MAX_CONFIRMATION_HINT_BYTES, 4096).
-spec new(module(), term()) -> {ok, descriptor()} | {error, term()}.
new(Module, Handle) when is_atom(Module), Module =/= undefined ->
case load_toolset_catalog(Module, Handle) of
{ok, Catalog} ->
{ok, {adk_toolset, Module,
{?CATALOG_TAG, ?CATALOG_VERSION, Handle, Catalog}}};
{error, _} = Error -> Error
end;
new(_Module, _Handle) ->
{error, invalid_toolset}.
%% @doc Re-read and compile the advertised schemas of a descriptor.
%%
%% Descriptors created by `new/2' are immutable catalog snapshots. Dynamic
%% backends remain live when a call is resolved, while a deliberate refresh
%% creates a new snapshot if the set of advertised tools has changed.
-spec refresh(descriptor()) -> {ok, descriptor()} | {error, term()}.
refresh({adk_toolset, Module,
{?CATALOG_TAG, ?CATALOG_VERSION, Handle, _Catalog}})
when is_atom(Module) ->
new(Module, Handle);
refresh({adk_toolset, Module, Handle}) when is_atom(Module) ->
new(Module, Handle);
refresh(_Descriptor) ->
{error, invalid_toolset}.
-spec is_descriptor(term()) -> boolean().
is_descriptor({adk_toolset, Module, _Handle}) when is_atom(Module) -> true;
is_descriptor(_) -> false.
-spec schemas(descriptor()) -> {ok, [map()]} | {error, term()}.
schemas({adk_toolset, _Module,
{?CATALOG_TAG, ?CATALOG_VERSION, _Handle,
#{schemas := Schemas, index := Index}}})
when is_list(Schemas), is_map(Index) ->
{ok, Schemas};
schemas({adk_toolset, Module, Handle}) when is_atom(Module) ->
case load_toolset_catalog(Module, Handle) of
{ok, Catalog} -> {ok, maps:get(schemas, Catalog)};
{error, _} = Error -> Error
end;
schemas(_Descriptor) ->
{error, invalid_toolset}.
-spec expand_tools([module() | descriptor()]) ->
{ok, [map()]} | {error, term()}.
expand_tools(Tools) when is_list(Tools) ->
expand_tools(Tools, [], #{}, 1);
expand_tools(_Tools) ->
{error, invalid_tools}.
expand_tools([], Acc, _Names, _EntryIndex) ->
{ok, lists:reverse(Acc)};
expand_tools([Module | Rest], Acc, Names, EntryIndex)
when is_atom(Module) ->
case module_catalog_entry(Module) of
{ok, Entry} ->
Schema = maps:get(schema, Entry),
Name = maps:get(<<"name">>, Schema),
Source = {module, EntryIndex, Module},
case add_tool_name(Name, Source, Names) of
{ok, Names1} ->
expand_tools(
Rest, [Schema | Acc], Names1, EntryIndex + 1);
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end;
expand_tools([Descriptor | Rest], Acc, Names, EntryIndex) ->
case schemas(Descriptor) of
{ok, Schemas} ->
Source = descriptor_source(Descriptor, EntryIndex),
case add_tool_schemas(Schemas, Source, Names, 1) of
{ok, Names1} ->
expand_tools(
Rest, lists:reverse(Schemas) ++ Acc, Names1,
EntryIndex + 1);
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end;
expand_tools(_Improper, _Acc, _Names, _EntryIndex) ->
{error, invalid_tools}.
descriptor_source({adk_toolset, Module, _Handle}, EntryIndex) ->
{toolset, EntryIndex, Module}.
add_tool_schemas([], _Source, Names, _SchemaIndex) -> {ok, Names};
add_tool_schemas([Schema | Rest], Source, Names0, SchemaIndex) ->
Name = maps:get(<<"name">>, Schema),
SchemaSource = erlang:append_element(Source, SchemaIndex),
case add_tool_name(Name, SchemaSource, Names0) of
{ok, Names1} ->
add_tool_schemas(
Rest, Source, Names1, SchemaIndex + 1);
{error, _} = Error -> Error
end.
add_tool_name(Name, Source, Names) ->
case maps:find(Name, Names) of
{ok, PreviousSource} ->
{error, {duplicate_tool_name, Name,
PreviousSource, Source}};
error -> {ok, Names#{Name => Source}}
end.
%% @doc Resolve a model call to either a module or a bounded executor call.
-spec resolve([module() | descriptor()], binary(), map(), map()) ->
{ok, {module, module()} | {resolved, adk_tool_executor:resolved_call()}} |
{error, not_found | term()}.
resolve(Tools, Name, Args, Context)
when is_list(Tools), is_binary(Name), is_map(Args), is_map(Context) ->
resolve_tools(Tools, Name, Args, Context);
resolve(_Tools, _Name, _Args, _Context) ->
{error, invalid_tool_resolution}.
%% @doc Locate a catalog entry and validate arguments without calling a live
%% dynamic resolver. Runner uses this boundary to enforce runtime policy before
%% resolved_call/4 can consult credentials, transports, or mutable backends.
-spec preflight([module() | descriptor()], binary(), map()) ->
{ok, term()} | {error, not_found | term()}.
preflight(Tools, Name, Args)
when is_list(Tools), is_binary(Name), is_map(Args) ->
preflight_tools(Tools, Name, Args);
preflight(_Tools, _Name, _Args) ->
{error, invalid_tool_resolution}.
preflight_tools([], _Name, _Args) -> {error, not_found};
preflight_tools([Module | Rest], Name, Args) when is_atom(Module) ->
case module_catalog_entry(Module) of
{ok, Entry} ->
Schema = maps:get(schema, Entry),
case maps:get(<<"name">>, Schema) =:= Name of
true ->
case validate_catalog_arguments(Entry, Args) of
{ok, _CanonicalArgs} ->
{ok, {module_target, Module}};
{error, _} = Error -> Error
end;
false ->
preflight_tools(Rest, Name, Args)
end;
{error, _} = Error -> Error
end;
preflight_tools([{adk_toolset, Module,
{?CATALOG_TAG, ?CATALOG_VERSION, Handle, Catalog}}
| Rest], Name, Args) ->
preflight_toolset_catalog(
Module, Handle, Catalog, Rest, Name, Args);
preflight_tools([{adk_toolset, Module, Handle} | Rest], Name, Args) ->
case load_toolset_catalog(Module, Handle) of
{ok, Catalog} ->
preflight_toolset_catalog(
Module, Handle, Catalog, Rest, Name, Args);
{error, _} = Error -> Error
end;
preflight_tools([_Invalid | _Rest], _Name, _Args) ->
{error, invalid_toolset}.
preflight_toolset_catalog(Module, Handle, #{index := Index}, Rest,
Name, Args) when is_map(Index) ->
case maps:find(Name, Index) of
error -> preflight_tools(Rest, Name, Args);
{ok, Entry} ->
case validate_catalog_arguments(Entry, Args) of
{ok, _CanonicalArgs} ->
{ok, {toolset_target, Module, Handle}};
{error, _} = Error -> Error
end
end;
preflight_toolset_catalog(_Module, _Handle, _Catalog, _Rest,
_Name, _Args) ->
{error, invalid_toolset}.
%% @doc Materialize a preflighted target after the caller's policy gate.
-spec materialize(term(), binary(), map(), map()) ->
{ok, {module, module()} | {resolved, adk_tool_executor:resolved_call()}}
| {error, term()}.
materialize({module_target, Module}, _Name, _Args, _Context)
when is_atom(Module) ->
{ok, {module, Module}};
materialize({toolset_target, Module, Handle}, Name, Args, Context)
when is_atom(Module), is_binary(Name), is_map(Args), is_map(Context) ->
case safe_resolved_call(Module, Handle, Name, Args, Context) of
{ok, Resolved} -> {ok, {resolved, Resolved}};
{error, unknown_tool} -> {error, tool_catalog_changed};
{error, not_found} -> {error, tool_catalog_changed};
{error, _} = Error -> Error
end;
materialize(_Target, _Name, _Args, _Context) ->
{error, invalid_tool_resolution}.
resolve_tools([], _Name, _Args, _Context) -> {error, not_found};
resolve_tools([Module | Rest], Name, Args, Context) when is_atom(Module) ->
case module_catalog_entry(Module) of
{ok, Entry} ->
Schema = maps:get(schema, Entry),
case maps:get(<<"name">>, Schema) =:= Name of
true ->
case validate_catalog_arguments(Entry, Args) of
{ok, _CanonicalArgs} -> {ok, {module, Module}};
{error, _} = Error -> Error
end;
false -> resolve_tools(Rest, Name, Args, Context)
end;
{error, _} = Error -> Error
end;
resolve_tools([{adk_toolset, Module,
{?CATALOG_TAG, ?CATALOG_VERSION, Handle, Catalog}}
| Rest], Name, Args, Context) ->
resolve_toolset_catalog(
Module, Handle, Catalog, Rest, Name, Args, Context);
resolve_tools([{adk_toolset, Module, Handle} | Rest], Name, Args, Context) ->
case load_toolset_catalog(Module, Handle) of
{ok, Catalog} ->
resolve_toolset_catalog(
Module, Handle, Catalog, Rest, Name, Args, Context);
{error, _} = Error -> Error
end;
resolve_tools([_Invalid | _Rest], _Name, _Args, _Context) ->
{error, invalid_toolset}.
resolve_toolset_catalog(Module, Handle, Catalog, Rest,
Name, Args, Context) ->
case Catalog of
#{index := Index} when is_map(Index) ->
resolve_toolset_index(
Module, Handle, Index, Rest, Name, Args, Context);
_ -> {error, invalid_toolset}
end.
resolve_toolset_index(Module, Handle, Index, Rest,
Name, Args, Context) ->
case maps:find(Name, Index) of
error -> resolve_tools(Rest, Name, Args, Context);
{ok, Entry} ->
case validate_catalog_arguments(Entry, Args) of
{ok, _CanonicalArgs} ->
case safe_resolved_call(
Module, Handle, Name, Args, Context) of
{ok, Resolved} -> {ok, {resolved, Resolved}};
{error, unknown_tool} ->
{error, tool_catalog_changed};
{error, not_found} ->
{error, tool_catalog_changed};
{error, _} = Error -> Error
end;
{error, _} = Error -> Error
end
end.
validate_catalog_arguments(#{parameters := Parameters}, Args)
when is_map(Args) ->
normalize_argument_result(
adk_json_schema:validate_compiled(Parameters, Args));
validate_catalog_arguments(_Entry, _Args) ->
{error, {invalid_tool_arguments, expected_object}}.
%% @doc Validate model-generated arguments against one already checked tool
%% schema. Validation errors contain only structural paths and constraints;
%% rejected argument values are never copied into the reason.
-spec validate_arguments(map(), map()) ->
{ok, map()} | {error, {invalid_tool_arguments, term()}}.
validate_arguments(Schema, Args) when is_map(Schema), is_map(Args) ->
case maps:find(<<"parameters">>, Schema) of
{ok, Parameters} ->
case adk_json_schema:compile(
normalize_schema_types(Parameters)) of
{ok, Compiled} ->
normalize_argument_result(
adk_json_schema:validate_compiled(Compiled, Args));
{error, Reason} ->
{error, {invalid_tool_arguments, Reason}}
end;
error ->
{error, {invalid_tool_arguments, missing_parameter_schema}}
end;
validate_arguments(_Schema, _Args) ->
{error, {invalid_tool_arguments, expected_object}}.
normalize_argument_result({ok, CanonicalArgs}) when is_map(CanonicalArgs) ->
{ok, CanonicalArgs};
normalize_argument_result({ok, _NonObject}) ->
{error, {invalid_tool_arguments, expected_object}};
normalize_argument_result({error, Reason}) ->
{error, {invalid_tool_arguments, Reason}}.
%% @doc Produce a JSON-safe correction response for a rejected model call.
%% Only schema structure is included; model-provided values are deliberately
%% excluded so validation failures cannot reflect secrets into history.
-spec invalid_arguments_response(term()) -> map().
invalid_arguments_response({invalid_tool_arguments, Reason}) ->
#{<<"success">> => false,
<<"error">> =>
#{<<"type">> => <<"invalid_tool_arguments">>,
<<"validation">> => validation_detail(Reason)}};
invalid_arguments_response(_Reason) ->
#{<<"success">> => false,
<<"error">> => #{<<"type">> => <<"invalid_tool_arguments">>}}.
validation_detail({schema_validation_failed, Path, Constraint})
when is_list(Path) ->
#{<<"kind">> => <<"schema_validation_failed">>,
<<"path">> => structural_path(Path),
<<"constraint">> => constraint_detail(Constraint)};
validation_detail({invalid_json_value, Reason}) ->
#{<<"kind">> => <<"invalid_json_value">>,
<<"constraint">> => constraint_detail(Reason)};
validation_detail(expected_object) ->
#{<<"kind">> => <<"expected_object">>};
validation_detail(missing_parameter_schema) ->
#{<<"kind">> => <<"missing_parameter_schema">>};
validation_detail(_Reason) ->
#{<<"kind">> => <<"invalid">>}.
structural_path(Path) ->
[Part || Part <- Path, is_binary(Part) orelse is_integer(Part)].
constraint_detail({Tag, Value}) when is_atom(Tag) ->
#{<<"kind">> => atom_to_binary(Tag, utf8),
<<"value">> => structural_value(Value)};
constraint_detail(Tag) when is_atom(Tag) ->
#{<<"kind">> => atom_to_binary(Tag, utf8)};
constraint_detail(_Constraint) ->
#{<<"kind">> => <<"invalid">>}.
structural_value(Value) when is_binary(Value); is_integer(Value);
is_float(Value); is_boolean(Value) -> Value;
structural_value(Value) when is_atom(Value) -> atom_to_binary(Value, utf8);
structural_value(Values) when is_list(Values) ->
[structural_value(Value) || Value <- Values,
is_binary(Value) orelse is_atom(Value) orelse
is_integer(Value) orelse is_float(Value) orelse
is_boolean(Value)];
structural_value(_Value) -> <<"invalid">>.
safe_resolved_call(Module, Handle, Name, Args, Context) ->
case load_callbacks(Module) of
ok ->
try Module:resolved_call(Handle, Name, Args, Context) of
{ok, Call} when is_map(Call) ->
validate_resolved_call(Call, Name, Args);
{error, _} = Error -> Error;
_ -> {error, invalid_resolved_tool_call}
catch
_:_ -> {error, toolset_unavailable}
end;
{error, _} = Error -> Error
end.
validate_resolved_call(Call, Name, Args) ->
case maps:get(name, Call, undefined) =:= Name andalso
maps:get(args, Call, undefined) =:= Args andalso
valid_executor(Call) andalso valid_optional_boolean(
parallel_safe, Call) andalso
valid_optional_boolean(pause_capable, Call) of
true ->
case normalize_confirmation(Call) of
{ok, Call1} ->
Allowed = [name, args, module, execute, parallel_safe,
pause_capable, timeout, deadline,
confirmation],
{ok, maps:with(Allowed, Call1)};
{error, _} = Error -> Error
end;
false -> {error, invalid_resolved_tool_call}
end.
normalize_confirmation(Call) ->
case maps:find(confirmation, Call) of
error -> {ok, Call};
{ok, true} ->
{ok, Call#{confirmation => #{required => true}}};
{ok, false} ->
{ok, Call#{confirmation => #{required => false}}};
{ok, Confirmation} when is_map(Confirmation) ->
normalize_confirmation_map(Call, Confirmation);
{ok, _Invalid} ->
{error, invalid_resolved_tool_call}
end.
normalize_confirmation_map(Call, Confirmation) ->
Unknown = maps:without([required, hint], Confirmation),
Required = maps:get(required, Confirmation, true),
Hint = maps:get(hint, Confirmation, undefined),
case map_size(Unknown) =:= 0 andalso is_boolean(Required) andalso
valid_confirmation_hint(Hint) of
true ->
Base = #{required => Required},
Canonical = case Hint of
undefined -> Base;
_ -> Base#{hint => Hint}
end,
{ok, Call#{confirmation => Canonical}};
false ->
{error, invalid_resolved_tool_call}
end.
valid_confirmation_hint(undefined) -> true;
valid_confirmation_hint(Hint) when is_binary(Hint),
byte_size(Hint) =<
?MAX_CONFIRMATION_HINT_BYTES ->
case unicode:characters_to_binary(Hint, utf8, utf8) of
Hint -> true;
_ -> false
end;
valid_confirmation_hint(_Hint) -> false.
valid_executor(Call) ->
case {maps:find(execute, Call), maps:find(module, Call)} of
{{ok, Fun}, error} -> is_function(Fun, 0);
{error, {ok, Module}} -> is_atom(Module) andalso Module =/= undefined;
_ -> false
end.
valid_optional_boolean(Key, Map) ->
case maps:find(Key, Map) of
error -> true;
{ok, Value} -> is_boolean(Value)
end.
module_catalog_entry(Module) ->
case code:ensure_loaded(Module) of
{module, Module} ->
case erlang:function_exported(Module, schema, 0) andalso
erlang:function_exported(Module, execute, 2) of
false -> {error, {invalid_tool_module, Module}};
true ->
cached_module_catalog_entry(Module)
end;
_ -> {error, {tool_module_unavailable, Module}}
end.
cached_module_catalog_entry(Module) ->
Fingerprint = Module:module_info(md5),
CacheKey = {?MODULE, module_catalog, Module},
case persistent_term:get(CacheKey, undefined) of
{?CATALOG_VERSION, Fingerprint, Entry} when is_map(Entry) ->
{ok, Entry};
_ ->
case compile_module_catalog_entry(Module) of
{ok, Entry} = Ok ->
persistent_term:put(
CacheKey, {?CATALOG_VERSION, Fingerprint, Entry}),
Ok;
{error, _} = Error -> Error
end
end.
compile_module_catalog_entry(Module) ->
try Module:schema() of
Schema when is_map(Schema) ->
case validate_schema(Schema) of
{ok, Validated} -> {ok, catalog_entry(Validated)};
{error, Reason} ->
{error, {invalid_tool_schema,
{module, Module}, Reason}}
end;
_ ->
{error, {invalid_tool_schema,
{module, Module}, invalid_schema}}
catch
_:_ -> {error, {tool_module_unavailable, Module}}
end.
load_callbacks(Module) ->
case code:ensure_loaded(Module) of
{module, Module} ->
case erlang:function_exported(Module, schemas, 1) andalso
erlang:function_exported(Module, resolved_call, 4) of
true -> ok;
false -> {error, {invalid_toolset_module, Module}}
end;
_ -> {error, {toolset_module_unavailable, Module}}
end.
load_toolset_catalog(Module, Handle) ->
case load_callbacks(Module) of
ok ->
try Module:schemas(Handle) of
Schemas when is_list(Schemas) ->
compile_toolset_catalog(Schemas, Module);
_ -> {error, invalid_toolset_schemas}
catch
_:_ -> {error, toolset_unavailable}
end;
{error, _} = Error -> Error
end.
compile_toolset_catalog(Schemas, Module) ->
validate_schema_list(Schemas, Module, 1, #{}, [], #{}).
validate_schema_list([], _Module, _SchemaIndex, _Names,
SchemasAcc, IndexAcc) ->
{ok, #{schemas => lists:reverse(SchemasAcc), index => IndexAcc}};
validate_schema_list([Schema | Rest], Module, SchemaIndex, Names,
SchemasAcc, IndexAcc) when is_map(Schema) ->
case validate_schema(Schema) of
{ok, Validated} ->
Name = maps:get(<<"name">>, Validated),
Source = {toolset, Module, SchemaIndex},
case maps:find(Name, Names) of
{ok, PreviousSource} ->
{error, {duplicate_tool_name, Name,
PreviousSource, Source}};
error ->
Entry = catalog_entry(Validated),
validate_schema_list(
Rest, Module, SchemaIndex + 1,
Names#{Name => Source},
[Validated | SchemasAcc],
IndexAcc#{Name => Entry})
end;
{error, Reason} ->
Identity = {toolset, Module, SchemaIndex,
diagnostic_schema_name(Schema)},
{error, {invalid_tool_schema, Identity, Reason}}
end;
validate_schema_list(_Invalid, Module, SchemaIndex, _Names,
_SchemasAcc, _IndexAcc) ->
{error, {invalid_toolset_schemas,
{toolset, Module, SchemaIndex}}}.
catalog_entry(Schema) ->
#{schema => Schema,
parameters => maps:get(<<"parameters">>, Schema)}.
diagnostic_schema_name(Schema) ->
case maps:find(<<"name">>, Schema) of
{ok, Name} when is_binary(Name) -> Name;
_ ->
case maps:find(name, Schema) of
{ok, Name} when is_binary(Name) -> Name;
{ok, Name} when is_atom(Name) -> atom_to_binary(Name, utf8);
_ -> undefined
end
end.
validate_schema(Schema0) when is_map(Schema0) ->
case adk_json:normalize(Schema0) of
{ok, Schema} -> validate_normalized_schema(Schema);
{error, _} -> {error, invalid_tool_schema}
end.
validate_normalized_schema(#{<<"name">> := Name} = Schema0)
when is_binary(Name), byte_size(Name) > 0 ->
case valid_description(Schema0) of
false -> {error, invalid_tool_schema};
true ->
Parameters0 = maps:get(
<<"parameters">>, Schema0,
#{<<"type">> => <<"object">>}),
Parameters = normalize_schema_types(Parameters0),
case adk_json_schema:compile(Parameters) of
{ok, Compiled} ->
{ok, Schema0#{<<"parameters">> => Compiled}};
{error, Reason} ->
{error, {invalid_tool_parameter_schema, Reason}}
end
end;
validate_normalized_schema(_Schema) -> {error, invalid_tool_schema}.
valid_description(Schema) ->
case maps:find(<<"description">>, Schema) of
error -> true;
{ok, Description} -> is_binary(Description)
end.
normalize_schema_types(Map) when is_map(Map) ->
maps:from_list(
[case Key of
<<"type">> -> {Key, normalize_type_value(Value)};
_ -> {Key, normalize_schema_types(Value)}
end || {Key, Value} <- maps:to_list(Map)]);
normalize_schema_types(List) when is_list(List) ->
[normalize_schema_types(Value) || Value <- List];
normalize_schema_types(Value) -> Value.
normalize_type_value(Types) when is_list(Types) ->
[normalize_type_value(Type) || Type <- Types];
normalize_type_value(<<"OBJECT">>) -> <<"object">>;
normalize_type_value(<<"ARRAY">>) -> <<"array">>;
normalize_type_value(<<"STRING">>) -> <<"string">>;
normalize_type_value(<<"NUMBER">>) -> <<"number">>;
normalize_type_value(<<"INTEGER">>) -> <<"integer">>;
normalize_type_value(<<"BOOLEAN">>) -> <<"boolean">>;
normalize_type_value(<<"NULL">>) -> <<"null">>;
normalize_type_value(Type) -> Type.