Redeo Docs
DocsLADR / Dynamic Steps

Dynamic Steps

Dynamic Steps

Dynamic Steps

A dynamic step is a step that loads its own configuration at runtime, usually from the previous step's output. Combined with the manifest field (a runtime snapshot of everything readable), dynamic steps let a ladder rewrite parts of itself based on observed state. This is the primitive that makes a ladder a self-improving organism rather than a fixed program.

Why dynamic steps exist

A ladder without dynamic steps is static. Same system prompt, same fields, same node count, same gates every invocation. Every "iteration" is just running the same fixed program again with different inputs. The reasoning strategy is locked at author time.

That covers most of what published reasoning strategies do. Tree-of-Thoughts, reflexion, self-consistency, self-refine — all of them are fixed programs. They can't change their own behavior based on what they observe.

But real reasoning does change strategy mid-flow. A human solving a hard math problem tries one approach, notices it's not working, and switches to a different approach. A research agent reading documents realizes it needs to look at a different folder than it initially planned. A code reviewer notices a specific class of bug and pivots to check for more instances of it.

Dynamic steps give a ladder that capability. A planner step reads what's available, decides what to do next, and emits a step config (system prompt, fields, node count, optional gates and recursion) as its output. The dynamic step loads that config and executes it. The ladder has rewritten its own behavior mid-execution.

This is the load-bearing piece for genuine self-improvement. Without dynamic steps, a ladder can refine its output (via gates, recursion, reflexion) but cannot refine its strategy. With dynamic steps, the strategy itself adapts to the situation.

How a dynamic step is declared

A dynamic step does not declare its own systemPrompt, fields, or nodes. Instead it declares a source via from:, and the runtime loads the config from that source at execution time.

yaml
- id: run_planned
  type: normal
  dynamic: true
  from: previous              # uses the previous step's output directly as config

The previous step (the planner) is responsible for emitting a valid step config as its output:

yaml
- id: plan_next
  type: normal
  fields:
    - { name: Available, type: manifest }
    - { name: Context,   type: text, from: input.context }
  systemPrompt: |
    Based on Available sources and the current state, generate the next step.
    Emit JSON: {"systemPrompt": "...", "fields": [...], "nodes": N}
    Reference only sources listed in Available.
  if:
    jsonMatches:                              # self-validate the emitted config
      type: object
      required: [systemPrompt, fields]
      properties:
        systemPrompt: { type: string, minLength: 1 }
        fields: { type: array, items: { type: object }, minItems: 1 }
        nodes: { type: number, minimum: 1, maximum: 5 }
      additionalProperties: false

The planner reads the manifest to know what's available. It reasons about what to do next. It emits a JSON config as its output. The dynamic step loads that config and executes it as if it had been declared statically.

Two steps, one plan-and-execute cycle. The plan is data; the execution is mechanical.

The from: source vocabulary

The from: field on a dynamic step uses the same source vocabulary used everywhere else in LADR (writes, retrieve result injection). One way to specify a source, everywhere.

yaml
# Default and most common: direct step output (fast, no slate roundtrip)
- id: run_planned
  dynamic: true
  from: previous

# Reference a specific prior step
- id: run_planned
  dynamic: true
  from: { stepId: plan_next }

# Reference a specific node of a prior step
- id: run_planned
  dynamic: true
  from: { stepId: plan_next, nodeRef: current }

# Reference a slate file (when you want the config persisted or inspectable)
- id: run_planned
  dynamic: true
  from: { slate: "Dynamic", folder: planned, file: next_step.md }

# Reference the current step's own output (rare; recursion-like)
- id: run_planned
  dynamic: true
  from: output

# A literal string (rare; for testing or fixed config injection)
- id: run_planned
  dynamic: true
  from: '{"systemPrompt": "Hello", "fields": [...]}'

The default and overwhelmingly most common case is from: previous. The planner step emits the config as its output; the dynamic step consumes it immediately. No slate roundtrip; the config exists only in the runtime's memory between the two steps.

