Skip to main content

skilllite_agent/
task_planner.rs

1//! Task Planner: task planning and tracking for agentic loops.
2//!
3//! EVO-2: Prompt templates are loaded from `~/.skilllite/chat/prompts/` at runtime.
4//! Compiled-in seed data provides the fallback when no external file exists.
5//!
6//! Responsibilities:
7//! - Generate task list from user message using LLM
8//! - Track task completion status
9//! - Build execution and task system prompts (from external templates)
10//! - Planning rules engine (rules loaded from file or seed)
11
12use std::path::Path;
13
14use anyhow::Result;
15
16use super::extensions::ToolAvailabilityView;
17use super::goal_boundaries::GoalBoundaries;
18use super::llm::LlmClient;
19use super::planning_rules;
20use super::skills::LoadedSkill;
21use super::soul::Soul;
22use super::tool_hint_resolver;
23use super::types::*;
24use skilllite_evolution::seed;
25
26/// Resolve the output directory path for prompt injection.
27fn resolve_output_dir() -> String {
28    get_output_dir().unwrap_or_else(|| {
29        skilllite_executor::chat_root()
30            .join("output")
31            .to_string_lossy()
32            .to_string()
33    })
34}
35
36/// Filter rules by user message: include rules with empty keywords (always) or
37/// rules whose keywords match the user message.
38fn filter_rules_for_user_message<'a>(
39    rules: &'a [PlanningRule],
40    user_message: &str,
41) -> Vec<&'a PlanningRule> {
42    let msg_lower = user_message.to_lowercase();
43    rules
44        .iter()
45        .filter(|r| {
46            if r.keywords.is_empty() && r.context_keywords.is_empty() {
47                return true;
48            }
49            let matches_keywords = r.keywords.iter().any(|k| {
50                user_message.contains(k.as_str()) || msg_lower.contains(&k.to_lowercase())
51            });
52            let matches_context = r.context_keywords.iter().any(|k| {
53                user_message.contains(k.as_str()) || msg_lower.contains(&k.to_lowercase())
54            });
55            matches_keywords || matches_context
56        })
57        .collect()
58}
59
60/// Build the "## CRITICAL" rules section for the planning prompt.
61fn build_rules_section(rules: &[PlanningRule]) -> String {
62    if rules.is_empty() {
63        return String::new();
64    }
65    let mut lines = vec![
66        "## CRITICAL: When user explicitly requests a Skill, ALWAYS use it".to_string(),
67        String::new(),
68    ];
69    for r in rules {
70        let inst = r.instruction.trim();
71        if !inst.is_empty() {
72            lines.push(inst.to_string());
73            lines.push(String::new());
74        }
75    }
76    lines.join("\n").trim_end().to_string()
77}
78
79// ─── TaskPlanner ────────────────────────────────────────────────────────────
80
81/// Task planner: generates and tracks task lists for the agentic loop.
82pub struct TaskPlanner {
83    /// Current task list.
84    pub task_list: Vec<Task>,
85    /// Planning rules (loaded from file or seed).
86    rules: Vec<PlanningRule>,
87    /// Rules filtered by actually available skills (computed lazily).
88    available_rules: Vec<PlanningRule>,
89    /// Rule IDs matched for the current user request.
90    matched_rule_ids: Vec<String>,
91    /// Chat data root for loading prompt templates.
92    chat_root: Option<std::path::PathBuf>,
93    /// EVO-5: Workspace path for project-level prompt overrides.
94    workspace: Option<std::path::PathBuf>,
95    /// Final tool availability for the current execution mode, if known.
96    availability: Option<ToolAvailabilityView>,
97}
98
99impl TaskPlanner {
100    /// Create a new TaskPlanner.
101    ///
102    /// `workspace`: per-project override directory.
103    /// `chat_root`: `~/.skilllite/chat/` for loading prompts from the seed system.
104    pub fn new(
105        workspace: Option<&Path>,
106        chat_root: Option<&Path>,
107        availability: Option<ToolAvailabilityView>,
108    ) -> Self {
109        let rules = planning_rules::load_rules(workspace, chat_root);
110        Self {
111            task_list: Vec::new(),
112            available_rules: rules.clone(),
113            rules,
114            matched_rule_ids: Vec::new(),
115            chat_root: chat_root.map(|p| p.to_path_buf()),
116            workspace: workspace.map(|p| p.to_path_buf()),
117            availability,
118        }
119    }
120
121    /// Delegate: resolve a hint to preferred tool names.
122    pub(crate) fn preferred_tool_names_for_hint(&self, hint: &str) -> Vec<String> {
123        match self.availability.as_ref() {
124            Some(view) => tool_hint_resolver::preferred_tool_names_with_availability(hint, view),
125            None => tool_hint_resolver::preferred_tool_names(hint),
126        }
127    }
128
129    /// Delegate: get human-readable guidance for a hint.
130    pub(crate) fn builtin_hint_guidance(&self, hint: &str) -> Option<String> {
131        match self.availability.as_ref() {
132            Some(view) => tool_hint_resolver::hint_guidance_with_availability(hint, view),
133            None => tool_hint_resolver::hint_guidance(hint).map(ToString::to_string),
134        }
135    }
136
137    fn filter_rules_by_available_skills(
138        &self,
139        rules: &[PlanningRule],
140        skills: &[LoadedSkill],
141    ) -> Vec<PlanningRule> {
142        rules
143            .iter()
144            .filter(|r| match r.tool_hint.as_deref() {
145                None => true,
146                Some(hint) => match self.availability.as_ref() {
147                    Some(view) => {
148                        tool_hint_resolver::is_hint_available_with_availability(hint, skills, view)
149                    }
150                    None => tool_hint_resolver::is_hint_available(hint, skills),
151                },
152            })
153            .cloned()
154            .collect()
155    }
156
157    fn sanitize_task_hints(&self, tasks: &mut [Task], skills: &[LoadedSkill]) {
158        for task in tasks.iter_mut() {
159            if let Some(ref hint) = task.tool_hint {
160                let available = match self.availability.as_ref() {
161                    Some(view) => {
162                        tool_hint_resolver::is_hint_available_with_availability(hint, skills, view)
163                    }
164                    None => tool_hint_resolver::is_hint_available(hint, skills),
165                };
166                if !available {
167                    tracing::info!(
168                        "Stripped unavailable tool_hint '{}' from task {}: {}",
169                        hint,
170                        task.id,
171                        task.description
172                    );
173                    task.tool_hint = None;
174                }
175            }
176        }
177    }
178
179    /// Generate task list from user message using LLM.
180    ///
181    /// `goal_boundaries`: Optional extracted boundaries (scope, exclusions, completion conditions)
182    /// to inject into planning. Used in run mode for long-running tasks.
183    /// `soul`: Optional SOUL identity document; when present, Scope & Boundaries are injected (A8).
184    #[allow(clippy::too_many_arguments)]
185    pub async fn generate_task_list(
186        &mut self,
187        client: &LlmClient,
188        model: &str,
189        user_message: &str,
190        skills: &[LoadedSkill],
191        conversation_context: Option<&str>,
192        goal_boundaries: Option<&GoalBoundaries>,
193        soul: Option<&Soul>,
194    ) -> Result<Vec<Task>> {
195        self.available_rules = self.filter_rules_by_available_skills(&self.rules, skills);
196        self.matched_rule_ids = filter_rules_for_user_message(&self.available_rules, user_message)
197            .into_iter()
198            .map(|r| r.id.clone())
199            .collect();
200
201        let visible_skills: Vec<&LoadedSkill> = match self.availability.as_ref() {
202            Some(view) => view.filter_callable_skills(skills),
203            None => skills.iter().collect(),
204        };
205
206        let skills_info = if visible_skills.is_empty() {
207            "None".to_string()
208        } else {
209            visible_skills
210                .iter()
211                .map(|s| {
212                    let desc = s
213                        .metadata
214                        .description
215                        .as_deref()
216                        .unwrap_or("No description");
217                    format!("- **{}**: {}", s.name, desc)
218                })
219                .collect::<Vec<_>>()
220                .join("\n")
221        };
222
223        let planning_prompt =
224            self.build_planning_prompt(&skills_info, user_message, Some(model), soul);
225
226        let mut user_content = format!(
227            "**PRIMARY — Plan ONLY based on this**:\nUser request: {}\n\n",
228            user_message
229        );
230        // A5: Inject goal boundaries when available (run mode)
231        if let Some(gb) = goal_boundaries {
232            if !gb.is_empty() {
233                user_content.push_str(&gb.to_planning_block());
234                user_content.push_str("\n\n");
235            }
236        }
237        if let Some(ctx) = conversation_context {
238            let needs_continue_context = user_message.contains("继续")
239                || user_message.contains("继续之前")
240                || user_message.contains("继续任务");
241            if needs_continue_context {
242                user_content.push_str(&format!(
243                    "Conversation context (use ONLY when user says 继续 — to understand what to continue):\n{}\n\n",
244                    ctx
245                ));
246            } else {
247                let max_ctx_bytes = 2000;
248                let ctx_truncated = if ctx.len() > max_ctx_bytes {
249                    format!(
250                        "{}...[truncated, {} bytes total]",
251                        safe_truncate(ctx, max_ctx_bytes),
252                        ctx.len()
253                    )
254                } else {
255                    ctx.to_string()
256                };
257                user_content.push_str(&format!(
258                    "Conversation context (REFERENCE ONLY — user's request above is the PRIMARY input; do NOT plan based on previous tasks in context):\n{}\n\n",
259                    ctx_truncated
260                ));
261            }
262        }
263        user_content.push_str("Generate task list based on the User request above:");
264
265        let messages = vec![
266            ChatMessage::system(&planning_prompt),
267            ChatMessage::user(&user_content),
268        ];
269
270        match client
271            .chat_completion(model, &messages, None, Some(0.3))
272            .await
273        {
274            Ok(resp) => {
275                let raw = resp
276                    .choices
277                    .first()
278                    .and_then(|c| c.message.content.clone())
279                    .filter(|s| !s.trim().is_empty())
280                    .unwrap_or_else(|| "[]".to_string());
281
282                match self.parse_task_list(&raw) {
283                    Ok(mut tasks) => {
284                        self.sanitize_task_hints(&mut tasks, skills);
285                        self.auto_enhance_tasks(&mut tasks);
286                        self.task_list = tasks.clone();
287                        Ok(tasks)
288                    }
289                    Err(e) => {
290                        tracing::warn!(
291                            "规划解析失败,使用 fallback 单任务。parse_task_list error: {}",
292                            e
293                        );
294                        let fallback = vec![Task {
295                            id: 1,
296                            description: user_message.to_string(),
297                            tool_hint: None,
298                            completed: false,
299                        }];
300                        self.task_list = fallback.clone();
301                        Ok(fallback)
302                    }
303                }
304            }
305            Err(e) => {
306                tracing::warn!("规划 LLM 调用失败,使用 fallback 单任务。error: {}", e);
307                let fallback = vec![Task {
308                    id: 1,
309                    description: user_message.to_string(),
310                    tool_hint: None,
311                    completed: false,
312                }];
313                self.task_list = fallback.clone();
314                Ok(fallback)
315            }
316        }
317    }
318
319    /// Return planning rule IDs matched for the current user request.
320    pub fn matched_rule_ids(&self) -> &[String] {
321        &self.matched_rule_ids
322    }
323
324    /// Parse the LLM response into a task list.
325    ///
326    /// Handles common LLM output quirks:
327    /// - `<think>…</think>` reasoning blocks (MiniMax, DeepSeek, Qwen3)
328    /// - Markdown code fences around JSON
329    /// - Natural-language preamble before the JSON array
330    /// - Empty or whitespace-only responses
331    pub(crate) fn parse_task_list(&self, raw: &str) -> Result<Vec<Task>> {
332        let mut cleaned = raw.trim().to_string();
333
334        // Strip <think>…</think> blocks (reasoning models)
335        while let Some(start) = cleaned.find("<think>") {
336            if let Some(end) = cleaned.find("</think>") {
337                let end_tag_end = end + "</think>".len();
338                cleaned = format!("{}{}", &cleaned[..start], &cleaned[end_tag_end..]);
339            } else {
340                // Unclosed <think> — drop everything from <think> onwards
341                cleaned = cleaned[..start].to_string();
342                break;
343            }
344        }
345        let cleaned = cleaned.trim().to_string();
346
347        if cleaned.is_empty() {
348            anyhow::bail!("LLM returned empty content (after stripping think blocks)");
349        }
350
351        // Strip markdown code fences
352        let cleaned = Self::strip_code_fences(&cleaned);
353
354        // Try direct parse first (fast path)
355        if let Ok(tasks) = serde_json::from_str::<Vec<Task>>(&cleaned) {
356            return Ok(tasks);
357        }
358
359        // Fallback: extract the first JSON array from the text
360        if let Some(json_str) = Self::extract_json_array(&cleaned) {
361            if let Ok(tasks) = serde_json::from_str::<Vec<Task>>(&json_str) {
362                return Ok(tasks);
363            }
364        }
365
366        tracing::debug!(
367            "parse_task_list raw (first 500 chars): {}",
368            &raw[..raw.len().min(500)]
369        );
370        anyhow::bail!("No valid JSON task array found in LLM response")
371    }
372
373    /// Strip markdown code fences (```` ```json ... ``` ````) from content.
374    fn strip_code_fences(s: &str) -> String {
375        let mut cleaned = s.trim().to_string();
376        if cleaned.starts_with("```json") {
377            cleaned = cleaned[7..].to_string();
378        } else if cleaned.starts_with("```") {
379            cleaned = cleaned[3..].to_string();
380        }
381        if cleaned.ends_with("```") {
382            cleaned = cleaned[..cleaned.len() - 3].to_string();
383        }
384        cleaned.trim().to_string()
385    }
386
387    /// Try to extract the first JSON array from mixed text content.
388    /// Scans for `[` and finds the matching `]`, handling nesting.
389    fn extract_json_array(s: &str) -> Option<String> {
390        let start = s.find('[')?;
391        let bytes = s.as_bytes();
392        let mut depth = 0i32;
393        let mut in_string = false;
394        let mut escape_next = false;
395        for (i, &b) in bytes[start..].iter().enumerate() {
396            if escape_next {
397                escape_next = false;
398                continue;
399            }
400            match b {
401                b'\\' if in_string => escape_next = true,
402                b'"' => in_string = !in_string,
403                b'[' if !in_string => depth += 1,
404                b']' if !in_string => {
405                    depth -= 1;
406                    if depth == 0 {
407                        return Some(s[start..start + i + 1].to_string());
408                    }
409                }
410                _ => {}
411            }
412        }
413        None
414    }
415
416    /// Apply sanitize_task_hints and auto_enhance_tasks to a task list (e.g. from replan).
417    /// Use this when accepting a new plan from update_task_plan so replan has the same
418    /// validation and enhancement as initial planning.
419    pub fn sanitize_and_enhance_tasks(&self, tasks: &mut Vec<Task>, skills: &[LoadedSkill]) {
420        self.sanitize_task_hints(tasks, skills);
421        self.auto_enhance_tasks(tasks);
422    }
423
424    /// Auto-enhance tasks: add SKILL.md writing if skill creation is detected.
425    fn auto_enhance_tasks(&self, tasks: &mut Vec<Task>) {
426        let has_skill_creation = tasks.iter().any(|t| {
427            let desc_lower = t.description.to_lowercase();
428            let hint_lower = t.tool_hint.as_deref().unwrap_or("").to_lowercase();
429            desc_lower.contains("skill-creator") || hint_lower.contains("skill-creator")
430        });
431
432        let has_skillmd_task = tasks.iter().any(|t| {
433            let desc_lower = t.description.to_lowercase();
434            desc_lower.contains("skill.md")
435        });
436
437        if has_skill_creation && !has_skillmd_task {
438            let max_id = tasks.iter().map(|t| t.id).max().unwrap_or(0);
439            tasks.push(Task {
440                id: max_id + 1,
441                description: "Use write_file to write actual SKILL.md content (skill description, usage, parameter documentation, etc.)".to_string(),
442                tool_hint: Some("file_write".to_string()),
443                completed: false,
444            });
445        }
446    }
447
448    /// Build the planning prompt from the external template.
449    /// Placeholders: {{TODAY}}, {{YESTERDAY}}, {{RULES_SECTION}}, {{SKILLS_INFO}},
450    /// {{OUTPUT_DIR}}, {{EXAMPLES_SECTION}}, {{SOUL_SCOPE_BLOCK}} (A8).
451    pub(crate) fn build_planning_prompt(
452        &self,
453        skills_info: &str,
454        user_message: &str,
455        model: Option<&str>,
456        soul: Option<&Soul>,
457    ) -> String {
458        let compact = get_compact_planning(model);
459        let rules_section = if compact {
460            let filtered: Vec<&PlanningRule> =
461                filter_rules_for_user_message(&self.available_rules, user_message);
462            build_rules_section(&filtered.iter().map(|r| (*r).clone()).collect::<Vec<_>>())
463        } else {
464            build_rules_section(&self.available_rules)
465        };
466        let examples_section = if compact {
467            planning_rules::compact_examples_section(user_message)
468        } else {
469            planning_rules::load_full_examples(self.chat_root.as_deref())
470        };
471        let output_dir = resolve_output_dir();
472        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
473        let yesterday = (chrono::Local::now() - chrono::Duration::days(1))
474            .format("%Y-%m-%d")
475            .to_string();
476
477        let template = seed::load_prompt_file_with_project(
478            self.chat_root
479                .as_deref()
480                .unwrap_or(Path::new("/nonexistent")),
481            self.workspace.as_deref(),
482            "planning.md",
483            include_str!("seed/planning.seed.md"),
484        );
485
486        let soul_scope = soul
487            .and_then(|s| s.to_planning_scope_block())
488            .unwrap_or_default();
489
490        template
491            .replace("{{TODAY}}", &today)
492            .replace("{{YESTERDAY}}", &yesterday)
493            .replace("{{RULES_SECTION}}", &rules_section)
494            .replace("{{SKILLS_INFO}}", skills_info)
495            .replace("{{OUTPUT_DIR}}", &output_dir)
496            .replace("{{EXAMPLES_SECTION}}", &examples_section)
497            .replace("{{SOUL_SCOPE_BLOCK}}", &soul_scope)
498    }
499
500    /// Build the main execution system prompt from the external template.
501    /// Placeholders: {{TODAY}}, {{YESTERDAY}}, {{SKILLS_LIST}}, {{OUTPUT_DIR}}.
502    pub fn build_execution_prompt(&self, skills: &[LoadedSkill]) -> String {
503        let visible_skills: Vec<&LoadedSkill> = match self.availability.as_ref() {
504            Some(view) => view.filter_callable_skills(skills),
505            None => skills.iter().collect(),
506        };
507        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
508        let yesterday = (chrono::Local::now() - chrono::Duration::days(1))
509            .format("%Y-%m-%d")
510            .to_string();
511        let skills_list: Vec<String> = visible_skills
512            .iter()
513            .map(|s| {
514                let desc = s
515                    .metadata
516                    .description
517                    .as_deref()
518                    .unwrap_or("No description");
519                if s.metadata.entry_point.is_empty() && !s.metadata.is_bash_tool_skill() {
520                    format!(
521                        "  - **{}**: {} ⛔ [Reference Only — NOT a callable tool, do NOT call it]",
522                        s.name, desc
523                    )
524                } else {
525                    format!("  - **{}**: {}", s.name, desc)
526                }
527            })
528            .collect();
529        let skills_list_str = skills_list.join("\n");
530        let output_dir = resolve_output_dir();
531
532        let template = seed::load_prompt_file_with_project(
533            self.chat_root
534                .as_deref()
535                .unwrap_or(Path::new("/nonexistent")),
536            self.workspace.as_deref(),
537            "execution.md",
538            include_str!("seed/execution.seed.md"),
539        );
540
541        template
542            .replace("{{TODAY}}", &today)
543            .replace("{{YESTERDAY}}", &yesterday)
544            .replace("{{SKILLS_LIST}}", &skills_list_str)
545            .replace("{{OUTPUT_DIR}}", &output_dir)
546    }
547
548    /// Build system prompt with task list and execution guidance.
549    ///
550    /// `goal_boundaries`: Optional extracted boundaries to inject (A5, run mode).
551    pub fn build_task_system_prompt(
552        &self,
553        skills: &[LoadedSkill],
554        goal_boundaries: Option<&GoalBoundaries>,
555    ) -> String {
556        let execution_prompt = self.build_execution_prompt(skills);
557        let task_list_json =
558            serde_json::to_string_pretty(&self.task_list).unwrap_or_else(|_| "[]".to_string());
559
560        let current_task = self.task_list.iter().find(|t| !t.completed);
561        let mut current_task_info = String::new();
562        let mut direct_call_instruction = String::new();
563
564        if let Some(task) = current_task {
565            let hint_str = task
566                .tool_hint
567                .as_deref()
568                .map(|h| format!("(Suggested tool: {})", h))
569                .unwrap_or_default();
570            current_task_info = format!(
571                "\n\n🎯 **Current task to execute**: Task {} - {} {}",
572                task.id, task.description, hint_str
573            );
574
575            if let Some(ref hint) = task.tool_hint {
576                if let Some(guidance) = self.builtin_hint_guidance(hint) {
577                    direct_call_instruction = format!(
578                        "\n\n⚡ **ACTION**: 当前任务 tool_hint 为 {}。{}",
579                        hint, guidance
580                    );
581                } else {
582                    direct_call_instruction = format!(
583                        "\n\n⚡ **ACTION**: 当前任务 tool_hint 为 {},请优先调用 {}。",
584                        hint, hint
585                    );
586                }
587            }
588        }
589
590        let boundaries_block = match goal_boundaries {
591            Some(gb) if !gb.is_empty() => format!("\n\n{}\n", gb.to_planning_block()),
592            _ => String::new(),
593        };
594
595        let match_rule = match self.availability.as_ref() {
596            Some(view) => tool_hint_resolver::generate_match_rule_with_availability(view),
597            None => tool_hint_resolver::generate_match_rule(),
598        };
599        format!(
600            "{}{}\n\
601             ---\n\n\
602             ## Current Task List\n\n\
603             {}\n\n\
604             ## Execution Rules\n\n\
605             {}\n\
606             2. **Strict sequential execution**: Execute tasks in order, do not skip tasks\n\
607             3. **Focus on current task**: Focus only on the current task\n\
608             4. **Structured completion**: After finishing a task, call `complete_task(task_id=N)`. Writing \"Task N completed\" in text is NOT sufficient.\n\
609             5. **Avoid unnecessary exploration**: Do NOT call list_directory or read_file unless the task explicitly requires it\n\
610             6. **🚫 EXECUTE BEFORE COMPLETING**: Your first response must be an actual tool call, NOT a completion summary. Call `complete_task` only AFTER the work is done.\n\
611             7. **🚫 NO PREMATURE FINISH CLAIMS**: Until `complete_task` is called for the current task, do NOT say the task is completed. If any tasks remain, do NOT say the whole job is finished.\n\
612             8. **Multi-task wording**: In multi-task flows, only report the completed task and explicitly continue to the next one, e.g. \"Task 1 is complete; now proceeding to Task 2.\"\n\
613             {}{}\n\n\
614             ⚠️ **Important**: After completing each task, you MUST call `complete_task(task_id=N)` so the system can track progress. Text declarations are ignored.",
615            execution_prompt,
616            boundaries_block,
617            task_list_json,
618            match_rule,
619            current_task_info,
620            direct_call_instruction
621        )
622    }
623
624    /// Mark a task as completed and return whether it was found.
625    pub fn mark_completed(&mut self, task_id: u32) -> bool {
626        for task in &mut self.task_list {
627            if task.id == task_id {
628                task.completed = true;
629                return true;
630            }
631        }
632        false
633    }
634
635    /// Check if all tasks are completed.
636    ///
637    /// Returns `false` when the task list is empty: an empty plan means the LLM
638    /// decided no explicit tasks were needed, which is *not* the same as having
639    /// finished a set of tasks.  Treating an empty list as "all done" causes
640    /// `run_with_task_planning` to fire the "final summary" branch immediately
641    /// after the first batch of tool calls, ending the loop prematurely.
642    pub fn all_completed(&self) -> bool {
643        !self.task_list.is_empty() && self.task_list.iter().all(|t| t.completed)
644    }
645
646    /// Check if the task list is empty (LLM decided no tools needed).
647    pub fn is_empty(&self) -> bool {
648        self.task_list.is_empty()
649    }
650
651    /// Get the current (first uncompleted) task.
652    pub fn current_task(&self) -> Option<&Task> {
653        self.task_list.iter().find(|t| !t.completed)
654    }
655
656    /// Build a nudge message to push the LLM to continue working on tasks.
657    pub fn build_nudge_message(&self) -> Option<String> {
658        let current = self.current_task()?;
659        let task_list_json =
660            serde_json::to_string_pretty(&self.task_list).unwrap_or_else(|_| "[]".to_string());
661
662        let tool_instruction = if let Some(ref hint) = current.tool_hint {
663            if hint == "analysis" {
664                "\nNo tool is required for this task; provide the analysis directly.".to_string()
665            } else if let Some(guidance) = self.builtin_hint_guidance(hint) {
666                format!("\n⚡ {}", guidance)
667            } else {
668                format!(
669                    "\n⚡ Call `{}` DIRECTLY now. Do NOT call list_directory or read_file first.",
670                    hint
671                )
672            }
673        } else {
674            "\nPlease use the available tools to complete this task.".to_string()
675        };
676
677        Some(format!(
678            "There are still pending tasks. Please continue.\n\n\
679             Updated task list:\n{}\n\n\
680             Current task: Task {} - {}\n{}\n\n\
681             ⚠️ After completing this task, call `complete_task(task_id={})` to record completion.\n\
682             ⚠️ Do NOT say this task is complete until you have actually called `complete_task`.\n\
683             ⚠️ Because tasks remain, do NOT say the whole job is finished.\n\
684             If the current plan no longer fits the goal, you may call `update_task_plan` to revise the plan, then continue.",
685            task_list_json, current.id, current.description, tool_instruction, current.id
686        ))
687    }
688
689    /// Build a per-task depth limit message.
690    /// Suggests complete_task first; if the approach is wrong, suggests update_task_plan.
691    pub fn build_depth_limit_message(&self, max_calls: usize) -> String {
692        let current_id = self.current_task().map(|t| t.id).unwrap_or(0);
693        format!(
694            "You have used {} tool calls for the current task. \
695             Based on the information gathered so far, call \
696             `complete_task(task_id={})` to record completion, then proceed to the next task. \
697             If the current approach is clearly wrong or the plan no longer fits the goal, \
698             you may call `update_task_plan` with a revised task list instead, then continue with the new plan.",
699            max_calls, current_id
700        )
701    }
702}
703
704#[cfg(test)]
705mod tests {
706    use super::*;
707    use crate::extensions::ExtensionRegistry;
708
709    #[test]
710    fn test_planning_prompt_phase1_balance() {
711        let planner = TaskPlanner::new(None, None, None);
712        let prompt = planner.build_planning_prompt("None", "hello", None, None);
713
714        assert!(
715            prompt.contains("First: Check if `[]` is correct"),
716            "prompt should contain First check for []"
717        );
718        assert!(
719            prompt.contains("Prefer `[]`"),
720            "prompt should contain Prefer [] in output format"
721        );
722        assert!(
723            prompt.contains("Minimize Tool Usage"),
724            "prompt should contain Core Principle"
725        );
726        assert!(
727            prompt.contains("Only when tools are needed"),
728            "prompt should qualify when to apply heuristics"
729        );
730        assert!(
731            prompt.contains("Three-phase model"),
732            "prompt should contain three-phase model"
733        );
734    }
735
736    #[test]
737    fn test_parse_task_list() {
738        let planner = TaskPlanner::new(None, None, None);
739
740        let json = r#"[{"id": 1, "description": "Use search_replace", "tool_hint": "file_edit", "completed": false}]"#;
741        let tasks = planner.parse_task_list(json).unwrap();
742        assert_eq!(tasks.len(), 1);
743        assert_eq!(tasks[0].description, "Use search_replace");
744        assert_eq!(tasks[0].tool_hint.as_deref(), Some("file_edit"));
745
746        let empty = planner.parse_task_list("[]").unwrap();
747        assert!(empty.is_empty());
748    }
749
750    #[test]
751    fn test_planning_prompt_contains_placeholders_resolved() {
752        let planner = TaskPlanner::new(None, None, None);
753        let prompt = planner.build_planning_prompt("None", "hello", None, None);
754
755        // All placeholders should be resolved (no {{...}} remaining)
756        assert!(!prompt.contains("{{TODAY}}"));
757        assert!(!prompt.contains("{{YESTERDAY}}"));
758        assert!(!prompt.contains("{{RULES_SECTION}}"));
759        assert!(!prompt.contains("{{SKILLS_INFO}}"));
760        assert!(!prompt.contains("{{OUTPUT_DIR}}"));
761        assert!(!prompt.contains("{{EXAMPLES_SECTION}}"));
762        assert!(!prompt.contains("{{SOUL_SCOPE_BLOCK}}"));
763    }
764
765    #[test]
766    fn test_execution_prompt_contains_placeholders_resolved() {
767        let planner = TaskPlanner::new(None, None, None);
768        let prompt = planner.build_execution_prompt(&[]);
769
770        assert!(!prompt.contains("{{TODAY}}"));
771        assert!(!prompt.contains("{{YESTERDAY}}"));
772        assert!(!prompt.contains("{{SKILLS_LIST}}"));
773        assert!(!prompt.contains("{{OUTPUT_DIR}}"));
774    }
775
776    #[test]
777    fn test_task_system_prompt_uses_filtered_match_rule() {
778        let registry = ExtensionRegistry::read_only(true, false, &[]);
779        let planner = TaskPlanner::new(None, None, Some(registry.availability().clone()));
780        let prompt = planner.build_task_system_prompt(&[], None);
781
782        assert!(prompt.contains("**MATCH tool_hint**:"));
783        assert!(prompt.contains("`file_read` →"));
784        assert!(prompt.contains("`memory_search` →"));
785        assert!(!prompt.contains("`file_write` →"));
786        assert!(!prompt.contains("`command` →"));
787    }
788
789    #[test]
790    fn test_generate_task_list_records_matched_rule_ids() {
791        let mut planner = TaskPlanner::new(None, None, None);
792        planner.available_rules = vec![
793            PlanningRule {
794                id: "always".to_string(),
795                priority: 50,
796                keywords: vec![],
797                context_keywords: vec![],
798                tool_hint: None,
799                instruction: "Always apply".to_string(),
800                mutable: false,
801                origin: "seed".to_string(),
802                reusable: false,
803                effectiveness: None,
804                trigger_count: None,
805            },
806            PlanningRule {
807                id: "weather".to_string(),
808                priority: 50,
809                keywords: vec!["天气".to_string()],
810                context_keywords: vec![],
811                tool_hint: Some("weather".to_string()),
812                instruction: "Use weather skill".to_string(),
813                mutable: false,
814                origin: "seed".to_string(),
815                reusable: false,
816                effectiveness: None,
817                trigger_count: None,
818            },
819            PlanningRule {
820                id: "other".to_string(),
821                priority: 50,
822                keywords: vec!["股票".to_string()],
823                context_keywords: vec![],
824                tool_hint: None,
825                instruction: "Use stock tool".to_string(),
826                mutable: false,
827                origin: "seed".to_string(),
828                reusable: false,
829                effectiveness: None,
830                trigger_count: None,
831            },
832        ];
833
834        planner.matched_rule_ids =
835            filter_rules_for_user_message(&planner.available_rules, "帮我查天气")
836                .into_iter()
837                .map(|r| r.id.clone())
838                .collect();
839
840        assert_eq!(
841            planner.matched_rule_ids(),
842            &["always".to_string(), "weather".to_string()]
843        );
844    }
845
846    #[test]
847    fn test_matched_rule_ids_preserved_in_fallback() {
848        // Create a TaskPlanner, forcing it to load rules from seed (fallback)
849        // by providing None for both workspace and chat_root.
850        let mut planner = TaskPlanner::new(None, None, None);
851
852        // Simulate some seed rules being loaded.
853        // In a real scenario, these would come from skilllite_evolution::seed::load_rules.
854        // For this test, we'll manually populate `planner.rules` and `planner.available_rules`
855        // as `TaskPlanner::new` already calls `planning_rules::load_rules` which handles this.
856        // We'll assume `planning_rules::load_rules(None, None)` correctly loads some default seed rules.
857        // Let's add some mock rules that would be typical seed rules.
858        planner.rules = vec![
859            PlanningRule {
860                id: "seed_always_match".to_string(),
861                priority: 50,
862                keywords: vec![], // Always matches
863                context_keywords: vec![],
864                tool_hint: None,
865                instruction: "Seed rule: Always apply".to_string(),
866                mutable: false,
867                origin: "seed".to_string(),
868                reusable: false,
869                effectiveness: None,
870                trigger_count: None,
871            },
872            PlanningRule {
873                id: "seed_weather_skill".to_string(),
874                priority: 70,
875                keywords: vec!["天气".to_string(), "气象".to_string()],
876                context_keywords: vec![],
877                tool_hint: Some("weather".to_string()),
878                instruction: "Seed rule: Use weather skill".to_string(),
879                mutable: false,
880                origin: "seed".to_string(),
881                reusable: false,
882                effectiveness: None,
883                trigger_count: None,
884            },
885            PlanningRule {
886                id: "seed_unrelated".to_string(),
887                priority: 30,
888                keywords: vec!["股票".to_string()],
889                context_keywords: vec![],
890                tool_hint: None,
891                instruction: "Seed rule: Unrelated to weather".to_string(),
892                mutable: false,
893                origin: "seed".to_string(),
894                reusable: false,
895                effectiveness: None,
896                trigger_count: None,
897            },
898        ];
899        // Ensure available_rules is also updated for filtering simulation
900        planner.available_rules = planner.rules.clone();
901
902        // Simulate generate_task_list call to populate matched_rule_ids
903        // We only care about the side effect on matched_rule_ids here.
904        // The actual LLM call and task parsing are not relevant for this specific test.
905        // So, we directly call filter_rules_for_user_message as generate_task_list does.
906        planner.matched_rule_ids =
907            filter_rules_for_user_message(&planner.available_rules, "请帮我查询一下天气情况")
908                .into_iter()
909                .map(|r| r.id.clone())
910                .collect();
911
912        // Assert that the matched_rule_ids contains the expected rule IDs from the fallback (seed) rules
913        let expected_ids = vec![
914            "seed_always_match".to_string(),
915            "seed_weather_skill".to_string(),
916        ];
917        // Sort both vectors for comparison as order might not be guaranteed
918        let mut actual_ids = planner.matched_rule_ids().to_vec();
919        actual_ids.sort();
920        let mut sorted_expected_ids = expected_ids.clone();
921        sorted_expected_ids.sort();
922
923        assert_eq!(actual_ids, sorted_expected_ids);
924    }
925}