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