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_session_id(session.id().to_string());
817        if let Some(idx) = session.project_index() {
818            c = c.with_project_index(idx);
819        }
820        c = c.with_fs_access(session_fs_access_policy(session));
821        c = c.with_forms(session.forms());
822        {
823            let s = session.clone();
824            c.on_memory_recent = Some(std::sync::Arc::new(move |count| {
825                s.set_memory_recent_count(count);
826            }));
827        }
828        {
829            let store = std::sync::Arc::new(crate::history_store::HistoryStoreImpl::new(
830                session.project_index(),
831                Some(session.clone()),
832                Some(session.id().to_string()),
833                Some(
834                    session
835                        .dir()
836                        .parent()
837                        .map(|p| p.to_path_buf())
838                        .unwrap_or_default(),
839                ),
840            ));
841            c = c.with_history_store(store);
842        }
843        c
844    } else {
845        ctx_with_anchors
846    };
847    let stream_tx = ctx.session_runtime.as_ref().map(|s| s.stream_tx());
848    let tool_call_id = uuid::Uuid::now_v7().to_string();
849    let args_preview = preview_tool_args(&positional, &named);
850    if let (Some(sink), Some(run_id), Some(parent_node)) =
851        (ctx.events, ctx.flow_run_id.clone(), &ctx.current_node_id)
852    {
853        sink.emit(crate::event::Event::ToolNode {
854            run_id: run_id.clone(),
855            parent_node_id: parent_node.clone(),
856            tool_use_id: tool_call_id.clone(),
857            tool_name: name.clone(),
858            args_preview: args_preview.clone(),
859        });
860        if let Some(tx) = &stream_tx {
861            let _ = tx.send(crate::stream::StreamFrame::ToolNode {
862                run_id: run_id.0.to_string(),
863                parent_node_id: parent_node.clone(),
864                tool_use_id: tool_call_id.clone(),
865                tool: name.clone(),
866                args_preview: args_preview.clone(),
867            });
868        }
869    }
870    if let Some(tx) = &stream_tx {
871        let _ = tx.send(crate::stream::StreamFrame::ToolUseStart {
872            tool: name.clone(),
873            args_preview: args_preview.clone(),
874            id: tool_call_id.clone(),
875        });
876    }
877    let call_args = ToolArgs { positional, named };
878    let diff_preview = prepare_diff_preview(&name, &call_args);
879    let level = tool.approval_level(&call_args, &ctx_with_anchors);
880    let gate = crate::approval::request_approval(
881        &ctx_with_anchors,
882        &tool_call_id,
883        &name,
884        &call_args,
885        level,
886        Some(tool.as_ref()),
887    )
888    .await;
889    let outcome = match gate {
890        crate::approval::ApprovalOutcome::Deny { reason } => Err(RuntimeError::ToolFailed(
891            format!("tool `{name}` denied by user: {reason}"),
892        )),
893        crate::approval::ApprovalOutcome::Approve => tool.call(call_args, &ctx_with_anchors).await,
894    };
895    if let Some(tx) = &stream_tx {
896        let (ok, preview) = match &outcome {
897            Ok(v) => (true, preview_tool_value(v)),
898            Err(e) => (false, format!("{e}")),
899        };
900        let _ = tx.send(crate::stream::StreamFrame::ToolUseDone {
901            tool: name.clone(),
902            ok,
903            preview,
904            id: tool_call_id,
905        });
906    }
907    if let Some(session) = ctx.session_runtime.as_ref()
908        && (name == "memory.todo.set" || name == "memory.todo.done")
909    {
910        session.refresh_todos_from_store_async().await;
911    }
912    if let Some(session) = ctx.session_runtime.as_ref()
913        && (name == "plan.write" || name == "plan.tick")
914    {
915        session.refresh_plans_from_store_async().await;
916    }
917    if let (Some(sink), Ok(value)) = (ctx.events, &outcome) {
918        if let Some((title, old_content, new_content, unified_diff)) =
919            complete_diff_preview(diff_preview, &name, value)
920        {
921            sink.emit(crate::event::Event::DiffPreview {
922                turn_id: ctx.turn_id.clone(),
923                flow_run_id: ctx.flow_run_id.clone(),
924                title,
925                old_content,
926                new_content,
927                unified_diff,
928            });
929        }
930    }
931    match outcome {
932        Ok(v) => v,
933        Err(e) => Value::Err(e),
934    }
935}
936
937type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
938
939fn prepare_diff_preview(name: &str, args: &ToolArgs) -> Option<DiffPreviewData> {
940    match name {
941        "fs.write" => {
942            let path = tool_arg_path(args, "path", 0)?;
943            let content = tool_arg_string(args, "content", 1)?;
944            let path_str = path.display().to_string();
945            let diff = match std::fs::read_to_string(&path).ok() {
946                Some(old) => crate::tools::fs::unified_diff_preview(&path_str, &old, &content),
947                None => format!("+++ {path_str}\n{content}"),
948            };
949            Some((path_str, None, None, Some(diff)))
950        }
951        "fs.edit" => {
952            let path = tool_arg_path(args, "path", 0)?;
953            let old_string = tool_arg_string(args, "old_string", 1)?;
954            let new_string = tool_arg_string(args, "new_string", 2)?;
955            let replace_all = matches!(args.named("replace_all"), Some(Value::Bool(true)));
956            let old = std::fs::read_to_string(&path).ok()?;
957            let new = if replace_all {
958                old.replace(&old_string, &new_string)
959            } else {
960                old.replacen(&old_string, &new_string, 1)
961            };
962            let path_str = path.display().to_string();
963            let diff = crate::tools::fs::unified_diff_preview(&path_str, &old, &new);
964            Some((path_str, None, None, Some(diff)))
965        }
966        _ => None,
967    }
968}
969
970fn complete_diff_preview(
971    prepared: Option<DiffPreviewData>,
972    name: &str,
973    value: &Value,
974) -> Option<DiffPreviewData> {
975    if prepared.is_some() {
976        return prepared;
977    }
978    match name {
979        "git.diff" => Some((
980            "git diff".into(),
981            None,
982            None,
983            value_struct_string(value, "diff"),
984        )),
985        "git.show" => Some((
986            value_struct_string(value, "sha")
987                .map(|sha| format!("git show {sha}"))
988                .unwrap_or_else(|| "git show".into()),
989            None,
990            None,
991            value_struct_string(value, "diff"),
992        )),
993        "git.log" => Some((
994            "git log HEAD".into(),
995            None,
996            None,
997            value_struct_string(value, "diff"),
998        )),
999        _ => None,
1000    }
1001}
1002
1003fn tool_arg_string(args: &ToolArgs, name: &str, pos: usize) -> Option<String> {
1004    let value = args.named(name).or_else(|| args.positional.get(pos))?;
1005    match value {
1006        Value::Str(s) => Some(s.clone()),
1007        _ => None,
1008    }
1009}
1010
1011fn tool_arg_path(args: &ToolArgs, name: &str, pos: usize) -> Option<std::path::PathBuf> {
1012    let value = args.named(name).or_else(|| args.positional.get(pos))?;
1013    match value {
1014        Value::Path(p) => Some(p.clone()),
1015        Value::Str(s) => Some(std::path::PathBuf::from(s)),
1016        _ => None,
1017    }
1018}
1019
1020fn value_struct_string(value: &Value, name: &str) -> Option<String> {
1021    let Value::Struct(fields) = value else {
1022        return None;
1023    };
1024    fields.iter().find_map(|(k, v)| match (k.as_str(), v) {
1025        (key, Value::Str(s)) if key == name => Some(s.clone()),
1026        _ => None,
1027    })
1028}
1029
1030#[derive(Default)]
1031pub(super) struct StreamCallCtx<'a> {
1032    session: Option<&'a crate::session::Session>,
1033    stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
1034    flow_run_id: Option<&'a crate::event::FlowRunId>,
1035    agent_entry: Option<&'a std::sync::Arc<crate::tools::agent_ctrl::FlowEntry>>,
1036    event_sink: Option<&'a crate::event::EventSink>,
1037    turn_id: Option<crate::event::TurnId>,
1038}
1039
1040pub(super) async fn call_and_maybe_stream(
1041    provider: &dyn crate::provider::Provider,
1042    req: crate::provider::LlmRequest,
1043    stream_ctx: StreamCallCtx<'_>,
1044    watch_rules: Option<crate::streaming::WatchRules>,
1045) -> Result<crate::provider::AssistantMessage, RuntimeError> {
1046    let mut base = LlmStream::new(provider, req)
1047        .with_event_sink(stream_ctx.event_sink)
1048        .with_turn_id(stream_ctx.turn_id)
1049        .with_flow_run_id(stream_ctx.flow_run_id.cloned());
1050
1051    let result = if let Some(tx) = stream_ctx.stream_tx {
1052        let mut stream = base.with_stream_tx(tx);
1053        if let Some(rules) = watch_rules {
1054            stream = stream.with_watch_rules(rules);
1055        }
1056        if let Some(session) = stream_ctx.session {
1057            stream = stream.with_session(session);
1058        }
1059        if let Some(entry) = stream_ctx.agent_entry {
1060            stream = stream.with_entry(entry);
1061        }
1062        stream.run().await
1063    } else {
1064        base.run().await
1065    };
1066    if let (Some(sess), Err(RuntimeError::AttachmentError { reason })) =
1067        (stream_ctx.session, &result)
1068    {
1069        let count = sess.record_attachment_degrade(reason);
1070        if count > 0 {
1071            let _ = sess.stream_tx().send(crate::stream::StreamFrame::Note(format!(
1072                "attachment degraded ({reason}); {count} image part(s) replaced. re-issue your last message to retry without them."
1073            )));
1074        }
1075    }
1076    result
1077}
1078
1079fn preview_tool_args(positional: &[Value], named: &[(String, Value)]) -> String {
1080    let mut parts: Vec<String> = positional.iter().map(preview_tool_value).collect();
1081    for (k, v) in named {
1082        parts.push(format!("{k}={}", preview_tool_value(v)));
1083    }
1084    truncate(&parts.join(", "), 4000)
1085}
1086
1087fn available_models_system_prompt() -> Option<String> {
1088    let mut aliases = crate::model_registry::all_aliases();
1089    let mut models = crate::model_registry::all_model_entries();
1090    if aliases.is_empty() && models.is_empty() {
1091        return None;
1092    }
1093    aliases.sort_by(|a, b| a.0.cmp(&b.0));
1094    models.sort_by(|a, b| a.0.cmp(&b.0));
1095    let mut lines = vec!["[available models]".to_string()];
1096    if !aliases.is_empty() {
1097        lines.push(format!(
1098            "Aliases: {}",
1099            aliases
1100                .into_iter()
1101                .map(|(alias, model)| format!("{alias} -> {model}"))
1102                .collect::<Vec<_>>()
1103                .join(", ")
1104        ));
1105    }
1106    if !models.is_empty() {
1107        lines.push(format!(
1108            "Models: {}",
1109            models
1110                .into_iter()
1111                .map(|(name, _)| {
1112                    let info = crate::model_registry::model_info(&name);
1113                    let thinking = if info.thinking_enabled() {
1114                        ", thinking"
1115                    } else {
1116                        ""
1117                    };
1118                    format!(
1119                        "{} ({} context{})",
1120                        info.name,
1121                        crate::humanize::format_count(info.context_budget),
1122                        thinking
1123                    )
1124                })
1125                .collect::<Vec<_>>()
1126                .join(", ")
1127        ));
1128    }
1129    lines.push("Use these names or aliases with flow.spawn's model parameter.".into());
1130    lines.push("[/available models]".into());
1131    Some(lines.join("\n"))
1132}
1133
1134fn working_directory_system_prompt(session: &crate::session::Session) -> Option<String> {
1135    let meta = session.meta()?;
1136    let cwd = meta
1137        .start_path
1138        .as_deref()
1139        .or(meta.project_root.as_deref())?;
1140    let mut lines = vec!["[working directory]".to_string()];
1141    lines.push(cwd.display().to_string());
1142    // Live re-detect in case .git/.atman was created after session start.
1143    let live_root = crate::session_meta::find_project_root(cwd);
1144    match (&live_root, &meta.project_root) {
1145        (Some(live), Some(stored)) if live == stored => {
1146            if Some(live.as_path()) != meta.start_path.as_deref() {
1147                lines.push(format!("project root: {}", live.display()));
1148            }
1149        }
1150        (Some(live), _) => {
1151            lines.push(format!("project root: {}", live.display()));
1152        }
1153        (None, Some(stored)) => {
1154            lines.push(format!(
1155                "project root (cached): {} (no longer detected)",
1156                stored.display()
1157            ));
1158        }
1159        (None, None) => {
1160            lines.push("(no project root — no .git or .atman found)".into());
1161        }
1162    }
1163    lines.push("[/working directory]".into());
1164    Some(lines.join("\n"))
1165}
1166
1167fn preview_tool_value(v: &Value) -> String {
1168    let raw = match v {
1169        Value::Str(s) => format!("{s:?}"),
1170        Value::Int(n) => n.to_string(),
1171        Value::Bool(b) => b.to_string(),
1172        Value::Float(f) => f.to_string(),
1173        Value::Unit => "()".into(),
1174        Value::List(items) => format!("list[{}]", items.len()),
1175        Value::Struct(f) => {
1176            let stdout = f
1177                .iter()
1178                .find(|(k, _)| k == "stdout")
1179                .and_then(|(_, v)| match v {
1180                    Value::Str(s) => Some(s.as_str()),
1181                    _ => None,
1182                });
1183            let stderr = f
1184                .iter()
1185                .find(|(k, _)| k == "stderr")
1186                .and_then(|(_, v)| match v {
1187                    Value::Str(s) => Some(s.as_str()),
1188                    _ => None,
1189                });
1190            let exit = f
1191                .iter()
1192                .find(|(k, _)| k == "exit")
1193                .and_then(|(_, v)| match v {
1194                    Value::Int(n) => Some(*n),
1195                    _ => None,
1196                });
1197            if let (Some(stdout), Some(exit)) = (stdout, exit) {
1198                let combined = if stdout.is_empty() {
1199                    stderr.unwrap_or("").to_string()
1200                } else {
1201                    stdout.to_string()
1202                };
1203                let lines: Vec<&str> = combined.lines().collect();
1204                if lines.len() > 10 {
1205                    format!(
1206                        "exit={exit}\n{}\n… ({} more lines, see `atman logs` for full output)",
1207                        lines[..10].join("\n"),
1208                        lines.len() - 10
1209                    )
1210                } else {
1211                    format!("exit={exit}\n{combined}")
1212                }
1213            } else {
1214                format!("struct[{}]", f.len())
1215            }
1216        }
1217        Value::Message(_) => "<message>".into(),
1218        Value::Err(e) => format!("err({e})"),
1219        Value::Path(p) => format!("{p:?}"),
1220        Value::EditProposal(_) => "<edit_proposal>".into(),
1221        Value::Lambda { .. } => "<lambda>".into(),
1222    };
1223    truncate(&raw, 2000)
1224}
1225
1226fn truncate(s: &str, max: usize) -> String {
1227    if s.chars().count() <= max {
1228        return s.to_string();
1229    }
1230    let mut out: String = s.chars().take(max).collect();
1231    out.push('…');
1232    out
1233}
1234
1235async fn eval_node<'a>(node: &'a Node, env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
1236    if ctx.flow_cancel.is_cancelled() {
1237        return Value::Err(RuntimeError::Cancelled("flow cancelled by user".into()));
1238    }
1239    match node {
1240        Node::ToolCall { path, args } => {
1241            let path_str = path
1242                .iter()
1243                .map(|p| p.name.as_str())
1244                .collect::<Vec<_>>()
1245                .join(".");
1246            match path_str.as_str() {
1247                "list.map" => return eval_list_map(args, env, ctx).await,
1248                "list.filter" => return eval_list_filter(args, env, ctx).await,
1249                "list.reduce" => return eval_list_reduce(args, env, ctx).await,
1250                "list.find" => return eval_list_find(args, env, ctx).await,
1251                "list.any" => return eval_list_any(args, env, ctx).await,
1252                "list.all" => return eval_list_all(args, env, ctx).await,
1253                _ => {}
1254            }
1255            dispatch_tool_call(path, args, Vec::new(), env, ctx).await
1256        }
1257        Node::DynamicFanout {
1258            source,
1259            lambda,
1260            collect,
1261        } => {
1262            return eval_dynamic_fanout(source, lambda, collect, env, ctx).await;
1263        }
1264        Node::Fanout { items, collect } => match collect {
1265            atman_dsl::ast::FanoutCollect::All => {
1266                let parent_id = ctx.current_node_id.clone();
1267                let branch_ctxs: Vec<EvalCtx<'a>> = (0..items.len())
1268                    .map(|i| {
1269                        let branch_id = match &parent_id {
1270                            Some(p) => format!("{p}.branch[{i}]"),
1271                            None => format!("branch[{i}]"),
1272                        };
1273                        if let (Some(sink), Some(run_id)) = (ctx.events, ctx.flow_run_id.clone()) {
1274                            sink.emit(crate::event::Event::FlowNodeStart {
1275                                run_id: run_id.clone(),
1276                                node_id: branch_id.clone(),
1277                                kind: crate::nodegraph::NodeKind::UserConfirm,
1278                                label: format!("branch[{i}]"),
1279                                parent_node_id: parent_id.clone(),
1280                            });
1281                            if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1282                                let _ = tx.send(crate::stream::StreamFrame::FlowNodeStart {
1283                                    run_id: run_id.0.to_string(),
1284                                    node_id: branch_id.clone(),
1285                                    kind: crate::nodegraph::NodeKind::UserConfirm,
1286                                    label: format!("branch[{i}]"),
1287                                    parent_node_id: parent_id.clone(),
1288                                });
1289                            }
1290                        }
1291                        ctx.with_node(branch_id)
1292                    })
1293                    .collect();
1294                let futs = items
1295                    .iter()
1296                    .zip(branch_ctxs.iter())
1297                    .map(|(item, bctx)| eval_expr(item, env, bctx));
1298                let results: Vec<Value> = futures::future::join_all(futs).await;
1299                for (bctx, v) in branch_ctxs.iter().zip(results.iter()) {
1300                    if let (Some(sink), Some(run_id), Some(bid)) =
1301                        (ctx.events, ctx.flow_run_id.clone(), &bctx.current_node_id)
1302                    {
1303                        let status = if v.is_err() {
1304                            crate::event::FlowNodeStatus::Err
1305                        } else {
1306                            crate::event::FlowNodeStatus::Ok
1307                        };
1308                        sink.emit(crate::event::Event::FlowNodeEnd {
1309                            run_id: run_id.clone(),
1310                            node_id: bid.clone(),
1311                            status: status.clone(),
1312                            output_preview: None,
1313                        });
1314                        if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1315                            let _ = tx.send(crate::stream::StreamFrame::FlowNodeEnd {
1316                                run_id: run_id.0.to_string(),
1317                                node_id: bid.clone(),
1318                                status,
1319                                output_preview: None,
1320                                parent_node_id: parent_id.clone(),
1321                            });
1322                        }
1323                    }
1324                }
1325                for v in &results {
1326                    if let Value::Err(e) = v {
1327                        return Value::Err(e.clone());
1328                    }
1329                }
1330                Value::List(results)
1331            }
1332            atman_dsl::ast::FanoutCollect::First => Value::Err(RuntimeError::ToolFailed(
1333                "fanout collect: first not yet implemented".into(),
1334            )),
1335        },
1336        Node::UserConfirm { msg } => {
1337            let v = eval_expr(msg, env, ctx).await;
1338            if v.is_err() {
1339                return v;
1340            }
1341            let prompt = match &v {
1342                Value::Str(s) => s.clone(),
1343                other => other.kind_name().to_string(),
1344            };
1345            let confirm_kind = crate::form::FormKind::Confirm {
1346                prompt: prompt.clone(),
1347            };
1348            // Daemon clients drive the confirm through the prompt resolver
1349            // over RPC; in-process TUI subscribes to FormRegistry. Boot /
1350            // headless / unit tests without either wired keep the historical
1351            // auto-approve so they don't deadlock.
1352            if let Some(resolver) = ctx.tool_ctx.prompt_resolver.clone() {
1353                let id = crate::rendezvous::PromptId::now();
1354                let payload =
1355                    serde_json::to_value(&confirm_kind).unwrap_or(serde_json::Value::Null);
1356                let timeout = std::time::Duration::from_secs(300);
1357                let result = crate::rendezvous::await_prompt_with_payload(
1358                    &resolver, id, "form_ask", payload, timeout,
1359                )
1360                .await;
1361                let answer: crate::form::FormAnswer = match result {
1362                    Ok(v) => {
1363                        serde_json::from_value(v).unwrap_or(crate::form::FormAnswer::Cancelled)
1364                    }
1365                    Err(_) => crate::form::FormAnswer::Cancelled,
1366                };
1367                return Value::Bool(matches!(
1368                    answer,
1369                    crate::form::FormAnswer::Confirmed { value: true }
1370                ));
1371            }
1372            let Some(session) = ctx.session_runtime.as_ref() else {
1373                return Value::Bool(true);
1374            };
1375            let forms = session.forms();
1376            if forms.subscriber_count() == 0 {
1377                return Value::Bool(true);
1378            }
1379            let Some(run_id) = ctx.flow_run_id.clone() else {
1380                return Value::Bool(true);
1381            };
1382            let pending = crate::form::PendingForm {
1383                form_id: uuid::Uuid::now_v7().to_string(),
1384                run_id,
1385                tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
1386                kind: confirm_kind,
1387                emitted_at: chrono::Utc::now(),
1388            };
1389            let rx = forms.request(pending);
1390            let answer = rx.await.unwrap_or(crate::form::FormAnswer::Cancelled);
1391            Value::Bool(matches!(
1392                answer,
1393                crate::form::FormAnswer::Confirmed { value: true }
1394            ))
1395        }
1396        Node::FixUntilTestPasses { kwargs } => eval_fix_until_test_passes(kwargs, env, ctx).await,
1397        Node::Message { role, args } => eval_message_node(*role, args, env, ctx).await,
1398        Node::Subflow { name, args } => {
1399            let Some(target) = ctx.flows.get(&name.name) else {
1400                return Value::Err(RuntimeError::UndefinedTool(format!(
1401                    "subflow({})",
1402                    name.name
1403                )));
1404            };
1405            let mut bindings = Vec::with_capacity(args.len());
1406            for (i, arg) in args.iter().enumerate() {
1407                let (param_name, value) = match arg {
1408                    Arg::Positional(e) => {
1409                        let Some(p) = target.params.get(i) else {
1410                            return Value::Err(RuntimeError::MissingArg(format!(
1411                                "subflow({}): too many positional args",
1412                                name.name
1413                            )));
1414                        };
1415                        let v = eval_expr(e, env, ctx).await;
1416                        (p.name.name.clone(), v)
1417                    }
1418                    Arg::Named { name: n, value } => {
1419                        let v = eval_expr(value, env, ctx).await;
1420                        (n.name.clone(), v)
1421                    }
1422                };
1423                if value.is_err() {
1424                    return value;
1425                }
1426                bindings.push((param_name, value));
1427            }
1428            let mut sub_env = Env::new();
1429            for (n, v) in bindings {
1430                sub_env.bind(n, v);
1431            }
1432            let sub_run_id = crate::event::FlowRunId::now();
1433            if let Some(sink) = ctx.events {
1434                sink.emit(crate::event::Event::FlowStart {
1435                    run_id: sub_run_id.clone(),
1436                    flow_name: name.name.clone(),
1437                    parent_run_id: ctx.flow_run_id.clone(),
1438                    parent_node_id: ctx.current_node_id.clone(),
1439                    spawned: false,
1440                });
1441            }
1442            if let Some(session) = ctx.session_runtime.as_ref() {
1443                let _ = session
1444                    .stream_tx()
1445                    .send(crate::stream::StreamFrame::FlowStart {
1446                        run_id: sub_run_id.0.to_string(),
1447                        flow_name: name.name.clone(),
1448                        parent_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1449                        parent_node_id: ctx.current_node_id.clone(),
1450                    });
1451            } else if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1452                let _ = tx.send(crate::stream::StreamFrame::FlowStart {
1453                    run_id: sub_run_id.0.to_string(),
1454                    flow_name: name.name.clone(),
1455                    parent_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1456                    parent_node_id: ctx.current_node_id.clone(),
1457                });
1458            }
1459            let sub_ctx = EvalCtx {
1460                flow_run_id: Some(sub_run_id.clone()),
1461                current_node_id: None,
1462                ..ctx.clone()
1463            };
1464            let outcome = crate::exec::exec_stmts(&target.body, &mut sub_env, &sub_ctx).await;
1465            let (result, status, ok) = match outcome {
1466                crate::exec::StmtOutcome::Return(v) => (v, crate::event::FlowStatus::Ok, true),
1467                crate::exec::StmtOutcome::Err(e) => {
1468                    let status = if matches!(&e, crate::error::RuntimeError::Cancelled(_)) {
1469                        crate::event::FlowStatus::Cancelled
1470                    } else {
1471                        crate::event::FlowStatus::Errored {
1472                            message: format!("{e}"),
1473                        }
1474                    };
1475                    (Value::Err(e.clone()), status, false)
1476                }
1477                crate::exec::StmtOutcome::Continue => {
1478                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1479                }
1480                crate::exec::StmtOutcome::LoopBreak => {
1481                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1482                }
1483                crate::exec::StmtOutcome::LoopContinue => {
1484                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1485                }
1486            };
1487            let cancelled = matches!(status, crate::event::FlowStatus::Cancelled);
1488            if let Some(sink) = ctx.events {
1489                sink.emit(crate::event::Event::FlowEnd {
1490                    run_id: sub_run_id.clone(),
1491                    flow_name: name.name.clone(),
1492                    status,
1493                });
1494            }
1495            if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1496                let _ = tx.send(crate::stream::StreamFrame::FlowDone {
1497                    run_id: sub_run_id.0.to_string(),
1498                    flow_name: name.name.clone(),
1499                    ok,
1500                    cancelled,
1501                });
1502            }
1503            result
1504        }
1505    }
1506}
1507
1508fn session_fs_access_policy(session: &crate::session::Session) -> crate::fs_access::FsAccessPolicy {
1509    let workspace = session
1510        .meta()
1511        .and_then(|m| m.project_root)
1512        .or_else(|| std::env::current_dir().ok());
1513    let mode = session
1514        .fs_access_mode()
1515        .unwrap_or(crate::fs_access::FsAccessMode::WorkspaceWrite);
1516    crate::fs_access::FsAccessPolicy { mode, workspace }
1517}
1518
1519fn tool_name(path: &[atman_dsl::ast::Ident]) -> String {
1520    let parts: Vec<&str> = path.iter().map(|i| i.name.as_str()).collect();
1521    parts.join(".")
1522}
1523
1524async fn eval_fix_until_test_passes<'a>(
1525    kwargs: &'a atman_dsl::ast::Kwargs,
1526    env: &'a Env,
1527    ctx: &'a EvalCtx<'a>,
1528) -> Value {
1529    let mut edit_flow_expr: Option<&Expr> = None;
1530    let mut test_expr: Option<&Expr> = None;
1531    let mut on_giveup_expr: Option<&Expr> = None;
1532    let mut max_iters: u32 = 5;
1533    let mut target_path: Option<std::path::PathBuf> = None;
1534
1535    for (k, v) in kwargs {
1536        match k.name.as_str() {
1537            "edit_flow" => edit_flow_expr = Some(v),
1538            "test" => test_expr = Some(v),
1539            "on_giveup" => on_giveup_expr = Some(v),
1540            "max_iters" => match eval_expr(v, env, ctx).await {
1541                Value::Int(n) if n > 0 => max_iters = n as u32,
1542                other => {
1543                    return Value::Err(RuntimeError::TypeMismatch {
1544                        expected: "positive int (max_iters)".into(),
1545                        actual: other.kind_name().into(),
1546                    });
1547                }
1548            },
1549            "target" => match eval_expr(v, env, ctx).await {
1550                Value::Path(p) => target_path = Some(p),
1551                Value::Str(s) => target_path = Some(std::path::PathBuf::from(s)),
1552                Value::Unit => {}
1553                other => {
1554                    return Value::Err(RuntimeError::TypeMismatch {
1555                        expected: "path (target)".into(),
1556                        actual: other.kind_name().into(),
1557                    });
1558                }
1559            },
1560            _ => {}
1561        }
1562    }
1563
1564    let Some(edit_flow_expr) = edit_flow_expr else {
1565        return Value::Err(RuntimeError::MissingArg(
1566            "fix_until_test_passes.edit_flow".into(),
1567        ));
1568    };
1569    let Some(test_expr) = test_expr else {
1570        return Value::Err(RuntimeError::MissingArg(
1571            "fix_until_test_passes.test".into(),
1572        ));
1573    };
1574
1575    let pristine: Option<String> = match &target_path {
1576        Some(p) => match tokio::fs::read_to_string(p).await {
1577            Ok(s) => Some(s),
1578            Err(e) => {
1579                return Value::Err(RuntimeError::ToolFailed(format!(
1580                    "fix_until_test_passes: cannot read target {}: {e}",
1581                    p.display()
1582                )));
1583            }
1584        },
1585        None => None,
1586    };
1587
1588    let mut prev_fail = String::new();
1589    let mut last_test_result: Option<Value> = None;
1590
1591    for iter in 0..max_iters {
1592        let mut loop_env = env.clone();
1593        loop_env.bind("iter", Value::Int(iter as i64));
1594        loop_env.bind("prev_fail", Value::Str(prev_fail.clone()));
1595
1596        let edit_v = eval_expr(edit_flow_expr, &loop_env, ctx).await;
1597        if edit_v.is_err() {
1598            return edit_v;
1599        }
1600        loop_env.bind("last_edit", edit_v);
1601
1602        let test_v = eval_expr(test_expr, &loop_env, ctx).await;
1603        if test_v.is_err() {
1604            return test_v;
1605        }
1606        let exit = test_v
1607            .field("exit_code")
1608            .or_else(|| test_v.field("exit"))
1609            .and_then(|v| match v {
1610                Value::Int(n) => Some(*n),
1611                _ => None,
1612            });
1613        last_test_result = Some(test_v.clone());
1614        if let Some(0) = exit {
1615            return Value::Struct(vec![
1616                ("status".into(), Value::Str("passed".into())),
1617                ("iters".into(), Value::Int((iter + 1) as i64)),
1618                ("test".into(), test_v),
1619            ]);
1620        }
1621        let stderr_tail = test_v
1622            .field("stderr_tail")
1623            .or_else(|| test_v.field("output"))
1624            .and_then(|v| match v {
1625                Value::Str(s) => Some(s.clone()),
1626                _ => None,
1627            })
1628            .unwrap_or_default();
1629        let stdout_tail = test_v
1630            .field("stdout_tail")
1631            .and_then(|v| match v {
1632                Value::Str(s) => Some(s.clone()),
1633                _ => None,
1634            })
1635            .unwrap_or_default();
1636        prev_fail = format!(
1637            "iter {iter} exit={:?}\n--- stderr ---\n{stderr_tail}\n--- stdout ---\n{stdout_tail}",
1638            exit
1639        );
1640
1641        if let (Some(target), Some(pristine)) = (&target_path, &pristine)
1642            && let Err(e) = tokio::fs::write(target, pristine.as_bytes()).await
1643        {
1644            return Value::Err(RuntimeError::ToolFailed(format!(
1645                "fix_until_test_passes: revert failed on {}: {e}",
1646                target.display()
1647            )));
1648        }
1649    }
1650
1651    if let Some(giveup) = on_giveup_expr {
1652        let mut giveup_env = env.clone();
1653        giveup_env.bind("iters", Value::Int(max_iters as i64));
1654        giveup_env.bind("prev_fail", Value::Str(prev_fail));
1655        return eval_expr(giveup, &giveup_env, ctx).await;
1656    }
1657
1658    Value::Struct(vec![
1659        ("status".into(), Value::Str("gave_up".into())),
1660        ("iters".into(), Value::Int(max_iters as i64)),
1661        ("last_test".into(), last_test_result.unwrap_or(Value::Unit)),
1662    ])
1663}
1664
1665async fn eval_message_node<'a>(
1666    ast_role: atman_dsl::ast::MessageRole,
1667    args: &'a [Arg],
1668    env: &'a Env,
1669    ctx: &'a EvalCtx<'a>,
1670) -> Value {
1671    use crate::message::{
1672        ImageData, ImageSource, Message, MessageOrigin, MessagePart, MessageRole,
1673    };
1674
1675    let role = match ast_role {
1676        atman_dsl::ast::MessageRole::User => MessageRole::User,
1677        atman_dsl::ast::MessageRole::Assistant => MessageRole::Assistant,
1678        atman_dsl::ast::MessageRole::System => MessageRole::System,
1679        atman_dsl::ast::MessageRole::Tool => MessageRole::Tool,
1680    };
1681    let turn_id = ctx
1682        .turn_id
1683        .clone()
1684        .unwrap_or_else(crate::event::TurnId::now);
1685
1686    let mut positional = Vec::new();
1687    let mut named: Vec<(String, Value)> = Vec::new();
1688    let mut attachment_paths_raw: Option<Vec<std::path::PathBuf>> = None;
1689    for arg in args {
1690        match arg {
1691            Arg::Positional(e) => {
1692                let v = eval_expr(e, env, ctx).await;
1693                if v.is_err() {
1694                    return v;
1695                }
1696                positional.push(v);
1697            }
1698            Arg::Named { name, value } => {
1699                if name.name == "attachments" {
1700                    if let Expr::List(items) = value {
1701                        let mut collected = Vec::with_capacity(items.len());
1702                        let mut all_fileref = true;
1703                        for it in items {
1704                            if let Expr::FileRef(f) = it {
1705                                collected.push(std::path::PathBuf::from(&f.path));
1706                            } else {
1707                                all_fileref = false;
1708                                break;
1709                            }
1710                        }
1711                        if all_fileref {
1712                            attachment_paths_raw = Some(collected);
1713                            continue;
1714                        }
1715                    }
1716                }
1717                let v = eval_expr(value, env, ctx).await;
1718                if v.is_err() {
1719                    return v;
1720                }
1721                named.push((name.name.clone(), v));
1722            }
1723        }
1724    }
1725    let take_named = |k: &str, named: &mut Vec<(String, Value)>| -> Option<Value> {
1726        let pos = named.iter().position(|(n, _)| n == k)?;
1727        Some(named.remove(pos).1)
1728    };
1729
1730    if role == MessageRole::Tool {
1731        let tool_use_id = match positional.first().or(take_named("id", &mut named).as_ref()) {
1732            Some(Value::Str(s)) => s.clone(),
1733            Some(other) => {
1734                return Value::Err(RuntimeError::TypeMismatch {
1735                    expected: "string (tool_use_id)".into(),
1736                    actual: other.kind_name().into(),
1737                });
1738            }
1739            None => {
1740                return Value::Err(RuntimeError::MissingArg("tool_result: id".into()));
1741            }
1742        };
1743        let content = match positional
1744            .get(1)
1745            .or(take_named("content", &mut named).as_ref())
1746        {
1747            Some(Value::Str(s)) => s.clone(),
1748            Some(other) => {
1749                return Value::Err(RuntimeError::TypeMismatch {
1750                    expected: "string (content)".into(),
1751                    actual: other.kind_name().into(),
1752                });
1753            }
1754            None => {
1755                return Value::Err(RuntimeError::MissingArg("tool_result: content".into()));
1756            }
1757        };
1758        let is_error = match take_named("is_error", &mut named) {
1759            Some(Value::Bool(b)) => b,
1760            Some(other) => {
1761                return Value::Err(RuntimeError::TypeMismatch {
1762                    expected: "bool (is_error)".into(),
1763                    actual: other.kind_name().into(),
1764                });
1765            }
1766            None => false,
1767        };
1768        return Value::Message(Message {
1769            role,
1770            parts: vec![MessagePart::ToolResult {
1771                tool_use_id,
1772                content,
1773                is_error,
1774            }],
1775            turn_id,
1776            origin: MessageOrigin::User,
1777        });
1778    }
1779
1780    let text = match positional.first() {
1781        Some(Value::Str(s)) => Some(s.clone()),
1782        Some(other) => {
1783            return Value::Err(RuntimeError::TypeMismatch {
1784                expected: "string (message text)".into(),
1785                actual: other.kind_name().into(),
1786            });
1787        }
1788        None => None,
1789    };
1790    let attachment_paths: Vec<std::path::PathBuf> = if let Some(raw) = attachment_paths_raw {
1791        raw
1792    } else {
1793        match take_named("attachments", &mut named) {
1794            Some(Value::List(items)) => {
1795                let mut ps = Vec::with_capacity(items.len());
1796                for it in items {
1797                    match it {
1798                        Value::Path(p) => ps.push(p),
1799                        Value::Str(s) => ps.push(std::path::PathBuf::from(s)),
1800                        other => {
1801                            return Value::Err(RuntimeError::TypeMismatch {
1802                                expected: "path (attachment)".into(),
1803                                actual: other.kind_name().into(),
1804                            });
1805                        }
1806                    }
1807                }
1808                ps
1809            }
1810            Some(other) => {
1811                return Value::Err(RuntimeError::TypeMismatch {
1812                    expected: "list of path".into(),
1813                    actual: other.kind_name().into(),
1814                });
1815            }
1816            None => Vec::new(),
1817        }
1818    };
1819
1820    let mut parts: Vec<MessagePart> = attachment_paths
1821        .into_iter()
1822        .map(|path| {
1823            let media_type = guess_image_mime(&path).unwrap_or_else(|| "image/png".to_string());
1824            MessagePart::Image {
1825                source: ImageSource {
1826                    media_type,
1827                    data: ImageData::Path { path },
1828                },
1829            }
1830        })
1831        .collect();
1832    if let Some(t) = text {
1833        parts.push(MessagePart::Text { text: t });
1834    }
1835
1836    Value::Message(Message {
1837        role,
1838        parts,
1839        turn_id,
1840        origin: MessageOrigin::User,
1841    })
1842}
1843
1844pub(super) fn render_injections(injections: &[crate::injection::Injection]) -> String {
1845    use crate::injection::{InjectionLevel, InjectionSource};
1846    let has_user = injections
1847        .iter()
1848        .any(|i| matches!(i.source, InjectionSource::User));
1849    let has_watcher = injections
1850        .iter()
1851        .any(|i| matches!(i.source, InjectionSource::Watcher { .. }));
1852    let mut out = String::new();
1853    if has_user {
1854        out.push_str(
1855            "The user sent the following steering message(s) while you were working. \
1856             Apply them to your next step if still relevant.\n\n",
1857        );
1858    }
1859    if has_watcher {
1860        out.push_str("A background watcher detected the following event(s):\n\n");
1861    }
1862    for inj in injections {
1863        let (tag, source_attr) = match &inj.source {
1864            InjectionSource::User => match inj.level {
1865                InjectionLevel::L2CourseCorrect => ("user_correction", "user".to_string()),
1866                _ => ("user_nudge", "user".to_string()),
1867            },
1868            InjectionSource::Watcher {
1869                watcher_id,
1870                kind,
1871                handle,
1872            } => (
1873                "watcher_event",
1874                format!("{kind} '{handle}' watcher {watcher_id}"),
1875            ),
1876        };
1877        out.push_str(&format!(
1878            "<{tag} id=\"{}\" ts=\"{}\" source=\"{}\">\n{}\n</{tag}>\n",
1879            inj.id.0,
1880            inj.created_at.to_rfc3339(),
1881            source_attr,
1882            inj.text
1883        ));
1884    }
1885    out
1886}
1887
1888fn guess_image_mime(path: &std::path::Path) -> Option<String> {
1889    let ext = path
1890        .extension()
1891        .and_then(|s| s.to_str())?
1892        .to_ascii_lowercase();
1893    Some(
1894        match ext.as_str() {
1895            "png" => "image/png",
1896            "jpg" | "jpeg" => "image/jpeg",
1897            "gif" => "image/gif",
1898            "webp" => "image/webp",
1899            _ => return None,
1900        }
1901        .to_string(),
1902    )
1903}
1904
1905fn contract_allows_shell(contract: Option<&atman_dsl::ast::Contract>) -> bool {
1906    let Some(c) = contract else { return false };
1907    for block in &c.blocks {
1908        if block.name.name != "capabilities" {
1909            continue;
1910        }
1911        for (k, v) in &block.kwargs {
1912            if k.name != "shell" {
1913                continue;
1914            }
1915            if let atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Bool(true)) = v {
1916                return true;
1917            }
1918        }
1919    }
1920    false
1921}
1922
1923pub struct TruncationStat {
1924    pub original_chars: usize,
1925    pub result_chars: usize,
1926    pub dropped_chars: usize,
1927    pub budget_tokens: u64,
1928}
1929
1930pub(super) fn sanitize_tool_pairs(
1931    messages: Vec<crate::message::Message>,
1932) -> Vec<crate::message::Message> {
1933    use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
1934    use std::collections::HashMap;
1935    let mut result_by_id: HashMap<String, Message> = HashMap::new();
1936    for m in &messages {
1937        for p in &m.parts {
1938            if let MessagePart::ToolResult { tool_use_id, .. } = p {
1939                result_by_id
1940                    .entry(tool_use_id.clone())
1941                    .or_insert_with(|| Message {
1942                        role: MessageRole::Tool,
1943                        parts: vec![p.clone()],
1944                        turn_id: m.turn_id.clone(),
1945                        origin: MessageOrigin::User,
1946                    });
1947            }
1948        }
1949    }
1950    let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 4);
1951    for m in &messages {
1952        let uses: Vec<String> = m
1953            .parts
1954            .iter()
1955            .filter_map(|p| match p {
1956                MessagePart::ToolUse { id, .. } => Some(id.clone()),
1957                _ => None,
1958            })
1959            .collect();
1960        let is_pure_result = m
1961            .parts
1962            .iter()
1963            .all(|p| matches!(p, MessagePart::ToolResult { .. }));
1964        if is_pure_result {
1965            continue;
1966        }
1967        out.push(m.clone());
1968        if !uses.is_empty() {
1969            for u in &uses {
1970                if let Some(rm) = result_by_id.get(u) {
1971                    if let Some(MessagePart::ToolResult {
1972                        tool_use_id,
1973                        content,
1974                        is_error,
1975                    }) = rm.parts.first()
1976                    {
1977                        out.push(Message {
1978                            role: MessageRole::Tool,
1979                            parts: vec![MessagePart::ToolResult {
1980                                tool_use_id: tool_use_id.clone(),
1981                                content: content.clone(),
1982                                is_error: *is_error,
1983                            }],
1984                            turn_id: m.turn_id.clone(),
1985                            origin: MessageOrigin::User,
1986                        });
1987                    }
1988                } else {
1989                    out.push(Message {
1990                        role: MessageRole::Tool,
1991                        parts: vec![MessagePart::ToolResult {
1992                            tool_use_id: u.clone(),
1993                            content: "[tool execution interrupted — no result captured]".into(),
1994                            is_error: true,
1995                        }],
1996                        turn_id: m.turn_id.clone(),
1997                        origin: MessageOrigin::User,
1998                    });
1999                }
2000            }
2001        }
2002    }
2003    out
2004}
2005
2006pub fn truncate_prompt_to_budget(prompt: String, budget_tokens: u64) -> String {
2007    truncate_prompt_to_budget_tracked(prompt, budget_tokens).0
2008}
2009
2010pub fn truncate_prompt_to_budget_tracked(
2011    prompt: String,
2012    budget_tokens: u64,
2013) -> (String, Option<TruncationStat>) {
2014    let budget_chars = budget_tokens.saturating_mul(4) as usize;
2015    if prompt.len() <= budget_chars {
2016        return (prompt, None);
2017    }
2018    let head_chars = budget_chars * 4 / 10;
2019    let tail_chars = budget_chars * 4 / 10;
2020    if head_chars + tail_chars >= prompt.len() {
2021        return (prompt, None);
2022    }
2023    let original_chars = prompt.len();
2024    let head_end = char_boundary(&prompt, head_chars, false);
2025    let tail_start = char_boundary(&prompt, prompt.len().saturating_sub(tail_chars), true);
2026    let head = &prompt[..head_end];
2027    let tail = &prompt[tail_start..];
2028    let dropped = original_chars - head.len() - tail.len();
2029    let result = format!("{head}\n\n[... truncated {dropped} chars ...]\n\n{tail}");
2030    let stat = TruncationStat {
2031        original_chars,
2032        result_chars: result.len(),
2033        dropped_chars: dropped,
2034        budget_tokens,
2035    };
2036    (result, Some(stat))
2037}
2038
2039fn char_boundary(s: &str, target: usize, round_up: bool) -> usize {
2040    let mut idx = target.min(s.len());
2041    while idx > 0 && idx < s.len() && !s.is_char_boundary(idx) {
2042        if round_up {
2043            idx += 1;
2044        } else {
2045            idx -= 1;
2046        }
2047    }
2048    idx
2049}
2050
2051// Bare primitive names inside `schema: { valid: bool, ... }` parse as tool calls; treat as Unit.
2052fn is_type_annotation(path: &[atman_dsl::ast::Ident]) -> bool {
2053    if path.len() != 1 {
2054        return false;
2055    }
2056    matches!(
2057        path[0].name.as_str(),
2058        "bool" | "int" | "float" | "string" | "path" | "bytes" | "duration"
2059    )
2060}
2061
2062fn eval_literal(lit: &Literal) -> Value {
2063    match lit {
2064        Literal::Str(s) => Value::Str(s.clone()),
2065        Literal::Int(n) => Value::Int(*n),
2066        Literal::Float(f) => Value::Float(*f),
2067        Literal::Bool(b) => Value::Bool(*b),
2068    }
2069}
2070
2071fn eval_binop(op: BinOp, l: &Value, r: &Value) -> Value {
2072    match op {
2073        BinOp::Eq => Value::Bool(value_eq(l, r)),
2074        BinOp::Ne => Value::Bool(!value_eq(l, r)),
2075        BinOp::Lt => value_cmp(l, r, |a, b| a < b, |a, b| a < b, |a, b| a < b),
2076        BinOp::Le => value_cmp(l, r, |a, b| a <= b, |a, b| a <= b, |a, b| a <= b),
2077        BinOp::Gt => value_cmp(l, r, |a, b| a > b, |a, b| a > b, |a, b| a > b),
2078        BinOp::Ge => value_cmp(l, r, |a, b| a >= b, |a, b| a >= b, |a, b| a >= b),
2079        BinOp::And => match (l, r) {
2080            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a && *b),
2081            _ => type_mismatch("bool && bool", l, r),
2082        },
2083        BinOp::Or => match (l, r) {
2084            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a || *b),
2085            _ => type_mismatch("bool || bool", l, r),
2086        },
2087        BinOp::Add => match (l, r) {
2088            (Value::Int(a), Value::Int(b)) => Value::Int(a + b),
2089            (Value::Float(a), Value::Float(b)) => Value::Float(a + b),
2090            (Value::Str(a), Value::Str(b)) => Value::Str(format!("{a}{b}")),
2091            (Value::Str(a), Value::Path(b)) => Value::Str(format!("{a}{}", b.display())),
2092            (Value::Path(a), Value::Str(b)) => Value::Str(format!("{}{b}", a.display())),
2093            _ => type_mismatch(
2094                "int+int | float+float | string+string | string+path | path+string",
2095                l,
2096                r,
2097            ),
2098        },
2099        BinOp::Sub => match (l, r) {
2100            (Value::Int(a), Value::Int(b)) => Value::Int(a - b),
2101            (Value::Float(a), Value::Float(b)) => Value::Float(a - b),
2102            _ => type_mismatch("int-int | float-float", l, r),
2103        },
2104        BinOp::Mul => match (l, r) {
2105            (Value::Int(a), Value::Int(b)) => Value::Int(a * b),
2106            (Value::Float(a), Value::Float(b)) => Value::Float(a * b),
2107            _ => type_mismatch("int*int | float*float", l, r),
2108        },
2109        BinOp::Div => match (l, r) {
2110            (Value::Int(_), Value::Int(0)) => {
2111                Value::Err(RuntimeError::ToolFailed("integer div by zero".into()))
2112            }
2113            (Value::Int(a), Value::Int(b)) => Value::Int(a / b),
2114            (Value::Float(a), Value::Float(b)) => Value::Float(a / b),
2115            _ => type_mismatch("int/int | float/float", l, r),
2116        },
2117        BinOp::Mod => match (l, r) {
2118            (Value::Int(_), Value::Int(0)) => {
2119                Value::Err(RuntimeError::ToolFailed("integer mod by zero".into()))
2120            }
2121            (Value::Int(a), Value::Int(b)) => Value::Int(a % b),
2122            (Value::Float(a), Value::Float(b)) => Value::Float(a % b),
2123            _ => type_mismatch("int%int | float%float", l, r),
2124        },
2125    }
2126}
2127
2128fn eval_unop(op: UnOp, v: &Value) -> Value {
2129    match op {
2130        UnOp::Not => match v {
2131            Value::Bool(b) => Value::Bool(!b),
2132            other => Value::Err(RuntimeError::TypeMismatch {
2133                expected: "bool".into(),
2134                actual: other.kind_name().into(),
2135            }),
2136        },
2137        UnOp::Neg => match v {
2138            Value::Int(n) => Value::Int(-n),
2139            Value::Float(n) => Value::Float(-n),
2140            other => Value::Err(RuntimeError::TypeMismatch {
2141                expected: "int or float".into(),
2142                actual: other.kind_name().into(),
2143            }),
2144        },
2145    }
2146}
2147
2148fn value_eq(l: &Value, r: &Value) -> bool {
2149    match (l, r) {
2150        (Value::Unit, Value::Unit) => true,
2151        (Value::Bool(a), Value::Bool(b)) => a == b,
2152        (Value::Int(a), Value::Int(b)) => a == b,
2153        (Value::Float(a), Value::Float(b)) => a == b,
2154        (Value::Str(a), Value::Str(b)) => a == b,
2155        (Value::Path(a), Value::Path(b)) => a == b,
2156        _ => false,
2157    }
2158}
2159
2160fn value_cmp(
2161    l: &Value,
2162    r: &Value,
2163    int_cmp: fn(i64, i64) -> bool,
2164    float_cmp: fn(f64, f64) -> bool,
2165    str_cmp: fn(&str, &str) -> bool,
2166) -> Value {
2167    match (l, r) {
2168        (Value::Int(a), Value::Int(b)) => Value::Bool(int_cmp(*a, *b)),
2169        (Value::Float(a), Value::Float(b)) => Value::Bool(float_cmp(*a, *b)),
2170        (Value::Str(a), Value::Str(b)) => Value::Bool(str_cmp(a, b)),
2171        _ => type_mismatch("comparable pair", l, r),
2172    }
2173}
2174
2175fn type_mismatch(expected: &str, l: &Value, r: &Value) -> Value {
2176    Value::Err(RuntimeError::TypeMismatch {
2177        expected: expected.into(),
2178        actual: format!("{} vs {}", l.kind_name(), r.kind_name()),
2179    })
2180}
2181
2182pub(super) fn input_with_cache_for_window(usage: &crate::provider::TokenUsage) -> u64 {
2183    usage.input + usage.cached_input
2184}
2185
2186#[cfg(test)]
2187mod tests {
2188    use super::*;
2189    use atman_dsl::parse::parse_file;
2190
2191    #[test]
2192    fn char_boundary_rounds_around_multibyte_characters() {
2193        let text = "a你😀b";
2194        assert_eq!(char_boundary(text, 2, false), 1);
2195        assert_eq!(char_boundary(text, 2, true), 4);
2196        assert_eq!(char_boundary(text, 6, false), 4);
2197        assert_eq!(char_boundary(text, 6, true), 8);
2198        assert_eq!(char_boundary(text, 8, false), 8);
2199        assert_eq!(char_boundary(text, 99, false), text.len());
2200    }
2201
2202    #[test]
2203    fn parse_context_mode_handles_variants() {
2204        assert!(matches!(
2205            parse_context_mode("session"),
2206            ContextMode::Session
2207        ));
2208        assert!(matches!(parse_context_mode("none"), ContextMode::None));
2209        assert!(matches!(parse_context_mode(""), ContextMode::None));
2210        assert!(matches!(
2211            parse_context_mode(" session "),
2212            ContextMode::Session
2213        ));
2214        match parse_context_mode("session_recent(5)") {
2215            ContextMode::SessionRecent(n) => assert_eq!(n, 5),
2216            other => panic!("expected SessionRecent(5), got {other:?}"),
2217        }
2218        match parse_context_mode("session_recent") {
2219            ContextMode::SessionRecent(n) => assert_eq!(n, 10),
2220            other => panic!("expected SessionRecent(10), got {other:?}"),
2221        }
2222        assert!(matches!(parse_context_mode("garbage"), ContextMode::None));
2223    }
2224
2225    #[test]
2226    fn input_with_cache_for_window_does_not_double_count_cache_write() {
2227        let usage = crate::provider::TokenUsage {
2228            input: 50_000,
2229            cached_input: 0,
2230            cache_write: 50_000,
2231            ..Default::default()
2232        };
2233
2234        assert_eq!(input_with_cache_for_window(&usage), 50_000);
2235    }
2236
2237    async fn eval_snippet(expr_src: &str) -> Value {
2238        let src = format!("flow t() {{\n    return {expr_src}\n}}\n");
2239        let file = parse_file(&src).expect("parse test snippet");
2240        let tools = ToolRegistry::new();
2241        let tool_ctx = ToolCtx::new();
2242        let providers = crate::provider::ProviderRegistry::new();
2243        let flows = std::collections::HashMap::new();
2244        let ctx = EvalCtx {
2245            tools: &tools,
2246            tool_ctx: &tool_ctx,
2247            providers: &providers,
2248            flows: &flows,
2249            contract: None,
2250            events: None,
2251            turn_id: None,
2252            flow_run_id: None,
2253            session_runtime: None,
2254            flow_cancel: tokio_util::sync::CancellationToken::new(),
2255            safety: None,
2256            current_node_id: None,
2257            source_dir: None,
2258        };
2259        let stmt = &file.flows[0].body[0];
2260        if let atman_dsl::ast::Stmt::Return { value } = stmt {
2261            eval_expr(value, &Env::new(), &ctx).await
2262        } else {
2263            panic!("expected return statement");
2264        }
2265    }
2266
2267    #[tokio::test]
2268    async fn literals_evaluate() {
2269        assert!(matches!(eval_snippet("42").await, Value::Int(42)));
2270        assert!(matches!(eval_snippet("true").await, Value::Bool(true)));
2271        assert!(matches!(
2272            eval_snippet(r#""hello""#).await,
2273            Value::Str(s) if s == "hello"
2274        ));
2275    }
2276
2277    #[tokio::test]
2278    async fn undefined_ident_yields_err_value() {
2279        assert!(matches!(
2280            eval_snippet("missing").await,
2281            Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2282        ));
2283    }
2284
2285    #[tokio::test]
2286    async fn binary_arithmetic_and_comparison() {
2287        assert!(matches!(eval_snippet("1 == 1").await, Value::Bool(true)));
2288        assert!(matches!(eval_snippet("2 < 3").await, Value::Bool(true)));
2289        assert!(matches!(
2290            eval_snippet(r#""a" + "b""#).await,
2291            Value::Str(s) if s == "ab"
2292        ));
2293    }
2294
2295    #[tokio::test]
2296    async fn type_mismatch_bubbles_up() {
2297        assert!(matches!(
2298            eval_snippet(r#"1 + "x""#).await,
2299            Value::Err(RuntimeError::TypeMismatch { .. })
2300        ));
2301    }
2302
2303    #[tokio::test]
2304    async fn err_short_circuits_binary() {
2305        assert!(matches!(
2306            eval_snippet("missing == 1").await,
2307            Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2308        ));
2309    }
2310
2311    #[tokio::test]
2312    async fn list_evaluates_all_items() {
2313        let v = eval_snippet("[1, 2, 3]").await;
2314        if let Value::List(items) = v {
2315            assert_eq!(items.len(), 3);
2316            assert!(matches!(items[2], Value::Int(3)));
2317        } else {
2318            panic!("expected list");
2319        }
2320    }
2321
2322    #[tokio::test]
2323    async fn struct_literal_evaluates_fields_in_order() {
2324        let v = eval_snippet(r#"{ severity: "critical", count: 3 }"#).await;
2325        if let Value::Struct(fields) = v {
2326            assert_eq!(fields[0].0, "severity");
2327            assert_eq!(fields[1].0, "count");
2328        } else {
2329            panic!("expected struct");
2330        }
2331    }
2332
2333    #[tokio::test]
2334    async fn undefined_tool_returns_undefined_tool_err() {
2335        let src = r#"flow t() { return fs.readnope("/tmp") }"#;
2336        let file = parse_file(src).unwrap();
2337        let tools = ToolRegistry::new();
2338        let tool_ctx = ToolCtx::new();
2339        let providers = crate::provider::ProviderRegistry::new();
2340        let flows = std::collections::HashMap::new();
2341        let ctx = EvalCtx {
2342            tools: &tools,
2343            tool_ctx: &tool_ctx,
2344            providers: &providers,
2345            flows: &flows,
2346            contract: None,
2347            events: None,
2348            turn_id: None,
2349            flow_run_id: None,
2350            session_runtime: None,
2351            flow_cancel: tokio_util::sync::CancellationToken::new(),
2352            safety: None,
2353            current_node_id: None,
2354            source_dir: None,
2355        };
2356        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2357            let v = eval_expr(value, &Env::new(), &ctx).await;
2358            assert!(matches!(
2359                v,
2360                Value::Err(RuntimeError::UndefinedTool(name)) if name == "fs.readnope"
2361            ));
2362        }
2363    }
2364
2365    #[tokio::test]
2366    async fn fanout_all_gathers_results_in_order() {
2367        use crate::tools::fs::FsRead;
2368        use std::sync::Arc;
2369        use tempfile::TempDir;
2370
2371        let dir = TempDir::new().unwrap();
2372        let pa = dir.path().join("a.txt");
2373        let pb = dir.path().join("b.txt");
2374        tokio::fs::write(&pa, b"AAA").await.unwrap();
2375        tokio::fs::write(&pb, b"BBB").await.unwrap();
2376
2377        let tools = ToolRegistry::new();
2378        tools.register(Arc::new(FsRead));
2379        let tool_ctx = ToolCtx::new();
2380        let providers = crate::provider::ProviderRegistry::new();
2381        let flows = std::collections::HashMap::new();
2382        let ctx = EvalCtx {
2383            tools: &tools,
2384            tool_ctx: &tool_ctx,
2385            providers: &providers,
2386            flows: &flows,
2387            contract: None,
2388            events: None,
2389            turn_id: None,
2390            flow_run_id: None,
2391            session_runtime: None,
2392            flow_cancel: tokio_util::sync::CancellationToken::new(),
2393            safety: None,
2394            current_node_id: None,
2395            source_dir: None,
2396        };
2397
2398        let mut env = Env::new();
2399        env.bind("a", Value::Path(pa));
2400        env.bind("b", Value::Path(pb));
2401
2402        let src = r#"flow t() { return fanout [ fs.read(a), fs.read(b) ] collect: all }"#;
2403        let file = parse_file(src).unwrap();
2404        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2405            let v = eval_expr(value, &env, &ctx).await;
2406            if let Value::List(items) = v {
2407                assert_eq!(items.len(), 2);
2408                assert!(matches!(&items[0], Value::Str(s) if s == "AAA"));
2409                assert!(matches!(&items[1], Value::Str(s) if s == "BBB"));
2410            } else {
2411                panic!("expected list");
2412            }
2413        }
2414    }
2415
2416    #[tokio::test]
2417    async fn fanout_all_short_circuits_on_err() {
2418        let src = r#"flow t() { return fanout [ 1, missing, 3 ] collect: all }"#;
2419        let file = parse_file(src).unwrap();
2420        let tools = ToolRegistry::new();
2421        let tool_ctx = ToolCtx::new();
2422        let providers = crate::provider::ProviderRegistry::new();
2423        let flows = std::collections::HashMap::new();
2424        let ctx = EvalCtx {
2425            tools: &tools,
2426            tool_ctx: &tool_ctx,
2427            providers: &providers,
2428            flows: &flows,
2429            contract: None,
2430            events: None,
2431            turn_id: None,
2432            flow_run_id: None,
2433            session_runtime: None,
2434            flow_cancel: tokio_util::sync::CancellationToken::new(),
2435            safety: None,
2436            current_node_id: None,
2437            source_dir: None,
2438        };
2439        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2440            let v = eval_expr(value, &Env::new(), &ctx).await;
2441            assert!(matches!(
2442                v,
2443                Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2444            ));
2445        }
2446    }
2447
2448    #[tokio::test]
2449    async fn llm_node_dispatches_to_mock_provider() {
2450        use crate::providers::mock::MockProvider;
2451        use std::sync::Arc;
2452
2453        {
2454            let _lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2455            crate::model_registry::set_model_config(crate::model_registry::ModelConfig {
2456                models: std::collections::HashMap::from([(
2457                    "mock".into(),
2458                    crate::model_registry::ModelEntry {
2459                        model: "mock".into(),
2460                        context_budget: Some(8_192),
2461                        ..Default::default()
2462                    },
2463                )]),
2464                ..Default::default()
2465            });
2466        }
2467
2468        let providers = crate::provider::ProviderRegistry::new();
2469        providers.register(Arc::new(MockProvider::new("mock").with_model(
2470            "mock",
2471            Value::Struct(vec![("severity".into(), Value::Str("info".into()))]),
2472        )));
2473        let tools = ToolRegistry::new();
2474        crate::tools::register_tier_zero(&tools);
2475        let tool_ctx = ToolCtx::new()
2476            .with_providers(std::sync::Arc::new(providers.clone()))
2477            .with_registry(std::sync::Arc::new(tools.clone()));
2478        let flows = std::collections::HashMap::new();
2479        let ctx = EvalCtx {
2480            tools: &tools,
2481            tool_ctx: &tool_ctx,
2482            providers: &providers,
2483            flows: &flows,
2484            contract: None,
2485            events: None,
2486            turn_id: None,
2487            flow_run_id: None,
2488            session_runtime: None,
2489            flow_cancel: tokio_util::sync::CancellationToken::new(),
2490            safety: None,
2491            current_node_id: None,
2492            source_dir: None,
2493        };
2494
2495        let src = r#"flow t() {
2496    return llm.call(
2497        model: "mock",
2498        prompt: "review please",
2499        input: 1,
2500    )
2501}
2502"#;
2503        let file = parse_file(src).unwrap();
2504        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2505            let v = eval_expr(value, &Env::new(), &ctx).await;
2506            if let Value::Struct(fields) = v {
2507                assert_eq!(fields[0].0, "severity");
2508                assert!(matches!(&fields[0].1, Value::Str(s) if s == "info"));
2509            } else {
2510                panic!("expected struct, got {v:?}");
2511            }
2512        }
2513    }
2514
2515    #[tokio::test]
2516    async fn llm_missing_model_reports_missing_arg() {
2517        let providers = crate::provider::ProviderRegistry::new();
2518        let tools = ToolRegistry::new();
2519        let tool_ctx = ToolCtx::new();
2520        let flows = std::collections::HashMap::new();
2521        let ctx = EvalCtx {
2522            tools: &tools,
2523            tool_ctx: &tool_ctx,
2524            providers: &providers,
2525            flows: &flows,
2526            contract: None,
2527            events: None,
2528            turn_id: None,
2529            flow_run_id: None,
2530            session_runtime: None,
2531            flow_cancel: tokio_util::sync::CancellationToken::new(),
2532            safety: None,
2533            current_node_id: None,
2534            source_dir: None,
2535        };
2536        let src = r#"flow t() { return llm.call(prompt: "hi") }"#;
2537        let file = parse_file(src).unwrap();
2538        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2539            let v = eval_expr(value, &Env::new(), &ctx).await;
2540            assert!(v.is_err(), "expected error, got {v:?}");
2541        }
2542    }
2543
2544    #[tokio::test]
2545    async fn user_confirm_stub_returns_true() {
2546        let providers = crate::provider::ProviderRegistry::new();
2547        let tools = ToolRegistry::new();
2548        let tool_ctx = ToolCtx::new();
2549        let flows = std::collections::HashMap::new();
2550        let ctx = EvalCtx {
2551            tools: &tools,
2552            tool_ctx: &tool_ctx,
2553            providers: &providers,
2554            flows: &flows,
2555            contract: None,
2556            events: None,
2557            turn_id: None,
2558            flow_run_id: None,
2559            session_runtime: None,
2560            flow_cancel: tokio_util::sync::CancellationToken::new(),
2561            safety: None,
2562            current_node_id: None,
2563            source_dir: None,
2564        };
2565        let src = r#"flow t() { return user_confirm("proceed?") }"#;
2566        let file = parse_file(src).unwrap();
2567        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2568            assert!(matches!(
2569                eval_expr(value, &Env::new(), &ctx).await,
2570                Value::Bool(true)
2571            ));
2572        }
2573    }
2574
2575    #[tokio::test]
2576    async fn subflow_calls_target_flow_with_positional_args() {
2577        let src = r#"flow child(n: Int) -> Int {
2578    return n + 100
2579}
2580
2581flow parent(x: Int) -> Int {
2582    y = subflow(child, x)
2583    return y + 1
2584}
2585"#;
2586        let file = parse_file(src).unwrap();
2587        let flows_map: std::collections::HashMap<_, _> = file
2588            .flows
2589            .iter()
2590            .map(|f| (f.name.name.clone(), f.clone()))
2591            .collect();
2592        let parent = &file.flows[1];
2593        let tools = ToolRegistry::new();
2594        let tool_ctx = ToolCtx::new();
2595        let providers = crate::provider::ProviderRegistry::new();
2596        let out = crate::exec::exec_flow_with_siblings(
2597            parent,
2598            vec![("x".into(), Value::Int(5))],
2599            &tools,
2600            &tool_ctx,
2601            &providers,
2602            &flows_map,
2603            None,
2604            None,
2605            None,
2606            None,
2607            tokio_util::sync::CancellationToken::new(),
2608            None,
2609            None,
2610        )
2611        .await
2612        .unwrap();
2613        assert!(matches!(out, Value::Int(106)));
2614    }
2615
2616    #[tokio::test]
2617    async fn subflow_missing_target_reports_undefined_tool() {
2618        let src = r#"flow parent() -> Int {
2619    return subflow(nope, 1)
2620}
2621"#;
2622        let file = parse_file(src).unwrap();
2623        let flows: std::collections::HashMap<_, _> = file
2624            .flows
2625            .iter()
2626            .map(|f| (f.name.name.clone(), f.clone()))
2627            .collect();
2628        let tools = ToolRegistry::new();
2629        let tool_ctx = ToolCtx::new();
2630        let providers = crate::provider::ProviderRegistry::new();
2631        let err = crate::exec::exec_flow_with_siblings(
2632            &file.flows[0],
2633            vec![],
2634            &tools,
2635            &tool_ctx,
2636            &providers,
2637            &flows,
2638            None,
2639            None,
2640            None,
2641            None,
2642            tokio_util::sync::CancellationToken::new(),
2643            None,
2644            None,
2645        )
2646        .await
2647        .unwrap_err();
2648        assert!(matches!(err, RuntimeError::UndefinedTool(name) if name.contains("nope")));
2649    }
2650
2651    #[tokio::test]
2652    async fn tool_call_dispatches_via_registry() {
2653        use crate::tools::fs::FsRead;
2654        use std::sync::Arc;
2655        use tempfile::TempDir;
2656
2657        let dir = TempDir::new().unwrap();
2658        let path = dir.path().join("hi.txt");
2659        tokio::fs::write(&path, b"hello runtime").await.unwrap();
2660
2661        let tools = ToolRegistry::new();
2662        tools.register(Arc::new(FsRead));
2663        let tool_ctx = ToolCtx::new();
2664        let providers = crate::provider::ProviderRegistry::new();
2665        let flows = std::collections::HashMap::new();
2666        let ctx = EvalCtx {
2667            tools: &tools,
2668            tool_ctx: &tool_ctx,
2669            providers: &providers,
2670            flows: &flows,
2671            contract: None,
2672            events: None,
2673            turn_id: None,
2674            flow_run_id: None,
2675            session_runtime: None,
2676            flow_cancel: tokio_util::sync::CancellationToken::new(),
2677            safety: None,
2678            current_node_id: None,
2679            source_dir: None,
2680        };
2681
2682        let mut env = Env::new();
2683        env.bind("p", Value::Path(path));
2684
2685        let src = r#"flow t() { return fs.read(p) }"#;
2686        let file = parse_file(src).unwrap();
2687        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2688            let v = eval_expr(value, &env, &ctx).await;
2689            assert!(matches!(v, Value::Str(s) if s == "hello runtime"));
2690        }
2691    }
2692
2693    #[tokio::test]
2694    async fn fanout_emits_branch_start_end_events_with_parent_linkage() {
2695        let src = r#"flow t() { return fanout [1, 2, 3] collect: all }"#;
2696        let file = parse_file(src).unwrap();
2697        let tools = ToolRegistry::new();
2698        let tool_ctx = ToolCtx::new();
2699        let providers = crate::provider::ProviderRegistry::new();
2700        let flows = std::collections::HashMap::new();
2701        let events = crate::event::EventSink::new();
2702        let ctx = EvalCtx {
2703            tools: &tools,
2704            tool_ctx: &tool_ctx,
2705            providers: &providers,
2706            flows: &flows,
2707            contract: None,
2708            events: Some(&events),
2709            turn_id: None,
2710            flow_run_id: Some(crate::event::FlowRunId::now()),
2711            session_runtime: None,
2712            flow_cancel: tokio_util::sync::CancellationToken::new(),
2713            safety: None,
2714            current_node_id: Some("stmt_1".into()),
2715            source_dir: None,
2716        };
2717        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2718            let _ = eval_expr(value, &Env::new(), &ctx).await;
2719        }
2720        let snap = events.snapshot();
2721        let starts: Vec<_> = snap
2722            .iter()
2723            .filter_map(|e| match e {
2724                crate::event::Event::FlowNodeStart {
2725                    node_id,
2726                    parent_node_id,
2727                    ..
2728                } => Some((node_id.clone(), parent_node_id.clone())),
2729                _ => None,
2730            })
2731            .collect();
2732        assert_eq!(starts.len(), 3);
2733        assert_eq!(starts[0].0, "stmt_1.branch[0]");
2734        assert_eq!(starts[1].0, "stmt_1.branch[1]");
2735        assert_eq!(starts[2].0, "stmt_1.branch[2]");
2736        assert!(starts.iter().all(|(_, p)| p.as_deref() == Some("stmt_1")));
2737        let ends = snap
2738            .iter()
2739            .filter(|e| matches!(e, crate::event::Event::FlowNodeEnd { .. }))
2740            .count();
2741        assert_eq!(ends, 3);
2742    }
2743
2744    #[test]
2745    fn resolve_tool_specs_wildcard_unknown_prefix_skips_silently() {
2746        let tools = crate::tool::ToolRegistry::new();
2747        let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2748            "nonexistent.*".into(),
2749        ))]);
2750        let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
2751            &[crate::value::Value::Str("nonexistent.*".into())],
2752            &tools,
2753        )
2754        .unwrap();
2755        assert!(
2756            specs.is_empty(),
2757            "wildcard with no matches should return empty list"
2758        );
2759    }
2760
2761    #[test]
2762    fn resolve_tool_specs_wildcard_matches_prefixed_tools() {
2763        let tools = crate::tool::ToolRegistry::new();
2764        struct FakeMcpTool {
2765            name: String,
2766            desc: String,
2767            schema: serde_json::Value,
2768        }
2769        impl crate::tool::Tool for FakeMcpTool {
2770            fn name(&self) -> &str {
2771                &self.name
2772            }
2773            fn description(&self) -> Option<&str> {
2774                Some(&self.desc)
2775            }
2776            fn input_schema(&self) -> serde_json::Value {
2777                self.schema.clone()
2778            }
2779            fn tier(&self) -> crate::tool::Tier {
2780                crate::tool::Tier::Zero
2781            }
2782            fn approval_level(
2783                &self,
2784                _args: &crate::tool::ToolArgs,
2785                _ctx: &crate::tool::ToolCtx,
2786            ) -> crate::tool::ApprovalLevel {
2787                crate::tool::ApprovalLevel::Auto
2788            }
2789            fn call<'a>(
2790                &'a self,
2791                _args: crate::tool::ToolArgs,
2792                _ctx: &'a crate::tool::ToolCtx,
2793            ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2794                Box::pin(async { Ok(crate::value::Value::Unit) })
2795            }
2796        }
2797        tools.register(std::sync::Arc::new(FakeMcpTool {
2798            name: "mcp.lark.send_mail".into(),
2799            desc: "send mail".into(),
2800            schema: serde_json::json!({"type":"object","properties":{}}),
2801        }));
2802        tools.register(std::sync::Arc::new(FakeMcpTool {
2803            name: "mcp.lark.read_inbox".into(),
2804            desc: "read inbox".into(),
2805            schema: serde_json::json!({"type":"object","properties":{}}),
2806        }));
2807        tools.register(std::sync::Arc::new(FakeMcpTool {
2808            name: "mcp.siyuan.search".into(),
2809            desc: "search notes".into(),
2810            schema: serde_json::json!({"type":"object","properties":{}}),
2811        }));
2812        // Non-MCP tool should NOT be matched.
2813        tools.register(std::sync::Arc::new(FakeMcpTool {
2814            name: "fs.read".into(),
2815            desc: "read file".into(),
2816            schema: serde_json::json!({"type":"object","properties":{}}),
2817        }));
2818
2819        // "mcp.*" matches all 3 mcp.* tools, but not fs.read
2820        let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2821            "mcp.*".into(),
2822        ))]);
2823        let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
2824            &[crate::value::Value::Str("mcp.*".into())],
2825            &tools,
2826        )
2827        .unwrap();
2828        assert_eq!(specs.len(), 3, "mcp.* should match 3 MCP tools");
2829        let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
2830        assert!(names.contains(&"mcp.lark.send_mail".into()));
2831        assert!(names.contains(&"mcp.lark.read_inbox".into()));
2832        assert!(names.contains(&"mcp.siyuan.search".into()));
2833
2834        // "mcp.lark.*" matches only the 2 lark tools
2835        let _expr2 = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2836            "mcp.lark.*".into(),
2837        ))]);
2838        let specs2 = crate::eval::llm_args::resolve_tool_specs_from_values(
2839            &[crate::value::Value::Str("mcp.lark.*".into())],
2840            &tools,
2841        )
2842        .unwrap();
2843        assert_eq!(specs2.len(), 2, "mcp.lark.* should match 2 lark tools");
2844    }
2845
2846    #[test]
2847    fn resolve_tool_specs_mixed_concrete_and_wildcard() {
2848        let tools = crate::tool::ToolRegistry::new();
2849        struct FakeMcpTool {
2850            name: String,
2851            desc: String,
2852            schema: serde_json::Value,
2853        }
2854        impl crate::tool::Tool for FakeMcpTool {
2855            fn name(&self) -> &str {
2856                &self.name
2857            }
2858            fn description(&self) -> Option<&str> {
2859                Some(&self.desc)
2860            }
2861            fn input_schema(&self) -> serde_json::Value {
2862                self.schema.clone()
2863            }
2864            fn tier(&self) -> crate::tool::Tier {
2865                crate::tool::Tier::Zero
2866            }
2867            fn approval_level(
2868                &self,
2869                _args: &crate::tool::ToolArgs,
2870                _ctx: &crate::tool::ToolCtx,
2871            ) -> crate::tool::ApprovalLevel {
2872                crate::tool::ApprovalLevel::Auto
2873            }
2874            fn call<'a>(
2875                &'a self,
2876                _args: crate::tool::ToolArgs,
2877                _ctx: &'a crate::tool::ToolCtx,
2878            ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2879                Box::pin(async { Ok(crate::value::Value::Unit) })
2880            }
2881        }
2882        tools.register(std::sync::Arc::new(FakeMcpTool {
2883            name: "mcp.lark.send_mail".into(),
2884            desc: "send mail".into(),
2885            schema: serde_json::json!({"type":"object","properties":{}}),
2886        }));
2887        tools.register(std::sync::Arc::new(FakeMcpTool {
2888            name: "bash.exec".into(),
2889            desc: "exec".into(),
2890            schema: serde_json::json!({"type":"object","properties":{}}),
2891        }));
2892
2893        // Build tools list from DSL parse so we get valid spans.
2894        let src = r#"flow t() -> string {
2895    reply = llm.call(
2896        tools: ["bash.exec", "mcp.*"],
2897    )
2898    return "ok"
2899}"#;
2900        let file = atman_dsl::parse::parse_file(src).unwrap();
2901        let body = &file.flows[0].body;
2902        let tools_values: Vec<crate::value::Value> = match &body[0] {
2903            atman_dsl::ast::Stmt::Bind { value, .. } => match value {
2904                Expr::Node(atman_dsl::ast::Node::ToolCall { args, .. }) => {
2905                    let tools_expr = args
2906                        .iter()
2907                        .find_map(|a| match a {
2908                            atman_dsl::ast::Arg::Named { name, value } if name.name == "tools" => {
2909                                Some(value.clone())
2910                            }
2911                            _ => None,
2912                        })
2913                        .unwrap();
2914                    if let atman_dsl::ast::Expr::List(items) = tools_expr {
2915                        items
2916                            .iter()
2917                            .map(|i| {
2918                                if let atman_dsl::ast::Expr::Literal(
2919                                    atman_dsl::ast::Literal::Str(s),
2920                                ) = i
2921                                {
2922                                    crate::value::Value::Str(s.clone())
2923                                } else {
2924                                    panic!("expected string literal in tools list");
2925                                }
2926                            })
2927                            .collect()
2928                    } else {
2929                        panic!("expected list");
2930                    }
2931                }
2932                _ => panic!("expected tool call"),
2933            },
2934            _ => panic!("expected bind stmt"),
2935        };
2936        let specs =
2937            crate::eval::llm_args::resolve_tool_specs_from_values(&tools_values, &tools).unwrap();
2938        assert_eq!(specs.len(), 2, "bash.exec + mcp.lark.send_mail = 2");
2939        let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
2940        assert!(names.contains(&"bash.exec".into()));
2941        assert!(names.contains(&"mcp.lark.send_mail".into()));
2942    }
2943}
2944
2945#[cfg(test)]
2946mod sanitize_tests {
2947    use super::*;
2948    use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
2949
2950    #[test]
2951    fn sanitize_fills_missing_tool_results() {
2952        let turn = crate::event::TurnId::now();
2953        let msgs = vec![
2954            Message {
2955                role: MessageRole::Assistant,
2956                parts: vec![MessagePart::ToolUse {
2957                    id: "call_orphan".into(),
2958                    name: "bash.exec".into(),
2959                    input: serde_json::json!({}),
2960                }],
2961                turn_id: turn.clone(),
2962                origin: MessageOrigin::User,
2963            },
2964            Message {
2965                role: MessageRole::User,
2966                parts: vec![MessagePart::Text {
2967                    text: "user interrupt".into(),
2968                }],
2969                turn_id: turn.clone(),
2970                origin: MessageOrigin::User,
2971            },
2972        ];
2973        let out = sanitize_tool_pairs(msgs);
2974        let has_filler = out.iter().any(|m| {
2975            m.parts.iter().any(|p| {
2976                matches!(p, MessagePart::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "call_orphan")
2977            })
2978        });
2979        assert!(
2980            has_filler,
2981            "should append error tool_result for orphan tool_use"
2982        );
2983    }
2984
2985    #[test]
2986    fn sanitize_noop_when_pairs_complete() {
2987        let turn = crate::event::TurnId::now();
2988        let msgs = vec![
2989            Message {
2990                role: MessageRole::Assistant,
2991                parts: vec![MessagePart::ToolUse {
2992                    id: "call_ok".into(),
2993                    name: "bash.exec".into(),
2994                    input: serde_json::json!({}),
2995                }],
2996                turn_id: turn.clone(),
2997                origin: MessageOrigin::User,
2998            },
2999            Message {
3000                role: MessageRole::Tool,
3001                parts: vec![MessagePart::ToolResult {
3002                    tool_use_id: "call_ok".into(),
3003                    content: "done".into(),
3004                    is_error: false,
3005                }],
3006                turn_id: turn.clone(),
3007                origin: MessageOrigin::User,
3008            },
3009        ];
3010        let out = sanitize_tool_pairs(msgs);
3011        assert_eq!(
3012            out.len(),
3013            2,
3014            "no filler should be added when pairs complete"
3015        );
3016    }
3017
3018    // --- stall timeout tests ---
3019    use crate::providers::mock::MockProvider;
3020
3021    fn stall_req(stall_secs: u64) -> crate::provider::LlmRequest {
3022        crate::provider::LlmRequest {
3023            model: "mock".into(),
3024            messages: vec![crate::provider::user_text_message("test")],
3025            system: None,
3026            input: crate::value::Value::Unit,
3027            schema: None,
3028            cache_prompt: false,
3029            tools: Vec::new(),
3030            thinking_enabled: false,
3031            stall_timeout_secs: stall_secs,
3032        }
3033    }
3034
3035    #[tokio::test]
3036    async fn stall_timeout_fires_when_no_chunks_arrive() {
3037        // chunk_delay = 3s, stall_timeout = 1s → stall fires before 2nd chunk
3038        let provider = MockProvider::new("mock")
3039            .with_model("mock", Value::Str("hello world test".into()))
3040            .with_chunk_delay(std::time::Duration::from_secs(3));
3041
3042        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3043        let result = call_and_maybe_stream(
3044            &provider,
3045            stall_req(1),
3046            StreamCallCtx {
3047                stream_tx: Some(stream_tx),
3048                ..Default::default()
3049            },
3050            None,
3051        )
3052        .await;
3053        match result {
3054            Err(RuntimeError::ToolFailed(msg)) => {
3055                assert!(
3056                    msg.contains("llm stall timeout after 1s"),
3057                    "expected stall message, got: {msg}"
3058                );
3059            }
3060            other => panic!("expected ToolFailed stall timeout, got: {other:?}"),
3061        }
3062    }
3063
3064    #[tokio::test]
3065    async fn stall_timeout_does_not_fire_when_chunks_keep_coming() {
3066        // chunk_delay = 100ms, stall_timeout = 2s → all chunks within 300ms, no stall
3067        let provider = MockProvider::new("mock")
3068            .with_model("mock", Value::Str("hello world test".into()))
3069            .with_chunk_delay(std::time::Duration::from_millis(100));
3070
3071        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3072        let result = call_and_maybe_stream(
3073            &provider,
3074            stall_req(2),
3075            StreamCallCtx {
3076                stream_tx: Some(stream_tx),
3077                ..Default::default()
3078            },
3079            None,
3080        )
3081        .await;
3082        match result {
3083            Ok(am) => {
3084                assert!(am.text_concat().contains("hello"));
3085            }
3086            other => panic!("expected Ok, got: {other:?}"),
3087        }
3088    }
3089
3090    #[tokio::test]
3091    async fn stall_timeout_zero_disables_detection() {
3092        // chunk_delay = 3s, stall_timeout = 0 → disabled, all chunks arrive
3093        let provider = MockProvider::new("mock")
3094            .with_model("mock", Value::Str("hello world test".into()))
3095            .with_chunk_delay(std::time::Duration::from_secs(3));
3096
3097        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3098        let result = call_and_maybe_stream(
3099            &provider,
3100            stall_req(0),
3101            StreamCallCtx {
3102                stream_tx: Some(stream_tx),
3103                ..Default::default()
3104            },
3105            None,
3106        )
3107        .await;
3108        match result {
3109            Ok(am) => {
3110                assert!(am.text_concat().contains("hello"));
3111            }
3112            other => panic!("expected Ok (stall disabled), got: {other:?}"),
3113        }
3114    }
3115
3116    #[tokio::test]
3117    async fn stall_timeout_resets_on_each_chunk() {
3118        // 3 chunks at 800ms each. stall=1s. First chunk at t=0, second at t=800ms (<1s),
3119        // third at t=1.6s (>1s from start, but only 800ms from last chunk). Should pass.
3120        let provider = MockProvider::new("mock")
3121            .with_model("mock", Value::Str("hello world test".into()))
3122            .with_chunk_delay(std::time::Duration::from_millis(800));
3123
3124        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3125        let result = call_and_maybe_stream(
3126            &provider,
3127            stall_req(1),
3128            StreamCallCtx {
3129                stream_tx: Some(stream_tx),
3130                ..Default::default()
3131            },
3132            None,
3133        )
3134        .await;
3135        match result {
3136            Ok(am) => {
3137                assert!(am.text_concat().contains("hello"));
3138            }
3139            other => panic!("expected Ok (timer reset each chunk), got: {other:?}"),
3140        }
3141    }
3142
3143    #[tokio::test]
3144    async fn stall_timeout_fires_between_first_and_second_chunk() {
3145        // first chunk at t≈0, then 2s gap, stall=1s fires at t=1s
3146        let provider = MockProvider::new("mock")
3147            .with_model("mock", Value::Str("hello world test".into()))
3148            .with_chunk_delay(std::time::Duration::from_secs(2));
3149
3150        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3151        let result = call_and_maybe_stream(
3152            &provider,
3153            stall_req(1),
3154            StreamCallCtx {
3155                stream_tx: Some(stream_tx),
3156                ..Default::default()
3157            },
3158            None,
3159        )
3160        .await;
3161        assert!(
3162            matches!(&result, Err(RuntimeError::ToolFailed(msg)) if msg.contains("stall timeout")),
3163            "expected stall timeout, got: {result:?}"
3164        );
3165    }
3166}