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 prompt = format!(
131 "Goal: {}\nSuccess Criteria: {}\nCurrent State: {}",
132 goal.description,
133 goal.success_criteria.join("; "),
134 current_state,
135 );
136 let req = StructuredRequest {
137 prompt,
138 system: Some(crate::prompts::LLM_GOAL_CHECK_SYSTEM.to_string()),
139 schema: Self::achievement_schema(),
140 schema_name: "goal_achievement".to_string(),
141 schema_description: Some(
142 "Strict, evidence-backed evaluation of whether every goal criterion is met"
143 .to_string(),
144 ),
145 mode: StructuredMode::Auto,
146 max_repair_attempts: 2,
147 };
148
149 let result = generate_blocking(&**llm, &req)
150 .await
151 .context("LLM achievement structured generation failed")?;
152 Self::achievement_from_value(result.object)
153 }
154
155 pub fn fallback_plan(prompt: &str) -> ExecutionPlan {
161 let content = match prompt.trim() {
162 "" => "Complete the requested task",
163 prompt => prompt,
164 };
165 let mut plan = ExecutionPlan::new(content, Complexity::Simple);
166 plan.add_step(Task::new("step-1", content));
167
168 plan
169 }
170
171 pub fn fallback_goal(prompt: &str) -> AgentGoal {
173 AgentGoal::new(prompt).with_criteria(vec![
174 "Task is completed successfully".to_string(),
175 "All requirements are met".to_string(),
176 ])
177 }
178
179 pub fn fallback_check_achievement(goal: &AgentGoal, current_state: &str) -> AchievementResult {
188 let _ = current_state;
189
190 AchievementResult {
191 achieved: false,
192 progress: goal.progress,
193 remaining_criteria: goal.success_criteria.clone(),
194 }
195 }
196
197 pub async fn pre_analyze(llm: &Arc<dyn LlmClient>, prompt: &str) -> Result<PreAnalysis> {
200 let req = StructuredRequest {
201 prompt: format!(
202 "Analyze this user request and return a compact pre-analysis object. \
203 Use at most 5 execution steps.\n\nUser request:\n{prompt}"
204 ),
205 system: Some(crate::prompts::PRE_ANALYSIS_SYSTEM.to_string()),
206 schema: Self::pre_analysis_schema(),
207 schema_name: "pre_analysis".to_string(),
208 schema_description: Some(
209 "Intent, goal, plan, and optimized input for an agent turn".to_string(),
210 ),
211 mode: StructuredMode::Auto,
212 max_repair_attempts: 2,
213 };
214
215 let result = generate_blocking(&**llm, &req)
216 .await
217 .context("LLM pre-analysis structured generation failed")?;
218
219 Self::pre_analysis_from_value(result.object, prompt)
220 .context("Failed to parse pre-analysis JSON from LLM response")
221 }
222
223 fn pre_analysis_from_value(
224 value: serde_json::Value,
225 original_prompt: &str,
226 ) -> Result<PreAnalysis> {
227 let parsed: PreAnalysisResponse = serde_json::from_value(value)
228 .context("pre-analysis object did not match the expected response shape")?;
229 Self::pre_analysis_from_response(parsed, original_prompt)
230 }
231
232 fn pre_analysis_from_response(
233 parsed: PreAnalysisResponse,
234 original_prompt: &str,
235 ) -> Result<PreAnalysis> {
236 let intent = match parsed.intent.to_lowercase().as_str() {
237 "plan" => crate::prompts::AgentStyle::Plan,
238 "explore" => crate::prompts::AgentStyle::Explore,
239 "verification" => crate::prompts::AgentStyle::Verification,
240 "codereview" | "code review" => crate::prompts::AgentStyle::CodeReview,
241 _ => crate::prompts::AgentStyle::GeneralPurpose,
242 };
243
244 let goal_description = parsed.goal.description.clone();
245 let goal =
246 AgentGoal::new(goal_description.clone()).with_criteria(parsed.goal.success_criteria);
247
248 let complexity = match parsed.execution_plan.complexity.as_str() {
249 "Simple" => Complexity::Simple,
250 "Medium" => Complexity::Medium,
251 "Complex" => Complexity::Complex,
252 "VeryComplex" => Complexity::VeryComplex,
253 _ => Complexity::Medium,
254 };
255
256 let mut plan = ExecutionPlan::new(goal_description, complexity);
257 for step_resp in parsed.execution_plan.steps {
258 let mut task = Task::new(step_resp.id, step_resp.description);
259 if let Some(tool) = step_resp.tool {
260 task = task.with_tool(tool);
261 }
262 if !step_resp.dependencies.is_empty() {
263 task = task.with_dependencies(step_resp.dependencies);
264 }
265 if let Some(criteria) = step_resp.success_criteria {
266 task = task.with_success_criteria(criteria);
267 }
268 plan.add_step(task);
269 }
270 for tool in parsed.execution_plan.required_tools {
271 plan.add_required_tool(tool);
272 }
273
274 Ok(PreAnalysis {
275 intent,
276 requires_planning: parsed.requires_planning,
277 goal,
278 execution_plan: plan,
279 optimized_input: if parsed.optimized_input.is_empty() {
280 original_prompt.to_string()
281 } else {
282 parsed.optimized_input
283 },
284 })
285 }
286
287 fn pre_analysis_schema() -> serde_json::Value {
288 serde_json::json!({
289 "type": "object",
290 "required": ["intent", "requires_planning", "goal", "execution_plan", "optimized_input"],
291 "properties": {
292 "intent": { "type": "string" },
293 "requires_planning": { "type": "boolean" },
294 "goal": {
295 "type": "object",
296 "required": ["description", "success_criteria"],
297 "properties": {
298 "description": { "type": "string", "minLength": 1 },
299 "success_criteria": {
300 "type": "array",
301 "items": { "type": "string" }
302 }
303 }
304 },
305 "execution_plan": {
306 "type": "object",
307 "required": ["complexity", "steps"],
308 "properties": {
309 "complexity": { "type": "string" },
310 "steps": {
311 "type": "array",
312 "items": {
313 "type": "object",
314 "required": ["id", "description"],
315 "properties": {
316 "id": { "type": "string" },
317 "description": { "type": "string" },
318 "tool": { "type": "string" },
319 "dependencies": {
320 "type": "array",
321 "items": { "type": "string" }
322 },
323 "success_criteria": { "type": "string" }
324 }
325 }
326 },
327 "required_tools": {
328 "type": "array",
329 "items": { "type": "string" }
330 }
331 }
332 },
333 "optimized_input": { "type": "string" }
334 }
335 })
336 }
337
338 fn achievement_schema() -> serde_json::Value {
339 serde_json::json!({
340 "type": "object",
341 "additionalProperties": false,
342 "required": ["achieved", "progress", "remaining_criteria"],
343 "properties": {
344 "achieved": { "type": "boolean" },
345 "progress": {
346 "type": "number",
347 "minimum": 0.0,
348 "maximum": 1.0
349 },
350 "remaining_criteria": {
351 "type": "array",
352 "items": { "type": "string" }
353 }
354 }
355 })
356 }
357
358 fn achievement_from_value(value: serde_json::Value) -> Result<AchievementResult> {
359 let parsed: AchievementResponse = serde_json::from_value(value)
360 .context("achievement object did not match the expected response shape")?;
361 Ok(AchievementResult {
362 achieved: parsed.achieved,
363 progress: parsed.progress.clamp(0.0, 1.0),
364 remaining_criteria: parsed.remaining_criteria,
365 })
366 }
367
368 fn parse_plan_response(text: &str) -> Result<ExecutionPlan> {
373 let parsed: PlanResponse = Self::parse_json_lenient(text)
374 .context("Failed to parse plan JSON from LLM response")?;
375
376 let complexity = match parsed.complexity.as_str() {
377 "Simple" => Complexity::Simple,
378 "Medium" => Complexity::Medium,
379 "Complex" => Complexity::Complex,
380 "VeryComplex" => Complexity::VeryComplex,
381 _ => Complexity::Medium,
382 };
383
384 let mut plan = ExecutionPlan::new(parsed.goal, complexity);
385
386 for step_resp in parsed.steps {
387 let mut task = Task::new(step_resp.id, step_resp.description);
388 if let Some(tool) = step_resp.tool {
389 task = task.with_tool(tool);
390 }
391 if !step_resp.dependencies.is_empty() {
392 task = task.with_dependencies(step_resp.dependencies);
393 }
394 if let Some(criteria) = step_resp.success_criteria {
395 task = task.with_success_criteria(criteria);
396 }
397 plan.add_step(task);
398 }
399
400 for tool in parsed.required_tools {
401 plan.add_required_tool(tool);
402 }
403
404 Ok(plan)
405 }
406
407 fn parse_goal_response(text: &str) -> Result<AgentGoal> {
408 let parsed: GoalResponse = Self::parse_json_lenient(text)
409 .context("Failed to parse goal JSON from LLM response")?;
410
411 Ok(AgentGoal::new(parsed.description).with_criteria(parsed.success_criteria))
412 }
413
414 #[cfg(test)]
415 fn parse_achievement_response(text: &str) -> Result<AchievementResult> {
416 let parsed: AchievementResponse = Self::parse_json_lenient(text)
417 .context("Failed to parse achievement JSON from LLM response")?;
418
419 Ok(AchievementResult {
420 achieved: parsed.achieved,
421 progress: parsed.progress.clamp(0.0, 1.0),
422 remaining_criteria: parsed.remaining_criteria,
423 })
424 }
425
426 fn parse_json_lenient<T: serde::de::DeserializeOwned>(text: &str) -> Result<T> {
433 let value = crate::llm::structured::extract_json_value(text)?;
434 Ok(serde_json::from_value(value)?)
435 }
436}
437
438#[cfg(test)]
443mod tests {
444 use super::*;
445
446 #[test]
447 fn test_parse_plan_response() {
448 let json = r#"{
449 "goal": "Build a REST API",
450 "complexity": "Complex",
451 "steps": [
452 {
453 "id": "step-1",
454 "description": "Set up project structure",
455 "tool": "bash",
456 "dependencies": [],
457 "success_criteria": "Project directory created"
458 },
459 {
460 "id": "step-2",
461 "description": "Implement endpoints",
462 "tool": "write",
463 "dependencies": ["step-1"],
464 "success_criteria": "Endpoints respond correctly"
465 }
466 ],
467 "required_tools": ["bash", "write", "read"]
468 }"#;
469
470 let plan = LlmPlanner::parse_plan_response(json).unwrap();
471 assert_eq!(plan.goal, "Build a REST API");
472 assert_eq!(plan.complexity, Complexity::Complex);
473 assert_eq!(plan.steps.len(), 2);
474 assert_eq!(plan.steps[0].id, "step-1");
475 assert_eq!(plan.steps[0].tool, Some("bash".to_string()));
476 assert_eq!(plan.steps[1].dependencies, vec!["step-1".to_string()]);
477 assert_eq!(plan.required_tools, vec!["bash", "write", "read"]);
478 }
479
480 #[test]
481 fn test_parse_plan_response_with_markdown_fences() {
482 let json = "```json\n{\"goal\": \"Test\", \"complexity\": \"Simple\", \"steps\": [{\"id\": \"step-1\", \"description\": \"Do it\"}], \"required_tools\": []}\n```";
483
484 let plan = LlmPlanner::parse_plan_response(json).unwrap();
485 assert_eq!(plan.goal, "Test");
486 assert_eq!(plan.complexity, Complexity::Simple);
487 assert_eq!(plan.steps.len(), 1);
488 }
489
490 #[test]
491 fn test_parse_plan_response_invalid() {
492 let bad_json = "This is not JSON at all";
493 let result = LlmPlanner::parse_plan_response(bad_json);
494 assert!(result.is_err());
495 }
496
497 #[test]
498 fn test_parse_plan_response_unknown_complexity() {
499 let json =
500 r#"{"goal": "Test", "complexity": "Unknown", "steps": [], "required_tools": []}"#;
501 let plan = LlmPlanner::parse_plan_response(json).unwrap();
502 assert_eq!(plan.complexity, Complexity::Medium); }
504
505 #[test]
506 fn test_parse_goal_response() {
507 let json = r#"{
508 "description": "Deploy the application to production",
509 "success_criteria": [
510 "All tests pass",
511 "Application is accessible at production URL",
512 "Health check returns 200"
513 ]
514 }"#;
515
516 let goal = LlmPlanner::parse_goal_response(json).unwrap();
517 assert_eq!(goal.description, "Deploy the application to production");
518 assert_eq!(goal.success_criteria.len(), 3);
519 assert_eq!(goal.success_criteria[0], "All tests pass");
520 }
521
522 #[test]
523 fn test_parse_goal_response_invalid() {
524 let result = LlmPlanner::parse_goal_response("not json");
525 assert!(result.is_err());
526 }
527
528 #[test]
529 fn test_parse_achievement_response() {
530 let json = r#"{
531 "achieved": false,
532 "progress": 0.65,
533 "remaining_criteria": ["Health check not verified"]
534 }"#;
535
536 let result = LlmPlanner::parse_achievement_response(json).unwrap();
537 assert!(!result.achieved);
538 assert!((result.progress - 0.65).abs() < f32::EPSILON);
539 assert_eq!(result.remaining_criteria, vec!["Health check not verified"]);
540 }
541
542 #[test]
543 fn test_parse_achievement_response_achieved() {
544 let json = r#"{"achieved": true, "progress": 1.0, "remaining_criteria": []}"#;
545 let result = LlmPlanner::parse_achievement_response(json).unwrap();
546 assert!(result.achieved);
547 assert!((result.progress - 1.0).abs() < f32::EPSILON);
548 assert!(result.remaining_criteria.is_empty());
549 }
550
551 #[test]
552 fn test_parse_achievement_response_clamps_progress() {
553 let json = r#"{"achieved": false, "progress": 1.5, "remaining_criteria": []}"#;
554 let result = LlmPlanner::parse_achievement_response(json).unwrap();
555 assert!((result.progress - 1.0).abs() < f32::EPSILON);
556 }
557
558 #[test]
559 fn test_fallback_plan() {
560 let short_prompt = "Fix bug";
561 let plan = LlmPlanner::fallback_plan(short_prompt);
562 assert_eq!(plan.complexity, Complexity::Simple);
563 assert_eq!(plan.steps.len(), 1);
564 assert_eq!(plan.goal, short_prompt);
565 assert_eq!(plan.steps[0].content, short_prompt);
566
567 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";
568 let plan = LlmPlanner::fallback_plan(long_prompt);
569 assert_eq!(plan.complexity, Complexity::Simple);
570 assert_eq!(plan.steps.len(), 1);
571 assert_eq!(plan.steps[0].content, long_prompt);
572 assert!(
573 !plan.steps[0].content.contains("Execute step"),
574 "fallback plans must not expose placeholder task text"
575 );
576
577 let plan = LlmPlanner::fallback_plan(" ");
578 assert_eq!(plan.goal, "Complete the requested task");
579 assert_eq!(plan.steps[0].content, "Complete the requested task");
580 }
581
582 #[test]
583 fn test_fallback_goal() {
584 let goal = LlmPlanner::fallback_goal("Fix the login bug");
585 assert_eq!(goal.description, "Fix the login bug");
586 assert_eq!(goal.success_criteria.len(), 2);
587 assert_eq!(goal.success_criteria[0], "Task is completed successfully");
588 }
589
590 #[test]
591 fn test_fallback_check_achievement_fails_closed_on_done_text() {
592 let goal = AgentGoal::new("Test task").with_criteria(vec!["Criterion 1".to_string()]);
593
594 let result = LlmPlanner::fallback_check_achievement(&goal, "The task is done.");
595 assert!(!result.achieved);
596 assert!(result.progress.abs() < f32::EPSILON);
597 assert_eq!(result.remaining_criteria, vec!["Criterion 1"]);
598 }
599
600 #[test]
601 fn test_fallback_check_achievement_rejects_negated_completion_text() {
602 let goal = AgentGoal::new("Test task").with_criteria(vec!["Criterion 1".to_string()]);
603
604 for state in [
605 "The task is not complete.",
606 "Verification is not done.",
607 "The implementation is unfinished.",
608 ] {
609 let result = LlmPlanner::fallback_check_achievement(&goal, state);
610 assert!(!result.achieved, "must fail closed for {state:?}");
611 assert_eq!(result.remaining_criteria, vec!["Criterion 1"]);
612 }
613 }
614
615 #[test]
616 fn test_fallback_check_achievement_not_done() {
617 let goal = AgentGoal::new("Test task")
618 .with_criteria(vec!["Criterion 1".to_string(), "Criterion 2".to_string()]);
619
620 let result = LlmPlanner::fallback_check_achievement(&goal, "Work in progress");
621 assert!(!result.achieved);
622 assert_eq!(result.remaining_criteria.len(), 2);
623 }
624
625 #[test]
626 fn test_parse_json_lenient_plain() {
627 let v: serde_json::Value = LlmPlanner::parse_json_lenient(" {\"a\": 1} ").unwrap();
628 assert_eq!(v["a"], 1);
629 }
630
631 #[test]
632 fn test_parse_json_lenient_with_fences() {
633 let text = "```json\n{\"a\": 1}\n```";
634 let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
635 assert_eq!(v["a"], 1);
636 }
637
638 #[test]
639 fn test_parse_json_lenient_with_surrounding_prose() {
640 let text = "Here is the plan:\n{\"goal\": \"test\"}\nDone.";
641 let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
642 assert_eq!(v["goal"], "test");
643 }
644
645 #[test]
646 fn test_parse_json_lenient_brace_inside_string_value() {
647 let text = "Result: {\"note\": \"use a closing brace } here\"} -- end.";
651 let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
652 assert_eq!(v["note"], "use a closing brace } here");
653 }
654
655 #[test]
656 fn test_parse_json_lenient_fenced_with_trailing_prose() {
657 let text = "```json\n{\"goal\": \"ship\"}\n```\nNote: revisit the `plan` later.";
660 let v: serde_json::Value = LlmPlanner::parse_json_lenient(text).unwrap();
661 assert_eq!(v["goal"], "ship");
662 }
663
664 #[test]
665 fn test_parse_json_lenient_rejects_non_json() {
666 let err = LlmPlanner::parse_json_lenient::<serde_json::Value>("no json here at all");
667 assert!(err.is_err());
668 }
669
670 struct ReplayClient {
672 responses: std::sync::Mutex<Vec<String>>,
673 }
674
675 impl ReplayClient {
676 fn new(responses: Vec<String>) -> Self {
677 Self {
678 responses: std::sync::Mutex::new(responses),
679 }
680 }
681 }
682
683 #[async_trait::async_trait]
684 impl LlmClient for ReplayClient {
685 async fn complete(
686 &self,
687 _messages: &[Message],
688 _system: Option<&str>,
689 _tools: &[crate::llm::ToolDefinition],
690 ) -> anyhow::Result<crate::llm::LlmResponse> {
691 let text = {
692 let mut r = self.responses.lock().unwrap();
693 if r.is_empty() {
694 String::new()
695 } else {
696 r.remove(0)
697 }
698 };
699 Ok(crate::llm::LlmResponse {
700 message: Message {
701 role: "assistant".to_string(),
702 content: vec![crate::llm::ContentBlock::Text { text }],
703 reasoning_content: None,
704 },
705 usage: crate::llm::TokenUsage::default(),
706 stop_reason: None,
707 token_logprobs: Vec::new(),
708 meta: None,
709 })
710 }
711
712 async fn complete_streaming(
713 &self,
714 _messages: &[Message],
715 _system: Option<&str>,
716 _tools: &[crate::llm::ToolDefinition],
717 _cancel_token: tokio_util::sync::CancellationToken,
718 ) -> anyhow::Result<tokio::sync::mpsc::Receiver<crate::llm::StreamEvent>> {
719 anyhow::bail!("streaming not used in planner tests")
720 }
721 }
722
723 #[tokio::test]
724 async fn test_pre_analyze_repairs_invalid_json() {
725 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"}"#;
728 let client: Arc<dyn LlmClient> = Arc::new(ReplayClient::new(vec![
729 "Sorry — here's the plan, but not as JSON.".to_string(),
730 good.to_string(),
731 ]));
732 let pa = LlmPlanner::pre_analyze(&client, "do x").await.unwrap();
733 assert_eq!(pa.optimized_input, "Do x carefully");
734 }
735
736 #[tokio::test]
737 async fn test_pre_analyze_first_try_with_fenced_json() {
738 let good = format!(
741 "```json\n{}\n```",
742 r#"{"intent":"plan","requires_planning":true,"goal":{"description":"g","success_criteria":[]},"execution_plan":{"complexity":"Medium","steps":[],"required_tools":[]},"optimized_input":"opt"}"#
743 );
744 let client: Arc<dyn LlmClient> = Arc::new(ReplayClient::new(vec![good]));
745 let pa = LlmPlanner::pre_analyze(&client, "do x").await.unwrap();
746 assert_eq!(pa.optimized_input, "opt");
747 }
748}