1use crate::llm::structured::{generate_blocking, StructuredMode, StructuredRequest};
8use crate::llm::{LlmClient, Message};
9use crate::planning::{AgentGoal, Complexity, ExecutionPlan, Task};
10use anyhow::{Context, Result};
11use serde::{Deserialize, Serialize};
12use std::sync::Arc;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AchievementResult {
17 pub achieved: bool,
19 pub progress: f32,
21 pub remaining_criteria: Vec<String>,
23}
24
25#[derive(Debug, Clone)]
27pub struct PreAnalysis {
28 pub intent: crate::prompts::AgentStyle,
29 pub requires_planning: bool,
30 pub goal: AgentGoal,
31 pub execution_plan: ExecutionPlan,
32 pub optimized_input: String,
34}
35
36pub struct LlmPlanner;
38
39#[derive(Debug, Deserialize)]
44struct PlanResponse {
45 goal: String,
46 complexity: String,
47 steps: Vec<StepResponse>,
48 #[serde(default)]
49 required_tools: Vec<String>,
50}
51
52#[derive(Debug, Deserialize)]
53struct StepResponse {
54 id: String,
55 description: String,
56 #[serde(default)]
57 tool: Option<String>,
58 #[serde(default)]
59 dependencies: Vec<String>,
60 #[serde(default)]
61 success_criteria: Option<String>,
62}
63
64#[derive(Debug, Deserialize)]
65struct GoalResponse {
66 description: String,
67 success_criteria: Vec<String>,
68}
69
70#[derive(Debug, Deserialize)]
71struct AchievementResponse {
72 achieved: bool,
73 progress: f32,
74 #[serde(default)]
75 remaining_criteria: Vec<String>,
76}
77
78#[derive(Debug, Deserialize)]
79struct PreAnalysisResponse {
80 intent: String,
81 requires_planning: bool,
82 goal: GoalResponse,
83 execution_plan: PreAnalysisPlan,
84 optimized_input: String,
85}
86
87#[derive(Debug, Deserialize)]
88struct PreAnalysisPlan {
89 complexity: String,
90 steps: Vec<StepResponse>,
91 #[serde(default)]
92 required_tools: Vec<String>,
93}
94
95impl LlmPlanner {
96 pub async fn create_plan(llm: &Arc<dyn LlmClient>, prompt: &str) -> Result<ExecutionPlan> {
98 let system = crate::prompts::LLM_PLAN_SYSTEM;
99
100 let messages = vec![Message::user(prompt)];
101 let response = llm
102 .complete(&messages, Some(system), &[])
103 .await
104 .context("LLM call failed during plan creation")?;
105
106 let text = response.text();
107 Self::parse_plan_response(&text)
108 }
109
110 pub async fn extract_goal(llm: &Arc<dyn LlmClient>, prompt: &str) -> Result<AgentGoal> {
112 let system = crate::prompts::LLM_GOAL_EXTRACT_SYSTEM;
113
114 let messages = vec![Message::user(prompt)];
115 let response = llm
116 .complete(&messages, Some(system), &[])
117 .await
118 .context("LLM call failed during goal extraction")?;
119
120 let text = response.text();
121 Self::parse_goal_response(&text)
122 }
123
124 pub async fn check_achievement(
126 llm: &Arc<dyn LlmClient>,
127 goal: &AgentGoal,
128 current_state: &str,
129 ) -> Result<AchievementResult> {
130 let system = crate::prompts::LLM_GOAL_CHECK_SYSTEM;
131
132 let user_message = format!(
133 "Goal: {}\nSuccess Criteria: {}\nCurrent State: {}",
134 goal.description,
135 goal.success_criteria.join("; "),
136 current_state,
137 );
138
139 let messages = vec![Message::user(&user_message)];
140 let response = llm
141 .complete(&messages, Some(system), &[])
142 .await
143 .context("LLM call failed during achievement check")?;
144
145 let text = response.text();
146 Self::parse_achievement_response(&text)
147 }
148
149 pub fn fallback_plan(prompt: &str) -> ExecutionPlan {
151 let complexity = if prompt.len() < 50 {
152 Complexity::Simple
153 } else if prompt.len() < 150 {
154 Complexity::Medium
155 } else if prompt.len() < 300 {
156 Complexity::Complex
157 } else {
158 Complexity::VeryComplex
159 };
160
161 let mut plan = ExecutionPlan::new(prompt, complexity);
162
163 let step_count = match complexity {
164 Complexity::Simple => 2,
165 Complexity::Medium => 4,
166 Complexity::Complex => 7,
167 Complexity::VeryComplex => 10,
168 };
169
170 for i in 0..step_count {
171 let step = Task::new(
172 format!("step-{}", i + 1),
173 crate::prompts::render(
174 crate::prompts::PLAN_FALLBACK_STEP,
175 &[("step_num", &(i + 1).to_string())],
176 ),
177 );
178 plan.add_step(step);
179 }
180
181 plan
182 }
183
184 pub fn fallback_goal(prompt: &str) -> AgentGoal {
186 AgentGoal::new(prompt).with_criteria(vec![
187 "Task is completed successfully".to_string(),
188 "All requirements are met".to_string(),
189 ])
190 }
191
192 pub fn fallback_check_achievement(goal: &AgentGoal, current_state: &str) -> AchievementResult {
194 let state_lower = current_state.to_lowercase();
195 let achieved = state_lower.contains("complete")
196 || state_lower.contains("done")
197 || state_lower.contains("finished");
198
199 let progress = if achieved { 1.0 } else { goal.progress };
200
201 let remaining_criteria = if achieved {
202 Vec::new()
203 } else {
204 goal.success_criteria.clone()
205 };
206
207 AchievementResult {
208 achieved,
209 progress,
210 remaining_criteria,
211 }
212 }
213
214 pub async fn pre_analyze(llm: &Arc<dyn LlmClient>, prompt: &str) -> Result<PreAnalysis> {
217 let req = StructuredRequest {
218 prompt: format!(
219 "Analyze this user request and return a compact pre-analysis object. \
220 Use at most 5 execution steps.\n\nUser request:\n{prompt}"
221 ),
222 system: Some(crate::prompts::PRE_ANALYSIS_SYSTEM.to_string()),
223 schema: Self::pre_analysis_schema(),
224 schema_name: "pre_analysis".to_string(),
225 schema_description: Some(
226 "Intent, goal, plan, and optimized input for an agent turn".to_string(),
227 ),
228 mode: StructuredMode::Auto,
229 max_repair_attempts: 2,
230 };
231
232 let result = generate_blocking(&**llm, &req)
233 .await
234 .context("LLM pre-analysis structured generation failed")?;
235
236 Self::pre_analysis_from_value(result.object, prompt)
237 .context("Failed to parse pre-analysis JSON from LLM response")
238 }
239
240 fn pre_analysis_from_value(
241 value: serde_json::Value,
242 original_prompt: &str,
243 ) -> Result<PreAnalysis> {
244 let parsed: PreAnalysisResponse = serde_json::from_value(value)
245 .context("pre-analysis object did not match the expected response shape")?;
246 Self::pre_analysis_from_response(parsed, original_prompt)
247 }
248
249 fn pre_analysis_from_response(
250 parsed: PreAnalysisResponse,
251 original_prompt: &str,
252 ) -> Result<PreAnalysis> {
253 let intent = match parsed.intent.to_lowercase().as_str() {
254 "plan" => crate::prompts::AgentStyle::Plan,
255 "explore" => crate::prompts::AgentStyle::Explore,
256 "verification" => crate::prompts::AgentStyle::Verification,
257 "codereview" | "code review" => crate::prompts::AgentStyle::CodeReview,
258 _ => crate::prompts::AgentStyle::GeneralPurpose,
259 };
260
261 let goal_description = parsed.goal.description.clone();
262 let goal =
263 AgentGoal::new(goal_description.clone()).with_criteria(parsed.goal.success_criteria);
264
265 let complexity = match parsed.execution_plan.complexity.as_str() {
266 "Simple" => Complexity::Simple,
267 "Medium" => Complexity::Medium,
268 "Complex" => Complexity::Complex,
269 "VeryComplex" => Complexity::VeryComplex,
270 _ => Complexity::Medium,
271 };
272
273 let mut plan = ExecutionPlan::new(goal_description, complexity);
274 for step_resp in parsed.execution_plan.steps {
275 let mut task = Task::new(step_resp.id, step_resp.description);
276 if let Some(tool) = step_resp.tool {
277 task = task.with_tool(tool);
278 }
279 if !step_resp.dependencies.is_empty() {
280 task = task.with_dependencies(step_resp.dependencies);
281 }
282 if let Some(criteria) = step_resp.success_criteria {
283 task = task.with_success_criteria(criteria);
284 }
285 plan.add_step(task);
286 }
287 for tool in parsed.execution_plan.required_tools {
288 plan.add_required_tool(tool);
289 }
290
291 Ok(PreAnalysis {
292 intent,
293 requires_planning: parsed.requires_planning,
294 goal,
295 execution_plan: plan,
296 optimized_input: if parsed.optimized_input.is_empty() {
297 original_prompt.to_string()
298 } else {
299 parsed.optimized_input
300 },
301 })
302 }
303
304 fn pre_analysis_schema() -> serde_json::Value {
305 serde_json::json!({
306 "type": "object",
307 "required": ["intent", "requires_planning", "goal", "execution_plan", "optimized_input"],
308 "properties": {
309 "intent": { "type": "string" },
310 "requires_planning": { "type": "boolean" },
311 "goal": {
312 "type": "object",
313 "required": ["description", "success_criteria"],
314 "properties": {
315 "description": { "type": "string", "minLength": 1 },
316 "success_criteria": {
317 "type": "array",
318 "items": { "type": "string" }
319 }
320 }
321 },
322 "execution_plan": {
323 "type": "object",
324 "required": ["complexity", "steps"],
325 "properties": {
326 "complexity": { "type": "string" },
327 "steps": {
328 "type": "array",
329 "items": {
330 "type": "object",
331 "required": ["id", "description"],
332 "properties": {
333 "id": { "type": "string" },
334 "description": { "type": "string" },
335 "tool": { "type": "string" },
336 "dependencies": {
337 "type": "array",
338 "items": { "type": "string" }
339 },
340 "success_criteria": { "type": "string" }
341 }
342 }
343 },
344 "required_tools": {
345 "type": "array",
346 "items": { "type": "string" }
347 }
348 }
349 },
350 "optimized_input": { "type": "string" }
351 }
352 })
353 }
354
355 fn parse_plan_response(text: &str) -> Result<ExecutionPlan> {
360 let parsed: PlanResponse = Self::parse_json_lenient(text)
361 .context("Failed to parse plan JSON from LLM response")?;
362
363 let complexity = match parsed.complexity.as_str() {
364 "Simple" => Complexity::Simple,
365 "Medium" => Complexity::Medium,
366 "Complex" => Complexity::Complex,
367 "VeryComplex" => Complexity::VeryComplex,
368 _ => Complexity::Medium,
369 };
370
371 let mut plan = ExecutionPlan::new(parsed.goal, complexity);
372
373 for step_resp in parsed.steps {
374 let mut task = Task::new(step_resp.id, step_resp.description);
375 if let Some(tool) = step_resp.tool {
376 task = task.with_tool(tool);
377 }
378 if !step_resp.dependencies.is_empty() {
379 task = task.with_dependencies(step_resp.dependencies);
380 }
381 if let Some(criteria) = step_resp.success_criteria {
382 task = task.with_success_criteria(criteria);
383 }
384 plan.add_step(task);
385 }
386
387 for tool in parsed.required_tools {
388 plan.add_required_tool(tool);
389 }
390
391 Ok(plan)
392 }
393
394 fn parse_goal_response(text: &str) -> Result<AgentGoal> {
395 let parsed: GoalResponse = Self::parse_json_lenient(text)
396 .context("Failed to parse goal JSON from LLM response")?;
397
398 Ok(AgentGoal::new(parsed.description).with_criteria(parsed.success_criteria))
399 }
400
401 fn parse_achievement_response(text: &str) -> Result<AchievementResult> {
402 let parsed: AchievementResponse = Self::parse_json_lenient(text)
403 .context("Failed to parse achievement JSON from LLM response")?;
404
405 Ok(AchievementResult {
406 achieved: parsed.achieved,
407 progress: parsed.progress.clamp(0.0, 1.0),
408 remaining_criteria: parsed.remaining_criteria,
409 })
410 }
411
412 fn parse_json_lenient<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
419 let value = crate::llm::structured::extract_json_value(text)?;
420 Ok(serde_json::from_value(value)?)
421 }
422}
423
424#[cfg(test)]
429mod tests {
430 use super::*;
431
432 #[test]
433 fn test_parse_plan_response() {
434 let json = r#"{
435 "goal": "Build a REST API",
436 "complexity": "Complex",
437 "steps": [
438 {
439 "id": "step-1",
440 "description": "Set up project structure",
441 "tool": "bash",
442 "dependencies": [],
443 "success_criteria": "Project directory created"
444 },
445 {
446 "id": "step-2",
447 "description": "Implement endpoints",
448 "tool": "write",
449 "dependencies": ["step-1"],
450 "success_criteria": "Endpoints respond correctly"
451 }
452 ],
453 "required_tools": ["bash", "write", "read"]
454 }"#;
455
456 let plan = LlmPlanner::parse_plan_response(json).unwrap();
457 assert_eq!(plan.goal, "Build a REST API");
458 assert_eq!(plan.complexity, Complexity::Complex);
459 assert_eq!(plan.steps.len(), 2);
460 assert_eq!(plan.steps[0].id, "step-1");
461 assert_eq!(plan.steps[0].tool, Some("bash".to_string()));
462 assert_eq!(plan.steps[1].dependencies, vec!["step-1".to_string()]);
463 assert_eq!(plan.required_tools, vec!["bash", "write", "read"]);
464 }
465
466 #[test]
467 fn test_parse_plan_response_with_markdown_fences() {
468 let json = "```json\n{\"goal\": \"Test\", \"complexity\": \"Simple\", \"steps\": [{\"id\": \"step-1\", \"description\": \"Do it\"}], \"required_tools\": []}\n```";
469
470 let plan = LlmPlanner::parse_plan_response(json).unwrap();
471 assert_eq!(plan.goal, "Test");
472 assert_eq!(plan.complexity, Complexity::Simple);
473 assert_eq!(plan.steps.len(), 1);
474 }
475
476 #[test]
477 fn test_parse_plan_response_invalid() {
478 let bad_json = "This is not JSON at all";
479 let result = LlmPlanner::parse_plan_response(bad_json);
480 assert!(result.is_err());
481 }
482
483 #[test]
484 fn test_parse_plan_response_unknown_complexity() {
485 let json =
486 r#"{"goal": "Test", "complexity": "Unknown", "steps": [], "required_tools": []}"#;
487 let plan = LlmPlanner::parse_plan_response(json).unwrap();
488 assert_eq!(plan.complexity, Complexity::Medium); }
490
491 #[test]
492 fn test_parse_goal_response() {
493 let json = r#"{
494 "description": "Deploy the application to production",
495 "success_criteria": [
496 "All tests pass",
497 "Application is accessible at production URL",
498 "Health check returns 200"
499 ]
500 }"#;
501
502 let goal = LlmPlanner::parse_goal_response(json).unwrap();
503 assert_eq!(goal.description, "Deploy the application to production");
504 assert_eq!(goal.success_criteria.len(), 3);
505 assert_eq!(goal.success_criteria[0], "All tests pass");
506 }
507
508 #[test]
509 fn test_parse_goal_response_invalid() {
510 let result = LlmPlanner::parse_goal_response("not json");
511 assert!(result.is_err());
512 }
513
514 #[test]
515 fn test_parse_achievement_response() {
516 let json = r#"{
517 "achieved": false,
518 "progress": 0.65,
519 "remaining_criteria": ["Health check not verified"]
520 }"#;
521
522 let result = LlmPlanner::parse_achievement_response(json).unwrap();
523 assert!(!result.achieved);
524 assert!((result.progress - 0.65).abs() < f32::EPSILON);
525 assert_eq!(result.remaining_criteria, vec!["Health check not verified"]);
526 }
527
528 #[test]
529 fn test_parse_achievement_response_achieved() {
530 let json = r#"{"achieved": true, "progress": 1.0, "remaining_criteria": []}"#;
531 let result = LlmPlanner::parse_achievement_response(json).unwrap();
532 assert!(result.achieved);
533 assert!((result.progress - 1.0).abs() < f32::EPSILON);
534 assert!(result.remaining_criteria.is_empty());
535 }
536
537 #[test]
538 fn test_parse_achievement_response_clamps_progress() {
539 let json = r#"{"achieved": false, "progress": 1.5, "remaining_criteria": []}"#;
540 let result = LlmPlanner::parse_achievement_response(json).unwrap();
541 assert!((result.progress - 1.0).abs() < f32::EPSILON);
542 }
543
544 #[test]
545 fn test_fallback_plan() {
546 let short_prompt = "Fix bug";
547 let plan = LlmPlanner::fallback_plan(short_prompt);
548 assert_eq!(plan.complexity, Complexity::Simple);
549 assert_eq!(plan.steps.len(), 2);
550 assert_eq!(plan.goal, short_prompt);
551
552 let long_prompt = "Implement a comprehensive authentication system with OAuth2 support, JWT tokens, refresh token rotation, multi-factor authentication, and role-based access control across all API endpoints with proper audit logging and session management capabilities for both web and mobile clients, including password reset flows, account lockout policies, and integration with external identity providers such as Google, GitHub, and SAML-based enterprise SSO systems";
553 let plan = LlmPlanner::fallback_plan(long_prompt);
554 assert_eq!(plan.complexity, Complexity::VeryComplex);
555 assert_eq!(plan.steps.len(), 10);
556 }
557
558 #[test]
559 fn test_fallback_goal() {
560 let goal = LlmPlanner::fallback_goal("Fix the login bug");
561 assert_eq!(goal.description, "Fix the login bug");
562 assert_eq!(goal.success_criteria.len(), 2);
563 assert_eq!(goal.success_criteria[0], "Task is completed successfully");
564 }
565
566 #[test]
567 fn test_fallback_check_achievement_done() {
568 let goal = AgentGoal::new("Test task").with_criteria(vec!["Criterion 1".to_string()]);
569
570 let result = LlmPlanner::fallback_check_achievement(&goal, "The task is done.");
571 assert!(result.achieved);
572 assert!((result.progress - 1.0).abs() < f32::EPSILON);
573 assert!(result.remaining_criteria.is_empty());
574 }
575
576 #[test]
577 fn test_fallback_check_achievement_not_done() {
578 let goal = AgentGoal::new("Test task")
579 .with_criteria(vec!["Criterion 1".to_string(), "Criterion 2".to_string()]);
580
581 let result = LlmPlanner::fallback_check_achievement(&goal, "Work in progress");
582 assert!(!result.achieved);
583 assert_eq!(result.remaining_criteria.len(), 2);
584 }
585
586 #[test]
587 fn test_parse_json_lenient_plain() {
588 let v: serde_json::Value = LlmPlanner::parse_json_lenient(" {\"a\": 1} ").unwrap();
589 assert_eq!(v["a"], 1);
590 }
591
592 #[test]
593 fn test_parse_json_lenient_with_fences() {
594 let text = "```json\n{\"a\": 1}\n```";
595 let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
596 assert_eq!(v["a"], 1);
597 }
598
599 #[test]
600 fn test_parse_json_lenient_with_surrounding_prose() {
601 let text = "Here is the plan:\n{\"goal\": \"test\"}\nDone.";
602 let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
603 assert_eq!(v["goal"], "test");
604 }
605
606 #[test]
607 fn test_parse_json_lenient_brace_inside_string_value() {
608 let text = "Result: {\"note\": \"use a closing brace } here\"} -- end.";
612 let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
613 assert_eq!(v["note"], "use a closing brace } here");
614 }
615
616 #[test]
617 fn test_parse_json_lenient_fenced_with_trailing_prose() {
618 let text = "```json\n{\"goal\": \"ship\"}\n```\nNote: revisit the `plan` later.";
621 let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
622 assert_eq!(v["goal"], "ship");
623 }
624
625 #[test]
626 fn test_parse_json_lenient_rejects_non_json() {
627 let err = LlmPlanner::parse_json_lenient::<serde_json::Value>("no json here at all");
628 assert!(err.is_err());
629 }
630
631 struct ReplayClient {
633 responses: std::sync::Mutex<Vec<String>>,
634 }
635
636 impl ReplayClient {
637 fn new(responses: Vec<String>) -> Self {
638 Self {
639 responses: std::sync::Mutex::new(responses),
640 }
641 }
642 }
643
644 #[async_trait::async_trait]
645 impl LlmClient for ReplayClient {
646 async fn complete(
647 &self,
648 _messages: &[Message],
649 _system: Option<&str>,
650 _tools: &[crate::llm::ToolDefinition],
651 ) -> anyhow::Result<crate::llm::LlmResponse> {
652 let text = {
653 let mut r = self.responses.lock().unwrap();
654 if r.is_empty() {
655 String::new()
656 } else {
657 r.remove(0)
658 }
659 };
660 Ok(crate::llm::LlmResponse {
661 message: Message {
662 role: "assistant".to_string(),
663 content: vec![crate::llm::ContentBlock::Text { text }],
664 reasoning_content: None,
665 },
666 usage: crate::llm::TokenUsage::default(),
667 stop_reason: None,
668 token_logprobs: Vec::new(),
669 meta: None,
670 })
671 }
672
673 async fn complete_streaming(
674 &self,
675 _messages: &[Message],
676 _system: Option<&str>,
677 _tools: &[crate::llm::ToolDefinition],
678 _cancel_token: tokio_util::sync::CancellationToken,
679 ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::llm::StreamEvent>> {
680 anyhow::bail!("streaming not used in planner tests")
681 }
682 }
683
684 #[tokio::test]
685 async fn test_pre_analyze_repairs_invalid_json() {
686 let good = r#"{"intent":"explore","requires_planning":false,"goal":{"description":"Do x","success_criteria":["done"]},"execution_plan":{"complexity":"Simple","steps":[],"required_tools":[]},"optimized_input":"Do x carefully"}"#;
689 let client: Arc<dyn LlmClient> = Arc::new(ReplayClient::new(vec![
690 "Sorry — here's the plan, but not as JSON.".to_string(),
691 good.to_string(),
692 ]));
693 let pa = LlmPlanner::pre_analyze(&client, "do x").await.unwrap();
694 assert_eq!(pa.optimized_input, "Do x carefully");
695 }
696
697 #[tokio::test]
698 async fn test_pre_analyze_first_try_with_fenced_json() {
699 let good = format!(
702 "```json\n{}\n```",
703 r#"{"intent":"plan","requires_planning":true,"goal":{"description":"g","success_criteria":[]},"execution_plan":{"complexity":"Medium","steps":[],"required_tools":[]},"optimized_input":"opt"}"#
704 );
705 let client: Arc<dyn LlmClient> = Arc::new(ReplayClient::new(vec![good]));
706 let pa = LlmPlanner::pre_analyze(&client, "do x").await.unwrap();
707 assert_eq!(pa.optimized_input, "opt");
708 }
709}