use crate::approve::TerminalApprover;
use crate::GlobalOpts;
use anyhow::{Context, Result};
use mecha_core::agent::Agent;
use mecha_core::config::SearchBackendConfig;
use mecha_core::config::{Config, PermissionMode};
use mecha_core::mcp::{self, McpClient};
use mecha_core::search::{Exa, SearchBackend, SearchChain, Searxng, Tavily, WebSearch};
use mecha_core::subagent::{Subagent, SubagentProfile};
use mecha_core::tool::{Approver, ModeApprover, Registry, ToolCtx};
use std::path::PathBuf;
use std::sync::Arc;
pub struct Prepared {
pub agent: Agent,
pub provider_name: String,
pub model: String,
pub workspace: PathBuf,
pub config: Config,
pub sandbox: Arc<mecha_core::sandbox::Sandbox>,
pub todo: Option<Arc<mecha_core::tool::todo::TodoTool>>,
pub mailbox: Option<Arc<mecha_core::mailbox::MailboxRoute>>,
pub _mcp: Vec<Arc<McpClient>>,
}
pub struct PreparedTools {
pub registry: Registry,
pub sandbox: Arc<mecha_core::sandbox::Sandbox>,
pub workspace: PathBuf,
pub config: Config,
pub approver: Arc<dyn Approver>,
pub todo: Option<Arc<mecha_core::tool::todo::TodoTool>>,
pub mailbox: Option<Arc<mecha_core::mailbox::MailboxRoute>>,
pub _mcp: Vec<Arc<McpClient>>,
}
pub async fn prepare(opts: &GlobalOpts, interactive: bool) -> Result<Prepared> {
build(prepare_tools(opts, interactive).await?, opts)
}
pub async fn prepare_with_approver(
opts: &GlobalOpts,
approver: Arc<dyn Approver>,
) -> Result<Prepared> {
let mut tools = prepare_tools(opts, true).await?;
if tools.config.tools.permission_mode == PermissionMode::Ask {
tools.approver = approver;
}
build(tools, opts)
}
fn build(tools: PreparedTools, opts: &GlobalOpts) -> Result<Prepared> {
let cfg = tools.config;
let (provider_name, provider_cfg) = cfg.provider(opts.provider.as_deref())?;
let mut provider = mecha_core::provider::build(provider_cfg)?;
let model = opts
.model
.clone()
.or_else(|| provider_cfg.model.clone())
.unwrap_or_else(|| provider.default_model().to_string());
if !provider_cfg.fallbacks.is_empty() && !opts.no_fallback {
let mut fallbacks = Vec::new();
for name in &provider_cfg.fallbacks {
anyhow::ensure!(
name != &provider_name,
"provider {provider_name:?} lists itself as a fallback"
);
let (fb_name, fb_cfg) = cfg.provider(Some(name)).with_context(|| {
format!("fallback {name:?} of provider {provider_name:?} is not configured")
})?;
fallbacks.push((fb_name, mecha_core::provider::build(fb_cfg)?));
}
provider = Box::new(mecha_core::provider::Failover::new(provider, fallbacks));
}
let ctx = ToolCtx {
workspace: tools.workspace.clone(),
shell_timeout: std::time::Duration::from_secs(cfg.tools.shell_timeout_secs),
security: cfg.security.clone(),
output_budget_bytes: cfg
.tools
.resolved_output_budget(provider_cfg.context_window),
..ToolCtx::default()
};
let hooks = mecha_core::hooks::HookSet::from_config(&cfg.hooks)?;
let hooks = (!opts.no_hooks && !hooks.is_empty()).then(|| Arc::new(hooks));
let outbox = if !opts.no_outbox && !cfg.outbox.tools.is_empty() {
let root = match &cfg.outbox.dir {
Some(dir) => dir.clone(),
None => mecha_core::outbox::OutboxStore::default_root()?,
};
let store = mecha_core::outbox::OutboxStore::open(root)?;
Some(Arc::new(mecha_core::outbox::OutboxRoute::new(
store,
cfg.outbox.tools.iter().cloned(),
cfg.outbox.publish_tools.iter().cloned(),
)))
} else {
None
};
let mut registry = tools.registry;
for profile in &cfg.subagents {
{
let excluded = excluded_by_allowlist(&profile.tools, &opts.tools);
if !excluded.is_empty() {
eprintln!(
"mecha: subagent `{}` not registered — `--tool` excludes {}",
profile.name,
excluded.join(", ")
);
continue;
}
}
let (_, child_provider_cfg) = cfg.provider(profile.provider.as_deref())?;
let child = build_subagent(
profile,
®istry,
&cfg,
child_provider_cfg,
&ctx,
hooks.as_ref(),
outbox.as_ref(),
)?;
registry.insert(Arc::new(child));
}
let mut agent = Agent::new(
provider,
registry,
tools.approver,
ctx,
cfg.agent.clone(),
Some(model.clone()),
)?
.with_pricing(provider_cfg.pricing())
.with_context_window(provider_cfg.context_window);
if let Some(hooks) = hooks {
agent.set_hooks(hooks);
}
if let Some(outbox) = outbox {
for name in outbox.routed() {
let narrowed_out =
!excluded_by_allowlist(std::slice::from_ref(&name.to_string()), &opts.tools)
.is_empty();
if agent.registry().get(name).is_none() && !narrowed_out {
eprintln!(
"mecha: [outbox] routes `{name}`, which is not a registered tool — \
check the spelling, or this routing protects nothing"
);
}
}
for name in outbox.publishes() {
if !outbox.routes(name) {
eprintln!(
"mecha: [outbox] calls `{name}` a publish but does not route it — \
add it to `tools`, or it executes unstaged"
);
}
}
agent.set_outbox(outbox);
}
if let Some(mb) = &tools.mailbox {
agent.set_mailbox(Arc::clone(mb));
}
Ok(Prepared {
agent,
provider_name,
model,
workspace: tools.workspace,
config: cfg,
sandbox: tools.sandbox,
todo: tools.todo,
mailbox: tools.mailbox,
_mcp: tools._mcp,
})
}
fn excluded_by_allowlist<'a>(wanted: &'a [String], allowlist: &[String]) -> Vec<&'a str> {
if allowlist.is_empty() {
return Vec::new();
}
wanted
.iter()
.filter(|t| !allowlist.iter().any(|kept| kept == *t))
.map(String::as_str)
.collect()
}
pub fn sandbox_line(sandbox: &mecha_core::sandbox::Sandbox) -> String {
if sandbox.is_enabled() {
format!(
"sandbox: {} · network {} · reads {}",
sandbox.backend().as_str(),
if sandbox.backend() == mecha_core::sandbox::Backend::Landlock {
"reachable (landlock cannot close UDP)"
} else if sandbox.can_reach_network() {
"on"
} else {
"off"
},
if sandbox.reaches_beyond_workspace() {
"beyond the workspace"
} else {
"the workspace only"
}
)
} else {
"sandbox: none — commands run as you, with your credentials".to_string()
}
}
pub async fn prepare_tools(opts: &GlobalOpts, interactive: bool) -> Result<PreparedTools> {
let cwd = std::env::current_dir().context("cannot determine the working directory")?;
let mut cfg = if opts.global_config_only {
Config::load_global()?
} else {
Config::load(&cwd)?
};
if let Some(effort) = opts.effort {
cfg.agent.effort = Some(effort);
}
if let Some(max_turns) = opts.max_turns {
cfg.agent.max_turns = max_turns;
}
if opts.max_output_tokens.is_some() {
cfg.agent.max_output_tokens = opts.max_output_tokens;
}
if opts.max_cost.is_some() {
cfg.agent.max_cost_usd = opts.max_cost;
}
if opts.compact_at.is_some() {
cfg.agent.compact_at_tokens = opts.compact_at;
}
if opts.no_thinking {
cfg.agent.thinking = false;
if matches!(
cfg.agent.effort,
Some(mecha_core::Effort::XHigh) | Some(mecha_core::Effort::Max)
) {
cfg.agent.effort = Some(mecha_core::Effort::High);
}
}
if let Some(system) = &opts.system {
cfg.agent.system_prompt = Some(read_maybe_file(system)?);
cfg.agent.system_prompt_file = None;
}
{
let base = cfg.agent.resolve_system_prompt()?.unwrap_or_default();
let stamp = match cfg.agent.timezone() {
Some(tz) => {
let now = chrono::Utc::now().with_timezone(&tz);
format!(
"Today is {}, and the user's timezone is {tz} (currently {}). \
Give times in that zone unless asked otherwise, and work out \
relative dates (\"next Tuesday\", \"this week\") from today \
rather than guessing.",
now.format("%A, %-d %B %Y"),
now.format("%Z, UTC%:z")
)
}
None => format!(
"Today is {}. Work out relative dates (\"next Tuesday\", \
\"this week\") from it rather than guessing.",
chrono::Local::now().format("%A, %-d %B %Y")
),
};
cfg.agent.system_prompt = Some(if base.is_empty() {
stamp
} else {
format!("{base}\n\n{stamp}")
});
cfg.agent.system_prompt_file = None;
}
if !opts.no_learned_rules {
if let Some(store) = mecha_core::learning::LearningStore::open_existing_default() {
for (domain, n) in store.over_budget_domains().unwrap_or_default() {
eprintln!(
"mecha: learned rules for `{domain}` number {n}, over the cap of {} — \
adherence degrades; `mecha learn` will consolidate before it may add",
mecha_core::learning::MAX_ACTIVE_RULES_PER_DOMAIN
);
}
let routed = mecha_core::learning::routed_domains();
for domain in store.unrouted_domains(&routed).unwrap_or_default() {
eprintln!(
"mecha: rules for `{domain}` are never loaded — nothing carries that \
domain, so they cannot fire. Check the filename, or route it."
);
}
if let Some(block) = store.rules_prompt_block_for(mecha_core::learning::RUN_DOMAINS)? {
let base = cfg.agent.resolve_system_prompt()?.unwrap_or_default();
cfg.agent.system_prompt = Some(if base.is_empty() {
block
} else {
format!("{base}\n\n{block}")
});
cfg.agent.system_prompt_file = None;
}
}
}
if !opts.tools.is_empty() {
cfg.tools.enabled = opts.tools.clone();
}
if opts.yes {
cfg.tools.permission_mode = PermissionMode::Allow;
}
if opts.read_only {
cfg.tools.permission_mode = PermissionMode::ReadOnly;
}
let workspace = opts
.workspace
.clone()
.or_else(|| cfg.tools.workspace.clone())
.unwrap_or(cwd);
let workspace = workspace
.canonicalize()
.with_context(|| format!("workspace {} does not exist", workspace.display()))?;
mecha_core::work::ensure_outside_mecha_home(&workspace)?;
let sandbox = Arc::new(mecha_core::sandbox::Sandbox::new(cfg.sandbox.clone()));
let mut registry = Registry::new().with_builtins(&cfg.tools, Arc::clone(&sandbox));
let todo = registry.get("todo").is_some().then(|| {
let handle = Arc::new(mecha_core::tool::todo::TodoTool::new());
registry.insert(Arc::clone(&handle) as Arc<dyn mecha_core::tool::Tool>);
handle
});
if !cfg.search.is_empty() {
let (chain, errors) = build_search_chain(&cfg.search);
for error in errors {
eprintln!("mecha: search backend unavailable — {error}");
}
if !chain.is_empty() {
let allowed = opts.tools.is_empty() || opts.tools.iter().any(|t| t == "web_search");
if allowed {
registry.insert(Arc::new(WebSearch::new(Arc::new(chain))));
}
}
}
let mut clients = Vec::new();
let wanted: Vec<_> = cfg
.mcp
.iter()
.filter(|c| !opts.no_mcp_servers.iter().any(|n| n == &c.name))
.cloned()
.collect();
if !opts.no_mcp && !wanted.is_empty() {
let (tools, connected, errors) = mcp::connect_all(&wanted, &sandbox, &workspace).await;
for error in errors {
eprintln!("mecha: MCP server unavailable — {error}");
}
for tool in tools {
if opts.tools.is_empty() || opts.tools.iter().any(|t| t == tool.name()) {
if !tool.name().contains("__") && registry.get(tool.name()).is_some() {
anyhow::bail!(
"MCP tool `{}` collides with an already-registered tool; its \
server sets `prefix_tools = false`, which promises distinct \
names. Restore the prefix or rename the tool.",
tool.name()
);
}
registry.insert(tool);
}
}
clients = connected;
}
let mailbox = if !opts.no_messages && cfg.messages.enabled {
let store = mecha_core::mailbox::MailboxStore::from_config(&cfg.messages)?;
use mecha_core::mailbox::InboundPolicy;
let inbound = cfg.messages.inbound.unwrap_or(if opts.global_config_only {
InboundPolicy::Accept
} else {
InboundPolicy::Hold
});
if inbound == InboundPolicy::Refuse {
eprintln!(
"mecha: [messages] inbound = \"refuse\" is not yet implemented and \
behaves as \"hold\" — messages accumulate pending until the cap"
);
}
let deliver = inbound == InboundPolicy::Accept;
let route = Arc::new(mecha_core::mailbox::MailboxRoute::new(store, deliver));
if opts.tools.is_empty() || opts.tools.iter().any(|t| t == "message_send") {
registry.insert(Arc::new(mecha_core::mailbox::MessageSendTool::new(
Arc::clone(&route),
)));
}
Some(route)
} else {
None
};
let approver: Arc<dyn Approver> =
if interactive && cfg.tools.permission_mode == PermissionMode::Ask {
Arc::new(TerminalApprover::default())
} else {
Arc::new(ModeApprover {
mode: cfg.tools.permission_mode,
})
};
let todo = todo.filter(|handle| {
registry.get("todo").is_some_and(|registered| {
std::ptr::eq(
Arc::as_ptr(registered) as *const (),
Arc::as_ptr(handle) as *const (),
)
})
});
if sandbox.is_enabled() && registry.get("shell").is_some() {
sandbox
.preflight(&workspace)
.await
.context("sandbox preflight failed — refusing to run `shell` unconfined")?;
}
Ok(PreparedTools {
registry,
sandbox,
workspace,
config: cfg,
approver,
todo,
mailbox,
_mcp: clients,
})
}
fn build_search_chain(configs: &[SearchBackendConfig]) -> (SearchChain, Vec<String>) {
let mut backends: Vec<Box<dyn SearchBackend>> = Vec::new();
let mut errors = Vec::new();
for cfg in configs.iter().filter(|c| !c.disabled) {
let built: Result<Box<dyn SearchBackend>> = match cfg.kind.as_str() {
"exa" => cfg
.resolve_api_key()
.context("no API key (set api_key_env, e.g. EXA_API_KEY)")
.and_then(|k| {
Ok(Box::new(Exa::new(k, cfg.base_url.clone())?) as Box<dyn SearchBackend>)
}),
"tavily" => cfg
.resolve_api_key()
.context("no API key (set api_key_env, e.g. TAVILY_API_KEY)")
.and_then(|k| {
Ok(Box::new(Tavily::new(k, cfg.base_url.clone())?) as Box<dyn SearchBackend>)
}),
"searxng" => cfg
.base_url
.clone()
.context("searxng needs `base_url` pointing at your instance")
.and_then(|u| Ok(Box::new(Searxng::new(u)?) as Box<dyn SearchBackend>)),
other => Err(anyhow::anyhow!(
"unknown search backend {other:?} (expected: exa, tavily, searxng)"
)),
};
match built {
Ok(b) => backends.push(b),
Err(e) => errors.push(format!("{}: {e}", cfg.kind)),
}
}
(SearchChain::new(backends), errors)
}
fn build_subagent(
profile: &SubagentProfile,
pool: &Registry,
cfg: &Config,
provider_cfg: &mecha_core::config::ProviderConfig,
ctx: &ToolCtx,
hooks: Option<&Arc<mecha_core::hooks::HookSet>>,
outbox: Option<&Arc<mecha_core::outbox::OutboxRoute>>,
) -> Result<Subagent> {
let mut child_registry = Registry::new();
for wanted in &profile.tools {
if wanted == "message_send" {
anyhow::bail!(
"subagent `{}` asks for `message_send`, which is not available to \
subagents: a child cannot stamp inter-agent messages with the \
taint of the context that requested them. Have the parent send \
based on the child's returned answer.",
profile.name
);
}
match pool.get(wanted) {
Some(tool) => child_registry.insert(Arc::clone(tool)),
None => anyhow::bail!(
"subagent `{}` asks for tool `{wanted}`, which is not available. \
Available: {}",
profile.name,
pool.iter().map(|t| t.name()).collect::<Vec<_>>().join(", ")
),
}
}
let mode = match cfg.tools.permission_mode {
PermissionMode::Ask => PermissionMode::ReadOnly,
other => other,
};
let mut child_cfg = cfg.agent.clone();
child_cfg.max_turns = profile.max_turns;
child_cfg.system_prompt = profile.system_prompt.clone();
child_cfg.system_prompt_file = None;
let mut child = Agent::new(
mecha_core::provider::build(provider_cfg)?,
child_registry,
Arc::new(ModeApprover { mode }),
ToolCtx {
workspace: ctx.workspace.clone(),
shell_timeout: ctx.shell_timeout,
security: ctx.security.clone(),
output_budget_bytes: ctx.output_budget_bytes,
..ToolCtx::default()
},
child_cfg,
profile.model.clone().or_else(|| provider_cfg.model.clone()),
)?;
if let Some(hooks) = hooks {
child.set_hooks(Arc::clone(hooks));
}
if let Some(outbox) = outbox {
child.set_outbox(Arc::clone(outbox));
}
Subagent::new(profile.clone(), Arc::new(child))
}
pub fn register_recall(agent: &mut Agent, session: &mecha_core::session::Session) {
agent
.registry_mut()
.insert(Arc::new(mecha_core::tool::recall::Recall::new(
session.path.clone(),
)));
}
pub fn read_maybe_file(value: &str) -> Result<String> {
match value.strip_prefix('@') {
Some(path) => std::fs::read_to_string(path)
.with_context(|| format!("reading system prompt from {path}")),
None => Ok(value.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::excluded_by_allowlist;
#[test]
fn an_absent_allowlist_excludes_nothing_and_a_present_one_excludes_what_it_omits() {
let wanted: Vec<String> = ["web_search", "http_fetch", "todo"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(
excluded_by_allowlist(&wanted, &[]).is_empty(),
"no --tool means every tool, not no tools"
);
let narrow: Vec<String> = vec!["fs_read".into()];
assert_eq!(
excluded_by_allowlist(&wanted, &narrow),
vec!["web_search", "http_fetch", "todo"]
);
let full: Vec<String> = ["web_search", "http_fetch", "todo", "fs_read"]
.iter()
.map(|s| s.to_string())
.collect();
assert!(
excluded_by_allowlist(&wanted, &full).is_empty(),
"an allowlist that covers the profile keeps it"
);
}
}