mod builder;
mod lifecycle;
mod profile;
mod roster;
mod spec;
use std::path::{Path, PathBuf};
use std::sync::Arc;
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;
#[cfg(feature = "mcp")]
use crate::mcp::connections::McpConnections;
use crate::{
config::Config,
context::WorkspaceContext,
error::RunError,
event::ContextFile,
fingerprint::{self, Snapshot},
memory::Memory,
run::{Effort, LoadedSkill, PreparedRun, RunContext},
runtime::{HookChainHold, Runtime, SessionScope, agents::AgentTools},
skills::SkillRoots,
templates::Template,
tools::{declared::DeclaredTools, host::WorkspaceHostTools},
};
pub struct Workspace {
root: PathBuf,
runtime: Arc<Runtime>,
scope: SessionScope,
mint_posture: MintPosture,
model: ModelInfo,
effort: Option<Effort>,
config: Config,
provider: 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>,
#[allow(dead_code, reason = "held for its Drop")]
declared_registration: DeclaredTools,
host_tools: Vec<String>,
#[allow(dead_code, reason = "held for its Drop")]
host_tool_registration: WorkspaceHostTools,
#[allow(dead_code, reason = "held for its Drop")]
hooks: HookChainHold,
#[cfg(feature = "mcp")]
#[allow(dead_code, reason = "held for its Drop")]
mcp_connections: McpConnections,
}
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)
.field("host_tools", &self.host_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();
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 tools = self.agent_tools(&agent);
let mut session =
self.runtime
.mint(spec.session_name.clone(), model, agent, &self.scope)?;
let row = self
.runtime
.agents()
.record_for_workspace(session.agent_id(), tools);
if !spec.profile.decides_reasoning() {
apply_effort(&mut session, spec.effort.or(self.effort))?;
}
Ok(self
.minted(session, spec, model_id, context_snapshot)
.with_agent_row(row))
}
pub fn resume(
&self,
agent_id: &str,
spec: impl Into<RunSpec>,
) -> Result<PreparedRun, RunError> {
let spec = spec.into();
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 effort = spec.effort.or(self.effort);
let changes_reasoning = 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, &self.root, &self.scope)?;
let row = self
.runtime
.agents()
.record_for_workspace(agent_id, self.resumed_tools(&session));
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()
};
apply_effort(&mut session, effort)?;
let context_snapshot = session.config().system.clone();
Ok(self
.minted(session, spec, model, context_snapshot)
.with_agent_row(row))
}
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 host_tools(&self) -> &[String] {
&self.host_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 mentra_runtime(&self) -> &mentra::Runtime {
self.runtime.mentra_runtime()
}
fn minted_agent(&self, profile: &RunProfile) -> AgentConfig {
let mut agent = profile.apply_to(self.agent.clone());
#[cfg(feature = "mcp")]
agent
.tool_profile
.hidden_tools
.extend(self.runtime.foreign_mcp_tools(&self.mcp_servers));
agent.tool_profile.hidden_tools.extend(
self.runtime
.foreign_native_tools(&self.root, &self.host_tools),
);
agent
}
fn agent_tools(&self, agent: &AgentConfig) -> AgentTools {
AgentTools {
hidden: agent.tool_profile.hidden_tools.clone(),
#[cfg(feature = "mcp")]
mcp_servers: self.mcp_servers.clone(),
host_tools: self.host_tools.clone(),
}
}
fn resumed_tools(&self, session: &Session) -> AgentTools {
let mut hidden = session.config().tool_profile.hidden_tools.clone();
#[cfg(feature = "mcp")]
hidden.extend(self.runtime.foreign_mcp_tools(&self.mcp_servers));
hidden.extend(
self.runtime
.foreign_native_tools(&self.root, &self.host_tools),
);
AgentTools {
hidden,
#[cfg(feature = "mcp")]
mcp_servers: self.mcp_servers.clone(),
host_tools: self.host_tools.clone(),
}
}
fn minted(
&self,
session: Session,
spec: RunSpec,
model: String,
context_snapshot: Option<String>,
) -> 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_retry_policy(self.runtime.retry_policy())
.with_context_snapshot(context_snapshot)
}
}
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));
}
}