1#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
2
3pub 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
63pub 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
72pub struct Runtime {
74 #[cfg(feature = "sqlite-store")]
76 pub store: Store,
77 pub skills: Vec<Skill>,
79 pub projects: Vec<Project>,
81 pub data_dir: String,
83 pub system_prompt: String,
85 pub channel: String,
87 pub project: Option<String>,
89 pub hook_runner: Arc<dyn HookRunner>,
91 pub permission_rules: Option<Arc<PermissionRules>>,
93 pub guardrail_runner: Option<Arc<dyn GuardrailRunner>>,
95 pub auto_compact: bool,
99}
100
101struct 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 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 #[cfg(feature = "sqlite-store")]
143 pub fn store_handle(&self) -> Arc<dyn kernex_memory::MemoryStore> {
144 Arc::new(self.store.clone())
145 }
146
147 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 #[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 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 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 #[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 if context.model.is_none() {
242 context.model = skill_ctx.model;
243 }
244
245 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 let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
253 if !toolboxes.is_empty() {
254 context.toolboxes = toolboxes;
255 }
256
257 context.hook_runner = Some(self.hook_runner.clone());
259 context.permission_rules = self.permission_rules.clone();
260
261 let raw_response = provider.complete(&context).await?;
263
264 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 #[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 #[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 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 #[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 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 let provider_name = provider.name().to_string();
419 let mut upstream = provider.complete_stream(&context).await?;
420
421 let (tx, rx) = tokio::sync::mpsc::channel::<StreamEvent>(64);
423
424 #[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 let _ = tx.send(event).await;
443 if is_terminal {
444 break;
445 }
446 }
447
448 #[cfg(feature = "sqlite-store")]
453 {
454 let elapsed_ms = started.elapsed().as_millis() as u64;
455 let accumulated = acc.into_text();
456 let persisted_text = if let Some(gr) = &guardrail_runner {
457 match gr.check_output(&accumulated).await {
458 GuardrailAction::Allow => accumulated,
459 GuardrailAction::Block(_) => String::new(),
460 GuardrailAction::Sanitize(clean) => clean,
461 }
462 } else {
463 accumulated
464 };
465 let response = Response {
466 text: persisted_text,
467 metadata: CompletionMeta {
468 provider_used: provider_name,
469 tokens_used: None,
470 processing_time_ms: elapsed_ms,
471 model: None,
472 session_id: None,
473 ..Default::default()
474 },
475 };
476 if let Err(e) = store
477 .store_exchange(&channel, &request_clone, &response, &project_key)
478 .await
479 {
480 tracing::warn!("failed to persist streaming exchange: {e}");
481 }
482 }
483 #[cfg(not(feature = "sqlite-store"))]
484 {
485 let _ = acc;
486 let _ = started;
487 let _ = provider_name;
488 let _ = guardrail_runner;
489 }
490 });
491
492 Ok(rx)
493 }
494
495 #[tracing::instrument(
501 name = "kernex.run",
502 skip_all,
503 fields(provider = provider.name(), sender = %request.sender_id, turns = config.max_turns)
504 )]
505 pub async fn run(
506 &self,
507 provider: &dyn Provider,
508 request: &Request,
509 config: &RunConfig,
510 ) -> Result<RunOutcome, KernexError> {
511 let needs = ContextNeeds::default();
512 let project_ref = self.project.as_deref();
513
514 let owned_req;
516 let request = if let Some(gr) = &self.guardrail_runner {
517 match gr.check_input(&request.text).await {
518 GuardrailAction::Allow => request,
519 GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
520 GuardrailAction::Sanitize(clean) => {
521 owned_req = Request {
522 text: clean,
523 ..request.clone()
524 };
525 &owned_req
526 }
527 }
528 } else {
529 request
530 };
531
532 let skill_ctx = build_skill_prompt(&self.skills);
533 let full_system_prompt = if skill_ctx.prompt.is_empty() {
534 self.system_prompt.clone()
535 } else if self.system_prompt.is_empty() {
536 skill_ctx.prompt.clone()
537 } else {
538 format!("{}\n\n{}", self.system_prompt, skill_ctx.prompt)
539 };
540
541 #[cfg(feature = "sqlite-store")]
542 let mut context = {
543 let (effective_needs, summarizer): (
544 std::borrow::Cow<'_, ContextNeeds>,
545 Option<ProviderSummarizer<'_>>,
546 ) = if self.auto_compact {
547 let mut owned = needs.clone();
548 owned.compact = CompactionStrategy::Summarize;
549 (
550 std::borrow::Cow::Owned(owned),
551 Some(ProviderSummarizer { provider }),
552 )
553 } else {
554 (std::borrow::Cow::Borrowed(&needs), None)
555 };
556 self.store
557 .build_context(
558 &self.channel,
559 request,
560 &full_system_prompt,
561 &effective_needs,
562 project_ref,
563 summarizer.as_ref().map(|s| s as &dyn Summarizer),
564 )
565 .await?
566 };
567
568 #[cfg(not(feature = "sqlite-store"))]
569 let mut context = {
570 let mut ctx = kernex_core::context::Context::new(&request.text);
571 ctx.system_prompt = full_system_prompt;
572 ctx
573 };
574
575 if context.model.is_none() {
577 context.model = skill_ctx.model;
578 }
579
580 let mcp_servers = match_skill_triggers(&self.skills, &request.text);
581 if !mcp_servers.is_empty() {
582 context.mcp_servers = mcp_servers;
583 }
584 let toolboxes = match_skill_toolboxes(&self.skills, &request.text);
585 if !toolboxes.is_empty() {
586 context.toolboxes = toolboxes;
587 }
588
589 context.max_turns = Some(config.max_turns);
591 context.hook_runner = Some(self.hook_runner.clone());
592 context.permission_rules = self.permission_rules.clone();
593
594 let raw_response = provider.complete(&context).await?;
595
596 let response = if let Some(gr) = &self.guardrail_runner {
598 match gr.check_output(&raw_response.text).await {
599 GuardrailAction::Allow => raw_response,
600 GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
601 GuardrailAction::Sanitize(clean) => Response {
602 text: clean,
603 metadata: raw_response.metadata,
604 },
605 }
606 } else {
607 raw_response
608 };
609
610 self.hook_runner.on_stop(&response.text).await;
612
613 #[allow(unused_variables)]
615 let project_key = project_ref.unwrap_or("default");
616 #[cfg(feature = "sqlite-store")]
617 self.store
618 .store_exchange(&self.channel, request, &response, project_key)
619 .await?;
620
621 #[cfg(feature = "sqlite-store")]
623 if let Some(tokens) = response.metadata.tokens_used {
624 let model = response.metadata.model.as_deref().unwrap_or("unknown");
625 let session = response.metadata.session_id.as_deref().unwrap_or("default");
626 let breakdown = UsageBreakdown {
627 input_tokens: response.metadata.input_tokens,
628 output_tokens: response.metadata.output_tokens,
629 cache_read_tokens: response.metadata.cache_read_tokens,
630 cache_creation_tokens: response.metadata.cache_creation_tokens,
631 };
632 if let Err(e) = self
633 .store
634 .record_usage_full(&request.sender_id, session, tokens, model, breakdown)
635 .await
636 {
637 tracing::warn!("failed to record token usage: {e}");
638 }
639 }
640
641 Ok(RunOutcome::EndTurn(response))
642 }
643}
644
645pub struct RuntimeBuilder {
647 data_dir: String,
648 #[cfg(feature = "sqlite-store")]
649 db_path: Option<String>,
650 system_prompt: String,
651 channel: String,
652 project: Option<String>,
653 hook_runner: Option<Arc<dyn HookRunner>>,
654 permission_rules: Option<Arc<PermissionRules>>,
655 guardrail_runner: Option<Arc<dyn GuardrailRunner>>,
656 auto_compact: bool,
657}
658
659impl RuntimeBuilder {
660 pub fn new() -> Self {
662 Self {
663 data_dir: "~/.kernex".to_string(),
664 #[cfg(feature = "sqlite-store")]
665 db_path: None,
666 system_prompt: String::new(),
667 channel: "cli".to_string(),
668 project: None,
669 hook_runner: None,
670 permission_rules: None,
671 guardrail_runner: None,
672 auto_compact: false,
675 }
676 }
677
678 pub fn from_file(path: &str) -> Result<Self, kernex_core::error::KernexError> {
702 let config = kernex_core::config::load_file(path)?;
703 Ok(Self::from_config(&config))
704 }
705
706 pub fn from_config(config: &kernex_core::config::KernexConfig) -> Self {
714 let mut builder = Self::new()
715 .data_dir(&config.runtime.data_dir)
716 .system_prompt(&config.runtime.system_prompt)
717 .channel(&config.runtime.channel);
718
719 if let Some(proj) = &config.runtime.project {
720 builder = builder.project(proj);
721 }
722
723 #[cfg(feature = "sqlite-store")]
724 {
725 builder = builder.db_path(&config.memory.db_path);
726 }
727
728 builder
729 }
730
731 pub fn from_env() -> Self {
740 let mut builder = Self::new();
741
742 if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
743 warn_if_data_dir_unusual(&dir);
744 builder = builder.data_dir(&dir);
745 }
746 #[cfg(feature = "sqlite-store")]
747 if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
748 builder = builder.db_path(&path);
749 }
750 if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
751 builder = builder.system_prompt(&prompt);
752 }
753 if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
754 builder = builder.channel(&channel);
755 }
756 if let Ok(project) = std::env::var("KERNEX_PROJECT") {
757 builder = builder.project(&project);
758 }
759
760 builder
761 }
762
763 pub fn data_dir(mut self, path: &str) -> Self {
765 self.data_dir = path.to_string();
766 self
767 }
768
769 #[cfg(feature = "sqlite-store")]
771 pub fn db_path(mut self, path: &str) -> Self {
772 self.db_path = Some(path.to_string());
773 self
774 }
775
776 pub fn system_prompt(mut self, prompt: &str) -> Self {
778 self.system_prompt = prompt.to_string();
779 self
780 }
781
782 pub fn channel(mut self, channel: &str) -> Self {
784 self.channel = channel.to_string();
785 self
786 }
787
788 pub fn project(mut self, project: &str) -> Self {
790 self.project = Some(project.to_string());
791 self
792 }
793
794 pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
796 self.hook_runner = Some(runner);
797 self
798 }
799
800 pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
802 self.permission_rules = Some(Arc::new(rules));
803 self
804 }
805
806 pub fn guardrail_runner(mut self, runner: Arc<dyn GuardrailRunner>) -> Self {
808 self.guardrail_runner = Some(runner);
809 self
810 }
811
812 pub fn auto_compact(mut self, enable: bool) -> Self {
828 self.auto_compact = enable;
829 self
830 }
831
832 pub async fn build(self) -> Result<Runtime, KernexError> {
834 let expanded_dir = kernex_core::shellexpand(&self.data_dir);
835
836 tokio::fs::create_dir_all(&expanded_dir)
838 .await
839 .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
840
841 #[cfg(feature = "sqlite-store")]
843 let store = {
844 let db_path = self
845 .db_path
846 .unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
847 let mem_config = MemoryConfig {
848 db_path: db_path.clone(),
849 ..Default::default()
850 };
851 Store::new(&mem_config).await?
852 };
853
854 let skills_data_dir = self.data_dir.clone();
859 let skills =
860 tokio::task::spawn_blocking(move || kernex_skills::load_skills(&skills_data_dir))
861 .await
862 .map_err(|e| {
863 KernexError::skill(kernex_skills::SkillError::Logic(format!(
864 "load_skills task failed: {e}"
865 )))
866 })?;
867 let projects_data_dir = self.data_dir.clone();
868 let projects =
869 tokio::task::spawn_blocking(move || kernex_skills::load_projects(&projects_data_dir))
870 .await
871 .map_err(|e| {
872 KernexError::skill(kernex_skills::SkillError::Logic(format!(
873 "load_projects task failed: {e}"
874 )))
875 })?;
876
877 tracing::info!(
878 "runtime initialized: {} skills, {} projects",
879 skills.len(),
880 projects.len()
881 );
882
883 let hook_runner: Arc<dyn HookRunner> =
884 self.hook_runner.unwrap_or_else(|| Arc::new(NoopHookRunner));
885
886 Ok(Runtime {
887 #[cfg(feature = "sqlite-store")]
888 store,
889 skills,
890 projects,
891 data_dir: expanded_dir,
892 system_prompt: self.system_prompt,
893 channel: self.channel,
894 project: self.project,
895 hook_runner,
896 permission_rules: self.permission_rules,
897 guardrail_runner: self.guardrail_runner,
898 auto_compact: self.auto_compact,
899 })
900 }
901}
902
903impl Default for RuntimeBuilder {
904 fn default() -> Self {
905 Self::new()
906 }
907}
908
909fn warn_if_data_dir_unusual(dir: &str) {
916 let path = std::path::Path::new(dir);
919 if !path.is_absolute() {
920 return;
921 }
922 let s = dir;
923 let in_home = std::env::var("HOME")
924 .ok()
925 .map(|h| !h.is_empty() && s.starts_with(&h))
926 .unwrap_or(false);
927 let usual = in_home
928 || s.starts_with("/tmp/")
929 || s.starts_with("/var/")
930 || s.starts_with("/Users/")
931 || s.starts_with("/home/")
932 || s == "/tmp"
933 || s == "/var";
934 if !usual {
935 tracing::warn!(
936 data_dir = %dir,
937 "KERNEX_DATA_DIR resolves outside $HOME / /tmp / /var — \
938 writes may land in unexpected locations"
939 );
940 }
941}
942
943#[cfg(test)]
944mod tests {
945 use super::*;
946
947 #[tokio::test]
948 async fn test_runtime_builder_creates_runtime() {
949 let tmp_dir = tempfile::TempDir::new().unwrap();
950 let tmp = tmp_dir.path();
951
952 let runtime = RuntimeBuilder::new()
953 .data_dir(tmp.to_str().unwrap())
954 .build()
955 .await
956 .unwrap();
957
958 assert!(runtime.skills.is_empty());
959 assert!(runtime.projects.is_empty());
960 assert!(runtime.system_prompt.is_empty());
961 assert_eq!(runtime.channel, "cli");
962 assert!(runtime.project.is_none());
963 assert!(std::path::Path::new(&runtime.data_dir).exists());
964 }
965
966 #[tokio::test]
967 async fn test_runtime_builder_custom_db_path() {
968 let tmp_dir = tempfile::TempDir::new().unwrap();
969 let tmp = tmp_dir.path();
970
971 let db = tmp.join("custom.db");
972 let runtime = RuntimeBuilder::new()
973 .data_dir(tmp.to_str().unwrap())
974 .db_path(db.to_str().unwrap())
975 .build()
976 .await
977 .unwrap();
978
979 assert!(db.exists());
980 drop(runtime);
981 }
982
983 #[tokio::test]
984 async fn test_runtime_builder_with_config() {
985 let tmp_dir = tempfile::TempDir::new().unwrap();
986 let tmp = tmp_dir.path();
987
988 let runtime = RuntimeBuilder::new()
989 .data_dir(tmp.to_str().unwrap())
990 .system_prompt("You are helpful.")
991 .channel("api")
992 .project("my-project")
993 .build()
994 .await
995 .unwrap();
996
997 assert_eq!(runtime.system_prompt, "You are helpful.");
998 assert_eq!(runtime.channel, "api");
999 assert_eq!(runtime.project, Some("my-project".to_string()));
1000 }
1001
1002 #[tokio::test]
1003 async fn test_runtime_builder_from_config() {
1004 use kernex_core::config::{KernexConfig, MemoryConfig, RuntimeConfig};
1005
1006 let tmp_dir = tempfile::TempDir::new().unwrap();
1007 let tmp = tmp_dir.path();
1008
1009 let cfg = KernexConfig {
1014 runtime: RuntimeConfig {
1015 name: "test-agent".to_string(),
1016 data_dir: tmp.to_str().unwrap().to_string(),
1017 channel: "slack".to_string(),
1018 project: Some("my-proj".to_string()),
1019 system_prompt: "Be concise.".to_string(),
1020 ..RuntimeConfig::default()
1021 },
1022 memory: MemoryConfig {
1023 db_path: tmp.join("memory.db").to_str().unwrap().to_string(),
1024 ..MemoryConfig::default()
1025 },
1026 ..KernexConfig::default()
1027 };
1028
1029 let runtime = RuntimeBuilder::from_config(&cfg).build().await.unwrap();
1030
1031 assert_eq!(runtime.channel, "slack");
1032 assert_eq!(runtime.project, Some("my-proj".to_string()));
1033 assert_eq!(runtime.system_prompt, "Be concise.");
1034 }
1035
1036 #[tokio::test]
1037 async fn test_runtime_builder_from_file_toml() {
1038 use std::io::Write;
1039
1040 let tmp_dir = tempfile::TempDir::new().unwrap();
1041 let tmp = tmp_dir.path();
1042 let escaped = tmp.to_str().unwrap().replace('\\', "\\\\");
1043
1044 let cfg_path = tmp.join("agent.toml");
1048 let mut f = std::fs::File::create(&cfg_path).unwrap();
1049 writeln!(
1050 f,
1051 r#"[runtime]
1052name = "file-agent"
1053data_dir = "{escaped}"
1054channel = "api"
1055project = "file-proj"
1056system_prompt = "From file."
1057
1058[memory]
1059db_path = "{escaped}/memory.db"
1060"#
1061 )
1062 .unwrap();
1063
1064 let runtime = RuntimeBuilder::from_file(cfg_path.to_str().unwrap())
1065 .unwrap()
1066 .build()
1067 .await
1068 .unwrap();
1069
1070 assert_eq!(runtime.channel, "api");
1071 assert_eq!(runtime.project, Some("file-proj".to_string()));
1072 assert_eq!(runtime.system_prompt, "From file.");
1073 }
1074}