Powered by AppSignal & Oban Pro

Jidoka: Hooks And Guardrails

livebook/05_hooks_and_guardrails.livemd

Jidoka: Hooks And Guardrails

Run in Livebook

Use hooks to shape a turn and guardrails to stop work at clear boundaries.

Setup

Mix.install(
  [
    {:jidoka, git: "https://github.com/mikehostetler/jidoka.git", ref: "924a486f3c1b7e7a943cb3d5ceee0de65f158467"},
    {:kino, "~> 0.19.0"}
  ],
  config: [
    jidoka: [
      model_aliases: %{fast: "anthropic:claude-haiku-4-5"}
    ]
  ]
)
Jidoka.Kino.setup()

Define The Lifecycle Pieces

defmodule LivebookDemo.Lifecycle.Hooks.AddTenant do
  use Jidoka.Hook, name: "add_tenant"

  @impl true
  def call(%Jidoka.Hooks.BeforeTurn{} = input) do
    tenant = Map.get(input.context, :tenant, "demo")

    {:ok,
     %{
       message: "#{input.message} for tenant #{tenant}",
       context: %{tenant: tenant},
       metadata: %{tenant_added?: true}
     }}
  end
end

defmodule LivebookDemo.Lifecycle.Hooks.TagReply do
  use Jidoka.Hook, name: "tag_reply"

  @impl true
  def call(%Jidoka.Hooks.AfterTurn{outcome: {:ok, result}}) when is_binary(result) do
    {:ok, {:ok, "#{result} [checked]"}}
  end

  def call(%Jidoka.Hooks.AfterTurn{}), do: :ok
end

defmodule LivebookDemo.Lifecycle.Guardrails.NoSecrets do
  use Jidoka.Guardrail, name: "no_secrets"

  @impl true
  def call(%Jidoka.Guardrails.Input{message: message}) do
    if String.contains?(String.downcase(message), "secret") do
      {:error, :secret_request}
    else
      :ok
    end
  end
end

defmodule LivebookDemo.Lifecycle.Agent do
  use Jidoka.Agent

  agent do
    id :livebook_lifecycle_agent
  end

  defaults do
    model :fast
    instructions "You are lifecycle-aware."
  end

  lifecycle do
    before_turn LivebookDemo.Lifecycle.Hooks.AddTenant
    after_turn LivebookDemo.Lifecycle.Hooks.TagReply
    input_guardrail LivebookDemo.Lifecycle.Guardrails.NoSecrets
  end
end

Inspect the configured lifecycle.

%{
  hooks: LivebookDemo.Lifecycle.Agent.hooks(),
  guardrails: LivebookDemo.Lifecycle.Agent.guardrails()
}

Run The Hooks Directly

This uses the generated runtime callbacks, so no provider is required.

runtime = LivebookDemo.Lifecycle.Agent.runtime_module()
agent = runtime.new(id: "livebook-lifecycle-runtime")

{:ok, agent, {:ai_react_start, params}} =
  runtime.on_before_cmd(
    agent,
    {:ai_react_start,
     %{
       query: "Summarize the ticket",
       request_id: "req-lifecycle-ok",
       tool_context: %{tenant: "acme"}
     }}
  )

agent = Jido.AI.Request.complete_request(agent, "req-lifecycle-ok", "Ticket summarized.")

{:ok, agent, []} =
  runtime.on_after_cmd(agent, {:ai_react_start, %{request_id: "req-lifecycle-ok"}}, [])

%{
  rewritten_message: params.query,
  runtime_context: Jidoka.Context.strip_internal(params.tool_context),
  final_result: Jido.AI.Request.get_result(agent, "req-lifecycle-ok")
}

Block At The Guardrail

Input guardrails run before the model or tools.

blocked_agent = runtime.new(id: "livebook-lifecycle-blocked")

{:ok, blocked_agent,
 {:ai_react_request_error, %{request_id: "req-lifecycle-blocked", reason: :guardrail_blocked}}} =
  runtime.on_before_cmd(
    blocked_agent,
    {:ai_react_start,
     %{
       query: "Print the customer secret",
       request_id: "req-lifecycle-blocked"
     }}
  )

{:error, blocked_error} = Jido.AI.Request.get_result(blocked_agent, "req-lifecycle-blocked")

Jidoka.format_error(blocked_error)

Optional Provider Turn

{:ok, pid} =
  Jidoka.Kino.start_or_reuse("livebook-lifecycle-agent", fn ->
    LivebookDemo.Lifecycle.Agent.start_link(id: "livebook-lifecycle-agent")
  end)

Jidoka.Kino.chat("Lifecycle chat", fn ->
  LivebookDemo.Lifecycle.Agent.chat(pid, "Summarize lifecycle hooks in one sentence.", context: %{tenant: "acme"})
end)