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