Powered by AppSignal & Oban Pro

Jidoka Kitchen Sink

livebook/kitchen_sink.livemd

Jidoka Kitchen Sink

dep =
  if File.exists?(Path.expand("../mix.exs", __DIR__)) do
    {:jidoka, path: Path.expand("..", __DIR__)}
  else
    {:jidoka, "~> 1.0"}
  end

Mix.install([dep], consolidate_protocols: false)

Build The Pieces First

defmodule LivebookDemo.KitchenSink.LoadTicket do
  use Jidoka.Action,
    name: "load_ticket",
    description: "Loads a ticket from the host application.",
    schema: Zoi.object(%{id: Zoi.string()})

  @impl true
  def run(%{id: id}, context) do
    {:ok,
     %{
       id: id,
       account_id: Map.fetch!(context, :account_id),
       status: :open,
       priority: :high,
       subject: "Invoice failed after plan change"
     }}
  end
end

defmodule LivebookDemo.KitchenSink.ScoreRisk do
  use Jidoka.Action,
    name: "score_risk",
    description: "Scores ticket risk from priority and account context.",
    schema: Zoi.object(%{priority: Zoi.enum([:low, :normal, :high])})

  @impl true
  def run(%{priority: :high}, _context), do: {:ok, %{risk: :high, escalation_required: true}}
  def run(_params, _context), do: {:ok, %{risk: :normal, escalation_required: false}}
end

defmodule LivebookDemo.KitchenSink.FetchPolicy do
  use Jidoka.Action,
    name: "fetch_policy",
    description: "Returns the policy summary for a support topic.",
    schema: Zoi.object(%{topic: Zoi.string()})

  @impl true
  def run(%{topic: "billing"}, _context), do: {:ok, %{policy: "Billing changes need account-owner review."}}
  def run(%{topic: topic}, _context), do: {:ok, %{policy: "No special policy for #{topic}."}}
end

defmodule LivebookDemo.KitchenSink.TriageWorkflow do
  use Jidoka.Workflow

  workflow do
    id :kitchen_sink_triage
    description "Loads a ticket, scores risk, and fetches policy context."
    input Zoi.object(%{ticket_id: Zoi.string(), topic: Zoi.string()})
  end

  steps do
    action :load_ticket, LivebookDemo.KitchenSink.LoadTicket,
      input: %{id: input(:ticket_id)}

    action :score_risk, LivebookDemo.KitchenSink.ScoreRisk,
      input: %{priority: from(:load_ticket, :priority)}

    action :fetch_policy, LivebookDemo.KitchenSink.FetchPolicy,
      input: %{topic: input(:topic)}
  end

  output %{
    ticket: from(:load_ticket),
    risk: from(:score_risk),
    policy: from(:fetch_policy, :policy)
  }
end

Controls And Specialists

defmodule LivebookDemo.KitchenSink.BlockSecrets do
  use Jidoka.Control, name: "block_secrets"

  @impl true
  def call(%Jidoka.Guardrails.Input{message: message}) do
    if String.contains?(String.downcase(message), "api_key") do
      {:block, :raw_secret_in_prompt}
    else
      :cont
    end
  end
end

defmodule LivebookDemo.KitchenSink.RequireCredentialApproval do
  use Jidoka.Control, name: "require_credential_approval"

  @impl true
  def call(%Jidoka.Guardrails.Tool{operation_kind: :action, context: context, tool_name: tool_name}) do
    if Map.get(context, :credential_ref) do
      Jidoka.Approval.request("Approve authenticated operation.",
        data: %{tool: to_string(tool_name), account_id: context.account_id}
      )
    else
      :cont
    end
  end
end

defmodule LivebookDemo.KitchenSink.ReviewHighRiskResult do
  use Jidoka.Control, name: "review_high_risk_result"

  @impl true
  def call(%Jidoka.Guardrails.Output{outcome: {:ok, %{risk: :high} = result}}) do
    Jidoka.Approval.request("Review high-risk result.", data: result)
  end

  def call(%Jidoka.Guardrails.Output{}), do: :cont
end

defmodule LivebookDemo.KitchenSink.ResearchSpecialist do
  defmodule Runtime do
    use Jido.Agent,
      name: "kitchen_sink_research_runtime",
      schema: Zoi.object(%{})
  end

  def name, do: "research_specialist"
  def runtime_module, do: Runtime
  def start_link(opts \\ []), do: Jidoka.start_agent(Runtime, opts)

  def chat(_pid, message, opts \\ []) do
    context = Keyword.get(opts, :context, %{})
    {:ok, %{summary: "researched #{message}", tenant: Map.get(context, :tenant, "none")}}
  end
end

Compile The Agent

