1use 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
26fn 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
36fn 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
60fn 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
79pub struct TaskPlanner {
83 pub task_list: Vec<Task>,
85 rules: Vec<PlanningRule>,
87 available_rules: Vec<PlanningRule>,
89 matched_rule_ids: Vec<String>,
91 chat_root: Option<std::path::PathBuf>,
93 workspace: Option<std::path::PathBuf>,
95 availability: Option<ToolAvailabilityView>,
97}
98
99impl TaskPlanner {
100 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 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 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 #[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 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 pub fn matched_rule_ids(&self) -> &[String] {
321 &self.matched_rule_ids
322 }
323
324 pub(crate) fn parse_task_list(&self, raw: &str) -> Result<Vec<Task>> {
332 let mut cleaned = raw.trim().to_string();
333
334 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 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 let cleaned = Self::strip_code_fences(&cleaned);
353
354 if let Ok(tasks) = serde_json::from_str::<Vec<Task>>(&cleaned) {
356 return Ok(tasks);
357 }
358
359 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 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 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 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 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 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 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 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 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 pub fn all_completed(&self) -> bool {
643 !self.task_list.is_empty() && self.task_list.iter().all(|t| t.completed)
644 }
645
646 pub fn is_empty(&self) -> bool {
648 self.task_list.is_empty()
649 }
650
651 pub fn current_task(&self) -> Option<&Task> {
653 self.task_list.iter().find(|t| !t.completed)
654 }
655
656 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 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 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 let mut planner = TaskPlanner::new(None, None, None);
851
852 planner.rules = vec![
859 PlanningRule {
860 id: "seed_always_match".to_string(),
861 priority: 50,
862 keywords: vec![], 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 planner.available_rules = planner.rules.clone();
901
902 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 let expected_ids = vec![
914 "seed_always_match".to_string(),
915 "seed_weather_skill".to_string(),
916 ];
917 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}