Skip to main content

skilllite_agent/
planning_rules.rs

1//! Planning rules for task generation.
2//!
3//! EVO-2: Rules are loaded from `~/.skilllite/chat/prompts/rules.json` at runtime.
4//! Compiled-in seed data provides the fallback when no external file exists.
5//! Edit the external JSON file to add or modify rules without recompiling.
6//!
7//! EVO-3 fix: Workspace rules (per-project, from `init --use-llm`) are now **merged**
8//! with global rules (seed + evolved) instead of replacing them. This ensures evolved
9//! rules are never silently discarded when a workspace file exists.
10
11use std::path::Path;
12
13use super::types::PlanningRule;
14use skilllite_evolution::seed;
15
16/// Load planning rules.
17///
18/// Resolution:
19/// 1. Global `~/.skilllite/chat/prompts/rules.json` (seed + evolved) — always loaded
20/// 2. Workspace `.skilllite/planning_rules.json` (per-project skill rules) — **merged on top**
21/// 3. Compiled-in seed data (fallback when no global file)
22///
23/// Merge semantics:
24/// - Workspace rule with same ID as an immutable (`mutable=false`) global rule → skipped
25/// - Workspace rule with same ID as a mutable global rule → overrides it
26/// - Workspace rule with a new ID → appended
27pub fn load_rules(workspace: Option<&Path>, chat_root: Option<&Path>) -> Vec<PlanningRule> {
28    // Base: global rules (seed + evolved)
29    let mut rules = if let Some(root) = chat_root {
30        seed::load_rules(root)
31    } else {
32        seed::load_rules(Path::new("/nonexistent"))
33    };
34
35    // Merge workspace-specific rules (per-project skill rules from `init --use-llm`)
36    if let Some(ws) = workspace {
37        let path = ws.join(".skilllite").join("planning_rules.json");
38        if path.exists() {
39            if let Ok(content) = skilllite_fs::read_file(&path) {
40                if let Ok(ws_rules) = serde_json::from_str::<Vec<PlanningRule>>(&content) {
41                    let ws_count = ws_rules.len();
42                    merge_workspace_rules(&mut rules, ws_rules);
43                    tracing::debug!(
44                        "Merged {} workspace rules from {}",
45                        ws_count,
46                        path.display()
47                    );
48                }
49            }
50        }
51    }
52
53    rules
54}
55
56/// Merge workspace rules into the base rule set.
57///
58/// - Same ID + base is immutable → skip (seed rules cannot be overridden)
59/// - Same ID + base is mutable → override (workspace takes priority)
60/// - New ID → append
61fn merge_workspace_rules(base: &mut Vec<PlanningRule>, workspace: Vec<PlanningRule>) {
62    for ws_rule in workspace {
63        if let Some(pos) = base.iter().position(|r| r.id == ws_rule.id) {
64            if base[pos].mutable {
65                base[pos] = ws_rule;
66            }
67            // Immutable (seed) rules are never overridden — silently skip
68        } else {
69            base.push(ws_rule);
70        }
71    }
72}
73
74/// Load full examples text from disk or compiled-in seed.
75pub fn load_full_examples(chat_root: Option<&Path>) -> String {
76    if let Some(root) = chat_root {
77        return seed::load_examples(root);
78    }
79    include_str!("seed/examples.seed.md").to_string()
80}
81
82/// Compact examples section: core examples + up to 3 matched by user message keywords.
83/// Runtime logic preserved (cannot be fully externalized as it depends on user input).
84pub fn compact_examples_section(user_message: &str) -> String {
85    let msg_lower = user_message.to_lowercase();
86    let mut lines = vec![
87        "Example 1 - Simple (no tools): \"Write a poem\", \"Translate X\", \"Explain this code\" → []".to_string(),
88        "Example 2 - Tools: \"Calculate 123*456\" → [{\"id\":1,\"description\":\"Use calculator\",\"tool_hint\":\"calculator\",\"completed\":false}]".to_string(),
89    ];
90    let is_city_or_place = user_message.contains("城市")
91        || user_message.contains("地方")
92        || user_message.contains("对比")
93        || user_message.contains("优劣势")
94        || user_message.contains("全方位")
95        || user_message.contains("两地")
96        || msg_lower.contains("city")
97        || msg_lower.contains("place");
98    let candidates: Vec<(&str, &str, &str)> = vec![
99        (
100            "介绍",
101            "景点",
102            "介绍+地点/景点/路线: agent-browser or http-request for fresh info. NOT [].",
103        ),
104        (
105            "城市",
106            "全方位",
107            "城市/地方/全方位分析: http-request for fresh data. NOT chat_history.",
108        ),
109        (
110            "对比",
111            "优劣势",
112            "对比/优劣势: http-request for fresh data. NOT chat_history.",
113        ),
114        (
115            "分析",
116            "稳定性",
117            "分析稳定性/项目: chat_history (ONLY when analyzing chat/project, NOT places)",
118        ),
119        ("历史", "记录", "历史记录: chat_history + analysis."),
120        (
121            "输出到",
122            "保存到",
123            "输出到output: write_output, file_write.",
124        ),
125        (
126            "继续",
127            "",
128            "继续: use context to infer task, often http-request.",
129        ),
130        ("天气", "气象", "天气: weather skill."),
131        ("官网", "网站", "官网/网站: file_write + preview, 2 tasks."),
132        (
133            "refactor",
134            "panic",
135            "编码refactor: file_read定位→file_edit修改→command测试.",
136        ),
137        ("整理", "项目", "模糊请求: file_list探索→analysis总结/确认."),
138    ];
139    let mut added = 0;
140    for (k1, k2, text) in candidates {
141        if added >= 3 {
142            break;
143        }
144        let matches = user_message.contains(k1)
145            || msg_lower.contains(&k1.to_lowercase())
146            || (!k2.is_empty()
147                && (user_message.contains(k2) || msg_lower.contains(&k2.to_lowercase())));
148        let skip = matches && k1 == "分析" && is_city_or_place;
149        if matches && !skip {
150            lines.push(format!("Example - {}: {}", k1, text));
151            added += 1;
152        }
153    }
154    lines.join("\n")
155}