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                                    if let Some(tx) =
1279                                        ctx.session_runtime.as_ref().map(|s| s.stream_tx())
1280                                    {
1281                                        let _ = tx.send(crate::stream::StreamFrame::LlmRetry);
1282                                    }
1283                                    crate::notify!(
1284                                        info,
1285                                        location = Inline,
1286                                        "thinking signature missing — retry {signature_retries}/3"
1287                                    );
1288                                    last_err = Some(e);
1289                                    continue 'llm_attempts;
1290                                }
1291                                // Exhausted — disable thinking
1292                                thinking_enabled = false;
1293                                if let Some(tx) =
1294                                    ctx.session_runtime.as_ref().map(|s| s.stream_tx())
1295                                {
1296                                    let _ = tx.send(crate::stream::StreamFrame::LlmRetry);
1297                                }
1298                                crate::notify!(
1299                                    warn,
1300                                    location = Inline,
1301                                    "thinking signature missing after 3 retries; disabling thinkingâ€Ĥ"
1302                                );
1303                                last_err = Some(e);
1304                                continue 'llm_attempts;
1305                            }
1306                            if thinking_enabled
1307                                && matches!(e.kind(), crate::error::ErrorKind::InvalidRequest)
1308                            {
1309                                thinking_enabled = false;
1310                                if let Some(tx) =
1311                                    ctx.session_runtime.as_ref().map(|s| s.stream_tx())
1312                                {
1313                                    let _ = tx.send(crate::stream::StreamFrame::LlmRetry);
1314                                }
1315                                crate::notify!(
1316                                    warn,
1317                                    location = Inline,
1318                                    "thinking mode disabled due to API error; retryingâ€Ĥ"
1319                                );
1320                                last_err = Some(e);
1321                                continue 'llm_attempts;
1322                            }
1323                            if attempt < retry_count {
1324                                let kind = e.kind();
1325                                let should_retry = match &retry_kinds_ref {
1326                                    Some(allowed) => allowed.contains(&kind),
1327                                    None => true,
1328                                };
1329                                if !should_retry {
1330                                    last_err = Some(e);
1331                                    break;
1332                                }
1333                                if matches!(
1334                                    kind,
1335                                    crate::error::ErrorKind::RateLimit
1336                                        | crate::error::ErrorKind::Timeout
1337                                        | crate::error::ErrorKind::ProviderDown
1338                                        | crate::error::ErrorKind::Transient
1339                                ) {
1340                                    let delay_ms = 1000u64 << attempt;
1341                                    if let Some(tx) =
1342                                        ctx.session_runtime.as_ref().map(|s| s.stream_tx())
1343                                    {
1344                                        let _ = tx.send(crate::stream::StreamFrame::Note(format!(
1345                                            "retrying in {}sâ€Ĥ",
1346                                            delay_ms / 1000
1347                                        )));
1348                                    }
1349                                    tokio::time::sleep(std::time::Duration::from_millis(delay_ms))
1350                                        .await;
1351                                }
1352                                last_err = Some(e);
1353                            } else {
1354                                last_err = Some(e);
1355                            }
1356                        }
1357                    }
1358                }
1359                break;
1360            }
1361            if let Some(fb) = args.fallback_expr.as_ref() {
1362                return eval_expr(fb, env, ctx).await;
1363            }
1364            if let Some(session) = ctx.session_runtime.as_ref()
1365                && !saw_context_overflow
1366            {
1367                crate::compaction::start_auto_compact(
1368                    session.clone(),
1369                    model.clone(),
1370                    ctx.providers.clone(),
1371                )
1372                .await;
1373            }
1374            Value::Err(last_err.unwrap_or(RuntimeError::ToolFailed("llm failed".into())))
1375        }
1376        Node::UserConfirm { msg } => {
1377            let v = eval_expr(msg, env, ctx).await;
1378            if v.is_err() {
1379                return v;
1380            }
1381            let prompt = match &v {
1382                Value::Str(s) => s.clone(),
1383                other => other.kind_name().to_string(),
1384            };
1385            let confirm_kind = crate::form::FormKind::Confirm {
1386                prompt: prompt.clone(),
1387            };
1388            // Daemon clients drive the confirm through the prompt resolver
1389            // over RPC; in-process TUI subscribes to FormRegistry. Boot /
1390            // headless / unit tests without either wired keep the historical
1391            // auto-approve so they don't deadlock.
1392            if let Some(resolver) = ctx.tool_ctx.prompt_resolver.clone() {
1393                let id = crate::rendezvous::PromptId::now();
1394                let payload =
1395                    serde_json::to_value(&confirm_kind).unwrap_or(serde_json::Value::Null);
1396                let timeout = std::time::Duration::from_secs(300);
1397                let result = crate::rendezvous::await_prompt_with_payload(
1398                    &resolver, id, "form_ask", payload, timeout,
1399                )
1400                .await;
1401                let answer: crate::form::FormAnswer = match result {
1402                    Ok(v) => {
1403                        serde_json::from_value(v).unwrap_or(crate::form::FormAnswer::Cancelled)
1404                    }
1405                    Err(_) => crate::form::FormAnswer::Cancelled,
1406                };
1407                return Value::Bool(matches!(
1408                    answer,
1409                    crate::form::FormAnswer::Confirmed { value: true }
1410                ));
1411            }
1412            let Some(session) = ctx.session_runtime.as_ref() else {
1413                return Value::Bool(true);
1414            };
1415            let forms = session.forms();
1416            if forms.subscriber_count() == 0 {
1417                return Value::Bool(true);
1418            }
1419            let Some(run_id) = ctx.flow_run_id.clone() else {
1420                return Value::Bool(true);
1421            };
1422            let pending = crate::form::PendingForm {
1423                form_id: uuid::Uuid::now_v7().to_string(),
1424                run_id,
1425                tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
1426                kind: confirm_kind,
1427                emitted_at: chrono::Utc::now(),
1428            };
1429            let rx = forms.request(pending);
1430            let answer = rx.await.unwrap_or(crate::form::FormAnswer::Cancelled);
1431            Value::Bool(matches!(
1432                answer,
1433                crate::form::FormAnswer::Confirmed { value: true }
1434            ))
1435        }
1436        Node::FixUntilTestPasses { kwargs } => eval_fix_until_test_passes(kwargs, env, ctx).await,
1437        Node::Message { role, args } => eval_message_node(*role, args, env, ctx).await,
1438        Node::Subflow { name, args } => {
1439            let Some(target) = ctx.flows.get(&name.name) else {
1440                return Value::Err(RuntimeError::UndefinedTool(format!(
1441                    "subflow({})",
1442                    name.name
1443                )));
1444            };
1445            let mut bindings = Vec::with_capacity(args.len());
1446            for (i, arg) in args.iter().enumerate() {
1447                let (param_name, value) = match arg {
1448                    Arg::Positional(e) => {
1449                        let Some((pname, _)) = target.params.get(i) else {
1450                            return Value::Err(RuntimeError::MissingArg(format!(
1451                                "subflow({}): too many positional args",
1452                                name.name
1453                            )));
1454                        };
1455                        let v = eval_expr(e, env, ctx).await;
1456                        (pname.name.clone(), v)
1457                    }
1458                    Arg::Named { name: n, value } => {
1459                        let v = eval_expr(value, env, ctx).await;
1460                        (n.name.clone(), v)
1461                    }
1462                };
1463                if value.is_err() {
1464                    return value;
1465                }
1466                bindings.push((param_name, value));
1467            }
1468            let mut sub_env = Env::new();
1469            for (n, v) in bindings {
1470                sub_env.bind(n, v);
1471            }
1472            let sub_run_id = crate::event::FlowRunId::now();
1473            if let Some(sink) = ctx.events {
1474                sink.emit(crate::event::Event::FlowStart {
1475                    run_id: sub_run_id.clone(),
1476                    flow_name: name.name.clone(),
1477                    parent_run_id: ctx.flow_run_id.clone(),
1478                    parent_node_id: ctx.current_node_id.clone(),
1479                });
1480            }
1481            if let Some(session) = ctx.session_runtime.as_ref() {
1482                let _ = session
1483                    .stream_tx()
1484                    .send(crate::stream::StreamFrame::FlowStart {
1485                        run_id: sub_run_id.0.to_string(),
1486                        flow_name: name.name.clone(),
1487                        parent_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1488                        parent_node_id: ctx.current_node_id.clone(),
1489                    });
1490            }
1491            let sub_ctx = EvalCtx {
1492                flow_run_id: Some(sub_run_id.clone()),
1493                current_node_id: None,
1494                ..ctx.clone()
1495            };
1496            let outcome = crate::exec::exec_stmts(&target.body, &mut sub_env, &sub_ctx).await;
1497            let (result, status, ok) = match outcome {
1498                crate::exec::StmtOutcome::Return(v) => (v, crate::event::FlowStatus::Ok, true),
1499                crate::exec::StmtOutcome::Err(e) => {
1500                    let status = if matches!(&e, crate::error::RuntimeError::Cancelled(_)) {
1501                        crate::event::FlowStatus::Cancelled
1502                    } else {
1503                        crate::event::FlowStatus::Errored {
1504                            message: format!("{e}"),
1505                        }
1506                    };
1507                    (Value::Err(e.clone()), status, false)
1508                }
1509                crate::exec::StmtOutcome::Continue => {
1510                    (Value::Unit, crate::event::FlowStatus::Ok, true)
1511                }
1512            };
1513            let cancelled = matches!(status, crate::event::FlowStatus::Cancelled);
1514            if let Some(sink) = ctx.events {
1515                sink.emit(crate::event::Event::FlowEnd {
1516                    run_id: sub_run_id.clone(),
1517                    flow_name: name.name.clone(),
1518                    status,
1519                });
1520            }
1521            if let Some(session) = ctx.session_runtime.as_ref() {
1522                let _ = session
1523                    .stream_tx()
1524                    .send(crate::stream::StreamFrame::FlowDone {
1525                        run_id: sub_run_id.0.to_string(),
1526                        flow_name: name.name.clone(),
1527                        ok,
1528                        cancelled,
1529                    });
1530            }
1531            result
1532        }
1533    }
1534}
1535
1536fn session_fs_access_policy(session: &crate::session::Session) -> crate::fs_access::FsAccessPolicy {
1537    let workspace = session
1538        .meta()
1539        .and_then(|m| m.project_root)
1540        .or_else(|| std::env::current_dir().ok());
1541    let mode = session
1542        .fs_access_mode()
1543        .unwrap_or(crate::fs_access::FsAccessMode::WorkspaceWrite);
1544    crate::fs_access::FsAccessPolicy { mode, workspace }
1545}
1546
1547fn tool_name(path: &[atman_dsl::ast::Ident]) -> String {
1548    let parts: Vec<&str> = path.iter().map(|i| i.name.as_str()).collect();
1549    parts.join(".")
1550}
1551
1552async fn eval_fix_until_test_passes<'a>(
1553    kwargs: &'a atman_dsl::ast::Kwargs,
1554    env: &'a Env,
1555    ctx: &'a EvalCtx<'a>,
1556) -> Value {
1557    let mut edit_flow_expr: Option<&Expr> = None;
1558    let mut test_expr: Option<&Expr> = None;
1559    let mut on_giveup_expr: Option<&Expr> = None;
1560    let mut max_iters: u32 = 5;
1561    let mut target_path: Option<std::path::PathBuf> = None;
1562
1563    for (k, v) in kwargs {
1564        match k.name.as_str() {
1565            "edit_flow" => edit_flow_expr = Some(v),
1566            "test" => test_expr = Some(v),
1567            "on_giveup" => on_giveup_expr = Some(v),
1568            "max_iters" => match eval_expr(v, env, ctx).await {
1569                Value::Int(n) if n > 0 => max_iters = n as u32,
1570                other => {
1571                    return Value::Err(RuntimeError::TypeMismatch {
1572                        expected: "positive int (max_iters)".into(),
1573                        actual: other.kind_name().into(),
1574                    });
1575                }
1576            },
1577            "target" => match eval_expr(v, env, ctx).await {
1578                Value::Path(p) => target_path = Some(p),
1579                Value::Str(s) => target_path = Some(std::path::PathBuf::from(s)),
1580                Value::Unit => {}
1581                other => {
1582                    return Value::Err(RuntimeError::TypeMismatch {
1583                        expected: "path (target)".into(),
1584                        actual: other.kind_name().into(),
1585                    });
1586                }
1587            },
1588            _ => {}
1589        }
1590    }
1591
1592    let Some(edit_flow_expr) = edit_flow_expr else {
1593        return Value::Err(RuntimeError::MissingArg(
1594            "fix_until_test_passes.edit_flow".into(),
1595        ));
1596    };
1597    let Some(test_expr) = test_expr else {
1598        return Value::Err(RuntimeError::MissingArg(
1599            "fix_until_test_passes.test".into(),
1600        ));
1601    };
1602
1603    let pristine: Option<String> = match &target_path {
1604        Some(p) => match tokio::fs::read_to_string(p).await {
1605            Ok(s) => Some(s),
1606            Err(e) => {
1607                return Value::Err(RuntimeError::ToolFailed(format!(
1608                    "fix_until_test_passes: cannot read target {}: {e}",
1609                    p.display()
1610                )));
1611            }
1612        },
1613        None => None,
1614    };
1615
1616    let mut prev_fail = String::new();
1617    let mut last_test_result: Option<Value> = None;
1618
1619    for iter in 0..max_iters {
1620        let mut loop_env = env.clone();
1621        loop_env.bind("iter", Value::Int(iter as i64));
1622        loop_env.bind("prev_fail", Value::Str(prev_fail.clone()));
1623
1624        let edit_v = eval_expr(edit_flow_expr, &loop_env, ctx).await;
1625        if edit_v.is_err() {
1626            return edit_v;
1627        }
1628        loop_env.bind("last_edit", edit_v);
1629
1630        let test_v = eval_expr(test_expr, &loop_env, ctx).await;
1631        if test_v.is_err() {
1632            return test_v;
1633        }
1634        let exit = test_v
1635            .field("exit_code")
1636            .or_else(|| test_v.field("exit"))
1637            .and_then(|v| match v {
1638                Value::Int(n) => Some(*n),
1639                _ => None,
1640            });
1641        last_test_result = Some(test_v.clone());
1642        if let Some(0) = exit {
1643            return Value::Struct(vec![
1644                ("status".into(), Value::Str("passed".into())),
1645                ("iters".into(), Value::Int((iter + 1) as i64)),
1646                ("test".into(), test_v),
1647            ]);
1648        }
1649        let stderr_tail = test_v
1650            .field("stderr_tail")
1651            .or_else(|| test_v.field("output"))
1652            .and_then(|v| match v {
1653                Value::Str(s) => Some(s.clone()),
1654                _ => None,
1655            })
1656            .unwrap_or_default();
1657        let stdout_tail = test_v
1658            .field("stdout_tail")
1659            .and_then(|v| match v {
1660                Value::Str(s) => Some(s.clone()),
1661                _ => None,
1662            })
1663            .unwrap_or_default();
1664        prev_fail = format!(
1665            "iter {iter} exit={:?}\n--- stderr ---\n{stderr_tail}\n--- stdout ---\n{stdout_tail}",
1666            exit
1667        );
1668
1669        if let (Some(target), Some(pristine)) = (&target_path, &pristine)
1670            && let Err(e) = tokio::fs::write(target, pristine.as_bytes()).await
1671        {
1672            return Value::Err(RuntimeError::ToolFailed(format!(
1673                "fix_until_test_passes: revert failed on {}: {e}",
1674                target.display()
1675            )));
1676        }
1677    }
1678
1679    if let Some(giveup) = on_giveup_expr {
1680        let mut giveup_env = env.clone();
1681        giveup_env.bind("iters", Value::Int(max_iters as i64));
1682        giveup_env.bind("prev_fail", Value::Str(prev_fail));
1683        return eval_expr(giveup, &giveup_env, ctx).await;
1684    }
1685
1686    Value::Struct(vec![
1687        ("status".into(), Value::Str("gave_up".into())),
1688        ("iters".into(), Value::Int(max_iters as i64)),
1689        ("last_test".into(), last_test_result.unwrap_or(Value::Unit)),
1690    ])
1691}
1692
1693async fn eval_message_node<'a>(
1694    ast_role: atman_dsl::ast::MessageRole,
1695    args: &'a [Arg],
1696    env: &'a Env,
1697    ctx: &'a EvalCtx<'a>,
1698) -> Value {
1699    use crate::message::{ImageData, ImageSource, Message, MessagePart, MessageRole};
1700
1701    let role = match ast_role {
1702        atman_dsl::ast::MessageRole::User => MessageRole::User,
1703        atman_dsl::ast::MessageRole::Assistant => MessageRole::Assistant,
1704        atman_dsl::ast::MessageRole::System => MessageRole::System,
1705        atman_dsl::ast::MessageRole::Tool => MessageRole::Tool,
1706    };
1707    let turn_id = ctx
1708        .turn_id
1709        .clone()
1710        .unwrap_or_else(crate::event::TurnId::now);
1711
1712    let mut positional = Vec::new();
1713    let mut named: Vec<(String, Value)> = Vec::new();
1714    let mut attachment_paths_raw: Option<Vec<std::path::PathBuf>> = None;
1715    for arg in args {
1716        match arg {
1717            Arg::Positional(e) => {
1718                let v = eval_expr(e, env, ctx).await;
1719                if v.is_err() {
1720                    return v;
1721                }
1722                positional.push(v);
1723            }
1724            Arg::Named { name, value } => {
1725                if name.name == "attachments" {
1726                    if let Expr::List(items) = value {
1727                        let mut collected = Vec::with_capacity(items.len());
1728                        let mut all_fileref = true;
1729                        for it in items {
1730                            if let Expr::FileRef(f) = it {
1731                                collected.push(std::path::PathBuf::from(&f.path));
1732                            } else {
1733                                all_fileref = false;
1734                                break;
1735                            }
1736                        }
1737                        if all_fileref {
1738                            attachment_paths_raw = Some(collected);
1739                            continue;
1740                        }
1741                    }
1742                }
1743                let v = eval_expr(value, env, ctx).await;
1744                if v.is_err() {
1745                    return v;
1746                }
1747                named.push((name.name.clone(), v));
1748            }
1749        }
1750    }
1751    let take_named = |k: &str, named: &mut Vec<(String, Value)>| -> Option<Value> {
1752        let pos = named.iter().position(|(n, _)| n == k)?;
1753        Some(named.remove(pos).1)
1754    };
1755
1756    if role == MessageRole::Tool {
1757        let tool_use_id = match positional.first().or(take_named("id", &mut named).as_ref()) {
1758            Some(Value::Str(s)) => s.clone(),
1759            Some(other) => {
1760                return Value::Err(RuntimeError::TypeMismatch {
1761                    expected: "string (tool_use_id)".into(),
1762                    actual: other.kind_name().into(),
1763                });
1764            }
1765            None => {
1766                return Value::Err(RuntimeError::MissingArg("tool_result: id".into()));
1767            }
1768        };
1769        let content = match positional
1770            .get(1)
1771            .or(take_named("content", &mut named).as_ref())
1772        {
1773            Some(Value::Str(s)) => s.clone(),
1774            Some(other) => {
1775                return Value::Err(RuntimeError::TypeMismatch {
1776                    expected: "string (content)".into(),
1777                    actual: other.kind_name().into(),
1778                });
1779            }
1780            None => {
1781                return Value::Err(RuntimeError::MissingArg("tool_result: content".into()));
1782            }
1783        };
1784        let is_error = match take_named("is_error", &mut named) {
1785            Some(Value::Bool(b)) => b,
1786            Some(other) => {
1787                return Value::Err(RuntimeError::TypeMismatch {
1788                    expected: "bool (is_error)".into(),
1789                    actual: other.kind_name().into(),
1790                });
1791            }
1792            None => false,
1793        };
1794        return Value::Message(Message {
1795            role,
1796            parts: vec![MessagePart::ToolResult {
1797                tool_use_id,
1798                content,
1799                is_error,
1800            }],
1801            turn_id,
1802        });
1803    }
1804
1805    let text = match positional.first() {
1806        Some(Value::Str(s)) => Some(s.clone()),
1807        Some(other) => {
1808            return Value::Err(RuntimeError::TypeMismatch {
1809                expected: "string (message text)".into(),
1810                actual: other.kind_name().into(),
1811            });
1812        }
1813        None => None,
1814    };
1815    let attachment_paths: Vec<std::path::PathBuf> = if let Some(raw) = attachment_paths_raw {
1816        raw
1817    } else {
1818        match take_named("attachments", &mut named) {
1819            Some(Value::List(items)) => {
1820                let mut ps = Vec::with_capacity(items.len());
1821                for it in items {
1822                    match it {
1823                        Value::Path(p) => ps.push(p),
1824                        Value::Str(s) => ps.push(std::path::PathBuf::from(s)),
1825                        other => {
1826                            return Value::Err(RuntimeError::TypeMismatch {
1827                                expected: "path (attachment)".into(),
1828                                actual: other.kind_name().into(),
1829                            });
1830                        }
1831                    }
1832                }
1833                ps
1834            }
1835            Some(other) => {
1836                return Value::Err(RuntimeError::TypeMismatch {
1837                    expected: "list of path".into(),
1838                    actual: other.kind_name().into(),
1839                });
1840            }
1841            None => Vec::new(),
1842        }
1843    };
1844
1845    let mut parts: Vec<MessagePart> = attachment_paths
1846        .into_iter()
1847        .map(|path| {
1848            let media_type = guess_image_mime(&path).unwrap_or_else(|| "image/png".to_string());
1849            MessagePart::Image {
1850                source: ImageSource {
1851                    media_type,
1852                    data: ImageData::Path { path },
1853                },
1854            }
1855        })
1856        .collect();
1857    if let Some(t) = text {
1858        parts.push(MessagePart::Text { text: t });
1859    }
1860
1861    Value::Message(Message {
1862        role,
1863        parts,
1864        turn_id,
1865    })
1866}
1867
1868fn render_injections(injections: &[crate::injection::Injection]) -> String {
1869    use crate::injection::InjectionLevel;
1870    let mut out = String::from(
1871        "The user sent the following steering message(s) while you were working. \
1872         Apply them to your next step if still relevant.\n\n",
1873    );
1874    for inj in injections {
1875        let tag = match inj.level {
1876            InjectionLevel::L2CourseCorrect => "user_correction",
1877            _ => "user_nudge",
1878        };
1879        out.push_str(&format!(
1880            "<{tag} id=\"{}\" ts=\"{}\">\n{}\n</{tag}>\n",
1881            inj.id.0,
1882            inj.created_at.to_rfc3339(),
1883            inj.text
1884        ));
1885    }
1886    out
1887}
1888
1889fn guess_image_mime(path: &std::path::Path) -> Option<String> {
1890    let ext = path
1891        .extension()
1892        .and_then(|s| s.to_str())?
1893        .to_ascii_lowercase();
1894    Some(
1895        match ext.as_str() {
1896            "png" => "image/png",
1897            "jpg" | "jpeg" => "image/jpeg",
1898            "gif" => "image/gif",
1899            "webp" => "image/webp",
1900            _ => return None,
1901        }
1902        .to_string(),
1903    )
1904}
1905
1906fn contract_allows_shell(contract: Option<&atman_dsl::ast::Contract>) -> bool {
1907    let Some(c) = contract else { return false };
1908    for block in &c.blocks {
1909        if block.name.name != "capabilities" {
1910            continue;
1911        }
1912        for (k, v) in &block.kwargs {
1913            if k.name != "shell" {
1914                continue;
1915            }
1916            if let atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Bool(true)) = v {
1917                return true;
1918            }
1919        }
1920    }
1921    false
1922}
1923
1924pub struct TruncationStat {
1925    pub original_chars: usize,
1926    pub result_chars: usize,
1927    pub dropped_chars: usize,
1928    pub budget_tokens: u64,
1929}
1930
1931pub(crate) fn resolve_tool_specs(
1932    expr: &Expr,
1933    tools: &crate::tool::ToolRegistry,
1934) -> Result<Vec<crate::tool::ToolSpec>, String> {
1935    let items = match expr {
1936        Expr::List(items) => items,
1937        _ => {
1938            return Err(
1939                "llm.tools: expected a list of tool references like [fs.read, bash.exec]".into(),
1940            );
1941        }
1942    };
1943    let mut out = Vec::with_capacity(items.len());
1944    for item in items {
1945        // Handle wildcard strings like "mcp.*" or "mcp.lark.*".
1946        // Expand against all registered tools whose names start with the prefix.
1947        if let Some(prefix) = wildcard_prefix(item) {
1948            for name in tools.names() {
1949                if name.starts_with(&prefix) {
1950                    if let Some(tool) = tools.get(&name) {
1951                        out.push(crate::tool::tool_spec(tool.as_ref()));
1952                    }
1953                }
1954            }
1955            continue;
1956        }
1957        let name = match tool_ref_name(item) {
1958            Some(n) => n,
1959            None => {
1960                return Err(format!(
1961                    "llm.tools: item is not a tool reference (want ident or ident.method, or a \"ns.*\" wildcard): {item:?}"
1962                ));
1963            }
1964        };
1965        let tool = tools
1966            .get(&name)
1967            .ok_or_else(|| format!("llm.tools: unknown tool `{name}`"))?;
1968        out.push(crate::tool::tool_spec(tool.as_ref()));
1969    }
1970    Ok(out)
1971}
1972
1973/// If `expr` is a string literal ending with `.*`, return the prefix
1974/// (everything before the `.*`), e.g. `"mcp.*"` → `Some("mcp.")`.
1975fn wildcard_prefix(expr: &Expr) -> Option<String> {
1976    match expr {
1977        Expr::Literal(atman_dsl::ast::Literal::Str(s)) => s.strip_suffix(".*").map(|prefix| {
1978            if prefix.is_empty() {
1979                // ".*" means match everything — use empty prefix so all tools match.
1980                String::new()
1981            } else {
1982                // "mcp.*" → prefix = "mcp", but we want "mcp." so only mcp tools match.
1983                format!("{prefix}.")
1984            }
1985        }),
1986        _ => None,
1987    }
1988}
1989
1990fn tool_ref_name(expr: &Expr) -> Option<String> {
1991    match expr {
1992        Expr::Ident(id) => Some(id.name.clone()),
1993        Expr::Member { base, field } => {
1994            let base = tool_ref_name(base)?;
1995            Some(format!("{base}.{}", field.name))
1996        }
1997        _ => None,
1998    }
1999}
2000
2001pub(crate) fn parse_error_kind_list(
2002    expr: &Expr,
2003) -> Result<std::collections::HashSet<crate::error::ErrorKind>, String> {
2004    let items = match expr {
2005        Expr::List(items) => items,
2006        _ => {
2007            return Err(
2008                "retry_classified: expected a list literal like [timeout, rate_limit]".into(),
2009            );
2010        }
2011    };
2012    let mut out = std::collections::HashSet::new();
2013    for item in items {
2014        let name = match item {
2015            Expr::Ident(id) => id.name.clone(),
2016            Expr::Literal(atman_dsl::ast::Literal::Str(s)) => s.clone(),
2017            _ => {
2018                return Err(
2019                    "retry_classified: each item must be an identifier or string kind name".into(),
2020                );
2021            }
2022        };
2023        match crate::error::ErrorKind::from_name(&name) {
2024            Some(k) => {
2025                out.insert(k);
2026            }
2027            None => return Err(format!("retry_classified: unknown error kind `{name}`")),
2028        }
2029    }
2030    Ok(out)
2031}
2032
2033fn sanitize_tool_pairs(messages: Vec<crate::message::Message>) -> Vec<crate::message::Message> {
2034    use crate::message::{Message, MessagePart, MessageRole};
2035    use std::collections::HashMap;
2036    let mut result_by_id: HashMap<String, Message> = HashMap::new();
2037    for m in &messages {
2038        for p in &m.parts {
2039            if let MessagePart::ToolResult { tool_use_id, .. } = p {
2040                result_by_id
2041                    .entry(tool_use_id.clone())
2042                    .or_insert_with(|| Message {
2043                        role: MessageRole::Tool,
2044                        parts: vec![p.clone()],
2045                        turn_id: m.turn_id.clone(),
2046                    });
2047            }
2048        }
2049    }
2050    let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 4);
2051    for m in &messages {
2052        let uses: Vec<String> = m
2053            .parts
2054            .iter()
2055            .filter_map(|p| match p {
2056                MessagePart::ToolUse { id, .. } => Some(id.clone()),
2057                _ => None,
2058            })
2059            .collect();
2060        let is_pure_result = m
2061            .parts
2062            .iter()
2063            .all(|p| matches!(p, MessagePart::ToolResult { .. }));
2064        if is_pure_result {
2065            continue;
2066        }
2067        out.push(m.clone());
2068        if !uses.is_empty() {
2069            let next_has_all = match out.len().checked_sub(1) {
2070                Some(_) => false,
2071                None => false,
2072            };
2073            let _ = next_has_all;
2074            let mut filler_parts: Vec<MessagePart> = Vec::new();
2075            for u in &uses {
2076                if let Some(rm) = result_by_id.get(u) {
2077                    if let Some(MessagePart::ToolResult {
2078                        tool_use_id,
2079                        content,
2080                        is_error,
2081                    }) = rm.parts.first()
2082                    {
2083                        filler_parts.push(MessagePart::ToolResult {
2084                            tool_use_id: tool_use_id.clone(),
2085                            content: content.clone(),
2086                            is_error: *is_error,
2087                        });
2088                    }
2089                } else {
2090                    filler_parts.push(MessagePart::ToolResult {
2091                        tool_use_id: u.clone(),
2092                        content: "[tool execution interrupted — no result captured]".into(),
2093                        is_error: true,
2094                    });
2095                }
2096            }
2097            out.push(Message {
2098                role: MessageRole::Tool,
2099                parts: filler_parts,
2100                turn_id: m.turn_id.clone(),
2101            });
2102        }
2103    }
2104    out
2105}
2106
2107pub fn truncate_prompt_to_budget(prompt: String, budget_tokens: u64) -> String {
2108    truncate_prompt_to_budget_tracked(prompt, budget_tokens).0
2109}
2110
2111pub fn truncate_prompt_to_budget_tracked(
2112    prompt: String,
2113    budget_tokens: u64,
2114) -> (String, Option<TruncationStat>) {
2115    let budget_chars = budget_tokens.saturating_mul(4) as usize;
2116    if prompt.len() <= budget_chars {
2117        return (prompt, None);
2118    }
2119    let head_chars = budget_chars * 4 / 10;
2120    let tail_chars = budget_chars * 4 / 10;
2121    if head_chars + tail_chars >= prompt.len() {
2122        return (prompt, None);
2123    }
2124    let original_chars = prompt.len();
2125    let head_end = char_boundary(&prompt, head_chars, false);
2126    let tail_start = char_boundary(&prompt, prompt.len().saturating_sub(tail_chars), true);
2127    let head = &prompt[..head_end];
2128    let tail = &prompt[tail_start..];
2129    let dropped = original_chars - head.len() - tail.len();
2130    let result = format!("{head}\n\n[... truncated {dropped} chars ...]\n\n{tail}");
2131    let stat = TruncationStat {
2132        original_chars,
2133        result_chars: result.len(),
2134        dropped_chars: dropped,
2135        budget_tokens,
2136    };
2137    (result, Some(stat))
2138}
2139
2140fn char_boundary(s: &str, target: usize, round_up: bool) -> usize {
2141    let mut idx = target.min(s.len());
2142    while idx > 0 && idx < s.len() && !s.is_char_boundary(idx) {
2143        if round_up {
2144            idx += 1;
2145        } else {
2146            idx -= 1;
2147        }
2148    }
2149    idx
2150}
2151
2152// Bare primitive names inside `schema: { valid: bool, ... }` parse as tool calls; treat as Unit.
2153fn is_type_annotation(path: &[atman_dsl::ast::Ident]) -> bool {
2154    if path.len() != 1 {
2155        return false;
2156    }
2157    matches!(
2158        path[0].name.as_str(),
2159        "bool" | "int" | "float" | "string" | "path" | "bytes" | "duration"
2160    )
2161}
2162
2163fn eval_literal(lit: &Literal) -> Value {
2164    match lit {
2165        Literal::Str(s) => Value::Str(s.clone()),
2166        Literal::Int(n) => Value::Int(*n),
2167        Literal::Float(f) => Value::Float(*f),
2168        Literal::Bool(b) => Value::Bool(*b),
2169    }
2170}
2171
2172fn eval_binop(op: BinOp, l: &Value, r: &Value) -> Value {
2173    match op {
2174        BinOp::Eq => Value::Bool(value_eq(l, r)),
2175        BinOp::Ne => Value::Bool(!value_eq(l, r)),
2176        BinOp::Lt => value_cmp(l, r, |a, b| a < b, |a, b| a < b, |a, b| a < b),
2177        BinOp::Le => value_cmp(l, r, |a, b| a <= b, |a, b| a <= b, |a, b| a <= b),
2178        BinOp::Gt => value_cmp(l, r, |a, b| a > b, |a, b| a > b, |a, b| a > b),
2179        BinOp::Ge => value_cmp(l, r, |a, b| a >= b, |a, b| a >= b, |a, b| a >= b),
2180        BinOp::And => match (l, r) {
2181            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a && *b),
2182            _ => type_mismatch("bool && bool", l, r),
2183        },
2184        BinOp::Or => match (l, r) {
2185            (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a || *b),
2186            _ => type_mismatch("bool || bool", l, r),
2187        },
2188        BinOp::Add => match (l, r) {
2189            (Value::Int(a), Value::Int(b)) => Value::Int(a + b),
2190            (Value::Float(a), Value::Float(b)) => Value::Float(a + b),
2191            (Value::Str(a), Value::Str(b)) => Value::Str(format!("{a}{b}")),
2192            (Value::Str(a), Value::Path(b)) => Value::Str(format!("{a}{}", b.display())),
2193            (Value::Path(a), Value::Str(b)) => Value::Str(format!("{}{b}", a.display())),
2194            _ => type_mismatch(
2195                "int+int | float+float | string+string | string+path | path+string",
2196                l,
2197                r,
2198            ),
2199        },
2200        BinOp::Sub => match (l, r) {
2201            (Value::Int(a), Value::Int(b)) => Value::Int(a - b),
2202            (Value::Float(a), Value::Float(b)) => Value::Float(a - b),
2203            _ => type_mismatch("int-int | float-float", l, r),
2204        },
2205        BinOp::Mul => match (l, r) {
2206            (Value::Int(a), Value::Int(b)) => Value::Int(a * b),
2207            (Value::Float(a), Value::Float(b)) => Value::Float(a * b),
2208            _ => type_mismatch("int*int | float*float", l, r),
2209        },
2210        BinOp::Div => match (l, r) {
2211            (Value::Int(_), Value::Int(0)) => {
2212                Value::Err(RuntimeError::ToolFailed("integer div by zero".into()))
2213            }
2214            (Value::Int(a), Value::Int(b)) => Value::Int(a / b),
2215            (Value::Float(a), Value::Float(b)) => Value::Float(a / b),
2216            _ => type_mismatch("int/int | float/float", l, r),
2217        },
2218        BinOp::Mod => match (l, r) {
2219            (Value::Int(_), Value::Int(0)) => {
2220                Value::Err(RuntimeError::ToolFailed("integer mod by zero".into()))
2221            }
2222            (Value::Int(a), Value::Int(b)) => Value::Int(a % b),
2223            (Value::Float(a), Value::Float(b)) => Value::Float(a % b),
2224            _ => type_mismatch("int%int | float%float", l, r),
2225        },
2226    }
2227}
2228
2229fn eval_unop(op: UnOp, v: &Value) -> Value {
2230    match op {
2231        UnOp::Not => match v {
2232            Value::Bool(b) => Value::Bool(!b),
2233            other => Value::Err(RuntimeError::TypeMismatch {
2234                expected: "bool".into(),
2235                actual: other.kind_name().into(),
2236            }),
2237        },
2238        UnOp::Neg => match v {
2239            Value::Int(n) => Value::Int(-n),
2240            Value::Float(n) => Value::Float(-n),
2241            other => Value::Err(RuntimeError::TypeMismatch {
2242                expected: "int or float".into(),
2243                actual: other.kind_name().into(),
2244            }),
2245        },
2246    }
2247}
2248
2249fn value_eq(l: &Value, r: &Value) -> bool {
2250    match (l, r) {
2251        (Value::Unit, Value::Unit) => true,
2252        (Value::Bool(a), Value::Bool(b)) => a == b,
2253        (Value::Int(a), Value::Int(b)) => a == b,
2254        (Value::Float(a), Value::Float(b)) => a == b,
2255        (Value::Str(a), Value::Str(b)) => a == b,
2256        (Value::Path(a), Value::Path(b)) => a == b,
2257        _ => false,
2258    }
2259}
2260
2261fn value_cmp(
2262    l: &Value,
2263    r: &Value,
2264    int_cmp: fn(i64, i64) -> bool,
2265    float_cmp: fn(f64, f64) -> bool,
2266    str_cmp: fn(&str, &str) -> bool,
2267) -> Value {
2268    match (l, r) {
2269        (Value::Int(a), Value::Int(b)) => Value::Bool(int_cmp(*a, *b)),
2270        (Value::Float(a), Value::Float(b)) => Value::Bool(float_cmp(*a, *b)),
2271        (Value::Str(a), Value::Str(b)) => Value::Bool(str_cmp(a, b)),
2272        _ => type_mismatch("comparable pair", l, r),
2273    }
2274}
2275
2276fn type_mismatch(expected: &str, l: &Value, r: &Value) -> Value {
2277    Value::Err(RuntimeError::TypeMismatch {
2278        expected: expected.into(),
2279        actual: format!("{} vs {}", l.kind_name(), r.kind_name()),
2280    })
2281}
2282
2283fn input_with_cache_for_window(usage: &crate::provider::TokenUsage) -> u64 {
2284    usage.input + usage.cached_input
2285}
2286
2287#[cfg(test)]
2288mod tests {
2289    use super::*;
2290    use atman_dsl::parse::parse_file;
2291
2292    #[test]
2293    fn parse_context_mode_handles_variants() {
2294        assert!(matches!(
2295            parse_context_mode("session"),
2296            ContextMode::Session
2297        ));
2298        assert!(matches!(parse_context_mode("none"), ContextMode::None));
2299        assert!(matches!(parse_context_mode(""), ContextMode::None));
2300        assert!(matches!(
2301            parse_context_mode(" session "),
2302            ContextMode::Session
2303        ));
2304        match parse_context_mode("session_recent(5)") {
2305            ContextMode::SessionRecent(n) => assert_eq!(n, 5),
2306            other => panic!("expected SessionRecent(5), got {other:?}"),
2307        }
2308        match parse_context_mode("session_recent") {
2309            ContextMode::SessionRecent(n) => assert_eq!(n, 10),
2310            other => panic!("expected SessionRecent(10), got {other:?}"),
2311        }
2312        assert!(matches!(parse_context_mode("garbage"), ContextMode::None));
2313    }
2314
2315    #[test]
2316    fn input_with_cache_for_window_does_not_double_count_cache_write() {
2317        let usage = crate::provider::TokenUsage {
2318            input: 50_000,
2319            cached_input: 0,
2320            cache_write: 50_000,
2321            ..Default::default()
2322        };
2323
2324        assert_eq!(input_with_cache_for_window(&usage), 50_000);
2325    }
2326
2327    async fn eval_snippet(expr_src: &str) -> Value {
2328        let src = format!("flow t() {{\n    return {expr_src}\n}}\n");
2329        let file = parse_file(&src).expect("parse test snippet");
2330        let tools = ToolRegistry::new();
2331        let tool_ctx = ToolCtx::new();
2332        let providers = crate::provider::ProviderRegistry::new();
2333        let flows = std::collections::HashMap::new();
2334        let ctx = EvalCtx {
2335            tools: &tools,
2336            tool_ctx: &tool_ctx,
2337            providers: &providers,
2338            flows: &flows,
2339            contract: None,
2340            events: None,
2341            turn_id: None,
2342            flow_run_id: None,
2343            session_runtime: None,
2344            flow_cancel: tokio_util::sync::CancellationToken::new(),
2345            safety: None,
2346            current_node_id: None,
2347            source_dir: None,
2348        };
2349        let stmt = &file.flows[0].body[0];
2350        if let atman_dsl::ast::Stmt::Return { value } = stmt {
2351            eval_expr(value, &Env::new(), &ctx).await
2352        } else {
2353            panic!("expected return statement");
2354        }
2355    }
2356
2357    #[tokio::test]
2358    async fn literals_evaluate() {
2359        assert!(matches!(eval_snippet("42").await, Value::Int(42)));
2360        assert!(matches!(eval_snippet("true").await, Value::Bool(true)));
2361        assert!(matches!(
2362            eval_snippet(r#""hello""#).await,
2363            Value::Str(s) if s == "hello"
2364        ));
2365    }
2366
2367    #[tokio::test]
2368    async fn undefined_ident_yields_err_value() {
2369        assert!(matches!(
2370            eval_snippet("missing").await,
2371            Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2372        ));
2373    }
2374
2375    #[tokio::test]
2376    async fn binary_arithmetic_and_comparison() {
2377        assert!(matches!(eval_snippet("1 == 1").await, Value::Bool(true)));
2378        assert!(matches!(eval_snippet("2 < 3").await, Value::Bool(true)));
2379        assert!(matches!(
2380            eval_snippet(r#""a" + "b""#).await,
2381            Value::Str(s) if s == "ab"
2382        ));
2383    }
2384
2385    #[tokio::test]
2386    async fn type_mismatch_bubbles_up() {
2387        assert!(matches!(
2388            eval_snippet(r#"1 + "x""#).await,
2389            Value::Err(RuntimeError::TypeMismatch { .. })
2390        ));
2391    }
2392
2393    #[tokio::test]
2394    async fn err_short_circuits_binary() {
2395        assert!(matches!(
2396            eval_snippet("missing == 1").await,
2397            Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2398        ));
2399    }
2400
2401    #[tokio::test]
2402    async fn list_evaluates_all_items() {
2403        let v = eval_snippet("[1, 2, 3]").await;
2404        if let Value::List(items) = v {
2405            assert_eq!(items.len(), 3);
2406            assert!(matches!(items[2], Value::Int(3)));
2407        } else {
2408            panic!("expected list");
2409        }
2410    }
2411
2412    #[tokio::test]
2413    async fn struct_literal_evaluates_fields_in_order() {
2414        let v = eval_snippet(r#"{ severity: "critical", count: 3 }"#).await;
2415        if let Value::Struct(fields) = v {
2416            assert_eq!(fields[0].0, "severity");
2417            assert_eq!(fields[1].0, "count");
2418        } else {
2419            panic!("expected struct");
2420        }
2421    }
2422
2423    #[tokio::test]
2424    async fn undefined_tool_returns_undefined_tool_err() {
2425        let src = r#"flow t() { return fs.readnope("/tmp") }"#;
2426        let file = parse_file(src).unwrap();
2427        let tools = ToolRegistry::new();
2428        let tool_ctx = ToolCtx::new();
2429        let providers = crate::provider::ProviderRegistry::new();
2430        let flows = std::collections::HashMap::new();
2431        let ctx = EvalCtx {
2432            tools: &tools,
2433            tool_ctx: &tool_ctx,
2434            providers: &providers,
2435            flows: &flows,
2436            contract: None,
2437            events: None,
2438            turn_id: None,
2439            flow_run_id: None,
2440            session_runtime: None,
2441            flow_cancel: tokio_util::sync::CancellationToken::new(),
2442            safety: None,
2443            current_node_id: None,
2444            source_dir: None,
2445        };
2446        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2447            let v = eval_expr(value, &Env::new(), &ctx).await;
2448            assert!(matches!(
2449                v,
2450                Value::Err(RuntimeError::UndefinedTool(name)) if name == "fs.readnope"
2451            ));
2452        }
2453    }
2454
2455    #[tokio::test]
2456    async fn fanout_all_gathers_results_in_order() {
2457        use crate::tools::fs::FsRead;
2458        use std::sync::Arc;
2459        use tempfile::TempDir;
2460
2461        let dir = TempDir::new().unwrap();
2462        let pa = dir.path().join("a.txt");
2463        let pb = dir.path().join("b.txt");
2464        tokio::fs::write(&pa, b"AAA").await.unwrap();
2465        tokio::fs::write(&pb, b"BBB").await.unwrap();
2466
2467        let tools = ToolRegistry::new();
2468        tools.register(Arc::new(FsRead));
2469        let tool_ctx = ToolCtx::new();
2470        let providers = crate::provider::ProviderRegistry::new();
2471        let flows = std::collections::HashMap::new();
2472        let ctx = EvalCtx {
2473            tools: &tools,
2474            tool_ctx: &tool_ctx,
2475            providers: &providers,
2476            flows: &flows,
2477            contract: None,
2478            events: None,
2479            turn_id: None,
2480            flow_run_id: None,
2481            session_runtime: None,
2482            flow_cancel: tokio_util::sync::CancellationToken::new(),
2483            safety: None,
2484            current_node_id: None,
2485            source_dir: None,
2486        };
2487
2488        let mut env = Env::new();
2489        env.bind("a", Value::Path(pa));
2490        env.bind("b", Value::Path(pb));
2491
2492        let src = r#"flow t() { return fanout [ fs.read(a), fs.read(b) ] collect: all }"#;
2493        let file = parse_file(src).unwrap();
2494        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2495            let v = eval_expr(value, &env, &ctx).await;
2496            if let Value::List(items) = v {
2497                assert_eq!(items.len(), 2);
2498                assert!(matches!(&items[0], Value::Str(s) if s == "AAA"));
2499                assert!(matches!(&items[1], Value::Str(s) if s == "BBB"));
2500            } else {
2501                panic!("expected list");
2502            }
2503        }
2504    }
2505
2506    #[tokio::test]
2507    async fn fanout_all_short_circuits_on_err() {
2508        let src = r#"flow t() { return fanout [ 1, missing, 3 ] collect: all }"#;
2509        let file = parse_file(src).unwrap();
2510        let tools = ToolRegistry::new();
2511        let tool_ctx = ToolCtx::new();
2512        let providers = crate::provider::ProviderRegistry::new();
2513        let flows = std::collections::HashMap::new();
2514        let ctx = EvalCtx {
2515            tools: &tools,
2516            tool_ctx: &tool_ctx,
2517            providers: &providers,
2518            flows: &flows,
2519            contract: None,
2520            events: None,
2521            turn_id: None,
2522            flow_run_id: None,
2523            session_runtime: None,
2524            flow_cancel: tokio_util::sync::CancellationToken::new(),
2525            safety: None,
2526            current_node_id: None,
2527            source_dir: None,
2528        };
2529        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2530            let v = eval_expr(value, &Env::new(), &ctx).await;
2531            assert!(matches!(
2532                v,
2533                Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2534            ));
2535        }
2536    }
2537
2538    #[tokio::test]
2539    async fn llm_node_dispatches_to_mock_provider() {
2540        use crate::providers::mock::MockProvider;
2541        use std::sync::Arc;
2542
2543        let mut providers = crate::provider::ProviderRegistry::new();
2544        providers.register(Arc::new(MockProvider::new("mock").with_model(
2545            "claude-opus-4.7",
2546            Value::Struct(vec![("severity".into(), Value::Str("info".into()))]),
2547        )));
2548        let tools = ToolRegistry::new();
2549        let tool_ctx = ToolCtx::new();
2550        let flows = std::collections::HashMap::new();
2551        let ctx = EvalCtx {
2552            tools: &tools,
2553            tool_ctx: &tool_ctx,
2554            providers: &providers,
2555            flows: &flows,
2556            contract: None,
2557            events: None,
2558            turn_id: None,
2559            flow_run_id: None,
2560            session_runtime: None,
2561            flow_cancel: tokio_util::sync::CancellationToken::new(),
2562            safety: None,
2563            current_node_id: None,
2564            source_dir: None,
2565        };
2566
2567        let src = r#"flow t() {
2568    return llm {
2569        model: "claude-opus-4.7"
2570        prompt: "review please"
2571        input: 1
2572    }
2573}
2574"#;
2575        let file = parse_file(src).unwrap();
2576        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2577            let v = eval_expr(value, &Env::new(), &ctx).await;
2578            if let Value::Struct(fields) = v {
2579                assert_eq!(fields[0].0, "severity");
2580                assert!(matches!(&fields[0].1, Value::Str(s) if s == "info"));
2581            } else {
2582                panic!("expected struct");
2583            }
2584        }
2585    }
2586
2587    #[tokio::test]
2588    async fn llm_missing_model_reports_missing_arg() {
2589        let providers = crate::provider::ProviderRegistry::new();
2590        let tools = ToolRegistry::new();
2591        let tool_ctx = ToolCtx::new();
2592        let flows = std::collections::HashMap::new();
2593        let ctx = EvalCtx {
2594            tools: &tools,
2595            tool_ctx: &tool_ctx,
2596            providers: &providers,
2597            flows: &flows,
2598            contract: None,
2599            events: None,
2600            turn_id: None,
2601            flow_run_id: None,
2602            session_runtime: None,
2603            flow_cancel: tokio_util::sync::CancellationToken::new(),
2604            safety: None,
2605            current_node_id: None,
2606            source_dir: None,
2607        };
2608        let src = r#"flow t() { return llm { prompt: "hi" } }"#;
2609        let file = parse_file(src).unwrap();
2610        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2611            let v = eval_expr(value, &Env::new(), &ctx).await;
2612            assert!(matches!(
2613                v,
2614                Value::Err(RuntimeError::MissingArg(name)) if name == "llm.model"
2615            ));
2616        }
2617    }
2618
2619    #[tokio::test]
2620    async fn user_confirm_stub_returns_true() {
2621        let providers = crate::provider::ProviderRegistry::new();
2622        let tools = ToolRegistry::new();
2623        let tool_ctx = ToolCtx::new();
2624        let flows = std::collections::HashMap::new();
2625        let ctx = EvalCtx {
2626            tools: &tools,
2627            tool_ctx: &tool_ctx,
2628            providers: &providers,
2629            flows: &flows,
2630            contract: None,
2631            events: None,
2632            turn_id: None,
2633            flow_run_id: None,
2634            session_runtime: None,
2635            flow_cancel: tokio_util::sync::CancellationToken::new(),
2636            safety: None,
2637            current_node_id: None,
2638            source_dir: None,
2639        };
2640        let src = r#"flow t() { return user_confirm("proceed?") }"#;
2641        let file = parse_file(src).unwrap();
2642        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2643            assert!(matches!(
2644                eval_expr(value, &Env::new(), &ctx).await,
2645                Value::Bool(true)
2646            ));
2647        }
2648    }
2649
2650    #[tokio::test]
2651    async fn subflow_calls_target_flow_with_positional_args() {
2652        let src = r#"flow child(n: Int) -> Int {
2653    return n + 100
2654}
2655
2656flow parent(x: Int) -> Int {
2657    y = subflow(child, x)
2658    return y + 1
2659}
2660"#;
2661        let file = parse_file(src).unwrap();
2662        let flows_map: std::collections::HashMap<_, _> = file
2663            .flows
2664            .iter()
2665            .map(|f| (f.name.name.clone(), f.clone()))
2666            .collect();
2667        let parent = &file.flows[1];
2668        let tools = ToolRegistry::new();
2669        let tool_ctx = ToolCtx::new();
2670        let providers = crate::provider::ProviderRegistry::new();
2671        let out = crate::exec::exec_flow_with_siblings(
2672            parent,
2673            vec![("x".into(), Value::Int(5))],
2674            &tools,
2675            &tool_ctx,
2676            &providers,
2677            &flows_map,
2678            None,
2679            None,
2680            None,
2681            None,
2682            tokio_util::sync::CancellationToken::new(),
2683            None,
2684            None,
2685        )
2686        .await
2687        .unwrap();
2688        assert!(matches!(out, Value::Int(106)));
2689    }
2690
2691    #[tokio::test]
2692    async fn subflow_missing_target_reports_undefined_tool() {
2693        let src = r#"flow parent() -> Int {
2694    return subflow(nope, 1)
2695}
2696"#;
2697        let file = parse_file(src).unwrap();
2698        let flows: std::collections::HashMap<_, _> = file
2699            .flows
2700            .iter()
2701            .map(|f| (f.name.name.clone(), f.clone()))
2702            .collect();
2703        let tools = ToolRegistry::new();
2704        let tool_ctx = ToolCtx::new();
2705        let providers = crate::provider::ProviderRegistry::new();
2706        let err = crate::exec::exec_flow_with_siblings(
2707            &file.flows[0],
2708            vec![],
2709            &tools,
2710            &tool_ctx,
2711            &providers,
2712            &flows,
2713            None,
2714            None,
2715            None,
2716            None,
2717            tokio_util::sync::CancellationToken::new(),
2718            None,
2719            None,
2720        )
2721        .await
2722        .unwrap_err();
2723        assert!(matches!(err, RuntimeError::UndefinedTool(name) if name.contains("nope")));
2724    }
2725
2726    #[tokio::test]
2727    async fn tool_call_dispatches_via_registry() {
2728        use crate::tools::fs::FsRead;
2729        use std::sync::Arc;
2730        use tempfile::TempDir;
2731
2732        let dir = TempDir::new().unwrap();
2733        let path = dir.path().join("hi.txt");
2734        tokio::fs::write(&path, b"hello runtime").await.unwrap();
2735
2736        let tools = ToolRegistry::new();
2737        tools.register(Arc::new(FsRead));
2738        let tool_ctx = ToolCtx::new();
2739        let providers = crate::provider::ProviderRegistry::new();
2740        let flows = std::collections::HashMap::new();
2741        let ctx = EvalCtx {
2742            tools: &tools,
2743            tool_ctx: &tool_ctx,
2744            providers: &providers,
2745            flows: &flows,
2746            contract: None,
2747            events: None,
2748            turn_id: None,
2749            flow_run_id: None,
2750            session_runtime: None,
2751            flow_cancel: tokio_util::sync::CancellationToken::new(),
2752            safety: None,
2753            current_node_id: None,
2754            source_dir: None,
2755        };
2756
2757        let mut env = Env::new();
2758        env.bind("p", Value::Path(path));
2759
2760        let src = r#"flow t() { return fs.read(p) }"#;
2761        let file = parse_file(src).unwrap();
2762        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2763            let v = eval_expr(value, &env, &ctx).await;
2764            assert!(matches!(v, Value::Str(s) if s == "hello runtime"));
2765        }
2766    }
2767
2768    #[tokio::test]
2769    async fn fanout_emits_branch_start_end_events_with_parent_linkage() {
2770        let src = r#"flow t() { return fanout [1, 2, 3] collect: all }"#;
2771        let file = parse_file(src).unwrap();
2772        let tools = ToolRegistry::new();
2773        let tool_ctx = ToolCtx::new();
2774        let providers = crate::provider::ProviderRegistry::new();
2775        let flows = std::collections::HashMap::new();
2776        let events = crate::event::EventSink::new();
2777        let ctx = EvalCtx {
2778            tools: &tools,
2779            tool_ctx: &tool_ctx,
2780            providers: &providers,
2781            flows: &flows,
2782            contract: None,
2783            events: Some(&events),
2784            turn_id: None,
2785            flow_run_id: Some(crate::event::FlowRunId::now()),
2786            session_runtime: None,
2787            flow_cancel: tokio_util::sync::CancellationToken::new(),
2788            safety: None,
2789            current_node_id: Some("stmt_1".into()),
2790            source_dir: None,
2791        };
2792        if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2793            let _ = eval_expr(value, &Env::new(), &ctx).await;
2794        }
2795        let snap = events.snapshot();
2796        let starts: Vec<_> = snap
2797            .iter()
2798            .filter_map(|e| match e {
2799                crate::event::Event::FlowNodeStart {
2800                    node_id,
2801                    parent_node_id,
2802                    ..
2803                } => Some((node_id.clone(), parent_node_id.clone())),
2804                _ => None,
2805            })
2806            .collect();
2807        assert_eq!(starts.len(), 3);
2808        assert_eq!(starts[0].0, "stmt_1.branch[0]");
2809        assert_eq!(starts[1].0, "stmt_1.branch[1]");
2810        assert_eq!(starts[2].0, "stmt_1.branch[2]");
2811        assert!(starts.iter().all(|(_, p)| p.as_deref() == Some("stmt_1")));
2812        let ends = snap
2813            .iter()
2814            .filter(|e| matches!(e, crate::event::Event::FlowNodeEnd { .. }))
2815            .count();
2816        assert_eq!(ends, 3);
2817    }
2818
2819    #[test]
2820    fn resolve_tool_specs_wildcard_unknown_prefix_skips_silently() {
2821        let tools = crate::tool::ToolRegistry::new();
2822        let expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2823            "nonexistent.*".into(),
2824        ))]);
2825        let specs = resolve_tool_specs(&expr, &tools).unwrap();
2826        assert!(
2827            specs.is_empty(),
2828            "wildcard with no matches should return empty list"
2829        );
2830    }
2831
2832    #[test]
2833    fn resolve_tool_specs_wildcard_matches_prefixed_tools() {
2834        let tools = crate::tool::ToolRegistry::new();
2835        struct FakeMcpTool {
2836            name: String,
2837            desc: String,
2838            schema: serde_json::Value,
2839        }
2840        impl crate::tool::Tool for FakeMcpTool {
2841            fn name(&self) -> &str {
2842                &self.name
2843            }
2844            fn description(&self) -> Option<&str> {
2845                Some(&self.desc)
2846            }
2847            fn input_schema(&self) -> serde_json::Value {
2848                self.schema.clone()
2849            }
2850            fn tier(&self) -> crate::tool::Tier {
2851                crate::tool::Tier::Zero
2852            }
2853            fn approval_level(
2854                &self,
2855                _args: &crate::tool::ToolArgs,
2856                _ctx: &crate::tool::ToolCtx,
2857            ) -> crate::tool::ApprovalLevel {
2858                crate::tool::ApprovalLevel::Auto
2859            }
2860            fn call<'a>(
2861                &'a self,
2862                _args: crate::tool::ToolArgs,
2863                _ctx: &'a crate::tool::ToolCtx,
2864            ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2865                Box::pin(async { Ok(crate::value::Value::Unit) })
2866            }
2867        }
2868        tools.register(std::sync::Arc::new(FakeMcpTool {
2869            name: "mcp.lark.send_mail".into(),
2870            desc: "send mail".into(),
2871            schema: serde_json::json!({"type":"object","properties":{}}),
2872        }));
2873        tools.register(std::sync::Arc::new(FakeMcpTool {
2874            name: "mcp.lark.read_inbox".into(),
2875            desc: "read inbox".into(),
2876            schema: serde_json::json!({"type":"object","properties":{}}),
2877        }));
2878        tools.register(std::sync::Arc::new(FakeMcpTool {
2879            name: "mcp.siyuan.search".into(),
2880            desc: "search notes".into(),
2881            schema: serde_json::json!({"type":"object","properties":{}}),
2882        }));
2883        // Non-MCP tool should NOT be matched.
2884        tools.register(std::sync::Arc::new(FakeMcpTool {
2885            name: "fs.read".into(),
2886            desc: "read file".into(),
2887            schema: serde_json::json!({"type":"object","properties":{}}),
2888        }));
2889
2890        // "mcp.*" matches all 3 mcp.* tools, but not fs.read
2891        let expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2892            "mcp.*".into(),
2893        ))]);
2894        let specs = resolve_tool_specs(&expr, &tools).unwrap();
2895        assert_eq!(specs.len(), 3, "mcp.* should match 3 MCP tools");
2896        let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
2897        assert!(names.contains(&"mcp.lark.send_mail".into()));
2898        assert!(names.contains(&"mcp.lark.read_inbox".into()));
2899        assert!(names.contains(&"mcp.siyuan.search".into()));
2900
2901        // "mcp.lark.*" matches only the 2 lark tools
2902        let expr2 = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2903            "mcp.lark.*".into(),
2904        ))]);
2905        let specs2 = resolve_tool_specs(&expr2, &tools).unwrap();
2906        assert_eq!(specs2.len(), 2, "mcp.lark.* should match 2 lark tools");
2907    }
2908
2909    #[test]
2910    fn resolve_tool_specs_mixed_concrete_and_wildcard() {
2911        let tools = crate::tool::ToolRegistry::new();
2912        struct FakeMcpTool {
2913            name: String,
2914            desc: String,
2915            schema: serde_json::Value,
2916        }
2917        impl crate::tool::Tool for FakeMcpTool {
2918            fn name(&self) -> &str {
2919                &self.name
2920            }
2921            fn description(&self) -> Option<&str> {
2922                Some(&self.desc)
2923            }
2924            fn input_schema(&self) -> serde_json::Value {
2925                self.schema.clone()
2926            }
2927            fn tier(&self) -> crate::tool::Tier {
2928                crate::tool::Tier::Zero
2929            }
2930            fn approval_level(
2931                &self,
2932                _args: &crate::tool::ToolArgs,
2933                _ctx: &crate::tool::ToolCtx,
2934            ) -> crate::tool::ApprovalLevel {
2935                crate::tool::ApprovalLevel::Auto
2936            }
2937            fn call<'a>(
2938                &'a self,
2939                _args: crate::tool::ToolArgs,
2940                _ctx: &'a crate::tool::ToolCtx,
2941            ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2942                Box::pin(async { Ok(crate::value::Value::Unit) })
2943            }
2944        }
2945        tools.register(std::sync::Arc::new(FakeMcpTool {
2946            name: "mcp.lark.send_mail".into(),
2947            desc: "send mail".into(),
2948            schema: serde_json::json!({"type":"object","properties":{}}),
2949        }));
2950        tools.register(std::sync::Arc::new(FakeMcpTool {
2951            name: "bash.exec".into(),
2952            desc: "exec".into(),
2953            schema: serde_json::json!({"type":"object","properties":{}}),
2954        }));
2955
2956        // Build tools list from DSL parse so we get valid spans.
2957        let src = r#"flow t() -> string {
2958    reply = llm {
2959        tools: [bash.exec, "mcp.*"]
2960    }
2961    return "ok"
2962}"#;
2963        let file = atman_dsl::parse::parse_file(src).unwrap();
2964        // Extract the tools list from the llm node
2965        let body = &file.flows[0].body;
2966        let tools_expr = match &body[0] {
2967            atman_dsl::ast::Stmt::Bind { value, .. } => match value {
2968                Expr::Node(atman_dsl::ast::Node::Llm { kwargs }) => kwargs
2969                    .iter()
2970                    .find(|(k, _)| k.name == "tools")
2971                    .map(|(_, v)| v.clone())
2972                    .unwrap(),
2973                _ => panic!("expected llm node"),
2974            },
2975            _ => panic!("expected bind stmt"),
2976        };
2977        let specs = resolve_tool_specs(&tools_expr, &tools).unwrap();
2978        assert_eq!(specs.len(), 2, "bash.exec + mcp.lark.send_mail = 2");
2979        let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
2980        assert!(names.contains(&"bash.exec".into()));
2981        assert!(names.contains(&"mcp.lark.send_mail".into()));
2982    }
2983}
2984
2985#[cfg(test)]
2986mod sanitize_tests {
2987    use super::*;
2988    use crate::message::{Message, MessagePart, MessageRole};
2989
2990    #[test]
2991    fn sanitize_fills_missing_tool_results() {
2992        let turn = crate::event::TurnId::now();
2993        let msgs = vec![
2994            Message {
2995                role: MessageRole::Assistant,
2996                parts: vec![MessagePart::ToolUse {
2997                    id: "call_orphan".into(),
2998                    name: "bash.exec".into(),
2999                    input: serde_json::json!({}),
3000                }],
3001                turn_id: turn.clone(),
3002            },
3003            Message {
3004                role: MessageRole::User,
3005                parts: vec![MessagePart::Text {
3006                    text: "user interrupt".into(),
3007                }],
3008                turn_id: turn.clone(),
3009            },
3010        ];
3011        let out = sanitize_tool_pairs(msgs);
3012        let has_filler = out.iter().any(|m| {
3013            m.parts.iter().any(|p| {
3014                matches!(p, MessagePart::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "call_orphan")
3015            })
3016        });
3017        assert!(
3018            has_filler,
3019            "should append error tool_result for orphan tool_use"
3020        );
3021    }
3022
3023    #[test]
3024    fn sanitize_noop_when_pairs_complete() {
3025        let turn = crate::event::TurnId::now();
3026        let msgs = vec![
3027            Message {
3028                role: MessageRole::Assistant,
3029                parts: vec![MessagePart::ToolUse {
3030                    id: "call_ok".into(),
3031                    name: "bash.exec".into(),
3032                    input: serde_json::json!({}),
3033                }],
3034                turn_id: turn.clone(),
3035            },
3036            Message {
3037                role: MessageRole::Tool,
3038                parts: vec![MessagePart::ToolResult {
3039                    tool_use_id: "call_ok".into(),
3040                    content: "done".into(),
3041                    is_error: false,
3042                }],
3043                turn_id: turn.clone(),
3044            },
3045        ];
3046        let out = sanitize_tool_pairs(msgs);
3047        assert_eq!(
3048            out.len(),
3049            2,
3050            "no filler should be added when pairs complete"
3051        );
3052    }
3053
3054    // --- stall timeout tests ---
3055    use crate::providers::mock::MockProvider;
3056
3057    fn stall_req(stall_secs: u64) -> crate::provider::LlmRequest {
3058        crate::provider::LlmRequest {
3059            model: "mock".into(),
3060            messages: vec![crate::provider::user_text_message("test")],
3061            system: None,
3062            input: crate::value::Value::Unit,
3063            schema: None,
3064            cache_prompt: false,
3065            tools: Vec::new(),
3066            thinking_enabled: false,
3067            stall_timeout_secs: stall_secs,
3068        }
3069    }
3070
3071    #[tokio::test]
3072    async fn stall_timeout_fires_when_no_chunks_arrive() {
3073        // chunk_delay = 3s, stall_timeout = 1s → stall fires before 2nd chunk
3074        let provider = MockProvider::new("mock")
3075            .with_model("mock", Value::Str("hello world test".into()))
3076            .with_chunk_delay(std::time::Duration::from_secs(3));
3077
3078        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3079        let result = call_and_maybe_stream(&provider, stall_req(1), None, Some(stream_tx)).await;
3080        match result {
3081            Err(RuntimeError::ToolFailed(msg)) => {
3082                assert!(
3083                    msg.contains("llm stall timeout after 1s"),
3084                    "expected stall message, got: {msg}"
3085                );
3086            }
3087            other => panic!("expected ToolFailed stall timeout, got: {other:?}"),
3088        }
3089    }
3090
3091    #[tokio::test]
3092    async fn stall_timeout_does_not_fire_when_chunks_keep_coming() {
3093        // chunk_delay = 100ms, stall_timeout = 2s → all chunks within 300ms, no stall
3094        let provider = MockProvider::new("mock")
3095            .with_model("mock", Value::Str("hello world test".into()))
3096            .with_chunk_delay(std::time::Duration::from_millis(100));
3097
3098        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3099        let result = call_and_maybe_stream(&provider, stall_req(2), None, Some(stream_tx)).await;
3100        match result {
3101            Ok(am) => {
3102                assert!(am.text_concat().contains("hello"));
3103            }
3104            other => panic!("expected Ok, got: {other:?}"),
3105        }
3106    }
3107
3108    #[tokio::test]
3109    async fn stall_timeout_zero_disables_detection() {
3110        // chunk_delay = 3s, stall_timeout = 0 → disabled, all chunks arrive
3111        let provider = MockProvider::new("mock")
3112            .with_model("mock", Value::Str("hello world test".into()))
3113            .with_chunk_delay(std::time::Duration::from_secs(3));
3114
3115        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3116        let result = call_and_maybe_stream(&provider, stall_req(0), None, Some(stream_tx)).await;
3117        match result {
3118            Ok(am) => {
3119                assert!(am.text_concat().contains("hello"));
3120            }
3121            other => panic!("expected Ok (stall disabled), got: {other:?}"),
3122        }
3123    }
3124
3125    #[tokio::test]
3126    async fn stall_timeout_resets_on_each_chunk() {
3127        // 3 chunks at 800ms each. stall=1s. First chunk at t=0, second at t=800ms (<1s),
3128        // third at t=1.6s (>1s from start, but only 800ms from last chunk). Should pass.
3129        let provider = MockProvider::new("mock")
3130            .with_model("mock", Value::Str("hello world test".into()))
3131            .with_chunk_delay(std::time::Duration::from_millis(800));
3132
3133        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3134        let result = call_and_maybe_stream(&provider, stall_req(1), None, Some(stream_tx)).await;
3135        match result {
3136            Ok(am) => {
3137                assert!(am.text_concat().contains("hello"));
3138            }
3139            other => panic!("expected Ok (timer reset each chunk), got: {other:?}"),
3140        }
3141    }
3142
3143    #[tokio::test]
3144    async fn stall_timeout_fires_between_first_and_second_chunk() {
3145        // first chunk at t≈0, then 2s gap, stall=1s fires at t=1s
3146        let provider = MockProvider::new("mock")
3147            .with_model("mock", Value::Str("hello world test".into()))
3148            .with_chunk_delay(std::time::Duration::from_secs(2));
3149
3150        let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3151        let result = call_and_maybe_stream(&provider, stall_req(1), None, Some(stream_tx)).await;
3152        assert!(
3153            matches!(&result, Err(RuntimeError::ToolFailed(msg)) if msg.contains("stall timeout")),
3154            "expected stall timeout, got: {result:?}"
3155        );
3156    }
3157}