Skip to main content

agents_kit/
knowledge.rs

1//! Inputs for privately managed knowledge ingestion and retrieval policy.
2//!
3//! These values carry caller-authored sources and scope choices, not retrieval
4//! internals or Database adapters. Nonempty ingestion atomically replaces the
5//! addressed source set. An empty request is a **no-op after scope validation**;
6//! it is not a deletion request. The receiving operation validates which scopes
7//! it owns and whether the selected backend supports managed knowledge.
8//!
9//! Retrieval is private and happens during a prompt. [`KnowledgePolicy::only`]
10//! narrows eligible owner scopes; it does not promise that a model will quote
11//! every source or follow requested wording. Database scope is shared only when
12//! callers deliberately supply the same Database domain. Agent and Workspace
13//! shutdown preserve sources for reopen; Conversation close removes its owned
14//! sources. Reopen must receive a compatible Database as well as control-plane
15//! storage; missing or mismatched persisted knowledge is an error.
16//!
17//! # Local ingestion and scope validation
18//!
19//! This executes ingestion with embedded Lance StateStore and memory LanceDB
20//! Database, without inference or an Ollama server. These are separate products:
21//! the StateStore holds control-plane records; the Database holds source content.
22//! The request carries sources, never private retrieval context or an adapter.
23//!
24//! ```
25//! use std::{num::NonZeroUsize, time::Duration};
26//! use agents_kit::{AgentBackend, AgentBuilder, AgentStorage};
27//! use agents_kit::config::{AgentConfig, CustomAgentConfig, CustomLoopLimits, CustomToolApproval};
28//! use agents_kit::knowledge::{IngestKnowledgeRequest, KnowledgePolicy, KnowledgeScope, KnowledgeSource};
29//! use agents_kit::{model::ModelSelector, tool::ToolSelection};
30//! use providers_kit::{Provider, ProviderConfig,
31//!     config::{OllamaConfig, ProviderKind, TransportConfig}};
32//! use storage_kit::{Database, StateStore,
33//!     database::config::{DatabaseConfig, LanceConfig},
34//!     state::config::{LanceStateStoreConfig, StateStoreConfig}};
35//! # let runtime = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
36//! # runtime.block_on(async {
37//! let store = StateStore::connect(StateStoreConfig::Lance(LanceStateStoreConfig::memory())).await?;
38//! let database = Database::connect(DatabaseConfig::lance(
39//!     LanceConfig::embedded_path("memory://"))).await?;
40//! let custom = CustomAgentConfig::new(ModelSelector::new("exact-model")?,
41//!     ToolSelection::None, CustomLoopLimits::new(4, 0, 1)?, CustomToolApproval::Deny)?;
42//! let config = AgentConfig::new(custom.into(), Duration::from_secs(10), NonZeroUsize::MIN)?;
43//! let provider = Provider::from_config(ProviderConfig::new(
44//!     ProviderKind::Ollama(OllamaConfig::local()), TransportConfig::default()))?;
45//! let agent = AgentBuilder::create(config, AgentStorage::new(store))
46//!     .with_provider(provider).with_knowledge_database(database).build().await?;
47//! let outcome = async {
48//!     agent.ingest_knowledge(IngestKnowledgeRequest::new(vec![
49//!         KnowledgeSource::text("runbook", "Incident runbook", "Escalate service incidents.")?,
50//!     ])?.with_scope(KnowledgeScope::Agent)).await?;
51//!     agent.ingest_knowledge(IngestKnowledgeRequest::new(vec![])?
52//!         .with_scope(KnowledgeScope::Agent)).await?; // retains the runbook
53//!     let invalid = agent.ingest_knowledge(IngestKnowledgeRequest::new(vec![])?
54//!         .with_scope(KnowledgeScope::Conversation)).await.unwrap_err();
55//!     assert_eq!(invalid.kind(), agents_kit::error::ErrorKind::InvalidInput);
56//!     let request = agents_kit::prompt::PromptRequest::new("How are incidents escalated?")?
57//!         .with_knowledge_policy(KnowledgePolicy::only(vec![KnowledgeScope::Agent])?)?;
58//!     assert_eq!(request.knowledge_policy(),
59//!         Some(&KnowledgePolicy::Only(vec![KnowledgeScope::Agent])));
60//!     // Sending this request through a Conversation requires the configured model.
61//!     Ok::<(), Box<dyn std::error::Error>>(())
62//! }.await;
63//! let shutdown = agent.shutdown().await;
64//! outcome?;
65//! shutdown?;
66//! # Ok::<(), Box<dyn std::error::Error>>(())
67//! # })?;
68//! # Ok::<(), Box<dyn std::error::Error>>(())
69//! ```
70
71use std::collections::{BTreeMap, BTreeSet};
72
73use crate::{AgentId, ConversationId, KitError, WorkspaceId};
74
75const RETRIEVAL_SCOPE_ORDER: [KnowledgeScope; 4] = [
76    KnowledgeScope::Conversation,
77    KnowledgeScope::Workspace,
78    KnowledgeScope::Agent,
79    KnowledgeScope::Database,
80];
81
82/// Complete portable lineage for one semantic-knowledge owner.
83#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
84#[serde(tag = "scope", rename_all = "snake_case")]
85pub(crate) enum KnowledgeOwnerId {
86    /// Shared data in the caller-supplied managed Database domain.
87    Database,
88    /// Data owned by one agent.
89    Agent {
90        /// Owning durable agent identity.
91        agent_id: AgentId,
92    },
93    /// Data owned by one workspace and its agent.
94    Workspace {
95        /// Owning durable agent identity.
96        agent_id: AgentId,
97        /// Owning durable workspace identity.
98        workspace_id: WorkspaceId,
99    },
100    /// Data owned by one conversation, workspace, and agent.
101    Conversation {
102        /// Owning durable agent identity.
103        agent_id: AgentId,
104        /// Owning durable workspace identity.
105        workspace_id: WorkspaceId,
106        /// Owning durable conversation identity.
107        conversation_id: ConversationId,
108    },
109}
110
111/// Positive limits for one structured knowledge response.
112#[derive(Clone, Copy, Debug, Eq, PartialEq)]
113pub(crate) struct KnowledgeBudget {
114    chunks: usize,
115    bytes: usize,
116}
117
118/// Validated semantic retrieval input.
119#[derive(Clone, Debug)]
120pub(crate) struct KnowledgeRetrievalRequest {
121    query: String,
122    owner: KnowledgeOwnerId,
123    policy: KnowledgePolicy,
124    budget: KnowledgeBudget,
125}
126
127/// Structured semantic context returned by a knowledge implementation.
128#[derive(Clone, Debug)]
129pub(crate) struct KnowledgeContext {
130    snippets: Vec<KnowledgeSnippet>,
131}
132
133/// One ranked piece of structured semantic context.
134#[derive(Clone, Debug)]
135pub(crate) struct KnowledgeSnippet {
136    source_id: String,
137    title: String,
138    text: String,
139    owner: KnowledgeOwnerId,
140    relevance_score: Option<f64>,
141    provenance: KnowledgeProvenance,
142    metadata: BTreeMap<String, serde_json::Value>,
143}
144
145/// Optional source location and structured provenance metadata.
146#[derive(Clone, Debug, Default)]
147pub(crate) struct KnowledgeProvenance {
148    locator: Option<String>,
149    metadata: BTreeMap<String, serde_json::Value>,
150}
151
152#[derive(
153    Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, serde::Deserialize, serde::Serialize,
154)]
155#[serde(rename_all = "snake_case")]
156/// A portable ownership scope from which managed knowledge may be retrieved.
157pub enum KnowledgeScope {
158    /// Sources shared through the caller-supplied managed Database domain.
159    Database,
160    /// Sources owned by the agent.
161    Agent,
162    /// Sources owned by the current workspace.
163    Workspace,
164    /// Sources owned by the current conversation.
165    Conversation,
166}
167
168/// Retrieval policy for managed knowledge.
169#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
170#[serde(rename_all = "snake_case")]
171pub enum KnowledgePolicy {
172    /// Applies normal inherited retrieval behavior.
173    Default,
174    /// Excludes managed-knowledge retrieval.
175    Disabled,
176    /// Retrieves only from the selected ownership scopes.
177    Only(
178        /// A nonempty selection without duplicate scopes.
179        Vec<KnowledgeScope>,
180    ),
181}
182
183impl KnowledgePolicy {
184    /// Validates and constructs an exact scope selection.
185    ///
186    /// # Errors
187    /// Returns InvalidInput for no scopes or duplicate scopes.
188    ///
189    /// ```
190    /// use agents_kit::knowledge::{KnowledgePolicy, KnowledgeScope};
191    /// let policy = KnowledgePolicy::only(vec![KnowledgeScope::Workspace])?;
192    /// assert_eq!(policy, KnowledgePolicy::Only(vec![KnowledgeScope::Workspace]));
193    /// assert!(KnowledgePolicy::only(vec![]).is_err());
194    /// # Ok::<(), agents_kit::KitError>(())
195    /// ```
196    pub fn only(scopes: Vec<KnowledgeScope>) -> Result<Self, KitError> {
197        let policy = Self::Only(scopes);
198        policy.validate()?;
199        Ok(policy)
200    }
201
202    pub(crate) fn validate(&self) -> Result<(), KitError> {
203        let Self::Only(scopes) = self else {
204            return Ok(());
205        };
206        if scopes.is_empty() {
207            return Err(KitError::invalid_input(
208                "knowledge scope selection must not be empty",
209            ));
210        }
211        let mut sorted = scopes.clone();
212        sorted.sort();
213        sorted.dedup();
214        if sorted.len() != scopes.len() {
215            return Err(KitError::invalid_input(
216                "knowledge scope selection must not contain duplicates",
217            ));
218        }
219        Ok(())
220    }
221}
222
223/// Caller-authored content for managed-knowledge ingestion.
224///
225/// Prefer [`KnowledgeSource::text`] and [`KnowledgeSource::bytes`] so source
226/// identity and content are validated at construction.
227///
228/// # Examples
229///
230/// ```
231/// use agents_kit::knowledge::{IngestKnowledgeRequest, KnowledgeSource};
232///
233/// let source = KnowledgeSource::text(
234///     "operations-handbook",
235///     "Operations handbook",
236///     "Escalate service incidents.",
237/// )?;
238/// let request = IngestKnowledgeRequest::new(vec![source])?;
239/// assert_eq!(request.sources()[0].source_id(), "operations-handbook");
240/// # Ok::<(), agents_kit::KitError>(())
241/// ```
242#[derive(Clone, Debug, Eq, PartialEq)]
243pub enum KnowledgeSource {
244    /// UTF-8 source content.
245    Text {
246        /// Stable caller-owned source identity.
247        source_id: String,
248        /// Human-readable source title.
249        title: String,
250        /// Nonempty UTF-8 source content.
251        text: String,
252    },
253    /// Binary source content with an explicit MIME type.
254    Bytes {
255        /// Stable caller-owned source identity.
256        source_id: String,
257        /// Human-readable source title.
258        title: String,
259        /// Nonempty exact source bytes.
260        bytes: Vec<u8>,
261        /// Nonempty unpadded MIME type.
262        mime_type: String,
263    },
264}
265
266impl KnowledgeSource {
267    /// Validates and constructs a text source.
268    ///
269    /// # Errors
270    /// Returns InvalidInput for empty content, or empty/padded source identity or title. Text content itself is preserved exactly.
271    ///
272    /// ```
273    /// use agents_kit::knowledge::KnowledgeSource;
274    /// let source = KnowledgeSource::text("guide", "Guide", " Keep this spacing. ")?;
275    /// assert_eq!(source.source_id(), "guide");
276    /// # Ok::<(), agents_kit::KitError>(())
277    /// ```
278    pub fn text(
279        source_id: impl Into<String>,
280        title: impl Into<String>,
281        text: impl Into<String>,
282    ) -> Result<Self, KitError> {
283        let source = Self::Text {
284            source_id: source_id.into(),
285            title: title.into(),
286            text: text.into(),
287        };
288        source.validate()?;
289        Ok(source)
290    }
291
292    /// Validates and constructs a binary source.
293    ///
294    /// # Errors
295    /// Returns InvalidInput for empty bytes, or empty/padded source identity, title, or MIME type.
296    ///
297    /// ```
298    /// use agents_kit::knowledge::KnowledgeSource;
299    /// let source = KnowledgeSource::bytes("file", "File", vec![1, 2], "application/octet-stream")?;
300    /// assert_eq!(source.title(), "File");
301    /// # Ok::<(), agents_kit::KitError>(())
302    /// ```
303    pub fn bytes(
304        source_id: impl Into<String>,
305        title: impl Into<String>,
306        bytes: Vec<u8>,
307        mime_type: impl Into<String>,
308    ) -> Result<Self, KitError> {
309        let source = Self::Bytes {
310            source_id: source_id.into(),
311            title: title.into(),
312            bytes,
313            mime_type: mime_type.into(),
314        };
315        source.validate()?;
316        Ok(source)
317    }
318
319    /// Returns the stable caller-owned source identity.
320    ///
321    /// ```
322    /// use agents_kit::knowledge::KnowledgeSource;
323    /// let source = KnowledgeSource::text("guide", "Guide", "contents")?;
324    /// assert_eq!(source.source_id(), "guide");
325    /// # Ok::<(), agents_kit::KitError>(())
326    /// ```
327    pub fn source_id(&self) -> &str {
328        match self {
329            Self::Text { source_id, .. } | Self::Bytes { source_id, .. } => source_id,
330        }
331    }
332
333    /// Returns the human-readable title.
334    ///
335    /// ```
336    /// use agents_kit::knowledge::KnowledgeSource;
337    /// let source = KnowledgeSource::text("guide", "Guide", "contents")?;
338    /// assert_eq!(source.title(), "Guide");
339    /// # Ok::<(), agents_kit::KitError>(())
340    /// ```
341    pub fn title(&self) -> &str {
342        match self {
343            Self::Text { title, .. } | Self::Bytes { title, .. } => title,
344        }
345    }
346
347    pub(crate) fn validate(&self) -> Result<(), KitError> {
348        let (source_id, title) = match self {
349            Self::Text {
350                source_id,
351                title,
352                text,
353            } => {
354                if text.is_empty() {
355                    return Err(KitError::invalid_input("knowledge text must not be empty"));
356                }
357                (source_id, title)
358            }
359            Self::Bytes {
360                source_id,
361                title,
362                bytes,
363                mime_type,
364            } => {
365                if bytes.is_empty() {
366                    return Err(KitError::invalid_input("knowledge bytes must not be empty"));
367                }
368                Self::validate_text("knowledge MIME type", mime_type)?;
369                (source_id, title)
370            }
371        };
372        Self::validate_text("knowledge source id", source_id)?;
373        Self::validate_text("knowledge source title", title)
374    }
375
376    fn validate_text(label: &str, value: &str) -> Result<(), KitError> {
377        if value.is_empty() || value.trim() != value {
378            return Err(KitError::invalid_input(format!(
379                "{label} must be nonempty and unpadded"
380            )));
381        }
382        Ok(())
383    }
384}
385
386/// Validated source set for one managed-knowledge ingestion operation.
387///
388/// ```
389/// use agents_kit::knowledge::{IngestKnowledgeRequest, KnowledgeSource};
390/// let request = IngestKnowledgeRequest::new(vec![
391///     KnowledgeSource::text("guide", "Guide", "Escalate incidents.")?,
392/// ])?;
393/// assert_eq!(request.sources().len(), 1);
394/// # Ok::<(), agents_kit::KitError>(())
395/// ```
396#[derive(Clone, Debug, Eq, PartialEq)]
397pub struct IngestKnowledgeRequest {
398    sources: Vec<KnowledgeSource>,
399    scope: Option<KnowledgeScope>,
400}
401
402impl IngestKnowledgeRequest {
403    /// Validates sources and rejects duplicate source identities.
404    ///
405    /// # Errors
406    /// Returns InvalidInput for invalid sources or repeated source identities. An empty source set is valid and is a no-op after the receiving operation validates the selected scope; it never requests deletion.
407    ///
408    /// ```
409    /// use agents_kit::knowledge::IngestKnowledgeRequest;
410    /// let request = IngestKnowledgeRequest::new(vec![])?;
411    /// assert!(request.sources().is_empty());
412    /// # Ok::<(), agents_kit::KitError>(())
413    /// ```
414    pub fn new(sources: Vec<KnowledgeSource>) -> Result<Self, KitError> {
415        let request = Self {
416            sources,
417            scope: None,
418        };
419        request.validate()?;
420        Ok(request)
421    }
422
423    /// Selects an explicit scope instead of the receiving operation's default.
424    #[must_use]
425    ///
426    /// ```
427    /// use agents_kit::knowledge::{IngestKnowledgeRequest, KnowledgeScope};
428    /// let request = IngestKnowledgeRequest::new(vec![])?.with_scope(KnowledgeScope::Workspace);
429    /// assert_eq!(request.scope(), Some(KnowledgeScope::Workspace));
430    /// # Ok::<(), agents_kit::KitError>(())
431    /// ```
432    pub fn with_scope(mut self, scope: KnowledgeScope) -> Self {
433        self.scope = Some(scope);
434        self
435    }
436
437    /// Borrows the validated sources in caller-provided order.
438    ///
439    /// ```
440    /// use agents_kit::knowledge::{IngestKnowledgeRequest, KnowledgeSource};
441    /// let request = IngestKnowledgeRequest::new(vec![KnowledgeSource::text("guide", "Guide", "contents")?])?;
442    /// assert_eq!(request.sources()[0].source_id(), "guide");
443    /// # Ok::<(), agents_kit::KitError>(())
444    /// ```
445    pub fn sources(&self) -> &[KnowledgeSource] {
446        &self.sources
447    }
448
449    /// Returns the explicitly selected scope, when present.
450    #[must_use]
451    ///
452    /// ```
453    /// use agents_kit::knowledge::IngestKnowledgeRequest;
454    /// assert_eq!(IngestKnowledgeRequest::new(vec![])?.scope(), None);
455    /// # Ok::<(), agents_kit::KitError>(())
456    /// ```
457    pub fn scope(&self) -> Option<KnowledgeScope> {
458        self.scope
459    }
460
461    /// Consumes the request into validated sources and optional scope.
462    #[must_use]
463    ///
464    /// ```
465    /// use agents_kit::knowledge::IngestKnowledgeRequest;
466    /// let (sources, scope) = IngestKnowledgeRequest::new(vec![])?.into_parts();
467    /// assert!(sources.is_empty());
468    /// assert_eq!(scope, None);
469    /// # Ok::<(), agents_kit::KitError>(())
470    /// ```
471    pub fn into_parts(self) -> (Vec<KnowledgeSource>, Option<KnowledgeScope>) {
472        (self.sources, self.scope)
473    }
474
475    pub(crate) fn validate(&self) -> Result<(), KitError> {
476        let mut ids = std::collections::BTreeSet::new();
477        for source in &self.sources {
478            source.validate()?;
479            if !ids.insert(source.source_id()) {
480                return Err(KitError::invalid_input(
481                    "knowledge source ids must be unique within one request",
482                ));
483            }
484        }
485        Ok(())
486    }
487}
488
489impl KnowledgeOwnerId {
490    fn scope(&self) -> KnowledgeScope {
491        match self {
492            Self::Database => KnowledgeScope::Database,
493            Self::Agent { .. } => KnowledgeScope::Agent,
494            Self::Workspace { .. } => KnowledgeScope::Workspace,
495            Self::Conversation { .. } => KnowledgeScope::Conversation,
496        }
497    }
498
499    fn owner_for_scope(&self, scope: KnowledgeScope) -> Option<Self> {
500        match (self, scope) {
501            (Self::Database, KnowledgeScope::Database)
502            | (Self::Agent { .. }, KnowledgeScope::Database)
503            | (Self::Workspace { .. }, KnowledgeScope::Database)
504            | (Self::Conversation { .. }, KnowledgeScope::Database) => Some(Self::Database),
505            (Self::Agent { agent_id }, KnowledgeScope::Agent)
506            | (Self::Workspace { agent_id, .. }, KnowledgeScope::Agent)
507            | (Self::Conversation { agent_id, .. }, KnowledgeScope::Agent) => Some(Self::Agent {
508                agent_id: agent_id.clone(),
509            }),
510            (
511                Self::Workspace {
512                    agent_id,
513                    workspace_id,
514                },
515                KnowledgeScope::Workspace,
516            )
517            | (
518                Self::Conversation {
519                    agent_id,
520                    workspace_id,
521                    ..
522                },
523                KnowledgeScope::Workspace,
524            ) => Some(Self::Workspace {
525                agent_id: agent_id.clone(),
526                workspace_id: workspace_id.clone(),
527            }),
528            (
529                Self::Conversation {
530                    agent_id,
531                    workspace_id,
532                    conversation_id,
533                },
534                KnowledgeScope::Conversation,
535            ) => Some(Self::Conversation {
536                agent_id: agent_id.clone(),
537                workspace_id: workspace_id.clone(),
538                conversation_id: conversation_id.clone(),
539            }),
540            _ => None,
541        }
542    }
543
544    pub(crate) fn ingestion_owner(
545        &self,
546        selected: Option<KnowledgeScope>,
547    ) -> Result<Self, KitError> {
548        self.owner_for_scope(selected.unwrap_or_else(|| self.scope()))
549            .ok_or_else(|| {
550                KitError::invalid_input(
551                    "selected knowledge scope is not applicable to this receiver",
552                )
553            })
554    }
555
556    pub(crate) fn retrieval_owners(&self, policy: &KnowledgePolicy) -> Result<Vec<Self>, KitError> {
557        policy.validate()?;
558        let selected = match policy {
559            KnowledgePolicy::Default => None,
560            KnowledgePolicy::Disabled => return Ok(Vec::new()),
561            KnowledgePolicy::Only(scopes) => {
562                for scope in scopes {
563                    if self.owner_for_scope(*scope).is_none() {
564                        return Err(KitError::invalid_input(
565                            "selected knowledge scope is not applicable to this owner",
566                        ));
567                    }
568                }
569                Some(scopes.iter().copied().collect::<BTreeSet<_>>())
570            }
571        };
572        Ok(RETRIEVAL_SCOPE_ORDER
573            .into_iter()
574            .filter(|scope| {
575                selected
576                    .as_ref()
577                    .is_none_or(|selected| selected.contains(scope))
578            })
579            .filter_map(|scope| self.owner_for_scope(scope))
580            .collect())
581    }
582}
583
584impl KnowledgeBudget {
585    /// Constructs positive chunk and UTF-8 text-byte limits.
586    pub(crate) fn new(chunks: usize, bytes: usize) -> Result<Self, KitError> {
587        if chunks == 0 || bytes == 0 {
588            return Err(KitError::invalid_input(
589                "knowledge chunk and byte budgets must be positive",
590            ));
591        }
592        Ok(Self { chunks, bytes })
593    }
594
595    /// Returns the maximum number of snippets.
596    #[must_use]
597    pub(crate) fn chunks(&self) -> usize {
598        self.chunks
599    }
600
601    /// Returns the maximum aggregate snippet-text bytes.
602    #[must_use]
603    pub(crate) fn bytes(&self) -> usize {
604        self.bytes
605    }
606}
607
608impl KnowledgeRetrievalRequest {
609    /// Validates and constructs one semantic retrieval request.
610    pub(crate) fn new(
611        query: String,
612        owner: KnowledgeOwnerId,
613        policy: KnowledgePolicy,
614        budget: KnowledgeBudget,
615    ) -> Result<Self, KitError> {
616        let request = Self {
617            query,
618            owner,
619            policy,
620            budget,
621        };
622        request.validate()?;
623        Ok(request)
624    }
625
626    /// Returns the exact caller-authored query.
627    pub(crate) fn query(&self) -> &str {
628        &self.query
629    }
630
631    /// Returns the complete requested owner lineage.
632    pub(crate) fn owner(&self) -> &KnowledgeOwnerId {
633        &self.owner
634    }
635
636    /// Returns the validated retrieval policy.
637    pub(crate) fn policy(&self) -> &KnowledgePolicy {
638        &self.policy
639    }
640
641    /// Returns the response budget.
642    #[must_use]
643    pub(crate) fn budget(&self) -> KnowledgeBudget {
644        self.budget
645    }
646
647    pub(crate) fn validate(&self) -> Result<(), KitError> {
648        if self.query.is_empty() || self.query.trim() != self.query {
649            return Err(KitError::invalid_input(
650                "knowledge query must be nonempty and unpadded",
651            ));
652        }
653        self.owner.retrieval_owners(&self.policy).map(|_| ())
654    }
655
656    pub(crate) fn validate_context(&self, context: &KnowledgeContext) -> Result<(), KitError> {
657        self.validate()?;
658        context
659            .validate()
660            .map_err(|_| KitError::invalid_state("knowledge backend returned malformed context"))?;
661        if context.snippets.len() > self.budget.chunks {
662            return Err(KitError::invalid_state(
663                "knowledge backend exceeded the requested chunk budget",
664            ));
665        }
666        let allowed = self.owner.retrieval_owners(&self.policy)?;
667        let mut previous_scope = None;
668        let mut text_bytes = 0_usize;
669        for snippet in &context.snippets {
670            let scope = allowed
671                .iter()
672                .position(|owner| owner == &snippet.owner)
673                .ok_or_else(|| {
674                    KitError::invalid_state(
675                        "knowledge backend returned an owner outside the requested lineage",
676                    )
677                })?;
678            if previous_scope.is_some_and(|previous| scope < previous) {
679                return Err(KitError::invalid_state(
680                    "knowledge backend returned snippets outside policy precedence",
681                ));
682            }
683            previous_scope = Some(scope);
684            text_bytes = text_bytes.checked_add(snippet.text.len()).ok_or_else(|| {
685                KitError::invalid_state("knowledge backend text-byte count overflowed")
686            })?;
687            if text_bytes > self.budget.bytes {
688                return Err(KitError::invalid_state(
689                    "knowledge backend exceeded the requested byte budget",
690                ));
691            }
692        }
693        Ok(())
694    }
695}
696
697impl KnowledgeContext {
698    /// Validates and constructs structured context.
699    pub(crate) fn new(snippets: Vec<KnowledgeSnippet>) -> Result<Self, KitError> {
700        let context = Self { snippets };
701        context.validate()?;
702        Ok(context)
703    }
704
705    /// Borrows snippets in semantic precedence and backend rank order.
706    pub(crate) fn snippets(&self) -> &[KnowledgeSnippet] {
707        &self.snippets
708    }
709
710    fn validate(&self) -> Result<(), KitError> {
711        for snippet in &self.snippets {
712            snippet.validate()?;
713        }
714        Ok(())
715    }
716}
717
718impl KnowledgeSnippet {
719    /// Validates and constructs one structured knowledge snippet.
720    pub(crate) fn new(
721        source_id: String,
722        title: String,
723        text: String,
724        owner: KnowledgeOwnerId,
725        relevance_score: Option<f64>,
726        provenance: KnowledgeProvenance,
727        metadata: BTreeMap<String, serde_json::Value>,
728    ) -> Result<Self, KitError> {
729        let snippet = Self {
730            source_id,
731            title,
732            text,
733            owner,
734            relevance_score,
735            provenance,
736            metadata,
737        };
738        snippet.validate()?;
739        Ok(snippet)
740    }
741
742    /// Returns the caller-owned source identity.
743    pub(crate) fn source_id(&self) -> &str {
744        &self.source_id
745    }
746
747    /// Returns the human-readable source title.
748    pub(crate) fn title(&self) -> &str {
749        &self.title
750    }
751
752    /// Returns exact snippet text.
753    pub(crate) fn text(&self) -> &str {
754        &self.text
755    }
756
757    fn validate(&self) -> Result<(), KitError> {
758        for (label, value) in [
759            ("knowledge source id", self.source_id.as_str()),
760            ("knowledge source title", self.title.as_str()),
761        ] {
762            if value.is_empty() || value.trim() != value {
763                return Err(KitError::invalid_input(format!(
764                    "{label} must be nonempty and unpadded"
765                )));
766            }
767        }
768        if self.text.trim().is_empty() {
769            return Err(KitError::invalid_input(
770                "knowledge snippet text must be nonblank",
771            ));
772        }
773        if self
774            .relevance_score
775            .is_some_and(|score| !score.is_finite() || score < 0.0)
776        {
777            return Err(KitError::invalid_input(
778                "knowledge relevance score must be finite and nonnegative",
779            ));
780        }
781        self.provenance.validate()?;
782        if self
783            .metadata
784            .keys()
785            .any(|key| key.is_empty() || key.trim() != key)
786        {
787            return Err(KitError::invalid_input(
788                "knowledge metadata keys must be nonempty and unpadded",
789            ));
790        }
791        Ok(())
792    }
793}
794
795impl KnowledgeProvenance {
796    fn validate(&self) -> Result<(), KitError> {
797        if self
798            .locator
799            .as_ref()
800            .is_some_and(|locator| locator.is_empty() || locator.trim() != locator)
801        {
802            return Err(KitError::invalid_input(
803                "knowledge provenance locator must be nonempty and unpadded",
804            ));
805        }
806        if self
807            .metadata
808            .keys()
809            .any(|key| key.is_empty() || key.trim() != key)
810        {
811            return Err(KitError::invalid_input(
812                "knowledge provenance metadata keys must be nonempty and unpadded",
813            ));
814        }
815        Ok(())
816    }
817}