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