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}