1use std::{
2 collections::BTreeSet,
3 fmt, fs,
4 path::{Path, PathBuf},
5 sync::Arc,
6};
7
8use serde_json::Value;
9use sha2::{Digest, Sha256};
10use soma_domain::provider_validation::{validate_manifest_schema, validate_provider_manifest};
11use soma_provider_adapters::{
12 manifest_file,
13 python::{
14 PythonInterpreter, PythonProvider, describe_persistent_catalog,
15 host::PythonExecutionProfile, supervisor::PythonSupervisorConfig,
16 },
17};
18use soma_provider_core::{ProviderCatalog, ProviderKind};
19
20use crate::{
21 provider_registry::{DynamicResourceTemplate, Provider, SharedAdapter},
22 providers::resource_files::{ResourceFileError, ResourceFileProvider},
23};
24
25const PROVIDER_ENV_PREFIX: &str = "SOMA";
30
31#[path = "filesystem_prompts.rs"]
32mod filesystem_prompts;
33#[path = "filesystem_python.rs"]
34mod filesystem_python;
35#[path = "filesystem_resources.rs"]
36mod filesystem_resources;
37#[path = "filesystem_uniqueness.rs"]
38mod filesystem_uniqueness;
39#[path = "filesystem_wasm.rs"]
40mod filesystem_wasm;
41
42pub use filesystem_python::{
43 PythonProviderEnvironmentPreparer, PythonProviderEnvironmentSelections,
44};
45use filesystem_python::{
46 collect_python_dependency_paths, fingerprint_python_environment, immutable_python_generation,
47 is_python_provider_source, python_worker_generation_digest, retain_python_snapshot,
48};
49
50#[derive(Clone)]
52pub struct FileProviderSource {
53 root: PathBuf,
54 python_environment_preparer: Option<Arc<dyn PythonProviderEnvironmentPreparer>>,
55 python_runner: PythonRunnerSelection,
56}
57
58#[derive(Debug, Clone, Default)]
60pub enum PythonRunnerSelection {
61 #[default]
63 OneShot,
64 Persistent(PythonSupervisorConfig),
66}
67
68#[derive(Clone)]
74pub struct PythonProviderRuntime {
75 runner: PythonRunnerSelection,
76 environment_preparer: Option<Arc<dyn PythonProviderEnvironmentPreparer>>,
77 capability_broker: crate::capabilities::CapabilityBroker,
78}
79
80impl PythonProviderRuntime {
81 pub fn new(runner: PythonRunnerSelection) -> Self {
83 Self {
84 runner,
85 environment_preparer: None,
86 capability_broker: crate::capabilities::CapabilityBroker::default_deny(),
87 }
88 }
89
90 pub fn with_capability_broker(
92 mut self,
93 capability_broker: crate::capabilities::CapabilityBroker,
94 ) -> Self {
95 self.capability_broker = capability_broker;
96 self
97 }
98
99 pub fn with_environment_preparer(
101 mut self,
102 preparer: Arc<dyn PythonProviderEnvironmentPreparer>,
103 ) -> Self {
104 self.environment_preparer = Some(preparer);
105 self
106 }
107
108 pub(crate) fn configure_source(
109 self,
110 mut source: FileProviderSource,
111 ) -> (FileProviderSource, crate::capabilities::CapabilityBroker) {
112 source = source.with_python_runner(self.runner);
113 if let Some(preparer) = self.environment_preparer {
114 source = source.with_python_environment_preparer(preparer);
115 }
116 (source, self.capability_broker)
117 }
118}
119
120impl fmt::Debug for PythonProviderRuntime {
121 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
122 formatter
123 .debug_struct("PythonProviderRuntime")
124 .field("runner", &self.runner)
125 .field("environment_preparer", &self.environment_preparer.is_some())
126 .field("capability_broker", &self.capability_broker)
127 .finish()
128 }
129}
130
131impl fmt::Debug for FileProviderSource {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 formatter
134 .debug_struct("FileProviderSource")
135 .field("root", &self.root)
136 .field(
137 "python_environment_preparer",
138 &self.python_environment_preparer.is_some(),
139 )
140 .field("python_runner", &self.python_runner)
141 .finish()
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct ProviderDirectoryInspection {
148 pub root: PathBuf,
150 pub exists: bool,
152 pub files: Vec<ProviderFileInspection>,
154 pub providers_loaded: usize,
156 pub providers_disabled: usize,
158 pub providers_invalid: usize,
160 pub providers_skipped: usize,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct ProviderFileInspection {
167 pub path: PathBuf,
169 pub file_name: String,
171 pub status: ProviderFileInspectionStatus,
173 pub provider_id: Option<String>,
175 pub provider_kind: Option<String>,
177 pub actions: Vec<String>,
179 pub error: Option<String>,
181}
182
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ProviderFileInspectionStatus {
186 Loaded,
188 Disabled,
190 Invalid,
192 Skipped,
195}
196
197impl FileProviderSource {
198 pub fn new(root: impl Into<PathBuf>) -> Self {
200 Self {
201 root: root.into(),
202 python_environment_preparer: None,
203 python_runner: PythonRunnerSelection::OneShot,
204 }
205 }
206
207 pub fn with_python_environment_preparer(
210 mut self,
211 preparer: Arc<dyn PythonProviderEnvironmentPreparer>,
212 ) -> Self {
213 self.python_environment_preparer = Some(preparer);
214 self
215 }
216
217 pub fn with_python_runner(mut self, runner: PythonRunnerSelection) -> Self {
219 self.python_runner = runner;
220 self
221 }
222
223 pub fn root(&self) -> &Path {
225 &self.root
226 }
227
228 pub fn inspect(&self) -> Result<ProviderDirectoryInspection, FileProviderLoadError> {
233 if !self.root.exists() {
234 return Ok(ProviderDirectoryInspection {
235 root: self.root.clone(),
236 exists: false,
237 files: Vec::new(),
238 providers_loaded: 0,
239 providers_disabled: 0,
240 providers_invalid: 0,
241 providers_skipped: 0,
242 });
243 }
244
245 let mut files = Vec::new();
246 let mut loaded_catalogs: Vec<Option<ProviderCatalog>> = Vec::new();
252 let mut dynamic_templates: Vec<Option<DynamicResourceTemplate>> = Vec::new();
259
260 for path in self.provider_paths()? {
261 let file_name = path
262 .file_name()
263 .and_then(|name| name.to_str())
264 .unwrap_or("<unknown>")
265 .to_owned();
266
267 if is_python_provider_source(&path) {
272 files.push(ProviderFileInspection {
273 path,
274 file_name,
275 status: ProviderFileInspectionStatus::Skipped,
276 provider_id: None,
277 provider_kind: Some(ProviderKind::Python.as_str().to_owned()),
278 actions: Vec::new(),
279 error: Some(
280 "Python providers can only be introspected by executing the module; \
281 non-executing inspection does not run them. Use `soma providers \
282 validate` or `soma providers inspect` to check this file."
283 .to_owned(),
284 ),
285 });
286 loaded_catalogs.push(None);
287 continue;
288 }
289
290 match load_catalog(&path) {
291 Ok(catalog) => {
292 let semantic_check = load_catalog_value(&path)
293 .map_err(|error| error.to_string())
294 .and_then(|value| {
295 validate_manifest_schema(&value).map_err(|error| error.to_string())
296 })
297 .and_then(|()| {
298 validate_provider_manifest(&catalog).map_err(|error| error.to_string())
299 })
300 .and_then(|()| compile_tool_schemas(&catalog));
301 match semantic_check {
302 Ok(()) => {
303 let status = if catalog.provider.enabled == Some(false) {
304 ProviderFileInspectionStatus::Disabled
305 } else {
306 ProviderFileInspectionStatus::Loaded
307 };
308 let actions = catalog
309 .tools
310 .iter()
311 .map(|tool| tool.name.clone())
312 .collect::<Vec<_>>();
313 files.push(ProviderFileInspection {
314 path,
315 file_name,
316 status,
317 provider_id: Some(catalog.provider.name.clone()),
318 provider_kind: Some(catalog.provider.kind.as_str().to_owned()),
319 actions,
320 error: None,
321 });
322 loaded_catalogs.push(
323 (status == ProviderFileInspectionStatus::Loaded).then_some(catalog),
324 );
325 }
326 Err(message) => {
327 files.push(ProviderFileInspection {
328 path,
329 file_name,
330 status: ProviderFileInspectionStatus::Invalid,
331 provider_id: Some(catalog.provider.name),
332 provider_kind: Some(catalog.provider.kind.as_str().to_owned()),
333 actions: Vec::new(),
334 error: Some(message),
335 });
336 loaded_catalogs.push(None);
337 }
338 }
339 }
340 Err(error) => {
341 files.push(ProviderFileInspection {
342 path,
343 file_name,
344 status: ProviderFileInspectionStatus::Invalid,
345 provider_id: None,
346 provider_kind: None,
347 actions: Vec::new(),
348 error: Some(error.to_string()),
349 });
350 loaded_catalogs.push(None);
351 }
352 }
353 }
354
355 dynamic_templates.resize(files.len(), None);
361 let (resource_files, resource_catalogs, resource_templates) =
362 filesystem_resources::inspect_files(self.resource_pairs_with_canonical_root()?);
363 files.extend(resource_files);
364 loaded_catalogs.extend(resource_catalogs);
365 dynamic_templates.extend(resource_templates);
366
367 filesystem_uniqueness::apply_directory_wide_checks(
368 &mut files,
369 &loaded_catalogs,
370 &dynamic_templates,
371 );
372 files.sort_by(|left, right| left.file_name.cmp(&right.file_name));
373 let providers_loaded = files
374 .iter()
375 .filter(|file| file.status == ProviderFileInspectionStatus::Loaded)
376 .count();
377 let providers_disabled = files
378 .iter()
379 .filter(|file| file.status == ProviderFileInspectionStatus::Disabled)
380 .count();
381 let providers_invalid = files
382 .iter()
383 .filter(|file| file.status == ProviderFileInspectionStatus::Invalid)
384 .count();
385 let providers_skipped = files
386 .iter()
387 .filter(|file| file.status == ProviderFileInspectionStatus::Skipped)
388 .count();
389
390 Ok(ProviderDirectoryInspection {
391 root: self.root.clone(),
392 exists: true,
393 files,
394 providers_loaded,
395 providers_disabled,
396 providers_invalid,
397 providers_skipped,
398 })
399 }
400
401 pub fn load(&self) -> Result<Vec<std::sync::Arc<dyn Provider>>, FileProviderLoadError> {
404 self.load_with_python_environments(&PythonProviderEnvironmentSelections::new())
405 }
406
407 pub fn load_with_python_environments(
409 &self,
410 selections: &PythonProviderEnvironmentSelections,
411 ) -> Result<Vec<std::sync::Arc<dyn Provider>>, FileProviderLoadError> {
412 if matches!(self.python_runner, PythonRunnerSelection::Persistent(_)) {
413 return Err(FileProviderLoadError {
414 path: self.root.clone(),
415 message: "persistent Python providers require the async preflight loading path"
416 .to_owned(),
417 });
418 }
419 self.validate_python_environment_selections(selections)?;
420 let mut providers: Vec<std::sync::Arc<dyn Provider>> = Vec::new();
421 let paths = self.provider_paths()?;
422 let immutable_generation = paths
423 .iter()
424 .any(|path| is_python_provider_source(path))
425 .then(|| immutable_python_generation(&self.root))
426 .transpose()?;
427 for path in paths {
428 if is_python_provider_source(&path)
429 && matches!(
430 self.python_runner,
431 PythonRunnerSelection::Persistent(PythonSupervisorConfig {
432 execution_profile: PythonExecutionProfile::Disabled,
433 ..
434 })
435 )
436 {
437 continue;
438 }
439 let interpreter = self.python_interpreter_with_environments(&path, selections)?;
440 let immutable = immutable_generation
441 .as_ref()
442 .filter(|_| is_python_provider_source(&path))
443 .map(|generation| generation.source(&self.root, &path))
444 .transpose()?;
445 let execution_path = immutable
446 .as_ref()
447 .map_or_else(|| path.clone(), |source| source.path.clone());
448 let mut catalog = load_catalog_with_interpreter(&execution_path, &interpreter)?;
449 catalog.provider.source = Some(path.display().to_string());
450 if catalog.provider.enabled == Some(false) {
451 continue;
452 }
453 let provider =
454 provider_for_catalog(execution_path, catalog, interpreter, &self.python_runner)?;
455 providers.push(immutable.map_or(provider.clone(), |source| {
456 retain_python_snapshot(provider, source.lease)
457 }));
458 }
459 for (absolute, relative, canonical_root) in self.resource_pairs_with_canonical_root()? {
460 let provider = ResourceFileProvider::arc(absolute.clone(), &relative, &canonical_root)
461 .map_err(|ResourceFileError(message)| FileProviderLoadError {
462 path: absolute,
463 message,
464 })?;
465 providers.push(provider);
466 }
467 Ok(providers)
468 }
469
470 pub async fn load_with_python_environments_async(
473 &self,
474 selections: &PythonProviderEnvironmentSelections,
475 ) -> Result<Vec<std::sync::Arc<dyn Provider>>, FileProviderLoadError> {
476 if matches!(self.python_runner, PythonRunnerSelection::OneShot) {
477 let source = self.clone();
478 let selections = selections.clone();
479 let root = self.root.clone();
480 return tokio::task::spawn_blocking(move || {
481 source.load_with_python_environments(&selections)
482 })
483 .await
484 .map_err(|error| FileProviderLoadError {
485 path: root,
486 message: format!("one-shot Python provider load task failed: {error}"),
487 })?;
488 }
489 self.validate_python_environment_selections(selections)?;
490 let mut providers: Vec<std::sync::Arc<dyn Provider>> = Vec::new();
491 let paths = self.provider_paths()?;
492 let immutable_generation = if paths.iter().any(|path| is_python_provider_source(path)) {
493 let root = self.root.clone();
494 Some(
495 tokio::task::spawn_blocking(move || immutable_python_generation(&root))
496 .await
497 .map_err(|error| FileProviderLoadError {
498 path: self.root.clone(),
499 message: format!("Python generation snapshot task failed: {error}"),
500 })??,
501 )
502 } else {
503 None
504 };
505 let worker_generation = immutable_generation
506 .as_ref()
507 .map(|generation| python_worker_generation_digest(&generation.digest, selections));
508 for path in paths {
509 if is_python_provider_source(&path)
510 && matches!(
511 self.python_runner,
512 PythonRunnerSelection::Persistent(PythonSupervisorConfig {
513 execution_profile: PythonExecutionProfile::Disabled,
514 ..
515 })
516 )
517 {
518 continue;
519 }
520 let interpreter_source = self.clone();
521 let interpreter_path = path.clone();
522 let interpreter_selections = selections.clone();
523 let interpreter = tokio::task::spawn_blocking(move || {
524 if interpreter_path.extension().and_then(|value| value.to_str()) == Some("py") {
525 let metadata = fs::symlink_metadata(&interpreter_path).map_err(|error| {
526 FileProviderLoadError {
527 path: interpreter_path.clone(),
528 message: format!("failed to inspect Python provider source: {error}"),
529 }
530 })?;
531 if metadata.file_type().is_symlink() || !metadata.file_type().is_file() {
532 return Err(FileProviderLoadError {
533 path: interpreter_path,
534 message: "persistent Python provider source must be a regular non-symlink file"
535 .to_owned(),
536 });
537 }
538 }
539 interpreter_source.python_interpreter_with_environments(
540 &interpreter_path,
541 &interpreter_selections,
542 )
543 })
544 .await
545 .map_err(|error| FileProviderLoadError {
546 path: path.clone(),
547 message: format!("Python environment preparation task failed: {error}"),
548 })??;
549 let immutable = immutable_generation
550 .as_ref()
551 .filter(|_| is_python_provider_source(&path))
552 .map(|generation| generation.source(&self.root, &path))
553 .transpose()?;
554 let execution_path = immutable
555 .as_ref()
556 .map_or_else(|| path.clone(), |source| source.path.clone());
557 let mut catalog = if is_python_provider_source(&execution_path)
558 && matches!(
559 self.python_runner,
560 PythonRunnerSelection::Persistent(PythonSupervisorConfig {
561 execution_profile: PythonExecutionProfile::Brokered,
562 ..
563 })
564 ) {
565 let PythonRunnerSelection::Persistent(config) = &self.python_runner else {
566 unreachable!("brokered execution is persistent")
567 };
568 describe_persistent_catalog(
569 execution_path.clone(),
570 interpreter.clone(),
571 config.clone(),
572 )
573 .await
574 .map_err(|error| FileProviderLoadError {
575 path: execution_path.clone(),
576 message: error.to_string(),
577 })?
578 } else {
579 let catalog_path = execution_path.clone();
580 let catalog_interpreter = interpreter.clone();
581 tokio::task::spawn_blocking(move || {
582 load_catalog_with_interpreter(&catalog_path, &catalog_interpreter)
583 })
584 .await
585 .map_err(|error| FileProviderLoadError {
586 path: path.clone(),
587 message: format!("Python catalog task failed: {error}"),
588 })??
589 };
590 catalog.provider.source = Some(path.display().to_string());
591 if catalog.provider.enabled == Some(false) {
592 continue;
593 }
594 if matches!(
595 catalog.provider.kind,
596 ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex
597 ) {
598 let PythonRunnerSelection::Persistent(config) = &self.python_runner else {
599 unreachable!("one-shot returned above")
600 };
601 let provider = PythonProvider::arc_persistent_in_generation(
602 execution_path.clone(),
603 catalog,
604 PROVIDER_ENV_PREFIX,
605 interpreter,
606 config.clone(),
607 worker_generation
608 .clone()
609 .expect("persistent Python source has an immutable generation"),
610 )
611 .map_err(|error| FileProviderLoadError {
612 path: execution_path.clone(),
613 message: error.to_string(),
614 })?;
615 if let Err(error) = provider.preflight().await {
616 for started in &providers {
617 started.retire().await;
618 }
619 soma_provider_core::Provider::retire(provider.as_ref()).await;
620 return Err(FileProviderLoadError {
621 path: execution_path,
622 message: error.to_string(),
623 });
624 }
625 let provider = SharedAdapter::wrap(provider);
626 providers.push(immutable.map_or(provider.clone(), |source| {
627 retain_python_snapshot(provider, source.lease)
628 }));
629 } else {
630 let provider = provider_for_catalog(
631 execution_path,
632 catalog,
633 interpreter,
634 &self.python_runner,
635 )?;
636 providers.push(immutable.map_or(provider.clone(), |source| {
637 retain_python_snapshot(provider, source.lease)
638 }));
639 }
640 }
641 for (absolute, relative, canonical_root) in self.resource_pairs_with_canonical_root()? {
642 let provider = ResourceFileProvider::arc(absolute.clone(), &relative, &canonical_root)
643 .map_err(|ResourceFileError(message)| FileProviderLoadError {
644 path: absolute,
645 message,
646 })?;
647 providers.push(provider);
648 }
649 Ok(providers)
650 }
651
652 pub fn fingerprint(&self) -> Result<String, FileProviderLoadError> {
655 self.fingerprint_with_python_environments(&PythonProviderEnvironmentSelections::new())
656 }
657
658 pub fn fingerprint_with_python_environments(
660 &self,
661 selections: &PythonProviderEnvironmentSelections,
662 ) -> Result<String, FileProviderLoadError> {
663 self.validate_python_environment_selections(selections)?;
664 let mut hasher = Sha256::new();
665 for path in self.fingerprint_paths()? {
666 fingerprint_file(&mut hasher, &self.root, &path)?;
667 }
668 for (path, candidate) in selections {
669 fingerprint_python_environment(&mut hasher, &self.root, path, candidate);
670 }
671 Ok(hasher
672 .finalize()
673 .iter()
674 .map(|byte| format!("{byte:02x}"))
675 .collect::<String>())
676 }
677
678 fn fingerprint_paths(&self) -> Result<Vec<PathBuf>, FileProviderLoadError> {
679 let provider_paths = self.provider_paths()?;
680 let mut paths = BTreeSet::new();
681 let mut has_python_provider = false;
682
683 for path in &provider_paths {
684 match path.extension().and_then(|extension| extension.to_str()) {
685 Some("wasm") => {
686 let sidecar = filesystem_wasm::wasm_sidecar_manifest_path(path);
687 if sidecar.is_file() {
688 paths.insert(sidecar);
689 } else {
690 paths.insert(path.clone());
691 }
692 }
693 Some("py") => {
694 has_python_provider = true;
695 paths.insert(path.clone());
696 }
697 _ => {
698 paths.insert(path.clone());
699 }
700 }
701 }
702
703 if has_python_provider {
704 collect_python_dependency_paths(&self.root, &mut paths)?;
705 }
706
707 for (absolute, _relative) in self.resource_paths()? {
708 paths.insert(absolute);
709 }
710
711 Ok(paths.into_iter().collect())
712 }
713
714 fn provider_paths(&self) -> Result<Vec<PathBuf>, FileProviderLoadError> {
721 if !self.root.exists() {
722 return Ok(Vec::new());
723 }
724 let mut paths = Vec::new();
725 collect_flat_files(&self.root, &mut paths, |path| {
726 is_provider_file(path) && !is_wasm_sidecar_manifest(path)
727 })?;
728 let tools_dir = self.root.join("tools");
729 if tools_dir.is_dir() {
730 collect_flat_files(&tools_dir, &mut paths, |path| {
731 is_tool_file(path) && !is_wasm_sidecar_manifest(path)
732 })?;
733 }
734 let prompts_dir = self.root.join("prompts");
735 if prompts_dir.is_dir() {
736 collect_flat_files(&prompts_dir, &mut paths, is_markdown_prompt_file)?;
737 }
738 paths.sort();
739 Ok(paths)
740 }
741
742 fn resource_paths(&self) -> Result<Vec<(PathBuf, PathBuf)>, FileProviderLoadError> {
747 filesystem_resources::resource_paths(&self.root)
748 }
749
750 fn resource_pairs_with_canonical_root(
755 &self,
756 ) -> Result<Vec<(PathBuf, PathBuf, PathBuf)>, FileProviderLoadError> {
757 let pairs = self.resource_paths()?;
758 if pairs.is_empty() {
759 return Ok(Vec::new());
760 }
761 let canonical_root = filesystem_resources::canonical_resources_root(&self.root)?
762 .expect("non-empty resource_paths() implies the resources dir exists");
763 Ok(pairs
764 .into_iter()
765 .map(|(absolute, relative)| (absolute, relative, canonical_root.clone()))
766 .collect())
767 }
768}
769
770fn collect_flat_files(
771 dir: &Path,
772 paths: &mut Vec<PathBuf>,
773 accept: impl Fn(&Path) -> bool,
774) -> Result<(), FileProviderLoadError> {
775 let entries = fs::read_dir(dir).map_err(|source| FileProviderLoadError {
776 path: dir.to_path_buf(),
777 message: format!("failed to read provider directory: {source}"),
778 })?;
779 for entry in entries {
780 let entry = entry.map_err(|source| FileProviderLoadError {
781 path: dir.to_path_buf(),
782 message: format!("failed to read provider directory entry: {source}"),
783 })?;
784 let path = entry.path();
785 if path.is_file() && accept(&path) {
786 paths.push(path);
787 }
788 }
789 Ok(())
790}
791
792#[derive(Debug)]
794pub struct FileProviderLoadError {
795 pub path: PathBuf,
797 pub message: String,
799}
800
801impl std::fmt::Display for FileProviderLoadError {
802 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
803 write!(f, "{}: {}", self.path.display(), self.message)
804 }
805}
806
807impl std::error::Error for FileProviderLoadError {}
808
809fn provider_for_catalog(
828 path: PathBuf,
829 catalog: ProviderCatalog,
830 interpreter: PythonInterpreter,
831 runner: &PythonRunnerSelection,
832) -> Result<std::sync::Arc<dyn Provider>, FileProviderLoadError> {
833 let kind = catalog.provider.kind;
834 if matches!(
835 kind,
836 ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex
837 ) {
838 let provider = match runner {
839 PythonRunnerSelection::OneShot => PythonProvider::arc_with_interpreter(
840 path,
841 catalog,
842 PROVIDER_ENV_PREFIX,
843 interpreter,
844 ),
845 PythonRunnerSelection::Persistent(config) => PythonProvider::arc_persistent(
846 path.clone(),
847 catalog,
848 PROVIDER_ENV_PREFIX,
849 interpreter,
850 config.clone(),
851 )
852 .map_err(|error| FileProviderLoadError {
853 path,
854 message: error.to_string(),
855 })?,
856 };
857 return Ok(SharedAdapter::wrap(provider));
858 }
859 manifest_file::build_provider(path.clone(), catalog, PROVIDER_ENV_PREFIX)
860 .map(SharedAdapter::wrap)
861 .ok_or_else(|| FileProviderLoadError {
862 path,
863 message: format!(
864 "provider kind `{}` is not enabled in this build (soma-provider-adapters feature missing)",
865 kind.as_str()
866 ),
867 })
868}
869
870fn is_provider_file(path: &Path) -> bool {
871 match path.extension().and_then(|extension| extension.to_str()) {
872 Some("json" | "ts" | "wasm" | "py") => true,
873 Some("md") => is_markdown_prompt_file(path),
874 _ => false,
875 }
876}
877
878fn is_tool_file(path: &Path) -> bool {
881 matches!(
882 path.extension().and_then(|extension| extension.to_str()),
883 Some("json" | "ts" | "wasm" | "py")
884 )
885}
886
887fn is_markdown_prompt_file(path: &Path) -> bool {
890 path.file_stem()
891 .and_then(|stem| stem.to_str())
892 .map(|stem| !stem.eq_ignore_ascii_case("readme"))
893 .unwrap_or(false)
894}
895
896fn is_wasm_sidecar_manifest(path: &Path) -> bool {
897 path.file_name()
898 .and_then(|name| name.to_str())
899 .is_some_and(|name| name.ends_with(".wasm.json"))
900}
901
902fn compile_tool_schemas(catalog: &ProviderCatalog) -> Result<(), String> {
909 for tool in &catalog.tools {
910 jsonschema::validator_for(&tool.input_schema)
911 .map_err(|error| format!("tool `{}` has invalid input_schema: {error}", tool.name))?;
912 if let Some(output_schema) = &tool.output_schema {
913 jsonschema::validator_for(output_schema).map_err(|error| {
914 format!("tool `{}` has invalid output_schema: {error}", tool.name)
915 })?;
916 }
917 }
918 Ok(())
919}
920
921fn fingerprint_file(
922 hasher: &mut Sha256,
923 root: &Path,
924 path: &Path,
925) -> Result<(), FileProviderLoadError> {
926 let bytes = fs::read(path).map_err(|source| FileProviderLoadError {
927 path: path.to_path_buf(),
928 message: format!("failed to read provider file for fingerprint: {source}"),
929 })?;
930 let label = path
931 .strip_prefix(root)
932 .unwrap_or(path)
933 .display()
934 .to_string();
935 hasher.update(label.as_bytes());
936 hasher.update([0]);
937 hasher.update(bytes.len().to_le_bytes());
938 hasher.update([0]);
939 hasher.update(bytes);
940 hasher.update([0xff]);
941 Ok(())
942}
943
944fn load_catalog(path: &Path) -> Result<ProviderCatalog, FileProviderLoadError> {
945 load_catalog_with_interpreter(path, &PythonInterpreter::Ambient)
946}
947
948fn load_catalog_with_interpreter(
949 path: &Path,
950 interpreter: &PythonInterpreter,
951) -> Result<ProviderCatalog, FileProviderLoadError> {
952 let extension = path.extension().and_then(|extension| extension.to_str());
953 let catalog = match extension {
954 Some("json") | Some("ts") | Some("wasm") | Some("md") => {
955 let value = load_catalog_value(path)?;
956 serde_json::from_value(value).map_err(|source| FileProviderLoadError {
957 path: path.to_path_buf(),
958 message: format!("invalid provider manifest JSON: {source}"),
959 })?
960 }
961 Some("py") => {
962 soma_provider_adapters::python::load_python_catalog_with_interpreter(
970 path,
971 PROVIDER_ENV_PREFIX,
972 interpreter,
973 )
974 .map_err(|source| FileProviderLoadError {
975 path: path.to_path_buf(),
976 message: format!("invalid Python provider: {source}"),
977 })?
978 }
979 _ => {
980 return Err(FileProviderLoadError {
981 path: path.to_path_buf(),
982 message: "unsupported provider file extension".to_owned(),
983 });
984 }
985 };
986 ensure_kind_matches(path, &catalog)?;
987 Ok(catalog)
988}
989
990fn load_catalog_value(path: &Path) -> Result<Value, FileProviderLoadError> {
998 let extension = path.extension().and_then(|extension| extension.to_str());
999 match extension {
1000 Some("json") => {
1001 serde_json::from_slice(&fs::read(path).map_err(|source| FileProviderLoadError {
1002 path: path.to_path_buf(),
1003 message: format!("failed to read provider manifest: {source}"),
1004 })?)
1005 .map_err(|source| FileProviderLoadError {
1006 path: path.to_path_buf(),
1007 message: format!("invalid provider manifest JSON: {source}"),
1008 })
1009 }
1010 Some("ts") => load_ts_catalog_value(path),
1011 Some("wasm") => filesystem_wasm::load_wasm_catalog_value(path),
1012 Some("md") => filesystem_prompts::load_markdown_catalog_value(path),
1013 _ => Err(FileProviderLoadError {
1014 path: path.to_path_buf(),
1015 message: "unsupported provider file extension".to_owned(),
1016 }),
1017 }
1018}
1019
1020fn load_ts_catalog_value(path: &Path) -> Result<Value, FileProviderLoadError> {
1021 let text = fs::read_to_string(path).map_err(|source| FileProviderLoadError {
1022 path: path.to_path_buf(),
1023 message: format!("failed to read TypeScript provider: {source}"),
1024 })?;
1025 let json_text = extract_ts_manifest(&text).ok_or_else(|| FileProviderLoadError {
1026 path: path.to_path_buf(),
1027 message: "TypeScript provider must contain `export default { ... }` manifest JSON"
1028 .to_owned(),
1029 })?;
1030 serde_json::from_str(json_text).map_err(|source| FileProviderLoadError {
1031 path: path.to_path_buf(),
1032 message: format!("invalid TypeScript provider manifest JSON: {source}"),
1033 })
1034}
1035
1036fn extract_ts_manifest(text: &str) -> Option<&str> {
1037 let marker = "export default";
1038 let start = text.find(marker)? + marker.len();
1039 let rest = text[start..].trim_start();
1040 let open = rest.find('{')?;
1041 let mut depth = 0usize;
1042 let mut in_string = false;
1043 let mut escaped = false;
1044 for (offset, ch) in rest[open..].char_indices() {
1045 if in_string {
1046 if escaped {
1047 escaped = false;
1048 } else if ch == '\\' {
1049 escaped = true;
1050 } else if ch == '"' {
1051 in_string = false;
1052 }
1053 continue;
1054 }
1055 match ch {
1056 '"' => in_string = true,
1057 '{' => depth += 1,
1058 '}' => {
1059 depth = depth.checked_sub(1)?;
1060 if depth == 0 {
1061 let end = open + offset + ch.len_utf8();
1062 return Some(rest[..end].trim());
1063 }
1064 }
1065 _ => {}
1066 }
1067 }
1068 None
1069}
1070
1071fn ensure_kind_matches(
1072 path: &Path,
1073 catalog: &ProviderCatalog,
1074) -> Result<(), FileProviderLoadError> {
1075 let extension = path.extension().and_then(|extension| extension.to_str());
1076 let required_extension = required_extension_for_kind(catalog.provider.kind);
1077 if required_extension.is_some_and(|expected| extension != Some(expected)) {
1078 return Err(FileProviderLoadError {
1079 path: path.to_path_buf(),
1080 message: format!(
1081 "provider kind `{}` requires a .{} file",
1082 catalog.provider.kind.as_str(),
1083 required_extension_for_kind(catalog.provider.kind).unwrap()
1084 ),
1085 });
1086 }
1087
1088 match extension {
1093 Some("ts") if catalog.provider.kind != ProviderKind::AiSdk => {
1094 return Err(FileProviderLoadError {
1095 path: path.to_path_buf(),
1096 message: format!(
1097 "provider kind `{}` does not match TypeScript provider extension",
1098 catalog.provider.kind.as_str()
1099 ),
1100 });
1101 }
1102 Some("wasm") if catalog.provider.kind != ProviderKind::Wasm => {
1103 return Err(FileProviderLoadError {
1104 path: path.to_path_buf(),
1105 message: format!(
1106 "provider kind `{}` does not match WASM provider extension",
1107 catalog.provider.kind.as_str()
1108 ),
1109 });
1110 }
1111 Some("py")
1112 if !matches!(
1113 catalog.provider.kind,
1114 ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex
1115 ) =>
1116 {
1117 return Err(FileProviderLoadError {
1118 path: path.to_path_buf(),
1119 message: format!(
1120 "provider kind `{}` does not match Python provider extension",
1121 catalog.provider.kind.as_str()
1122 ),
1123 });
1124 }
1125 _ => {}
1126 }
1127 Ok(())
1128}
1129
1130fn required_extension_for_kind(kind: ProviderKind) -> Option<&'static str> {
1131 match kind {
1132 ProviderKind::AiSdk => Some("ts"),
1133 ProviderKind::Wasm => Some("wasm"),
1134 ProviderKind::Python | ProviderKind::Langchain | ProviderKind::Llamaindex => Some("py"),
1135 ProviderKind::StaticRust | ProviderKind::Openapi | ProviderKind::Mcp => None,
1136 }
1137}
1138
1139#[cfg(test)]
1140#[path = "filesystem_tests.rs"]
1141mod tests;