defmodule GptAgent do @moduledoc """ Provides a GPT conversation agent """ use GenServer use TypedStruct alias GptAgent.Events.{ RunCompleted, RunStarted, ThreadCreated, ToolCallOutputRecorded, ToolCallRequested, UserMessageAdded } alias GptAgent.Values.NonblankString typedstruct do field :pid, pid(), enforce: true field :callback_handler, pid(), enforce: true field :assistant_id, binary(), enforce: true field :thread_id, binary() | nil field :running?, boolean(), default: false field :run_id, binary() | nil field :tool_calls, [ToolCallRequested.t()], default: [] field :tool_outputs, [ToolCallOutputRecorded.t()], default: [] end defp ok(state, next), do: {:ok, state, next} defp noreply(state), do: {:noreply, state} defp reply(state, reply), do: {:reply, reply, state} defp reply(state, reply, next), do: {:reply, reply, state, next} defp send_callback(state, callback) do send(state.callback_handler, {__MODULE__, state.pid, callback}) state end @doc """ Initializes the GPT Agent """ @spec init(map()) :: {:ok, t(), {:continue, :create_thread}} def init(init_arg) do init_arg |> Enum.into(%{pid: self()}) |> then(&struct!(__MODULE__, &1)) |> ok({:continue, :create_thread}) end def handle_continue(:create_thread, %__MODULE__{thread_id: nil} = state) do {:ok, %{body: %{"id" => thread_id}}} = OpenAiClient.post("/v1/threads", json: "") state |> Map.put(:thread_id, thread_id) |> send_callback(%ThreadCreated{id: thread_id}) |> send_callback(:ready) |> noreply() end def handle_continue(:create_thread, state) do state |> send_callback(:ready) |> noreply() end def handle_continue(:run, state) do {:ok, %{body: %{"id" => id}}} = OpenAiClient.post("/v1/threads/#{state.thread_id}/runs", json: %{ "assistant_id" => state.assistant_id } ) Process.send_after(self(), {:check_run_status, id}, heartbeat_interval_ms()) state |> Map.put(:running?, true) |> Map.put(:run_id, id) |> send_callback(%RunStarted{ id: id, thread_id: state.thread_id, assistant_id: state.assistant_id }) |> noreply() end defp heartbeat_interval_ms, do: Application.get_env(:gpt_agent, :heartbeat_interval_ms, 1000) def handle_call({:add_user_message, _message}, _caller, %__MODULE__{running?: true} = state) do reply(state, {:error, :run_in_progress}) end def handle_call({:add_user_message, message}, _caller, state) do {:ok, message} = NonblankString.new(message) {:ok, %{body: %{"id" => id}}} = OpenAiClient.post("/v1/threads/#{state.thread_id}/messages", json: message) state |> send_callback(%UserMessageAdded{ id: id, thread_id: state.thread_id, content: message }) |> reply(:ok, {:continue, :run}) end def handle_call( {:submit_tool_output, _tool_call_id, _tool_output}, _caller, %__MODULE__{running?: false} = state ) do reply(state, {:error, :run_not_in_progress}) end def handle_call({:submit_tool_output, tool_call_id, tool_output}, _caller, state) do case Enum.find_index(state.tool_calls, fn %ToolCallRequested{id: id} -> id == tool_call_id end) do nil -> reply(state, {:error, :invalid_tool_call_id}) index -> {tool_call, tool_calls} = List.pop_at(state.tool_calls, index) tool_output = %ToolCallOutputRecorded{ id: tool_call_id, thread_id: state.thread_id, run_id: tool_call.run_id, name: tool_call.name, output: Jason.encode!(tool_output) } tool_outputs = [tool_output | state.tool_outputs] state |> send_callback(tool_output) |> Map.put(:tool_calls, tool_calls) |> Map.put(:tool_outputs, tool_outputs) |> possibly_send_outputs_to_openai() |> reply(:ok) end end defp possibly_send_outputs_to_openai( %{running?: true, tool_calls: [], tool_outputs: [_ | _]} = state ) do OpenAiClient.post("/v1/threads/#{state.thread_id}/runs/#{state.run_id}/submit_tool_outputs", json: %{tool_outputs: state.tool_outputs} ) Process.send_after(self(), {:check_run_status, state.run_id}, heartbeat_interval_ms()) %{state | tool_outputs: []} end defp possibly_send_outputs_to_openai(state), do: state def handle_info({:check_run_status, id}, state) do {:ok, %{body: %{"status" => status} = response}} = OpenAiClient.get("/v1/threads/#{state.thread_id}/runs/#{id}", []) handle_run_status(status, id, response, state) end defp handle_run_status("completed", id, _response, state) do state |> Map.put(:running?, false) |> send_callback(%RunCompleted{ id: id, thread_id: state.thread_id, assistant_id: state.assistant_id }) |> noreply() end defp handle_run_status("requires_action", id, response, state) do %{"required_action" => %{"submit_tool_outputs" => %{"tool_calls" => tool_calls}}} = response tool_calls |> Enum.reduce(state, fn tool_call, state -> tool_call = %ToolCallRequested{ id: tool_call["id"], thread_id: state.thread_id, run_id: id, name: tool_call["function"]["name"], arguments: Jason.decode!(tool_call["function"]["arguments"]) } state |> Map.put(:tool_calls, [tool_call | state.tool_calls]) |> send_callback(tool_call) end) |> noreply() end defp handle_run_status(_status, id, _response, state) do Process.send_after(self(), {:check_run_status, id}, heartbeat_interval_ms()) noreply(state) end @doc """ Starts the GPT Agent """ @spec start_link(pid(), binary(), binary() | nil) :: {:ok, pid()} | {:error, reason :: term()} def start_link(callback_handler, assistant_id, thread_id \\ nil) when is_pid(callback_handler) do GenServer.start_link(__MODULE__, callback_handler: callback_handler, assistant_id: assistant_id, thread_id: thread_id ) end def add_user_message(pid, message) do GenServer.call(pid, {:add_user_message, message}) end def submit_tool_output(pid, tool_call_id, tool_output) do GenServer.call(pid, {:submit_tool_output, tool_call_id, tool_output}) end end