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_output_store(session.output_store())
817 .with_session_id(session.id().to_string());
818 if let Some(idx) = session.project_index() {
819 c = c.with_project_index(idx);
820 }
821 c = c.with_fs_access(session_fs_access_policy(session));
822 c = c.with_forms(session.forms());
823 {
824 let s = session.clone();
825 c.on_memory_recent = Some(std::sync::Arc::new(move |count| {
826 s.set_memory_recent_count(count);
827 }));
828 }
829 {
830 let store = std::sync::Arc::new(crate::history_store::HistoryStoreImpl::new(
831 session.project_index(),
832 Some(session.clone()),
833 Some(session.id().to_string()),
834 Some(
835 session
836 .dir()
837 .parent()
838 .map(|p| p.to_path_buf())
839 .unwrap_or_default(),
840 ),
841 ));
842 c = c.with_history_store(store);
843 }
844 c
845 } else {
846 ctx_with_anchors
847 };
848 let stream_tx = ctx.session_runtime.as_ref().map(|s| s.stream_tx());
849 let tool_call_id = uuid::Uuid::now_v7().to_string();
850 let args_preview = preview_tool_args(&positional, &named);
851 if let (Some(sink), Some(run_id), Some(parent_node)) =
852 (ctx.events, ctx.flow_run_id.clone(), &ctx.current_node_id)
853 {
854 sink.emit(crate::event::Event::ToolNode {
855 run_id: run_id.clone(),
856 parent_node_id: parent_node.clone(),
857 tool_use_id: tool_call_id.clone(),
858 tool_name: name.clone(),
859 args_preview: args_preview.clone(),
860 });
861 if let Some(tx) = &stream_tx {
862 let _ = tx.send(crate::stream::StreamFrame::ToolNode {
863 run_id: run_id.0.to_string(),
864 parent_node_id: parent_node.clone(),
865 tool_use_id: tool_call_id.clone(),
866 tool: name.clone(),
867 args_preview: args_preview.clone(),
868 });
869 }
870 }
871 if let Some(tx) = &stream_tx {
872 let _ = tx.send(crate::stream::StreamFrame::ToolUseStart {
873 tool: name.clone(),
874 args_preview: args_preview.clone(),
875 id: tool_call_id.clone(),
876 });
877 }
878 let call_args = ToolArgs { positional, named };
879 let diff_preview = prepare_diff_preview(&name, &call_args);
880 let level = tool.approval_level(&call_args, &ctx_with_anchors);
881 let gate = crate::approval::request_approval(
882 &ctx_with_anchors,
883 &tool_call_id,
884 &name,
885 &call_args,
886 level,
887 Some(tool.as_ref()),
888 )
889 .await;
890 let outcome = match gate {
891 crate::approval::ApprovalOutcome::Deny { reason } => Err(RuntimeError::ToolFailed(
892 format!("tool `{name}` denied by user: {reason}"),
893 )),
894 crate::approval::ApprovalOutcome::Approve => tool.call(call_args, &ctx_with_anchors).await,
895 };
896 if let Some(tx) = &stream_tx {
897 let (ok, preview) = match &outcome {
898 Ok(v) => (true, preview_tool_value(v)),
899 Err(e) => (false, format!("{e}")),
900 };
901 let _ = tx.send(crate::stream::StreamFrame::ToolUseDone {
902 tool: name.clone(),
903 ok,
904 preview,
905 id: tool_call_id,
906 });
907 }
908 if let Some(session) = ctx.session_runtime.as_ref()
909 && (name == "memory.todo.set" || name == "memory.todo.done")
910 {
911 session.refresh_todos_from_store_async().await;
912 }
913 if let Some(session) = ctx.session_runtime.as_ref()
914 && (name == "plan.write" || name == "plan.tick")
915 {
916 session.refresh_plans_from_store_async().await;
917 }
918 if let (Some(sink), Ok(value)) = (ctx.events, &outcome) {
919 if let Some((title, old_content, new_content, unified_diff)) =
920 complete_diff_preview(diff_preview, &name, value)
921 {
922 sink.emit(crate::event::Event::DiffPreview {
923 turn_id: ctx.turn_id.clone(),
924 flow_run_id: ctx.flow_run_id.clone(),
925 title,
926 old_content,
927 new_content,
928 unified_diff,
929 });
930 }
931 }
932 match outcome {
933 Ok(v) => v,
934 Err(e) => Value::Err(e),
935 }
936}
937
938type DiffPreviewData = (String, Option<String>, Option<String>, Option<String>);
939
940fn prepare_diff_preview(name: &str, args: &ToolArgs) -> Option<DiffPreviewData> {
941 match name {
942 "fs.write" => {
943 let path = tool_arg_path(args, "path", 0)?;
944 let content = tool_arg_string(args, "content", 1)?;
945 let path_str = path.display().to_string();
946 let diff = match std::fs::read_to_string(&path).ok() {
947 Some(old) => crate::tools::fs::unified_diff_preview(&path_str, &old, &content),
948 None => format!("+++ {path_str}\n{content}"),
949 };
950 Some((path_str, None, None, Some(diff)))
951 }
952 "fs.edit" => {
953 let path = tool_arg_path(args, "path", 0)?;
954 let old_string = tool_arg_string(args, "old_string", 1)?;
955 let new_string = tool_arg_string(args, "new_string", 2)?;
956 let replace_all = matches!(args.named("replace_all"), Some(Value::Bool(true)));
957 let old = std::fs::read_to_string(&path).ok()?;
958 let new = if replace_all {
959 old.replace(&old_string, &new_string)
960 } else {
961 old.replacen(&old_string, &new_string, 1)
962 };
963 let path_str = path.display().to_string();
964 let diff = crate::tools::fs::unified_diff_preview(&path_str, &old, &new);
965 Some((path_str, None, None, Some(diff)))
966 }
967 _ => None,
968 }
969}
970
971fn complete_diff_preview(
972 prepared: Option<DiffPreviewData>,
973 name: &str,
974 value: &Value,
975) -> Option<DiffPreviewData> {
976 if prepared.is_some() {
977 return prepared;
978 }
979 match name {
980 "git.diff" => Some((
981 "git diff".into(),
982 None,
983 None,
984 value_struct_string(value, "diff"),
985 )),
986 "git.show" => Some((
987 value_struct_string(value, "sha")
988 .map(|sha| format!("git show {sha}"))
989 .unwrap_or_else(|| "git show".into()),
990 None,
991 None,
992 value_struct_string(value, "diff"),
993 )),
994 "git.log" => Some((
995 "git log HEAD".into(),
996 None,
997 None,
998 value_struct_string(value, "diff"),
999 )),
1000 _ => None,
1001 }
1002}
1003
1004fn tool_arg_string(args: &ToolArgs, name: &str, pos: usize) -> Option<String> {
1005 let value = args.named(name).or_else(|| args.positional.get(pos))?;
1006 match value {
1007 Value::Str(s) => Some(s.clone()),
1008 _ => None,
1009 }
1010}
1011
1012fn tool_arg_path(args: &ToolArgs, name: &str, pos: usize) -> Option<std::path::PathBuf> {
1013 let value = args.named(name).or_else(|| args.positional.get(pos))?;
1014 match value {
1015 Value::Path(p) => Some(p.clone()),
1016 Value::Str(s) => Some(std::path::PathBuf::from(s)),
1017 _ => None,
1018 }
1019}
1020
1021fn value_struct_string(value: &Value, name: &str) -> Option<String> {
1022 let Value::Struct(fields) = value else {
1023 return None;
1024 };
1025 fields.iter().find_map(|(k, v)| match (k.as_str(), v) {
1026 (key, Value::Str(s)) if key == name => Some(s.clone()),
1027 _ => None,
1028 })
1029}
1030
1031#[derive(Default)]
1032pub(super) struct StreamCallCtx<'a> {
1033 session: Option<&'a crate::session::Session>,
1034 stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
1035 flow_run_id: Option<&'a crate::event::FlowRunId>,
1036 agent_entry: Option<&'a std::sync::Arc<crate::tools::agent_ctrl::FlowEntry>>,
1037 event_sink: Option<&'a crate::event::EventSink>,
1038 turn_id: Option<crate::event::TurnId>,
1039}
1040
1041pub(super) async fn call_and_maybe_stream(
1042 provider: &dyn crate::provider::Provider,
1043 req: crate::provider::LlmRequest,
1044 stream_ctx: StreamCallCtx<'_>,
1045 watch_rules: Option<crate::streaming::WatchRules>,
1046) -> Result<crate::provider::AssistantMessage, RuntimeError> {
1047 let mut base = LlmStream::new(provider, req)
1048 .with_event_sink(stream_ctx.event_sink)
1049 .with_turn_id(stream_ctx.turn_id)
1050 .with_flow_run_id(stream_ctx.flow_run_id.cloned());
1051
1052 let result = if let Some(tx) = stream_ctx.stream_tx {
1053 let mut stream = base.with_stream_tx(tx);
1054 if let Some(rules) = watch_rules {
1055 stream = stream.with_watch_rules(rules);
1056 }
1057 if let Some(session) = stream_ctx.session {
1058 stream = stream.with_session(session);
1059 }
1060 if let Some(entry) = stream_ctx.agent_entry {
1061 stream = stream.with_entry(entry);
1062 }
1063 stream.run().await
1064 } else {
1065 base.run().await
1066 };
1067 if let (Some(sess), Err(RuntimeError::AttachmentError { reason })) =
1068 (stream_ctx.session, &result)
1069 {
1070 let count = sess.record_attachment_degrade(reason);
1071 if count > 0 {
1072 let _ = sess.stream_tx().send(crate::stream::StreamFrame::Note(format!(
1073 "attachment degraded ({reason}); {count} image part(s) replaced. re-issue your last message to retry without them."
1074 )));
1075 }
1076 }
1077 result
1078}
1079
1080fn preview_tool_args(positional: &[Value], named: &[(String, Value)]) -> String {
1081 let mut parts: Vec<String> = positional.iter().map(preview_tool_value).collect();
1082 for (k, v) in named {
1083 parts.push(format!("{k}={}", preview_tool_value(v)));
1084 }
1085 truncate(&parts.join(", "), 4000)
1086}
1087
1088fn available_models_system_prompt() -> Option<String> {
1089 let mut aliases = crate::model_registry::all_aliases();
1090 let mut models = crate::model_registry::all_model_entries();
1091 if aliases.is_empty() && models.is_empty() {
1092 return None;
1093 }
1094 aliases.sort_by(|a, b| a.0.cmp(&b.0));
1095 models.sort_by(|a, b| a.0.cmp(&b.0));
1096 let mut lines = vec!["[available models]".to_string()];
1097 if !aliases.is_empty() {
1098 lines.push(format!(
1099 "Aliases: {}",
1100 aliases
1101 .into_iter()
1102 .map(|(alias, model)| format!("{alias} -> {model}"))
1103 .collect::<Vec<_>>()
1104 .join(", ")
1105 ));
1106 }
1107 if !models.is_empty() {
1108 lines.push(format!(
1109 "Models: {}",
1110 models
1111 .into_iter()
1112 .map(|(name, _)| {
1113 let info = crate::model_registry::model_info(&name);
1114 let thinking = if info.thinking_enabled() {
1115 ", thinking"
1116 } else {
1117 ""
1118 };
1119 format!(
1120 "{} ({} context{})",
1121 info.name,
1122 crate::humanize::format_count(info.context_budget),
1123 thinking
1124 )
1125 })
1126 .collect::<Vec<_>>()
1127 .join(", ")
1128 ));
1129 }
1130 lines.push("Use these names or aliases with flow.spawn's model parameter.".into());
1131 lines.push("[/available models]".into());
1132 Some(lines.join("\n"))
1133}
1134
1135fn working_directory_system_prompt(session: &crate::session::Session) -> Option<String> {
1136 let meta = session.meta()?;
1137 let cwd = meta
1138 .start_path
1139 .as_deref()
1140 .or(meta.project_root.as_deref())?;
1141 let mut lines = vec!["[working directory]".to_string()];
1142 lines.push(cwd.display().to_string());
1143 let live_root = crate::session_meta::find_project_root(cwd);
1145 match (&live_root, &meta.project_root) {
1146 (Some(live), Some(stored)) if live == stored => {
1147 if Some(live.as_path()) != meta.start_path.as_deref() {
1148 lines.push(format!("project root: {}", live.display()));
1149 }
1150 }
1151 (Some(live), _) => {
1152 lines.push(format!("project root: {}", live.display()));
1153 }
1154 (None, Some(stored)) => {
1155 lines.push(format!(
1156 "project root (cached): {} (no longer detected)",
1157 stored.display()
1158 ));
1159 }
1160 (None, None) => {
1161 lines.push("(no project root — no .git or .atman found)".into());
1162 }
1163 }
1164 lines.push("[/working directory]".into());
1165 Some(lines.join("\n"))
1166}
1167
1168fn preview_tool_value(v: &Value) -> String {
1169 let raw = match v {
1170 Value::Str(s) => format!("{s:?}"),
1171 Value::Int(n) => n.to_string(),
1172 Value::Bool(b) => b.to_string(),
1173 Value::Float(f) => f.to_string(),
1174 Value::Unit => "()".into(),
1175 Value::List(items) => format!("list[{}]", items.len()),
1176 Value::Struct(f) => {
1177 let stdout = f
1178 .iter()
1179 .find(|(k, _)| k == "stdout")
1180 .and_then(|(_, v)| match v {
1181 Value::Str(s) => Some(s.as_str()),
1182 _ => None,
1183 });
1184 let stderr = f
1185 .iter()
1186 .find(|(k, _)| k == "stderr")
1187 .and_then(|(_, v)| match v {
1188 Value::Str(s) => Some(s.as_str()),
1189 _ => None,
1190 });
1191 let exit = f
1192 .iter()
1193 .find(|(k, _)| k == "exit")
1194 .and_then(|(_, v)| match v {
1195 Value::Int(n) => Some(*n),
1196 _ => None,
1197 });
1198 if let (Some(stdout), Some(exit)) = (stdout, exit) {
1199 let combined = if stdout.is_empty() {
1200 stderr.unwrap_or("").to_string()
1201 } else {
1202 stdout.to_string()
1203 };
1204 let lines: Vec<&str> = combined.lines().collect();
1205 if lines.len() > 10 {
1206 format!(
1207 "exit={exit}\n{}\n… ({} more lines, see `atman logs` for full output)",
1208 lines[..10].join("\n"),
1209 lines.len() - 10
1210 )
1211 } else {
1212 format!("exit={exit}\n{combined}")
1213 }
1214 } else {
1215 format!("struct[{}]", f.len())
1216 }
1217 }
1218 Value::Message(_) => "<message>".into(),
1219 Value::Err(e) => format!("err({e})"),
1220 Value::Path(p) => format!("{p:?}"),
1221 Value::EditProposal(_) => "<edit_proposal>".into(),
1222 Value::Lambda { .. } => "<lambda>".into(),
1223 };
1224 truncate(&raw, 2000)
1225}
1226
1227fn truncate(s: &str, max: usize) -> String {
1228 if s.chars().count() <= max {
1229 return s.to_string();
1230 }
1231 let mut out: String = s.chars().take(max).collect();
1232 out.push('…');
1233 out
1234}
1235
1236async fn eval_node<'a>(node: &'a Node, env: &'a Env, ctx: &'a EvalCtx<'a>) -> Value {
1237 if ctx.flow_cancel.is_cancelled() {
1238 return Value::Err(RuntimeError::Cancelled("flow cancelled by user".into()));
1239 }
1240 match node {
1241 Node::ToolCall { path, args } => {
1242 let path_str = path
1243 .iter()
1244 .map(|p| p.name.as_str())
1245 .collect::<Vec<_>>()
1246 .join(".");
1247 match path_str.as_str() {
1248 "list.map" => return eval_list_map(args, env, ctx).await,
1249 "list.filter" => return eval_list_filter(args, env, ctx).await,
1250 "list.reduce" => return eval_list_reduce(args, env, ctx).await,
1251 "list.find" => return eval_list_find(args, env, ctx).await,
1252 "list.any" => return eval_list_any(args, env, ctx).await,
1253 "list.all" => return eval_list_all(args, env, ctx).await,
1254 _ => {}
1255 }
1256 dispatch_tool_call(path, args, Vec::new(), env, ctx).await
1257 }
1258 Node::DynamicFanout {
1259 source,
1260 lambda,
1261 collect,
1262 } => {
1263 return eval_dynamic_fanout(source, lambda, collect, env, ctx).await;
1264 }
1265 Node::Fanout { items, collect } => match collect {
1266 atman_dsl::ast::FanoutCollect::All => {
1267 let parent_id = ctx.current_node_id.clone();
1268 let branch_ctxs: Vec<EvalCtx<'a>> = (0..items.len())
1269 .map(|i| {
1270 let branch_id = match &parent_id {
1271 Some(p) => format!("{p}.branch[{i}]"),
1272 None => format!("branch[{i}]"),
1273 };
1274 if let (Some(sink), Some(run_id)) = (ctx.events, ctx.flow_run_id.clone()) {
1275 sink.emit(crate::event::Event::FlowNodeStart {
1276 run_id: run_id.clone(),
1277 node_id: branch_id.clone(),
1278 kind: crate::nodegraph::NodeKind::UserConfirm,
1279 label: format!("branch[{i}]"),
1280 parent_node_id: parent_id.clone(),
1281 });
1282 if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1283 let _ = tx.send(crate::stream::StreamFrame::FlowNodeStart {
1284 run_id: run_id.0.to_string(),
1285 node_id: branch_id.clone(),
1286 kind: crate::nodegraph::NodeKind::UserConfirm,
1287 label: format!("branch[{i}]"),
1288 parent_node_id: parent_id.clone(),
1289 });
1290 }
1291 }
1292 ctx.with_node(branch_id)
1293 })
1294 .collect();
1295 let futs = items
1296 .iter()
1297 .zip(branch_ctxs.iter())
1298 .map(|(item, bctx)| eval_expr(item, env, bctx));
1299 let results: Vec<Value> = futures::future::join_all(futs).await;
1300 for (bctx, v) in branch_ctxs.iter().zip(results.iter()) {
1301 if let (Some(sink), Some(run_id), Some(bid)) =
1302 (ctx.events, ctx.flow_run_id.clone(), &bctx.current_node_id)
1303 {
1304 let status = if v.is_err() {
1305 crate::event::FlowNodeStatus::Err
1306 } else {
1307 crate::event::FlowNodeStatus::Ok
1308 };
1309 sink.emit(crate::event::Event::FlowNodeEnd {
1310 run_id: run_id.clone(),
1311 node_id: bid.clone(),
1312 status: status.clone(),
1313 output_preview: None,
1314 });
1315 if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1316 let _ = tx.send(crate::stream::StreamFrame::FlowNodeEnd {
1317 run_id: run_id.0.to_string(),
1318 node_id: bid.clone(),
1319 status,
1320 output_preview: None,
1321 parent_node_id: parent_id.clone(),
1322 });
1323 }
1324 }
1325 }
1326 for v in &results {
1327 if let Value::Err(e) = v {
1328 return Value::Err(e.clone());
1329 }
1330 }
1331 Value::List(results)
1332 }
1333 atman_dsl::ast::FanoutCollect::First => Value::Err(RuntimeError::ToolFailed(
1334 "fanout collect: first not yet implemented".into(),
1335 )),
1336 },
1337 Node::UserConfirm { msg } => {
1338 let v = eval_expr(msg, env, ctx).await;
1339 if v.is_err() {
1340 return v;
1341 }
1342 let prompt = match &v {
1343 Value::Str(s) => s.clone(),
1344 other => other.kind_name().to_string(),
1345 };
1346 let confirm_kind = crate::form::FormKind::Confirm {
1347 prompt: prompt.clone(),
1348 };
1349 if let Some(resolver) = ctx.tool_ctx.prompt_resolver.clone() {
1354 let id = crate::rendezvous::PromptId::now();
1355 let payload =
1356 serde_json::to_value(&confirm_kind).unwrap_or(serde_json::Value::Null);
1357 let timeout = std::time::Duration::from_secs(300);
1358 let result = crate::rendezvous::await_prompt_with_payload(
1359 &resolver, id, "form_ask", payload, timeout,
1360 )
1361 .await;
1362 let answer: crate::form::FormAnswer = match result {
1363 Ok(v) => {
1364 serde_json::from_value(v).unwrap_or(crate::form::FormAnswer::Cancelled)
1365 }
1366 Err(_) => crate::form::FormAnswer::Cancelled,
1367 };
1368 return Value::Bool(matches!(
1369 answer,
1370 crate::form::FormAnswer::Confirmed { value: true }
1371 ));
1372 }
1373 let Some(session) = ctx.session_runtime.as_ref() else {
1374 return Value::Bool(true);
1375 };
1376 let forms = session.forms();
1377 if forms.subscriber_count() == 0 {
1378 return Value::Bool(true);
1379 }
1380 let Some(run_id) = ctx.flow_run_id.clone() else {
1381 return Value::Bool(true);
1382 };
1383 let pending = crate::form::PendingForm {
1384 form_id: uuid::Uuid::now_v7().to_string(),
1385 run_id,
1386 tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
1387 kind: confirm_kind,
1388 emitted_at: chrono::Utc::now(),
1389 };
1390 let rx = forms.request(pending);
1391 let answer = rx.await.unwrap_or(crate::form::FormAnswer::Cancelled);
1392 Value::Bool(matches!(
1393 answer,
1394 crate::form::FormAnswer::Confirmed { value: true }
1395 ))
1396 }
1397 Node::FixUntilTestPasses { kwargs } => eval_fix_until_test_passes(kwargs, env, ctx).await,
1398 Node::Message { role, args } => eval_message_node(*role, args, env, ctx).await,
1399 Node::Subflow { name, args } => {
1400 let Some(target) = ctx.flows.get(&name.name) else {
1401 return Value::Err(RuntimeError::UndefinedTool(format!(
1402 "subflow({})",
1403 name.name
1404 )));
1405 };
1406 let mut bindings = Vec::with_capacity(args.len());
1407 for (i, arg) in args.iter().enumerate() {
1408 let (param_name, value) = match arg {
1409 Arg::Positional(e) => {
1410 let Some(p) = target.params.get(i) else {
1411 return Value::Err(RuntimeError::MissingArg(format!(
1412 "subflow({}): too many positional args",
1413 name.name
1414 )));
1415 };
1416 let v = eval_expr(e, env, ctx).await;
1417 (p.name.name.clone(), v)
1418 }
1419 Arg::Named { name: n, value } => {
1420 let v = eval_expr(value, env, ctx).await;
1421 (n.name.clone(), v)
1422 }
1423 };
1424 if value.is_err() {
1425 return value;
1426 }
1427 bindings.push((param_name, value));
1428 }
1429 let mut sub_env = Env::new();
1430 for (n, v) in bindings {
1431 sub_env.bind(n, v);
1432 }
1433 let sub_run_id = crate::event::FlowRunId::now();
1434 if let Some(sink) = ctx.events {
1435 sink.emit(crate::event::Event::FlowStart {
1436 run_id: sub_run_id.clone(),
1437 flow_name: name.name.clone(),
1438 parent_run_id: ctx.flow_run_id.clone(),
1439 parent_node_id: ctx.current_node_id.clone(),
1440 spawned: false,
1441 });
1442 }
1443 if let Some(session) = ctx.session_runtime.as_ref() {
1444 let _ = session
1445 .stream_tx()
1446 .send(crate::stream::StreamFrame::FlowStart {
1447 run_id: sub_run_id.0.to_string(),
1448 flow_name: name.name.clone(),
1449 parent_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1450 parent_node_id: ctx.current_node_id.clone(),
1451 });
1452 } else if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1453 let _ = tx.send(crate::stream::StreamFrame::FlowStart {
1454 run_id: sub_run_id.0.to_string(),
1455 flow_name: name.name.clone(),
1456 parent_run_id: ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
1457 parent_node_id: ctx.current_node_id.clone(),
1458 });
1459 }
1460 let sub_ctx = EvalCtx {
1461 flow_run_id: Some(sub_run_id.clone()),
1462 current_node_id: None,
1463 ..ctx.clone()
1464 };
1465 let outcome = crate::exec::exec_stmts(&target.body, &mut sub_env, &sub_ctx).await;
1466 let (result, status, ok) = match outcome {
1467 crate::exec::StmtOutcome::Return(v) => (v, crate::event::FlowStatus::Ok, true),
1468 crate::exec::StmtOutcome::Err(e) => {
1469 let status = if matches!(&e, crate::error::RuntimeError::Cancelled(_)) {
1470 crate::event::FlowStatus::Cancelled
1471 } else {
1472 crate::event::FlowStatus::Errored {
1473 message: format!("{e}"),
1474 }
1475 };
1476 (Value::Err(e.clone()), status, false)
1477 }
1478 crate::exec::StmtOutcome::Continue => {
1479 (Value::Unit, crate::event::FlowStatus::Ok, true)
1480 }
1481 crate::exec::StmtOutcome::LoopBreak => {
1482 (Value::Unit, crate::event::FlowStatus::Ok, true)
1483 }
1484 crate::exec::StmtOutcome::LoopContinue => {
1485 (Value::Unit, crate::event::FlowStatus::Ok, true)
1486 }
1487 };
1488 let cancelled = matches!(status, crate::event::FlowStatus::Cancelled);
1489 if let Some(sink) = ctx.events {
1490 sink.emit(crate::event::Event::FlowEnd {
1491 run_id: sub_run_id.clone(),
1492 flow_name: name.name.clone(),
1493 status,
1494 });
1495 }
1496 if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1497 let _ = tx.send(crate::stream::StreamFrame::FlowDone {
1498 run_id: sub_run_id.0.to_string(),
1499 flow_name: name.name.clone(),
1500 ok,
1501 cancelled,
1502 });
1503 }
1504 result
1505 }
1506 }
1507}
1508
1509fn session_fs_access_policy(session: &crate::session::Session) -> crate::fs_access::FsAccessPolicy {
1510 let workspace = session
1511 .meta()
1512 .and_then(|m| m.project_root)
1513 .or_else(|| std::env::current_dir().ok());
1514 let mode = session
1515 .fs_access_mode()
1516 .unwrap_or(crate::fs_access::FsAccessMode::WorkspaceWrite);
1517 crate::fs_access::FsAccessPolicy { mode, workspace }
1518}
1519
1520fn tool_name(path: &[atman_dsl::ast::Ident]) -> String {
1521 let parts: Vec<&str> = path.iter().map(|i| i.name.as_str()).collect();
1522 parts.join(".")
1523}
1524
1525async fn eval_fix_until_test_passes<'a>(
1526 kwargs: &'a atman_dsl::ast::Kwargs,
1527 env: &'a Env,
1528 ctx: &'a EvalCtx<'a>,
1529) -> Value {
1530 let mut edit_flow_expr: Option<&Expr> = None;
1531 let mut test_expr: Option<&Expr> = None;
1532 let mut on_giveup_expr: Option<&Expr> = None;
1533 let mut max_iters: u32 = 5;
1534 let mut target_path: Option<std::path::PathBuf> = None;
1535
1536 for (k, v) in kwargs {
1537 match k.name.as_str() {
1538 "edit_flow" => edit_flow_expr = Some(v),
1539 "test" => test_expr = Some(v),
1540 "on_giveup" => on_giveup_expr = Some(v),
1541 "max_iters" => match eval_expr(v, env, ctx).await {
1542 Value::Int(n) if n > 0 => max_iters = n as u32,
1543 other => {
1544 return Value::Err(RuntimeError::TypeMismatch {
1545 expected: "positive int (max_iters)".into(),
1546 actual: other.kind_name().into(),
1547 });
1548 }
1549 },
1550 "target" => match eval_expr(v, env, ctx).await {
1551 Value::Path(p) => target_path = Some(p),
1552 Value::Str(s) => target_path = Some(std::path::PathBuf::from(s)),
1553 Value::Unit => {}
1554 other => {
1555 return Value::Err(RuntimeError::TypeMismatch {
1556 expected: "path (target)".into(),
1557 actual: other.kind_name().into(),
1558 });
1559 }
1560 },
1561 _ => {}
1562 }
1563 }
1564
1565 let Some(edit_flow_expr) = edit_flow_expr else {
1566 return Value::Err(RuntimeError::MissingArg(
1567 "fix_until_test_passes.edit_flow".into(),
1568 ));
1569 };
1570 let Some(test_expr) = test_expr else {
1571 return Value::Err(RuntimeError::MissingArg(
1572 "fix_until_test_passes.test".into(),
1573 ));
1574 };
1575
1576 let pristine: Option<String> = match &target_path {
1577 Some(p) => match tokio::fs::read_to_string(p).await {
1578 Ok(s) => Some(s),
1579 Err(e) => {
1580 return Value::Err(RuntimeError::ToolFailed(format!(
1581 "fix_until_test_passes: cannot read target {}: {e}",
1582 p.display()
1583 )));
1584 }
1585 },
1586 None => None,
1587 };
1588
1589 let mut prev_fail = String::new();
1590 let mut last_test_result: Option<Value> = None;
1591
1592 for iter in 0..max_iters {
1593 let mut loop_env = env.clone();
1594 loop_env.bind("iter", Value::Int(iter as i64));
1595 loop_env.bind("prev_fail", Value::Str(prev_fail.clone()));
1596
1597 let edit_v = eval_expr(edit_flow_expr, &loop_env, ctx).await;
1598 if edit_v.is_err() {
1599 return edit_v;
1600 }
1601 loop_env.bind("last_edit", edit_v);
1602
1603 let test_v = eval_expr(test_expr, &loop_env, ctx).await;
1604 if test_v.is_err() {
1605 return test_v;
1606 }
1607 let exit = test_v
1608 .field("exit_code")
1609 .or_else(|| test_v.field("exit"))
1610 .and_then(|v| match v {
1611 Value::Int(n) => Some(*n),
1612 _ => None,
1613 });
1614 last_test_result = Some(test_v.clone());
1615 if let Some(0) = exit {
1616 return Value::Struct(vec![
1617 ("status".into(), Value::Str("passed".into())),
1618 ("iters".into(), Value::Int((iter + 1) as i64)),
1619 ("test".into(), test_v),
1620 ]);
1621 }
1622 let stderr_tail = test_v
1623 .field("stderr_tail")
1624 .or_else(|| test_v.field("output"))
1625 .and_then(|v| match v {
1626 Value::Str(s) => Some(s.clone()),
1627 _ => None,
1628 })
1629 .unwrap_or_default();
1630 let stdout_tail = test_v
1631 .field("stdout_tail")
1632 .and_then(|v| match v {
1633 Value::Str(s) => Some(s.clone()),
1634 _ => None,
1635 })
1636 .unwrap_or_default();
1637 prev_fail = format!(
1638 "iter {iter} exit={:?}\n--- stderr ---\n{stderr_tail}\n--- stdout ---\n{stdout_tail}",
1639 exit
1640 );
1641
1642 if let (Some(target), Some(pristine)) = (&target_path, &pristine)
1643 && let Err(e) = tokio::fs::write(target, pristine.as_bytes()).await
1644 {
1645 return Value::Err(RuntimeError::ToolFailed(format!(
1646 "fix_until_test_passes: revert failed on {}: {e}",
1647 target.display()
1648 )));
1649 }
1650 }
1651
1652 if let Some(giveup) = on_giveup_expr {
1653 let mut giveup_env = env.clone();
1654 giveup_env.bind("iters", Value::Int(max_iters as i64));
1655 giveup_env.bind("prev_fail", Value::Str(prev_fail));
1656 return eval_expr(giveup, &giveup_env, ctx).await;
1657 }
1658
1659 Value::Struct(vec![
1660 ("status".into(), Value::Str("gave_up".into())),
1661 ("iters".into(), Value::Int(max_iters as i64)),
1662 ("last_test".into(), last_test_result.unwrap_or(Value::Unit)),
1663 ])
1664}
1665
1666async fn eval_message_node<'a>(
1667 ast_role: atman_dsl::ast::MessageRole,
1668 args: &'a [Arg],
1669 env: &'a Env,
1670 ctx: &'a EvalCtx<'a>,
1671) -> Value {
1672 use crate::message::{
1673 ImageData, ImageSource, Message, MessageOrigin, MessagePart, MessageRole,
1674 };
1675
1676 let role = match ast_role {
1677 atman_dsl::ast::MessageRole::User => MessageRole::User,
1678 atman_dsl::ast::MessageRole::Assistant => MessageRole::Assistant,
1679 atman_dsl::ast::MessageRole::System => MessageRole::System,
1680 atman_dsl::ast::MessageRole::Tool => MessageRole::Tool,
1681 };
1682 let turn_id = ctx
1683 .turn_id
1684 .clone()
1685 .unwrap_or_else(crate::event::TurnId::now);
1686
1687 let mut positional = Vec::new();
1688 let mut named: Vec<(String, Value)> = Vec::new();
1689 let mut attachment_paths_raw: Option<Vec<std::path::PathBuf>> = None;
1690 for arg in args {
1691 match arg {
1692 Arg::Positional(e) => {
1693 let v = eval_expr(e, env, ctx).await;
1694 if v.is_err() {
1695 return v;
1696 }
1697 positional.push(v);
1698 }
1699 Arg::Named { name, value } => {
1700 if name.name == "attachments" {
1701 if let Expr::List(items) = value {
1702 let mut collected = Vec::with_capacity(items.len());
1703 let mut all_fileref = true;
1704 for it in items {
1705 if let Expr::FileRef(f) = it {
1706 collected.push(std::path::PathBuf::from(&f.path));
1707 } else {
1708 all_fileref = false;
1709 break;
1710 }
1711 }
1712 if all_fileref {
1713 attachment_paths_raw = Some(collected);
1714 continue;
1715 }
1716 }
1717 }
1718 let v = eval_expr(value, env, ctx).await;
1719 if v.is_err() {
1720 return v;
1721 }
1722 named.push((name.name.clone(), v));
1723 }
1724 }
1725 }
1726 let take_named = |k: &str, named: &mut Vec<(String, Value)>| -> Option<Value> {
1727 let pos = named.iter().position(|(n, _)| n == k)?;
1728 Some(named.remove(pos).1)
1729 };
1730
1731 if role == MessageRole::Tool {
1732 let tool_use_id = match positional.first().or(take_named("id", &mut named).as_ref()) {
1733 Some(Value::Str(s)) => s.clone(),
1734 Some(other) => {
1735 return Value::Err(RuntimeError::TypeMismatch {
1736 expected: "string (tool_use_id)".into(),
1737 actual: other.kind_name().into(),
1738 });
1739 }
1740 None => {
1741 return Value::Err(RuntimeError::MissingArg("tool_result: id".into()));
1742 }
1743 };
1744 let content = match positional
1745 .get(1)
1746 .or(take_named("content", &mut named).as_ref())
1747 {
1748 Some(Value::Str(s)) => s.clone(),
1749 Some(other) => {
1750 return Value::Err(RuntimeError::TypeMismatch {
1751 expected: "string (content)".into(),
1752 actual: other.kind_name().into(),
1753 });
1754 }
1755 None => {
1756 return Value::Err(RuntimeError::MissingArg("tool_result: content".into()));
1757 }
1758 };
1759 let is_error = match take_named("is_error", &mut named) {
1760 Some(Value::Bool(b)) => b,
1761 Some(other) => {
1762 return Value::Err(RuntimeError::TypeMismatch {
1763 expected: "bool (is_error)".into(),
1764 actual: other.kind_name().into(),
1765 });
1766 }
1767 None => false,
1768 };
1769 return Value::Message(Message {
1770 role,
1771 parts: vec![MessagePart::ToolResult {
1772 tool_use_id,
1773 content,
1774 is_error,
1775 }],
1776 turn_id,
1777 origin: MessageOrigin::User,
1778 });
1779 }
1780
1781 let text = match positional.first() {
1782 Some(Value::Str(s)) => Some(s.clone()),
1783 Some(other) => {
1784 return Value::Err(RuntimeError::TypeMismatch {
1785 expected: "string (message text)".into(),
1786 actual: other.kind_name().into(),
1787 });
1788 }
1789 None => None,
1790 };
1791 let attachment_paths: Vec<std::path::PathBuf> = if let Some(raw) = attachment_paths_raw {
1792 raw
1793 } else {
1794 match take_named("attachments", &mut named) {
1795 Some(Value::List(items)) => {
1796 let mut ps = Vec::with_capacity(items.len());
1797 for it in items {
1798 match it {
1799 Value::Path(p) => ps.push(p),
1800 Value::Str(s) => ps.push(std::path::PathBuf::from(s)),
1801 other => {
1802 return Value::Err(RuntimeError::TypeMismatch {
1803 expected: "path (attachment)".into(),
1804 actual: other.kind_name().into(),
1805 });
1806 }
1807 }
1808 }
1809 ps
1810 }
1811 Some(other) => {
1812 return Value::Err(RuntimeError::TypeMismatch {
1813 expected: "list of path".into(),
1814 actual: other.kind_name().into(),
1815 });
1816 }
1817 None => Vec::new(),
1818 }
1819 };
1820
1821 let mut parts: Vec<MessagePart> = attachment_paths
1822 .into_iter()
1823 .map(|path| {
1824 let media_type = guess_image_mime(&path).unwrap_or_else(|| "image/png".to_string());
1825 MessagePart::Image {
1826 source: ImageSource {
1827 media_type,
1828 data: ImageData::Path { path },
1829 },
1830 }
1831 })
1832 .collect();
1833 if let Some(t) = text {
1834 parts.push(MessagePart::Text { text: t });
1835 }
1836
1837 Value::Message(Message {
1838 role,
1839 parts,
1840 turn_id,
1841 origin: MessageOrigin::User,
1842 })
1843}
1844
1845pub(super) fn render_injections(injections: &[crate::injection::Injection]) -> String {
1846 use crate::injection::{InjectionLevel, InjectionSource};
1847 let has_user = injections
1848 .iter()
1849 .any(|i| matches!(i.source, InjectionSource::User));
1850 let has_watcher = injections
1851 .iter()
1852 .any(|i| matches!(i.source, InjectionSource::Watcher { .. }));
1853 let mut out = String::new();
1854 if has_user {
1855 out.push_str(
1856 "The user sent the following steering message(s) while you were working. \
1857 Apply them to your next step if still relevant.\n\n",
1858 );
1859 }
1860 if has_watcher {
1861 out.push_str("A background watcher detected the following event(s):\n\n");
1862 }
1863 for inj in injections {
1864 let (tag, source_attr) = match &inj.source {
1865 InjectionSource::User => match inj.level {
1866 InjectionLevel::L2CourseCorrect => ("user_correction", "user".to_string()),
1867 _ => ("user_nudge", "user".to_string()),
1868 },
1869 InjectionSource::Watcher {
1870 watcher_id,
1871 kind,
1872 handle,
1873 } => (
1874 "watcher_event",
1875 format!("{kind} '{handle}' watcher {watcher_id}"),
1876 ),
1877 };
1878 out.push_str(&format!(
1879 "<{tag} id=\"{}\" ts=\"{}\" source=\"{}\">\n{}\n</{tag}>\n",
1880 inj.id.0,
1881 inj.created_at.to_rfc3339(),
1882 source_attr,
1883 inj.text
1884 ));
1885 }
1886 out
1887}
1888
1889fn guess_image_mime(path: &std::path::Path) -> Option<String> {
1890 let ext = path
1891 .extension()
1892 .and_then(|s| s.to_str())?
1893 .to_ascii_lowercase();
1894 Some(
1895 match ext.as_str() {
1896 "png" => "image/png",
1897 "jpg" | "jpeg" => "image/jpeg",
1898 "gif" => "image/gif",
1899 "webp" => "image/webp",
1900 _ => return None,
1901 }
1902 .to_string(),
1903 )
1904}
1905
1906fn contract_allows_shell(contract: Option<&atman_dsl::ast::Contract>) -> bool {
1907 let Some(c) = contract else { return false };
1908 for block in &c.blocks {
1909 if block.name.name != "capabilities" {
1910 continue;
1911 }
1912 for (k, v) in &block.kwargs {
1913 if k.name != "shell" {
1914 continue;
1915 }
1916 if let atman_dsl::ast::Expr::Literal(atman_dsl::ast::Literal::Bool(true)) = v {
1917 return true;
1918 }
1919 }
1920 }
1921 false
1922}
1923
1924pub struct TruncationStat {
1925 pub original_chars: usize,
1926 pub result_chars: usize,
1927 pub dropped_chars: usize,
1928 pub budget_tokens: u64,
1929}
1930
1931pub(super) fn sanitize_tool_pairs(
1932 messages: Vec<crate::message::Message>,
1933) -> Vec<crate::message::Message> {
1934 use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
1935 use std::collections::HashMap;
1936 let mut result_by_id: HashMap<String, Message> = HashMap::new();
1937 for m in &messages {
1938 for p in &m.parts {
1939 if let MessagePart::ToolResult { tool_use_id, .. } = p {
1940 result_by_id
1941 .entry(tool_use_id.clone())
1942 .or_insert_with(|| Message {
1943 role: MessageRole::Tool,
1944 parts: vec![p.clone()],
1945 turn_id: m.turn_id.clone(),
1946 origin: MessageOrigin::User,
1947 });
1948 }
1949 }
1950 }
1951 let mut out: Vec<Message> = Vec::with_capacity(messages.len() + 4);
1952 for m in &messages {
1953 let uses: Vec<String> = m
1954 .parts
1955 .iter()
1956 .filter_map(|p| match p {
1957 MessagePart::ToolUse { id, .. } => Some(id.clone()),
1958 _ => None,
1959 })
1960 .collect();
1961 let is_pure_result = m
1962 .parts
1963 .iter()
1964 .all(|p| matches!(p, MessagePart::ToolResult { .. }));
1965 if is_pure_result {
1966 continue;
1967 }
1968 out.push(m.clone());
1969 if !uses.is_empty() {
1970 for u in &uses {
1971 if let Some(rm) = result_by_id.get(u) {
1972 if let Some(MessagePart::ToolResult {
1973 tool_use_id,
1974 content,
1975 is_error,
1976 }) = rm.parts.first()
1977 {
1978 out.push(Message {
1979 role: MessageRole::Tool,
1980 parts: vec![MessagePart::ToolResult {
1981 tool_use_id: tool_use_id.clone(),
1982 content: content.clone(),
1983 is_error: *is_error,
1984 }],
1985 turn_id: m.turn_id.clone(),
1986 origin: MessageOrigin::User,
1987 });
1988 }
1989 } else {
1990 out.push(Message {
1991 role: MessageRole::Tool,
1992 parts: vec![MessagePart::ToolResult {
1993 tool_use_id: u.clone(),
1994 content: "[tool execution interrupted — no result captured]".into(),
1995 is_error: true,
1996 }],
1997 turn_id: m.turn_id.clone(),
1998 origin: MessageOrigin::User,
1999 });
2000 }
2001 }
2002 }
2003 }
2004 out
2005}
2006
2007pub fn truncate_prompt_to_budget(prompt: String, budget_tokens: u64) -> String {
2008 truncate_prompt_to_budget_tracked(prompt, budget_tokens).0
2009}
2010
2011pub fn truncate_prompt_to_budget_tracked(
2012 prompt: String,
2013 budget_tokens: u64,
2014) -> (String, Option<TruncationStat>) {
2015 let budget_chars = budget_tokens.saturating_mul(4) as usize;
2016 if prompt.len() <= budget_chars {
2017 return (prompt, None);
2018 }
2019 let head_chars = budget_chars * 4 / 10;
2020 let tail_chars = budget_chars * 4 / 10;
2021 if head_chars + tail_chars >= prompt.len() {
2022 return (prompt, None);
2023 }
2024 let original_chars = prompt.len();
2025 let head_end = char_boundary(&prompt, head_chars, false);
2026 let tail_start = char_boundary(&prompt, prompt.len().saturating_sub(tail_chars), true);
2027 let head = &prompt[..head_end];
2028 let tail = &prompt[tail_start..];
2029 let dropped = original_chars - head.len() - tail.len();
2030 let result = format!("{head}\n\n[... truncated {dropped} chars ...]\n\n{tail}");
2031 let stat = TruncationStat {
2032 original_chars,
2033 result_chars: result.len(),
2034 dropped_chars: dropped,
2035 budget_tokens,
2036 };
2037 (result, Some(stat))
2038}
2039
2040fn char_boundary(s: &str, target: usize, round_up: bool) -> usize {
2041 let mut idx = target.min(s.len());
2042 while idx > 0 && idx < s.len() && !s.is_char_boundary(idx) {
2043 if round_up {
2044 idx += 1;
2045 } else {
2046 idx -= 1;
2047 }
2048 }
2049 idx
2050}
2051
2052fn is_type_annotation(path: &[atman_dsl::ast::Ident]) -> bool {
2054 if path.len() != 1 {
2055 return false;
2056 }
2057 matches!(
2058 path[0].name.as_str(),
2059 "bool" | "int" | "float" | "string" | "path" | "bytes" | "duration"
2060 )
2061}
2062
2063fn eval_literal(lit: &Literal) -> Value {
2064 match lit {
2065 Literal::Str(s) => Value::Str(s.clone()),
2066 Literal::Int(n) => Value::Int(*n),
2067 Literal::Float(f) => Value::Float(*f),
2068 Literal::Bool(b) => Value::Bool(*b),
2069 }
2070}
2071
2072fn eval_binop(op: BinOp, l: &Value, r: &Value) -> Value {
2073 match op {
2074 BinOp::Eq => Value::Bool(value_eq(l, r)),
2075 BinOp::Ne => Value::Bool(!value_eq(l, r)),
2076 BinOp::Lt => value_cmp(l, r, |a, b| a < b, |a, b| a < b, |a, b| a < b),
2077 BinOp::Le => value_cmp(l, r, |a, b| a <= b, |a, b| a <= b, |a, b| a <= b),
2078 BinOp::Gt => value_cmp(l, r, |a, b| a > b, |a, b| a > b, |a, b| a > b),
2079 BinOp::Ge => value_cmp(l, r, |a, b| a >= b, |a, b| a >= b, |a, b| a >= b),
2080 BinOp::And => match (l, r) {
2081 (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a && *b),
2082 _ => type_mismatch("bool && bool", l, r),
2083 },
2084 BinOp::Or => match (l, r) {
2085 (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a || *b),
2086 _ => type_mismatch("bool || bool", l, r),
2087 },
2088 BinOp::Add => match (l, r) {
2089 (Value::Int(a), Value::Int(b)) => Value::Int(a + b),
2090 (Value::Float(a), Value::Float(b)) => Value::Float(a + b),
2091 (Value::Str(a), Value::Str(b)) => Value::Str(format!("{a}{b}")),
2092 (Value::Str(a), Value::Path(b)) => Value::Str(format!("{a}{}", b.display())),
2093 (Value::Path(a), Value::Str(b)) => Value::Str(format!("{}{b}", a.display())),
2094 _ => type_mismatch(
2095 "int+int | float+float | string+string | string+path | path+string",
2096 l,
2097 r,
2098 ),
2099 },
2100 BinOp::Sub => match (l, r) {
2101 (Value::Int(a), Value::Int(b)) => Value::Int(a - b),
2102 (Value::Float(a), Value::Float(b)) => Value::Float(a - b),
2103 _ => type_mismatch("int-int | float-float", l, r),
2104 },
2105 BinOp::Mul => match (l, r) {
2106 (Value::Int(a), Value::Int(b)) => Value::Int(a * b),
2107 (Value::Float(a), Value::Float(b)) => Value::Float(a * b),
2108 _ => type_mismatch("int*int | float*float", l, r),
2109 },
2110 BinOp::Div => match (l, r) {
2111 (Value::Int(_), Value::Int(0)) => {
2112 Value::Err(RuntimeError::ToolFailed("integer div by zero".into()))
2113 }
2114 (Value::Int(a), Value::Int(b)) => Value::Int(a / b),
2115 (Value::Float(a), Value::Float(b)) => Value::Float(a / b),
2116 _ => type_mismatch("int/int | float/float", l, r),
2117 },
2118 BinOp::Mod => match (l, r) {
2119 (Value::Int(_), Value::Int(0)) => {
2120 Value::Err(RuntimeError::ToolFailed("integer mod by zero".into()))
2121 }
2122 (Value::Int(a), Value::Int(b)) => Value::Int(a % b),
2123 (Value::Float(a), Value::Float(b)) => Value::Float(a % b),
2124 _ => type_mismatch("int%int | float%float", l, r),
2125 },
2126 }
2127}
2128
2129fn eval_unop(op: UnOp, v: &Value) -> Value {
2130 match op {
2131 UnOp::Not => match v {
2132 Value::Bool(b) => Value::Bool(!b),
2133 other => Value::Err(RuntimeError::TypeMismatch {
2134 expected: "bool".into(),
2135 actual: other.kind_name().into(),
2136 }),
2137 },
2138 UnOp::Neg => match v {
2139 Value::Int(n) => Value::Int(-n),
2140 Value::Float(n) => Value::Float(-n),
2141 other => Value::Err(RuntimeError::TypeMismatch {
2142 expected: "int or float".into(),
2143 actual: other.kind_name().into(),
2144 }),
2145 },
2146 }
2147}
2148
2149fn value_eq(l: &Value, r: &Value) -> bool {
2150 match (l, r) {
2151 (Value::Unit, Value::Unit) => true,
2152 (Value::Bool(a), Value::Bool(b)) => a == b,
2153 (Value::Int(a), Value::Int(b)) => a == b,
2154 (Value::Float(a), Value::Float(b)) => a == b,
2155 (Value::Str(a), Value::Str(b)) => a == b,
2156 (Value::Path(a), Value::Path(b)) => a == b,
2157 _ => false,
2158 }
2159}
2160
2161fn value_cmp(
2162 l: &Value,
2163 r: &Value,
2164 int_cmp: fn(i64, i64) -> bool,
2165 float_cmp: fn(f64, f64) -> bool,
2166 str_cmp: fn(&str, &str) -> bool,
2167) -> Value {
2168 match (l, r) {
2169 (Value::Int(a), Value::Int(b)) => Value::Bool(int_cmp(*a, *b)),
2170 (Value::Float(a), Value::Float(b)) => Value::Bool(float_cmp(*a, *b)),
2171 (Value::Str(a), Value::Str(b)) => Value::Bool(str_cmp(a, b)),
2172 _ => type_mismatch("comparable pair", l, r),
2173 }
2174}
2175
2176fn type_mismatch(expected: &str, l: &Value, r: &Value) -> Value {
2177 Value::Err(RuntimeError::TypeMismatch {
2178 expected: expected.into(),
2179 actual: format!("{} vs {}", l.kind_name(), r.kind_name()),
2180 })
2181}
2182
2183pub(super) fn input_with_cache_for_window(usage: &crate::provider::TokenUsage) -> u64 {
2184 usage.input + usage.cached_input
2185}
2186
2187#[cfg(test)]
2188mod tests {
2189 use super::*;
2190 use atman_dsl::parse::parse_file;
2191
2192 #[test]
2193 fn char_boundary_rounds_around_multibyte_characters() {
2194 let text = "a你😀b";
2195 assert_eq!(char_boundary(text, 2, false), 1);
2196 assert_eq!(char_boundary(text, 2, true), 4);
2197 assert_eq!(char_boundary(text, 6, false), 4);
2198 assert_eq!(char_boundary(text, 6, true), 8);
2199 assert_eq!(char_boundary(text, 8, false), 8);
2200 assert_eq!(char_boundary(text, 99, false), text.len());
2201 }
2202
2203 #[test]
2204 fn parse_context_mode_handles_variants() {
2205 assert!(matches!(
2206 parse_context_mode("session"),
2207 ContextMode::Session
2208 ));
2209 assert!(matches!(parse_context_mode("none"), ContextMode::None));
2210 assert!(matches!(parse_context_mode(""), ContextMode::None));
2211 assert!(matches!(
2212 parse_context_mode(" session "),
2213 ContextMode::Session
2214 ));
2215 match parse_context_mode("session_recent(5)") {
2216 ContextMode::SessionRecent(n) => assert_eq!(n, 5),
2217 other => panic!("expected SessionRecent(5), got {other:?}"),
2218 }
2219 match parse_context_mode("session_recent") {
2220 ContextMode::SessionRecent(n) => assert_eq!(n, 10),
2221 other => panic!("expected SessionRecent(10), got {other:?}"),
2222 }
2223 assert!(matches!(parse_context_mode("garbage"), ContextMode::None));
2224 }
2225
2226 #[test]
2227 fn input_with_cache_for_window_does_not_double_count_cache_write() {
2228 let usage = crate::provider::TokenUsage {
2229 input: 50_000,
2230 cached_input: 0,
2231 cache_write: 50_000,
2232 ..Default::default()
2233 };
2234
2235 assert_eq!(input_with_cache_for_window(&usage), 50_000);
2236 }
2237
2238 async fn eval_snippet(expr_src: &str) -> Value {
2239 let src = format!("flow t() {{\n return {expr_src}\n}}\n");
2240 let file = parse_file(&src).expect("parse test snippet");
2241 let tools = ToolRegistry::new();
2242 let tool_ctx = ToolCtx::new();
2243 let providers = crate::provider::ProviderRegistry::new();
2244 let flows = std::collections::HashMap::new();
2245 let ctx = EvalCtx {
2246 tools: &tools,
2247 tool_ctx: &tool_ctx,
2248 providers: &providers,
2249 flows: &flows,
2250 contract: None,
2251 events: None,
2252 turn_id: None,
2253 flow_run_id: None,
2254 session_runtime: None,
2255 flow_cancel: tokio_util::sync::CancellationToken::new(),
2256 safety: None,
2257 current_node_id: None,
2258 source_dir: None,
2259 };
2260 let stmt = &file.flows[0].body[0];
2261 if let atman_dsl::ast::Stmt::Return { value } = stmt {
2262 eval_expr(value, &Env::new(), &ctx).await
2263 } else {
2264 panic!("expected return statement");
2265 }
2266 }
2267
2268 #[tokio::test]
2269 async fn literals_evaluate() {
2270 assert!(matches!(eval_snippet("42").await, Value::Int(42)));
2271 assert!(matches!(eval_snippet("true").await, Value::Bool(true)));
2272 assert!(matches!(
2273 eval_snippet(r#""hello""#).await,
2274 Value::Str(s) if s == "hello"
2275 ));
2276 }
2277
2278 #[tokio::test]
2279 async fn undefined_ident_yields_err_value() {
2280 assert!(matches!(
2281 eval_snippet("missing").await,
2282 Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2283 ));
2284 }
2285
2286 #[tokio::test]
2287 async fn binary_arithmetic_and_comparison() {
2288 assert!(matches!(eval_snippet("1 == 1").await, Value::Bool(true)));
2289 assert!(matches!(eval_snippet("2 < 3").await, Value::Bool(true)));
2290 assert!(matches!(
2291 eval_snippet(r#""a" + "b""#).await,
2292 Value::Str(s) if s == "ab"
2293 ));
2294 }
2295
2296 #[tokio::test]
2297 async fn type_mismatch_bubbles_up() {
2298 assert!(matches!(
2299 eval_snippet(r#"1 + "x""#).await,
2300 Value::Err(RuntimeError::TypeMismatch { .. })
2301 ));
2302 }
2303
2304 #[tokio::test]
2305 async fn err_short_circuits_binary() {
2306 assert!(matches!(
2307 eval_snippet("missing == 1").await,
2308 Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2309 ));
2310 }
2311
2312 #[tokio::test]
2313 async fn list_evaluates_all_items() {
2314 let v = eval_snippet("[1, 2, 3]").await;
2315 if let Value::List(items) = v {
2316 assert_eq!(items.len(), 3);
2317 assert!(matches!(items[2], Value::Int(3)));
2318 } else {
2319 panic!("expected list");
2320 }
2321 }
2322
2323 #[tokio::test]
2324 async fn struct_literal_evaluates_fields_in_order() {
2325 let v = eval_snippet(r#"{ severity: "critical", count: 3 }"#).await;
2326 if let Value::Struct(fields) = v {
2327 assert_eq!(fields[0].0, "severity");
2328 assert_eq!(fields[1].0, "count");
2329 } else {
2330 panic!("expected struct");
2331 }
2332 }
2333
2334 #[tokio::test]
2335 async fn undefined_tool_returns_undefined_tool_err() {
2336 let src = r#"flow t() { return fs.readnope("/tmp") }"#;
2337 let file = parse_file(src).unwrap();
2338 let tools = ToolRegistry::new();
2339 let tool_ctx = ToolCtx::new();
2340 let providers = crate::provider::ProviderRegistry::new();
2341 let flows = std::collections::HashMap::new();
2342 let ctx = EvalCtx {
2343 tools: &tools,
2344 tool_ctx: &tool_ctx,
2345 providers: &providers,
2346 flows: &flows,
2347 contract: None,
2348 events: None,
2349 turn_id: None,
2350 flow_run_id: None,
2351 session_runtime: None,
2352 flow_cancel: tokio_util::sync::CancellationToken::new(),
2353 safety: None,
2354 current_node_id: None,
2355 source_dir: None,
2356 };
2357 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2358 let v = eval_expr(value, &Env::new(), &ctx).await;
2359 assert!(matches!(
2360 v,
2361 Value::Err(RuntimeError::UndefinedTool(name)) if name == "fs.readnope"
2362 ));
2363 }
2364 }
2365
2366 #[tokio::test]
2367 async fn fanout_all_gathers_results_in_order() {
2368 use crate::tools::fs::FsRead;
2369 use std::sync::Arc;
2370 use tempfile::TempDir;
2371
2372 let dir = TempDir::new().unwrap();
2373 let pa = dir.path().join("a.txt");
2374 let pb = dir.path().join("b.txt");
2375 tokio::fs::write(&pa, b"AAA").await.unwrap();
2376 tokio::fs::write(&pb, b"BBB").await.unwrap();
2377
2378 let tools = ToolRegistry::new();
2379 tools.register(Arc::new(FsRead));
2380 let tool_ctx = ToolCtx::new();
2381 let providers = crate::provider::ProviderRegistry::new();
2382 let flows = std::collections::HashMap::new();
2383 let ctx = EvalCtx {
2384 tools: &tools,
2385 tool_ctx: &tool_ctx,
2386 providers: &providers,
2387 flows: &flows,
2388 contract: None,
2389 events: None,
2390 turn_id: None,
2391 flow_run_id: None,
2392 session_runtime: None,
2393 flow_cancel: tokio_util::sync::CancellationToken::new(),
2394 safety: None,
2395 current_node_id: None,
2396 source_dir: None,
2397 };
2398
2399 let mut env = Env::new();
2400 env.bind("a", Value::Path(pa));
2401 env.bind("b", Value::Path(pb));
2402
2403 let src = r#"flow t() { return fanout [ fs.read(a), fs.read(b) ] collect: all }"#;
2404 let file = parse_file(src).unwrap();
2405 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2406 let v = eval_expr(value, &env, &ctx).await;
2407 if let Value::List(items) = v {
2408 assert_eq!(items.len(), 2);
2409 assert!(matches!(&items[0], Value::Str(s) if s == "AAA"));
2410 assert!(matches!(&items[1], Value::Str(s) if s == "BBB"));
2411 } else {
2412 panic!("expected list");
2413 }
2414 }
2415 }
2416
2417 #[tokio::test]
2418 async fn fanout_all_short_circuits_on_err() {
2419 let src = r#"flow t() { return fanout [ 1, missing, 3 ] collect: all }"#;
2420 let file = parse_file(src).unwrap();
2421 let tools = ToolRegistry::new();
2422 let tool_ctx = ToolCtx::new();
2423 let providers = crate::provider::ProviderRegistry::new();
2424 let flows = std::collections::HashMap::new();
2425 let ctx = EvalCtx {
2426 tools: &tools,
2427 tool_ctx: &tool_ctx,
2428 providers: &providers,
2429 flows: &flows,
2430 contract: None,
2431 events: None,
2432 turn_id: None,
2433 flow_run_id: None,
2434 session_runtime: None,
2435 flow_cancel: tokio_util::sync::CancellationToken::new(),
2436 safety: None,
2437 current_node_id: None,
2438 source_dir: None,
2439 };
2440 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2441 let v = eval_expr(value, &Env::new(), &ctx).await;
2442 assert!(matches!(
2443 v,
2444 Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2445 ));
2446 }
2447 }
2448
2449 #[tokio::test]
2450 async fn llm_node_dispatches_to_mock_provider() {
2451 use crate::providers::mock::MockProvider;
2452 use std::sync::Arc;
2453
2454 {
2455 let _lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2456 crate::model_registry::set_model_config(crate::model_registry::ModelConfig {
2457 models: std::collections::HashMap::from([(
2458 "mock".into(),
2459 crate::model_registry::ModelEntry {
2460 model: "mock".into(),
2461 context_budget: Some(8_192),
2462 ..Default::default()
2463 },
2464 )]),
2465 ..Default::default()
2466 });
2467 }
2468
2469 let providers = crate::provider::ProviderRegistry::new();
2470 providers.register(Arc::new(MockProvider::new("mock").with_model(
2471 "mock",
2472 Value::Struct(vec![("severity".into(), Value::Str("info".into()))]),
2473 )));
2474 let tools = ToolRegistry::new();
2475 crate::tools::register_tier_zero(&tools);
2476 let tool_ctx = ToolCtx::new()
2477 .with_providers(std::sync::Arc::new(providers.clone()))
2478 .with_registry(std::sync::Arc::new(tools.clone()));
2479 let flows = std::collections::HashMap::new();
2480 let ctx = EvalCtx {
2481 tools: &tools,
2482 tool_ctx: &tool_ctx,
2483 providers: &providers,
2484 flows: &flows,
2485 contract: None,
2486 events: None,
2487 turn_id: None,
2488 flow_run_id: None,
2489 session_runtime: None,
2490 flow_cancel: tokio_util::sync::CancellationToken::new(),
2491 safety: None,
2492 current_node_id: None,
2493 source_dir: None,
2494 };
2495
2496 let src = r#"flow t() {
2497 return llm.call(
2498 model: "mock",
2499 prompt: "review please",
2500 input: 1,
2501 )
2502}
2503"#;
2504 let file = parse_file(src).unwrap();
2505 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2506 let v = eval_expr(value, &Env::new(), &ctx).await;
2507 if let Value::Struct(fields) = v {
2508 assert_eq!(fields[0].0, "severity");
2509 assert!(matches!(&fields[0].1, Value::Str(s) if s == "info"));
2510 } else {
2511 panic!("expected struct, got {v:?}");
2512 }
2513 }
2514 }
2515
2516 #[tokio::test]
2517 async fn llm_missing_model_reports_missing_arg() {
2518 let providers = crate::provider::ProviderRegistry::new();
2519 let tools = ToolRegistry::new();
2520 let tool_ctx = ToolCtx::new();
2521 let flows = std::collections::HashMap::new();
2522 let ctx = EvalCtx {
2523 tools: &tools,
2524 tool_ctx: &tool_ctx,
2525 providers: &providers,
2526 flows: &flows,
2527 contract: None,
2528 events: None,
2529 turn_id: None,
2530 flow_run_id: None,
2531 session_runtime: None,
2532 flow_cancel: tokio_util::sync::CancellationToken::new(),
2533 safety: None,
2534 current_node_id: None,
2535 source_dir: None,
2536 };
2537 let src = r#"flow t() { return llm.call(prompt: "hi") }"#;
2538 let file = parse_file(src).unwrap();
2539 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2540 let v = eval_expr(value, &Env::new(), &ctx).await;
2541 assert!(v.is_err(), "expected error, got {v:?}");
2542 }
2543 }
2544
2545 #[tokio::test]
2546 async fn user_confirm_stub_returns_true() {
2547 let providers = crate::provider::ProviderRegistry::new();
2548 let tools = ToolRegistry::new();
2549 let tool_ctx = ToolCtx::new();
2550 let flows = std::collections::HashMap::new();
2551 let ctx = EvalCtx {
2552 tools: &tools,
2553 tool_ctx: &tool_ctx,
2554 providers: &providers,
2555 flows: &flows,
2556 contract: None,
2557 events: None,
2558 turn_id: None,
2559 flow_run_id: None,
2560 session_runtime: None,
2561 flow_cancel: tokio_util::sync::CancellationToken::new(),
2562 safety: None,
2563 current_node_id: None,
2564 source_dir: None,
2565 };
2566 let src = r#"flow t() { return user_confirm("proceed?") }"#;
2567 let file = parse_file(src).unwrap();
2568 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2569 assert!(matches!(
2570 eval_expr(value, &Env::new(), &ctx).await,
2571 Value::Bool(true)
2572 ));
2573 }
2574 }
2575
2576 #[tokio::test]
2577 async fn subflow_calls_target_flow_with_positional_args() {
2578 let src = r#"flow child(n: Int) -> Int {
2579 return n + 100
2580}
2581
2582flow parent(x: Int) -> Int {
2583 y = subflow(child, x)
2584 return y + 1
2585}
2586"#;
2587 let file = parse_file(src).unwrap();
2588 let flows_map: std::collections::HashMap<_, _> = file
2589 .flows
2590 .iter()
2591 .map(|f| (f.name.name.clone(), f.clone()))
2592 .collect();
2593 let parent = &file.flows[1];
2594 let tools = ToolRegistry::new();
2595 let tool_ctx = ToolCtx::new();
2596 let providers = crate::provider::ProviderRegistry::new();
2597 let out = crate::exec::exec_flow_with_siblings(
2598 parent,
2599 vec![("x".into(), Value::Int(5))],
2600 &tools,
2601 &tool_ctx,
2602 &providers,
2603 &flows_map,
2604 None,
2605 None,
2606 None,
2607 None,
2608 tokio_util::sync::CancellationToken::new(),
2609 None,
2610 None,
2611 )
2612 .await
2613 .unwrap();
2614 assert!(matches!(out, Value::Int(106)));
2615 }
2616
2617 #[tokio::test]
2618 async fn subflow_missing_target_reports_undefined_tool() {
2619 let src = r#"flow parent() -> Int {
2620 return subflow(nope, 1)
2621}
2622"#;
2623 let file = parse_file(src).unwrap();
2624 let flows: std::collections::HashMap<_, _> = file
2625 .flows
2626 .iter()
2627 .map(|f| (f.name.name.clone(), f.clone()))
2628 .collect();
2629 let tools = ToolRegistry::new();
2630 let tool_ctx = ToolCtx::new();
2631 let providers = crate::provider::ProviderRegistry::new();
2632 let err = crate::exec::exec_flow_with_siblings(
2633 &file.flows[0],
2634 vec![],
2635 &tools,
2636 &tool_ctx,
2637 &providers,
2638 &flows,
2639 None,
2640 None,
2641 None,
2642 None,
2643 tokio_util::sync::CancellationToken::new(),
2644 None,
2645 None,
2646 )
2647 .await
2648 .unwrap_err();
2649 assert!(matches!(err, RuntimeError::UndefinedTool(name) if name.contains("nope")));
2650 }
2651
2652 #[tokio::test]
2653 async fn tool_call_dispatches_via_registry() {
2654 use crate::tools::fs::FsRead;
2655 use std::sync::Arc;
2656 use tempfile::TempDir;
2657
2658 let dir = TempDir::new().unwrap();
2659 let path = dir.path().join("hi.txt");
2660 tokio::fs::write(&path, b"hello runtime").await.unwrap();
2661
2662 let tools = ToolRegistry::new();
2663 tools.register(Arc::new(FsRead));
2664 let tool_ctx = ToolCtx::new();
2665 let providers = crate::provider::ProviderRegistry::new();
2666 let flows = std::collections::HashMap::new();
2667 let ctx = EvalCtx {
2668 tools: &tools,
2669 tool_ctx: &tool_ctx,
2670 providers: &providers,
2671 flows: &flows,
2672 contract: None,
2673 events: None,
2674 turn_id: None,
2675 flow_run_id: None,
2676 session_runtime: None,
2677 flow_cancel: tokio_util::sync::CancellationToken::new(),
2678 safety: None,
2679 current_node_id: None,
2680 source_dir: None,
2681 };
2682
2683 let mut env = Env::new();
2684 env.bind("p", Value::Path(path));
2685
2686 let src = r#"flow t() { return fs.read(p) }"#;
2687 let file = parse_file(src).unwrap();
2688 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2689 let v = eval_expr(value, &env, &ctx).await;
2690 assert!(matches!(v, Value::Str(s) if s == "hello runtime"));
2691 }
2692 }
2693
2694 #[tokio::test]
2695 async fn fanout_emits_branch_start_end_events_with_parent_linkage() {
2696 let src = r#"flow t() { return fanout [1, 2, 3] collect: all }"#;
2697 let file = parse_file(src).unwrap();
2698 let tools = ToolRegistry::new();
2699 let tool_ctx = ToolCtx::new();
2700 let providers = crate::provider::ProviderRegistry::new();
2701 let flows = std::collections::HashMap::new();
2702 let events = crate::event::EventSink::new();
2703 let ctx = EvalCtx {
2704 tools: &tools,
2705 tool_ctx: &tool_ctx,
2706 providers: &providers,
2707 flows: &flows,
2708 contract: None,
2709 events: Some(&events),
2710 turn_id: None,
2711 flow_run_id: Some(crate::event::FlowRunId::now()),
2712 session_runtime: None,
2713 flow_cancel: tokio_util::sync::CancellationToken::new(),
2714 safety: None,
2715 current_node_id: Some("stmt_1".into()),
2716 source_dir: None,
2717 };
2718 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2719 let _ = eval_expr(value, &Env::new(), &ctx).await;
2720 }
2721 let snap = events.snapshot();
2722 let starts: Vec<_> = snap
2723 .iter()
2724 .filter_map(|e| match e {
2725 crate::event::Event::FlowNodeStart {
2726 node_id,
2727 parent_node_id,
2728 ..
2729 } => Some((node_id.clone(), parent_node_id.clone())),
2730 _ => None,
2731 })
2732 .collect();
2733 assert_eq!(starts.len(), 3);
2734 assert_eq!(starts[0].0, "stmt_1.branch[0]");
2735 assert_eq!(starts[1].0, "stmt_1.branch[1]");
2736 assert_eq!(starts[2].0, "stmt_1.branch[2]");
2737 assert!(starts.iter().all(|(_, p)| p.as_deref() == Some("stmt_1")));
2738 let ends = snap
2739 .iter()
2740 .filter(|e| matches!(e, crate::event::Event::FlowNodeEnd { .. }))
2741 .count();
2742 assert_eq!(ends, 3);
2743 }
2744
2745 #[test]
2746 fn resolve_tool_specs_wildcard_unknown_prefix_skips_silently() {
2747 let tools = crate::tool::ToolRegistry::new();
2748 let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2749 "nonexistent.*".into(),
2750 ))]);
2751 let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
2752 &[crate::value::Value::Str("nonexistent.*".into())],
2753 &tools,
2754 )
2755 .unwrap();
2756 assert!(
2757 specs.is_empty(),
2758 "wildcard with no matches should return empty list"
2759 );
2760 }
2761
2762 #[test]
2763 fn resolve_tool_specs_wildcard_matches_prefixed_tools() {
2764 let tools = crate::tool::ToolRegistry::new();
2765 struct FakeMcpTool {
2766 name: String,
2767 desc: String,
2768 schema: serde_json::Value,
2769 }
2770 impl crate::tool::Tool for FakeMcpTool {
2771 fn name(&self) -> &str {
2772 &self.name
2773 }
2774 fn description(&self) -> Option<&str> {
2775 Some(&self.desc)
2776 }
2777 fn input_schema(&self) -> serde_json::Value {
2778 self.schema.clone()
2779 }
2780 fn tier(&self) -> crate::tool::Tier {
2781 crate::tool::Tier::Zero
2782 }
2783 fn approval_level(
2784 &self,
2785 _args: &crate::tool::ToolArgs,
2786 _ctx: &crate::tool::ToolCtx,
2787 ) -> crate::tool::ApprovalLevel {
2788 crate::tool::ApprovalLevel::Auto
2789 }
2790 fn call<'a>(
2791 &'a self,
2792 _args: crate::tool::ToolArgs,
2793 _ctx: &'a crate::tool::ToolCtx,
2794 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2795 Box::pin(async { Ok(crate::value::Value::Unit) })
2796 }
2797 }
2798 tools.register(std::sync::Arc::new(FakeMcpTool {
2799 name: "mcp.lark.send_mail".into(),
2800 desc: "send mail".into(),
2801 schema: serde_json::json!({"type":"object","properties":{}}),
2802 }));
2803 tools.register(std::sync::Arc::new(FakeMcpTool {
2804 name: "mcp.lark.read_inbox".into(),
2805 desc: "read inbox".into(),
2806 schema: serde_json::json!({"type":"object","properties":{}}),
2807 }));
2808 tools.register(std::sync::Arc::new(FakeMcpTool {
2809 name: "mcp.siyuan.search".into(),
2810 desc: "search notes".into(),
2811 schema: serde_json::json!({"type":"object","properties":{}}),
2812 }));
2813 tools.register(std::sync::Arc::new(FakeMcpTool {
2815 name: "fs.read".into(),
2816 desc: "read file".into(),
2817 schema: serde_json::json!({"type":"object","properties":{}}),
2818 }));
2819
2820 let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2822 "mcp.*".into(),
2823 ))]);
2824 let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
2825 &[crate::value::Value::Str("mcp.*".into())],
2826 &tools,
2827 )
2828 .unwrap();
2829 assert_eq!(specs.len(), 3, "mcp.* should match 3 MCP tools");
2830 let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
2831 assert!(names.contains(&"mcp.lark.send_mail".into()));
2832 assert!(names.contains(&"mcp.lark.read_inbox".into()));
2833 assert!(names.contains(&"mcp.siyuan.search".into()));
2834
2835 let _expr2 = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2837 "mcp.lark.*".into(),
2838 ))]);
2839 let specs2 = crate::eval::llm_args::resolve_tool_specs_from_values(
2840 &[crate::value::Value::Str("mcp.lark.*".into())],
2841 &tools,
2842 )
2843 .unwrap();
2844 assert_eq!(specs2.len(), 2, "mcp.lark.* should match 2 lark tools");
2845 }
2846
2847 #[test]
2848 fn resolve_tool_specs_mixed_concrete_and_wildcard() {
2849 let tools = crate::tool::ToolRegistry::new();
2850 struct FakeMcpTool {
2851 name: String,
2852 desc: String,
2853 schema: serde_json::Value,
2854 }
2855 impl crate::tool::Tool for FakeMcpTool {
2856 fn name(&self) -> &str {
2857 &self.name
2858 }
2859 fn description(&self) -> Option<&str> {
2860 Some(&self.desc)
2861 }
2862 fn input_schema(&self) -> serde_json::Value {
2863 self.schema.clone()
2864 }
2865 fn tier(&self) -> crate::tool::Tier {
2866 crate::tool::Tier::Zero
2867 }
2868 fn approval_level(
2869 &self,
2870 _args: &crate::tool::ToolArgs,
2871 _ctx: &crate::tool::ToolCtx,
2872 ) -> crate::tool::ApprovalLevel {
2873 crate::tool::ApprovalLevel::Auto
2874 }
2875 fn call<'a>(
2876 &'a self,
2877 _args: crate::tool::ToolArgs,
2878 _ctx: &'a crate::tool::ToolCtx,
2879 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
2880 Box::pin(async { Ok(crate::value::Value::Unit) })
2881 }
2882 }
2883 tools.register(std::sync::Arc::new(FakeMcpTool {
2884 name: "mcp.lark.send_mail".into(),
2885 desc: "send mail".into(),
2886 schema: serde_json::json!({"type":"object","properties":{}}),
2887 }));
2888 tools.register(std::sync::Arc::new(FakeMcpTool {
2889 name: "bash.exec".into(),
2890 desc: "exec".into(),
2891 schema: serde_json::json!({"type":"object","properties":{}}),
2892 }));
2893
2894 let src = r#"flow t() -> string {
2896 reply = llm.call(
2897 tools: ["bash.exec", "mcp.*"],
2898 )
2899 return "ok"
2900}"#;
2901 let file = atman_dsl::parse::parse_file(src).unwrap();
2902 let body = &file.flows[0].body;
2903 let tools_values: Vec<crate::value::Value> = match &body[0] {
2904 atman_dsl::ast::Stmt::Bind { value, .. } => match value {
2905 Expr::Node(atman_dsl::ast::Node::ToolCall { args, .. }) => {
2906 let tools_expr = args
2907 .iter()
2908 .find_map(|a| match a {
2909 atman_dsl::ast::Arg::Named { name, value } if name.name == "tools" => {
2910 Some(value.clone())
2911 }
2912 _ => None,
2913 })
2914 .unwrap();
2915 if let atman_dsl::ast::Expr::List(items) = tools_expr {
2916 items
2917 .iter()
2918 .map(|i| {
2919 if let atman_dsl::ast::Expr::Literal(
2920 atman_dsl::ast::Literal::Str(s),
2921 ) = i
2922 {
2923 crate::value::Value::Str(s.clone())
2924 } else {
2925 panic!("expected string literal in tools list");
2926 }
2927 })
2928 .collect()
2929 } else {
2930 panic!("expected list");
2931 }
2932 }
2933 _ => panic!("expected tool call"),
2934 },
2935 _ => panic!("expected bind stmt"),
2936 };
2937 let specs =
2938 crate::eval::llm_args::resolve_tool_specs_from_values(&tools_values, &tools).unwrap();
2939 assert_eq!(specs.len(), 2, "bash.exec + mcp.lark.send_mail = 2");
2940 let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
2941 assert!(names.contains(&"bash.exec".into()));
2942 assert!(names.contains(&"mcp.lark.send_mail".into()));
2943 }
2944}
2945
2946#[cfg(test)]
2947mod sanitize_tests {
2948 use super::*;
2949 use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
2950
2951 #[test]
2952 fn sanitize_fills_missing_tool_results() {
2953 let turn = crate::event::TurnId::now();
2954 let msgs = vec![
2955 Message {
2956 role: MessageRole::Assistant,
2957 parts: vec![MessagePart::ToolUse {
2958 id: "call_orphan".into(),
2959 name: "bash.exec".into(),
2960 input: serde_json::json!({}),
2961 }],
2962 turn_id: turn.clone(),
2963 origin: MessageOrigin::User,
2964 },
2965 Message {
2966 role: MessageRole::User,
2967 parts: vec![MessagePart::Text {
2968 text: "user interrupt".into(),
2969 }],
2970 turn_id: turn.clone(),
2971 origin: MessageOrigin::User,
2972 },
2973 ];
2974 let out = sanitize_tool_pairs(msgs);
2975 let has_filler = out.iter().any(|m| {
2976 m.parts.iter().any(|p| {
2977 matches!(p, MessagePart::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "call_orphan")
2978 })
2979 });
2980 assert!(
2981 has_filler,
2982 "should append error tool_result for orphan tool_use"
2983 );
2984 }
2985
2986 #[test]
2987 fn sanitize_noop_when_pairs_complete() {
2988 let turn = crate::event::TurnId::now();
2989 let msgs = vec![
2990 Message {
2991 role: MessageRole::Assistant,
2992 parts: vec![MessagePart::ToolUse {
2993 id: "call_ok".into(),
2994 name: "bash.exec".into(),
2995 input: serde_json::json!({}),
2996 }],
2997 turn_id: turn.clone(),
2998 origin: MessageOrigin::User,
2999 },
3000 Message {
3001 role: MessageRole::Tool,
3002 parts: vec![MessagePart::ToolResult {
3003 tool_use_id: "call_ok".into(),
3004 content: "done".into(),
3005 is_error: false,
3006 }],
3007 turn_id: turn.clone(),
3008 origin: MessageOrigin::User,
3009 },
3010 ];
3011 let out = sanitize_tool_pairs(msgs);
3012 assert_eq!(
3013 out.len(),
3014 2,
3015 "no filler should be added when pairs complete"
3016 );
3017 }
3018
3019 use crate::providers::mock::MockProvider;
3021
3022 fn stall_req(stall_secs: u64) -> crate::provider::LlmRequest {
3023 crate::provider::LlmRequest {
3024 model: "mock".into(),
3025 messages: vec![crate::provider::user_text_message("test")],
3026 system: None,
3027 input: crate::value::Value::Unit,
3028 schema: None,
3029 cache_prompt: false,
3030 tools: Vec::new(),
3031 thinking_enabled: false,
3032 stall_timeout_secs: stall_secs,
3033 }
3034 }
3035
3036 #[tokio::test]
3037 async fn stall_timeout_fires_when_no_chunks_arrive() {
3038 let provider = MockProvider::new("mock")
3040 .with_model("mock", Value::Str("hello world test".into()))
3041 .with_chunk_delay(std::time::Duration::from_secs(3));
3042
3043 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3044 let result = call_and_maybe_stream(
3045 &provider,
3046 stall_req(1),
3047 StreamCallCtx {
3048 stream_tx: Some(stream_tx),
3049 ..Default::default()
3050 },
3051 None,
3052 )
3053 .await;
3054 match result {
3055 Err(RuntimeError::ToolFailed(msg)) => {
3056 assert!(
3057 msg.contains("llm stall timeout after 1s"),
3058 "expected stall message, got: {msg}"
3059 );
3060 }
3061 other => panic!("expected ToolFailed stall timeout, got: {other:?}"),
3062 }
3063 }
3064
3065 #[tokio::test]
3066 async fn stall_timeout_does_not_fire_when_chunks_keep_coming() {
3067 let provider = MockProvider::new("mock")
3069 .with_model("mock", Value::Str("hello world test".into()))
3070 .with_chunk_delay(std::time::Duration::from_millis(100));
3071
3072 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3073 let result = call_and_maybe_stream(
3074 &provider,
3075 stall_req(2),
3076 StreamCallCtx {
3077 stream_tx: Some(stream_tx),
3078 ..Default::default()
3079 },
3080 None,
3081 )
3082 .await;
3083 match result {
3084 Ok(am) => {
3085 assert!(am.text_concat().contains("hello"));
3086 }
3087 other => panic!("expected Ok, got: {other:?}"),
3088 }
3089 }
3090
3091 #[tokio::test]
3092 async fn stall_timeout_zero_disables_detection() {
3093 let provider = MockProvider::new("mock")
3095 .with_model("mock", Value::Str("hello world test".into()))
3096 .with_chunk_delay(std::time::Duration::from_secs(3));
3097
3098 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3099 let result = call_and_maybe_stream(
3100 &provider,
3101 stall_req(0),
3102 StreamCallCtx {
3103 stream_tx: Some(stream_tx),
3104 ..Default::default()
3105 },
3106 None,
3107 )
3108 .await;
3109 match result {
3110 Ok(am) => {
3111 assert!(am.text_concat().contains("hello"));
3112 }
3113 other => panic!("expected Ok (stall disabled), got: {other:?}"),
3114 }
3115 }
3116
3117 #[tokio::test]
3118 async fn stall_timeout_resets_on_each_chunk() {
3119 let provider = MockProvider::new("mock")
3122 .with_model("mock", Value::Str("hello world test".into()))
3123 .with_chunk_delay(std::time::Duration::from_millis(800));
3124
3125 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3126 let result = call_and_maybe_stream(
3127 &provider,
3128 stall_req(1),
3129 StreamCallCtx {
3130 stream_tx: Some(stream_tx),
3131 ..Default::default()
3132 },
3133 None,
3134 )
3135 .await;
3136 match result {
3137 Ok(am) => {
3138 assert!(am.text_concat().contains("hello"));
3139 }
3140 other => panic!("expected Ok (timer reset each chunk), got: {other:?}"),
3141 }
3142 }
3143
3144 #[tokio::test]
3145 async fn stall_timeout_fires_between_first_and_second_chunk() {
3146 let provider = MockProvider::new("mock")
3148 .with_model("mock", Value::Str("hello world test".into()))
3149 .with_chunk_delay(std::time::Duration::from_secs(2));
3150
3151 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3152 let result = call_and_maybe_stream(
3153 &provider,
3154 stall_req(1),
3155 StreamCallCtx {
3156 stream_tx: Some(stream_tx),
3157 ..Default::default()
3158 },
3159 None,
3160 )
3161 .await;
3162 assert!(
3163 matches!(&result, Err(RuntimeError::ToolFailed(msg)) if msg.contains("stall timeout")),
3164 "expected stall timeout, got: {result:?}"
3165 );
3166 }
3167}