Packages

AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.

Current section

Files

Jump to
raxol_agent lib raxol agent user_model.ex
Raw

lib/raxol/agent/user_model.ex

defmodule Raxol.Agent.UserModel do
@moduledoc """
A derived, per-user model of who the agent is talking to.
The UserModel reasons over past conversation items on an auxiliary model to
build a short representation of the user (preferences, goals, habits) and keeps
it per `user_id`. It is a `Raxol.Agent.Memory` provider whose contribution is a
`build_user_context/1` block, injected into the LAST user message rather than
the system prompt, so refreshing it does not invalidate the cacheable system
prefix. Its `search/2`, `store/2`, and `forget/2` are no-ops.
`refresh_async/4` derives in a background `Task` off the GenServer so the
foreground turn never waits on the model call; `refresh/4` is the synchronous
form for explicit use and tests. The auxiliary model is configured the same way
as `Raxol.Agent.SelfImprove`: `backend` (an `AIBackend`, defaults to Mock),
`model`, and `backend_opts`.
Refreshing on a turn cadence is left to the runtime, which calls
`refresh_async/4` after a turn, mirroring `SelfImprove.after_turn/3`.
"""
use Raxol.Core.Behaviours.BaseManager
use Raxol.Agent.Memory
alias Raxol.Agent.Auxiliary
@default_backend Raxol.Agent.Backend.Mock
@derive_prompt """
From the conversation below, write a short profile of the user: their
preferences, goals, and working habits. Be concise and concrete. Write only the
profile, no preamble.
"""
# -- Public API -------------------------------------------------------------
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
GenServer.start_link(__MODULE__, opts, name: Keyword.get(opts, :name, __MODULE__))
end
@doc "The stored context string for `user_id`, or `nil`."
@spec get_context(GenServer.server(), String.t()) :: String.t() | nil
def get_context(server, user_id), do: GenServer.call(server, {:get, user_id})
@doc "Set a user's context directly (bypasses derivation)."
@spec put_context(GenServer.server(), String.t(), String.t()) :: :ok
def put_context(server, user_id, context), do: GenServer.call(server, {:put, user_id, context})
@doc "Derive and store a user's context synchronously from conversation items."
@spec refresh(GenServer.server(), String.t(), [map()], keyword()) ::
{:ok, String.t()} | {:error, term()}
def refresh(server, user_id, items, opts \\ []) do
GenServer.call(server, {:refresh, user_id, items, opts})
end
@doc "Derive a user's context in the background; the store updates when it completes."
@spec refresh_async(GenServer.server(), String.t(), [map()], keyword()) :: :ok
def refresh_async(server, user_id, items, opts \\ []) do
GenServer.cast(server, {:refresh, user_id, items, opts})
end
# -- Memory behaviour -------------------------------------------------------
@impl Raxol.Agent.Memory
def search(_query, _opts), do: []
@impl Raxol.Agent.Memory
def store(record, _opts), do: {:ok, record}
@impl Raxol.Agent.Memory
def forget(_id, _opts), do: :ok
@impl Raxol.Agent.Memory
def build_user_context(opts) do
server = Keyword.get(opts, :server, __MODULE__)
case Keyword.get(opts, :user_id) do
nil -> nil
user_id -> user_id |> then(&get_context(server, &1)) |> wrap()
end
end
defp wrap(nil), do: nil
defp wrap(context), do: "## About the user\n\n" <> context
# -- BaseManager callbacks --------------------------------------------------
@impl Raxol.Core.Behaviours.BaseManager
def init_manager(opts) do
{:ok,
%{
contexts: %{},
backend: opts[:backend],
model: opts[:model],
backend_opts: opts[:backend_opts] || [],
auxiliary: opts[:auxiliary],
default: opts[:default],
available?: opts[:available?]
}}
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_call({:get, user_id}, _from, state) do
{:reply, Map.get(state.contexts, user_id), state}
end
def handle_manager_call({:put, user_id, context}, _from, state) do
{:reply, :ok, store_context(state, user_id, context)}
end
def handle_manager_call({:refresh, user_id, items, opts}, _from, state) do
case derive(items, config(state, opts)) do
{:ok, context} -> {:reply, {:ok, context}, store_context(state, user_id, context)}
error -> {:reply, error, state}
end
end
@impl Raxol.Core.Behaviours.BaseManager
def handle_manager_cast({:refresh, user_id, items, opts}, state) do
server = self()
config = config(state, opts)
Task.start(fn ->
case derive(items, config) do
{:ok, context} -> GenServer.cast(server, {:store, user_id, context})
_ -> :ok
end
end)
{:noreply, state}
end
def handle_manager_cast({:store, user_id, context}, state) do
{:noreply, store_context(state, user_id, context)}
end
# -- derivation -------------------------------------------------------------
defp derive(items, config) do
messages = [
%{role: :system, content: @derive_prompt},
%{role: :user, content: transcript(items)}
]
{backend, opts} = resolve_backend(config)
case backend.complete(messages, opts) do
{:ok, %{content: content}} when is_binary(content) -> {:ok, String.trim(content)}
{:error, reason} -> {:error, reason}
_ -> {:error, :derive_failed}
end
end
defp transcript(items), do: Enum.map_join(items, "\n", &line/1)
defp line(item) do
data = Map.get(item, :data, %{})
text = Map.get(data, :content) || Map.get(data, :text) || ""
"#{Map.get(data, :role) || Map.get(item, :created_by) || "?"}: #{text}"
end
defp config(state, opts) do
%{
backend: Keyword.get(opts, :backend, state.backend),
model: Keyword.get(opts, :model, state.model),
backend_opts: Keyword.get(opts, :backend_opts, state.backend_opts),
auxiliary: Keyword.get(opts, :auxiliary, state.auxiliary),
default: Keyword.get(opts, :default, state.default),
available?: Keyword.get(opts, :available?, state.available?)
}
end
# An explicit backend or model wins. Otherwise route the user-model task through
# the auxiliary resolver, degrading to the default backend if no slot is available.
defp resolve_backend(config) do
if config.backend || config.model do
{config.backend || @default_backend, backend_opts(config)}
else
case Auxiliary.select(:user_model, aux_opts(config)) do
{:ok, module, opts} -> {module, Keyword.merge(config.backend_opts, opts)}
{:error, _reason} -> {@default_backend, config.backend_opts}
end
end
end
defp aux_opts(config) do
[auxiliary: config.auxiliary, default: config.default, available?: config.available?]
|> Enum.reject(fn {_key, value} -> is_nil(value) end)
end
defp backend_opts(%{model: nil, backend_opts: base}), do: base
defp backend_opts(%{model: model, backend_opts: base}), do: Keyword.put(base, :model, model)
defp store_context(state, user_id, context) do
%{state | contexts: Map.put(state.contexts, user_id, context)}
end
end