Skip to main content

converge_core/
capability.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Capability abstractions for Converge providers.
5//!
6//! This module defines the abstract capability traits that providers can implement.
7//! A single provider may implement multiple capabilities (e.g., Qwen3-VL supports
8//! Embedding + Reranking + Vision).
9//!
10//! # Architecture
11//!
12//! ```text
13//! Capabilities (what)          Providers (who/where)
14//! ──────────────────          ────────────────────
15//! Completion                   Anthropic, OpenAI, Ollama
16//! Embedding                    OpenAI, Qwen3-VL, Ollama/nomic
17//! Reranking                    Qwen3-VL, Cohere
18//! VectorRecall                 LanceDB, Qdrant
19//! GraphRecall                  Neo4j, NebulaGraph
20//! Vision                       Claude, GPT-4V, Qwen-VL
21//! ```
22//!
23//! # Design Principles
24//!
25//! 1. **Capabilities produce candidates, not decisions** - Aligned with Converge's
26//!    "LLMs suggest, never decide" principle. Embeddings, reranking, and recall
27//!    operations return scored candidates that must go through validation.
28//!
29//! 2. **Stores are caches, not truth** - Vector and graph stores can be rebuilt
30//!    from the authoritative Context at any time.
31//!
32//! 3. **Explicit provenance** - Every operation tracks its source for auditability.
33//!
34//! # Example
35//!
36//! ```ignore
37//! use converge_core::capability::{Embedding, EmbedInput, EmbedRequest};
38//!
39//! // Embed text and images in a shared space (Qwen3-VL style)
40//! let request = EmbedRequest::new(vec![
41//!     EmbedInput::Text("Product description".into()),
42//!     EmbedInput::image_path("/screenshots/dashboard.png"),
43//! ]);
44//!
45//! let response = embedder.embed(&request).await?;
46//! // response.embeddings contains vectors in unified semantic space
47//! ```
48
49use serde::{Deserialize, Serialize};
50use std::path::PathBuf;
51
52// =============================================================================
53// COMMON TYPES
54// =============================================================================
55
56/// Input modalities that capabilities can handle.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
58pub enum Modality {
59    /// Plain text input.
60    Text,
61    /// Image input (PNG, JPEG, etc.).
62    Image,
63    /// Video input (frames or full video).
64    Video,
65    /// Audio input (speech, sound).
66    Audio,
67    /// Structured data (JSON, tables).
68    Structured,
69}
70
71/// Error from a capability operation.
72#[derive(Debug, Clone)]
73pub struct CapabilityError {
74    /// Error kind.
75    pub kind: CapabilityErrorKind,
76    /// Human-readable message.
77    pub message: String,
78    /// Whether the operation can be retried.
79    pub retryable: bool,
80}
81
82impl std::fmt::Display for CapabilityError {
83    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
84        write!(f, "{:?}: {}", self.kind, self.message)
85    }
86}
87
88impl std::error::Error for CapabilityError {}
89
90/// Kind of capability error.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum CapabilityErrorKind {
93    /// Authentication failed.
94    Authentication,
95    /// Rate limit exceeded.
96    RateLimit,
97    /// Invalid input.
98    InvalidInput,
99    /// Unsupported modality.
100    UnsupportedModality,
101    /// Network error.
102    Network,
103    /// Provider returned an error.
104    ProviderError,
105    /// Store operation failed.
106    StoreError,
107    /// Resource not found.
108    NotFound,
109    /// Operation timed out.
110    Timeout,
111}
112
113impl CapabilityError {
114    /// Creates an invalid input error.
115    #[must_use]
116    pub fn invalid_input(message: impl Into<String>) -> Self {
117        Self {
118            kind: CapabilityErrorKind::InvalidInput,
119            message: message.into(),
120            retryable: false,
121        }
122    }
123
124    /// Creates an unsupported modality error.
125    #[must_use]
126    pub fn unsupported_modality(modality: Modality) -> Self {
127        Self {
128            kind: CapabilityErrorKind::UnsupportedModality,
129            message: format!("Modality {modality:?} is not supported by this provider"),
130            retryable: false,
131        }
132    }
133
134    /// Creates a store error.
135    #[must_use]
136    pub fn store(message: impl Into<String>) -> Self {
137        Self {
138            kind: CapabilityErrorKind::StoreError,
139            message: message.into(),
140            retryable: false,
141        }
142    }
143
144    /// Creates a network error.
145    #[must_use]
146    pub fn network(message: impl Into<String>) -> Self {
147        Self {
148            kind: CapabilityErrorKind::Network,
149            message: message.into(),
150            retryable: true,
151        }
152    }
153
154    /// Creates an authentication error.
155    #[must_use]
156    pub fn auth(message: impl Into<String>) -> Self {
157        Self {
158            kind: CapabilityErrorKind::Authentication,
159            message: message.into(),
160            retryable: false,
161        }
162    }
163
164    /// Creates a not found error.
165    #[must_use]
166    pub fn not_found(message: impl Into<String>) -> Self {
167        Self {
168            kind: CapabilityErrorKind::NotFound,
169            message: message.into(),
170            retryable: false,
171        }
172    }
173}
174
175// =============================================================================
176// EMBEDDING CAPABILITY
177// =============================================================================
178
179/// Input for embedding operations.
180///
181/// Supports multiple modalities for multimodal embedders like Qwen3-VL.
182#[derive(Debug, Clone)]
183pub enum EmbedInput {
184    /// Plain text input.
185    Text(String),
186    /// Raw image bytes with MIME type.
187    ImageBytes {
188        /// Image data.
189        data: Vec<u8>,
190        /// MIME type (e.g., "image/png").
191        mime_type: String,
192    },
193    /// Path to an image file (for lazy loading).
194    ImagePath(PathBuf),
195    /// Video frame at a specific timestamp.
196    VideoFrame {
197        /// Path to video file.
198        path: PathBuf,
199        /// Timestamp in milliseconds.
200        timestamp_ms: u64,
201    },
202    /// Mixed modality input (e.g., text + image together).
203    /// Qwen3-VL supports this for joint embedding.
204    Mixed(Vec<EmbedInput>),
205}
206
207impl EmbedInput {
208    /// Creates a text input.
209    #[must_use]
210    pub fn text(s: impl Into<String>) -> Self {
211        Self::Text(s.into())
212    }
213
214    /// Creates an image path input.
215    #[must_use]
216    pub fn image_path(path: impl Into<PathBuf>) -> Self {
217        Self::ImagePath(path.into())
218    }
219
220    /// Creates an image bytes input.
221    #[must_use]
222    pub fn image_bytes(data: Vec<u8>, mime_type: impl Into<String>) -> Self {
223        Self::ImageBytes {
224            data,
225            mime_type: mime_type.into(),
226        }
227    }
228
229    /// Returns the primary modality of this input.
230    #[must_use]
231    pub fn modality(&self) -> Modality {
232        match self {
233            Self::Text(_) => Modality::Text,
234            Self::ImageBytes { .. } | Self::ImagePath(_) => Modality::Image,
235            Self::VideoFrame { .. } => Modality::Video,
236            Self::Mixed(_) => Modality::Structured, // Mixed is its own thing
237        }
238    }
239}
240
241/// Request for embedding operation.
242#[derive(Debug, Clone)]
243pub struct EmbedRequest {
244    /// Inputs to embed.
245    pub inputs: Vec<EmbedInput>,
246    /// Desired embedding dimensions (if configurable).
247    /// Qwen3-VL supports configurable dimensions.
248    pub dimensions: Option<usize>,
249    /// Task-specific instruction for the embedder.
250    /// Helps the model understand the embedding purpose.
251    pub task_instruction: Option<String>,
252    /// Whether to normalize the output vectors.
253    pub normalize: bool,
254}
255
256impl EmbedRequest {
257    /// Creates a new embedding request.
258    #[must_use]
259    pub fn new(inputs: Vec<EmbedInput>) -> Self {
260        Self {
261            inputs,
262            dimensions: None,
263            task_instruction: None,
264            normalize: true,
265        }
266    }
267
268    /// Creates a request for a single text input.
269    #[must_use]
270    pub fn text(s: impl Into<String>) -> Self {
271        Self::new(vec![EmbedInput::text(s)])
272    }
273
274    /// Sets the desired dimensions.
275    #[must_use]
276    pub fn with_dimensions(mut self, dim: usize) -> Self {
277        self.dimensions = Some(dim);
278        self
279    }
280
281    /// Sets the task instruction.
282    #[must_use]
283    pub fn with_task(mut self, instruction: impl Into<String>) -> Self {
284        self.task_instruction = Some(instruction.into());
285        self
286    }
287
288    /// Sets normalization preference.
289    #[must_use]
290    pub fn with_normalize(mut self, normalize: bool) -> Self {
291        self.normalize = normalize;
292        self
293    }
294}
295
296/// Response from an embedding operation.
297#[derive(Debug, Clone)]
298pub struct EmbedResponse {
299    /// Embedding vectors, one per input.
300    pub embeddings: Vec<Vec<f32>>,
301    /// Model that generated the embeddings.
302    pub model: String,
303    /// Dimensions of each embedding.
304    pub dimensions: usize,
305    /// Token/unit usage statistics.
306    pub usage: Option<EmbedUsage>,
307}
308
309/// Usage statistics for embedding operations.
310#[derive(Debug, Clone, Default)]
311pub struct EmbedUsage {
312    /// Total tokens processed.
313    pub total_tokens: u32,
314}
315
316/// Trait for providers that can generate embeddings.
317///
318/// Embeddings map inputs (text, images, etc.) to dense vectors in a
319/// shared semantic space. These vectors enable similarity search.
320pub trait Embedding: Send + Sync {
321    /// Name of this embedding provider.
322    fn name(&self) -> &str;
323
324    /// Modalities this embedder supports.
325    fn modalities(&self) -> Vec<Modality>;
326
327    /// Default embedding dimensions.
328    fn default_dimensions(&self) -> usize;
329
330    /// Generates embeddings for the given inputs.
331    ///
332    /// # Errors
333    ///
334    /// Returns error if embedding fails.
335    fn embed(&self, request: &EmbedRequest) -> Result<EmbedResponse, CapabilityError>;
336
337    /// Checks if this embedder supports a given modality.
338    fn supports(&self, modality: Modality) -> bool {
339        self.modalities().contains(&modality)
340    }
341}
342
343// =============================================================================
344// RERANKING CAPABILITY
345// =============================================================================
346
347/// Request for reranking operation.
348#[derive(Debug, Clone)]
349pub struct RerankRequest {
350    /// The query to rank against.
351    pub query: EmbedInput,
352    /// Candidate items to rerank.
353    pub candidates: Vec<EmbedInput>,
354    /// Maximum number of results to return.
355    pub top_k: Option<usize>,
356    /// Minimum score threshold (0.0-1.0).
357    pub min_score: Option<f64>,
358}
359
360impl RerankRequest {
361    /// Creates a new rerank request.
362    #[must_use]
363    pub fn new(query: EmbedInput, candidates: Vec<EmbedInput>) -> Self {
364        Self {
365            query,
366            candidates,
367            top_k: None,
368            min_score: None,
369        }
370    }
371
372    /// Creates a text-only rerank request.
373    #[must_use]
374    pub fn text(query: impl Into<String>, candidates: Vec<String>) -> Self {
375        Self::new(
376            EmbedInput::text(query),
377            candidates.into_iter().map(EmbedInput::text).collect(),
378        )
379    }
380
381    /// Sets the top-k limit.
382    #[must_use]
383    pub fn with_top_k(mut self, k: usize) -> Self {
384        self.top_k = Some(k);
385        self
386    }
387
388    /// Sets the minimum score threshold.
389    #[must_use]
390    pub fn with_min_score(mut self, score: f64) -> Self {
391        self.min_score = Some(score);
392        self
393    }
394}
395
396/// A single ranked item from reranking.
397#[derive(Debug, Clone)]
398pub struct RankedItem {
399    /// Index in the original candidates list.
400    pub index: usize,
401    /// Relevance score (0.0-1.0, higher = more relevant).
402    pub score: f64,
403}
404
405/// Response from a reranking operation.
406#[derive(Debug, Clone)]
407pub struct RerankResponse {
408    /// Ranked items, sorted by score descending.
409    pub ranked: Vec<RankedItem>,
410    /// Model that performed the reranking.
411    pub model: String,
412}
413
414/// Trait for providers that can rerank candidates by relevance.
415///
416/// Reranking takes a query and a list of candidates, returning
417/// fine-grained relevance scores. This is the second stage in
418/// two-stage retrieval (embedding recall → reranking).
419pub trait Reranking: Send + Sync {
420    /// Name of this reranker.
421    fn name(&self) -> &str;
422
423    /// Modalities this reranker supports.
424    fn modalities(&self) -> Vec<Modality>;
425
426    /// Reranks candidates by relevance to the query.
427    ///
428    /// # Errors
429    ///
430    /// Returns error if reranking fails.
431    fn rerank(&self, request: &RerankRequest) -> Result<RerankResponse, CapabilityError>;
432}
433
434// =============================================================================
435// VECTOR RECALL CAPABILITY (Vector Store)
436// =============================================================================
437
438/// A stored vector with its metadata.
439#[derive(Debug, Clone, Serialize, Deserialize)]
440pub struct VectorRecord {
441    /// Unique identifier.
442    pub id: String,
443    /// The embedding vector.
444    pub vector: Vec<f32>,
445    /// Associated metadata (JSON-serializable).
446    pub payload: serde_json::Value,
447}
448
449/// Query for vector similarity search.
450#[derive(Debug, Clone)]
451pub struct VectorQuery {
452    /// Query vector.
453    pub vector: Vec<f32>,
454    /// Maximum number of results.
455    pub top_k: usize,
456    /// Metadata filter (provider-specific).
457    pub filter: Option<serde_json::Value>,
458    /// Minimum similarity threshold (0.0-1.0).
459    pub min_score: Option<f64>,
460}
461
462impl VectorQuery {
463    /// Creates a new vector query.
464    #[must_use]
465    pub fn new(vector: Vec<f32>, top_k: usize) -> Self {
466        Self {
467            vector,
468            top_k,
469            filter: None,
470            min_score: None,
471        }
472    }
473
474    /// Sets a metadata filter.
475    #[must_use]
476    pub fn with_filter(mut self, filter: serde_json::Value) -> Self {
477        self.filter = Some(filter);
478        self
479    }
480
481    /// Sets minimum similarity threshold.
482    #[must_use]
483    pub fn with_min_score(mut self, score: f64) -> Self {
484        self.min_score = Some(score);
485        self
486    }
487}
488
489/// A match from vector similarity search.
490#[derive(Debug, Clone)]
491pub struct VectorMatch {
492    /// ID of the matched record.
493    pub id: String,
494    /// Similarity score (0.0-1.0 for cosine, higher = more similar).
495    pub score: f64,
496    /// Payload from the matched record.
497    pub payload: serde_json::Value,
498}
499
500/// Trait for vector stores that enable similarity search.
501///
502/// Vector stores are **caches**, not authoritative state. They can
503/// always be rebuilt from the Context which is the source of truth.
504///
505/// # Design Note
506///
507/// This follows Converge's principle: vector stores expand what
508/// agents can *see*, not what they are allowed to *decide*.
509pub trait VectorRecall: Send + Sync {
510    /// Name of this vector store.
511    fn name(&self) -> &str;
512
513    /// Insert or update a vector record.
514    ///
515    /// # Errors
516    ///
517    /// Returns error if upsert fails.
518    fn upsert(&self, record: &VectorRecord) -> Result<(), CapabilityError>;
519
520    /// Batch upsert multiple records.
521    ///
522    /// Default implementation calls `upsert` for each record.
523    ///
524    /// # Errors
525    ///
526    /// Returns error if any upsert fails.
527    fn upsert_batch(&self, records: &[VectorRecord]) -> Result<(), CapabilityError> {
528        for record in records {
529            self.upsert(record)?;
530        }
531        Ok(())
532    }
533
534    /// Query for similar vectors.
535    ///
536    /// # Errors
537    ///
538    /// Returns error if query fails.
539    fn query(&self, query: &VectorQuery) -> Result<Vec<VectorMatch>, CapabilityError>;
540
541    /// Delete a record by ID.
542    ///
543    /// # Errors
544    ///
545    /// Returns error if deletion fails.
546    fn delete(&self, id: &str) -> Result<(), CapabilityError>;
547
548    /// Clear all records from the store.
549    ///
550    /// This is safe because vector stores are regenerable caches.
551    ///
552    /// # Errors
553    ///
554    /// Returns error if clear fails.
555    fn clear(&self) -> Result<(), CapabilityError>;
556
557    /// Count of records in the store.
558    ///
559    /// # Errors
560    ///
561    /// Returns error if count fails.
562    fn count(&self) -> Result<usize, CapabilityError>;
563}
564
565// =============================================================================
566// GRAPH RECALL CAPABILITY (Graph Store)
567// =============================================================================
568
569/// A node in a knowledge graph.
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct GraphNode {
572    /// Unique identifier.
573    pub id: String,
574    /// Node label/type (e.g., "Company", "Person", "Product").
575    pub label: String,
576    /// Node properties.
577    pub properties: serde_json::Value,
578}
579
580/// An edge in a knowledge graph.
581#[derive(Debug, Clone, Serialize, Deserialize)]
582pub struct GraphEdge {
583    /// Source node ID.
584    pub from: String,
585    /// Target node ID.
586    pub to: String,
587    /// Relationship type (e.g., "`WORKS_FOR`", "`COMPETES_WITH`").
588    pub relationship: String,
589    /// Edge properties (optional).
590    pub properties: Option<serde_json::Value>,
591}
592
593/// Query for graph traversal.
594#[derive(Debug, Clone)]
595pub struct GraphQuery {
596    /// Starting node ID(s).
597    pub start_nodes: Vec<String>,
598    /// Relationship types to traverse (empty = all).
599    pub relationships: Vec<String>,
600    /// Maximum traversal depth.
601    pub max_depth: usize,
602    /// Maximum results to return.
603    pub limit: usize,
604}
605
606impl GraphQuery {
607    /// Creates a new graph query starting from a single node.
608    #[must_use]
609    pub fn from_node(id: impl Into<String>) -> Self {
610        Self {
611            start_nodes: vec![id.into()],
612            relationships: Vec::new(),
613            max_depth: 2,
614            limit: 100,
615        }
616    }
617
618    /// Sets the relationships to traverse.
619    #[must_use]
620    pub fn with_relationships(mut self, rels: Vec<String>) -> Self {
621        self.relationships = rels;
622        self
623    }
624
625    /// Sets the maximum depth.
626    #[must_use]
627    pub fn with_max_depth(mut self, depth: usize) -> Self {
628        self.max_depth = depth;
629        self
630    }
631
632    /// Sets the result limit.
633    #[must_use]
634    pub fn with_limit(mut self, limit: usize) -> Self {
635        self.limit = limit;
636        self
637    }
638}
639
640/// Result from a graph query.
641#[derive(Debug, Clone)]
642pub struct GraphResult {
643    /// Nodes in the result.
644    pub nodes: Vec<GraphNode>,
645    /// Edges connecting the nodes.
646    pub edges: Vec<GraphEdge>,
647}
648
649/// Trait for graph stores that enable knowledge graph operations.
650///
651/// Graph stores capture structured relationships between entities.
652/// Like vector stores, they are caches that can be rebuilt.
653pub trait GraphRecall: Send + Sync {
654    /// Name of this graph store.
655    fn name(&self) -> &str;
656
657    /// Add a node to the graph.
658    ///
659    /// # Errors
660    ///
661    /// Returns error if operation fails.
662    fn add_node(&self, node: &GraphNode) -> Result<(), CapabilityError>;
663
664    /// Add an edge between nodes.
665    ///
666    /// # Errors
667    ///
668    /// Returns error if operation fails.
669    fn add_edge(&self, edge: &GraphEdge) -> Result<(), CapabilityError>;
670
671    /// Query the graph by traversal.
672    ///
673    /// # Errors
674    ///
675    /// Returns error if query fails.
676    fn traverse(&self, query: &GraphQuery) -> Result<GraphResult, CapabilityError>;
677
678    /// Find nodes by label and properties.
679    ///
680    /// # Errors
681    ///
682    /// Returns error if query fails.
683    fn find_nodes(
684        &self,
685        label: &str,
686        properties: Option<&serde_json::Value>,
687    ) -> Result<Vec<GraphNode>, CapabilityError>;
688
689    /// Get a node by ID.
690    ///
691    /// # Errors
692    ///
693    /// Returns error if query fails.
694    fn get_node(&self, id: &str) -> Result<Option<GraphNode>, CapabilityError>;
695
696    /// Delete a node and its edges.
697    ///
698    /// # Errors
699    ///
700    /// Returns error if deletion fails.
701    fn delete_node(&self, id: &str) -> Result<(), CapabilityError>;
702
703    /// Clear all nodes and edges.
704    ///
705    /// # Errors
706    ///
707    /// Returns error if clear fails.
708    fn clear(&self) -> Result<(), CapabilityError>;
709}
710
711// =============================================================================
712// CAPABILITY METADATA
713// =============================================================================
714
715/// Metadata about a capability provider.
716#[derive(Debug, Clone)]
717pub struct CapabilityMetadata {
718    /// Provider name.
719    pub provider: String,
720    /// Capabilities offered.
721    pub capabilities: Vec<CapabilityKind>,
722    /// Supported modalities.
723    pub modalities: Vec<Modality>,
724    /// Whether this is a local/on-premises provider.
725    pub is_local: bool,
726    /// Typical latency in milliseconds.
727    pub typical_latency_ms: u32,
728}
729
730/// Kinds of capabilities.
731#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
732pub enum CapabilityKind {
733    /// Text/image generation (LLM completion).
734    Completion,
735    /// Vector embedding generation.
736    Embedding,
737    /// Relevance reranking.
738    Reranking,
739    /// Vector similarity search.
740    VectorRecall,
741    /// Graph traversal and querying.
742    GraphRecall,
743    /// Full-text document search.
744    DocRecall,
745    /// Vision/image understanding.
746    Vision,
747    /// Audio processing (speech-to-text, etc.).
748    Audio,
749    /// Code execution.
750    CodeExecution,
751}
752
753// =============================================================================
754// TESTS
755// =============================================================================
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn embed_input_modality() {
763        assert_eq!(EmbedInput::text("hello").modality(), Modality::Text);
764        assert_eq!(
765            EmbedInput::image_path("/foo.png").modality(),
766            Modality::Image
767        );
768    }
769
770    #[test]
771    fn embed_request_builder() {
772        let req = EmbedRequest::text("test")
773            .with_dimensions(512)
774            .with_task("retrieval")
775            .with_normalize(false);
776
777        assert_eq!(req.inputs.len(), 1);
778        assert_eq!(req.dimensions, Some(512));
779        assert_eq!(req.task_instruction, Some("retrieval".into()));
780        assert!(!req.normalize);
781    }
782
783    #[test]
784    fn rerank_request_builder() {
785        let req = RerankRequest::text("query", vec!["a".into(), "b".into()])
786            .with_top_k(5)
787            .with_min_score(0.5);
788
789        assert_eq!(req.candidates.len(), 2);
790        assert_eq!(req.top_k, Some(5));
791        assert_eq!(req.min_score, Some(0.5));
792    }
793
794    #[test]
795    fn vector_query_builder() {
796        let query = VectorQuery::new(vec![0.1, 0.2, 0.3], 10)
797            .with_min_score(0.8)
798            .with_filter(serde_json::json!({"type": "document"}));
799
800        assert_eq!(query.top_k, 10);
801        assert_eq!(query.min_score, Some(0.8));
802        assert!(query.filter.is_some());
803    }
804
805    #[test]
806    fn graph_query_builder() {
807        let query = GraphQuery::from_node("company-1")
808            .with_relationships(vec!["COMPETES_WITH".into()])
809            .with_max_depth(3)
810            .with_limit(50);
811
812        assert_eq!(query.start_nodes, vec!["company-1"]);
813        assert_eq!(query.max_depth, 3);
814        assert_eq!(query.limit, 50);
815    }
816
817    #[test]
818    fn capability_error_creation() {
819        let err = CapabilityError::unsupported_modality(Modality::Video);
820        assert_eq!(err.kind, CapabilityErrorKind::UnsupportedModality);
821        assert!(!err.retryable);
822
823        let err = CapabilityError::network("connection refused");
824        assert_eq!(err.kind, CapabilityErrorKind::Network);
825        assert!(err.retryable);
826    }
827}