Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ This directory contains the current Elixir/OTP implementation of Symphony, based

## How it works

1. Polls the configured tracker for candidate work (the included production adapter is Linear)
1. Polls the configured tracker for candidate work (included adapters: Linear and GitHub Issues)
2. Creates a workspace per issue
3. Launches Codex in [App Server mode](https://developers.openai.com/codex/app-server/) inside the
workspace
4. Sends a workflow prompt to Codex
5. Keeps Codex working on the issue until the work is done

During app-server sessions, the selected tracker adapter may advertise provider-native tools. The
included Linear adapter serves `linear_graphql` so repo skills can make raw Linear GraphQL calls.
Symphony executes that tool with its configured auth and removes `LINEAR_API_KEY` from the Codex
child environment, so the agent does not need a second tracker login.
Linear adapter serves `linear_graphql`; the GitHub Issues adapter serves `github_api`. Symphony
executes those tools with configured host-side auth and removes declared tracker-token environment
variables from the Codex child, so the agent does not need a second tracker login.

If a claimed issue moves to a terminal state (`Done`, `Closed`, `Cancelled`, or `Duplicate`),
Symphony stops the active agent for that issue and cleans up matching workspaces.
Expand Down Expand Up @@ -238,6 +238,20 @@ codex:
`tracker_payload`, and missing cursors to `tracker_pagination`; logs and tool responses carry the
human-readable provider detail.

### GitHub Issues adapter

- Config: use `tracker.kind: github` with required `tracker.provider.repo` in `owner/repo` form,
optional `token` (defaults to `GITHUB_TOKEN` and accepts `$VAR`), and optional `api_url`
(default `https://api.github.com`, HTTPS only). Set explicit `active_states` and
`terminal_states`; active entries may be `open` and terminal entries may be `closed`.
- Reads and identity: polling is scoped to the configured repository; `issue.id` is the
repository issue number, `issue.identifier` is `GH-<number>`, hidden or deleted `404` issues are
omitted on refresh, and pull requests returned by the Issues API are not dispatchable.
- Tool and auth: `github_api` accepts a relative REST `path` plus optional `params` and JSON
`body`; Symphony executes it host-side with the session-bound token, strips `GITHUB_TOKEN` and
configured `$VAR` token names from the Codex child, and leaves raw tool access limited by that
token's GitHub permissions.

## Web dashboard

The observability UI now runs on a minimal Phoenix stack:
Expand Down Expand Up @@ -291,6 +305,15 @@ The live test creates a temporary Linear project and issue, writes a temporary `
a real agent turn, verifies the workspace side effect, requires Codex to comment on and close the
Linear issue, then marks the project completed so the run remains visible in Linear.

Run the opt-in GitHub Issues live test with a disposable/scratch repository:

```bash
cd elixir
export SYMPHONY_LIVE_GITHUB_REPO=owner/scratch-repo
export GITHUB_TOKEN=...
SYMPHONY_RUN_GITHUB_LIVE_E2E=1 mix test test/symphony_elixir/github_live_e2e_test.exs
```

## FAQ

### Why Elixir?
Expand Down
63 changes: 63 additions & 0 deletions elixir/lib/symphony_elixir/github/adapter.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
defmodule SymphonyElixir.GitHub.Adapter do
@moduledoc """
GitHub Issues-backed tracker adapter.
"""

@behaviour SymphonyElixir.Tracker

alias SymphonyElixir.GitHub.{AgentTool, Client}
alias SymphonyElixir.Tracker.Issue

@active_states ["open"]
@terminal_states ["closed"]

@spec validate_config(map()) :: :ok | {:error, term()}
def validate_config(tracker_settings) do
with :ok <-
validate_states(
tracker_settings.active_states,
@active_states,
:missing_github_active_states
),
:ok <-
validate_states(
tracker_settings.terminal_states,
@terminal_states,
:missing_github_terminal_states
) do
Client.validate_settings(tracker_settings)
end
end

@spec fetch_issues_by_states([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()}
def fetch_issues_by_states(states), do: client_module().fetch_issues_by_states(states)

@spec fetch_issues_by_ids([String.t()]) :: {:ok, [Issue.t()]} | {:error, term()}
def fetch_issues_by_ids(issue_ids), do: client_module().fetch_issues_by_ids(issue_ids)

@spec agent_tool_specs() :: [map()]
def agent_tool_specs, do: AgentTool.tool_specs()

@spec execute_agent_tool(String.t(), term(), keyword()) :: map()
def execute_agent_tool(tool, arguments, opts), do: AgentTool.execute(tool, arguments, opts)

@spec secret_environment_names(map()) :: [String.t()]
def secret_environment_names(tracker_settings), do: Client.secret_environment_names(tracker_settings)

defp client_module do
Application.get_env(:symphony_elixir, :github_client_module, Client)
end

defp validate_states(states, allowed_states, _missing_error) when is_list(states) do
if Enum.all?(states, &(normalize_state(&1) in allowed_states)) do
:ok
else
{:error, :invalid_github_states}
end
end

defp validate_states(_states, _allowed_states, missing_error), do: {:error, missing_error}

defp normalize_state(state) when is_binary(state), do: state |> String.trim() |> String.downcase()
defp normalize_state(_state), do: ""
end
173 changes: 173 additions & 0 deletions elixir/lib/symphony_elixir/github/agent_tool.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
defmodule SymphonyElixir.GitHub.AgentTool do
@moduledoc """
Provider-native GitHub REST tool exposed to Codex app-server turns.
"""

alias SymphonyElixir.GitHub.Client

@github_api_tool "github_api"
@allowed_methods ["GET", "POST", "PATCH", "PUT", "DELETE"]
@github_api_description """
Execute a GitHub REST API request using Symphony's configured auth.
"""
@github_api_input_schema %{
"type" => "object",
"additionalProperties" => false,
"required" => ["method", "path"],
"properties" => %{
"method" => %{
"type" => "string",
"enum" => @allowed_methods,
"description" => "GitHub REST method."
},
"path" => %{
"type" => "string",
"description" => "GitHub REST path such as /repos/owner/repo/issues/1/comments."
},
"params" => %{
"type" => ["object", "null"],
"description" => "Optional query parameters.",
"additionalProperties" => true
},
"body" => %{
"description" => "Optional JSON request body."
}
}
}

@spec execute(String.t() | nil, term(), keyword()) :: map()
def execute(tool, arguments, opts) do
case tool do
@github_api_tool -> execute_github_api(arguments, opts)
other -> unsupported_tool_response(other)
end
end

@spec tool_specs() :: [map()]
def tool_specs do
[
%{
"name" => @github_api_tool,
"description" => @github_api_description,
"inputSchema" => @github_api_input_schema
}
]
end

defp execute_github_api(arguments, opts) do
github_client = Keyword.get(opts, :github_client, &Client.request/5)
client_opts = Keyword.take(opts, [:tracker_settings])

with {:ok, method, path, params, body} <- normalize_arguments(arguments),
{:ok, %{status: status, body: response_body}} <-
github_client.(method, path, params, body, client_opts),
true <- is_integer(status) do
rest_response(status, response_body)
else
{:error, reason} -> failure_response(tool_error_payload(reason))
_ -> failure_response(tool_error_payload(:github_unknown_payload))
end
end

defp normalize_arguments(arguments) when is_map(arguments) do
with {:ok, method} <- normalize_method(Map.get(arguments, "method")),
{:ok, path} <- normalize_path(Map.get(arguments, "path")),
{:ok, params} <- normalize_params(Map.get(arguments, "params")) do
{:ok, method, path, params, Map.get(arguments, "body")}
end
end

defp normalize_arguments(_arguments), do: {:error, :invalid_arguments}

defp normalize_method(method) when is_binary(method) do
normalized = method |> String.trim() |> String.upcase()
if normalized in @allowed_methods, do: {:ok, normalized}, else: {:error, :invalid_method}
end

defp normalize_method(_method), do: {:error, :invalid_method}

defp normalize_path(path) when is_binary(path) do
trimmed = String.trim(path)

if String.starts_with?(trimmed, "/") and not String.contains?(trimmed, ["://", "\n", "\r", <<0>>]) do
{:ok, trimmed}
else
{:error, :invalid_path}
end
end

defp normalize_path(_path), do: {:error, :invalid_path}

defp normalize_params(nil), do: {:ok, %{}}
defp normalize_params(params) when is_map(params), do: {:ok, params}
defp normalize_params(_params), do: {:error, :invalid_params}

defp rest_response(status, body) do
dynamic_tool_response(status in 200..299, encode_payload(%{"status" => status, "body" => body}))
end

defp failure_response(payload), do: dynamic_tool_response(false, encode_payload(payload))

defp dynamic_tool_response(success, output) do
%{
"success" => success,
"output" => output,
"contentItems" => [%{"type" => "inputText", "text" => output}]
}
end

defp encode_payload(payload) do
case Jason.encode(payload, pretty: true) do
{:ok, output} -> output
{:error, _reason} -> inspect(payload)
end
end

defp unsupported_tool_response(tool) do
failure_response(%{
"error" => %{
"message" => "Unsupported dynamic tool: #{inspect(tool)}.",
"supportedTools" => supported_tool_names()
}
})
end

defp tool_error_payload(:invalid_arguments) do
%{"error" => %{"message" => "`github_api` expects an object with `method` and `path`."}}
end

defp tool_error_payload(:invalid_method) do
%{"error" => %{"message" => "`github_api.method` must be GET, POST, PATCH, PUT, or DELETE."}}
end

defp tool_error_payload(:invalid_path) do
%{"error" => %{"message" => "`github_api.path` must be a relative GitHub REST path."}}
end

defp tool_error_payload(:invalid_params) do
%{"error" => %{"message" => "`github_api.params` must be a JSON object when provided."}}
end

defp tool_error_payload(:missing_github_token) do
%{
"error" => %{
"message" => "Symphony is missing GitHub auth. Set `tracker.provider.token` in `WORKFLOW.md` or export `GITHUB_TOKEN`."
}
}
end

defp tool_error_payload({:github_api_request, reason}) do
%{
"error" => %{
"message" => "GitHub API request failed before receiving a successful response.",
"reason" => inspect(reason)
}
}
end

defp tool_error_payload(reason) do
%{"error" => %{"message" => "GitHub API tool execution failed.", "reason" => inspect(reason)}}
end

defp supported_tool_names, do: Enum.map(tool_specs(), & &1["name"])
end
Loading
Loading