Sessions And Stores

Copy Markdown View Source

A Jidoka session keeps an agent conversation alive across turns. It stores request history, snapshots, pending reviews, and the latest result. It does not own provider clients or long-running processes.

Use This When

  • Use a session when the same agent answers more than one user message.
  • Use a session when a turn must resume after a process restart.
  • Use a session when a human-in-the-loop interrupt must be picked up later.
  • Use a single Jidoka.turn/3 or Jidoka.chat/3 call when the work is one-shot and the caller does not need to remember anything between turns.
  • Use a store when sessions must survive a node restart or be shared across workers; keep the in-memory store for tests and local exploration.

Prerequisites

mix deps.get
mix test

Start A Session

The smallest durable session is a store plus a session id.

{:ok, pid} = Jidoka.Harness.Store.InMemory.start_link()
store = {Jidoka.Harness.Store.InMemory, pid: pid}

{:ok, session} =
  Jidoka.Session.start(MyApp.SupportAgent, "support-123", store: store)

{:ok, session, text} =
  Jidoka.Session.chat(session.session_id, "Say hi to Ada.", store: store)

That call ran through the same runtime as Jidoka.turn/3, then persisted the updated session under id "support-123". A second Jidoka.Session.chat/3 against the same id continues the conversation.

Concepts

A session is data. Apps usually call Jidoka.Session; stores persist the session data between turns.

╭──────────────────────╮
│   Jidoka.Session     │
│ start / run / chat   │
│ resume / replay      │
╰──────────┬───────────╯
           │ reads and writes
           ▼
╭──────────────────────────╮
│ Durable session data     │
│ spec / requests          │
│ snapshots / result       │
│ pending_reviews          │
╰──────────┬───────────────╯
           │ persists through
           ▼
╭──────────────────────────╮
│ Store                    │
│ put / get / list / claim │
╰──────────────────────────╯
  • Jidoka.Session is the developer-facing facade. It wraps start/run/chat/resume and derives sensible defaults.
  • Jidoka.Harness.Session is the durable data struct. Its schema_version/0 is 1; older or newer payloads fail at normalization rather than silently loading a half-valid session.
  • Jidoka.Harness.Store is the persistence behaviour: put_session/2, get_session/2, list_sessions/1, and optional claim_session/3 for atomic single-runner semantics.

A session status is one of :new, :running, :hibernated, :waiting, :finished, or :error. Jidoka computes it from snapshots, pending reviews, and the latest result.

How To

Step 1: Start A Session

Jidoka.Session.start/2 accepts a DSL module, a Jidoka.Agent.Spec, or a keyword list of spec attributes. Pass store: to persist immediately.

{:ok, session} =
  Jidoka.Session.start(MyApp.SupportAgent,
    session_id: "support-123",
    store: store,
    metadata: %{tenant: "acme"}
  )

session.session_id
#=> "support-123"
session.status
#=> :new
session.metadata
#=> %{tenant: "acme"}

If no session id is supplied, Jidoka generates one through Jidoka.Id.generate/2. Passing session_id: is preferred for any flow that needs a persistent external handle (a chat thread id, a ticket id, a workflow id).

Step 2: Run Turns

Jidoka.Session.run/3 is the full-result API. It returns the underlying Jidoka.Turn.Result, a hibernation snapshot, or an error, along with the updated session struct so callers without a store still have durable state.

{:ok, session, %Jidoka.Turn.Result{} = result} =
  Jidoka.Session.run(session.session_id, "Look up order A1001",
    store: store
  )

result.content
result.events
result.value

Jidoka.Session.chat/3 is the text-only API. It is the right default for product code.

{:ok, session, text} =
  Jidoka.Session.chat(session.session_id, "And what is its status?",
    store: store
  )

Both functions accept either a session struct or a session id. With a store the id is enough; without a store, hold onto the returned struct.

Step 3: Hibernate And Resume

Pass a checkpoint policy when you want the turn to pause at a safe boundary:

{:hibernate, session, snapshot} =
  Jidoka.Session.chat(session.session_id, "Refund order A1001",
    store: store,
    checkpoint: :after_prompt
  )

session.status
#=> :hibernated

Resume picks up the latest snapshot recorded on the session:

{:ok, session, %Jidoka.Turn.Result{}} =
  Jidoka.Session.resume(session.session_id,
    store: store
  )

See Snapshots And Resume for the full snapshot lifecycle and serialization format.

Step 4: List Pending Reviews

Pending review requests are derived from snapshot metadata when an operation control returns {:interrupt, reason}. They can be listed per session or across an entire store:

