Skip to main content

atman_runtime/
eval.rs

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