Packages

An OTP-native coding agent SDK. Build, orchestrate, and observe AI coding agents with Elixir supervision trees, streaming events, and a JSON-RPC 2.0 interface.

Current section

Files

Jump to
opal lib opal.ex
Raw

lib/opal.ex

defmodule Opal do
@moduledoc """
Public API for the Opal coding agent SDK.
Provides functions to start agent sessions, send prompts (async and sync),
steer agents mid-run, and manage session lifecycle. All agent events are
broadcast via `Opal.Events` for real-time observability.
Session defaults (model, tools, shell, data_dir) come from `Opal.Config`
and can be overridden per-session.
## Quick Start
{:ok, agent} = Opal.start_session(%{
system_prompt: "You are a helpful coding assistant.",
working_dir: "/path/to/project"
})
:ok = Opal.prompt(agent, "List all Elixir files")
# Or synchronously:
{:ok, response} = Opal.prompt_sync(agent, "What is 2 + 2?")
"""
require Logger
@doc """
Starts a new agent session with the given configuration.
All keys are optional — defaults come from `config :opal` via `Opal.Config`.
## Config Keys
* `:model` — a `{provider_atom, model_id_string}` tuple
* `:tools` — list of modules implementing `Opal.Tool`
* `:system_prompt` — the system prompt string (default: `""`)
* `:working_dir` — base directory for tool execution (default: current dir)
* `:provider` — module implementing `Opal.Provider` (default: `Opal.Provider.Copilot`)
* `:session` — if `true`, starts an `Opal.Session` process for persistence/branching
* `:shell` — shell type for `Opal.Tool.Shell` (default: platform auto-detect)
* `:data_dir` — override data directory (default: `~/.opal`)
## Examples
# Minimal — everything from config :opal
{:ok, agent} = Opal.start_session(%{working_dir: "/project"})
# Override model for this session
{:ok, agent} = Opal.start_session(%{
model: {:copilot, "gpt-5"},
working_dir: "/project"
})
"""
@spec start_session(map()) :: {:ok, pid()} | {:error, term()}
def start_session(config) when is_map(config) do
cfg = Opal.Config.new(config)
{provider_raw, model_id} = Map.get(config, :model) || cfg.default_model
provider_atom = if is_atom(provider_raw), do: provider_raw, else: String.to_existing_atom(provider_raw)
model = Opal.Model.new(provider_atom, model_id)
session_id = generate_session_id()
opts = [
session_id: session_id,
system_prompt: Map.get(config, :system_prompt, ""),
model: model,
tools: Map.get(config, :tools) || cfg.default_tools,
working_dir: Map.get(config, :working_dir, File.cwd!()),
config: cfg,
session: Map.get(config, :session, false)
]
Opal.Config.ensure_dirs!(cfg)
case DynamicSupervisor.start_child(Opal.SessionSupervisor, {Opal.SessionServer, opts}) do
{:ok, session_server} ->
agent = Opal.SessionServer.agent(session_server)
{:ok, agent}
{:error, _} = error ->
error
end
end
@doc """
Sends an asynchronous prompt to the agent.
Subscribe to `Opal.Events` with the session ID to receive streaming output.
Returns `:ok` immediately.
"""
@spec prompt(GenServer.server(), String.t()) :: :ok
def prompt(agent, text) do
Opal.Agent.prompt(agent, text)
end
@doc """
Sends a prompt and waits synchronously for the final response.
Subscribes to the agent's events, sends the prompt, and collects text
deltas until `:agent_end` is received. Returns the accumulated text.
## Options
* `timeout` — maximum wait time in milliseconds (default: `60_000`)
"""
@spec prompt_sync(GenServer.server(), String.t(), timeout()) ::
{:ok, String.t()} | {:error, term()}
def prompt_sync(agent, text, timeout \\ 60_000) do
state = Opal.Agent.get_state(agent)
Opal.Events.subscribe(state.session_id)
Opal.Agent.prompt(agent, text)
collect_response(state.session_id, "", timeout)
after
state = Opal.Agent.get_state(agent)
Opal.Events.unsubscribe(state.session_id)
end
@doc """
Steers the agent mid-run.
If idle, acts like `prompt/2`. If running, the message is picked up
between tool executions.
"""
@spec steer(GenServer.server(), String.t()) :: :ok
def steer(agent, text) do
Opal.Agent.steer(agent, text)
end
@doc """
Sends a follow-up prompt to the agent. Convenience wrapper for `prompt/2`.
"""
@spec follow_up(GenServer.server(), String.t()) :: :ok
def follow_up(agent, text) do
Opal.Agent.follow_up(agent, text)
end
@doc """
Aborts the current agent run.
"""
@spec abort(GenServer.server()) :: :ok
def abort(agent) do
Opal.Agent.abort(agent)
end
@doc """
Changes the model on a running agent session.
The new model takes effect on the next prompt. Conversation history is preserved.
Opal.set_model(agent, :copilot, "gpt-5")
"""
@spec set_model(pid(), atom(), String.t(), keyword()) :: :ok
def set_model(agent, provider, model_id, opts \\ []) do
model = Opal.Model.new(provider, model_id, opts)
GenServer.call(agent, {:set_model, model})
end
@doc """
Returns the full context window (system prompt + all messages) for a session.
"""
@spec get_context(pid()) :: [Opal.Message.t()]
def get_context(agent) do
GenServer.call(agent, :get_context)
end
@doc """
Stops a session and cleans up.
Terminates the entire session supervision tree (agent, tools, sub-agents).
"""
@spec stop_session(pid()) :: :ok | {:error, :not_found}
def stop_session(agent) when is_pid(agent) do
# The agent's parent is the SessionServer supervisor
case find_session_server(agent) do
{:ok, session_server} ->
DynamicSupervisor.terminate_child(Opal.SessionSupervisor, session_server)
:error ->
{:error, :not_found}
end
end
# --- Private Helpers ---
# Collects streamed text deltas until :agent_end is received.
defp collect_response(session_id, acc, timeout) do
receive do
{:opal_event, ^session_id, {:message_delta, %{delta: delta}}} ->
collect_response(session_id, acc <> delta, timeout)
{:opal_event, ^session_id, {:agent_end, _messages}} ->
{:ok, acc}
{:opal_event, ^session_id, {:agent_end, _messages, _usage}} ->
{:ok, acc}
{:opal_event, ^session_id, {:error, reason}} ->
{:error, reason}
{:opal_event, ^session_id, _other} ->
# Ignore other events (tool_execution_start, thinking, etc.)
collect_response(session_id, acc, timeout)
after
timeout ->
{:error, :timeout}
end
end
defp generate_session_id do
:crypto.strong_rand_bytes(16) |> Base.encode16(case: :lower)
end
# Finds the SessionServer supervisor that owns the given agent pid.
defp find_session_server(agent) do
case Process.info(agent, :dictionary) do
{:dictionary, dict} ->
case Keyword.get(dict, :"$ancestors") do
[parent | _] when is_pid(parent) -> {:ok, parent}
_ -> :error
end
nil ->
:error
end
end
end