Skip to main content

atman_runtime/eval/
mod.rs

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