use crate::router::Router;
use crate::traits::ModelProvider;
use crate::utils::truncate_str;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use tracing::{info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct TaskPlanStep {
pub description: String,
#[serde(default)]
pub tool_hint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct TaskPlanResponse {
goal: String,
steps: Vec<TaskPlanStep>,
#[serde(default)]
success_criteria: Vec<String>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub(crate) struct TaskPlan {
pub goal: String,
pub steps: Vec<TaskPlanStep>,
pub success_criteria: Vec<String>,
}
const MAX_PLAN_STEPS: usize = 7;
#[allow(dead_code)]
pub(crate) async fn generate_task_plan(
provider: Arc<dyn ModelProvider>,
router: &Router,
user_text: &str,
conversation_context: Option<&str>,
) -> Option<TaskPlan> {
let model = router.select(crate::router::Tier::Primary).to_string();
let system = "You are a task planner. Analyze the user request and return a JSON plan. \
Return ONLY valid JSON, no markdown fences.";
let context_block = conversation_context
.filter(|ctx| !ctx.is_empty())
.map(|ctx| format!("Recent conversation context:\n{}\n\n", ctx))
.unwrap_or_default();
let user_prompt = format!(
"{}Analyze this user request and create a step-by-step plan.\n\n\
User request: \"{}\"\n\n\
Return JSON only:\n\
{{\n \
\"goal\": \"one-line summary of what the user wants\",\n \
\"steps\": [\n \
{{ \"description\": \"specific action step\", \"tool_hint\": \"likely tool name or null\" }}\n \
],\n \
\"success_criteria\": [\n \
\"concrete criterion for task completion\"\n \
]\n\
}}\n\n\
Rules:\n\
- 2-7 steps. Simple questions that need one lookup: 2 steps.\n\
- Each step should be a concrete action, not vague \
(\"search memory for twitter schedule\" not \"investigate\")\n\
- tool_hint is advisory — helps understand step type\n\
- success_criteria: what does \"done\" look like?\n\
- IMPORTANT: When the request contains MULTIPLE distinct actions \
(create + run, write + execute + fix, build + deploy + verify), \
each action MUST be its own step. Never collapse \"create X then run it\" \
into a single step.\n\
- When the task involves creating a file that processes other files, \
ALWAYS start with an inspection step to understand the target files' format.\n\n\
Examples of compound task decomposition:\n\
\"Create a script and run it\" → 3 steps: inspect target files, create script, run script\n\
\"Write tests then fix any failures\" → 3 steps: write tests, run tests, fix failures\n\
\"Build a checker, run it, fix issues\" → 4 steps: inspect files, create checker, run checker, fix reported issues",
context_block,
truncate_str(user_text, 500)
);
let messages = vec![
json!({ "role": "system", "content": system }),
json!({ "role": "user", "content": user_prompt }),
];
let result = match tokio::time::timeout(
std::time::Duration::from_secs(15),
provider.chat(&model, &messages, &[]),
)
.await
{
Ok(Ok(response)) => response,
Ok(Err(e)) => {
warn!(error = %e, "Task planning call failed");
return None;
}
Err(_) => {
warn!("Task planning call timed out (15s)");
return None;
}
};
let content = result.content.as_deref().unwrap_or("");
let json_str = match crate::utils::extract_json_object(content) {
Some(s) => s,
None => {
warn!("Task planning response: no valid JSON found");
return None;
}
};
let parsed: TaskPlanResponse = match serde_json::from_str(&json_str) {
Ok(p) => p,
Err(e) => {
warn!(error = %e, "Task planning response unparseable");
return None;
}
};
if parsed.steps.is_empty() {
return None;
}
let mut steps = parsed.steps;
if steps.len() < 3 && looks_like_compound_task(user_text) {
let has_execution_step = steps.iter().any(|s| {
let d = s.description.to_lowercase();
d.contains("run") || d.contains("execute") || d.contains("test")
});
if !has_execution_step {
steps.push(TaskPlanStep {
description: "Run/execute the created artifact and capture output".to_string(),
tool_hint: Some("terminal".to_string()),
});
}
let has_fix_step = steps.iter().any(|s| {
let d = s.description.to_lowercase();
d.contains("fix") || d.contains("repair") || d.contains("resolve")
});
let lower = user_text.to_lowercase();
if !has_fix_step
&& (lower.contains("fix") || lower.contains("repair") || lower.contains("resolve"))
{
steps.push(TaskPlanStep {
description: "Fix any issues found in the previous step".to_string(),
tool_hint: Some("edit_file".to_string()),
});
}
}
steps.truncate(MAX_PLAN_STEPS);
info!(
goal = %parsed.goal,
step_count = steps.len(),
"Task plan generated"
);
Some(TaskPlan {
goal: parsed.goal,
steps,
success_criteria: parsed.success_criteria,
})
}
fn looks_like_compound_task(user_text: &str) -> bool {
let lower = user_text.to_lowercase();
let has_word = |kw: &str| crate::agent::contains_keyword_as_words(&lower, kw);
let create_actions = ["create", "write", "build", "make", "generate"];
let execute_actions = ["run", "execute", "test", "launch", "start"];
let fix_actions = ["fix", "repair", "resolve", "correct", "patch"];
let verify_phrases = ["show me", "show me the"];
let mut categories = 0u8;
if create_actions.iter().any(|a| has_word(a)) {
categories += 1;
}
if execute_actions.iter().any(|a| has_word(a)) {
categories += 1;
}
if fix_actions.iter().any(|a| has_word(a)) {
categories += 1;
}
if has_word("verify")
|| has_word("check")
|| has_word("display")
|| has_word("report")
|| verify_phrases.iter().any(|p| lower.contains(p))
{
categories += 1;
}
categories >= 2
}
pub(crate) fn should_skip_planning(
_task_kind: &crate::agent::CompletionTaskKind,
user_text: &str,
is_acknowledgment: bool,
) -> bool {
if is_acknowledgment {
return true;
}
let lower = user_text.trim().to_lowercase();
let control_commands = [
"cancel",
"stop",
"nevermind",
"never mind",
"abort",
"forget it",
"forget that",
"nvm",
];
if control_commands.iter().any(|cmd| lower == *cmd) {
return true;
}
false
}
pub(crate) async fn evaluate_step_completion(
provider: Arc<dyn ModelProvider>,
router: &Router,
step_description: &str,
tool_results_summary: &str,
) -> Option<String> {
let model = router.select(crate::router::Tier::Primary).to_string();
let prompt = format!(
"You are evaluating whether a task step is complete.\n\n\
Current step: \"{}\"\n\n\
Tool results from this step:\n{}\n\n\
Has the agent gathered enough information or completed enough work \
to move to the next step? Be practical \u{2014} if the key information \
has been found, the step is complete even if more could theoretically \
be explored.\n\n\
Return JSON only:\n\
{{\"complete\": true/false, \"evidence\": \"brief summary of what was accomplished\"}}",
step_description,
truncate_str(tool_results_summary, 1500)
);
let messages = vec![
json!({
"role": "system",
"content": "You are a task progress evaluator. Return ONLY valid JSON."
}),
json!({ "role": "user", "content": prompt }),
];
let result = match tokio::time::timeout(
std::time::Duration::from_secs(10),
provider.chat(&model, &messages, &[]),
)
.await
{
Ok(Ok(response)) => response,
Ok(Err(e)) => {
warn!(error = %e, "Re-planner call failed");
return None;
}
Err(_) => {
warn!("Re-planner call timed out (10s)");
return None;
}
};
let content = result.content.as_deref().unwrap_or("");
let json_str = crate::utils::extract_json_object(content)?;
#[derive(Deserialize)]
struct ReplanResult {
complete: bool,
#[serde(default)]
evidence: String,
}
match serde_json::from_str::<ReplanResult>(&json_str) {
Ok(r) if r.complete => {
info!(evidence = %r.evidence, "Re-planner: step complete");
Some(r.evidence)
}
Ok(_) => {
info!("Re-planner: step not yet complete");
None
}
Err(e) => {
warn!(error = %e, "Re-planner response parse failed");
None
}
}
}
pub(crate) fn summarize_tool_calls_for_replan(tool_calls: &[String], max_entries: usize) -> String {
tool_calls
.iter()
.rev()
.take(max_entries)
.collect::<Vec<_>>()
.into_iter()
.rev()
.map(|call| {
let display = crate::agent::post_task::display_tool_call(call);
format!("- {}", display)
})
.collect::<Vec<_>>()
.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_does_not_skip_conversational_with_enough_text() {
use crate::agent::CompletionTaskKind;
assert!(!should_skip_planning(
&CompletionTaskKind::Conversational,
"hello there how are you doing today",
false
));
}
#[test]
fn test_should_not_skip_short_text() {
use crate::agent::CompletionTaskKind;
assert!(!should_skip_planning(
&CompletionTaskKind::Find,
"hi",
false
));
}
#[test]
fn test_should_skip_control_commands() {
use crate::agent::CompletionTaskKind;
assert!(should_skip_planning(
&CompletionTaskKind::Conversational,
"cancel",
false
));
assert!(should_skip_planning(
&CompletionTaskKind::Conversational,
"stop",
false
));
assert!(should_skip_planning(
&CompletionTaskKind::Conversational,
"nevermind",
false
));
assert!(should_skip_planning(
&CompletionTaskKind::Conversational,
"nvm",
false
));
assert!(should_skip_planning(
&CompletionTaskKind::Conversational,
"abort",
false
));
}
#[test]
fn test_should_skip_acknowledgment() {
use crate::agent::CompletionTaskKind;
assert!(should_skip_planning(
&CompletionTaskKind::Change,
"ok thanks that looks good",
true
));
}
#[test]
fn test_should_not_skip_real_task() {
use crate::agent::CompletionTaskKind;
assert!(!should_skip_planning(
&CompletionTaskKind::Find,
"Can you figure out how the Twitter scheduled task got removed?",
false
));
}
#[test]
fn test_should_not_skip_change_task() {
use crate::agent::CompletionTaskKind;
assert!(!should_skip_planning(
&CompletionTaskKind::Change,
"Create a website about zeigarnik effect and deploy to cloudflare",
false
));
}
#[test]
fn test_parse_plan_response() {
let json = r#"{
"goal": "Find how Twitter task was removed",
"steps": [
{"description": "Search memory for twitter schedule", "tool_hint": "manage_memories"},
{"description": "Check goal trace", "tool_hint": "goal_trace"},
{"description": "Determine root cause", "tool_hint": null}
],
"success_criteria": ["Identified removal cause"]
}"#;
let parsed: TaskPlanResponse = serde_json::from_str(json).unwrap();
assert_eq!(parsed.steps.len(), 3);
assert_eq!(parsed.goal, "Find how Twitter task was removed");
assert_eq!(parsed.success_criteria.len(), 1);
}
#[test]
fn test_parse_plan_truncates_at_7() {
let steps: Vec<TaskPlanStep> = (0..10)
.map(|i| TaskPlanStep {
description: format!("Step {}", i),
tool_hint: None,
})
.collect();
let mut plan_steps = steps;
plan_steps.truncate(MAX_PLAN_STEPS);
assert_eq!(plan_steps.len(), 7);
}
#[test]
fn test_compound_task_create_and_run() {
assert!(looks_like_compound_task(
"Create a Python script and run it to check all blog posts"
));
}
#[test]
fn test_compound_task_create_run_fix() {
assert!(looks_like_compound_task(
"Write a checker script, execute it, and fix any issues found"
));
}
#[test]
fn test_compound_task_build_and_verify() {
assert!(looks_like_compound_task(
"Build the project and show me the test results"
));
}
#[test]
fn test_not_compound_simple_create() {
assert!(!looks_like_compound_task(
"Create a Python script at ~/projects/blog/tools/checker.py"
));
}
#[test]
fn test_not_compound_simple_question() {
assert!(!looks_like_compound_task("What time is it in Tokyo?"));
}
#[test]
fn test_compound_task_generate_and_test() {
assert!(looks_like_compound_task(
"Generate unit tests for the router module, then run them"
));
}
}