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