Turn And Effect Contracts

Copy Markdown View Source

A Jidoka turn is a pure data pipeline. The runtime compiles a Jidoka.Agent.Spec into a Jidoka.Turn.Plan, threads a Jidoka.Turn.State through Runic, and mediates external work through Jidoka.Effect.Intent/Jidoka.Effect.Result pairs. This guide documents every data contract on that path so that custom harnesses, traces, and storage layers can interoperate with the runtime without guessing.

When To Use This

  • Use this guide when you are building a custom harness, sidecar, or trace exporter and need the on-the-wire shape of turn state and effects.
  • Use this guide when reading a snapshot or journal in tests and needing to decode each field.
  • Do not use this guide as a runtime walkthrough. See Runtime And Harness for the execution model.

Prerequisites

Quick Example

A turn round-trip produces every contract this guide describes.

alias Jidoka.{Agent, Turn, Effect}

spec = MyApp.TimeAgent.spec()
{:ok, plan} = Turn.Plan.new(spec)
{:ok, request} = Turn.Request.from_input("What time is it in Chicago?")

llm = fn _intent, _journal ->
  {:ok, %{type: :final, content: "Chicago time is 09:30."}}
end

{:ok, result} = MyApp.TimeAgent.run_turn(request.input, llm: llm)

result.content              #=> "Chicago time is 09:30."
result.journal              #=> %Jidoka.Effect.Journal{intents: %{...}, results: %{...}}
hd(result.events).type      #=> :turn_started (or similar)

plan, request, the in-flight Turn.State, the Turn.Result, and the Effect.Journal are all addressable, inspectable values.

Concepts

╭──────────────╮     ╭───────────────╮     ╭──────────────╮
│  Agent.Spec  │────▶│   Turn.Plan   │────▶│  Turn.State  │
╰──────────────╯     ╰───────────────╯     ╰──────┬───────╯
                                                  │ pending_effects
                                                  ▼
                                          ╭───────────────╮
                                          │ Effect.Intent │
                                          ╰──────┬────────╯
                                                 │ capability
                                                 ▼
                                          ╭───────────────╮
                                          │ Effect.Result │
                                          ╰──────┬────────╯
                                                 │ journal
                                                 ▼
                                          ╭───────────────╮
                                          │ Turn.Result   │
                                          ╰───────────────╯

Three rules anchor the model:

  1. The plan is derived from the spec; the state is derived from the plan plus a request.
  2. The harness only ever produces effects through Effect.Intent and consumes them through Effect.Result. The journal records both.
  3. The final Turn.Result is projected from the terminal Turn.State.

Fields

Jidoka.Turn.Plan

Compiled execution defaults for one turn.

FieldTypeDefaultPurpose
specAgent.Spec.t()requiredThe immutable spec the plan was compiled from.
workflow_profile:chat | :tool_loop | :structured_result | :controlled_tool_loop:tool_loopSelects the Runic profile.
max_model_turnspositive integerspec.controls.max_turns or Jidoka.Config.default_max_model_turns/0Upper bound on model rounds.
timeout_mspositive integerspec.controls.timeout_ms or Jidoka.Config.default_turn_timeout_ms/0Hard wall-clock limit.
phases[atom()]full phase listRunic phase order for the turn.
metadatamap%{}Plan-level metadata.

Built by Jidoka.Turn.Plan.new/1 which also runs Spec.validate_operation_policies/1 before returning.

Jidoka.Turn.Request

Input envelope for one turn.

FieldTypeDefaultPurpose
inputnon-empty stringrequiredUser-facing input passed to prompt assembly.
request_idnon-empty stringgenerated "turn_…"Stable id used by snapshots and logs.
agent_stateAgent.State.t()empty agent stateCarries history across turns.
contextmap%{}Per-turn context map (validated against spec.context_schema).
metadatamap%{}Caller metadata.

Turn.Request.from_input/2 accepts a string, map, or keyword list and fills in defaults.

Jidoka.Turn.State

