Current section
Files
Jump to
Current section
Files
lib/gen_agent/backends/codex.ex
defmodule GenAgent.Backends.Codex do
@moduledoc """
`GenAgent.Backend` implementation backed by `CodexWrapper`.
## Why this uses `exec_json` instead of streaming
`CodexWrapper.Exec.stream/2` and `CodexWrapper.ExecResume.stream/2`
currently hang against `codex-cli ≥ 0.118` because the underlying
Erlang `Port` leaves stdin open and Codex reads stdin at startup.
See https://github.com/joshrotenberg/codex_wrapper_ex/issues/37.
This backend calls `Exec.execute_json/2` (or
`ExecResume.execute_json/2` for follow-up turns) which wraps
`System.cmd`, so stdin is closed cleanly. The tradeoff is that a
turn's events arrive all at once after the CLI exits, rather than
progressively. That's fine for GenAgent because the prompt task
blocks on the whole turn anyway and `handle_stream_event` still
fires for every event in order.
If/when the upstream streaming bug is fixed, this backend can switch
to real streaming without any caller-visible change.
## Session continuation
Codex reports its persistent thread identifier as `thread_id` in the
first `thread.started` event of a turn, not in the terminal
`turn.completed` event. The `EventTranslator` captures it and
injects it into the `:result` event as `session_id`. This backend's
`update_session/2` then records it on the session struct, and the
next turn is dispatched via `ExecResume` with that id.
## Options accepted by `start_session/1`
Config-level (forwarded to `CodexWrapper.Config.new/1`):
* `:binary`, `:working_dir` (aliased as `:cwd`), `:env`, `:timeout`,
`:verbose`
Exec-level (forwarded to `CodexWrapper.Exec`):
* `:model`, `:sandbox`, `:approval_policy`, `:full_auto`,
`:dangerously_bypass_approvals_and_sandbox`, `:skip_git_repo_check`,
`:ephemeral`, `:cd`, `:add_dirs`, `:search`, `:output_schema`,
`:config_overrides`, `:enabled_features`, `:disabled_features`,
`:images`
Backend-only:
* `:exec_fn` -- a 2-arity function `(prompt, session) -> {:ok,
[%JsonLineEvent{}]} | {:error, term()}` that replaces the default
`Exec`/`ExecResume` dispatch. Intended for tests.
Codex has no equivalent of Claude's `--system-prompt`; if you need
system-level instructions, pass them via `AGENTS.md` in the working
directory or through Codex's configuration layer.
"""
@behaviour GenAgent.Backend
alias CodexWrapper.{Config, Exec, ExecResume, JsonLineEvent}
alias GenAgent.Backends.Codex.EventTranslator
@config_keys [:binary, :working_dir, :env, :timeout, :verbose]
defstruct [
:config,
:exec_opts,
:exec_fn,
thread_id: nil
]
@type t :: %__MODULE__{
config: Config.t(),
exec_opts: keyword(),
exec_fn: (String.t(), t() -> {:ok, [JsonLineEvent.t()]} | {:error, term()}),
thread_id: String.t() | nil
}
@impl GenAgent.Backend
def start_session(opts) do
{exec_fn, opts} = Keyword.pop(opts, :exec_fn, &default_exec/2)
opts = normalize_cwd(opts)
{config_opts, exec_opts} = Keyword.split(opts, @config_keys)
config = Config.new(config_opts)
{:ok,
%__MODULE__{
config: config,
exec_opts: exec_opts,
exec_fn: exec_fn
}}
end
@impl GenAgent.Backend
def prompt(%__MODULE__{} = session, prompt) when is_binary(prompt) do
case session.exec_fn.(prompt, session) do
{:ok, json_events} when is_list(json_events) ->
{:ok, EventTranslator.translate(json_events), session}
{:error, reason} ->
{:error, reason}
end
rescue
e -> {:error, {:exec_fn_raised, Exception.message(e)}}
end
@impl GenAgent.Backend
def update_session(%__MODULE__{} = session, %{session_id: sid}) when is_binary(sid) do
%{session | thread_id: sid}
end
def update_session(%__MODULE__{} = session, _data), do: session
@impl GenAgent.Backend
def resume_session(session_id, opts) when is_binary(session_id) do
{:ok, session} = start_session(opts)
{:ok, %{session | thread_id: session_id}}
end
@impl GenAgent.Backend
def terminate_session(%__MODULE__{}), do: :ok
# ---------------------------------------------------------------------------
# Default exec_fn -- routes between Exec and ExecResume based on thread_id
# ---------------------------------------------------------------------------
defp default_exec(prompt, %__MODULE__{thread_id: nil} = session) do
exec = build_exec(prompt, session.exec_opts)
Exec.execute_json(exec, session.config)
end
defp default_exec(prompt, %__MODULE__{thread_id: tid} = session) when is_binary(tid) do
resume = build_exec_resume(tid, prompt, session.exec_opts)
ExecResume.execute_json(resume, session.config)
end
defp build_exec(prompt, exec_opts) do
Enum.reduce(exec_opts, Exec.new(prompt), fn
{:model, v}, e ->
Exec.model(e, v)
{:sandbox, v}, e ->
Exec.sandbox(e, v)
{:approval_policy, v}, e ->
Exec.approval_policy(e, v)
{:full_auto, true}, e ->
Exec.full_auto(e)
{:dangerously_bypass_approvals_and_sandbox, true}, e ->
Exec.dangerously_bypass_approvals_and_sandbox(e)
{:skip_git_repo_check, true}, e ->
Exec.skip_git_repo_check(e)
{:ephemeral, true}, e ->
Exec.ephemeral(e)
{:cd, v}, e ->
Exec.cd(e, v)
{:search, true}, e ->
Exec.search(e)
{:output_schema, v}, e ->
Exec.output_schema(e, v)
{:config_overrides, v}, e ->
Enum.reduce(v, e, &Exec.config(&2, &1))
{:enabled_features, v}, e ->
Enum.reduce(v, e, &Exec.enable(&2, &1))
{:disabled_features, v}, e ->
Enum.reduce(v, e, &Exec.disable(&2, &1))
{:add_dirs, v}, e ->
Enum.reduce(v, e, &Exec.add_dir(&2, &1))
{:images, v}, e ->
Enum.reduce(v, e, &Exec.image(&2, &1))
_other, e ->
e
end)
end
defp build_exec_resume(thread_id, prompt, exec_opts) do
resume =
ExecResume.new()
|> ExecResume.session_id(thread_id)
|> ExecResume.prompt(prompt)
Enum.reduce(exec_opts, resume, fn
{:model, v}, r ->
ExecResume.model(r, v)
{:full_auto, true}, r ->
ExecResume.full_auto(r)
{:dangerously_bypass_approvals_and_sandbox, true}, r ->
ExecResume.dangerously_bypass_approvals_and_sandbox(r)
{:skip_git_repo_check, true}, r ->
ExecResume.skip_git_repo_check(r)
{:ephemeral, true}, r ->
ExecResume.ephemeral(r)
_other, r ->
r
end)
end
defp normalize_cwd(opts) do
case Keyword.pop(opts, :cwd) do
{nil, rest} -> rest
{cwd, rest} -> Keyword.put_new(rest, :working_dir, cwd)
end
end
end