Skip to main content

lib/skill_kit/hooks.ex

defmodule SkillKit.Hooks do
  @moduledoc """
  Dispatches lifecycle hooks at agent boundaries.

  Two public functions:

  - `call/4` — wraps a boundary in a telemetry span with pre/post hook
    dispatch. Blocks for a decision from pre-event hooks.
  - `cast/3` — fires a single hook event, fire-and-forget.

  Hook events are derived from boundary names: `call(catalog, :tool_use, ...)`
  fires `:pre_tool_use` before and `:post_tool_use` after the callback.
  """

  require Logger

  alias SkillKit.Catalog
  alias SkillKit.Hook
  alias SkillKit.Telemetry

  @type decision :: :ok | {:deny, term()} | {:pending, term()}

  @doc """
  Wraps a boundary in a telemetry span with pre/post hook dispatch.

  Derives hook events from the boundary name: `pre_<boundary>` before
  executing `func`, `post_<boundary>` after. The telemetry span uses
  the boundary name directly.

  `func` must return `{result, post_context}` where `post_context` is
  passed to post-event hooks.
  """
  @spec call(GenServer.server() | SkillKit.Agent.t(), atom(), map(), (-> {term(), map()})) ::
          term()
  def call(agent_or_catalog, boundary, context, func) do
    catalog = catalog_ref(agent_or_catalog)
    pre_event = :"pre_#{boundary}"
    post_event = :"post_#{boundary}"

    Telemetry.span([boundary], context, fn ->
      case fire(catalog, pre_event, context) do
        :ok ->
          {result, post_context} = func.()
          notify(catalog, post_event, post_context)
          {result, %{}, Map.put(context, :status, :ok)}

        {:deny, reason} ->
          {{:deny, reason}, %{}, Map.put(context, :status, :denied)}

        {:pending, state} ->
          {{:pending, state}, %{}, Map.put(context, :status, :suspended)}
      end
    end)
  catch
    :exit, {reason, {GenServer, :call, _}} when reason in [:noproc, :normal, :shutdown] ->
      func.() |> elem(0)
  end

  @doc """
  Fires a single hook event, fire-and-forget.
  """
  @spec cast(GenServer.server() | SkillKit.Agent.t(), Hook.event(), map()) :: :ok
  def cast(agent_or_catalog, event, context) do
    catalog = catalog_ref(agent_or_catalog)
    notify(catalog, event, context)
  catch
    :exit, {reason, {GenServer, :call, _}} when reason in [:noproc, :normal, :shutdown] -> :ok
  end

  # -- Private: catalog resolution ------------------------------------------

  defp catalog_ref(%SkillKit.Agent{} = agent) do
    {:via, Registry, {agent.registry, {agent.name, :catalog}}}
  end

  defp catalog_ref(server), do: server

  # -- Private: hook dispatch -----------------------------------------------

  defp fire(catalog, event, context) do
    catalog
    |> Catalog.list_hooks(event)
    |> filter_by_matcher(event, context)
    |> reduce_until_denied(context)
  end

  defp notify(catalog, event, context) do
    catalog
    |> Catalog.list_hooks(event)
    |> filter_by_matcher(event, context)
    |> Enum.each(fn hook ->
      try do
        invoke_handler(hook.handler, context)
      rescue
        e ->
          Logger.warning("Post-event hook handler failed: #{Exception.message(e)}")
      end
    end)

    :ok
  end

  defp reduce_until_denied([], _context), do: :ok

  defp reduce_until_denied([hook | rest], context) do
    case invoke_handler(hook.handler, context) do
      :ok ->
        reduce_until_denied(rest, context)

      {:deny, _reason} = deny ->
        deny

      {:pending, _state} = pending ->
        pending

      other ->
        Logger.warning("Hook handler returned unexpected value: #{inspect(other)}")
        reduce_until_denied(rest, context)
    end
  end

  defp invoke_handler({mod, config}, context) when is_map(config) do
    apply(mod, :execute, [config, context])
  end

  defp invoke_handler(fun, context) when is_function(fun, 1), do: fun.(context)

  defp invoke_handler({mod, fun, args}, context) do
    apply(mod, fun, [context | args])
  end

  defp filter_by_matcher(hooks, event, context) do
    match_target = match_target_for(event, context)
    Enum.filter(hooks, &matches?(&1, match_target))
  end

  defp matches?(%Hook{matcher: nil}, _target), do: true
  defp matches?(%Hook{matcher: regex}, target), do: Regex.match?(regex, target)

  defp match_target_for(event, context) when event in [:pre_tool_use, :post_tool_use] do
    tool_match_target(Map.get(context, :tool))
  end

  defp match_target_for(event, context) when event in [:pre_subagent, :post_subagent] do
    Map.get(context, :name, Map.get(context, :agent_name, ""))
  end

  defp match_target_for(event, context)
       when event in [:pre_skill_activation, :post_skill_activation] do
    skill_match_target(Map.get(context, :skill))
  end

  defp match_target_for(event, context)
       when event in [:pre_llm_request, :post_llm_request] do
    Map.get(context, :model, Map.get(context, :agent_name, ""))
  end

  defp match_target_for(_event, context), do: Map.get(context, :agent_name, "")

  defp tool_match_target(nil), do: ""
  defp tool_match_target(tool), do: tool |> Module.split() |> List.last()

  defp skill_match_target(nil), do: ""
  defp skill_match_target(skill), do: skill.name
end