Skip to main content

atman_runtime/eval/
mod.rs

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