Luigit
repositories / smith

smith

There are many coding harnesses - but this one is fast

owned by admin

smith-harness/src/runtime.rs

Raw
//! Runtime assembly: configuration to agent (`SMH-SPEC-SPEC0001`,
//! Providers).
//!
//! One shape serves eval and RPC. Mock providers run scripted responses for
//! offline use; real providers post through the vendor transport with
//! credentials resolved from the environment.

use smith::config::Config;
use smith::error::Result;
use smith::provider::RecordSink;
use smith::provider::StreamFn;
use smith_ai::auth::AuthStore;
use smith_ai::models::ProviderKind;
use smith_ai::transport::{VendorEndpoint, transport};
use smith_core::agent::Agent;
use smith_core::store::open_session;
use smith_core::tools::ToolSession;
use std::path::{Path, PathBuf};

/// Which provider a runtime uses.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RuntimeProvider {
    /// Scripted responses; `smh-mock` in the catalog.
    Mock(Vec<Vec<smith::stream::StreamEvent>>),
    /// OpenAI-compatible endpoint.
    OpenAiCompatible {
        /// Base URL, for example `https://api.openai.com/v1`.
        base_url: String,
        /// Model id.
        model: String,
    },
    /// Anthropic Messages endpoint.
    Anthropic {
        /// Base URL.
        base_url: String,
        /// Model id.
        model: String,
    },
    /// Google Gemini endpoint.
    Google {
        /// Base URL.
        base_url: String,
        /// Model id.
        model: String,
    },
}

impl RuntimeProvider {
    /// The catalog model id this provider serves.
    #[must_use]
    pub fn model(&self) -> &str {
        match self {
            Self::Mock(_) => "mock-smith",
            Self::OpenAiCompatible { model, .. }
            | Self::Anthropic { model, .. }
            | Self::Google { model, .. } => model,
        }
    }

    const fn vendor_kind(&self) -> ProviderKind {
        match self {
            Self::Mock(_) | Self::OpenAiCompatible { .. } => ProviderKind::OpenAiCompatible,
            Self::Anthropic { .. } => ProviderKind::Anthropic,
            Self::Google { .. } => ProviderKind::Google,
        }
    }
}

/// Everything needed to assemble a runtime.
#[derive(Clone, Debug)]
pub struct RuntimeConfig {
    /// Provider selection.
    pub provider: RuntimeProvider,
    /// Working directory for tool effects.
    pub workdir: PathBuf,
    /// Session file path; created when missing.
    pub session_path: PathBuf,
    /// Explicit credential override; the highest-precedence source.
    pub api_key: Option<String>,
    /// Credential store directory; the binary passes the XDG default, tests
    /// an isolated one so a developer's own credentials never leak in.
    pub auth_dir: PathBuf,
}

impl RuntimeConfig {
    /// A mock runtime under `workdir` with a fresh session file.
    #[must_use]
    pub fn mock(workdir: impl Into<PathBuf>, script: Vec<Vec<smith::stream::StreamEvent>>) -> Self {
        let workdir = workdir.into();
        let session_path = workdir.join("session.smh");
        // An auth store beside the session keeps mock runtimes isolated.
        let auth_dir = workdir.join("auth");
        Self {
            provider: RuntimeProvider::Mock(script),
            workdir,
            session_path,
            api_key: None,
            auth_dir,
        }
    }
}

impl RuntimeProvider {
    /// A real provider endpoint with an explicit model; the base URL falls
    /// back to the vendor default.
    #[must_use]
    pub fn real(kind: ProviderKind, base_url: Option<String>, model: String) -> Self {
        let base_url =
            base_url.unwrap_or_else(|| smith_ai::auth::default_base_url(kind).to_string());
        match kind {
            ProviderKind::OpenAiCompatible | ProviderKind::Plugin => {
                Self::OpenAiCompatible { base_url, model }
            }
            ProviderKind::Anthropic => Self::Anthropic { base_url, model },
            ProviderKind::Google => Self::Google { base_url, model },
        }
    }
}

/// Assemble an agent from a configuration, opening or creating its session.
///
/// # Errors
///
/// Returns configuration, session, or credential-resolution failures; a
/// missing credential for a real provider fails here, before any dispatch.
pub fn build_agent(config: &RuntimeConfig) -> Result<Agent> {
    let smith_config = Config::default_valid();
    let opened = open_session(Path::new(&config.session_path), smith_config)?;
    let tools = ToolSession::with_session(config.workdir.clone(), opened.session, opened.writer);
    let (sink, drain) = smith::provider::record_channel();
    let stream = resolve_stream(
        &config.provider,
        config.api_key.clone(),
        &AuthStore::at(&config.auth_dir),
        sink,
    )?;
    Ok(Agent::with_provider_records(
        stream,
        tools,
        config.provider.model().to_string(),
        Some(drain),
    ))
}

