use std::{
collections::{BTreeMap, HashMap},
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use mentra::{
BuiltinProvider, ModelSelector, ProviderId, RuntimePolicy,
provider_core::{StaticCredentialSource, responses, responses::ResponsesProvider},
};
use crate::{
approval::ApprovalGate, hooks::Interceptor, provider, run::RunError, shell::ShellAccess, store,
tools::SpawnTool,
};
use super::{
Runtime,
dispatch::{DispatchHook, HookDispatch},
environment::EnvironmentExecutor,
};
const SHARED_IDENTIFIER: &str = "basis:runtime";
pub struct RuntimeBuilder {
command_timeout: Option<std::time::Duration>,
provider: Option<BuiltinProvider>,
base_url: Option<String>,
api_key: Option<String>,
model: ModelSelector,
history: Option<History>,
interceptors: Vec<Arc<dyn Interceptor>>,
command_environment: BTreeMap<String, String>,
host_tools:
Vec<Box<dyn FnOnce(mentra::RuntimeBuilder) -> mentra::RuntimeBuilder + Send + Sync>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum History {
Directory(PathBuf),
Ephemeral,
}
impl std::fmt::Debug for RuntimeBuilder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RuntimeBuilder")
.field("provider", &self.provider)
.field("base_url", &self.base_url)
.field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
.field("model", &self.model)
.field("history", &self.history)
.field(
"interceptors",
&self
.interceptors
.iter()
.map(|interceptor| interceptor.name())
.collect::<Vec<_>>(),
)
.field(
"command_environment",
&self.command_environment.keys().collect::<Vec<_>>(),
)
.finish()
}
}
impl Default for RuntimeBuilder {
fn default() -> Self {
Self {
command_timeout: None,
provider: None,
base_url: None,
api_key: None,
model: ModelSelector::NewestAvailable,
history: None,
interceptors: Vec::new(),
command_environment: BTreeMap::new(),
host_tools: Vec::new(),
}
}
}
impl RuntimeBuilder {
pub fn with_provider(self, provider: BuiltinProvider) -> Self {
Self {
provider: Some(provider),
..self
}
}
pub fn with_base_url(self, base_url: impl Into<String>) -> Self {
Self {
base_url: Some(base_url.into()),
..self
}
}
pub fn with_api_key(self, api_key: impl Into<String>) -> Self {
Self {
api_key: Some(api_key.into()),
..self
}
}
pub fn with_model(self, model: ModelSelector) -> Self {
Self { model, ..self }
}
pub fn with_store_dir(self, dir: impl Into<PathBuf>) -> Self {
Self {
history: Some(History::Directory(dir.into())),
..self
}
}
pub fn with_ephemeral_history(self) -> Self {
Self {
history: Some(History::Ephemeral),
..self
}
}
pub fn with_interceptor(self, interceptor: impl Interceptor + 'static) -> Self {
Self {
interceptors: {
let mut interceptors = self.interceptors;
interceptors.push(Arc::new(interceptor));
interceptors
},
..self
}
}
pub fn with_tool<T>(self, tool: T) -> Self
where
T: mentra::tool::ExecutableTool + 'static,
{
Self {
host_tools: {
let mut host_tools = self.host_tools;
host_tools.push(Box::new(move |builder| builder.with_tool(tool)));
host_tools
},
..self
}
}
#[must_use]
pub fn with_command_timeout(self, timeout: std::time::Duration) -> Self {
Self {
command_timeout: Some(timeout),
..self
}
}
pub fn with_command_environment(
mut self,
name: impl Into<String>,
value: impl Into<String>,
) -> Self {
self.command_environment.insert(name.into(), value.into());
self
}
pub fn build(self) -> Result<Runtime, RunError> {
let policy = with_command_patience(shared_policy(), self.command_timeout);
self.build_with(SHARED_IDENTIFIER.to_string(), policy)
}
pub(crate) fn build_for(
self,
workspace: &Path,
shell: ShellAccess,
) -> Result<Runtime, RunError> {
let policy = git_protected(RuntimePolicy::workspace_bounded(workspace), workspace)
.allow_shell_commands(shell.is_granted())
.allow_background_commands(shell.is_granted());
let policy = with_command_patience(policy, self.command_timeout);
self.build_with(store::runtime_identifier(workspace), policy)
}
fn build_with(self, identifier: String, policy: RuntimePolicy) -> Result<Runtime, RunError> {
let choice = provider::resolve_with(
self.provider,
self.base_url.as_deref(),
self.api_key.as_deref(),
)?;
let dispatch = Arc::new(HookDispatch::new(self.interceptors));
let gate = ApprovalGate::new();
let levels = gate.levels();
let builder = mentra::Runtime::builder()
.with_runtime_identifier(identifier)
.with_policy(policy)
.with_tool_authorizer(gate)
.with_tool(SpawnTool::new())
.with_pre_hook(DispatchHook(Arc::clone(&dispatch)));
let builder = self
.host_tools
.into_iter()
.fold(builder, |builder, register| register(builder));
let builder = if self.command_environment.is_empty() {
builder
} else {
builder.with_executor(EnvironmentExecutor::new(self.command_environment))
};
let builder = match &self.history {
Some(History::Directory(dir)) => builder.with_store(store::store_in(dir)),
Some(History::Ephemeral) => builder.with_store(store::volatile()),
None => builder,
};
let mentra = match &choice.base_url {
Some(base_url) => {
builder.with_registered_provider(compatible_provider(base_url, &choice.api_key))
}
None => builder.with_provider(choice.provider, choice.api_key.clone()),
}
.build()?;
Ok(Runtime {
mentra,
provider: choice.provider,
provider_label: ProviderId::from(choice.provider).to_string(),
model: self.model,
dispatch,
levels,
#[cfg(feature = "mcp")]
mcp_claims: Mutex::new(HashMap::new()),
declared_claims: Mutex::new(HashMap::new()),
})
}
}
pub(crate) fn shared_policy() -> RuntimePolicy {
RuntimePolicy::default()
.allow_shell_commands(true)
.allow_background_commands(true)
.with_default_command_timeout(std::time::Duration::from_secs(120))
.with_max_command_timeout(std::time::Duration::from_secs(600))
}
fn with_command_patience(
policy: RuntimePolicy,
timeout: Option<std::time::Duration>,
) -> RuntimePolicy {
match timeout {
None => policy,
Some(timeout) => policy
.with_default_command_timeout(timeout)
.with_max_command_timeout(timeout),
}
}
fn git_protected(policy: RuntimePolicy, workspace: &Path) -> RuntimePolicy {
let git = workspace.join(".git");
policy
.with_denied_write_root(git.join("hooks"))
.with_denied_write_root(git.join("config"))
}
fn compatible_provider(base_url: &str, api_key: &str) -> ResponsesProvider<StaticCredentialSource> {
let mut definition = responses::openai_definition();
definition.base_url = Some(base_url.to_string());
definition.descriptor.display_name = Some(format!("OpenAI-compatible ({base_url})"));
ResponsesProvider::new(definition, StaticCredentialSource::new(api_key))
.without_hybrid_http_previous_response_id()
}
#[cfg(test)]
mod tests;