Use from: { slate: ... } when you want the config to persist across calls. For example: a long-running planner that learns from past plans, where each plan is stored in a slate file for inspection and reuse.

What the loaded config can contain

The planner emits a JSON object that conforms to the shape of a step config. Because LADR's grammar is Turing-complete, the loaded config can include control flowif, jump, recursion, retrieve. The only restriction is no nested dynamic: true.

json
{
  "systemPrompt": "Verify the candidate. Output JSON {score: 1-5, verdict: 'accept'|'reject'}.",
  "fields": [
    { "name": "Candidate", "type": "ingest", "from": {"stepId": "generate", "loopRef": "current"} },
    { "name": "Threshold", "type": "knobInfo", "from": "threshold" }
  ],
  "nodes": 3,
  "if": {
    "integerRange": [4, 5],
    "then": "continue",
    "else": {"jump": {"stepId": "reflect"}}
  },
  "recursion": {"maxDepth": 2},
  "slateWrite": {
    "to": {"slate": "Memory", "folder": "trace", "file": "verified.md"},
    "on": "append",
    "match": {"type": "object", "required": ["score"]}
  },
  "retrieve": {
    "to": {"slate": "Memory", "folder": "docs"},
    "as": "RetrievedDocuments",
    "maxRounds": 3,
    "requestMatch": {...}
  }
}

The dynamic step executes as if this config had been written into the YAML directly. Gates, jumps, recursion, writes, retrieve — all work identically to static steps.

Hard restriction: the loaded config cannot itself contain dynamic: true. A dynamic step cannot spawn another dynamic step. This prevents direct infinite self-modification loops. (Indirect loops via jumps are still possible and bounded by the hop and spend ceilings.)

The manifest field

The planner step needs to know what's available before it can decide what to do next. That's the role of the type: manifest field.

yaml
fields:
  - name: Available
    type: manifest

At runtime, this resolves to a JSON snapshot of everything readable at the current step:

json
{
  "inputs": ["context", "question"],
  "knobs": {"branches": 5, "verifiers": 3, "iterations": 2},
  "steps": [
    {"id": "generate", "loop": 0, "nodes": 5, "survivors": 3},
    {"id": "verify",   "loop": 0, "nodes": 3, "survivors": 3}
  ],
  "slates": [
    {
      "title": "Crucible Memory",
      "folders": [
        {"name": "trace",   "files": [{"name": "verified.md", "size": 412}]},
        {"name": "lessons", "files": [{"name": "failures.md", "size": 188}]},
        {"name": "solution","files": [{"name": "best.md", "size": 250}]}
      ]
    }
  ],
  "time": {
    "now": "2026-07-21T20:47:26Z",
    "localNow": "2026-07-21T16:47:26-04:00",
    "timezone": "America/New_York"
  }
}

The planner LLM reads this snapshot as part of its prompt. It can see:

  • What inputs are available (typically context).
  • What knobs the caller set, and to what values.
  • What steps have run, how many nodes they had, and how many survived gates.
  • What slates exist, their folders, and the current sizes of their files.
  • The current time (UTC always; local time + timezone if the caller declared one).

The time key lets the planner reason about elapsed time, deadlines, and pacing — see Fields > Fallback sources for the full list of auto-injected time inputs.

The manifest currently ships unfiltered: every step, every slate, every input is included. Optional filtering (manifest: { steps: last_3 } or manifest: { slates: ["Crucible Memory"] }) is planned, and will be non-breaking.

The manifest is the runtime telling the planner "here's what you have to work with." The planner's job is to decide what to do with it.

Runtime validation of loaded config

