Skip to main content

clawft_core/agent/
context.rs

1//! Context builder for assembling LLM prompts.
2//!
3//! Combines the system prompt, active skill prompts, long-term memory,
4//! and conversation history into a message list suitable for the LLM
5//! pipeline. Ported from Python `nanobot/agent/context.py`.
6//!
7//! # Message assembly order
8//!
9//! 1. **System prompt** (role=`"system"`) -- identity and instructions
10//! 2. **Active skill prompts** (role=`"system"`) -- prefixed with `# Skill: {name}`
11//! 3. **Memory context** (role=`"system"`) -- prefixed with `# Relevant Memory:`
12//! 4. **Conversation history** -- recent messages from the session
13//!
14//! The current user message is **not** added here; the caller appends it.
15
16use 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/// Cached bootstrap file entry with modification-time tracking.
37#[cfg(feature = "native")]
38#[derive(Debug, Clone)]
39struct CachedFile {
40    content: String,
41    mtime: SystemTime,
42}
43
44/// Cache for bootstrap files, keyed by resolved path.
45///
46/// Uses `tokio::sync::Mutex` because the cache is checked/updated
47/// inside async `build_system_prompt()`. The critical section is
48/// short (one HashMap lookup + optional fs::metadata call).
49#[cfg(feature = "native")]
50type BootstrapCache = Arc<Mutex<HashMap<PathBuf, CachedFile>>>;
51
52/// On browser, mtime-based caching is not available, so we use a
53/// simple empty struct as a no-op placeholder.
54#[cfg(not(feature = "native"))]
55type BootstrapCache = Arc<Mutex<()>>;
56
57// Re-export the canonical LlmMessage from pipeline::traits so that
58// consumers of context.rs use the same type as the rest of the pipeline.
59pub use crate::pipeline::traits::LlmMessage;
60
61/// Builder for assembling LLM context from multiple sources.
62///
63/// Combines configuration, memory, skills, and session history into
64/// a structured message list for the LLM pipeline. Each source is
65/// read asynchronously through the platform abstraction.
66///
67/// # Usage
68///
69/// ```rust,ignore
70/// let ctx = ContextBuilder::new(config, memory, skills, platform);
71/// let system_prompt = ctx.build_system_prompt().await;
72/// let messages = ctx.build_messages(&session, &["research"]).await;
73/// ```
74pub 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    /// Create a new context builder.
84    ///
85    /// # Arguments
86    ///
87    /// * `config` -- Agent configuration (system prompt, memory window, etc.)
88    /// * `memory` -- Shared memory store for reading long-term memory
89    /// * `skills` -- Shared skills loader for reading active skill prompts
90    /// * `platform` -- Platform abstraction for filesystem/env access
91    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    /// Load a bootstrap file, using the mtime cache to skip disk reads
112    /// when the file has not been modified since last load.
113    ///
114    /// Returns `Some(content)` if the file exists and is non-empty,
115    /// `None` otherwise.
116    #[cfg(feature = "native")]
117    async fn load_cached_file(
118        platform: &Arc<P>,
119        cache: &BootstrapCache,
120        file_path: &Path,
121    ) -> Option<String> {
122        // Check current mtime via tokio::fs::metadata (platform trait
123        // does not expose metadata, so we bypass it for this stat call).
124        let current_mtime = match tokio::fs::metadata(file_path).await {
125            Ok(meta) => meta.modified().ok(),
126            Err(_) => return None, // file does not exist or is inaccessible
127        };
128
129        // Cache hit: mtime matches
130        {
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        // Cache miss or stale: re-read from disk
140        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    /// Load a bootstrap file (browser version without mtime caching).
159    ///
160    /// On browser/WASM, `tokio::fs::metadata` is not available, so we
161    /// skip the mtime cache and read directly through the platform.
162    #[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    /// Build the system prompt from configuration.
175    ///
176    /// Assembles the core identity prompt. This includes:
177    /// - A static identity header describing the assistant
178    /// - Workspace path information
179    /// - Memory file paths for the agent to reference
180    ///
181    /// The returned string is suitable for a `role="system"` message.
182    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        // Load bootstrap files from the workspace (AGENTS.md, SOUL.md, etc.)
189        // Searches: workspace root first, then .clawft/ subdirectory.
190        // Uses mtime-based cache to skip disk reads when files have not changed.
191        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; // First match wins
213                    }
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        // If SOUL.md or IDENTITY.md exists, use it as the identity preamble
222        // instead of the hardcoded default. The config section is always appended.
223        let has_custom_identity =
224            loaded_files.contains_key("SOUL.md") || loaded_files.contains_key("IDENTITY.md");
225
226        if has_custom_identity {
227            // Custom identity from bootstrap files
228            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            // Append configuration context
235            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            // Default identity when no SOUL.md or IDENTITY.md exists
250            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        // Append remaining bootstrap files (AGENTS.md, USER.md, TOOLS.md)
267        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    /// Build the complete message list for an LLM call.
277    ///
278    /// Assembles messages in the canonical order:
279    /// 1. System prompt (role=`"system"`)
280    /// 2. Active skill prompts (role=`"system"`, one per skill)
281    /// 3. Long-term memory context (role=`"system"`, if non-empty)
282    /// 4. Conversation history from `session.get_history(memory_window)`
283    ///
284    /// The current user message is **not** included -- the caller adds
285    /// it after calling this method.
286    ///
287    /// # Arguments
288    ///
289    /// * `session` -- Current conversation session
290    /// * `active_skills` -- Names of skills to include in context
291    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    /// Get a reference to the agent configuration.
302    pub fn config(&self) -> &AgentsConfig {
303        &self.config
304    }
305
306    /// Build a system prompt message for a specific agent definition.
307    ///
308    /// Prepends the agent's `system_prompt` (with template rendering)
309    /// to the standard system prompt.  If the agent has no custom
310    /// system prompt, only the standard prompt is returned.
311    ///
312    /// # Arguments
313    ///
314    /// * `agent` -- The agent definition to inject
315    /// * `args` -- Arguments string for template variable expansion
316    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    /// Build the complete message list for an agent-aware LLM call.
333    ///
334    /// Like [`build_messages`](Self::build_messages) but additionally:
335    /// - Uses the agent's system prompt (template-rendered)
336    /// - Activates the agent's declared skills
337    /// - Appends any extra skill instructions as a system message
338    ///
339    /// # Arguments
340    ///
341    /// * `session` -- Current conversation session
342    /// * `agent` -- Agent definition to use
343    /// * `args` -- Arguments string for template rendering
344    /// * `extra_skill_instructions` -- Optional additional skill text to inject
345    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    /// Core message assembly logic shared by [`build_messages`] and
358    /// [`build_messages_for_agent`].
359    ///
360    /// Assembles messages in order: system prompt, skill prompts,
361    /// optional extra instructions, memory context, and conversation
362    /// history.
363    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        // 1. System prompt
373        messages.push(LlmMessage {
374            role: "system".into(),
375            content: system_prompt,
376            tool_call_id: None,
377            tool_calls: None,
378        });
379
380        // 2. Active skill prompts
381        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        // 3. Extra skill instructions (e.g. from SkillRegistry)
416        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        // 4. Memory context
428        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        // 5. Conversation history (truncated to memory_window)
444        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
469/// Expand a workspace path, replacing `~/` with the actual home directory.
470fn 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    /// Helper to create a ContextBuilder backed by temp directories.
510    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        // Should have system prompt + memory message
578        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        // No "Relevant Memory" message when memory is empty
595        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        // Create a skill on disk and load it
608        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        // Load it into cache
626        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        // Should have system prompt + 3 history messages
652        // (memory_window=5, so all 3 fit)
653        let history_roles: Vec<&str> = messages
654            .iter()
655            .skip(1) // skip system prompt
656            .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        // memory_window=5, so only last 5 history messages
676        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        // Set up memory
689        memory.write_long_term("a long-term fact").await.unwrap();
690
691        // Set up a skill
692        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        // Set up session
711        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        // Order: system, skill, memory, history
717        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        // Create skill on disk but do NOT pre-load it
735        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        // Request a skill that does not exist
769        let messages = ctx.build_messages(&session, &["nonexistent".into()]).await;
770
771        // Should just have system prompt, no error
772        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    // ── Agent definition integration tests ───────────────────────────
823
824    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        // Should contain the rendered agent prompt
853        assert!(prompt.contains("# Agent: researcher"));
854        assert!(prompt.contains("You are a Rust researcher"));
855        assert!(prompt.contains("Arguments: topic1 topic2"));
856        // Should also contain the base system prompt
857        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        // Should just be the base prompt, no "# Agent:" header
871        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}