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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 6 additions & 3 deletions ail-core/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ Consumed by `ail` (the binary) and future language-server / SDK targets.
| `config/domain.rs` | Validated domain types — no `Deserialize` derives |
| `config/validation/mod.rs` | `validate()` entry point, `cfg_err!` macro, `tools_to_policy` helper |
| `config/validation/step_body.rs` | `parse_step_body()` — primary field count check + body construction (including `parse_do_while_body`, `parse_for_each_body`, `load_loop_pipeline_steps`) |
| `config/validation/on_result.rs` | `parse_result_branches()` — DTO → domain for result matchers and actions |
| `config/validation/on_result.rs` | `parse_result_branches()` — DTO → domain for result matchers and actions (incl. `expression:` via `parse_condition_expression`, and `matches:` desugared to expression form) |
| `config/validation/regex_literal.rs` | `parse_regex_literal()` — parses `/PATTERN/FLAGS` into a compiled `regex::Regex` with source preservation (SPEC §12.3) |
| `config/validation/system_prompt.rs` | `parse_append_system_prompt()` — DTO → domain for system prompt entries |
| `config/validation/sampling.rs` | `validate_sampling()` — DTO → domain with range checks; normalizes thinking (f64 OR bool) to `Option<f64>` (SPEC §30.6.1) |
| `config/inheritance.rs` | FROM inheritance — path resolution, cycle detection, DTO merging, hook operations (SPEC §7, §8) |
Expand Down Expand Up @@ -76,8 +77,9 @@ pub struct Step { pub id: StepId, pub body: StepBody, pub message: Option<Str
// then: private post-processing chain (SPEC §5.7) — runs after the step completes
// output_schema: optional JSON Schema for validating step output (SPEC §26.1); validated at parse time, response validated at runtime
// input_schema: optional JSON Schema for validating preceding step's output (SPEC §26.2); validated at parse time, runtime validation before step executes
pub enum Condition { Always, Never, Expression(ConditionExpr) } // SPEC §12 — None means Always; Never skips; Expression evaluates at runtime
pub enum Condition { Always, Never, Expression(ConditionExpr), Regex(RegexCondition) } // SPEC §12 — None means Always; Never skips; Expression evaluates comparison at runtime; Regex evaluates regex::is_match at runtime (SPEC §12.2/§12.3). Does NOT derive PartialEq — regex::Regex doesn't implement it.
pub struct ConditionExpr { pub lhs: String, pub op: ConditionOp, pub rhs: String }
pub struct RegexCondition { pub lhs: String, pub regex: regex::Regex, pub source: String } // SPEC §12.3 — regex compiled at parse time via parse_regex_literal(); source is original /PATTERN/FLAGS for diagnostics
pub enum ConditionOp { Eq, Ne, Contains, StartsWith, EndsWith }
pub enum OnError { Continue, Retry { max_retries: u32 }, AbortPipeline } // SPEC §16 — None means AbortPipeline (default)
pub enum StepBody { Prompt(String), Skill { name: String }, SubPipeline { path: String, prompt: Option<String> }, NamedPipeline { name: String, prompt: Option<String> }, Action(ActionKind), Context(ContextSource), DoWhile { max_iterations, exit_when, steps }, ForEach { over, as_name, max_items, on_max_items, steps } }
Expand All @@ -90,8 +92,9 @@ pub enum ActionKind { PauseForHuman, ModifyOutput { headless_behavior: HitlHeadl
pub enum JoinErrorMode { FailFast, WaitForAll } // SPEC §29.7 — default FailFast
pub enum HitlHeadlessBehavior { Skip, Abort, UseDefault }
pub struct ResultBranch { pub matcher: ResultMatcher, pub action: ResultAction }
pub enum ResultMatcher { Contains(String), ExitCode(ExitCodeMatch), Field { name: String, equals: serde_json::Value }, Always }
pub enum ResultMatcher { Contains(String), ExitCode(ExitCodeMatch), Field { name: String, equals: serde_json::Value }, Expression { source: String, condition: Condition }, Always }
// Field: exact equality match against a named field in validated input JSON (SPEC §26.4); requires input_schema
// Expression: §12.2 condition grammar (SPEC §5.4 `expression:`) — source is original expression string for materialize/diagnostics; condition is Expression(ConditionExpr) or Regex(RegexCondition). Named `matches:` in YAML desugars to Expression at parse time (SPEC §5.4).
pub enum ExitCodeMatch { Exact(i32), Any }
pub enum ResultAction { Continue, Break, AbortPipeline, PauseForHuman, Pipeline { path: String, prompt: Option<String> } }
// Pipeline.path may contain {{ variable }} syntax — resolved at execution time (SPEC §11)
Expand Down
1 change: 1 addition & 0 deletions ail-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ rusqlite = { version = "0.31", features = ["bundled"] }
tempfile = "3"
ureq = { version = "2", features = ["json"] }
event-listener = "5"
regex = "1"

[dev-dependencies]
stub-llm = { path = "../stub-llm" }
39 changes: 37 additions & 2 deletions ail-core/src/config/domain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -162,16 +162,24 @@ impl Pipeline {
}

/// Controls whether a step executes (SPEC §12).
#[derive(Debug, Clone, PartialEq)]
///
/// `PartialEq` is intentionally not derived: `Regex` variants carry a
/// compiled [`regex::Regex`], which does not implement `PartialEq`. Compare
/// via pattern-match (`matches!`) or by inspecting the contained source.
#[derive(Debug, Clone)]
pub enum Condition {
/// Step always executes (same as omitting `condition:`).
Always,
/// Step is unconditionally skipped.
Never,
/// Expression condition evaluated at runtime against session state (SPEC §12.2).
/// Comparison expression evaluated at runtime against session state (SPEC §12.2).
/// The string may contain `{{ variable }}` template syntax which is resolved
/// before evaluating the expression operator.
Expression(ConditionExpr),
/// Regex-match expression evaluated at runtime (SPEC §12.2 `matches` operator,
/// regex semantics from §12.3). The pattern is compiled at parse time; an
/// invalid regex fails pipeline load, not evaluation.
Regex(RegexCondition),
}

/// A parsed condition expression (SPEC §12.2).
Expand All @@ -188,6 +196,21 @@ pub struct ConditionExpr {
pub rhs: String,
}

/// A parsed regex-match condition (SPEC §12.2 `matches` / §12.3).
///
/// The `lhs` is a template string resolved at evaluation time; the compiled
/// regex is applied to the resolved string. `source` preserves the original
/// `/PATTERN/FLAGS` literal for error messages and materialize output.
#[derive(Debug, Clone)]
pub struct RegexCondition {
/// Left-hand side — a template string resolved at evaluation time.
pub lhs: String,
/// Compiled regex, built at parse time per §12.3.
pub regex: regex::Regex,
/// Original source literal, e.g. `/warn|error/i`. Preserved for diagnostics.
pub source: String,
}

/// Comparison operators for condition expressions (SPEC §12.2).
#[derive(Debug, Clone, PartialEq)]
pub enum ConditionOp {
Expand Down Expand Up @@ -427,6 +450,18 @@ pub enum ResultMatcher {
name: String,
equals: serde_json::Value,
},
/// Arbitrary §12.2 expression matcher (SPEC §5.4 `expression:`). Accepts the full
/// `Condition` union because the condition parser returns `Expression` for
/// comparison operators and `Regex` for the `matches` operator.
///
/// The `source` field preserves the original expression string for diagnostics
/// and materialize output. `Condition::Always`/`Never` do not occur here —
/// only `Expression` and `Regex` variants are produced by the expression
/// parser used for this matcher.
Expression {
source: String,
condition: Condition,
},
Always,
}

Expand Down
7 changes: 7 additions & 0 deletions ail-core/src/config/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,13 @@ pub enum OnResultDto {
pub struct OnResultBranchDto {
pub contains: Option<String>,
pub exit_code: Option<ExitCodeDto>,
/// Regex literal `/PATTERN/FLAGS` matched against the step's `response`
/// (SPEC §5.4, §12.3). Shorthand for `expression: '{{ step.<id>.response }}
/// matches /.../flags'`.
pub matches: Option<String>,
/// Full §12.2 condition expression (SPEC §5.4 `expression:`). Supports any
/// operator the §12.2 grammar supports, including `matches` for regex.
pub expression: Option<String>,
pub always: Option<bool>,
pub action: Option<String>,
/// Optional prompt override passed to the child session when action is `pipeline:`.
Expand Down
50 changes: 43 additions & 7 deletions ail-core/src/config/validation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
#![allow(clippy::result_large_err)]

mod on_result;
mod regex_literal;
mod sampling;
mod step_body;
mod system_prompt;

pub(crate) use regex_literal::parse_regex_literal;

use std::collections::{HashMap, HashSet};
use std::path::PathBuf;

use super::domain::{
ActionKind, Condition, ConditionExpr, ConditionOp, JoinErrorMode, OnError, Pipeline,
ProviderConfig, Step, StepBody, StepId, ToolPolicy,
ProviderConfig, RegexCondition, Step, StepBody, StepId, ToolPolicy,
};
use super::dto::{ChainStepDto, PipelineFileDto, StepDto, ToolsDto};
use crate::error::AilError;
Expand All @@ -39,20 +42,50 @@ fn tools_to_policy(t: ToolsDto) -> ToolPolicy {
}
}

/// Parse a condition expression string into a `Condition::Expression`.
/// Parse a condition expression string into a [`Condition`] (SPEC §12.2).
///
/// Supported operators: `==`, `!=`, `contains`, `starts_with`, `ends_with`.
/// The LHS is typically a template variable (e.g. `{{ step.test.exit_code }}`),
/// and the RHS is a literal or quoted string.
/// Supported operators: `==`, `!=`, `contains`, `starts_with`, `ends_with`,
/// `matches`. The LHS is typically a template variable (e.g. `{{ step.test.exit_code }}`),
/// and the RHS is a literal for comparison operators or a `/PATTERN/FLAGS`
/// regex literal for `matches` (see §12.3).
///
/// Examples:
/// `"{{ step.test.exit_code }} == 0"`
/// `"{{ step.review.response }} contains 'LGTM'"`
/// `"{{ step.build.exit_code }} != 0"`
/// `"{{ step.lint.stdout }} matches /warning|error/i"`
pub(in crate::config) fn parse_condition_expression(
raw: &str,
step_id: &str,
) -> Result<Condition, AilError> {
// The `matches` operator is parsed first because its RHS has its own
// syntax (a regex literal) and must not be confused with other operators.
if let Some(pos) = find_operator_position(raw, "matches") {
let lhs = raw[..pos].trim().to_string();
let rhs_raw = raw[pos + "matches".len()..].trim();

if lhs.is_empty() {
return Err(cfg_err!(
"Step '{step_id}' condition expression has an empty left-hand side"
));
}
if rhs_raw.is_empty() {
return Err(cfg_err!(
"Step '{step_id}' condition expression 'matches' operator requires a regex \
literal on the right-hand side (e.g. /pattern/flags) (SPEC §12.3)"
));
}

let parsed = parse_regex_literal(rhs_raw)
.map_err(|e| cfg_err!("Step '{step_id}' condition 'matches' operator: {e}"))?;

return Ok(Condition::Regex(RegexCondition {
lhs,
regex: parsed.regex,
source: parsed.source,
}));
}

// Operator tokens in order of specificity (multi-word first to avoid partial matches).
let operators: &[(&str, ConditionOp)] = &[
("starts_with", ConditionOp::StartsWith),
Expand Down Expand Up @@ -90,7 +123,7 @@ pub(in crate::config) fn parse_condition_expression(
Err(cfg_err!(
"Step '{step_id}' specifies condition '{raw}' which is not a recognised \
named condition ('always', 'never') and does not contain a supported operator \
(==, !=, contains, starts_with, ends_with)"
(==, !=, contains, starts_with, ends_with, matches)"
))
}

Expand Down Expand Up @@ -1220,7 +1253,10 @@ mod tests {
step.condition = Some("never".to_string());
let dto = minimal_dto(vec![step]);
let pipeline = validate(dto, source()).expect("should succeed");
assert_eq!(pipeline.steps[0].condition, Some(Condition::Never));
assert!(matches!(
pipeline.steps[0].condition,
Some(Condition::Never)
));
}

#[test]
Expand Down
28 changes: 26 additions & 2 deletions ail-core/src/config/validation/on_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use crate::config::domain::{ExitCodeMatch, ResultAction, ResultBranch, ResultMat
use crate::config::dto::{ExitCodeDto, FieldEqualsActionDto, OnResultDto};
use crate::error::AilError;

use super::cfg_err;
use super::{cfg_err, parse_condition_expression};

/// Parse `on_result` from either the multi-branch array format or the
/// field-equals binary branch format (SPEC §5.4, §26.4).
Expand All @@ -32,6 +32,8 @@ fn parse_result_branches(
let matcher_count = [
branch.contains.is_some(),
branch.exit_code.is_some(),
branch.matches.is_some(),
branch.expression.is_some(),
branch.always.is_some(),
]
.iter()
Expand All @@ -41,7 +43,7 @@ fn parse_result_branches(
if matcher_count != 1 {
return Err(cfg_err!(
"Step '{step_id}' on_result branch {i} must have exactly one matcher \
(contains, exit_code, always); found {matcher_count}"
(contains, exit_code, matches, expression, always); found {matcher_count}"
));
}

Expand All @@ -65,6 +67,28 @@ fn parse_result_branches(
}
};
ResultMatcher::ExitCode(exit_code_match)
} else if let Some(regex_literal) = branch.matches {
// Named `matches:` is shorthand for `expression: '{{ step.<id>.response }} matches /.../'`
// (SPEC §5.4). Desugar at parse time so both forms share a single evaluator.
let source = format!("{{{{ step.{step_id}.response }}}} matches {regex_literal}");
let condition = parse_condition_expression(&source, step_id).map_err(|e| {
cfg_err!(
"Step '{step_id}' on_result branch {i} matches: {}",
e.detail()
)
})?;
ResultMatcher::Expression { source, condition }
} else if let Some(expr_str) = branch.expression {
let condition = parse_condition_expression(&expr_str, step_id).map_err(|e| {
cfg_err!(
"Step '{step_id}' on_result branch {i} expression: {}",
e.detail()
)
})?;
ResultMatcher::Expression {
source: expr_str,
condition,
}
} else {
ResultMatcher::Always
};
Expand Down
Loading
Loading