# 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)