Skip to main content

kernex_runtime/
lib.rs

1//! kernex-runtime: The facade crate that composes all Kernex components.
2#![deny(clippy::unwrap_used, clippy::expect_used)]
3#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
4//!
5//! Provides `Runtime` for configuring and running an AI agent runtime
6//! with sandboxed execution, multi-provider support, persistent memory,
7//! skills, and multi-agent pipeline orchestration.
8//!
9//! # Quick Start
10//!
11//! ```rust,ignore
12//! use kernex_runtime::RuntimeBuilder;
13//! use kernex_core::traits::Provider;
14//! use kernex_core::message::Request;
15//! use kernex_providers::ollama::OllamaProvider;
16//!
17//! #[tokio::main]
18//! async fn main() -> anyhow::Result<()> {
19//!     let runtime = RuntimeBuilder::new()
20//!         .data_dir("~/.my-agent")
21//!         .build()
22//!         .await?;
23//!
24//!     let provider = OllamaProvider::from_config(
25//!         "http://localhost:11434".into(),
26//!         "llama3.2".into(),
27//!         None,
28//!     )?;
29//!
30//!     let request = Request::text("user-1", "Hello!");
31//!     let response = runtime.complete(&provider, &request).await?;
32//!     println!("{}", response.text);
33//!
34//!     Ok(())
35//! }
36//! ```
37
38#[cfg(feature = "sqlite-store")]
39use kernex_core::config::MemoryConfig;
40use kernex_core::context::ContextNeeds;
41use kernex_core::error::KernexError;
42use kernex_core::message::{Request, Response};
43use kernex_core::traits::Provider;
44#[cfg(feature = "sqlite-store")]
45use kernex_memory::Store;
46use kernex_skills::{
47    build_skill_prompt, match_skill_toolboxes, match_skill_triggers, Project, Skill,
48};
49
50/// Re-export sub-crates for convenience.
51pub use kernex_core as core;
52#[cfg(feature = "sqlite-store")]
53pub use kernex_memory as memory;
54pub use kernex_pipelines as pipelines;
55pub use kernex_providers as providers;
56pub use kernex_sandbox as sandbox;
57pub use kernex_skills as skills;
58
59/// A configured Kernex runtime with all subsystems initialized.
60pub struct Runtime {
61    /// Persistent memory store.
62    #[cfg(feature = "sqlite-store")]
63    pub store: Store,
64    /// Loaded skills from the data directory.
65    pub skills: Vec<Skill>,
66    /// Loaded projects from the data directory.
67    pub projects: Vec<Project>,
68    /// Data directory path (expanded).
69    pub data_dir: String,
70    /// Base system prompt prepended to every request.
71    pub system_prompt: String,
72    /// Communication channel identifier (e.g. "cli", "api", "slack").
73    pub channel: String,
74    /// Active project key for scoping memory and lessons.
75    pub project: Option<String>,
76}
77
78impl Runtime {
79    /// Send a request through the full runtime pipeline:
80    /// build context from memory → enrich with skills → complete via provider → save exchange.
81    ///
82    /// This is the high-level convenience method that wires together all
83    /// Kernex subsystems in a single call.
84    pub async fn complete(
85        &self,
86        provider: &dyn Provider,
87        request: &Request,
88    ) -> Result<Response, KernexError> {
89        self.complete_with_needs(provider, request, &ContextNeeds::default())
90            .await
91    }
92
93    /// Like [`complete`](Self::complete), but with explicit control over which
94    /// context blocks are loaded from memory.
95    pub async fn complete_with_needs(
96        &self,
97        provider: &dyn Provider,
98        request: &Request,
99        #[allow(unused_variables)] needs: &ContextNeeds,
100    ) -> Result<Response, KernexError> {
101        let project_ref = self.project.as_deref();
102
103        // Build skill prompt and append to base system prompt.
104        let skill_prompt = build_skill_prompt(&self.skills);
105        let full_system_prompt = if skill_prompt.is_empty() {
106            self.system_prompt.clone()
107        } else if self.system_prompt.is_empty() {
108            skill_prompt
109        } else {
110            format!("{}\n\n{}", self.system_prompt, skill_prompt)
111        };
112
113        // Build context from memory (history, recall, facts, lessons, etc).
114        #[cfg(feature = "sqlite-store")]
115        let mut context = self
116            .store
117            .build_context(
118                &self.channel,
119                request,
120                &full_system_prompt,
121                needs,
122                project_ref,
123            )
124            .await?;
125
126        #[cfg(not(feature = "sqlite-store"))]
127        let mut context = {
128            let mut ctx = kernex_core::context::Context::new(&request.text);
129            ctx.system_prompt = full_system_prompt;
130            ctx
131        };
132
133        // Enrich context with triggered MCP servers.
134        let mcp_servers = match_skill_triggers(&self.skills, &request.text);
135        if !mcp_servers.is_empty() {
136            context.mcp_servers = mcp_servers;
137        }
138
139        // Enrich context with triggered toolboxes.
140        let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
141        if !toolboxes.is_empty() {
142            context.toolboxes = toolboxes;
143        }
144
145        // Send to provider.
146        let response = provider.complete(&context).await?;
147
148        // Persist exchange in memory.
149        #[allow(unused_variables)]
150        let project_key = project_ref.unwrap_or("default");
151
152        #[cfg(feature = "sqlite-store")]
153        self.store
154            .store_exchange(&self.channel, request, &response, project_key)
155            .await?;
156
157        Ok(response)
158    }
159}
160
161/// Builder for constructing a `Runtime` with the desired configuration.
162pub struct RuntimeBuilder {
163    data_dir: String,
164    #[cfg(feature = "sqlite-store")]
165    db_path: Option<String>,
166    system_prompt: String,
167    channel: String,
168    project: Option<String>,
169}
170
171impl RuntimeBuilder {
172    /// Create a new builder with default settings.
173    pub fn new() -> Self {
174        Self {
175            data_dir: "~/.kernex".to_string(),
176            #[cfg(feature = "sqlite-store")]
177            db_path: None,
178            system_prompt: String::new(),
179            channel: "cli".to_string(),
180            project: None,
181        }
182    }
183
184    /// Create a new builder configured from environment variables.
185    ///
186    /// Recognizes:
187    /// - `KERNEX_DATA_DIR`
188    /// - `KERNEX_DB_PATH` (when `sqlite-store` feature is enabled)
189    /// - `KERNEX_SYSTEM_PROMPT`
190    /// - `KERNEX_CHANNEL`
191    /// - `KERNEX_PROJECT`
192    pub fn from_env() -> Self {
193        let mut builder = Self::new();
194
195        if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
196            builder = builder.data_dir(&dir);
197        }
198        #[cfg(feature = "sqlite-store")]
199        if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
200            builder = builder.db_path(&path);
201        }
202        if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
203            builder = builder.system_prompt(&prompt);
204        }
205        if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
206            builder = builder.channel(&channel);
207        }
208        if let Ok(project) = std::env::var("KERNEX_PROJECT") {
209            builder = builder.project(&project);
210        }
211
212        builder
213    }
214
215    /// Set the data directory (default: `~/.kernex`).
216    pub fn data_dir(mut self, path: &str) -> Self {
217        self.data_dir = path.to_string();
218        self
219    }
220
221    /// Set a custom database path (default: `{data_dir}/memory.db`).
222    #[cfg(feature = "sqlite-store")]
223    pub fn db_path(mut self, path: &str) -> Self {
224        self.db_path = Some(path.to_string());
225        self
226    }
227
228    /// Set the base system prompt.
229    pub fn system_prompt(mut self, prompt: &str) -> Self {
230        self.system_prompt = prompt.to_string();
231        self
232    }
233
234    /// Set the channel identifier (default: `"cli"`).
235    pub fn channel(mut self, channel: &str) -> Self {
236        self.channel = channel.to_string();
237        self
238    }
239
240    /// Set the active project for scoping memory.
241    pub fn project(mut self, project: &str) -> Self {
242        self.project = Some(project.to_string());
243        self
244    }
245
246    /// Build and initialize the runtime.
247    pub async fn build(self) -> Result<Runtime, KernexError> {
248        let expanded_dir = kernex_core::shellexpand(&self.data_dir);
249
250        // Ensure data directory exists.
251        tokio::fs::create_dir_all(&expanded_dir)
252            .await
253            .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
254
255        // Initialize store.
256        #[cfg(feature = "sqlite-store")]
257        let store = {
258            let db_path = self
259                .db_path
260                .unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
261            let mem_config = MemoryConfig {
262                db_path: db_path.clone(),
263                ..Default::default()
264            };
265            Store::new(&mem_config).await?
266        };
267
268        // Load skills and projects.
269        let skills = kernex_skills::load_skills(&self.data_dir);
270        let projects = kernex_skills::load_projects(&self.data_dir);
271
272        tracing::info!(
273            "runtime initialized: {} skills, {} projects",
274            skills.len(),
275            projects.len()
276        );
277
278        Ok(Runtime {
279            #[cfg(feature = "sqlite-store")]
280            store,
281            skills,
282            projects,
283            data_dir: expanded_dir,
284            system_prompt: self.system_prompt,
285            channel: self.channel,
286            project: self.project,
287        })
288    }
289}
290
291impl Default for RuntimeBuilder {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300
301    #[tokio::test]
302    async fn test_runtime_builder_creates_runtime() {
303        let tmp = std::env::temp_dir().join("__kernex_test_runtime__");
304        let _ = std::fs::remove_dir_all(&tmp);
305
306        let runtime = RuntimeBuilder::new()
307            .data_dir(tmp.to_str().unwrap())
308            .build()
309            .await
310            .unwrap();
311
312        assert!(runtime.skills.is_empty());
313        assert!(runtime.projects.is_empty());
314        assert!(runtime.system_prompt.is_empty());
315        assert_eq!(runtime.channel, "cli");
316        assert!(runtime.project.is_none());
317        assert!(std::path::Path::new(&runtime.data_dir).exists());
318
319        let _ = std::fs::remove_dir_all(&tmp);
320    }
321
322    #[tokio::test]
323    async fn test_runtime_builder_custom_db_path() {
324        let tmp = std::env::temp_dir().join("__kernex_test_runtime_db__");
325        let _ = std::fs::remove_dir_all(&tmp);
326        std::fs::create_dir_all(&tmp).unwrap();
327
328        let db = tmp.join("custom.db");
329        let runtime = RuntimeBuilder::new()
330            .data_dir(tmp.to_str().unwrap())
331            .db_path(db.to_str().unwrap())
332            .build()
333            .await
334            .unwrap();
335
336        assert!(db.exists());
337        drop(runtime);
338        let _ = std::fs::remove_dir_all(&tmp);
339    }
340
341    #[tokio::test]
342    async fn test_runtime_builder_with_config() {
343        let tmp = std::env::temp_dir().join("__kernex_test_runtime_cfg__");
344        let _ = std::fs::remove_dir_all(&tmp);
345
346        let runtime = RuntimeBuilder::new()
347            .data_dir(tmp.to_str().unwrap())
348            .system_prompt("You are helpful.")
349            .channel("api")
350            .project("my-project")
351            .build()
352            .await
353            .unwrap();
354
355        assert_eq!(runtime.system_prompt, "You are helpful.");
356        assert_eq!(runtime.channel, "api");
357        assert_eq!(runtime.project, Some("my-project".to_string()));
358
359        let _ = std::fs::remove_dir_all(&tmp);
360    }
361}