Runtime And Harness

Copy Markdown View Source

Jidoka separates authoring, executable data, and effect execution.

Jidoka.Agent DSL
-> Jidoka.Agent.Spec
-> Jidoka.Turn.Plan
-> Jidoka.Harness
-> Jidoka.Runtime.TurnRunner
-> Runic workflow steps
-> Effect interpreter
-> ReqLLM / Jido.Action

For process-hosted agents, Jido.AgentServer sits around that harness:

Jido.AgentServer
-> Jido.Signal "jidoka.turn.run"
-> Jidoka.Runtime.Actions.RunTurn
-> Jidoka.Harness
-> Jido agent state update

Harness

Jidoka.Harness is the execution boundary. It currently owns:

  • run_turn/3;
  • resume/2;
  • request normalization;
  • context schema validation;
  • runtime normalization;
  • approval response normalization;
  • delegation to Jidoka.Runtime.TurnRunner.

The harness is intentionally thin. Future session queues, stores, replay, approval flows, and eval fixtures belong here rather than in the root Jidoka module.

Sessions And Stores

Jidoka.Session is the ergonomic API for durable sessions. It delegates to Jidoka.Harness, and the underlying data struct is still Jidoka.Harness.Session.

Jidoka.Harness.Session is the durable harness envelope for work that spans requests or process restarts. It contains:

  • the canonical agent spec;
  • request history;
  • hibernated snapshots;
  • pending review requests;
  • the latest result or error;
  • metadata owned by the application/harness.

Sessions are still data. They do not contain runtime clients or processes.

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

{:ok, session} =
  Jidoka.session(spec, "support-session-1", store: store)

{:hibernate, session, snapshot} =
  Jidoka.Session.run(session.session_id, "Hello",
    store: store,
    llm: llm,
    checkpoint: :after_prompt
  )

{:ok, session, result} =
  Jidoka.Session.resume(session.session_id,
    store: store,
    llm: llm
  )

The store behaviour is intentionally small: put/get/list sessions. Pending review listing is derived from stored session data:

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

Replay is a projection over stored data, not a runtime call:

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

Observability And Evals

Core runtime events are neutral Jidoka.Event data. Jidoka.Trace projects them into a compact timeline, and callers decide whether to persist that timeline:

{:ok, sink} = Jidoka.Trace.Sink.InMemory.start_link()

:ok =
  Jidoka.Trace.record(result.events, {Jidoka.Trace.Sink.InMemory, pid: sink},
    policy:
      Jidoka.Trace.Policy.new!(
        sample_rate: 1.0,
        redact_keys: [:api_key, :authorization],
        omit_keys: [:messages, :prompt]
      )
  )

Jidoka.inspect/1 returns stable views for agents, turns, snapshots, sessions, replay, effect journals, review objects, memory results, and eval runs. These views are projection-oriented and avoid provider-specific client data.

Eval cases are deterministic harness fixtures:

{:ok, run} =
  Jidoka.Eval.run_case(
    [
      id: "support_lookup",
      agent: spec,
      input: "Check account acct_123",
      assertions: %{
        contains: "acct_123",
        operation_called: "lookup_account"
      }
    ],
    llm: llm,
    operations: operations
  )

The eval runner does not add another agent runtime. It uses Jidoka.Harness.run_turn/3, then records assertion results and observations on Jidoka.Eval.Run.

Eval input validation and eval execution failures are intentionally different:

  • invalid eval case data returns {:error, reason};
  • a harness runtime error returns {:ok, %Jidoka.Eval.Run{status: :error}};
  • a hibernated turn also returns {:ok, %Jidoka.Eval.Run{status: :error}} with %{reason: :hibernated, snapshot: ...} in run.error.

That keeps eval outcomes serializable as evidence while still rejecting invalid eval definitions before execution.

Memory

Memory is opt-in agent policy plus per-run store capability:

spec =
  Jidoka.agent!(
    id: "support_agent",
    instructions: "Use recalled memory when useful.",
    memory: %{scope: :session, max_entries: 5}
  )

{:ok, pid} = Jidoka.Memory.Store.InMemory.start_link()
memory_store = {Jidoka.Memory.Store.InMemory, pid: pid}

{:ok, _write} =
  Jidoka.Harness.write_memory(spec, "Ada prefers concise answers.",
    memory_store: memory_store
  )

Before prompt assembly, the harness recalls memory through the supplied store and passes a typed Jidoka.Memory.RecallResult into the Runic turn state. Prompt assembly then:

  • adds a memory_recalled trace event when entries are present;
  • adds a compact "Relevant memory" system message;
  • exposes prompt.memory for preflight, tests, and provider runtime code.

Jidoka.preflight/3 accepts the same memory_store: option, so memory contributions are visible without calling an LLM.

Operation Sources

Jidoka keeps one runtime operation path. Different executable surfaces should compile into Agent.Spec.Operation plus a capability function:

source =
  Jidoka.Operation.Source.Local.new!(
    operations: [
      %{
        name: "lookup_ticket",
        description: "Looks up a ticket.",
        kind: :tool,
        handler: fn args -> %{ticket_id: args["ticket_id"], status: "open"} end
      }
    ]
  )

