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