use std::{
path::{Path, PathBuf},
sync::Arc,
};
use mentra::{ModelInfo, ModelSelector};
#[cfg(feature = "mcp")]
use crate::mcp::{self, McpConfig, connections::McpConnections};
use crate::{
compaction::Compaction,
config::{self, Config},
context::{ContextConfig, SystemPrompt, WorkspaceContext},
error::RunError,
event::ContextFile,
hooks::{self, HookRunner, HooksConfig},
memory::{self, MemoryConfig},
run::LoadedSkill,
runtime::{Runtime, RuntimeBuilder, RuntimeRecipe, dispatch},
shell::ShellAccess,
skills::{self, SkillsConfig},
store,
templates::{self, Template, TemplatesConfig},
tools::declared::{self, DeclaredTools, ToolsConfig},
};
use super::{Workspace, WorkspaceReuse, lifecycle::MintPosture, roster::ToolRoster};
pub struct WorkspaceBuilder {
path: PathBuf,
runtime: RuntimeSource,
discovery_enabled: bool,
fresh_only: bool,
model: WorkspaceModel,
context: ContextConfig,
config: Option<Config>,
system_prompt: Option<SystemPrompt>,
skills: SkillsConfig,
memory: MemoryConfig,
roster: ToolRoster,
#[cfg(feature = "mcp")]
mcp: McpConfig,
templates: TemplatesConfig,
hooks: HooksConfig,
tools: ToolsConfig,
shell: ShellAccess,
compaction: Compaction,
}
#[derive(Debug)]
enum WorkspaceModel {
Inherited,
Selector(ModelSelector),
Resolved(ModelInfo),
}
enum RuntimeSource {
Shared(Arc<Runtime>),
Private(Box<RuntimeBuilder>),
Reusable(Box<RuntimeRecipe>),
}
impl std::fmt::Debug for WorkspaceBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WorkspaceBuilder")
.field("path", &self.path)
.field(
"runtime",
match &self.runtime {
RuntimeSource::Shared(runtime) => runtime,
RuntimeSource::Private(recipe) => &**recipe,
RuntimeSource::Reusable(recipe) => recipe,
},
)
.field("discovery_enabled", &self.discovery_enabled)
.field("fresh_only", &self.fresh_only)
.field("model", &self.model)
.field("context", &self.context)
.field("config", &self.config)
.field("system_prompt", &self.system_prompt)
.field("skills", &self.skills)
.field("memory", &self.memory)
.field("roster", &self.roster)
.field("templates", &self.templates)
.field("hooks", &self.hooks)
.field("tools", &self.tools)
.field("shell", &self.shell)
.field("compaction", &self.compaction)
.finish_non_exhaustive()
}
}
impl WorkspaceBuilder {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
runtime: RuntimeSource::Private(Box::default()),
discovery_enabled: true,
fresh_only: false,
model: WorkspaceModel::Inherited,
context: ContextConfig::default(),
config: None,
system_prompt: None,
skills: SkillsConfig::default(),
memory: MemoryConfig::default(),
roster: ToolRoster::default(),
#[cfg(feature = "mcp")]
mcp: McpConfig::default(),
templates: TemplatesConfig::default(),
hooks: HooksConfig::default(),
tools: ToolsConfig::default(),
shell: ShellAccess::default(),
compaction: Compaction::default(),
}
}
pub fn with_runtime(self, runtime: Arc<Runtime>) -> Self {
Self {
runtime: RuntimeSource::Shared(runtime),
..self
}
}
pub fn with_runtime_builder(self, runtime: RuntimeBuilder) -> Self {
Self {
runtime: RuntimeSource::Private(Box::new(runtime)),
..self
}
}
#[must_use]
pub fn with_runtime_recipe(self, recipe: RuntimeRecipe) -> Self {
Self {
runtime: RuntimeSource::Reusable(Box::new(recipe)),
..self
}
}
#[must_use]
pub fn fresh_only(self) -> Self {
Self {
fresh_only: true,
..self
}
}
pub fn with_model(self, model: ModelSelector) -> Self {
Self {
model: WorkspaceModel::Selector(model),
..self
}
}
#[must_use]
pub fn with_resolved_model(self, model: ModelInfo) -> Self {
Self {
model: WorkspaceModel::Resolved(model),
..self
}
}
pub fn with_context(self, context: ContextConfig) -> Self {
Self { context, ..self }
}
#[must_use]
pub fn without_discovery(self) -> Self {
Self {
discovery_enabled: false,
..self
}
}
pub fn with_config(self, config: Config) -> Self {
Self {
config: Some(config),
..self
}
}
pub fn with_system_prompt(self, system_prompt: SystemPrompt) -> Self {
Self {
system_prompt: Some(system_prompt),
..self
}
}
pub fn with_skills(self, skills: SkillsConfig) -> Self {
Self { skills, ..self }
}
pub fn with_memory(self, memory: MemoryConfig) -> Self {
Self { memory, ..self }
}
pub fn with_tool_roster(self, roster: ToolRoster) -> Self {
Self { roster, ..self }
}
#[cfg(feature = "mcp")]
pub fn with_mcp(self, mcp: McpConfig) -> Self {
Self { mcp, ..self }
}
pub fn with_templates(self, templates: TemplatesConfig) -> Self {
Self { templates, ..self }
}
pub fn with_hooks(self, hooks: HooksConfig) -> Self {
Self { hooks, ..self }
}
pub fn with_tools(self, tools: ToolsConfig) -> Self {
Self { tools, ..self }
}
pub fn with_shell(self, shell: ShellAccess) -> Self {
Self { shell, ..self }
}
pub fn with_compaction(self, compaction: Compaction) -> Self {
Self { compaction, ..self }
}
pub async fn open(self) -> Result<Workspace, RunError> {
if let RuntimeSource::Reusable(recipe) = &self.runtime {
if self.discovery_enabled {
return Err(RunError::ReusableWorkspaceRequiresDiscoveryOff);
}
if !self.fresh_only {
return Err(RunError::ReusableWorkspaceRequiresFreshOnly);
}
let WorkspaceModel::Resolved(model) = &self.model else {
return Err(RunError::ReusableWorkspaceRequiresResolvedModel);
};
if self.roster.as_profile().allowed_tools.is_none() {
return Err(RunError::ReusableWorkspaceRequiresExactRoster);
}
if model.provider.as_str() != recipe.provider().as_str() {
return Err(RunError::ResolvedModelProviderMismatch {
model: model.id.clone(),
model_provider: model.provider.as_str().to_string(),
runtime_provider: recipe.provider().as_str().to_string(),
});
}
}
let path = crate::context::resolve_workspace(&self.path)?;
let context = if self.discovery_enabled {
WorkspaceContext::discover_with(&path, &self.context)?
} else {
WorkspaceContext::discover_with(&path, &ContextConfig::none())?
};
if !self.discovery_enabled && matches!(&self.runtime, RuntimeSource::Shared(_)) {
return Err(RunError::DiscoveryDisabledSharedRuntime);
}
if self.fresh_only && matches!(&self.runtime, RuntimeSource::Shared(_)) {
return Err(RunError::FreshOnlySharedRuntime);
}
let fresh_only = self.fresh_only;
let config = match self.config {
Some(config) => config,
None if self.discovery_enabled => {
config::Config::discover(&path, self.context.global_dir.as_deref())?
}
None => Config::default(),
};
let loaded_hooks = if self.discovery_enabled {
hooks::load(&path, &self.hooks)?
} else {
Vec::new()
};
let declared_sources = if self.discovery_enabled {
declared::discover(&path, &self.tools)?
} else {
Vec::new()
};
let store_dir = match &self.runtime {
RuntimeSource::Shared(_) => None,
RuntimeSource::Private(recipe) => recipe.named_store_dir().map(Path::to_path_buf),
RuntimeSource::Reusable(_) => None,
};
let memory_config = self.memory;
let (memory_sources, memories) = if self.discovery_enabled {
tokio::task::spawn_blocking(move || {
let memory_sources = memory::roots(&memory_config, store_dir.as_deref());
let memories = memory::load(&memory_sources)?;
Ok::<_, memory::MemoryError>((memory_sources, memories))
})
.await
.map_err(RunError::MemoryDiscovery)??
} else {
(Vec::new(), Vec::new())
};
let memory_roots: Vec<PathBuf> = memory_sources
.iter()
.map(|source| source.path.clone())
.collect();
let shared = matches!(self.runtime, RuntimeSource::Shared(_));
let (runtime, reusable_recipe) = match self.runtime {
RuntimeSource::Shared(runtime) => (runtime, None),
RuntimeSource::Private(recipe) => (
Arc::new(recipe.with_config(&config).build_for(
&path,
self.shell,
&memory_roots,
)?),
None,
),
RuntimeSource::Reusable(recipe) => {
let runtime = Arc::new(recipe.build_for(&path, self.shell, &memory_roots).await?);
(runtime, Some(recipe))
}
};
let model = match self.model {
WorkspaceModel::Inherited => runtime.resolve_model(config.model_selector()).await?,
WorkspaceModel::Selector(selector) => runtime.resolve_model(Some(selector)).await?,
WorkspaceModel::Resolved(model) => {
if model.provider.as_str() != runtime.provider() {
return Err(RunError::ResolvedModelProviderMismatch {
model: model.id.clone(),
model_provider: model.provider.as_str().to_string(),
runtime_provider: runtime.provider().to_string(),
});
}
model
}
};
let (skills_dirs, skills) = if self.discovery_enabled {
let dirs = register_skills(runtime.mentra_runtime_internal(), &path, &self.skills)?;
let loaded = runtime
.mentra_runtime_internal()
.skills()
.into_iter()
.map(|skill| LoadedSkill {
name: skill.name,
description: skill.description,
model_invocable: skill.model_invocable,
path: skill.path,
})
.collect();
(dirs, loaded)
} else {
(Vec::new(), Vec::new())
};
let declared_tools =
DeclaredTools::register(Arc::clone(&runtime), &path, &declared_sources)?;
let declared_tool_names = declared_tools.names().to_vec();
let (templates_dirs, templates) = if self.discovery_enabled {
load_templates(&path, &self.templates)?
} else {
(Vec::new(), Vec::new())
};
let runner = runtime.interceptors().iter().cloned().fold(
HookRunner::new(&path, loaded_hooks),
|runner, interceptor| runner.with_interceptor(interceptor),
);
let foreign_tools = Arc::new(std::sync::RwLock::new(std::collections::BTreeSet::new()));
let hook_registration = runtime.register_workspace(dispatch::WorkspaceGuardEntry {
runner: Arc::new(runner),
shell: self.shell,
root: path.clone(),
shared,
foreign_tools: Arc::clone(&foreign_tools),
});
#[cfg(feature = "mcp")]
let (mcp_connections, mcp_files, mcp_servers) = {
if self.discovery_enabled {
let (files, servers) = discovered_mcp(&path, &self.mcp)?;
let connections =
McpConnections::connect(Arc::clone(&runtime), &path, servers).await;
let names = connections.names().to_vec();
(connections, files, names)
} else {
(
McpConnections::empty(Arc::clone(&runtime), &path),
Vec::new(),
Vec::new(),
)
}
};
#[cfg(not(feature = "mcp"))]
let (mcp_files, mcp_servers): (Vec<ContextFile>, Vec<String>) = (Vec::new(), Vec::new());
let reuse = reusable_recipe.map(|recipe| WorkspaceReuse::new(recipe, self.shell));
Ok(Workspace {
agent: agent_config(
&path,
&context,
self.system_prompt.as_ref(),
memory::index_block(&memories).as_deref(),
self.roster,
self.compaction,
runtime.transcripts_dir().to_path_buf(),
),
identifier: store::runtime_identifier(&path),
root: path,
provider: runtime.provider().to_string(),
runtime,
reuse,
mint_posture: MintPosture::new(fresh_only),
model,
effort: config.effort.as_ref().map(|effort| effort.value),
config,
context,
memories,
skills_dirs,
skills,
templates_dirs,
templates,
mcp_files,
mcp_servers,
declared_tool_files: sourced(&declared_sources),
declared_tools: declared_tool_names,
declared_registration: declared_tools,
hook_registration,
foreign_tools,
#[cfg(feature = "mcp")]
mcp_connections,
})
}
}
fn sourced(sources: &[declared::ToolsSource]) -> Vec<ContextFile> {
sources
.iter()
.map(|source| ContextFile {
path: source.path.clone(),
scope: source.scope.label(),
})
.collect()
}
#[cfg(feature = "mcp")]
fn discovered_mcp(
workspace: &Path,
config: &McpConfig,
) -> Result<(Vec<ContextFile>, Vec<mcp::ConfiguredServer>), RunError> {
let files: Vec<ContextFile> = mcp::discover(workspace, config)?
.iter()
.map(|source| ContextFile {
path: source.path.clone(),
scope: source.scope.label(),
})
.collect();
Ok((files, mcp::configured(workspace, config)?))
}
fn register_skills(
runtime: &mentra::Runtime,
workspace: &Path,
config: &SkillsConfig,
) -> Result<Vec<PathBuf>, RunError> {
let sources = skills::discover(workspace, config);
let paths: Vec<PathBuf> = sources.iter().map(|source| source.path.clone()).collect();
runtime.register_skills_dirs(&paths)?;
Ok(paths)
}
pub(crate) fn load_templates(
workspace: &Path,
config: &TemplatesConfig,
) -> Result<(Vec<PathBuf>, Vec<Template>), RunError> {
let sources = templates::discover(workspace, config);
let dirs: Vec<PathBuf> = sources.iter().map(|source| source.path.clone()).collect();
Ok((dirs, templates::load_sources(&sources)?))
}
fn agent_config(
workspace: &Path,
context: &WorkspaceContext,
system_prompt: Option<&SystemPrompt>,
memory_index: Option<&str>,
roster: ToolRoster,
compaction: Compaction,
transcripts: PathBuf,
) -> mentra::agent::AgentConfig {
mentra::agent::AgentConfig {
system: context.render_with_appendix(system_prompt, memory_index),
tool_profile: roster.into_profile(),
workspace: mentra::agent::WorkspaceConfig {
base_dir: workspace.to_path_buf(),
..Default::default()
},
compaction: compaction.into_mentra(transcripts),
memory: mentra::agent::MemoryConfig {
auto_recall_enabled: false,
write_tools_enabled: false,
..Default::default()
},
..Default::default()
}
}
#[cfg(test)]
mod tests;