Skip to main content

falsegreen_agent/
managed.rs

1//! One-call preparation of the pinned model, runtime, and owned private server.
2
3use std::env;
4use std::path::PathBuf;
5use std::time::Duration;
6
7use thiserror::Error;
8
9use crate::artifact::{ArtifactFetcher, DownloadPolicy};
10use crate::hardware::{HardwareProfile, RuntimeBackend};
11use crate::inference::{ModelCapabilities, OpenAiCompatibleProvider, RuntimeProvenance};
12use crate::model::{ModelAcquireError, ModelCacheInspection, ModelInstall, NeoHorseModelManager};
13use crate::runtime::{
14    LLAMA_CPP_COMMIT, LLAMA_CPP_VERSION, LlamaRuntimeManager, RuntimeCacheInspection, RuntimeError,
15    RuntimeInstall,
16};
17use crate::server::{ManagedServerEndpoint, ServerError, ServerLaunchConfig, ServerManager};
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ManagedRuntimeConfig {
21    pub cache_root: PathBuf,
22    pub preferred_backend: Option<RuntimeBackend>,
23    pub download_policy: DownloadPolicy,
24    pub startup_timeout: Duration,
25    pub health_timeout: Duration,
26}
27
28impl ManagedRuntimeConfig {
29    #[must_use]
30    pub fn new(cache_root: impl Into<PathBuf>) -> Self {
31        Self {
32            cache_root: cache_root.into(),
33            preferred_backend: None,
34            download_policy: DownloadPolicy::default(),
35            startup_timeout: Duration::from_secs(10 * 60),
36            health_timeout: Duration::from_secs(2),
37        }
38    }
39
40    /// Resolve the user cache without writing it. `FALSEGREEN_AGENT_CACHE_DIR` is the explicit
41    /// override; platform cache conventions are used otherwise.
42    pub fn discover() -> Result<Self, ManagedRuntimeError> {
43        Ok(Self::new(default_cache_root()?))
44    }
45
46    /// Inspect the selected managed stack without downloading artifacts or starting a process.
47    pub fn inspect(
48        &self,
49        verify_digests: bool,
50    ) -> Result<ManagedRuntimeInspection, ManagedRuntimeError> {
51        if !self.cache_root.is_absolute() {
52            return Err(ManagedRuntimeError::RelativeCacheRoot(
53                self.cache_root.clone(),
54            ));
55        }
56        let hardware = HardwareProfile::detect();
57        let backend = self
58            .preferred_backend
59            .unwrap_or_else(|| hardware.recommended_backend());
60        let fetcher = ArtifactFetcher::new(self.download_policy);
61        let runtime = LlamaRuntimeManager::new(&self.cache_root, fetcher.clone()).inspect_for(
62            hardware.platform,
63            backend,
64            verify_digests,
65        )?;
66        let model = NeoHorseModelManager::new(&self.cache_root, fetcher).inspect(verify_digests)?;
67        let disk_probe = self
68            .cache_root
69            .ancestors()
70            .find(|path| path.exists())
71            .map(std::path::Path::to_path_buf)
72            .unwrap_or_else(|| PathBuf::from("/"));
73        let available_disk_bytes = fs2::available_space(&disk_probe).map_err(|source| {
74            ManagedRuntimeError::CacheInspection {
75                path: disk_probe,
76                source,
77            }
78        })?;
79        Ok(ManagedRuntimeInspection {
80            cache_root: self.cache_root.clone(),
81            hardware,
82            backend,
83            runtime,
84            model,
85            available_disk_bytes,
86        })
87    }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ManagedRuntimeInspection {
92    pub cache_root: PathBuf,
93    pub hardware: HardwareProfile,
94    pub backend: RuntimeBackend,
95    pub runtime: RuntimeCacheInspection,
96    pub model: ModelCacheInspection,
97    pub available_disk_bytes: u64,
98}
99
100#[derive(Debug)]
101pub struct ManagedRuntime {
102    config: ManagedRuntimeConfig,
103    hardware: HardwareProfile,
104    server: ServerManager,
105}
106
107impl ManagedRuntime {
108    #[must_use]
109    pub fn new(config: ManagedRuntimeConfig) -> Self {
110        Self {
111            config,
112            hardware: HardwareProfile::detect(),
113            server: ServerManager::new(),
114        }
115    }
116
117    #[must_use]
118    pub fn with_hardware(mut self, hardware: HardwareProfile) -> Self {
119        self.hardware = hardware;
120        self
121    }
122
123    /// Acquire, verify, and start the pinned local stack. Keeping this value alive owns the server;
124    /// dropping it terminates the child process. The managed distribution is explicitly recorded
125    /// as distinct from the frozen full-stack NeoHorse qualification.
126    pub fn prepare(&mut self) -> Result<PreparedRuntime, ManagedRuntimeError> {
127        if !self.config.cache_root.is_absolute() {
128            return Err(ManagedRuntimeError::RelativeCacheRoot(
129                self.config.cache_root.clone(),
130            ));
131        }
132        let fetcher = ArtifactFetcher::new(self.config.download_policy);
133        let runtime = LlamaRuntimeManager::new(&self.config.cache_root, fetcher.clone())
134            .ensure(&self.hardware, self.config.preferred_backend)?;
135        let model = NeoHorseModelManager::new(&self.config.cache_root, fetcher).ensure()?;
136        let launch_config = server_config(
137            &runtime,
138            &model,
139            &self.hardware,
140            self.config.startup_timeout,
141            self.config.health_timeout,
142        );
143        let endpoint = self.server.ensure_running(launch_config.clone())?;
144        let runtime_provenance = managed_runtime_provenance(
145            &runtime,
146            &model.capabilities,
147            &launch_config.arguments(endpoint.port),
148            &self.hardware,
149        );
150        Ok(PreparedRuntime {
151            endpoint,
152            runtime,
153            model_path: model.path,
154            capabilities: managed_capabilities(model.capabilities),
155            runtime_provenance,
156            hardware: self.hardware.clone(),
157        })
158    }
159
160    pub fn shutdown(&mut self) -> Result<(), ManagedRuntimeError> {
161        self.server.shutdown()?;
162        Ok(())
163    }
164
165    #[must_use]
166    pub fn diagnostics(&self) -> String {
167        self.server.diagnostics()
168    }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct PreparedRuntime {
173    pub endpoint: ManagedServerEndpoint,
174    pub runtime: RuntimeInstall,
175    pub model_path: PathBuf,
176    pub capabilities: ModelCapabilities,
177    pub runtime_provenance: RuntimeProvenance,
178    pub hardware: HardwareProfile,
179}
180
181impl PreparedRuntime {
182    #[must_use]
183    pub fn provider(&self, inference_timeout: Duration) -> OpenAiCompatibleProvider {
184        OpenAiCompatibleProvider::new(
185            &self.endpoint.endpoint,
186            &self.capabilities.identifier,
187            None,
188            inference_timeout,
189        )
190        .with_model_capabilities(self.capabilities.clone())
191        .with_runtime_provenance(self.runtime_provenance.clone())
192    }
193}
194
195#[derive(Debug, Error)]
196pub enum ManagedRuntimeError {
197    #[error(transparent)]
198    Runtime(#[from] RuntimeError),
199    #[error(transparent)]
200    Model(#[from] ModelAcquireError),
201    #[error(transparent)]
202    Server(#[from] ServerError),
203    #[error("managed cache root must be absolute: {0}")]
204    RelativeCacheRoot(PathBuf),
205    #[error("could not determine a user cache directory; set FALSEGREEN_AGENT_CACHE_DIR")]
206    MissingCacheDirectory,
207    #[error("could not inspect managed cache space at {path}: {source}")]
208    CacheInspection {
209        path: PathBuf,
210        #[source]
211        source: std::io::Error,
212    },
213}
214
215fn managed_capabilities(mut capabilities: ModelCapabilities) -> ModelCapabilities {
216    // The GGUF identity is selected and verified, but the frozen `ModelQualification` describes an
217    // Ollama-bundled ROCm 7.2 / RX 7900 XTX stack. Official b10630 distributions are different
218    // runtime artifacts, so retaining that composite qualification would be a false claim.
219    capabilities.qualification = None;
220    capabilities
221}
222
223fn managed_runtime_provenance(
224    runtime: &RuntimeInstall,
225    model: &ModelCapabilities,
226    launch_arguments: &[String],
227    hardware: &HardwareProfile,
228) -> RuntimeProvenance {
229    RuntimeProvenance {
230        runtime: "llama.cpp llama-server".to_owned(),
231        version: LLAMA_CPP_VERSION.to_owned(),
232        commit: LLAMA_CPP_COMMIT.to_owned(),
233        distribution: format!("official GitHub release {}", runtime.release),
234        artifact: runtime.artifact_file_name.clone(),
235        artifact_sha256: Some(runtime.artifact_sha256.clone()),
236        platform: runtime.platform.to_string(),
237        backend: runtime.backend.cache_key().to_owned(),
238        accelerator: hardware.gpus.first().map(|gpu| {
239            format!(
240                "{} device={} architecture={} subsystem={}:{}",
241                gpu.vendor,
242                gpu.device_id.as_deref().unwrap_or("unknown"),
243                gpu.architecture.as_deref().unwrap_or("unknown"),
244                gpu.subsystem_vendor_id.as_deref().unwrap_or("unknown"),
245                gpu.subsystem_device_id.as_deref().unwrap_or("unknown")
246            )
247        }),
248        driver: hardware.graphics_driver.clone(),
249        launch_arguments: launch_arguments.to_vec(),
250        context_tokens: model.context_window_tokens,
251        chat_template: model.chat_template.clone(),
252        mtp_enabled: Some(false),
253        qualified_stack: false,
254        qualification_note: "NeoHorse V1's frozen qualification used the Ollama-bundled ROCm 7.2 backend on an AMD Radeon RX 7900 XTX (gfx1100); this managed distribution is separately pinned but not that qualified stack".to_owned(),
255    }
256}
257
258fn server_config(
259    runtime: &RuntimeInstall,
260    model: &ModelInstall,
261    hardware: &HardwareProfile,
262    startup_timeout: Duration,
263    health_timeout: Duration,
264) -> ServerLaunchConfig {
265    ServerLaunchConfig {
266        executable: runtime.server_executable.clone(),
267        model: model.path.clone(),
268        model_identifier: model.capabilities.identifier.clone(),
269        backend: runtime.backend,
270        context_tokens: model.capabilities.context_window_tokens.unwrap_or(8_192),
271        logical_cpus: hardware.logical_cpus,
272        startup_timeout,
273        health_timeout,
274    }
275}
276
277fn default_cache_root() -> Result<PathBuf, ManagedRuntimeError> {
278    if let Some(path) = env::var_os("FALSEGREEN_AGENT_CACHE_DIR") {
279        let path = PathBuf::from(path);
280        return absolute_cache_path(path);
281    }
282    if cfg!(target_os = "windows") {
283        if let Some(path) = env::var_os("LOCALAPPDATA") {
284            return Ok(PathBuf::from(path).join("falsegreen-agent"));
285        }
286    } else if let Some(path) = env::var_os("XDG_CACHE_HOME") {
287        return Ok(PathBuf::from(path).join("falsegreen-agent"));
288    }
289    let home = env::var_os("HOME")
290        .map(PathBuf::from)
291        .ok_or(ManagedRuntimeError::MissingCacheDirectory)?;
292    if cfg!(target_os = "macos") {
293        Ok(home.join("Library/Caches/falsegreen-agent"))
294    } else {
295        Ok(home.join(".cache/falsegreen-agent"))
296    }
297}
298
299fn absolute_cache_path(path: PathBuf) -> Result<PathBuf, ManagedRuntimeError> {
300    if path.is_absolute() {
301        Ok(path)
302    } else {
303        Err(ManagedRuntimeError::RelativeCacheRoot(path))
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use std::path::PathBuf;
310    use std::time::Duration;
311
312    use super::{
313        ManagedRuntimeConfig, managed_capabilities, managed_runtime_provenance, server_config,
314    };
315    use crate::hardware::{
316        Architecture, HardwareProfile, OperatingSystem, RuntimeBackend, RuntimePlatform,
317    };
318    use crate::inference::{
319        InferenceProvider, ModelCapabilities, ModelQualification, OpenAiCompatibleProvider,
320    };
321    use crate::model::ModelInstall;
322    use crate::runtime::{LLAMA_CPP_COMMIT, LLAMA_CPP_RELEASE, RuntimeInstall};
323
324    #[test]
325    fn server_seam_uses_detected_threads_and_pinned_inputs() {
326        let runtime = RuntimeInstall {
327            root: PathBuf::from("/cache/runtime"),
328            server_executable: PathBuf::from("/cache/runtime/llama-server"),
329            artifact_file_name: "llama-b10630-bin-ubuntu-rocm-7.14-x64.tar.gz".to_owned(),
330            artifact_sha256: "runtime-digest".to_owned(),
331            platform: RuntimePlatform {
332                os: OperatingSystem::Linux,
333                architecture: Architecture::X86_64,
334            },
335            backend: RuntimeBackend::Rocm,
336            release: LLAMA_CPP_RELEASE.to_owned(),
337            commit: LLAMA_CPP_COMMIT.to_owned(),
338            reused: false,
339        };
340        let model = ModelInstall {
341            path: PathBuf::from("/cache/model.gguf"),
342            capabilities: ModelCapabilities {
343                identifier: "neohorse".to_owned(),
344                repository: None,
345                artifact: None,
346                artifact_sha256: None,
347                quantization: None,
348                chat_template: None,
349                context_window_tokens: Some(8_192),
350                native_tools: true,
351                qualification: None,
352            },
353            reused: false,
354        };
355        let hardware = HardwareProfile {
356            platform: runtime.platform,
357            kernel_release: Some("test-kernel".to_owned()),
358            logical_cpus: 24,
359            total_memory_bytes: None,
360            gpus: Vec::new(),
361            rocm_available: true,
362            vulkan_available: true,
363            graphics_driver: Some("amdgpu on kernel test-kernel".to_owned()),
364            diagnostics: Vec::new(),
365        };
366        let config = server_config(
367            &runtime,
368            &model,
369            &hardware,
370            Duration::from_secs(10),
371            Duration::from_secs(1),
372        );
373        assert_eq!(config.logical_cpus, 24);
374        assert_eq!(config.backend, RuntimeBackend::Rocm);
375        assert_eq!(config.context_tokens, 8_192);
376    }
377
378    #[test]
379    fn explicit_cache_configuration_is_deterministic() {
380        let config = ManagedRuntimeConfig::new("/var/cache/falsegreen-test");
381        assert_eq!(
382            config.cache_root,
383            PathBuf::from("/var/cache/falsegreen-test")
384        );
385        assert!(config.preferred_backend.is_none());
386    }
387
388    #[test]
389    fn every_managed_backend_fails_closed_on_frozen_stack_qualification() {
390        let qualified = ModelCapabilities {
391            identifier: "neohorse".to_owned(),
392            repository: None,
393            artifact: None,
394            artifact_sha256: None,
395            quantization: None,
396            chat_template: None,
397            context_window_tokens: Some(8_192),
398            native_tools: true,
399            qualification: Some(ModelQualification {
400                profile_name: "neohorse-v1".to_owned(),
401                revision: "revision".to_owned(),
402                expected_artifact: "model.gguf".to_owned(),
403                runtime: "Ollama-bundled ROCm HIP backend".to_owned(),
404                runtime_version: "ROCm 7.2 / llama-server 0.3.0-dev".to_owned(),
405                runtime_commit: "d222767c7".to_owned(),
406                accelerator: "AMD Radeon RX 7900 XTX".to_owned(),
407                architecture: "gfx1100".to_owned(),
408                mtp_enabled: false,
409                artifact_validated: true,
410            }),
411        };
412        for backend in [
413            RuntimeBackend::Cpu,
414            RuntimeBackend::Metal,
415            RuntimeBackend::Vulkan,
416            RuntimeBackend::Rocm,
417        ] {
418            let runtime = RuntimeInstall {
419                root: PathBuf::from("/cache/runtime"),
420                server_executable: PathBuf::from("/cache/runtime/llama-server"),
421                artifact_file_name: format!("managed-{backend:?}"),
422                artifact_sha256: "runtime-digest".to_owned(),
423                platform: RuntimePlatform {
424                    os: if backend == RuntimeBackend::Metal {
425                        OperatingSystem::Macos
426                    } else {
427                        OperatingSystem::Linux
428                    },
429                    architecture: Architecture::X86_64,
430                },
431                backend,
432                release: LLAMA_CPP_RELEASE.to_owned(),
433                commit: LLAMA_CPP_COMMIT.to_owned(),
434                reused: false,
435            };
436            let capabilities = managed_capabilities(qualified.clone());
437            let hardware = HardwareProfile {
438                platform: runtime.platform,
439                kernel_release: Some("test-kernel".to_owned()),
440                logical_cpus: 8,
441                total_memory_bytes: None,
442                gpus: Vec::new(),
443                rocm_available: backend == RuntimeBackend::Rocm,
444                vulkan_available: backend == RuntimeBackend::Vulkan,
445                graphics_driver: None,
446                diagnostics: Vec::new(),
447            };
448            let provenance = managed_runtime_provenance(&runtime, &capabilities, &[], &hardware);
449            assert!(capabilities.qualification.is_none(), "backend {backend:?}");
450            assert!(!provenance.qualified_stack, "backend {backend:?}");
451            assert!(provenance.qualification_note.contains("ROCm 7.2"));
452            assert_eq!(provenance.backend, backend.cache_key());
453            let provider = OpenAiCompatibleProvider::new(
454                "http://127.0.0.1:1",
455                "neohorse",
456                None,
457                Duration::from_secs(1),
458            )
459            .with_model_capabilities(capabilities)
460            .with_runtime_provenance(provenance.clone());
461            let reported = provider.capabilities();
462            assert!(reported.model.qualification.is_none());
463            assert_eq!(reported.runtime_provenance, Some(provenance));
464        }
465    }
466}