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(¶meter.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}