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