fn resolve_stream(
    provider: &RuntimeProvider,
    api_key: Option<String>,
    store: &AuthStore,
    records: RecordSink,
) -> Result<StreamFn> {
    match provider {
        RuntimeProvider::Mock(script) => Ok(crate::mock_stream(script.clone())),
        RuntimeProvider::OpenAiCompatible { base_url, model }
        | RuntimeProvider::Anthropic { base_url, model }
        | RuntimeProvider::Google { base_url, model } => {
            let kind = provider.vendor_kind();
            let api_key = match api_key {
                Some(explicit) => explicit,
                None => resolve_credential(store, kind)?,
            };
            Ok(transport(VendorEndpoint {
                kind,
                base_url: base_url.clone(),
                model: model.clone(),
                api_key,
                executor: crate::http::default_executor(),
                records,
            }))
        }
    }
}

/// Credentials resolve `store` first, then the provider environment
/// variable; a miss names both sources and fails before dispatch.
///
/// # Errors
///
/// Returns an authentication fault naming the provider and every source
/// tried, without network access.
fn resolve_credential(store: &AuthStore, kind: ProviderKind) -> Result<String> {
    match store.load(kind) {
        Ok(Some(secret)) => Ok(secret),
        Ok(None) => {
            let var = smith_ai::auth::credential_env_var(kind);
            match std::env::var(var) {
                Ok(value) if !value.is_empty() => Ok(value),
                _ => Err(smith::error::SmithError::Provider {
                    fault: smith::error::ProviderFault::Authentication {
                        message: format!(
                            "no credential for {} (tried store, env {var})",
                            kind.slug()
                        ),
                    },
                }),
            }
        }
        Err(error) => Err(error),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mock_text_reply;

    #[test]
    fn mock_runtime_completes_and_persists_a_turn() {
        let dir = std::env::temp_dir().join(format!("smith_harness_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap_or(());
        let config = RuntimeConfig::mock(dir.join("work"), vec![mock_text_reply("assembled")]);
        let mut agent = build_agent(&config).unwrap();
        let outcome = agent.run_turn("hello").unwrap();
        assert_eq!(outcome.text, "assembled");
        drop(agent);
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn real_providers_fail_fast_without_credentials() {
        let dir = std::env::temp_dir().join(format!("smith_harness_auth_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap_or(());
        // The credential check reads the environment; a missing variable
        // is the normal fresh-runner state, so the test does not mutate it.
        let config = RuntimeConfig {
            provider: RuntimeProvider::OpenAiCompatible {
                base_url: "https://example.test/v1".to_string(),
                model: "gpt-5.1".to_string(),
            },
            api_key: None,
            ..RuntimeConfig::mock(&dir, Vec::new())
        };
        let Err(err) = build_agent(&config) else {
            unreachable!("no credential must fail assembly");
        };
        assert_eq!(err.code(), "PROVIDER_AUTH");
        let _ = std::fs::remove_dir_all(&dir);
    }

    #[test]
    fn stored_credential_resolves_without_network() {
        let home = std::env::temp_dir().join(format!("smith_harness_store_{}", std::process::id()));
        let _ = std::fs::remove_dir_all(&home);
        let store = AuthStore::at(&home);
        store
            .save(smith_ai::models::ProviderKind::Anthropic, "sk-from-store")
            .unwrap_or(());

        assert_eq!(
            resolve_credential(&store, smith_ai::models::ProviderKind::Anthropic)
                .unwrap_or_default(),
            "sk-from-store"
        );

        let _ = std::fs::remove_dir_all(&home);
    }

    #[test]
    fn real_provider_defaults_base_url_per_vendor() {
        let anthropic = RuntimeProvider::real(
            smith_ai::models::ProviderKind::Anthropic,
            None,
            "m".to_string(),
        );
        let RuntimeProvider::Anthropic { base_url, model } = anthropic else {
            unreachable!("anthropic kind builds the anthropic endpoint");
        };
        assert_eq!(base_url, "https://api.anthropic.com");
        assert_eq!(model, "m");

        let overridden = RuntimeProvider::real(
            smith_ai::models::ProviderKind::Google,
            Some("https://proxy.test".to_string()),
            "m".to_string(),
        );
        let RuntimeProvider::Google { base_url, .. } = overridden else {
            unreachable!("google kind builds the google endpoint");
        };
        assert_eq!(base_url, "https://proxy.test");
    }
}