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