Powered by AppSignal & Oban Pro

Interactive sessions and managed processes

03_sessions_and_processes.livemd

Interactive sessions and managed processes

What you will learn

This notebook covers the two stateful lifecycle APIs: multi-turn provider sessions and direct local processes. Sessions use official provider headless protocols; managed processes use a structured executable plus argv without shell interpolation.

By the end you will have:

  • opened a harness-owned provider session;
  • completed and queued normalized turns;
  • replayed the session lifecycle;
  • started a local process without a shell command;
  • consumed normalized stdout, stderr, and exit events.

Read the interactive-session guide and managed-process guide for the corresponding API contracts.

The session section invokes a real provider and may consume API or subscription usage. The managed-process section is entirely local.

Install the local package

Livebook Desktop may not inherit the PATH from your interactive shell. Add the common CLI installation directories before resolving a provider. Extend extra_cli_paths when needed.

extra_cli_paths = [
  Path.join(System.user_home!(), ".local/bin"),
  Path.join(System.user_home!(), ".local/share/mise/shims"),
  Path.join(System.user_home!(), ".bun/bin"),
  "/opt/homebrew/bin",
  "/usr/local/bin"
]

path =
  (extra_cli_paths ++ String.split(System.get_env("PATH", ""), ":", trim: true))
  |> Enum.uniq()
  |> Enum.join(":")

System.put_env("PATH", path)

Mix.install([
  {:jido_harness, path: Path.expand("..", __DIR__)}
])

defmodule JidoHarnessLivebook do
  def completed!({:ok, %{status: :completed} = result}), do: result

  def completed!({:ok, result}) do
    raise result.error || "provider turn finished with status #{inspect(result.status)}"
  end

  def completed!({:error, %_{} = error}), do: raise(error)
  def completed!({:error, error}), do: raise("provider turn failed: #{inspect(error)}")
end

Start a multi-turn session

Choose a provider whose status reports at least one session transport.

provider = :codex
cwd = Path.expand("..", __DIR__)

{:ok, status} = Jido.Harness.status(provider)
ready? = Jido.Harness.ProviderStatus.ready?(status)

unless ready? do
  raise """
  #{provider} is not ready in this Livebook runtime.
  executable: #{inspect(status.executable)}
  error: #{inspect(status.error)}
  PATH: #{System.get_env("PATH")}
  """
end

%{
  provider: provider,
  ready?: ready?,
  session_transports: Enum.map(status.session_transports, &{&1.name, &1.capabilities})
}
{:ok, session_id} =
  Jido.Harness.Session.start(provider, %{
    cwd: cwd,
    turn_runtime_timeout_ms: 300_000,
    session_idle_timeout_ms: 600_000,
    metadata: %{source: "sessions-livebook"}
  })

{:ok, session_info} = Jido.Harness.Session.info(session_id)
session_info

Send and queue turns

send_message/3 requires an idle session. follow_up/3 queues a turn in FIFO order, so it is safe to submit the second request before awaiting the first.

{:ok, first_turn_id} =
  Jido.Harness.Session.send_message(
    session_id,
    "Reply with exactly: first-turn-ready"
  )

{:ok, second_turn_id} =
  Jido.Harness.Session.follow_up(
    session_id,
    "Reply with exactly: second-turn-ready"
  )

%{first_turn_id: first_turn_id, queued_turn_id: second_turn_id}
first_turn =
  session_id
  |> Jido.Harness.Session.await(first_turn_id, 300_000)
  |> JidoHarnessLivebook.completed!()

second_turn =
  session_id
  |> Jido.Harness.Session.await(second_turn_id, 300_000)
  |> JidoHarnessLivebook.completed!()

%{
  first: %{status: first_turn.status, text: first_turn.text},
  second: %{status: second_turn.status, text: second_turn.text},
  provider_session_id: second_turn.provider_session_id
}

Session events include both lifecycle and turn boundaries. Closing is graceful; kill/1 is available for forced cancellation.

:ok = Jido.Harness.Session.close(session_id)
{:ok, session_events} = Jido.Harness.Session.replay(session_id, cursor: 0, limit: 100)

%{
  event_types: Enum.map(session_events, & &1.type),
  final_info: Jido.Harness.Session.info(session_id)
}

Run a local structured process

This example starts the current Elixir executable directly. The code string is one argv value; no shell parses or interpolates it.

elixir = System.find_executable("elixir") || raise "elixir executable not found"

{:ok, process_id} =
  Jido.Harness.Process.start(%{
    executable: elixir,
    argv: ["-e", ~S|IO.puts("stdout from a managed process"); IO.puts(:stderr, "stderr too")|],
    cwd: cwd,
    stdin: false,
    runtime_timeout_ms: 30_000,
    metadata: %{source: "processes-livebook"}
  })

{:ok, process_info} = Jido.Harness.Process.await(process_id, 30_000)
{:ok, process_events} = Jido.Harness.Process.replay(process_id, cursor: 0, limit: 100)

%{
  process_id: process_id,
  state: process_info.state,
  exit_status: process_info.exit_status,
  events: Enum.map(process_events, &%{sequence: &1.sequence, type: &1.type, data: &1.data})
}
Jido.Harness.Process.prune(process_id)
Jido.Harness.Session.prune(session_id)