Skip to content
Orbital

Decide with a model

Route a run with a small decision model instead of an agent turn, and say what happens when it cannot answer

Let a small, fast decision model choose the next step. A decide step asks it a structured question about the run and follows the route its answer selects. A deciding fan-out asks it which branches to start. Neither spends an agent turn.

decide.toml
[steps.ready]
kind = "decide"
state = "Change: {{ diff }}"
routes = [{ if = "verdict == 'ship'", to = "steps.done" }]
default = "steps.fix"
[steps.ready.questions.verdict]
kind = "choice"
options.ship = "The change is complete and correct."
options.rework = "The change is incomplete or wrong."

Every decide step and every deciding fan-out must name a default path in default: the one step, or the branches, that a run takes when no answer can be used. Validation refuses a step without one. The default path is taken when:

  • no decision model is configured, or the step’s decider names one that is not;
  • the model cannot be reached, refuses the request or answers with something unusable;
  • any answer’s confidence is below the step’s threshold.

No agent answers in its place, and no other model is tried. A run on a computer with no decision model set up still finishes; every decision takes its default path, and each one says so on the run page.

A decide step is a step with kind = "decide". It declares:

  • state, or state_file: what the model is asked about. It is a template, like an agent prompt, and reads {{ context.x }} and {{ inputs.x }}.
  • One or more questions, each a [steps.<id>.questions.<name>] table with a kind of choice, score or noul (yes or no), and an optional description.
  • For a choice, one options.<value> per option. For a score, one levels.<value> per level.
  • Optionally decider, to name the model, and threshold, from 0 to 1.

The answers go in the run context as <name> and <name>_confidence. A route tests an answer with if, such as verdict == 'ship', and a later step can read the confidence. A noul answer is true or false. A score answers with the value of its most probable level.

The run tries the routes in the order you list them and follows the first whose condition holds. default takes the place of next.

{{ diff }} is the run’s change against its base. Only the state of a decide step or a deciding fan-out can read it. When the diff does not fit the model’s context, Orbital cuts it and ends it with [… diff truncated …]. A model with a small context, such as Laya with 512 tokens, sees only the start of a large change, so give it a question that a small part of the diff can answer.

A step’s decider names a decision model you set up in Settings. A step that names none asks the model marked as the default. The threshold is how sure the model must be of every answer. A step that sets none takes the threshold of its model, which starts at 0.5.

The threshold applies to the whole step. If any answer falls below it, the step takes its default path, even if the other answers were sure. A yes or no takes its confidence from the probability of its side. A score takes it from its most probable level.

A fan-out that declares state and a question decides which of its branches start. choices.<answer> lists the branches each answer starts, every branch is listed under at least one answer, and default lists the branches that start when no decision is made.

  • With one choice question, the option the model picks starts every branch listed under it.
  • With several noul questions, each answered yes starts every branch listed under its name.
  • When the model picks nothing that starts a branch, or its answer cannot be used, the default branches start.

The fan-in joins only the branches that started. The fan-out records which branches started, and a run restarted at the fan-out starts the same ones.

Each decide step shows as a Decision card in the run’s Session tab. It lists each question with the answer and its confidence, marks a confidence below the threshold, says why the run took the default path when it did, and names the step it went to and the model that was asked. The run’s Logs tab records the same decision as a DecisionMade event.

A deciding fan-out writes no card; the Logs tab shows the branches it started.

The decision is recorded with the step. A run that restarts, or replays its history, reads the recorded decision and takes the same route, even if the model would answer differently now. A step the run reaches again, in a loop or after a retry, is a new visit and asks again.

You need a project in Orbital and one decision model set up in Settings, or you can run the example without one and watch it take its default path. See Set up decision models.

Download the example and unpack it into ~/.orbital/workflows/. Keep every relative path.

decide-review.toml
version = 3
entry = "steps.implement"
[steps.implement]
prompt = "Make the change the goal asks for: {{ inputs.goal }}"
next = "steps.ready"
[steps.ready]
kind = "decide"
state = "Goal: {{ inputs.goal }}. Change: {{ diff }}"
decider = "jev"
threshold = 0.7
default = "steps.fix"
routes = [
{ if = "verdict == 'ship'", to = "steps.done" },
]
[steps.ready.questions.verdict]
kind = "choice"
description = "Does the change do what the goal asks, and nothing that breaks it?"
options = { ship = "The change is complete and correct.", rework = "The change is incomplete or wrong." }
[steps.fix]
prompt = "The change is not ready. Compare it with the goal and fix what is missing: {{ inputs.goal }}"
max_visits = 2
next = "steps.ready"
[steps.done]
kind = "terminal"

Download decide-review.toml

ready asks one choice question about the change. The ship answer goes to done. The default path goes to fix, which also runs when jev answers with less than 0.7 confidence, and then asks again. max_visits bounds that loop.

Terminal window
orbital validate ~/.orbital/workflows/decide-review.toml

Start a run of decide-review with a goal. When the run reaches ready, open the Session tab to read the decision card.

deciding-fan-out.toml
version = 3
entry = "steps.implement"
[steps.implement]
prompt = "Make the change the goal asks for: {{ inputs.goal }}"
next = "steps.areas"
[steps.areas]
kind = "fan_out"
state = "What the change touches: {{ diff }}"
decider = "jev"
join = "steps.join"
branches = ["steps.security", "steps.docs", "steps.tests"]
default = ["steps.tests"]
choices.security = ["steps.security"]
choices.docs = ["steps.docs"]
choices.tests = ["steps.tests"]
[steps.areas.questions.security]
kind = "noul"
description = "The change touches authentication or secrets."
[steps.areas.questions.docs]
kind = "noul"
description = "The change touches documentation."
[steps.areas.questions.tests]
kind = "noul"
description = "The change touches tests."
[steps.security]
prompt = "Review the change for security problems."
outputs.assessment = "text"
next = "steps.join"
[steps.docs]
prompt = "Review the documentation the change touches."
outputs.assessment = "text"
next = "steps.join"
[steps.tests]
prompt = "Review the tests the change touches."
outputs.assessment = "text"
next = "steps.join"
[steps.join]
kind = "fan_in"
prompt = "Summarise the reviews that ran."
next = "steps.done"
[steps.done]
kind = "terminal"

Download deciding-fan-out.toml

areas asks one yes or no question per area, and each area answered yes starts its review. With no usable answer it starts tests alone, because default lists only that branch.