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