Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ The AIL Pipeline Language Specification — for pipeline authors and implementer
| [s09-calling-pipelines.md](core/s09-calling-pipelines.md) | §9 Calling Pipelines as Steps | Sub-pipeline isolation; failure propagation | **alpha** — sub-pipeline isolation, failure propagation, and depth guards implemented |
| [s10-named-pipelines.md](core/s10-named-pipelines.md) | §10 Named Pipelines | Multiple named pipelines in one file — define, reference, execute, circular detection | **v0.2** |
| [s11-template-variables.md](core/s11-template-variables.md) | §11 Template Variables | `{{ }}` syntax; all variable paths incl. `{{ step.<id>.result }}` for context steps | **alpha** — all template variables implemented including `step.<id>.result`/`stdout`/`stderr`/`exit_code`, env vars, session vars |
| [s12-conditions.md](core/s12-conditions.md) | §12 Conditions | `condition:` field; named conditions (if_code_changed, etc.) | partial — `never`/`always` implemented |
| [s12-conditions.md](core/s12-conditions.md) | §12 Conditions | `condition:` field; named conditions (if_code_changed, etc.); §12.3 regex syntax (shared with `on_result: matches:` / `expression:`) | partial — `never`/`always` implemented |
| [s13-hitl-gates.md](core/s13-hitl-gates.md) | §13 HITL Gates | pause_for_human; tool permission flow diagram | partial — `pause_for_human` implemented in `execute_with_control()` (TUI/JSON mode); no-op in simple `execute()` mode |
| [s14-built-in-modules.md](core/s14-built-in-modules.md) | §14 Built-in Modules | ail/janitor, ail/security-audit, ail/test-writer, etc. | deferred |
| [s15-providers.md](core/s15-providers.md) | §15 Providers | Provider strings; aliases; `resume:` for session continuity | partial — `defaults.model`/`defaults.provider` ✓; per-step `model:` ✓; per-step `resume:` ✓; provider string format/aliases deferred |
Expand Down
71 changes: 70 additions & 1 deletion spec/core/s05-step-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,82 @@ Rules are evaluated in declared order; the first match fires. Used when differen
| Operator | Meaning |
|---|---|
| `contains: "TEXT"` | Response contains literal string (case-insensitive). |
| `matches: "REGEX"` | Response matches regular expression. |
| `matches: /REGEX/FLAGS` | Response matches regular expression. Shorthand for `expression: '{{ step.<id>.response }} matches /.../flags'`. See §12.3 for regex syntax and semantics. |
| `starts_with: "TEXT"` | Response begins with literal string. |
| `is_empty` | Response is blank or whitespace only. |
| `exit_code: N` | Process exit code equals N. Valid on `shell:` sources within `context:` steps only. |
| `exit_code: any` | Any non-zero exit code. Valid on `shell:` sources within `context:` steps only. |
| `expression: "EXPR"` | Evaluates a §12.2 condition expression against the session's template state. Fires when the expression is true. See below. |
| `always` | Unconditionally fires. |

#### `expression:` — general-purpose template-expression matcher

> **Implementation status:** specified in this section, not yet implemented. Tracked via #130.

`expression:` is the escape hatch for cases the named matchers do not cover. Its grammar is **identical to** the condition expression grammar defined in §12.2 — no new syntax, no second implementation. The LHS is template-resolved at runtime against the turn log; the RHS is a literal.

```yaml
# Exit code comparisons beyond `0` / `any` — e.g. distinguish "test failed" (1) from "crash" (>1).
- id: tests
context:
shell: "cargo test"
on_result:
- expression: "{{ step.tests.exit_code }} == 1"
action: pipeline: ./pipelines/triage-failure.yaml
- exit_code: any
action: abort_pipeline
- exit_code: 0
action: continue

# Branch on a field that isn't the step's `response` — here, stderr of a preceding context step.
- id: decide
prompt: ./prompts/decide.md
on_result:
- expression: "{{ step.build.stderr }} contains 'rate limit'"
action: pause_for_human
message: "Upstream rate-limited — human input required."
- always
action: continue

# Regex matching on stdout — catch either warning class with one branch.
- id: lint
context:
shell: "cargo clippy"
on_result:
- expression: '{{ step.lint.stdout }} matches /warning|deprecated/i'
action: pause_for_human
- exit_code: 0
action: continue
```

