defmodule SyntropyWeb.Api.RunRequest do @moduledoc false @type parsed_request :: %{prompt: String.t(), opts: keyword()} @strategies ~w(single parallel all auto) @required_source_fields ~w(engine repository review_goal) @source_tool_actions ~w(search open_file related_files dependency_context) @spec parse(map()) :: {:ok, parsed_request()} | {:error, [map()]} def parse(params) do with {:ok, prompt} <- parse_prompt(Map.get(params, "prompt")), {:ok, strategy} <- parse_strategy(Map.get(params, "strategy")), {:ok, agent_ids} <- parse_agent_ids(Map.get(params, "agent_ids")), {:ok, agent_count} <- parse_positive_integer(Map.get(params, "agent_count"), 3, "agent_count"), {:ok, synthesize} <- parse_boolean(Map.get(params, "synthesize"), true, "synthesize"), {:ok, source_review} <- parse_source_review(Map.get(params, "source_review")), {:ok, source_tool} <- parse_source_tool(Map.get(params, "source_tool")), :ok <- validate_source_tool_context(source_tool, source_review), {:ok, code_review} <- parse_code_review(Map.get(params, "code_review")), {:ok, kind} <- parse_kind(Map.get(params, "kind"), source_review), :ok <- validate_code_review_context(kind, code_review, source_review, source_tool) do {:ok, %{ prompt: prompt, opts: [] |> Keyword.put(:strategy, strategy) |> maybe_put(:agent_ids, agent_ids) |> Keyword.put(:agent_count, agent_count) |> Keyword.put(:synthesize, synthesize) |> Keyword.put(:kind, kind) |> maybe_put(:source_review, source_review) |> maybe_put(:source_tool, source_tool) |> maybe_put(:code_review, code_review) }} else {:error, errors} -> {:error, errors} end end defp maybe_put(opts, _key, nil), do: opts defp maybe_put(opts, key, value), do: Keyword.put(opts, key, value) defp parse_prompt(prompt) when is_binary(prompt) do trimmed = String.trim(prompt) if trimmed == "" do {:error, [error("prompt", "must be a non-empty string")]} else {:ok, trimmed} end end defp parse_prompt(_prompt), do: {:error, [error("prompt", "is required")]} defp parse_strategy(nil), do: {:ok, :parallel} defp parse_strategy(strategy) when is_binary(strategy) do if strategy in @strategies do {:ok, String.to_atom(strategy)} else {:error, [error("strategy", "must be one of single, parallel, all, auto")]} end end defp parse_strategy(_strategy), do: {:error, [error("strategy", "must be one of single, parallel, all, auto")]} defp parse_agent_ids(nil), do: {:ok, nil} defp parse_agent_ids(agent_ids) when is_list(agent_ids) do if Enum.all?(agent_ids, &is_binary/1) do {:ok, agent_ids} else {:error, [error("agent_ids", "must be an array of strings")]} end end defp parse_agent_ids(_agent_ids), do: {:error, [error("agent_ids", "must be an array of strings")]} defp parse_positive_integer(nil, default, _field), do: {:ok, default} defp parse_positive_integer(value, _default, _field) when is_integer(value) and value > 0, do: {:ok, value} defp parse_positive_integer(value, _default, field) when is_binary(value) do case Integer.parse(value) do {parsed, ""} when parsed > 0 -> {:ok, parsed} _other -> {:error, [error(field, "must be a positive integer")]} end end defp parse_positive_integer(_value, _default, field), do: {:error, [error(field, "must be a positive integer")]} defp parse_boolean(nil, default, _field), do: {:ok, default} defp parse_boolean(value, _default, _field) when is_boolean(value), do: {:ok, value} defp parse_boolean(_value, _default, field), do: {:error, [error(field, "must be a boolean")]} defp parse_kind(nil, nil), do: {:ok, "prompt"} defp parse_kind(nil, _source_review), do: {:ok, "source_review"} defp parse_kind(kind, _source_review) when kind in ["prompt", "source_review", "code_review"], do: {:ok, kind} defp parse_kind(_kind, _source_review), do: {:error, [error("kind", "must be prompt, source_review, or code_review")]} # Receipts runs read a local repository, so they sit behind the same gate # as the local source tool. `code_review` is never inferred: the kind must # be explicit and the payload must travel with it, exclusively. defp parse_code_review(nil), do: {:ok, nil} defp parse_code_review(code_review) when is_map(code_review) do checks = Map.get(code_review, "checks") cond do not source_tool_enabled?() -> {:error, [error("code_review", "is disabled in this environment")]} not present_string?(Map.get(code_review, "repo_path")) -> {:error, [error("code_review.repo_path", "is required")]} not present_string?(Map.get(code_review, "base")) -> {:error, [error("code_review.base", "is required")]} not (is_nil(checks) or (is_list(checks) and Enum.all?(checks, &present_string?/1))) -> {:error, [error("code_review.checks", "must be an array of check names")]} true -> {:ok, normalize_code_review(code_review, checks)} end end defp parse_code_review(_code_review), do: {:error, [error("code_review", "must be an object")]} defp validate_code_review_context("code_review", nil, _source_review, _source_tool), do: {:error, [error("code_review", "is required when kind is code_review")]} defp validate_code_review_context("code_review", _code_review, source_review, source_tool) when not is_nil(source_review) or not is_nil(source_tool), do: {:error, [error("code_review", "cannot be combined with source_review or source_tool")]} defp validate_code_review_context("code_review", _code_review, _source_review, _source_tool), do: :ok defp validate_code_review_context(_kind, nil, _source_review, _source_tool), do: :ok defp validate_code_review_context(_kind, _code_review, _source_review, _source_tool), do: {:error, [error("kind", "must be code_review when a code_review object is provided")]} defp normalize_code_review(code_review, checks) do normalized = %{ "repo_path" => String.trim(Map.get(code_review, "repo_path")), "base" => String.trim(Map.get(code_review, "base")) } normalized = case Map.get(code_review, "head") do head when is_binary(head) -> Map.put(normalized, "head", String.trim(head)) _other -> normalized end if is_list(checks) do Map.put(normalized, "checks", Enum.map(checks, &String.trim/1)) else normalized end end defp parse_source_review(nil), do: {:ok, nil} defp parse_source_review(source_review) when is_map(source_review) do missing = Enum.filter(@required_source_fields, fn field -> not present_string?(Map.get(source_review, field)) end) focus_areas = Map.get(source_review, "focus_areas") cond do missing != [] -> {:error, Enum.map(missing, fn field -> error("source_review.#{field}", "is required") end)} not is_list(focus_areas) -> {:error, [error("source_review.focus_areas", "must be an array")]} true -> {:ok, source_review} end end defp parse_source_review(_source_review), do: {:error, [error("source_review", "must be an object")]} defp parse_source_tool(nil), do: {:ok, nil} defp parse_source_tool(source_tool) when is_map(source_tool) do cond do not source_tool_enabled?() -> {:error, [error("source_tool", "is disabled in this environment")]} Map.get(source_tool, "engine") != "librarian" -> {:error, [error("source_tool.engine", "must be librarian")]} not present_string?(Map.get(source_tool, "repo_path")) -> {:error, [error("source_tool.repo_path", "is required")]} true -> with {:ok, allowed_actions} <- parse_source_tool_actions(Map.get(source_tool, "allowed_actions")) do {:ok, normalize_source_tool(source_tool, allowed_actions)} end end end defp parse_source_tool(_source_tool), do: {:error, [error("source_tool", "must be an object")]} defp validate_source_tool_context(nil, _source_review), do: :ok defp validate_source_tool_context(_source_tool, source_review) when is_map(source_review) do if Map.get(source_review, "engine") == "librarian" do :ok else {:error, [error("source_tool", "requires a librarian source_review")]} end end defp validate_source_tool_context(_source_tool, _source_review), do: {:error, [error("source_tool", "requires source_review")]} defp source_tool_enabled? do Application.get_env(:syntropy, :source_tool_enabled, false) || System.get_env("SYNTROPY_ENABLE_LOCAL_REPO_REVIEW") == "true" end defp normalize_source_tool(source_tool, allowed_actions) do %{ "engine" => "librarian", "repo_path" => String.trim(Map.get(source_tool, "repo_path")), "allowed_actions" => allowed_actions } |> maybe_put_source_tool_integer(source_tool, "budget") |> maybe_put_source_tool_integer(source_tool, "timeout_ms") |> maybe_put_source_tool_integer(source_tool, "max_stdout_bytes") end defp parse_source_tool_actions(nil), do: {:ok, @source_tool_actions} defp parse_source_tool_actions(actions) when is_list(actions) do normalized = actions |> Enum.map(fn action when is_binary(action) -> String.trim(action) _action -> nil end) unsupported = normalized |> Enum.reject(&(&1 in @source_tool_actions)) cond do normalized == [] -> {:error, [error("source_tool.allowed_actions", "must include at least one action")]} unsupported != [] -> {:error, [ error( "source_tool.allowed_actions", "must contain only search, open_file, related_files, or dependency_context" ) ]} true -> {:ok, Enum.uniq(normalized)} end end defp parse_source_tool_actions(_actions), do: {:error, [error("source_tool.allowed_actions", "must be an array of strings")]} defp maybe_put_source_tool_integer(normalized, source_tool, key) do case positive_integer_value(Map.get(source_tool, key)) do nil -> normalized value -> Map.put(normalized, key, value) end end defp positive_integer_value(value) when is_integer(value) and value > 0, do: value defp positive_integer_value(value) when is_binary(value) do case Integer.parse(value) do {parsed, ""} when parsed > 0 -> parsed _other -> nil end end defp positive_integer_value(_value), do: nil defp present_string?(value), do: is_binary(value) and String.trim(value) != "" defp error(field, message), do: %{field: field, message: message} end