Current section
Files
Jump to
Current section
Files
lib/asm/host_tool.ex
defmodule ASM.HostTool do
@moduledoc """
Provider-neutral host dynamic tool contracts.
Codex app-server renders `%Spec{}` values as upstream `dynamicTools` and maps
dynamic tool JSON-RPC requests into `%Request{}` / `%Response{}` events.
"""
@sensitive_metadata_fragments [
"API_KEY",
"AUTH",
"BEARER",
"CREDENTIAL",
"PASSWORD",
"SECRET",
"TOKEN"
]
@doc false
@spec normalize_metadata(term()) :: {:ok, map()} | {:error, term()}
def normalize_metadata(nil), do: {:ok, %{}}
def normalize_metadata(metadata) when is_map(metadata) do
sensitive_keys =
metadata
|> Map.keys()
|> Enum.map(&to_string/1)
|> Enum.filter(&sensitive_metadata_key?/1)
|> Enum.uniq()
if sensitive_keys == [] do
{:ok, metadata}
else
{:error, {:sensitive_host_tool_metadata_keys, sensitive_keys}}
end
end
def normalize_metadata(metadata), do: {:error, {:invalid_host_tool_metadata, metadata}}
defp sensitive_metadata_key?(key) do
normalized = String.upcase(key)
Enum.any?(@sensitive_metadata_fragments, &String.contains?(normalized, &1))
end
defmodule Spec do
@moduledoc "Host dynamic tool declaration."
@enforce_keys [:name, :input_schema]
defstruct name: nil,
description: nil,
input_schema: nil,
output_schema: nil,
metadata: %{}
@type t :: %__MODULE__{
name: String.t(),
description: String.t() | nil,
input_schema: map(),
output_schema: map() | nil,
metadata: map()
}
@spec new(keyword() | map() | t()) :: {:ok, t()} | {:error, term()}
def new(%__MODULE__{} = spec), do: {:ok, spec}
def new(attrs) when is_list(attrs) or is_map(attrs) do
attrs = normalize_attrs(attrs)
with {:ok, name} <- required_string(attrs, :name),
{:ok, input_schema} <- required_map(attrs, :input_schema),
{:ok, metadata} <- ASM.HostTool.normalize_metadata(Map.get(attrs, :metadata, %{})) do
{:ok,
%__MODULE__{
name: name,
description: optional_string(attrs, :description),
input_schema: input_schema,
output_schema: optional_map(attrs, :output_schema),
metadata: metadata
}}
end
end
@spec new!(keyword() | map() | t()) :: t()
def new!(attrs) do
case new(attrs) do
{:ok, spec} -> spec
{:error, reason} -> raise ArgumentError, "invalid host tool spec: #{inspect(reason)}"
end
end
@spec normalize_list([keyword() | map() | t()] | nil) :: {:ok, [t()]} | {:error, term()}
def normalize_list(nil), do: {:ok, []}
def normalize_list([]), do: {:ok, []}
def normalize_list(specs) when is_list(specs) do
Enum.reduce_while(specs, {:ok, []}, fn spec, {:ok, acc} ->
case new(spec) do
{:ok, normalized} -> {:cont, {:ok, [normalized | acc]}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
|> case do
{:ok, normalized} -> {:ok, Enum.reverse(normalized)}
{:error, reason} -> {:error, reason}
end
end
def normalize_list(other), do: {:error, {:invalid_host_tools, other}}
@spec to_dynamic_tool(t()) :: map()
def to_dynamic_tool(%__MODULE__{} = spec) do
%{
"name" => spec.name,
"description" => spec.description,
"inputSchema" => spec.input_schema,
"outputSchema" => spec.output_schema
}
|> drop_nil_values()
end
defp normalize_attrs(attrs) do
attrs
|> Enum.into(%{})
|> Enum.reduce(%{}, fn {key, value}, acc ->
Map.put(acc, normalize_key(key), value)
end)
end
defp normalize_key("name"), do: :name
defp normalize_key("description"), do: :description
defp normalize_key("input_schema"), do: :input_schema
defp normalize_key("inputSchema"), do: :input_schema
defp normalize_key("output_schema"), do: :output_schema
defp normalize_key("outputSchema"), do: :output_schema
defp normalize_key("metadata"), do: :metadata
defp normalize_key(key) when is_atom(key), do: key
defp normalize_key(key), do: key
defp required_string(attrs, key) do
case Map.get(attrs, key) do
value when is_binary(value) and value != "" -> {:ok, value}
value -> {:error, {:missing_required_string, key, value}}
end
end
defp required_map(attrs, key) do
case Map.get(attrs, key) do
value when is_map(value) -> {:ok, value}
value -> {:error, {:missing_required_map, key, value}}
end
end
defp optional_string(attrs, key) do
case Map.get(attrs, key) do
value when is_binary(value) and value != "" -> value
_other -> nil
end
end
defp optional_map(attrs, key) do
case Map.get(attrs, key) do
value when is_map(value) -> value
_other -> nil
end
end
defp drop_nil_values(map) do
Map.reject(map, fn {_key, value} -> is_nil(value) end)
end
end
defmodule Request do
@moduledoc "Host dynamic tool invocation request."
@enforce_keys [:id, :session_id, :run_id, :provider, :tool_name]
defstruct id: nil,
session_id: nil,
run_id: nil,
provider: nil,
provider_session_id: nil,
provider_turn_id: nil,
tool_name: nil,
arguments: %{},
raw: nil,
metadata: %{}
@type t :: %__MODULE__{
id: String.t() | integer(),
session_id: String.t(),
run_id: String.t(),
provider: atom(),
provider_session_id: String.t() | nil,
provider_turn_id: String.t() | nil,
tool_name: String.t(),
arguments: term(),
raw: term(),
metadata: map()
}
@spec new(keyword() | map() | t()) :: {:ok, t()} | {:error, term()}
def new(%__MODULE__{} = request), do: {:ok, request}
def new(attrs) when is_list(attrs) or is_map(attrs) do
attrs = Enum.into(attrs, %{})
with {:ok, id} <- required_id(attrs, :id),
{:ok, session_id} <- required_string(attrs, :session_id),
{:ok, run_id} <- required_string(attrs, :run_id),
{:ok, provider} <- required_atom(attrs, :provider),
{:ok, tool_name} <- required_string(attrs, :tool_name),
{:ok, metadata} <- ASM.HostTool.normalize_metadata(Map.get(attrs, :metadata, %{})) do
{:ok,
%__MODULE__{
id: id,
session_id: session_id,
run_id: run_id,
provider: provider,
provider_session_id: optional_string(attrs, :provider_session_id),
provider_turn_id: optional_string(attrs, :provider_turn_id),
tool_name: tool_name,
arguments: Map.get(attrs, :arguments, %{}),
raw: Map.get(attrs, :raw),
metadata: metadata
}}
end
end
@spec new!(keyword() | map() | t()) :: t()
def new!(attrs) do
case new(attrs) do
{:ok, request} -> request
{:error, reason} -> raise ArgumentError, "invalid host tool request: #{inspect(reason)}"
end
end
defp required_id(attrs, key) do
case Map.get(attrs, key) do
value when is_binary(value) and value != "" -> {:ok, value}
value when is_integer(value) -> {:ok, value}
value -> {:error, {:missing_required_id, key, value}}
end
end
defp required_string(attrs, key) do
case Map.get(attrs, key) do
value when is_binary(value) and value != "" -> {:ok, value}
value -> {:error, {:missing_required_string, key, value}}
end
end
defp required_atom(attrs, key) do
case Map.get(attrs, key) do
value when is_atom(value) -> {:ok, value}
value -> {:error, {:missing_required_atom, key, value}}
end
end
defp optional_string(attrs, key) do
case Map.get(attrs, key) do
value when is_binary(value) and value != "" -> value
_other -> nil
end
end
end
defmodule Response do
@moduledoc "Host dynamic tool invocation response."
@enforce_keys [:request_id, :success?]
defstruct request_id: nil,
success?: false,
output: nil,
content_items: [],
error: nil,
metadata: %{}
@type t :: %__MODULE__{
request_id: String.t() | integer(),
success?: boolean(),
output: term(),
content_items: [map()],
error: term(),
metadata: map()
}
@spec new(keyword() | map() | t()) :: {:ok, t()} | {:error, term()}
def new(%__MODULE__{} = response), do: {:ok, response}
def new(attrs) when is_list(attrs) or is_map(attrs) do
attrs = normalize_attrs(attrs)
with {:ok, request_id} <- required_id(attrs, :request_id),
{:ok, success?} <- required_boolean(attrs, :success?),
{:ok, metadata} <- ASM.HostTool.normalize_metadata(Map.get(attrs, :metadata, %{})) do
{:ok,
%__MODULE__{
request_id: request_id,
success?: success?,
output: Map.get(attrs, :output),
content_items: normalize_content_items(Map.get(attrs, :content_items, [])),
error: Map.get(attrs, :error),
metadata: metadata
}}
end
end
@spec new!(keyword() | map() | t()) :: t()
def new!(attrs) do
case new(attrs) do
{:ok, response} -> response
{:error, reason} -> raise ArgumentError, "invalid host tool response: #{inspect(reason)}"
end
end
@spec to_dynamic_tool_response(t()) :: map()
def to_dynamic_tool_response(%__MODULE__{} = response) do
%{
"success" => response.success?,
"output" => encode_output(response.output),
"contentItems" => response.content_items,
"error" => response.error
}
|> Map.reject(fn
{_key, nil} -> true
{"contentItems", []} -> true
{_key, _value} -> false
end)
end
defp normalize_attrs(attrs) do
attrs
|> Enum.into(%{})
|> Enum.reduce(%{}, fn {key, value}, acc ->
Map.put(acc, normalize_key(key), value)
end)
end
defp normalize_key("request_id"), do: :request_id
defp normalize_key("requestId"), do: :request_id
defp normalize_key("success?"), do: :success?
defp normalize_key("success"), do: :success?
defp normalize_key("output"), do: :output
defp normalize_key("content_items"), do: :content_items
defp normalize_key("contentItems"), do: :content_items
defp normalize_key("error"), do: :error
defp normalize_key("metadata"), do: :metadata
defp normalize_key(key) when is_atom(key), do: key
defp normalize_key(key), do: key
defp required_id(attrs, key) do
case Map.get(attrs, key) do
value when is_binary(value) and value != "" -> {:ok, value}
value when is_integer(value) -> {:ok, value}
value -> {:error, {:missing_required_id, key, value}}
end
end
defp required_boolean(attrs, key) do
case Map.get(attrs, key) do
value when is_boolean(value) -> {:ok, value}
value -> {:error, {:missing_required_boolean, key, value}}
end
end
defp normalize_content_items(items) when is_list(items) do
Enum.filter(items, &is_map/1)
end
defp normalize_content_items(_items), do: []
defp encode_output(nil), do: nil
defp encode_output(output) when is_binary(output), do: output
defp encode_output(output) do
Jason.encode!(output)
rescue
_error -> inspect(output)
end
end
end