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