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.hook_runner = Some(self.hook_runner.clone());
595 context.permission_rules = self.permission_rules.clone();
596
597 let raw_response = provider.complete(&context).await?;
598
599 let response = if let Some(gr) = &self.guardrail_runner {
601 match gr.check_output(&raw_response.text).await {
602 GuardrailAction::Allow => raw_response,
603 GuardrailAction::Block(reason) => return Err(KernexError::Guardrail(reason)),
604 GuardrailAction::Sanitize(clean) => Response {
605 text: clean,
606 metadata: raw_response.metadata,
607 },
608 }
609 } else {
610 raw_response
611 };
612
613 #[cfg(feature = "sqlite-store")]
617 if let Some(tokens) = response.metadata.tokens_used {
618 let model = response.metadata.model.as_deref().unwrap_or("unknown");
619 let session = response.metadata.session_id.as_deref().unwrap_or("default");
620 let breakdown = UsageBreakdown {
621 input_tokens: response.metadata.input_tokens,
622 output_tokens: response.metadata.output_tokens,
623 cache_read_tokens: response.metadata.cache_read_tokens,
624 cache_creation_tokens: response.metadata.cache_creation_tokens,
625 };
626 if let Err(e) = self
627 .store
628 .record_usage_full(&request.sender_id, session, tokens, model, breakdown)
629 .await
630 {
631 tracing::warn!("failed to record token usage: {e}");
632 }
633 }
634
635 if response.metadata.stop_reason.as_deref() == Some("max_turns") {
639 return Ok(RunOutcome::MaxTurns);
640 }
641
642 self.hook_runner.on_stop(&response.text).await;
644
645 #[allow(unused_variables)]
647 let project_key = project_ref.unwrap_or("default");
648 #[cfg(feature = "sqlite-store")]
649 self.store
650 .store_exchange(&self.channel, request, &response, project_key)
651 .await?;
652
653 Ok(RunOutcome::EndTurn(response))
654 }
655}
656
657pub struct RuntimeBuilder {
659 data_dir: String,
660 #[cfg(feature = "sqlite-store")]
661 db_path: Option<String>,
662 system_prompt: String,
663 channel: String,
664 project: Option<String>,
665 hook_runner: Option<Arc<dyn HookRunner>>,
666 permission_rules: Option<Arc<PermissionRules>>,
667 guardrail_runner: Option<Arc<dyn GuardrailRunner>>,
668 auto_compact: bool,
669}
670
671impl RuntimeBuilder {
672 pub fn new() -> Self {
674 Self {
675 data_dir: "~/.kernex".to_string(),
676 #[cfg(feature = "sqlite-store")]
677 db_path: None,
678 system_prompt: String::new(),
679 channel: "cli".to_string(),
680 project: None,
681 hook_runner: None,
682 permission_rules: None,
683 guardrail_runner: None,
684 auto_compact: false,
687 }
688 }
689
690 pub fn from_file(path: &str) -> Result<Self, kernex_core::error::KernexError> {
714 let config = kernex_core::config::load_file(path)?;
715 Ok(Self::from_config(&config))
716 }
717
718 pub fn from_config(config: &kernex_core::config::KernexConfig) -> Self {
726 let mut builder = Self::new()
727 .data_dir(&config.runtime.data_dir)
728 .system_prompt(&config.runtime.system_prompt)
729 .channel(&config.runtime.channel);
730
731 if let Some(proj) = &config.runtime.project {
732 builder = builder.project(proj);
733 }
734
735 #[cfg(feature = "sqlite-store")]
736 {
737 builder = builder.db_path(&config.memory.db_path);
738 }
739
740 builder
741 }
742
743 pub fn from_env() -> Self {
752 let mut builder = Self::new();
753
754 if let Ok(dir) = std::env::var("KERNEX_DATA_DIR") {
755 warn_if_data_dir_unusual(&dir);
756 builder = builder.data_dir(&dir);
757 }
758 #[cfg(feature = "sqlite-store")]
759 if let Ok(path) = std::env::var("KERNEX_DB_PATH") {
760 builder = builder.db_path(&path);
761 }
762 if let Ok(prompt) = std::env::var("KERNEX_SYSTEM_PROMPT") {
763 builder = builder.system_prompt(&prompt);
764 }
765 if let Ok(channel) = std::env::var("KERNEX_CHANNEL") {
766 builder = builder.channel(&channel);
767 }
768 if let Ok(project) = std::env::var("KERNEX_PROJECT") {
769 builder = builder.project(&project);
770 }
771
772 builder
773 }
774
775 pub fn data_dir(mut self, path: &str) -> Self {
777 self.data_dir = path.to_string();
778 self
779 }
780
781 #[cfg(feature = "sqlite-store")]
783 pub fn db_path(mut self, path: &str) -> Self {
784 self.db_path = Some(path.to_string());
785 self
786 }
787
788 pub fn system_prompt(mut self, prompt: &str) -> Self {
790 self.system_prompt = prompt.to_string();
791 self
792 }
793
794 pub fn channel(mut self, channel: &str) -> Self {
796 self.channel = channel.to_string();
797 self
798 }
799
800 pub fn project(mut self, project: &str) -> Self {
802 self.project = Some(project.to_string());
803 self
804 }
805
806 pub fn hook_runner(mut self, runner: Arc<dyn HookRunner>) -> Self {
808 self.hook_runner = Some(runner);
809 self
810 }
811
812 pub fn permission_rules(mut self, rules: PermissionRules) -> Self {
814 self.permission_rules = Some(Arc::new(rules));
815 self
816 }
817
818 pub fn guardrail_runner(mut self, runner: Arc<dyn GuardrailRunner>) -> Self {
820 self.guardrail_runner = Some(runner);
821 self
822 }
823
824 pub fn auto_compact(mut self, enable: bool) -> Self {
840 self.auto_compact = enable;
841 self
842 }
843
844 pub async fn build(self) -> Result<Runtime, KernexError> {
846 let expanded_dir = kernex_core::shellexpand(&self.data_dir);
847
848 tokio::fs::create_dir_all(&expanded_dir)
850 .await
851 .map_err(|e| KernexError::Config(format!("failed to create data dir: {e}")))?;
852
853 #[cfg(feature = "sqlite-store")]
855 let store = {
856 let db_path = self
857 .db_path
858 .unwrap_or_else(|| format!("{expanded_dir}/memory.db"));
859 let mem_config = MemoryConfig {
860 db_path: db_path.clone(),
861 ..Default::default()
862 };
863 Store::new(&mem_config).await?
864 };
865
866 let skills_data_dir = self.data_dir.clone();
871 let skills =
872 tokio::task::spawn_blocking(move || kernex_skills::load_skills(&skills_data_dir))
873 .await
874 .map_err(|e| {
875 KernexError::skill(kernex_skills::SkillError::Logic(format!(
876 "load_skills task failed: {e}"
877 )))
878 })?;
879 let projects_data_dir = self.data_dir.clone();
880 let projects =
881 tokio::task::spawn_blocking(move || kernex_skills::load_projects(&projects_data_dir))
882 .await
883 .map_err(|e| {
884 KernexError::skill(kernex_skills::SkillError::Logic(format!(
885 "load_projects task failed: {e}"
886 )))
887 })?;
888
889 tracing::info!(
890 "runtime initialized: {} skills, {} projects",
891 skills.len(),
892 projects.len()
893 );
894
895 let hook_runner: Arc<dyn HookRunner> =
896 self.hook_runner.unwrap_or_else(|| Arc::new(NoopHookRunner));
897
898 Ok(Runtime {
899 #[cfg(feature = "sqlite-store")]
900 store,
901 skills,
902 projects,
903 data_dir: expanded_dir,
904 system_prompt: self.system_prompt,
905 channel: self.channel,
906 project: self.project,
907 hook_runner,
908 permission_rules: self.permission_rules,
909 guardrail_runner: self.guardrail_runner,
910 auto_compact: self.auto_compact,
911 })
912 }
913}
914
915impl Default for RuntimeBuilder {
916 fn default() -> Self {
917 Self::new()
918 }
919}
920
921fn warn_if_data_dir_unusual(dir: &str) {
928 let path = std::path::Path::new(dir);
931 if !path.is_absolute() {
932 return;
933 }
934 let s = dir;
935 let in_home = std::env::var("HOME")
936 .ok()
937 .map(|h| !h.is_empty() && s.starts_with(&h))
938 .unwrap_or(false);
939 let usual = in_home
940 || s.starts_with("/tmp/")
941 || s.starts_with("/var/")
942 || s.starts_with("/Users/")
943 || s.starts_with("/home/")
944 || s == "/tmp"
945 || s == "/var";
946 if !usual {
947 tracing::warn!(
948 data_dir = %dir,
949 "KERNEX_DATA_DIR resolves outside $HOME / /tmp / /var — \
950 writes may land in unexpected locations"
951 );
952 }
953}
954
955#[cfg(test)]
956mod tests {
957 use super::*;
958
959 #[tokio::test]
960 async fn test_runtime_builder_creates_runtime() {
961 let tmp_dir = tempfile::TempDir::new().unwrap();
962 let tmp = tmp_dir.path();
963
964 let runtime = RuntimeBuilder::new()
965 .data_dir(tmp.to_str().unwrap())
966 .build()
967 .await
968 .unwrap();
969
970 assert!(runtime.skills.is_empty());
971 assert!(runtime.projects.is_empty());
972 assert!(runtime.system_prompt.is_empty());
973 assert_eq!(runtime.channel, "cli");
974 assert!(runtime.project.is_none());
975 assert!(std::path::Path::new(&runtime.data_dir).exists());
976 }
977
978 #[tokio::test]
979 async fn test_runtime_builder_custom_db_path() {
980 let tmp_dir = tempfile::TempDir::new().unwrap();
981 let tmp = tmp_dir.path();
982
983 let db = tmp.join("custom.db");
984 let runtime = RuntimeBuilder::new()
985 .data_dir(tmp.to_str().unwrap())
986 .db_path(db.to_str().unwrap())
987 .build()
988 .await
989 .unwrap();
990
991 assert!(db.exists());
992 drop(runtime);
993 }
994
995 #[tokio::test]
996 async fn test_runtime_builder_with_config() {
997 let tmp_dir = tempfile::TempDir::new().unwrap();
998 let tmp = tmp_dir.path();
999
1000 let runtime = RuntimeBuilder::new()
1001 .data_dir(tmp.to_str().unwrap())
1002 .system_prompt("You are helpful.")
1003 .channel("api")
1004 .project("my-project")
1005 .build()
1006 .await
1007 .unwrap();
1008
1009 assert_eq!(runtime.system_prompt, "You are helpful.");
1010 assert_eq!(runtime.channel, "api");
1011 assert_eq!(runtime.project, Some("my-project".to_string()));
1012 }
1013
1014 #[tokio::test]
1015 async fn test_runtime_builder_from_config() {
1016 use kernex_core::config::{KernexConfig, MemoryConfig, RuntimeConfig};
1017
1018 let tmp_dir = tempfile::TempDir::new().unwrap();
1019 let tmp = tmp_dir.path();
1020
1021 let cfg = KernexConfig {
1026 runtime: RuntimeConfig {
1027 name: "test-agent".to_string(),
1028 data_dir: tmp.to_str().unwrap().to_string(),
1029 channel: "slack".to_string(),
1030 project: Some("my-proj".to_string()),
1031 system_prompt: "Be concise.".to_string(),
1032 ..RuntimeConfig::default()
1033 },
1034 memory: MemoryConfig {
1035 db_path: tmp.join("memory.db").to_str().unwrap().to_string(),
1036 ..MemoryConfig::default()
1037 },
1038 ..KernexConfig::default()
1039 };
1040
1041 let runtime = RuntimeBuilder::from_config(&cfg).build().await.unwrap();
1042
1043 assert_eq!(runtime.channel, "slack");
1044 assert_eq!(runtime.project, Some("my-proj".to_string()));
1045 assert_eq!(runtime.system_prompt, "Be concise.");
1046 }
1047
1048 #[tokio::test]
1049 async fn test_runtime_builder_from_file_toml() {
1050 use std::io::Write;
1051
1052 let tmp_dir = tempfile::TempDir::new().unwrap();
1053 let tmp = tmp_dir.path();
1054 let escaped = tmp.to_str().unwrap().replace('\\', "\\\\");
1055
1056 let cfg_path = tmp.join("agent.toml");
1060 let mut f = std::fs::File::create(&cfg_path).unwrap();
1061 writeln!(
1062 f,
1063 r#"[runtime]
1064name = "file-agent"
1065data_dir = "{escaped}"
1066channel = "api"
1067project = "file-proj"
1068system_prompt = "From file."
1069
1070[memory]
1071db_path = "{escaped}/memory.db"
1072"#
1073 )
1074 .unwrap();
1075
1076 let runtime = RuntimeBuilder::from_file(cfg_path.to_str().unwrap())
1077 .unwrap()
1078 .build()
1079 .await
1080 .unwrap();
1081
1082 assert_eq!(runtime.channel, "api");
1083 assert_eq!(runtime.project, Some("file-proj".to_string()));
1084 assert_eq!(runtime.system_prompt, "From file.");
1085 }
1086
1087 struct StopReasonProvider(Option<&'static str>);
1090
1091 #[async_trait::async_trait]
1092 impl Provider for StopReasonProvider {
1093 fn name(&self) -> &str {
1094 "mock"
1095 }
1096 fn requires_api_key(&self) -> bool {
1097 false
1098 }
1099 async fn complete(&self, _ctx: &Context) -> Result<Response, KernexError> {
1100 Ok(Response {
1101 text: "partial".to_string(),
1102 metadata: CompletionMeta {
1103 stop_reason: self.0.map(|s| s.to_string()),
1104 ..Default::default()
1105 },
1106 })
1107 }
1108 async fn is_available(&self) -> bool {
1109 true
1110 }
1111 }
1112
1113 async fn run_with_stop_reason(stop: Option<&'static str>) -> RunOutcome {
1114 let tmp_dir = tempfile::TempDir::new().unwrap();
1115 let runtime = RuntimeBuilder::new()
1116 .data_dir(tmp_dir.path().to_str().unwrap())
1117 .build()
1118 .await
1119 .unwrap();
1120 runtime
1121 .run(
1122 &StopReasonProvider(stop),
1123 &Request::text("user-1", "hi"),
1124 &RunConfig::default(),
1125 )
1126 .await
1127 .unwrap()
1128 }
1129
1130 #[tokio::test]
1131 async fn run_maps_max_turns_stop_reason_to_max_turns_outcome() {
1132 let outcome = run_with_stop_reason(Some("max_turns")).await;
1133 assert!(
1134 matches!(outcome, RunOutcome::MaxTurns),
1135 "max_turns stop_reason should yield RunOutcome::MaxTurns, got {outcome:?}"
1136 );
1137 }
1138
1139 #[tokio::test]
1140 async fn run_returns_end_turn_for_normal_stop_reason() {
1141 match run_with_stop_reason(Some("end_turn")).await {
1142 RunOutcome::EndTurn(resp) => assert_eq!(resp.text, "partial"),
1143 other => panic!("expected EndTurn, got {other:?}"),
1144 }
1145 }
1146}