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` — model specification. Accepts any form that `Opal.Model.coerce/2` supports: * A `{provider, model_id}` tuple (e.g. `{:anthropic, "claude-sonnet-4-5"}`) * A `"provider:model_id"` string (e.g. `"anthropic:claude-sonnet-4-5"`) * A bare model ID string defaults to Copilot (e.g. `"claude-sonnet-4-5"`) * `: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`. Auto-selected based on model provider: `Opal.Provider.Copilot` for `:copilot`, `Opal.Provider.LLM` for all others (Anthropic, OpenAI, Google, etc.) * `: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"}) # Use Anthropic directly (auto-selects Opal.Provider.LLM) {:ok, agent} = Opal.start_session(%{ model: {:anthropic, "claude-sonnet-4-5"}, working_dir: "/project" }) # Equivalent string form {:ok, agent} = Opal.start_session(%{ model: "anthropic:claude-sonnet-4-5", working_dir: "/project" }) # Use Copilot (auto-selects Opal.Provider.Copilot) {: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 opts = Opal.Session.Builder.build_opts(config) Opal.Config.ensure_dirs!(opts[:config]) 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) Opal.Agent.Collector.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. The provider is automatically updated based on the model's provider atom: `:copilot` uses `Opal.Provider.Copilot`, all others use `Opal.Provider.LLM`. Accepts any model specification that `Opal.Model.coerce/2` supports: * A `"provider:model_id"` string (e.g. `"anthropic:claude-sonnet-4-5"`) * A `{provider, model_id}` tuple (e.g. `{:copilot, "gpt-5"}`) * An `%Opal.Model{}` struct ## Examples Opal.set_model(agent, {:copilot, "gpt-5"}) Opal.set_model(agent, "anthropic:claude-sonnet-4-5") Opal.set_model(agent, "anthropic:claude-sonnet-4-5", thinking_level: :high) """ @spec set_model(pid(), Opal.Model.t() | String.t() | {atom(), String.t()}, keyword()) :: :ok def set_model(agent, model_spec, opts \\ []) do model = Opal.Model.coerce(model_spec, opts) provider_module = Opal.Model.provider_module(model) GenServer.call(agent, {:set_model, model}) GenServer.call(agent, {:set_provider, provider_module}) 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 @doc """ Returns a curated info map for the given agent. Useful for inspecting agent state without depending on internal structs. The returned map includes: * `:session_id` — unique session identifier * `:session_dir` — on-disk path for session persistence * `:status` — current agent status (`:idle`, `:running`, etc.) * `:model` — the active `%Opal.Model{}` struct * `:provider` — provider module (e.g. `Opal.Provider.Copilot`) * `:session` — session process pid (or `nil`) * `:working_dir` — base directory for tool execution * `:context_files` — list of discovered context files * `:available_skills` — list of available skill structs * `:mcp_servers` — list of connected MCP servers * `:tools` — list of tool modules * `:message_count` — number of messages in the conversation * `:token_usage` — token usage statistics """ @spec get_info(pid()) :: map() def get_info(agent) do state = Opal.Agent.get_state(agent) session_dir = Path.join(Opal.Config.sessions_dir(state.config), state.session_id) %{ session_id: state.session_id, session_dir: session_dir, status: state.status, model: state.model, provider: state.provider, session: state.session, working_dir: state.working_dir, context_files: state.context_files, available_skills: state.available_skills, mcp_servers: state.mcp_servers, tools: state.tools, message_count: length(state.messages), token_usage: state.token_usage } end @doc """ Syncs the agent's message list with the given messages. Typically used after session compaction to update the agent with the compacted message history. """ @spec sync_messages(pid(), list()) :: :ok def sync_messages(agent, messages) do GenServer.call(agent, {:sync_messages, messages}) end @doc """ Sets the thinking level on the agent's current model. Updates only the thinking level while preserving the current model and provider. Returns `:ok`. ## Examples Opal.set_thinking_level(agent, :high) Opal.set_thinking_level(agent, :off) """ @spec set_thinking_level(pid(), atom()) :: :ok def set_thinking_level(agent, level) do state = Opal.Agent.get_state(agent) model = %{state.model | thinking_level: level} GenServer.call(agent, {:set_model, model}) end # --- Private Helpers --- # 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