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