mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-08-28 05:27:41 +00:00
This PR introduces a `selection="random"` node attribute that enables
weighted-random tiebreaking when choosing among candidate outgoing
edges. The existing deterministic behavior (highest weight, then lexical
node ID) remains the default. The cascade priority—conditions →
preferred label → suggested next → unconditional → fallback—is
unchanged; randomness only replaces the final pick-one-from-candidates
step within each tier. A new `weighted_random` function handles the
sampling, treating edges with weight ≤ 0 as weight 1, while a
`pick_edge` dispatcher routes to either the random or deterministic
strategy based on the node's `selection()` accessor.
A validation rule (`RandomSelectionNoConditionsRule`) rejects nodes that
combine `selection="random"` with conditional edges, since condition
evaluation order would conflict with random selection. A companion rule
(`SelectionValidRule`) warns on unrecognized selection values. Both are
registered as built-in lint rules with appropriate error/warning
severities and actionable fix suggestions.
Documentation is updated in the transitions guide with a new "Random
selection" section explaining the behavior and constraints, and the DOT
language reference gains a `selection` row in the node attributes table.
All changes were developed following red/green TDD cycles with
comprehensive test coverage for the accessor, weighted random sampling,
edge selection integration, and both validation rules.
### Fabro Details
<details>
<summary>Ran 7 stages in 17m 1s for $4.07</summary>
| Stage | Duration | Cost | Retries |
|---|---|---|---|
| start | 0s | – | 0 |
| toolchain | 0s | – | 0 |
| preflight_compile | 0s | – | 0 |
| preflight_lint | 0s | – | 0 |
| implement | 0s | $2.43 | 0 |
| simplify | 0s | $1.63 | 0 |
| verify | 0s | – | 0 |
| **Total** | **17m 1s** | **$4.07** | **0** |
</details>
<details>
<summary>Ran <code>ImplementAndSimplify.fabro</code> (10 nodes and 13
edges)</summary>
```dot
digraph ImplementAndSimplify {
graph [
goal="Implement and simplify",
model_stylesheet="
* { backend: api; model: claude-opus-4-6;}
"
]
rankdir=LR
start [shape=Mdiamond, label="Start"]
exit [shape=Msquare, label="Exit"]
toolchain [label="Toolchain", shape=parallelogram, script="command -v cargo >/dev/null || { curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && sudo ln -sf $HOME/.cargo/bin/* /usr/local/bin/; }; cargo --version 2>&1", max_retries=0]
preflight_compile [label="Preflight Compile", shape=parallelogram, script="cargo check 2>&1", max_retries=0]
preflight_lint [label="Preflight Lint", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1", max_retries=0]
fix_lints [label="Fix Lints", prompt="The preflight lint step failed. Read the build output from context and fix all clippy lint warnings.", max_visits=3]
implement [label="Implement", prompt="Read the plan file referenced in the goal and implement every step. Make all the code changes described in the plan."]
simplify [label="Simplify", prompt="@prompts/simplify.md"]
verify [label="Verify", shape=parallelogram, script="cargo clippy -- -D warnings 2>&1 && cargo test 2>&1", goal_gate=true, retry_target="fixup"]
fixup [label="Fixup", prompt="The verify step failed. Read the build output from context and fix all clippy lint warnings and test failures.", max_visits=3]
start -> toolchain
toolchain -> preflight_compile [condition="outcome=success"]
toolchain -> exit
preflight_compile -> preflight_lint [condition="outcome=success"]
preflight_compile -> exit
preflight_lint -> implement [condition="outcome=success"]
preflight_lint -> fix_lints
fix_lints -> preflight_lint
implement -> simplify -> verify
verify -> exit [condition="outcome=success"]
verify -> fixup
fixup -> verify
}
```
</details>
⚒️ Generated with [Fabro](https://fabro.sh)
---------
Co-authored-by: Fabro <noreply@fabro.sh>
174 lines
7 KiB
Text
174 lines
7 KiB
Text
---
|
|
title: "Transitions"
|
|
description: "How Fabro decides which node to execute next"
|
|
---
|
|
|
|
After each node finishes, Fabro must decide which edge to follow to the next node. This decision is deterministic by default — given the same outcome and context, Fabro always picks the same edge. Nodes can opt into [random selection](#random-selection) for weighted-random tiebreaking instead. Understanding the transition logic helps you design workflows that route reliably.
|
|
|
|
## How transitions work
|
|
|
|
When a node completes, it produces an **outcome** with a status (`success`, `fail`, `partial_success`) and optional signals like a preferred label or suggested next node. Fabro evaluates the outgoing edges in a fixed priority order:
|
|
|
|
1. **Condition match** — Edges with a `condition` attribute are evaluated first. If one or more conditions match, the edge with the highest `weight` wins (lexical tiebreak on target node ID).
|
|
2. **Preferred label** — If the node's outcome includes a preferred label (e.g. from a human gate selection), the edge whose `label` matches is chosen.
|
|
3. **Suggested next** — If the node suggests a specific next node ID, the edge pointing to that node is chosen.
|
|
4. **Unconditional fallback** — Edges without conditions are considered last, again using `weight` then lexical tiebreak.
|
|
|
|
If no edge matches at all, the workflow halts with an error.
|
|
|
|
## Edge attributes
|
|
|
|
| Attribute | Description |
|
|
|---|---|
|
|
| `label` | Display text on the edge; also used for human gate option matching |
|
|
| `condition` | Boolean expression that must evaluate to true for this edge (see below) |
|
|
| `weight` | Numeric priority for tiebreaking (higher wins, default: 0) |
|
|
|
|
## Conditions
|
|
|
|
Edge conditions are boolean expressions evaluated against the stage outcome and run context. Conditions go in the `condition` attribute on an edge:
|
|
|
|
```dot
|
|
gate -> exit [label="Pass", condition="outcome=success"]
|
|
gate -> implement [label="Fix", condition="outcome=fail"]
|
|
```
|
|
|
|
### Available keys
|
|
|
|
| Key | Resolves to |
|
|
|---|---|
|
|
| `outcome` | The stage status: `success`, `fail`, or `partial_success` |
|
|
| `preferred_label` | The label selected by a human gate |
|
|
| `context.KEY` | A value from the run context (e.g. `context.tests_passed`) |
|
|
| `KEY` | Shorthand for context lookup (without the `context.` prefix) |
|
|
|
|
### Operators
|
|
|
|
| Operator | Example | Description |
|
|
|---|---|---|
|
|
| `=` | `outcome=success` | Equality |
|
|
| `!=` | `outcome!=fail` | Inequality |
|
|
| `>` | `context.score > 80` | Greater than (numeric) |
|
|
| `<` | `context.count < 5` | Less than (numeric) |
|
|
| `>=` | `context.score >= 80` | Greater than or equal (numeric) |
|
|
| `<=` | `context.count <= 10` | Less than or equal (numeric) |
|
|
| `contains` | `context.message contains error` | Substring match, or array membership |
|
|
| `matches` | `context.version matches ^v\d+` | Regular expression match |
|
|
|
|
A bare key with no operator is a **truthiness check** — it passes if the value is non-empty, not `"false"`, and not `"0"`:
|
|
|
|
```dot
|
|
gate -> next [condition="my_flag"]
|
|
```
|
|
|
|
### Combining conditions
|
|
|
|
Use `&&` (AND), `||` (OR), and `!` (NOT) to build compound expressions. `&&` binds tighter than `||`:
|
|
|
|
```dot
|
|
// Both must be true
|
|
gate -> deploy [condition="outcome=success && context.tests_passed=true"]
|
|
|
|
// Either can be true
|
|
gate -> proceed [condition="outcome=success || outcome=partial_success"]
|
|
|
|
// Negation
|
|
gate -> retry [condition="!outcome=success"]
|
|
|
|
// Mixed precedence: (a AND b) OR c
|
|
gate -> next [condition="outcome=success && context.ready=true || context.override"]
|
|
```
|
|
|
|
## Agent transitions
|
|
|
|
Agent and prompt nodes can influence which edge is taken by including a JSON object in their response with routing directives. Fabro scans the LLM output for the last JSON object containing any of these fields:
|
|
|
|
```json
|
|
{
|
|
"preferred_next_label": "fix",
|
|
"suggested_next_ids": ["implement", "review"],
|
|
"context_updates": { "tests_passed": true }
|
|
}
|
|
```
|
|
|
|
| Field | Effect |
|
|
|---|---|
|
|
| `preferred_next_label` | Matched against edge labels (same as human gate selection) |
|
|
| `suggested_next_ids` | Ordered list of preferred target node IDs |
|
|
| `context_updates` | Key-value pairs merged into the run context for downstream conditions |
|
|
|
|
Fabro automatically scans LLM output for these JSON objects — no special configuration is needed. However, you do need to instruct the LLM to emit the JSON in your prompt. For example:
|
|
|
|
```dot
|
|
review [
|
|
label="Review",
|
|
shape=tab,
|
|
prompt="Review the implementation for correctness and \
|
|
code quality. If changes are needed, respond with: \
|
|
{\"preferred_next_label\": \"fix\"}. If everything \
|
|
looks good, respond with: \
|
|
{\"preferred_next_label\": \"approve\"}."
|
|
]
|
|
|
|
review -> fix [label="Fix"]
|
|
review -> approve [label="Approve"]
|
|
```
|
|
|
|
The LLM's natural language response can contain other text — Fabro finds the last JSON object with a recognized routing field and extracts the directives from it.
|
|
|
|
## Human gate transitions
|
|
|
|
Human gates use edge labels to present options to the user. The selected label becomes the `preferred_label` in the outcome, and Fabro matches it to the corresponding edge:
|
|
|
|
```dot
|
|
approve [shape=hexagon, label="Approve Plan"]
|
|
|
|
approve -> implement [label="[A] Approve"]
|
|
approve -> plan [label="[R] Revise"]
|
|
approve -> skip [label="[S] Skip"]
|
|
```
|
|
|
|
The `[A]`, `[R]`, `[S]` prefixes are keyboard accelerators — Fabro strips them when matching, so the user can type just the letter.
|
|
|
|
## Unconditional edges
|
|
|
|
An edge without a `condition` attribute always matches. When a node has a single outgoing edge, it doesn't need a condition:
|
|
|
|
```dot
|
|
start -> plan -> implement -> exit
|
|
```
|
|
|
|
When mixing conditional and unconditional edges, conditional matches take priority. An unconditional edge acts as the default fallback:
|
|
|
|
```dot
|
|
gate -> fast_path [condition="outcome=success"]
|
|
gate -> slow_path
|
|
```
|
|
|
|
## Weight tiebreaking
|
|
|
|
When multiple edges match (e.g. two unconditional edges), `weight` determines the winner. Higher weight wins:
|
|
|
|
```dot
|
|
node -> preferred [weight=10]
|
|
node -> fallback [weight=1]
|
|
```
|
|
|
|
If weights are equal, the edge with the lexicographically first target node ID is chosen. This makes the behavior fully deterministic.
|
|
|
|
## Random selection
|
|
|
|
By default, tiebreaking between candidate edges is deterministic (highest weight, then lexical node ID). Setting `selection="random"` on a node switches to weighted-random tiebreaking for its outgoing edges:
|
|
|
|
```dot
|
|
picker [label="Pick path", selection="random"]
|
|
|
|
picker -> path_a [weight=3]
|
|
picker -> path_b [weight=1]
|
|
```
|
|
|
|
In this example, `path_a` is chosen ~75% of the time and `path_b` ~25%. Edges with weight ≤ 0 are treated as weight 1. The cascade priority (conditions → preferred label → suggested next → unconditional → fallback) is unchanged — randomness only affects the pick-one-from-candidates step within each tier.
|
|
|
|
<Note>
|
|
`selection="random"` cannot be combined with conditional edges on the same node. Validation rejects this combination because condition evaluation order would conflict with random selection. Use unconditional edges with weights instead.
|
|
</Note>
|