Run a step only when it applies
Give a step a when and it runs only if the condition holds. Otherwise it is skipped: it costs nothing, and the run carries on without it.
refund:
when:
eq: { path: "${nodes.triage.output.category}", value: billing }A condition is structured YAML, not a string to parse. Each one is a single operator, and the set is closed:
- exists: "${…}"
- The path has a value. False when it is missing.
- is_not_empty: "${…}"
- The path has a value that is not empty. False when it is missing.
- is_empty: "${…}"
- The path is empty or missing.
- eq: { path: "${…}", value: … }
- The value at path equals value.
- ne: { path: "${…}", value: … }
- The value at path is different from value.
- all: [ conditions ]
- Every condition holds. Stops at the first that does not.
- any: [ conditions ]
- At least one holds. Stops at the first that does.
- not: { condition }
- The condition does not hold.
eq and ne compare the value as written, so value: false matches a boolean false and value: 3 matches the number 3. Empty
means missing, null, a blank string, or an empty list or object; false and 0 are values, not empty.
Conditions are checked when you publish. A path must exist in the schema it reads, and
comparing a field against a value its schema can never hold, say a category outside its enum, fails with impossible_comparison, because that step could
never run.
A worked example
A support ticket is triaged. Only billing tickets get a refund decision; bugs and refused refunds go to engineering; every ticket gets a reply, with optional lines that appear only when there is something to put in them.
name: support-routing
description: Triage a ticket, decide refunds only for billing, escalate bugs and refused refunds.
input:
schema:
type: object
required: [subject, body]
properties:
subject: { type: string }
body: { type: string }
order_id: { type: string }
schemas:
triage:
type: object
required: [category]
properties:
category: { type: string, enum: [billing, bug, question] }
product: { type: string }
refund:
type: object
required: [eligible, amount]
properties:
eligible: { type: boolean }
amount: { type: string }
note:
type: object
required: [text]
properties:
text: { type: string }
nodes:
triage:
target: { product: otium-small }
estimate: { tokens_in: 500, tokens_out: 30 }
body:
messages:
- role: user
content: "Classify this ticket as billing, bug or question, and name the product if it says. Reply as JSON.\n\n${input.subject}\n${input.body}"
output:
validators:
- { type: json_schema, schema: { $ref: "#/schemas/triage" } }
on_invalid: { retry: 2 }
# Runs only for billing tickets. triage never skips, so a plain eq is safe here.
refund:
target: { product: otium-small }
estimate: { tokens_in: 600, tokens_out: 40 }
when:
eq: { path: "${nodes.triage.output.category}", value: billing }
body:
messages:
- role: user
content: "Is this customer owed a refund, and how much? Reply as JSON.\n\n${input.body}"
output:
validators:
- { type: json_schema, schema: { $ref: "#/schemas/refund" } }
on_invalid: { retry: 2 }
# Reads refund's output in its prompt, so when refund is skipped, this is skipped too.
refund_note:
target: { product: otium-small }
estimate: { tokens_in: 200, tokens_out: 60 }
body:
messages:
- role: user
content: "Write a one-line note confirming a refund of ${nodes.refund.output.amount}. Reply as JSON."
output:
validators:
- { type: json_schema, schema: { $ref: "#/schemas/note" } }
on_invalid: { retry: 1 }
# Bugs, and refunds that were refused. refund only exists for billing tickets, so the
# absence-tolerant `exists` is checked before the strict `eq` reads it.
escalate:
target: { product: otium-small }
estimate: { tokens_in: 600, tokens_out: 60 }
when:
any:
- eq: { path: "${nodes.triage.output.category}", value: bug }
- all:
- exists: "${nodes.refund.output.eligible}"
- eq: { path: "${nodes.refund.output.eligible}", value: false }
body:
messages:
- role: user
content: "Summarise this ticket for the engineering queue. Reply as JSON.\n\n${input.body}"
output:
validators:
- { type: json_schema, schema: { $ref: "#/schemas/note" } }
on_invalid: { retry: 1 }
# Always runs. Optional lines appear only when there is something to say.
reply:
target: { product: otium-medium }
estimate: { tokens_in: 700, tokens_out: 250 }
body:
messages:
- role: system
content: "Draft a short support reply. Reply as JSON."
- role: user
sections:
- text: "Subject: ${input.subject}\n"
- when: { is_not_empty: "${input.order_id}" }
text: "Order: ${input.order_id}\n"
- when: { is_not_empty: "${nodes.triage.output.product}" }
text: "Product: ${nodes.triage.output.product}\n"
- text: "\n${input.body}"
output:
validators:
- { type: json_schema, schema: { $ref: "#/schemas/note" } }
on_invalid: { retry: 1 }
Its tests pin each branch, and say why each skipped step was skipped:
workflow: conditions-support.yaml
cases:
- name: a billing ticket gets a refund decision
input: { subject: "Charged twice", body: "I was billed twice this month." }
nodes:
triage: { output: { category: billing, product: "Pro plan" } }
refund: { output: { eligible: true, amount: "$20" } }
refund_note: { output: { text: "Refund of $20 confirmed." } }
reply: { output: { text: "Sorry about that, the refund is on its way." } }
expect:
status: succeeded
succeeded: [triage, refund, refund_note, reply]
skipped: { escalate: guard_false }
prompts:
refund_note: { contains: ["a refund of $20"] }
reply: { contains: ["Product: Pro plan"], not_contains: ["Order:"] }
- name: a bug skips the refund branch and escalates
input:
{
subject: "Crash on save",
body: "The editor crashes when I save.",
order_id: "A-1001",
}
nodes:
triage: { output: { category: bug } }
escalate: { output: { text: "Editor crashes on save." } }
reply: { output: { text: "Thanks, engineering is looking at it." } }
expect:
status: succeeded
succeeded: [triage, escalate, reply]
skipped: { refund: guard_false, refund_note: upstream_skipped }
prompts:
reply: { contains: ["Order: A-1001"], not_contains: ["Product:"] }
- name: a refused refund escalates
input:
{ subject: "Refund please", body: "I want my money back from last year." }
nodes:
triage: { output: { category: billing } }
refund: { output: { eligible: false, amount: "$0" } }
refund_note: { output: { text: "Refund of $0 confirmed." } }
escalate: { output: { text: "Refund refused; customer disputes." } }
reply: { output: { text: "We have passed this to the team." } }
expect:
status: succeeded
succeeded: [triage, refund, refund_note, escalate, reply]
When a value isn't there
A condition can read a step that was itself skipped, whose output therefore does not exist. What that means depends on the operator, and it is never left to guesswork:
exists,is_not_emptyandis_emptytreat a missing value as an answer: the first two are false,is_emptyis true. Use them to ask whether an optional step produced anything.eqandnedo not. Reaching one over a missing value skips the step with the reasonupstream_skipped. There is no null and no "unknown": a missing value is never quietly equal or unequal to anything.allandanystop as soon as the answer is known, left to right. So put anexistsoris_not_emptyfirst and theeqafter it is only reached when there is something to compare.
escalate:
when:
any:
- eq: { path: "${nodes.triage.output.category}", value: bug }
- all:
- exists: "${nodes.refund.output.eligible}" # false when refund was skipped
- eq: { path: "${nodes.refund.output.eligible}", value: false }The compiler enforces this. An eq or ne that reads a step which can
skip, with no exists or is_not_empty before it, fails with unguarded_strict_predicate, so you decide at publish which of the two answers you
meant. is_empty does not count as a guard: when it is true, the value is the one thing
it proves is not there.
How skips travel
A step that is skipped or fails takes with it the steps that read its output, because
their bindings have nothing to resolve to. They are skipped with the reason upstream_skipped or upstream_failed. In the example, a bug ticket
skips refund, and refund_note, which puts the refund amount in its prompt,
is skipped with it.
A step that only tests another in its when is different. The skip does
not travel across; the condition decides what it means. That is how escalate still runs for a bug ticket although it reads refund, which never ran.
Hand-written ordering follows the same rule as reading: after carries a skip, after_settled does not. See Ordering by hand.
A skip a condition chose is the graph working as designed, not a failure. A run in which only
conditions pruned steps ends succeeded with the outcome completed_with_skips. Each step in the run views carries its terminal_reason (guard_false for its own condition) and a disposition: pruned for a skip the graph chose, degraded for something that stopped a step running. Count failures by disposition, never by the number of
skipped steps.
Optional lines in a prompt
Sometimes a whole step is not optional but one line of its prompt is. A message's content can be a list of sections instead, each with its own
optional when. A section whose condition is false is left out, label and all, so an absent
value never leaves a stray Order: with nothing after it. The reply step
in the example does this:
- role: user
sections:
- text: "Subject: ${input.subject}\n"
- when: { is_not_empty: "${input.order_id}" }
text: "Order: ${input.order_id}\n"
- when: { is_not_empty: "${nodes.triage.output.product}" }
text: "Product: ${nodes.triage.output.product}\n"
- text: "\n${input.body}"Section conditions use the same operators as when, checked as the prompt is
built. Two rules follow from that:
- Put a value that may be missing inside the section that tests it. Inside, a missing value just leaves the section out; anywhere else in the prompt it fails the step.
- Sections are for optional fields: an input field that is not always sent, or an output field a step does not always fill. They do not rescue a step that was skipped. Reading a skipped step's output anywhere in a prompt, a section included, skips this step too.
Put the text every item shares first. A model server can reuse a prompt prefix that is identical across items, and a guarded section makes everything after it differ from item to item.