openai/symphony
Publicmirrored from https://github.com/openai/symphonyAvailable
elixir/lib/symphony_elixir/codex/dynamic_tool.ex
209lines · modecode
| 1 | defmodule SymphonyElixir.Codex.DynamicTool do |
| 2 | @moduledoc """ |
| 3 | Executes client-side tool calls requested by Codex app-server turns. |
| 4 | """ |
| 5 | |
| 6 | alias SymphonyElixir.Linear.Client |
| 7 | |
| 8 | @linear_graphql_tool "linear_graphql" |
| 9 | @linear_graphql_description """ |
| 10 | Execute a raw GraphQL query or mutation against Linear using Symphony's configured auth. |
| 11 | """ |
| 12 | @linear_graphql_input_schema %{ |
| 13 | "type" => "object", |
| 14 | "additionalProperties" => false, |
| 15 | "required" => ["query"], |
| 16 | "properties" => %{ |
| 17 | "query" => %{ |
| 18 | "type" => "string", |
| 19 | "description" => "GraphQL query or mutation document to execute against Linear." |
| 20 | }, |
| 21 | "variables" => %{ |
| 22 | "type" => ["object", "null"], |
| 23 | "description" => "Optional GraphQL variables object.", |
| 24 | "additionalProperties" => true |
| 25 | } |
| 26 | } |
| 27 | } |
| 28 | |
| 29 | @spec execute(String.t() | nil, term(), keyword()) :: map() |
| 30 | def execute(tool, arguments, opts \\ []) do |
| 31 | case tool do |
| 32 | @linear_graphql_tool -> |
| 33 | execute_linear_graphql(arguments, opts) |
| 34 | |
| 35 | other -> |
| 36 | failure_response(%{ |
| 37 | "error" => %{ |
| 38 | "message" => "Unsupported dynamic tool: #{inspect(other)}.", |
| 39 | "supportedTools" => supported_tool_names() |
| 40 | } |
| 41 | }) |
| 42 | end |
| 43 | end |
| 44 | |
| 45 | @spec tool_specs() :: [map()] |
| 46 | def tool_specs do |
| 47 | [ |
| 48 | %{ |
| 49 | "name" => @linear_graphql_tool, |
| 50 | "description" => @linear_graphql_description, |
| 51 | "inputSchema" => @linear_graphql_input_schema |
| 52 | } |
| 53 | ] |
| 54 | end |
| 55 | |
| 56 | defp execute_linear_graphql(arguments, opts) do |
| 57 | linear_client = Keyword.get(opts, :linear_client, &Client.graphql/3) |
| 58 | |
| 59 | with {:ok, query, variables} <- normalize_linear_graphql_arguments(arguments), |
| 60 | {:ok, response} <- linear_client.(query, variables, []) do |
| 61 | graphql_response(response) |
| 62 | else |
| 63 | {:error, reason} -> |
| 64 | failure_response(tool_error_payload(reason)) |
| 65 | end |
| 66 | end |
| 67 | |
| 68 | defp normalize_linear_graphql_arguments(arguments) when is_binary(arguments) do |
| 69 | case String.trim(arguments) do |
| 70 | "" -> {:error, :missing_query} |
| 71 | query -> {:ok, query, %{}} |
| 72 | end |
| 73 | end |
| 74 | |
| 75 | defp normalize_linear_graphql_arguments(arguments) when is_map(arguments) do |
| 76 | case normalize_query(arguments) do |
| 77 | {:ok, query} -> |
| 78 | case normalize_variables(arguments) do |
| 79 | {:ok, variables} -> |
| 80 | {:ok, query, variables} |
| 81 | |
| 82 | {:error, reason} -> |
| 83 | {:error, reason} |
| 84 | end |
| 85 | |
| 86 | {:error, reason} -> |
| 87 | {:error, reason} |
| 88 | end |
| 89 | end |
| 90 | |
| 91 | defp normalize_linear_graphql_arguments(_arguments), do: {:error, :invalid_arguments} |
| 92 | |
| 93 | defp normalize_query(arguments) do |
| 94 | case Map.get(arguments, "query") || Map.get(arguments, :query) do |
| 95 | query when is_binary(query) -> |
| 96 | case String.trim(query) do |
| 97 | "" -> {:error, :missing_query} |
| 98 | trimmed -> {:ok, trimmed} |
| 99 | end |
| 100 | |
| 101 | _ -> |
| 102 | {:error, :missing_query} |
| 103 | end |
| 104 | end |
| 105 | |
| 106 | defp normalize_variables(arguments) do |
| 107 | case Map.get(arguments, "variables") || Map.get(arguments, :variables) || %{} do |
| 108 | variables when is_map(variables) -> {:ok, variables} |
| 109 | _ -> {:error, :invalid_variables} |
| 110 | end |
| 111 | end |
| 112 | |
| 113 | defp graphql_response(response) do |
| 114 | success = |
| 115 | case response do |
| 116 | %{"errors" => errors} when is_list(errors) and errors != [] -> false |
| 117 | %{errors: errors} when is_list(errors) and errors != [] -> false |
| 118 | _ -> true |
| 119 | end |
| 120 | |
| 121 | dynamic_tool_response(success, encode_payload(response)) |
| 122 | end |
| 123 | |
| 124 | defp failure_response(payload) do |
| 125 | dynamic_tool_response(false, encode_payload(payload)) |
| 126 | end |
| 127 | |
| 128 | defp dynamic_tool_response(success, output) when is_boolean(success) and is_binary(output) do |
| 129 | %{ |
| 130 | "success" => success, |
| 131 | "output" => output, |
| 132 | "contentItems" => [ |
| 133 | %{ |
| 134 | "type" => "inputText", |
| 135 | "text" => output |
| 136 | } |
| 137 | ] |
| 138 | } |
| 139 | end |
| 140 | |
| 141 | defp encode_payload(payload) when is_map(payload) or is_list(payload) do |
| 142 | Jason.encode!(payload, pretty: true) |
| 143 | end |
| 144 | |
| 145 | defp encode_payload(payload), do: inspect(payload) |
| 146 | |
| 147 | defp tool_error_payload(:missing_query) do |
| 148 | %{ |
| 149 | "error" => %{ |
| 150 | "message" => "`linear_graphql` requires a non-empty `query` string." |
| 151 | } |
| 152 | } |
| 153 | end |
| 154 | |
| 155 | defp tool_error_payload(:invalid_arguments) do |
| 156 | %{ |
| 157 | "error" => %{ |
| 158 | "message" => "`linear_graphql` expects either a GraphQL query string or an object with `query` and optional `variables`." |
| 159 | } |
| 160 | } |
| 161 | end |
| 162 | |
| 163 | defp tool_error_payload(:invalid_variables) do |
| 164 | %{ |
| 165 | "error" => %{ |
| 166 | "message" => "`linear_graphql.variables` must be a JSON object when provided." |
| 167 | } |
| 168 | } |
| 169 | end |
| 170 | |
| 171 | defp tool_error_payload(:missing_linear_api_token) do |
| 172 | %{ |
| 173 | "error" => %{ |
| 174 | "message" => "Symphony is missing Linear auth. Set `linear.api_key` in `WORKFLOW.md` or export `LINEAR_API_KEY`." |
| 175 | } |
| 176 | } |
| 177 | end |
| 178 | |
| 179 | defp tool_error_payload({:linear_api_status, status}) do |
| 180 | %{ |
| 181 | "error" => %{ |
| 182 | "message" => "Linear GraphQL request failed with HTTP #{status}.", |
| 183 | "status" => status |
| 184 | } |
| 185 | } |
| 186 | end |
| 187 | |
| 188 | defp tool_error_payload({:linear_api_request, reason}) do |
| 189 | %{ |
| 190 | "error" => %{ |
| 191 | "message" => "Linear GraphQL request failed before receiving a successful response.", |
| 192 | "reason" => inspect(reason) |
| 193 | } |
| 194 | } |
| 195 | end |
| 196 | |
| 197 | defp tool_error_payload(reason) do |
| 198 | %{ |
| 199 | "error" => %{ |
| 200 | "message" => "Linear GraphQL tool execution failed.", |
| 201 | "reason" => inspect(reason) |
| 202 | } |
| 203 | } |
| 204 | end |
| 205 | |
| 206 | defp supported_tool_names do |
| 207 | Enum.map(tool_specs(), & &1["name"]) |
| 208 | end |
| 209 | end |
| 210 | |