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