Skip to main content

kernex_runtime/
lib.rs

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