basis 0.9.0

The basis SDK: workspace discovery, run lifecycle, one event stream, and the two seams. No protocol, no transport, no TTY.
Documentation
//! What can go wrong, named once at the root.
//!
//! [`RunError`] is the crate's universal error — opening a workspace,
//! preparing a run, driving one — and it used to live in
//! [`run`](mod@crate::run), where history put it. Four of that module's in-edges
//! (the store, the event mapping, the runtime, the budget) imported nothing
//! from `run` *but* this type, which manufactured four of the crate's import
//! cycles out of one name. At the root, an error is something every module
//! may name without owing the run module anything; `run` re-exports it, so
//! `basis::run::RunError` still reads.

use thiserror::Error;

#[cfg(feature = "mcp")]
use crate::mcp::McpError;
use crate::{context::ContextError, provider::ProviderError};

/// Anything that can go wrong opening a workspace, preparing a run, or driving
/// one.
///
/// One error type across all three, rather than a `WorkspaceError` beside it:
/// opening a workspace exists to prepare runs, and every failure listed here is
/// a failure a caller of [`run`](crate::run()) has always been able to receive.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RunError {
    #[error("prompt is empty")]
    EmptyPrompt,

    /// The shared allowance this turn draws on has nothing left.
    ///
    /// A decision rather than a failure of the work, which is why it is its own
    /// variant: a caller fanning out over a [`BudgetPool`](crate::BudgetPool)
    /// stops minting on this, where it would retry on a provider error. Raised
    /// before the prompt is sent and before the stream opens, so the
    /// conversation is left exactly as it was.
    #[error("the shared token budget is spent: {spent} of {limit} tokens reported")]
    BudgetExhausted { limit: u64, spent: u64 },

    #[error("no session to resume")]
    NoSuchSession,

    /// The directory named for this runtime's conversations holds a basis
    /// ≤0.6 store — mentra's SQLite database — which this build neither links
    /// nor migrates (ADR-0023).
    ///
    /// basis's own words rather than mentra's: the upstream file store
    /// detects the same file and names its `store-sqlite` cargo feature,
    /// which is advice for a mentra embedder, not for the person whose
    /// conversations are in the file. Raised before any file store is opened
    /// in the directory, because an empty store beside the database would
    /// read as every conversation being lost. See
    /// [`store`](crate::store)'s module docs for where the check runs.
    #[error(
        "'{}' holds conversations from basis 0.6 or earlier (runtime.sqlite, a SQLite \
         database); this build persists conversations as plain files and the database is \
         not migrated. To continue an old conversation, use basis 0.6. To start new work \
         here, point the store somewhere fresh (`RuntimeBuilder::with_store_dir`; for the \
         CLI, `BASIS_DATA_DIR`) or move the old store directory aside",
        dir.display()
    )]
    LegacyStore {
        /// The store directory holding the pre-0.7 database.
        dir: std::path::PathBuf,
    },

    #[error(transparent)]
    Config(#[from] crate::config::ConfigError),

    #[error(transparent)]
    Context(#[from] ContextError),

    /// Host-resolved model metadata names a provider other than the runtime's.
    ///
    /// Raised while opening a workspace, applying a per-run profile, or
    /// switching an attached [`PreparedRun`](crate::PreparedRun), before model
    /// catalogue, model request, or tool activity. The mismatch cannot be
    /// repaired by looking up the id: provider identity is part of the host's
    /// resolved contract.
    #[error(
        "resolved model `{model}` belongs to provider `{model_provider}`, but the runtime uses \
         `{runtime_provider}`"
    )]
    ResolvedModelProviderMismatch {
        /// The host-resolved model id.
        model: String,
        /// The provider named by the model metadata.
        model_provider: String,
        /// The provider registered on the runtime.
        runtime_provider: String,
    },

    /// Complete provider request options contain one or more extra headers,
    /// but this runtime can persist its Mentra agent configs.
    ///
    /// Header names and values are deliberately absent: either can itself be
    /// sensitive. Use an explicitly ephemeral runtime for request-scoped
    /// credentials, or configure durable connection credentials on the
    /// provider instead.
    #[error(
        "run profile request headers require a runtime built with \
         RuntimeBuilder::with_ephemeral_history"
    )]
    RunProfileHeadersRequireEphemeralHistory,

    /// A [`RunProfile`](crate::RunProfile) field Mentra cannot change on an
    /// already persisted agent.
    ///
    /// Refused before the session is looked up or resumed, rather than
    /// projecting the supported subset and silently dropping part of the
    /// host's contract. Resolved model metadata and the dedicated reasoning
    /// override are each supported alone through Mentra's exact session
    /// setters; every other field is named here when present.
    #[error("run profile field `{field}` cannot be applied while resuming a session")]
    UnsupportedResumeProfile {
        /// The first unsupported field in deterministic profile order.
        field: &'static str,
    },

    /// A resumed profile model would require separately persisting both model
    /// and reasoning changes, because the profile or an effective legacy
    /// effort also changes reasoning.
    ///
    /// Mentra 0.23 exposes one setter for each but no atomic combined update.
    /// Refused before session lookup so a failed second write can never leave
    /// half of the host's profile in force.
    #[error(
        "a resumed run profile cannot change model and reasoning together; \
         apply only one persisted override"
    )]
    NonAtomicResumeProfile,

    /// Discovery was disabled on a builder borrowing a shared runtime.
    ///
    /// Mentra's runtime-global skill loader can be changed after an `Arc` is
    /// borrowed, and its model-visible descriptions are read on every round.
    /// No one-time inspection can therefore prove that a shared runtime stays
    /// discovery-free. Gate 1a's fresh-only lifecycle fails closed before
    /// runtime acquisition, model resolution, provider requests, workspace
    /// tool registration, or interception; use
    /// [`WorkspaceBuilder::with_runtime_builder`](crate::WorkspaceBuilder::with_runtime_builder)
    /// so opening privately constructs the runtime it owns.
    #[error(
        "discovery-disabled workspaces cannot borrow a shared runtime; supply a fresh private \
         runtime recipe with WorkspaceBuilder::with_runtime_builder"
    )]
    DiscoveryDisabledSharedRuntime,

    /// Fresh-only ownership was requested with a borrowed runtime.
    #[error(
        "fresh-only workspaces cannot borrow a shared runtime; supply a fresh private runtime \
         recipe with WorkspaceBuilder::with_runtime_builder"
    )]
    FreshOnlySharedRuntime,

    /// The workspace's one independent mint/resume attempt was already used.
    #[error(
        "this fresh-only workspace has already attempted its one independent prepare or resume; \
         open a new workspace with a fresh private runtime to try again"
    )]
    FreshOnlyRunAlreadyAttempted,

    /// A runtime builder contains state that has exactly one owner and cannot
    /// honestly be reconstructed for a second runtime.
    ///
    /// Raised by [`RuntimeBuilder::into_reusable_recipe`](crate::RuntimeBuilder::into_reusable_recipe)
    /// before a provider factory is called or a runtime is built. The named
    /// component is deliberately coarse: provider and tool instances may
    /// close over credentials or request state that must not reach an error.
    #[error("a reusable runtime recipe cannot replay one-shot {component}")]
    NonReusableRuntimeComponent {
        /// The one-shot part of the builder (`provider`, `host tools`, or
        /// `history`).
        component: &'static str,
    },

    /// A repeatable provider was configured on a builder consumed through the
    /// synchronous one-shot build path.
    ///
    /// Warming is asynchronous and part of the reusable contract, so silently
    /// skipping it would construct a different runtime than the host asked
    /// for. Convert the builder to a [`RuntimeRecipe`](crate::runtime::RuntimeRecipe)
    /// and let the workspace lifecycle drive it instead.
    #[error("a reusable registered provider requires RuntimeBuilder::into_reusable_recipe")]
    ReusableProviderRequiresRuntimeRecipe,

    /// The host's repeatable provider factory failed before a runtime existed.
    #[error("the reusable runtime provider factory failed: {0}")]
    RuntimeRecipeProviderFactory(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),

    /// A repeatable provider factory returned a provider under a different id
    /// from the immutable id declared by its recipe.
    #[error(
        "the reusable runtime provider factory declared `{declared}` but generated `{generated}`"
    )]
    RuntimeRecipeProviderMismatch {
        /// Provider id fixed when the recipe was created.
        declared: String,
        /// Provider id returned by this generation's factory.
        generated: String,
    },

    /// The host could not warm the provider clone installed in a newly built
    /// runtime; the runtime is dropped before this error is returned.
    #[error("the reusable runtime provider warm-up failed: {0}")]
    RuntimeRecipeProviderWarm(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),

    /// A reusable workspace was opened without the only supported discovery
    /// posture for consume/rebuild.
    #[error("reusable workspaces require WorkspaceBuilder::without_discovery")]
    ReusableWorkspaceRequiresDiscoveryOff,

    /// A reusable workspace was opened without the one-independent-mint gate.
    #[error("reusable workspaces require WorkspaceBuilder::fresh_only")]
    ReusableWorkspaceRequiresFreshOnly,

    /// A reusable workspace was opened with a selector or inherited model
    /// policy instead of complete host-resolved metadata.
    #[error("reusable workspaces require WorkspaceBuilder::with_resolved_model")]
    ReusableWorkspaceRequiresResolvedModel,

    /// A reusable workspace used a deny-list/default roster whose effective
    /// tool set can widen when runtime registrations change.
    #[error("reusable workspaces require an exact ToolRoster::only allow-list")]
    ReusableWorkspaceRequiresExactRoster,

    /// Checkout tools have not yet been explicitly bound to this generation.
    #[error("this reusable workspace generation has not bound its host tools")]
    ReusableWorkspaceToolsUnbound,

    /// Checkout tools were already bound once for this generation.
    #[error("this reusable workspace generation already bound its host tools")]
    ReusableWorkspaceAlreadyBound,

    /// A raw Mentra runtime or session handle escaped this generation.
    #[error("this reusable workspace generation exposed raw Mentra state and cannot be reused")]
    ReusableWorkspaceRawAccess,

    /// Rebuild sealed this generation, so it cannot mint another run.
    #[error("this reusable workspace generation is sealed for rebuild")]
    ReusableWorkspaceSealed,

    /// One or more runs, observer guards, or detached event forwarders still
    /// retain this generation.
    #[error("this reusable workspace generation still has {leases} outstanding lifecycle lease(s)")]
    ReusableWorkspaceOutstanding {
        /// Number of live run-derived leases at the rebuild boundary.
        leases: usize,
    },

    /// The operation requires a workspace opened from a reusable recipe.
    #[error("this workspace was not opened from a reusable runtime recipe")]
    WorkspaceNotReusable,

    /// Basis could not recover unique ownership of the old runtime after all
    /// tracked workspace registrations were dropped.
    #[error("the old reusable runtime still has outstanding Basis owners")]
    ReusableRuntimeNotUnique,

    /// A reusable checkout supplied a host tool name the provider wire cannot
    /// carry safely.
    #[error("`{name}` cannot be a reusable host tool name: {reason}")]
    ReusableHostToolName {
        /// The complete name returned by the tool descriptor.
        name: String,
        /// Which provider-safe name rule it violated.
        reason: &'static str,
    },

    #[error(transparent)]
    Provider(#[from] ProviderError),

    #[error("runtime error: {0}")]
    Runtime(#[from] mentra::error::RuntimeError),

    /// A typed turn answered, but not in the shape that was asked for.
    ///
    /// Separate from [`Runtime`](Self::Runtime) because the two call for
    /// different reactions and basis can tell them apart honestly: this one is
    /// basis's own verdict. The typed path asks mentra for the raw payload and
    /// deserializes it here, so a value that does not fit `T` is a schema or
    /// prompt problem — retry with a clearer schema — while a provider failure
    /// is not. The exchange stays in the session's transcript either way; see
    /// [`PreparedRun::output`](crate::PreparedRun::output), which delivers this
    /// inside an [`OutputFailure`](crate::OutputFailure) so the report the turn
    /// earned comes with it.
    #[error("the run's output did not match the requested type: {0}")]
    OutputMismatch(#[source] serde_json::Error),

    #[error("failed to write an event: {0}")]
    Sink(#[from] std::io::Error),

    #[error("event forwarding task failed: {0}")]
    Forwarder(#[from] tokio::task::JoinError),

    #[error("failed to load skills: {0}")]
    Skills(#[from] mentra::SkillLoadError),

    #[error(transparent)]
    #[cfg(feature = "mcp")]
    Mcp(#[from] McpError),

    #[error("failed to load prompt templates: {0}")]
    Templates(#[from] crate::templates::TemplateError),

    #[error("failed to load memories: {0}")]
    Memory(#[from] crate::memory::MemoryError),

    /// The blocking thread [`WorkspaceBuilder::open`](crate::WorkspaceBuilder::open)
    /// runs memory discovery on (roots, per-file reads, `canonicalize`)
    /// panicked or was cancelled before it returned (whole-wave review, G7).
    ///
    /// Not `#[from]`: [`Forwarder`](Self::Forwarder) already claims
    /// `tokio::task::JoinError` for the event-forwarding task, and thiserror
    /// cannot generate two `From` impls for one source type on one enum — so
    /// this is built by hand at the one call site that needs it.
    #[error("memory discovery failed: {0}")]
    MemoryDiscovery(#[source] tokio::task::JoinError),

    #[error("failed to load hooks: {0}")]
    Hooks(#[from] crate::hooks::HookConfigError),

    #[error("failed to load declared tools: {0}")]
    Tools(#[from] crate::tools::declared::DeclaredToolError),

    /// A command target name that cannot be routed on
    /// ([`RuntimeBuilder::with_command_target`](crate::RuntimeBuilder::with_command_target),
    /// ADR-0021).
    ///
    /// Raised by `build` rather than by a panic at the registering call,
    /// because that is where this builder answers every other piece of bad
    /// input — an unattributed credential is refused by `provider::resolve_with`
    /// at exactly the same moment. A host reading its targets out of its own
    /// configuration can then report a bad name the way it reports every other
    /// bad setting, instead of losing the process to it.
    #[error("`{name}` cannot be a command target name: {reason}")]
    CommandTarget { name: String, reason: String },

    /// A host tool ([`RuntimeBuilder::with_tool`](crate::RuntimeBuilder::with_tool))
    /// whose name collides with one basis already registered — `spawn`, a
    /// mentra builtin, or an earlier host tool on the same builder (decision
    /// D5d).
    ///
    /// mentra's registry is a map and its plain `with_tool` *replaces*, so
    /// without this a host tool named `spawn` would silently take over the
    /// name and inherit every rule an operator ever wrote about commands and
    /// delegation. Raised by `build`, after basis's own registrations exist to
    /// collide against, rather than a silent swap.
    #[error("a host tool could not be registered: {0}")]
    HostTool(#[from] mentra::tool::ToolNameCollision),
}