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