Skip to content
Draft
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
8 changes: 4 additions & 4 deletions elixir/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ Title: {{ issue.title }} Body: {{ issue.description }}
Notes:

- If a value is missing, defaults are used.
- Safer Codex defaults are used when policy fields are omitted:
- `codex.approval_policy` defaults to `{"reject":{"sandbox_approval":true,"rules":true,"mcp_elicitations":true}}`
- `codex.thread_sandbox` defaults to `workspace-write`
- Default Codex policy fields are tuned for unattended orchestration:
- `codex.approval_policy` defaults to `never`
- `codex.thread_sandbox` defaults to `danger-full-access`
- `codex.turn_sandbox_policy` defaults to a `workspaceWrite` policy rooted at the current issue workspace
- Supported `codex.approval_policy` values depend on the targeted Codex app-server version. In the current local Codex schema, string values include `untrusted`, `on-failure`, `on-request`, and `never`, and object-form `reject` is also supported.
- Supported `codex.approval_policy` values depend on the targeted Codex app-server version. In the current local Codex schema, string values include `untrusted`, `on-failure`, `on-request`, `granular`, and `never`.
- Supported `codex.thread_sandbox` values: `read-only`, `workspace-write`, `danger-full-access`.
- When `codex.turn_sandbox_policy` is set explicitly, Symphony passes the map through to Codex
unchanged. Compatibility then depends on the targeted Codex app-server version rather than local
Expand Down
19 changes: 14 additions & 5 deletions elixir/lib/symphony_elixir/codex/app_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,23 @@ defmodule SymphonyElixir.Codex.AppServer do
) do
on_message = Keyword.get(opts, :on_message, &default_on_message/1)

tool_executor =
Keyword.get(opts, :tool_executor, fn tool, arguments ->
DynamicTool.execute(tool, arguments)
end)

case start_turn(port, thread_id, prompt, issue, workspace, approval_policy, turn_sandbox_policy) do
{:ok, turn_id} ->
session_id = "#{thread_id}-#{turn_id}"

tool_executor =
Keyword.get(opts, :tool_executor, fn tool, arguments ->
DynamicTool.execute(
tool,
arguments,
issue: issue,
workspace: workspace,
thread_id: thread_id,
turn_id: turn_id,
session_id: session_id
)
end)

Logger.info("Codex session started for #{issue_context(issue)} session_id=#{session_id}")