When the runtime loads a dynamic step's config from from:, it validates against four criteria. Failures fire the step's onInvalid: action (default: abort).

  1. Schema conformance. The loaded JSON must be a well-formed step config. Required fields: systemPrompt (non-empty), fields (non-empty array). Optional fields within the loaded config: nodes, if, slateWrite, retrieve, recursion. No other fields. No nested dynamic: true.

  2. Field reference resolution. Every ingest, multi_ingest, slateRead, knobInfo reference in the loaded config must resolve to something in the current manifest. If the planner emits {"from": {"stepId": "nonexistent_step"}}, validation fails.

  3. No nested dynamic. The loaded config cannot itself be dynamic: true. Direct self-modification loops are disallowed.

  4. Node count within cap. The nodes value (if specified) must be within the platform maximum (default 20). The runtime clamps silently if the planner overshoots; explicit over-cap values fail validation.

If any check fails, the runtime returns an error and the step's onInvalid: action fires:

yaml
- id: run_planned
  dynamic: true
  from: previous
  onInvalid: abort              # default; also: continue | { jump: { stepId: fallback } }
  • abort (default): terminate the ladder.
  • continue: skip the dynamic step and proceed to the next one. Useful when the dynamic step is an optimization, not a requirement.
  • { jump: { stepId: ... } }: jump to a fallback step that handles the failure explicitly.

onInvalid gives the ladder author a controlled way to handle planner failures. Without it, a single bad planner output would terminate the whole ladder.

Execution lifecycle