{:ok, [%Jidoka.Review.Request{} = request]} =
  Jidoka.Session.pending_reviews(session)

{:ok, all_pending} = Jidoka.Session.pending_reviews(store)

The store-level helper iterates list_sessions/1 and flattens session.pending_reviews, so it works the same for any compliant backend. For the durable approval flow itself, see Human In The Loop.

Step 5: Implement A Custom Store

A store is a module implementing Jidoka.Harness.Store. The required callbacks are small.

defmodule MyApp.PostgresSessionStore do
  @behaviour Jidoka.Harness.Store

  alias Jidoka.Harness.Session

  @impl true
  def put_session(%Session{} = session, _opts) do
    MyApp.Repo.upsert_session(session)
    {:ok, session}
  end

  @impl true
  def get_session(session_id, _opts) when is_binary(session_id) do
    case MyApp.Repo.fetch_session(session_id) do
      nil -> {:error, {:session_not_found, session_id}}
      session -> {:ok, session}
    end
  end

  @impl true
  def list_sessions(_opts) do
    {:ok, MyApp.Repo.all_sessions()}
  end
end

claim_session/3 is optional. Implement it when the backend has a native atomic compare-and-set; otherwise the default fallback uses get_session/2 followed by put_session/2 after rejecting any session already in :running state.

Callers reference a store as either Module or {Module, opts}. The in-memory store is {Jidoka.Harness.Store.InMemory, pid: pid} so the same shape works for stores that need configuration (database, namespace, region).

Step 6: Inspect Sessions

Replay is a data-only projection over what a session already knows. It does not call any capability and is safe to run anywhere.

{:ok, replay} = Jidoka.Session.replay(session)
replay.timeline
replay.journal
replay.pending_reviews

For human-readable inspection of a session, snapshot, or request, use Jidoka.inspect/1. For trace projection see Tracing And Events.

Common Patterns

  • Session per external identifier. Use the same id the surrounding product uses (chat thread, ticket, workflow) instead of generating a fresh one. This keeps lookups idempotent.
  • Pass the store on every call. The store reference is just data, and passing it makes the call self-contained. Avoid hiding it behind global state.
  • Prefer chat/3 for product code. Reach for run/3 when you need the full result, the journal, or to observe a hibernation snapshot.
  • Keep capabilities out of session metadata. Provider clients, pids, and credentials belong in the runtime options for each call, not on the serializable session.
  • Use claim_session/3 in multi-worker deployments. It is the difference between two workers racing on the same turn and one worker observing {:error, {:session_already_running, _}} and backing off.

Testing

Sessions are easy to test because every capability is injectable. A deterministic LLM and the in-memory store are usually enough.

test "session keeps history across turns" do
  {:ok, pid} = Jidoka.Harness.Store.InMemory.start_link()
  store = {Jidoka.Harness.Store.InMemory, pid: pid}

  llm = fn _intent, journal ->
    case map_size(journal.results) do
      0 -> {:ok, %{type: :final, content: "first"}}
      _ -> {:ok, %{type: :final, content: "second"}}
    end
  end

  {:ok, session} = Jidoka.Session.start(MyApp.SupportAgent, "s1", store: store)
  {:ok, _session, "first"} = Jidoka.Session.chat("s1", "hi", store: store, llm: llm)
  {:ok, session, "second"} = Jidoka.Session.chat("s1", "again", store: store, llm: llm)

  assert length(session.requests) == 2
  assert session.status == :finished
end

For multi-worker safety, write a test that calls Jidoka.Session.run/3 twice concurrently against the same id and assert one call returns {:error, {:session_already_running, _}}.

Troubleshooting

SymptomLikely CauseFix
{:error, :missing_harness_store}A session id was passed without a store: option.Pass the store on every call, or hold the session struct and pass it directly.
{:error, {:session_not_found, id}}The id was never started against this store.Call Jidoka.Session.start/3 with session_id: id, store: store.
{:error, {:session_already_running, id}}Two callers tried to run the same session at the same time.Serialize callers; if this is expected, retry after the prior call returns.
{:error, {:missing_session_snapshot, id}}Resume was called on a session that never hibernated.Run a new turn instead, or hibernate explicitly with a checkpoint policy.
{:error, {:conflicting_session_ids, _, _}}Both :id and :session_id were passed with different values.Pass only :session_id, or make them equal.
{:error, {:unsupported_session_schema_version, _, 1}}A persisted payload predates the current schema.Migrate the row to schema version 1 or discard it.

Reference

Key modules touched in this guide: