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_cancel(
1418 &resolver,
1419 id,
1420 "form_ask",
1421 payload,
1422 timeout,
1423 &ctx.tool_ctx.cancel,
1424 )
1425 .await;
1426 let answer: crate::form::FormAnswer = match result {
1427 Ok(v) => {
1428 serde_json::from_value(v).unwrap_or(crate::form::FormAnswer::Cancelled)
1429 }
1430 Err(_) => crate::form::FormAnswer::Cancelled,
1431 };
1432 return Value::Bool(matches!(
1433 answer,
1434 crate::form::FormAnswer::Confirmed { value: true }
1435 ));
1436 }
1437 let Some(session) = ctx.session_runtime.as_ref() else {
1438 return Value::Bool(true);
1439 };
1440 let forms = session.forms();
1441 if forms.subscriber_count() == 0 {
1442 return Value::Bool(true);
1443 }
1444 let Some(run_id) = ctx.flow_run_id.clone() else {
1445 return Value::Bool(true);
1446 };
1447 let pending = crate::form::PendingForm {
1448 form_id: uuid::Uuid::now_v7().to_string(),
1449 run_id,
1450 tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
1451 form: crate::form::CompositeForm {
1452 questions: vec![crate::form::FormQuestion {
1453 id: "question".into(),
1454 kind: confirm_kind.clone(),
1455 }],
1456 },
1457 kind: confirm_kind,
1458 emitted_at: chrono::Utc::now(),
1459 };
1460 let rx = forms.request(pending);
1461 let submission = rx.await.unwrap_or(crate::form::FormSubmission::Rejected);
1462 Value::Bool(matches!(
1463 submission,
1464 crate::form::FormSubmission::Submitted { answers }
1465 if matches!(answers.first(), Some(crate::form::FormAnswer::Confirmed { value: true }))
1466 ))
1467 }
1468 Node::FixUntilTestPasses { kwargs } => eval_fix_until_test_passes(kwargs, env, ctx).await,
1469 Node::Message { role, args } => eval_message_node(*role, args, env, ctx).await,
1470 Node::Subflow { name, args } => {
1471 let Some(target) = ctx.flows.get(&name.name) else {
1472 return Value::Err(RuntimeError::UndefinedTool(format!(
1473 "subflow({})",
1474 name.name
1475 )));
1476 };
1477 let mut bindings = Vec::with_capacity(args.len());
1478 for (i, arg) in args.iter().enumerate() {
1479 let (param_name, value) = match arg {
1480 Arg::Positional(e) => {
1481 let Some(p) = target.params.get(i) else {
1482 return Value::Err(RuntimeError::MissingArg(format!(
1483 "subflow({}): too many positional args",
1484 name.name
1485 )));
1486 };
1487 let v = eval_expr(e, env, ctx).await;
1488 (p.name.name.clone(), v)
1489 }
1490 Arg::Named { name: n, value } => {
1491 let v = eval_expr(value, env, ctx).await;
1492 (n.name.clone(), v)
1493 }
1494 };
1495 if value.is_err() {
1496 return value;
1497 }
1498 bindings.push((param_name, value));
1499 }
1500 let mut sub_env = Env::new();
1501 for (n, v) in bindings {
1502 sub_env.bind(n, v);
1503 }
1504 let sub_run_id = crate::event::FlowRunId::now();
1505 let flow_registry = match ctx.tool_ctx.flow_registry.clone() {
1506 Some(registry) => registry,
1507 None => {
1508 return Value::Err(RuntimeError::ToolFailed(
1509 "subflow: trusted flow registry is unavailable".into(),
1510 ));
1511 }
1512 };
1513 let Some(parent_run_id) = ctx
1514 .tool_ctx
1515 .flow_identity
1516 .as_ref()
1517 .map(|identity| identity.run_id.clone())
1518 else {
1519 return Value::Err(RuntimeError::ToolFailed(
1520 "subflow: trusted parent flow identity is unavailable".into(),
1521 ));
1522 };
1523 let child_identity = match flow_registry.register_child(
1524 &parent_run_id,
1525 sub_run_id.clone(),
1526 crate::flow_authority::InvocationKind::InlineSubflow,
1527 crate::flow_authority::contract_allows_shell(target.contract.as_ref()),
1528 crate::flow_authority::ChildWorkspaceAuthority::Inherit,
1529 ) {
1530 Ok(identity) => identity,
1531 Err(error) => return Value::Err(error),
1532 };
1533 let lifecycle_guard = flow_registry.lifecycle_guard(&sub_run_id);
1534 let _block_guard = match flow_registry.block_on_descendant(&parent_run_id, &sub_run_id)
1535 {
1536 Ok(guard) => guard,
1537 Err(error) => return Value::Err(error),
1538 };
1539 if let Some(sink) = ctx.events {
1540 sink.emit(crate::event::Event::FlowStart {
1541 run_id: sub_run_id.clone(),
1542 flow_name: name.name.clone(),
1543 parent_run_id: Some(parent_run_id.clone()),
1544 parent_node_id: ctx.current_node_id.clone(),
1545 spawned: false,
1546 });
1547 }
1548 if let Some(session) = ctx.session_runtime.as_ref() {
1549 let _ = session
1550 .stream_tx()
1551 .send(crate::stream::StreamFrame::FlowStart {
1552 run_id: sub_run_id.0.to_string(),
1553 flow_name: name.name.clone(),
1554 parent_run_id: Some(parent_run_id.0.to_string()),
1555 parent_node_id: ctx.current_node_id.clone(),
1556 });
1557 } else if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1558 let _ = tx.send(crate::stream::StreamFrame::FlowStart {
1559 run_id: sub_run_id.0.to_string(),
1560 flow_name: name.name.clone(),
1561 parent_run_id: Some(parent_run_id.0.to_string()),
1562 parent_node_id: ctx.current_node_id.clone(),
1563 });
1564 }
1565 let mut sub_tool_ctx = ctx.tool_ctx.clone();
1566 sub_tool_ctx.flow_run_id = Some(sub_run_id.clone());
1567 sub_tool_ctx.flow_identity = Some(child_identity);
1568 let sub_ctx = EvalCtx {
1569 tool_ctx: &sub_tool_ctx,
1570 contract: target.contract.as_ref(),
1571 flow_run_id: Some(sub_run_id.clone()),
1572 current_node_id: None,
1573 ..ctx.clone()
1574 };
1575 let outcome = crate::exec::exec_stmts(&target.body, &mut sub_env, &sub_ctx).await;
1576 drop(lifecycle_guard);
1577 let (result, status, ok) = match outcome {
1578 crate::exec::StmtOutcome::Return(v) => (v, crate::event::FlowStatus::Ok, true),
1579 crate::exec::StmtOutcome::Err(e) => {
1580 let status = if matches!(&e, crate::error::RuntimeError::Cancelled(_)) {
1581 crate::event::FlowStatus::Cancelled
1582 } else {
1583 crate::event::FlowStatus::Errored {
1584 message: format!("{e}"),
1585 }
1586 };
1587 (Value::Err(e.clone()), status, false)
1588 }
1589 crate::exec::StmtOutcome::Continue => {
1590 (Value::Unit, crate::event::FlowStatus::Ok, true)
1591 }
1592 crate::exec::StmtOutcome::LoopBreak => {
1593 (Value::Unit, crate::event::FlowStatus::Ok, true)
1594 }
1595 crate::exec::StmtOutcome::LoopContinue => {
1596 (Value::Unit, crate::event::FlowStatus::Ok, true)
1597 }
1598 };
1599 let cancelled = matches!(status, crate::event::FlowStatus::Cancelled);
1600 if let Some(sink) = ctx.events {
1601 sink.emit(crate::event::Event::FlowEnd {
1602 run_id: sub_run_id.clone(),
1603 flow_name: name.name.clone(),
1604 status,
1605 });
1606 }
1607 if let Some(tx) = ctx.tool_ctx.stream_tx.as_ref() {
1608 let _ = tx.send(crate::stream::StreamFrame::FlowDone {
1609 run_id: sub_run_id.0.to_string(),
1610 flow_name: name.name.clone(),
1611 ok,
1612 cancelled,
1613 suicide: false,
1614 });
1615 }
1616 result
1617 }
1618 }
1619}
1620
1621fn session_fs_access_policy(session: &crate::session::Session) -> crate::fs_access::FsAccessPolicy {
1622 let workspace = session
1623 .meta()
1624 .and_then(|m| m.project_root)
1625 .or_else(|| std::env::current_dir().ok());
1626 let mode = session
1627 .fs_access_mode()
1628 .unwrap_or(crate::fs_access::FsAccessMode::WorkspaceWrite);
1629 crate::fs_access::FsAccessPolicy { mode, workspace }
1630}
1631
1632fn tool_name(path: &[atman_dsl::ast::Ident]) -> String {
1633 let parts: Vec<&str> = path.iter().map(|i| i.name.as_str()).collect();
1634 parts.join(".")
1635}
1636
1637async fn eval_fix_until_test_passes<'a>(
1638 kwargs: &'a atman_dsl::ast::Kwargs,
1639 env: &'a Env,
1640 ctx: &'a EvalCtx<'a>,
1641) -> Value {
1642 let mut edit_flow_expr: Option<&Expr> = None;
1643 let mut test_expr: Option<&Expr> = None;
1644 let mut on_giveup_expr: Option<&Expr> = None;
1645 let mut max_iters: u32 = 5;
1646 let mut target_path: Option<std::path::PathBuf> = None;
1647
1648 for (k, v) in kwargs {
1649 match k.name.as_str() {
1650 "edit_flow" => edit_flow_expr = Some(v),
1651 "test" => test_expr = Some(v),
1652 "on_giveup" => on_giveup_expr = Some(v),
1653 "max_iters" => match eval_expr(v, env, ctx).await {
1654 Value::Int(n) if n > 0 => max_iters = n as u32,
1655 other => {
1656 return Value::Err(RuntimeError::TypeMismatch {
1657 expected: "positive int (max_iters)".into(),
1658 actual: other.kind_name().into(),
1659 });
1660 }
1661 },
1662 "target" => match eval_expr(v, env, ctx).await {
1663 Value::Path(p) => target_path = Some(p),
1664 Value::Str(s) => target_path = Some(std::path::PathBuf::from(s)),
1665 Value::Unit => {}
1666 other => {
1667 return Value::Err(RuntimeError::TypeMismatch {
1668 expected: "path (target)".into(),
1669 actual: other.kind_name().into(),
1670 });
1671 }
1672 },
1673 _ => {}
1674 }
1675 }
1676
1677 let Some(edit_flow_expr) = edit_flow_expr else {
1678 return Value::Err(RuntimeError::MissingArg(
1679 "fix_until_test_passes.edit_flow".into(),
1680 ));
1681 };
1682 let Some(test_expr) = test_expr else {
1683 return Value::Err(RuntimeError::MissingArg(
1684 "fix_until_test_passes.test".into(),
1685 ));
1686 };
1687
1688 let pristine: Option<String> = match &target_path {
1689 Some(p) => match tokio::fs::read_to_string(p).await {
1690 Ok(s) => Some(s),
1691 Err(e) => {
1692 return Value::Err(RuntimeError::ToolFailed(format!(
1693 "fix_until_test_passes: cannot read target {}: {e}",
1694 p.display()
1695 )));
1696 }
1697 },
1698 None => None,
1699 };
1700
1701 let mut prev_fail = String::new();
1702 let mut last_test_result: Option<Value> = None;
1703
1704 for iter in 0..max_iters {
1705 let mut loop_env = env.clone();
1706 loop_env.bind("iter", Value::Int(iter as i64));
1707 loop_env.bind("prev_fail", Value::Str(prev_fail.clone()));
1708
1709 let edit_v = eval_expr(edit_flow_expr, &loop_env, ctx).await;
1710 if edit_v.is_err() {
1711 return edit_v;
1712 }
1713 loop_env.bind("last_edit", edit_v);
1714
1715 let test_v = eval_expr(test_expr, &loop_env, ctx).await;
1716 if test_v.is_err() {
1717 return test_v;
1718 }
1719 let exit = test_v
1720 .field("exit_code")
1721 .or_else(|| test_v.field("exit"))
1722 .and_then(|v| match v {
1723 Value::Int(n) => Some(*n),
1724 _ => None,
1725 });
1726 last_test_result = Some(test_v.clone());
1727 if let Some(0) = exit {
1728 return Value::Struct(vec![
1729 ("status".into(), Value::Str("passed".into())),
1730 ("iters".into(), Value::Int((iter + 1) as i64)),
1731 ("test".into(), test_v),
1732 ]);
1733 }
1734 let stderr_tail = test_v
1735 .field("stderr_tail")
1736 .or_else(|| test_v.field("output"))
1737 .and_then(|v| match v {
1738 Value::Str(s) => Some(s.clone()),
1739 _ => None,
1740 })
1741 .unwrap_or_default();
1742 let stdout_tail = test_v
1743 .field("stdout_tail")
1744 .and_then(|v| match v {
1745 Value::Str(s) => Some(s.clone()),
1746 _ => None,
1747 })
1748 .unwrap_or_default();
1749 prev_fail = format!(
1750 "iter {iter} exit={:?}\n--- stderr ---\n{stderr_tail}\n--- stdout ---\n{stdout_tail}",
1751 exit
1752 );
1753
1754 if let (Some(target), Some(pristine)) = (&target_path, &pristine)
1755 && let Err(e) = tokio::fs::write(target, pristine.as_bytes()).await
1756 {
1757 return Value::Err(RuntimeError::ToolFailed(format!(
1758 "fix_until_test_passes: revert failed on {}: {e}",
1759 target.display()
1760 )));
1761 }
1762 }
1763
1764 if let Some(giveup) = on_giveup_expr {
1765 let mut giveup_env = env.clone();
1766 giveup_env.bind("iters", Value::Int(max_iters as i64));
1767 giveup_env.bind("prev_fail", Value::Str(prev_fail));
1768 return eval_expr(giveup, &giveup_env, ctx).await;
1769 }
1770
1771 Value::Struct(vec![
1772 ("status".into(), Value::Str("gave_up".into())),
1773 ("iters".into(), Value::Int(max_iters as i64)),
1774 ("last_test".into(), last_test_result.unwrap_or(Value::Unit)),
1775 ])
1776}
1777
1778async fn eval_message_node<'a>(
1779 ast_role: atman_dsl::ast::MessageRole,
1780 args: &'a [Arg],
1781 env: &'a Env,
1782 ctx: &'a EvalCtx<'a>,
1783) -> Value {
1784 use crate::message::{
1785 ImageData, ImageSource, Message, MessageOrigin, MessagePart, MessageRole,
1786 };
1787
1788 let role = match ast_role {
1789 atman_dsl::ast::MessageRole::User => MessageRole::User,
1790 atman_dsl::ast::MessageRole::Assistant => MessageRole::Assistant,
1791 atman_dsl::ast::MessageRole::System => MessageRole::System,
1792 atman_dsl::ast::MessageRole::Tool => MessageRole::Tool,
1793 };
1794 let turn_id = ctx
1795 .turn_id
1796 .clone()
1797 .unwrap_or_else(crate::event::TurnId::now);
1798
1799 let mut positional = Vec::new();
1800 let mut named: Vec<(String, Value)> = Vec::new();
1801 let mut attachment_paths_raw: Option<Vec<std::path::PathBuf>> = None;
1802 for arg in args {
1803 match arg {
1804 Arg::Positional(e) => {
1805 let v = eval_expr(e, env, ctx).await;
1806 if v.is_err() {
1807 return v;
1808 }
1809 positional.push(v);
1810 }
1811 Arg::Named { name, value } => {
1812 if name.name == "attachments" {
1813 if let Expr::List(items) = value {
1814 let mut collected = Vec::with_capacity(items.len());
1815 let mut all_fileref = true;
1816 for it in items {
1817 if let Expr::FileRef(f) = it {
1818 collected.push(std::path::PathBuf::from(&f.path));
1819 } else {
1820 all_fileref = false;
1821 break;
1822 }
1823 }
1824 if all_fileref {
1825 attachment_paths_raw = Some(collected);
1826 continue;
1827 }
1828 }
1829 }
1830 let v = eval_expr(value, env, ctx).await;
1831 if v.is_err() {
1832 return v;
1833 }
1834 named.push((name.name.clone(), v));
1835 }
1836 }
1837 }
1838 let take_named = |k: &str, named: &mut Vec<(String, Value)>| -> Option<Value> {
1839 let pos = named.iter().position(|(n, _)| n == k)?;
1840 Some(named.remove(pos).1)
1841 };
1842
1843 if role == MessageRole::Tool {
1844 let tool_use_id = match positional.first().or(take_named("id", &mut named).as_ref()) {
1845 Some(Value::Str(s)) => s.clone(),
1846 Some(other) => {
1847 return Value::Err(RuntimeError::TypeMismatch {
1848 expected: "string (tool_use_id)".into(),
1849 actual: other.kind_name().into(),
1850 });
1851 }
1852 None => {
1853 return Value::Err(RuntimeError::MissingArg("tool_result: id".into()));
1854 }
1855 };
1856 let content = match positional
1857 .get(1)
1858 .or(take_named("content", &mut named).as_ref())
1859 {
1860 Some(Value::Str(s)) => s.clone(),
1861 Some(other) => {
1862 return Value::Err(RuntimeError::TypeMismatch {
1863 expected: "string (content)".into(),
1864 actual: other.kind_name().into(),
1865 });
1866 }
1867 None => {
1868 return Value::Err(RuntimeError::MissingArg("tool_result: content".into()));
1869 }
1870 };
1871 let is_error = match take_named("is_error", &mut named) {
1872 Some(Value::Bool(b)) => b,
1873 Some(other) => {
1874 return Value::Err(RuntimeError::TypeMismatch {
1875 expected: "bool (is_error)".into(),
1876 actual: other.kind_name().into(),
1877 });
1878 }
1879 None => false,
1880 };
1881 return Value::Message(Message {
1882 role,
1883 parts: vec![MessagePart::ToolResult {
1884 tool_use_id,
1885 content,
1886 is_error,
1887 }],
1888 turn_id,
1889 origin: MessageOrigin::User,
1890 });
1891 }
1892
1893 let text = match positional.first() {
1894 Some(Value::Str(s)) => Some(s.clone()),
1895 Some(other) => {
1896 return Value::Err(RuntimeError::TypeMismatch {
1897 expected: "string (message text)".into(),
1898 actual: other.kind_name().into(),
1899 });
1900 }
1901 None => None,
1902 };
1903 let attachment_paths: Vec<std::path::PathBuf> = if let Some(raw) = attachment_paths_raw {
1904 raw
1905 } else {
1906 match take_named("attachments", &mut named) {
1907 Some(Value::List(items)) => {
1908 let mut ps = Vec::with_capacity(items.len());
1909 for it in items {
1910 match it {
1911 Value::Path(p) => ps.push(p),
1912 Value::Str(s) => ps.push(std::path::PathBuf::from(s)),
1913 other => {
1914 return Value::Err(RuntimeError::TypeMismatch {
1915 expected: "path (attachment)".into(),
1916 actual: other.kind_name().into(),
1917 });
1918 }
1919 }
1920 }
1921 ps
1922 }
1923 Some(other) => {
1924 return Value::Err(RuntimeError::TypeMismatch {
1925 expected: "list of path".into(),
1926 actual: other.kind_name().into(),
1927 });
1928 }
1929 None => Vec::new(),
1930 }
1931 };
1932
1933 let mut parts: Vec<MessagePart> =
1934 Vec::with_capacity(attachment_paths.len() + usize::from(text.is_some()));
1935 for path in attachment_paths {
1936 let source = if let Some(session) = ctx.session_runtime.as_ref() {
1937 match session.import_image_path(&path) {
1938 Ok(source) => source,
1939 Err(error) => return Value::Err(error),
1940 }
1941 } else {
1942 let media_type = guess_image_mime(&path).unwrap_or_else(|| "image/png".to_string());
1943 ImageSource {
1944 media_type,
1945 data: ImageData::Path { path },
1946 detail: crate::provider::ImageDetail::Auto,
1947 }
1948 };
1949 parts.push(MessagePart::Image { source });
1950 }
1951 if let Some(t) = text {
1952 parts.push(MessagePart::Text { text: t });
1953 }
1954
1955 Value::Message(Message {
1956 role,
1957 parts,
1958 turn_id,
1959 origin: MessageOrigin::User,
1960 })
1961}
1962
1963pub(super) fn render_injections(injections: &[crate::injection::Injection]) -> String {
1964 use crate::injection::{InjectionLevel, InjectionSource};
1965 let has_user = injections
1966 .iter()
1967 .any(|i| matches!(i.source, InjectionSource::User));
1968 let has_watcher = injections
1969 .iter()
1970 .any(|i| matches!(i.source, InjectionSource::Watcher { .. }));
1971 let mut out = String::new();
1972 if has_user {
1973 out.push_str(
1974 "The user sent the following steering message(s) while you were working. \
1975 Apply them to your next step if still relevant.\n\n",
1976 );
1977 }
1978 if has_watcher {
1979 out.push_str("A background watcher detected the following event(s):\n\n");
1980 }
1981 for inj in injections {
1982 let (tag, source_attr) = match &inj.source {
1983 InjectionSource::User => match inj.level {
1984 InjectionLevel::L2CourseCorrect => ("user_correction", "user".to_string()),
1985 _ => ("user_nudge", "user".to_string()),
1986 },
1987 InjectionSource::Watcher {
1988 watcher_id,
1989 kind,
1990 handle,
1991 } => (
1992 "watcher_event",
1993 format!("{kind} '{handle}' watcher {watcher_id}"),
1994 ),
1995 };
1996 out.push_str(&format!(
1997 "<{tag} id=\"{}\" ts=\"{}\" source=\"{}\">\n{}\n</{tag}>\n",
1998 inj.id.0,
1999 inj.created_at.to_rfc3339(),
2000 source_attr,
2001 inj.text
2002 ));
2003 }
2004 out
2005}
2006
2007fn guess_image_mime(path: &std::path::Path) -> Option<String> {
2008 let ext = path
2009 .extension()
2010 .and_then(|s| s.to_str())?
2011 .to_ascii_lowercase();
2012 Some(
2013 match ext.as_str() {
2014 "png" => "image/png",
2015 "jpg" | "jpeg" => "image/jpeg",
2016 "gif" => "image/gif",
2017 "webp" => "image/webp",
2018 _ => return None,
2019 }
2020 .to_string(),
2021 )
2022}
2023
2024fn contract_allows_shell(contract: Option<&atman_dsl::ast::Contract>) -> bool {
2025 crate::flow_authority::contract_allows_shell(contract)
2026}
2027
2028pub struct TruncationStat {
2029 pub original_chars: usize,
2030 pub result_chars: usize,
2031 pub dropped_chars: usize,
2032 pub budget_tokens: u64,
2033}
2034
2035pub fn truncate_prompt_to_budget(prompt: String, budget_tokens: u64) -> String {
2036 truncate_prompt_to_budget_tracked(prompt, budget_tokens).0
2037}
2038
2039pub fn truncate_prompt_to_budget_tracked(
2040 prompt: String,
2041 budget_tokens: u64,
2042) -> (String, Option<TruncationStat>) {
2043 let budget_chars = budget_tokens.saturating_mul(4) as usize;
2044 if prompt.len() <= budget_chars {
2045 return (prompt, None);
2046 }
2047 let head_chars = budget_chars * 4 / 10;
2048 let tail_chars = budget_chars * 4 / 10;
2049 if head_chars + tail_chars >= prompt.len() {
2050 return (prompt, None);
2051 }
2052 let original_chars = prompt.len();
2053 let head_end = char_boundary(&prompt, head_chars, false);
2054 let tail_start = char_boundary(&prompt, prompt.len().saturating_sub(tail_chars), true);
2055 let head = &prompt[..head_end];
2056 let tail = &prompt[tail_start..];
2057 let dropped = original_chars - head.len() - tail.len();
2058 let result = format!("{head}\n\n[... truncated {dropped} chars ...]\n\n{tail}");
2059 let stat = TruncationStat {
2060 original_chars,
2061 result_chars: result.len(),
2062 dropped_chars: dropped,
2063 budget_tokens,
2064 };
2065 (result, Some(stat))
2066}
2067
2068fn char_boundary(s: &str, target: usize, round_up: bool) -> usize {
2069 let mut idx = target.min(s.len());
2070 while idx > 0 && idx < s.len() && !s.is_char_boundary(idx) {
2071 if round_up {
2072 idx += 1;
2073 } else {
2074 idx -= 1;
2075 }
2076 }
2077 idx
2078}
2079
2080fn is_type_annotation(path: &[atman_dsl::ast::Ident]) -> bool {
2082 if path.len() != 1 {
2083 return false;
2084 }
2085 matches!(
2086 path[0].name.as_str(),
2087 "bool" | "int" | "float" | "string" | "path" | "bytes" | "duration"
2088 )
2089}
2090
2091fn eval_literal(lit: &Literal) -> Value {
2092 match lit {
2093 Literal::Str(s) => Value::Str(s.clone()),
2094 Literal::Int(n) => Value::Int(*n),
2095 Literal::Float(f) => Value::Float(*f),
2096 Literal::Bool(b) => Value::Bool(*b),
2097 }
2098}
2099
2100fn eval_binop(op: BinOp, l: &Value, r: &Value) -> Value {
2101 match op {
2102 BinOp::Eq => Value::Bool(value_eq(l, r)),
2103 BinOp::Ne => Value::Bool(!value_eq(l, r)),
2104 BinOp::Lt => value_cmp(l, r, |a, b| a < b, |a, b| a < b, |a, b| a < b),
2105 BinOp::Le => value_cmp(l, r, |a, b| a <= b, |a, b| a <= b, |a, b| a <= b),
2106 BinOp::Gt => value_cmp(l, r, |a, b| a > b, |a, b| a > b, |a, b| a > b),
2107 BinOp::Ge => value_cmp(l, r, |a, b| a >= b, |a, b| a >= b, |a, b| a >= b),
2108 BinOp::And => match (l, r) {
2109 (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a && *b),
2110 _ => type_mismatch("bool && bool", l, r),
2111 },
2112 BinOp::Or => match (l, r) {
2113 (Value::Bool(a), Value::Bool(b)) => Value::Bool(*a || *b),
2114 _ => type_mismatch("bool || bool", l, r),
2115 },
2116 BinOp::Add => match (l, r) {
2117 (Value::Int(a), Value::Int(b)) => Value::Int(a + b),
2118 (Value::Float(a), Value::Float(b)) => Value::Float(a + b),
2119 (Value::Str(a), Value::Str(b)) => Value::Str(format!("{a}{b}")),
2120 (Value::Str(a), Value::Path(b)) => Value::Str(format!("{a}{}", b.display())),
2121 (Value::Path(a), Value::Str(b)) => Value::Str(format!("{}{b}", a.display())),
2122 _ => type_mismatch(
2123 "int+int | float+float | string+string | string+path | path+string",
2124 l,
2125 r,
2126 ),
2127 },
2128 BinOp::Sub => 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::Mul => match (l, r) {
2134 (Value::Int(a), Value::Int(b)) => Value::Int(a * b),
2135 (Value::Float(a), Value::Float(b)) => Value::Float(a * b),
2136 _ => type_mismatch("int*int | float*float", l, r),
2137 },
2138 BinOp::Div => match (l, r) {
2139 (Value::Int(_), Value::Int(0)) => {
2140 Value::Err(RuntimeError::ToolFailed("integer div by zero".into()))
2141 }
2142 (Value::Int(a), Value::Int(b)) => Value::Int(a / b),
2143 (Value::Float(a), Value::Float(b)) => Value::Float(a / b),
2144 _ => type_mismatch("int/int | float/float", l, r),
2145 },
2146 BinOp::Mod => match (l, r) {
2147 (Value::Int(_), Value::Int(0)) => {
2148 Value::Err(RuntimeError::ToolFailed("integer mod by zero".into()))
2149 }
2150 (Value::Int(a), Value::Int(b)) => Value::Int(a % b),
2151 (Value::Float(a), Value::Float(b)) => Value::Float(a % b),
2152 _ => type_mismatch("int%int | float%float", l, r),
2153 },
2154 }
2155}
2156
2157fn eval_unop(op: UnOp, v: &Value) -> Value {
2158 match op {
2159 UnOp::Not => match v {
2160 Value::Bool(b) => Value::Bool(!b),
2161 other => Value::Err(RuntimeError::TypeMismatch {
2162 expected: "bool".into(),
2163 actual: other.kind_name().into(),
2164 }),
2165 },
2166 UnOp::Neg => match v {
2167 Value::Int(n) => Value::Int(-n),
2168 Value::Float(n) => Value::Float(-n),
2169 other => Value::Err(RuntimeError::TypeMismatch {
2170 expected: "int or float".into(),
2171 actual: other.kind_name().into(),
2172 }),
2173 },
2174 }
2175}
2176
2177fn value_eq(l: &Value, r: &Value) -> bool {
2178 match (l, r) {
2179 (Value::Unit, Value::Unit) => true,
2180 (Value::Bool(a), Value::Bool(b)) => a == b,
2181 (Value::Int(a), Value::Int(b)) => a == b,
2182 (Value::Float(a), Value::Float(b)) => a == b,
2183 (Value::Str(a), Value::Str(b)) => a == b,
2184 (Value::Path(a), Value::Path(b)) => a == b,
2185 _ => false,
2186 }
2187}
2188
2189fn value_cmp(
2190 l: &Value,
2191 r: &Value,
2192 int_cmp: fn(i64, i64) -> bool,
2193 float_cmp: fn(f64, f64) -> bool,
2194 str_cmp: fn(&str, &str) -> bool,
2195) -> Value {
2196 match (l, r) {
2197 (Value::Int(a), Value::Int(b)) => Value::Bool(int_cmp(*a, *b)),
2198 (Value::Float(a), Value::Float(b)) => Value::Bool(float_cmp(*a, *b)),
2199 (Value::Str(a), Value::Str(b)) => Value::Bool(str_cmp(a, b)),
2200 _ => type_mismatch("comparable pair", l, r),
2201 }
2202}
2203
2204fn type_mismatch(expected: &str, l: &Value, r: &Value) -> Value {
2205 Value::Err(RuntimeError::TypeMismatch {
2206 expected: expected.into(),
2207 actual: format!("{} vs {}", l.kind_name(), r.kind_name()),
2208 })
2209}
2210
2211#[cfg(test)]
2212mod tests {
2213 use super::*;
2214 use atman_dsl::parse::parse_file;
2215
2216 fn authorized_eval_tool_ctx(workspace: Option<&std::path::Path>) -> ToolCtx {
2217 let trust = crate::trust::TrustConfig {
2218 mode: crate::trust::TrustMode::Eager,
2219 escalation: crate::trust::EscalationPolicy::Allow,
2220 ..crate::trust::TrustConfig::default()
2221 };
2222 let flows = std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::default());
2223 let run_id = crate::event::FlowRunId::now();
2224 let identity = flows
2225 .register_root(
2226 "eval-test".into(),
2227 run_id.clone(),
2228 crate::flow_authority::EffectiveAuthority::root(&trust, true, None),
2229 )
2230 .unwrap();
2231 let broker = crate::permission::PermissionBroker::shared(std::sync::Arc::clone(&flows));
2232 let mut ctx = ToolCtx::new()
2233 .with_flow_registry(flows)
2234 .with_permission_broker(broker)
2235 .with_trust(trust)
2236 .with_approval(std::sync::Arc::new(crate::session::ApprovalRegistry::new()))
2237 .with_anchors(None, Some(run_id), None);
2238 ctx.flow_identity = Some(identity);
2239 if let Some(path) = workspace {
2240 ctx = ctx.with_workspace(crate::git_workspace::WorkspaceBinding {
2241 workspace_id: "eval-test".into(),
2242 path: path.to_path_buf(),
2243 repository_root: path.to_path_buf(),
2244 branch: None,
2245 });
2246 }
2247 ctx
2248 }
2249
2250 #[test]
2251 fn spawned_tool_context_resolves_working_directory_record_body() {
2252 let temp = tempfile::tempdir().unwrap();
2253 let ctx = authorized_eval_tool_ctx(Some(temp.path()))
2254 .with_history_segment(crate::tool::HistorySegment::Spawned);
2255 let rendered = tool_context_working_directory_context(&ctx).unwrap();
2256
2257 assert_eq!(
2258 std::path::PathBuf::from(&rendered).canonicalize().unwrap(),
2259 temp.path().canonicalize().unwrap()
2260 );
2261 assert!(!rendered.contains("{pwd}"));
2262 }
2263
2264 #[test]
2265 fn char_boundary_rounds_around_multibyte_characters() {
2266 let text = "a你😀b";
2267 assert_eq!(char_boundary(text, 2, false), 1);
2268 assert_eq!(char_boundary(text, 2, true), 4);
2269 assert_eq!(char_boundary(text, 6, false), 4);
2270 assert_eq!(char_boundary(text, 6, true), 8);
2271 assert_eq!(char_boundary(text, 8, false), 8);
2272 assert_eq!(char_boundary(text, 99, false), text.len());
2273 }
2274
2275 #[test]
2276 fn parse_context_mode_handles_variants() {
2277 assert!(matches!(
2278 parse_context_mode("session"),
2279 ContextMode::Session
2280 ));
2281 assert!(matches!(parse_context_mode("none"), ContextMode::None));
2282 assert!(matches!(parse_context_mode(""), ContextMode::None));
2283 assert!(matches!(
2284 parse_context_mode(" session "),
2285 ContextMode::Session
2286 ));
2287 match parse_context_mode("session_recent(5)") {
2288 ContextMode::SessionRecent(n) => assert_eq!(n, 5),
2289 other => panic!("expected SessionRecent(5), got {other:?}"),
2290 }
2291 match parse_context_mode("session_recent") {
2292 ContextMode::SessionRecent(n) => assert_eq!(n, 10),
2293 other => panic!("expected SessionRecent(10), got {other:?}"),
2294 }
2295 assert!(matches!(parse_context_mode("garbage"), ContextMode::None));
2296 }
2297
2298 async fn eval_snippet(expr_src: &str) -> Value {
2299 let src = format!("flow t() {{\n return {expr_src}\n}}\n");
2300 let file = parse_file(&src).expect("parse test snippet");
2301 let tools = ToolRegistry::new();
2302 let tool_ctx = ToolCtx::new();
2303 let providers = crate::provider::ProviderRegistry::new();
2304 let flows = std::collections::HashMap::new();
2305 let ctx = EvalCtx {
2306 tools: &tools,
2307 tool_ctx: &tool_ctx,
2308 providers: &providers,
2309 flows: &flows,
2310 contract: None,
2311 events: None,
2312 turn_id: None,
2313 flow_run_id: None,
2314 session_runtime: None,
2315 flow_cancel: tokio_util::sync::CancellationToken::new(),
2316 safety: None,
2317 current_node_id: None,
2318 source_dir: None,
2319 };
2320 let stmt = &file.flows[0].body[0];
2321 if let atman_dsl::ast::Stmt::Return { value } = stmt {
2322 eval_expr(value, &Env::new(), &ctx).await
2323 } else {
2324 panic!("expected return statement");
2325 }
2326 }
2327
2328 #[tokio::test]
2329 async fn literals_evaluate() {
2330 assert!(matches!(eval_snippet("42").await, Value::Int(42)));
2331 assert!(matches!(eval_snippet("true").await, Value::Bool(true)));
2332 assert!(matches!(
2333 eval_snippet(r#""hello""#).await,
2334 Value::Str(s) if s == "hello"
2335 ));
2336 }
2337
2338 #[tokio::test]
2339 async fn undefined_ident_yields_err_value() {
2340 assert!(matches!(
2341 eval_snippet("missing").await,
2342 Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2343 ));
2344 }
2345
2346 #[tokio::test]
2347 async fn binary_arithmetic_and_comparison() {
2348 assert!(matches!(eval_snippet("1 == 1").await, Value::Bool(true)));
2349 assert!(matches!(eval_snippet("2 < 3").await, Value::Bool(true)));
2350 assert!(matches!(
2351 eval_snippet(r#""a" + "b""#).await,
2352 Value::Str(s) if s == "ab"
2353 ));
2354 }
2355
2356 #[tokio::test]
2357 async fn type_mismatch_bubbles_up() {
2358 assert!(matches!(
2359 eval_snippet(r#"1 + "x""#).await,
2360 Value::Err(RuntimeError::TypeMismatch { .. })
2361 ));
2362 }
2363
2364 #[tokio::test]
2365 async fn err_short_circuits_binary() {
2366 assert!(matches!(
2367 eval_snippet("missing == 1").await,
2368 Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2369 ));
2370 }
2371
2372 #[tokio::test]
2373 async fn list_evaluates_all_items() {
2374 let v = eval_snippet("[1, 2, 3]").await;
2375 if let Value::List(items) = v {
2376 assert_eq!(items.len(), 3);
2377 assert!(matches!(items[2], Value::Int(3)));
2378 } else {
2379 panic!("expected list");
2380 }
2381 }
2382
2383 #[tokio::test]
2384 async fn struct_literal_evaluates_fields_in_order() {
2385 let v = eval_snippet(r#"{ severity: "critical", count: 3 }"#).await;
2386 if let Value::Struct(fields) = v {
2387 assert_eq!(fields[0].0, "severity");
2388 assert_eq!(fields[1].0, "count");
2389 } else {
2390 panic!("expected struct");
2391 }
2392 }
2393
2394 #[tokio::test]
2395 async fn undefined_tool_returns_undefined_tool_err() {
2396 let src = r#"flow t() { return fs.readnope("/tmp") }"#;
2397 let file = parse_file(src).unwrap();
2398 let tools = ToolRegistry::new();
2399 let tool_ctx = ToolCtx::new();
2400 let providers = crate::provider::ProviderRegistry::new();
2401 let flows = std::collections::HashMap::new();
2402 let ctx = EvalCtx {
2403 tools: &tools,
2404 tool_ctx: &tool_ctx,
2405 providers: &providers,
2406 flows: &flows,
2407 contract: None,
2408 events: None,
2409 turn_id: None,
2410 flow_run_id: None,
2411 session_runtime: None,
2412 flow_cancel: tokio_util::sync::CancellationToken::new(),
2413 safety: None,
2414 current_node_id: None,
2415 source_dir: None,
2416 };
2417 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2418 let v = eval_expr(value, &Env::new(), &ctx).await;
2419 assert!(matches!(
2420 v,
2421 Value::Err(RuntimeError::UndefinedTool(name)) if name == "fs.readnope"
2422 ));
2423 }
2424 }
2425
2426 #[tokio::test]
2427 async fn eager_allow_session_backed_root_flow_spawn_executes_without_pending() {
2428 use crate::flow_authority::EffectiveAuthority;
2429 use crate::tools::agent_ctrl::AgentSpawn;
2430 use crate::trust::{EscalationPolicy, TrustConfig, TrustMode};
2431 use std::sync::Arc;
2432 use tempfile::TempDir;
2433
2434 let root = TempDir::new().unwrap();
2435 let trust = TrustConfig {
2436 mode: TrustMode::Eager,
2437 escalation: EscalationPolicy::Allow,
2438 ..TrustConfig::default()
2439 };
2440 let session =
2441 Arc::new(crate::session::Session::open_with_trust(root.path(), trust.clone()).unwrap());
2442 let run_id = crate::event::FlowRunId::now();
2443 let identity = session
2444 .flow_registry
2445 .register_root(
2446 session.id().to_string(),
2447 run_id.clone(),
2448 EffectiveAuthority::root(&trust, true, None),
2449 )
2450 .unwrap();
2451 let flow_path = root.path().join("acceptance.at");
2452 std::fs::write(&flow_path, "flow child() { return \"ok\" }\n").unwrap();
2453 let spawn_token = session.flow_registry.issue_spawn_permit(&identity);
2454 let source = format!(
2455 "flow t() {{ return flow.spawn(flow: \"{}\", spawn_token: \"{}\", async: true) }}",
2456 flow_path.display(),
2457 spawn_token,
2458 );
2459 let file = parse_file(&source).unwrap();
2460 let tools = ToolRegistry::new();
2461 tools.register(Arc::new(AgentSpawn));
2462 let mut tool_ctx = ToolCtx::new();
2463 tool_ctx.flow_identity = Some(identity);
2464 let providers = crate::provider::ProviderRegistry::new();
2465 let flows = std::collections::HashMap::new();
2466 let ctx = EvalCtx {
2467 tools: &tools,
2468 tool_ctx: &tool_ctx,
2469 providers: &providers,
2470 flows: &flows,
2471 contract: None,
2472 events: None,
2473 turn_id: None,
2474 flow_run_id: Some(run_id),
2475 session_runtime: Some(Arc::clone(&session)),
2476 flow_cancel: tokio_util::sync::CancellationToken::new(),
2477 safety: None,
2478 current_node_id: None,
2479 source_dir: None,
2480 };
2481 let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] else {
2482 panic!("expected return statement");
2483 };
2484
2485 let result = eval_expr(value, &Env::new(), &ctx).await;
2486
2487 assert!(
2488 matches!(&result, Value::Struct(fields) if fields.iter().any(|(key, value)| key == "status" && matches!(value, Value::Str(status) if status == "running"))),
2489 "flow.spawn did not execute: {result:?}"
2490 );
2491 let requests = session.permission_broker.list();
2492 assert_eq!(requests.len(), 1);
2493 assert_eq!(requests[0].intent.tool_name, "flow.spawn");
2494 assert!(matches!(
2495 requests[0].state,
2496 crate::permission::PermissionRequestState::Approved { .. }
2497 ));
2498 assert!(session.approval().list_pending().is_empty());
2499 }
2500
2501 #[tokio::test]
2502 async fn fanout_all_gathers_results_in_order() {
2503 use crate::tools::fs::FsRead;
2504 use std::sync::Arc;
2505 use tempfile::TempDir;
2506
2507 let dir = TempDir::new().unwrap();
2508 let pa = dir.path().join("a.txt");
2509 let pb = dir.path().join("b.txt");
2510 tokio::fs::write(&pa, b"AAA").await.unwrap();
2511 tokio::fs::write(&pb, b"BBB").await.unwrap();
2512
2513 let tools = ToolRegistry::new();
2514 tools.register(Arc::new(FsRead));
2515 let tool_ctx = authorized_eval_tool_ctx(Some(dir.path()));
2516 let providers = crate::provider::ProviderRegistry::new();
2517 let flows = std::collections::HashMap::new();
2518 let ctx = EvalCtx {
2519 tools: &tools,
2520 tool_ctx: &tool_ctx,
2521 providers: &providers,
2522 flows: &flows,
2523 contract: None,
2524 events: None,
2525 turn_id: None,
2526 flow_run_id: tool_ctx
2527 .flow_identity
2528 .as_ref()
2529 .map(|identity| identity.run_id.clone()),
2530 session_runtime: None,
2531 flow_cancel: tokio_util::sync::CancellationToken::new(),
2532 safety: None,
2533 current_node_id: None,
2534 source_dir: None,
2535 };
2536
2537 let mut env = Env::new();
2538 env.bind("a", Value::Path(pa));
2539 env.bind("b", Value::Path(pb));
2540
2541 let src = r#"flow t() { return fanout [ fs.read(a), fs.read(b) ] collect: all }"#;
2542 let file = parse_file(src).unwrap();
2543 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2544 let v = eval_expr(value, &env, &ctx).await;
2545 if let Value::List(items) = v {
2546 assert_eq!(items.len(), 2);
2547 assert!(matches!(&items[0], Value::Str(s) if s == "AAA"));
2548 assert!(matches!(&items[1], Value::Str(s) if s == "BBB"));
2549 } else {
2550 panic!("expected list, got {v:?}");
2551 }
2552 }
2553 }
2554
2555 #[tokio::test]
2556 async fn fanout_all_short_circuits_on_err() {
2557 let src = r#"flow t() { return fanout [ 1, missing, 3 ] collect: all }"#;
2558 let file = parse_file(src).unwrap();
2559 let tools = ToolRegistry::new();
2560 let tool_ctx = ToolCtx::new();
2561 let providers = crate::provider::ProviderRegistry::new();
2562 let flows = std::collections::HashMap::new();
2563 let ctx = EvalCtx {
2564 tools: &tools,
2565 tool_ctx: &tool_ctx,
2566 providers: &providers,
2567 flows: &flows,
2568 contract: None,
2569 events: None,
2570 turn_id: None,
2571 flow_run_id: None,
2572 session_runtime: None,
2573 flow_cancel: tokio_util::sync::CancellationToken::new(),
2574 safety: None,
2575 current_node_id: None,
2576 source_dir: None,
2577 };
2578 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2579 let v = eval_expr(value, &Env::new(), &ctx).await;
2580 assert!(matches!(
2581 v,
2582 Value::Err(RuntimeError::UndefinedVar(name)) if name == "missing"
2583 ));
2584 }
2585 }
2586
2587 #[tokio::test]
2588 async fn llm_node_dispatches_to_mock_provider() {
2589 use crate::providers::mock::MockProvider;
2590 use std::sync::Arc;
2591
2592 {
2593 let _lock = crate::model_registry::MODEL_CONFIG_LOCK.lock().unwrap();
2594 crate::model_registry::set_model_config(crate::model_registry::ModelConfig {
2595 models: std::collections::HashMap::from([(
2596 "mock".into(),
2597 crate::model_registry::ModelEntry {
2598 model: "mock".into(),
2599 context_budget: Some(8_192),
2600 ..Default::default()
2601 },
2602 )]),
2603 ..Default::default()
2604 });
2605 }
2606
2607 let providers = crate::provider::ProviderRegistry::new();
2608 providers.register(Arc::new(MockProvider::new("mock").with_model(
2609 "mock",
2610 Value::Struct(vec![("severity".into(), Value::Str("info".into()))]),
2611 )));
2612 let tools = ToolRegistry::new();
2613 crate::tools::register_tier_zero(&tools);
2614 let tool_ctx = authorized_eval_tool_ctx(None)
2615 .with_providers(std::sync::Arc::new(providers.clone()))
2616 .with_registry(std::sync::Arc::new(tools.clone()));
2617 let flows = std::collections::HashMap::new();
2618 let ctx = EvalCtx {
2619 tools: &tools,
2620 tool_ctx: &tool_ctx,
2621 providers: &providers,
2622 flows: &flows,
2623 contract: None,
2624 events: None,
2625 turn_id: None,
2626 flow_run_id: tool_ctx
2627 .flow_identity
2628 .as_ref()
2629 .map(|identity| identity.run_id.clone()),
2630 session_runtime: None,
2631 flow_cancel: tokio_util::sync::CancellationToken::new(),
2632 safety: None,
2633 current_node_id: None,
2634 source_dir: None,
2635 };
2636
2637 let src = r#"flow t() {
2638 return llm.call(
2639 model: "mock",
2640 prompt: "review please",
2641 input: 1,
2642 )
2643}
2644"#;
2645 let file = parse_file(src).unwrap();
2646 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2647 let v = eval_expr(value, &Env::new(), &ctx).await;
2648 if let Value::Struct(fields) = v {
2649 assert_eq!(fields[0].0, "severity");
2650 assert!(matches!(&fields[0].1, Value::Str(s) if s == "info"));
2651 } else {
2652 panic!("expected struct, got {v:?}");
2653 }
2654 }
2655 }
2656
2657 #[tokio::test]
2658 async fn llm_missing_model_reports_missing_arg() {
2659 let providers = crate::provider::ProviderRegistry::new();
2660 let tools = ToolRegistry::new();
2661 let tool_ctx = ToolCtx::new();
2662 let flows = std::collections::HashMap::new();
2663 let ctx = EvalCtx {
2664 tools: &tools,
2665 tool_ctx: &tool_ctx,
2666 providers: &providers,
2667 flows: &flows,
2668 contract: None,
2669 events: None,
2670 turn_id: None,
2671 flow_run_id: None,
2672 session_runtime: None,
2673 flow_cancel: tokio_util::sync::CancellationToken::new(),
2674 safety: None,
2675 current_node_id: None,
2676 source_dir: None,
2677 };
2678 let src = r#"flow t() { return llm.call(prompt: "hi") }"#;
2679 let file = parse_file(src).unwrap();
2680 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2681 let v = eval_expr(value, &Env::new(), &ctx).await;
2682 assert!(v.is_err(), "expected error, got {v:?}");
2683 }
2684 }
2685
2686 #[tokio::test]
2687 async fn user_confirm_stub_returns_true() {
2688 let providers = crate::provider::ProviderRegistry::new();
2689 let tools = ToolRegistry::new();
2690 let tool_ctx = ToolCtx::new();
2691 let flows = std::collections::HashMap::new();
2692 let ctx = EvalCtx {
2693 tools: &tools,
2694 tool_ctx: &tool_ctx,
2695 providers: &providers,
2696 flows: &flows,
2697 contract: None,
2698 events: None,
2699 turn_id: None,
2700 flow_run_id: None,
2701 session_runtime: None,
2702 flow_cancel: tokio_util::sync::CancellationToken::new(),
2703 safety: None,
2704 current_node_id: None,
2705 source_dir: None,
2706 };
2707 let src = r#"flow t() { return user_confirm("proceed?") }"#;
2708 let file = parse_file(src).unwrap();
2709 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2710 assert!(matches!(
2711 eval_expr(value, &Env::new(), &ctx).await,
2712 Value::Bool(true)
2713 ));
2714 }
2715 }
2716
2717 #[tokio::test]
2718 async fn subflow_calls_target_flow_with_positional_args() {
2719 let src = r#"flow child(n: Int) -> Int {
2720 return n + 100
2721}
2722
2723flow parent(x: Int) -> Int {
2724 y = subflow(child, x)
2725 return y + 1
2726}
2727"#;
2728 let file = parse_file(src).unwrap();
2729 let flows_map: std::collections::HashMap<_, _> = file
2730 .flows
2731 .iter()
2732 .map(|f| (f.name.name.clone(), f.clone()))
2733 .collect();
2734 let parent = &file.flows[1];
2735 let tools = ToolRegistry::new();
2736 let flow_registry = std::sync::Arc::new(crate::tools::agent_ctrl::FlowRegistry::new());
2737 let root_run_id = crate::event::FlowRunId::now();
2738 let root_identity = flow_registry
2739 .register_root(
2740 "test-session".into(),
2741 root_run_id.clone(),
2742 crate::flow_authority::EffectiveAuthority::root(
2743 &crate::trust::TrustConfig::default(),
2744 false,
2745 None,
2746 ),
2747 )
2748 .unwrap();
2749 let mut tool_ctx = ToolCtx::new();
2750 tool_ctx.flow_run_id = Some(root_run_id);
2751 tool_ctx.flow_registry = Some(flow_registry);
2752 tool_ctx.flow_identity = Some(root_identity);
2753 let providers = crate::provider::ProviderRegistry::new();
2754 let out = crate::exec::exec_flow_with_siblings(
2755 parent,
2756 vec![("x".into(), Value::Int(5))],
2757 &tools,
2758 &tool_ctx,
2759 &providers,
2760 &flows_map,
2761 None,
2762 None,
2763 None,
2764 None,
2765 tokio_util::sync::CancellationToken::new(),
2766 None,
2767 None,
2768 )
2769 .await
2770 .unwrap();
2771 assert!(matches!(out, Value::Int(106)));
2772 }
2773
2774 #[tokio::test]
2775 async fn subflow_rejects_spoofed_run_id_without_trusted_identity() {
2776 let src = r#"flow child() -> Int {
2777 return 1
2778}
2779
2780flow parent() -> Int {
2781 return subflow(child)
2782}
2783"#;
2784 let file = parse_file(src).unwrap();
2785 let flows: std::collections::HashMap<_, _> = file
2786 .flows
2787 .iter()
2788 .map(|flow| (flow.name.name.clone(), flow.clone()))
2789 .collect();
2790 let tools = ToolRegistry::new();
2791 let mut tool_ctx = ToolCtx::new();
2792 tool_ctx.flow_run_id = Some(crate::event::FlowRunId::now());
2793 tool_ctx.flow_registry = Some(std::sync::Arc::new(
2794 crate::tools::agent_ctrl::FlowRegistry::new(),
2795 ));
2796 let providers = crate::provider::ProviderRegistry::new();
2797
2798 let error = crate::exec::exec_flow_with_siblings(
2799 &file.flows[1],
2800 vec![],
2801 &tools,
2802 &tool_ctx,
2803 &providers,
2804 &flows,
2805 None,
2806 None,
2807 tool_ctx.flow_run_id.clone(),
2808 None,
2809 tokio_util::sync::CancellationToken::new(),
2810 None,
2811 None,
2812 )
2813 .await
2814 .unwrap_err();
2815
2816 assert!(matches!(
2817 error,
2818 RuntimeError::ToolFailed(message)
2819 if message == "subflow: trusted parent flow identity is unavailable"
2820 ));
2821 }
2822
2823 #[tokio::test]
2824 async fn subflow_missing_target_reports_undefined_tool() {
2825 let src = r#"flow parent() -> Int {
2826 return subflow(nope, 1)
2827}
2828"#;
2829 let file = parse_file(src).unwrap();
2830 let flows: std::collections::HashMap<_, _> = file
2831 .flows
2832 .iter()
2833 .map(|f| (f.name.name.clone(), f.clone()))
2834 .collect();
2835 let tools = ToolRegistry::new();
2836 let tool_ctx = ToolCtx::new();
2837 let providers = crate::provider::ProviderRegistry::new();
2838 let err = crate::exec::exec_flow_with_siblings(
2839 &file.flows[0],
2840 vec![],
2841 &tools,
2842 &tool_ctx,
2843 &providers,
2844 &flows,
2845 None,
2846 None,
2847 None,
2848 None,
2849 tokio_util::sync::CancellationToken::new(),
2850 None,
2851 None,
2852 )
2853 .await
2854 .unwrap_err();
2855 assert!(matches!(err, RuntimeError::UndefinedTool(name) if name.contains("nope")));
2856 }
2857
2858 #[tokio::test]
2859 async fn tool_call_dispatches_via_registry() {
2860 use crate::tools::fs::FsRead;
2861 use std::sync::Arc;
2862 use tempfile::TempDir;
2863
2864 let dir = TempDir::new().unwrap();
2865 let path = dir.path().join("hi.txt");
2866 tokio::fs::write(&path, b"hello runtime").await.unwrap();
2867
2868 let tools = ToolRegistry::new();
2869 tools.register(Arc::new(FsRead));
2870 let tool_ctx = authorized_eval_tool_ctx(Some(dir.path()));
2871 let providers = crate::provider::ProviderRegistry::new();
2872 let flows = std::collections::HashMap::new();
2873 let ctx = EvalCtx {
2874 tools: &tools,
2875 tool_ctx: &tool_ctx,
2876 providers: &providers,
2877 flows: &flows,
2878 contract: None,
2879 events: None,
2880 turn_id: None,
2881 flow_run_id: tool_ctx
2882 .flow_identity
2883 .as_ref()
2884 .map(|identity| identity.run_id.clone()),
2885 session_runtime: None,
2886 flow_cancel: tokio_util::sync::CancellationToken::new(),
2887 safety: None,
2888 current_node_id: None,
2889 source_dir: None,
2890 };
2891
2892 let mut env = Env::new();
2893 env.bind("p", Value::Path(path));
2894
2895 let src = r#"flow t() { return fs.read(p) }"#;
2896 let file = parse_file(src).unwrap();
2897 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2898 let v = eval_expr(value, &env, &ctx).await;
2899 assert!(matches!(v, Value::Str(s) if s == "hello runtime"));
2900 }
2901 }
2902
2903 #[tokio::test]
2904 async fn fanout_emits_branch_start_end_events_with_parent_linkage() {
2905 let src = r#"flow t() { return fanout [1, 2, 3] collect: all }"#;
2906 let file = parse_file(src).unwrap();
2907 let tools = ToolRegistry::new();
2908 let tool_ctx = ToolCtx::new();
2909 let providers = crate::provider::ProviderRegistry::new();
2910 let flows = std::collections::HashMap::new();
2911 let events = crate::event::EventSink::new();
2912 let ctx = EvalCtx {
2913 tools: &tools,
2914 tool_ctx: &tool_ctx,
2915 providers: &providers,
2916 flows: &flows,
2917 contract: None,
2918 events: Some(&events),
2919 turn_id: None,
2920 flow_run_id: Some(crate::event::FlowRunId::now()),
2921 session_runtime: None,
2922 flow_cancel: tokio_util::sync::CancellationToken::new(),
2923 safety: None,
2924 current_node_id: Some("stmt_1".into()),
2925 source_dir: None,
2926 };
2927 if let atman_dsl::ast::Stmt::Return { value } = &file.flows[0].body[0] {
2928 let _ = eval_expr(value, &Env::new(), &ctx).await;
2929 }
2930 let snap = events.snapshot();
2931 let starts: Vec<_> = snap
2932 .iter()
2933 .filter_map(|e| match e {
2934 crate::event::Event::FlowNodeStart {
2935 node_id,
2936 parent_node_id,
2937 ..
2938 } => Some((node_id.clone(), parent_node_id.clone())),
2939 _ => None,
2940 })
2941 .collect();
2942 assert_eq!(starts.len(), 3);
2943 assert_eq!(starts[0].0, "stmt_1.branch[0]");
2944 assert_eq!(starts[1].0, "stmt_1.branch[1]");
2945 assert_eq!(starts[2].0, "stmt_1.branch[2]");
2946 assert!(starts.iter().all(|(_, p)| p.as_deref() == Some("stmt_1")));
2947 let ends = snap
2948 .iter()
2949 .filter(|e| matches!(e, crate::event::Event::FlowNodeEnd { .. }))
2950 .count();
2951 assert_eq!(ends, 3);
2952 }
2953
2954 #[test]
2955 fn resolve_tool_specs_wildcard_unknown_prefix_skips_silently() {
2956 let tools = crate::tool::ToolRegistry::new();
2957 let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
2958 "nonexistent.*".into(),
2959 ))]);
2960 let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
2961 &[crate::value::Value::Str("nonexistent.*".into())],
2962 &tools,
2963 )
2964 .unwrap();
2965 assert!(
2966 specs.is_empty(),
2967 "wildcard with no matches should return empty list"
2968 );
2969 }
2970
2971 #[test]
2972 fn resolve_tool_specs_wildcard_matches_prefixed_tools() {
2973 let tools = crate::tool::ToolRegistry::new();
2974 struct FakeMcpTool {
2975 name: String,
2976 desc: String,
2977 schema: serde_json::Value,
2978 }
2979 impl crate::tool::Tool for FakeMcpTool {
2980 fn name(&self) -> &str {
2981 &self.name
2982 }
2983 fn description(&self) -> Option<&str> {
2984 Some(&self.desc)
2985 }
2986 fn input_schema(&self) -> serde_json::Value {
2987 self.schema.clone()
2988 }
2989 fn tier(&self) -> crate::tool::Tier {
2990 crate::tool::Tier::Zero
2991 }
2992 fn approval_level(
2993 &self,
2994 _args: &crate::tool::ToolArgs,
2995 _ctx: &crate::tool::ToolCtx,
2996 ) -> crate::tool::ApprovalLevel {
2997 crate::tool::ApprovalLevel::Auto
2998 }
2999 fn call<'a>(
3000 &'a self,
3001 _args: crate::tool::ToolArgs,
3002 _ctx: &'a crate::tool::ToolCtx,
3003 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
3004 Box::pin(async { Ok(crate::value::Value::Unit) })
3005 }
3006 }
3007 tools.register(std::sync::Arc::new(FakeMcpTool {
3008 name: "mcp.lark.send_mail".into(),
3009 desc: "send mail".into(),
3010 schema: serde_json::json!({"type":"object","properties":{}}),
3011 }));
3012 tools.register(std::sync::Arc::new(FakeMcpTool {
3013 name: "mcp.lark.read_inbox".into(),
3014 desc: "read inbox".into(),
3015 schema: serde_json::json!({"type":"object","properties":{}}),
3016 }));
3017 tools.register(std::sync::Arc::new(FakeMcpTool {
3018 name: "mcp.siyuan.search".into(),
3019 desc: "search notes".into(),
3020 schema: serde_json::json!({"type":"object","properties":{}}),
3021 }));
3022 tools.register(std::sync::Arc::new(FakeMcpTool {
3024 name: "fs.read".into(),
3025 desc: "read file".into(),
3026 schema: serde_json::json!({"type":"object","properties":{}}),
3027 }));
3028
3029 let _expr = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
3031 "mcp.*".into(),
3032 ))]);
3033 let specs = crate::eval::llm_args::resolve_tool_specs_from_values(
3034 &[crate::value::Value::Str("mcp.*".into())],
3035 &tools,
3036 )
3037 .unwrap();
3038 assert_eq!(specs.len(), 3, "mcp.* should match 3 MCP tools");
3039 let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
3040 assert!(names.contains(&"mcp.lark.send_mail".into()));
3041 assert!(names.contains(&"mcp.lark.read_inbox".into()));
3042 assert!(names.contains(&"mcp.siyuan.search".into()));
3043
3044 let _expr2 = Expr::List(vec![Expr::Literal(atman_dsl::ast::Literal::Str(
3046 "mcp.lark.*".into(),
3047 ))]);
3048 let specs2 = crate::eval::llm_args::resolve_tool_specs_from_values(
3049 &[crate::value::Value::Str("mcp.lark.*".into())],
3050 &tools,
3051 )
3052 .unwrap();
3053 assert_eq!(specs2.len(), 2, "mcp.lark.* should match 2 lark tools");
3054 }
3055
3056 #[test]
3057 fn resolve_tool_specs_mixed_concrete_and_wildcard() {
3058 let tools = crate::tool::ToolRegistry::new();
3059 struct FakeMcpTool {
3060 name: String,
3061 desc: String,
3062 schema: serde_json::Value,
3063 }
3064 impl crate::tool::Tool for FakeMcpTool {
3065 fn name(&self) -> &str {
3066 &self.name
3067 }
3068 fn description(&self) -> Option<&str> {
3069 Some(&self.desc)
3070 }
3071 fn input_schema(&self) -> serde_json::Value {
3072 self.schema.clone()
3073 }
3074 fn tier(&self) -> crate::tool::Tier {
3075 crate::tool::Tier::Zero
3076 }
3077 fn approval_level(
3078 &self,
3079 _args: &crate::tool::ToolArgs,
3080 _ctx: &crate::tool::ToolCtx,
3081 ) -> crate::tool::ApprovalLevel {
3082 crate::tool::ApprovalLevel::Auto
3083 }
3084 fn call<'a>(
3085 &'a self,
3086 _args: crate::tool::ToolArgs,
3087 _ctx: &'a crate::tool::ToolCtx,
3088 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
3089 Box::pin(async { Ok(crate::value::Value::Unit) })
3090 }
3091 }
3092 tools.register(std::sync::Arc::new(FakeMcpTool {
3093 name: "mcp.lark.send_mail".into(),
3094 desc: "send mail".into(),
3095 schema: serde_json::json!({"type":"object","properties":{}}),
3096 }));
3097 tools.register(std::sync::Arc::new(FakeMcpTool {
3098 name: "bash.exec".into(),
3099 desc: "exec".into(),
3100 schema: serde_json::json!({"type":"object","properties":{}}),
3101 }));
3102
3103 let src = r#"flow t() -> string {
3105 reply = llm.call(
3106 tools: ["bash.exec", "mcp.*"],
3107 )
3108 return "ok"
3109}"#;
3110 let file = atman_dsl::parse::parse_file(src).unwrap();
3111 let body = &file.flows[0].body;
3112 let tools_values: Vec<crate::value::Value> = match &body[0] {
3113 atman_dsl::ast::Stmt::Bind { value, .. } => match value {
3114 Expr::Node(atman_dsl::ast::Node::ToolCall { args, .. }) => {
3115 let tools_expr = args
3116 .iter()
3117 .find_map(|a| match a {
3118 atman_dsl::ast::Arg::Named { name, value } if name.name == "tools" => {
3119 Some(value.clone())
3120 }
3121 _ => None,
3122 })
3123 .unwrap();
3124 if let atman_dsl::ast::Expr::List(items) = tools_expr {
3125 items
3126 .iter()
3127 .map(|i| {
3128 if let atman_dsl::ast::Expr::Literal(
3129 atman_dsl::ast::Literal::Str(s),
3130 ) = i
3131 {
3132 crate::value::Value::Str(s.clone())
3133 } else {
3134 panic!("expected string literal in tools list");
3135 }
3136 })
3137 .collect()
3138 } else {
3139 panic!("expected list");
3140 }
3141 }
3142 _ => panic!("expected tool call"),
3143 },
3144 _ => panic!("expected bind stmt"),
3145 };
3146 let specs =
3147 crate::eval::llm_args::resolve_tool_specs_from_values(&tools_values, &tools).unwrap();
3148 assert_eq!(specs.len(), 2, "bash.exec + mcp.lark.send_mail = 2");
3149 let names: Vec<String> = specs.iter().map(|s| s.name.clone()).collect();
3150 assert!(names.contains(&"bash.exec".into()));
3151 assert!(names.contains(&"mcp.lark.send_mail".into()));
3152 }
3153}
3154
3155#[cfg(test)]
3156mod sanitize_tests {
3157 use super::*;
3158 use crate::message::{Message, MessageOrigin, MessagePart, MessageRole};
3159
3160 #[test]
3161 fn sanitize_fills_missing_tool_results() {
3162 let turn = crate::event::TurnId::now();
3163 let msgs = vec![
3164 Message {
3165 role: MessageRole::Assistant,
3166 parts: vec![MessagePart::ToolUse {
3167 id: "call_orphan".into(),
3168 name: "bash.exec".into(),
3169 input: serde_json::json!({}),
3170 intent: None,
3171 }],
3172 turn_id: turn.clone(),
3173 origin: MessageOrigin::User,
3174 },
3175 Message {
3176 role: MessageRole::User,
3177 parts: vec![MessagePart::Text {
3178 text: "user interrupt".into(),
3179 }],
3180 turn_id: turn.clone(),
3181 origin: MessageOrigin::User,
3182 },
3183 ];
3184 let out = crate::message::normalize_tool_pairs_for_model(&msgs);
3185 let has_filler = out.iter().any(|m| {
3186 m.parts.iter().any(|p| {
3187 matches!(p, MessagePart::ToolResult { tool_use_id, is_error: true, .. } if tool_use_id == "call_orphan")
3188 })
3189 });
3190 assert!(
3191 has_filler,
3192 "should append error tool_result for orphan tool_use"
3193 );
3194 }
3195
3196 #[test]
3197 fn sanitize_noop_when_pairs_complete() {
3198 let turn = crate::event::TurnId::now();
3199 let msgs = vec![
3200 Message {
3201 role: MessageRole::Assistant,
3202 parts: vec![MessagePart::ToolUse {
3203 id: "call_ok".into(),
3204 name: "bash.exec".into(),
3205 input: serde_json::json!({}),
3206 intent: None,
3207 }],
3208 turn_id: turn.clone(),
3209 origin: MessageOrigin::User,
3210 },
3211 Message {
3212 role: MessageRole::Tool,
3213 parts: vec![MessagePart::ToolResult {
3214 tool_use_id: "call_ok".into(),
3215 content: "done".into(),
3216 is_error: false,
3217 }],
3218 turn_id: turn.clone(),
3219 origin: MessageOrigin::User,
3220 },
3221 ];
3222 let out = crate::message::normalize_tool_pairs_for_model(&msgs);
3223 assert_eq!(
3224 out.len(),
3225 2,
3226 "no filler should be added when pairs complete"
3227 );
3228 }
3229
3230 use crate::providers::mock::MockProvider;
3232
3233 fn stall_req(stall_secs: u64) -> crate::provider::LlmRequest {
3234 crate::provider::LlmRequest {
3235 model: "mock".into(),
3236 messages: vec![crate::provider::user_text_message("test")],
3237 system: None,
3238 input: crate::value::Value::Unit,
3239 schema: None,
3240 cache_prompt: false,
3241 prompt_cache_key: None,
3242 tools: Vec::new(),
3243 reasoning: crate::provider::ReasoningSelection::ProviderDefault,
3244 stall_timeout_secs: stall_secs,
3245 }
3246 }
3247
3248 #[tokio::test]
3249 async fn stall_timeout_fires_when_no_chunks_arrive() {
3250 let provider = MockProvider::new("mock")
3252 .with_model("mock", Value::Str("hello world test".into()))
3253 .with_chunk_delay(std::time::Duration::from_secs(3));
3254
3255 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3256 let result = call_and_maybe_stream(
3257 &provider,
3258 stall_req(1),
3259 StreamCallCtx {
3260 stream_tx: Some(stream_tx),
3261 ..Default::default()
3262 },
3263 None,
3264 )
3265 .await;
3266 match result {
3267 Err(RuntimeError::ToolFailed(msg)) => {
3268 assert!(
3269 msg.contains("llm stall timeout after 1s"),
3270 "expected stall message, got: {msg}"
3271 );
3272 }
3273 other => panic!("expected ToolFailed stall timeout, got: {other:?}"),
3274 }
3275 }
3276
3277 #[tokio::test]
3278 async fn stall_timeout_does_not_fire_when_chunks_keep_coming() {
3279 let provider = MockProvider::new("mock")
3281 .with_model("mock", Value::Str("hello world test".into()))
3282 .with_chunk_delay(std::time::Duration::from_millis(100));
3283
3284 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3285 let result = call_and_maybe_stream(
3286 &provider,
3287 stall_req(2),
3288 StreamCallCtx {
3289 stream_tx: Some(stream_tx),
3290 ..Default::default()
3291 },
3292 None,
3293 )
3294 .await;
3295 match result {
3296 Ok(am) => {
3297 assert!(am.text_concat().contains("hello"));
3298 }
3299 other => panic!("expected Ok, got: {other:?}"),
3300 }
3301 }
3302
3303 #[tokio::test]
3304 async fn stall_timeout_zero_disables_detection() {
3305 let provider = MockProvider::new("mock")
3307 .with_model("mock", Value::Str("hello world test".into()))
3308 .with_chunk_delay(std::time::Duration::from_secs(3));
3309
3310 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3311 let result = call_and_maybe_stream(
3312 &provider,
3313 stall_req(0),
3314 StreamCallCtx {
3315 stream_tx: Some(stream_tx),
3316 ..Default::default()
3317 },
3318 None,
3319 )
3320 .await;
3321 match result {
3322 Ok(am) => {
3323 assert!(am.text_concat().contains("hello"));
3324 }
3325 other => panic!("expected Ok (stall disabled), got: {other:?}"),
3326 }
3327 }
3328
3329 #[tokio::test]
3330 async fn stall_timeout_resets_on_each_chunk() {
3331 let provider = MockProvider::new("mock")
3334 .with_model("mock", Value::Str("hello world test".into()))
3335 .with_chunk_delay(std::time::Duration::from_millis(800));
3336
3337 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3338 let result = call_and_maybe_stream(
3339 &provider,
3340 stall_req(1),
3341 StreamCallCtx {
3342 stream_tx: Some(stream_tx),
3343 ..Default::default()
3344 },
3345 None,
3346 )
3347 .await;
3348 match result {
3349 Ok(am) => {
3350 assert!(am.text_concat().contains("hello"));
3351 }
3352 other => panic!("expected Ok (timer reset each chunk), got: {other:?}"),
3353 }
3354 }
3355
3356 #[tokio::test]
3357 async fn stall_timeout_fires_between_first_and_second_chunk() {
3358 let provider = MockProvider::new("mock")
3360 .with_model("mock", Value::Str("hello world test".into()))
3361 .with_chunk_delay(std::time::Duration::from_secs(2));
3362
3363 let (stream_tx, _rx) = tokio::sync::broadcast::channel(16);
3364 let result = call_and_maybe_stream(
3365 &provider,
3366 stall_req(1),
3367 StreamCallCtx {
3368 stream_tx: Some(stream_tx),
3369 ..Default::default()
3370 },
3371 None,
3372 )
3373 .await;
3374 assert!(
3375 matches!(&result, Err(RuntimeError::ToolFailed(msg)) if msg.contains("stall timeout")),
3376 "expected stall timeout, got: {result:?}"
3377 );
3378 }
3379}