defmodule LivebookDemo.KitchenSink.SupportDeskAgent do
  use Jidoka.Agent

  @result_schema Zoi.object(%{
                   category: Zoi.enum([:billing, :technical, :account]),
                   risk: Zoi.enum([:normal, :high]),
                   next_step: Zoi.string(),
                   escalation_required: Zoi.boolean()
                 })

  agent :kitchen_sink_support_desk do
    model :fast
    instructions "Triage support tickets with deterministic tools before giving guidance."
    character :none

    context(
      Zoi.object(%{
        account_id: Zoi.string() |> Zoi.default("acct_demo"),
        actor_id: Zoi.string() |> Zoi.default("system"),
        tenant: Zoi.string() |> Zoi.default("demo"),
        session: Zoi.string() |> Zoi.optional(),
        credential_ref: Zoi.any() |> Zoi.optional()
      })
    )

    result @result_schema do
      repair 1
      on_validation_error :repair
    end

    schedule :daily_support_digest do
      cron "0 9 * * *"
      timezone "America/Chicago"
      prompt "Prepare the daily support digest."
      conversation "support-digest"
      overlap :skip
    end
  end

  tools do
    action LivebookDemo.KitchenSink.LoadTicket
  end

  capabilities do
    workflow LivebookDemo.KitchenSink.TriageWorkflow,
      as: :triage_ticket,
      result: :structured

    subagent LivebookDemo.KitchenSink.ResearchSpecialist,
      as: "research_specialist",
      description: "Ask the specialist for focused background."
  end

  lifecycle do
    memory do
      mode :conversation
      namespace {:context, :session}
      capture :conversation
      retrieve limit: 3
      inject :instructions
    end

    compaction do
      mode :manual
      strategy :summary
      max_messages 8
      keep_last 2
      max_summary_chars 500
      prompt "Compact this support conversation for future turns."
    end
  end

  controls do
    input LivebookDemo.KitchenSink.BlockSecrets

    operation LivebookDemo.KitchenSink.RequireCredentialApproval,
      when: [kind: :action]

    result LivebookDemo.KitchenSink.ReviewHighRiskResult
  end
end

alias LivebookDemo.KitchenSink.SupportDeskAgent

{:ok, definition} = Jidoka.inspect_agent(SupportDeskAgent)

%{
  id: SupportDeskAgent.id(),
  tools: SupportDeskAgent.tool_names(),
  workflows: Enum.map(SupportDeskAgent.workflows(), & &1.name),
  subagents: Enum.map(SupportDeskAgent.subagents(), & &1.name),
  schedules: Enum.map(SupportDeskAgent.schedules(), & &1.id),
  controls: Map.new(SupportDeskAgent.controls(), fn {stage, controls} -> {stage, length(controls)} end),
  memory: definition.memory != nil,
  compaction: definition.compaction != nil
}

Exercise Deterministic Pieces

context = %{
  account_id: "acct_123",
  actor_id: "user_123",
  tenant: "acme",
  session: "ticket-123"
}

{:ok, ticket} = LivebookDemo.KitchenSink.LoadTicket.run(%{id: "ticket-123"}, context)

{:ok, workflow_result} =
  LivebookDemo.KitchenSink.TriageWorkflow.run(%{ticket_id: "ticket-123", topic: "billing"},
    context: context
  )

{:ok, typed_result} =
  LivebookDemo.KitchenSink.SupportDeskAgent.result()
  |> Jidoka.Output.parse(
    ~s({"category":"billing","risk":"high","next_step":"Escalate to account owner.","escalation_required":true})
  )

%{
  ticket: ticket,
  workflow_result: workflow_result,
  typed_result: typed_result
}

Inspect A Provider-Free Turn

context = %{
  account_id: "acct_123",
  actor_id: "user_123",
  tenant: "acme",
  session: "ticket-123"
}

session =
  LivebookDemo.KitchenSink.SupportDeskAgent
  |> Jidoka.session("ticket-123", context: context)

stop_before_provider = fn input ->
  Jidoka.Approval.request("Pause before provider execution.", data: %{request: input.request_id})
end

try do
  {:interrupt, interrupt} =
    Jidoka.chat(session, "Triage ticket-123.",
      controls: [input: stop_before_provider]
    )

  {:ok, request} = Jidoka.inspect_request(session)
  {:ok, trace} = Jidoka.inspect_trace(session)
  {:ok, view} = Jidoka.Session.snapshot(session)

  %{
    interrupt: Map.take(interrupt, [:kind, :message, :data]),
    request_id: request.request_id,
    trace_events: Enum.map(trace.events, &{&1.category, &1.event}),
    view_kind: view.kind,
    view_agent_id: view.agent_id
  }
after
  if pid = Jidoka.Session.whereis(session), do: Jidoka.stop_agent(pid)
end

Schedule And Import Boundaries

context = %{
  account_id: "acct_123",
  actor_id: "user_123",
  tenant: "acme",
  session: "ticket-123"
}

schedule_id = "kitchen-sink-triage-#{System.unique_integer([:positive])}"

{:ok, _schedule} =
  Jidoka.schedule_workflow(LivebookDemo.KitchenSink.TriageWorkflow,
    id: schedule_id,
    cron: "0 9 * * *",
    input: %{ticket_id: "ticket-123", topic: "billing"},
    context: context,
    enabled?: false
  )

{:ok, scheduled_run} = Jidoka.run_schedule(schedule_id)
Jidoka.cancel_schedule(schedule_id)

import_spec = %{
  "agent" => %{"id" => "portable_support_agent"},
  "defaults" => %{"instructions" => "Use allowlisted support operations."},
  "capabilities" => %{
    "tools" => ["load_ticket"],
    "workflows" => [%{"workflow" => "kitchen_sink_triage", "as" => "triage_ticket"}],
    "subagents" => [%{"agent" => "research_specialist"}]
  }
}

{:ok, imported} =
  Jidoka.import_agent(import_spec,
    available_tools: [LivebookDemo.KitchenSink.LoadTicket],
    available_workflows: %{"kitchen_sink_triage" => LivebookDemo.KitchenSink.TriageWorkflow},
    available_subagents: %{"research_specialist" => LivebookDemo.KitchenSink.ResearchSpecialist}
  )

%{
  scheduled_result: scheduled_run.result,
  imported_tools: Enum.map(imported.tool_modules, & &1.name()),
  imported_workflows: Enum.map(imported.workflows, & &1.name),
  imported_subagents: Enum.map(imported.subagents, & &1.name)
}