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