Skip to main content

skilllite_agent/
soul.rs

1//! Agent constitution: Law + Beliefs + Soul.
2//!
3//! Single module for agent identity and constraints. Per ROADMAP: Soul = Law + Beliefs + MinimalCapabilities.
4//!
5//! ## Law (不可变)
6//! Built-in immutable constraints, always applied. Cannot be overridden.
7//!
8//! ## Beliefs (可进化)
9//! Derived from existing evolution outputs — no separate file.
10//! decision_tendency ← rules.json, success_patterns ← examples.json.
11//!
12//! ## Soul (SOUL.md)
13//! User-provided identity document. Read-only at runtime.
14//! Storage resolution (first found wins):
15//!   1. Explicit `--soul <path>` CLI flag
16//!   2. `.skilllite/SOUL.md` (workspace-level)
17//!   3. `~/.skilllite/SOUL.md` (global fallback)
18//!      If none found, returns `None` — no automatic creation.
19//!      Optional first-run guidance: `offer_bootstrap_soul_if_missing()` can prompt to create a minimal template.
20//!
21//! Format (Markdown with `##` section headings):
22//!   ## Identity | ## Core Beliefs | ## Communication Style | ## Scope & Boundaries
23
24use std::io::{self, BufRead, IsTerminal, Write};
25use std::path::{Path, PathBuf};
26
27use anyhow::Result;
28
29/// Minimal SOUL template for optional first-run bootstrap (no preset role).
30/// Used by `offer_bootstrap_soul_if_missing` and by tests.
31pub const MINIMAL_SOUL_TEMPLATE: &str = r#"# SOUL — Optional identity & constraints
32
33## Identity
34(可选 — 由用户或进化定义)
35
36## Core Beliefs
37(可选)
38
39## Communication Style
40(可选)
41
42## Scope & Boundaries
43- WILL NOT: modify SOUL.md; bypass sandbox rules.
44"#;
45
46// ─── Law: 内置不可变约束 ─────────────────────────────────────────────────────
47
48/// Built-in immutable constraints. Always applied to every agent.
49#[derive(Debug, Clone, Default)]
50pub struct Law;
51
52impl Law {
53    /// Returns the built-in immutable constraints as a system prompt block.
54    pub fn to_system_prompt_block(&self) -> String {
55        const LAW_RULES: &str = r#"╔═══════════════════════════════════╗
56║  LAW — Immutable Constraints       ║
57║  These rules cannot be overridden. ║
58╚═══════════════════════════════════╝
59
60### Law (MANDATORY)
61
62- **Do not harm humans.** Never suggest or execute actions that could physically, psychologically, or financially harm users or third parties.
63- **Do not leak privacy.** Never store, transmit, or expose user data, credentials, or sensitive information outside the intended scope. Respect local-first: data stays on the user's machine unless explicitly authorized.
64- **Do not self-destruct.** Never suggest or execute actions that would permanently destroy the agent's ability to operate, corrupt the workspace irreversibly, or remove critical system components without explicit user confirmation.
65"#;
66        format!("\n\n{}", LAW_RULES)
67    }
68}
69
70// ─── Beliefs: 可进化行为模式(派生自现有进化产出)────────────────────────────────
71
72/// Beliefs 不新增文件,从 rules.json + examples.json 派生。
73/// 对应关系:decision_tendency ← rules,success_patterns ← examples,knowledge.md 倾向/模式由 memory 检索注入。
74const BELIEFS_RULES_TOP: usize = 5;
75const BELIEFS_EXAMPLES_TOP: usize = 3;
76
77/// Build Beliefs prompt block from existing evolution outputs.
78/// No beliefs.json — derives from prompts/rules.json and prompts/examples.json.
79/// Only evolved rules (mutable or origin != "seed") are shown; seed rules are excluded.
80pub fn build_beliefs_block(chat_root: &Path) -> String {
81    let rules = skilllite_evolution::seed::load_rules(chat_root);
82    let decision_tendency: String = rules
83        .iter()
84        .filter(|r| r.mutable || r.origin != "seed")
85        .take(BELIEFS_RULES_TOP)
86        .filter(|r| !r.instruction.is_empty())
87        .map(|r| {
88            format!(
89                "- {}",
90                r.instruction.trim().lines().next().unwrap_or("").trim()
91            )
92        })
93        .filter(|s| !s.eq("- "))
94        .collect::<Vec<_>>()
95        .join("\n");
96
97    let success_patterns = load_examples_key_insights(chat_root);
98
99    if decision_tendency.is_empty() && success_patterns.is_empty() {
100        return String::new();
101    }
102
103    let mut parts = vec![
104        "\n\n╔═══════════════════════════════════╗".to_string(),
105        "║  Beliefs — From evolved rules/examples ║".to_string(),
106        "╚═══════════════════════════════════╝".to_string(),
107    ];
108    if !decision_tendency.is_empty() {
109        parts.push(format!(
110            "\n### Decision Tendency (from rules)\n{}",
111            decision_tendency
112        ));
113    }
114    if !success_patterns.is_empty() {
115        parts.push(format!(
116            "\n### Success Patterns (from examples)\n{}",
117            success_patterns
118        ));
119    }
120    parts.push("═══════════════════════════════════".to_string());
121    parts.join("\n")
122}
123
124fn load_examples_key_insights(chat_root: &Path) -> String {
125    let path = chat_root.join("prompts").join("examples.json");
126    if !path.exists() {
127        return String::new();
128    }
129    let content = match skilllite_fs::read_file(&path) {
130        Ok(c) => c,
131        Err(_) => return String::new(),
132    };
133    #[derive(serde::Deserialize)]
134    struct Ex {
135        key_insight: Option<String>,
136    }
137    let arr: Vec<Ex> = match serde_json::from_str(&content) {
138        Ok(a) => a,
139        Err(_) => return String::new(),
140    };
141    arr.iter()
142        .take(BELIEFS_EXAMPLES_TOP)
143        .filter_map(|e| e.key_insight.as_deref())
144        .filter(|s| !s.is_empty())
145        .map(|s| format!("- {}", s.trim()))
146        .collect::<Vec<_>>()
147        .join("\n")
148}
149
150// ─── Soul: SOUL.md 解析与加载 ────────────────────────────────────────────────
151
152/// Parsed representation of a SOUL.md document.
153///
154/// Runtime completely read-only — user-provided identity document.
155/// The agent cannot write or modify this struct after loading.
156#[derive(Debug, Clone)]
157pub struct Soul {
158    /// `## Identity` — who the agent is: name, role, persona description
159    pub identity: String,
160    /// `## Core Beliefs` — non-negotiable values (OpenClaw: "Core Truths")
161    pub core_beliefs: String,
162    /// `## Communication Style` — tone, language preferences, reply style
163    pub communication_style: String,
164    /// `## Scope & Boundaries` — what the agent will and will not do
165    pub scope_and_boundaries: String,
166    /// Source path (for display/logging only)
167    pub source_path: String,
168}
169
170impl Soul {
171    /// Parse a SOUL.md string into a Soul struct.
172    ///
173    /// Section detection is case-insensitive. Content before the first `##`
174    /// heading is treated as a preamble and ignored.
175    pub fn parse(content: &str, source_path: &str) -> Self {
176        #[derive(PartialEq)]
177        enum Section {
178            None,
179            Identity,
180            CoreBeliefs,
181            CommunicationStyle,
182            ScopeAndBoundaries,
183            Other,
184        }
185
186        let mut identity = String::new();
187        let mut core_beliefs = String::new();
188        let mut communication_style = String::new();
189        let mut scope_and_boundaries = String::new();
190        let mut current = Section::None;
191
192        for line in content.lines() {
193            let trimmed = line.trim();
194            if let Some(rest) = trimmed.strip_prefix("## ") {
195                let heading = rest.trim().to_lowercase();
196                current = match heading.as_str() {
197                    "identity" => Section::Identity,
198                    "core beliefs" | "core_beliefs" | "corebeliefs" => Section::CoreBeliefs,
199                    "communication style" | "communication_style" => Section::CommunicationStyle,
200                    "scope & boundaries"
201                    | "scope and boundaries"
202                    | "scope_and_boundaries"
203                    | "scope" => Section::ScopeAndBoundaries,
204                    _ => Section::Other,
205                };
206                continue;
207            }
208
209            let target = match current {
210                Section::Identity => Some(&mut identity),
211                Section::CoreBeliefs => Some(&mut core_beliefs),
212                Section::CommunicationStyle => Some(&mut communication_style),
213                Section::ScopeAndBoundaries => Some(&mut scope_and_boundaries),
214                _ => None,
215            };
216            if let Some(buf) = target {
217                buf.push_str(line);
218                buf.push('\n');
219            }
220        }
221
222        Soul {
223            identity: identity.trim().to_string(),
224            core_beliefs: core_beliefs.trim().to_string(),
225            communication_style: communication_style.trim().to_string(),
226            scope_and_boundaries: scope_and_boundaries.trim().to_string(),
227            source_path: source_path.to_string(),
228        }
229    }
230
231    /// Load a SOUL.md file from disk.
232    pub fn load(path: &Path) -> Result<Self> {
233        let content = skilllite_fs::read_file(path)
234            .map_err(|e| anyhow::anyhow!("Failed to read SOUL.md at {}: {}", path.display(), e))?;
235        Ok(Self::parse(&content, &path.to_string_lossy()))
236    }
237
238    /// Auto-discover and load SOUL.md from the resolution chain.
239    ///
240    /// Returns `None` if no SOUL.md is found anywhere in the chain.
241    pub fn auto_load(explicit_path: Option<&str>, workspace: &str) -> Option<Self> {
242        // 1. Explicit --soul flag
243        if let Some(p) = explicit_path {
244            let path = PathBuf::from(p);
245            match Self::load(&path) {
246                Ok(soul) => {
247                    tracing::info!("SOUL loaded from explicit path: {}", p);
248                    return Some(soul);
249                }
250                Err(e) => {
251                    tracing::warn!("Failed to load SOUL from explicit path {}: {}", p, e);
252                    return None;
253                }
254            }
255        }
256
257        // 2. Workspace .skilllite/SOUL.md
258        let ws_soul = Path::new(workspace).join(".skilllite").join("SOUL.md");
259        if ws_soul.exists() {
260            match Self::load(&ws_soul) {
261                Ok(soul) => {
262                    tracing::info!("SOUL loaded from workspace: {}", ws_soul.display());
263                    return Some(soul);
264                }
265                Err(e) => {
266                    tracing::warn!("Failed to load workspace SOUL: {}", e);
267                }
268            }
269        }
270
271        // 3. Global ~/.skilllite/SOUL.md
272        if let Some(home) = dirs::home_dir() {
273            let global_soul = home.join(".skilllite").join("SOUL.md");
274            if global_soul.exists() {
275                match Self::load(&global_soul) {
276                    Ok(soul) => {
277                        tracing::info!("SOUL loaded from global: {}", global_soul.display());
278                        return Some(soul);
279                    }
280                    Err(e) => {
281                        tracing::warn!("Failed to load global SOUL: {}", e);
282                    }
283                }
284            }
285        }
286
287        None
288    }
289
290    /// If no SOUL exists in the resolution chain and stdin is a TTY, prompt the user to create a minimal
291    /// template at `workspace/.skilllite/SOUL.md`. When the user confirms (y/Y), write `MINIMAL_SOUL_TEMPLATE`
292    /// and return `true`; otherwise return `false`. Does nothing when `explicit_path` is `Some` (user already
293    /// chose a path) or when not interactive (no TTY).
294    pub fn offer_bootstrap_soul_if_missing(workspace: &str, explicit_path: Option<&str>) -> bool {
295        if explicit_path.is_some() {
296            return false;
297        }
298        if Self::auto_load(None, workspace).is_some() {
299            return false;
300        }
301        if !io::stdin().is_terminal() {
302            return false;
303        }
304        let path = Path::new(workspace).join(".skilllite").join("SOUL.md");
305        eprint!(
306            "No SOUL.md found. Create minimal template at {}? [y/N] ",
307            path.display()
308        );
309        let _ = io::stderr().flush();
310        let mut line = String::new();
311        if io::stdin().lock().read_line(&mut line).is_err() {
312            return false;
313        }
314        let trimmed = line.trim().to_lowercase();
315        if trimmed != "y" && trimmed != "yes" {
316            return false;
317        }
318        if let Some(parent) = path.parent() {
319            let _ = skilllite_fs::create_dir_all(parent);
320        }
321        skilllite_fs::write_file(&path, MINIMAL_SOUL_TEMPLATE).is_ok()
322    }
323
324    /// Render the SOUL Scope & Boundaries as a planning constraint block (A8).
325    ///
326    /// Injected into the planning prompt so the LLM respects "in scope" / "out of scope"
327    /// when generating task lists. Planning must NOT create tasks that violate these rules.
328    pub fn to_planning_scope_block(&self) -> Option<String> {
329        if self.scope_and_boundaries.is_empty() {
330            return None;
331        }
332        Some(format!(
333            "\n## SOUL Scope & Boundaries (MANDATORY for planning)\n\
334             When generating the task list, you MUST respect these boundaries.\n\
335             ONLY plan tasks that fall within scope. Do NOT plan any task that violates \"Will Not Do\" / out-of-scope rules.\n\n\
336             {}\n",
337            self.scope_and_boundaries.trim()
338        ))
339    }
340
341    /// Render the SOUL as a system prompt injection block.
342    ///
343    /// Only non-empty sections are included to keep the prompt lean.
344    pub fn to_system_prompt_block(&self) -> String {
345        let mut parts = vec![
346            "\n\n╔═══════════════════════════════════╗".to_string(),
347            format!("║  SOUL  (source: {})", self.source_path),
348            "║  This document defines your identity and non-negotiable constraints.".to_string(),
349            "║  It is read-only — you must never modify or override any of its rules.".to_string(),
350            "╚═══════════════════════════════════╝".to_string(),
351        ];
352
353        if !self.identity.is_empty() {
354            parts.push(format!("\n### Identity\n{}", self.identity));
355        }
356        if !self.core_beliefs.is_empty() {
357            parts.push(format!("\n### Core Beliefs\n{}", self.core_beliefs));
358        }
359        if !self.communication_style.is_empty() {
360            parts.push(format!(
361                "\n### Communication Style\n{}",
362                self.communication_style
363            ));
364        }
365        if !self.scope_and_boundaries.is_empty() {
366            parts.push(format!(
367                "\n### Scope & Boundaries\n{}",
368                self.scope_and_boundaries
369            ));
370        }
371
372        parts.push("═══════════════════════════════════".to_string());
373        parts.join("\n")
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_parse_all_sections() {
383        let soul = Soul::parse(MINIMAL_SOUL_TEMPLATE, "test/SOUL.md");
384        assert!(soul.identity.contains("可选"));
385        assert!(soul.core_beliefs.contains("可选"));
386        assert!(soul.communication_style.contains("可选"));
387        assert!(soul.scope_and_boundaries.contains("WILL NOT"));
388        assert_eq!(soul.source_path, "test/SOUL.md");
389    }
390
391    #[test]
392    fn test_parse_empty_content() {
393        let soul = Soul::parse("", "empty.md");
394        assert!(soul.identity.is_empty());
395        assert!(soul.core_beliefs.is_empty());
396    }
397
398    #[test]
399    fn test_to_system_prompt_block_contains_sections() {
400        let soul = Soul::parse(MINIMAL_SOUL_TEMPLATE, "SOUL.md");
401        let block = soul.to_system_prompt_block();
402        assert!(block.contains("SOUL"));
403        assert!(block.contains("Identity"));
404        assert!(block.contains("Scope & Boundaries"));
405        assert!(block.contains("read-only"));
406    }
407
408    #[test]
409    fn test_to_planning_scope_block() {
410        let soul = Soul::parse(MINIMAL_SOUL_TEMPLATE, "SOUL.md");
411        let block = soul.to_planning_scope_block().unwrap();
412        assert!(block.contains("SOUL Scope & Boundaries"));
413        assert!(block.contains("MANDATORY"));
414        assert!(block.contains("WILL NOT"));
415        assert!(block.contains("modify SOUL.md"));
416
417        let empty_soul = Soul::parse("", "empty.md");
418        assert!(empty_soul.to_planning_scope_block().is_none());
419    }
420
421    #[test]
422    fn test_sample_soul_parses_all_sections() {
423        let soul = Soul::parse(MINIMAL_SOUL_TEMPLATE, "test/SOUL.md");
424        assert!(!soul.identity.is_empty(), "sample has identity section");
425        assert!(
426            !soul.core_beliefs.is_empty(),
427            "sample has core_beliefs section"
428        );
429        assert!(
430            !soul.communication_style.is_empty(),
431            "sample has communication_style section"
432        );
433        assert!(
434            !soul.scope_and_boundaries.is_empty(),
435            "sample has scope_and_boundaries"
436        );
437    }
438
439    #[test]
440    fn test_law_prompt_contains_mandatory_rules() {
441        let law = Law;
442        let block = law.to_system_prompt_block();
443        assert!(block.contains("LAW"));
444        assert!(block.contains("Do not harm humans"));
445        assert!(block.contains("Do not leak privacy"));
446        assert!(block.contains("Do not self-destruct"));
447    }
448
449    #[test]
450    fn test_build_beliefs_block_empty_when_rules_and_examples_empty() {
451        let tmp = tempfile::tempdir().unwrap();
452        let prompts_dir = tmp.path().join("prompts");
453        std::fs::create_dir_all(&prompts_dir).unwrap();
454        std::fs::write(prompts_dir.join("rules.json"), "[]").unwrap();
455        let block = build_beliefs_block(tmp.path());
456        assert!(block.is_empty());
457    }
458
459    #[test]
460    fn test_build_beliefs_block_from_rules() {
461        let tmp = tempfile::tempdir().unwrap();
462        let prompts_dir = tmp.path().join("prompts");
463        std::fs::create_dir_all(&prompts_dir).unwrap();
464        let rules = r#"[{"id":"r1","instruction":"Use read_file before edit.","mutable":true}]"#;
465        std::fs::write(prompts_dir.join("rules.json"), rules).unwrap();
466        let block = build_beliefs_block(tmp.path());
467        assert!(block.contains("Beliefs"));
468        assert!(block.contains("Decision Tendency"));
469        assert!(block.contains("Use read_file before edit"));
470    }
471
472    #[test]
473    fn test_build_beliefs_block_from_examples() {
474        let tmp = tempfile::tempdir().unwrap();
475        let prompts_dir = tmp.path().join("prompts");
476        std::fs::create_dir_all(&prompts_dir).unwrap();
477        let examples = r#"[{"id":"e1","task_pattern":"x","plan_template":"y","key_insight":"Read then edit."}]"#;
478        std::fs::write(prompts_dir.join("examples.json"), examples).unwrap();
479        let block = build_beliefs_block(tmp.path());
480        assert!(block.contains("Beliefs"));
481        assert!(block.contains("Success Patterns"));
482        assert!(block.contains("Read then edit"));
483    }
484}