defmodule Snakepit.GRPC.BridgeServer do @moduledoc """ gRPC server implementation for the Snakepit Bridge service. Handles tool execution and session management through the unified bridge protocol. """ use GRPC.Server, service: Snakepit.Bridge.BridgeService.Service alias Google.Protobuf.{Any, Timestamp} alias Snakepit.Bridge.{ CleanupSessionRequest, CleanupSessionResponse, ExecuteElixirToolRequest, ExecuteElixirToolResponse, ExecuteToolRequest, ExecuteToolResponse, GetExposedElixirToolsRequest, GetExposedElixirToolsResponse, GetSessionRequest, GetSessionResponse, HeartbeatRequest, HeartbeatResponse, InitializeSessionResponse, ParameterSpec, PingRequest, PingResponse, RegisterToolsRequest, RegisterToolsResponse, ToolChunk, ToolSpec } alias Snakepit.Bridge.SessionStore alias Snakepit.Bridge.ToolRegistry alias Snakepit.GRPC.Client, as: GRPCClient alias Snakepit.GRPCWorker alias Snakepit.Logger, as: SLog alias Snakepit.Pool.Registry, as: PoolRegistry alias Snakepit.Telemetry.Correlation @log_category :grpc # Health & Session Management def ping(%PingRequest{message: message}, _stream) do SLog.debug(@log_category, "Ping received", message: message) %PingResponse{ message: "pong: #{message}", server_time: %Timestamp{seconds: System.system_time(:second), nanos: 0} } end def initialize_session(request, _stream) do SLog.info(@log_category, "Initializing session", session_id: request.session_id) case SessionStore.create_session(request.session_id, metadata: request.metadata) do {:ok, _session} -> # Success - session was created or already existed (both are fine) %InitializeSessionResponse{ success: true, error_message: nil, available_tools: %{} } {:error, reason} -> # Only raise on actual errors (not :already_exists, which is now handled) raise GRPC.RPCError, status: :internal, message: format_error(reason) end end def cleanup_session(%CleanupSessionRequest{session_id: session_id, force: _force}, _stream) do SLog.info(@log_category, "Cleaning up session", session_id: session_id) # NOTE: The force flag is not currently used. SessionStore.delete_session is always idempotent # and immediately deletes the session regardless of state. Future enhancements could add # soft-delete or cleanup verification if needed. SessionStore.delete_session(session_id) %CleanupSessionResponse{ success: true, resources_cleaned: 1 } end def get_session(%GetSessionRequest{session_id: session_id}, _stream) do SLog.debug(@log_category, "GetSession", session_id: session_id) case SessionStore.get_session(session_id) do {:ok, session} -> tools = Map.get(session, :tools, %{}) metadata = Map.get(session, :metadata, %{}) tool_count = map_size(tools) %GetSessionResponse{ session_id: session_id, metadata: metadata, created_at: %Timestamp{seconds: session.created_at, nanos: 0}, tool_count: tool_count } {:error, :not_found} -> raise GRPC.RPCError, status: :not_found, message: "Session not found: #{session_id}" end end def heartbeat(%HeartbeatRequest{session_id: session_id, client_time: _client_time}, _stream) do SLog.debug(@log_category, "Heartbeat", session_id: session_id) # Check if session exists and update last_accessed session_valid = case SessionStore.get_session(session_id) do {:ok, _session} -> # Getting the session automatically updates last_accessed true {:error, :not_found} -> false end %HeartbeatResponse{ server_time: %Timestamp{seconds: System.system_time(:second), nanos: 0}, session_valid: session_valid } end # Tool Execution def execute_tool(%ExecuteToolRequest{} = request, stream) do SLog.info(@log_category, "ExecuteTool", tool_name: request.tool_name, session_id: request.session_id ) start_time = System.monotonic_time(:millisecond) correlation_id = resolve_request_correlation_id(request, stream) request = ensure_request_correlation(request, correlation_id) with {:ok, _session} <- SessionStore.get_session(request.session_id), {:ok, tool} <- ToolRegistry.get_tool(request.session_id, request.tool_name), {:ok, result} <- execute_tool_handler(tool, request, request.session_id, correlation_id) do execution_time = System.monotonic_time(:millisecond) - start_time {encoded_result, binary_result} = encode_tool_result(result) %ExecuteToolResponse{ success: true, result: encoded_result, binary_result: binary_result || <<>>, error_message: nil, metadata: %{ "execution_time" => to_string(execution_time), "tool_type" => to_string(tool.type) }, execution_time_ms: execution_time } else {:error, reason} -> %ExecuteToolResponse{ success: false, result: nil, error_message: format_error(reason), metadata: %{}, execution_time_ms: System.monotonic_time(:millisecond) - start_time } end end defp execute_tool_handler(%{type: :local} = tool, request, session_id, _correlation_id) do # Execute local Elixir tool case decode_tool_parameters(request.parameters, request.binary_parameters) do {:ok, params} -> ToolRegistry.execute_local_tool(session_id, tool.name, params) {:error, reason} -> {:error, reason} end end defp execute_tool_handler(%{type: :remote} = tool, request, session_id, correlation_id) do # Forward to Python worker SLog.debug(@log_category, "Executing remote tool", tool_name: tool.name, worker_id: tool.worker_id ) with {:ok, params} <- decode_remote_tool_parameters(request.parameters, request.binary_parameters), {:ok, channel, cleanup} <- ensure_worker_channel(tool.worker_id) do result = try do forward_tool_to_worker(channel, request, session_id, params, correlation_id) after cleanup.() end case result do {:ok, response} -> {:ok, response} {:error, reason} -> SLog.error(@log_category, "Failed to execute remote tool", tool_name: tool.name, worker_id: tool.worker_id, reason: reason ) {:error, {:remote_execution_failed, reason}} end else {:error, {:invalid_parameter, _, _}} = error -> error {:error, reason} -> SLog.error(@log_category, "Failed to execute remote tool", tool_name: tool.name, worker_id: tool.worker_id, reason: reason ) {:error, {:remote_execution_failed, reason}} end end defp decode_tool_parameters(params, binary_params) when is_map(params) and (is_map(binary_params) or is_nil(binary_params)) do decoded = Enum.reduce_while(params, {:ok, %{}}, fn {key, any_value}, {:ok, acc} -> case decode_any_value(any_value) do {:ok, decoded} -> {:cont, {:ok, Map.put(acc, key, decoded)}} {:error, reason} -> {:halt, {:error, {:invalid_parameter, key, reason}}} end end) case decoded do {:ok, acc} -> merge_binary_parameters(acc, binary_params || %{}) other -> other end end defp decode_tool_parameters(_, _), do: {:ok, %{}} # For remote tools, we decode JSON parameters without merging binary params # Binary params are forwarded separately via the protobuf message defp decode_remote_tool_parameters(params, binary_params) do with {:ok, decoded} <- decode_tool_parameters(params, %{}), :ok <- validate_binary_parameters(binary_params || %{}) do {:ok, decoded} end end defp validate_binary_parameters(binary_params) when is_map(binary_params) do Enum.reduce_while(binary_params, :ok, fn {key, value}, :ok -> if is_binary(value) do {:cont, :ok} else {:halt, {:error, {:invalid_parameter, normalize_param_key(key), :not_binary}}} end end) end defp validate_binary_parameters(_), do: :ok defp decode_any_value(%Any{ type_url: "type.googleapis.com/google.protobuf.StringValue", value: value }) do decode_json(value) end defp decode_any_value(%Any{type_url: type_url, value: value}) do case decode_json(value) do {:ok, decoded} -> {:ok, decoded} {:error, message} -> {:error, {:invalid_json, message, type_url}} end end defp decode_any_value(value) when is_map(value) or is_list(value) do {:ok, value} end defp merge_binary_parameters(decoded, binary_params) when map_size(binary_params) == 0, do: {:ok, decoded} defp merge_binary_parameters(decoded, binary_params) when is_map(binary_params) do Enum.reduce_while(binary_params, {:ok, decoded}, fn {key, value}, {:ok, acc} -> if is_binary(value) do {:cont, {:ok, Map.put(acc, normalize_param_key(key), {:binary, value})}} else {:halt, {:error, {:invalid_parameter, normalize_param_key(key), :not_binary}}} end end) end defp merge_binary_parameters(decoded, _binary_params), do: {:ok, decoded} defp normalize_param_key(key) when is_atom(key), do: Atom.to_string(key) defp normalize_param_key(key) when is_binary(key), do: key defp normalize_param_key(key), do: to_string(key) defp decode_json(value) when is_binary(value) do case Jason.decode(value) do {:ok, decoded} -> {:ok, decoded} {:error, %Jason.DecodeError{} = decode_error} -> {:error, Exception.message(decode_error)} end end defp decode_json(_), do: {:error, "expected JSON encoded string"} # Helper functions for remote tool execution defp get_worker_port(worker_id) do case PoolRegistry.get_worker_pid(worker_id) do {:ok, pid} when is_pid(pid) -> # Try to get port from worker state - this is a simplified approach try do case GenServer.call(pid, :get_port, 1000) do {:ok, port} -> {:ok, port} _ -> {:error, "Could not get port from worker"} end catch _exit, _reason -> {:error, "Worker not responding"} end {:error, _reason} -> {:error, "Worker not found: #{worker_id}"} end end defp create_worker_channel(port) do GRPC.Stub.connect("localhost:#{port}") rescue error -> {:error, "Failed to connect to worker: #{inspect(error)}"} end defp forward_tool_to_worker(channel, request, session_id, decoded_params, correlation_id) do worker_metadata = ensure_metadata_correlation(request.metadata, correlation_id) # Create the request to forward to the worker worker_request = %ExecuteToolRequest{ session_id: session_id, tool_name: request.tool_name, parameters: request.parameters, metadata: worker_metadata, binary_parameters: request.binary_parameters } binary_params = worker_request.binary_parameters || %{} opts = worker_request.metadata |> tool_call_options() |> Keyword.put(:binary_parameters, binary_params) |> Keyword.put(:correlation_id, correlation_id) case GRPCClient.execute_tool( channel, worker_request.session_id, worker_request.tool_name, decoded_params, opts ) do {:ok, response} -> {:ok, response} {:error, reason} -> {:error, reason} end end defp tool_call_options(metadata) when is_map(metadata) do value = Map.get(metadata, "timeout_ms") || Map.get(metadata, :timeout_ms) case parse_timeout_ms(value) do {:ok, timeout} -> [timeout: timeout] :error -> [] end end defp resolve_request_correlation_id(request, stream) do request |> correlation_id_from_metadata() |> case do nil -> correlation_id_from_headers(stream) value -> value end |> Correlation.ensure() end defp correlation_id_from_metadata(%{metadata: metadata}) when is_map(metadata) do Map.get(metadata, "correlation_id") || Map.get(metadata, :correlation_id) end defp correlation_id_from_metadata(_), do: nil defp correlation_id_from_headers(nil), do: nil defp correlation_id_from_headers(stream) do stream |> GRPC.Stream.get_headers() |> Map.get("x-snakepit-correlation-id") end defp ensure_request_correlation(%ExecuteToolRequest{} = request, correlation_id) do metadata = ensure_metadata_correlation(request.metadata, correlation_id) %{request | metadata: metadata} end defp ensure_metadata_correlation(nil, correlation_id) do %{"correlation_id" => correlation_id} end defp ensure_metadata_correlation(metadata, correlation_id) when is_map(metadata) do Map.put(metadata, "correlation_id", correlation_id) end defp parse_timeout_ms(nil), do: :error defp parse_timeout_ms(value) when is_integer(value) and value > 0, do: {:ok, value} defp parse_timeout_ms(value) when is_binary(value) do case Integer.parse(value) do {int, ""} when int > 0 -> {:ok, int} _ -> :error end end defp parse_timeout_ms(_), do: :error defp ensure_worker_channel(worker_id) do case get_existing_worker_channel(worker_id) do {:ok, channel} -> {:ok, channel, fn -> :ok end} {:error, reason} when is_binary(reason) -> {:error, reason} {:error, _reason} -> with {:ok, port} <- get_worker_port(worker_id), {:ok, channel} <- create_worker_channel(port) do {:ok, channel, fn -> disconnect_channel(channel) end} end end end defp get_existing_worker_channel(worker_id) do with {:ok, pid, metadata} <- fetch_worker_safely(worker_id), :ok <- validate_worker_module(metadata) do GRPCWorker.get_channel(pid) end rescue _ -> {:error, :channel_unavailable} end defp fetch_worker_safely(worker_id) do case PoolRegistry.fetch_worker(worker_id) do {:ok, pid, metadata} when is_pid(pid) -> {:ok, pid, metadata} {:error, _} -> {:error, "Worker not found: #{worker_id}"} end end defp validate_worker_module(metadata) do case Map.get(metadata, :worker_module, GRPCWorker) do module when module == GRPCWorker -> :ok _ -> {:error, :unsupported_worker_module} end end defp disconnect_channel(channel) do _ = GRPC.Stub.disconnect(channel) :ok rescue _ -> :ok end def execute_streaming_tool(%ExecuteToolRequest{} = request, stream) do SLog.info(@log_category, "ExecuteStreamingTool", tool_name: request.tool_name, session_id: request.session_id ) start_time_ms = System.monotonic_time(:millisecond) correlation_id = resolve_request_correlation_id(request, stream) request = ensure_request_correlation(request, correlation_id) with {:ok, _session} <- SessionStore.get_session(request.session_id), {:ok, tool} <- ToolRegistry.get_tool(request.session_id, request.tool_name), :ok <- ensure_streaming_supported(tool, request) do case execute_remote_stream(tool, request, stream, correlation_id, start_time_ms) do :ok -> stream {:error, reason} -> raise_streaming_rpc_error(request, reason) end else {:error, :not_found} -> raise GRPC.RPCError, status: :not_found, message: "Session not found: #{request.session_id}" {:error, message} when is_binary(message) -> raise GRPC.RPCError, status: :not_found, message: message {:error, {:streaming_not_supported, _} = reason} -> raise_streaming_rpc_error(request, reason) end end # Streaming Support Validation defp ensure_streaming_supported(%{type: :remote} = tool, _request) do if tool_supports_streaming?(tool) do :ok else {:error, {:streaming_not_supported, tool.name}} end end defp ensure_streaming_supported(_tool, _request) do {:error, {:streaming_not_supported, :local_tool}} end defp tool_supports_streaming?(%{metadata: metadata}) when is_map(metadata) do value = Map.get(metadata, "supports_streaming") || Map.get(metadata, :supports_streaming) case value do true -> true "true" -> true "1" -> true 1 -> true _ -> false end end defp tool_supports_streaming?(_), do: false # Remote Stream Execution defp execute_remote_stream( %{type: :remote} = tool, %ExecuteToolRequest{} = request, stream, correlation_id, start_time_ms ) do with {:ok, decoded_params} <- decode_remote_tool_parameters(request.parameters, request.binary_parameters), {:ok, channel, cleanup} <- ensure_worker_channel(tool.worker_id), {:ok, worker_stream} <- forward_streaming_tool_to_worker(channel, request, decoded_params, correlation_id) do try do forward_worker_stream(worker_stream, stream, start_time_ms, tool) after cleanup.() end end end defp forward_streaming_tool_to_worker( channel, %ExecuteToolRequest{} = request, decoded_params, correlation_id ) do worker_metadata = ensure_metadata_correlation(request.metadata, correlation_id) binary_params = request.binary_parameters || %{} opts = worker_metadata |> tool_call_options() |> Keyword.put(:binary_parameters, binary_params) |> Keyword.put(:correlation_id, correlation_id) channel |> GRPCClient.execute_streaming_tool( request.session_id, request.tool_name, decoded_params, opts ) |> normalize_stream_response() end defp normalize_stream_response({:ok, stream, _headers}), do: {:ok, stream} defp normalize_stream_response({:ok, stream}), do: {:ok, stream} defp normalize_stream_response({:error, reason}), do: {:error, reason} defp normalize_stream_response(other), do: {:error, {:unexpected_stream_response, other}} # Stream Forwarding defp forward_worker_stream(worker_stream, grpc_stream, start_time_ms, tool) do acc0 = %{sent: 0, final_seen?: false} result = Enum.reduce_while(worker_stream, acc0, fn item, acc -> case normalize_stream_item(item) do {:ok, %ToolChunk{} = chunk} -> final_seen? = acc.final_seen? or chunk.is_final chunk = maybe_decorate_final_chunk(chunk, start_time_ms, tool) case safe_send_reply(grpc_stream, chunk) do :ok -> {:cont, %{acc | sent: acc.sent + 1, final_seen?: final_seen?}} {:error, reason} -> {:halt, {:error, {:stream_send_failed, reason}}} end :skip -> {:cont, acc} {:error, reason} -> {:halt, {:error, reason}} end end) case result do {:error, reason} -> {:error, reason} %{final_seen?: true} -> :ok %{final_seen?: false} = acc -> send_synthetic_final_chunk(grpc_stream, start_time_ms, tool, acc.sent) end end # Safe wrapper for GRPC.Server.send_reply that handles exceptions # Returns :ok on success, {:error, reason} on failure defp safe_send_reply(nil, _chunk) do # Test mode: nil stream means no actual sending :ok end defp safe_send_reply(grpc_stream, chunk) do try do _stream = GRPC.Server.send_reply(grpc_stream, chunk) :ok rescue e in GRPC.RPCError -> {:error, {:grpc_error, e.status, e.message}} e -> {:error, {:send_exception, Exception.message(e)}} catch :exit, reason -> {:error, {:stream_exit, reason}} end end defp normalize_stream_item({:ok, %ToolChunk{} = chunk}), do: {:ok, chunk} defp normalize_stream_item(%ToolChunk{} = chunk), do: {:ok, chunk} defp normalize_stream_item({:trailers, _trailers}), do: :skip defp normalize_stream_item({:error, reason}), do: {:error, reason} defp normalize_stream_item(other), do: {:error, {:unexpected_stream_item, other}} # Chunk Decoration and Synthetic Final defp maybe_decorate_final_chunk(%ToolChunk{is_final: true} = chunk, start_time_ms, tool) do exec_ms = System.monotonic_time(:millisecond) - start_time_ms metadata = (chunk.metadata || %{}) |> Map.put_new("execution_time_ms", to_string(exec_ms)) |> Map.put_new("tool_type", to_string(tool.type)) |> Map.put_new("worker_id", to_string(tool.worker_id || "")) %{chunk | metadata: metadata} end defp maybe_decorate_final_chunk(chunk, _start_time_ms, _tool), do: chunk defp send_synthetic_final_chunk(grpc_stream, start_time_ms, tool, sent_count) do exec_ms = System.monotonic_time(:millisecond) - start_time_ms chunk = %ToolChunk{ chunk_id: "sp-final-#{:erlang.unique_integer([:positive, :monotonic])}", data: <<>>, is_final: true, metadata: %{ "synthetic_final" => "true", "execution_time_ms" => to_string(exec_ms), "tool_type" => to_string(tool.type), "chunks_sent" => to_string(sent_count) } } case safe_send_reply(grpc_stream, chunk) do :ok -> :ok {:error, reason} -> {:error, {:stream_send_failed, reason}} end end # Error Handling for Streaming defp raise_streaming_rpc_error(%ExecuteToolRequest{} = request, {:streaming_not_supported, _}) do hint = "Streaming execution is not enabled for tool #{request.tool_name}. " <> "Enable streaming support on the adapter (supports_streaming: true) or use ExecuteTool instead." raise GRPC.RPCError, status: :unimplemented, message: hint end defp raise_streaming_rpc_error(_request, {:stream_send_failed, reason}) do raise GRPC.RPCError, status: :unavailable, message: "Client stream closed: #{inspect(reason)}" end defp raise_streaming_rpc_error(_request, {:invalid_parameter, _key, _reason} = error) do raise GRPC.RPCError, status: :invalid_argument, message: format_error(error) end defp raise_streaming_rpc_error(_request, reason) do raise GRPC.RPCError, status: :internal, message: format_error(reason) end defp format_error(reason) when is_binary(reason), do: reason defp format_error(reason) when is_atom(reason), do: to_string(reason) defp format_error({:error, reason}), do: format_error(reason) defp format_error({:unknown_type, type}), do: "Unknown type: #{inspect(type)}" defp format_error({:invalid_constraints, reason}), do: "Invalid constraints: #{reason}" defp format_error({:invalid_parameter, key, {:invalid_json, message}}) do "Invalid parameter #{key}: #{message}" end defp format_error({:invalid_parameter, key, {:invalid_json, message, type_url}}) do "Invalid parameter #{key} (#{type_url}): #{message}" end defp format_error({:invalid_parameter, key, reason}) do "Invalid parameter #{key}: #{inspect(reason)}" end defp format_error({:remote_execution_failed, reason}) when is_binary(reason) do "Remote tool execution failed: #{reason}" end defp format_error({:remote_execution_failed, reason}) do "Remote tool execution failed: #{inspect(reason)}" end defp format_error({:validation_failed, details}) when is_map(details) do "Validation failed: #{inspect(details)}" end defp format_error(reason), do: inspect(reason) # Tool Registration & Discovery def register_tools(%RegisterToolsRequest{} = request, _stream) do SLog.info(@log_category, "RegisterTools", session_id: request.session_id, worker_id: request.worker_id ) with {:ok, _session} <- SessionStore.get_session(request.session_id), tool_specs <- convert_proto_tools_to_specs(request.tools, request.worker_id), {:ok, registered_names} <- ToolRegistry.register_tools(request.session_id, tool_specs) do tool_ids = Map.new(registered_names, fn name -> {name, "#{request.session_id}:#{name}"} end) %RegisterToolsResponse{ success: true, tool_ids: tool_ids, error_message: nil } else {:error, reason} -> %RegisterToolsResponse{ success: false, tool_ids: %{}, error_message: format_error(reason) } end end defp convert_proto_tools_to_specs(tools, worker_id) do Enum.map(tools, fn tool_reg -> %{ name: tool_reg.name, description: tool_reg.description, parameters: tool_reg.parameters, metadata: Map.put( tool_reg.metadata, "supports_streaming", to_string(tool_reg.supports_streaming) ), worker_id: worker_id } end) end def get_exposed_elixir_tools(%GetExposedElixirToolsRequest{session_id: session_id}, _stream) do SLog.debug(@log_category, "GetExposedElixirTools", session_id: session_id) tools = ToolRegistry.list_exposed_elixir_tools(session_id) tool_specs = Enum.map(tools, fn tool -> # Convert metadata, handling different value types metadata = Map.new(tool.metadata, fn {k, v} when is_binary(v) or is_atom(v) or is_number(v) -> {to_string(k), to_string(v)} {k, v} when is_list(v) -> # Don't include complex lists in metadata {to_string(k), inspect(v)} {k, v} -> # For other types, use inspect {to_string(k), inspect(v)} end) # Remove parameters from metadata since they're handled separately metadata = Map.delete(metadata, "parameters") %ToolSpec{ name: tool.name, description: tool.description, parameters: encode_parameter_specs(tool.parameters), metadata: metadata, supports_streaming: Map.get(metadata, "supports_streaming", "false") == "true" } end) %GetExposedElixirToolsResponse{ tools: tool_specs } end def execute_elixir_tool(%ExecuteElixirToolRequest{} = request, _stream) do SLog.info(@log_category, "ExecuteElixirTool", tool_name: request.tool_name, session_id: request.session_id ) start_time = System.monotonic_time(:millisecond) with {:ok, _session} <- SessionStore.get_session(request.session_id), {:ok, tool} <- ToolRegistry.get_tool(request.session_id, request.tool_name), :local <- tool.type, {:ok, params} <- decode_tool_parameters(request.parameters, %{}), {:ok, result} <- ToolRegistry.execute_local_tool(request.session_id, request.tool_name, params) do execution_time = System.monotonic_time(:millisecond) - start_time {encoded_result, binary_result} = encode_tool_result(result) %ExecuteElixirToolResponse{ success: true, result: encoded_result, binary_result: binary_result || <<>>, error_message: nil, metadata: %{ "execution_time" => to_string(execution_time) }, execution_time_ms: execution_time } else :remote -> %ExecuteElixirToolResponse{ success: false, result: nil, error_message: "Tool #{request.tool_name} is not an Elixir tool", metadata: %{}, execution_time_ms: System.monotonic_time(:millisecond) - start_time } {:error, reason} -> %ExecuteElixirToolResponse{ success: false, result: nil, error_message: format_error(reason), metadata: %{}, execution_time_ms: System.monotonic_time(:millisecond) - start_time } end end defp encode_parameter_specs(params) when is_list(params) do Enum.map(params, &encode_single_parameter_spec/1) end defp encode_parameter_specs(_), do: [] defp encode_single_parameter_spec(param) do # Convert atom keys to strings normalized_param = normalize_param_map(param) %ParameterSpec{ name: Map.get(normalized_param, "name", ""), type: to_string(Map.get(normalized_param, "type", "any")), description: to_string(Map.get(normalized_param, "description", "")), required: Map.get(normalized_param, "required", false), default_value: encode_default_value(Map.get(normalized_param, "default")), validation_json: Jason.encode!(Map.get(normalized_param, "validation", %{})) } end defp normalize_param_map(%{} = param) do Map.new(param, fn {k, v} -> {to_string(k), v} end) end defp normalize_param_map(param), do: param defp encode_default_value(nil), do: nil defp encode_default_value(value) do {any, _binary} = encode_tool_result(value) any end defp encode_tool_result({:binary, data}) when is_binary(data) do {empty_any(), data} end defp encode_tool_result({:binary, data, metadata}) when is_binary(data) do {encode_any(metadata), data} end defp encode_tool_result(value) do {encode_any(value), nil} end defp encode_any(value) do case Jason.encode(value) do {:ok, json_string} when is_binary(json_string) -> %Any{ type_url: "type.googleapis.com/google.protobuf.StringValue", value: json_string } {:error, _} -> %Any{ type_url: "type.googleapis.com/google.protobuf.StringValue", value: inspect(value) } end end # Used when we only have an opaque binary payload and no metadata defp empty_any do %Any{ type_url: "type.googleapis.com/google.protobuf.StringValue", value: "{}" } end end