Runic Spine Internals

Copy Markdown View Source

Jidoka owns the ReAct-style agent loop in its own Runic workflow rather than delegating it to Jido.AI.ReAct. This guide explains why the spine lives in Jidoka, how Jidoka.Workflow.Compiler and Jidoka.Workflow.Steps carve the turn into pure phases, and what contributors must preserve when adding new steps. It is written for people maintaining the Jidoka runtime, not for agent authors.

When To Use This

  • Use this guide when you are about to add, reorder, or rewrite a workflow step in Jidoka.Workflow.Steps or change how Jidoka.Workflow.Compiler wires steps together.
  • Use this guide before introducing any new "framework" that wraps the turn loop; the answer is usually to add a narrowly scoped workflow step, not to replace the spine.
  • Do not use this guide as a tutorial on writing agents. Authors should read Getting Started and Agent DSL.

Prerequisites

mix deps.get
mix test test/jidoka/workflow_test.exs

Quick Example

The smallest interesting view of the spine is the model-turn workflow itself. Reading it is the fastest way to see that the Runic graph stays tiny on purpose:

alias Jidoka.Turn
alias Jidoka.Workflow.Compiler
alias Runic.Workflow

spec = Jidoka.agent!(id: "spine_demo", model: %{provider: :test, id: "m"})
{:ok, plan} = Jidoka.plan(spec)

workflow = Compiler.model_turn_workflow(plan)

state =
  Turn.State.new!(
    spec: plan.spec,
    plan: plan,
    request: Turn.Request.new!(input: "hi"),
    agent_state: Jidoka.Agent.State.new!()
  )

workflow
|> Workflow.react_until_satisfied(state)
|> Workflow.raw_productions(:plan_model_effect)
|> List.last()
|> Map.fetch!(:pending_effects)
#=> [%Jidoka.Effect.Intent{kind: :llm, ...}]

The compiled Runic workflow has exactly two steps: :assemble_prompt -> :plan_model_effect. Both run as pure functions over Jidoka.Turn.State. Nothing in the workflow calls a provider, opens a socket, or touches a process.

Concepts

Three ideas explain why the spine looks the way it does.

  1. Jidoka owns the loop, not Jido.AI.ReAct. V1 leaned on Jido.AI.ReAct to drive turns. Jidoka deliberately reverses that by exposing a stable Turn.Plan contract and runs it with Runic so the loop is deterministic, inspectable, and free of provider-specific control flow.
  2. Functional core, effect shell. Jidoka.Workflow.Steps is pure. It returns the next Turn.State plus declared Effect.Intent values. The runtime shell (Jidoka.Runtime.TurnRunner and Jidoka.Runtime.EffectInterpreter) is the only place that performs IO.
  3. Spec is immutable, Plan is data, Harness is the boundary. Jidoka.Agent.Spec never changes after compilation. Jidoka.Turn.Plan is normalized executable data derived from a spec. Jidoka.Harness is the named seam where data meets capabilities.
╭─────────────────────╮
│   Jidoka.Harness    │  effect shell (IO allowed)
│  (runtime/turn_*)   │
╰──────────┬──────────╯
           │ injects Turn.State
           ▼
╭─────────────────────╮
│ Runic workflow:     │  functional core (no IO)
│  assemble_prompt    │
│  plan_model_effect  │
╰──────────┬──────────╯
           │ returns Turn.State + Effect.Intent
           ▼
╭─────────────────────╮
│ EffectInterpreter   │  effect shell records and dispatches
╰─────────────────────╯

The split exists so the same Runic graph can run under tests with injected capabilities, under live ReqLLM, under hibernation/resume, and under a Jido AgentServer without any branch in the workflow itself.

How To

Step 1: Read The Current Spine

Compiler.model_turn_workflow/1 is intentionally one screen of code:

def model_turn_workflow(%Turn.Plan{} = _plan) do
  assemble_prompt = Runic.step(&Steps.assemble_prompt/1, name: :assemble_prompt)
  plan_model_effect = Runic.step(&Steps.plan_model_effect/1, name: :plan_model_effect)

  Workflow.new(name: :jidoka_v2_model_turn)
  |> Workflow.add(assemble_prompt)
  |> Workflow.add(plan_model_effect, to: :assemble_prompt)
end

Read both step functions in Jidoka.Workflow.Steps before you change anything. The combination of Turn.Transition.new!/1 -> event/3 -> commit/1 is the only way new events should enter Turn.State.events.

Step 2: Add A New Pure Step

Pure steps must take a single Turn.State argument and return a Turn.State. They must not call providers, sockets, files, processes, or :os.system_time.

defmodule MyExt.Steps do
  alias Jidoka.Turn

  @spec annotate_prompt(Turn.State.t()) :: Turn.State.t()
  def annotate_prompt(%Turn.State{} = state) do
    state
    |> Turn.Transition.new!()
    |> Turn.Transition.event(:prompt_assembled,
      agent_id: state.spec.id,
      request_id: state.request.request_id,
      loop_index: state.loop_index,
      data: %{annotated_by: :my_ext}
    )
    |> Turn.Transition.commit()
  end
end

Use existing event names from Jidoka.Event whenever the semantics match. New event names should be added to Jidoka.Event so trace, replay, stream, and UI consumers share the same vocabulary.

Step 3: Compose The Step Through The Compiler

The compiler is the only allowed place to attach new pure steps to the spine. Wire the step downstream of an existing named step so the data flow is explicit:

