Skip to main content

kernex_runtime/
lib.rs

1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
2
3//! kernex-runtime: The facade crate that composes all Kernex components.
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
38pub use kernex_adapter_core::{Adapter, AdapterError, AdapterId, AdapterRegistry, Capability};
39
40#[cfg(feature = "opentelemetry")]
41pub mod telemetry;
42
43#[cfg(feature = "sqlite-store")]
44use kernex_core::config::MemoryConfig;
45use kernex_core::context::{CompactionStrategy, Context, ContextNeeds};
46use kernex_core::error::KernexError;
47use kernex_core::guardrails::{GuardrailAction, GuardrailRunner};
48use kernex_core::hooks::{HookRunner, NoopHookRunner};
49use kernex_core::message::{CompletionMeta, Request, Response};
50use kernex_core::permissions::PermissionRules;
51use kernex_core::run::{RunConfig, RunOutcome};
52use kernex_core::stream::StreamEvent;
53use kernex_core::traits::Provider;
54use kernex_core::traits::StreamingProvider;
55use kernex_core::traits::Summarizer;
56#[cfg(feature = "sqlite-store")]
57use kernex_memory::{Store, UsageBreakdown};
58use kernex_skills::{
59    build_skill_prompt, match_skill_toolboxes, match_skill_triggers, Project, Skill,
60};
61use std::sync::Arc;
62
63/// Re-export sub-crates for convenience.
64pub use kernex_core as core;
65#[cfg(feature = "sqlite-store")]
66pub use kernex_memory as memory;
67pub use kernex_pipelines as pipelines;
68pub use kernex_providers as providers;
69pub use kernex_sandbox as sandbox;
70pub use kernex_skills as skills;
71
72/// A configured Kernex runtime with all subsystems initialized.
73pub struct Runtime {
74    /// Persistent memory store.
75    #[cfg(feature = "sqlite-store")]
76    pub store: Store,
77    /// Loaded skills from the data directory.
78    pub skills: Vec<Skill>,
79    /// Loaded projects from the data directory.
80    pub projects: Vec<Project>,
81    /// Data directory path (expanded).
82    pub data_dir: String,
83    /// Base system prompt prepended to every request.
84    pub system_prompt: String,
85    /// Communication channel identifier (e.g. "cli", "api", "slack").
86    pub channel: String,
87    /// Active project key for scoping memory and lessons.
88    pub project: Option<String>,
89    /// Hook runner for tool lifecycle events.
90    pub hook_runner: Arc<dyn HookRunner>,
91    /// Declarative allow/deny rules applied before each tool call.
92    pub permission_rules: Option<Arc<PermissionRules>>,
93    /// Optional guardrail applied to input before provider call and output after.
94    pub guardrail_runner: Option<Arc<dyn GuardrailRunner>>,
95    /// When true, conversations whose history exceeds `max_context_messages`
96    /// have their overflow summarized via the active provider instead of
97    /// silently dropped. See [`RuntimeBuilder::auto_compact`].
98    pub auto_compact: bool,
99}
100
101/// Adapter that lets `Store::build_context` reuse the active provider as a
102/// summarizer when [`Runtime::auto_compact`] is enabled.
103///
104/// Wraps a `&dyn Provider` and answers [`Summarizer::summarize`] by sending a
105/// fixed instruction prompt through the same provider that is handling the
106/// turn. Costs one extra round-trip per overflow event (not per turn). The
107/// summarizer is constructed per-call inside the runtime; it does not persist
108/// state across requests, so there's no risk of summary drift.
109struct ProviderSummarizer<'a> {
110    provider: &'a dyn Provider,
111}
112
113#[async_trait::async_trait]
114impl Summarizer for ProviderSummarizer<'_> {
115    async fn summarize(&self, text: &str) -> Result<String, KernexError> {
116        // Tight, role-stable prompt. The model never sees the original system
117        // prompt (we deliberately call with an empty one) so its output is
118        // a pure summary, not another agent reply.
119        let instruction = format!(
120            "You are a conversation summarizer. Summarize the following \
121             exchange in 200 words or fewer. Focus on: decisions made, files \
122             touched, errors encountered, and unresolved questions. Skip \
123             greetings and small talk. Output the summary only — no preamble.\n\n\
124             ---\n{text}\n---"
125        );
126        let mut ctx = Context::new(&instruction);
127        ctx.system_prompt.clear();
128        let response = self.provider.complete(&ctx).await?;
129        Ok(response.text)
130    }
131}
132
133impl Runtime {
134    /// Return an `Arc<dyn MemoryStore>` over this runtime's composed
135    /// memory store. Downstream consumers (a binary CLI, an HTTP API, an
136    /// MCP shim) can call this to share the runtime's connection pool
137    /// rather than opening a second SQLite connection against the same
138    /// database file.
139    ///
140    /// `Store` already implements `Clone` (its `SqlitePool` is internally
141    /// reference-counted), so cloning here shares the same pool.
142    #[cfg(feature = "sqlite-store")]
143    pub fn store_handle(&self) -> Arc<dyn kernex_memory::MemoryStore> {
144        Arc::new(self.store.clone())
145    }
146
147    /// Send a request through the full runtime pipeline:
148    /// build context from memory → enrich with skills → complete via provider → save exchange.
149    ///
150    /// This is the high-level convenience method that wires together all
151    /// Kernex subsystems in a single call.
152    pub async fn complete(
153        &self,
154        provider: &dyn Provider,
155        request: &Request,
156    ) -> Result<Response, KernexError> {
157        self.complete_with_needs(provider, request, &ContextNeeds::default())
158            .await
159    }
160
161    /// Like [`complete`](Self::complete), but with explicit control over which
162    /// context blocks are loaded from memory.
163    #[tracing::instrument(
164        name = "kernex.complete",
165        skip_all,
166        fields(provider = provider.name(), sender = %request.sender_id)
167    )]
168    pub async fn complete_with_needs(
169        &self,
170        provider: &dyn Provider,
171        request: &Request,
172        #[allow(unused_variables)] needs: &ContextNeeds,
173    ) -> Result<Response, KernexError> {
174        let project_ref = self.project.as_deref();
175
176        // Input guardrail: check (and optionally sanitize) the request text
177        // before it reaches the provider or is stored in memory.
178        let owned_req;
179        let request = if let Some(gr) = &self.guardrail_runner {
180            match gr.check_input(&request.text).await {
181                GuardrailAction::Allow => request,
182                GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
183                GuardrailAction::Sanitize(clean) => {
184                    owned_req = Request {
185                        text: clean,
186                        ..request.clone()
187                    };
188                    &owned_req
189                }
190            }
191        } else {
192            request
193        };
194
195        // Build skill context (prompt block + optional model override).
196        let skill_ctx = build_skill_prompt(&self.skills);
197        let full_system_prompt = if skill_ctx.prompt.is_empty() {
198            self.system_prompt.clone()
199        } else if self.system_prompt.is_empty() {
200            skill_ctx.prompt.clone()
201        } else {
202            format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
203        };
204
205        // Build context from memory (history, recall, facts, lessons, etc).
206        #[cfg(feature = "sqlite-store")]
207        let mut context = {
208            let (effective_needs, summarizer): (
209                std::borrow::Cow<'_, ContextNeeds>,
210                Option<ProviderSummarizer<'_>>,
211            ) = if self.auto_compact {
212                let mut owned = needs.clone();
213                owned.compact = CompactionStrategy::Summarize;
214                (
215                    std::borrow::Cow::Owned(owned),
216                    Some(ProviderSummarizer { provider }),
217                )
218            } else {
219                (std::borrow::Cow::Borrowed(needs), None)
220            };
221            self.store
222                .build_context(
223                    &self.channel,
224                    request,
225                    &full_system_prompt,
226                    &effective_needs,
227                    project_ref,
228                    summarizer.as_ref().map(|s| s as &dyn Summarizer),
229                )
230                .await?
231        };
232
233        #[cfg(not(feature = "sqlite-store"))]
234        let mut context = {
235            let mut ctx = kernex_core::context::Context::new(&request.text);
236            ctx.system_prompt = full_system_prompt;
237            ctx
238        };
239
240        // Apply skill model override when no model was already set on context.
241        if context.model.is_none() {
242            context.model = skill_ctx.model;
243        }
244
245        // Enrich context with triggered MCP servers.
246        let mcp_servers = match_skill_triggers(&self.skills, &request.text);
247        if !mcp_servers.is_empty() {
248            context.mcp_servers = mcp_servers;
249        }
250
251        // Enrich context with triggered toolboxes.
252        let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
253        if !toolboxes.is_empty() {
254            context.toolboxes = toolboxes;
255        }
256
257        // Wire hooks and permission rules into context.
258        context.hook_runner = Some(self.hook_runner.clone());
259        context.permission_rules = self.permission_rules.clone();
260
261        // Send to provider.
262        let raw_response = provider.complete(&context).await?;
263
264        // Output guardrail: check (and optionally sanitize) the response text.
265        let response = if let Some(gr) = &self.guardrail_runner {
266            match gr.check_output(&raw_response.text).await {
267                GuardrailAction::Allow => raw_response,
268                GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
269                GuardrailAction::Sanitize(clean) => Response {
270                    text: clean,
271                    metadata: raw_response.metadata,
272                },
273            }
274        } else {
275            raw_response
276        };
277
278        // Persist exchange in memory.
279        #[allow(unused_variables)]
280        let project_key = project_ref.unwrap_or("default");
281
282        #[cfg(feature = "sqlite-store")]
283        self.store
284            .store_exchange(&self.channel, request, &response, project_key)
285            .await?;
286
287        // Record token usage if the provider reported a count.
288        #[cfg(feature = "sqlite-store")]
289        if let Some(tokens) = response.metadata.tokens_used {
290            let model = response.metadata.model.as_deref().unwrap_or("unknown");
291            let session = response.metadata.session_id.as_deref().unwrap_or("default");
292            let breakdown = UsageBreakdown {
293                input_tokens: response.metadata.input_tokens,
294                output_tokens: response.metadata.output_tokens,
295                cache_read_tokens: response.metadata.cache_read_tokens,
296                cache_creation_tokens: response.metadata.cache_creation_tokens,
297            };
298            if let Err(e) = self
299                .store
300                .record_usage_full(&request.sender_id, session, tokens, model, breakdown)
301                .await
302            {
303                tracing::warn!("failed to record token usage: {e}");
304            }
305        }
306
307        Ok(response)
308    }
309
310    /// Stream a request through the runtime pipeline, returning events as they arrive.
311    ///
312    /// Builds context from memory, enriches with skills, opens a streaming connection
313    /// to the provider, and persists the exchange to memory after the stream completes.
314    /// Returns a channel receiver that yields [`StreamEvent`]s until `Done` or `Error`.
315    pub async fn complete_stream(
316        &self,
317        provider: &dyn StreamingProvider,
318        request: &Request,
319    ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, KernexError> {
320        self.complete_stream_with_needs(provider, request, &ContextNeeds::default())
321            .await
322    }
323
324    /// Like [`complete_stream`](Self::complete_stream), but with explicit control over which
325    /// context blocks are loaded from memory.
326    #[tracing::instrument(
327        name = "kernex.stream",
328        skip_all,
329        fields(provider = provider.name(), sender = %request.sender_id)
330    )]
331    pub async fn complete_stream_with_needs(
332        &self,
333        provider: &dyn StreamingProvider,
334        request: &Request,
335        #[allow(unused_variables)] needs: &ContextNeeds,
336    ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, KernexError> {
337        let project_ref = self.project.as_deref();
338
339        // Input guardrail: check (and optionally sanitize) the request text
340        // before the stream is opened. Block returns early; Sanitize clones the request.
341        let owned_req;
342        let request = if let Some(gr) = &self.guardrail_runner {
343            match gr.check_input(&request.text).await {
344                GuardrailAction::Allow => request,
345                GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
346                GuardrailAction::Sanitize(clean) => {
347                    owned_req = Request {
348                        text: clean,
349                        ..request.clone()
350                    };
351                    &owned_req
352                }
353            }
354        } else {
355            request
356        };
357
358        let skill_ctx = build_skill_prompt(&self.skills);
359        let full_system_prompt = if skill_ctx.prompt.is_empty() {
360            self.system_prompt.clone()
361        } else if self.system_prompt.is_empty() {
362            skill_ctx.prompt.clone()
363        } else {
364            format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
365        };
366
367        #[cfg(feature = "sqlite-store")]
368        let mut context = {
369            let (effective_needs, summarizer): (
370                std::borrow::Cow<'_, ContextNeeds>,
371                Option<ProviderSummarizer<'_>>,
372            ) = if self.auto_compact {
373                let mut owned = needs.clone();
374                owned.compact = CompactionStrategy::Summarize;
375                (
376                    std::borrow::Cow::Owned(owned),
377                    Some(ProviderSummarizer { provider }),
378                )
379            } else {
380                (std::borrow::Cow::Borrowed(needs), None)
381            };
382            self.store
383                .build_context(
384                    &self.channel,
385                    request,
386                    &full_system_prompt,
387                    &effective_needs,
388                    project_ref,
389                    summarizer.as_ref().map(|s| s as &dyn Summarizer),
390                )
391                .await?
392        };
393
394        #[cfg(not(feature = "sqlite-store"))]
395        let mut context = {
396            let mut ctx = kernex_core::context::Context::new(&request.text);
397            ctx.system_prompt = full_system_prompt;
398            ctx
399        };
400
401        if context.model.is_none() {
402            context.model = skill_ctx.model;
403        }
404
405        let mcp_servers = match_skill_triggers(&self.skills, &request.text);
406        if !mcp_servers.is_empty() {
407            context.mcp_servers = mcp_servers;
408        }
409        let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
410        if !toolboxes.is_empty() {
411            context.toolboxes = toolboxes;
412        }
413
414        context.hook_runner = Some(self.hook_runner.clone());
415        context.permission_rules = self.permission_rules.clone();
416
417        // Open streaming connection to provider.
418        let provider_name = provider.name().to_string();
419        let mut upstream = provider.complete_stream(&context).await?;
420
421        // Forwarding channel returned to the caller.
422        let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
423
424        // Background task: forward events and persist exchange when done.
425        #[cfg(feature = "sqlite-store")]
426        let store = self.store.clone();
427        let channel = self.channel.clone();
428        let request_clone = request.clone();
429        #[allow(unused_variables)]
430        let project_key = project_ref.unwrap_or("default").to_string();
431        let guardrail_runner = self.guardrail_runner.clone();
432
433        tokio::spawn(async move {
434            use kernex_core::stream::{StreamAccumulator, StreamEvent as SE};
435            let mut acc = StreamAccumulator::new();
436            let started = std::time::Instant::now();
437
438            while let Some(event) = upstream.recv().await {
439                acc.push(&event);
440                let is_terminal = matches!(event, SE::Done | SE::Error(_));
441                // Best-effort forward; drop silently if receiver was dropped.
442                let _ = tx.send(event).await;
443                if is_terminal {
444                    break;
445                }
446            }
447
448            // Persist accumulated exchange to memory.
449            // Output guardrail runs on the full accumulated text before storage.
450            // The stream has already been forwarded to the caller so the guardrail
451            // only affects what is persisted — it does not modify the streamed tokens.
452            #[cfg(feature = "sqlite-store")]
453            {
454                let elapsed_ms = started.elapsed().as_millis() as u64;
455                let accumulated = acc.into_text();
456                let persisted_text = if let Some(gr) = &guardrail_runner {
457                    match gr.check_output(&accumulated).await {
458                        GuardrailAction::Allow => accumulated,
459                        GuardrailAction::Block(_) => String::new(),
460                        GuardrailAction::Sanitize(clean) => clean,
461                    }
462                } else {
463                    accumulated
464                };
465                let response = Response {
466                    text: persisted_text,
467                    metadata: CompletionMeta {
468                        provider_used: provider_name,
469                        tokens_used: None,
470                        processing_time_ms: elapsed_ms,
471                        model: None,
472                        session_id: None,
473                        ..Default::default()
474                    },
475                };
476                if let Err(e) = store
477                    .store_exchange(&channel, &request_clone, &response, &project_key)
478                    .await
479                {
480                    tracing::warn!("failed to persist streaming exchange: {e}");
481                }
482            }
483            #[cfg(not(feature = "sqlite-store"))]
484            {
485                let _ = acc;
486                let _ = started;
487                let _ = provider_name;
488                let _ = guardrail_runner;
489            }
490        });
491
492        Ok(rx)
493    }
494
495    /// Run the agent with explicit lifecycle control.
496    ///
497    /// Sets `max_turns` in context so the provider's agentic loop respects it,
498    /// wires the runtime hook runner, calls the provider, fires the `on_stop`
499    /// hook, and wraps the outcome in [`RunOutcome`].
500    #[tracing::instrument(
501        name = "kernex.run",
502        skip_all,
503        fields(provider = provider.name(), sender = %request.sender_id, turns = config.max_turns)
504    )]
505    pub async fn run(
506        &self,
507        provider: &dyn Provider,
508        request: &Request,
509        config: &RunConfig,
510    ) -> Result<RunOutcome, KernexError> {
511        let needs = ContextNeeds::default();
512        let project_ref = self.project.as_deref();
513
514        // Input guardrail.
515        let owned_req;
516        let request = if let Some(gr) = &self.guardrail_runner {
517            match gr.check_input(&request.text).await {
518                GuardrailAction::Allow => request,
519                GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
520                GuardrailAction::Sanitize(clean) => {
521                    owned_req = Request {
522                        text: clean,
523                        ..request.clone()
524                    };
525                    &owned_req
526                }
527            }
528        } else {
529            request
530        };
531
532        let skill_ctx = build_skill_prompt(&self.skills);
533        let full_system_prompt = if skill_ctx.prompt.is_empty() {
534            self.system_prompt.clone()
535        } else if self.system_prompt.is_empty() {
536            skill_ctx.prompt.clone()
537        } else {
538            format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
539        };
540
541        #[cfg(feature = "sqlite-store")]
542        let mut context = {
543            let (effective_needs, summarizer): (
544                std::borrow::Cow<'_, ContextNeeds>,
545                Option<ProviderSummarizer<'_>>,
546            ) = if self.auto_compact {
547                let mut owned = needs.clone();
548                owned.compact = CompactionStrategy::Summarize;
549                (
550                    std::borrow::Cow::Owned(owned),
551                    Some(ProviderSummarizer { provider }),
552                )
553            } else {
554                (std::borrow::Cow::Borrowed(&needs), None)
555            };
556            self.store
557                .build_context(
558                    &self.channel,
559                    request,
560                    &full_system_prompt,
561                    &effective_needs,
562                    project_ref,
563                    summarizer.as_ref().map(|s| s as &dyn Summarizer),
564                )
565                .await?
566        };
567
568        #[cfg(not(feature = "sqlite-store"))]
569        let mut context = {
570            let mut ctx = kernex_core::context::Context::new(&request.text);
571            ctx.system_prompt = full_system_prompt;
572            ctx
573        };
574
575        // Apply skill model override when no model was already set on context.
576        if context.model.is_none() {
577            context.model = skill_ctx.model;
578        }
579
580        let mcp_servers = match_skill_triggers(&self.skills, &request.text);
581        if !mcp_servers.is_empty() {
582            context.mcp_servers = mcp_servers;
583        }
584        let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
585        if !toolboxes.is_empty() {
586            context.toolboxes = toolboxes;
587        }
588
589        // Set max_turns, hooks, and permission rules.
590        context.max_turns = Some(config.max_turns);
591        context.hook_runner = Some(self.hook_runner.clone());
592        context.permission_rules = self.permission_rules.clone();
593
594        let raw_response = provider.complete(&context).await?;
595
596        // Output guardrail.
597        let response = if let Some(gr) = &self.guardrail_runner {
598            match gr.check_output(&raw_response.text).await {
599                GuardrailAction::Allow => raw_response,
600                GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
601                GuardrailAction::Sanitize(clean) => Response {
602                    text: clean,
603                    metadata: raw_response.metadata,
604                },
605            }
606        } else {
607            raw_response
608        };
609
610        // Fire on_stop hook.
611        self.hook_runner.on_stop(&response.text).await;
612
613        // Persist exchange.
614        #[allow(unused_variables)]
615        let project_key = project_ref.unwrap_or("default");
616        #[cfg(feature = "sqlite-store")]
617        self.store
618            .store_exchange(&self.channel, request, &response, project_key)
619            .await?;
620
621        // Record token usage if the provider reported a count.
622        #[cfg(feature = "sqlite-store")]
623        if let Some(tokens) = response.metadata.tokens_used {
624            let model = response.metadata.model.as_deref().unwrap_or("unknown");
625            let session = response.metadata.session_id.as_deref().unwrap_or("default");
626            let breakdown = UsageBreakdown {
627                input_tokens: response.metadata.input_tokens,
628                output_tokens: response.metadata.output_tokens,
629                cache_read_tokens: response.metadata.cache_read_tokens,
630                cache_creation_tokens: response.metadata.cache_creation_tokens,
631            };
632            if let Err(e) = self
633                .store
634                .record_usage_full(&request.sender_id, session, tokens, model, breakdown)
635                .await
636            {
637                tracing::warn!("failed to record token usage: {e}");
638            }
639        }
640
641        Ok(RunOutcome::EndTurn(response))
642    }
643}
644
645/// Builder for constructing a `Runtime` with the desired configuration.
646pub struct RuntimeBuilder {
647    data_dir: String,
648    #[cfg(feature = "sqlite-store")]
649    db_path: Option<String>,
650    system_prompt: String,
651    channel: String,
652    project: Option<String>,
653    hook_runner: Option<Arc<dyn HookRunner>>,
654    permission_rules: Option<Arc<PermissionRules>>,
655    guardrail_runner: Option<Arc<dyn GuardrailRunner>>,
656    auto_compact: bool,
657}
658
659impl RuntimeBuilder {
660    /// Create a new builder with default settings.
661    pub fn new() -> Self {
662        Self {
663            data_dir: "~/.kernex".to_string(),
664            #[cfg(feature = "sqlite-store")]
665            db_path: None,
666            system_prompt: String::new(),
667            channel: "cli".to_string(),
668            project: None,
669            hook_runner: None,
670            permission_rules: None,
671            guardrail_runner: None,
672            // Default off for backward compatibility with v0.4.0 callers.
673            // kernex-agent flips it on; future major versions may default it on.
674            auto_compact: false,
675        }
676    }
677
678    /// Create a new builder pre-populated from a declarative agent definition file.
679    ///
680    /// The file format is detected by extension: `.yaml` / `.yml` use YAML
681    /// (requires the `yaml` feature on `kernex-core`); all other extensions
682    /// use TOML. Missing files silently fall back to defaults.
683    ///
684    /// Maps `[runtime]` fields (`data_dir`, `system_prompt`, `channel`,
685    /// `project`) and `[memory]` → `db_path` into the builder. Provider
686    /// selection is left to the caller.
687    ///
688    /// # Example (agent.toml)
689    ///
690    /// ```toml
691    /// [runtime]
692    /// name       = "my-agent"
693    /// data_dir   = "~/.my-agent"
694    /// channel    = "api"
695    /// project    = "acme"
696    /// system_prompt = "You are a helpful coding assistant."
697    ///
698    /// [memory]
699    /// db_path = "~/.my-agent/memory.db"
700    /// ```
701    pub fn from_file(path: &str) -> Result<Self, kernex_core::error::KernexError> {
702        let config = kernex_core::config::load_file(path)?;
703        Ok(Self::from_config(&config))
704    }
705
706    /// Populate a builder from a pre-parsed [`KernexConfig`].
707    ///
708    /// Maps `runtime.{data_dir, system_prompt, channel, project}` and
709    /// `memory.db_path` into builder fields. Provider selection is left
710    /// to the caller.
711    ///
712    /// [`KernexConfig`]: kernex_core::config::KernexConfig
713    pub fn from_config(config: &kernex_core::config::KernexConfig) -> Self {
714        let mut builder = Self::new()
715            .data_dir(&config.runtime.data_dir)
716            .system_prompt(&config.runtime.system_prompt)
717            .channel(&config.runtime.channel);
718
719        if let Some(proj) = &config.runtime.project {
720            builder = builder.project(proj);
721        }
722
723        #[cfg(feature = "sqlite-store")]
724        {
725            builder = builder.db_path(&config.memory.db_path);
726        }
727
728        builder
729    }
730
731    /// Create a new builder configured from environment variables.
732    ///
733    /// Recognizes:
734    /// - `KERNEX_DATA_DIR`
735    /// - `KERNEX_DB_PATH` (when `sqlite-store` feature is enabled)
736    /// - `KERNEX_SYSTEM_PROMPT`
737    /// - `KERNEX_CHANNEL`
738    /// - `KERNEX_PROJECT`
739    pub fn from_env() -> Self {
740        let mut builder = Self::new();
741
742        if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
743            warn_if_data_dir_unusual(&dir);
744            builder = builder.data_dir(&dir);
745        }
746        #[cfg(feature = "sqlite-store")]
747        if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
748            builder = builder.db_path(&path);
749        }
750        if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
751            builder = builder.system_prompt(&prompt);
752        }
753        if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
754            builder = builder.channel(&channel);
755        }
756        if let Ok(project) = std::env::var("KERNEX_PROJECT") {
757            builder = builder.project(&project);
758        }
759
760        builder
761    }
762
763    /// Set the data directory (default: `~/.kernex`).
764    pub fn data_dir(mut self, path: &str) -> Self {
765        self.data_dir = path.to_string();
766        self
767    }
768
769    /// Set a custom database path (default: `{data_dir}/memory.db`).
770    #[cfg(feature = "sqlite-store")]
771    pub fn db_path(mut self, path: &str) -> Self {
772        self.db_path = Some(path.to_string());
773        self
774    }
775
776    /// Set the base system prompt.
777    pub fn system_prompt(mut self, prompt: &str) -> Self {
778        self.system_prompt = prompt.to_string();
779        self
780    }
781
782    /// Set the channel identifier (default: `"cli"`).
783    pub fn channel(mut self, channel: &str) -> Self {
784        self.channel = channel.to_string();
785        self
786    }
787
788    /// Set the active project for scoping memory.
789    pub fn project(mut self, project: &str) -> Self {
790        self.project = Some(project.to_string());
791        self
792    }
793
794    /// Set a hook runner for tool lifecycle events.
795    pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
796        self.hook_runner = Some(runner);
797        self
798    }
799
800    /// Set declarative allow/deny permission rules for tool calls.
801    pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
802        self.permission_rules = Some(Arc::new(rules));
803        self
804    }
805
806    /// Set a guardrail runner that intercepts and filters input/output text.
807    pub fn guardrail_runner(mut self, runner: Arc<dyn GuardrailRunner>) -> Self {
808        self.guardrail_runner = Some(runner);
809        self
810    }
811
812    /// Enable automatic context compaction on long conversations.
813    ///
814    /// When enabled, every call into the runtime that builds context (e.g.
815    /// [`Runtime::complete`], [`Runtime::complete_stream`], [`Runtime::run`])
816    /// will, on overflow past `max_context_messages`, summarize the dropped
817    /// rows via the active provider and prepend the summary to the system
818    /// prompt under `[Earlier conversation summary]`. The summarization
819    /// adds one extra provider round-trip per overflow event (not per turn),
820    /// uses a fixed instruction prompt that never reveals the agent's own
821    /// system prompt, and falls back to the default Drop behavior if the
822    /// provider call fails.
823    ///
824    /// Default is **off** to preserve v0.4.0 behavior. Recommended for any
825    /// long-running interactive session; the default exists only so existing
826    /// callers do not silently change billing characteristics.
827    pub fn auto_compact(mut self, enable: bool) -> Self {
828        self.auto_compact = enable;
829        self
830    }
831
832    /// Build and initialize the runtime.
833    pub async fn build(self) -> Result<Runtime, KernexError> {
834        let expanded_dir = kernex_core::shellexpand(&self.data_dir);
835
836        // Ensure data directory exists.
837        tokio::fs::create_dir_all(&expanded_dir)
838            .await
839            .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
840
841        // Initialize store.
842        #[cfg(feature = "sqlite-store")]
843        let store = {
844            let db_path = self
845                .db_path
846                .unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
847            let mem_config = MemoryConfig {
848                db_path: db_path.clone(),
849                ..Default::default()
850            };
851            Store::new(&mem_config).await?
852        };
853
854        // Load skills and projects. These functions use synchronous std::fs
855        // internally; offload to a blocking thread so we do not stall the
856        // tokio executor on cold start (especially relevant for projects with
857        // large skills/ trees).
858        let skills_data_dir = self.data_dir.clone();
859        let skills =
860            tokio::task::spawn_blocking(move || kernex_skills::load_skills(&skills_data_dir))
861                .await
862                .map_err(|e| {
863                    KernexError::skill(kernex_skills::SkillError::Logic(format!(
864                        "load_skills task failed: {e}"
865                    )))
866                })?;
867        let projects_data_dir = self.data_dir.clone();
868        let projects =
869            tokio::task::spawn_blocking(move || kernex_skills::load_projects(&projects_data_dir))
870                .await
871                .map_err(|e| {
872                    KernexError::skill(kernex_skills::SkillError::Logic(format!(
873                        "load_projects task failed: {e}"
874                    )))
875                })?;
876
877        tracing::info!(
878            "runtime initialized: {} skills, {} projects",
879            skills.len(),
880            projects.len()
881        );
882
883        let hook_runner: Arc<dyn HookRunner> =
884            self.hook_runner.unwrap_or_else(|| Arc::new(NoopHookRunner));
885
886        Ok(Runtime {
887            #[cfg(feature = "sqlite-store")]
888            store,
889            skills,
890            projects,
891            data_dir: expanded_dir,
892            system_prompt: self.system_prompt,
893            channel: self.channel,
894            project: self.project,
895            hook_runner,
896            permission_rules: self.permission_rules,
897            guardrail_runner: self.guardrail_runner,
898            auto_compact: self.auto_compact,
899        })
900    }
901}
902
903impl Default for RuntimeBuilder {
904    fn default() -> Self {
905        Self::new()
906    }
907}
908
909/// Emit a warning when `KERNEX_DATA_DIR` resolves to a path outside the
910/// usual locations. Misconfigured env on a shared host (e.g.
911/// `KERNEX_DATA_DIR=/etc`) means writes happen in places the operator
912/// likely didn't intend; the sandbox's blocklist policy still allows
913/// writes outside the configured data dir, so the agent could end up
914/// writing `/etc/cron.d/` before anyone notices.
915fn warn_if_data_dir_unusual(dir: &str) {
916    // Only act on absolute paths; relative paths are project-scoped and
917    // resolve under cwd, which is always operator-chosen.
918    let path = std::path::Path::new(dir);
919    if !path.is_absolute() {
920        return;
921    }
922    let s = dir;
923    let in_home = std::env::var("HOME")
924        .ok()
925        .map(|h| !h.is_empty() && s.starts_with(&h))
926        .unwrap_or(false);
927    let usual = in_home
928        || s.starts_with("/tmp/")
929        || s.starts_with("/var/")
930        || s.starts_with("/Users/")
931        || s.starts_with("/home/")
932        || s == "/tmp"
933        || s == "/var";
934    if !usual {
935        tracing::warn!(
936            data_dir = %dir,
937            "KERNEX_DATA_DIR resolves outside $HOME / /tmp / /var — \
938             writes may land in unexpected locations"
939        );
940    }
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946
947    #[tokio::test]
948    async fn test_runtime_builder_creates_runtime() {
949        let tmp_dir = tempfile::TempDir::new().unwrap();
950        let tmp = tmp_dir.path();
951
952        let runtime = RuntimeBuilder::new()
953            .data_dir(tmp.to_str().unwrap())
954            .build()
955            .await
956            .unwrap();
957
958        assert!(runtime.skills.is_empty());
959        assert!(runtime.projects.is_empty());
960        assert!(runtime.system_prompt.is_empty());
961        assert_eq!(runtime.channel, "cli");
962        assert!(runtime.project.is_none());
963        assert!(std::path::Path::new(&runtime.data_dir).exists());
964    }
965
966    #[tokio::test]
967    async fn test_runtime_builder_custom_db_path() {
968        let tmp_dir = tempfile::TempDir::new().unwrap();
969        let tmp = tmp_dir.path();
970
971        let db = tmp.join("custom.db");
972        let runtime = RuntimeBuilder::new()
973            .data_dir(tmp.to_str().unwrap())
974            .db_path(db.to_str().unwrap())
975            .build()
976            .await
977            .unwrap();
978
979        assert!(db.exists());
980        drop(runtime);
981    }
982
983    #[tokio::test]
984    async fn test_runtime_builder_with_config() {
985        let tmp_dir = tempfile::TempDir::new().unwrap();
986        let tmp = tmp_dir.path();
987
988        let runtime = RuntimeBuilder::new()
989            .data_dir(tmp.to_str().unwrap())
990            .system_prompt("You are helpful.")
991            .channel("api")
992            .project("my-project")
993            .build()
994            .await
995            .unwrap();
996
997        assert_eq!(runtime.system_prompt, "You are helpful.");
998        assert_eq!(runtime.channel, "api");
999        assert_eq!(runtime.project, Some("my-project".to_string()));
1000    }
1001
1002    #[tokio::test]
1003    async fn test_runtime_builder_from_config() {
1004        use kernex_core::config::{KernexConfig, MemoryConfig, RuntimeConfig};
1005
1006        let tmp_dir = tempfile::TempDir::new().unwrap();
1007        let tmp = tmp_dir.path();
1008
1009        // Override `memory.db_path` so the test does not share
1010        // `~/.kernex/data/memory.db` with sibling tests; that shared file
1011        // races on migration replay across parallel runs (#duplicate-column
1012        // / #UNIQUE-constraint regressions seen in CI).
1013        let cfg = KernexConfig {
1014            runtime: RuntimeConfig {
1015                name: "test-agent".to_string(),
1016                data_dir: tmp.to_str().unwrap().to_string(),
1017                channel: "slack".to_string(),
1018                project: Some("my-proj".to_string()),
1019                system_prompt: "Be concise.".to_string(),
1020                ..RuntimeConfig::default()
1021            },
1022            memory: MemoryConfig {
1023                db_path: tmp.join("memory.db").to_str().unwrap().to_string(),
1024                ..MemoryConfig::default()
1025            },
1026            ..KernexConfig::default()
1027        };
1028
1029        let runtime = RuntimeBuilder::from_config(&cfg).build().await.unwrap();
1030
1031        assert_eq!(runtime.channel, "slack");
1032        assert_eq!(runtime.project, Some("my-proj".to_string()));
1033        assert_eq!(runtime.system_prompt, "Be concise.");
1034    }
1035
1036    #[tokio::test]
1037    async fn test_runtime_builder_from_file_toml() {
1038        use std::io::Write;
1039
1040        let tmp_dir = tempfile::TempDir::new().unwrap();
1041        let tmp = tmp_dir.path();
1042        let escaped = tmp.to_str().unwrap().replace('\\', "\\\\");
1043
1044        // Pin both `data_dir` and `[memory] db_path` inside the TempDir so
1045        // this test does not race against the shared `~/.kernex/data/memory.db`
1046        // default that other parallel tests hit.
1047        let cfg_path = tmp.join("agent.toml");
1048        let mut f = std::fs::File::create(&cfg_path).unwrap();
1049        writeln!(
1050            f,
1051            r#"[runtime]
1052name = "file-agent"
1053data_dir = "{escaped}"
1054channel = "api"
1055project = "file-proj"
1056system_prompt = "From file."
1057
1058[memory]
1059db_path = "{escaped}/memory.db"
1060"#
1061        )
1062        .unwrap();
1063
1064        let runtime = RuntimeBuilder::from_file(cfg_path.to_str().unwrap())
1065            .unwrap()
1066            .build()
1067            .await
1068            .unwrap();
1069
1070        assert_eq!(runtime.channel, "api");
1071        assert_eq!(runtime.project, Some("file-proj".to_string()));
1072        assert_eq!(runtime.system_prompt, "From file.");
1073    }
1074}