**Evaluation semantics.**

| Case | Behaviour |
|---|---|
| Expression evaluates to **true** | Branch matches, `action` fires. |
| Expression evaluates to **false** | Branch does not match, evaluation continues with the next branch. |
| LHS template variable **cannot be resolved** (step did not run, unknown field, etc.) | Pipeline aborts with `ail:template/unresolved-variable`. |
| Expression string **fails to parse** at load time | Pipeline load fails with `ail:config/validation-failed`. The grammar is validated at parse time, not at match time. |

An `on_result` branch that cannot be evaluated is a pipeline bug, not a silent non-match. This is the same contract the rest of the template system enforces (see §11).

**Relationship to the named matchers.**

| Form | When to use |
|---|---|
| `contains:`, `matches:`, `starts_with:`, `is_empty` | Matching the step's `response` — the common case. Keeps YAML terse and intent obvious. |
| `exit_code: N` / `exit_code: any` | Matching `exit_code` of a `shell:` source. |
| `field:` + `equals:` | Matching a named field of validated structured output (§26.4). The only matcher that runs against a parsed JSON value rather than a rendered string — prefer it over `expression:` when the step has an `input_schema`. |
| `expression:` | Anything not covered above — comparisons against `stdout`/`stderr`, non-zero exit codes with specific values, comparisons against other steps' fields, future numeric/confidence signals, etc. |
| `always` | Default / catch-all. |

Prefer a named matcher when one fits — it reads as intent. Reach for `expression:` when no named matcher expresses the condition cleanly.

**Why `expression:` is a matcher, not an action.** `on_result` may declare multiple branches; each branch is an independent match→action pair. Putting the predicate on the matcher keeps branches orthogonal and evaluable in declared order, matching how `contains:`, `exit_code:`, and `always` already compose.