Ephemeral value threaded through the workflow.

FieldTypePurpose
spec / plan / requestspec, plan, request structsInputs to the loop.
agent_stateAgent.State.t()Mutable accumulator (messages, operation results).
memoryMemory.RecallResult.t() | nilMost recent recall.
promptprovider-neutral prompt or nilMaterialized prompt after assembly.
llm_resultEffect.LLMDecision.t() | nilLast decoded LLM decision.
operation_planEffect.OperationRequest.t() | nilPending operation request.
pending_effects[Effect.Intent.t()]Effects awaiting interpretation.
pending_interruptReview.Interrupt.t() | nilReview boundary, if any.
result / result_valuestring / termFinal assistant content and validated structured value.
result_repair_countnon-negative integerRepair attempts so far.
status:running | :waiting | :finishedLoop state.
loop_indexnon-negative integerCurrent model round.
started_at_msinteger or nilWall-clock start.
journalEffect.Journal.t()Recorded intents and results.
events[Jidoka.Event.t()]Append-only event log.
diagnosticslistAppend-only diagnostic blobs.

Mutations go through Jidoka.Turn.Transition.

Jidoka.Turn.Transition

A pure transition value: new state plus pending events and diagnostics.

FieldTypePurpose
statemapThe next state.
events[Jidoka.Event.t()]Events to append on commit.
diagnosticslistDiagnostics to append on commit.

Transition.event/3 builds a Jidoka.Event with stable sequence ordering. Transition.commit/1 folds events and diagnostics back into the state.

Jidoka.Turn.Cursor

A pointer to the next safe resume boundary.

FieldTypeDefaultPurpose
phase:start | :after_prompt | :before_effect | :review | :wait:startLogical phase boundary.
loop_indexnon-negative integer0Loop round at hibernation time.
metadatamap%{}Boundary metadata (e.g. effect_id, interrupt_id).

Constructors after_prompt/0, before_effect/1, review/1 produce the common cursor shapes.

Jidoka.Turn.Result

Final app-facing value.

FieldTypePurpose
contentstringFinal assistant text.
valueterm or nilValidated structured value when spec.result is set.
agent_stateAgent.State.t()Conversation state after the turn.
journalEffect.Journal.t()Effects observed during the turn.
events[Jidoka.Event.t()]Ordered event log.
usagemapAggregated LLM token and cost usage for the turn.
metadatamapCaller metadata.

Produced by Jidoka.Turn.Result.from_turn_state!/1 once status reaches :finished.

When the LLM capability is backed by ReqLLM, usage contains normalized token and cost fields when the provider returns them:

result.usage
#=> %{
#=>   llm_calls: 2,
#=>   input_tokens: 800,
#=>   output_tokens: 240,
#=>   total_tokens: 1040,
#=>   reasoning_tokens: 0,
#=>   total_cost: 0.00048
#=> }

Per-call usage remains available in the journal:

result.journal.results[effect_id].metadata.usage

Jidoka.Effect.Intent

Data description of an external effect.

FieldTypePurpose
idnon-empty stringStable id ("<kind>:<idempotency_key>").
kind:llm | :operationWhat the capability must do.
payloadmapPayload (normalized to an Effect.OperationRequest for :operation).
idempotency_keynon-empty stringStable key (sha256 of {kind, payload} by default).
idempotency:pure | :idempotent | :dedupe | :reconcile | :unsafe_onceReplay safety class.
metadatamapCaller metadata.

Build with Effect.Intent.new/3 (kind + payload + opts) or Intent.new/1 (full map).

Jidoka.Effect.Result

Normalized result of one interpreted effect.

FieldTypePurpose
intent_idnon-empty stringThe intent this result answers.
kind:llm | :operationMirrors the intent.
status:ok | :errorInterpreter outcome.
outputtermDecoded payload (LLM decision map for :llm; raw operation output for :operation).
metadatamapCapability metadata.

Effect.Result.ok/2, Effect.Result.ok/3, Effect.Result.error/2, and Effect.Result.error/3 are the convenience constructors. The third argument accepts metadata: for capability-owned metadata such as LLM usage.

