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, SessionScope},
shell::ShellAccess,
skills::{self, SkillRoots, SkillsConfig},
store,
templates::{self, Template, TemplatesConfig},
tools::{
declared::{self, DeclaredTools, ToolsConfig},
host::WorkspaceHostTools,
},
};
use super::{Workspace, 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,
host_tools: Vec<Box<dyn crate::tools::ExecutableTool>>,
shell: ShellAccess,
compaction: Compaction,
}
#[derive(Debug)]
enum WorkspaceModel {
Inherited,
Selector(ModelSelector),
Resolved(ModelInfo),
}
enum RuntimeSource {
Shared(Arc<Runtime>),
Private(Box<RuntimeBuilder>),
}
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,
},
)
.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("host_tools", &self.host_tools.len())
.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(),
host_tools: Vec::new(),
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 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
}
}
fn probing_no_files(self) -> Self {
Self {
context: ContextConfig::none(),
config: Some(self.config.unwrap_or_default()),
skills: SkillsConfig::none(),
memory: MemoryConfig::disabled(),
templates: TemplatesConfig::none(),
hooks: self.hooks.supplied_only(),
tools: self.tools.supplied_only(),
#[cfg(feature = "mcp")]
mcp: self.mcp.supplied_only(),
..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_tool<T>(self, tool: T) -> Self
where
T: crate::tools::ExecutableTool + 'static,
{
Self {
host_tools: {
let mut host_tools = self.host_tools;
host_tools.push(Box::new(tool));
host_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(mut self) -> Result<Workspace, RunError> {
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;
if !self.discovery_enabled {
self = self.probing_no_files();
}
let path = crate::context::resolve_workspace(&self.path)?;
let context = WorkspaceContext::discover_with(&path, &self.context)?;
let config = match self.config {
Some(config) => config,
None => config::Config::discover(&path, self.context.global_dir.as_deref())?,
};
let loaded_hooks = hooks::load(&path, &self.hooks)?;
let supplied_tools = declared::load_supplied(&self.tools)?;
let declared_sources = declared::discover(&path, &self.tools)?;
let store_dir = match &self.runtime {
RuntimeSource::Shared(_) => None,
RuntimeSource::Private(recipe) => recipe.named_store_dir().map(Path::to_path_buf),
};
let memory_config = self.memory;
let (memory_sources, memories) = 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)??;
let memory_roots: Vec<PathBuf> = memory_sources
.iter()
.map(|source| source.path.clone())
.collect();
let runtime = match self.runtime {
RuntimeSource::Shared(runtime) => runtime,
RuntimeSource::Private(recipe) => Arc::new(recipe.with_config(&config).build_for(
&path,
self.shell,
&memory_roots,
)?),
};
let scope = SessionScope {
identifier: store::runtime_identifier(&path),
policy: runtime.session_policy(&path, self.shell, &memory_roots),
};
let audience = scope.audience();
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_registration = register_skills(Arc::clone(&runtime), &path, &self.skills)?;
let skills: Vec<LoadedSkill> = runtime
.mentra_runtime()
.skills()
.into_iter()
.map(|skill| LoadedSkill {
name: skill.name,
description: skill.description,
model_invocable: skill.model_invocable,
path: skill.path,
root: skill.root,
})
.collect();
let declared_tools = DeclaredTools::register_with_supplied(
Arc::clone(&runtime),
&audience,
&path,
&declared_sources,
&supplied_tools,
)?;
let declared_tool_names = declared_tools.names().to_vec();
let host_tools = WorkspaceHostTools::register(
Arc::clone(&runtime),
&audience,
&path,
std::mem::take(&mut self.host_tools),
)?;
let host_tool_names = host_tools.names().to_vec();
let (templates_dirs, templates) = load_templates(&path, &self.templates)?;
let runner = HookRunner::new(&path, loaded_hooks);
let runner = runner.with_interceptor(crate::runtime::agents::ForeignToolGuard::new(
Arc::clone(runtime.agents()),
runtime.tool_claims(),
));
let hooks = runtime.register_hook_chain(&audience, &path, runner)?;
#[cfg(feature = "mcp")]
let (mcp_connections, mcp_files, mcp_servers) = {
let (files, servers) = discovered_mcp(&path, &self.mcp)?;
let connections =
McpConnections::connect(Arc::clone(&runtime), &audience, &path, servers).await;
let names = connections.names().to_vec();
(connections, files, names)
};
#[cfg(not(feature = "mcp"))]
let (mcp_files, mcp_servers): (Vec<ContextFile>, Vec<String>) = (Vec::new(), Vec::new());
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(),
),
root: path,
provider: runtime.provider().to_string(),
runtime,
scope,
mint_posture: MintPosture::new(fresh_only),
model,
effort: config.effort.as_ref().map(|effort| effort.value),
config,
context,
memories,
skills_registration,
skills,
templates_dirs,
templates,
mcp_files,
mcp_servers,
declared_tool_files: sourced(&declared_sources),
declared_tools: declared_tool_names,
declared_registration: declared_tools,
host_tools: host_tool_names,
host_tool_registration: host_tools,
hooks,
#[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: Arc<Runtime>,
workspace: &Path,
config: &SkillsConfig,
) -> Result<SkillRoots, RunError> {
let sources = skills::discover(workspace, config);
let paths: Vec<PathBuf> = sources.iter().map(|source| source.path.clone()).collect();
SkillRoots::register(runtime, 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;