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(
1418                    &resolver, id, "form_ask", payload, timeout,
1419                )
1420                .await;
1421                let answer: crate::form::FormAnswer = match result {
1422                    Ok(v) => {
1423                        serde_json::from_value(v).unwrap_or(crate::form::FormAnswer::Cancelled)
1424                    }
1425                    Err(_) => crate::form::FormAnswer::Cancelled,
1426                };
1427                return Value::Bool(matches!(
1428                    answer,
1429                    crate::form::FormAnswer::Confirmed { value: true }
1430                ));
1431            }
1432            let Some(session) = ctx.session_runtime.as_ref() else {
1433                return Value::Bool(true);
1434            };
1435            let forms = session.forms();
1436            if forms.subscriber_count() == 0 {
1437                return Value::Bool(true);
1438            }
1439            let Some(run_id) = ctx.flow_run_id.clone() else {
1440                return Value::Bool(true);
1441            };
1442            let pending = crate::form::PendingForm {
1443                form_id: uuid::Uuid::now_v7().to_string(),
1444                run_id,
1445                tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
1446                form: crate::form::CompositeForm {
1447                    questions: vec![crate::form::FormQuestion {
1448                        id: "question".into(),
1449                        kind: confirm_kind.clone(),
1450                    }],
1451                },
1452                kind: confirm_kind,
1453                emitted_at: chrono::Utc::now(),
1454            };
1455            let rx = forms.request(pending);
1456            let submission = rx.await.unwrap_or(crate::form::FormSubmission::Rejected);
1457            Value::Bool(matches!(
1458                submission,
1459                crate::form::FormSubmission::Submitted { answers }
1460                    if matches!(answers.first(), Some(crate::form::FormAnswer::Confirmed { value: true }))
1461            ))
1462        }
1463        Node::FixUntilTestPasses { kwargs } => eval_fix_until_test_passes(kwargs, env, ctx).await,
1464        Node::Message { role, args } => eval_message_node(*role, args, env, ctx).await,
1465        Node::Subflow { name, args } => {
1466            let Some(target) = ctx.flows.get(&name.name) else {
1467                return Value::Err(RuntimeError::UndefinedTool(format!(
1468                    "subflow({})",
1469                    name.name
1470                )));
1471            };
1472            let mut bindings = Vec::with_capacity(args.len());
1473            for (i, arg) in args.iter().enumerate() {
1474                let (param_name, value) = match arg {
1475                    Arg::Positional(e) => {
1476                        let Some(p) = target.params.get(i) else {
1477                            return Value::Err(RuntimeError::MissingArg(format!(
1478                                "subflow({}): too many positional args",
1479                                name.name
1480                            )));
1481                        };
1482                        let v = eval_expr(e, env, ctx).await;
1483                        (p.name.name.clone(), v)
1484                    }
1485                    Arg::Named { name: n, value } => {
1486                        let v = eval_expr(value, env, ctx).await;
1487                        (n.name.clone(), v)
1488                    }
1489                };
1490                if value.is_err() {
1491                    return value;
1492                }
1493                bindings.push((param_name, value));
1494            }
1495            let mut sub_env = Env::new();
1496            for (n, v) in bindings {
1497                sub_env.bind(n, v);
1498            }
1499            let sub_run_id = crate::event::FlowRunId::now();
1500            let flow_registry = match ctx.tool_ctx.flow_registry.clone() {
1501                Some(registry) => registry,
1502                None => {
1503                    return Value::Err(RuntimeError::ToolFailed(
1504                        "subflow: trusted flow registry is unavailable".into(),
1505                    ));
1506                }
1507            };
1508            let Some(parent_run_id) = ctx
1509                .tool_ctx
1510                .flow_identity
1511                .as_ref()
1512                .map(|identity| identity.run_id.clone())
1513            else {
1514                return Value::Err(RuntimeError::ToolFailed(
1515                    "subflow: trusted parent flow identity is unavailable".into(),
1516                ));
1517            };
1518            let child_identity = match flow_registry.register_child(
1519                &parent_run_id,
1520                sub_run_id.clone(),
1521                crate::flow_authority::InvocationKind::InlineSubflow,
1522                crate::flow_authority::contract_allows_shell(target.contract.as_ref()),
1523                crate::flow_authority::ChildWorkspaceAuthority::Inherit,
1524            ) {
1525                Ok(identity) => identity,
1526                Err(error) => return Value::Err(error),
1527            };
1528            let lifecycle_guard = flow_registry.lifecycle_guard(&sub_run_id);
1529            let _block_guard = match flow_registry.block_on_descendant(&parent_run_id, &sub_run_id)
1530            {
1531                Ok(guard) => guard,
1532                Err(error) => return Value::Err(error),
1533            };
1534            if let Some(sink) = ctx.events {
1535                sink.emit(crate::event::Event::FlowStart {
1536                    run_id: sub_run_id.clone(),
1537                    flow_name: name.name.clone(),
1538                    parent_run_id: Some(parent_run_id.clone()),
1539                    parent_node_id: ctx.current_node_id.clone(),
1540                    spawned: false,
1541                });
1542            }
1543            if let Some(session) = ctx.session_runtime.as_ref() {
1544                let _ = session
1545                    .stream_tx()
1546                    .send(crate::stream::StreamFrame::FlowStart {
1547                        run_id: sub_run_id.0.to_string(),
1548                        flow_name: name.name.clone(),
1549                        parent_run_id: Some(parent_run_id.0.to_string()),
1550                        parent_node_id: ctx.current_node_id.clone(),
1551                    });
1552            } else if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1553                let _ = tx.send(crate::stream::StreamFrame::FlowStart {
1554                    run_id: sub_run_id.0.to_string(),
1555                    flow_name: name.name.clone(),
1556                    parent_run_id: Some(parent_run_id.0.to_string()),
1557                    parent_node_id: ctx.current_node_id.clone(),
1558                });
1559            }
1560            let mut sub_tool_ctx = ctx.tool_ctx.clone();
1561            sub_tool_ctx.flow_run_id = Some(sub_run_id.clone());
1562            sub_tool_ctx.flow_identity = Some(child_identity);
1563            let sub_ctx = EvalCtx {
1564                tool_ctx: &sub_tool_ctx,
1565                contract: target.contract.as_ref(),
1566                flow_run_id: Some(sub_run_id.clone()),
1567                current_node_id: None,
1568                ..ctx.clone()
1569            };
1570            let outcome = crate::exec::exec_stmts(&target.body, &mut sub_env, &sub_ctx).await;
1571            drop(lifecycle_guard);
1572            let (result, status, ok) = match outcome {
1573                crate::exec::StmtOutcome::Return(v) => (v, crate::event::FlowStatus::Ok, true),
1574                crate::exec::StmtOutcome::Err(e) => {
1575                    let status = if matches!(&e, crate::error::RuntimeError::Cancelled(_)) {
1576                        crate::event::FlowStatus::Cancelled
1577                    } else {
1578                        crate::event::FlowStatus::Errored {
1579                            message: format!("{e}"),
1580                        }
1581                    };
1582                    (Value::Err(e.clone()), status, false)
1583                }
1584                crate::exec::StmtOutcome::Continue => {
1585                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1586                }
1587                crate::exec::StmtOutcome::LoopBreak => {
1588                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1589                }
1590                crate::exec::StmtOutcome::LoopContinue => {
1591                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1592                }
1593            };
1594            let cancelled = matches!(status, crate::event::FlowStatus::Cancelled);
1595            if let Some(sink) = ctx.events {
1596                sink.emit(crate::event::Event::FlowEnd {
1597                    run_id: sub_run_id.clone(),
1598                    flow_name: name.name.clone(),
1599                    status,
1600                });
1601            }
1602            if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1603                let _ = tx.send(crate::stream::StreamFrame::FlowDone {
1604                    run_id: sub_run_id.0.to_string(),
1605                    flow_name: name.name.clone(),
1606                    ok,
1607                    cancelled,
1608                    suicide: false,
1609                });
1610            }
1611            result
1612        }
1613    }
1614}
1615
1616fn session_fs_access_policy(session: &crate::session::Session) -> crate::fs_access::FsAccessPolicy {
1617    let workspace = session
1618        .meta()
1619        .and_then(|m| m.project_root)
1620        .or_else(|| std::env::current_dir().ok());
1621    let mode = session
1622        .fs_access_mode()
1623        .unwrap_or(crate::fs_access::FsAccessMode::WorkspaceWrite);
1624    crate::fs_access::FsAccessPolicy { mode, workspace }
1625}
1626
1627fn tool_name(path: &[atman_dsl::ast::Ident]) -> String {
1628    let parts: Vec<&str> = path.iter().map(|i| i.name.as_str()).collect();
1629    parts.join(".")
1630}
1631
1632async fn eval_fix_until_test_passes<'a>(
1633    kwargs: &'a atman_dsl::ast::Kwargs,
1634    env: &'a Env,
1635    ctx: &'a EvalCtx<'a>,
1636) -> Value {
1637    let mut edit_flow_expr: Option<&Expr> = None;
1638    let mut test_expr: Option<&Expr> = None;
1639    let mut on_giveup_expr: Option<&Expr> = None;
1640    let mut max_iters: u32 = 5;
1641    let mut target_path: Option<std::path::PathBuf> = None;
1642
1643    for (k, v) in kwargs {
1644        match k.name.as_str() {
1645            "edit_flow" => edit_flow_expr = Some(v),
1646            "test" => test_expr = Some(v),
1647            "on_giveup" => on_giveup_expr = Some(v),
1648            "max_iters" => match eval_expr(v, env, ctx).await {
1649                Value::Int(n) if n > 0 => max_iters = n as u32,
1650                other => {
1651                    return Value::Err(RuntimeError::TypeMismatch {
1652                        expected: "positive int (max_iters)".into(),
1653                        actual: other.kind_name().into(),
1654                    });
1655                }
1656            },
1657            "target" => match eval_expr(v, env, ctx).await {
1658                Value::Path(p) => target_path = Some(p),
1659                Value::Str(s) => target_path = Some(std::path::PathBuf::from(s)),
1660                Value::Unit => {}
1661                other => {
1662                    return Value::Err(RuntimeError::TypeMismatch {
1663                        expected: "path (target)".into(),
1664                        actual: other.kind_name().into(),
1665                    });
1666                }
1667            },
1668            _ => {}
1669        }
1670    }
1671
1672    let Some(edit_flow_expr) = edit_flow_expr else {
1673        return Value::Err(RuntimeError::MissingArg(
1674            "fix_until_test_passes.edit_flow".into(),
1675        ));
1676    };
1677    let Some(test_expr) = test_expr else {
1678        return Value::Err(RuntimeError::MissingArg(
1679            "fix_until_test_passes.test".into(),
1680        ));
1681    };
1682
1683    let pristine: Option<String> = match &target_path {
1684        Some(p) => match tokio::fs::read_to_string(p).await {
1685            Ok(s) => Some(s),
1686            Err(e) => {
1687                return Value::Err(RuntimeError::ToolFailed(format!(
1688                    "fix_until_test_passes: cannot read target {}: {e}",
1689                    p.display()
1690                )));
1691            }
1692        },
1693        None => None,
1694    };
1695
1696    let mut prev_fail = String::new();
1697    let mut last_test_result: Option<Value> = None;
1698
1699    for iter in 0..max_iters {
1700        let mut loop_env = env.clone();
1701        loop_env.bind("iter", Value::Int(iter as i64));
1702        loop_env.bind("prev_fail", Value::Str(prev_fail.clone()));
1703
1704        let edit_v = eval_expr(edit_flow_expr, &loop_env, ctx).await;
1705        if edit_v.is_err() {
1706            return edit_v;
1707        }
1708        loop_env.bind("last_edit", edit_v);
1709
1710        let test_v = eval_expr(test_expr, &loop_env, ctx).await;
1711        if test_v.is_err() {
1712            return test_v;
1713        }
1714        let exit = test_v
1715            .field("exit_code")
1716            .or_else(|| test_v.field("exit"))
1717            .and_then(|v| match v {
1718                Value::Int(n) => Some(*n),
1719                _ => None,
1720            });
1721        last_test_result = Some(test_v.clone());
1722        if let Some(0) = exit {
1723            return Value::Struct(vec![
1724                ("status".into(), Value::Str("passed".into())),
1725                ("iters".into(), Value::Int((iter + 1) as i64)),
1726                ("test".into(), test_v),
1727            ]);
1728        }
1729        let stderr_tail = test_v
1730            .field("stderr_tail")
1731            .or_else(|| test_v.field("output"))
1732            .and_then(|v| match v {
1733                Value::Str(s) => Some(s.clone()),
1734                _ => None,
1735            })
1736            .unwrap_or_default();
1737        let stdout_tail = test_v
1738            .field("stdout_tail")
1739            .and_then(|v| match v {
1740                Value::Str(s) => Some(s.clone()),
1741                _ => None,
1742            })
1743            .unwrap_or_default();
1744        prev_fail = format!(
1745            "iter {iter} exit={:?}\n--- stderr ---\n{stderr_tail}\n--- stdout ---\n{stdout_tail}",
1746            exit
1747        );
1748
1749        if let (Some(target), Some(pristine)) = (&target_path, &pristine)
1750            && let Err(e) = tokio::fs::write(target, pristine.as_bytes()).await
1751        {
1752            return Value::Err(RuntimeError::ToolFailed(format!(
1753                "fix_until_test_passes: revert failed on {}: {e}",
1754                target.display()
1755            )));
1756        }
1757    }
1758
1759    if let Some(giveup) = on_giveup_expr {
1760        let mut giveup_env = env.clone();
1761        giveup_env.bind("iters", Value::Int(max_iters as i64));
1762        giveup_env.bind("prev_fail", Value::Str(prev_fail));
1763        return eval_expr(giveup, &giveup_env, ctx).await;
1764    }
1765
1766    Value::Struct(vec![
1767        ("status".into(), Value::Str("gave_up".into())),
1768        ("iters".into(), Value::Int(max_iters as i64)),
1769        ("last_test".into(), last_test_result.unwrap_or(Value::Unit)),
1770    ])
1771}
1772
1773async fn eval_message_node<'a>(
1774    ast_role: atman_dsl::ast::MessageRole,
1775    args: &'a [Arg],
1776    env: &'a Env,
1777    ctx: &'a EvalCtx<'a>,
1778) -> Value {
1779    use crate::message::{
1780        ImageData, ImageSource, Message, MessageOrigin, MessagePart, MessageRole,
1781    };
1782
1783    let role = match ast_role {
1784        atman_dsl::ast::MessageRole::User => MessageRole::User,
1785        atman_dsl::ast::MessageRole::Assistant => MessageRole::Assistant,
1786        atman_dsl::ast::MessageRole::System => MessageRole::System,
1787        atman_dsl::ast::MessageRole::Tool => MessageRole::Tool,
1788    };
1789    let turn_id = ctx
1790        .turn_id
1791        .clone()
1792        .unwrap_or_else(crate::event::TurnId::now);
1793
1794    let mut positional = Vec::new();
1795    let mut named: Vec<(String, Value)> = Vec::new();
1796    let mut attachment_paths_raw: Option<Vec<std::path::PathBuf>> = None;
1797    for arg in args {
1798        match arg {
1799            Arg::Positional(e) => {
1800                let v = eval_expr(e, env, ctx).await;
1801                if v.is_err() {
1802                    return v;
1803                }
1804                positional.push(v);
1805            }
1806            Arg::Named { name, value } => {
1807                if name.name == "attachments" {
1808                    if let Expr::List(items) = value {
1809                        let mut collected = Vec::with_capacity(items.len());
1810                        let mut all_fileref = true;
1811                        for it in items {
1812                            if let Expr::FileRef(f) = it {
1813                                collected.push(std::path::PathBuf::from(&f.path));
1814                            } else {
1815                                all_fileref = false;
1816                                break;
1817                            }
1818                        }
1819                        if all_fileref {
1820                            attachment_paths_raw = Some(collected);
1821                            continue;
1822                        }
1823                    }
1824                }
1825                let v = eval_expr(value, env, ctx).await;
1826                if v.is_err() {
1827                    return v;
1828                }
1829                named.push((name.name.clone(), v));
1830            }
1831        }
1832    }
1833    let take_named = |k: &str, named: &mut Vec<(String, Value)>| -> Option<Value> {
1834        let pos = named.iter().position(|(n, _)| n == k)?;
1835        Some(named.remove(pos).1)
1836    };
1837
1838    if role == MessageRole::Tool {
1839        let tool_use_id = match positional.first().or(take_named("id", &mut named).as_ref()) {
1840            Some(Value::Str(s)) => s.clone(),
1841            Some(other) => {
1842                return Value::Err(RuntimeError::TypeMismatch {
1843                    expected: "string (tool_use_id)".into(),
1844                    actual: other.kind_name().into(),
1845                });
1846            }
1847            None => {
1848                return Value::Err(RuntimeError::MissingArg("tool_result: id".into()));
1849            }
1850        };
1851        let content = match positional
1852            .get(1)
1853            .or(take_named("content", &mut named).as_ref())
1854        {
1855            Some(Value::Str(s)) => s.clone(),
1856            Some(other) => {
1857                return Value::Err(RuntimeError::TypeMismatch {
1858                    expected: "string (content)".into(),
1859                    actual: other.kind_name().into(),
1860                });
1861            }
1862            None => {
1863                return Value::Err(RuntimeError::MissingArg("tool_result: content".into()));
1864            }
1865        };
1866        let is_error = match take_named("is_error", &mut named) {
1867            Some(Value::Bool(b)) => b,
1868            Some(other) => {
1869                return Value::Err(RuntimeError::TypeMismatch {
1870                    expected: "bool (is_error)".into(),
1871                    actual: other.kind_name().into(),
1872                });
1873            }
1874            None => false,
1875        };
1876        return Value::Message(Message {
1877            role,
1878            parts: vec![MessagePart::ToolResult {
1879                tool_use_id,
1880                content,
1881                is_error,
1882            }],
1883            turn_id,
1884            origin: MessageOrigin::User,
1885        });
1886    }
1887
1888    let text = match positional.first() {
1889        Some(Value::Str(s)) => Some(s.clone()),
1890        Some(other) => {
1891            return Value::Err(RuntimeError::TypeMismatch {
1892                expected: "string (message text)".into(),
1893                actual: other.kind_name().into(),
1894            });
1895        }
1896        None => None,
1897    };
1898    let attachment_paths: Vec<std::path::PathBuf> = if let Some(raw) = attachment_paths_raw {
1899        raw
1900    } else {
1901        match take_named("attachments", &mut named) {
1902            Some(Value::List(items)) => {
1903                let mut ps = Vec::with_capacity(items.len());
1904                for it in items {
1905                    match it {
1906                        Value::Path(p) => ps.push(p),
1907                        Value::Str(s) => ps.push(std::path::PathBuf::from(s)),
1908                        other => {
1909                            return Value::Err(RuntimeError::TypeMismatch {
1910                                expected: "path (attachment)".into(),
1911                                actual: other.kind_name().into(),
1912                            });
1913                        }
1914                    }
1915                }
1916                ps
1917            }
1918            Some(other) => {
1919                return Value::Err(RuntimeError::TypeMismatch {
1920                    expected: "list of path".into(),
1921                    actual: other.kind_name().into(),
1922                });
1923            }
1924            None => Vec::new(),
1925        }
1926    };
1927
1928    let mut parts: Vec<MessagePart> =
1929        Vec::with_capacity(attachment_paths.len() + usize::from(text.is_some()));
1930    for path in attachment_paths {
1931        let source = if let Some(session) = ctx.session_runtime.as_ref() {
1932            match session.import_image_path(&path) {
1933                Ok(source) => source,
1934                Err(error) => return Value::Err(error),
1935            }
1936        } else {
1937            let media_type = guess_image_mime(&path).unwrap_or_else(|| "image/png".to_string());
1938            ImageSource {
1939                media_type,
1940                data: ImageData::Path { path },
1941                detail: crate::provider::ImageDetail::Auto,
1942            }
1943        };
1944        parts.push(MessagePart::Image { source });
1945    }
1946    if let Some(t) = text {
1947        parts.push(MessagePart::Text { text: t });
1948    }
1949
1950    Value::Message(Message {
1951        role,
1952        parts,
1953        turn_id,
1954        origin: MessageOrigin::User,
1955    })
1956}
1957
1958pub(super) fn render_injections(injections: &[crate::injection::Injection]) -> String {
1959    use crate::injection::{InjectionLevel, InjectionSource};
1960    let has_user = injections
1961        .iter()
1962        .any(|i| matches!(i.source, InjectionSource::User));
1963    let has_watcher = injections
1964        .iter()
1965        .any(|i| matches!(i.source, InjectionSource::Watcher { .. }));
1966    let mut out = String::new();
1967    if has_user {
1968        out.push_str(
1969            "The user sent the following steering message(s) while you were working. \
1970             Apply them to your next step if still relevant.\n\n",
1971        );
1972    }
1973    if has_watcher {
1974        out.push_str("A background watcher detected the following event(s):\n\n");
1975    }
1976    for inj in injections {
1977        let (tag, source_attr) = match &inj.source {
1978            InjectionSource::User => match inj.level {
1979                InjectionLevel::L2CourseCorrect => ("user_correction", "user".to_string()),
1980                _ => ("user_nudge", "user".to_string()),
1981            },
1982            InjectionSource::Watcher {
1983                watcher_id,
1984                kind,
1985                handle,
1986            } => (
1987                "watcher_event",
1988                format!("{kind} '{handle}' watcher {watcher_id}"),
1989            ),
1990        };
1991        out.push_str(&format!(
1992            "<{tag} id=\"{}\" ts=\"{}\" source=\"{}\">\n{}\n</{tag}>\n",
1993            inj.id.0,
1994            inj.created_at.to_rfc3339(),
1995            source_attr,
1996            inj.text
1997        ));
1998    }
1999    out
2000}
2001
2002fn guess_image_mime(path: &std::path::Path) -> Option<String> {
2003    let ext = path
2004        .extension()
2005        .and_then(|s| s.to_str())?
2006        .to_ascii_lowercase();
2007    Some(
2008        match ext.as_str() {
2009            "png" => "image/png",
2010            "jpg" | "jpeg" => "image/jpeg",
2011            "gif" => "image/gif",
2012            "webp" => "image/webp",
2013            _ => return None,
2014        }
2015        .to_string(),
2016    )
2017}
2018
2019fn contract_allows_shell(contract: Option<&atman_dsl::ast::Contract>) -> bool {
2020    crate::flow_authority::contract_allows_shell(contract)
2021}
2022
2023pub struct TruncationStat {
2024    pub original_chars: usize,
2025    pub result_chars: usize,
2026    pub dropped_chars: usize,
2027    pub budget_tokens: u64,
2028}
2029
2030pub fn truncate_prompt_to_budget(prompt: String, budget_tokens: u64) -> String {
2031    truncate_prompt_to_budget_tracked(prompt, budget_tokens).0
2032}
2033
2034pub fn truncate_prompt_to_budget_tracked(
2035    prompt: String,
2036    budget_tokens: u64,
2037) -> (String, Option<TruncationStat>) {
2038    let budget_chars = budget_tokens.saturating_mul(4) as usize;
2039    if prompt.len() <= budget_chars {
2040        return (prompt, None);
2041    }
2042    let head_chars = budget_chars * 4 / 10;
2043    let tail_chars = budget_chars * 4 / 10;
2044    if head_chars + tail_chars >= prompt.len() {
2045        return (prompt, None);
2046    }
2047    let original_chars = prompt.len();
2048    let head_end = char_boundary(&prompt, head_chars, false);
2049    let tail_start = char_boundary(&prompt, prompt.len().saturating_sub(tail_chars), true);
2050    let head = &prompt[..head_end];
2051    let tail = &prompt[tail_start..];
2052    let dropped = original_chars - head.len() - tail.len();
2053    let result = format!("{head}\n\n[... truncated {dropped} chars ...]\n\n{tail}");
2054    let stat = TruncationStat {
2055        original_chars,
2056        result_chars: result.len(),
2057        dropped_chars: dropped,
2058        budget_tokens,
2059    };
2060    (result, Some(stat))
2061}
2062
2063fn char_boundary(s: &str, target: usize, round_up: bool) -> usize {
2064    let mut idx = target.min(s.len());
2065    while idx > 0 && idx < s.len() && !s.is_char_boundary(idx) {
2066        if round_up {
2067            idx += 1;
2068        } else {
2069            idx -= 1;
2070        }
2071    }
2072    idx
2073}
2074
2075// Bare primitive names inside `schema: { valid: bool, ... }` parse as tool calls; treat as Unit.
2076fn is_type_annotation(path: &[atman_dsl::ast::Ident]) -> bool {
2077    if path.len() != 1 {
2078        return false;
2079    }
2080    matches!(
2081        path[0].name.as_str(),
2082        "bool" | "int" | "float" | "string" | "path" | "bytes" | "duration"
2083    )
2084}
2085
2086fn eval_literal(lit: &Literal) -> Value {
2087    match lit {
2088        Literal::Str(s) => Value::Str(s.clone()),
2089        Literal::Int(n) => Value::Int(*n),
2090        Literal::Float(f) => Value::Float(*f),
2091        Literal::Bool(b) => Value::Bool(*b),
2092    }
2093}
2094
2095fn eval_binop(op: BinOp, l: &Value, r: &Value) -> Value {
2096    match op {
2097        BinOp::Eq => Value::Bool(value_eq(l, r)),
2098        BinOp::Ne => Value::Bool(!value_eq(l, r)),
2099        BinOp::Lt => value_cmp(l, r, |a, b| a < b, |a, b| a < b, |a, b| a < b),
2100        BinOp::Le => value_cmp(l, r, |a, b| a <= b, |a, b| a <= b, |a, b| a <= b),
2101        BinOp::Gt => value_cmp(l, r, |a, b| a > b, |a, b| a > b, |a, b| a > b),
2102        BinOp::Ge => value_cmp(l, r, |a, b| a >= b, |a, b| a >= b, |a, b| a >= b),
2103        BinOp::And => match (l, r) {
2104            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a && *b),
2105            _ => type_mismatch("bool && bool", l, r),
2106        },
2107        BinOp::Or => match (l, r) {
2108            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a || *b),
2109            _ => type_mismatch("bool || bool", l, r),
2110        },
2111        BinOp::Add => match (l, r) {
2112            (Value::Int(a), Value::Int(b)) => Value::Int(a + b),
2113            (Value::Float(a), Value::Float(b)) => Value::Float(a + b),
2114            (Value::Str(a), Value::Str(b)) => Value::Str(format!("{a}{b}")),
2115            (Value::Str(a), Value::Path(b)) => Value::Str(format!("{a}{}", b.display())),
2116            (Value::Path(a), Value::Str(b)) => Value::Str(format!("{}{b}", a.display())),
2117            _ => type_mismatch(
2118                "int+int | float+float | string+string | string+path | path+string",
2119                l,
2120                r,
2121            ),
2122        },
2123        BinOp::Sub => match (l, r) {
2124            (Value::Int(a), Value::Int(b)) => Value::Int(a - b),
2125            (Value::Float(a), Value::Float(b)) => Value::Float(a - b),
2126            _ => type_mismatch("int-int | float-float", l, r),
2127        },
2128        BinOp::Mul => 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::Div => match (l, r) {
2134            (Value::Int(_), Value::Int(0)) => {
2135                Value::Err(RuntimeError::ToolFailed("integer div by zero".into()))
2136            }
2137            (Value::Int(a), Value::Int(b)) => Value::Int(a / b),
2138            (Value::Float(a), Value::Float(b)) => Value::Float(a / b),
2139            _ => type_mismatch("int/int | float/float", l, r),
2140        },
2141        BinOp::Mod => match (l, r) {
2142            (Value::Int(_), Value::Int(0)) => {
2143                Value::Err(RuntimeError::ToolFailed("integer mod by zero".into()))
2144            }
2145            (Value::Int(a), Value::Int(b)) => Value::Int(a % b),
2146            (Value::Float(a), Value::Float(b)) => Value::Float(a % b),
2147            _ => type_mismatch("int%int | float%float", l, r),
2148        },
2149    }
2150}
2151
2152fn eval_unop(op: UnOp, v: &Value) -> Value {
2153    match op {
2154        UnOp::Not => match v {
2155            Value::Bool(b) => Value::Bool(!b),
2156            other => Value::Err(RuntimeError::TypeMismatch {
2157                expected: "bool".into(),
2158                actual: other.kind_name().into(),
2159            }),
2160        },
2161        UnOp::Neg => match v {
2162            Value::Int(n) => Value::Int(-n),
2163            Value::Float(n) => Value::Float(-n),
2164            other => Value::Err(RuntimeError::TypeMismatch {
2165                expected: "int or float".into(),
2166                actual: other.kind_name().into(),
2167            }),
2168        },
2169    }
2170}
2171
2172fn value_eq(l: &Value, r: &Value) -> bool {
2173    match (l, r) {
2174        (Value::Unit, Value::Unit) => true,
2175        (Value::Bool(a), Value::Bool(b)) => a == b,
2176        (Value::Int(a), Value::Int(b)) => a == b,
2177        (Value::Float(a), Value::Float(b)) => a == b,
2178        (Value::Str(a), Value::Str(b)) => a == b,
2179        (Value::Path(a), Value::Path(b)) => a == b,
2180        _ => false,
2181    }
2182}
2183
2184fn value_cmp(
2185    l: &Value,
2186    r: &Value,
2187    int_cmp: fn(i64, i64) -> bool,
2188    float_cmp: fn(f64, f64) -> bool,
2189    str_cmp: fn(&str, &str) -> bool,
2190) -> Value {
2191    match (l, r) {
2192        (Value::Int(a), Value::Int(b)) => Value::Bool(int_cmp(*a, *b)),
2193        (Value::Float(a), Value::Float(b)) => Value::Bool(float_cmp(*a, *b)),
2194        (Value::Str(a), Value::Str(b)) => Value::Bool(str_cmp(a, b)),
2195        _ => type_mismatch("comparable pair", l, r),
2196    }
2197}
2198
2199fn type_mismatch(expected: &str, l: &Value, r: &Value) -> Value {
2200    Value::Err(RuntimeError::TypeMismatch {
2201        expected: expected.into(),
2202        actual: format!("{} vs {}", l.kind_name(), r.kind_name()),
2203    })
2204}
2205
2206#[cfg(test)]
2207mod tests {
2208    use super::*;
2209    use atman_dsl::parse::parse_file;
2210
2211    fn authorized_eval_tool_ctx(workspace: Option<&std::path::Path>) -> ToolCtx {
2212        let trust = crate::trust::TrustConfig {
2213            mode: crate::trust::TrustMode::Eager,
2214            escalation: crate::trust::EscalationPolicy::Allow,
2215            ..crate::trust::TrustConfig::default()
2216        };
2217        let flows = std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::default());
2218        let run_id = crate::event::FlowRunId::now();
2219        let identity = flows
2220            .register_root(
2221                "eval-test".into(),
2222                run_id.clone(),
2223                crate::flow_authority::EffectiveAuthority::root(&trust, true, None),
2224            )
2225            .unwrap();
2226        let broker = crate::permission::PermissionBroker::shared(std::sync::Arc::clone(&flows));
2227        let mut ctx = ToolCtx::new()
2228            .with_flow_registry(flows)
2229            .with_permission_broker(broker)
2230            .with_trust(trust)
2231            .with_approval(std::sync::Arc::new(crate::session::ApprovalRegistry::new()))
2232            .with_anchors(None, Some(run_id), None);
2233        ctx.flow_identity = Some(identity);
2234        if let Some(path) = workspace {
2235            ctx = ctx.with_workspace(crate::git_workspace::WorkspaceBinding {
2236                workspace_id: "eval-test".into(),
2237                path: path.to_path_buf(),
2238                repository_root: path.to_path_buf(),
2239                branch: None,
2240            });
2241        }
2242        ctx
2243    }
2244
2245    #[test]
2246    fn spawned_tool_context_resolves_working_directory_record_body() {
2247        let temp = tempfile::tempdir().unwrap();
2248        let ctx = authorized_eval_tool_ctx(Some(temp.path()))
2249            .with_history_segment(crate::tool::HistorySegment::Spawned);
2250        let rendered = tool_context_working_directory_context(&ctx).unwrap();
2251
2252        assert_eq!(
2253            std::path::PathBuf::from(&rendered).canonicalize().unwrap(),
2254            temp.path().canonicalize().unwrap()
2255        );
2256        assert!(!rendered.contains("{pwd}"));
2257    }
2258
2259    #[test]
2260    fn char_boundary_rounds_around_multibyte_characters() {
2261        let text = "a你😀b";
2262        assert_eq!(char_boundary(text, 2, false), 1);
2263        assert_eq!(char_boundary(text, 2, true), 4);
2264        assert_eq!(char_boundary(text, 6, false), 4);
2265        assert_eq!(char_boundary(text, 6, true), 8);
2266        assert_eq!(char_boundary(text, 8, false), 8);
2267        assert_eq!(char_boundary(text, 99, false), text.len());
2268    }
2269
2270    #[test]
2271    fn parse_context_mode_handles_variants() {
2272        assert!(matches!(
2273            parse_context_mode("session"),
2274            ContextMode::Session
2275        ));
2276        assert!(matches!(parse_context_mode("none"), ContextMode::None));
2277        assert!(matches!(parse_context_mode(""), ContextMode::None));
2278        assert!(matches!(
2279            parse_context_mode(" session "),
2280            ContextMode::Session
2281        ));
2282        match parse_context_mode("session_recent(5)") {
2283            ContextMode::SessionRecent(n) => assert_eq!(n, 5),
2284            other => panic!("expected SessionRecent(5), got {other:?}"),
2285        }
2286        match parse_context_mode("session_recent") {
2287            ContextMode::SessionRecent(n) => assert_eq!(n, 10),
2288            other => panic!("expected SessionRecent(10), got {other:?}"),
2289        }
2290        assert!(matches!(parse_context_mode("garbage"), ContextMode::None));
2291    }
2292
2293    async fn eval_snippet(expr_src: &str) -> Value {
2294        let src = format!("flow t() {{\n    return {expr_src}\n}}\n");
2295        let file = parse_file(&src).expect("parse test snippet");
2296        let tools = ToolRegistry::new();
2297        let tool_ctx = ToolCtx::new();
2298        let providers = crate::provider::ProviderRegistry::new();
2299        let flows = std::collections::HashMap::new();
2300        let ctx = EvalCtx {
2301            tools: &tools,
2302            tool_ctx: &tool_ctx,
2303            providers: &providers,
2304            flows: &flows,
2305            contract: None,
2306            events: None,
2307            turn_id: None,
2308            flow_run_id: None,
2309            session_runtime: None,
2310            flow_cancel: tokio_util::sync::CancellationToken::new(),
2311            safety: None,
2312            current_node_id: None,
2313            source_dir: None,
2314        };
2315        let stmt = &file.flows[0].body[0];
2316        if let atman_dsl::ast::Stmt::Return { value } = stmt {
2317            eval_expr(value, &Env::new(), &ctx).await
2318        } else {
2319            panic!("expected return statement");
2320        }
2321    }
2322
2323    #[tokio::test]
2324    async fn literals_evaluate() {
2325        assert!(matches!(eval_snippet("42").await, Value::Int(42)));
2326        assert!(matches!(eval_snippet("true").await, Value::Bool(true)));
2327        assert!(matches!(
2328            eval_snippet(r#""hello""#).await,
2329            Value::Str(s) if s == "hello"
2330        ));
2331    }
2332
2333    #[tokio::test]
2334    async fn undefined_ident_yields_err_value() {
2335        assert!(matches!(
2336            eval_snippet("missing").await,
2337            Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2338        ));
2339    }
2340
2341    #[tokio::test]
2342    async fn binary_arithmetic_and_comparison() {
2343        assert!(matches!(eval_snippet("1 == 1").await, Value::Bool(true)));
2344        assert!(matches!(eval_snippet("2 < 3").await, Value::Bool(true)));
2345        assert!(matches!(
2346            eval_snippet(r#""a" + "b""#).await,
2347            Value::Str(s) if s == "ab"
2348        ));
2349    }
2350
2351    #[tokio::test]
2352    async fn type_mismatch_bubbles_up() {
2353        assert!(matches!(
2354            eval_snippet(r#"1 + "x""#).await,
2355            Value::Err(RuntimeError::TypeMismatch { .. })
2356        ));
2357    }
2358
2359    #[tokio::test]
2360    async fn err_short_circuits_binary() {
2361        assert!(matches!(
2362            eval_snippet("missing == 1").await,
2363            Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2364        ));
2365    }
2366
2367    #[tokio::test]
2368    async fn list_evaluates_all_items() {
2369        let v = eval_snippet("[1, 2, 3]").await;
2370        if let Value::List(items) = v {
2371            assert_eq!(items.len(), 3);
2372            assert!(matches!(items[2], Value::Int(3)));
2373        } else {
2374            panic!("expected list");
2375        }
2376    }
2377
2378    #[tokio::test]
2379    async fn struct_literal_evaluates_fields_in_order() {
2380        let v = eval_snippet(r#"{ severity: "critical", count: 3 }"#).await;
2381        if let Value::Struct(fields) = v {
2382            assert_eq!(fields[0].0, "severity");
2383            assert_eq!(fields[1].0, "count");
2384        } else {
2385            panic!("expected struct");
2386        }
2387    }
2388
2389    #[tokio::test]
2390    async fn undefined_tool_returns_undefined_tool_err() {
2391        let src = r#"flow t() { return fs.readnope("/tmp") }"#;
2392        let file = parse_file(src).unwrap();
2393        let tools = ToolRegistry::new();
2394        let tool_ctx = ToolCtx::new();
2395        let providers = crate::provider::ProviderRegistry::new();
2396        let flows = std::collections::HashMap::new();
2397        let ctx = EvalCtx {
2398            tools: &tools,
2399            tool_ctx: &tool_ctx,
2400            providers: &providers,
2401            flows: &flows,
2402            contract: None,
2403            events: None,
2404            turn_id: None,
2405            flow_run_id: None,
2406            session_runtime: None,
2407            flow_cancel: tokio_util::sync::CancellationToken::new(),
2408            safety: None,
2409            current_node_id: None,
2410            source_dir: None,
2411        };
2412        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2413            let v = eval_expr(value, &Env::new(), &ctx).await;
2414            assert!(matches!(
2415                v,
2416                Value::Err(RuntimeError::UndefinedTool(name)) if name == "fs.readnope"
2417            ));
2418        }
2419    }
2420
2421    #[tokio::test]
2422    async fn eager_allow_session_backed_root_flow_spawn_executes_without_pending() {
2423        use crate::flow_authority::EffectiveAuthority;
2424        use crate::tools::agent_ctrl::AgentSpawn;
2425        use crate::trust::{EscalationPolicy, TrustConfig, TrustMode};
2426        use std::sync::Arc;
2427        use tempfile::TempDir;
2428
2429        let root = TempDir::new().unwrap();
2430        let trust = TrustConfig {
2431            mode: TrustMode::Eager,
2432            escalation: EscalationPolicy::Allow,
2433            ..TrustConfig::default()
2434        };
2435        let session =
2436            Arc::new(crate::session::Session::open_with_trust(root.path(), trust.clone()).unwrap());
2437        let run_id = crate::event::FlowRunId::now();
2438        let identity = session
2439            .flow_registry
2440            .register_root(
2441                session.id().to_string(),
2442                run_id.clone(),
2443                EffectiveAuthority::root(&trust, true, None),
2444            )
2445            .unwrap();
2446        let flow_path = root.path().join("acceptance.at");
2447        std::fs::write(&flow_path, "flow child() { return \"ok\" }\n").unwrap();
2448        let spawn_token = session.flow_registry.issue_spawn_permit(&identity);
2449        let source = format!(
2450            "flow t() {{ return flow.spawn(flow: \"{}\", spawn_token: \"{}\", async: true) }}",
2451            flow_path.display(),
2452            spawn_token,
2453        );
2454        let file = parse_file(&source).unwrap();
2455        let tools = ToolRegistry::new();
2456        tools.register(Arc::new(AgentSpawn));
2457        let mut tool_ctx = ToolCtx::new();
2458        tool_ctx.flow_identity = Some(identity);
2459        let providers = crate::provider::ProviderRegistry::new();
2460        let flows = std::collections::HashMap::new();
2461        let ctx = EvalCtx {
2462            tools: &tools,
2463            tool_ctx: &tool_ctx,
2464            providers: &providers,
2465            flows: &flows,
2466            contract: None,
2467            events: None,
2468            turn_id: None,
2469            flow_run_id: Some(run_id),
2470            session_runtime: Some(Arc::clone(&session)),
2471            flow_cancel: tokio_util::sync::CancellationToken::new(),
2472            safety: None,
2473            current_node_id: None,
2474            source_dir: None,
2475        };
2476        let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] else {
2477            panic!("expected return statement");
2478        };
2479
2480        let result = eval_expr(value, &Env::new(), &ctx).await;
2481
2482        assert!(
2483            matches!(&result, Value::Struct(fields) if fields.iter().any(|(key, value)| key == "status" && matches!(value, Value::Str(status) if status == "running"))),
2484            "flow.spawn did not execute: {result:?}"
2485        );
2486        let requests = session.permission_broker.list();
2487        assert_eq!(requests.len(), 1);
2488        assert_eq!(requests[0].intent.tool_name, "flow.spawn");
2489        assert!(matches!(
2490            requests[0].state,
2491            crate::permission::PermissionRequestState::Approved { .. }
2492        ));
2493        assert!(session.approval().list_pending().is_empty());
2494    }
2495
2496    #[tokio::test]
2497    async fn fanout_all_gathers_results_in_order() {
2498        use crate::tools::fs::FsRead;
2499        use std::sync::Arc;
2500        use tempfile::TempDir;
2501
2502        let dir = TempDir::new().unwrap();
2503        let pa = dir.path().join("a.txt");
2504        let pb = dir.path().join("b.txt");
2505        tokio::fs::write(&pa, b"AAA").await.unwrap();
2506        tokio::fs::write(&pb, b"BBB").await.unwrap();
2507
2508        let tools = ToolRegistry::new();
2509        tools.register(Arc::new(FsRead));
2510        let tool_ctx = authorized_eval_tool_ctx(Some(dir.path()));
2511        let providers = crate::provider::ProviderRegistry::new();
2512        let flows = std::collections::HashMap::new();
2513        let ctx = EvalCtx {
2514            tools: &tools,
2515            tool_ctx: &tool_ctx,
2516            providers: &providers,
2517            flows: &flows,
2518            contract: None,
2519            events: None,
2520            turn_id: None,
2521            flow_run_id: tool_ctx
2522                .flow_identity
2523                .as_ref()
2524                .map(|identity| identity.run_id.clone()),
2525            session_runtime: None,
2526            flow_cancel: tokio_util::sync::CancellationToken::new(),
2527            safety: None,
2528            current_node_id: None,
2529            source_dir: None,
2530        };
2531
2532        let mut env = Env::new();
2533        env.bind("a", Value::Path(pa));
2534        env.bind("b", Value::Path(pb));
2535
2536        let src = r#"flow t() { return fanout [ fs.read(a), fs.read(b) ] collect: all }"#;
2537        let file = parse_file(src).unwrap();
2538        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2539            let v = eval_expr(value, &env, &ctx).await;
2540            if let Value::List(items) = v {
2541                assert_eq!(items.len(), 2);
2542                assert!(matches!(&items[0], Value::Str(s) if s == "AAA"));
2543                assert!(matches!(&items[1], Value::Str(s) if s == "BBB"));
2544            } else {
2545                panic!("expected list, got {v:?}");
2546            }
2547        }
2548    }
2549
2550    #[tokio::test]
2551    async fn fanout_all_short_circuits_on_err() {
2552        let src = r#"flow t() { return fanout [ 1, missing, 3 ] collect: all }"#;
2553        let file = parse_file(src).unwrap();
2554        let tools = ToolRegistry::new();
2555        let tool_ctx = ToolCtx::new();
2556        let providers = crate::provider::ProviderRegistry::new();
2557        let flows = std::collections::HashMap::new();
2558        let ctx = EvalCtx {
2559            tools: &tools,
2560            tool_ctx: &tool_ctx,
2561            providers: &providers,
2562            flows: &flows,
2563            contract: None,
2564            events: None,
2565            turn_id: None,
2566            flow_run_id: None,
2567            session_runtime: None,
2568            flow_cancel: tokio_util::sync::CancellationToken::new(),
2569            safety: None,
2570            current_node_id: None,
2571            source_dir: None,
2572        };
2573        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2574            let v = eval_expr(value, &Env::new(), &ctx).await;
2575            assert!(matches!(
2576                v,
2577                Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2578            ));
2579        }
2580    }
2581
2582    #[tokio::test]
2583    async fn llm_node_dispatches_to_mock_provider() {
2584        use crate::providers::mock::MockProvider;
2585        use std::sync::Arc;
2586
2587        {
2588            let _lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2589            crate::model_registry::set_model_config(crate::model_registry::ModelConfig {
2590                models: std::collections::HashMap::from([(
2591                    "mock".into(),
2592                    crate::model_registry::ModelEntry {
2593                        model: "mock".into(),
2594                        context_budget: Some(8_192),
2595                        ..Default::default()
2596                    },
2597                )]),
2598                ..Default::default()
2599            });
2600        }
2601
2602        let providers = crate::provider::ProviderRegistry::new();
2603        providers.register(Arc::new(MockProvider::new("mock").with_model(
2604            "mock",
2605            Value::Struct(vec![("severity".into(), Value::Str("info".into()))]),
2606        )));
2607        let tools = ToolRegistry::new();
2608        crate::tools::register_tier_zero(&tools);
2609        let tool_ctx = authorized_eval_tool_ctx(None)
2610            .with_providers(std::sync::Arc::new(providers.clone()))
2611            .with_registry(std::sync::Arc::new(tools.clone()));
2612        let flows = std::collections::HashMap::new();
2613        let ctx = EvalCtx {
2614            tools: &tools,
2615            tool_ctx: &tool_ctx,
2616            providers: &providers,
2617            flows: &flows,
2618            contract: None,
2619            events: None,
2620            turn_id: None,
2621            flow_run_id: tool_ctx
2622                .flow_identity
2623                .as_ref()
2624                .map(|identity| identity.run_id.clone()),
2625            session_runtime: None,
2626            flow_cancel: tokio_util::sync::CancellationToken::new(),
2627            safety: None,
2628            current_node_id: None,
2629            source_dir: None,
2630        };
2631
2632        let src = r#"flow t() {
2633    return llm.call(
2634        model: "mock",
2635        prompt: "review please",
2636        input: 1,
2637    )
2638}
2639"#;
2640        let file = parse_file(src).unwrap();
2641        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2642            let v = eval_expr(value, &Env::new(), &ctx).await;
2643            if let Value::Struct(fields) = v {
2644                assert_eq!(fields[0].0, "severity");
2645                assert!(matches!(&fields[0].1, Value::Str(s) if s == "info"));
2646            } else {
2647                panic!("expected struct, got {v:?}");
2648            }
2649        }
2650    }
2651
2652    #[tokio::test]
2653    async fn llm_missing_model_reports_missing_arg() {
2654        let providers = crate::provider::ProviderRegistry::new();
2655        let tools = ToolRegistry::new();
2656        let tool_ctx = ToolCtx::new();
2657        let flows = std::collections::HashMap::new();
2658        let ctx = EvalCtx {
2659            tools: &tools,
2660            tool_ctx: &tool_ctx,
2661            providers: &providers,
2662            flows: &flows,
2663            contract: None,
2664            events: None,
2665            turn_id: None,
2666            flow_run_id: None,
2667            session_runtime: None,
2668            flow_cancel: tokio_util::sync::CancellationToken::new(),
2669            safety: None,
2670            current_node_id: None,
2671            source_dir: None,
2672        };
2673        let src = r#"flow t() { return llm.call(prompt: "hi") }"#;
2674        let file = parse_file(src).unwrap();
2675        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2676            let v = eval_expr(value, &Env::new(), &ctx).await;
2677            assert!(v.is_err(), "expected error, got {v:?}");
2678        }
2679    }
2680
2681    #[tokio::test]
2682    async fn user_confirm_stub_returns_true() {
2683        let providers = crate::provider::ProviderRegistry::new();
2684        let tools = ToolRegistry::new();
2685        let tool_ctx = ToolCtx::new();
2686        let flows = std::collections::HashMap::new();
2687        let ctx = EvalCtx {
2688            tools: &tools,
2689            tool_ctx: &tool_ctx,
2690            providers: &providers,
2691            flows: &flows,
2692            contract: None,
2693            events: None,
2694            turn_id: None,
2695            flow_run_id: None,
2696            session_runtime: None,
2697            flow_cancel: tokio_util::sync::CancellationToken::new(),
2698            safety: None,
2699            current_node_id: None,
2700            source_dir: None,
2701        };
2702        let src = r#"flow t() { return user_confirm("proceed?") }"#;
2703        let file = parse_file(src).unwrap();
2704        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2705            assert!(matches!(
2706                eval_expr(value, &Env::new(), &ctx).await,
2707                Value::Bool(true)
2708            ));
2709        }
2710    }
2711
2712    #[tokio::test]
2713    async fn subflow_calls_target_flow_with_positional_args() {
2714        let src = r#"flow child(n: Int) -> Int {
2715    return n + 100
2716}
2717
2718flow parent(x: Int) -> Int {
2719    y = subflow(child, x)
2720    return y + 1
2721}
2722"#;
2723        let file = parse_file(src).unwrap();
2724        let flows_map: std::collections::HashMap<_, _> = file
2725            .flows
2726            .iter()
2727            .map(|f| (f.name.name.clone(), f.clone()))
2728            .collect();
2729        let parent = &file.flows[1];
2730        let tools = ToolRegistry::new();
2731        let flow_registry = std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::new());
2732        let root_run_id = crate::event::FlowRunId::now();
2733        let root_identity = flow_registry
2734            .register_root(
2735                "test-session".into(),
2736                root_run_id.clone(),
2737                crate::flow_authority::EffectiveAuthority::root(
2738                    &crate::trust::TrustConfig::default(),
2739                    false,
2740                    None,
2741                ),
2742            )
2743            .unwrap();
2744        let mut tool_ctx = ToolCtx::new();
2745        tool_ctx.flow_run_id = Some(root_run_id);
2746        tool_ctx.flow_registry = Some(flow_registry);
2747        tool_ctx.flow_identity = Some(root_identity);
2748        let providers = crate::provider::ProviderRegistry::new();
2749        let out = crate::exec::exec_flow_with_siblings(
2750            parent,
2751            vec![("x".into(), Value::Int(5))],
2752            &tools,
2753            &tool_ctx,
2754            &providers,
2755            &flows_map,
2756            None,
2757            None,
2758            None,
2759            None,
2760            tokio_util::sync::CancellationToken::new(),
2761            None,
2762            None,
2763        )
2764        .await
2765        .unwrap();
2766        assert!(matches!(out, Value::Int(106)));
2767    }
2768
2769    #[tokio::test]
2770    async fn subflow_rejects_spoofed_run_id_without_trusted_identity() {
2771        let src = r#"flow child() -> Int {
2772    return 1
2773}
2774
2775flow parent() -> Int {
2776    return subflow(child)
2777}
2778"#;
2779        let file = parse_file(src).unwrap();
2780        let flows: std::collections::HashMap<_, _> = file
2781            .flows
2782            .iter()
2783            .map(|flow| (flow.name.name.clone(), flow.clone()))
2784            .collect();
2785        let tools = ToolRegistry::new();
2786        let mut tool_ctx = ToolCtx::new();
2787        tool_ctx.flow_run_id = Some(crate::event::FlowRunId::now());
2788        tool_ctx.flow_registry = Some(std::sync::Arc::new(
2789            crate::tools::agent_ctrl::FlowRegistry::new(),
2790        ));
2791        let providers = crate::provider::ProviderRegistry::new();
2792
2793        let error = crate::exec::exec_flow_with_siblings(
2794            &file.flows[1],
2795            vec![],
2796            &tools,
2797            &tool_ctx,
2798            &providers,
2799            &flows,
2800            None,
2801            None,
2802            tool_ctx.flow_run_id.clone(),
2803            None,
2804            tokio_util::sync::CancellationToken::new(),
2805            None,
2806            None,
2807        )
2808        .await
2809        .unwrap_err();
2810
2811        assert!(matches!(
2812            error,
2813            RuntimeError::ToolFailed(message)
2814                if message == "subflow: trusted parent flow identity is unavailable"
2815        ));
2816    }
2817
2818    #[tokio::test]
2819    async fn subflow_missing_target_reports_undefined_tool() {
2820        let src = r#"flow parent() -> Int {
2821    return subflow(nope, 1)
2822}
2823"#;
2824        let file = parse_file(src).unwrap();
2825        let flows: std::collections::HashMap<_, _> = file
2826            .flows
2827            .iter()
2828            .map(|f| (f.name.name.clone(), f.clone()))
2829            .collect();
2830        let tools = ToolRegistry::new();
2831        let tool_ctx = ToolCtx::new();
2832        let providers = crate::provider::ProviderRegistry::new();
2833        let err = crate::exec::exec_flow_with_siblings(
2834            &file.flows[0],
2835            vec![],
2836            &tools,
2837            &tool_ctx,
2838            &providers,
2839            &flows,
2840            None,
2841            None,
2842            None,
2843            None,
2844            tokio_util::sync::CancellationToken::new(),
2845            None,
2846            None,
2847        )
2848        .await
2849        .unwrap_err();
2850        assert!(matches!(err, RuntimeError::UndefinedTool(name) if name.contains("nope")));
2851    }
2852
2853    #[tokio::test]
2854    async fn tool_call_dispatches_via_registry() {
2855        use crate::tools::fs::FsRead;
2856        use std::sync::Arc;
2857        use tempfile::TempDir;
2858
2859        let dir = TempDir::new().unwrap();
2860        let path = dir.path().join("hi.txt");
2861        tokio::fs::write(&path, b"hello runtime").await.unwrap();
2862
2863        let tools = ToolRegistry::new();
2864        tools.register(Arc::new(FsRead));
2865        let tool_ctx = authorized_eval_tool_ctx(Some(dir.path()));
2866        let providers = crate::provider::ProviderRegistry::new();
2867        let flows = std::collections::HashMap::new();
2868        let ctx = EvalCtx {
2869            tools: &tools,
2870            tool_ctx: &tool_ctx,
2871            providers: &providers,
2872            flows: &flows,
2873            contract: None,
2874            events: None,
2875            turn_id: None,
2876            flow_run_id: tool_ctx
2877                .flow_identity
2878                .as_ref()
2879                .map(|identity| identity.run_id.clone()),
2880            session_runtime: None,
2881            flow_cancel: tokio_util::sync::CancellationToken::new(),
2882            safety: None,
2883            current_node_id: None,
2884            source_dir: None,
2885        };
2886
2887        let mut env = Env::new();
2888        env.bind("p", Value::Path(path));
2889
2890        let src = r#"flow t() { return fs.read(p) }"#;
2891        let file = parse_file(src).unwrap();
2892        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2893            let v = eval_expr(value, &env, &ctx).await;
2894            assert!(matches!(v, Value::Str(s) if s == "hello runtime"));
2895        }
2896    }
2897
2898    #[tokio::test]
2899    async fn fanout_emits_branch_start_end_events_with_parent_linkage() {
2900        let src = r#"flow t() { return fanout [1, 2, 3] collect: all }"#;
2901        let file = parse_file(src).unwrap();
2902        let tools = ToolRegistry::new();
2903        let tool_ctx = ToolCtx::new();
2904        let providers = crate::provider::ProviderRegistry::new();
2905        let flows = std::collections::HashMap::new();
2906        let events = crate::event::EventSink::new();
2907        let ctx = EvalCtx {
2908            tools: &tools,
2909            tool_ctx: &tool_ctx,
2910            providers: &providers,
2911            flows: &flows,
2912            contract: None,
2913            events: Some(&events),
2914            turn_id: None,
2915            flow_run_id: Some(crate::event::FlowRunId::now()),
2916            session_runtime: None,
2917            flow_cancel: tokio_util::sync::CancellationToken::new(),
2918            safety: None,
2919            current_node_id: Some("stmt_1".into()),
2920            source_dir: None,
2921        };
2922        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2923            let _ = eval_expr(value, &Env::new(), &ctx).await;
2924        }
2925        let snap = events.snapshot();
2926        let starts: Vec<_> = snap
2927            .iter()
2928            .filter_map(|e| match e {
2929                crate::event::Event::FlowNodeStart {
2930                    node_id,
2931                    parent_node_id,
2932                    ..
2933                } => Some((node_id.clone(), parent_node_id.clone())),
2934                _ => None,
2935            })
2936            .collect();
2937        assert_eq!(starts.len(), 3);
2938        assert_eq!(starts[0].0, "stmt_1.branch[0]");
2939        assert_eq!(starts[1].0, "stmt_1.branch[1]");
2940        assert_eq!(starts[2].0, "stmt_1.branch[2]");
2941        assert!(starts.iter().all(|(_, p)| p.as_deref() == Some("stmt_1")));
2942        let ends = snap
2943            .iter()
2944            .filter(|e| matches!(e, crate::event::Event::FlowNodeEnd { .. }))
2945            .count();
2946        assert_eq!(ends, 3);
2947    }
2948
2949    #[test]
2950    fn resolve_tool_specs_wildcard_unknown_prefix_skips_silently() {
2951        let tools = crate::tool::ToolRegistry::new();
2952        let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2953            "nonexistent.*".into(),
2954        ))]);
2955        let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
2956            &[crate::value::Value::Str("nonexistent.*".into())],
2957            &tools,
2958        )
2959        .unwrap();
2960        assert!(
2961            specs.is_empty(),
2962            "wildcard with no matches should return empty list"
2963        );
2964    }
2965
2966    #[test]
2967    fn resolve_tool_specs_wildcard_matches_prefixed_tools() {
2968        let tools = crate::tool::ToolRegistry::new();
2969        struct FakeMcpTool {
2970            name: String,
2971            desc: String,
2972            schema: serde_json::Value,
2973        }
2974        impl crate::tool::Tool for FakeMcpTool {
2975            fn name(&self) -> &str {
2976                &self.name
2977            }
2978            fn description(&self) -> Option<&str> {
2979                Some(&self.desc)
2980            }
2981            fn input_schema(&self) -> serde_json::Value {
2982                self.schema.clone()
2983            }
2984            fn tier(&self) -> crate::tool::Tier {
2985                crate::tool::Tier::Zero
2986            }
2987            fn approval_level(
2988                &self,
2989                _args: &crate::tool::ToolArgs,
2990                _ctx: &crate::tool::ToolCtx,
2991            ) -> crate::tool::ApprovalLevel {
2992                crate::tool::ApprovalLevel::Auto
2993            }
2994            fn call<'a>(
2995                &'a self,
2996                _args: crate::tool::ToolArgs,
2997                _ctx: &'a crate::tool::ToolCtx,
2998            ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2999                Box::pin(async { Ok(crate::value::Value::Unit) })
3000            }
3001        }
3002        tools.register(std::sync::Arc::new(FakeMcpTool {
3003            name: "mcp.lark.send_mail".into(),
3004            desc: "send mail".into(),
3005            schema: serde_json::json!({"type":"object","properties":{}}),
3006        }));
3007        tools.register(std::sync::Arc::new(FakeMcpTool {
3008            name: "mcp.lark.read_inbox".into(),
3009            desc: "read inbox".into(),
3010            schema: serde_json::json!({"type":"object","properties":{}}),
3011        }));
3012        tools.register(std::sync::Arc::new(FakeMcpTool {
3013            name: "mcp.siyuan.search".into(),
3014            desc: "search notes".into(),
3015            schema: serde_json::json!({"type":"object","properties":{}}),
3016        }));
3017        // Non-MCP tool should NOT be matched.
3018        tools.register(std::sync::Arc::new(FakeMcpTool {
3019            name: "fs.read".into(),
3020            desc: "read file".into(),
3021            schema: serde_json::json!({"type":"object","properties":{}}),
3022        }));
3023
3024        // "mcp.*" matches all 3 mcp.* tools, but not fs.read
3025        let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
3026            "mcp.*".into(),
3027        ))]);
3028        let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
3029            &[crate::value::Value::Str("mcp.*".into())],
3030            &tools,
3031        )
3032        .unwrap();
3033        assert_eq!(specs.len(), 3, "mcp.* should match 3 MCP tools");
3034        let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
3035        assert!(names.contains(&"mcp.lark.send_mail".into()));
3036        assert!(names.contains(&"mcp.lark.read_inbox".into()));
3037        assert!(names.contains(&"mcp.siyuan.search".into()));
3038
3039        // "mcp.lark.*" matches only the 2 lark tools
3040        let _expr2 = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
3041            "mcp.lark.*".into(),
3042        ))]);
3043        let specs2 = crate::eval::llm_args::resolve_tool_specs_from_values(
3044            &[crate::value::Value::Str("mcp.lark.*".into())],
3045            &tools,
3046        )
3047        .unwrap();
3048        assert_eq!(specs2.len(), 2, "mcp.lark.* should match 2 lark tools");
3049    }
3050
3051    #[test]
3052    fn resolve_tool_specs_mixed_concrete_and_wildcard() {
3053        let tools = crate::tool::ToolRegistry::new();
3054        struct FakeMcpTool {
3055            name: String,
3056            desc: String,
3057            schema: serde_json::Value,
3058        }
3059        impl crate::tool::Tool for FakeMcpTool {
3060            fn name(&self) -> &str {
3061                &self.name
3062            }
3063            fn description(&self) -> Option<&str> {
3064                Some(&self.desc)
3065            }
3066            fn input_schema(&self) -> serde_json::Value {
3067                self.schema.clone()
3068            }
3069            fn tier(&self) -> crate::tool::Tier {
3070                crate::tool::Tier::Zero
3071            }
3072            fn approval_level(
3073                &self,
3074                _args: &crate::tool::ToolArgs,
3075                _ctx: &crate::tool::ToolCtx,
3076            ) -> crate::tool::ApprovalLevel {
3077                crate::tool::ApprovalLevel::Auto
3078            }
3079            fn call<'a>(
3080                &'a self,
3081                _args: crate::tool::ToolArgs,
3082                _ctx: &'a crate::tool::ToolCtx,
3083            ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
3084                Box::pin(async { Ok(crate::value::Value::Unit) })
3085            }
3086        }
3087        tools.register(std::sync::Arc::new(FakeMcpTool {
3088            name: "mcp.lark.send_mail".into(),
3089            desc: "send mail".into(),
3090            schema: serde_json::json!({"type":"object","properties":{}}),
3091        }));
3092        tools.register(std::sync::Arc::new(FakeMcpTool {
3093            name: "bash.exec".into(),
3094            desc: "exec".into(),
3095            schema: serde_json::json!({"type":"object","properties":{}}),
3096        }));
3097
3098        // Build tools list from DSL parse so we get valid spans.
3099        let src = r#"flow t() -> string {
3100    reply = llm.call(
3101        tools: ["bash.exec", "mcp.*"],
3102    )
3103    return "ok"
3104}"#;
3105        let file = atman_dsl::parse::parse_file(src).unwrap();
3106        let body = &file.flows[0].body;
3107        let tools_values: Vec<crate::value::Value> = match &body[0] {
3108            atman_dsl::ast::Stmt::Bind { value, .. } => match value {
3109                Expr::Node(atman_dsl::ast::Node::ToolCall { args, .. }) => {
3110                    let tools_expr = args
3111                        .iter()
3112                        .find_map(|a| match a {
3113                            atman_dsl::ast::Arg::Named { name, value } if name.name == "tools" => {
3114                                Some(value.clone())
3115                            }
3116                            _ => None,
3117                        })
3118                        .unwrap();
3119                    if let atman_dsl::ast::Expr::List(items) = tools_expr {
3120                        items
3121                            .iter()
3122                            .map(|i| {
3123                                if let atman_dsl::ast::Expr::Literal(
3124                                    atman_dsl::ast::Literal::Str(s),
3125                                ) = i
3126                                {
3127                                    crate::value::Value::Str(s.clone())
3128                                } else {
3129                                    panic!("expected string literal in tools list");
3130                                }
3131                            })
3132                            .collect()
3133                    } else {
3134                        panic!("expected list");
3135                    }
3136                }
3137                _ => panic!("expected tool call"),
3138            },
3139            _ => panic!("expected bind stmt"),
3140        };
3141        let specs =
3142            crate::eval::llm_args::resolve_tool_specs_from_values(&tools_values, &tools).unwrap();
3143        assert_eq!(specs.len(), 2, "bash.exec + mcp.lark.send_mail = 2");
3144        let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
3145        assert!(names.contains(&"bash.exec".into()));
3146        assert!(names.contains(&"mcp.lark.send_mail".into()));
3147    }
3148}
3149
3150#[cfg(test)]
3151mod sanitize_tests {
3152    use super::*;
3153    use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
3154
3155    #[test]
3156    fn sanitize_fills_missing_tool_results() {
3157        let turn = crate::event::TurnId::now();
3158        let msgs = vec![
3159            Message {
3160                role: MessageRole::Assistant,
3161                parts: vec![MessagePart::ToolUse {
3162                    id: "call_orphan".into(),
3163                    name: "bash.exec".into(),
3164                    input: serde_json::json!({}),
3165                    intent: None,
3166                }],
3167                turn_id: turn.clone(),
3168                origin: MessageOrigin::User,
3169            },
3170            Message {
3171                role: MessageRole::User,
3172                parts: vec![MessagePart::Text {
3173                    text: "user interrupt".into(),
3174                }],
3175                turn_id: turn.clone(),
3176                origin: MessageOrigin::User,
3177            },
3178        ];
3179        let out = crate::message::normalize_tool_pairs_for_model(&msgs);
3180        let has_filler = out.iter().any(|m| {
3181            m.parts.iter().any(|p| {
3182                matches!(p, MessagePart::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "call_orphan")
3183            })
3184        });
3185        assert!(
3186            has_filler,
3187            "should append error tool_result for orphan tool_use"
3188        );
3189    }
3190
3191    #[test]
3192    fn sanitize_noop_when_pairs_complete() {
3193        let turn = crate::event::TurnId::now();
3194        let msgs = vec![
3195            Message {
3196                role: MessageRole::Assistant,
3197                parts: vec![MessagePart::ToolUse {
3198                    id: "call_ok".into(),
3199                    name: "bash.exec".into(),
3200                    input: serde_json::json!({}),
3201                    intent: None,
3202                }],
3203                turn_id: turn.clone(),
3204                origin: MessageOrigin::User,
3205            },
3206            Message {
3207                role: MessageRole::Tool,
3208                parts: vec![MessagePart::ToolResult {
3209                    tool_use_id: "call_ok".into(),
3210                    content: "done".into(),
3211                    is_error: false,
3212                }],
3213                turn_id: turn.clone(),
3214                origin: MessageOrigin::User,
3215            },
3216        ];
3217        let out = crate::message::normalize_tool_pairs_for_model(&msgs);
3218        assert_eq!(
3219            out.len(),
3220            2,
3221            "no filler should be added when pairs complete"
3222        );
3223    }
3224
3225    // --- stall timeout tests ---
3226    use crate::providers::mock::MockProvider;
3227
3228    fn stall_req(stall_secs: u64) -> crate::provider::LlmRequest {
3229        crate::provider::LlmRequest {
3230            model: "mock".into(),
3231            messages: vec![crate::provider::user_text_message("test")],
3232            system: None,
3233            input: crate::value::Value::Unit,
3234            schema: None,
3235            cache_prompt: false,
3236            prompt_cache_key: None,
3237            tools: Vec::new(),
3238            reasoning: crate::provider::ReasoningSelection::ProviderDefault,
3239            stall_timeout_secs: stall_secs,
3240        }
3241    }
3242
3243    #[tokio::test]
3244    async fn stall_timeout_fires_when_no_chunks_arrive() {
3245        // chunk_delay = 3s, stall_timeout = 1s → stall fires before 2nd chunk
3246        let provider = MockProvider::new("mock")
3247            .with_model("mock", Value::Str("hello world test".into()))
3248            .with_chunk_delay(std::time::Duration::from_secs(3));
3249
3250        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3251        let result = call_and_maybe_stream(
3252            &provider,
3253            stall_req(1),
3254            StreamCallCtx {
3255                stream_tx: Some(stream_tx),
3256                ..Default::default()
3257            },
3258            None,
3259        )
3260        .await;
3261        match result {
3262            Err(RuntimeError::ToolFailed(msg)) => {
3263                assert!(
3264                    msg.contains("llm stall timeout after 1s"),
3265                    "expected stall message, got: {msg}"
3266                );
3267            }
3268            other => panic!("expected ToolFailed stall timeout, got: {other:?}"),
3269        }
3270    }
3271
3272    #[tokio::test]
3273    async fn stall_timeout_does_not_fire_when_chunks_keep_coming() {
3274        // chunk_delay = 100ms, stall_timeout = 2s → all chunks within 300ms, no stall
3275        let provider = MockProvider::new("mock")
3276            .with_model("mock", Value::Str("hello world test".into()))
3277            .with_chunk_delay(std::time::Duration::from_millis(100));
3278
3279        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3280        let result = call_and_maybe_stream(
3281            &provider,
3282            stall_req(2),
3283            StreamCallCtx {
3284                stream_tx: Some(stream_tx),
3285                ..Default::default()
3286            },
3287            None,
3288        )
3289        .await;
3290        match result {
3291            Ok(am) => {
3292                assert!(am.text_concat().contains("hello"));
3293            }
3294            other => panic!("expected Ok, got: {other:?}"),
3295        }
3296    }
3297
3298    #[tokio::test]
3299    async fn stall_timeout_zero_disables_detection() {
3300        // chunk_delay = 3s, stall_timeout = 0 → disabled, all chunks arrive
3301        let provider = MockProvider::new("mock")
3302            .with_model("mock", Value::Str("hello world test".into()))
3303            .with_chunk_delay(std::time::Duration::from_secs(3));
3304
3305        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3306        let result = call_and_maybe_stream(
3307            &provider,
3308            stall_req(0),
3309            StreamCallCtx {
3310                stream_tx: Some(stream_tx),
3311                ..Default::default()
3312            },
3313            None,
3314        )
3315        .await;
3316        match result {
3317            Ok(am) => {
3318                assert!(am.text_concat().contains("hello"));
3319            }
3320            other => panic!("expected Ok (stall disabled), got: {other:?}"),
3321        }
3322    }
3323
3324    #[tokio::test]
3325    async fn stall_timeout_resets_on_each_chunk() {
3326        // 3 chunks at 800ms each. stall=1s. First chunk at t=0, second at t=800ms (<1s),
3327        // third at t=1.6s (>1s from start, but only 800ms from last chunk). Should pass.
3328        let provider = MockProvider::new("mock")
3329            .with_model("mock", Value::Str("hello world test".into()))
3330            .with_chunk_delay(std::time::Duration::from_millis(800));
3331
3332        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3333        let result = call_and_maybe_stream(
3334            &provider,
3335            stall_req(1),
3336            StreamCallCtx {
3337                stream_tx: Some(stream_tx),
3338                ..Default::default()
3339            },
3340            None,
3341        )
3342        .await;
3343        match result {
3344            Ok(am) => {
3345                assert!(am.text_concat().contains("hello"));
3346            }
3347            other => panic!("expected Ok (timer reset each chunk), got: {other:?}"),
3348        }
3349    }
3350
3351    #[tokio::test]
3352    async fn stall_timeout_fires_between_first_and_second_chunk() {
3353        // first chunk at t≈0, then 2s gap, stall=1s fires at t=1s
3354        let provider = MockProvider::new("mock")
3355            .with_model("mock", Value::Str("hello world test".into()))
3356            .with_chunk_delay(std::time::Duration::from_secs(2));
3357
3358        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3359        let result = call_and_maybe_stream(
3360            &provider,
3361            stall_req(1),
3362            StreamCallCtx {
3363                stream_tx: Some(stream_tx),
3364                ..Default::default()
3365            },
3366            None,
3367        )
3368        .await;
3369        assert!(
3370            matches!(&result, Err(RuntimeError::ToolFailed(msg)) if msg.contains("stall timeout")),
3371            "expected stall timeout, got: {result:?}"
3372        );
3373    }
3374}