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}