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 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 #[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 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 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 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 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 #[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 if response.metadata.stop_reason.as_deref() == Some("max_turns") {
640 return Ok(RunOutcome::MaxTurns);
641 }
642
643 if response.metadata.stop_reason.as_deref() == Some("budget_exhausted") {
647 return Ok(RunOutcome::BudgetExhausted);
648 }
649
650 self.hook_runner.on_stop(&response.text).await;
652
653 #[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
665pub 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 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 auto_compact: false,
695 }
696 }
697
698 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 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 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 pub fn data_dir(mut self, path: &str) -> Self {
785 self.data_dir = path.to_string();
786 self
787 }
788
789 #[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 pub fn system_prompt(mut self, prompt: &str) -> Self {
798 self.system_prompt = prompt.to_string();
799 self
800 }
801
802 pub fn channel(mut self, channel: &str) -> Self {
804 self.channel = channel.to_string();
805 self
806 }
807
808 pub fn project(mut self, project: &str) -> Self {
810 self.project = Some(project.to_string());
811 self
812 }
813
814 pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
816 self.hook_runner = Some(runner);
817 self
818 }
819
820 pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
822 self.permission_rules = Some(Arc::new(rules));
823 self
824 }
825
826 pub fn guardrail_runner(mut self, runner: Arc<dyn GuardrailRunner>) -> Self {
828 self.guardrail_runner = Some(runner);
829 self
830 }
831
832 pub fn auto_compact(mut self, enable: bool) -> Self {
848 self.auto_compact = enable;
849 self
850 }
851
852 pub async fn build(self) -> Result<Runtime, KernexError> {
854 let expanded_dir = kernex_core::shellexpand(&self.data_dir);
855
856 tokio::fs::create_dir_all(&expanded_dir)
858 .await
859 .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
860
861 #[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 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
929fn warn_if_data_dir_unusual(dir: &str) {
936 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 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 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 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}