Skip to main content

atman_runtime/
exec.rs

1use std::{collections::HashMap, path::PathBuf};
2
3use atman_dsl::ast::{Arg, CmpOp, Expr, FlowDecl, Node, Stmt, WatchAction, WatchDecl, WatchEvent};
4
5use crate::env::Env;
6use crate::error::RuntimeError;
7use crate::eval::{EvalCtx, eval_expr};
8use crate::streaming::{WarnRule, WatchRules};
9use crate::tool::{BoxFut, Tool, ToolArgs, ToolCtx, ToolRegistry};
10use crate::value::Value;
11
12fn bind_pattern(
13    pattern: &atman_dsl::ast::Pattern,
14    value: Value,
15    env: &mut Env,
16) -> Result<(), RuntimeError> {
17    use atman_dsl::ast::{Pattern, PatternFieldBinding};
18    match pattern {
19        Pattern::Ident(id) => {
20            env.bind(id.name.clone(), value);
21            Ok(())
22        }
23        Pattern::Struct { fields } => {
24            let pairs = match value {
25                Value::Struct(pairs) => pairs,
26                other => {
27                    return Err(RuntimeError::TypeMismatch {
28                        expected: "struct for destructuring bind".into(),
29                        actual: other.kind_name().into(),
30                    });
31                }
32            };
33            for field in fields {
34                let Some((_, matched)) = pairs.iter().find(|(k, _)| k == &field.source.name) else {
35                    return Err(RuntimeError::MissingArg(format!(
36                        "destructure: struct has no field `{}`",
37                        field.source.name
38                    )));
39                };
40                match &field.binding {
41                    PatternFieldBinding::Same => {
42                        env.bind(field.source.name.clone(), matched.clone());
43                    }
44                    PatternFieldBinding::Rename(target) => {
45                        env.bind(target.name.clone(), matched.clone());
46                    }
47                    PatternFieldBinding::Nested(inner) => {
48                        bind_pattern(inner, matched.clone(), env)?;
49                    }
50                }
51            }
52            Ok(())
53        }
54    }
55}
56
57pub enum StmtOutcome {
58    Continue,
59    Return(Value),
60    Err(RuntimeError),
61    LoopBreak,
62    LoopContinue,
63}
64
65pub fn exec_stmts<'a>(
66    stmts: &'a [Stmt],
67    env: &'a mut Env,
68    ctx: &'a EvalCtx<'a>,
69) -> BoxFut<'a, StmtOutcome> {
70    exec_stmts_prefixed(stmts, env, ctx, String::new())
71}
72
73pub fn exec_stmts_prefixed<'a>(
74    stmts: &'a [Stmt],
75    env: &'a mut Env,
76    ctx: &'a EvalCtx<'a>,
77    prefix: String,
78) -> BoxFut<'a, StmtOutcome> {
79    Box::pin(async move {
80        let watches = collect_watches(stmts);
81        let parent_node_id = ctx.current_node_id.clone();
82        for (i, stmt) in stmts.iter().enumerate() {
83            let node_id = if prefix.is_empty() {
84                format!("{i}")
85            } else {
86                format!("{prefix}.{i}")
87            };
88            // Check for pending L4 stop / L3 redirect between statements.
89            if let Some(session) = ctx.session_runtime.as_ref()
90                && let Some(turn_id) = ctx.turn_id.as_ref()
91            {
92                if let Some(inj) = session.peek_pending_l2_or_higher(turn_id) {
93                    match inj.level {
94                        crate::injection::InjectionLevel::L4HardStop => {
95                            session.mark_injection_consumed(&inj.id);
96                            emit_flow_node_start(ctx, &node_id, stmt, parent_node_id.as_deref());
97                            emit_flow_node_end(
98                                ctx,
99                                &node_id,
100                                &StmtOutcome::Continue,
101                                parent_node_id.as_deref(),
102                                Some("cancelled: hard stop"),
103                            );
104                            return StmtOutcome::Err(RuntimeError::Cancelled(
105                                "hard stop from user".into(),
106                            ));
107                        }
108                        crate::injection::InjectionLevel::L3Redirect => {
109                            if let Some(target) = inj.redirect_target.clone() {
110                                session.mark_injection_consumed(&inj.id);
111                                return StmtOutcome::Err(RuntimeError::Redirect(target));
112                            }
113                        }
114                        _ => {}
115                    }
116                }
117            }
118            emit_flow_node_start(ctx, &node_id, stmt, parent_node_id.as_deref());
119            let stmt_ctx = ctx.with_node(&node_id);
120            let (outcome, preview) = exec_stmt(stmt, env, &stmt_ctx, &watches).await;
121            emit_flow_node_end(
122                ctx,
123                &node_id,
124                &outcome,
125                parent_node_id.as_deref(),
126                preview.as_deref(),
127            );
128            match outcome {
129                StmtOutcome::Continue => continue,
130                other => return other,
131            }
132        }
133        StmtOutcome::Continue
134    })
135}
136
137fn emit_flow_node_start(
138    ctx: &EvalCtx<'_>,
139    node_id: &str,
140    stmt: &Stmt,
141    parent_node_id: Option<&str>,
142) {
143    let (kind, label) = stmt_to_node_kind_label(stmt);
144    emit_flow_node_start_raw(ctx, node_id, kind, &label, parent_node_id);
145}
146
147fn emit_flow_node_start_raw(
148    ctx: &EvalCtx<'_>,
149    node_id: &str,
150    kind: crate::nodegraph::NodeKind,
151    label: &str,
152    parent_node_id: Option<&str>,
153) {
154    let Some(run_id) = ctx.flow_run_id.clone() else {
155        return;
156    };
157    if let Some(sink) = ctx.events {
158        sink.emit(crate::event::Event::FlowNodeStart {
159            run_id: run_id.clone(),
160            node_id: node_id.to_string(),
161            kind: kind.clone(),
162            label: label.to_string(),
163            parent_node_id: parent_node_id.map(String::from),
164        });
165    }
166    if let Some(tx) = ctx.tool_ctx.stream_tx.clone() {
167        let _ = tx.send(crate::stream::StreamFrame::FlowNodeStart {
168            run_id: run_id.0.to_string(),
169            node_id: node_id.to_string(),
170            kind,
171            label: label.to_string(),
172            parent_node_id: parent_node_id.map(String::from),
173        });
174    }
175}
176
177fn value_preview(v: &Value) -> Option<String> {
178    let raw = match v {
179        Value::Str(s) => s.clone(),
180        Value::Message(m) => {
181            let text = m.text_concat();
182            let tool_uses: Vec<String> = m
183                .parts
184                .iter()
185                .filter_map(|p| match p {
186                    crate::message::MessagePart::ToolUse { name, .. } => Some(name.clone()),
187                    _ => None,
188                })
189                .collect();
190            match (text.trim().is_empty(), tool_uses.is_empty()) {
191                (false, true) => text,
192                (false, false) => format!("{}\n\n→ tool_uses: {}", text, tool_uses.join(", ")),
193                (true, false) => format!("→ tool_uses: {}", tool_uses.join(", ")),
194                (true, true) => return None,
195            }
196        }
197        Value::Path(p) => p.display().to_string(),
198        Value::Int(n) => n.to_string(),
199        Value::Float(n) => n.to_string(),
200        Value::Bool(b) => b.to_string(),
201        Value::Unit => return None,
202        Value::Err(e) => format!("err: {e}"),
203        Value::List(items) => format!("list[{}]", items.len()),
204        Value::Struct(fields) => format!(
205            "{{{}}}",
206            fields
207                .iter()
208                .map(|(k, _)| k.as_str())
209                .collect::<Vec<_>>()
210                .join(", ")
211        ),
212        Value::EditProposal(_) => "<edit proposal>".into(),
213        Value::Lambda { .. } => "<lambda>".into(),
214    };
215    let trimmed = raw.trim();
216    if trimmed.is_empty() {
217        None
218    } else {
219        Some(trimmed.chars().take(4000).collect())
220    }
221}
222
223fn emit_flow_node_end(
224    ctx: &EvalCtx<'_>,
225    node_id: &str,
226    outcome: &StmtOutcome,
227    parent_node_id: Option<&str>,
228    output_preview: Option<&str>,
229) {
230    let Some(run_id) = ctx.flow_run_id.clone() else {
231        return;
232    };
233    let status = match outcome {
234        StmtOutcome::Err(_) => crate::event::FlowNodeStatus::Err,
235        StmtOutcome::LoopBreak => crate::event::FlowNodeStatus::Ok,
236        StmtOutcome::LoopContinue => crate::event::FlowNodeStatus::Ok,
237        _ => crate::event::FlowNodeStatus::Ok,
238    };
239    let preview_owned = output_preview.map(String::from);
240    if let Some(sink) = ctx.events {
241        sink.emit(crate::event::Event::FlowNodeEnd {
242            run_id: run_id.clone(),
243            node_id: node_id.to_string(),
244            status: status.clone(),
245            output_preview: preview_owned.clone(),
246        });
247    }
248    if let Some(tx) = ctx.tool_ctx.stream_tx.clone() {
249        let _ = tx.send(crate::stream::StreamFrame::FlowNodeEnd {
250            run_id: run_id.0.to_string(),
251            node_id: node_id.to_string(),
252            status,
253            output_preview: preview_owned,
254            parent_node_id: parent_node_id.map(String::from),
255        });
256    }
257}
258
259fn stmt_to_node_kind_label(stmt: &Stmt) -> (crate::nodegraph::NodeKind, String) {
260    use crate::nodegraph::NodeKind;
261    match stmt {
262        Stmt::Bind { value, .. } | Stmt::Expr(value) => expr_to_node_kind_label(value),
263        Stmt::Return { .. } => (NodeKind::Return, "return".into()),
264        Stmt::When { cond, .. } => {
265            let preview = crate::nodegraph::format_expr_short(cond);
266            (
267                NodeKind::When {
268                    condition_preview: preview.clone(),
269                },
270                format!("when {preview}"),
271            )
272        }
273        Stmt::Watch(_) => (NodeKind::Return, "watch".into()),
274        Stmt::Loop { .. } => (NodeKind::Loop, "loop".into()),
275        Stmt::Break => (NodeKind::Return, "break".into()),
276        Stmt::Continue => (NodeKind::Return, "continue".into()),
277    }
278}
279
280fn expr_to_node_kind_label(expr: &Expr) -> (crate::nodegraph::NodeKind, String) {
281    use crate::nodegraph::NodeKind;
282    match expr {
283        Expr::Node(Node::ToolCall { path, .. })
284            if path.len() == 2 && path[0].name == "llm" && path[1].name == "call" =>
285        {
286            (NodeKind::Llm { model: None }, "llm.call".into())
287        }
288        Expr::Node(Node::ToolCall { path, .. }) => {
289            let p = path
290                .iter()
291                .map(|s| s.name.clone())
292                .collect::<Vec<_>>()
293                .join(".");
294            (NodeKind::ToolCall { path: p.clone() }, format!("⟶ {p}"))
295        }
296        Expr::Node(Node::Fanout { items, collect }) => (
297            NodeKind::Fanout {
298                collect: (*collect).into(),
299            },
300            format!("fanout Ɨ{}", items.len()),
301        ),
302        Expr::Node(Node::Subflow { name, .. }) => (
303            NodeKind::Subflow {
304                name: name.name.clone(),
305            },
306            format!("subflow({})", name.name),
307        ),
308        _ => (NodeKind::Return, "expr".into()),
309    }
310}
311
312fn collect_watches(stmts: &[Stmt]) -> HashMap<String, Vec<&WatchDecl>> {
313    let mut out: HashMap<String, Vec<&WatchDecl>> = HashMap::new();
314    for stmt in stmts {
315        if let Stmt::Watch(w) = stmt {
316            out.entry(w.target.name.clone()).or_default().push(w);
317        }
318    }
319    out
320}
321
322fn exec_stmt<'a>(
323    stmt: &'a Stmt,
324    env: &'a mut Env,
325    ctx: &'a EvalCtx<'a>,
326    watches: &'a HashMap<String, Vec<&'a WatchDecl>>,
327) -> BoxFut<'a, (StmtOutcome, Option<String>)> {
328    Box::pin(async move {
329        match stmt {
330            Stmt::Bind { name, value } => {
331                let watch_target = name.as_single_ident().map(|id| id.name.clone());
332                let v = if let Some(target) = watch_target.as_ref()
333                    && let Some(ws) = watches.get(target)
334                {
335                    match eval_bind_with_watches(value, env, ctx, ws).await {
336                        Ok(v) => v,
337                        Err(e) => return (StmtOutcome::Err(e), None),
338                    }
339                } else {
340                    eval_expr(value, env, ctx).await
341                };
342                if let Value::Err(e) = v {
343                    return (StmtOutcome::Err(e), None);
344                }
345                let preview = value_preview(&v);
346                if let Err(e) = bind_pattern(name, v, env) {
347                    return (StmtOutcome::Err(e), None);
348                }
349                (StmtOutcome::Continue, preview)
350            }
351            Stmt::When { cond, body } => {
352                let c = eval_expr(cond, env, ctx).await;
353                let truthy = match c {
354                    Value::Bool(b) => b,
355                    Value::Unit => false,
356                    Value::Err(e) => {
357                        return (StmtOutcome::Err(e), None);
358                    }
359                    _ => true,
360                };
361                if truthy {
362                    let prefix = ctx.current_node_id.clone().unwrap_or_default();
363                    (
364                        exec_stmts_prefixed(body, env, ctx, prefix).await,
365                        Some("true".into()),
366                    )
367                } else {
368                    (StmtOutcome::Continue, Some("false".into()))
369                }
370            }
371            Stmt::Return { value } => {
372                let v = eval_expr(value, env, ctx).await;
373                if let Value::Err(e) = v {
374                    return (StmtOutcome::Err(e), None);
375                }
376                let preview = value_preview(&v);
377                (StmtOutcome::Return(v), preview)
378            }
379            Stmt::Expr(e) => {
380                let v = eval_expr(e, env, ctx).await;
381                if let Value::Err(err) = v {
382                    return (StmtOutcome::Err(err), None);
383                }
384                let preview = value_preview(&v);
385                (StmtOutcome::Continue, preview)
386            }
387            Stmt::Watch(_) => (StmtOutcome::Continue, None),
388            Stmt::Loop { body } => {
389                let loop_node_id = ctx.current_node_id.clone();
390                let mut iter = 0u64;
391                loop {
392                    let iter_id = match &loop_node_id {
393                        Some(p) => format!("{p}.iter[{iter}]"),
394                        None => format!("iter[{iter}]"),
395                    };
396                    emit_flow_node_start_raw(
397                        ctx,
398                        &iter_id,
399                        crate::nodegraph::NodeKind::Return,
400                        &format!("iteration {iter}"),
401                        loop_node_id.as_deref(),
402                    );
403                    let iter_ctx = ctx.with_node(&iter_id);
404                    let outcome = exec_stmts_prefixed(body, env, &iter_ctx, iter_id.clone()).await;
405                    let preview = match &outcome {
406                        StmtOutcome::LoopBreak => Some("break"),
407                        StmtOutcome::LoopContinue => Some("continue"),
408                        _ => None,
409                    };
410                    emit_flow_node_end(ctx, &iter_id, &outcome, loop_node_id.as_deref(), preview);
411                    match outcome {
412                        StmtOutcome::Continue => {}
413                        StmtOutcome::LoopContinue => {}
414                        StmtOutcome::LoopBreak => break,
415                        other => return (other, Some("loop interrupted".into())),
416                    }
417                    iter += 1;
418                }
419                (StmtOutcome::Continue, Some("loop end".into()))
420            }
421            Stmt::Break => (StmtOutcome::LoopBreak, Some("break".into())),
422            Stmt::Continue => (StmtOutcome::LoopContinue, Some("continue".into())),
423        }
424    })
425}
426
427async fn eval_bind_with_watches(
428    expr: &Expr,
429    env: &mut Env,
430    ctx: &EvalCtx<'_>,
431    watches: &[&WatchDecl],
432) -> Result<Value, RuntimeError> {
433    let Expr::Node(Node::ToolCall { path, args }) = expr else {
434        return Ok(eval_expr(expr, env, ctx).await);
435    };
436    if !(path.len() == 2 && path[0].name == "llm" && path[1].name == "call") {
437        return Ok(eval_expr(expr, env, ctx).await);
438    };
439
440    let mut positional = Vec::new();
441    let mut named = Vec::new();
442    for arg in args {
443        match arg {
444            Arg::Positional(expr) => {
445                let value = eval_expr(expr, env, ctx).await;
446                if value.is_err() {
447                    return Ok(value);
448                }
449                positional.push(value);
450            }
451            Arg::Named { name, value } => {
452                let value = eval_expr(value, env, ctx).await;
453                if value.is_err() {
454                    return Ok(value);
455                }
456                named.push((name.name.clone(), value));
457            }
458        }
459    }
460
461    let registry = std::sync::Arc::new(ctx.tools.clone());
462    let call_args = ToolArgs { positional, named };
463    let mut tool_ctx = ctx
464        .tool_ctx
465        .clone()
466        .with_anchors(
467            ctx.turn_id.clone(),
468            ctx.flow_run_id.clone(),
469            ctx.events.map(|s| s.next_seq_peek()),
470        )
471        .with_registry(registry)
472        .with_current_node(ctx.current_node_id.clone())
473        .with_providers(std::sync::Arc::new(ctx.providers.clone()))
474        .with_watch_rules(collect_watch_rules(watches));
475    if let Some(sink) = ctx.events {
476        tool_ctx = tool_ctx.with_events(sink.clone());
477    }
478    if let Some(session) = ctx.session_runtime.as_ref() {
479        tool_ctx = tool_ctx
480            .with_session_messages(session.messages_full())
481            .with_session_messages_handle(session.messages_handle())
482            .with_session_runtime(session.clone())
483            .with_watch_hub(std::sync::Arc::clone(&session.watch_hub))
484            .with_flow_registry(std::sync::Arc::clone(&session.flow_registry))
485            .with_compact_lock_handle(session.compact_lock_handle());
486    }
487    if let Some(safety) = ctx.safety.cloned() {
488        tool_ctx = tool_ctx.with_safety(safety);
489    }
490    if let Some(model) = &ctx.tool_ctx.current_model {
491        tool_ctx = tool_ctx.with_current_model(model.clone());
492    }
493    if let Some(tx) = ctx.tool_ctx.stream_tx.clone() {
494        tool_ctx = tool_ctx.with_stream_tx(tx);
495    }
496
497    let result = crate::tools::llm_call::LlmCallTool
498        .call(call_args, &tool_ctx)
499        .await;
500    Ok(match result {
501        Ok(v) => v,
502        Err(e) => Value::Err(e),
503    })
504}
505
506fn render_warn_msg(msg: &Option<Expr>, fallback: &str) -> String {
507    match msg {
508        Some(Expr::Literal(atman_dsl::ast::Literal::Str(s))) => s.clone(),
509        _ => fallback.to_string(),
510    }
511}
512
513fn collect_watch_rules(watches: &[&WatchDecl]) -> WatchRules {
514    let mut rules = WatchRules::default();
515    for w in watches {
516        for on in &w.on_blocks {
517            let has_abort = on
518                .actions
519                .iter()
520                .any(|a| matches!(a, WatchAction::Abort { .. }));
521            let warn_msg_expr = on.actions.iter().find_map(|a| match a {
522                WatchAction::Warn { msg } => Some(msg),
523                _ => None,
524            });
525            if !has_abort && warn_msg_expr.is_none() {
526                continue;
527            }
528            match &on.event {
529                WatchEvent::Token { patterns } => {
530                    for p in patterns {
531                        if has_abort {
532                            rules
533                                .token_matches
534                                .push((p.clone(), format!("token match: {p}")));
535                        }
536                        if let Some(msg_expr) = warn_msg_expr {
537                            rules.warn_token.push(WarnRule {
538                                target: w.target.name.clone(),
539                                message: render_warn_msg(
540                                    msg_expr,
541                                    &format!("watch warn: token `{p}`"),
542                                ),
543                                pattern: p.clone(),
544                            });
545                        }
546                    }
547                }
548                WatchEvent::TokensConsumed { cmp, value }
549                    if matches!(cmp, CmpOp::Gt | CmpOp::Ge) =>
550                {
551                    let threshold = if matches!(cmp, CmpOp::Ge) {
552                        value.saturating_sub(1)
553                    } else {
554                        *value
555                    };
556                    if has_abort {
557                        rules.tokens_gt = Some(match rules.tokens_gt {
558                            Some(existing) => existing.min(threshold),
559                            None => threshold,
560                        });
561                    }
562                    if let Some(msg_expr) = warn_msg_expr {
563                        rules.warn_tokens_gt.push((
564                            threshold,
565                            WarnRule {
566                                target: w.target.name.clone(),
567                                message: render_warn_msg(
568                                    msg_expr,
569                                    &format!("watch warn: tokens_consumed > {threshold}"),
570                                ),
571                                pattern: format!("tokens_consumed>{threshold}"),
572                            },
573                        ));
574                    }
575                }
576                WatchEvent::Elapsed { cmp, duration_ms }
577                    if matches!(cmp, CmpOp::Gt | CmpOp::Ge) =>
578                {
579                    let threshold = if matches!(cmp, CmpOp::Ge) {
580                        duration_ms.saturating_sub(1)
581                    } else {
582                        *duration_ms
583                    };
584                    if has_abort {
585                        rules.elapsed_ms_gt = Some(match rules.elapsed_ms_gt {
586                            Some(existing) => existing.min(threshold),
587                            None => threshold,
588                        });
589                    }
590                    if let Some(msg_expr) = warn_msg_expr {
591                        rules.warn_elapsed_ms_gt.push((
592                            threshold,
593                            WarnRule {
594                                target: w.target.name.clone(),
595                                message: render_warn_msg(
596                                    msg_expr,
597                                    &format!("watch warn: elapsed > {threshold}ms"),
598                                ),
599                                pattern: format!("elapsed>{threshold}ms"),
600                            },
601                        ));
602                    }
603                }
604                _ => {}
605            }
606        }
607    }
608    rules
609}
610
611pub async fn exec_flow(
612    flow: &FlowDecl,
613    args: Vec<(String, Value)>,
614    tools: &ToolRegistry,
615    tool_ctx: &ToolCtx,
616    providers: &crate::provider::ProviderRegistry,
617    source_dir: Option<PathBuf>,
618) -> Result<Value, RuntimeError> {
619    let flows = std::collections::HashMap::new();
620    exec_flow_with_siblings(
621        flow,
622        args,
623        tools,
624        tool_ctx,
625        providers,
626        &flows,
627        None,
628        None,
629        None,
630        None,
631        tokio_util::sync::CancellationToken::new(),
632        None,
633        source_dir,
634    )
635    .await
636}
637
638#[allow(clippy::too_many_arguments)]
639pub async fn exec_flow_with_siblings(
640    flow: &FlowDecl,
641    args: Vec<(String, Value)>,
642    tools: &ToolRegistry,
643    tool_ctx: &ToolCtx,
644    providers: &crate::provider::ProviderRegistry,
645    flows: &std::collections::HashMap<String, FlowDecl>,
646    events: Option<&crate::event::EventSink>,
647    turn_id: Option<crate::event::TurnId>,
648    flow_run_id: Option<crate::event::FlowRunId>,
649    session: Option<std::sync::Arc<crate::session::Session>>,
650    flow_cancel: tokio_util::sync::CancellationToken,
651    safety: Option<&crate::safety::SafetyConfig>,
652    source_dir: Option<PathBuf>,
653) -> Result<Value, RuntimeError> {
654    let ctx = EvalCtx {
655        tools,
656        tool_ctx,
657        providers,
658        flows,
659        contract: flow.contract.as_ref(),
660        events,
661        turn_id,
662        flow_run_id,
663        session_runtime: session,
664        flow_cancel,
665        safety,
666        current_node_id: None,
667        source_dir,
668    };
669    let mut env = Env::new();
670    let provided: std::collections::HashSet<String> = args.iter().map(|(n, _)| n.clone()).collect();
671    for (name, value) in args {
672        env.bind(name, value);
673    }
674    for p in &flow.params {
675        if !provided.contains(&p.name.name) {
676            if let Some(default) = &p.default {
677                let val = crate::eval::eval_expr(default, &env, &ctx).await;
678                env.bind(p.name.name.clone(), val);
679            }
680        }
681    }
682    let raw_outcome = exec_stmts(&flow.body, &mut env, &ctx).await;
683    match raw_outcome {
684        StmtOutcome::Return(v) => Ok(v),
685        StmtOutcome::Err(e) => Err(e),
686        StmtOutcome::Continue => Ok(Value::Unit),
687        StmtOutcome::LoopBreak => Err(RuntimeError::ToolFailed("break outside loop".into())),
688        StmtOutcome::LoopContinue => Err(RuntimeError::ToolFailed("continue outside loop".into())),
689    }
690}
691
692#[cfg(test)]
693mod tests {
694    use super::*;
695    use atman_dsl::parse::parse_file;
696
697    async fn run(src: &str, args: Vec<(String, Value)>) -> Result<Value, RuntimeError> {
698        let file = parse_file(src).expect("parse test src");
699        let tools = ToolRegistry::new();
700        let tool_ctx = ToolCtx::new();
701        let providers = crate::provider::ProviderRegistry::new();
702        exec_flow(&file.flows[0], args, &tools, &tool_ctx, &providers, None).await
703    }
704
705    #[tokio::test]
706    async fn bind_and_return() {
707        let out = run(
708            r#"flow t() -> Int {
709    x = 1
710    y = x + 2
711    return y
712}
713"#,
714            vec![],
715        )
716        .await
717        .unwrap();
718        assert!(matches!(out, Value::Int(3)));
719    }
720
721    #[tokio::test]
722    async fn when_true_executes_body() {
723        let out = run(
724            r#"flow t() -> Int {
725    x = 5
726    when x > 3 {
727        return 42
728    }
729    return 0
730}
731"#,
732            vec![],
733        )
734        .await
735        .unwrap();
736        assert!(matches!(out, Value::Int(42)));
737    }
738
739    #[tokio::test]
740    async fn when_false_skips_body() {
741        let out = run(
742            r#"flow t() -> Int {
743    x = 1
744    when x > 3 {
745        return 42
746    }
747    return 0
748}
749"#,
750            vec![],
751        )
752        .await
753        .unwrap();
754        assert!(matches!(out, Value::Int(0)));
755    }
756
757    #[tokio::test]
758    async fn err_in_bind_stops_flow() {
759        let err = run(
760            r#"flow t() -> Int {
761    x = missing
762    return 1
763}
764"#,
765            vec![],
766        )
767        .await
768        .unwrap_err();
769        assert!(matches!(err, RuntimeError::UndefinedVar(n) if n == "missing"));
770    }
771
772    #[tokio::test]
773    async fn flow_args_bind_before_body() {
774        let out = run(
775            r#"flow t() -> Int {
776    return n + 1
777}
778"#,
779            vec![("n".into(), Value::Int(4))],
780        )
781        .await
782        .unwrap();
783        assert!(matches!(out, Value::Int(5)));
784    }
785
786    #[tokio::test]
787    async fn when_cond_unit_is_falsy() {
788        let out = run(
789            r#"flow t() -> Int {
790    when 1 {
791        return 1
792    }
793    return 0
794}
795"#,
796            vec![],
797        )
798        .await
799        .unwrap();
800        assert!(matches!(out, Value::Int(1)));
801    }
802
803    #[tokio::test]
804    async fn when_cond_struct_is_truthy() {
805        let out = run(
806            r#"flow t() -> Int {
807    x = { a: 1 }
808    when x {
809        return 42
810    }
811    return 0
812}
813"#,
814            vec![],
815        )
816        .await
817        .unwrap();
818        assert!(matches!(out, Value::Int(42)));
819    }
820
821    #[tokio::test]
822    async fn flow_falls_through_to_unit_without_return() {
823        let out = run(
824            r#"flow t() {
825    x = 1
826}
827"#,
828            vec![],
829        )
830        .await
831        .unwrap();
832        assert!(matches!(out, Value::Unit));
833    }
834}