Skip to main content

lib/jido_messaging/demo/chat_agent_runner.ex

defmodule Jido.Messaging.Demo.ChatAgentRunner do
  @moduledoc """
  Wrapper that runs the ChatAgent within the Jido.Messaging AgentRunner framework.

  This module bridges the Jido.AI.Agent with Jido.Messaging's agent system by:
  1. Starting the ChatAgent GenServer
  2. Providing a handler function for the AgentRunner
  3. Managing the agent lifecycle

  ## Architecture

      ┌─────────────────┐
      │   AgentRunner   │  <- Subscribes to Signal Bus
      │ (per-room)      │
      └────────┬────────┘
               │ calls handler
               ▼
      ┌─────────────────┐
      │ ChatAgentRunner │  <- Manages ChatAgent lifecycle
      └────────┬────────┘
               │ delegates to
               ▼
      ┌─────────────────┐
      │   ChatAgent     │  <- ReAct reasoning + tools
      │ (GenServer)     │
      └─────────────────┘

  ## Usage

      # Get an agent config for use with AgentRunner
      config = ChatAgentRunner.agent_config()

      # Or start directly
      {:ok, pid} = ChatAgentRunner.start_link(room_id: "demo:lobby", ...)
  """

  use GenServer
  require Logger

  alias Jido.Messaging.Demo.ChatAgent

  @schema Zoi.struct(
            __MODULE__,
            %{
              agent_pid: Zoi.pid(),
              room_id: Zoi.string(),
              instance_module: Zoi.module()
            },
            coerce: false
          )

  @type t :: unquote(Zoi.type_spec(@schema))

  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @agent_name "ChatAgent"
  # Use :all for demo - responds to every message
  # Use :mention to require @ChatAgent in message text
  @trigger :all

  @doc """
  Returns an agent_config map suitable for use with AgentRunner.

  The handler will delegate to a running ChatAgent instance.
  """
  def agent_config(opts \\ []) do
    %{
      name: Keyword.get(opts, :name, @agent_name),
      trigger: Keyword.get(opts, :trigger, @trigger),
      handler: &handle_message/2
    }
  end

  @doc """
  Start the ChatAgentRunner GenServer.

  This starts both the runner and the underlying ChatAgent.
  """
  def start_link(opts) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc """
  Get the ChatAgent pid for direct interaction.
  """
  def get_agent_pid do
    GenServer.call(__MODULE__, :get_agent_pid)
  end

  # Handler function called by AgentRunner

  defp handle_message(message, _context) do
    text = extract_text(message)
    source = message.metadata[:channel] || message.metadata["channel"] || "unknown"

    user =
      message.metadata[:username] || message.metadata["username"] ||
        message.metadata[:display_name] || message.metadata["display_name"] || "unknown"

    prompt = "[#{source} #{user}] #{text}"
    Logger.info("[ChatAgentRunner] Processing: #{prompt}")

    # Get the ChatAgent pid
    case get_agent_pid_safe() do
      {:ok, agent_pid} ->
        case ChatAgent.chat(agent_pid, prompt, timeout: 30_000) do
          {:ok, response} ->
            Logger.info("[ChatAgentRunner] Response: #{response}")
            {:reply, response}

          {:error, reason} ->
            Logger.warning("[ChatAgentRunner] Chat failed: #{inspect(reason)}")
            {:reply, "Sorry, I encountered an error. Please try again."}
        end

      {:error, :not_running} ->
        Logger.warning("[ChatAgentRunner] ChatAgent not running")
        {:reply, "I'm still waking up... try again in a moment!"}
    end
  end

  defp get_agent_pid_safe do
    try do
      {:ok, get_agent_pid()}
    catch
      :exit, _ -> {:error, :not_running}
    end
  end

  defp extract_text(%{content: content}) when is_list(content) do
    content
    |> Enum.find_value("", fn
      %{text: text} when is_binary(text) -> text
      %{"text" => text} when is_binary(text) -> text
      %Jido.Chat.Content.Text{text: text} -> text
      _ -> nil
    end)
  end

  defp extract_text(_), do: ""

  # GenServer callbacks

  @impl true
  def init(opts) do
    room_id = Keyword.get(opts, :room_id, "demo:lobby")
    instance_module = Keyword.get(opts, :instance_module, Jido.Messaging.Demo.Messaging)
    jido_name = Keyword.get(opts, :jido_name, Jido.Messaging.Demo.Jido)

    # Start the ChatAgent GenServer via Jido runtime
    case start_chat_agent(jido_name) do
      {:ok, agent_pid} ->
        Logger.info("[ChatAgentRunner] Started ChatAgent: #{inspect(agent_pid)}")

        state = %__MODULE__{
          agent_pid: agent_pid,
          room_id: room_id,
          instance_module: instance_module
        }

        {:ok, state}

      {:error, reason} ->
        Logger.error("[ChatAgentRunner] Failed to start ChatAgent: #{inspect(reason)}")
        {:stop, reason}
    end
  end

  @impl true
  def handle_call(:get_agent_pid, _from, state) do
    {:reply, state.agent_pid, state}
  end

  @impl true
  def terminate(_reason, state) do
    if state.agent_pid && Process.alive?(state.agent_pid) do
      GenServer.stop(state.agent_pid, :normal)
    end

    :ok
  end

  defp start_chat_agent(jido_name) do
    # Start the ChatAgent using Jido runtime
    Jido.start_agent(jido_name, ChatAgent)
  end
end