Skip to main content

atman_runtime/
validate.rs

1use std::collections::{HashMap, HashSet};
2
3use atman_dsl::ast::{Arg, Expr, FlowDecl, Node, Stmt, WatchEvent};
4
5use crate::tool::ToolRegistry;
6
7#[derive(Debug, thiserror::Error)]
8pub enum ValidationError {
9    #[error("undefined variable `{0}`")]
10    UndefinedVar(String),
11
12    #[error("undefined tool `{0}`")]
13    UndefinedTool(String),
14
15    #[error("invocation user_message must reference a declared string parameter")]
16    InvalidInvocationUserMessage,
17
18    #[error(
19        "watch on `{target}` uses event `{event}`, but bind is a {target_kind} node — expected one of {expected}"
20    )]
21    WatchEventMismatch {
22        target: String,
23        event: String,
24        target_kind: String,
25        expected: String,
26    },
27}
28
29pub fn validate(flow: &FlowDecl, tools: &ToolRegistry) -> Result<(), Vec<ValidationError>> {
30    let mut errors = Vec::new();
31    validate_invocation_contract(flow, &mut errors);
32    let mut scope: HashSet<String> = flow.params.iter().map(|p| p.name.name.clone()).collect();
33    for name in BUILTIN_VARS {
34        scope.insert(name.to_string());
35    }
36    let mut kinds: HashMap<String, &'static str> = HashMap::new();
37    walk_stmts(&flow.body, &mut scope, &mut kinds, tools, &mut errors);
38    if errors.is_empty() {
39        Ok(())
40    } else {
41        Err(errors)
42    }
43}
44
45fn validate_invocation_contract(flow: &FlowDecl, errors: &mut Vec<ValidationError>) {
46    let Some((_, value)) = flow.contract.as_ref().and_then(|contract| {
47        contract
48            .blocks
49            .iter()
50            .find(|block| block.name.name == "invocation")
51            .and_then(|block| {
52                block
53                    .kwargs
54                    .iter()
55                    .find(|(name, _)| name.name == "user_message")
56            })
57    }) else {
58        return;
59    };
60    let atman_dsl::ast::Expr::Ident(parameter_name) = value else {
61        errors.push(ValidationError::InvalidInvocationUserMessage);
62        return;
63    };
64    let Some(parameter) = flow
65        .params
66        .iter()
67        .find(|parameter| parameter.name.name == parameter_name.name)
68    else {
69        errors.push(ValidationError::InvalidInvocationUserMessage);
70        return;
71    };
72    let is_string = matches!(
73        &parameter.ty,
74        atman_dsl::ast::TypeExpr::Named(name) if name.name == "string"
75    );
76    let has_supported_default = parameter.default.as_ref().is_none_or(|default| {
77        matches!(
78            default,
79            atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Str(_))
80        )
81    });
82    if !is_string || !has_supported_default {
83        errors.push(ValidationError::InvalidInvocationUserMessage);
84    }
85}
86
87const BUILTIN_VARS: &[&str] = &[
88    "session",
89    "fs",
90    "bash",
91    "term",
92    "task",
93    "web",
94    "hunk",
95    "git",
96    "test",
97    "memory",
98    "plan",
99    "form",
100    "help",
101    "preview",
102    "session_tool",
103    "sleep",
104    "watch",
105    "watcher",
106];
107
108fn infer_node_kind(value: &Expr) -> Option<&'static str> {
109    match value {
110        Expr::Node(Node::ToolCall { path, .. })
111            if path.len() == 2 && path[0].name == "llm" && path[1].name == "call" =>
112        {
113            Some("llm")
114        }
115        Expr::Node(Node::ToolCall { path, .. }) => {
116            let _ = path;
117            Some("tool_call")
118        }
119        Expr::Node(Node::Fanout { .. }) => Some("fanout"),
120        Expr::Node(Node::UserConfirm { .. }) => Some("user_confirm"),
121        Expr::Node(Node::Subflow { .. }) => Some("subflow"),
122        Expr::Node(Node::FixUntilTestPasses { .. }) => Some("fix_until"),
123        Expr::Node(Node::Message { .. }) => Some("message"),
124        _ => None,
125    }
126}
127
128fn watch_event_expected_kinds(event: &WatchEvent) -> &'static [&'static str] {
129    match event {
130        WatchEvent::Token { .. } => &["llm"],
131        WatchEvent::TokensConsumed { .. } => &["llm"],
132        WatchEvent::Elapsed { .. } => &["llm", "tool_call", "subflow", "fix_until"],
133    }
134}
135
136fn walk_stmts(
137    stmts: &[Stmt],
138    scope: &mut HashSet<String>,
139    kinds: &mut HashMap<String, &'static str>,
140    tools: &ToolRegistry,
141    errors: &mut Vec<ValidationError>,
142) {
143    for stmt in stmts {
144        match stmt {
145            Stmt::Bind { name, value } => {
146                walk_expr(value, scope, tools, errors);
147                let bound = name.bound_names();
148                if let Some(k) = infer_node_kind(value)
149                    && let Some(single) = name.as_single_ident()
150                {
151                    kinds.insert(single.name.clone(), k);
152                }
153                for n in bound {
154                    scope.insert(n);
155                }
156            }
157            Stmt::When { cond, body } => {
158                walk_expr(cond, scope, tools, errors);
159                walk_stmts(body, scope, kinds, tools, errors);
160            }
161            Stmt::Return { value } => walk_expr(value, scope, tools, errors),
162            Stmt::Expr(e) => walk_expr(e, scope, tools, errors),
163            Stmt::Watch(w) => {
164                if !scope.contains(&w.target.name) {
165                    errors.push(ValidationError::UndefinedVar(w.target.name.clone()));
166                    continue;
167                }
168                let Some(target_kind) = kinds.get(&w.target.name).copied() else {
169                    continue;
170                };
171                for on in &w.on_blocks {
172                    let expected = watch_event_expected_kinds(&on.event);
173                    if !expected.contains(&target_kind) {
174                        errors.push(ValidationError::WatchEventMismatch {
175                            target: w.target.name.clone(),
176                            event: watch_event_label(&on.event).into(),
177                            target_kind: target_kind.into(),
178                            expected: expected.join(", "),
179                        });
180                    }
181                }
182            }
183            Stmt::Loop { body } => {
184                walk_stmts(body, scope, kinds, tools, errors);
185            }
186            Stmt::Break => {}
187            Stmt::Continue => {}
188        }
189    }
190}
191
192fn watch_event_label(event: &WatchEvent) -> &'static str {
193    match event {
194        WatchEvent::Token { .. } => "token",
195        WatchEvent::TokensConsumed { .. } => "tokens_consumed",
196        WatchEvent::Elapsed { .. } => "elapsed",
197    }
198}
199
200fn walk_expr(
201    expr: &Expr,
202    scope: &HashSet<String>,
203    tools: &ToolRegistry,
204    errors: &mut Vec<ValidationError>,
205) {
206    match expr {
207        Expr::Literal(_) | Expr::FileRef(_) => {}
208        Expr::Ident(id) => {
209            if !scope.contains(&id.name) {
210                errors.push(ValidationError::UndefinedVar(id.name.clone()));
211            }
212        }
213        Expr::Member { base, .. } => walk_expr(base, scope, tools, errors),
214        Expr::Binary { left, right, .. } => {
215            walk_expr(left, scope, tools, errors);
216            walk_expr(right, scope, tools, errors);
217        }
218        Expr::Unary { operand, .. } => walk_expr(operand, scope, tools, errors),
219        Expr::List(items) => {
220            for item in items {
221                walk_expr(item, scope, tools, errors);
222            }
223        }
224        Expr::Struct(fields) => {
225            for (_, v) in fields {
226                walk_expr(v, scope, tools, errors);
227            }
228        }
229        Expr::Node(node) => walk_node(node, scope, tools, errors),
230        Expr::Call { args, .. } => {
231            for a in args {
232                walk_expr(a, scope, tools, errors);
233            }
234        }
235        Expr::Pipe { lhs, rhs } => {
236            walk_expr(lhs, scope, tools, errors);
237            walk_expr(rhs, scope, tools, errors);
238        }
239        Expr::Lambda { params, body } => {
240            let mut child_scope = scope.clone();
241            for p in params {
242                child_scope.insert(p.name.clone());
243            }
244            walk_expr(body, &child_scope, tools, errors);
245        }
246        Expr::Annotated { expr, .. } => {
247            // Type names and type list expressions in annotation position
248            // are not variable references
249            match expr.as_ref() {
250                Expr::Ident(id) if crate::eval::is_type_name(&id.name) => {}
251                Expr::List(inner) if inner.len() == 1 => {
252                    if let Expr::Ident(id) = &inner[0] {
253                        if crate::eval::is_type_name(&id.name) {
254                            return;
255                        }
256                    }
257                    walk_expr(expr, scope, tools, errors);
258                }
259                _ => walk_expr(expr, scope, tools, errors),
260            }
261        }
262    }
263}
264
265fn walk_node(
266    node: &Node,
267    scope: &HashSet<String>,
268    tools: &ToolRegistry,
269    errors: &mut Vec<ValidationError>,
270) {
271    match node {
272        Node::ToolCall { path, args } => {
273            let name = path
274                .iter()
275                .map(|i| i.name.as_str())
276                .collect::<Vec<_>>()
277                .join(".");
278            // Evaluator intrinsics are not registered or exposed as provider tools.
279            let is_intrinsic = crate::eval::is_evaluator_intrinsic(&name);
280            if !is_intrinsic && !tools.has(&name) {
281                errors.push(ValidationError::UndefinedTool(name));
282            }
283            for arg in args {
284                match arg {
285                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
286                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
287                }
288            }
289        }
290        Node::DynamicFanout { source, lambda, .. } => {
291            walk_expr(source, scope, tools, errors);
292            walk_expr(lambda, scope, tools, errors);
293        }
294        Node::Fanout { items, .. } => {
295            for item in items {
296                walk_expr(item, scope, tools, errors);
297            }
298        }
299        Node::UserConfirm { msg } => walk_expr(msg, scope, tools, errors),
300        Node::Subflow { args, .. } => {
301            for arg in args {
302                match arg {
303                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
304                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
305                }
306            }
307        }
308        Node::FixUntilTestPasses { kwargs } => {
309            for (_, v) in kwargs {
310                walk_expr(v, scope, tools, errors);
311            }
312        }
313        Node::Message { args, .. } => {
314            for arg in args {
315                match arg {
316                    Arg::Positional(e) => walk_expr(e, scope, tools, errors),
317                    Arg::Named { value, .. } => walk_expr(value, scope, tools, errors),
318                }
319            }
320        }
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use crate::tools;
328    use atman_dsl::parse::parse_file;
329
330    fn registry_with_fs() -> ToolRegistry {
331        let reg = ToolRegistry::new();
332        tools::register_tier_zero(&reg);
333        reg
334    }
335
336    #[test]
337    fn valid_flow_using_declared_var_and_registered_tool() {
338        let src = r#"flow t(p: path) -> string {
339    body = fs.read(p)
340    return body
341}
342"#;
343        let file = parse_file(src).unwrap();
344        validate(&file.flows[0], &registry_with_fs()).expect("valid flow");
345    }
346
347    #[test]
348    fn invocation_user_message_requires_a_declared_string_parameter() {
349        for source in [
350            r#"flow t(count: int) -> int {
351    contract { invocation { user_message: count } }
352    return count
353}"#,
354            r#"flow t(prompt: string) -> string {
355    contract { invocation { user_message: "prompt" } }
356    return prompt
357}"#,
358            r#"flow t(prefix: string, prompt: string = prefix) -> string {
359    contract { invocation { user_message: prompt } }
360    return prompt
361}"#,
362        ] {
363            let file = parse_file(source).unwrap();
364            let errors = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
365            assert!(
366                errors
367                    .iter()
368                    .any(|error| matches!(error, ValidationError::InvalidInvocationUserMessage))
369            );
370        }
371    }
372
373    #[test]
374    fn undefined_var_is_reported() {
375        let src = r#"flow t() -> Int {
376    return missing
377}
378"#;
379        let file = parse_file(src).unwrap();
380        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
381        assert!(
382            errs.iter()
383                .any(|e| matches!(e, ValidationError::UndefinedVar(name) if name == "missing"))
384        );
385    }
386
387    #[test]
388    fn undefined_tool_is_reported() {
389        let src = r#"flow t(p: path) -> Int {
390    return fs.nope(p)
391}
392"#;
393        let file = parse_file(src).unwrap();
394        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
395        assert!(
396            errs.iter()
397                .any(|e| matches!(e, ValidationError::UndefinedTool(name) if name == "fs.nope"))
398        );
399    }
400
401    #[test]
402    fn errors_accumulate_not_fail_fast() {
403        let src = r#"flow t() -> Int {
404    x = nope1
405    y = nope2.tool()
406    return x
407}
408"#;
409        let file = parse_file(src).unwrap();
410        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
411        assert!(errs.len() >= 2);
412    }
413
414    #[test]
415    fn watch_on_llm_bind_with_token_event_is_ok() {
416        let src = r#"flow r() -> string {
417    x = llm.call(model: "m", prompt: "hi")
418    watch x { on token(match: "bad") { abort("no") } }
419    return x
420}
421"#;
422        let file = parse_file(src).unwrap();
423        validate(&file.flows[0], &registry_with_fs()).expect("token on llm is fine");
424    }
425
426    #[test]
427    fn watch_token_on_non_llm_bind_is_rejected() {
428        let src = r#"flow r(p: path) -> string {
429    body = fs.read(p)
430    watch body { on token(match: "bad") { warn() } }
431    return body
432}
433"#;
434        let file = parse_file(src).unwrap();
435        let errs = validate(&file.flows[0], &registry_with_fs()).unwrap_err();
436        let mismatch = errs
437            .iter()
438            .find(|e| matches!(e, ValidationError::WatchEventMismatch { .. }))
439            .expect("expected WatchEventMismatch");
440        let msg = mismatch.to_string();
441        assert!(msg.contains("body"), "msg: {msg}");
442        assert!(msg.contains("token"), "msg: {msg}");
443        assert!(msg.contains("llm"), "msg: {msg}");
444    }
445
446    #[test]
447    fn bind_introduces_variable_for_later_stmts() {
448        let src = r#"flow t() -> Int {
449    x = 1
450    return x
451}
452"#;
453        let file = parse_file(src).unwrap();
454        validate(&file.flows[0], &registry_with_fs()).expect("valid flow");
455    }
456
457    #[test]
458    fn invocation_env_is_a_valid_intrinsic_without_a_registered_tool() {
459        let file = parse_file(
460            r#"flow t() -> string {
461    return env("effort")
462}"#,
463        )
464        .unwrap();
465
466        validate(&file.flows[0], &ToolRegistry::new()).expect("env is evaluator-owned");
467    }
468}