Skip to main content

atman_runtime/eval/
mod.rs

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