atman_runtime/tools/
flow_check.rs1use crate::error::RuntimeError;
2use crate::storage;
3use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
4use crate::value::Value;
5
6pub struct FlowCheck;
7
8impl Tool for FlowCheck {
9 fn name(&self) -> &str {
10 "flow.check"
11 }
12
13 fn tier(&self) -> Tier {
14 Tier::Zero
15 }
16
17 fn description(&self) -> Option<&str> {
18 Some(
19 "Validate and lint a .at flow file. Checks all flows in the file \
20 for undefined variables, undefined tools, and type mismatches \
21 (errors), plus unused params and too many positional args \
22 (warnings). Pass `flow` as a filename (e.g. 'subagent.at') or \
23 path. Use after writing or editing a flow to catch mistakes \
24 before spawning.",
25 )
26 }
27
28 fn input_schema(&self) -> serde_json::Value {
29 serde_json::json!({
30 "type": "object",
31 "properties": {
32 "flow": {
33 "type": "string",
34 "description": "Flow file name or path (e.g. 'subagent.at' or '/path/to/my.at')"
35 }
36 },
37 "required": ["flow"]
38 })
39 }
40
41 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
42 Box::pin(async move {
43 let flow_ref = match args.named("flow").or_else(|| args.positional.first()) {
44 Some(Value::Str(s)) if !s.trim().is_empty() => s.clone(),
45 Some(other) => {
46 return Err(RuntimeError::TypeMismatch {
47 expected: "non-empty flow string".into(),
48 actual: other.kind_name().into(),
49 });
50 }
51 None => {
52 return Err(RuntimeError::MissingArg("flow.check.flow".into()));
53 }
54 };
55
56 let (path, src) = read_flow_source(&flow_ref).await?;
57 let file = atman_dsl::parse::parse_file(&src).map_err(|e| {
58 RuntimeError::ToolFailed(format!("flow.check: parse {}: {e}", path.display()))
59 })?;
60
61 let registry = ctx
62 .registry
63 .as_ref()
64 .ok_or_else(|| RuntimeError::ToolFailed("flow.check: no tool registry".into()))?;
65
66 let mut errors: Vec<Value> = Vec::new();
67 for flow in &file.flows {
68 if let Err(errs) = crate::validate::validate(flow, registry) {
69 for e in errs {
70 errors.push(Value::Struct(vec![
71 ("kind".into(), Value::Str("validate".into())),
72 ("flow".into(), Value::Str(flow.name.name.clone())),
73 ("message".into(), Value::Str(format!("{e:?}"))),
74 ]));
75 }
76 }
77 }
78
79 let mut warnings: Vec<Value> = Vec::new();
80 for hit in crate::flow_lint::lint_file(&file) {
81 warnings.push(Value::Struct(vec![
82 ("kind".into(), Value::Str(hit.rule.slug().into())),
83 ("flow".into(), Value::Str(hit.flow.clone())),
84 ("message".into(), Value::Str(hit.message.clone())),
85 ]));
86 }
87
88 let valid = errors.is_empty();
89
90 Ok(Value::Struct(vec![
91 ("valid".into(), Value::Bool(valid)),
92 ("errors".into(), Value::List(errors)),
93 ("warnings".into(), Value::List(warnings)),
94 ]))
95 })
96 }
97}
98
99async fn read_flow_source(flow_ref: &str) -> Result<(std::path::PathBuf, String), RuntimeError> {
100 for path in flow_candidates(flow_ref) {
101 match tokio::fs::read_to_string(&path).await {
102 Ok(src) => return Ok((path, src)),
103 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
104 Err(e) => {
105 return Err(RuntimeError::ToolFailed(format!(
106 "flow.check: read {}: {e}",
107 path.display()
108 )));
109 }
110 }
111 }
112 Err(RuntimeError::ToolFailed(format!(
113 "flow.check: flow `{flow_ref}` not found"
114 )))
115}
116
117fn flow_candidates(flow_ref: &str) -> Vec<std::path::PathBuf> {
118 let path = std::path::PathBuf::from(flow_ref);
119 if path.is_absolute() {
120 return vec![path];
121 }
122 let file_name = if flow_ref.ends_with(".at") {
123 flow_ref.to_string()
124 } else {
125 format!("{flow_ref}.at")
126 };
127 let mut out = Vec::new();
128 if let Ok(config_dir) = storage::config_dir() {
129 out.push(config_dir.join("commands").join(&file_name));
130 }
131 out.push(std::path::PathBuf::from(file_name));
132 out
133}