{:ok, compiled} = Jidoka.Operation.Source.compile(source)

spec =
  Jidoka.agent!(
    id: "support_agent",
    instructions: "Use lookup_ticket when needed.",
    operations: compiled.operations
  )

Jidoka.turn(spec, "Check ticket T-100",
  llm: llm,
  operations: compiled.capability
)

Controls still match by operation kind and name. The local source above uses kind :tool; Jido action sources use kind :action. Both execute through the same Effect.Intent / Effect.Result journal path.

Turn Runner

Jidoka.Runtime.TurnRunner owns the loop:

  1. run input controls;
  2. run the Runic prompt/effect planning workflow;
  3. optionally hibernate at a safe checkpoint;
  4. interpret pending effects through runtime capabilities;
  5. apply effect results to turn state;
  6. validate and optionally repair structured final results;
  7. loop until final answer or max model turns;
  8. run output controls before returning.

Operation controls run inside the effect interpreter immediately before an operation capability is called. If a control returns {:interrupt, reason}, the runner marks the turn state as :waiting and hibernates at a review cursor instead of calling the operation.

Effects

External work is represented as data:

%Jidoka.Effect.Intent{
  kind: :llm | :operation,
  payload: %{},
  idempotency_key: "...",
  idempotency: :idempotent
}

The effect interpreter records intents and results in Effect.Journal. On resume, existing results are reused instead of re-running the same effect.

Operation Idempotency

Every operation declares one idempotency policy:

  • :pure means the operation can be recomputed from input;
  • :idempotent means the runtime can safely retry with the same key;
  • :dedupe means Jidoka should prefer a recorded journal result;
  • :reconcile means incomplete work should be surfaced for application reconciliation;
  • :unsafe_once means Jidoka must not retry automatically.

:unsafe_once operations require an explicit operation control. The control can allow, block, or interrupt for human review, but it must be present before the spec can be compiled into a Turn.Plan. This makes risky work visible at preflight time instead of discovering it after a model chooses the operation.

If a journal already has a result for an operation effect, resume replays that result and does not call the operation capability again. If an :unsafe_once intent was recorded without a result, resume returns a typed execution error instead of retrying the operation. Later harness/session storage can use that same shape to route the case to a reconciliation queue.

Durability

Jidoka snapshots semantic state:

{:hibernate, snapshot} =
  Jidoka.turn(spec, "Hello",
    llm: llm,
    checkpoint: :after_prompt
  )

{:ok, result} = Jidoka.resume(snapshot, llm: llm)

Current checkpoint policies:

  • :none
  • :after_prompt
  • :after_each_phase
  • :before_each_effect

This is safe-boundary durability, not arbitrary process resurrection.

Versioned durability boundaries:

  • Jidoka.Runtime.AgentSnapshot.schema_version() == 1;
  • serialized snapshots use the opaque prefix jidoka:snapshot:v1:;
  • Jidoka.Harness.Session.schema_version() == 1;
  • import documents use Jidoka.Import.AgentDocument.version() == 1.

Unsupported versions fail during normalization instead of attempting a partial resume/import.

Human-In-The-Loop Review

An operation control can pause execution:

def call(%Jidoka.Runtime.Controls.OperationContext{} = operation) do
  if operation.operation == "refund_order" do
    {:interrupt, :approval_required}
  else
    :cont
  end
end

The returned snapshot has:

Resume with an approval response:

approval = Jidoka.Review.Response.approve(snapshot.turn_state.pending_interrupt)
{:ok, result} = Jidoka.resume(snapshot, approval: approval, llm: llm, operations: operations)

Resume with a denial:

denial = Jidoka.Review.Response.deny(snapshot.turn_state.pending_interrupt, reason: :rejected)
{:error, error} = Jidoka.resume(snapshot, approval: denial, llm: llm, operations: operations)

The approved operation resumes from the pending Effect.Intent; Jidoka does not re-run operation controls for that approved interrupt. The journal still prevents duplicate effect results on normal hibernate/resume boundaries.

Structured Results

If Agent.Spec.result is present, a final model decision must include a structured result value in addition to user-facing content:

%{
  type: :final,
  content: "Ada is ready.",
  result: %{name: "Ada", confidence: 10}
}

The runtime validates the value with the configured Zoi schema before marking the turn finished. Validated data is stored on Turn.State.result_value and returned as Turn.Result.value. Output controls run after validation, so their context receives both result text and result_value data.

If a model omits the explicit result field but returns JSON as content, Jidoka attempts to validate that decoded JSON as the structured result. Plain text content is still preserved for unstructured agents.

If validation fails and max_repairs has not been exhausted, Jidoka appends a repair instruction to the durable agent state and runs another model turn. This uses the same Runic/effect loop; it is not a provider-specific structured output API.

Jido Relationship

Jidoka uses Jido as the foundation:

Jidoka does not delegate the core loop to Jido.AI.ReAct. The ReAct-style loop is implemented through Jidoka's Runic/effect/harness spine.