Redeo Docs
DocsLukiScript / Recursion

Specification

Recursion

A step with a recursion block can spawn a child execution of the same stilt from its own output, enabling recursive refinement.

Recursion

A step with a recursion block can spawn a child execution of the same stilt from its own output, enabling recursive refinement.

How Recursion Works

A step with a recursion block can spawn a child execution of the same stilt. The step runs normally first — fields resolve, the prompt is assembled, the LLM call is made, and the output is stored. Then the recursion check fires. If the current depth hasn't reached maxDepth, a child instance is created. The child runs the same stilt config from step 0, with the step's output replacing input.context. The parent stilt pauses at this step and waits for the child to finish.

The child runs through every step from top to bottom. If the child also reaches the recursion step, it produces its own output, and the same check fires — spawn another child if depth allows. This nesting continues until a level hits maxDepth.

At maxDepth, the recursion step's output is used directly — no child is spawned. That output flows into any remaining steps after it, and the exit step produces the final output for that deepest instance.

That deepest instance's exit output then bubbles back up. The level above receives it, and it replaces the recursion step's stored output. Any steps after the recursion step in that level now see the child's refined output instead of the raw step output. That level's exit output bubbles up again.

This continues until the original parent receives the final refined output. The parent's recursion step output is replaced, any downstream steps run with the refined output, and the parent's exit step produces the final answer returned to the caller.

maxDepth 2 — Step by Step

Consider a stilt with three steps where refine has maxDepth: 2:

yaml
steps:
  - id: analyze
    name: Analyze
    type: normal
    fields:
      - name: Context
        type: text
        from: input.context
    systemPrompt: "Identify key themes and weaknesses."

  - id: refine
    name: Refine
    type: normal
    recursion:
      maxDepth: 2
    fields:
      - name: Context
        type: text
        from: input.context
    systemPrompt: "Improve depth and coherence."

  - id: polish
    name: Polish
    type: normal
    fields:
      - name: Refined
        type: ingest
        from:
          stepId: refine
          loopRef: current
    systemPrompt: "Final polish for clarity and tone."

exit: polish

Level 0 (original call, depth=0): The user's question arrives as input.context. The analyze step reads it and identifies themes. The refine step reads the same input, produces a draft — labeled R0. Recursion check: depth 0 < maxDepth 2, so a child is spawned with input.context = R0. The parent pauses here.

Level 1 (first child, depth=1): The child starts from step 0 with input.context = R0. The analyze step reads R0 and identifies what to improve. The refine step reads R0, produces R1. Recursion check: depth 1 < maxDepth 2, so another child is spawned with input.context = R1. This child pauses here.

Level 2 (grandchild, depth=2): The grandchild starts from step 0 with input.context = R1. The analyze step reads R1. The refine step reads R1, produces R2. Recursion check: depth 2 = maxDepth 2 — no more recursion. R2 is the raw output. Now the polish step runs — it ingests R2 via the Refined field and produces P2. Since exit: polish, the grandchild returns P2 as its final output.

Back to Level 1: The grandchild returned P2. This replaces the refine step's stored output (was R1, now P2). The polish step runs — it ingests P2 and produces P1. The child returns P1.

Back to Level 0: The child returned P1. This replaces the refine step's stored output (was R0, now P1). The polish step runs — it ingests P1 and produces P0. Since exit: polish, P0 is the stilt's final answer.

The output went through three rounds of analyze+refine (one per depth level) and three rounds of polish (one per depth level). Each level builds on the previous level's result.

If the recursion step is also the exit step — meaning there are no steps after it — the behavior is simpler. The deepest level's exit output bubbles straight up through each level without any further processing at intermediate levels, because there are no steps to run after the recursion step.

Depth Control

The recursion block has one property: maxDepth. It can be a hardcoded integer or a knob reference:

yaml
recursion:
  maxDepth: 3

recursion:
  maxDepth: "{{knobs.iterations}}"

maxDepth controls how many levels of recursion can happen. At depth 0 (the original run), recursion is allowed if depth < maxDepth. Each spawned child increments depth by 1. When depth equals maxDepth, the step's raw output is used — no child spawns.