> **Planned extensions (not in v0.3).**
> Numeric comparison operators (`<`, `<=`, `>`, `>=`) and boolean combinators (`&&`, `||`) are deliberately **not** in the v0.3 grammar. They are tracked as a joint extension to §12.2 and `expression:` — both must extend together, since `expression:` is defined as "§12.2 with no additions." The primary consumer is confidence-score gating on native LLM runners (#128, #130), e.g. `expression: "{{ step.review.confidence }} >= 0.75"`. Until then, `expression:` supports the §12.2 string-oriented operators only.

#### Supported actions

| Action | Effect |
Expand Down
57 changes: 55 additions & 2 deletions spec/core/s12-conditions.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,24 @@ condition: "{{ step.check.response }} ends_with 'done'"
| `contains` | Case-insensitive substring check |
| `starts_with` | Case-insensitive prefix check |
| `ends_with` | Case-insensitive suffix check |
| `matches` | Regular expression match. RHS is a `/PATTERN/FLAGS` literal. See §12.3. |

#### Syntax Rules

- The **left-hand side** is a template expression (e.g. `{{ step.test.exit_code }}`) resolved at runtime.
- The **right-hand side** is a literal value. Surrounding single or double quotes are stripped: `'LGTM'` and `"LGTM"` both resolve to `LGTM`.
- Word-based operators (`contains`, `starts_with`, `ends_with`) require whitespace boundaries — they are not confused with template variable names like `{{ step.contains_test.response }}`.
- The **right-hand side** is a literal value. Surrounding single or double quotes are stripped: `'LGTM'` and `"LGTM"` both resolve to `LGTM`. The `matches` operator takes a `/PATTERN/FLAGS` regex literal instead — see §12.3.
- Word-based operators (`contains`, `starts_with`, `ends_with`, `matches`) require whitespace boundaries — they are not confused with template variable names like `{{ step.contains_test.response }}`.
- Symbolic operators (`==`, `!=`) are matched outside `{{ }}` template blocks.

#### Error Handling

- If the LHS template variable cannot be resolved (e.g. references a step that has not run), the pipeline aborts with a `CONDITION_INVALID` error (`ail:condition/invalid`).
- If the condition string is not a recognised named condition and does not contain a supported operator, validation fails with `CONFIG_VALIDATION_FAILED` at parse time.
- If a `matches` operator is present but its RHS fails to compile as a valid regex literal, validation fails with `CONFIG_VALIDATION_FAILED` at parse time (see §12.3).

#### Reuse in `on_result: expression:`

The `expression:` matcher on `on_result` (§5.4) reuses this grammar without modification. Any valid condition expression is a valid `on_result: expression:` expression and vice versa. When the grammar is extended (e.g. numeric operators for confidence-score gating — #130), the extension applies to both sites simultaneously.

#### Template Variables in Conditions

Expand All @@ -63,4 +69,51 @@ condition: "{{ step.lint.stdout }} contains 'no warnings'"
condition: "{{ env.DEPLOY_TARGET }} == production"
```

### 12.3 Regular Expression Syntax

This section is the single source of truth for regex syntax in `ail`. It is referenced by the `matches` operator in §12.2 (which in turn is reused by `on_result: expression:` in §5.4) and by the named `matches:` matcher in §5.4. Any future consumer of regex in the spec should link here rather than redefine.

#### Literal form

Regex literals use conventional `/PATTERN/FLAGS` syntax — a leading `/`, the pattern, a closing `/`, and zero or more flag characters. This reads the same way as regex literals in JavaScript, Perl, or Ruby.

```yaml
condition: '{{ step.test.response }} matches /^PASS\b/'
condition: '{{ step.build.stderr }} matches /error|warning/i'
condition: '{{ step.review.response }} matches /LGTM|SHIP IT/'
condition: '{{ step.lint.stdout }} matches /^E\d{4}/m'
```

#### Flags

| Flag | Meaning |
|---|---|
| `i` | Case-insensitive |
| `m` | Multiline — `^` and `$` match at line boundaries |
| `s` | Dotall — `.` matches newlines |

**Unsupported flags.** `g` (global) is rejected at parse time — `ail` regex matching is boolean (does the string match or not), so a "global" flag has no meaning. `x` (verbose) and other Perl-style flags are not supported as trailing flags; use `(?x)` inline syntax inside the pattern if you need them.

#### Parsing rule

The regex literal is delimited by the *first* `/` and the *last* `/` followed by zero or more flag characters (`[ims]*`) at end-of-string. Forward slashes inside the pattern do not need escaping as long as the literal isn't ambiguous: `/a/b/` matches the pattern `a/b` with no flags; `/a/b/i` matches `a/b` case-insensitively. When in doubt, escape as `\/`.

#### Matching semantics

- **Engine:** Rust `regex` crate (linear-time; no backreferences or lookaround — the standard RE2-family tradeoff for bounded match cost).
- **Anchoring:** unanchored by default. `/PASS/` matches `"tests PASSED"`. Use `^`/`$` for exact matching.
- **Case sensitivity:** case-sensitive unless the `i` flag is set.
- **Invalid regex:** compilation failure (including unsupported flags) is caught at parse time with `CONFIG_VALIDATION_FAILED`. A pipeline with a broken regex refuses to load — never "works until the first request arrives that would have triggered it."

#### YAML quoting

Prefer **single-quoted** YAML strings around any condition containing a regex. Single quotes leave backslashes literal, so `\d`, `\b`, `\s` work as written:

```yaml
condition: '{{ step.x.response }} matches /\d{3}-\d{4}/' # clean
condition: "{{ step.x.response }} matches /\\d{3}-\\d{4}/" # works, but doubled backslashes
```

YAML double quotes interpret backslashes. `"/\d+/"` becomes `/d+/` silently — a failure mode that is painful to debug. Single-quoted is the idiomatic form.

---
Loading