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