diff --git a/spec/README.md b/spec/README.md index 52fe41d..71a9c67 100644 --- a/spec/README.md +++ b/spec/README.md @@ -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..result }}` for context steps | **alpha** — all template variables implemented including `step..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 | diff --git a/spec/core/s05-step-specification.md b/spec/core/s05-step-specification.md index ca6bdc7..3ebf77d 100644 --- a/spec/core/s05-step-specification.md +++ b/spec/core/s05-step-specification.md @@ -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..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 | diff --git a/spec/core/s12-conditions.md b/spec/core/s12-conditions.md index 1cf95e6..5ae5768 100644 --- a/spec/core/s12-conditions.md +++ b/spec/core/s12-conditions.md @@ -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 @@ -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. + ---