use std::path::{Path, PathBuf};
use anyhow::Result;
use super::super::extensions::ToolAvailabilityView;
use super::super::goal_boundaries;
use super::super::llm::LlmClient;
use super::super::planning_guard;
use super::super::prompt;
use super::super::skills::LoadedSkill;
use super::super::soul::Soul;
use super::super::task_planner::TaskPlanner;
use super::super::types::*;
use super::helpers::extract_goal_boundaries_hybrid;
pub(super) struct PlanningResult {
pub planner: TaskPlanner,
pub messages: Vec<ChatMessage>,
pub chat_root: PathBuf,
}
#[allow(clippy::too_many_arguments)]
pub(super) async fn run_planning_phase(
config: &AgentConfig,
initial_messages: Vec<ChatMessage>,
user_message: &str,
skills: &[LoadedSkill],
availability: &ToolAvailabilityView,
event_sink: &mut dyn EventSink,
session_key: Option<&str>,
client: &LlmClient,
workspace: &Path,
) -> Result<PlanningResult> {
let chat_root = skilllite_executor::chat_root();
let mut planner = TaskPlanner::new(
Some(workspace),
Some(&chat_root),
Some(availability.clone()),
);
let conversation_context: Option<String> = if config.skip_history_for_planning {
tracing::debug!(
"skip_history_for_planning=true: excluding transcript from planning prompt"
);
None
} else {
let ctx: Vec<String> = initial_messages
.iter()
.filter_map(|m| m.content.as_ref().map(|c| format!("[{}] {}", m.role, c)))
.collect();
if ctx.is_empty() {
None
} else {
Some(ctx.join("\n"))
}
};
let soul = Soul::auto_load(config.soul_path.as_deref(), &config.workspace);
let effective_boundaries = if session_key == Some("run") {
match extract_goal_boundaries_hybrid(client, &config.model, user_message).await {
Ok(gb) => Some(gb),
Err(e) => {
tracing::warn!("Goal boundaries extraction failed: {}, using regex only", e);
Some(goal_boundaries::extract_goal_boundaries(user_message))
}
}
} else {
config.goal_boundaries.clone()
};
let _tasks = planner
.generate_task_list(
client,
&config.model,
user_message,
skills,
conversation_context.as_deref(),
effective_boundaries.as_ref(),
soul.as_ref(),
)
.await?;
if planner.is_empty() {
if let Some(guard) = planning_guard::guard_empty_plan(user_message) {
tracing::info!("Planning guard replaced empty plan: {}", guard.reason);
planner.task_list = guard.fallback_tasks;
}
}
event_sink.on_task_plan(&planner.task_list);
let system_prompt = if planner.is_empty() {
prompt::build_system_prompt(
config.system_prompt.as_deref(),
skills,
&config.workspace,
session_key,
config.enable_memory,
Some(availability),
Some(&chat_root),
soul.as_ref(),
config.context_append.as_deref(),
)
} else {
let mut p = planner.build_task_system_prompt(skills, effective_boundaries.as_ref());
if let Some(s) = &soul {
p = format!("{}\n\n{}", s.to_system_prompt_block(), p);
}
if let Some(ref ctx) = config.context_append {
if !ctx.is_empty() {
p.push_str(&format!("\n\n{}", ctx.trim()));
}
}
if let Some(sk) = session_key {
p.push_str(&format!(
"\n\nCurrent session: {} — use session_key '{}' for chat_history and chat_plan.\n\
/compact compresses conversation; result appears as [compaction] in chat_history. \
When user asks about 最新的/compact or /compact效果, read chat_history with session_key '{}'.",
sk, sk, sk
));
}
p
};
let mut messages = Vec::new();
messages.push(ChatMessage::system(&system_prompt));
messages.extend(initial_messages);
messages.push(ChatMessage::user(user_message));
maybe_save_checkpoint(
session_key,
user_message,
config,
&planner,
&messages,
&chat_root,
);
let _ = (soul, effective_boundaries); Ok(PlanningResult {
planner,
messages,
chat_root,
})
}
pub(super) fn build_task_focus_message(
planner: &TaskPlanner,
tools_already_called: &[String],
) -> Option<String> {
let current = planner.current_task()?;
let tool_hint = current.tool_hint.as_deref().unwrap_or("");
let pending_tasks = planner.task_list.iter().filter(|t| !t.completed).count();
let preferred_tools = planner.preferred_tool_names_for_hint(tool_hint).join(",");
let already_called = if tools_already_called.is_empty() {
"none".to_string()
} else {
tools_already_called.join(",")
};
Some(format!(
"[internal_task_focus]\n\
current_task_id={}\n\
pending_tasks={}\n\
tool_hint={}\n\
already_called_this_session={}\n\
final_summary_allowed=false\n\
replan_allowed=true\n\
preferred_tools={}\n\
do_not_quote_or_repeat_this_block=true\n\
[/internal_task_focus]",
current.id,
pending_tasks,
if tool_hint.is_empty() {
"none"
} else {
tool_hint
},
already_called,
if preferred_tools.is_empty() {
"none".to_string()
} else {
preferred_tools
}
))
}
pub(super) fn maybe_save_checkpoint(
session_key: Option<&str>,
user_message: &str,
config: &AgentConfig,
planner: &TaskPlanner,
messages: &[ChatMessage],
chat_root: &Path,
) {
if session_key != Some("run") {
return;
}
let cp = crate::run_checkpoint::RunCheckpoint::new(
user_message.to_string(),
config.workspace.clone(),
planner.task_list.clone(),
messages.to_vec(),
);
if let Err(e) = crate::run_checkpoint::save_checkpoint(chat_root, &cp) {
tracing::debug!("Checkpoint save failed: {}", e);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::planning_guard;
#[test]
fn test_build_task_focus_message_uses_internal_control_block() {
let mut planner = TaskPlanner::new(None, None, None);
planner.task_list = vec![
Task {
id: 1,
description: "Write the page".to_string(),
tool_hint: Some("file_write".to_string()),
completed: false,
},
Task {
id: 2,
description: "Preview the page".to_string(),
tool_hint: Some("preview".to_string()),
completed: false,
},
];
let msg = build_task_focus_message(&planner, &[]).unwrap();
assert!(msg.contains("[internal_task_focus]"));
assert!(msg.contains("current_task_id=1"));
assert!(msg.contains("pending_tasks=2"));
assert!(msg.contains("tool_hint=file_write"));
assert!(msg.contains("already_called_this_session=none"));
assert!(msg.contains("final_summary_allowed=false"));
assert!(msg.contains("preferred_tools=write_file,write_output"));
assert!(!msg.contains("Task progress update"));
assert!(!msg.contains("\"id\": 1"));
assert!(!msg.contains("Preferred tools:"));
let tools = vec!["write_file".to_string(), "preview_server".to_string()];
let msg2 = build_task_focus_message(&planner, &tools).unwrap();
assert!(msg2.contains("already_called_this_session=write_file,preview_server"));
}
#[test]
fn test_planning_guard_replaces_empty_plan_for_code_requests() {
let mut planner = TaskPlanner::new(None, None, None);
assert!(planner.is_empty());
if let Some(guard) = planning_guard::guard_empty_plan(
"在 crates/skilllite-agent/src/task_planner.rs 中补一个单测并验证 rules_used",
) {
planner.task_list = guard.fallback_tasks;
}
assert!(!planner.is_empty());
assert_eq!(planner.task_list[0].tool_hint.as_deref(), Some("file_read"));
}
}