When the runtime reaches a dynamic step in the cursor, this sequence runs:

  1. Hop +1. The dynamic step itself costs 1 hop, just like any step.

  2. Resolve from:. The runtime reads the source (typically the previous step's output) and gets a string.

  3. Parse the source as JSON. If parsing fails, the runtime returns an error and fires onInvalid:.

  4. Validate the parsed config against the four criteria above. If any check fails, same path: error + onInvalid:.

  5. Execute the loaded config as if it were the step's own. Field resolution, LLM calls, gates, writes, retrieve, recursion — all work identically to a static step.

  6. Apply the runtime ceilings to any control flow inside the loaded config. Gates evaluate per node. Jumps reposition the cursor. Recursion spawns children. All of it counts against the ladder's hop, LLM-call, and spend ceilings.

  7. Publish step_complete when done. The step that executed was the dynamic step; the loaded config is not separately identified in the trace. (Foundry shows the loaded config in the step inspector for debugging.)

The total resource consumption of a dynamic step is: 1 hop (the dynamic step itself) + 1 hop per gate evaluation inside the loaded config + 1 hop per recursion level + tokens for every LLM call. The hop ceiling bounds everything at runtime.

Safety constraints

Dynamic steps are LLM-generated code-as-data. Under LADR's Turing-complete grammar, generated configs can include full control flow: gates, jumps, recursion, retrieve. Safety comes from runtime ceilings, not grammar restrictions.

The complete safety stack:

  • Spend cap. The ladder's executionBudget.maxSpend bounds total dollar cost. A dynamic step that loops or recurses still pays for every LLM call. The cap hard-kills execution on breach.
  • Hop ceiling. Every step (dynamic or not) costs 1 hop. Every gate, every recursion inside the loaded config also costs hops. The ceiling bounds total step transitions.
  • LLM call ceiling. Every call (in the dynamic step or in any control flow it spawns) counts against the ladder's call cap.
  • No nested dynamic. A dynamic step's loaded config cannot itself be dynamic: true. Direct infinite self-modification is disallowed.
  • Single step per generation. The loaded config is one step's config, not a sub-ladder. The planner cannot emit a whole new ladder inline; it can only emit one step.
  • Field reference validation at load time. Every reference in the loaded config must resolve against the current manifest. The planner cannot emit a step that reads from non-existent sources.
  • Node count cap. nodes in the loaded config is bounded by the platform maximum.

Together these constraints mean: a dynamic step can do anything a static step can do, but its resource consumption is bounded by the same ceilings. A malicious or buggy planner cannot escape the budget.

Patterns

Adaptive refinement. After verification, generate a step that targets a specific weakness in the candidate.

yaml
- id: diagnose
  fields:
    - { name: Available, type: manifest }
    - { name: Candidate, type: ingest, from: { stepId: verify, loopRef: current } }
  systemPrompt: |
    Read the candidate and verifier feedback.
    If there's a specific weakness, emit JSON for a step that addresses it:
    {"systemPrompt": "...", "fields": [...], "nodes": 1}
    If not, emit {"systemPrompt": "Output DONE.", "fields": [], "nodes": 1}.

- id: targeted_fix
  dynamic: true
  from: previous
  onInvalid: continue

The ladder adaptively decides what to do next based on what the verifier found.

Memory-informed next step. After reading slate lessons from past calls, generate a step that addresses the most recent failure pattern.

yaml
- id: read_lessons
  fields:
    - { name: Lessons, type: slateRead, from: { slate: "Memory", folder: lessons, file: failures.md } }
    - { name: Context, type: text, from: input.context }
  systemPrompt: |
    Based on Lessons from prior calls, generate a step that avoids past mistakes.
    Emit JSON config.

- id: apply_lesson
  dynamic: true
  from: previous

The ladder uses accumulated memory to shape its current strategy.

Plan, validate, execute. The planner self-validates its output via if: { jsonMatches: ... } so it can react to its own failure.

yaml
- id: plan
  fields:
    - { name: Available, type: manifest }
  systemPrompt: "Generate the next step config as JSON."
  if:
    jsonMatches:
      type: object
      required: [systemPrompt, fields]
      properties: { ... }
    then: continue
    else: { jump: { stepId: fallback_plan } }     # self-recovery on bad emission

- id: execute_plan
  dynamic: true
  from: previous

If the planner emits malformed JSON or missing fields, it jumps back to itself (or to a fallback planner) for another attempt, bounded by the hop ceiling.

Persistent plans (slate-stored). When you want the plan to be inspectable or reusable, route it through a slate.

yaml
- id: plan_to_slate
  fields:
    - { name: Available, type: manifest }
  systemPrompt: "Generate the next step config as JSON."
  slateWrite:
    to: { slate: "Plans", folder: current, file: next.md }
    on: overwrite
    match: { type: object, required: [systemPrompt, fields] }

- id: execute_from_slate
  dynamic: true
  from: { slate: "Plans", folder: current, file: next.md }

The plan persists in the slate. A subsequent call can read it via slateRead to inspect what was planned, or to reuse it.

Common pitfalls

Planner emits overly-generic configs. A planner that always emits {"systemPrompt": "Try again.", "fields": [], "nodes": 1} adds an LLM call without adding reasoning. Constrain the planner with a strict jsonMatches schema that requires meaningful fields and a non-trivial system prompt.

Planner references the wrong step ids. The planner must emit from: { stepId: X } references that exist in the current manifest. If the planner hallucinates a step id, validation fails and onInvalid: fires. Give the planner the manifest as a field so it can see what's actually available.

Dynamic steps in tight loops. A reflexion loop that includes a dynamic step will re-plan on every iteration. Each plan is an LLM call; the spend adds up fast. Either bound the loop via executionBudget.maxHops or hoist the planner outside the loop.

Forgetting onInvalid. If the planner ever emits invalid JSON, the dynamic step aborts the whole ladder by default. Always declare onInvalid: explicitly — continue to skip, or { jump: ... } to recover — unless abort is genuinely the right behavior.

Expecting nested dynamic. A dynamic step's loaded config cannot itself be dynamic. If you want two-layer meta-reasoning, use a cross-ladder jump instead: the dynamic step's loaded config jumps to another ladder that has its own planner + dynamic step pair.

Treating dynamic steps as a silver bullet. Most ladders do not need dynamic steps. The four other power axes (knobs, gates+jumps, slates, retrieve) cover the vast majority of reasoning strategies. Dynamic steps add cost and complexity; reach for them only when the strategy itself needs to adapt to observed state, not just the output.