caclawphony/elixir/lib/symphony_elixir/codex/dynamic_tool.ex
Alex Kotliarskyi fa75ec68c2 🎼
2026-03-04 09:29:11 -08:00

213 lines
5.4 KiB
Elixir

defmodule SymphonyElixir.Codex.DynamicTool do
@moduledoc """
Executes client-side tool calls requested by Codex app-server turns.
"""
alias SymphonyElixir.Linear.Client
@linear_graphql_tool "linear_graphql"
@linear_graphql_description """
Execute a raw GraphQL query or mutation against Linear using Symphony's configured auth.
"""
@linear_graphql_input_schema %{
"type" => "object",
"additionalProperties" => false,
"required" => ["query"],
"properties" => %{
"query" => %{
"type" => "string",
"description" => "GraphQL query or mutation document to execute against Linear."
},
"variables" => %{
"type" => ["object", "null"],
"description" => "Optional GraphQL variables object.",
"additionalProperties" => true
}
}
}
@spec execute(String.t() | nil, term(), keyword()) :: map()
def execute(tool, arguments, opts \\ []) do
case tool do
@linear_graphql_tool ->
execute_linear_graphql(arguments, opts)
other ->
failure_response(%{
"error" => %{
"message" => "Unsupported dynamic tool: #{inspect(other)}.",
"supportedTools" => supported_tool_names()
}
})
end
end
@spec tool_specs() :: [map()]
def tool_specs do
[
%{
"name" => @linear_graphql_tool,
"description" => @linear_graphql_description,
"inputSchema" => @linear_graphql_input_schema
}
]
end
defp execute_linear_graphql(arguments, opts) do
linear_client = Keyword.get(opts, :linear_client, &Client.graphql/3)
with {:ok, query, variables} <- normalize_linear_graphql_arguments(arguments),
{:ok, response} <- linear_client.(query, variables, []) do
graphql_response(response)
else
{:error, reason} ->
failure_response(tool_error_payload(reason))
end
end
defp normalize_linear_graphql_arguments(arguments) when is_binary(arguments) do
case String.trim(arguments) do
"" -> {:error, :missing_query}
query -> {:ok, query, %{}}
end
end
defp normalize_linear_graphql_arguments(arguments) when is_map(arguments) do
case normalize_query(arguments) do
{:ok, query} ->
case normalize_variables(arguments) do
{:ok, variables} ->
{:ok, query, variables}
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
defp normalize_linear_graphql_arguments(_arguments), do: {:error, :invalid_arguments}
defp normalize_query(arguments) do
case Map.get(arguments, "query") || Map.get(arguments, :query) do
query when is_binary(query) ->
case String.trim(query) do
"" -> {:error, :missing_query}
trimmed -> {:ok, trimmed}
end
_ ->
{:error, :missing_query}
end
end
defp normalize_variables(arguments) do
case Map.get(arguments, "variables") || Map.get(arguments, :variables) || %{} do
variables when is_map(variables) -> {:ok, variables}
_ -> {:error, :invalid_variables}
end
end
defp graphql_response(response) do
success =
case response do
%{"errors" => errors} when is_list(errors) and errors != [] -> false
%{errors: errors} when is_list(errors) and errors != [] -> false
_ -> true
end
%{
"success" => success,
"contentItems" => [
%{
"type" => "inputText",
"text" => encode_payload(response)
}
]
}
end
defp failure_response(payload) do
%{
"success" => false,
"contentItems" => [
%{
"type" => "inputText",
"text" => encode_payload(payload)
}
]
}
end
defp encode_payload(payload) when is_map(payload) or is_list(payload) do
Jason.encode!(payload, pretty: true)
end
defp encode_payload(payload), do: inspect(payload)
defp tool_error_payload(:missing_query) do
%{
"error" => %{
"message" => "`linear_graphql` requires a non-empty `query` string."
}
}
end
defp tool_error_payload(:invalid_arguments) do
%{
"error" => %{
"message" => "`linear_graphql` expects either a GraphQL query string or an object with `query` and optional `variables`."
}
}
end
defp tool_error_payload(:invalid_variables) do
%{
"error" => %{
"message" => "`linear_graphql.variables` must be a JSON object when provided."
}
}
end
defp tool_error_payload(:missing_linear_api_token) do
%{
"error" => %{
"message" => "Symphony is missing Linear auth. Set `linear.api_key` in `WORKFLOW.md` or export `LINEAR_API_KEY`."
}
}
end
defp tool_error_payload({:linear_api_status, status}) do
%{
"error" => %{
"message" => "Linear GraphQL request failed with HTTP #{status}.",
"status" => status
}
}
end
defp tool_error_payload({:linear_api_request, reason}) do
%{
"error" => %{
"message" => "Linear GraphQL request failed before receiving a successful response.",
"reason" => inspect(reason)
}
}
end
defp tool_error_payload(reason) do
%{
"error" => %{
"message" => "Linear GraphQL tool execution failed.",
"reason" => inspect(reason)
}
}
end
defp supported_tool_names do
Enum.map(tool_specs(), & &1["name"])
end
end