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}