Skip to main content

examples/dnd_session.exs

# Run with: mix run examples/dnd_session.exs
#
# A miniature D&D round, run entirely offline (TestModel, no API key).
#
# It shows the full coordination stack for the D&D use case:
#
#   * a DM agent (ExAgent.Server) whose tool mutates the shared world,
#   * a bot player agent that takes its turn,
#   * a human "player" turn (driven by the script — in a real app, a LiveView),
#   * an ExAgent.Session coordinating them (SupervisorPolicy: DM between every
#     actor) over a single-writer shared_state (the world), with events.
#
# Nothing here is D&D-specific to ExAgent — the Session is agnostic. The "world"
# is just a map; in a real game it'd be an Ecto schema.

alias ExAgent.{Event, Message.Part, Models.Test, PubSub, Server, Session}
alias ExAgent.Session.{Participant, SharedState}

# --- The shared world (single-writer: only the Session mutates it) ---------
# In a real app this would be an Ecto embedded_schema. Here, a plain map.
defmodule World do
  def new, do: %{round: 1, scene: "a dark tavern", goblin_hp: 7, log: []}

  def change(world, opts) do
    world
    |> maybe(:scene, opts[:scene])
    |> maybe(:goblin_hp, opts[:goblin_hp] && max(0, world.goblin_hp + opts[:goblin_hp]))
    |> Map.put(:log, [{Keyword.get(opts, :by, "?"), Keyword.get(opts, :say, "")} | world.log])
  end

  defp maybe(map, _key, nil), do: map
  defp maybe(map, key, value), do: Map.put(map, key, value)
end

# --- DM agent: narrates and sets the scene via a tool ----------------------
# The tool receives a SharedState handle in deps and proposes a change through
# the Session (single-writer). Offline-scripted narration.
narrate_tool =
  ExAgent.Tool.new(
    name: "set_scene",
    description: "Narrate and update the scene.",
    parameters_json_schema: %{
      "type" => "object",
      "properties" => %{"narration" => %{"type" => "string"}},
      "required" => ["narration"]
    },
    takes_ctx: true,
    call: fn ctx, %{"narration" => text} ->
      handle = ctx.deps

      {:ok, _world} =
        SharedState.propose_change(handle, fn w ->
          {:ok, World.change(w, scene: text, by: "dm", say: text)}
        end)

      {:ok, "scene set"}
    end
  )

{:ok, dm} =
  Server.start_link(
    agent:
      ExAgent.new(
        # Two DM turns: each is a set_scene tool call followed by narration text.
        model: %Test{
          script: [
            {:tool_calls,
             [%Part.ToolCall{tool_name: "set_scene", args: %{"narration" => "a tense tavern"}}]},
            "The goblin snarls across the room!",
            {:tool_calls,
             [%Part.ToolCall{tool_name: "set_scene", args: %{"narration" => "chaos erupts"}}]},
            "The goblin staggers, wounded!"
          ]
        },
        instructions: "You are the Dungeon Master.",
        tools: [narrate_tool]
      ),
    agent_id: "dnd_dm",
    pubsub: :local
  )

# --- Bot player: attacks on its turn ---------------------------------------
attack_tool =
  ExAgent.Tool.new(
    name: "attack",
    description: "Attack the goblin for N damage.",
    parameters_json_schema: %{
      "type" => "object",
      "properties" => %{"damage" => %{"type" => "integer"}},
      "required" => ["damage"]
    },
    takes_ctx: true,
    call: fn ctx, %{"damage" => dmg} ->
      handle = ctx.deps

      {:ok, _world} =
        SharedState.propose_change(handle, fn w ->
          {:ok, World.change(w, goblin_hp: -dmg, by: "bot", say: "bot hits for #{dmg}")}
        end)

      {:ok, "attacked"}
    end
  )

{:ok, bot} =
  Server.start_link(
    agent:
      ExAgent.new(
        # One bot turn: an attack tool call followed by a battle cry.
        model: %Test{
          script: [
            {:tool_calls, [%Part.ToolCall{tool_name: "attack", args: %{"damage" => 3}}]},
            "I swing my sword!"
          ]
        },
        instructions: "You are a brave bot adventurer.",
        tools: [attack_tool]
      ),
    agent_id: "dnd_bot",
    pubsub: :local
  )

# --- The session: DM alternates with the bot and a human -------------------
{:ok, game} =
  Session.start_link(
    shared_state: World.new(),
    # DM goes between every actor: dm, bot, dm, human, dm, bot, …
    policy: {:supervisor, supervisor: "dm", workers: ["bot", "human"]},
    participants: [
      Participant.new(id: "dm", kind: :agent, ref: dm),
      Participant.new(id: "bot", kind: :agent, ref: bot),
      Participant.new(id: "human", kind: :human)
    ],
    session_id: "dnd_game",
    pubsub: :local
  )

:ok = PubSub.subscribe({PubSub.Local, []}, Event.session_topic("dnd_game"))
{:ok, _first} = Session.start(game)

IO.puts("=== D&D round (DM, bot, human coordinated by Session) ===\n")

# Helpers as closures over the shared `game` / agent servers.
drive_agent = fn id, server, _tool, _args ->
  handle = SharedState.new(game, id)
  # One run: the model calls its tool (mutating the world via the handle), then
  # narrates. The narration is the run's final text output.
  {:ok, %{output: narration}} = Server.chat(server, "your turn", deps: handle)
  {:ok, _next} = Session.end_turn(game, id)
  IO.puts("  → #{narration}")
  :ok
end

human_turn = fn action, damage ->
  {:ok, _world, _next} =
    Session.take_turn(game, "human", fn w ->
      {:ok, World.change(w, goblin_hp: damage, by: "human", say: action)}
    end)

  IO.puts("[human] #{action}")
  :ok
end

print_world = fn ->
  w = Session.read_state(game)
  IO.puts("  world: goblin_hp=#{w.goblin_hp}, scene=#{inspect(w.scene)}")
end

# Drive a few turns. The human turn is played by the script (in a real app, a
# LiveView would call take_turn when the player submits an action).
rounds = [
  {"dm", fn -> drive_agent.("dm", dm, "set_scene", %{"narration" => "tavern, tense"}) end},
  {"bot", fn -> drive_agent.("bot", bot, "attack", %{"damage" => 3}) end},
  {"dm", fn -> drive_agent.("dm", dm, "set_scene", %{"narration" => "goblin reels"}) end},
  {"human", fn -> human_turn.("I cast firebolt for 4 damage!", -4) end}
]

for {who, turn} <- rounds do
  current = Session.current(game)
  IO.puts("[#{who}] (turn was #{current})")
  turn.()
  print_world.()
end

IO.puts("\n=== final world ===")
IO.inspect(Session.read_state(game), label: "world", pretty: true)
:ok = Session.close(game)