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