emit_message(
Expand Down
305 changes: 297 additions & 8 deletions elixir/lib/symphony_elixir/codex/dynamic_tool.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ defmodule SymphonyElixir.Codex.DynamicTool do
Executes client-side tool calls requested by Codex app-server turns.
"""

alias SymphonyElixir.Linear.Client
alias SymphonyElixir.{CodexMonitor.Store, Config, Linear.Client}

@linear_graphql_tool "linear_graphql"
@codex_monitor_task_tool "codex_monitor_task"
@linear_graphql_description """
Execute a raw GraphQL query or mutation against Linear using Symphony's configured auth.
"""
@codex_monitor_task_description """
Read and update the current CodexMonitor task, including board state, worklog entries, and run telemetry.
"""
@linear_graphql_input_schema %{
"type" => "object",
"additionalProperties" => false,
Expand All @@ -25,13 +29,50 @@ defmodule SymphonyElixir.Codex.DynamicTool do
}
}
}
@codex_monitor_task_input_schema %{
"type" => "object",
"additionalProperties" => false,
"required" => ["action"],
"properties" => %{
"action" => %{
"type" => "string",
"enum" => ["get_task", "append_worklog", "update_state", "update_run"],
"description" => "Operation to perform against the current CodexMonitor task."
},
"taskId" => %{
"type" => ["string", "null"],
"description" => "Optional task id override. Defaults to the current issue/task id."
},
"message" => %{
"type" => ["string", "null"],
"description" => "Worklog entry to append for `append_worklog`."
},
"state" => %{
"type" => ["string", "null"],
"description" => "Target state for `update_state`."
},
"threadId" => %{"type" => ["string", "null"]},
"worktreeWorkspaceId" => %{"type" => ["string", "null"]},
"branchName" => %{"type" => ["string", "null"]},
"pullRequestUrl" => %{"type" => ["string", "null"]},
"sessionId" => %{"type" => ["string", "null"]},
"lastEvent" => %{"type" => ["string", "null"]},
"lastMessage" => %{"type" => ["string", "null"]},
"lastError" => %{"type" => ["string", "null"]},
"retryCount" => %{"type" => ["integer", "null"]},
"tokenTotal" => %{"type" => ["integer", "null"]}
}
}

@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)

@codex_monitor_task_tool ->
execute_codex_monitor_task(arguments, opts)

other ->
failure_response(%{
"error" => %{
Expand All @@ -44,13 +85,25 @@ defmodule SymphonyElixir.Codex.DynamicTool do

@spec tool_specs() :: [map()]
def tool_specs do
[
%{
"name" => @linear_graphql_tool,
"description" => @linear_graphql_description,
"inputSchema" => @linear_graphql_input_schema
}
]
case Config.settings!().tracker.kind do
"codex_monitor" ->
[
%{
"name" => @codex_monitor_task_tool,
"description" => @codex_monitor_task_description,
"inputSchema" => @codex_monitor_task_input_schema
}
]

_ ->
[
%{
"name" => @linear_graphql_tool,
"description" => @linear_graphql_description,
"inputSchema" => @linear_graphql_input_schema
}
]
end
end

defp execute_linear_graphql(arguments, opts) do
Expand All @@ -65,6 +118,144 @@ defmodule SymphonyElixir.Codex.DynamicTool do
end
end

defp execute_codex_monitor_task(arguments, opts) do
store = Keyword.get(opts, :codex_monitor_store, Store)

with {:ok, normalized} <- normalize_codex_monitor_task_arguments(arguments, opts),
{:ok, response} <- execute_codex_monitor_action(store, normalized) do
graphql_response(response)
else
{:error, reason} ->
failure_response(tool_error_payload(reason))
end
end

defp normalize_codex_monitor_task_arguments(arguments, opts) when is_map(arguments) do
with {:ok, action} <- required_string(arguments, "action"),
{:ok, task_id} <- resolve_task_id(arguments, opts, action) do
{:ok,
%{
action: action,
task_id: task_id,
message: optional_string(arguments, "message"),
state: optional_string(arguments, "state"),
run_updates: %{
thread_id: optional_string(arguments, "threadId") || Keyword.get(opts, :thread_id),
worktree_workspace_id: optional_string(arguments, "worktreeWorkspaceId"),
branch_name: optional_string(arguments, "branchName"),
pull_request_url: optional_string(arguments, "pullRequestUrl"),
session_id: optional_string(arguments, "sessionId") || Keyword.get(opts, :session_id),
last_event: optional_string(arguments, "lastEvent"),
last_message: optional_string(arguments, "lastMessage"),
last_error: optional_string(arguments, "lastError"),
retry_count: optional_integer(arguments, "retryCount"),
token_total: optional_integer(arguments, "tokenTotal")
}
}}
end
end

defp normalize_codex_monitor_task_arguments(_arguments, _opts),
do: {:error, :invalid_codex_monitor_task_arguments}

defp execute_codex_monitor_action(store, %{action: "get_task", task_id: task_id}) do
store.get_task_context(task_id)
end

defp execute_codex_monitor_action(store, %{action: "append_worklog", task_id: task_id, message: message}) do
with {:ok, message} <- require_value(message, :missing_codex_monitor_message),
:ok <- store.append_worklog(task_id, message),
{:ok, task_context} <- store.get_task_context(task_id) do
{:ok, task_context}
end
end

defp execute_codex_monitor_action(store, %{action: "update_state", task_id: task_id, state: state, message: message}) do
with {:ok, state} <- require_value(state, :missing_codex_monitor_state),
:ok <- store.update_issue_state(task_id, state),
:ok <- maybe_append_worklog(store, task_id, message),
{:ok, task_context} <- store.get_task_context(task_id) do
{:ok, task_context}
end
end

defp execute_codex_monitor_action(store, %{action: "update_run", task_id: task_id, run_updates: run_updates}) do
with :ok <- store.update_task_run(task_id, prune_nil_map(run_updates)),
{:ok, task_context} <- store.get_task_context(task_id) do
{:ok, task_context}
end
end

defp execute_codex_monitor_action(_store, %{action: action}) do
{:error, {:unsupported_codex_monitor_task_action, action}}
end

defp required_string(arguments, key) do
case optional_string(arguments, key) do
nil -> {:error, {:missing_required_argument, key}}
value -> {:ok, value}
end
end

defp resolve_task_id(arguments, opts, action) do
case optional_string(arguments, "taskId") || issue_id_from_opts(opts) do
nil when action in ["get_task", "append_worklog", "update_state", "update_run"] ->
{:error, :missing_codex_monitor_task_id}

task_id ->
{:ok, task_id}
end
end

defp issue_id_from_opts(opts) do
case Keyword.get(opts, :issue) do
%{id: issue_id} when is_binary(issue_id) -> issue_id
_ -> nil
end
end

defp optional_string(arguments, key) do
case Map.get(arguments, key) || Map.get(arguments, String.to_atom(key)) do
value when is_binary(value) ->
case String.trim(value) do
"" -> nil
trimmed -> trimmed
end

_ ->
nil
end
end

defp optional_integer(arguments, key) do
case Map.get(arguments, key) || Map.get(arguments, String.to_atom(key)) do
value when is_integer(value) ->
value

value when is_binary(value) ->
case Integer.parse(value) do
{parsed, ""} -> parsed
_ -> nil
end

_ ->
nil
end
end

defp require_value(nil, reason), do: {:error, reason}
defp require_value(value, _reason), do: {:ok, value}

defp maybe_append_worklog(_store, _task_id, nil), do: :ok
defp maybe_append_worklog(store, task_id, message), do: store.append_worklog(task_id, message)

defp prune_nil_map(map) when is_map(map) do
Enum.reduce(map, %{}, fn
{_key, nil}, acc -> acc
{key, value}, acc -> Map.put(acc, key, value)
end)
end

defp normalize_linear_graphql_arguments(arguments) when is_binary(arguments) do
case String.trim(arguments) do
"" -> {:error, :missing_query}
Expand Down Expand Up @@ -176,6 +367,104 @@ defmodule SymphonyElixir.Codex.DynamicTool do
}
end

defp tool_error_payload(:missing_codex_monitor_database_path) do
%{
"error" => %{
"message" => "Symphony is missing `tracker.database_path` for the CodexMonitor adapter."
}
}
end

defp tool_error_payload(:missing_codex_monitor_task_id) do
%{
"error" => %{
"message" => "`codex_monitor_task` requires a task id or current issue context."
}
}
end

defp tool_error_payload(:missing_codex_monitor_message) do
%{
"error" => %{
"message" => "`codex_monitor_task` requires `message` for `append_worklog`."
}
}
end

defp tool_error_payload(:missing_codex_monitor_state) do
%{
"error" => %{
"message" => "`codex_monitor_task` requires `state` for `update_state`."
}
}
end

defp tool_error_payload(:invalid_codex_monitor_task_arguments) do
%{
"error" => %{
"message" => "`codex_monitor_task` expects a JSON object with at least an `action` field."
}
}
end

defp tool_error_payload({:missing_required_argument, key}) do
%{
"error" => %{
"message" => "Missing required argument `#{key}`."
}
}
end

defp tool_error_payload({:unsupported_codex_monitor_task_action, action}) do
%{
"error" => %{
"message" => "Unsupported `codex_monitor_task` action #{inspect(action)}."
}
}
end

defp tool_error_payload({:unknown_codex_monitor_state, state_name}) do
%{
"error" => %{
"message" => "Unknown CodexMonitor task state #{inspect(state_name)}."
}
}
end

defp tool_error_payload(:task_not_found) do
%{
"error" => %{
"message" => "The requested CodexMonitor task was not found."
}
}
end

defp tool_error_payload(:sqlite3_not_found) do
%{
"error" => %{
"message" => "The local `sqlite3` binary is required for the CodexMonitor Symphony adapter."
}
}
end

defp tool_error_payload({:sqlite_command_failed, status, detail}) do
%{
"error" => %{
"message" => "CodexMonitor SQLite command failed with status #{status}.",
"detail" => to_string(detail)
}
}
end

defp tool_error_payload({:sqlite_json_decode_failed, reason}) do
%{
"error" => %{
"message" => "Failed to decode CodexMonitor SQLite JSON output.",
"reason" => inspect(reason)
}
}
end

defp tool_error_payload({:linear_api_status, status}) do
%{
"error" => %{
Expand Down
Loading