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 tokens_used = acc.total_tokens();
456                let stop_reason = acc.usage().and_then(|u| u.stop_reason.clone());
457                let accumulated = acc.into_text();
458                let persisted_text = if let Some(gr) = &guardrail_runner {
459                    match gr.check_output(&accumulated).await {
460                        GuardrailAction::Allow => accumulated,
461                        GuardrailAction::Block(_) => String::new(),
462                        GuardrailAction::Sanitize(clean) => clean,
463                    }
464                } else {
465                    accumulated
466                };
467                let response = Response {
468                    text: persisted_text,
469                    metadata: CompletionMeta {
470                        provider_used: provider_name,
471                        tokens_used,
472                        processing_time_ms: elapsed_ms,
473                        model: None,
474                        session_id: None,
475                        stop_reason,
476                        ..Default::default()
477                    },
478                };
479                if let Err(e) = store
480                    .store_exchange(&channel, &request_clone, &response, &project_key)
481                    .await
482                {
483                    tracing::warn!("failed to persist streaming exchange: {e}");
484                }
485            }
486            #[cfg(not(feature = "sqlite-store"))]
487            {
488                let _ = acc;
489                let _ = started;
490                let _ = provider_name;
491                let _ = guardrail_runner;
492            }
493        });
494
495        Ok(rx)
496    }
497
498    /// Run the agent with explicit lifecycle control.
499    ///
500    /// Sets `max_turns` in context so the provider's agentic loop respects it,
501    /// wires the runtime hook runner, calls the provider, fires the `on_stop`
502    /// hook, and wraps the outcome in [`RunOutcome`].
503    #[tracing::instrument(
504        name = "kernex.run",
505        skip_all,
506        fields(provider = provider.name(), sender = %request.sender_id, turns = config.max_turns)
507    )]
508    pub async fn run(
509        &self,
510        provider: &dyn Provider,
511        request: &Request,
512        config: &RunConfig,
513    ) -> Result<RunOutcome, KernexError> {
514        let needs = ContextNeeds::default();
515        let project_ref = self.project.as_deref();
516
517        // Input guardrail.
518        let owned_req;
519        let request = if let Some(gr) = &self.guardrail_runner {
520            match gr.check_input(&request.text).await {
521                GuardrailAction::Allow => request,
522                GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
523                GuardrailAction::Sanitize(clean) => {
524                    owned_req = Request {
525                        text: clean,
526                        ..request.clone()
527                    };
528                    &owned_req
529                }
530            }
531        } else {
532            request
533        };
534
535        let skill_ctx = build_skill_prompt(&self.skills);
536        let full_system_prompt = if skill_ctx.prompt.is_empty() {
537            self.system_prompt.clone()
538        } else if self.system_prompt.is_empty() {
539            skill_ctx.prompt.clone()
540        } else {
541            format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
542        };
543
544        #[cfg(feature = "sqlite-store")]
545        let mut context = {
546            let (effective_needs, summarizer): (
547                std::borrow::Cow<'_, ContextNeeds>,
548                Option<ProviderSummarizer<'_>>,
549            ) = if self.auto_compact {
550                let mut owned = needs.clone();
551                owned.compact = CompactionStrategy::Summarize;
552                (
553                    std::borrow::Cow::Owned(owned),
554                    Some(ProviderSummarizer { provider }),
555                )
556            } else {
557                (std::borrow::Cow::Borrowed(&needs), None)
558            };
559            self.store
560                .build_context(
561                    &self.channel,
562                    request,
563                    &full_system_prompt,
564                    &effective_needs,
565                    project_ref,
566                    summarizer.as_ref().map(|s| s as &dyn Summarizer),
567                )
568                .await?
569        };
570
571        #[cfg(not(feature = "sqlite-store"))]
572        let mut context = {
573            let mut ctx = kernex_core::context::Context::new(&request.text);
574            ctx.system_prompt = full_system_prompt;
575            ctx
576        };
577
578        // Apply skill model override when no model was already set on context.
579        if context.model.is_none() {
580            context.model = skill_ctx.model;
581        }
582
583        let mcp_servers = match_skill_triggers(&self.skills, &request.text);
584        if !mcp_servers.is_empty() {
585            context.mcp_servers = mcp_servers;
586        }
587        let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
588        if !toolboxes.is_empty() {
589            context.toolboxes = toolboxes;
590        }
591
592        // Set max_turns, hooks, and permission rules.
593        context.max_turns = Some(config.max_turns);
594        context.hook_runner = Some(self.hook_runner.clone());
595        context.permission_rules = self.permission_rules.clone();
596
597        let raw_response = provider.complete(&context).await?;
598
599        // Output guardrail.
600        let response = if let Some(gr) = &self.guardrail_runner {
601            match gr.check_output(&raw_response.text).await {
602                GuardrailAction::Allow => raw_response,
603                GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
604                GuardrailAction::Sanitize(clean) => Response {
605                    text: clean,
606                    metadata: raw_response.metadata,
607                },
608            }
609        } else {
610            raw_response
611        };
612
613        // Record token usage if the provider reported a count. Tokens are
614        // billed even when the run exhausts its turn budget, so this runs
615        // before the max-turns branch below.
616        #[cfg(feature = "sqlite-store")]
617        if let Some(tokens) = response.metadata.tokens_used {
618            let model = response.metadata.model.as_deref().unwrap_or("unknown");
619            let session = response.metadata.session_id.as_deref().unwrap_or("default");
620            let breakdown = UsageBreakdown {
621                input_tokens: response.metadata.input_tokens,
622                output_tokens: response.metadata.output_tokens,
623                cache_read_tokens: response.metadata.cache_read_tokens,
624                cache_creation_tokens: response.metadata.cache_creation_tokens,
625            };
626            if let Err(e) = self
627                .store
628                .record_usage_full(&request.sender_id, session, tokens, model, breakdown)
629                .await
630            {
631                tracing::warn!("failed to record token usage: {e}");
632            }
633        }
634
635        // Turn-budget exhaustion: the provider hit `max_turns` without a final
636        // answer. Surface it as `RunOutcome::MaxTurns` rather than persisting an
637        // empty/synthetic exchange or firing `on_stop` with a non-answer.
638        if response.metadata.stop_reason.as_deref() == Some("max_turns") {
639            return Ok(RunOutcome::MaxTurns);
640        }
641
642        // Fire on_stop hook.
643        self.hook_runner.on_stop(&response.text).await;
644
645        // Persist exchange.
646        #[allow(unused_variables)]
647        let project_key = project_ref.unwrap_or("default");
648        #[cfg(feature = "sqlite-store")]
649        self.store
650            .store_exchange(&self.channel, request, &response, project_key)
651            .await?;
652
653        Ok(RunOutcome::EndTurn(response))
654    }
655}
656
657/// Builder for constructing a `Runtime` with the desired configuration.
658pub struct RuntimeBuilder {
659    data_dir: String,
660    #[cfg(feature = "sqlite-store")]
661    db_path: Option<String>,
662    system_prompt: String,
663    channel: String,
664    project: Option<String>,
665    hook_runner: Option<Arc<dyn HookRunner>>,
666    permission_rules: Option<Arc<PermissionRules>>,
667    guardrail_runner: Option<Arc<dyn GuardrailRunner>>,
668    auto_compact: bool,
669}
670
671impl RuntimeBuilder {
672    /// Create a new builder with default settings.
673    pub fn new() -> Self {
674        Self {
675            data_dir: "~/.kernex".to_string(),
676            #[cfg(feature = "sqlite-store")]
677            db_path: None,
678            system_prompt: String::new(),
679            channel: "cli".to_string(),
680            project: None,
681            hook_runner: None,
682            permission_rules: None,
683            guardrail_runner: None,
684            // Default off for backward compatibility with v0.4.0 callers.
685            // kernex-agent flips it on; future major versions may default it on.
686            auto_compact: false,
687        }
688    }
689
690    /// Create a new builder pre-populated from a declarative agent definition file.
691    ///
692    /// The file format is detected by extension: `.yaml` / `.yml` use YAML
693    /// (requires the `yaml` feature on `kernex-core`); all other extensions
694    /// use TOML. Missing files silently fall back to defaults.
695    ///
696    /// Maps `[runtime]` fields (`data_dir`, `system_prompt`, `channel`,
697    /// `project`) and `[memory]` → `db_path` into the builder. Provider
698    /// selection is left to the caller.
699    ///
700    /// # Example (agent.toml)
701    ///
702    /// ```toml
703    /// [runtime]
704    /// name       = "my-agent"
705    /// data_dir   = "~/.my-agent"
706    /// channel    = "api"
707    /// project    = "acme"
708    /// system_prompt = "You are a helpful coding assistant."
709    ///
710    /// [memory]
711    /// db_path = "~/.my-agent/memory.db"
712    /// ```
713    pub fn from_file(path: &str) -> Result<Self, kernex_core::error::KernexError> {
714        let config = kernex_core::config::load_file(path)?;
715        Ok(Self::from_config(&config))
716    }
717
718    /// Populate a builder from a pre-parsed [`KernexConfig`].
719    ///
720    /// Maps `runtime.{data_dir, system_prompt, channel, project}` and
721    /// `memory.db_path` into builder fields. Provider selection is left
722    /// to the caller.
723    ///
724    /// [`KernexConfig`]: kernex_core::config::KernexConfig
725    pub fn from_config(config: &kernex_core::config::KernexConfig) -> Self {
726        let mut builder = Self::new()
727            .data_dir(&config.runtime.data_dir)
728            .system_prompt(&config.runtime.system_prompt)
729            .channel(&config.runtime.channel);
730
731        if let Some(proj) = &config.runtime.project {
732            builder = builder.project(proj);
733        }
734
735        #[cfg(feature = "sqlite-store")]
736        {
737            builder = builder.db_path(&config.memory.db_path);
738        }
739
740        builder
741    }
742
743    /// Create a new builder configured from environment variables.
744    ///
745    /// Recognizes:
746    /// - `KERNEX_DATA_DIR`
747    /// - `KERNEX_DB_PATH` (when `sqlite-store` feature is enabled)
748    /// - `KERNEX_SYSTEM_PROMPT`
749    /// - `KERNEX_CHANNEL`
750    /// - `KERNEX_PROJECT`
751    pub fn from_env() -> Self {
752        let mut builder = Self::new();
753
754        if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
755            warn_if_data_dir_unusual(&dir);
756            builder = builder.data_dir(&dir);
757        }
758        #[cfg(feature = "sqlite-store")]
759        if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
760            builder = builder.db_path(&path);
761        }
762        if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
763            builder = builder.system_prompt(&prompt);
764        }
765        if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
766            builder = builder.channel(&channel);
767        }
768        if let Ok(project) = std::env::var("KERNEX_PROJECT") {
769            builder = builder.project(&project);
770        }
771
772        builder
773    }
774
775    /// Set the data directory (default: `~/.kernex`).
776    pub fn data_dir(mut self, path: &str) -> Self {
777        self.data_dir = path.to_string();
778        self
779    }
780
781    /// Set a custom database path (default: `{data_dir}/memory.db`).
782    #[cfg(feature = "sqlite-store")]
783    pub fn db_path(mut self, path: &str) -> Self {
784        self.db_path = Some(path.to_string());
785        self
786    }
787
788    /// Set the base system prompt.
789    pub fn system_prompt(mut self, prompt: &str) -> Self {
790        self.system_prompt = prompt.to_string();
791        self
792    }
793
794    /// Set the channel identifier (default: `"cli"`).
795    pub fn channel(mut self, channel: &str) -> Self {
796        self.channel = channel.to_string();
797        self
798    }
799
800    /// Set the active project for scoping memory.
801    pub fn project(mut self, project: &str) -> Self {
802        self.project = Some(project.to_string());
803        self
804    }
805
806    /// Set a hook runner for tool lifecycle events.
807    pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
808        self.hook_runner = Some(runner);
809        self
810    }
811
812    /// Set declarative allow/deny permission rules for tool calls.
813    pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
814        self.permission_rules = Some(Arc::new(rules));
815        self
816    }
817
818    /// Set a guardrail runner that intercepts and filters input/output text.
819    pub fn guardrail_runner(mut self, runner: Arc<dyn GuardrailRunner>) -> Self {
820        self.guardrail_runner = Some(runner);
821        self
822    }
823
824    /// Enable automatic context compaction on long conversations.
825    ///
826    /// When enabled, every call into the runtime that builds context (e.g.
827    /// [`Runtime::complete`], [`Runtime::complete_stream`], [`Runtime::run`])
828    /// will, on overflow past `max_context_messages`, summarize the dropped
829    /// rows via the active provider and prepend the summary to the system
830    /// prompt under `[Earlier conversation summary]`. The summarization
831    /// adds one extra provider round-trip per overflow event (not per turn),
832    /// uses a fixed instruction prompt that never reveals the agent's own
833    /// system prompt, and falls back to the default Drop behavior if the
834    /// provider call fails.
835    ///
836    /// Default is **off** to preserve v0.4.0 behavior. Recommended for any
837    /// long-running interactive session; the default exists only so existing
838    /// callers do not silently change billing characteristics.
839    pub fn auto_compact(mut self, enable: bool) -> Self {
840        self.auto_compact = enable;
841        self
842    }
843
844    /// Build and initialize the runtime.
845    pub async fn build(self) -> Result<Runtime, KernexError> {
846        let expanded_dir = kernex_core::shellexpand(&self.data_dir);
847
848        // Ensure data directory exists.
849        tokio::fs::create_dir_all(&expanded_dir)
850            .await
851            .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
852
853        // Initialize store.
854        #[cfg(feature = "sqlite-store")]
855        let store = {
856            let db_path = self
857                .db_path
858                .unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
859            let mem_config = MemoryConfig {
860                db_path: db_path.clone(),
861                ..Default::default()
862            };
863            Store::new(&mem_config).await?
864        };
865
866        // Load skills and projects. These functions use synchronous std::fs
867        // internally; offload to a blocking thread so we do not stall the
868        // tokio executor on cold start (especially relevant for projects with
869        // large skills/ trees).
870        let skills_data_dir = self.data_dir.clone();
871        let skills =
872            tokio::task::spawn_blocking(move || kernex_skills::load_skills(&skills_data_dir))
873                .await
874                .map_err(|e| {
875                    KernexError::skill(kernex_skills::SkillError::Logic(format!(
876                        "load_skills task failed: {e}"
877                    )))
878                })?;
879        let projects_data_dir = self.data_dir.clone();
880        let projects =
881            tokio::task::spawn_blocking(move || kernex_skills::load_projects(&projects_data_dir))
882                .await
883                .map_err(|e| {
884                    KernexError::skill(kernex_skills::SkillError::Logic(format!(
885                        "load_projects task failed: {e}"
886                    )))
887                })?;
888
889        tracing::info!(
890            "runtime initialized: {} skills, {} projects",
891            skills.len(),
892            projects.len()
893        );
894
895        let hook_runner: Arc<dyn HookRunner> =
896            self.hook_runner.unwrap_or_else(|| Arc::new(NoopHookRunner));
897
898        Ok(Runtime {
899            #[cfg(feature = "sqlite-store")]
900            store,
901            skills,
902            projects,
903            data_dir: expanded_dir,
904            system_prompt: self.system_prompt,
905            channel: self.channel,
906            project: self.project,
907            hook_runner,
908            permission_rules: self.permission_rules,
909            guardrail_runner: self.guardrail_runner,
910            auto_compact: self.auto_compact,
911        })
912    }
913}
914
915impl Default for RuntimeBuilder {
916    fn default() -> Self {
917        Self::new()
918    }
919}
920
921/// Emit a warning when `KERNEX_DATA_DIR` resolves to a path outside the
922/// usual locations. Misconfigured env on a shared host (e.g.
923/// `KERNEX_DATA_DIR=/etc`) means writes happen in places the operator
924/// likely didn't intend; the sandbox's blocklist policy still allows
925/// writes outside the configured data dir, so the agent could end up
926/// writing `/etc/cron.d/` before anyone notices.
927fn warn_if_data_dir_unusual(dir: &str) {
928    // Only act on absolute paths; relative paths are project-scoped and
929    // resolve under cwd, which is always operator-chosen.
930    let path = std::path::Path::new(dir);
931    if !path.is_absolute() {
932        return;
933    }
934    let s = dir;
935    let in_home = std::env::var("HOME")
936        .ok()
937        .map(|h| !h.is_empty() && s.starts_with(&h))
938        .unwrap_or(false);
939    let usual = in_home
940        || s.starts_with("/tmp/")
941        || s.starts_with("/var/")
942        || s.starts_with("/Users/")
943        || s.starts_with("/home/")
944        || s == "/tmp"
945        || s == "/var";
946    if !usual {
947        tracing::warn!(
948            data_dir = %dir,
949            "KERNEX_DATA_DIR resolves outside $HOME / /tmp / /var — \
950             writes may land in unexpected locations"
951        );
952    }
953}
954
955#[cfg(test)]
956mod tests {
957    use super::*;
958
959    #[tokio::test]
960    async fn test_runtime_builder_creates_runtime() {
961        let tmp_dir = tempfile::TempDir::new().unwrap();
962        let tmp = tmp_dir.path();
963
964        let runtime = RuntimeBuilder::new()
965            .data_dir(tmp.to_str().unwrap())
966            .build()
967            .await
968            .unwrap();
969
970        assert!(runtime.skills.is_empty());
971        assert!(runtime.projects.is_empty());
972        assert!(runtime.system_prompt.is_empty());
973        assert_eq!(runtime.channel, "cli");
974        assert!(runtime.project.is_none());
975        assert!(std::path::Path::new(&runtime.data_dir).exists());
976    }
977
978    #[tokio::test]
979    async fn test_runtime_builder_custom_db_path() {
980        let tmp_dir = tempfile::TempDir::new().unwrap();
981        let tmp = tmp_dir.path();
982
983        let db = tmp.join("custom.db");
984        let runtime = RuntimeBuilder::new()
985            .data_dir(tmp.to_str().unwrap())
986            .db_path(db.to_str().unwrap())
987            .build()
988            .await
989            .unwrap();
990
991        assert!(db.exists());
992        drop(runtime);
993    }
994
995    #[tokio::test]
996    async fn test_runtime_builder_with_config() {
997        let tmp_dir = tempfile::TempDir::new().unwrap();
998        let tmp = tmp_dir.path();
999
1000        let runtime = RuntimeBuilder::new()
1001            .data_dir(tmp.to_str().unwrap())
1002            .system_prompt("You are helpful.")
1003            .channel("api")
1004            .project("my-project")
1005            .build()
1006            .await
1007            .unwrap();
1008
1009        assert_eq!(runtime.system_prompt, "You are helpful.");
1010        assert_eq!(runtime.channel, "api");
1011        assert_eq!(runtime.project, Some("my-project".to_string()));
1012    }
1013
1014    #[tokio::test]
1015    async fn test_runtime_builder_from_config() {
1016        use kernex_core::config::{KernexConfig, MemoryConfig, RuntimeConfig};
1017
1018        let tmp_dir = tempfile::TempDir::new().unwrap();
1019        let tmp = tmp_dir.path();
1020
1021        // Override `memory.db_path` so the test does not share
1022        // `~/.kernex/data/memory.db` with sibling tests; that shared file
1023        // races on migration replay across parallel runs (#duplicate-column
1024        // / #UNIQUE-constraint regressions seen in CI).
1025        let cfg = KernexConfig {
1026            runtime: RuntimeConfig {
1027                name: "test-agent".to_string(),
1028                data_dir: tmp.to_str().unwrap().to_string(),
1029                channel: "slack".to_string(),
1030                project: Some("my-proj".to_string()),
1031                system_prompt: "Be concise.".to_string(),
1032                ..RuntimeConfig::default()
1033            },
1034            memory: MemoryConfig {
1035                db_path: tmp.join("memory.db").to_str().unwrap().to_string(),
1036                ..MemoryConfig::default()
1037            },
1038            ..KernexConfig::default()
1039        };
1040
1041        let runtime = RuntimeBuilder::from_config(&cfg).build().await.unwrap();
1042
1043        assert_eq!(runtime.channel, "slack");
1044        assert_eq!(runtime.project, Some("my-proj".to_string()));
1045        assert_eq!(runtime.system_prompt, "Be concise.");
1046    }
1047
1048    #[tokio::test]
1049    async fn test_runtime_builder_from_file_toml() {
1050        use std::io::Write;
1051
1052        let tmp_dir = tempfile::TempDir::new().unwrap();
1053        let tmp = tmp_dir.path();
1054        let escaped = tmp.to_str().unwrap().replace('\\', "\\\\");
1055
1056        // Pin both `data_dir` and `[memory] db_path` inside the TempDir so
1057        // this test does not race against the shared `~/.kernex/data/memory.db`
1058        // default that other parallel tests hit.
1059        let cfg_path = tmp.join("agent.toml");
1060        let mut f = std::fs::File::create(&cfg_path).unwrap();
1061        writeln!(
1062            f,
1063            r#"[runtime]
1064name = "file-agent"
1065data_dir = "{escaped}"
1066channel = "api"
1067project = "file-proj"
1068system_prompt = "From file."
1069
1070[memory]
1071db_path = "{escaped}/memory.db"
1072"#
1073        )
1074        .unwrap();
1075
1076        let runtime = RuntimeBuilder::from_file(cfg_path.to_str().unwrap())
1077            .unwrap()
1078            .build()
1079            .await
1080            .unwrap();
1081
1082        assert_eq!(runtime.channel, "api");
1083        assert_eq!(runtime.project, Some("file-proj".to_string()));
1084        assert_eq!(runtime.system_prompt, "From file.");
1085    }
1086
1087    /// Mock provider returning a `Response` with a fixed `stop_reason`, to test
1088    /// how `Runtime::run` maps the provider's stop reason to a `RunOutcome`.
1089    struct StopReasonProvider(Option<&'static str>);
1090
1091    #[async_trait::async_trait]
1092    impl Provider for StopReasonProvider {
1093        fn name(&self) -> &str {
1094            "mock"
1095        }
1096        fn requires_api_key(&self) -> bool {
1097            false
1098        }
1099        async fn complete(&self, _ctx: &Context) -> Result<Response, KernexError> {
1100            Ok(Response {
1101                text: "partial".to_string(),
1102                metadata: CompletionMeta {
1103                    stop_reason: self.0.map(|s| s.to_string()),
1104                    ..Default::default()
1105                },
1106            })
1107        }
1108        async fn is_available(&self) -> bool {
1109            true
1110        }
1111    }
1112
1113    async fn run_with_stop_reason(stop: Option<&'static str>) -> RunOutcome {
1114        let tmp_dir = tempfile::TempDir::new().unwrap();
1115        let runtime = RuntimeBuilder::new()
1116            .data_dir(tmp_dir.path().to_str().unwrap())
1117            .build()
1118            .await
1119            .unwrap();
1120        runtime
1121            .run(
1122                &StopReasonProvider(stop),
1123                &Request::text("user-1", "hi"),
1124                &RunConfig::default(),
1125            )
1126            .await
1127            .unwrap()
1128    }
1129
1130    #[tokio::test]
1131    async fn run_maps_max_turns_stop_reason_to_max_turns_outcome() {
1132        let outcome = run_with_stop_reason(Some("max_turns")).await;
1133        assert!(
1134            matches!(outcome, RunOutcome::MaxTurns),
1135            "max_turns stop_reason should yield RunOutcome::MaxTurns, got {outcome:?}"
1136        );
1137    }
1138
1139    #[tokio::test]
1140    async fn run_returns_end_turn_for_normal_stop_reason() {
1141        match run_with_stop_reason(Some("end_turn")).await {
1142            RunOutcome::EndTurn(resp) => assert_eq!(resp.text, "partial"),
1143            other => panic!("expected EndTurn, got {other:?}"),
1144        }
1145    }
1146}