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
Current section
Files
lib/raxol/agent/thread_log_router.ex
defmodule Raxol.Agent.ThreadLogRouter do
@moduledoc """
Telemetry handler that routes agent-side events
(`[:raxol, :agent, :policy, :*]` and
`[:raxol, :agent, :sandbox, :denied]`) into a
`Raxol.Agent.ThreadLog` adapter as durable audit entries.
## How it works
`attach/3` calls `:telemetry.attach_many/4` for the agent-side
event family, passing the ThreadLog adapter tuple and the agent's
`thread_id` in the handler config. Every matching event is
translated into one `ThreadLog.append/5` call with
`kind: :policy_result` (for policy events) or
`kind: :sandbox_deny` (for sandbox denials).
Detach via `detach/1` (e.g. in the agent's `terminate/2`).
## Wiring
Consumers typically call this at agent startup once
`agent_module.thread_log/0` resolves to a non-nil adapter:
adapter = Raxol.Agent.ThreadLog.normalize(MyAgent.thread_log())
handler_id = "raxol_agent_thread_log_router_\#{agent_id}"
Raxol.Agent.ThreadLogRouter.attach(handler_id, adapter, agent_id)
# ... later, on terminate
Raxol.Agent.ThreadLogRouter.detach(handler_id)
When `adapter` is `nil`, `attach/3` returns `:ok` without
attaching, so callers don't need to branch.
## Event mapping
| Telemetry event | ThreadLog kind | Payload |
| --- | --- | --- |
| `[:raxol, :agent, :policy, :cache_hit]` | `:policy_result` | `%{policy: :cache, decision: :hit, key, params}` |
| `[:raxol, :agent, :policy, :cache_miss]` | `:policy_result` | `%{policy: :cache, decision: :miss, key, params}` |
| `[:raxol, :agent, :policy, :retry_attempt]` | `:policy_result` | `%{policy: :retry, decision: :attempt, attempt, reason, backoff_ms}` |
| `[:raxol, :agent, :policy, :retry_exhausted]` | `:policy_result` | `%{policy: :retry, decision: :exhausted, attempt, reason}` |
| `[:raxol, :agent, :policy, :timeout]` | `:policy_result` | `%{policy: :timeout, decision: :fired, wall_ms}` |
| `[:raxol, :agent, :policy, :applied]` | `:policy_result` | `%{policy: :applied, policy_kinds, outcome}` |
| `[:raxol, :agent, :sandbox, :denied]` | `:sandbox_deny` | `%{action, reason}` |
All payloads include a `metadata` map mirroring the telemetry
event's metadata (minus `agent_id`/`agent_module` which are
redundant with the thread_id).
"""
alias Raxol.Agent.ThreadLog
@policy_events [
[:raxol, :agent, :policy, :cache_hit],
[:raxol, :agent, :policy, :cache_miss],
[:raxol, :agent, :policy, :retry_attempt],
[:raxol, :agent, :policy, :retry_exhausted],
[:raxol, :agent, :policy, :timeout],
[:raxol, :agent, :policy, :applied]
]
@sandbox_events [
[:raxol, :agent, :sandbox, :denied]
]
@all_events @policy_events ++ @sandbox_events
@doc """
Attach a telemetry handler that routes agent-side events to the
given ThreadLog adapter under `thread_id`.
`adapter` is the `{module, config}` tuple (or bare module) as
returned by `Raxol.Agent.ThreadLog.normalize/1`. Pass `nil` to
no-op (useful when the agent declares no `thread_log/0`).
Returns `:ok` on success or when adapter is `nil`. Returns
`{:error, :already_exists}` if `handler_id` is already attached.
"""
@spec attach(
binary(),
ThreadLog.config() | {module(), ThreadLog.config()} | nil,
binary()
) ::
:ok | {:error, term()}
def attach(_handler_id, nil, _thread_id), do: :ok
def attach(handler_id, adapter, thread_id)
when is_binary(handler_id) and is_binary(thread_id) do
normalized = ThreadLog.normalize(adapter)
case :telemetry.attach_many(
handler_id,
@all_events,
&__MODULE__.handle/4,
%{adapter: normalized, thread_id: thread_id}
) do
:ok -> :ok
{:error, _reason} = err -> err
end
end
@doc "Detach a previously-attached handler. Idempotent."
@spec detach(binary()) :: :ok
def detach(handler_id) when is_binary(handler_id) do
_ = :telemetry.detach(handler_id)
:ok
end
@doc false
def handle(event, _measurements, metadata, %{
adapter: adapter,
thread_id: thread_id
}) do
{kind, payload} = translate(event, metadata)
audit_metadata = Map.drop(metadata, [:agent_id, :agent_module])
_ =
ThreadLog.append(adapter, thread_id, kind, payload,
metadata: audit_metadata
)
:ok
end
# --- Per-event translation -----------------------------------------------
defp translate([:raxol, :agent, :policy, sub], metadata) do
{:policy_result, build_policy_payload(sub, metadata)}
end
defp translate([:raxol, :agent, :sandbox, :denied], metadata) do
payload = %{
action: Map.get(metadata, :action),
reason: Map.get(metadata, :reason)
}
{:sandbox_deny, payload}
end
defp build_policy_payload(:cache_hit, metadata),
do: %{
policy: :cache,
decision: :hit,
key: metadata[:key],
params: metadata[:params]
}
defp build_policy_payload(:cache_miss, metadata),
do: %{
policy: :cache,
decision: :miss,
key: metadata[:key],
params: metadata[:params]
}
defp build_policy_payload(:retry_attempt, metadata),
do: %{
policy: :retry,
decision: :attempt,
attempt: metadata[:attempt],
reason: metadata[:reason],
backoff_ms: metadata[:backoff_ms]
}
defp build_policy_payload(:retry_exhausted, metadata),
do: %{
policy: :retry,
decision: :exhausted,
attempt: metadata[:attempt],
reason: metadata[:reason]
}
defp build_policy_payload(:timeout, metadata),
do: %{policy: :timeout, decision: :fired, wall_ms: metadata[:wall_ms]}
defp build_policy_payload(:applied, metadata),
do: %{
policy: :applied,
policy_kinds: metadata[:policy_kinds],
outcome: metadata[:outcome]
}
defp build_policy_payload(other, metadata),
do: %{policy: other, decision: :unknown, metadata: metadata}
end