Skip to main content

lib/skill_kit.ex

defmodule SkillKit do
  @moduledoc """
  SkillKit — an Elixir framework for building LLM agent systems.

  This module is the public API for starting agents, sending messages,
  and receiving streamed responses.

  ## Quick Start

      {:ok, agent} = SkillKit.start_agent("agents/my-agent",
        skills: ["skills"],
        caller: self()
      )

      :ok = SkillKit.send_message(agent, "Hello")

      receive do
        %SkillKit.Event.Delta{text: text} -> IO.write(text)
        %SkillKit.Types.AssistantMessage{content: text} -> IO.puts("Done.")
        %SkillKit.Event.Error{reason: reason} -> IO.puts("Error")
      end

      SkillKit.stop_agent(agent)

  ## Events

  The caller process receives structs directly:

    * `%SkillKit.Event.Delta{agent: name, text: text}` — real-time text fragment
    * `%SkillKit.Event.ToolCallStart{agent: name, id: id, name: name}` — tool call began
    * `%SkillKit.Event.ToolCallComplete{agent: name, id: id, name: name, input: input}` — tool call parsed
    * `%SkillKit.Types.AssistantMessage{agent: name, content: text}` — complete response at turn end
    * `%SkillKit.Types.ToolResult{agent: name, content: content}` — tool result
    * `%SkillKit.Event.InputRequested{agent: name, tool_call_id: id}` — tool suspended, needs input
    * `%SkillKit.Event.Error{agent: name, reason: reason}` — LLM or execution error

  ## Configuration

      config :skill_kit, SkillKit.LLM,
        providers: [
          anthropic: SkillKit.LLM.Anthropic
        ],
        default_provider: :anthropic
  """

  alias SkillKit.Agent
  alias SkillKit.AgentRef
  alias SkillKit.Event.Error, as: EventError
  alias SkillKit.Runtime
  alias SkillKit.Types.AssistantMessage
  alias SkillKit.Types.UserMessage

  @type agent :: AgentRef.t()

  @valid_opts [:tools, :skills, :runtime, :scope, :conversation_store, :caller, :name]

  @doc """
  Starts a new agent.

  The first argument identifies the agent. It accepts:
  - `"path"` — string path, resolved as `{Kit.Local, dir: "path"}`
  - `MyModule` — bare module, resolved as `{MyModule, []}`
  - `{module, opts}` — a kit provider tuple

  When the agent is loaded from a kit provider, the kit's skills and
  sub-agents are automatically included in the tool pool.

  Returns `{:ok, agent_ref}` where `agent_ref` is an opaque reference
  used with `send_message/2` and `stop_agent/1`.

  ## Options

    * `:tools` — list of tool providers (default: `[]`). Tools are always
      available to the LLM — called directly, no activation needed.
      Examples: `[{SkillKit.Tools.Shell, cwd: "."}]`.
    * `:skills` — list of skill providers (default: `[]`). Skills appear
      only in `activate_skill`'s enum. When the LLM activates a skill,
      a child agent is forked with the skill's underlying tool module
      added to its `:tools` list, so the child can execute it directly.
    * `:runtime` — `{module, config}` for agent spawning (default: `{Runtime.Local, []}`)
    * `:scope` — authorization scope (default: `nil`)
    * `:conversation_store` — `{module, config}` for persistence (default: `nil`)
    * `:caller` — pid for events (default: `self()`)
    * `:name` — override agent name

  """
  @spec start_agent(Agent.t() | String.t() | {module(), keyword()} | module()) ::
          {:ok, agent()} | {:error, term()}
  def start_agent(source) do
    start_agent(source, [])
  end

  @spec start_agent(Agent.t() | String.t() | {module(), keyword()} | module(), keyword()) ::
          {:ok, agent()} | {:error, term()}
  def start_agent(source, opts) do
    source
    |> resolve_and_build(opts)
    |> assign_caller()
    |> Runtime.start_agent()
  end

  defp resolve_and_build(source, opts) do
    Keyword.validate!(opts, @valid_opts)

    agent = resolve_agent(source)
    tools = normalize_providers(Keyword.get(opts, :tools, []))
    skills = normalize_providers(Keyword.get(opts, :skills, []))
    agent_provider = agent_as_provider(source)
    all_skills = merge_agent_provider(agent_provider, skills)

    %{
      agent
      | name: Keyword.get(opts, :name, agent.name),
        tools: tools,
        skills: all_skills,
        runtime: Keyword.get(opts, :runtime, agent.runtime),
        scope: Keyword.get(opts, :scope, agent.scope),
        conversation_store: Keyword.get(opts, :conversation_store, agent.conversation_store),
        caller: Keyword.get(opts, :caller),
        registry: :"skill_kit_registry_#{:erlang.unique_integer([:positive])}"
    }
  end

  defp assign_caller(%{caller: nil} = agent) do
    %{agent | caller: self()}
  end

  defp assign_caller(agent), do: agent

  # -------------------------------------------------------------------
  # Agent resolution
  # -------------------------------------------------------------------

  defp resolve_agent(%Agent{} = definition), do: definition

  defp resolve_agent(path) when is_binary(path) do
    resolve_agent({SkillKit.Kit.Local, dir: path})
  end

  defp resolve_agent(module) when is_atom(module) do
    resolve_agent({module, []})
  end

  defp resolve_agent({module, config}) do
    case module.load_kits(config) do
      {:ok, kits} ->
        kits
        |> Enum.map(& &1.agent)
        |> Enum.find(& &1) ||
          raise "No agent (AGENT.md) found in agent: provider #{inspect(module)}"

      {:error, reason} ->
        raise "Failed to load agent from #{inspect(module)}: #{inspect(reason)}"
    end
  end

  # -------------------------------------------------------------------
  # Skills normalization (string/module/tuple sugar)
  # -------------------------------------------------------------------

  defp normalize_providers(providers) do
    Enum.map(providers, &normalize_provider_entry/1)
  end

  defp normalize_provider_entry(path) when is_binary(path), do: {SkillKit.Kit.Local, dir: path}
  defp normalize_provider_entry(module) when is_atom(module), do: {module, []}
  defp normalize_provider_entry({module, config}), do: {module, config}

  # -------------------------------------------------------------------
  # Auto-include agent kit's tools
  # -------------------------------------------------------------------

  defp agent_as_provider(%Agent{}), do: nil
  defp agent_as_provider(path) when is_binary(path), do: {SkillKit.Kit.Local, dir: path}
  defp agent_as_provider(module) when is_atom(module), do: {module, []}
  defp agent_as_provider({module, config}), do: {module, config}

  defp merge_agent_provider(nil, skills), do: skills
  defp merge_agent_provider(provider, skills), do: [provider | skills]

  @doc """
  Sends a user message to the agent referenced by `agent`.

  Returns `:ok` if the message was delivered, or `{:error, :not_found}`
  if the agent's mailbox process cannot be found.
  """
  @spec send_message(agent(), String.t()) :: :ok | {:error, :not_found}
  def send_message(%AgentRef{} = agent, content) when is_binary(content) do
    message = %UserMessage{content: content}

    try do
      case Registry.lookup(agent.registry, {agent.name, :mailbox}) do
        [{pid, _}] ->
          GenServer.cast(pid, {:message, message})
          :ok

        [] ->
          {:error, :not_found}
      end
    rescue
      ArgumentError -> {:error, :not_found}
    end
  end

  @doc """
  Dispatches a discrete event to the agent for processing as a bounded task.

  Events are processed in an isolated sub-loop: the content is treated as
  a user message, the sub-loop runs with a scoped tool set and a
  configurable initial message history, and intermediate tool calls,
  reasoning, and sub-agent events stay inside the sub-loop. The parent
  agent's `state.messages` is NOT mutated by the cast itself.

  Surfacing back to the main conversation is opt-in via the `send_message`
  tool. Every event sub-loop is injected with `SkillKit.Tools.SendMessage`
  bound to the parent agent; if the sub-loop's LLM calls it, that
  delivers a `UserMessage` to the parent's mailbox, which then runs a
  normal turn and produces an assistant response visible in the main
  conversation. If the sub-loop finishes without calling `send_message`,
  the event is handled silently and nothing reaches the main conversation.

  This contrasts with `send_message/2`, which adds to the ongoing
  conversation and produces regular assistant turns with all intermediate
  steps visible.

  Primary caller today is `SkillKit.Webhook.Inbox.Memory.put/2`, which
  emits webhook deliveries to their bound agent with a scoped tool set
  (webhook config tool stripped, `webhook_inbox` injected). Future callers
  include cron-like schedulers, admin-initiated runs, and any other
  event-driven trigger that wants isolated processing.

  ## Options

    * `:system_append` (string, required) — appended to the parent agent's
      system prompt for the duration of the sub-loop
    * `:initial_messages` (`:empty | :forked | [msg]`, default `:empty`) —
      the message history the sub-loop starts with; `:forked` copies the
      parent's history (dropping a trailing `activate_skill` tool use),
      `:empty` starts fresh
    * `:tools_add` (list of `{module, context}`) — extra tools injected
      into the sub-loop
    * `:tools_remove` (list of modules) — parent tools stripped from the
      sub-loop. Does not apply to the auto-injected `send_message` tool.
    * `:skills_remove_prefix` (string) — skill namespace prefix to hide from
      `activate_skill` during the sub-loop (e.g. `"webhook:"`)
    * `:allow_activate_skill` (boolean, default `false`) — whether the
      `activate_skill` meta-tool is exposed in the sub-loop
    * `:sub_agent_name` (string, required) — tag applied to events forwarded
      to the parent's caller so chat printers can attribute them

  Returns `:ok` if the cast was delivered, or `{:error, :not_found}` if
  the agent's server process cannot be found.
  """
  @spec send_event(agent(), String.t(), keyword()) :: :ok | {:error, :not_found}
  def send_event(%AgentRef{} = agent, content, opts)
      when is_binary(content) and is_list(opts) do
    case Registry.lookup(agent.registry, {agent.name, :server}) do
      [{pid, _}] ->
        GenServer.cast(pid, {:process_event, content, opts})
        :ok

      [] ->
        {:error, :not_found}
    end
  rescue
    ArgumentError -> {:error, :not_found}
  end

  @doc """
  Responds to a suspended tool call with input.

  When a tool returns `{:pending, state}`, the caller receives an
  `%Event.InputRequested{}` event. Call `respond/3` with the
  `tool_call_id` from the event and the answer to resume execution.
  """
  @spec respond(agent(), String.t(), any()) :: :ok | {:error, :not_found}
  def respond(%AgentRef{} = agent, tool_call_id, answer) do
    case Registry.lookup(agent.registry, {agent.name, :pending_tool, tool_call_id}) do
      [{pid, _}] ->
        send(pid, {:resume, answer})
        :ok

      [] ->
        {:error, :not_found}
    end
  rescue
    ArgumentError -> {:error, :not_found}
  end

  @doc """
  Sends a message and blocks until the agent responds.

  Returns `{:ok, text}` on success, `{:error, reason}` on LLM error,
  or `{:error, :timeout}` if the turn doesn't complete within `timeout` ms.

  Must be called from the process registered as `:caller` in `start_agent/2`.
  Intermediate events (`:delta`, `:tool_call`, `:tool_result`) remain in
  the caller's mailbox and are not consumed.

  ## Examples

      {:ok, "Hello!"} = SkillKit.send_message_sync(agent, "Hi")
      {:error, :timeout} = SkillKit.send_message_sync(agent, "Hi", 100)
  """
  @spec send_message_sync(agent(), String.t(), timeout()) ::
          {:ok, AssistantMessage.t()} | {:error, term()}
  def send_message_sync(%AgentRef{} = agent, content, timeout \\ 5000) do
    case send_message(agent, content) do
      :ok -> await_response(agent.name, timeout)
      {:error, reason} -> {:error, reason}
    end
  end

  defp await_response(agent_name, timeout) do
    receive do
      %AssistantMessage{agent: ^agent_name} = msg -> {:ok, msg}
      %EventError{agent: ^agent_name, reason: reason} -> {:error, reason}
    after
      timeout -> {:error, :timeout}
    end
  end

  @doc """
  Stops a running agent and all its child processes.
  """
  @spec stop_agent(agent()) :: :ok
  def stop_agent(%AgentRef{supervisor_pid: pid}) do
    Supervisor.stop(pid)
  end
end