Current section
Files
Jump to
Current section
Files
lib/codex/thread.ex
defmodule Codex.Thread do
@moduledoc """
Represents a Codex conversation thread and exposes turn execution APIs.
"""
alias Codex.ApprovalError
alias Codex.Approvals
alias Codex.Error
alias Codex.Events
alias Codex.Items
alias Codex.Options
alias Codex.OutputSchemaFile
alias Codex.Thread.Options, as: ThreadOptions
alias Codex.Tools
alias Codex.Telemetry
alias Codex.Turn.Result
@enforce_keys [:codex_opts, :thread_opts]
defstruct thread_id: nil,
codex_opts: nil,
thread_opts: nil,
metadata: %{},
labels: %{},
continuation_token: nil,
usage: %{},
pending_tool_outputs: [],
pending_tool_failures: []
@type t :: %__MODULE__{
thread_id: String.t() | nil,
codex_opts: Options.t(),
thread_opts: ThreadOptions.t(),
metadata: map(),
labels: map(),
continuation_token: String.t() | nil,
usage: map(),
pending_tool_outputs: [map()],
pending_tool_failures: [map()]
}
@doc false
@spec build(Options.t(), ThreadOptions.t(), keyword()) :: t()
def build(%Options{} = opts, %ThreadOptions{} = thread_opts, extra \\ []) do
struct!(
__MODULE__,
Keyword.merge(
[
thread_id: nil,
codex_opts: opts,
thread_opts: thread_opts,
metadata: %{},
labels: %{},
continuation_token: nil,
usage: %{},
pending_tool_outputs: [],
pending_tool_failures: []
],
extra
)
)
end
@doc """
Executes a blocking turn against the codex engine.
"""
@spec run(t(), String.t(), map() | keyword()) ::
{:ok, Result.t()} | {:error, term()}
def run(%__MODULE__{} = thread, input, turn_opts \\ %{}) when is_binary(input) do
span_token = make_ref()
meta = %{
thread_id: thread.thread_id,
input: input,
originator: :sdk,
span_token: span_token
}
Telemetry.emit([:codex, :thread, :start], %{system_time: System.system_time()}, meta)
started_monotonic = System.monotonic_time()
with {:ok, exec_opts, cleanup, exec_meta} <- build_exec_options(thread, turn_opts) do
structured_output? = Map.get(exec_meta, :structured_output?, false)
try do
case Codex.Exec.run(input, exec_opts) do
{:ok, exec_result} ->
duration = System.monotonic_time() - started_monotonic
Telemetry.emit(
[:codex, :thread, :stop],
%{duration: duration, system_time: System.system_time()},
Map.put(meta, :result, :ok)
)
exec_result = Map.put(exec_result, :structured_output?, structured_output?)
{:ok, finalize_turn(thread, exec_result, exec_meta)}
{:error, reason} ->
duration = System.monotonic_time() - started_monotonic
Telemetry.emit(
[:codex, :thread, :exception],
%{duration: duration, system_time: System.system_time()},
meta
|> Map.put(:reason, reason)
|> Map.put(:result, :error)
)
{:error, reason}
end
after
cleanup.()
end
end
end
@doc """
Executes a turn and returns a stream of events for progressive consumption.
The stream is lazy; events will not be produced until enumerated.
"""
@spec run_streamed(t(), String.t(), map() | keyword()) ::
{:ok, Enumerable.t()} | {:error, term()}
def run_streamed(%__MODULE__{} = thread, input, turn_opts \\ %{}) when is_binary(input) do
with {:ok, exec_opts, cleanup, exec_meta} <- build_exec_options(thread, turn_opts) do
structured_output? = Map.get(exec_meta, :structured_output?, false)
case Codex.Exec.run_stream(input, exec_opts) do
{:ok, stream} ->
wrapped =
Stream.transform(
stream,
fn -> :ok end,
fn event, acc -> {[maybe_decode_stream_event(event, structured_output?)], acc} end,
fn _ -> cleanup.() end
)
{:ok, wrapped}
{:error, reason} ->
cleanup.()
{:error, reason}
end
end
end
@doc """
Executes an auto-run loop, retrying while a continuation token is present.
Options:
* `:max_attempts` – maximum number of attempts (default: 3)
* `:backoff` – unary function invoked with current attempt (default: exponential sleep)
* `:turn_opts` – per-turn options forwarded to each attempt
"""
@spec run_auto(t(), String.t(), keyword()) :: {:ok, Result.t()} | {:error, term()}
def run_auto(%__MODULE__{} = thread, input, opts \\ []) when is_binary(input) do
max_attempts = Keyword.get(opts, :max_attempts, 3)
backoff = Keyword.get(opts, :backoff, &default_backoff/1)
turn_opts = Keyword.get(opts, :turn_opts, %{})
do_run_auto(thread, input, turn_opts, max_attempts, backoff, 1, [], %{})
end
defp do_run_auto(
thread,
input,
turn_opts,
max_attempts,
backoff,
attempt,
acc_events,
acc_usage
) do
case run(thread, input, turn_opts) do
{:ok, %Result{} = result} ->
with {:ok, processed} <- handle_tool_requests(result, attempt) do
merged_events = acc_events ++ processed.events
merged_usage = merge_usage(acc_usage, processed.usage)
cond do
processed.thread.continuation_token && attempt < max_attempts ->
backoff.(attempt)
do_run_auto(
processed.thread,
input,
turn_opts,
max_attempts,
backoff,
attempt + 1,
merged_events,
merged_usage
)
processed.thread.continuation_token ->
{:error,
{:max_attempts_reached, max_attempts,
%{continuation: processed.thread.continuation_token}}}
true ->
final_thread = %{processed.thread | usage: merged_usage}
{:ok,
%Result{
processed
| events: merged_events,
usage: merged_usage,
thread: final_thread,
attempts: attempt
}}
end
end
{:error, _} = error ->
error
end
end
defp default_backoff(attempt) when attempt >= 1 do
exponent = attempt - 1
delay = trunc(:math.pow(2, exponent))
Process.sleep(delay * 100)
end
defp build_exec_options(thread, turn_opts) do
turn_opts_map = Map.new(turn_opts)
schema = Map.get(turn_opts_map, :output_schema, Map.get(turn_opts_map, "output_schema"))
with {:ok, schema_path, cleanup} <- OutputSchemaFile.create(schema) do
filtered_turn_opts =
turn_opts_map
|> Map.delete(:output_schema)
|> Map.delete("output_schema")
exec_opts =
%{
codex_opts: thread.codex_opts,
thread: thread,
turn_opts: filtered_turn_opts,
continuation_token: thread.continuation_token,
attachments: thread.thread_opts.attachments,
tool_outputs: thread.pending_tool_outputs,
tool_failures: thread.pending_tool_failures
}
|> maybe_put(:output_schema_path, schema_path)
{:ok, exec_opts, cleanup, %{structured_output?: not is_nil(schema)}}
end
end
defp finalize_turn(thread, %{events: events} = exec_result, opts) do
{updated_thread, final_response, usage} = fold_events(thread, events, opts)
updated_thread =
updated_thread
|> Map.put(:usage, usage || thread.usage)
|> Map.put(:pending_tool_outputs, [])
|> Map.put(:pending_tool_failures, [])
%Result{
thread: updated_thread,
events: events,
final_response: final_response,
usage: usage,
raw: exec_result,
attempts: 1
}
end
defp fold_events(thread, events, opts) do
structured? = Map.get(opts, :structured_output?, false)
Enum.reduce(events, {thread, nil, thread.usage, thread.continuation_token}, fn event,
{acc_thread,
response,
usage,
continuation} ->
case event do
%Events.ThreadStarted{} = started ->
labels =
case started.metadata do
%{"labels" => label_map} -> label_map
_ -> acc_thread.labels
end
updated =
acc_thread
|> maybe_put(:thread_id, started.thread_id)
|> Map.put(:metadata, started.metadata || %{})
|> Map.put(:labels, labels)
{updated, response, usage, continuation}
%Events.TurnContinuation{continuation_token: token} ->
updated = Map.put(acc_thread, :continuation_token, token)
{updated, response, usage, token}
%Events.ItemAgentMessageDelta{item: item} ->
new_response =
case item do
%{"content" => %{"type" => "text", "text" => text}} ->
decode_agent_message(Map.get(item, "id"), text, structured?)
%{"text" => text} when is_binary(text) ->
decode_agent_message(Map.get(item, "id"), text, structured?)
_ ->
response
end
{acc_thread, new_response || response, usage, continuation}
%Events.ItemCompleted{item: %Items.AgentMessage{text: text} = item} ->
decoded_item = maybe_decode_agent_item(item, text, structured?)
{acc_thread, decoded_item, usage, continuation}
%Events.ItemCompleted{} ->
{acc_thread, response, usage, continuation}
%Events.TurnCompleted{} = completed ->
new_usage = completed.usage || usage
new_response =
completed.final_response
|> decode_final_response(structured?)
|> Kernel.||(response)
new_continuation =
if new_response do
nil
else
acc_thread.continuation_token || continuation
end
updated =
acc_thread
|> maybe_put(:thread_id, completed.thread_id)
|> Map.put(:continuation_token, new_continuation)
{updated, new_response, new_usage, new_continuation}
_other ->
{acc_thread, response, usage, continuation}
end
end)
|> then(fn {acc_thread, response, usage, continuation} ->
updated_thread =
acc_thread
|> Map.put(:continuation_token, continuation)
{updated_thread, response, usage}
end)
end
defp merge_usage(nil, nil), do: %{}
defp merge_usage(map, nil) when is_map(map), do: map
defp merge_usage(nil, map) when is_map(map), do: map
defp merge_usage(left, right) when is_map(left) and is_map(right) do
Map.merge(left, right, fn _key, l, r ->
cond do
is_number(l) and is_number(r) -> l + r
true -> r || l
end
end)
end
defp decode_final_response(nil, _structured?), do: nil
defp decode_final_response(%Items.AgentMessage{} = item, _structured?), do: item
defp decode_final_response(%{"type" => "text", "text" => text}, structured?)
when is_binary(text) do
decode_agent_message(nil, text, structured?)
end
defp decode_final_response(%{type: "text", text: text}, structured?) when is_binary(text) do
decode_agent_message(nil, text, structured?)
end
defp decode_final_response(_other, _structured?), do: nil
defp decode_agent_message(id, text, structured?) do
maybe_parse_structured(id, text, structured?)
end
defp maybe_decode_agent_item(%Items.AgentMessage{id: id} = item, text, structured?) do
case maybe_parse_structured(id, text, structured?) do
%Items.AgentMessage{} = decoded -> decoded
_ -> item
end
end
defp maybe_parse_structured(id, text, true) when is_binary(text) do
with {:ok, decoded} <- Jason.decode(text) do
%Items.AgentMessage{id: id, text: text, parsed: decoded}
else
_ -> %Items.AgentMessage{id: id, text: text}
end
end
defp maybe_parse_structured(id, text, _structured?) when is_binary(text) do
%Items.AgentMessage{id: id, text: text}
end
defp maybe_parse_structured(_id, _text, _structured?), do: nil
defp maybe_decode_stream_event(
%Events.ItemCompleted{item: %Items.AgentMessage{text: text} = item} = event,
structured?
) do
decoded = maybe_decode_agent_item(item, text, structured?)
%Events.ItemCompleted{event | item: decoded}
end
defp maybe_decode_stream_event(
%Events.TurnCompleted{final_response: response} = event,
structured?
) do
decoded = decode_final_response(response, structured?) || response
%Events.TurnCompleted{event | final_response: decoded}
end
defp maybe_decode_stream_event(event, _structured?), do: event
defp maybe_put(map, _key, nil), do: map
defp maybe_put(map, key, value), do: Map.put(map, key, value)
defp handle_tool_requests(%Result{} = result, attempt) do
tool_events = Enum.filter(result.events, &match?(%Events.ToolCallRequested{}, &1))
Enum.reduce_while(tool_events, {:ok, result}, fn event, {:ok, acc_result} ->
case maybe_invoke_tool(acc_result.thread, event, attempt) do
{:ok, output} ->
outputs = Map.get(acc_result.raw, :tool_outputs, [])
pending_outputs =
acc_result.thread.pending_tool_outputs ++ [%{call_id: event.call_id, output: output}]
updated_raw =
Map.put(
acc_result.raw,
:tool_outputs,
outputs ++ [%{call_id: event.call_id, output: output}]
)
updated_thread =
Map.put(acc_result.thread, :pending_tool_outputs, pending_outputs)
updated_result = %Result{acc_result | raw: updated_raw, thread: updated_thread}
{:cont, {:ok, updated_result}}
{:failure, reason} ->
failures =
Map.get(acc_result.raw, :tool_failures, []) ++
[%{call_id: event.call_id, reason: normalize_tool_failure(reason)}]
pending_failures =
acc_result.thread.pending_tool_failures ++
[%{call_id: event.call_id, reason: normalize_tool_failure(reason)}]
updated_raw = Map.put(acc_result.raw, :tool_failures, failures)
updated_thread =
Map.put(acc_result.thread, :pending_tool_failures, pending_failures)
updated_result = %Result{acc_result | raw: updated_raw, thread: updated_thread}
{:cont, {:ok, updated_result}}
{:error, reason} ->
{:halt, {:error, reason}}
end
end)
end
defp handle_tool_requests(result, _attempt), do: {:ok, result}
defp maybe_invoke_tool(thread, %Events.ToolCallRequested{} = event, attempt) do
context = build_tool_context(thread, event, attempt)
# Prefer approval_hook over approval_policy
policy_or_hook = thread.thread_opts.approval_hook || thread.thread_opts.approval_policy
timeout = thread.thread_opts.approval_timeout_ms || 30_000
case Approvals.review_tool(policy_or_hook, event, context, timeout: timeout) do
:allow ->
case Tools.invoke(event.tool_name, event.arguments, context) do
{:ok, output} ->
{:ok, output}
{:error, reason} ->
{:failure,
Error.new(:tool_failure, "tool #{event.tool_name} failed", %{
tool: event.tool_name,
reason: reason
})}
end
{:deny, reason} ->
{:error, ApprovalError.new(event.tool_name, reason)}
end
end
defp build_tool_context(thread, event, attempt) do
metadata = thread.thread_opts.metadata || %{}
tool_context =
metadata[:tool_context] || metadata["tool_context"] || %{}
%{
thread: thread,
metadata: metadata,
context: tool_context,
event: event,
attempt: attempt,
retry?: attempt > 1
}
end
defp normalize_tool_failure(%Codex.Error{} = error) do
details =
case error.details do
value when is_map(value) -> value
_ -> %{}
end
%{
message: error.message,
kind: error.kind,
details: details
}
end
defp normalize_tool_failure(value) when is_map(value), do: value
defp normalize_tool_failure(value) when is_binary(value), do: %{message: value}
defp normalize_tool_failure(value), do: %{message: inspect(value)}
end