1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
3#[cfg(feature = "opentelemetry")]
38pub mod telemetry;
39
40#[cfg(feature = "sqlite-store")]
41use kernex_core::config::MemoryConfig;
42use kernex_core::context::{CompactionStrategy, Context, ContextNeeds};
43use kernex_core::error::KernexError;
44use kernex_core::guardrails::{GuardrailAction, GuardrailRunner};
45use kernex_core::hooks::{HookRunner, NoopHookRunner};
46use kernex_core::message::{CompletionMeta, Request, Response};
47use kernex_core::permissions::PermissionRules;
48use kernex_core::run::{RunConfig, RunOutcome};
49use kernex_core::stream::StreamEvent;
50use kernex_core::traits::Provider;
51use kernex_core::traits::StreamingProvider;
52use kernex_core::traits::Summarizer;
53#[cfg(feature = "sqlite-store")]
54use kernex_memory::{Store, UsageBreakdown};
55use kernex_skills::{
56 build_skill_prompt, match_skill_toolboxes, match_skill_triggers, Project, Skill,
57};
58use std::sync::Arc;
59
60pub use kernex_core as core;
62#[cfg(feature = "sqlite-store")]
63pub use kernex_memory as memory;
64pub use kernex_pipelines as pipelines;
65pub use kernex_providers as providers;
66pub use kernex_sandbox as sandbox;
67pub use kernex_skills as skills;
68
69pub struct Runtime {
71 #[cfg(feature = "sqlite-store")]
73 pub store: Store,
74 pub skills: Vec<Skill>,
76 pub projects: Vec<Project>,
78 pub data_dir: String,
80 pub system_prompt: String,
82 pub channel: String,
84 pub project: Option<String>,
86 pub hook_runner: Arc<dyn HookRunner>,
88 pub permission_rules: Option<Arc<PermissionRules>>,
90 pub guardrail_runner: Option<Arc<dyn GuardrailRunner>>,
92 pub auto_compact: bool,
96}
97
98struct ProviderSummarizer<'a> {
107 provider: &'a dyn Provider,
108}
109
110#[async_trait::async_trait]
111impl Summarizer for ProviderSummarizer<'_> {
112 async fn summarize(&self, text: &str) -> Result<String, KernexError> {
113 let instruction = format!(
117 "You are a conversation summarizer. Summarize the following \
118 exchange in 200 words or fewer. Focus on: decisions made, files \
119 touched, errors encountered, and unresolved questions. Skip \
120 greetings and small talk. Output the summary only — no preamble.\n\n\
121 ---\n{text}\n---"
122 );
123 let mut ctx = Context::new(&instruction);
124 ctx.system_prompt.clear();
125 let response = self.provider.complete(&ctx).await?;
126 Ok(response.text)
127 }
128}
129
130impl Runtime {
131 pub async fn complete(
137 &self,
138 provider: &dyn Provider,
139 request: &Request,
140 ) -> Result<Response, KernexError> {
141 self.complete_with_needs(provider, request, &ContextNeeds::default())
142 .await
143 }
144
145 #[tracing::instrument(
148 name = "kernex.complete",
149 skip_all,
150 fields(provider = provider.name(), sender = %request.sender_id)
151 )]
152 pub async fn complete_with_needs(
153 &self,
154 provider: &dyn Provider,
155 request: &Request,
156 #[allow(unused_variables)] needs: &ContextNeeds,
157 ) -> Result<Response, KernexError> {
158 let project_ref = self.project.as_deref();
159
160 let owned_req;
163 let request = if let Some(gr) = &self.guardrail_runner {
164 match gr.check_input(&request.text).await {
165 GuardrailAction::Allow => request,
166 GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
167 GuardrailAction::Sanitize(clean) => {
168 owned_req = Request {
169 text: clean,
170 ..request.clone()
171 };
172 &owned_req
173 }
174 }
175 } else {
176 request
177 };
178
179 let skill_ctx = build_skill_prompt(&self.skills);
181 let full_system_prompt = if skill_ctx.prompt.is_empty() {
182 self.system_prompt.clone()
183 } else if self.system_prompt.is_empty() {
184 skill_ctx.prompt.clone()
185 } else {
186 format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
187 };
188
189 #[cfg(feature = "sqlite-store")]
191 let mut context = {
192 let (effective_needs, summarizer): (
193 std::borrow::Cow<'_, ContextNeeds>,
194 Option<ProviderSummarizer<'_>>,
195 ) = if self.auto_compact {
196 let mut owned = needs.clone();
197 owned.compact = CompactionStrategy::Summarize;
198 (
199 std::borrow::Cow::Owned(owned),
200 Some(ProviderSummarizer { provider }),
201 )
202 } else {
203 (std::borrow::Cow::Borrowed(needs), None)
204 };
205 self.store
206 .build_context(
207 &self.channel,
208 request,
209 &full_system_prompt,
210 &effective_needs,
211 project_ref,
212 summarizer.as_ref().map(|s| s as &dyn Summarizer),
213 )
214 .await?
215 };
216
217 #[cfg(not(feature = "sqlite-store"))]
218 let mut context = {
219 let mut ctx = kernex_core::context::Context::new(&request.text);
220 ctx.system_prompt = full_system_prompt;
221 ctx
222 };
223
224 if context.model.is_none() {
226 context.model = skill_ctx.model;
227 }
228
229 let mcp_servers = match_skill_triggers(&self.skills, &request.text);
231 if !mcp_servers.is_empty() {
232 context.mcp_servers = mcp_servers;
233 }
234
235 let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
237 if !toolboxes.is_empty() {
238 context.toolboxes = toolboxes;
239 }
240
241 context.hook_runner = Some(self.hook_runner.clone());
243 context.permission_rules = self.permission_rules.clone();
244
245 let raw_response = provider.complete(&context).await?;
247
248 let response = if let Some(gr) = &self.guardrail_runner {
250 match gr.check_output(&raw_response.text).await {
251 GuardrailAction::Allow => raw_response,
252 GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
253 GuardrailAction::Sanitize(clean) => Response {
254 text: clean,
255 metadata: raw_response.metadata,
256 },
257 }
258 } else {
259 raw_response
260 };
261
262 #[allow(unused_variables)]
264 let project_key = project_ref.unwrap_or("default");
265
266 #[cfg(feature = "sqlite-store")]
267 self.store
268 .store_exchange(&self.channel, request, &response, project_key)
269 .await?;
270
271 #[cfg(feature = "sqlite-store")]
273 if let Some(tokens) = response.metadata.tokens_used {
274 let model = response.metadata.model.as_deref().unwrap_or("unknown");
275 let session = response.metadata.session_id.as_deref().unwrap_or("default");
276 let breakdown = UsageBreakdown {
277 input_tokens: response.metadata.input_tokens,
278 output_tokens: response.metadata.output_tokens,
279 cache_read_tokens: response.metadata.cache_read_tokens,
280 cache_creation_tokens: response.metadata.cache_creation_tokens,
281 };
282 if let Err(e) = self
283 .store
284 .record_usage_full(&request.sender_id, session, tokens, model, breakdown)
285 .await
286 {
287 tracing::warn!("failed to record token usage: {e}");
288 }
289 }
290
291 Ok(response)
292 }
293
294 pub async fn complete_stream(
300 &self,
301 provider: &dyn StreamingProvider,
302 request: &Request,
303 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, KernexError> {
304 self.complete_stream_with_needs(provider, request, &ContextNeeds::default())
305 .await
306 }
307
308 #[tracing::instrument(
311 name = "kernex.stream",
312 skip_all,
313 fields(provider = provider.name(), sender = %request.sender_id)
314 )]
315 pub async fn complete_stream_with_needs(
316 &self,
317 provider: &dyn StreamingProvider,
318 request: &Request,
319 #[allow(unused_variables)] needs: &ContextNeeds,
320 ) -> Result<tokio::sync::mpsc::Receiver<StreamEvent>, KernexError> {
321 let project_ref = self.project.as_deref();
322
323 let owned_req;
326 let request = if let Some(gr) = &self.guardrail_runner {
327 match gr.check_input(&request.text).await {
328 GuardrailAction::Allow => request,
329 GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
330 GuardrailAction::Sanitize(clean) => {
331 owned_req = Request {
332 text: clean,
333 ..request.clone()
334 };
335 &owned_req
336 }
337 }
338 } else {
339 request
340 };
341
342 let skill_ctx = build_skill_prompt(&self.skills);
343 let full_system_prompt = if skill_ctx.prompt.is_empty() {
344 self.system_prompt.clone()
345 } else if self.system_prompt.is_empty() {
346 skill_ctx.prompt.clone()
347 } else {
348 format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
349 };
350
351 #[cfg(feature = "sqlite-store")]
352 let mut context = {
353 let (effective_needs, summarizer): (
354 std::borrow::Cow<'_, ContextNeeds>,
355 Option<ProviderSummarizer<'_>>,
356 ) = if self.auto_compact {
357 let mut owned = needs.clone();
358 owned.compact = CompactionStrategy::Summarize;
359 (
360 std::borrow::Cow::Owned(owned),
361 Some(ProviderSummarizer { provider }),
362 )
363 } else {
364 (std::borrow::Cow::Borrowed(needs), None)
365 };
366 self.store
367 .build_context(
368 &self.channel,
369 request,
370 &full_system_prompt,
371 &effective_needs,
372 project_ref,
373 summarizer.as_ref().map(|s| s as &dyn Summarizer),
374 )
375 .await?
376 };
377
378 #[cfg(not(feature = "sqlite-store"))]
379 let mut context = {
380 let mut ctx = kernex_core::context::Context::new(&request.text);
381 ctx.system_prompt = full_system_prompt;
382 ctx
383 };
384
385 if context.model.is_none() {
386 context.model = skill_ctx.model;
387 }
388
389 let mcp_servers = match_skill_triggers(&self.skills, &request.text);
390 if !mcp_servers.is_empty() {
391 context.mcp_servers = mcp_servers;
392 }
393 let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
394 if !toolboxes.is_empty() {
395 context.toolboxes = toolboxes;
396 }
397
398 context.hook_runner = Some(self.hook_runner.clone());
399 context.permission_rules = self.permission_rules.clone();
400
401 let provider_name = provider.name().to_string();
403 let mut upstream = provider.complete_stream(&context).await?;
404
405 let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
407
408 #[cfg(feature = "sqlite-store")]
410 let store = self.store.clone();
411 let channel = self.channel.clone();
412 let request_clone = request.clone();
413 #[allow(unused_variables)]
414 let project_key = project_ref.unwrap_or("default").to_string();
415 let guardrail_runner = self.guardrail_runner.clone();
416
417 tokio::spawn(async move {
418 use kernex_core::stream::{StreamAccumulator, StreamEvent as SE};
419 let mut acc = StreamAccumulator::new();
420 let started = std::time::Instant::now();
421
422 while let Some(event) = upstream.recv().await {
423 acc.push(&event);
424 let is_terminal = matches!(event, SE::Done | SE::Error(_));
425 let _ = tx.send(event).await;
427 if is_terminal {
428 break;
429 }
430 }
431
432 #[cfg(feature = "sqlite-store")]
437 {
438 let elapsed_ms = started.elapsed().as_millis() as u64;
439 let accumulated = acc.into_text();
440 let persisted_text = if let Some(gr) = &guardrail_runner {
441 match gr.check_output(&accumulated).await {
442 GuardrailAction::Allow => accumulated,
443 GuardrailAction::Block(_) => String::new(),
444 GuardrailAction::Sanitize(clean) => clean,
445 }
446 } else {
447 accumulated
448 };
449 let response = Response {
450 text: persisted_text,
451 metadata: CompletionMeta {
452 provider_used: provider_name,
453 tokens_used: None,
454 processing_time_ms: elapsed_ms,
455 model: None,
456 session_id: None,
457 ..Default::default()
458 },
459 };
460 if let Err(e) = store
461 .store_exchange(&channel, &request_clone, &response, &project_key)
462 .await
463 {
464 tracing::warn!("failed to persist streaming exchange: {e}");
465 }
466 }
467 #[cfg(not(feature = "sqlite-store"))]
468 {
469 let _ = acc;
470 let _ = started;
471 let _ = provider_name;
472 let _ = guardrail_runner;
473 }
474 });
475
476 Ok(rx)
477 }
478
479 #[tracing::instrument(
485 name = "kernex.run",
486 skip_all,
487 fields(provider = provider.name(), sender = %request.sender_id, turns = config.max_turns)
488 )]
489 pub async fn run(
490 &self,
491 provider: &dyn Provider,
492 request: &Request,
493 config: &RunConfig,
494 ) -> Result<RunOutcome, KernexError> {
495 let needs = ContextNeeds::default();
496 let project_ref = self.project.as_deref();
497
498 let owned_req;
500 let request = if let Some(gr) = &self.guardrail_runner {
501 match gr.check_input(&request.text).await {
502 GuardrailAction::Allow => request,
503 GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
504 GuardrailAction::Sanitize(clean) => {
505 owned_req = Request {
506 text: clean,
507 ..request.clone()
508 };
509 &owned_req
510 }
511 }
512 } else {
513 request
514 };
515
516 let skill_ctx = build_skill_prompt(&self.skills);
517 let full_system_prompt = if skill_ctx.prompt.is_empty() {
518 self.system_prompt.clone()
519 } else if self.system_prompt.is_empty() {
520 skill_ctx.prompt.clone()
521 } else {
522 format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
523 };
524
525 #[cfg(feature = "sqlite-store")]
526 let mut context = {
527 let (effective_needs, summarizer): (
528 std::borrow::Cow<'_, ContextNeeds>,
529 Option<ProviderSummarizer<'_>>,
530 ) = if self.auto_compact {
531 let mut owned = needs.clone();
532 owned.compact = CompactionStrategy::Summarize;
533 (
534 std::borrow::Cow::Owned(owned),
535 Some(ProviderSummarizer { provider }),
536 )
537 } else {
538 (std::borrow::Cow::Borrowed(&needs), None)
539 };
540 self.store
541 .build_context(
542 &self.channel,
543 request,
544 &full_system_prompt,
545 &effective_needs,
546 project_ref,
547 summarizer.as_ref().map(|s| s as &dyn Summarizer),
548 )
549 .await?
550 };
551
552 #[cfg(not(feature = "sqlite-store"))]
553 let mut context = {
554 let mut ctx = kernex_core::context::Context::new(&request.text);
555 ctx.system_prompt = full_system_prompt;
556 ctx
557 };
558
559 if context.model.is_none() {
561 context.model = skill_ctx.model;
562 }
563
564 let mcp_servers = match_skill_triggers(&self.skills, &request.text);
565 if !mcp_servers.is_empty() {
566 context.mcp_servers = mcp_servers;
567 }
568 let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
569 if !toolboxes.is_empty() {
570 context.toolboxes = toolboxes;
571 }
572
573 context.max_turns = Some(config.max_turns);
575 context.hook_runner = Some(self.hook_runner.clone());
576 context.permission_rules = self.permission_rules.clone();
577
578 let raw_response = provider.complete(&context).await?;
579
580 let response = if let Some(gr) = &self.guardrail_runner {
582 match gr.check_output(&raw_response.text).await {
583 GuardrailAction::Allow => raw_response,
584 GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
585 GuardrailAction::Sanitize(clean) => Response {
586 text: clean,
587 metadata: raw_response.metadata,
588 },
589 }
590 } else {
591 raw_response
592 };
593
594 self.hook_runner.on_stop(&response.text).await;
596
597 #[allow(unused_variables)]
599 let project_key = project_ref.unwrap_or("default");
600 #[cfg(feature = "sqlite-store")]
601 self.store
602 .store_exchange(&self.channel, request, &response, project_key)
603 .await?;
604
605 #[cfg(feature = "sqlite-store")]
607 if let Some(tokens) = response.metadata.tokens_used {
608 let model = response.metadata.model.as_deref().unwrap_or("unknown");
609 let session = response.metadata.session_id.as_deref().unwrap_or("default");
610 let breakdown = UsageBreakdown {
611 input_tokens: response.metadata.input_tokens,
612 output_tokens: response.metadata.output_tokens,
613 cache_read_tokens: response.metadata.cache_read_tokens,
614 cache_creation_tokens: response.metadata.cache_creation_tokens,
615 };
616 if let Err(e) = self
617 .store
618 .record_usage_full(&request.sender_id, session, tokens, model, breakdown)
619 .await
620 {
621 tracing::warn!("failed to record token usage: {e}");
622 }
623 }
624
625 Ok(RunOutcome::EndTurn(response))
626 }
627}
628
629pub struct RuntimeBuilder {
631 data_dir: String,
632 #[cfg(feature = "sqlite-store")]
633 db_path: Option<String>,
634 system_prompt: String,
635 channel: String,
636 project: Option<String>,
637 hook_runner: Option<Arc<dyn HookRunner>>,
638 permission_rules: Option<Arc<PermissionRules>>,
639 guardrail_runner: Option<Arc<dyn GuardrailRunner>>,
640 auto_compact: bool,
641}
642
643impl RuntimeBuilder {
644 pub fn new() -> Self {
646 Self {
647 data_dir: "~/.kernex".to_string(),
648 #[cfg(feature = "sqlite-store")]
649 db_path: None,
650 system_prompt: String::new(),
651 channel: "cli".to_string(),
652 project: None,
653 hook_runner: None,
654 permission_rules: None,
655 guardrail_runner: None,
656 auto_compact: false,
659 }
660 }
661
662 pub fn from_file(path: &str) -> Result<Self, kernex_core::error::KernexError> {
686 let config = kernex_core::config::load_file(path)?;
687 Ok(Self::from_config(&config))
688 }
689
690 pub fn from_config(config: &kernex_core::config::KernexConfig) -> Self {
698 let mut builder = Self::new()
699 .data_dir(&config.runtime.data_dir)
700 .system_prompt(&config.runtime.system_prompt)
701 .channel(&config.runtime.channel);
702
703 if let Some(proj) = &config.runtime.project {
704 builder = builder.project(proj);
705 }
706
707 #[cfg(feature = "sqlite-store")]
708 {
709 builder = builder.db_path(&config.memory.db_path);
710 }
711
712 builder
713 }
714
715 pub fn from_env() -> Self {
724 let mut builder = Self::new();
725
726 if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
727 warn_if_data_dir_unusual(&dir);
728 builder = builder.data_dir(&dir);
729 }
730 #[cfg(feature = "sqlite-store")]
731 if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
732 builder = builder.db_path(&path);
733 }
734 if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
735 builder = builder.system_prompt(&prompt);
736 }
737 if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
738 builder = builder.channel(&channel);
739 }
740 if let Ok(project) = std::env::var("KERNEX_PROJECT") {
741 builder = builder.project(&project);
742 }
743
744 builder
745 }
746
747 pub fn data_dir(mut self, path: &str) -> Self {
749 self.data_dir = path.to_string();
750 self
751 }
752
753 #[cfg(feature = "sqlite-store")]
755 pub fn db_path(mut self, path: &str) -> Self {
756 self.db_path = Some(path.to_string());
757 self
758 }
759
760 pub fn system_prompt(mut self, prompt: &str) -> Self {
762 self.system_prompt = prompt.to_string();
763 self
764 }
765
766 pub fn channel(mut self, channel: &str) -> Self {
768 self.channel = channel.to_string();
769 self
770 }
771
772 pub fn project(mut self, project: &str) -> Self {
774 self.project = Some(project.to_string());
775 self
776 }
777
778 pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
780 self.hook_runner = Some(runner);
781 self
782 }
783
784 pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
786 self.permission_rules = Some(Arc::new(rules));
787 self
788 }
789
790 pub fn guardrail_runner(mut self, runner: Arc<dyn GuardrailRunner>) -> Self {
792 self.guardrail_runner = Some(runner);
793 self
794 }
795
796 pub fn auto_compact(mut self, enable: bool) -> Self {
812 self.auto_compact = enable;
813 self
814 }
815
816 pub async fn build(self) -> Result<Runtime, KernexError> {
818 let expanded_dir = kernex_core::shellexpand(&self.data_dir);
819
820 tokio::fs::create_dir_all(&expanded_dir)
822 .await
823 .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
824
825 #[cfg(feature = "sqlite-store")]
827 let store = {
828 let db_path = self
829 .db_path
830 .unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
831 let mem_config = MemoryConfig {
832 db_path: db_path.clone(),
833 ..Default::default()
834 };
835 Store::new(&mem_config).await?
836 };
837
838 let skills_data_dir = self.data_dir.clone();
843 let skills =
844 tokio::task::spawn_blocking(move || kernex_skills::load_skills(&skills_data_dir))
845 .await
846 .map_err(|e| {
847 KernexError::skill(kernex_skills::SkillError::Logic(format!(
848 "load_skills task failed: {e}"
849 )))
850 })?;
851 let projects_data_dir = self.data_dir.clone();
852 let projects =
853 tokio::task::spawn_blocking(move || kernex_skills::load_projects(&projects_data_dir))
854 .await
855 .map_err(|e| {
856 KernexError::skill(kernex_skills::SkillError::Logic(format!(
857 "load_projects task failed: {e}"
858 )))
859 })?;
860
861 tracing::info!(
862 "runtime initialized: {} skills, {} projects",
863 skills.len(),
864 projects.len()
865 );
866
867 let hook_runner: Arc<dyn HookRunner> =
868 self.hook_runner.unwrap_or_else(|| Arc::new(NoopHookRunner));
869
870 Ok(Runtime {
871 #[cfg(feature = "sqlite-store")]
872 store,
873 skills,
874 projects,
875 data_dir: expanded_dir,
876 system_prompt: self.system_prompt,
877 channel: self.channel,
878 project: self.project,
879 hook_runner,
880 permission_rules: self.permission_rules,
881 guardrail_runner: self.guardrail_runner,
882 auto_compact: self.auto_compact,
883 })
884 }
885}
886
887impl Default for RuntimeBuilder {
888 fn default() -> Self {
889 Self::new()
890 }
891}
892
893fn warn_if_data_dir_unusual(dir: &str) {
900 let path = std::path::Path::new(dir);
903 if !path.is_absolute() {
904 return;
905 }
906 let s = dir;
907 let in_home = std::env::var("HOME")
908 .ok()
909 .map(|h| !h.is_empty() && s.starts_with(&h))
910 .unwrap_or(false);
911 let usual = in_home
912 || s.starts_with("/tmp/")
913 || s.starts_with("/var/")
914 || s.starts_with("/Users/")
915 || s.starts_with("/home/")
916 || s == "/tmp"
917 || s == "/var";
918 if !usual {
919 tracing::warn!(
920 data_dir = %dir,
921 "KERNEX_DATA_DIR resolves outside $HOME / /tmp / /var — \
922 writes may land in unexpected locations"
923 );
924 }
925}
926
927#[cfg(test)]
928mod tests {
929 use super::*;
930
931 #[tokio::test]
932 async fn test_runtime_builder_creates_runtime() {
933 let tmp_dir = tempfile::TempDir::new().unwrap();
934 let tmp = tmp_dir.path();
935
936 let runtime = RuntimeBuilder::new()
937 .data_dir(tmp.to_str().unwrap())
938 .build()
939 .await
940 .unwrap();
941
942 assert!(runtime.skills.is_empty());
943 assert!(runtime.projects.is_empty());
944 assert!(runtime.system_prompt.is_empty());
945 assert_eq!(runtime.channel, "cli");
946 assert!(runtime.project.is_none());
947 assert!(std::path::Path::new(&runtime.data_dir).exists());
948 }
949
950 #[tokio::test]
951 async fn test_runtime_builder_custom_db_path() {
952 let tmp_dir = tempfile::TempDir::new().unwrap();
953 let tmp = tmp_dir.path();
954
955 let db = tmp.join("custom.db");
956 let runtime = RuntimeBuilder::new()
957 .data_dir(tmp.to_str().unwrap())
958 .db_path(db.to_str().unwrap())
959 .build()
960 .await
961 .unwrap();
962
963 assert!(db.exists());
964 drop(runtime);
965 }
966
967 #[tokio::test]
968 async fn test_runtime_builder_with_config() {
969 let tmp_dir = tempfile::TempDir::new().unwrap();
970 let tmp = tmp_dir.path();
971
972 let runtime = RuntimeBuilder::new()
973 .data_dir(tmp.to_str().unwrap())
974 .system_prompt("You are helpful.")
975 .channel("api")
976 .project("my-project")
977 .build()
978 .await
979 .unwrap();
980
981 assert_eq!(runtime.system_prompt, "You are helpful.");
982 assert_eq!(runtime.channel, "api");
983 assert_eq!(runtime.project, Some("my-project".to_string()));
984 }
985
986 #[tokio::test]
987 async fn test_runtime_builder_from_config() {
988 use kernex_core::config::{KernexConfig, MemoryConfig, RuntimeConfig};
989
990 let tmp_dir = tempfile::TempDir::new().unwrap();
991 let tmp = tmp_dir.path();
992
993 let cfg = KernexConfig {
998 runtime: RuntimeConfig {
999 name: "test-agent".to_string(),
1000 data_dir: tmp.to_str().unwrap().to_string(),
1001 channel: "slack".to_string(),
1002 project: Some("my-proj".to_string()),
1003 system_prompt: "Be concise.".to_string(),
1004 ..RuntimeConfig::default()
1005 },
1006 memory: MemoryConfig {
1007 db_path: tmp.join("memory.db").to_str().unwrap().to_string(),
1008 ..MemoryConfig::default()
1009 },
1010 ..KernexConfig::default()
1011 };
1012
1013 let runtime = RuntimeBuilder::from_config(&cfg).build().await.unwrap();
1014
1015 assert_eq!(runtime.channel, "slack");
1016 assert_eq!(runtime.project, Some("my-proj".to_string()));
1017 assert_eq!(runtime.system_prompt, "Be concise.");
1018 }
1019
1020 #[tokio::test]
1021 async fn test_runtime_builder_from_file_toml() {
1022 use std::io::Write;
1023
1024 let tmp_dir = tempfile::TempDir::new().unwrap();
1025 let tmp = tmp_dir.path();
1026 let escaped = tmp.to_str().unwrap().replace('\\', "\\\\");
1027
1028 let cfg_path = tmp.join("agent.toml");
1032 let mut f = std::fs::File::create(&cfg_path).unwrap();
1033 writeln!(
1034 f,
1035 r#"[runtime]
1036name = "file-agent"
1037data_dir = "{escaped}"
1038channel = "api"
1039project = "file-proj"
1040system_prompt = "From file."
1041
1042[memory]
1043db_path = "{escaped}/memory.db"
1044"#
1045 )
1046 .unwrap();
1047
1048 let runtime = RuntimeBuilder::from_file(cfg_path.to_str().unwrap())
1049 .unwrap()
1050 .build()
1051 .await
1052 .unwrap();
1053
1054 assert_eq!(runtime.channel, "api");
1055 assert_eq!(runtime.project, Some("file-proj".to_string()));
1056 assert_eq!(runtime.system_prompt, "From file.");
1057 }
1058}