Packages

AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.

Current section

Files

Jump to
raxol_agent lib raxol agent command_hook.ex
Raw

lib/raxol/agent/command_hook.ex

defmodule Raxol.Agent.CommandHook do
@moduledoc """
Pre/post execution hooks for agent commands.
Hooks can inspect, modify, or deny commands before and after execution.
This enables audit logging, permission checks, rate limiting, and other
cross-cutting concerns without modifying individual agent modules.
## Usage
Define a hook module:
defmodule MyAuditHook do
@behaviour Raxol.Agent.CommandHook
@impl true
def pre_execute(command, context) do
Logger.info("Executing: \#{inspect(command.type)}")
{:ok, command}
end
@impl true
def post_execute(command, result, context) do
Logger.info("Result: \#{inspect(result)}")
{:ok, result}
end
end
Register hooks when starting an agent:
Agent.Session.start_link(
id: :my_agent,
app_module: MyAgent,
hooks: [MyAuditHook, MyPermissionHook]
)
## Hook Results
- `{:ok, command}` -- allow (optionally modified)
- `{:deny, reason}` -- block execution, send denial as command result
- `{:ok, result}` -- post-hook, optionally modify result
"""
alias Raxol.Core.Runtime.Command
@type hook_context :: %{
agent_id: term(),
agent_module: module()
}
@type pre_result :: {:ok, Command.t()} | {:deny, term()}
@type post_result :: {:ok, term()} | {:error, term()}
@doc """
Called before a command is executed.
Return `{:ok, command}` to allow (optionally modify the command),
or `{:deny, reason}` to block execution.
"""
@callback pre_execute(command :: Command.t(), context :: hook_context()) ::
pre_result()
@doc """
Called after a command has been executed.
Return `{:ok, result}` to pass through (optionally modify the result).
"""
@callback post_execute(
command :: Command.t(),
result :: term(),
context :: hook_context()
) :: post_result()
@optional_callbacks post_execute: 3
@doc """
Run a command through a chain of pre-execution hooks.
Hooks are evaluated in order. The first `:deny` short-circuits the chain.
Each hook may modify the command before passing it to the next.
"""
@spec run_pre_hooks([module()], Command.t(), hook_context()) :: pre_result()
def run_pre_hooks([], command, _context), do: {:ok, command}
def run_pre_hooks([hook | rest], command, context) do
case hook.pre_execute(command, context) do
{:ok, command} -> run_pre_hooks(rest, command, context)
{:deny, _reason} = denied -> denied
end
end
@doc """
Run a result through a chain of post-execution hooks.
Hooks are evaluated in order. Each hook may modify the result.
"""
@spec run_post_hooks([module()], Command.t(), term(), hook_context()) ::
post_result()
def run_post_hooks([], _command, result, _context), do: {:ok, result}
def run_post_hooks([hook | rest], command, result, context) do
if function_exported?(hook, :post_execute, 3) do
case hook.post_execute(command, result, context) do
{:ok, result} -> run_post_hooks(rest, command, result, context)
{:error, _reason} = error -> error
end
else
run_post_hooks(rest, command, result, context)
end
end
@doc """
Wrap a list of commands, applying pre/post hooks around each execution.
Returns a new list of commands where hookable types (`:shell`, `:async`,
`:system`, `:send_agent`) are wrapped to run hooks. Non-hookable types
(`:none`, `:quit`, `:delay`, etc.) pass through unchanged.
Denied commands are replaced with a `:none` command that sends a
`{:command_denied, type, reason}` result back to the agent.
"""
@spec wrap_commands([Command.t()], [module()], hook_context()) :: [
Command.t()
]
def wrap_commands(commands, [], _context), do: commands
def wrap_commands(commands, hooks, context) do
Enum.map(commands, &maybe_wrap(&1, hooks, context))
end
@hookable_types [:shell, :async, :system, :send_agent, :task]
defp maybe_wrap(%Command{type: type} = command, hooks, context)
when type in @hookable_types do
case run_pre_hooks(hooks, command, context) do
{:ok, command} ->
wrap_with_post_hooks(command, hooks, context)
{:deny, reason} ->
Command.new(:async, fn sender ->
sender.({:command_denied, type, reason})
end)
end
end
defp maybe_wrap(command, _hooks, _context), do: command
defp wrap_with_post_hooks(command, hooks, context) do
if Enum.any?(hooks, &function_exported?(&1, :post_execute, 3)) do
case command.type do
:async ->
original_fun = command.data
wrapped_fun = fn sender ->
wrapped_sender = fn result ->
case run_post_hooks(hooks, command, result, context) do
{:ok, modified_result} -> sender.(modified_result)
{:error, reason} -> sender.({:hook_error, reason})
end
end
original_fun.(wrapped_sender)
end
%{command | data: wrapped_fun}
:task ->
original_fun = command.data
wrapped_fun = fn ->
result = original_fun.()
case run_post_hooks(hooks, command, result, context) do
{:ok, modified_result} -> modified_result
{:error, reason} -> {:hook_error, reason}
end
end
%{command | data: wrapped_fun}
_ ->
# :shell, :system, :send_agent -- post-hooks can't easily wrap
# these since they use Port/GenServer internally. Pre-hooks
# are sufficient for these types.
command
end
else
command
end
end
end