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::hooks::{HookRunner, NoopHookRunner};
43use kernex_core::message::{Request, Response};
44use kernex_core::permissions::PermissionRules;
45use kernex_core::run::{RunConfig, RunOutcome};
46use kernex_core::traits::Provider;
47#[cfg(feature = "sqlite-store")]
48use kernex_memory::Store;
49use kernex_skills::{
50    build_skill_prompt, match_skill_toolboxes, match_skill_triggers, Project, Skill,
51};
52use std::sync::Arc;
53
54/// Re-export sub-crates for convenience.
55pub use kernex_core as core;
56#[cfg(feature = "sqlite-store")]
57pub use kernex_memory as memory;
58pub use kernex_pipelines as pipelines;
59pub use kernex_providers as providers;
60pub use kernex_sandbox as sandbox;
61pub use kernex_skills as skills;
62
63/// A configured Kernex runtime with all subsystems initialized.
64pub struct Runtime {
65    /// Persistent memory store.
66    #[cfg(feature = "sqlite-store")]
67    pub store: Store,
68    /// Loaded skills from the data directory.
69    pub skills: Vec<Skill>,
70    /// Loaded projects from the data directory.
71    pub projects: Vec<Project>,
72    /// Data directory path (expanded).
73    pub data_dir: String,
74    /// Base system prompt prepended to every request.
75    pub system_prompt: String,
76    /// Communication channel identifier (e.g. "cli", "api", "slack").
77    pub channel: String,
78    /// Active project key for scoping memory and lessons.
79    pub project: Option<String>,
80    /// Hook runner for tool lifecycle events.
81    pub hook_runner: Arc<dyn HookRunner>,
82    /// Declarative allow/deny rules applied before each tool call.
83    pub permission_rules: Option<Arc<PermissionRules>>,
84}
85
86impl Runtime {
87    /// Send a request through the full runtime pipeline:
88    /// build context from memory → enrich with skills → complete via provider → save exchange.
89    ///
90    /// This is the high-level convenience method that wires together all
91    /// Kernex subsystems in a single call.
92    pub async fn complete(
93        &self,
94        provider: &dyn Provider,
95        request: &Request,
96    ) -> Result<Response, KernexError> {
97        self.complete_with_needs(provider, request, &ContextNeeds::default())
98            .await
99    }
100
101    /// Like [`complete`](Self::complete), but with explicit control over which
102    /// context blocks are loaded from memory.
103    pub async fn complete_with_needs(
104        &self,
105        provider: &dyn Provider,
106        request: &Request,
107        #[allow(unused_variables)] needs: &ContextNeeds,
108    ) -> Result<Response, KernexError> {
109        let project_ref = self.project.as_deref();
110
111        // Build skill context (prompt block + optional model override).
112        let skill_ctx = build_skill_prompt(&self.skills);
113        let full_system_prompt = if skill_ctx.prompt.is_empty() {
114            self.system_prompt.clone()
115        } else if self.system_prompt.is_empty() {
116            skill_ctx.prompt.clone()
117        } else {
118            format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
119        };
120
121        // Build context from memory (history, recall, facts, lessons, etc).
122        #[cfg(feature = "sqlite-store")]
123        let mut context = self
124            .store
125            .build_context(
126                &self.channel,
127                request,
128                &full_system_prompt,
129                needs,
130                project_ref,
131                None,
132            )
133            .await?;
134
135        #[cfg(not(feature = "sqlite-store"))]
136        let mut context = {
137            let mut ctx = kernex_core::context::Context::new(&request.text);
138            ctx.system_prompt = full_system_prompt;
139            ctx
140        };
141
142        // Apply skill model override when no model was already set on context.
143        if context.model.is_none() {
144            context.model = skill_ctx.model;
145        }
146
147        // Enrich context with triggered MCP servers.
148        let mcp_servers = match_skill_triggers(&self.skills, &request.text);
149        if !mcp_servers.is_empty() {
150            context.mcp_servers = mcp_servers;
151        }
152
153        // Enrich context with triggered toolboxes.
154        let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
155        if !toolboxes.is_empty() {
156            context.toolboxes = toolboxes;
157        }
158
159        // Wire hooks and permission rules into context.
160        context.hook_runner = Some(self.hook_runner.clone());
161        context.permission_rules = self.permission_rules.clone();
162
163        // Send to provider.
164        let response = provider.complete(&context).await?;
165
166        // Persist exchange in memory.
167        #[allow(unused_variables)]
168        let project_key = project_ref.unwrap_or("default");
169
170        #[cfg(feature = "sqlite-store")]
171        self.store
172            .store_exchange(&self.channel, request, &response, project_key)
173            .await?;
174
175        // Record token usage if the provider reported a count.
176        #[cfg(feature = "sqlite-store")]
177        if let Some(tokens) = response.metadata.tokens_used {
178            let model = response.metadata.model.as_deref().unwrap_or("unknown");
179            let session = response.metadata.session_id.as_deref().unwrap_or("default");
180            if let Err(e) = self
181                .store
182                .record_usage(&request.sender_id, session, tokens, model)
183                .await
184            {
185                tracing::warn!("failed to record token usage: {e}");
186            }
187        }
188
189        Ok(response)
190    }
191
192    /// Run the agent with explicit lifecycle control.
193    ///
194    /// Sets `max_turns` in context so the provider's agentic loop respects it,
195    /// wires the runtime hook runner, calls the provider, fires the `on_stop`
196    /// hook, and wraps the outcome in [`RunOutcome`].
197    pub async fn run(
198        &self,
199        provider: &dyn Provider,
200        request: &Request,
201        config: &RunConfig,
202    ) -> Result<RunOutcome, KernexError> {
203        let needs = ContextNeeds::default();
204        let project_ref = self.project.as_deref();
205
206        let skill_ctx = build_skill_prompt(&self.skills);
207        let full_system_prompt = if skill_ctx.prompt.is_empty() {
208            self.system_prompt.clone()
209        } else if self.system_prompt.is_empty() {
210            skill_ctx.prompt.clone()
211        } else {
212            format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
213        };
214
215        #[cfg(feature = "sqlite-store")]
216        let mut context = self
217            .store
218            .build_context(
219                &self.channel,
220                request,
221                &full_system_prompt,
222                &needs,
223                project_ref,
224                None,
225            )
226            .await?;
227
228        #[cfg(not(feature = "sqlite-store"))]
229        let mut context = {
230            let mut ctx = kernex_core::context::Context::new(&request.text);
231            ctx.system_prompt = full_system_prompt;
232            ctx
233        };
234
235        // Apply skill model override when no model was already set on context.
236        if context.model.is_none() {
237            context.model = skill_ctx.model;
238        }
239
240        let mcp_servers = match_skill_triggers(&self.skills, &request.text);
241        if !mcp_servers.is_empty() {
242            context.mcp_servers = mcp_servers;
243        }
244        let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
245        if !toolboxes.is_empty() {
246            context.toolboxes = toolboxes;
247        }
248
249        // Set max_turns, hooks, and permission rules.
250        context.max_turns = Some(config.max_turns);
251        context.hook_runner = Some(self.hook_runner.clone());
252        context.permission_rules = self.permission_rules.clone();
253
254        let response = provider.complete(&context).await?;
255
256        // Fire on_stop hook.
257        self.hook_runner.on_stop(&response.text).await;
258
259        // Persist exchange.
260        #[allow(unused_variables)]
261        let project_key = project_ref.unwrap_or("default");
262        #[cfg(feature = "sqlite-store")]
263        self.store
264            .store_exchange(&self.channel, request, &response, project_key)
265            .await?;
266
267        // Record token usage if the provider reported a count.
268        #[cfg(feature = "sqlite-store")]
269        if let Some(tokens) = response.metadata.tokens_used {
270            let model = response.metadata.model.as_deref().unwrap_or("unknown");
271            let session = response.metadata.session_id.as_deref().unwrap_or("default");
272            if let Err(e) = self
273                .store
274                .record_usage(&request.sender_id, session, tokens, model)
275                .await
276            {
277                tracing::warn!("failed to record token usage: {e}");
278            }
279        }
280
281        Ok(RunOutcome::EndTurn(response))
282    }
283}
284
285/// Builder for constructing a `Runtime` with the desired configuration.
286pub struct RuntimeBuilder {
287    data_dir: String,
288    #[cfg(feature = "sqlite-store")]
289    db_path: Option<String>,
290    system_prompt: String,
291    channel: String,
292    project: Option<String>,
293    hook_runner: Option<Arc<dyn HookRunner>>,
294    permission_rules: Option<Arc<PermissionRules>>,
295}
296
297impl RuntimeBuilder {
298    /// Create a new builder with default settings.
299    pub fn new() -> Self {
300        Self {
301            data_dir: "~/.kernex".to_string(),
302            #[cfg(feature = "sqlite-store")]
303            db_path: None,
304            system_prompt: String::new(),
305            channel: "cli".to_string(),
306            project: None,
307            hook_runner: None,
308            permission_rules: None,
309        }
310    }
311
312    /// Create a new builder configured from environment variables.
313    ///
314    /// Recognizes:
315    /// - `KERNEX_DATA_DIR`
316    /// - `KERNEX_DB_PATH` (when `sqlite-store` feature is enabled)
317    /// - `KERNEX_SYSTEM_PROMPT`
318    /// - `KERNEX_CHANNEL`
319    /// - `KERNEX_PROJECT`
320    pub fn from_env() -> Self {
321        let mut builder = Self::new();
322
323        if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
324            builder = builder.data_dir(&dir);
325        }
326        #[cfg(feature = "sqlite-store")]
327        if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
328            builder = builder.db_path(&path);
329        }
330        if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
331            builder = builder.system_prompt(&prompt);
332        }
333        if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
334            builder = builder.channel(&channel);
335        }
336        if let Ok(project) = std::env::var("KERNEX_PROJECT") {
337            builder = builder.project(&project);
338        }
339
340        builder
341    }
342
343    /// Set the data directory (default: `~/.kernex`).
344    pub fn data_dir(mut self, path: &str) -> Self {
345        self.data_dir = path.to_string();
346        self
347    }
348
349    /// Set a custom database path (default: `{data_dir}/memory.db`).
350    #[cfg(feature = "sqlite-store")]
351    pub fn db_path(mut self, path: &str) -> Self {
352        self.db_path = Some(path.to_string());
353        self
354    }
355
356    /// Set the base system prompt.
357    pub fn system_prompt(mut self, prompt: &str) -> Self {
358        self.system_prompt = prompt.to_string();
359        self
360    }
361
362    /// Set the channel identifier (default: `"cli"`).
363    pub fn channel(mut self, channel: &str) -> Self {
364        self.channel = channel.to_string();
365        self
366    }
367
368    /// Set the active project for scoping memory.
369    pub fn project(mut self, project: &str) -> Self {
370        self.project = Some(project.to_string());
371        self
372    }
373
374    /// Set a hook runner for tool lifecycle events.
375    pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
376        self.hook_runner = Some(runner);
377        self
378    }
379
380    /// Set declarative allow/deny permission rules for tool calls.
381    pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
382        self.permission_rules = Some(Arc::new(rules));
383        self
384    }
385
386    /// Build and initialize the runtime.
387    pub async fn build(self) -> Result<Runtime, KernexError> {
388        let expanded_dir = kernex_core::shellexpand(&self.data_dir);
389
390        // Ensure data directory exists.
391        tokio::fs::create_dir_all(&expanded_dir)
392            .await
393            .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
394
395        // Initialize store.
396        #[cfg(feature = "sqlite-store")]
397        let store = {
398            let db_path = self
399                .db_path
400                .unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
401            let mem_config = MemoryConfig {
402                db_path: db_path.clone(),
403                ..Default::default()
404            };
405            Store::new(&mem_config).await?
406        };
407
408        // Load skills and projects.
409        let skills = kernex_skills::load_skills(&self.data_dir);
410        let projects = kernex_skills::load_projects(&self.data_dir);
411
412        tracing::info!(
413            "runtime initialized: {} skills, {} projects",
414            skills.len(),
415            projects.len()
416        );
417
418        let hook_runner: Arc<dyn HookRunner> =
419            self.hook_runner.unwrap_or_else(|| Arc::new(NoopHookRunner));
420
421        Ok(Runtime {
422            #[cfg(feature = "sqlite-store")]
423            store,
424            skills,
425            projects,
426            data_dir: expanded_dir,
427            system_prompt: self.system_prompt,
428            channel: self.channel,
429            project: self.project,
430            hook_runner,
431            permission_rules: self.permission_rules,
432        })
433    }
434}
435
436impl Default for RuntimeBuilder {
437    fn default() -> Self {
438        Self::new()
439    }
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[tokio::test]
447    async fn test_runtime_builder_creates_runtime() {
448        let tmp = std::env::temp_dir().join("__kernex_test_runtime__");
449        let _ = std::fs::remove_dir_all(&tmp);
450
451        let runtime = RuntimeBuilder::new()
452            .data_dir(tmp.to_str().unwrap())
453            .build()
454            .await
455            .unwrap();
456
457        assert!(runtime.skills.is_empty());
458        assert!(runtime.projects.is_empty());
459        assert!(runtime.system_prompt.is_empty());
460        assert_eq!(runtime.channel, "cli");
461        assert!(runtime.project.is_none());
462        assert!(std::path::Path::new(&runtime.data_dir).exists());
463
464        let _ = std::fs::remove_dir_all(&tmp);
465    }
466
467    #[tokio::test]
468    async fn test_runtime_builder_custom_db_path() {
469        let tmp = std::env::temp_dir().join("__kernex_test_runtime_db__");
470        let _ = std::fs::remove_dir_all(&tmp);
471        std::fs::create_dir_all(&tmp).unwrap();
472
473        let db = tmp.join("custom.db");
474        let runtime = RuntimeBuilder::new()
475            .data_dir(tmp.to_str().unwrap())
476            .db_path(db.to_str().unwrap())
477            .build()
478            .await
479            .unwrap();
480
481        assert!(db.exists());
482        drop(runtime);
483        let _ = std::fs::remove_dir_all(&tmp);
484    }
485
486    #[tokio::test]
487    async fn test_runtime_builder_with_config() {
488        let tmp = std::env::temp_dir().join("__kernex_test_runtime_cfg__");
489        let _ = std::fs::remove_dir_all(&tmp);
490
491        let runtime = RuntimeBuilder::new()
492            .data_dir(tmp.to_str().unwrap())
493            .system_prompt("You are helpful.")
494            .channel("api")
495            .project("my-project")
496            .build()
497            .await
498            .unwrap();
499
500        assert_eq!(runtime.system_prompt, "You are helpful.");
501        assert_eq!(runtime.channel, "api");
502        assert_eq!(runtime.project, Some("my-project".to_string()));
503
504        let _ = std::fs::remove_dir_all(&tmp);
505    }
506}