Using a knob reference lets the caller control depth at call time. A recursion knob type is the conventional way to expose this (covered in section 3.1).

Only one step in the entire stilt can have a recursion block. If two steps have recursion, validation fails before the stilt runs.

What the Child Sees

Every child instance runs the same stilt config — the same steps, fields, system prompts, and exit step. Three things change:

input.context is replaced. Instead of the caller's original message, the child's input.context becomes the parent step's output. Every text field with from: input.context in the child reads this refined output. This was covered in section 5.2 — during recursion, input.context becomes whatever the parent recursion step produced.

maxLoops is always 1. Regardless of the parent's loop count, the child runs a single pass through the steps. No looping in the child. This means loopRef: accumulate in the child collects nothing — there are no previous loops. Fields using loopRef: 0 work since loop 0 is the only loop that exists. The multi_ingest field on the child's recursion step with loopRef: accumulate will be empty on the child's only pass.

Knobs are inherited. The child gets the same knob values as the parent. If the parent had coverage: 8, the child also has coverage: 8. Node counts driven by knobs match the parent's. The recursion knob itself is also inherited, but since the child checks depth >= maxDepth, the knob value still controls when recursion stops.

Loops and Recursion Together

Loops and recursion are independent dimensions of control flow. The loop counter doesn't advance during child execution.

When a looping stilt has a recursion step, the parent hits the recursion step on loop 0, spawns a child, waits for it, gets the refined result, stores it, then moves to loop 1. On loop 1, the recursion step runs again, spawns another child, waits, gets the result. Each loop iteration gets its own independent child. The child always runs with maxLoops: 1 — it does one clean pass.

The recursion step's refined output (after the child returns) is what gets stored for that loop iteration. A multi_ingest field with loopRef: accumulate on the recursion step collects the refined outputs — one per loop. Loop 2 sees the refined output from loop 0 and loop 1.

Example: a stilt with 3 loops and maxDepth 2. The recursion step runs 3 times (once per loop). Each time, it spawns a child that itself spawns a grandchild. The grandchild's polished output bubbles up to the child, then to the parent. The parent stores that polished output for that loop iteration. Loop 2's multi_ingest field sees: refined output from loop 0, refined output from loop 1 — both having gone through two levels of recursion.

Example

A recursive refinement step inside a looping stilt:

yaml
name: Recursive Draft Refinement

allowedTargets:
  strategy: universal

exit: final

knobs:
  iterations:
    name: Iterations
    type: recursion
    input: numerical
    default: 2
    min: 1
    max: 5
  rounds:
    name: Rounds
    type: loops
    input: numerical
    default: 3
    min: 1
    max: 5

steps:
  - id: draft
    name: Draft
    type: normal
    fields:
      - name: Context
        type: text
        from: input.context
    systemPrompt: "Write an initial draft."

  - id: final
    name: Final Draft
    type: normal
    recursion:
      maxDepth: "{{knobs.iterations}}"
    fields:
      - name: Context
        type: text
        from: input.context
      - name: Earlier
        type: multi_ingest
        from:
          - stepId: final
            loopRef: accumulate
    systemPrompt: "Review and improve. Build on earlier drafts."

Walkthrough with the default knobs (3 rounds, maxDepth 2): the stilt runs 3 loops. On each loop, draft produces an initial draft from the context, then final reads the context plus all previous loops' final outputs (accumulated). final produces its output, then spawns a child (depth 1). The child's input.context is that output — it runs the same two steps, final spawns a grandchild (depth 2). The grandchild runs but doesn't recurse further (depth = maxDepth). The grandchild's exit output bubbles up through both levels, replacing the parent final step's output. That refined output is stored for this loop iteration. On the next loop, the Earlier field accumulates one more entry.

Common Validation Failures

Putting recursion on a group step — groups can't recurse, only normal and sequential steps can. Having more than one step with a recursion block — only one step per stilt can recurse. Referencing a knob in maxDepth that doesn't exist in the knobs section. Setting maxDepth to 0 — the step produces its output but no child ever spawns, making the recursion block pointless.