Skip to main content

atman_runtime/eval/
mod.rs

1pub(crate) mod llm_args;
2mod llm_context;
3pub(crate) mod llm_dispatch;
4pub(crate) mod llm_parse;
5
6use atman_dsl::ast::{Arg, BinOp, Expr, Literal, Node, UnOp};
7
8use std::sync::Arc;
9
10use crate::env::Env;
11use crate::error::RuntimeError;
12use crate::streaming::LlmStream;
13use crate::tool::{BoxFut, ToolArgs, ToolCtx, ToolRegistry};
14use crate::value::Value;
15
16#[derive(Clone)]
17pub struct EvalCtx<'a> {
18    pub tools: &'a ToolRegistry,
19    pub tool_ctx: &'a ToolCtx,
20    pub providers: &'a crate::provider::ProviderRegistry,
21    pub flows: &'a std::collections::HashMap<String, atman_dsl::ast::FlowDecl>,
22    pub contract: Option<&'a atman_dsl::ast::Contract>,
23    pub events: Option<&'a crate::event::EventSink>,
24    pub turn_id: Option<crate::event::TurnId>,
25    pub flow_run_id: Option<crate::event::FlowRunId>,
26    pub session_runtime: Option<std::sync::Arc<crate::session::Session>>,
27    pub flow_cancel: tokio_util::sync::CancellationToken,
28    pub safety: Option<&'a crate::safety::SafetyConfig>,
29    pub current_node_id: Option<String>,
30    /// Directory of the .at source file. When set, relative `@` paths
31    /// are resolved against this directory instead of the process CWD.
32    pub source_dir: Option<std::path::PathBuf>,
33}
34
35impl<'a> EvalCtx<'a> {
36    pub fn with_node(&self, node_id: impl Into<String>) -> Self {
37        let mut c = self.clone();
38        c.current_node_id = Some(node_id.into());
39        c
40    }
41}
42
43pub fn eval_expr<'a>(expr: &'a Expr, env: &'a Env, ctx: &'a EvalCtx<'a>) -> BoxFut<'a, Value> {
44    Box::pin(async move { eval_expr_inner(expr, env, ctx).await })
45}
46
47#[derive(Clone, Copy, Debug)]
48pub(crate) enum ContextMode {
49    None,
50    Session,
51    SessionRecent(usize),
52}
53
54pub(super) fn parse_context_mode(s: &str) -> ContextMode {
55    match s.trim() {
56        "session" => ContextMode::Session,
57        "none" | "" => ContextMode::None,
58        other if other.starts_with("session_recent") => {
59            let rest = &other["session_recent".len()..];
60            let rest = rest
61                .trim()
62                .trim_start_matches('(')
63                .trim_end_matches(')')
64                .trim();
65            let n: usize = rest.parse().unwrap_or(10);
66            ContextMode::SessionRecent(n.max(1))
67        }
68        _ => ContextMode::None,
69    }
70}
71
72pub(super) fn is_context_overflow_error(err: &RuntimeError) -> bool {
73    let RuntimeError::ToolFailed(msg) = err else {
74        return false;
75    };
76    let msg = msg.to_ascii_lowercase();
77    msg.contains("maximum context length")
78        || msg.contains("context overflow")
79        || msg.contains("context window")
80        || msg.contains("context length")
81        || msg.contains("prompt is too long")
82        || msg.contains("input is too long")
83        || msg.contains("too many tokens")
84}
85
86pub(super) fn rebuild_session_llm_messages(
87    session: &crate::session::Session,
88    context_mode: ContextMode,
89    turn_id: &crate::event::TurnId,
90    prompt: Option<&str>,
91    extra_messages: &[crate::message::Message],
92) -> Vec<crate::message::Message> {
93    let mut messages = match context_mode {
94        ContextMode::Session => session.messages().to_vec(),
95        ContextMode::SessionRecent(n) => {
96            let all = session.messages();
97            let start = all.len().saturating_sub(n);
98            all[start..].to_vec()
99        }
100        ContextMode::None => Vec::new(),
101    };
102    if let Some(prompt) = prompt
103        && !prompt.is_empty()
104    {
105        messages.push(crate::message::Message::user_text(
106            turn_id.clone(),
107            prompt.to_string(),
108        ));
109    }
110    messages.extend_from_slice(extra_messages);
111    messages
112}
113
114pub(super) async fn session_system_context(session: &crate::session::Session) -> Vec<String> {
115    let mut parts = Vec::new();
116    if let Some(goal) = session.goal() {
117        parts.push(format!("[session goal]\n{goal}\n[/session goal]"));
118    }
119    if let Some(cwd_note) = working_directory_system_prompt(session) {
120        parts.push(cwd_note);
121    }
122    if let Some(plan) = session.plan_system_prompt().await {
123        parts.push(format!(
124            "[active plan]\n{plan}\n[/active plan]\n\nCall plan.tick to mark a step done. Call plan.write to revise."
125        ));
126    }
127    if let Some(model_info) = available_models_system_prompt() {
128        parts.push(model_info);
129    }
130    parts
131}
132
133pub(super) fn append_system_context(system: &mut Option<String>, parts: Vec<String>) {
134    if parts.is_empty() {
135        return;
136    }
137    match system {
138        Some(existing) if !existing.is_empty() => {
139            existing.push_str("\n\n");
140            existing.push_str(&parts.join("\n\n"));
141        }
142        Some(existing) => {
143            *existing = parts.join("\n\n");
144        }
145        None => {
146            *system = Some(parts.join("\n\n"));
147        }
148    }
149}
150
151async fn eval_expr_inner<'a>(expr: &'a Expr, env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
152    match expr {
153        Expr::Literal(lit) => eval_literal(lit),
154        Expr::Ident(id) => match env.lookup(&id.name) {
155            Some(v) => v.clone(),
156            None => Value::Err(RuntimeError::UndefinedVar(id.name.clone())),
157        },
158        Expr::FileRef(f) => {
159            let path = if std::path::Path::new(&f.path).is_relative() {
160                if let Some(dir) = &ctx.source_dir {
161                    dir.join(&f.path)
162                } else {
163                    std::path::PathBuf::from(&f.path)
164                }
165            } else {
166                std::path::PathBuf::from(&f.path)
167            };
168            match tokio::fs::read_to_string(&path).await {
169                Ok(s) => Value::Str(s),
170                Err(e) => Value::Err(RuntimeError::ToolFailed(format!(
171                    "@\"{}\": {e}",
172                    path.display()
173                ))),
174            }
175        }
176        Expr::Member { base, field } => {
177            let base_v = eval_expr(base, env, ctx).await;
178            if base_v.is_err() {
179                return base_v;
180            }
181            match base_v.field(&field.name) {
182                Some(v) => v.clone(),
183                None => Value::Err(RuntimeError::UndefinedVar(format!(".{}", field.name))),
184            }
185        }
186        Expr::Binary { op, left, right } => {
187            let l = eval_expr(left, env, ctx).await;
188            if l.is_err() {
189                return l;
190            }
191            let r = eval_expr(right, env, ctx).await;
192            if r.is_err() {
193                return r;
194            }
195            eval_binop(*op, &l, &r)
196        }
197        Expr::Unary { op, operand } => {
198            let v = eval_expr(operand, env, ctx).await;
199            if v.is_err() {
200                return v;
201            }
202            eval_unop(*op, &v)
203        }
204        Expr::List(items) => {
205            let mut acc = Vec::with_capacity(items.len());
206            for item in items {
207                let v = eval_expr(item, env, ctx).await;
208                if v.is_err() {
209                    return v;
210                }
211                acc.push(v);
212            }
213            Value::List(acc)
214        }
215        Expr::Struct(fields) => {
216            let mut acc = Vec::with_capacity(fields.len());
217            for (k, v) in fields {
218                let val = eval_expr(v, env, ctx).await;
219                if val.is_err() {
220                    return val;
221                }
222                acc.push((k.name.clone(), val));
223            }
224            Value::Struct(acc)
225        }
226        Expr::Node(node) => eval_node(node, env, ctx).await,
227        Expr::Call { .. } => Value::Err(RuntimeError::ToolFailed(
228            "bare function call not supported; use namespaced tool call".into(),
229        )),
230        Expr::Pipe { lhs, rhs } => eval_pipe(lhs, rhs, env, ctx).await,
231        Expr::Annotated { expr, annotation } => eval_annotated(expr, annotation, env, ctx).await,
232        Expr::Lambda { params, body } => Value::Lambda {
233            params: params.clone(),
234            body: Arc::new((**body).clone()),
235            captured_env: env.clone(),
236        },
237    }
238}
239
240fn arg_positional<'a>(
241    args: &'a [Arg],
242    index: usize,
243    fn_name: &str,
244) -> Result<&'a Expr, RuntimeError> {
245    let mut pos = 0;
246    for arg in args {
247        match arg {
248            Arg::Positional(e) => {
249                if pos == index {
250                    return Ok(e);
251                }
252                pos += 1;
253            }
254            Arg::Named { .. } => {}
255        }
256    }
257    Err(RuntimeError::MissingArg(format!(
258        "{fn_name}: missing positional arg {index}"
259    )))
260}
261
262async fn eval_list_map<'a>(args: &'a [Arg], env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
263    let list_expr = match arg_positional(args, 0, "list.map") {
264        Ok(e) => e,
265        Err(e) => return Value::Err(e),
266    };
267    let lambda_expr = match arg_positional(args, 1, "list.map") {
268        Ok(e) => e,
269        Err(e) => return Value::Err(e),
270    };
271
272    let items_val = eval_expr(list_expr, env, ctx).await;
273    let lambda_val = eval_expr(lambda_expr, env, ctx).await;
274
275    let items = match items_val {
276        Value::List(items) => items,
277        other => {
278            return Value::Err(RuntimeError::TypeMismatch {
279                expected: "list".into(),
280                actual: other.kind_name().into(),
281            });
282        }
283    };
284    let (params, body, captured_env) = match lambda_val {
285        Value::Lambda {
286            params,
287            body,
288            captured_env,
289        } => (params, body, captured_env),
290        other => {
291            return Value::Err(RuntimeError::TypeMismatch {
292                expected: "lambda".into(),
293                actual: other.kind_name().into(),
294            });
295        }
296    };
297    if params.len() != 1 {
298        return Value::Err(RuntimeError::ToolFailed(format!(
299            "list.map: lambda must have 1 parameter, got {}",
300            params.len()
301        )));
302    }
303
304    let mut out = Vec::with_capacity(items.len());
305    for item in items {
306        let mut call_env = captured_env.child();
307        call_env.bind(params[0].name.clone(), item);
308        out.push(eval_expr(&body, &call_env, ctx).await);
309    }
310    Value::List(out)
311}
312
313async fn eval_list_filter<'a>(args: &'a [Arg], env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
314    let list_expr = match arg_positional(args, 0, "list.filter") {
315        Ok(e) => e,
316        Err(e) => return Value::Err(e),
317    };
318    let lambda_expr = match arg_positional(args, 1, "list.filter") {
319        Ok(e) => e,
320        Err(e) => return Value::Err(e),
321    };
322
323    let items_val = eval_expr(list_expr, env, ctx).await;
324    let lambda_val = eval_expr(lambda_expr, env, ctx).await;
325
326    let items = match items_val {
327        Value::List(items) => items,
328        other => {
329            return Value::Err(RuntimeError::TypeMismatch {
330                expected: "list".into(),
331                actual: other.kind_name().into(),
332            });
333        }
334    };
335    let (params, body, captured_env) = match lambda_val {
336        Value::Lambda {
337            params,
338            body,
339            captured_env,
340        } => (params, body, captured_env),
341        other => {
342            return Value::Err(RuntimeError::TypeMismatch {
343                expected: "lambda".into(),
344                actual: other.kind_name().into(),
345            });
346        }
347    };
348    if params.len() != 1 {
349        return Value::Err(RuntimeError::ToolFailed(format!(
350            "list.filter: lambda must have 1 parameter, got {}",
351            params.len()
352        )));
353    }
354
355    let mut out = Vec::new();
356    for item in items {
357        let mut call_env = captured_env.child();
358        call_env.bind(params[0].name.clone(), item.clone());
359        let keep = eval_expr(&body, &call_env, ctx).await;
360        if let Value::Bool(true) = keep {
361            out.push(item);
362        }
363    }
364    Value::List(out)
365}
366
367async fn eval_list_reduce<'a>(args: &'a [Arg], env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
368    let list_expr = match arg_positional(args, 0, "list.reduce") {
369        Ok(e) => e,
370        Err(e) => return Value::Err(e),
371    };
372    let lambda_expr = match arg_positional(args, 1, "list.reduce") {
373        Ok(e) => e,
374        Err(e) => return Value::Err(e),
375    };
376    let init_expr = match arg_positional(args, 2, "list.reduce") {
377        Ok(e) => e,
378        Err(e) => return Value::Err(e),
379    };
380
381    let items = match eval_expr(list_expr, env, ctx).await {
382        Value::List(items) => items,
383        other => {
384            return Value::Err(RuntimeError::TypeMismatch {
385                expected: "list".into(),
386                actual: other.kind_name().into(),
387            });
388        }
389    };
390    let (params, body, captured_env) = match eval_expr(lambda_expr, env, ctx).await {
391        Value::Lambda {
392            params,
393            body,
394            captured_env,
395        } => (params, body, captured_env),
396        other => {
397            return Value::Err(RuntimeError::TypeMismatch {
398                expected: "lambda".into(),
399                actual: other.kind_name().into(),
400            });
401        }
402    };
403    if params.len() != 2 {
404        return Value::Err(RuntimeError::ToolFailed(format!(
405            "list.reduce: lambda must have 2 parameters (acc, x), got {}",
406            params.len()
407        )));
408    }
409
410    let mut acc = eval_expr(init_expr, env, ctx).await;
411    if let Value::Err(_) = &acc {
412        return acc;
413    }
414    for item in items {
415        let mut call_env = captured_env.child();
416        call_env.bind(params[0].name.clone(), acc.clone());
417        call_env.bind(params[1].name.clone(), item);
418        acc = eval_expr(&body, &call_env, ctx).await;
419        if let Value::Err(_) = &acc {
420            return acc;
421        }
422    }
423    acc
424}
425
426async fn eval_list_find<'a>(args: &'a [Arg], env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
427    let list_expr = match arg_positional(args, 0, "list.find") {
428        Ok(e) => e,
429        Err(e) => return Value::Err(e),
430    };
431    let lambda_expr = match arg_positional(args, 1, "list.find") {
432        Ok(e) => e,
433        Err(e) => return Value::Err(e),
434    };
435
436    let items = match eval_expr(list_expr, env, ctx).await {
437        Value::List(items) => items,
438        other => {
439            return Value::Err(RuntimeError::TypeMismatch {
440                expected: "list".into(),
441                actual: other.kind_name().into(),
442            });
443        }
444    };
445    let (params, body, captured_env) = match eval_expr(lambda_expr, env, ctx).await {
446        Value::Lambda {
447            params,
448            body,
449            captured_env,
450        } => (params, body, captured_env),
451        other => {
452            return Value::Err(RuntimeError::TypeMismatch {
453                expected: "lambda".into(),
454                actual: other.kind_name().into(),
455            });
456        }
457    };
458    if params.len() != 1 {
459        return Value::Err(RuntimeError::ToolFailed(format!(
460            "list.find: lambda must have 1 parameter, got {}",
461            params.len()
462        )));
463    }
464
465    for item in items {
466        let mut call_env = captured_env.child();
467        call_env.bind(params[0].name.clone(), item.clone());
468        let found = eval_expr(&body, &call_env, ctx).await;
469        if let Value::Bool(true) = found {
470            return item;
471        }
472    }
473    Value::Unit
474}
475
476async fn eval_list_any<'a>(args: &'a [Arg], env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
477    let list_expr = match arg_positional(args, 0, "list.any") {
478        Ok(e) => e,
479        Err(e) => return Value::Err(e),
480    };
481    let lambda_expr = match arg_positional(args, 1, "list.any") {
482        Ok(e) => e,
483        Err(e) => return Value::Err(e),
484    };
485
486    let items = match eval_expr(list_expr, env, ctx).await {
487        Value::List(items) => items,
488        other => {
489            return Value::Err(RuntimeError::TypeMismatch {
490                expected: "list".into(),
491                actual: other.kind_name().into(),
492            });
493        }
494    };
495    let (params, body, captured_env) = match eval_expr(lambda_expr, env, ctx).await {
496        Value::Lambda {
497            params,
498            body,
499            captured_env,
500        } => (params, body, captured_env),
501        other => {
502            return Value::Err(RuntimeError::TypeMismatch {
503                expected: "lambda".into(),
504                actual: other.kind_name().into(),
505            });
506        }
507    };
508    if params.len() != 1 {
509        return Value::Err(RuntimeError::ToolFailed(format!(
510            "list.any: lambda must have 1 parameter, got {}",
511            params.len()
512        )));
513    }
514
515    for item in items {
516        let mut call_env = captured_env.child();
517        call_env.bind(params[0].name.clone(), item);
518        let found = eval_expr(&body, &call_env, ctx).await;
519        if let Value::Bool(true) = found {
520            return Value::Bool(true);
521        }
522    }
523    Value::Bool(false)
524}
525
526async fn eval_list_all<'a>(args: &'a [Arg], env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
527    let list_expr = match arg_positional(args, 0, "list.all") {
528        Ok(e) => e,
529        Err(e) => return Value::Err(e),
530    };
531    let lambda_expr = match arg_positional(args, 1, "list.all") {
532        Ok(e) => e,
533        Err(e) => return Value::Err(e),
534    };
535
536    let items = match eval_expr(list_expr, env, ctx).await {
537        Value::List(items) => items,
538        other => {
539            return Value::Err(RuntimeError::TypeMismatch {
540                expected: "list".into(),
541                actual: other.kind_name().into(),
542            });
543        }
544    };
545    let (params, body, captured_env) = match eval_expr(lambda_expr, env, ctx).await {
546        Value::Lambda {
547            params,
548            body,
549            captured_env,
550        } => (params, body, captured_env),
551        other => {
552            return Value::Err(RuntimeError::TypeMismatch {
553                expected: "lambda".into(),
554                actual: other.kind_name().into(),
555            });
556        }
557    };
558    if params.len() != 1 {
559        return Value::Err(RuntimeError::ToolFailed(format!(
560            "list.all: lambda must have 1 parameter, got {}",
561            params.len()
562        )));
563    }
564
565    for item in items {
566        let mut call_env = captured_env.child();
567        call_env.bind(params[0].name.clone(), item);
568        let ok = eval_expr(&body, &call_env, ctx).await;
569        if !matches!(ok, Value::Bool(true)) {
570            return Value::Bool(false);
571        }
572    }
573    Value::Bool(true)
574}
575
576pub async fn eval_dynamic_fanout<'a>(
577    source: &'a Expr,
578    lambda: &'a Expr,
579    collect: &'a atman_dsl::ast::FanoutCollect,
580    env: &'a Env,
581    ctx: &'a EvalCtx<'a>,
582) -> Value {
583    let list_val = eval_expr(source, env, ctx).await;
584    let Value::List(items) = list_val else {
585        return Value::Err(RuntimeError::TypeMismatch {
586            expected: "list".into(),
587            actual: list_val.kind_name().into(),
588        });
589    };
590
591    let lambda_val = eval_expr(lambda, env, ctx).await;
592    let Value::Lambda {
593        params,
594        body,
595        captured_env,
596    } = lambda_val
597    else {
598        return Value::Err(RuntimeError::TypeMismatch {
599            expected: "lambda".into(),
600            actual: lambda_val.kind_name().into(),
601        });
602    };
603
604    let mut results = Vec::new();
605    for item in items {
606        let mut call_env = captured_env.child();
607        if let Some(param) = params.first() {
608            call_env.bind(param.name.clone(), item);
609        }
610        let result = eval_expr(&body, &call_env, ctx).await;
611        if let Value::Err(e) = &result {
612            return Value::Err(e.clone());
613        }
614        if matches!(collect, atman_dsl::ast::FanoutCollect::First) {
615            return result;
616        }
617        results.push(result);
618    }
619
620    match collect {
621        atman_dsl::ast::FanoutCollect::All => Value::List(results),
622        atman_dsl::ast::FanoutCollect::First => results.into_iter().next().unwrap_or(Value::Unit),
623    }
624}
625
626/// Check if an identifier name is a known type annotation.
627pub(crate) fn is_type_name(name: &str) -> bool {
628    matches!(
629        name,
630        "bool" | "int" | "float" | "string" | "path" | "bytes" | "duration"
631    )
632}
633
634/// Check if an Expr is a type list expression like `[string]` or `[int]`.
635fn is_type_list_expr(expr: &Expr) -> bool {
636    match expr {
637        Expr::List(items) if items.len() == 1 => {
638            matches!(&items[0], Expr::Ident(id) if is_type_name(&id.name))
639        }
640        _ => false,
641    }
642}
643
644/// Convert a type Expr to its string representation.
645fn type_expr_to_string(expr: &Expr) -> String {
646    match expr {
647        Expr::Ident(id) => id.name.clone(),
648        Expr::List(items) if items.len() == 1 => {
649            if let Expr::Ident(id) = &items[0] {
650                format!("list of {}", id.name)
651            } else {
652                "list".to_string()
653            }
654        }
655        _ => "unknown".to_string(),
656    }
657}
658
659async fn eval_annotated<'a>(
660    expr: &'a Expr,
661    annotation: &str,
662    env: &'a Env,
663    ctx: &'a EvalCtx<'a>,
664) -> Value {
665    match expr {
666        // Scalar type name → { type, desc }
667        Expr::Ident(id) if is_type_name(&id.name) => Value::Struct(vec![
668            ("type".into(), Value::Str(id.name.clone())),
669            ("desc".into(), Value::Str(annotation.to_string())),
670        ]),
671        // List type → { type: "list of X", desc }
672        Expr::List(_) if is_type_list_expr(expr) => {
673            let type_str = type_expr_to_string(expr);
674            Value::Struct(vec![
675                ("type".into(), Value::Str(type_str)),
676                ("desc".into(), Value::Str(annotation.to_string())),
677            ])
678        }
679        // Other expressions → evaluate, discard annotation
680        _ => eval_expr(expr, env, ctx).await,
681    }
682}
683
684async fn eval_pipe<'a>(lhs: &'a Expr, rhs: &'a Expr, env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
685    let piped = eval_expr(lhs, env, ctx).await;
686    if piped.is_err() {
687        return piped;
688    }
689    match rhs {
690        Expr::Node(Node::ToolCall { path, args }) => {
691            dispatch_tool_call(path, args, vec![piped], env, ctx).await
692        }
693        other => Value::Err(RuntimeError::ToolFailed(format!(
694            "pipe rhs must be a tool call like `ns.tool(...)`, got {}",
695            expr_shape(other)
696        ))),
697    }
698}
699
700pub(crate) fn expr_shape(e: &Expr) -> &'static str {
701    match e {
702        Expr::Literal(_) => "literal",
703        Expr::Ident(_) => "identifier",
704        Expr::FileRef(_) => "file ref",
705        Expr::Member { .. } => "member access",
706        Expr::Binary { .. } => "binary expr",
707        Expr::Unary { .. } => "unary expr",
708        Expr::Call { .. } => "bare call",
709        Expr::Pipe { .. } => "pipe expr",
710        Expr::Struct(_) => "struct literal",
711        Expr::List(_) => "list literal",
712        Expr::Node(_) => "flow node",
713        Expr::Annotated { expr, .. } => expr_shape(expr),
714        Expr::Lambda { .. } => "lambda",
715    }
716}
717
718async fn dispatch_tool_call<'a>(
719    path: &'a [atman_dsl::ast::Ident],
720    args: &'a [Arg],
721    prefix_positional: Vec<Value>,
722    env: &'a Env,
723    ctx: &'a EvalCtx<'a>,
724) -> Value {
725    if ctx.flow_cancel.is_cancelled() {
726        return Value::Err(RuntimeError::Cancelled("flow cancelled by user".into()));
727    }
728    let name = tool_name(path);
729    let tool = match ctx.tools.get(&name) {
730        Some(t) => t,
731        None => {
732            if is_type_annotation(path) {
733                return Value::Unit;
734            }
735            return Value::Err(RuntimeError::UndefinedTool(name));
736        }
737    };
738    if matches!(tool.tier(), crate::tool::Tier::Four) && !contract_allows_shell(ctx.contract) {
739        return Value::Err(RuntimeError::ToolFailed(format!(
740            "tool `{name}` is Tier 4 (shell); flow contract must declare `capabilities {{ shell: true }}`"
741        )));
742    }
743    let mut positional = prefix_positional;
744    let mut named = Vec::new();
745    for arg in args {
746        match arg {
747            Arg::Positional(e) => {
748                let v = eval_expr(e, env, ctx).await;
749                if v.is_err() {
750                    return v;
751                }
752                positional.push(v);
753            }
754            Arg::Named { name, value } => {
755                let v = eval_expr(value, env, ctx).await;
756                if v.is_err() {
757                    return v;
758                }
759                named.push((name.name.clone(), v));
760            }
761        }
762    }
763    let ctx_with_anchors = ctx
764        .tool_ctx
765        .clone()
766        .with_anchors(
767            ctx.turn_id.clone(),
768            ctx.flow_run_id.clone(),
769            ctx.events.map(|s| s.next_seq_peek()),
770        )
771        .with_registry(std::sync::Arc::new(ctx.tools.clone()));
772    let ctx_with_anchors = if let Some(sink) = ctx.events {
773        ctx_with_anchors.with_events(sink.clone())
774    } else {
775        ctx_with_anchors
776    };
777    let ctx_with_anchors = if matches!(tool.tier(), crate::tool::Tier::Four) {
778        ctx_with_anchors
779    } else {
780        let mut c = ctx_with_anchors;
781        c.sandbox = None;
782        c
783    };
784    let ctx_with_anchors = if let Some(session) = ctx.session_runtime.as_ref() {
785        ctx_with_anchors
786            .with_session_messages(session.messages_full())
787            .with_session_messages_handle(session.messages_handle())
788            .with_session_runtime(session.clone())
789            .with_watch_hub(std::sync::Arc::clone(&session.watch_hub))
790            .with_flow_registry(std::sync::Arc::clone(&session.flow_registry))
791            .with_compact_lock_handle(session.compact_lock_handle())
792    } else {
793        ctx_with_anchors
794    };
795    let ctx_with_anchors = ctx_with_anchors.with_current_node(ctx.current_node_id.clone());
796    let ctx_with_anchors = if let Some(s) = ctx.safety.cloned() {
797        ctx_with_anchors.with_safety(s)
798    } else {
799        ctx_with_anchors
800    };
801    let ctx_with_anchors =
802        ctx_with_anchors.with_providers(std::sync::Arc::new(ctx.providers.clone()));
803    let mut ctx_with_anchors = if let Some(model) = &ctx.tool_ctx.current_model {
804        ctx_with_anchors.with_current_model(model.clone())
805    } else {
806        ctx_with_anchors
807    };
808    if let Some(tx) = ctx.tool_ctx.stream_tx.clone() {
809        ctx_with_anchors = ctx_with_anchors.with_stream_tx(tx);
810    }
811    let ctx_with_anchors = if let Some(session) = ctx.session_runtime.as_ref() {
812        let mut c = ctx_with_anchors
813            .with_read_files(session.read_files())
814            .with_approval(session.approval())
815            .with_session_dir(session.dir().to_path_buf())
816            .with_output_store(session.output_store())
817            .with_session_id(session.id().to_string());
818        if let Some(idx) = session.project_index() {
819            c = c.with_project_index(idx);
820        }
821        c = c.with_fs_access(session_fs_access_policy(session));
822        c = c.with_forms(session.forms());
823        {
824            let s = session.clone();
825            c.on_memory_recent = Some(std::sync::Arc::new(move |count| {
826                s.set_memory_recent_count(count);
827            }));
828        }
829        {
830            let store = std::sync::Arc::new(crate::history_store::HistoryStoreImpl::new(
831                session.project_index(),
832                Some(session.clone()),
833                Some(session.id().to_string()),
834                Some(
835                    session
836                        .dir()
837                        .parent()
838                        .map(|p| p.to_path_buf())
839                        .unwrap_or_default(),
840                ),
841            ));
842            c = c.with_history_store(store);
843        }
844        c
845    } else {
846        ctx_with_anchors
847    };
848    let stream_tx = ctx.session_runtime.as_ref().map(|s| s.stream_tx());
849    let tool_call_id = uuid::Uuid::now_v7().to_string();
850    let args_preview = preview_tool_args(&positional, &named);
851    if let (Some(sink), Some(run_id), Some(parent_node)) =
852        (ctx.events, ctx.flow_run_id.clone(), &ctx.current_node_id)
853    {
854        sink.emit(crate::event::Event::ToolNode {
855            run_id: run_id.clone(),
856            parent_node_id: parent_node.clone(),
857            tool_use_id: tool_call_id.clone(),
858            tool_name: name.clone(),
859            args_preview: args_preview.clone(),
860        });
861        if let Some(tx) = &stream_tx {
862            let _ = tx.send(crate::stream::StreamFrame::ToolNode {
863                run_id: run_id.0.to_string(),
864                parent_node_id: parent_node.clone(),
865                tool_use_id: tool_call_id.clone(),
866                tool: name.clone(),
867                args_preview: args_preview.clone(),
868            });
869        }
870    }
871    if let Some(tx) = &stream_tx {
872        let _ = tx.send(crate::stream::StreamFrame::ToolUseStart {
873            tool: name.clone(),
874            args_preview: args_preview.clone(),
875            id: tool_call_id.clone(),
876        });
877    }
878    let call_args = ToolArgs { positional, named };
879    let diff_preview = prepare_diff_preview(&name, &call_args);
880    let level = tool.approval_level(&call_args, &ctx_with_anchors);
881    let gate = crate::approval::request_approval(
882        &ctx_with_anchors,
883        &tool_call_id,
884        &name,
885        &call_args,
886        level,
887        Some(tool.as_ref()),
888    )
889    .await;
890    let outcome = match gate {
891        crate::approval::ApprovalOutcome::Deny { reason } => Err(RuntimeError::ToolFailed(
892            format!("tool `{name}` denied by user: {reason}"),
893        )),
894        crate::approval::ApprovalOutcome::Approve => tool.call(call_args, &ctx_with_anchors).await,
895    };
896    if let Some(tx) = &stream_tx {
897        let (ok, preview) = match &outcome {
898            Ok(v) => (true, preview_tool_value(v)),
899            Err(e) => (false, format!("{e}")),
900        };
901        let _ = tx.send(crate::stream::StreamFrame::ToolUseDone {
902            tool: name.clone(),
903            ok,
904            preview,
905            id: tool_call_id,
906        });
907    }
908    if let Some(session) = ctx.session_runtime.as_ref()
909        && (name == "memory.todo.set" || name == "memory.todo.done")
910    {
911        session.refresh_todos_from_store_async().await;
912    }
913    if let Some(session) = ctx.session_runtime.as_ref()
914        && (name == "plan.write" || name == "plan.tick")
915    {
916        session.refresh_plans_from_store_async().await;
917    }
918    if let (Some(sink), Ok(value)) = (ctx.events, &outcome) {
919        if let Some((title, old_content, new_content, unified_diff)) =
920            complete_diff_preview(diff_preview, &name, value)
921        {
922            sink.emit(crate::event::Event::DiffPreview {
923                turn_id: ctx.turn_id.clone(),
924                flow_run_id: ctx.flow_run_id.clone(),
925                title,
926                old_content,
927                new_content,
928                unified_diff,
929            });
930        }
931    }
932    match outcome {
933        Ok(v) => v,
934        Err(e) => Value::Err(e),
935    }
936}
937
938type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
939
940fn prepare_diff_preview(name: &str, args: &ToolArgs) -> Option<DiffPreviewData> {
941    match name {
942        "fs.write" => {
943            let path = tool_arg_path(args, "path", 0)?;
944            let content = tool_arg_string(args, "content", 1)?;
945            let path_str = path.display().to_string();
946            let diff = match std::fs::read_to_string(&path).ok() {
947                Some(old) => crate::tools::fs::unified_diff_preview(&path_str, &old, &content),
948                None => format!("+++ {path_str}\n{content}"),
949            };
950            Some((path_str, None, None, Some(diff)))
951        }
952        "fs.edit" => {
953            let path = tool_arg_path(args, "path", 0)?;
954            let old_string = tool_arg_string(args, "old_string", 1)?;
955            let new_string = tool_arg_string(args, "new_string", 2)?;
956            let replace_all = matches!(args.named("replace_all"), Some(Value::Bool(true)));
957            let old = std::fs::read_to_string(&path).ok()?;
958            let new = if replace_all {
959                old.replace(&old_string, &new_string)
960            } else {
961                old.replacen(&old_string, &new_string, 1)
962            };
963            let path_str = path.display().to_string();
964            let diff = crate::tools::fs::unified_diff_preview(&path_str, &old, &new);
965            Some((path_str, None, None, Some(diff)))
966        }
967        _ => None,
968    }
969}
970
971fn complete_diff_preview(
972    prepared: Option<DiffPreviewData>,
973    name: &str,
974    value: &Value,
975) -> Option<DiffPreviewData> {
976    if prepared.is_some() {
977        return prepared;
978    }
979    match name {
980        "git.diff" => Some((
981            "git diff".into(),
982            None,
983            None,
984            value_struct_string(value, "diff"),
985        )),
986        "git.show" => Some((
987            value_struct_string(value, "sha")
988                .map(|sha| format!("git show {sha}"))
989                .unwrap_or_else(|| "git show".into()),
990            None,
991            None,
992            value_struct_string(value, "diff"),
993        )),
994        "git.log" => Some((
995            "git log HEAD".into(),
996            None,
997            None,
998            value_struct_string(value, "diff"),
999        )),
1000        _ => None,
1001    }
1002}
1003
1004fn tool_arg_string(args: &ToolArgs, name: &str, pos: usize) -> Option<String> {
1005    let value = args.named(name).or_else(|| args.positional.get(pos))?;
1006    match value {
1007        Value::Str(s) => Some(s.clone()),
1008        _ => None,
1009    }
1010}
1011
1012fn tool_arg_path(args: &ToolArgs, name: &str, pos: usize) -> Option<std::path::PathBuf> {
1013    let value = args.named(name).or_else(|| args.positional.get(pos))?;
1014    match value {
1015        Value::Path(p) => Some(p.clone()),
1016        Value::Str(s) => Some(std::path::PathBuf::from(s)),
1017        _ => None,
1018    }
1019}
1020
1021fn value_struct_string(value: &Value, name: &str) -> Option<String> {
1022    let Value::Struct(fields) = value else {
1023        return None;
1024    };
1025    fields.iter().find_map(|(k, v)| match (k.as_str(), v) {
1026        (key, Value::Str(s)) if key == name => Some(s.clone()),
1027        _ => None,
1028    })
1029}
1030
1031#[derive(Default)]
1032pub(super) struct StreamCallCtx<'a> {
1033    session: Option<&'a crate::session::Session>,
1034    stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
1035    flow_run_id: Option<&'a crate::event::FlowRunId>,
1036    agent_entry: Option<&'a std::sync::Arc<crate::tools::agent_ctrl::FlowEntry>>,
1037    event_sink: Option<&'a crate::event::EventSink>,
1038    turn_id: Option<crate::event::TurnId>,
1039}
1040
1041pub(super) async fn call_and_maybe_stream(
1042    provider: &dyn crate::provider::Provider,
1043    req: crate::provider::LlmRequest,
1044    stream_ctx: StreamCallCtx<'_>,
1045    watch_rules: Option<crate::streaming::WatchRules>,
1046) -> Result<crate::provider::AssistantMessage, RuntimeError> {
1047    let mut base = LlmStream::new(provider, req)
1048        .with_event_sink(stream_ctx.event_sink)
1049        .with_turn_id(stream_ctx.turn_id)
1050        .with_flow_run_id(stream_ctx.flow_run_id.cloned());
1051
1052    let result = if let Some(tx) = stream_ctx.stream_tx {
1053        let mut stream = base.with_stream_tx(tx);
1054        if let Some(rules) = watch_rules {
1055            stream = stream.with_watch_rules(rules);
1056        }
1057        if let Some(session) = stream_ctx.session {
1058            stream = stream.with_session(session);
1059        }
1060        if let Some(entry) = stream_ctx.agent_entry {
1061            stream = stream.with_entry(entry);
1062        }
1063        stream.run().await
1064    } else {
1065        base.run().await
1066    };
1067    if let (Some(sess), Err(RuntimeError::AttachmentError { reason })) =
1068        (stream_ctx.session, &result)
1069    {
1070        let count = sess.record_attachment_degrade(reason);
1071        if count > 0 {
1072            let _ = sess.stream_tx().send(crate::stream::StreamFrame::Note(format!(
1073                "attachment degraded ({reason}); {count} image part(s) replaced. re-issue your last message to retry without them."
1074            )));
1075        }
1076    }
1077    result
1078}
1079
1080fn preview_tool_args(positional: &[Value], named: &[(String, Value)]) -> String {
1081    let mut parts: Vec<String> = positional.iter().map(preview_tool_value).collect();
1082    for (k, v) in named {
1083        parts.push(format!("{k}={}", preview_tool_value(v)));
1084    }
1085    truncate(&parts.join(", "), 4000)
1086}
1087
1088fn available_models_system_prompt() -> Option<String> {
1089    let mut aliases = crate::model_registry::all_aliases();
1090    let mut models = crate::model_registry::all_model_entries();
1091    if aliases.is_empty() && models.is_empty() {
1092        return None;
1093    }
1094    aliases.sort_by(|a, b| a.0.cmp(&b.0));
1095    models.sort_by(|a, b| a.0.cmp(&b.0));
1096    let mut lines = vec!["[available models]".to_string()];
1097    if !aliases.is_empty() {
1098        lines.push(format!(
1099            "Aliases: {}",
1100            aliases
1101                .into_iter()
1102                .map(|(alias, model)| format!("{alias} -> {model}"))
1103                .collect::<Vec<_>>()
1104                .join(", ")
1105        ));
1106    }
1107    if !models.is_empty() {
1108        lines.push(format!(
1109            "Models: {}",
1110            models
1111                .into_iter()
1112                .map(|(name, _)| {
1113                    let info = crate::model_registry::model_info(&name);
1114                    let thinking = if info.thinking_enabled() {
1115                        ", thinking"
1116                    } else {
1117                        ""
1118                    };
1119                    format!(
1120                        "{} ({} context{})",
1121                        info.name,
1122                        crate::humanize::format_count(info.context_budget),
1123                        thinking
1124                    )
1125                })
1126                .collect::<Vec<_>>()
1127                .join(", ")
1128        ));
1129    }
1130    lines.push("Use these names or aliases with flow.spawn's model parameter.".into());
1131    lines.push("[/available models]".into());
1132    Some(lines.join("\n"))
1133}
1134
1135fn working_directory_system_prompt(session: &crate::session::Session) -> Option<String> {
1136    let meta = session.meta()?;
1137    let cwd = meta
1138        .start_path
1139        .as_deref()
1140        .or(meta.project_root.as_deref())?;
1141    let mut lines = vec!["[working directory]".to_string()];
1142    lines.push(cwd.display().to_string());
1143    // Live re-detect in case .git/.atman was created after session start.
1144    let live_root = crate::session_meta::find_project_root(cwd);
1145    match (&live_root, &meta.project_root) {
1146        (Some(live), Some(stored)) if live == stored => {
1147            if Some(live.as_path()) != meta.start_path.as_deref() {
1148                lines.push(format!("project root: {}", live.display()));
1149            }
1150        }
1151        (Some(live), _) => {
1152            lines.push(format!("project root: {}", live.display()));
1153        }
1154        (None, Some(stored)) => {
1155            lines.push(format!(
1156                "project root (cached): {} (no longer detected)",
1157                stored.display()
1158            ));
1159        }
1160        (None, None) => {
1161            lines.push("(no project root — no .git or .atman found)".into());
1162        }
1163    }
1164    lines.push("[/working directory]".into());
1165    Some(lines.join("\n"))
1166}
1167
1168fn preview_tool_value(v: &Value) -> String {
1169    let raw = match v {
1170        Value::Str(s) => format!("{s:?}"),
1171        Value::Int(n) => n.to_string(),
1172        Value::Bool(b) => b.to_string(),
1173        Value::Float(f) => f.to_string(),
1174        Value::Unit => "()".into(),
1175        Value::List(items) => format!("list[{}]", items.len()),
1176        Value::Struct(f) => {
1177            let stdout = f
1178                .iter()
1179                .find(|(k, _)| k == "stdout")
1180                .and_then(|(_, v)| match v {
1181                    Value::Str(s) => Some(s.as_str()),
1182                    _ => None,
1183                });
1184            let stderr = f
1185                .iter()
1186                .find(|(k, _)| k == "stderr")
1187                .and_then(|(_, v)| match v {
1188                    Value::Str(s) => Some(s.as_str()),
1189                    _ => None,
1190                });
1191            let exit = f
1192                .iter()
1193                .find(|(k, _)| k == "exit")
1194                .and_then(|(_, v)| match v {
1195                    Value::Int(n) => Some(*n),
1196                    _ => None,
1197                });
1198            if let (Some(stdout), Some(exit)) = (stdout, exit) {
1199                let combined = if stdout.is_empty() {
1200                    stderr.unwrap_or("").to_string()
1201                } else {
1202                    stdout.to_string()
1203                };
1204                let lines: Vec<&str> = combined.lines().collect();
1205                if lines.len() > 10 {
1206                    format!(
1207                        "exit={exit}\n{}\n… ({} more lines, see `atman logs` for full output)",
1208                        lines[..10].join("\n"),
1209                        lines.len() - 10
1210                    )
1211                } else {
1212                    format!("exit={exit}\n{combined}")
1213                }
1214            } else {
1215                format!("struct[{}]", f.len())
1216            }
1217        }
1218        Value::Message(_) => "<message>".into(),
1219        Value::Err(e) => format!("err({e})"),
1220        Value::Path(p) => format!("{p:?}"),
1221        Value::EditProposal(_) => "<edit_proposal>".into(),
1222        Value::Lambda { .. } => "<lambda>".into(),
1223    };
1224    truncate(&raw, 2000)
1225}
1226
1227fn truncate(s: &str, max: usize) -> String {
1228    if s.chars().count() <= max {
1229        return s.to_string();
1230    }
1231    let mut out: String = s.chars().take(max).collect();
1232    out.push('…');
1233    out
1234}
1235
1236async fn eval_node<'a>(node: &'a Node, env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
1237    if ctx.flow_cancel.is_cancelled() {
1238        return Value::Err(RuntimeError::Cancelled("flow cancelled by user".into()));
1239    }
1240    match node {
1241        Node::ToolCall { path, args } => {
1242            let path_str = path
1243                .iter()
1244                .map(|p| p.name.as_str())
1245                .collect::<Vec<_>>()
1246                .join(".");
1247            match path_str.as_str() {
1248                "list.map" => return eval_list_map(args, env, ctx).await,
1249                "list.filter" => return eval_list_filter(args, env, ctx).await,
1250                "list.reduce" => return eval_list_reduce(args, env, ctx).await,
1251                "list.find" => return eval_list_find(args, env, ctx).await,
1252                "list.any" => return eval_list_any(args, env, ctx).await,
1253                "list.all" => return eval_list_all(args, env, ctx).await,
1254                _ => {}
1255            }
1256            dispatch_tool_call(path, args, Vec::new(), env, ctx).await
1257        }
1258        Node::DynamicFanout {
1259            source,
1260            lambda,
1261            collect,
1262        } => {
1263            return eval_dynamic_fanout(source, lambda, collect, env, ctx).await;
1264        }
1265        Node::Fanout { items, collect } => match collect {
1266            atman_dsl::ast::FanoutCollect::All => {
1267                let parent_id = ctx.current_node_id.clone();
1268                let branch_ctxs: Vec<EvalCtx<'a>> = (0..items.len())
1269                    .map(|i| {
1270                        let branch_id = match &parent_id {
1271                            Some(p) => format!("{p}.branch[{i}]"),
1272                            None => format!("branch[{i}]"),
1273                        };
1274                        if let (Some(sink), Some(run_id)) = (ctx.events, ctx.flow_run_id.clone()) {
1275                            sink.emit(crate::event::Event::FlowNodeStart {
1276                                run_id: run_id.clone(),
1277                                node_id: branch_id.clone(),
1278                                kind: crate::nodegraph::NodeKind::UserConfirm,
1279                                label: format!("branch[{i}]"),
1280                                parent_node_id: parent_id.clone(),
1281                            });
1282                            if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1283                                let _ = tx.send(crate::stream::StreamFrame::FlowNodeStart {
1284                                    run_id: run_id.0.to_string(),
1285                                    node_id: branch_id.clone(),
1286                                    kind: crate::nodegraph::NodeKind::UserConfirm,
1287                                    label: format!("branch[{i}]"),
1288                                    parent_node_id: parent_id.clone(),
1289                                });
1290                            }
1291                        }
1292                        ctx.with_node(branch_id)
1293                    })
1294                    .collect();
1295                let futs = items
1296                    .iter()
1297                    .zip(branch_ctxs.iter())
1298                    .map(|(item, bctx)| eval_expr(item, env, bctx));
1299                let results: Vec<Value> = futures::future::join_all(futs).await;
1300                for (bctx, v) in branch_ctxs.iter().zip(results.iter()) {
1301                    if let (Some(sink), Some(run_id), Some(bid)) =
1302                        (ctx.events, ctx.flow_run_id.clone(), &bctx.current_node_id)
1303                    {
1304                        let status = if v.is_err() {
1305                            crate::event::FlowNodeStatus::Err
1306                        } else {
1307                            crate::event::FlowNodeStatus::Ok
1308                        };
1309                        sink.emit(crate::event::Event::FlowNodeEnd {
1310                            run_id: run_id.clone(),
1311                            node_id: bid.clone(),
1312                            status: status.clone(),
1313                            output_preview: None,
1314                        });
1315                        if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1316                            let _ = tx.send(crate::stream::StreamFrame::FlowNodeEnd {
1317                                run_id: run_id.0.to_string(),
1318                                node_id: bid.clone(),
1319                                status,
1320                                output_preview: None,
1321                                parent_node_id: parent_id.clone(),
1322                            });
1323                        }
1324                    }
1325                }
1326                for v in &results {
1327                    if let Value::Err(e) = v {
1328                        return Value::Err(e.clone());
1329                    }
1330                }
1331                Value::List(results)
1332            }
1333            atman_dsl::ast::FanoutCollect::First => Value::Err(RuntimeError::ToolFailed(
1334                "fanout collect: first not yet implemented".into(),
1335            )),
1336        },
1337        Node::UserConfirm { msg } => {
1338            let v = eval_expr(msg, env, ctx).await;
1339            if v.is_err() {
1340                return v;
1341            }
1342            let prompt = match &v {
1343                Value::Str(s) => s.clone(),
1344                other => other.kind_name().to_string(),
1345            };
1346            let confirm_kind = crate::form::FormKind::Confirm {
1347                prompt: prompt.clone(),
1348            };
1349            // Daemon clients drive the confirm through the prompt resolver
1350            // over RPC; in-process TUI subscribes to FormRegistry. Boot /
1351            // headless / unit tests without either wired keep the historical
1352            // auto-approve so they don't deadlock.
1353            if let Some(resolver) = ctx.tool_ctx.prompt_resolver.clone() {
1354                let id = crate::rendezvous::PromptId::now();
1355                let payload =
1356                    serde_json::to_value(&confirm_kind).unwrap_or(serde_json::Value::Null);
1357                let timeout = std::time::Duration::from_secs(300);
1358                let result = crate::rendezvous::await_prompt_with_payload(
1359                    &resolver, id, "form_ask", payload, timeout,
1360                )
1361                .await;
1362                let answer: crate::form::FormAnswer = match result {
1363                    Ok(v) => {
1364                        serde_json::from_value(v).unwrap_or(crate::form::FormAnswer::Cancelled)
1365                    }
1366                    Err(_) => crate::form::FormAnswer::Cancelled,
1367                };
1368                return Value::Bool(matches!(
1369                    answer,
1370                    crate::form::FormAnswer::Confirmed { value: true }
1371                ));
1372            }
1373            let Some(session) = ctx.session_runtime.as_ref() else {
1374                return Value::Bool(true);
1375            };
1376            let forms = session.forms();
1377            if forms.subscriber_count() == 0 {
1378                return Value::Bool(true);
1379            }
1380            let Some(run_id) = ctx.flow_run_id.clone() else {
1381                return Value::Bool(true);
1382            };
1383            let pending = crate::form::PendingForm {
1384                form_id: uuid::Uuid::now_v7().to_string(),
1385                run_id,
1386                tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
1387                kind: confirm_kind,
1388                emitted_at: chrono::Utc::now(),
1389            };
1390            let rx = forms.request(pending);
1391            let answer = rx.await.unwrap_or(crate::form::FormAnswer::Cancelled);
1392            Value::Bool(matches!(
1393                answer,
1394                crate::form::FormAnswer::Confirmed { value: true }
1395            ))
1396        }
1397        Node::FixUntilTestPasses { kwargs } => eval_fix_until_test_passes(kwargs, env, ctx).await,
1398        Node::Message { role, args } => eval_message_node(*role, args, env, ctx).await,
1399        Node::Subflow { name, args } => {
1400            let Some(target) = ctx.flows.get(&name.name) else {
1401                return Value::Err(RuntimeError::UndefinedTool(format!(
1402                    "subflow({})",
1403                    name.name
1404                )));
1405            };
1406            let mut bindings = Vec::with_capacity(args.len());
1407            for (i, arg) in args.iter().enumerate() {
1408                let (param_name, value) = match arg {
1409                    Arg::Positional(e) => {
1410                        let Some(p) = target.params.get(i) else {
1411                            return Value::Err(RuntimeError::MissingArg(format!(
1412                                "subflow({}): too many positional args",
1413                                name.name
1414                            )));
1415                        };
1416                        let v = eval_expr(e, env, ctx).await;
1417                        (p.name.name.clone(), v)
1418                    }
1419                    Arg::Named { name: n, value } => {
1420                        let v = eval_expr(value, env, ctx).await;
1421                        (n.name.clone(), v)
1422                    }
1423                };
1424                if value.is_err() {
1425                    return value;
1426                }
1427                bindings.push((param_name, value));
1428            }
1429            let mut sub_env = Env::new();
1430            for (n, v) in bindings {
1431                sub_env.bind(n, v);
1432            }
1433            let sub_run_id = crate::event::FlowRunId::now();
1434            if let Some(sink) = ctx.events {
1435                sink.emit(crate::event::Event::FlowStart {
1436                    run_id: sub_run_id.clone(),
1437                    flow_name: name.name.clone(),
1438                    parent_run_id: ctx.flow_run_id.clone(),
1439                    parent_node_id: ctx.current_node_id.clone(),
1440                    spawned: false,
1441                });
1442            }
1443            if let Some(session) = ctx.session_runtime.as_ref() {
1444                let _ = session
1445                    .stream_tx()
1446                    .send(crate::stream::StreamFrame::FlowStart {
1447                        run_id: sub_run_id.0.to_string(),
1448                        flow_name: name.name.clone(),
1449                        parent_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1450                        parent_node_id: ctx.current_node_id.clone(),
1451                    });
1452            } else if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1453                let _ = tx.send(crate::stream::StreamFrame::FlowStart {
1454                    run_id: sub_run_id.0.to_string(),
1455                    flow_name: name.name.clone(),
1456                    parent_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1457                    parent_node_id: ctx.current_node_id.clone(),
1458                });
1459            }
1460            let sub_ctx = EvalCtx {
1461                flow_run_id: Some(sub_run_id.clone()),
1462                current_node_id: None,
1463                ..ctx.clone()
1464            };
1465            let outcome = crate::exec::exec_stmts(&target.body, &mut sub_env, &sub_ctx).await;
1466            let (result, status, ok) = match outcome {
1467                crate::exec::StmtOutcome::Return(v) => (v, crate::event::FlowStatus::Ok, true),
1468                crate::exec::StmtOutcome::Err(e) => {
1469                    let status = if matches!(&e, crate::error::RuntimeError::Cancelled(_)) {
1470                        crate::event::FlowStatus::Cancelled
1471                    } else {
1472                        crate::event::FlowStatus::Errored {
1473                            message: format!("{e}"),
1474                        }
1475                    };
1476                    (Value::Err(e.clone()), status, false)
1477                }
1478                crate::exec::StmtOutcome::Continue => {
1479                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1480                }
1481                crate::exec::StmtOutcome::LoopBreak => {
1482                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1483                }
1484                crate::exec::StmtOutcome::LoopContinue => {
1485                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1486                }
1487            };
1488            let cancelled = matches!(status, crate::event::FlowStatus::Cancelled);
1489            if let Some(sink) = ctx.events {
1490                sink.emit(crate::event::Event::FlowEnd {
1491                    run_id: sub_run_id.clone(),
1492                    flow_name: name.name.clone(),
1493                    status,
1494                });
1495            }
1496            if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1497                let _ = tx.send(crate::stream::StreamFrame::FlowDone {
1498                    run_id: sub_run_id.0.to_string(),
1499                    flow_name: name.name.clone(),
1500                    ok,
1501                    cancelled,
1502                });
1503            }
1504            result
1505        }
1506    }
1507}
1508
1509fn session_fs_access_policy(session: &crate::session::Session) -> crate::fs_access::FsAccessPolicy {
1510    let workspace = session
1511        .meta()
1512        .and_then(|m| m.project_root)
1513        .or_else(|| std::env::current_dir().ok());
1514    let mode = session
1515        .fs_access_mode()
1516        .unwrap_or(crate::fs_access::FsAccessMode::WorkspaceWrite);
1517    crate::fs_access::FsAccessPolicy { mode, workspace }
1518}
1519
1520fn tool_name(path: &[atman_dsl::ast::Ident]) -> String {
1521    let parts: Vec<&str> = path.iter().map(|i| i.name.as_str()).collect();
1522    parts.join(".")
1523}
1524
1525async fn eval_fix_until_test_passes<'a>(
1526    kwargs: &'a atman_dsl::ast::Kwargs,
1527    env: &'a Env,
1528    ctx: &'a EvalCtx<'a>,
1529) -> Value {
1530    let mut edit_flow_expr: Option<&Expr> = None;
1531    let mut test_expr: Option<&Expr> = None;
1532    let mut on_giveup_expr: Option<&Expr> = None;
1533    let mut max_iters: u32 = 5;
1534    let mut target_path: Option<std::path::PathBuf> = None;
1535
1536    for (k, v) in kwargs {
1537        match k.name.as_str() {
1538            "edit_flow" => edit_flow_expr = Some(v),
1539            "test" => test_expr = Some(v),
1540            "on_giveup" => on_giveup_expr = Some(v),
1541            "max_iters" => match eval_expr(v, env, ctx).await {
1542                Value::Int(n) if n > 0 => max_iters = n as u32,
1543                other => {
1544                    return Value::Err(RuntimeError::TypeMismatch {
1545                        expected: "positive int (max_iters)".into(),
1546                        actual: other.kind_name().into(),
1547                    });
1548                }
1549            },
1550            "target" => match eval_expr(v, env, ctx).await {
1551                Value::Path(p) => target_path = Some(p),
1552                Value::Str(s) => target_path = Some(std::path::PathBuf::from(s)),
1553                Value::Unit => {}
1554                other => {
1555                    return Value::Err(RuntimeError::TypeMismatch {
1556                        expected: "path (target)".into(),
1557                        actual: other.kind_name().into(),
1558                    });
1559                }
1560            },
1561            _ => {}
1562        }
1563    }
1564
1565    let Some(edit_flow_expr) = edit_flow_expr else {
1566        return Value::Err(RuntimeError::MissingArg(
1567            "fix_until_test_passes.edit_flow".into(),
1568        ));
1569    };
1570    let Some(test_expr) = test_expr else {
1571        return Value::Err(RuntimeError::MissingArg(
1572            "fix_until_test_passes.test".into(),
1573        ));
1574    };
1575
1576    let pristine: Option<String> = match &target_path {
1577        Some(p) => match tokio::fs::read_to_string(p).await {
1578            Ok(s) => Some(s),
1579            Err(e) => {
1580                return Value::Err(RuntimeError::ToolFailed(format!(
1581                    "fix_until_test_passes: cannot read target {}: {e}",
1582                    p.display()
1583                )));
1584            }
1585        },
1586        None => None,
1587    };
1588
1589    let mut prev_fail = String::new();
1590    let mut last_test_result: Option<Value> = None;
1591
1592    for iter in 0..max_iters {
1593        let mut loop_env = env.clone();
1594        loop_env.bind("iter", Value::Int(iter as i64));
1595        loop_env.bind("prev_fail", Value::Str(prev_fail.clone()));
1596
1597        let edit_v = eval_expr(edit_flow_expr, &loop_env, ctx).await;
1598        if edit_v.is_err() {
1599            return edit_v;
1600        }
1601        loop_env.bind("last_edit", edit_v);
1602
1603        let test_v = eval_expr(test_expr, &loop_env, ctx).await;
1604        if test_v.is_err() {
1605            return test_v;
1606        }
1607        let exit = test_v
1608            .field("exit_code")
1609            .or_else(|| test_v.field("exit"))
1610            .and_then(|v| match v {
1611                Value::Int(n) => Some(*n),
1612                _ => None,
1613            });
1614        last_test_result = Some(test_v.clone());
1615        if let Some(0) = exit {
1616            return Value::Struct(vec![
1617                ("status".into(), Value::Str("passed".into())),
1618                ("iters".into(), Value::Int((iter + 1) as i64)),
1619                ("test".into(), test_v),
1620            ]);
1621        }
1622        let stderr_tail = test_v
1623            .field("stderr_tail")
1624            .or_else(|| test_v.field("output"))
1625            .and_then(|v| match v {
1626                Value::Str(s) => Some(s.clone()),
1627                _ => None,
1628            })
1629            .unwrap_or_default();
1630        let stdout_tail = test_v
1631            .field("stdout_tail")
1632            .and_then(|v| match v {
1633                Value::Str(s) => Some(s.clone()),
1634                _ => None,
1635            })
1636            .unwrap_or_default();
1637        prev_fail = format!(
1638            "iter {iter} exit={:?}\n--- stderr ---\n{stderr_tail}\n--- stdout ---\n{stdout_tail}",
1639            exit
1640        );
1641
1642        if let (Some(target), Some(pristine)) = (&target_path, &pristine)
1643            && let Err(e) = tokio::fs::write(target, pristine.as_bytes()).await
1644        {
1645            return Value::Err(RuntimeError::ToolFailed(format!(
1646                "fix_until_test_passes: revert failed on {}: {e}",
1647                target.display()
1648            )));
1649        }
1650    }
1651
1652    if let Some(giveup) = on_giveup_expr {
1653        let mut giveup_env = env.clone();
1654        giveup_env.bind("iters", Value::Int(max_iters as i64));
1655        giveup_env.bind("prev_fail", Value::Str(prev_fail));
1656        return eval_expr(giveup, &giveup_env, ctx).await;
1657    }
1658
1659    Value::Struct(vec![
1660        ("status".into(), Value::Str("gave_up".into())),
1661        ("iters".into(), Value::Int(max_iters as i64)),
1662        ("last_test".into(), last_test_result.unwrap_or(Value::Unit)),
1663    ])
1664}
1665
1666async fn eval_message_node<'a>(
1667    ast_role: atman_dsl::ast::MessageRole,
1668    args: &'a [Arg],
1669    env: &'a Env,
1670    ctx: &'a EvalCtx<'a>,
1671) -> Value {
1672    use crate::message::{
1673        ImageData, ImageSource, Message, MessageOrigin, MessagePart, MessageRole,
1674    };
1675
1676    let role = match ast_role {
1677        atman_dsl::ast::MessageRole::User => MessageRole::User,
1678        atman_dsl::ast::MessageRole::Assistant => MessageRole::Assistant,
1679        atman_dsl::ast::MessageRole::System => MessageRole::System,
1680        atman_dsl::ast::MessageRole::Tool => MessageRole::Tool,
1681    };
1682    let turn_id = ctx
1683        .turn_id
1684        .clone()
1685        .unwrap_or_else(crate::event::TurnId::now);
1686
1687    let mut positional = Vec::new();
1688    let mut named: Vec<(String, Value)> = Vec::new();
1689    let mut attachment_paths_raw: Option<Vec<std::path::PathBuf>> = None;
1690    for arg in args {
1691        match arg {
1692            Arg::Positional(e) => {
1693                let v = eval_expr(e, env, ctx).await;
1694                if v.is_err() {
1695                    return v;
1696                }
1697                positional.push(v);
1698            }
1699            Arg::Named { name, value } => {
1700                if name.name == "attachments" {
1701                    if let Expr::List(items) = value {
1702                        let mut collected = Vec::with_capacity(items.len());
1703                        let mut all_fileref = true;
1704                        for it in items {
1705                            if let Expr::FileRef(f) = it {
1706                                collected.push(std::path::PathBuf::from(&f.path));
1707                            } else {
1708                                all_fileref = false;
1709                                break;
1710                            }
1711                        }
1712                        if all_fileref {
1713                            attachment_paths_raw = Some(collected);
1714                            continue;
1715                        }
1716                    }
1717                }
1718                let v = eval_expr(value, env, ctx).await;
1719                if v.is_err() {
1720                    return v;
1721                }
1722                named.push((name.name.clone(), v));
1723            }
1724        }
1725    }
1726    let take_named = |k: &str, named: &mut Vec<(String, Value)>| -> Option<Value> {
1727        let pos = named.iter().position(|(n, _)| n == k)?;
1728        Some(named.remove(pos).1)
1729    };
1730
1731    if role == MessageRole::Tool {
1732        let tool_use_id = match positional.first().or(take_named("id", &mut named).as_ref()) {
1733            Some(Value::Str(s)) => s.clone(),
1734            Some(other) => {
1735                return Value::Err(RuntimeError::TypeMismatch {
1736                    expected: "string (tool_use_id)".into(),
1737                    actual: other.kind_name().into(),
1738                });
1739            }
1740            None => {
1741                return Value::Err(RuntimeError::MissingArg("tool_result: id".into()));
1742            }
1743        };
1744        let content = match positional
1745            .get(1)
1746            .or(take_named("content", &mut named).as_ref())
1747        {
1748            Some(Value::Str(s)) => s.clone(),
1749            Some(other) => {
1750                return Value::Err(RuntimeError::TypeMismatch {
1751                    expected: "string (content)".into(),
1752                    actual: other.kind_name().into(),
1753                });
1754            }
1755            None => {
1756                return Value::Err(RuntimeError::MissingArg("tool_result: content".into()));
1757            }
1758        };
1759        let is_error = match take_named("is_error", &mut named) {
1760            Some(Value::Bool(b)) => b,
1761            Some(other) => {
1762                return Value::Err(RuntimeError::TypeMismatch {
1763                    expected: "bool (is_error)".into(),
1764                    actual: other.kind_name().into(),
1765                });
1766            }
1767            None => false,
1768        };
1769        return Value::Message(Message {
1770            role,
1771            parts: vec![MessagePart::ToolResult {
1772                tool_use_id,
1773                content,
1774                is_error,
1775            }],
1776            turn_id,
1777            origin: MessageOrigin::User,
1778        });
1779    }
1780
1781    let text = match positional.first() {
1782        Some(Value::Str(s)) => Some(s.clone()),
1783        Some(other) => {
1784            return Value::Err(RuntimeError::TypeMismatch {
1785                expected: "string (message text)".into(),
1786                actual: other.kind_name().into(),
1787            });
1788        }
1789        None => None,
1790    };
1791    let attachment_paths: Vec<std::path::PathBuf> = if let Some(raw) = attachment_paths_raw {
1792        raw
1793    } else {
1794        match take_named("attachments", &mut named) {
1795            Some(Value::List(items)) => {
1796                let mut ps = Vec::with_capacity(items.len());
1797                for it in items {
1798                    match it {
1799                        Value::Path(p) => ps.push(p),
1800                        Value::Str(s) => ps.push(std::path::PathBuf::from(s)),
1801                        other => {
1802                            return Value::Err(RuntimeError::TypeMismatch {
1803                                expected: "path (attachment)".into(),
1804                                actual: other.kind_name().into(),
1805                            });
1806                        }
1807                    }
1808                }
1809                ps
1810            }
1811            Some(other) => {
1812                return Value::Err(RuntimeError::TypeMismatch {
1813                    expected: "list of path".into(),
1814                    actual: other.kind_name().into(),
1815                });
1816            }
1817            None => Vec::new(),
1818        }
1819    };
1820
1821    let mut parts: Vec<MessagePart> = attachment_paths
1822        .into_iter()
1823        .map(|path| {
1824            let media_type = guess_image_mime(&path).unwrap_or_else(|| "image/png".to_string());
1825            MessagePart::Image {
1826                source: ImageSource {
1827                    media_type,
1828                    data: ImageData::Path { path },
1829                },
1830            }
1831        })
1832        .collect();
1833    if let Some(t) = text {
1834        parts.push(MessagePart::Text { text: t });
1835    }
1836
1837    Value::Message(Message {
1838        role,
1839        parts,
1840        turn_id,
1841        origin: MessageOrigin::User,
1842    })
1843}
1844
1845pub(super) fn render_injections(injections: &[crate::injection::Injection]) -> String {
1846    use crate::injection::{InjectionLevel, InjectionSource};
1847    let has_user = injections
1848        .iter()
1849        .any(|i| matches!(i.source, InjectionSource::User));
1850    let has_watcher = injections
1851        .iter()
1852        .any(|i| matches!(i.source, InjectionSource::Watcher { .. }));
1853    let mut out = String::new();
1854    if has_user {
1855        out.push_str(
1856            "The user sent the following steering message(s) while you were working. \
1857             Apply them to your next step if still relevant.\n\n",
1858        );
1859    }
1860    if has_watcher {
1861        out.push_str("A background watcher detected the following event(s):\n\n");
1862    }
1863    for inj in injections {
1864        let (tag, source_attr) = match &inj.source {
1865            InjectionSource::User => match inj.level {
1866                InjectionLevel::L2CourseCorrect => ("user_correction", "user".to_string()),
1867                _ => ("user_nudge", "user".to_string()),
1868            },
1869            InjectionSource::Watcher {
1870                watcher_id,
1871                kind,
1872                handle,
1873            } => (
1874                "watcher_event",
1875                format!("{kind} '{handle}' watcher {watcher_id}"),
1876            ),
1877        };
1878        out.push_str(&format!(
1879            "<{tag} id=\"{}\" ts=\"{}\" source=\"{}\">\n{}\n</{tag}>\n",
1880            inj.id.0,
1881            inj.created_at.to_rfc3339(),
1882            source_attr,
1883            inj.text
1884        ));
1885    }
1886    out
1887}
1888
1889fn guess_image_mime(path: &std::path::Path) -> Option<String> {
1890    let ext = path
1891        .extension()
1892        .and_then(|s| s.to_str())?
1893        .to_ascii_lowercase();
1894    Some(
1895        match ext.as_str() {
1896            "png" => "image/png",
1897            "jpg" | "jpeg" => "image/jpeg",
1898            "gif" => "image/gif",
1899            "webp" => "image/webp",
1900            _ => return None,
1901        }
1902        .to_string(),
1903    )
1904}
1905
1906fn contract_allows_shell(contract: Option<&atman_dsl::ast::Contract>) -> bool {
1907    let Some(c) = contract else { return false };
1908    for block in &c.blocks {
1909        if block.name.name != "capabilities" {
1910            continue;
1911        }
1912        for (k, v) in &block.kwargs {
1913            if k.name != "shell" {
1914                continue;
1915            }
1916            if let atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Bool(true)) = v {
1917                return true;
1918            }
1919        }
1920    }
1921    false
1922}
1923
1924pub struct TruncationStat {
1925    pub original_chars: usize,
1926    pub result_chars: usize,
1927    pub dropped_chars: usize,
1928    pub budget_tokens: u64,
1929}
1930
1931pub(super) fn sanitize_tool_pairs(
1932    messages: Vec<crate::message::Message>,
1933) -> Vec<crate::message::Message> {
1934    use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
1935    use std::collections::HashMap;
1936    let mut result_by_id: HashMap<String, Message> = HashMap::new();
1937    for m in &messages {
1938        for p in &m.parts {
1939            if let MessagePart::ToolResult { tool_use_id, .. } = p {
1940                result_by_id
1941                    .entry(tool_use_id.clone())
1942                    .or_insert_with(|| Message {
1943                        role: MessageRole::Tool,
1944                        parts: vec![p.clone()],
1945                        turn_id: m.turn_id.clone(),
1946                        origin: MessageOrigin::User,
1947                    });
1948            }
1949        }
1950    }
1951    let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 4);
1952    for m in &messages {
1953        let uses: Vec<String> = m
1954            .parts
1955            .iter()
1956            .filter_map(|p| match p {
1957                MessagePart::ToolUse { id, .. } => Some(id.clone()),
1958                _ => None,
1959            })
1960            .collect();
1961        let is_pure_result = m
1962            .parts
1963            .iter()
1964            .all(|p| matches!(p, MessagePart::ToolResult { .. }));
1965        if is_pure_result {
1966            continue;
1967        }
1968        out.push(m.clone());
1969        if !uses.is_empty() {
1970            for u in &uses {
1971                if let Some(rm) = result_by_id.get(u) {
1972                    if let Some(MessagePart::ToolResult {
1973                        tool_use_id,
1974                        content,
1975                        is_error,
1976                    }) = rm.parts.first()
1977                    {
1978                        out.push(Message {
1979                            role: MessageRole::Tool,
1980                            parts: vec![MessagePart::ToolResult {
1981                                tool_use_id: tool_use_id.clone(),
1982                                content: content.clone(),
1983                                is_error: *is_error,
1984                            }],
1985                            turn_id: m.turn_id.clone(),
1986                            origin: MessageOrigin::User,
1987                        });
1988                    }
1989                } else {
1990                    out.push(Message {
1991                        role: MessageRole::Tool,
1992                        parts: vec![MessagePart::ToolResult {
1993                            tool_use_id: u.clone(),
1994                            content: "[tool execution interrupted — no result captured]".into(),
1995                            is_error: true,
1996                        }],
1997                        turn_id: m.turn_id.clone(),
1998                        origin: MessageOrigin::User,
1999                    });
2000                }
2001            }
2002        }
2003    }
2004    out
2005}
2006
2007pub fn truncate_prompt_to_budget(prompt: String, budget_tokens: u64) -> String {
2008    truncate_prompt_to_budget_tracked(prompt, budget_tokens).0
2009}
2010
2011pub fn truncate_prompt_to_budget_tracked(
2012    prompt: String,
2013    budget_tokens: u64,
2014) -> (String, Option<TruncationStat>) {
2015    let budget_chars = budget_tokens.saturating_mul(4) as usize;
2016    if prompt.len() <= budget_chars {
2017        return (prompt, None);
2018    }
2019    let head_chars = budget_chars * 4 / 10;
2020    let tail_chars = budget_chars * 4 / 10;
2021    if head_chars + tail_chars >= prompt.len() {
2022        return (prompt, None);
2023    }
2024    let original_chars = prompt.len();
2025    let head_end = char_boundary(&prompt, head_chars, false);
2026    let tail_start = char_boundary(&prompt, prompt.len().saturating_sub(tail_chars), true);
2027    let head = &prompt[..head_end];
2028    let tail = &prompt[tail_start..];
2029    let dropped = original_chars - head.len() - tail.len();
2030    let result = format!("{head}\n\n[... truncated {dropped} chars ...]\n\n{tail}");
2031    let stat = TruncationStat {
2032        original_chars,
2033        result_chars: result.len(),
2034        dropped_chars: dropped,
2035        budget_tokens,
2036    };
2037    (result, Some(stat))
2038}
2039
2040fn char_boundary(s: &str, target: usize, round_up: bool) -> usize {
2041    let mut idx = target.min(s.len());
2042    while idx > 0 && idx < s.len() && !s.is_char_boundary(idx) {
2043        if round_up {
2044            idx += 1;
2045        } else {
2046            idx -= 1;
2047        }
2048    }
2049    idx
2050}
2051
2052// Bare primitive names inside `schema: { valid: bool, ... }` parse as tool calls; treat as Unit.
2053fn is_type_annotation(path: &[atman_dsl::ast::Ident]) -> bool {
2054    if path.len() != 1 {
2055        return false;
2056    }
2057    matches!(
2058        path[0].name.as_str(),
2059        "bool" | "int" | "float" | "string" | "path" | "bytes" | "duration"
2060    )
2061}
2062
2063fn eval_literal(lit: &Literal) -> Value {
2064    match lit {
2065        Literal::Str(s) => Value::Str(s.clone()),
2066        Literal::Int(n) => Value::Int(*n),
2067        Literal::Float(f) => Value::Float(*f),
2068        Literal::Bool(b) => Value::Bool(*b),
2069    }
2070}
2071
2072fn eval_binop(op: BinOp, l: &Value, r: &Value) -> Value {
2073    match op {
2074        BinOp::Eq => Value::Bool(value_eq(l, r)),
2075        BinOp::Ne => Value::Bool(!value_eq(l, r)),
2076        BinOp::Lt => value_cmp(l, r, |a, b| a < b, |a, b| a < b, |a, b| a < b),
2077        BinOp::Le => value_cmp(l, r, |a, b| a <= b, |a, b| a <= b, |a, b| a <= b),
2078        BinOp::Gt => value_cmp(l, r, |a, b| a > b, |a, b| a > b, |a, b| a > b),
2079        BinOp::Ge => value_cmp(l, r, |a, b| a >= b, |a, b| a >= b, |a, b| a >= b),
2080        BinOp::And => match (l, r) {
2081            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a && *b),
2082            _ => type_mismatch("bool && bool", l, r),
2083        },
2084        BinOp::Or => match (l, r) {
2085            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a || *b),
2086            _ => type_mismatch("bool || bool", l, r),
2087        },
2088        BinOp::Add => match (l, r) {
2089            (Value::Int(a), Value::Int(b)) => Value::Int(a + b),
2090            (Value::Float(a), Value::Float(b)) => Value::Float(a + b),
2091            (Value::Str(a), Value::Str(b)) => Value::Str(format!("{a}{b}")),
2092            (Value::Str(a), Value::Path(b)) => Value::Str(format!("{a}{}", b.display())),
2093            (Value::Path(a), Value::Str(b)) => Value::Str(format!("{}{b}", a.display())),
2094            _ => type_mismatch(
2095                "int+int | float+float | string+string | string+path | path+string",
2096                l,
2097                r,
2098            ),
2099        },
2100        BinOp::Sub => match (l, r) {
2101            (Value::Int(a), Value::Int(b)) => Value::Int(a - b),
2102            (Value::Float(a), Value::Float(b)) => Value::Float(a - b),
2103            _ => type_mismatch("int-int | float-float", l, r),
2104        },
2105        BinOp::Mul => match (l, r) {
2106            (Value::Int(a), Value::Int(b)) => Value::Int(a * b),
2107            (Value::Float(a), Value::Float(b)) => Value::Float(a * b),
2108            _ => type_mismatch("int*int | float*float", l, r),
2109        },
2110        BinOp::Div => match (l, r) {
2111            (Value::Int(_), Value::Int(0)) => {
2112                Value::Err(RuntimeError::ToolFailed("integer div by zero".into()))
2113            }
2114            (Value::Int(a), Value::Int(b)) => Value::Int(a / b),
2115            (Value::Float(a), Value::Float(b)) => Value::Float(a / b),
2116            _ => type_mismatch("int/int | float/float", l, r),
2117        },
2118        BinOp::Mod => match (l, r) {
2119            (Value::Int(_), Value::Int(0)) => {
2120                Value::Err(RuntimeError::ToolFailed("integer mod by zero".into()))
2121            }
2122            (Value::Int(a), Value::Int(b)) => Value::Int(a % b),
2123            (Value::Float(a), Value::Float(b)) => Value::Float(a % b),
2124            _ => type_mismatch("int%int | float%float", l, r),
2125        },
2126    }
2127}
2128
2129fn eval_unop(op: UnOp, v: &Value) -> Value {
2130    match op {
2131        UnOp::Not => match v {
2132            Value::Bool(b) => Value::Bool(!b),
2133            other => Value::Err(RuntimeError::TypeMismatch {
2134                expected: "bool".into(),
2135                actual: other.kind_name().into(),
2136            }),
2137        },
2138        UnOp::Neg => match v {
2139            Value::Int(n) => Value::Int(-n),
2140            Value::Float(n) => Value::Float(-n),
2141            other => Value::Err(RuntimeError::TypeMismatch {
2142                expected: "int or float".into(),
2143                actual: other.kind_name().into(),
2144            }),
2145        },
2146    }
2147}
2148
2149fn value_eq(l: &Value, r: &Value) -> bool {
2150    match (l, r) {
2151        (Value::Unit, Value::Unit) => true,
2152        (Value::Bool(a), Value::Bool(b)) => a == b,
2153        (Value::Int(a), Value::Int(b)) => a == b,
2154        (Value::Float(a), Value::Float(b)) => a == b,
2155        (Value::Str(a), Value::Str(b)) => a == b,
2156        (Value::Path(a), Value::Path(b)) => a == b,
2157        _ => false,
2158    }
2159}
2160
2161fn value_cmp(
2162    l: &Value,
2163    r: &Value,
2164    int_cmp: fn(i64, i64) -> bool,
2165    float_cmp: fn(f64, f64) -> bool,
2166    str_cmp: fn(&str, &str) -> bool,
2167) -> Value {
2168    match (l, r) {
2169        (Value::Int(a), Value::Int(b)) => Value::Bool(int_cmp(*a, *b)),
2170        (Value::Float(a), Value::Float(b)) => Value::Bool(float_cmp(*a, *b)),
2171        (Value::Str(a), Value::Str(b)) => Value::Bool(str_cmp(a, b)),
2172        _ => type_mismatch("comparable pair", l, r),
2173    }
2174}
2175
2176fn type_mismatch(expected: &str, l: &Value, r: &Value) -> Value {
2177    Value::Err(RuntimeError::TypeMismatch {
2178        expected: expected.into(),
2179        actual: format!("{} vs {}", l.kind_name(), r.kind_name()),
2180    })
2181}
2182
2183pub(super) fn input_with_cache_for_window(usage: &crate::provider::TokenUsage) -> u64 {
2184    usage.input + usage.cached_input
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189    use super::*;
2190    use atman_dsl::parse::parse_file;
2191
2192    #[test]
2193    fn char_boundary_rounds_around_multibyte_characters() {
2194        let text = "a你😀b";
2195        assert_eq!(char_boundary(text, 2, false), 1);
2196        assert_eq!(char_boundary(text, 2, true), 4);
2197        assert_eq!(char_boundary(text, 6, false), 4);
2198        assert_eq!(char_boundary(text, 6, true), 8);
2199        assert_eq!(char_boundary(text, 8, false), 8);
2200        assert_eq!(char_boundary(text, 99, false), text.len());
2201    }
2202
2203    #[test]
2204    fn parse_context_mode_handles_variants() {
2205        assert!(matches!(
2206            parse_context_mode("session"),
2207            ContextMode::Session
2208        ));
2209        assert!(matches!(parse_context_mode("none"), ContextMode::None));
2210        assert!(matches!(parse_context_mode(""), ContextMode::None));
2211        assert!(matches!(
2212            parse_context_mode(" session "),
2213            ContextMode::Session
2214        ));
2215        match parse_context_mode("session_recent(5)") {
2216            ContextMode::SessionRecent(n) => assert_eq!(n, 5),
2217            other => panic!("expected SessionRecent(5), got {other:?}"),
2218        }
2219        match parse_context_mode("session_recent") {
2220            ContextMode::SessionRecent(n) => assert_eq!(n, 10),
2221            other => panic!("expected SessionRecent(10), got {other:?}"),
2222        }
2223        assert!(matches!(parse_context_mode("garbage"), ContextMode::None));
2224    }
2225
2226    #[test]
2227    fn input_with_cache_for_window_does_not_double_count_cache_write() {
2228        let usage = crate::provider::TokenUsage {
2229            input: 50_000,
2230            cached_input: 0,
2231            cache_write: 50_000,
2232            ..Default::default()
2233        };
2234
2235        assert_eq!(input_with_cache_for_window(&usage), 50_000);
2236    }
2237
2238    async fn eval_snippet(expr_src: &str) -> Value {
2239        let src = format!("flow t() {{\n    return {expr_src}\n}}\n");
2240        let file = parse_file(&src).expect("parse test snippet");
2241        let tools = ToolRegistry::new();
2242        let tool_ctx = ToolCtx::new();
2243        let providers = crate::provider::ProviderRegistry::new();
2244        let flows = std::collections::HashMap::new();
2245        let ctx = EvalCtx {
2246            tools: &tools,
2247            tool_ctx: &tool_ctx,
2248            providers: &providers,
2249            flows: &flows,
2250            contract: None,
2251            events: None,
2252            turn_id: None,
2253            flow_run_id: None,
2254            session_runtime: None,
2255            flow_cancel: tokio_util::sync::CancellationToken::new(),
2256            safety: None,
2257            current_node_id: None,
2258            source_dir: None,
2259        };
2260        let stmt = &file.flows[0].body[0];
2261        if let atman_dsl::ast::Stmt::Return { value } = stmt {
2262            eval_expr(value, &Env::new(), &ctx).await
2263        } else {
2264            panic!("expected return statement");
2265        }
2266    }
2267
2268    #[tokio::test]
2269    async fn literals_evaluate() {
2270        assert!(matches!(eval_snippet("42").await, Value::Int(42)));
2271        assert!(matches!(eval_snippet("true").await, Value::Bool(true)));
2272        assert!(matches!(
2273            eval_snippet(r#""hello""#).await,
2274            Value::Str(s) if s == "hello"
2275        ));
2276    }
2277
2278    #[tokio::test]
2279    async fn undefined_ident_yields_err_value() {
2280        assert!(matches!(
2281            eval_snippet("missing").await,
2282            Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2283        ));
2284    }
2285
2286    #[tokio::test]
2287    async fn binary_arithmetic_and_comparison() {
2288        assert!(matches!(eval_snippet("1 == 1").await, Value::Bool(true)));
2289        assert!(matches!(eval_snippet("2 < 3").await, Value::Bool(true)));
2290        assert!(matches!(
2291            eval_snippet(r#""a" + "b""#).await,
2292            Value::Str(s) if s == "ab"
2293        ));
2294    }
2295
2296    #[tokio::test]
2297    async fn type_mismatch_bubbles_up() {
2298        assert!(matches!(
2299            eval_snippet(r#"1 + "x""#).await,
2300            Value::Err(RuntimeError::TypeMismatch { .. })
2301        ));
2302    }
2303
2304    #[tokio::test]
2305    async fn err_short_circuits_binary() {
2306        assert!(matches!(
2307            eval_snippet("missing == 1").await,
2308            Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2309        ));
2310    }
2311
2312    #[tokio::test]
2313    async fn list_evaluates_all_items() {
2314        let v = eval_snippet("[1, 2, 3]").await;
2315        if let Value::List(items) = v {
2316            assert_eq!(items.len(), 3);
2317            assert!(matches!(items[2], Value::Int(3)));
2318        } else {
2319            panic!("expected list");
2320        }
2321    }
2322
2323    #[tokio::test]
2324    async fn struct_literal_evaluates_fields_in_order() {
2325        let v = eval_snippet(r#"{ severity: "critical", count: 3 }"#).await;
2326        if let Value::Struct(fields) = v {
2327            assert_eq!(fields[0].0, "severity");
2328            assert_eq!(fields[1].0, "count");
2329        } else {
2330            panic!("expected struct");
2331        }
2332    }
2333
2334    #[tokio::test]
2335    async fn undefined_tool_returns_undefined_tool_err() {
2336        let src = r#"flow t() { return fs.readnope("/tmp") }"#;
2337        let file = parse_file(src).unwrap();
2338        let tools = ToolRegistry::new();
2339        let tool_ctx = ToolCtx::new();
2340        let providers = crate::provider::ProviderRegistry::new();
2341        let flows = std::collections::HashMap::new();
2342        let ctx = EvalCtx {
2343            tools: &tools,
2344            tool_ctx: &tool_ctx,
2345            providers: &providers,
2346            flows: &flows,
2347            contract: None,
2348            events: None,
2349            turn_id: None,
2350            flow_run_id: None,
2351            session_runtime: None,
2352            flow_cancel: tokio_util::sync::CancellationToken::new(),
2353            safety: None,
2354            current_node_id: None,
2355            source_dir: None,
2356        };
2357        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2358            let v = eval_expr(value, &Env::new(), &ctx).await;
2359            assert!(matches!(
2360                v,
2361                Value::Err(RuntimeError::UndefinedTool(name)) if name == "fs.readnope"
2362            ));
2363        }
2364    }
2365
2366    #[tokio::test]
2367    async fn fanout_all_gathers_results_in_order() {
2368        use crate::tools::fs::FsRead;
2369        use std::sync::Arc;
2370        use tempfile::TempDir;
2371
2372        let dir = TempDir::new().unwrap();
2373        let pa = dir.path().join("a.txt");
2374        let pb = dir.path().join("b.txt");
2375        tokio::fs::write(&pa, b"AAA").await.unwrap();
2376        tokio::fs::write(&pb, b"BBB").await.unwrap();
2377
2378        let tools = ToolRegistry::new();
2379        tools.register(Arc::new(FsRead));
2380        let tool_ctx = ToolCtx::new();
2381        let providers = crate::provider::ProviderRegistry::new();
2382        let flows = std::collections::HashMap::new();
2383        let ctx = EvalCtx {
2384            tools: &tools,
2385            tool_ctx: &tool_ctx,
2386            providers: &providers,
2387            flows: &flows,
2388            contract: None,
2389            events: None,
2390            turn_id: None,
2391            flow_run_id: None,
2392            session_runtime: None,
2393            flow_cancel: tokio_util::sync::CancellationToken::new(),
2394            safety: None,
2395            current_node_id: None,
2396            source_dir: None,
2397        };
2398
2399        let mut env = Env::new();
2400        env.bind("a", Value::Path(pa));
2401        env.bind("b", Value::Path(pb));
2402
2403        let src = r#"flow t() { return fanout [ fs.read(a), fs.read(b) ] collect: all }"#;
2404        let file = parse_file(src).unwrap();
2405        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2406            let v = eval_expr(value, &env, &ctx).await;
2407            if let Value::List(items) = v {
2408                assert_eq!(items.len(), 2);
2409                assert!(matches!(&items[0], Value::Str(s) if s == "AAA"));
2410                assert!(matches!(&items[1], Value::Str(s) if s == "BBB"));
2411            } else {
2412                panic!("expected list");
2413            }
2414        }
2415    }
2416
2417    #[tokio::test]
2418    async fn fanout_all_short_circuits_on_err() {
2419        let src = r#"flow t() { return fanout [ 1, missing, 3 ] collect: all }"#;
2420        let file = parse_file(src).unwrap();
2421        let tools = ToolRegistry::new();
2422        let tool_ctx = ToolCtx::new();
2423        let providers = crate::provider::ProviderRegistry::new();
2424        let flows = std::collections::HashMap::new();
2425        let ctx = EvalCtx {
2426            tools: &tools,
2427            tool_ctx: &tool_ctx,
2428            providers: &providers,
2429            flows: &flows,
2430            contract: None,
2431            events: None,
2432            turn_id: None,
2433            flow_run_id: None,
2434            session_runtime: None,
2435            flow_cancel: tokio_util::sync::CancellationToken::new(),
2436            safety: None,
2437            current_node_id: None,
2438            source_dir: None,
2439        };
2440        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2441            let v = eval_expr(value, &Env::new(), &ctx).await;
2442            assert!(matches!(
2443                v,
2444                Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2445            ));
2446        }
2447    }
2448
2449    #[tokio::test]
2450    async fn llm_node_dispatches_to_mock_provider() {
2451        use crate::providers::mock::MockProvider;
2452        use std::sync::Arc;
2453
2454        {
2455            let _lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2456            crate::model_registry::set_model_config(crate::model_registry::ModelConfig {
2457                models: std::collections::HashMap::from([(
2458                    "mock".into(),
2459                    crate::model_registry::ModelEntry {
2460                        model: "mock".into(),
2461                        context_budget: Some(8_192),
2462                        ..Default::default()
2463                    },
2464                )]),
2465                ..Default::default()
2466            });
2467        }
2468
2469        let providers = crate::provider::ProviderRegistry::new();
2470        providers.register(Arc::new(MockProvider::new("mock").with_model(
2471            "mock",
2472            Value::Struct(vec![("severity".into(), Value::Str("info".into()))]),
2473        )));
2474        let tools = ToolRegistry::new();
2475        crate::tools::register_tier_zero(&tools);
2476        let tool_ctx = ToolCtx::new()
2477            .with_providers(std::sync::Arc::new(providers.clone()))
2478            .with_registry(std::sync::Arc::new(tools.clone()));
2479        let flows = std::collections::HashMap::new();
2480        let ctx = EvalCtx {
2481            tools: &tools,
2482            tool_ctx: &tool_ctx,
2483            providers: &providers,
2484            flows: &flows,
2485            contract: None,
2486            events: None,
2487            turn_id: None,
2488            flow_run_id: None,
2489            session_runtime: None,
2490            flow_cancel: tokio_util::sync::CancellationToken::new(),
2491            safety: None,
2492            current_node_id: None,
2493            source_dir: None,
2494        };
2495
2496        let src = r#"flow t() {
2497    return llm.call(
2498        model: "mock",
2499        prompt: "review please",
2500        input: 1,
2501    )
2502}
2503"#;
2504        let file = parse_file(src).unwrap();
2505        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2506            let v = eval_expr(value, &Env::new(), &ctx).await;
2507            if let Value::Struct(fields) = v {
2508                assert_eq!(fields[0].0, "severity");
2509                assert!(matches!(&fields[0].1, Value::Str(s) if s == "info"));
2510            } else {
2511                panic!("expected struct, got {v:?}");
2512            }
2513        }
2514    }
2515
2516    #[tokio::test]
2517    async fn llm_missing_model_reports_missing_arg() {
2518        let providers = crate::provider::ProviderRegistry::new();
2519        let tools = ToolRegistry::new();
2520        let tool_ctx = ToolCtx::new();
2521        let flows = std::collections::HashMap::new();
2522        let ctx = EvalCtx {
2523            tools: &tools,
2524            tool_ctx: &tool_ctx,
2525            providers: &providers,
2526            flows: &flows,
2527            contract: None,
2528            events: None,
2529            turn_id: None,
2530            flow_run_id: None,
2531            session_runtime: None,
2532            flow_cancel: tokio_util::sync::CancellationToken::new(),
2533            safety: None,
2534            current_node_id: None,
2535            source_dir: None,
2536        };
2537        let src = r#"flow t() { return llm.call(prompt: "hi") }"#;
2538        let file = parse_file(src).unwrap();
2539        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2540            let v = eval_expr(value, &Env::new(), &ctx).await;
2541            assert!(v.is_err(), "expected error, got {v:?}");
2542        }
2543    }
2544
2545    #[tokio::test]
2546    async fn user_confirm_stub_returns_true() {
2547        let providers = crate::provider::ProviderRegistry::new();
2548        let tools = ToolRegistry::new();
2549        let tool_ctx = ToolCtx::new();
2550        let flows = std::collections::HashMap::new();
2551        let ctx = EvalCtx {
2552            tools: &tools,
2553            tool_ctx: &tool_ctx,
2554            providers: &providers,
2555            flows: &flows,
2556            contract: None,
2557            events: None,
2558            turn_id: None,
2559            flow_run_id: None,
2560            session_runtime: None,
2561            flow_cancel: tokio_util::sync::CancellationToken::new(),
2562            safety: None,
2563            current_node_id: None,
2564            source_dir: None,
2565        };
2566        let src = r#"flow t() { return user_confirm("proceed?") }"#;
2567        let file = parse_file(src).unwrap();
2568        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2569            assert!(matches!(
2570                eval_expr(value, &Env::new(), &ctx).await,
2571                Value::Bool(true)
2572            ));
2573        }
2574    }
2575
2576    #[tokio::test]
2577    async fn subflow_calls_target_flow_with_positional_args() {
2578        let src = r#"flow child(n: Int) -> Int {
2579    return n + 100
2580}
2581
2582flow parent(x: Int) -> Int {
2583    y = subflow(child, x)
2584    return y + 1
2585}
2586"#;
2587        let file = parse_file(src).unwrap();
2588        let flows_map: std::collections::HashMap<_, _> = file
2589            .flows
2590            .iter()
2591            .map(|f| (f.name.name.clone(), f.clone()))
2592            .collect();
2593        let parent = &file.flows[1];
2594        let tools = ToolRegistry::new();
2595        let tool_ctx = ToolCtx::new();
2596        let providers = crate::provider::ProviderRegistry::new();
2597        let out = crate::exec::exec_flow_with_siblings(
2598            parent,
2599            vec![("x".into(), Value::Int(5))],
2600            &tools,
2601            &tool_ctx,
2602            &providers,
2603            &flows_map,
2604            None,
2605            None,
2606            None,
2607            None,
2608            tokio_util::sync::CancellationToken::new(),
2609            None,
2610            None,
2611        )
2612        .await
2613        .unwrap();
2614        assert!(matches!(out, Value::Int(106)));
2615    }
2616
2617    #[tokio::test]
2618    async fn subflow_missing_target_reports_undefined_tool() {
2619        let src = r#"flow parent() -> Int {
2620    return subflow(nope, 1)
2621}
2622"#;
2623        let file = parse_file(src).unwrap();
2624        let flows: std::collections::HashMap<_, _> = file
2625            .flows
2626            .iter()
2627            .map(|f| (f.name.name.clone(), f.clone()))
2628            .collect();
2629        let tools = ToolRegistry::new();
2630        let tool_ctx = ToolCtx::new();
2631        let providers = crate::provider::ProviderRegistry::new();
2632        let err = crate::exec::exec_flow_with_siblings(
2633            &file.flows[0],
2634            vec![],
2635            &tools,
2636            &tool_ctx,
2637            &providers,
2638            &flows,
2639            None,
2640            None,
2641            None,
2642            None,
2643            tokio_util::sync::CancellationToken::new(),
2644            None,
2645            None,
2646        )
2647        .await
2648        .unwrap_err();
2649        assert!(matches!(err, RuntimeError::UndefinedTool(name) if name.contains("nope")));
2650    }
2651
2652    #[tokio::test]
2653    async fn tool_call_dispatches_via_registry() {
2654        use crate::tools::fs::FsRead;
2655        use std::sync::Arc;
2656        use tempfile::TempDir;
2657
2658        let dir = TempDir::new().unwrap();
2659        let path = dir.path().join("hi.txt");
2660        tokio::fs::write(&path, b"hello runtime").await.unwrap();
2661
2662        let tools = ToolRegistry::new();
2663        tools.register(Arc::new(FsRead));
2664        let tool_ctx = ToolCtx::new();
2665        let providers = crate::provider::ProviderRegistry::new();
2666        let flows = std::collections::HashMap::new();
2667        let ctx = EvalCtx {
2668            tools: &tools,
2669            tool_ctx: &tool_ctx,
2670            providers: &providers,
2671            flows: &flows,
2672            contract: None,
2673            events: None,
2674            turn_id: None,
2675            flow_run_id: None,
2676            session_runtime: None,
2677            flow_cancel: tokio_util::sync::CancellationToken::new(),
2678            safety: None,
2679            current_node_id: None,
2680            source_dir: None,
2681        };
2682
2683        let mut env = Env::new();
2684        env.bind("p", Value::Path(path));
2685
2686        let src = r#"flow t() { return fs.read(p) }"#;
2687        let file = parse_file(src).unwrap();
2688        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2689            let v = eval_expr(value, &env, &ctx).await;
2690            assert!(matches!(v, Value::Str(s) if s == "hello runtime"));
2691        }
2692    }
2693
2694    #[tokio::test]
2695    async fn fanout_emits_branch_start_end_events_with_parent_linkage() {
2696        let src = r#"flow t() { return fanout [1, 2, 3] collect: all }"#;
2697        let file = parse_file(src).unwrap();
2698        let tools = ToolRegistry::new();
2699        let tool_ctx = ToolCtx::new();
2700        let providers = crate::provider::ProviderRegistry::new();
2701        let flows = std::collections::HashMap::new();
2702        let events = crate::event::EventSink::new();
2703        let ctx = EvalCtx {
2704            tools: &tools,
2705            tool_ctx: &tool_ctx,
2706            providers: &providers,
2707            flows: &flows,
2708            contract: None,
2709            events: Some(&events),
2710            turn_id: None,
2711            flow_run_id: Some(crate::event::FlowRunId::now()),
2712            session_runtime: None,
2713            flow_cancel: tokio_util::sync::CancellationToken::new(),
2714            safety: None,
2715            current_node_id: Some("stmt_1".into()),
2716            source_dir: None,
2717        };
2718        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2719            let _ = eval_expr(value, &Env::new(), &ctx).await;
2720        }
2721        let snap = events.snapshot();
2722        let starts: Vec<_> = snap
2723            .iter()
2724            .filter_map(|e| match e {
2725                crate::event::Event::FlowNodeStart {
2726                    node_id,
2727                    parent_node_id,
2728                    ..
2729                } => Some((node_id.clone(), parent_node_id.clone())),
2730                _ => None,
2731            })
2732            .collect();
2733        assert_eq!(starts.len(), 3);
2734        assert_eq!(starts[0].0, "stmt_1.branch[0]");
2735        assert_eq!(starts[1].0, "stmt_1.branch[1]");
2736        assert_eq!(starts[2].0, "stmt_1.branch[2]");
2737        assert!(starts.iter().all(|(_, p)| p.as_deref() == Some("stmt_1")));
2738        let ends = snap
2739            .iter()
2740            .filter(|e| matches!(e, crate::event::Event::FlowNodeEnd { .. }))
2741            .count();
2742        assert_eq!(ends, 3);
2743    }
2744
2745    #[test]
2746    fn resolve_tool_specs_wildcard_unknown_prefix_skips_silently() {
2747        let tools = crate::tool::ToolRegistry::new();
2748        let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2749            "nonexistent.*".into(),
2750        ))]);
2751        let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
2752            &[crate::value::Value::Str("nonexistent.*".into())],
2753            &tools,
2754        )
2755        .unwrap();
2756        assert!(
2757            specs.is_empty(),
2758            "wildcard with no matches should return empty list"
2759        );
2760    }
2761
2762    #[test]
2763    fn resolve_tool_specs_wildcard_matches_prefixed_tools() {
2764        let tools = crate::tool::ToolRegistry::new();
2765        struct FakeMcpTool {
2766            name: String,
2767            desc: String,
2768            schema: serde_json::Value,
2769        }
2770        impl crate::tool::Tool for FakeMcpTool {
2771            fn name(&self) -> &str {
2772                &self.name
2773            }
2774            fn description(&self) -> Option<&str> {
2775                Some(&self.desc)
2776            }
2777            fn input_schema(&self) -> serde_json::Value {
2778                self.schema.clone()
2779            }
2780            fn tier(&self) -> crate::tool::Tier {
2781                crate::tool::Tier::Zero
2782            }
2783            fn approval_level(
2784                &self,
2785                _args: &crate::tool::ToolArgs,
2786                _ctx: &crate::tool::ToolCtx,
2787            ) -> crate::tool::ApprovalLevel {
2788                crate::tool::ApprovalLevel::Auto
2789            }
2790            fn call<'a>(
2791                &'a self,
2792                _args: crate::tool::ToolArgs,
2793                _ctx: &'a crate::tool::ToolCtx,
2794            ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2795                Box::pin(async { Ok(crate::value::Value::Unit) })
2796            }
2797        }
2798        tools.register(std::sync::Arc::new(FakeMcpTool {
2799            name: "mcp.lark.send_mail".into(),
2800            desc: "send mail".into(),
2801            schema: serde_json::json!({"type":"object","properties":{}}),
2802        }));
2803        tools.register(std::sync::Arc::new(FakeMcpTool {
2804            name: "mcp.lark.read_inbox".into(),
2805            desc: "read inbox".into(),
2806            schema: serde_json::json!({"type":"object","properties":{}}),
2807        }));
2808        tools.register(std::sync::Arc::new(FakeMcpTool {
2809            name: "mcp.siyuan.search".into(),
2810            desc: "search notes".into(),
2811            schema: serde_json::json!({"type":"object","properties":{}}),
2812        }));
2813        // Non-MCP tool should NOT be matched.
2814        tools.register(std::sync::Arc::new(FakeMcpTool {
2815            name: "fs.read".into(),
2816            desc: "read file".into(),
2817            schema: serde_json::json!({"type":"object","properties":{}}),
2818        }));
2819
2820        // "mcp.*" matches all 3 mcp.* tools, but not fs.read
2821        let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2822            "mcp.*".into(),
2823        ))]);
2824        let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
2825            &[crate::value::Value::Str("mcp.*".into())],
2826            &tools,
2827        )
2828        .unwrap();
2829        assert_eq!(specs.len(), 3, "mcp.* should match 3 MCP tools");
2830        let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
2831        assert!(names.contains(&"mcp.lark.send_mail".into()));
2832        assert!(names.contains(&"mcp.lark.read_inbox".into()));
2833        assert!(names.contains(&"mcp.siyuan.search".into()));
2834
2835        // "mcp.lark.*" matches only the 2 lark tools
2836        let _expr2 = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2837            "mcp.lark.*".into(),
2838        ))]);
2839        let specs2 = crate::eval::llm_args::resolve_tool_specs_from_values(
2840            &[crate::value::Value::Str("mcp.lark.*".into())],
2841            &tools,
2842        )
2843        .unwrap();
2844        assert_eq!(specs2.len(), 2, "mcp.lark.* should match 2 lark tools");
2845    }
2846
2847    #[test]
2848    fn resolve_tool_specs_mixed_concrete_and_wildcard() {
2849        let tools = crate::tool::ToolRegistry::new();
2850        struct FakeMcpTool {
2851            name: String,
2852            desc: String,
2853            schema: serde_json::Value,
2854        }
2855        impl crate::tool::Tool for FakeMcpTool {
2856            fn name(&self) -> &str {
2857                &self.name
2858            }
2859            fn description(&self) -> Option<&str> {
2860                Some(&self.desc)
2861            }
2862            fn input_schema(&self) -> serde_json::Value {
2863                self.schema.clone()
2864            }
2865            fn tier(&self) -> crate::tool::Tier {
2866                crate::tool::Tier::Zero
2867            }
2868            fn approval_level(
2869                &self,
2870                _args: &crate::tool::ToolArgs,
2871                _ctx: &crate::tool::ToolCtx,
2872            ) -> crate::tool::ApprovalLevel {
2873                crate::tool::ApprovalLevel::Auto
2874            }
2875            fn call<'a>(
2876                &'a self,
2877                _args: crate::tool::ToolArgs,
2878                _ctx: &'a crate::tool::ToolCtx,
2879            ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2880                Box::pin(async { Ok(crate::value::Value::Unit) })
2881            }
2882        }
2883        tools.register(std::sync::Arc::new(FakeMcpTool {
2884            name: "mcp.lark.send_mail".into(),
2885            desc: "send mail".into(),
2886            schema: serde_json::json!({"type":"object","properties":{}}),
2887        }));
2888        tools.register(std::sync::Arc::new(FakeMcpTool {
2889            name: "bash.exec".into(),
2890            desc: "exec".into(),
2891            schema: serde_json::json!({"type":"object","properties":{}}),
2892        }));
2893
2894        // Build tools list from DSL parse so we get valid spans.
2895        let src = r#"flow t() -> string {
2896    reply = llm.call(
2897        tools: ["bash.exec", "mcp.*"],
2898    )
2899    return "ok"
2900}"#;
2901        let file = atman_dsl::parse::parse_file(src).unwrap();
2902        let body = &file.flows[0].body;
2903        let tools_values: Vec<crate::value::Value> = match &body[0] {
2904            atman_dsl::ast::Stmt::Bind { value, .. } => match value {
2905                Expr::Node(atman_dsl::ast::Node::ToolCall { args, .. }) => {
2906                    let tools_expr = args
2907                        .iter()
2908                        .find_map(|a| match a {
2909                            atman_dsl::ast::Arg::Named { name, value } if name.name == "tools" => {
2910                                Some(value.clone())
2911                            }
2912                            _ => None,
2913                        })
2914                        .unwrap();
2915                    if let atman_dsl::ast::Expr::List(items) = tools_expr {
2916                        items
2917                            .iter()
2918                            .map(|i| {
2919                                if let atman_dsl::ast::Expr::Literal(
2920                                    atman_dsl::ast::Literal::Str(s),
2921                                ) = i
2922                                {
2923                                    crate::value::Value::Str(s.clone())
2924                                } else {
2925                                    panic!("expected string literal in tools list");
2926                                }
2927                            })
2928                            .collect()
2929                    } else {
2930                        panic!("expected list");
2931                    }
2932                }
2933                _ => panic!("expected tool call"),
2934            },
2935            _ => panic!("expected bind stmt"),
2936        };
2937        let specs =
2938            crate::eval::llm_args::resolve_tool_specs_from_values(&tools_values, &tools).unwrap();
2939        assert_eq!(specs.len(), 2, "bash.exec + mcp.lark.send_mail = 2");
2940        let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
2941        assert!(names.contains(&"bash.exec".into()));
2942        assert!(names.contains(&"mcp.lark.send_mail".into()));
2943    }
2944}
2945
2946#[cfg(test)]
2947mod sanitize_tests {
2948    use super::*;
2949    use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
2950
2951    #[test]
2952    fn sanitize_fills_missing_tool_results() {
2953        let turn = crate::event::TurnId::now();
2954        let msgs = vec![
2955            Message {
2956                role: MessageRole::Assistant,
2957                parts: vec![MessagePart::ToolUse {
2958                    id: "call_orphan".into(),
2959                    name: "bash.exec".into(),
2960                    input: serde_json::json!({}),
2961                }],
2962                turn_id: turn.clone(),
2963                origin: MessageOrigin::User,
2964            },
2965            Message {
2966                role: MessageRole::User,
2967                parts: vec![MessagePart::Text {
2968                    text: "user interrupt".into(),
2969                }],
2970                turn_id: turn.clone(),
2971                origin: MessageOrigin::User,
2972            },
2973        ];
2974        let out = sanitize_tool_pairs(msgs);
2975        let has_filler = out.iter().any(|m| {
2976            m.parts.iter().any(|p| {
2977                matches!(p, MessagePart::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "call_orphan")
2978            })
2979        });
2980        assert!(
2981            has_filler,
2982            "should append error tool_result for orphan tool_use"
2983        );
2984    }
2985
2986    #[test]
2987    fn sanitize_noop_when_pairs_complete() {
2988        let turn = crate::event::TurnId::now();
2989        let msgs = vec![
2990            Message {
2991                role: MessageRole::Assistant,
2992                parts: vec![MessagePart::ToolUse {
2993                    id: "call_ok".into(),
2994                    name: "bash.exec".into(),
2995                    input: serde_json::json!({}),
2996                }],
2997                turn_id: turn.clone(),
2998                origin: MessageOrigin::User,
2999            },
3000            Message {
3001                role: MessageRole::Tool,
3002                parts: vec![MessagePart::ToolResult {
3003                    tool_use_id: "call_ok".into(),
3004                    content: "done".into(),
3005                    is_error: false,
3006                }],
3007                turn_id: turn.clone(),
3008                origin: MessageOrigin::User,
3009            },
3010        ];
3011        let out = sanitize_tool_pairs(msgs);
3012        assert_eq!(
3013            out.len(),
3014            2,
3015            "no filler should be added when pairs complete"
3016        );
3017    }
3018
3019    // --- stall timeout tests ---
3020    use crate::providers::mock::MockProvider;
3021
3022    fn stall_req(stall_secs: u64) -> crate::provider::LlmRequest {
3023        crate::provider::LlmRequest {
3024            model: "mock".into(),
3025            messages: vec![crate::provider::user_text_message("test")],
3026            system: None,
3027            input: crate::value::Value::Unit,
3028            schema: None,
3029            cache_prompt: false,
3030            tools: Vec::new(),
3031            thinking_enabled: false,
3032            stall_timeout_secs: stall_secs,
3033        }
3034    }
3035
3036    #[tokio::test]
3037    async fn stall_timeout_fires_when_no_chunks_arrive() {
3038        // chunk_delay = 3s, stall_timeout = 1s → stall fires before 2nd chunk
3039        let provider = MockProvider::new("mock")
3040            .with_model("mock", Value::Str("hello world test".into()))
3041            .with_chunk_delay(std::time::Duration::from_secs(3));
3042
3043        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3044        let result = call_and_maybe_stream(
3045            &provider,
3046            stall_req(1),
3047            StreamCallCtx {
3048                stream_tx: Some(stream_tx),
3049                ..Default::default()
3050            },
3051            None,
3052        )
3053        .await;
3054        match result {
3055            Err(RuntimeError::ToolFailed(msg)) => {
3056                assert!(
3057                    msg.contains("llm stall timeout after 1s"),
3058                    "expected stall message, got: {msg}"
3059                );
3060            }
3061            other => panic!("expected ToolFailed stall timeout, got: {other:?}"),
3062        }
3063    }
3064
3065    #[tokio::test]
3066    async fn stall_timeout_does_not_fire_when_chunks_keep_coming() {
3067        // chunk_delay = 100ms, stall_timeout = 2s → all chunks within 300ms, no stall
3068        let provider = MockProvider::new("mock")
3069            .with_model("mock", Value::Str("hello world test".into()))
3070            .with_chunk_delay(std::time::Duration::from_millis(100));
3071
3072        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3073        let result = call_and_maybe_stream(
3074            &provider,
3075            stall_req(2),
3076            StreamCallCtx {
3077                stream_tx: Some(stream_tx),
3078                ..Default::default()
3079            },
3080            None,
3081        )
3082        .await;
3083        match result {
3084            Ok(am) => {
3085                assert!(am.text_concat().contains("hello"));
3086            }
3087            other => panic!("expected Ok, got: {other:?}"),
3088        }
3089    }
3090
3091    #[tokio::test]
3092    async fn stall_timeout_zero_disables_detection() {
3093        // chunk_delay = 3s, stall_timeout = 0 → disabled, all chunks arrive
3094        let provider = MockProvider::new("mock")
3095            .with_model("mock", Value::Str("hello world test".into()))
3096            .with_chunk_delay(std::time::Duration::from_secs(3));
3097
3098        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3099        let result = call_and_maybe_stream(
3100            &provider,
3101            stall_req(0),
3102            StreamCallCtx {
3103                stream_tx: Some(stream_tx),
3104                ..Default::default()
3105            },
3106            None,
3107        )
3108        .await;
3109        match result {
3110            Ok(am) => {
3111                assert!(am.text_concat().contains("hello"));
3112            }
3113            other => panic!("expected Ok (stall disabled), got: {other:?}"),
3114        }
3115    }
3116
3117    #[tokio::test]
3118    async fn stall_timeout_resets_on_each_chunk() {
3119        // 3 chunks at 800ms each. stall=1s. First chunk at t=0, second at t=800ms (<1s),
3120        // third at t=1.6s (>1s from start, but only 800ms from last chunk). Should pass.
3121        let provider = MockProvider::new("mock")
3122            .with_model("mock", Value::Str("hello world test".into()))
3123            .with_chunk_delay(std::time::Duration::from_millis(800));
3124
3125        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3126        let result = call_and_maybe_stream(
3127            &provider,
3128            stall_req(1),
3129            StreamCallCtx {
3130                stream_tx: Some(stream_tx),
3131                ..Default::default()
3132            },
3133            None,
3134        )
3135        .await;
3136        match result {
3137            Ok(am) => {
3138                assert!(am.text_concat().contains("hello"));
3139            }
3140            other => panic!("expected Ok (timer reset each chunk), got: {other:?}"),
3141        }
3142    }
3143
3144    #[tokio::test]
3145    async fn stall_timeout_fires_between_first_and_second_chunk() {
3146        // first chunk at t≈0, then 2s gap, stall=1s fires at t=1s
3147        let provider = MockProvider::new("mock")
3148            .with_model("mock", Value::Str("hello world test".into()))
3149            .with_chunk_delay(std::time::Duration::from_secs(2));
3150
3151        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3152        let result = call_and_maybe_stream(
3153            &provider,
3154            stall_req(1),
3155            StreamCallCtx {
3156                stream_tx: Some(stream_tx),
3157                ..Default::default()
3158            },
3159            None,
3160        )
3161        .await;
3162        assert!(
3163            matches!(&result, Err(RuntimeError::ToolFailed(msg)) if msg.contains("stall timeout")),
3164            "expected stall timeout, got: {result:?}"
3165        );
3166    }
3167}