Workflow.new(name: :jidoka_v2_model_turn)
|> Workflow.add(assemble_prompt)
|> Workflow.add(MyExt.Steps.annotate_prompt(), to: :assemble_prompt)
|> Workflow.add(plan_model_effect, to: :annotate_prompt)

Never reach into TurnRunner to run a step out-of-band. Steps belong to the workflow graph; the runner only consumes its productions.

Step 4: Declare A New Effect (Not An IO Call)

When a step needs an external action, it must declare an Effect.Intent and let the shell call the capability. The plan_model_effect step is the canonical pattern:

effect =
  Effect.Intent.new(:llm, payload,
    idempotency: :idempotent,
    idempotency_key: stable_key([state.spec.id, state.request.request_id,
                                 :llm, state.loop_index, state.prompt])
  )

%Turn.State{state | pending_effects: [effect]}

If you call a provider from inside a step, the rest of the runtime breaks: hibernation, replay, deterministic tests, and the effect journal all depend on intents being declared before any IO happens.

Step 5: Verify Step Ordering

Phase ordering is not a comment, it is a contract. Jidoka.Runtime.TurnRunner expects this order per loop iteration:

  1. Controls.run_input_controls/1 (once, at the start of the turn).
  2. The Runic workflow runs :assemble_prompt then :plan_model_effect.
  3. Optional checkpoint hibernate at Turn.Cursor.after_prompt/0.
  4. Operation controls evaluate the pending intent.
  5. EffectInterpreter.interpret_pending/3 invokes the capability.
  6. Turn.State.apply_effect_result/2 folds the result into state.
  7. Either loop again (status :running) or run output controls and finish.

If a new step changes ordering, it must be reflected in the runner and in the phase list on Turn.Plan (see plan.phases in Jidoka.Projection).

Common Patterns

  • Append events through Turn.Transition. Mutating state.events directly bypasses the seq numbering and the event defaults.
  • Use Jidoka.project/1 inside events. Event data: should carry projections, not raw structs. That keeps trace sinks and snapshots serializable.
  • Keep the prompt assembly deterministic. assemble_prompt/1 already encodes operations, memory, and result contract into a stable map. New contributions should be merged into that map rather than threading new fields through state.
  • Reuse stable_key/1 style hashing for idempotency. Idempotency keys should be derived from inputs, not generated with random ids.

Change Points

  • Workflow steps. New runtime phases belong in Jidoka.Workflow.Steps and must keep the same single-argument Turn.State -> Turn.State shape used by built-in steps.
  • Event vocabulary. New event names belong in Jidoka.Event so trace and replay consumers remain stable.
  • Spec changes. New agent definition fields belong in Jidoka.Agent.Spec and must stay serializable data.

Invariants

Contributors must preserve every rule below. They are the load-bearing assumptions the rest of the runtime relies on.

  1. Agent.Spec is immutable. Once produced from the DSL, an import, or Jidoka.agent!/1, the struct must not be patched in place. Build a new spec value when the definition changes.
  2. Turn.Plan is pure data. It contains no pids, sockets, functions, or credentials. Jidoka.Runtime.AgentSnapshot.serialize/1 enforces this property at the snapshot boundary; new plan fields must round-trip through :erlang.term_to_binary/1.
  3. Steps are pure. No process dictionary writes, no send/2, no :os.system_time. Time comes through the runner's :clock option.
  4. Steps never call capabilities. They only declare Effect.Intent values. Any IO from inside a step is a bug.
  5. The Runic workflow does not own checkpointing. Hibernation is the runner's responsibility (maybe_hibernate_after_prompt/3 and maybe_hibernate_before_effect/3). Steps must remain hibernate-agnostic.
  6. Turn.State.events only grows. Events are appended with monotonically increasing seq. No step should drop or reorder events.
  7. Phase ordering is stable. The phase list in Turn.Plan.phases is part of the public projection. Reordering steps requires updating phases and the runner together.

Testing

The two ways to test the spine without a provider are running the workflow directly and running the harness with injected capabilities.

test "spine produces an llm intent after one pass" do
  alias Jidoka.Turn
  alias Jidoka.Workflow.Compiler
  alias Runic.Workflow

  spec = Jidoka.agent!(id: "spine_test", model: %{provider: :test, id: "m"})
  {:ok, plan} = Jidoka.plan(spec)

  state =
    Turn.State.new!(
      spec: plan.spec,
      plan: plan,
      request: Turn.Request.new!(input: "hi"),
      agent_state: Jidoka.Agent.State.new!()
    )

  productions =
    Compiler.model_turn_workflow(plan)
    |> Workflow.react_until_satisfied(state)
    |> Workflow.raw_productions(:plan_model_effect)

  assert [%Turn.State{pending_effects: [intent]} | _] = Enum.reverse(productions)
  assert intent.kind == :llm
end

When a step adds new events, assert against Jidoka.Trace.timeline/1 rather than raw structs so the test stays stable across event metadata churn.

Troubleshooting

SymptomLikely CauseFix
Step output is dropped between iterationsStep returned something other than %Turn.State{}Always return Turn.Transition.commit/1 or the original state.
Snapshot serialization fails with :non_serializable_snapshot_valueA step stuffed a function, pid, or socket into stateMove the value into a runtime capability and reference it by id.
New step never runsNot wired through Workflow.add(step, to: :predecessor)Add the step in Jidoka.Workflow.Compiler.model_turn_workflow/1.
Events appear out of order in tracesDirect state.events mutationUse Turn.Transition.event/3 -> commit/1.
Tests pass locally but live LLM failsA pure step assumed provider behaviorMove the assumption into the capability or the ReqLLM adapter.

Reference