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}