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