Skip to main content

examples/adaptive/adaptive_researcher.ex

defmodule Jido.Runic.Examples.Adaptive.AdaptiveResearcher do
  @moduledoc """
  Adaptive research agent demonstrating dynamic workflow construction.

  Unlike the Studio `OrchestratorAgent` which uses a fixed 5-node pipeline,
  this agent dynamically selects its Phase 2 writing pipeline based on
  Phase 1 research results:

  - **Phase 1** — `PlanQueries → SimulateSearch`
  - **Phase 2 (rich)** — `BuildOutline → DraftArticle → EditAndAssemble`
  - **Phase 2 (thin)** — `DraftArticle → EditAndAssemble`

  The agent uses the standard `Jido.Runic.Strategy` with `runic.set_workflow`
  to hot-swap workflows between phases.
  """

  require Logger

  use Jido.Agent,
    name: "adaptive_researcher",
    strategy: {Jido.Runic.Strategy, workflow_fn: &__MODULE__.build_phase_1/0},
    schema: []

  @doc false
  @spec plugin_specs() :: [Jido.Plugin.Spec.t()]
  def plugin_specs, do: []

  alias Jido.Agent.Strategy.State, as: StratState
  alias Runic.Workflow

  alias Jido.Runic.Examples.Studio.Actions.{
    PlanQueries,
    SimulateSearch,
    BuildOutline,
    DraftArticle,
    EditAndAssemble
  }

  @rich_threshold 500

  @doc "Build the Phase 1 research workflow: PlanQueries → SimulateSearch."
  def build_phase_1 do
    Workflow.new(name: :phase_1_research)
    |> Workflow.add(PlanQueries)
    |> Workflow.add(SimulateSearch, to: :plan_queries)
  end

  @doc """
  Build the Phase 2 writing workflow, dynamically shaped by research results.

  When the research summary is rich (>= #{@rich_threshold} chars), includes
  an outline step. Otherwise skips straight to drafting.
  """
  def build_phase_2(productions) do
    research_summary = extract_research_summary(productions)
    rich? = String.length(research_summary) >= @rich_threshold

    if rich? do
      Logger.info("[Adaptive] Rich results (#{String.length(research_summary)} chars) — full pipeline")

      Workflow.new(name: :phase_2_full)
      |> Workflow.add(BuildOutline)
      |> Workflow.add(DraftArticle, to: :build_outline)
      |> Workflow.add(EditAndAssemble, to: :draft_article)
    else
      Logger.info("[Adaptive] Thin results (#{String.length(research_summary)} chars) — skipping outline")

      Workflow.new(name: :phase_2_slim)
      |> Workflow.add(DraftArticle)
      |> Workflow.add(EditAndAssemble, to: :draft_article)
    end
  end

  @doc """
  Run the full adaptive research pipeline for a topic.

  Phase 1 researches the topic, then Phase 2's shape is determined
  by how rich the research results are.

  ## Options

    * `:jido` - Name of a running Jido instance (required)
    * `:timeout` - Timeout in ms for each phase (default: 120_000)
    * `:debug` - Enable debug event buffer (default: true)

  ## Returns

  A map with `:topic`, `:productions`, `:phase_2_type`, `:status`, and `:pid`.
  """
  @spec run(String.t(), keyword()) :: map()
  def run(topic, opts \\ []) do
    jido = Keyword.fetch!(opts, :jido)
    timeout = Keyword.get(opts, :timeout, 120_000)
    debug = Keyword.get(opts, :debug, true)

    Logger.info("[Adaptive] Starting adaptive pipeline for topic: #{inspect(topic)}")

    server_opts = [agent: __MODULE__, jido: jido, debug: debug]
    {:ok, pid} = Jido.AgentServer.start_link(server_opts)

    # Phase 1: Research
    Logger.info("[Adaptive] Phase 1: PlanQueries → SimulateSearch")

    feed_signal =
      Jido.Signal.new!(
        "runic.feed",
        %{data: %{topic: topic}},
        source: "/adaptive/researcher"
      )

    Jido.AgentServer.cast(pid, feed_signal)

    case Jido.AgentServer.await_completion(pid, timeout: timeout) do
      {:ok, %{status: :completed}} ->
        :ok

      {:ok, %{status: :failed}} ->
        Logger.error("[Adaptive] Phase 1 FAILED")
        return_error(pid, topic, :phase_1_failed)

      {:error, reason} ->
        Logger.error("[Adaptive] Phase 1 ERROR: #{inspect(reason)}")
        return_error(pid, topic, reason)
    end

    {:ok, server_state} = Jido.AgentServer.state(pid)
    strat = StratState.get(server_state.agent)
    phase_1_productions = Workflow.raw_productions(strat.workflow)

    Logger.info("[Adaptive] Phase 1 complete — #{length(phase_1_productions)} productions")

    # Build Phase 2 dynamically
    phase_2_workflow = build_phase_2(phase_1_productions)
    phase_2_type = if phase_2_workflow.name == :phase_2_full, do: :full, else: :slim

    # Hot-swap workflow
    set_workflow_signal =
      Jido.Signal.new!(
        "runic.set_workflow",
        %{workflow: phase_2_workflow},
        source: "/adaptive/researcher"
      )

    Jido.AgentServer.cast(pid, set_workflow_signal)
    Process.sleep(50)

    # Feed Phase 1 productions into Phase 2
    phase_2_input = reshape_for_phase_2(phase_1_productions, phase_2_type)

    feed_phase_2 =
      Jido.Signal.new!(
        "runic.feed",
        %{data: phase_2_input},
        source: "/adaptive/researcher"
      )

    Logger.info("[Adaptive] Phase 2 (#{phase_2_type}): feeding research data")
    Jido.AgentServer.cast(pid, feed_phase_2)

    case Jido.AgentServer.await_completion(pid, timeout: timeout) do
      {:ok, %{status: :completed}} ->
        {:ok, final_state} = Jido.AgentServer.state(pid)
        final_strat = StratState.get(final_state.agent)
        productions = Workflow.raw_productions(final_strat.workflow)

        Logger.info("[Adaptive] Phase 2 complete — #{length(productions)} productions")

        %{
          topic: topic,
          productions: productions,
          phase_1_productions: phase_1_productions,
          phase_2_type: phase_2_type,
          status: :completed,
          pid: pid
        }

      {:ok, %{status: :failed}} ->
        Logger.error("[Adaptive] Phase 2 FAILED")
        return_error(pid, topic, :phase_2_failed)

      {:error, reason} ->
        Logger.error("[Adaptive] Phase 2 ERROR: #{inspect(reason)}")
        return_error(pid, topic, reason)
    end
  end

  @doc "Extract the final article markdown from run results."
  def article(%{productions: productions}) do
    productions
    |> Enum.filter(fn
      %{markdown: _} -> true
      _ -> false
    end)
    |> List.last()
  end

  # -- Private Helpers ---------------------------------------------------------

  defp extract_research_summary(productions) do
    Enum.find_value(productions, "", fn
      %{research_summary: summary} when is_binary(summary) -> summary
      _ -> nil
    end)
  end

  defp reshape_for_phase_2(productions, :full) do
    topic =
      Enum.find_value(productions, "Unknown", fn
        %{topic: t} when is_binary(t) -> t
        _ -> nil
      end)

    research_summary = extract_research_summary(productions)
    %{topic: topic, research_summary: research_summary}
  end

  defp reshape_for_phase_2(productions, :slim) do
    topic =
      Enum.find_value(productions, "Unknown", fn
        %{topic: t} when is_binary(t) -> t
        _ -> nil
      end)

    research_summary = extract_research_summary(productions)
    %{topic: topic, outline: [research_summary]}
  end

  defp return_error(pid, topic, reason) do
    %{
      topic: topic,
      productions: [],
      phase_1_productions: [],
      phase_2_type: nil,
      status: {:error, reason},
      pid: pid
    }
  end
end