Packages
Elixir wrapper around the official TypeScript Claude Agent SDK (@anthropic-ai/claude-agent-sdk).
Current section
Files
Jump to
Current section
Files
lib/claude_agent/session.ex
defmodule ClaudeAgent.Session do
@moduledoc """
A GenServer that manages a stateful conversation session with Claude.
Sessions maintain conversation history, allowing for multi-turn conversations
where Claude remembers previous context.
## Usage
{:ok, session} = ClaudeAgent.Session.start_link()
# First message
{:ok, response1} = ClaudeAgent.Session.chat(session, "My favorite color is blue")
# Claude will remember the context
{:ok, response2} = ClaudeAgent.Session.chat(session, "What's my favorite color?")
# Reset conversation history
ClaudeAgent.Session.reset(session)
# Stop the session
ClaudeAgent.Session.stop(session)
"""
use GenServer
require Logger
alias ClaudeAgent.{Config, Tool}
@type state :: %{
config: Config.t(),
history: list(map()),
tools: list(Tool.t())
}
# Client API
@doc """
Starts a new session process.
## Options
Same options as `ClaudeAgent.chat/2`, plus:
* `:tools` - List of tools available for the entire session
* `:name` - Optional name to register the process
## Examples
{:ok, session} = ClaudeAgent.Session.start_link()
{:ok, session} = ClaudeAgent.Session.start_link(name: :my_session)
"""
@spec start_link(keyword()) :: GenServer.on_start()
def start_link(opts \\ []) do
{name, opts} = Keyword.pop(opts, :name)
gen_opts = if name, do: [name: name], else: []
GenServer.start_link(__MODULE__, opts, gen_opts)
end
@doc """
Starts a session under the ClaudeAgent.SessionSupervisor.
"""
@spec start_supervised(keyword()) :: DynamicSupervisor.on_start_child()
def start_supervised(opts \\ []) do
DynamicSupervisor.start_child(ClaudeAgent.SessionSupervisor, {__MODULE__, opts})
end
@doc """
Sends a message in the session and waits for the response.
The conversation history is automatically maintained.
"""
@spec chat(GenServer.server(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
def chat(session, message, opts \\ []) do
GenServer.call(session, {:chat, message, opts}, opts[:timeout] || 300_000)
end
@doc """
Same as `chat/3` but raises on error.
"""
@spec chat!(GenServer.server(), String.t(), keyword()) :: String.t()
def chat!(session, message, opts \\ []) do
case chat(session, message, opts) do
{:ok, response} -> response
{:error, reason} -> raise "Session chat failed: #{inspect(reason)}"
end
end
@doc """
Streams a message response via callback.
"""
@spec stream(GenServer.server(), String.t(), keyword(), function()) ::
:ok | {:error, term()}
def stream(session, message, opts \\ [], callback) when is_function(callback, 1) do
GenServer.call(session, {:stream, message, opts, callback}, opts[:timeout] || 300_000)
end
@doc """
Gets the current conversation history.
"""
@spec get_history(GenServer.server()) :: list(map())
def get_history(session) do
GenServer.call(session, :get_history)
end
@doc """
Resets the conversation history.
"""
@spec reset(GenServer.server()) :: :ok
def reset(session) do
GenServer.call(session, :reset)
end
@doc """
Adds a tool to the session.
"""
@spec add_tool(GenServer.server(), Tool.t()) :: :ok
def add_tool(session, %Tool{} = tool) do
GenServer.call(session, {:add_tool, tool})
end
@doc """
Stops the session.
"""
@spec stop(GenServer.server()) :: :ok
def stop(session) do
GenServer.stop(session)
end
# Server Callbacks
@impl true
def init(opts) do
{tools, opts} = Keyword.pop(opts, :tools, [])
config = Config.new(opts)
state = %{
config: config,
history: [],
tools: tools
}
{:ok, state}
end
@impl true
def handle_call({:chat, message, opts}, _from, state) do
config = merge_config(state.config, opts)
bridge_opts = build_bridge_opts(config, state)
# Add conversation history context to the prompt
prompt = build_prompt_with_history(message, state.history)
case ClaudeAgent.PortBridge.chat(prompt, bridge_opts) do
{:ok, response} ->
# Update history with the new exchange
history =
state.history ++
[
%{role: "user", content: message},
%{role: "assistant", content: response}
]
{:reply, {:ok, response}, %{state | history: history}}
{:error, _} = error ->
{:reply, error, state}
end
end
@impl true
def handle_call({:stream, message, opts, callback}, from, state) do
config = merge_config(state.config, opts)
bridge_opts = build_bridge_opts(config, state)
prompt = build_prompt_with_history(message, state.history)
# Collect chunks to update history after streaming completes
chunks = []
wrapped_callback = fn
%{type: :chunk, content: content} = msg ->
callback.(msg)
{:collect, content}
%{type: :end} = msg ->
callback.(msg)
:done
other ->
callback.(other)
:ok
end
# Spawn a task to handle streaming
parent = self()
Task.start(fn ->
result =
stream_and_collect(prompt, bridge_opts, wrapped_callback, chunks, fn collected ->
# Send collected content back to update history
send(parent, {:stream_complete, from, message, collected})
end)
case result do
:ok -> :ok
{:error, _} = error -> GenServer.reply(from, error)
end
end)
{:noreply, state}
end
@impl true
def handle_call(:get_history, _from, state) do
{:reply, state.history, state}
end
@impl true
def handle_call(:reset, _from, state) do
{:reply, :ok, %{state | history: []}}
end
@impl true
def handle_call({:add_tool, tool}, _from, state) do
{:reply, :ok, %{state | tools: [tool | state.tools]}}
end
@impl true
def handle_info({:stream_complete, from, message, collected}, state) do
response = Enum.join(collected)
history =
state.history ++
[
%{role: "user", content: message},
%{role: "assistant", content: response}
]
GenServer.reply(from, :ok)
{:noreply, %{state | history: history}}
end
# Private Functions
defp merge_config(%Config{} = base, opts) do
opts_map = Map.new(opts)
base
|> Map.from_struct()
|> Map.merge(opts_map)
|> then(&struct(Config, &1))
end
defp build_bridge_opts(%Config{} = config, state) do
bridge_opts = Config.to_bridge_opts(config)
case state.tools do
[] -> bridge_opts
tools -> Map.put(bridge_opts, "tools", Enum.map(tools, &Tool.to_definition/1))
end
end
defp build_prompt_with_history(message, []) do
message
end
defp build_prompt_with_history(message, history) do
context =
history
|> Enum.map(fn
%{role: "user", content: content} -> "Human: #{content}"
%{role: "assistant", content: content} -> "Assistant: #{content}"
end)
|> Enum.join("\n\n")
"""
Previous conversation:
#{context}
Current message:
Human: #{message}
"""
end
defp stream_and_collect(prompt, bridge_opts, callback, chunks, on_complete) do
collected = Agent.start_link(fn -> [] end)
result =
ClaudeAgent.PortBridge.stream(prompt, bridge_opts, fn msg ->
case callback.(msg) do
{:collect, content} ->
case collected do
{:ok, agent} -> Agent.update(agent, fn c -> c ++ [content] end)
_ -> :ok
end
:done ->
case collected do
{:ok, agent} ->
final = Agent.get(agent, & &1)
Agent.stop(agent)
on_complete.(final)
_ ->
on_complete.(chunks)
end
_ ->
:ok
end
end)
result
end
end