mod builder;
pub(crate) mod lifecycle;
mod profile;
mod roster;
mod spec;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::{Arc, RwLock};
use mentra::{ModelInfo, Session, agent::AgentConfig, provider::ReasoningOptions};
pub use builder::WorkspaceBuilder;
pub use profile::RunProfile;
pub use roster::ToolRoster;
pub(crate) use spec::DEFAULT_SESSION_NAME;
pub use spec::RunSpec;
pub(crate) use builder::load_templates;
use lifecycle::{MintPosture, ReuseLease, ReuseLifecycle};
#[cfg(feature = "mcp")]
use crate::mcp::connections::McpConnections;
use crate::{
config::Config,
context::WorkspaceContext,
error::RunError,
event::ContextFile,
fingerprint::{self, Snapshot},
hooks::HookRunner,
memory::Memory,
run::{Effort, LoadedSkill, PreparedRun, RunContext},
runtime::{
Runtime, RuntimeRecipe,
dispatch::{self, HookRegistration},
},
shell::ShellAccess,
skills::SkillRoots,
templates::Template,
tools::declared::DeclaredTools,
};
pub struct Workspace {
root: PathBuf,
runtime: Arc<Runtime>,
reuse: Option<WorkspaceReuse>,
mint_posture: MintPosture,
model: ModelInfo,
effort: Option<Effort>,
config: Config,
provider: String,
identifier: String,
context: WorkspaceContext,
memories: Vec<Memory>,
agent: AgentConfig,
skills_registration: SkillRoots,
skills: Vec<LoadedSkill>,
templates_dirs: Vec<PathBuf>,
templates: Vec<Template>,
mcp_files: Vec<ContextFile>,
mcp_servers: Vec<String>,
declared_tool_files: Vec<ContextFile>,
declared_tools: Vec<String>,
declared_registration: DeclaredTools,
#[allow(dead_code, reason = "held for its Drop")]
hook_registration: HookRegistration,
foreign_tools: Arc<RwLock<BTreeSet<String>>>,
#[cfg(feature = "mcp")]
#[allow(dead_code, reason = "held for its Drop")]
mcp_connections: McpConnections,
}
struct WorkspaceReuse {
recipe: Box<RuntimeRecipe>,
lifecycle: ReuseLifecycle,
shell: ShellAccess,
}
impl WorkspaceReuse {
fn new(recipe: Box<RuntimeRecipe>, shell: ShellAccess) -> Self {
Self {
recipe,
lifecycle: ReuseLifecycle::unbound(),
shell,
}
}
}
impl std::fmt::Debug for Workspace {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Workspace")
.field("root", &self.root)
.field("provider", &self.provider)
.field("model", &self.model.id)
.field("fresh_only", &self.mint_posture.is_fresh_only())
.field("config_files", &self.config.files)
.field("context_files", &self.context.documents().len())
.field("memories", &self.memories.len())
.field("skills", &self.skills.len())
.field("templates", &self.templates.len())
.field("mcp_servers", &self.mcp_servers)
.field("declared_tools", &self.declared_tools)
.finish_non_exhaustive()
}
}
impl Workspace {
pub async fn open(path: impl Into<PathBuf>) -> Result<Self, RunError> {
Self::builder(path).open().await
}
pub fn builder(path: impl Into<PathBuf>) -> WorkspaceBuilder {
WorkspaceBuilder::new(path)
}
pub fn prepare(&self, spec: impl Into<RunSpec>) -> Result<PreparedRun, RunError> {
let spec = spec.into();
let reuse_lease = self.reuse_lease()?;
self.mint_posture.claim()?;
if let Some(model) = spec.profile.resolved_model() {
validate_model_provider(model, &self.provider)?;
}
if spec.profile.has_extra_headers() && !self.runtime.has_ephemeral_history() {
return Err(RunError::RunProfileHeadersRequireEphemeralHistory);
}
let model = spec
.profile
.resolved_model()
.cloned()
.unwrap_or_else(|| self.model.clone());
let model_id = model.id.clone();
let agent = self.minted_agent(&spec.profile);
let context_snapshot = agent.system.clone();
let mut session =
self.runtime
.mint(spec.session_name.clone(), model, agent, &self.identifier)?;
if !spec.profile.decides_reasoning() {
apply_effort(&mut session, spec.effort.or(self.effort))?;
}
Ok(self.minted(session, spec, model_id, context_snapshot, reuse_lease))
}
pub fn resume(
&self,
agent_id: &str,
spec: impl Into<RunSpec>,
) -> Result<PreparedRun, RunError> {
let spec = spec.into();
let reuse_lease = self.reuse_lease()?;
self.mint_posture.claim()?;
if let Some(model) = spec.profile.resolved_model() {
validate_model_provider(model, &self.provider)?;
}
if let Some(field) = spec.profile.unsupported_on_resume() {
return Err(RunError::UnsupportedResumeProfile { field });
}
let legacy_effort = spec.effort.or(self.effort);
let changes_reasoning = spec.profile.reasoning().is_some() || legacy_effort.is_some();
if spec.profile.resolved_model().is_some() && changes_reasoning {
return Err(RunError::NonAtomicResumeProfile);
}
let mut session = self.runtime.resume_minted(agent_id)?;
let model = if let Some(model) = spec.profile.resolved_model() {
session.set_model(model.clone())?;
model.id.clone()
} else if !changes_reasoning && session_on_resolved_model(&session, &self.model) {
session.set_model(self.model.clone())?;
self.model.id.clone()
} else {
session.metadata().model.clone()
};
if let Some(reasoning) = spec.profile.reasoning() {
session.set_reasoning(reasoning.clone())?;
} else {
apply_effort(&mut session, legacy_effort)?;
}
Ok(self.minted(session, spec, model, None, reuse_lease))
}
pub fn fingerprint(&self) -> Snapshot {
fingerprint::snapshot(&self.root)
}
pub fn path(&self) -> &Path {
&self.root
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn model(&self) -> &str {
&self.model.id
}
pub fn provider(&self) -> &str {
&self.provider
}
pub fn context(&self) -> &WorkspaceContext {
&self.context
}
pub fn memories(&self) -> &[Memory] {
&self.memories
}
pub fn skills(&self) -> &[LoadedSkill] {
&self.skills
}
pub fn templates(&self) -> &[Template] {
&self.templates
}
pub fn mcp_servers(&self) -> &[String] {
&self.mcp_servers
}
pub fn declared_tools(&self) -> &[String] {
&self.declared_tools
}
pub fn config(&self) -> &Config {
&self.config
}
pub fn config_files(&self) -> &[ContextFile] {
&self.config.files
}
pub fn declared_tool_files(&self) -> &[ContextFile] {
&self.declared_tool_files
}
pub fn bind_host_tools(
self,
tools: Vec<Box<dyn crate::tools::ExecutableTool>>,
) -> Result<Self, RunError> {
let reuse = self.reuse.as_ref().ok_or(RunError::WorkspaceNotReusable)?;
reuse.lifecycle.require_unbound()?;
validate_reusable_host_tools(self.runtime.mentra_runtime_internal(), &tools)?;
for tool in tools {
self.runtime
.mentra_runtime_internal()
.try_register_tool(tool)?;
}
reuse.lifecycle.mark_bound()?;
Ok(self)
}
pub async fn rebuild_for_reuse(mut self) -> Result<Self, RunError> {
let reuse = self.reuse.take().ok_or(RunError::WorkspaceNotReusable)?;
reuse.lifecycle.seal_for_rebuild()?;
#[cfg(feature = "mcp")]
drop(self.mcp_connections);
drop(self.declared_registration);
drop(self.hook_registration);
drop(self.skills_registration);
let old_runtime = match Arc::try_unwrap(self.runtime) {
Ok(runtime) => runtime,
Err(runtime) => {
drop(runtime);
return Err(RunError::ReusableRuntimeNotUnique);
}
};
drop(old_runtime);
let WorkspaceReuse {
recipe,
shell,
lifecycle: _,
} = reuse;
let runtime = Arc::new(recipe.build_for(&self.root, shell, &[]).await?);
validate_model_provider(&self.model, runtime.provider())?;
let declared_registration = DeclaredTools::register(Arc::clone(&runtime), &self.root, &[])?;
let foreign_tools = Arc::new(RwLock::new(BTreeSet::new()));
let runner = runtime.interceptors().iter().cloned().fold(
HookRunner::new(&self.root, Vec::new()),
|runner, interceptor| runner.with_interceptor(interceptor),
);
let hook_registration = runtime.register_workspace(dispatch::WorkspaceGuardEntry {
runner: Arc::new(runner),
shell,
root: self.root.clone(),
shared: false,
foreign_tools: Arc::clone(&foreign_tools),
});
#[cfg(feature = "mcp")]
let mcp_connections = McpConnections::empty(Arc::clone(&runtime), &self.root);
let mut agent = self.agent;
agent.compaction.transcript_dir = runtime.transcripts_dir().to_path_buf();
let provider = runtime.provider().to_string();
let reuse = Some(WorkspaceReuse::new(recipe, shell));
let skills_registration = SkillRoots::none(Arc::clone(&runtime));
Ok(Self {
root: self.root,
runtime,
reuse,
mint_posture: MintPosture::new(true),
model: self.model,
effort: self.effort,
config: self.config,
provider,
identifier: self.identifier,
context: self.context,
memories: self.memories,
agent,
skills_registration,
skills: self.skills,
templates_dirs: self.templates_dirs,
templates: self.templates,
mcp_files: Vec::new(),
mcp_servers: Vec::new(),
declared_tool_files: Vec::new(),
declared_tools: Vec::new(),
declared_registration,
hook_registration,
foreign_tools,
#[cfg(feature = "mcp")]
mcp_connections,
})
}
pub fn mentra_runtime(&self) -> &mentra::Runtime {
if let Some(reuse) = &self.reuse {
reuse.lifecycle.poison();
}
self.runtime.mentra_runtime_internal()
}
fn minted_agent(&self, profile: &RunProfile) -> AgentConfig {
let mut agent = profile.apply_to(self.agent.clone());
let mut foreign = BTreeSet::new();
for name in self
.runtime
.foreign_declared_tools(self.declared_registration.root())
{
agent.tool_profile.hidden_tools.insert(name.clone());
foreign.insert(name);
}
#[cfg(feature = "mcp")]
for descriptor in self.runtime.mentra_runtime_internal().tools() {
let name = &descriptor.provider.name;
if let Some((server, _)) = mentra::mcp::parse_mcp_tool_name(name)
&& !self.mcp_servers.iter().any(|own| own == server)
{
agent.tool_profile.hidden_tools.insert(name.clone());
foreign.insert(name.clone());
}
}
*self
.foreign_tools
.write()
.expect("foreign tool set poisoned") = foreign;
agent
}
fn minted(
&self,
session: Session,
spec: RunSpec,
model: String,
context_snapshot: Option<String>,
reuse_lease: Option<ReuseLease>,
) -> PreparedRun {
let bounds = spec.turn_options();
PreparedRun::new(
session,
RunContext {
workspace: self.root.clone(),
prompt: spec.prompt,
provider: self.provider.clone(),
model,
context: self.context.clone(),
skills_dirs: self.skills_registration.dirs().to_vec(),
skills: self.skills.clone(),
templates_dirs: self.templates_dirs.clone(),
templates: self.templates.clone(),
mcp_files: self.mcp_files.clone(),
mcp_servers: self.mcp_servers.clone(),
},
)
.with_bounds(bounds)
.with_provider_retry(self.runtime.provider_retry())
.with_context_snapshot(context_snapshot)
.with_reuse_lease(reuse_lease)
}
fn reuse_lease(&self) -> Result<Option<ReuseLease>, RunError> {
self.reuse
.as_ref()
.map(|reuse| reuse.lifecycle.lease_run())
.transpose()
}
}
fn validate_reusable_host_tools(
runtime: &mentra::Runtime,
tools: &[Box<dyn crate::tools::ExecutableTool>],
) -> Result<(), RunError> {
let mut names = runtime
.tools()
.into_iter()
.map(|descriptor| descriptor.provider.name)
.collect::<BTreeSet<_>>();
for tool in tools {
let name = tool.descriptor().provider.name;
validate_reusable_host_tool_name(&name)?;
if !names.insert(name.clone()) {
return Err(RunError::HostTool(mentra::tool::ToolNameCollision { name }));
}
}
Ok(())
}
fn validate_reusable_host_tool_name(name: &str) -> Result<(), RunError> {
let reason = if name.is_empty() {
Some("a name cannot be empty")
} else if name.len() > 64 {
Some("a name cannot exceed 64 bytes")
} else if name.starts_with("mcp__") {
Some("the `mcp__` prefix is reserved for MCP bridges")
} else if !name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
Some("a name may contain only ASCII letters, digits, `_`, and `-`")
} else {
None
};
match reason {
Some(reason) => Err(RunError::ReusableHostToolName {
name: name.to_string(),
reason,
}),
None => Ok(()),
}
}
fn validate_model_provider(model: &ModelInfo, runtime_provider: &str) -> Result<(), RunError> {
if model.provider.as_str() == runtime_provider {
return Ok(());
}
Err(RunError::ResolvedModelProviderMismatch {
model: model.id.clone(),
model_provider: model.provider.as_str().to_string(),
runtime_provider: runtime_provider.to_string(),
})
}
fn session_on_resolved_model(session: &Session, model: &ModelInfo) -> bool {
session.metadata().model == model.id
}
fn apply_effort(session: &mut Session, effort: Option<Effort>) -> Result<(), RunError> {
let Some(effort) = effort else {
return Ok(());
};
session.set_reasoning(Some(ReasoningOptions {
effort: Some(effort.into()),
summary: None,
}))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_workspace_can_be_shared_across_tasks() {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<Workspace>();
assert_send_sync::<RunSpec>();
}
#[test]
fn a_freshly_created_session_is_on_its_own_model() {
let mock = mentra::test::MockRuntime::builder()
.model("gpt-5", "openai")
.build()
.expect("mock runtime builds");
let model = mock.model();
let session = mock
.runtime()
.create_session("s", model.clone())
.expect("session");
assert!(session_on_resolved_model(&session, &model));
}
#[test]
fn a_session_on_a_different_model_does_not_match() {
let mock = mentra::test::MockRuntime::builder()
.model("gpt-5", "openai")
.build()
.expect("mock runtime builds");
let session = mock
.runtime()
.create_session("s", mock.model())
.expect("session");
let workspace_model = ModelInfo::new("gpt-6", "openai");
assert!(!session_on_resolved_model(&session, &workspace_model));
}
}