Skip to main content

a3s_code_core/planning/
llm_planner.rs

1//! LLM-powered planning logic
2//!
3//! Provides intelligent plan generation, goal extraction, and achievement
4//! evaluation by sending structured prompts to an LLM and parsing JSON responses.
5//! Falls back to heuristic logic when no LLM client is available.
6
7use 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/// Result of evaluating goal achievement
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct AchievementResult {
17    /// Whether the goal has been achieved
18    pub achieved: bool,
19    /// Progress toward goal (0.0 - 1.0)
20    pub progress: f32,
21    /// Criteria that remain unmet
22    pub remaining_criteria: Vec<String>,
23}
24
25/// Pre-analysis result — intent, goal, plan, and optimized input in one LLM call.
26#[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    /// LLM-rewritten version of the user input with ambiguities resolved.
33    pub optimized_input: String,
34}
35
36/// LLM-powered planner that generates plans, extracts goals, and evaluates achievement
37pub struct LlmPlanner;
38
39// ============================================================================
40// JSON response schemas for LLM parsing
41// ============================================================================
42
43#[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    /// Generate an execution plan from a prompt using LLM
97    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    /// Extract a goal with success criteria from a prompt using LLM
111    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    /// Evaluate whether a goal has been achieved given current state
125    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    /// Create a fallback plan using heuristic logic (no LLM required)
150    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    /// Create a fallback goal using heuristic logic (no LLM required)
185    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    /// Create a fallback achievement result using heuristic logic (no LLM required)
193    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    /// Perform pre-analysis in a single LLM call: intent classification, goal extraction,
215    /// execution plan, and input optimization. Falls back to heuristics on failure.
216    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    // ========================================================================
356    // JSON parsing helpers
357    // ========================================================================
358
359    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    /// Parse JSON from possibly-dirty LLM output into the target type.
413    ///
414    /// Uses the shared robust extractor ([`crate::llm::structured::extract_json_value`]),
415    /// which handles ```json fences, surrounding prose, and braces embedded in
416    /// strings — unlike the previous naive first-`{`/last-`}` slice, which broke
417    /// on fenced output or any `}` inside a string value.
418    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// ============================================================================
425// Tests
426// ============================================================================
427
428#[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); // falls back to Medium
489    }
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        // The old naive first-`{`/last-`}` slice broke when a string value
609        // contained a `}` followed by trailing prose; the robust extractor
610        // balances braces while respecting string boundaries.
611        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        // ```json fence followed by an explanation. The naive parser's
619        // `rfind('}')` could grab a brace from the trailing prose.
620        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    /// Replays a fixed sequence of assistant text responses, one per call.
632    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        // First response is unparseable; pre_analyze must re-prompt and succeed
687        // on the second (valid) response.
688        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        // A single ```json-fenced response must parse on the first attempt
700        // (robust extractor), with no repair round needed.
701        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}