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