Skip to main content

atman_runtime/tools/
flow_list.rs

1use crate::error::RuntimeError;
2use crate::storage;
3use crate::tool::{BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
4use crate::value::Value;
5use atman_dsl::ast::{Expr, FlowDecl, Literal, Stmt, TypeExpr};
6use std::path::{Path, PathBuf};
7
8const DEFAULT_SEARCH_LIMIT: usize = 10;
9const MAX_SEARCH_LIMIT: usize = 50;
10
11pub struct FlowList;
12pub struct FlowInstances;
13pub struct FlowSearch;
14pub struct FlowDescribe;
15
16#[derive(Clone)]
17struct FlowParameter {
18    name: String,
19    ty: String,
20    default: Option<Value>,
21}
22
23#[derive(Clone)]
24struct FlowEntry {
25    name: String,
26    reference: String,
27    version: String,
28    summary: String,
29    scope: &'static str,
30    source_path: PathBuf,
31    params: Vec<FlowParameter>,
32}
33
34struct FlowFile {
35    name: String,
36    description: String,
37    scope: &'static str,
38    source_path: PathBuf,
39    flows: Vec<FlowEntry>,
40}
41
42struct FlowCatalog {
43    fingerprint: String,
44    files: Vec<FlowFile>,
45}
46
47impl FlowCatalog {
48    fn load(ctx: &ToolCtx) -> Result<Self, RuntimeError> {
49        let config_dir = storage::config_dir()
50            .map_err(|error| RuntimeError::ToolFailed(format!("flow catalog: {error}")))?;
51        let project_root = super::flow_source::project_root_for_ctx(ctx);
52        let sources =
53            super::flow_source::installed_sources(Some(&config_dir), project_root.as_deref());
54        Self::load_sources(sources)
55    }
56
57    #[cfg(test)]
58    fn load_from(commands_dir: &Path) -> Result<Self, RuntimeError> {
59        let mut sources = Vec::new();
60        if commands_dir.is_dir() {
61            let read = std::fs::read_dir(commands_dir).map_err(|error| {
62                RuntimeError::ToolFailed(format!(
63                    "flow catalog: read {}: {error}",
64                    commands_dir.display()
65                ))
66            })?;
67            for entry in read.flatten() {
68                let path = entry.path();
69                if path.extension().and_then(|extension| extension.to_str()) != Some("at") {
70                    continue;
71                }
72                sources.push(super::flow_source::InstalledFlowSource {
73                    path,
74                    scope: super::flow_source::FlowSourceScope::User,
75                });
76            }
77        }
78        Self::load_sources(sources)
79    }
80
81    fn load_sources(
82        sources: Vec<super::flow_source::InstalledFlowSource>,
83    ) -> Result<Self, RuntimeError> {
84        let mut files = Vec::new();
85        for source in sources {
86            if let Ok(file) = scan_flow_file(&source.path, source.scope) {
87                files.push(file);
88            }
89        }
90        files.sort_by(|left, right| left.name.cmp(&right.name));
91        let mut hasher = blake3::Hasher::new();
92        for file in &files {
93            for flow in &file.flows {
94                hasher.update(flow.reference.as_bytes());
95                hasher.update(&[0]);
96                hasher.update(flow.version.as_bytes());
97                hasher.update(&[0]);
98            }
99        }
100        Ok(Self {
101            fingerprint: format!("blake3:{}", hasher.finalize().to_hex()),
102            files,
103        })
104    }
105
106    fn searchable_entries(&self) -> Vec<&FlowEntry> {
107        let mut entries = self
108            .files
109            .iter()
110            .flat_map(|file| file.flows.iter())
111            .filter(|flow| flow.name != "describe")
112            .collect::<Vec<_>>();
113        entries.sort_by(|left, right| left.reference.cmp(&right.reference));
114        entries
115    }
116
117    fn find(&self, flow_ref: &str) -> Option<&FlowEntry> {
118        if flow_ref.contains('@') {
119            let normalized = normalize_flow_ref(flow_ref)?;
120            return self
121                .files
122                .iter()
123                .flat_map(|file| file.flows.iter())
124                .find(|flow| flow.reference == normalized);
125        }
126        let file_name = normalize_flow_file(flow_ref)?;
127        self.files
128            .iter()
129            .find(|file| file.name == file_name)?
130            .flows
131            .iter()
132            .find(|flow| flow.name != "describe")
133    }
134
135    fn legacy_value(&self) -> Value {
136        Value::List(
137            self.files
138                .iter()
139                .map(|file| {
140                    Value::Struct(vec![
141                        ("file".into(), Value::Str(file.name.clone())),
142                        ("description".into(), Value::Str(file.description.clone())),
143                        ("scope".into(), Value::Str(file.scope.into())),
144                        (
145                            "source_path".into(),
146                            Value::Str(file.source_path.display().to_string()),
147                        ),
148                        (
149                            "flows".into(),
150                            Value::List(file.flows.iter().map(legacy_flow_value).collect()),
151                        ),
152                    ])
153                })
154                .collect(),
155        )
156    }
157}
158
159impl Tool for FlowList {
160    fn name(&self) -> &str {
161        "flow.list"
162    }
163
164    fn tier(&self) -> Tier {
165        Tier::Zero
166    }
167
168    fn description(&self) -> Option<&str> {
169        Some(
170            "Return the complete flow catalog for compatibility. The result is unbounded; use \
171             flow.search followed by flow.describe for model-driven discovery.",
172        )
173    }
174
175    fn input_schema(&self) -> serde_json::Value {
176        serde_json::json!({"type": "object", "properties": {}})
177    }
178
179    fn call<'a>(&'a self, _args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
180        Box::pin(async move { Ok(FlowCatalog::load(ctx)?.legacy_value()) })
181    }
182}
183
184impl Tool for FlowInstances {
185    fn name(&self) -> &str {
186        "flow.instances"
187    }
188
189    fn tier(&self) -> Tier {
190        Tier::Zero
191    }
192
193    fn description(&self) -> Option<&str> {
194        Some(
195            "List spawned flow instances visible to the current session and return a single-use spawn_token. \
196             Inspect running work, reuse suitable instances, and kill obsolete flows before passing the token to flow.spawn.",
197        )
198    }
199
200    fn input_schema(&self) -> serde_json::Value {
201        serde_json::json!({"type": "object", "properties": {}, "additionalProperties": false})
202    }
203
204    fn call<'a>(&'a self, _args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
205        Box::pin(async move {
206            let registry = ctx.flow_registry.as_ref().ok_or_else(|| {
207                RuntimeError::ToolFailed("flow.instances: no flow registry available".into())
208            })?;
209            let identity = ctx.flow_identity.as_ref().ok_or_else(|| {
210                RuntimeError::ToolFailed(
211                    "flow.instances: trusted caller flow identity is unavailable".into(),
212                )
213            })?;
214            let (mut instances, token) = registry.inspect_for_spawn(identity);
215            const MAX_INSTANCES: usize = 50;
216            let truncated = instances.len().saturating_sub(MAX_INSTANCES);
217            if truncated > 0 {
218                instances.drain(..truncated);
219            }
220            let items = instances
221                .into_iter()
222                .map(|instance| {
223                    Value::Struct(vec![
224                        ("handle".into(), Value::Str(instance.handle)),
225                        ("goal".into(), Value::Str(instance.goal)),
226                        ("model".into(), Value::Str(instance.model)),
227                        ("status".into(), Value::Str(instance.status)),
228                        (
229                            "run_id".into(),
230                            Value::Str(instance.child_run_id.0.to_string()),
231                        ),
232                        (
233                            "started_at".into(),
234                            Value::Str(instance.started_at.to_rfc3339()),
235                        ),
236                    ])
237                })
238                .collect();
239            Ok(Value::Struct(vec![
240                ("instances".into(), Value::List(items)),
241                ("spawn_token".into(), Value::Str(token)),
242                ("truncated".into(), Value::Int(truncated as i64)),
243            ]))
244        })
245    }
246}
247
248impl Tool for FlowSearch {
249    fn name(&self) -> &str {
250        "flow.search"
251    }
252
253    fn tier(&self) -> Tier {
254        Tier::Zero
255    }
256
257    fn description(&self) -> Option<&str> {
258        Some(
259            "Search installed DSL flows by ranked keywords without loading the complete catalog into context. \
260             Results contain an exact flow ref, a source fingerprint, and a short summary. \
261             Use flow.describe on one result before flow.spawn when its parameters are unknown.",
262        )
263    }
264
265    fn input_schema(&self) -> serde_json::Value {
266        serde_json::json!({
267            "type": "object",
268            "properties": {
269                "query": {"type": "string", "description": "Case-insensitive keywords ranked across flow name, ref, and summary. Empty string lists the first page."},
270                "limit": {"type": "integer", "minimum": 1, "maximum": MAX_SEARCH_LIMIT, "default": DEFAULT_SEARCH_LIMIT},
271                "cursor": {"type": "string", "minLength": 1, "description": "Pagination only. Omit this field for the first page. For a later page, pass the non-empty next_cursor returned by the immediately preceding flow.search call verbatim; never send an empty or invented value."}
272            },
273            "required": ["query"],
274            "additionalProperties": false
275        })
276    }
277
278    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
279        Box::pin(async move {
280            let query = string_arg(&args, "query", "flow.search")?;
281            let limit = limit_arg(&args, "flow.search")?;
282            let cursor = optional_string_arg(&args, "cursor", "flow.search")?;
283            search_catalog(&FlowCatalog::load(ctx)?, query, limit, cursor.as_deref())
284        })
285    }
286}
287
288impl Tool for FlowDescribe {
289    fn name(&self) -> &str {
290        "flow.describe"
291    }
292
293    fn tier(&self) -> Tier {
294        Tier::Zero
295    }
296
297    fn description(&self) -> Option<&str> {
298        Some(
299            "Describe one installed DSL flow. Returns its exact ref, current source fingerprint, \
300             summary, and parameter contract. Accepts an exact ref or installed flow-file shorthand. \
301             An optional version rejects stale search results.",
302        )
303    }
304
305    fn input_schema(&self) -> serde_json::Value {
306        serde_json::json!({
307            "type": "object",
308            "properties": {
309                "ref": {"type": "string", "description": "Exact flow ref returned by flow.search, or installed flow file such as subagent.at."},
310                "version": {"type": "string", "minLength": 1, "description": "Staleness guard. Pass the non-empty source fingerprint returned by the current flow.search result verbatim. Omit this field when no fingerprint is available; never send an empty or invented value."}
311            },
312            "required": ["ref"],
313            "additionalProperties": false
314        })
315    }
316
317    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
318        Box::pin(async move {
319            let flow_ref = string_arg(&args, "ref", "flow.describe")?;
320            let expected_version = optional_string_arg(&args, "version", "flow.describe")?;
321            describe_catalog_entry(
322                &FlowCatalog::load(ctx)?,
323                flow_ref,
324                expected_version.as_deref(),
325            )
326        })
327    }
328}
329
330fn search_catalog(
331    catalog: &FlowCatalog,
332    query: &str,
333    limit: usize,
334    cursor: Option<&str>,
335) -> ToolResult {
336    let normalized_query = query.trim().to_lowercase();
337    let search_fingerprint = search_fingerprint(&catalog.fingerprint, &normalized_query);
338    let offset = cursor
339        .map(|cursor| parse_cursor(cursor, &search_fingerprint))
340        .transpose()?
341        .unwrap_or(0);
342    let mut entries = catalog
343        .searchable_entries()
344        .into_iter()
345        .filter_map(|flow| search_score(flow, &normalized_query).map(|score| (score, flow)))
346        .collect::<Vec<_>>();
347    entries.sort_by(|(left_score, left), (right_score, right)| {
348        right_score
349            .cmp(left_score)
350            .then_with(|| left.reference.cmp(&right.reference))
351    });
352    if offset > entries.len() {
353        return Err(RuntimeError::ToolFailed(
354            "flow.search: cursor offset is outside the result set".into(),
355        ));
356    }
357    let end = (offset + limit).min(entries.len());
358    let items = entries[offset..end]
359        .iter()
360        .map(|(_, flow)| {
361            Value::Struct(vec![
362                ("ref".into(), Value::Str(flow.reference.clone())),
363                ("version".into(), Value::Str(flow.version.clone())),
364                ("summary".into(), Value::Str(flow.summary.clone())),
365                ("scope".into(), Value::Str(flow.scope.into())),
366                (
367                    "source_path".into(),
368                    Value::Str(flow.source_path.display().to_string()),
369                ),
370            ])
371        })
372        .collect();
373    let next_cursor = if end < entries.len() {
374        Value::Str(format!("{search_fingerprint}:{end}"))
375    } else {
376        Value::Unit
377    };
378    Ok(Value::Struct(vec![
379        ("items".into(), Value::List(items)),
380        ("next_cursor".into(), next_cursor),
381        ("total".into(), Value::Int(entries.len() as i64)),
382        (
383            "catalog_fingerprint".into(),
384            Value::Str(catalog.fingerprint.clone()),
385        ),
386    ]))
387}
388
389fn search_score(flow: &FlowEntry, query: &str) -> Option<u32> {
390    if query.is_empty() {
391        return Some(0);
392    }
393    let name = flow.name.to_lowercase();
394    let reference = flow.reference.to_lowercase();
395    let summary = flow.summary.to_lowercase();
396    let mut score = if name.contains(query) || reference.contains(query) || summary.contains(query)
397    {
398        100
399    } else {
400        0
401    };
402    for token in query
403        .split(|character: char| !character.is_alphanumeric())
404        .filter(|token| !token.is_empty())
405    {
406        if name == token {
407            score += 24;
408        } else if name.contains(token) {
409            score += 12;
410        }
411        if reference.contains(token) {
412            score += 8;
413        }
414        if summary.contains(token) {
415            score += 4;
416        }
417    }
418    (score > 0).then_some(score)
419}
420
421fn describe_catalog_entry(
422    catalog: &FlowCatalog,
423    flow_ref: &str,
424    expected_version: Option<&str>,
425) -> ToolResult {
426    let flow = catalog.find(flow_ref).ok_or_else(|| {
427        RuntimeError::ToolFailed(format!("flow.describe: flow `{flow_ref}` not found"))
428    })?;
429    if expected_version.is_some_and(|version| version != flow.version) {
430        return Err(RuntimeError::ToolFailed(format!(
431            "flow.describe: stale version for `{}`; search again",
432            flow.reference
433        )));
434    }
435    Ok(flow_detail_value(flow))
436}
437
438fn scan_flow_file(
439    path: &Path,
440    scope: super::flow_source::FlowSourceScope,
441) -> Result<FlowFile, RuntimeError> {
442    let source = std::fs::read_to_string(path).map_err(|error| {
443        RuntimeError::ToolFailed(format!("flow catalog: read {}: {error}", path.display()))
444    })?;
445    let parsed = atman_dsl::parse::parse_file(&source).map_err(|error| {
446        RuntimeError::ToolFailed(format!("flow catalog: parse {}: {error}", path.display()))
447    })?;
448    let file_name = path
449        .file_name()
450        .and_then(|name| name.to_str())
451        .unwrap_or_default()
452        .to_string();
453    let description = parsed
454        .flows
455        .iter()
456        .find(|flow| flow.name.name == "describe")
457        .and_then(extract_return_string_literal)
458        .unwrap_or_default();
459    let version = format!("blake3:{}", blake3::hash(source.as_bytes()).to_hex());
460    let flows = parsed
461        .flows
462        .iter()
463        .map(|flow| flow_entry(&file_name, &description, &version, scope, path, flow))
464        .collect();
465    Ok(FlowFile {
466        name: file_name,
467        description,
468        scope: scope.as_str(),
469        source_path: path.to_path_buf(),
470        flows,
471    })
472}
473
474fn flow_entry(
475    file_name: &str,
476    description: &str,
477    version: &str,
478    scope: super::flow_source::FlowSourceScope,
479    source_path: &Path,
480    flow: &FlowDecl,
481) -> FlowEntry {
482    FlowEntry {
483        name: flow.name.name.clone(),
484        reference: format!("{file_name}@{}", flow.name.name),
485        version: version.to_string(),
486        summary: description.to_string(),
487        scope: scope.as_str(),
488        source_path: source_path.to_path_buf(),
489        params: flow
490            .params
491            .iter()
492            .map(|parameter| FlowParameter {
493                name: parameter.name.name.clone(),
494                ty: render_type(&parameter.ty),
495                default: parameter.default.as_ref().map(expr_to_value),
496            })
497            .collect(),
498    }
499}
500
501fn flow_detail_value(flow: &FlowEntry) -> Value {
502    Value::Struct(vec![
503        ("name".into(), Value::Str(flow.name.clone())),
504        ("ref".into(), Value::Str(flow.reference.clone())),
505        ("version".into(), Value::Str(flow.version.clone())),
506        ("summary".into(), Value::Str(flow.summary.clone())),
507        ("scope".into(), Value::Str(flow.scope.into())),
508        (
509            "source_path".into(),
510            Value::Str(flow.source_path.display().to_string()),
511        ),
512        (
513            "params".into(),
514            Value::List(
515                flow.params
516                    .iter()
517                    .map(|parameter| {
518                        Value::Struct(vec![
519                            ("name".into(), Value::Str(parameter.name.clone())),
520                            ("type".into(), Value::Str(parameter.ty.clone())),
521                            ("required".into(), Value::Bool(parameter.default.is_none())),
522                            (
523                                "default".into(),
524                                parameter.default.clone().unwrap_or(Value::Unit),
525                            ),
526                        ])
527                    })
528                    .collect(),
529            ),
530        ),
531    ])
532}
533
534fn legacy_flow_value(flow: &FlowEntry) -> Value {
535    Value::Struct(vec![
536        ("name".into(), Value::Str(flow.name.clone())),
537        ("ref".into(), Value::Str(flow.reference.clone())),
538        (
539            "params".into(),
540            Value::List(
541                flow.params
542                    .iter()
543                    .map(|parameter| {
544                        Value::Struct(vec![
545                            ("name".into(), Value::Str(parameter.name.clone())),
546                            ("ty".into(), Value::Str(parameter.ty.clone())),
547                            (
548                                "default".into(),
549                                parameter.default.clone().unwrap_or(Value::Unit),
550                            ),
551                        ])
552                    })
553                    .collect(),
554            ),
555        ),
556    ])
557}
558
559fn normalize_flow_ref(flow_ref: &str) -> Option<String> {
560    let (file, flow) = flow_ref.split_once('@')?;
561    if file.is_empty() || flow.is_empty() {
562        return None;
563    }
564    let file = normalize_flow_file(file)?;
565    Some(format!("{file}@{flow}"))
566}
567
568fn normalize_flow_file(file: &str) -> Option<String> {
569    let file = file.trim();
570    if file.is_empty() {
571        return None;
572    }
573    Some(if file.ends_with(".at") {
574        file.to_string()
575    } else {
576        format!("{file}.at")
577    })
578}
579
580fn search_fingerprint(catalog_fingerprint: &str, query: &str) -> String {
581    let mut hasher = blake3::Hasher::new();
582    hasher.update(catalog_fingerprint.as_bytes());
583    hasher.update(&[0]);
584    hasher.update(query.as_bytes());
585    format!("blake3:{}", hasher.finalize().to_hex())
586}
587
588fn parse_cursor(cursor: &str, expected_fingerprint: &str) -> Result<usize, RuntimeError> {
589    let (fingerprint, offset) = cursor.rsplit_once(':').ok_or_else(|| {
590        RuntimeError::ToolFailed("flow.search: malformed cursor; search again".into())
591    })?;
592    if fingerprint != expected_fingerprint {
593        return Err(RuntimeError::ToolFailed(
594            "flow.search: stale cursor; search again".into(),
595        ));
596    }
597    offset
598        .parse::<usize>()
599        .map_err(|_| RuntimeError::ToolFailed("flow.search: malformed cursor; search again".into()))
600}
601
602fn string_arg<'a>(args: &'a ToolArgs, name: &str, tool: &str) -> Result<&'a str, RuntimeError> {
603    match args.named(name) {
604        Some(Value::Str(value)) => Ok(value),
605        Some(other) => Err(RuntimeError::ToolFailed(format!(
606            "{tool}: `{name}` must be a string, got {}",
607            other.kind_name()
608        ))),
609        None => Err(RuntimeError::MissingArg(name.to_string())),
610    }
611}
612
613fn optional_string_arg(
614    args: &ToolArgs,
615    name: &str,
616    tool: &str,
617) -> Result<Option<String>, RuntimeError> {
618    match args.named(name) {
619        Some(Value::Str(value)) if value.trim().is_empty() => Ok(None),
620        Some(Value::Str(value)) => Ok(Some(value.clone())),
621        Some(Value::Unit) | None => Ok(None),
622        Some(other) => Err(RuntimeError::ToolFailed(format!(
623            "{tool}: `{name}` must be a string, got {}",
624            other.kind_name()
625        ))),
626    }
627}
628
629fn limit_arg(args: &ToolArgs, tool: &str) -> Result<usize, RuntimeError> {
630    match args.named("limit") {
631        None | Some(Value::Unit) => Ok(DEFAULT_SEARCH_LIMIT),
632        Some(Value::Int(limit)) if (1..=MAX_SEARCH_LIMIT as i64).contains(limit) => {
633            Ok(*limit as usize)
634        }
635        Some(Value::Int(_)) => Err(RuntimeError::ToolFailed(format!(
636            "{tool}: `limit` must be between 1 and {MAX_SEARCH_LIMIT}"
637        ))),
638        Some(other) => Err(RuntimeError::ToolFailed(format!(
639            "{tool}: `limit` must be an integer, got {}",
640            other.kind_name()
641        ))),
642    }
643}
644
645fn extract_return_string_literal(flow: &FlowDecl) -> Option<String> {
646    flow.body.iter().find_map(|statement| match statement {
647        Stmt::Return {
648            value: Expr::Literal(Literal::Str(value)),
649        } => Some(value.clone()),
650        _ => None,
651    })
652}
653
654pub(super) fn render_type(ty: &TypeExpr) -> String {
655    match ty {
656        TypeExpr::Named(name) => name.name.clone(),
657        TypeExpr::List(inner) => format!("[{}]", render_type(inner)),
658        TypeExpr::Struct(fields) => format!(
659            "{{{}}}",
660            fields
661                .iter()
662                .map(|(name, ty)| format!("{}: {}", name.name, render_type(ty)))
663                .collect::<Vec<_>>()
664                .join(", ")
665        ),
666    }
667}
668
669fn expr_to_value(expr: &Expr) -> Value {
670    match expr {
671        Expr::Literal(Literal::Int(value)) => Value::Int(*value),
672        Expr::Literal(Literal::Float(value)) => Value::Float(*value),
673        Expr::Literal(Literal::Bool(value)) => Value::Bool(*value),
674        Expr::Literal(Literal::Str(value)) => Value::Str(value.clone()),
675        _ => Value::Unit,
676    }
677}
678
679#[cfg(test)]
680mod tests {
681    use super::*;
682
683    fn write_flow(dir: &Path, name: &str, source: &str) {
684        std::fs::write(dir.join(name), source).unwrap();
685    }
686
687    fn next_cursor(value: &Value) -> Option<&str> {
688        match value.field("next_cursor") {
689            Some(Value::Str(cursor)) => Some(cursor),
690            _ => None,
691        }
692    }
693
694    #[test]
695    fn optional_discovery_values_treat_blank_strings_as_omitted() {
696        for value in ["", " ", "\t\n"] {
697            let args = ToolArgs {
698                named: vec![("value".into(), Value::Str(value.into()))],
699                ..Default::default()
700            };
701            assert_eq!(
702                optional_string_arg(&args, "value", "flow.test").unwrap(),
703                None
704            );
705        }
706
707        let args = ToolArgs {
708            named: vec![("value".into(), Value::Str("blake3:123".into()))],
709            ..Default::default()
710        };
711        assert_eq!(
712            optional_string_arg(&args, "value", "flow.test").unwrap(),
713            Some("blake3:123".into())
714        );
715    }
716
717    #[test]
718    fn search_is_bounded_and_cursor_is_bound_to_catalog_revision() {
719        let dir = tempfile::tempdir().unwrap();
720        write_flow(
721            dir.path(),
722            "agents.at",
723            r#"
724flow describe() -> string { return "Delegated work" }
725flow alpha(goal: string) -> string { return goal }
726flow beta(goal: string) -> string { return goal }
727flow gamma(goal: string) -> string { return goal }
728"#,
729        );
730        let catalog = FlowCatalog::load_from(dir.path()).unwrap();
731        let first = search_catalog(&catalog, "", 2, None).unwrap();
732        let cursor = next_cursor(&first).unwrap().to_string();
733        assert_eq!(
734            first.field("items").and_then(|items| match items {
735                Value::List(items) => Some(items.len()),
736                _ => None,
737            }),
738            Some(2)
739        );
740        let second = search_catalog(&catalog, "", 2, Some(&cursor)).unwrap();
741        assert!(next_cursor(&second).is_none());
742
743        write_flow(
744            dir.path(),
745            "extra.at",
746            "flow delta(goal: string) -> string { return goal }",
747        );
748        let changed = FlowCatalog::load_from(dir.path()).unwrap();
749        let error = search_catalog(&changed, "", 2, Some(&cursor)).unwrap_err();
750        assert!(error.to_string().contains("stale cursor"));
751    }
752
753    #[test]
754    fn describe_returns_parameter_contract_and_rejects_stale_version() {
755        let dir = tempfile::tempdir().unwrap();
756        write_flow(
757            dir.path(),
758            "review.at",
759            r#"
760flow describe() -> string { return "Review code" }
761flow review(goal: string, retries: int = 3) -> string { return goal }
762"#,
763        );
764        let catalog = FlowCatalog::load_from(dir.path()).unwrap();
765        let flow = catalog.find("review@review").unwrap();
766        let described =
767            describe_catalog_entry(&catalog, "review.at@review", Some(&flow.version)).unwrap();
768        assert_eq!(
769            described.field("summary").and_then(as_str),
770            Some("Review code")
771        );
772        let params = match described.field("params") {
773            Some(Value::List(params)) => params,
774            _ => panic!("params must be a list"),
775        };
776        assert_eq!(params.len(), 2);
777        assert_eq!(params[0].field("required").and_then(as_bool), Some(true));
778        assert_eq!(params[1].field("default").and_then(as_int), Some(3));
779
780        let error =
781            describe_catalog_entry(&catalog, "review@review", Some("blake3:stale")).unwrap_err();
782        assert!(error.to_string().contains("stale version"));
783    }
784
785    #[test]
786    fn search_ranks_partial_natural_language_matches() {
787        let dir = tempfile::tempdir().unwrap();
788        write_flow(
789            dir.path(),
790            "subagent.at",
791            r#"
792flow describe() -> string { return "Sub-agent flows for isolated research, verification, implementation, and review. The research role reads files without making changes." }
793flow subagent(goal: string, role: string = "research") -> string { return goal }
794"#,
795        );
796        write_flow(
797            dir.path(),
798            "review.at",
799            r#"
800flow describe() -> string { return "Review files" }
801flow review(goal: string) -> string { return goal }
802"#,
803        );
804        let catalog = FlowCatalog::load_from(dir.path()).unwrap();
805        let result = search_catalog(&catalog, "subagent research read files", 5, None).unwrap();
806        let items = match result.field("items") {
807            Some(Value::List(items)) => items,
808            _ => panic!("items must be a list"),
809        };
810        assert_eq!(
811            items[0].field("ref").and_then(as_str),
812            Some("subagent.at@subagent")
813        );
814    }
815
816    #[test]
817    fn describe_accepts_the_same_file_shorthand_as_spawn() {
818        let dir = tempfile::tempdir().unwrap();
819        write_flow(
820            dir.path(),
821            "subagent.at",
822            r#"
823flow describe() -> string { return "Delegated work" }
824flow subagent(goal: string) -> string { return goal }
825flow research_loop(goal: string) -> string { return goal }
826"#,
827        );
828        let catalog = FlowCatalog::load_from(dir.path()).unwrap();
829        let described = describe_catalog_entry(&catalog, "subagent.at", None).unwrap();
830        assert_eq!(
831            described.field("ref").and_then(as_str),
832            Some("subagent.at@subagent")
833        );
834    }
835
836    fn as_str(value: &Value) -> Option<&str> {
837        match value {
838            Value::Str(value) => Some(value),
839            _ => None,
840        }
841    }
842
843    fn as_bool(value: &Value) -> Option<bool> {
844        match value {
845            Value::Bool(value) => Some(*value),
846            _ => None,
847        }
848    }
849
850    fn as_int(value: &Value) -> Option<i64> {
851        match value {
852            Value::Int(value) => Some(*value),
853            _ => None,
854        }
855    }
856}