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