1use std::collections::HashMap;
17use std::path::Path;
18use std::sync::Arc;
19#[cfg(feature = "native")]
20use std::path::PathBuf;
21#[cfg(feature = "native")]
22use std::time::SystemTime;
23
24use crate::runtime::Mutex;
25use tracing::{debug, warn};
26
27use clawft_platform::Platform;
28use clawft_types::config::AgentsConfig;
29use clawft_types::session::Session;
30
31use super::agents::AgentDefinition;
32use super::helpers::render_template;
33use super::memory::MemoryStore;
34use super::skills::SkillsLoader;
35
36#[cfg(feature = "native")]
38#[derive(Debug, Clone)]
39struct CachedFile {
40 content: String,
41 mtime: SystemTime,
42}
43
44#[cfg(feature = "native")]
50type BootstrapCache = Arc<Mutex<HashMap<PathBuf, CachedFile>>>;
51
52#[cfg(not(feature = "native"))]
55type BootstrapCache = Arc<Mutex<()>>;
56
57pub use crate::pipeline::traits::LlmMessage;
60
61pub struct ContextBuilder<P: Platform> {
75 config: AgentsConfig,
76 memory: Arc<MemoryStore<P>>,
77 skills: Arc<SkillsLoader<P>>,
78 platform: Arc<P>,
79 bootstrap_cache: BootstrapCache,
80}
81
82impl<P: Platform> ContextBuilder<P> {
83 pub fn new(
92 config: AgentsConfig,
93 memory: Arc<MemoryStore<P>>,
94 skills: Arc<SkillsLoader<P>>,
95 platform: Arc<P>,
96 ) -> Self {
97 Self {
98 config,
99 memory,
100 skills,
101 platform,
102 bootstrap_cache: {
103 #[cfg(feature = "native")]
104 { Arc::new(Mutex::new(HashMap::new())) }
105 #[cfg(not(feature = "native"))]
106 { Arc::new(Mutex::new(())) }
107 },
108 }
109 }
110
111 #[cfg(feature = "native")]
117 async fn load_cached_file(
118 platform: &Arc<P>,
119 cache: &BootstrapCache,
120 file_path: &Path,
121 ) -> Option<String> {
122 let current_mtime = match tokio::fs::metadata(file_path).await {
125 Ok(meta) => meta.modified().ok(),
126 Err(_) => return None, };
128
129 {
131 let cache_guard = cache.lock().await;
132 if let Some(cached) = cache_guard.get(file_path)
133 && current_mtime == Some(cached.mtime)
134 {
135 return Some(cached.content.clone());
136 }
137 }
138
139 match platform.fs().read_to_string(file_path).await {
141 Ok(content) if !content.trim().is_empty() => {
142 if let Some(mtime) = current_mtime {
143 let mut cache_guard = cache.lock().await;
144 cache_guard.insert(
145 file_path.to_path_buf(),
146 CachedFile {
147 content: content.clone(),
148 mtime,
149 },
150 );
151 }
152 Some(content)
153 }
154 _ => None,
155 }
156 }
157
158 #[cfg(not(feature = "native"))]
163 async fn load_cached_file(
164 platform: &Arc<P>,
165 _cache: &BootstrapCache,
166 file_path: &Path,
167 ) -> Option<String> {
168 match platform.fs().read_to_string(file_path).await {
169 Ok(content) if !content.trim().is_empty() => Some(content),
170 _ => None,
171 }
172 }
173
174 pub async fn build_system_prompt(&self) -> String {
183 let workspace = &self.config.defaults.workspace;
184 let model = &self.config.defaults.model;
185
186 let mut parts = Vec::new();
187
188 let bootstrap_files = ["SOUL.md", "IDENTITY.md", "AGENTS.md", "USER.md", "TOOLS.md"];
192 let mut loaded_files = HashMap::new();
193 for filename in &bootstrap_files {
194 let home = self.platform.fs().home_dir();
195 if let Some(home) = home {
196 let ws_path = expand_workspace(workspace, &home);
197 let candidates = [
198 ws_path.join(filename),
199 ws_path.join(".clawft").join(filename),
200 ];
201 for file_path in &candidates {
202 debug!(file = %file_path.display(), "checking for bootstrap file");
203 if let Some(content) = Self::load_cached_file(
204 &self.platform,
205 &self.bootstrap_cache,
206 file_path,
207 )
208 .await
209 {
210 debug!(file = %filename, bytes = content.len(), "loaded bootstrap file");
211 loaded_files.insert(*filename, content);
212 break; }
214 }
215 if !loaded_files.contains_key(filename) {
216 debug!(file = %filename, workspace = %ws_path.display(), "bootstrap file not found");
217 }
218 }
219 }
220
221 let has_custom_identity =
224 loaded_files.contains_key("SOUL.md") || loaded_files.contains_key("IDENTITY.md");
225
226 if has_custom_identity {
227 if let Some(soul) = loaded_files.get("SOUL.md") {
229 parts.push(format!("## SOUL.md\n\n{soul}"));
230 }
231 if let Some(identity) = loaded_files.get("IDENTITY.md") {
232 parts.push(format!("## IDENTITY.md\n\n{identity}"));
233 }
234 parts.push(format!(
236 "## Configuration\n\n\
237 Model: {model}\n\
238 Workspace: {workspace}\n\
239 Memory: {workspace}/memory/MEMORY.md\n\
240 History: {workspace}/memory/HISTORY.md\n\
241 Skills: {workspace}/skills/\n\n\
242 You have access to tools that allow you to:\n\
243 - Read, write, and edit files\n\
244 - Execute shell commands\n\
245 - Search the web and fetch web pages\n\
246 - Send messages to users on chat channels"
247 ));
248 } else {
249 parts.push(format!(
251 "# clawft\n\n\
252 You are clawft, a helpful AI assistant. You have access to tools that allow you to:\n\
253 - Read, write, and edit files\n\
254 - Execute shell commands\n\
255 - Search the web and fetch web pages\n\
256 - Send messages to users on chat channels\n\n\
257 ## Configuration\n\
258 Model: {model}\n\
259 Workspace: {workspace}\n\
260 Memory: {workspace}/memory/MEMORY.md\n\
261 History: {workspace}/memory/HISTORY.md\n\
262 Skills: {workspace}/skills/"
263 ));
264 }
265
266 for filename in &["AGENTS.md", "USER.md", "TOOLS.md"] {
268 if let Some(content) = loaded_files.get(filename) {
269 parts.push(format!("## {filename}\n\n{content}"));
270 }
271 }
272
273 parts.join("\n\n---\n\n")
274 }
275
276 pub async fn build_messages(
292 &self,
293 session: &Session,
294 active_skills: &[String],
295 ) -> Vec<LlmMessage> {
296 let system_prompt = self.build_system_prompt().await;
297 self.build_messages_inner(session, system_prompt, active_skills, None)
298 .await
299 }
300
301 pub fn config(&self) -> &AgentsConfig {
303 &self.config
304 }
305
306 pub async fn build_system_prompt_for_agent(
317 &self,
318 agent: &AgentDefinition,
319 args: &str,
320 ) -> String {
321 let base = self.build_system_prompt().await;
322
323 match &agent.system_prompt {
324 Some(template) => {
325 let rendered = render_template(template, args, &agent.variables);
326 format!("# Agent: {}\n\n{}\n\n---\n\n{}", agent.name, rendered, base)
327 }
328 None => base,
329 }
330 }
331
332 pub async fn build_messages_for_agent(
346 &self,
347 session: &Session,
348 agent: &AgentDefinition,
349 args: &str,
350 extra_skill_instructions: Option<&str>,
351 ) -> Vec<LlmMessage> {
352 let system_prompt = self.build_system_prompt_for_agent(agent, args).await;
353 self.build_messages_inner(session, system_prompt, &agent.skills, extra_skill_instructions)
354 .await
355 }
356
357 async fn build_messages_inner(
364 &self,
365 session: &Session,
366 system_prompt: String,
367 active_skills: &[String],
368 extra_instructions: Option<&str>,
369 ) -> Vec<LlmMessage> {
370 let mut messages = Vec::new();
371
372 messages.push(LlmMessage {
374 role: "system".into(),
375 content: system_prompt,
376 tool_call_id: None,
377 tool_calls: None,
378 });
379
380 for skill_name in active_skills {
382 match self.skills.get_skill(skill_name).await {
383 Some(skill) => {
384 if let Some(ref prompt) = skill.prompt {
385 messages.push(LlmMessage {
386 role: "system".into(),
387 content: format!("# Skill: {}\n\n{}", skill.name, prompt),
388 tool_call_id: None,
389 tool_calls: None,
390 });
391 }
392 }
393 None => match self.skills.load_skill(skill_name).await {
394 Ok(skill) => {
395 if let Some(ref prompt) = skill.prompt {
396 messages.push(LlmMessage {
397 role: "system".into(),
398 content: format!("# Skill: {}\n\n{}", skill.name, prompt),
399 tool_call_id: None,
400 tool_calls: None,
401 });
402 }
403 }
404 Err(e) => {
405 warn!(
406 skill = %skill_name,
407 error = %e,
408 "failed to load skill for context"
409 );
410 }
411 },
412 }
413 }
414
415 if let Some(instructions) = extra_instructions
417 && !instructions.trim().is_empty()
418 {
419 messages.push(LlmMessage {
420 role: "system".into(),
421 content: format!("# Skill Instructions\n\n{instructions}"),
422 tool_call_id: None,
423 tool_calls: None,
424 });
425 }
426
427 match self.memory.read_long_term().await {
429 Ok(memory) if !memory.trim().is_empty() => {
430 messages.push(LlmMessage {
431 role: "system".into(),
432 content: format!("# Relevant Memory:\n\n{memory}"),
433 tool_call_id: None,
434 tool_calls: None,
435 });
436 }
437 Ok(_) => {}
438 Err(e) => {
439 warn!(error = %e, "failed to read long-term memory for context");
440 }
441 }
442
443 let window = self.config.defaults.memory_window.max(0) as usize;
445 let history = session.get_history(window);
446 for msg in history {
447 let role = msg
448 .get("role")
449 .and_then(|v| v.as_str())
450 .unwrap_or("user")
451 .to_string();
452 let content = msg
453 .get("content")
454 .and_then(|v| v.as_str())
455 .unwrap_or("")
456 .to_string();
457 messages.push(LlmMessage {
458 role,
459 content,
460 tool_call_id: None,
461 tool_calls: None,
462 });
463 }
464
465 messages
466 }
467}
468
469fn expand_workspace(workspace: &str, home: &std::path::Path) -> std::path::PathBuf {
471 if let Some(rest) = workspace.strip_prefix("~/") {
472 home.join(rest)
473 } else {
474 std::path::PathBuf::from(workspace)
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481 use crate::agent::memory::MemoryStore;
482 use crate::agent::skills::SkillsLoader;
483 use clawft_platform::NativePlatform;
484 use clawft_types::config::AgentDefaults;
485 use std::path::PathBuf;
486 use std::sync::atomic::{AtomicU64, Ordering};
487
488 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
489
490 fn temp_dir(prefix: &str) -> PathBuf {
491 let id = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
492 let pid = std::process::id();
493 std::env::temp_dir().join(format!("clawft_ctx_test_{prefix}_{pid}_{id}"))
494 }
495
496 fn test_config() -> AgentsConfig {
497 AgentsConfig {
498 defaults: AgentDefaults {
499 workspace: "~/.clawft/workspace".into(),
500 model: "test-model/v1".into(),
501 max_tokens: 4096,
502 temperature: 0.5,
503 max_tool_iterations: 10,
504 memory_window: 5,
505 },
506 }
507 }
508
509 async fn setup(
511 prefix: &str,
512 ) -> (
513 ContextBuilder<NativePlatform>,
514 PathBuf,
515 Arc<MemoryStore<NativePlatform>>,
516 Arc<SkillsLoader<NativePlatform>>,
517 ) {
518 let dir = temp_dir(prefix);
519 let mem_dir = dir.join("memory");
520 let skills_dir = dir.join("skills");
521
522 let platform = Arc::new(NativePlatform::new());
523
524 let memory = Arc::new(MemoryStore::with_paths(
525 mem_dir.join("MEMORY.md"),
526 mem_dir.join("HISTORY.md"),
527 platform.clone(),
528 ));
529
530 let skills = Arc::new(SkillsLoader::with_dir(skills_dir.clone(), platform.clone()));
531
532 let ctx = ContextBuilder::new(test_config(), memory.clone(), skills.clone(), platform);
533
534 (ctx, dir, memory, skills)
535 }
536
537 #[tokio::test]
538 async fn build_system_prompt_contains_identity() {
539 let (ctx, dir, _, _) = setup("prompt").await;
540
541 let prompt = ctx.build_system_prompt().await;
542
543 assert!(prompt.contains("clawft"));
544 assert!(prompt.contains("test-model/v1"));
545 assert!(prompt.contains("MEMORY.md"));
546 assert!(prompt.contains("HISTORY.md"));
547
548 let _ = tokio::fs::remove_dir_all(&dir).await;
549 }
550
551 #[tokio::test]
552 async fn build_messages_includes_system_prompt() {
553 let (ctx, dir, _, _) = setup("sys_msg").await;
554 let session = Session::new("test:1");
555
556 let messages = ctx.build_messages(&session, &[]).await;
557
558 assert!(!messages.is_empty());
559 assert_eq!(messages[0].role, "system");
560 assert!(messages[0].content.contains("clawft"));
561
562 let _ = tokio::fs::remove_dir_all(&dir).await;
563 }
564
565 #[tokio::test]
566 async fn build_messages_includes_memory() {
567 let (ctx, dir, memory, _) = setup("memory").await;
568
569 memory
570 .write_long_term("important fact: Rust is fast")
571 .await
572 .unwrap();
573
574 let session = Session::new("test:2");
575 let messages = ctx.build_messages(&session, &[]).await;
576
577 let memory_msg = messages
579 .iter()
580 .find(|m| m.content.contains("Relevant Memory"));
581 assert!(memory_msg.is_some());
582 assert!(memory_msg.unwrap().content.contains("Rust is fast"));
583
584 let _ = tokio::fs::remove_dir_all(&dir).await;
585 }
586
587 #[tokio::test]
588 async fn build_messages_skips_empty_memory() {
589 let (ctx, dir, _, _) = setup("no_mem").await;
590 let session = Session::new("test:3");
591
592 let messages = ctx.build_messages(&session, &[]).await;
593
594 let memory_msg = messages
596 .iter()
597 .find(|m| m.content.contains("Relevant Memory"));
598 assert!(memory_msg.is_none());
599
600 let _ = tokio::fs::remove_dir_all(&dir).await;
601 }
602
603 #[tokio::test]
604 async fn build_messages_includes_skill_prompts() {
605 let (ctx, dir, _, skills) = setup("skills").await;
606
607 let skills_dir = dir.join("skills");
609 tokio::fs::create_dir_all(skills_dir.join("research"))
610 .await
611 .unwrap();
612 tokio::fs::write(
613 skills_dir.join("research").join("skill.json"),
614 r#"{"name":"research","description":"Research skill","variables":["topic"]}"#,
615 )
616 .await
617 .unwrap();
618 tokio::fs::write(
619 skills_dir.join("research").join("prompt.md"),
620 "You are a research expert.",
621 )
622 .await
623 .unwrap();
624
625 skills.load_skill("research").await.unwrap();
627
628 let session = Session::new("test:4");
629 let messages = ctx.build_messages(&session, &["research".into()]).await;
630
631 let skill_msg = messages
632 .iter()
633 .find(|m| m.content.contains("# Skill: research"));
634 assert!(skill_msg.is_some());
635 assert!(skill_msg.unwrap().content.contains("research expert"));
636
637 let _ = tokio::fs::remove_dir_all(&dir).await;
638 }
639
640 #[tokio::test]
641 async fn build_messages_includes_conversation_history() {
642 let (ctx, dir, _, _) = setup("history").await;
643
644 let mut session = Session::new("test:5");
645 session.add_message("user", "What is Rust?", None);
646 session.add_message("assistant", "Rust is a systems language.", None);
647 session.add_message("user", "Tell me more.", None);
648
649 let messages = ctx.build_messages(&session, &[]).await;
650
651 let history_roles: Vec<&str> = messages
654 .iter()
655 .skip(1) .map(|m| m.role.as_str())
657 .collect();
658
659 assert_eq!(history_roles, vec!["user", "assistant", "user"]);
660
661 let _ = tokio::fs::remove_dir_all(&dir).await;
662 }
663
664 #[tokio::test]
665 async fn build_messages_truncates_history_to_window() {
666 let (ctx, dir, _, _) = setup("truncate").await;
667
668 let mut session = Session::new("test:6");
669 for i in 0..20 {
670 session.add_message("user", &format!("message {i}"), None);
671 }
672
673 let messages = ctx.build_messages(&session, &[]).await;
674
675 let history_msgs: Vec<&LlmMessage> = messages.iter().filter(|m| m.role == "user").collect();
677 assert_eq!(history_msgs.len(), 5);
678 assert!(history_msgs[0].content.contains("message 15"));
679 assert!(history_msgs[4].content.contains("message 19"));
680
681 let _ = tokio::fs::remove_dir_all(&dir).await;
682 }
683
684 #[tokio::test]
685 async fn build_messages_order_is_correct() {
686 let (ctx, dir, memory, skills) = setup("order").await;
687
688 memory.write_long_term("a long-term fact").await.unwrap();
690
691 let skills_dir = dir.join("skills");
693 tokio::fs::create_dir_all(skills_dir.join("test_skill"))
694 .await
695 .unwrap();
696 tokio::fs::write(
697 skills_dir.join("test_skill").join("skill.json"),
698 r#"{"name":"test_skill","description":"Test","variables":[]}"#,
699 )
700 .await
701 .unwrap();
702 tokio::fs::write(
703 skills_dir.join("test_skill").join("prompt.md"),
704 "skill prompt content",
705 )
706 .await
707 .unwrap();
708 skills.load_skill("test_skill").await.unwrap();
709
710 let mut session = Session::new("test:7");
712 session.add_message("user", "hello", None);
713
714 let messages = ctx.build_messages(&session, &["test_skill".into()]).await;
715
716 assert_eq!(messages.len(), 4);
718 assert_eq!(messages[0].role, "system");
719 assert!(messages[0].content.contains("clawft"));
720 assert_eq!(messages[1].role, "system");
721 assert!(messages[1].content.contains("# Skill: test_skill"));
722 assert_eq!(messages[2].role, "system");
723 assert!(messages[2].content.contains("Relevant Memory"));
724 assert_eq!(messages[3].role, "user");
725 assert_eq!(messages[3].content, "hello");
726
727 let _ = tokio::fs::remove_dir_all(&dir).await;
728 }
729
730 #[tokio::test]
731 async fn build_messages_loads_uncached_skill_on_demand() {
732 let (ctx, dir, _, _) = setup("on_demand").await;
733
734 let skills_dir = dir.join("skills");
736 tokio::fs::create_dir_all(skills_dir.join("lazy_skill"))
737 .await
738 .unwrap();
739 tokio::fs::write(
740 skills_dir.join("lazy_skill").join("skill.json"),
741 r#"{"name":"lazy_skill","description":"Lazy","variables":[]}"#,
742 )
743 .await
744 .unwrap();
745 tokio::fs::write(
746 skills_dir.join("lazy_skill").join("prompt.md"),
747 "loaded on demand",
748 )
749 .await
750 .unwrap();
751
752 let session = Session::new("test:8");
753 let messages = ctx.build_messages(&session, &["lazy_skill".into()]).await;
754
755 let skill_msg = messages
756 .iter()
757 .find(|m| m.content.contains("# Skill: lazy_skill"));
758 assert!(skill_msg.is_some());
759
760 let _ = tokio::fs::remove_dir_all(&dir).await;
761 }
762
763 #[tokio::test]
764 async fn build_messages_handles_missing_skill_gracefully() {
765 let (ctx, dir, _, _) = setup("missing_skill").await;
766 let session = Session::new("test:9");
767
768 let messages = ctx.build_messages(&session, &["nonexistent".into()]).await;
770
771 assert!(!messages.is_empty());
773 assert_eq!(messages[0].role, "system");
774
775 let _ = tokio::fs::remove_dir_all(&dir).await;
776 }
777
778 #[test]
779 fn expand_workspace_with_tilde() {
780 let home = PathBuf::from("/home/testuser");
781 let expanded = expand_workspace("~/.clawft/workspace", &home);
782 assert_eq!(expanded, PathBuf::from("/home/testuser/.clawft/workspace"));
783 }
784
785 #[test]
786 fn expand_workspace_absolute() {
787 let home = PathBuf::from("/home/testuser");
788 let expanded = expand_workspace("/opt/workspace", &home);
789 assert_eq!(expanded, PathBuf::from("/opt/workspace"));
790 }
791
792 #[test]
793 fn config_accessor() {
794 let config = test_config();
795 let platform = Arc::new(NativePlatform::new());
796 let memory = Arc::new(MemoryStore::with_paths(
797 PathBuf::from("/tmp/m.md"),
798 PathBuf::from("/tmp/h.md"),
799 platform.clone(),
800 ));
801 let skills = Arc::new(SkillsLoader::with_dir(
802 PathBuf::from("/tmp/skills"),
803 platform.clone(),
804 ));
805 let ctx = ContextBuilder::new(config.clone(), memory, skills, platform);
806 assert_eq!(ctx.config().defaults.memory_window, 5);
807 }
808
809 #[tokio::test]
810 async fn llm_message_has_expected_fields() {
811 let msg = LlmMessage {
812 role: "system".into(),
813 content: "test content".into(),
814 tool_call_id: Some("tc-1".into()),
815 tool_calls: None,
816 };
817 assert_eq!(msg.role, "system");
818 assert_eq!(msg.content, "test content");
819 assert_eq!(msg.tool_call_id.as_deref(), Some("tc-1"));
820 }
821
822 use crate::agent::agents::AgentDefinition;
825 use std::collections::HashMap;
826
827 fn test_agent() -> AgentDefinition {
828 let mut vars = HashMap::new();
829 vars.insert("lang".into(), "Rust".into());
830 AgentDefinition {
831 name: "researcher".into(),
832 description: "Research agent".into(),
833 model: Some("custom-model/v2".into()),
834 system_prompt: Some("You are a ${lang} researcher. Arguments: $ARGUMENTS".into()),
835 skills: vec![],
836 allowed_tools: vec!["read_file".into()],
837 max_turns: Some(5),
838 variables: vars,
839 source_path: None,
840 }
841 }
842
843 #[tokio::test]
844 async fn build_system_prompt_for_agent_renders_template() {
845 let (ctx, dir, _, _) = setup("agent_prompt").await;
846 let agent = test_agent();
847
848 let prompt = ctx
849 .build_system_prompt_for_agent(&agent, "topic1 topic2")
850 .await;
851
852 assert!(prompt.contains("# Agent: researcher"));
854 assert!(prompt.contains("You are a Rust researcher"));
855 assert!(prompt.contains("Arguments: topic1 topic2"));
856 assert!(prompt.contains("clawft"));
858
859 let _ = tokio::fs::remove_dir_all(&dir).await;
860 }
861
862 #[tokio::test]
863 async fn build_system_prompt_for_agent_without_custom_prompt() {
864 let (ctx, dir, _, _) = setup("agent_no_prompt").await;
865 let mut agent = test_agent();
866 agent.system_prompt = None;
867
868 let prompt = ctx.build_system_prompt_for_agent(&agent, "").await;
869
870 assert!(!prompt.contains("# Agent:"));
872 assert!(prompt.contains("clawft"));
873
874 let _ = tokio::fs::remove_dir_all(&dir).await;
875 }
876
877 #[tokio::test]
878 async fn build_messages_for_agent_includes_agent_system_prompt() {
879 let (ctx, dir, _, _) = setup("agent_msgs").await;
880 let agent = test_agent();
881 let session = Session::new("test:agent1");
882
883 let messages = ctx
884 .build_messages_for_agent(&session, &agent, "my-args", None)
885 .await;
886
887 assert!(!messages.is_empty());
888 assert_eq!(messages[0].role, "system");
889 assert!(messages[0].content.contains("# Agent: researcher"));
890 assert!(messages[0].content.contains("Rust researcher"));
891
892 let _ = tokio::fs::remove_dir_all(&dir).await;
893 }
894
895 #[tokio::test]
896 async fn build_messages_for_agent_injects_extra_skill_instructions() {
897 let (ctx, dir, _, _) = setup("agent_skill_instr").await;
898 let agent = test_agent();
899 let session = Session::new("test:agent2");
900
901 let messages = ctx
902 .build_messages_for_agent(&session, &agent, "", Some("Always cite your sources."))
903 .await;
904
905 let skill_instr = messages
906 .iter()
907 .find(|m| m.content.contains("# Skill Instructions"));
908 assert!(skill_instr.is_some());
909 assert!(
910 skill_instr
911 .unwrap()
912 .content
913 .contains("Always cite your sources.")
914 );
915
916 let _ = tokio::fs::remove_dir_all(&dir).await;
917 }
918
919 #[tokio::test]
920 async fn build_messages_for_agent_skips_empty_skill_instructions() {
921 let (ctx, dir, _, _) = setup("agent_empty_instr").await;
922 let agent = test_agent();
923 let session = Session::new("test:agent3");
924
925 let messages = ctx
926 .build_messages_for_agent(&session, &agent, "", Some(" "))
927 .await;
928
929 let skill_instr = messages
930 .iter()
931 .find(|m| m.content.contains("# Skill Instructions"));
932 assert!(skill_instr.is_none());
933
934 let _ = tokio::fs::remove_dir_all(&dir).await;
935 }
936
937 #[tokio::test]
938 async fn build_messages_for_agent_includes_memory() {
939 let (ctx, dir, memory, _) = setup("agent_memory").await;
940 let agent = test_agent();
941
942 memory
943 .write_long_term("agent-level memory test")
944 .await
945 .unwrap();
946
947 let session = Session::new("test:agent4");
948 let messages = ctx
949 .build_messages_for_agent(&session, &agent, "", None)
950 .await;
951
952 let mem_msg = messages
953 .iter()
954 .find(|m| m.content.contains("Relevant Memory"));
955 assert!(mem_msg.is_some());
956 assert!(mem_msg.unwrap().content.contains("agent-level memory test"));
957
958 let _ = tokio::fs::remove_dir_all(&dir).await;
959 }
960
961 #[tokio::test]
962 async fn build_messages_for_agent_includes_history() {
963 let (ctx, dir, _, _) = setup("agent_history").await;
964 let agent = test_agent();
965
966 let mut session = Session::new("test:agent5");
967 session.add_message("user", "hello agent", None);
968 session.add_message("assistant", "hello user", None);
969
970 let messages = ctx
971 .build_messages_for_agent(&session, &agent, "", None)
972 .await;
973
974 let user_msgs: Vec<_> = messages.iter().filter(|m| m.role == "user").collect();
975 assert_eq!(user_msgs.len(), 1);
976 assert_eq!(user_msgs[0].content, "hello agent");
977
978 let _ = tokio::fs::remove_dir_all(&dir).await;
979 }
980}