Jidoka.Effect.Journal

Replay log keyed by intent id.

FieldTypePurpose
intents%{String.t() => Effect.Intent.t()}Recorded intents.
results%{String.t() => Effect.Result.t()}Recorded results.

Use Journal.put_intent/2 and Journal.put_result/2 to extend. Use Journal.result_for/2 to ask "has this intent already been satisfied?" - the basis of replay safety.

Jidoka.Effect.OperationRequest And Jidoka.Effect.OperationResult

Typed payload/observation pair for operation effects.

OperationRequest fieldTypePurpose
namenon-empty stringOperation name from Spec.Operation.
argumentsmapArguments decoded from the LLM decision.
request_idstring or nilSource turn request id.
loop_indexnon-negative integerLoop round at planning time.
metadatamapCaller metadata.
OperationResult fieldTypePurpose
operationnon-empty stringOperation name.
argumentsmapArguments used.
outputtermRaw observation.
contentstring or nilPre-rendered message content.
request_idstring or nilTurn request id.
loop_indexnon-negative integerLoop round.
effect_idstring or nilOriginating Effect.Intent.id.
metadatamapCaller metadata.

OperationResult.from_effect/2 is the canonical bridge from an Intent + capability output to a durable observation.

Jidoka.Effect.LLMDecision

Constrained JSON decision protocol returned by every LLM capability.

FieldTypePurpose
type:final | :operationBranch of the decision protocol.
contentstring or nilRequired for :final. Optional metadata text for :operation.
resultterm or nilStructured result for :final when spec.result is set.
namenon-empty string or nilRequired for :operation.
argumentsmapOperation arguments. Required for :operation.
metadatamapProvider metadata.

LLMDecision.final/2 and LLMDecision.operation/3 are the two builder helpers. Capabilities may return either an LLMDecision struct or a map that LLMDecision.from_input/1 accepts.

Common Patterns

  • Treat Effect.Intent.id as the only identity that matters. The journal, cursor metadata, and Effect.Result.intent_id all key off it.
  • Decide once, observe once. An LLM decision returns one Intent; that intent's result is the only payload the runtime trusts.
  • Use the cursor for resume boundaries, not the state. A cursor is small, serializable, and stable across versions; the state can carry rich data.
  • Prefer LLMDecision structs in fake LLMs. Returning a map works (the runtime calls LLMDecision.from_input/1), but a struct catches typos sooner.

Testing

A deterministic test asserts on the journal, not on the prompt.

test "operation effect is recorded once" do
  llm = fn _intent, journal ->
    case map_size(journal.results) do
      0 -> {:ok, Jidoka.Effect.LLMDecision.operation("local_time", %{"city" => "Chicago"})}
      _ -> {:ok, Jidoka.Effect.LLMDecision.final("Chicago time is 09:30.")}
    end
  end

  {:ok, result} = MyApp.TimeAgent.run_turn("What time is it in Chicago?", llm: llm)

  operation_results =
    result.journal.results
    |> Map.values()
    |> Enum.filter(&(&1.kind == :operation))

  assert length(operation_results) == 1
end

Troubleshooting

SymptomLikely CauseFix
{:error, {:effect_result_mismatch, _, _}}A capability returned a result whose intent_id does not match the current pending intent.Ensure capabilities pass through the Intent they were given and only call Effect.Result.ok/error against it.
{:error, {:invalid_llm_decision_type, _}}LLM output is missing or has a non-:final/:operation type.Tighten the prompt or use the deterministic LLM path.
{:error, {:unknown_operation, name}}Decision named an operation that is not in spec.operations.Add the operation or change the model's allowed tools.
Turn.State.status stays :waiting foreverA pending_interrupt was not resolved.Resume the snapshot through the review API before continuing.
Turn.Result.events is emptyThe state was committed without any Transition.event/3 calls.Use Turn.Transition instead of mutating state directly.

Reference