syncable_cli/agent/
mod.rs

1//! Agent module for interactive AI-powered CLI assistance
2//!
3//! This module provides an agent layer using the Rig library that allows users
4//! to interact with the CLI through natural language conversations.
5//!
6//! # Features
7//!
8//! - **Conversation History**: Maintains context across multiple turns
9//! - **Automatic Compaction**: Compresses old history when token count exceeds threshold
10//! - **Tool Tracking**: Records tool calls for better context preservation
11//!
12//! # Usage
13//!
14//! ```bash
15//! # Interactive mode
16//! sync-ctl chat
17//!
18//! # With specific provider
19//! sync-ctl chat --provider openai --model gpt-5.2
20//!
21//! # Single query
22//! sync-ctl chat --query "What security issues does this project have?"
23//! ```
24//!
25//! # Interactive Commands
26//!
27//! - `/model` - Switch to a different AI model
28//! - `/provider` - Switch provider (prompts for API key if needed)
29//! - `/help` - Show available commands
30//! - `/clear` - Clear conversation history
31//! - `/exit` - Exit the chat
32
33pub mod commands;
34pub mod compact;
35pub mod history;
36pub mod ide;
37pub mod prompts;
38pub mod session;
39pub mod tools;
40pub mod ui;
41use colored::Colorize;
42use history::{ConversationHistory, ToolCallRecord};
43use ide::IdeClient;
44use rig::{
45    client::{CompletionClient, ProviderClient},
46    completion::Prompt,
47    providers::{anthropic, openai},
48};
49use session::{ChatSession, PlanMode};
50use commands::TokenUsage;
51use std::path::Path;
52use std::sync::Arc;
53use tokio::sync::Mutex as TokioMutex;
54use ui::{ResponseFormatter, ToolDisplayHook};
55
56/// Provider type for the agent
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum ProviderType {
59    #[default]
60    OpenAI,
61    Anthropic,
62    Bedrock,
63}
64
65impl std::fmt::Display for ProviderType {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match self {
68            ProviderType::OpenAI => write!(f, "openai"),
69            ProviderType::Anthropic => write!(f, "anthropic"),
70            ProviderType::Bedrock => write!(f, "bedrock"),
71        }
72    }
73}
74
75impl std::str::FromStr for ProviderType {
76    type Err = String;
77
78    fn from_str(s: &str) -> Result<Self, Self::Err> {
79        match s.to_lowercase().as_str() {
80            "openai" => Ok(ProviderType::OpenAI),
81            "anthropic" => Ok(ProviderType::Anthropic),
82            "bedrock" | "aws" | "aws-bedrock" => Ok(ProviderType::Bedrock),
83            _ => Err(format!("Unknown provider: {}. Use: openai, anthropic, or bedrock", s)),
84        }
85    }
86}
87
88/// Error types for the agent
89#[derive(Debug, thiserror::Error)]
90pub enum AgentError {
91    #[error("Missing API key. Set {0} environment variable.")]
92    MissingApiKey(String),
93
94    #[error("Provider error: {0}")]
95    ProviderError(String),
96
97    #[error("Tool error: {0}")]
98    ToolError(String),
99}
100
101pub type AgentResult<T> = Result<T, AgentError>;
102
103/// Get the system prompt for the agent based on query type and plan mode
104fn get_system_prompt(project_path: &Path, query: Option<&str>, plan_mode: PlanMode) -> String {
105    // In planning mode, use the read-only exploration prompt
106    if plan_mode.is_planning() {
107        return prompts::get_planning_prompt(project_path);
108    }
109
110    if let Some(q) = query {
111        // First check if it's a code development task (highest priority)
112        if prompts::is_code_development_query(q) {
113            return prompts::get_code_development_prompt(project_path);
114        }
115        // Then check if it's DevOps generation (Docker, Terraform, Helm)
116        if prompts::is_generation_query(q) {
117            return prompts::get_devops_prompt(project_path);
118        }
119    }
120    // Default to analysis prompt
121    prompts::get_analysis_prompt(project_path)
122}
123
124/// Run the agent in interactive mode with custom REPL supporting /model and /provider commands
125pub async fn run_interactive(
126    project_path: &Path,
127    provider: ProviderType,
128    model: Option<String>,
129) -> AgentResult<()> {
130    use tools::*;
131
132    let mut session = ChatSession::new(project_path, provider, model);
133
134    // Initialize conversation history with compaction support
135    let mut conversation_history = ConversationHistory::new();
136
137    // Initialize IDE client for native diff viewing
138    let ide_client: Option<Arc<TokioMutex<IdeClient>>> = {
139        let mut client = IdeClient::new().await;
140        if client.is_ide_available() {
141            match client.connect().await {
142                Ok(()) => {
143                    println!(
144                        "{} Connected to {} IDE companion",
145                        "โœ“".green(),
146                        client.ide_name().unwrap_or("VS Code")
147                    );
148                    Some(Arc::new(TokioMutex::new(client)))
149                }
150                Err(e) => {
151                    // IDE detected but companion not running or connection failed
152                    println!(
153                        "{} IDE companion not connected: {}",
154                        "!".yellow(),
155                        e
156                    );
157                    None
158                }
159            }
160        } else {
161            println!("{} No IDE detected (TERM_PROGRAM={})", "ยท".dimmed(), std::env::var("TERM_PROGRAM").unwrap_or_default());
162            None
163        }
164    };
165
166    // Load API key from config file to env if not already set
167    ChatSession::load_api_key_to_env(session.provider);
168
169    // Check if API key is configured, prompt if not
170    if !ChatSession::has_api_key(session.provider) {
171        ChatSession::prompt_api_key(session.provider)?;
172    }
173
174    session.print_banner();
175
176    // Raw Rig messages for multi-turn - preserves Reasoning blocks for thinking
177    // Our ConversationHistory only stores text summaries, but rig needs full Message structure
178    let mut raw_chat_history: Vec<rig::completion::Message> = Vec::new();
179
180    // Pending input for auto-continue after plan creation
181    let mut pending_input: Option<String> = None;
182    // Auto-accept mode for plan execution (skips write confirmations)
183    let mut auto_accept_writes = false;
184
185    loop {
186        // Show conversation status if we have history
187        if !conversation_history.is_empty() {
188            println!("{}", format!("  ๐Ÿ’ฌ Context: {}", conversation_history.status()).dimmed());
189        }
190
191        // Check for pending input (from plan menu selection)
192        let input = if let Some(pending) = pending_input.take() {
193            // Show what we're executing
194            println!("{} {}", "โ†’".cyan(), pending.dimmed());
195            pending
196        } else {
197            // New user turn - reset auto-accept mode from previous plan execution
198            auto_accept_writes = false;
199
200            // Read user input (returns InputResult)
201            let input_result = match session.read_input() {
202                Ok(result) => result,
203                Err(_) => break,
204            };
205
206            // Handle the input result
207            match input_result {
208                ui::InputResult::Submit(text) => ChatSession::process_submitted_text(&text),
209                ui::InputResult::Cancel | ui::InputResult::Exit => break,
210                ui::InputResult::TogglePlanMode => {
211                    // Toggle planning mode - minimal feedback, no extra newlines
212                    let new_mode = session.toggle_plan_mode();
213                    if new_mode.is_planning() {
214                        println!("{}", "โ˜… plan mode".yellow());
215                    } else {
216                        println!("{}", "โ–ถ standard mode".green());
217                    }
218                    continue;
219                }
220            }
221        };
222
223        if input.is_empty() {
224            continue;
225        }
226
227        // Check for commands
228        if ChatSession::is_command(&input) {
229            // Special handling for /clear to also clear conversation history
230            if input.trim().to_lowercase() == "/clear" || input.trim().to_lowercase() == "/c" {
231                conversation_history.clear();
232                raw_chat_history.clear();
233            }
234            match session.process_command(&input) {
235                Ok(true) => continue,
236                Ok(false) => break, // /exit
237                Err(e) => {
238                    eprintln!("{}", format!("Error: {}", e).red());
239                    continue;
240                }
241            }
242        }
243
244        // Check API key before making request (in case provider changed)
245        if !ChatSession::has_api_key(session.provider) {
246            eprintln!("{}", "No API key configured. Use /provider to set one.".yellow());
247            continue;
248        }
249
250        // Check if compaction is needed before making the request
251        if conversation_history.needs_compaction() {
252            println!("{}", "  ๐Ÿ“ฆ Compacting conversation history...".dimmed());
253            if let Some(summary) = conversation_history.compact() {
254                println!("{}", format!("  โœ“ Compressed {} turns", summary.matches("Turn").count()).dimmed());
255            }
256        }
257
258        // Pre-request check: estimate if we're approaching context limit
259        // Check raw_chat_history (actual messages) not conversation_history
260        // because conversation_history may be out of sync
261        let estimated_input_tokens = estimate_raw_history_tokens(&raw_chat_history)
262            + input.len() / 4  // New input
263            + 5000; // System prompt overhead estimate
264
265        if estimated_input_tokens > 150_000 {
266            println!("{}", "  โš  Large context detected. Pre-truncating...".yellow());
267
268            let old_count = raw_chat_history.len();
269            // Keep last 20 messages when approaching limit
270            if raw_chat_history.len() > 20 {
271                let drain_count = raw_chat_history.len() - 20;
272                raw_chat_history.drain(0..drain_count);
273                conversation_history.clear(); // Stay in sync
274                println!("{}", format!("  โœ“ Truncated {} โ†’ {} messages", old_count, raw_chat_history.len()).dimmed());
275            }
276        }
277
278        // Retry loop for automatic error recovery
279        // MAX_RETRIES is for failures without progress
280        // MAX_CONTINUATIONS is for truncations WITH progress (more generous)
281        // TOOL_CALL_CHECKPOINT is the interval at which we ask user to confirm
282        // MAX_TOOL_CALLS is the absolute maximum (300 = 6 checkpoints x 50)
283        const MAX_RETRIES: u32 = 3;
284        const MAX_CONTINUATIONS: u32 = 10;
285        const TOOL_CALL_CHECKPOINT: usize = 50;
286        const MAX_TOOL_CALLS: usize = 300;
287        let mut retry_attempt = 0;
288        let mut continuation_count = 0;
289        let mut total_tool_calls: usize = 0;
290        let mut auto_continue_tools = false; // User can select "always" to skip future prompts
291        let mut current_input = input.clone();
292        let mut succeeded = false;
293
294        while retry_attempt < MAX_RETRIES && continuation_count < MAX_CONTINUATIONS && !succeeded {
295
296            // Log if this is a continuation attempt
297            if continuation_count > 0 {
298                eprintln!("{}", format!("  ๐Ÿ“ก Sending continuation request...").dimmed());
299            }
300
301            // Create hook for Claude Code style tool display
302            let hook = ToolDisplayHook::new();
303
304            let project_path_buf = session.project_path.clone();
305            // Select prompt based on query type (analysis vs generation) and plan mode
306            let preamble = get_system_prompt(&session.project_path, Some(&current_input), session.plan_mode);
307            let is_generation = prompts::is_generation_query(&current_input);
308            let is_planning = session.plan_mode.is_planning();
309
310            // Note: using raw_chat_history directly which preserves Reasoning blocks
311            // This is needed for extended thinking to work with multi-turn conversations
312
313            let response = match session.provider {
314                ProviderType::OpenAI => {
315                    let client = openai::Client::from_env();
316                    // For GPT-5.x reasoning models, enable reasoning with summary output
317                    // so we can see the model's thinking process
318                    let reasoning_params = if session.model.starts_with("gpt-5") || session.model.starts_with("o1") {
319                        Some(serde_json::json!({
320                            "reasoning": {
321                                "effort": "medium",
322                                "summary": "detailed"
323                            }
324                        }))
325                    } else {
326                        None
327                    };
328
329                    let mut builder = client
330                        .agent(&session.model)
331                        .preamble(&preamble)
332                        .max_tokens(4096)
333                        .tool(AnalyzeTool::new(project_path_buf.clone()))
334                        .tool(SecurityScanTool::new(project_path_buf.clone()))
335                        .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
336                        .tool(HadolintTool::new(project_path_buf.clone()))
337                        .tool(TerraformFmtTool::new(project_path_buf.clone()))
338                        .tool(TerraformValidateTool::new(project_path_buf.clone()))
339                        .tool(TerraformInstallTool::new())
340                        .tool(ReadFileTool::new(project_path_buf.clone()))
341                        .tool(ListDirectoryTool::new(project_path_buf.clone()));
342
343                    // Add tools based on mode
344                    if is_planning {
345                        // Plan mode: read-only shell + plan creation tools
346                        builder = builder
347                            .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
348                            .tool(PlanCreateTool::new(project_path_buf.clone()))
349                            .tool(PlanListTool::new(project_path_buf.clone()));
350                    } else if is_generation {
351                        // Standard mode + generation query: all tools including file writes and plan execution
352                        let (mut write_file_tool, mut write_files_tool) = if let Some(ref client) = ide_client {
353                            (
354                                WriteFileTool::new(project_path_buf.clone())
355                                    .with_ide_client(client.clone()),
356                                WriteFilesTool::new(project_path_buf.clone())
357                                    .with_ide_client(client.clone()),
358                            )
359                        } else {
360                            (
361                                WriteFileTool::new(project_path_buf.clone()),
362                                WriteFilesTool::new(project_path_buf.clone()),
363                            )
364                        };
365                        // Disable confirmations if auto-accept mode is enabled (from plan menu)
366                        if auto_accept_writes {
367                            write_file_tool = write_file_tool.without_confirmation();
368                            write_files_tool = write_files_tool.without_confirmation();
369                        }
370                        builder = builder
371                            .tool(write_file_tool)
372                            .tool(write_files_tool)
373                            .tool(ShellTool::new(project_path_buf.clone()))
374                            .tool(PlanListTool::new(project_path_buf.clone()))
375                            .tool(PlanNextTool::new(project_path_buf.clone()))
376                            .tool(PlanUpdateTool::new(project_path_buf.clone()));
377                    }
378
379                    if let Some(params) = reasoning_params {
380                        builder = builder.additional_params(params);
381                    }
382
383                    let agent = builder.build();
384                    // Allow up to 50 tool call turns for complex generation tasks
385                    // Use hook to display tool calls as they happen
386                    // Pass conversation history for context continuity
387                    agent.prompt(&current_input)
388                        .with_history(&mut raw_chat_history)
389                        .with_hook(hook.clone())
390                        .multi_turn(50)
391                        .await
392                }
393                ProviderType::Anthropic => {
394                    let client = anthropic::Client::from_env();
395
396                    // TODO: Extended thinking for Claude is disabled because rig-bedrock/rig-anthropic
397                    // don't properly handle thinking blocks in multi-turn conversations with tool use.
398                    // When thinking is enabled, ALL assistant messages must start with thinking blocks
399                    // BEFORE tool_use blocks, but rig doesn't preserve/replay these.
400                    // See: forge/crates/forge_services/src/provider/bedrock/provider.rs for reference impl.
401
402                    let mut builder = client
403                        .agent(&session.model)
404                        .preamble(&preamble)
405                        .max_tokens(4096)
406                        .tool(AnalyzeTool::new(project_path_buf.clone()))
407                        .tool(SecurityScanTool::new(project_path_buf.clone()))
408                        .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
409                        .tool(HadolintTool::new(project_path_buf.clone()))
410                        .tool(TerraformFmtTool::new(project_path_buf.clone()))
411                        .tool(TerraformValidateTool::new(project_path_buf.clone()))
412                        .tool(TerraformInstallTool::new())
413                        .tool(ReadFileTool::new(project_path_buf.clone()))
414                        .tool(ListDirectoryTool::new(project_path_buf.clone()));
415
416                    // Add tools based on mode
417                    if is_planning {
418                        // Plan mode: read-only shell + plan creation tools
419                        builder = builder
420                            .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
421                            .tool(PlanCreateTool::new(project_path_buf.clone()))
422                            .tool(PlanListTool::new(project_path_buf.clone()));
423                    } else if is_generation {
424                        // Standard mode + generation query: all tools including file writes and plan execution
425                        let (mut write_file_tool, mut write_files_tool) = if let Some(ref client) = ide_client {
426                            (
427                                WriteFileTool::new(project_path_buf.clone())
428                                    .with_ide_client(client.clone()),
429                                WriteFilesTool::new(project_path_buf.clone())
430                                    .with_ide_client(client.clone()),
431                            )
432                        } else {
433                            (
434                                WriteFileTool::new(project_path_buf.clone()),
435                                WriteFilesTool::new(project_path_buf.clone()),
436                            )
437                        };
438                        // Disable confirmations if auto-accept mode is enabled (from plan menu)
439                        if auto_accept_writes {
440                            write_file_tool = write_file_tool.without_confirmation();
441                            write_files_tool = write_files_tool.without_confirmation();
442                        }
443                        builder = builder
444                            .tool(write_file_tool)
445                            .tool(write_files_tool)
446                            .tool(ShellTool::new(project_path_buf.clone()))
447                            .tool(PlanListTool::new(project_path_buf.clone()))
448                            .tool(PlanNextTool::new(project_path_buf.clone()))
449                            .tool(PlanUpdateTool::new(project_path_buf.clone()));
450                    }
451
452                    let agent = builder.build();
453
454                    // Allow up to 50 tool call turns for complex generation tasks
455                    // Use hook to display tool calls as they happen
456                    // Pass conversation history for context continuity
457                    agent.prompt(&current_input)
458                        .with_history(&mut raw_chat_history)
459                        .with_hook(hook.clone())
460                        .multi_turn(50)
461                        .await
462                }
463                ProviderType::Bedrock => {
464                    // Bedrock provider via rig-bedrock - same pattern as OpenAI/Anthropic
465                    let client = rig_bedrock::client::Client::from_env();
466
467                    // Extended thinking for Claude models via Bedrock
468                    // This enables Claude to show its reasoning process before responding.
469                    // Requires vendored rig-bedrock that preserves Reasoning blocks with tool calls.
470                    // Extended thinking budget - reduced to help with rate limits
471                    // 8000 is enough for most tasks, increase to 16000 for complex analysis
472                    let thinking_params = serde_json::json!({
473                        "thinking": {
474                            "type": "enabled",
475                            "budget_tokens": 8000
476                        }
477                    });
478
479                    let mut builder = client
480                        .agent(&session.model)
481                        .preamble(&preamble)
482                        .max_tokens(64000)  // Max output tokens for Claude Sonnet on Bedrock
483                        .tool(AnalyzeTool::new(project_path_buf.clone()))
484                        .tool(SecurityScanTool::new(project_path_buf.clone()))
485                        .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
486                        .tool(HadolintTool::new(project_path_buf.clone()))
487                        .tool(TerraformFmtTool::new(project_path_buf.clone()))
488                        .tool(TerraformValidateTool::new(project_path_buf.clone()))
489                        .tool(TerraformInstallTool::new())
490                        .tool(ReadFileTool::new(project_path_buf.clone()))
491                        .tool(ListDirectoryTool::new(project_path_buf.clone()));
492
493                    // Add tools based on mode
494                    if is_planning {
495                        // Plan mode: read-only shell + plan creation tools
496                        builder = builder
497                            .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
498                            .tool(PlanCreateTool::new(project_path_buf.clone()))
499                            .tool(PlanListTool::new(project_path_buf.clone()));
500                    } else if is_generation {
501                        // Standard mode + generation query: all tools including file writes and plan execution
502                        let (mut write_file_tool, mut write_files_tool) = if let Some(ref client) = ide_client {
503                            (
504                                WriteFileTool::new(project_path_buf.clone())
505                                    .with_ide_client(client.clone()),
506                                WriteFilesTool::new(project_path_buf.clone())
507                                    .with_ide_client(client.clone()),
508                            )
509                        } else {
510                            (
511                                WriteFileTool::new(project_path_buf.clone()),
512                                WriteFilesTool::new(project_path_buf.clone()),
513                            )
514                        };
515                        // Disable confirmations if auto-accept mode is enabled (from plan menu)
516                        if auto_accept_writes {
517                            write_file_tool = write_file_tool.without_confirmation();
518                            write_files_tool = write_files_tool.without_confirmation();
519                        }
520                        builder = builder
521                            .tool(write_file_tool)
522                            .tool(write_files_tool)
523                            .tool(ShellTool::new(project_path_buf.clone()))
524                            .tool(PlanListTool::new(project_path_buf.clone()))
525                            .tool(PlanNextTool::new(project_path_buf.clone()))
526                            .tool(PlanUpdateTool::new(project_path_buf.clone()));
527                    }
528
529                    // Add thinking params for extended reasoning
530                    builder = builder.additional_params(thinking_params);
531
532                    let agent = builder.build();
533
534                    // Use same multi-turn pattern as OpenAI/Anthropic
535                    agent.prompt(&current_input)
536                        .with_history(&mut raw_chat_history)
537                        .with_hook(hook.clone())
538                        .multi_turn(50)
539                        .await
540                }
541            };
542
543            match response {
544                Ok(text) => {
545                    // Show final response
546                    println!();
547                    ResponseFormatter::print_response(&text);
548
549                    // Track token usage - use actual from hook if available, else estimate
550                    let hook_usage = hook.get_usage().await;
551                    if hook_usage.has_data() {
552                        // Use actual token counts from API response
553                        session.token_usage.add_actual(hook_usage.input_tokens, hook_usage.output_tokens);
554                    } else {
555                        // Fall back to estimation when API doesn't provide usage
556                        let prompt_tokens = TokenUsage::estimate_tokens(&input);
557                        let completion_tokens = TokenUsage::estimate_tokens(&text);
558                        session.token_usage.add_estimated(prompt_tokens, completion_tokens);
559                    }
560                    // Reset hook usage for next request batch
561                    hook.reset_usage().await;
562
563                    // Show context indicator like Forge: [model/~tokens]
564                    let model_short = session.model.split('/').last()
565                        .unwrap_or(&session.model)
566                        .split(':').next()
567                        .unwrap_or(&session.model);
568                    println!();
569                    println!(
570                        "  {}[{}/{}]{}",
571                        ui::colors::ansi::DIM,
572                        model_short,
573                        session.token_usage.format_compact(),
574                        ui::colors::ansi::RESET
575                    );
576
577                    // Extract tool calls from the hook state for history tracking
578                    let tool_calls = extract_tool_calls_from_hook(&hook).await;
579                    let batch_tool_count = tool_calls.len();
580                    total_tool_calls += batch_tool_count;
581
582                    // Show tool call summary if significant
583                    if batch_tool_count > 10 {
584                        println!("{}", format!("  โœ“ Completed with {} tool calls ({} total this session)", batch_tool_count, total_tool_calls).dimmed());
585                    }
586
587                    // Add to conversation history with tool call records
588                    conversation_history.add_turn(input.clone(), text.clone(), tool_calls.clone());
589
590                    // Check if this heavy turn requires immediate compaction
591                    // This helps prevent context overflow in subsequent requests
592                    if conversation_history.needs_compaction() {
593                        println!("{}", "  ๐Ÿ“ฆ Compacting conversation history...".dimmed());
594                        if let Some(summary) = conversation_history.compact() {
595                            println!("{}", format!("  โœ“ Compressed {} turns", summary.matches("Turn").count()).dimmed());
596                        }
597                    }
598
599                    // Also update legacy session history for compatibility
600                    session.history.push(("user".to_string(), input.clone()));
601                    session.history.push(("assistant".to_string(), text.clone()));
602
603                    // Check if plan_create was called - show interactive menu
604                    if let Some(plan_info) = find_plan_create_call(&tool_calls) {
605                        println!(); // Space before menu
606
607                        // Show the plan action menu (don't switch modes yet - let user choose)
608                        match ui::show_plan_action_menu(&plan_info.0, plan_info.1) {
609                            ui::PlanActionResult::ExecuteAutoAccept => {
610                                // Now switch to standard mode for execution
611                                if session.plan_mode.is_planning() {
612                                    session.plan_mode = session.plan_mode.toggle();
613                                }
614                                auto_accept_writes = true;
615                                pending_input = Some(format!(
616                                    "Execute the plan at '{}'. Use plan_next to get tasks and execute them in order. Auto-accept all file writes.",
617                                    plan_info.0
618                                ));
619                                succeeded = true;
620                            }
621                            ui::PlanActionResult::ExecuteWithReview => {
622                                // Now switch to standard mode for execution
623                                if session.plan_mode.is_planning() {
624                                    session.plan_mode = session.plan_mode.toggle();
625                                }
626                                pending_input = Some(format!(
627                                    "Execute the plan at '{}'. Use plan_next to get tasks and execute them in order.",
628                                    plan_info.0
629                                ));
630                                succeeded = true;
631                            }
632                            ui::PlanActionResult::ChangePlan(feedback) => {
633                                // Stay in plan mode for modifications
634                                pending_input = Some(format!(
635                                    "Please modify the plan at '{}'. User feedback: {}",
636                                    plan_info.0, feedback
637                                ));
638                                succeeded = true;
639                            }
640                            ui::PlanActionResult::Cancel => {
641                                // Just complete normally, don't execute
642                                succeeded = true;
643                            }
644                        }
645                    } else {
646                        succeeded = true;
647                    }
648                }
649                Err(e) => {
650                    let err_str = e.to_string();
651
652                    println!();
653
654                    // Check if this is a max depth error - handle as checkpoint
655                    if err_str.contains("MaxDepth") || err_str.contains("max_depth") || err_str.contains("reached limit") {
656                        // Extract what was done before hitting the limit
657                        let completed_tools = extract_tool_calls_from_hook(&hook).await;
658                        let agent_thinking = extract_agent_messages_from_hook(&hook).await;
659                        let batch_tool_count = completed_tools.len();
660                        total_tool_calls += batch_tool_count;
661
662                        eprintln!("{}", format!(
663                            "โš  Reached {} tool calls this batch ({} total). Maximum allowed: {}",
664                            batch_tool_count, total_tool_calls, MAX_TOOL_CALLS
665                        ).yellow());
666
667                        // Check if we've hit the absolute maximum
668                        if total_tool_calls >= MAX_TOOL_CALLS {
669                            eprintln!("{}", format!("Maximum tool call limit ({}) reached.", MAX_TOOL_CALLS).red());
670                            eprintln!("{}", "The task is too complex. Try breaking it into smaller parts.".dimmed());
671                            break;
672                        }
673
674                        // Ask user if they want to continue (unless auto-continue is enabled)
675                        let should_continue = if auto_continue_tools {
676                            eprintln!("{}", "  Auto-continuing (you selected 'always')...".dimmed());
677                            true
678                        } else {
679                            eprintln!("{}", "Excessive tool calls used. Want to continue?".yellow());
680                            eprintln!("{}", "  [y] Yes, continue  [n] No, stop  [a] Always continue".dimmed());
681                            print!("  > ");
682                            let _ = std::io::Write::flush(&mut std::io::stdout());
683
684                            // Read user input
685                            let mut response = String::new();
686                            match std::io::stdin().read_line(&mut response) {
687                                Ok(_) => {
688                                    let resp = response.trim().to_lowercase();
689                                    if resp == "a" || resp == "always" {
690                                        auto_continue_tools = true;
691                                        true
692                                    } else {
693                                        resp == "y" || resp == "yes" || resp.is_empty()
694                                    }
695                                }
696                                Err(_) => false,
697                            }
698                        };
699
700                        if !should_continue {
701                            eprintln!("{}", "Stopped by user. Type 'continue' to resume later.".dimmed());
702                            // Add partial progress to history
703                            if !completed_tools.is_empty() {
704                                conversation_history.add_turn(
705                                    current_input.clone(),
706                                    format!("[Stopped at checkpoint - {} tools completed]", batch_tool_count),
707                                    vec![]
708                                );
709                            }
710                            break;
711                        }
712
713                        // Continue from checkpoint
714                        eprintln!("{}", format!(
715                            "  โ†’ Continuing... {} remaining tool calls available",
716                            MAX_TOOL_CALLS - total_tool_calls
717                        ).dimmed());
718
719                        // Add partial progress to history (without duplicating tool calls)
720                        conversation_history.add_turn(
721                            current_input.clone(),
722                            format!("[Checkpoint - {} tools completed, continuing...]", batch_tool_count),
723                            vec![]
724                        );
725
726                        // Build continuation prompt
727                        current_input = build_continuation_prompt(&input, &completed_tools, &agent_thinking);
728
729                        // Brief delay before continuation
730                        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
731                        continue; // Continue the loop without incrementing retry_attempt
732                    } else if err_str.contains("rate") || err_str.contains("Rate") || err_str.contains("429")
733                        || err_str.contains("Too many tokens") || err_str.contains("please wait")
734                        || err_str.contains("throttl") || err_str.contains("Throttl") {
735                        eprintln!("{}", "โš  Rate limited by API provider.".yellow());
736                        // Wait before retry for rate limits (longer wait for "too many tokens")
737                        retry_attempt += 1;
738                        let wait_secs = if err_str.contains("Too many tokens") { 30 } else { 5 };
739                        eprintln!("{}", format!("  Waiting {} seconds before retry ({}/{})...", wait_secs, retry_attempt, MAX_RETRIES).dimmed());
740                        tokio::time::sleep(tokio::time::Duration::from_secs(wait_secs)).await;
741                    } else if is_input_too_long_error(&err_str) {
742                        // Context too large - truncate raw_chat_history directly
743                        // NOTE: We truncate raw_chat_history (actual messages) not conversation_history
744                        // because conversation_history may be empty/stale during errors
745                        eprintln!("{}", "โš  Context too large for model. Truncating history...".yellow());
746
747                        let old_token_count = estimate_raw_history_tokens(&raw_chat_history);
748                        let old_msg_count = raw_chat_history.len();
749
750                        // Strategy: Keep only the last N messages (user/assistant pairs)
751                        // More aggressive truncation on each retry: 10 โ†’ 6 โ†’ 4 messages
752                        let keep_count = match retry_attempt {
753                            0 => 10,
754                            1 => 6,
755                            _ => 4,
756                        };
757
758                        if raw_chat_history.len() > keep_count {
759                            // Drain older messages, keep the most recent ones
760                            let drain_count = raw_chat_history.len() - keep_count;
761                            raw_chat_history.drain(0..drain_count);
762                        }
763
764                        let new_token_count = estimate_raw_history_tokens(&raw_chat_history);
765                        eprintln!("{}", format!(
766                            "  โœ“ Truncated: {} messages (~{} tokens) โ†’ {} messages (~{} tokens)",
767                            old_msg_count, old_token_count, raw_chat_history.len(), new_token_count
768                        ).green());
769
770                        // Also clear conversation_history to stay in sync
771                        conversation_history.clear();
772
773                        // Retry with truncated context
774                        retry_attempt += 1;
775                        if retry_attempt < MAX_RETRIES {
776                            eprintln!("{}", format!("  โ†’ Retrying with truncated context ({}/{})...", retry_attempt, MAX_RETRIES).dimmed());
777                            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
778                        } else {
779                            eprintln!("{}", "Context still too large after truncation. Try /clear to reset.".red());
780                            break;
781                        }
782                    } else if is_truncation_error(&err_str) {
783                        // Truncation error - try intelligent continuation
784                        let completed_tools = extract_tool_calls_from_hook(&hook).await;
785                        let agent_thinking = extract_agent_messages_from_hook(&hook).await;
786
787                        // Count actually completed tools (not in-progress)
788                        let completed_count = completed_tools.iter()
789                            .filter(|t| !t.result_summary.contains("IN PROGRESS"))
790                            .count();
791                        let in_progress_count = completed_tools.len() - completed_count;
792
793                        if !completed_tools.is_empty() && continuation_count < MAX_CONTINUATIONS {
794                            // We have partial progress - continue from where we left off
795                            continuation_count += 1;
796                            let status_msg = if in_progress_count > 0 {
797                                format!(
798                                    "โš  Response truncated. {} completed, {} in-progress. Auto-continuing ({}/{})...",
799                                    completed_count, in_progress_count, continuation_count, MAX_CONTINUATIONS
800                                )
801                            } else {
802                                format!(
803                                    "โš  Response truncated. {} tool calls completed. Auto-continuing ({}/{})...",
804                                    completed_count, continuation_count, MAX_CONTINUATIONS
805                                )
806                            };
807                            eprintln!("{}", status_msg.yellow());
808
809                            // Add partial progress to conversation history
810                            // NOTE: We intentionally pass empty tool_calls here because the
811                            // continuation prompt already contains the detailed file list.
812                            // Including them in history would duplicate the context and waste tokens.
813                            conversation_history.add_turn(
814                                current_input.clone(),
815                                format!("[Partial response - {} tools completed, {} in-progress before truncation. See continuation prompt for details.]",
816                                    completed_count, in_progress_count),
817                                vec![]  // Don't duplicate - continuation prompt has the details
818                            );
819
820                            // Check if we need compaction after adding this heavy turn
821                            // This is important for long multi-turn sessions with many tool calls
822                            if conversation_history.needs_compaction() {
823                                eprintln!("{}", "  ๐Ÿ“ฆ Compacting history before continuation...".dimmed());
824                                if let Some(summary) = conversation_history.compact() {
825                                    eprintln!("{}", format!("  โœ“ Compressed {} turns", summary.matches("Turn").count()).dimmed());
826                                }
827                            }
828
829                            // Build continuation prompt with context
830                            current_input = build_continuation_prompt(&input, &completed_tools, &agent_thinking);
831
832                            // Log continuation details for debugging
833                            eprintln!("{}", format!(
834                                "  โ†’ Continuing with {} files read, {} written, {} other actions tracked",
835                                completed_tools.iter().filter(|t| t.tool_name == "read_file").count(),
836                                completed_tools.iter().filter(|t| t.tool_name == "write_file" || t.tool_name == "write_files").count(),
837                                completed_tools.iter().filter(|t| t.tool_name != "read_file" && t.tool_name != "write_file" && t.tool_name != "write_files" && t.tool_name != "list_directory").count()
838                            ).dimmed());
839
840                            // Brief delay before continuation
841                            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
842                            // Don't increment retry_attempt - this is progress via continuation
843                        } else if retry_attempt < MAX_RETRIES {
844                            // No tool calls completed - simple retry
845                            retry_attempt += 1;
846                            eprintln!("{}", format!("โš  Response error (attempt {}/{}). Retrying...", retry_attempt, MAX_RETRIES).yellow());
847                            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
848                        } else {
849                            // Max retries/continuations reached
850                            eprintln!("{}", format!("Error: {}", e).red());
851                            if continuation_count >= MAX_CONTINUATIONS {
852                                eprintln!("{}", format!("Max continuations ({}) reached. The task is too complex for one request.", MAX_CONTINUATIONS).dimmed());
853                            } else {
854                                eprintln!("{}", "Max retries reached. The response may be too complex.".dimmed());
855                            }
856                            eprintln!("{}", "Try breaking your request into smaller parts.".dimmed());
857                            break;
858                        }
859                    } else if err_str.contains("timeout") || err_str.contains("Timeout") {
860                        // Timeout - simple retry
861                        retry_attempt += 1;
862                        if retry_attempt < MAX_RETRIES {
863                            eprintln!("{}", format!("โš  Request timed out (attempt {}/{}). Retrying...", retry_attempt, MAX_RETRIES).yellow());
864                            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
865                        } else {
866                            eprintln!("{}", "Request timed out. Please try again.".red());
867                            break;
868                        }
869                    } else {
870                        // Unknown error - show details and break
871                        eprintln!("{}", format!("Error: {}", e).red());
872                        if continuation_count > 0 {
873                            eprintln!("{}", format!("  (occurred during continuation attempt {})", continuation_count).dimmed());
874                        }
875                        eprintln!("{}", "Error details for debugging:".dimmed());
876                        eprintln!("{}", format!("  - retry_attempt: {}/{}", retry_attempt, MAX_RETRIES).dimmed());
877                        eprintln!("{}", format!("  - continuation_count: {}/{}", continuation_count, MAX_CONTINUATIONS).dimmed());
878                        break;
879                    }
880                }
881            }
882        }
883        println!();
884    }
885
886    Ok(())
887}
888
889/// Extract tool call records from the hook state for history tracking
890async fn extract_tool_calls_from_hook(hook: &ToolDisplayHook) -> Vec<ToolCallRecord> {
891    let state = hook.state();
892    let guard = state.lock().await;
893
894    guard.tool_calls.iter().enumerate().map(|(i, tc)| {
895        let result = if tc.is_running {
896            // Tool was in progress when error occurred
897            "[IN PROGRESS - may need to be re-run]".to_string()
898        } else if let Some(output) = &tc.output {
899            truncate_string(output, 200)
900        } else {
901            "completed".to_string()
902        };
903
904        ToolCallRecord {
905            tool_name: tc.name.clone(),
906            args_summary: truncate_string(&tc.args, 100),
907            result_summary: result,
908            // Generate a unique tool ID for proper message pairing
909            tool_id: Some(format!("tool_{}_{}", tc.name, i)),
910            // Mark read-only tools as droppable (their results can be re-fetched)
911            droppable: matches!(
912                tc.name.as_str(),
913                "read_file" | "list_directory" | "analyze_project"
914            ),
915        }
916    }).collect()
917}
918
919/// Extract any agent thinking/messages from the hook for context
920async fn extract_agent_messages_from_hook(hook: &ToolDisplayHook) -> Vec<String> {
921    let state = hook.state();
922    let guard = state.lock().await;
923    guard.agent_messages.clone()
924}
925
926/// Helper to truncate strings for summaries
927fn truncate_string(s: &str, max_len: usize) -> String {
928    if s.len() <= max_len {
929        s.to_string()
930    } else {
931        format!("{}...", &s[..max_len.saturating_sub(3)])
932    }
933}
934
935/// Estimate token count from raw rig Messages
936/// This is used for context length management to prevent "input too long" errors.
937/// Estimates ~4 characters per token.
938fn estimate_raw_history_tokens(messages: &[rig::completion::Message]) -> usize {
939    use rig::completion::message::{AssistantContent, UserContent};
940
941    messages.iter().map(|msg| -> usize {
942        match msg {
943            rig::completion::Message::User { content } => {
944                content.iter().map(|c| -> usize {
945                    match c {
946                        UserContent::Text(t) => t.text.len() / 4,
947                        _ => 100, // Estimate for images/documents
948                    }
949                }).sum::<usize>()
950            }
951            rig::completion::Message::Assistant { content, .. } => {
952                content.iter().map(|c| -> usize {
953                    match c {
954                        AssistantContent::Text(t) => t.text.len() / 4,
955                        AssistantContent::ToolCall(tc) => {
956                            // arguments is serde_json::Value, convert to string for length estimate
957                            let args_len = tc.function.arguments.to_string().len();
958                            (tc.function.name.len() + args_len) / 4
959                        }
960                        _ => 100,
961                    }
962                }).sum::<usize>()
963            }
964        }
965    }).sum()
966}
967
968/// Find a plan_create tool call in the list and extract plan info
969/// Returns (plan_path, task_count) if found
970fn find_plan_create_call(tool_calls: &[ToolCallRecord]) -> Option<(String, usize)> {
971    for tc in tool_calls {
972        if tc.tool_name == "plan_create" {
973            // Try to parse the result_summary as JSON to extract plan_path
974            // Note: result_summary may be truncated, so we have multiple fallbacks
975            let plan_path = if let Ok(result) = serde_json::from_str::<serde_json::Value>(&tc.result_summary) {
976                result.get("plan_path")
977                    .and_then(|v| v.as_str())
978                    .map(|s| s.to_string())
979            } else {
980                None
981            };
982
983            // If JSON parsing failed, find the most recently created plan file
984            // This is more reliable than trying to reconstruct the path from truncated args
985            let plan_path = plan_path.unwrap_or_else(|| {
986                find_most_recent_plan_file().unwrap_or_else(|| "plans/plan.md".to_string())
987            });
988
989            // Count tasks by reading the plan file directly
990            let task_count = count_tasks_in_plan_file(&plan_path).unwrap_or(0);
991
992            return Some((plan_path, task_count));
993        }
994    }
995    None
996}
997
998/// Find the most recently created plan file in the plans directory
999fn find_most_recent_plan_file() -> Option<String> {
1000    let plans_dir = std::env::current_dir().ok()?.join("plans");
1001    if !plans_dir.exists() {
1002        return None;
1003    }
1004
1005    let mut newest: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1006
1007    for entry in std::fs::read_dir(&plans_dir).ok()?.flatten() {
1008        let path = entry.path();
1009        if path.extension().map(|e| e == "md").unwrap_or(false) {
1010            if let Ok(metadata) = entry.metadata() {
1011                if let Ok(modified) = metadata.modified() {
1012                    if newest.as_ref().map(|(_, t)| modified > *t).unwrap_or(true) {
1013                        newest = Some((path, modified));
1014                    }
1015                }
1016            }
1017        }
1018    }
1019
1020    newest.map(|(path, _)| {
1021        // Return relative path
1022        path.strip_prefix(std::env::current_dir().unwrap_or_default())
1023            .map(|p| p.display().to_string())
1024            .unwrap_or_else(|_| path.display().to_string())
1025    })
1026}
1027
1028/// Count tasks (checkbox items) in a plan file
1029fn count_tasks_in_plan_file(plan_path: &str) -> Option<usize> {
1030    use regex::Regex;
1031
1032    // Try both relative and absolute paths
1033    let path = std::path::Path::new(plan_path);
1034    let content = if path.exists() {
1035        std::fs::read_to_string(path).ok()?
1036    } else {
1037        // Try with current directory
1038        std::fs::read_to_string(std::env::current_dir().ok()?.join(plan_path)).ok()?
1039    };
1040
1041    // Count task checkboxes: - [ ], - [x], - [~], - [!]
1042    let task_regex = Regex::new(r"^\s*-\s*\[[ x~!]\]").ok()?;
1043    let count = content.lines()
1044        .filter(|line| task_regex.is_match(line))
1045        .count();
1046
1047    Some(count)
1048}
1049
1050/// Check if an error is a truncation/JSON parsing error that can be recovered via continuation
1051fn is_truncation_error(err_str: &str) -> bool {
1052    err_str.contains("JsonError")
1053        || err_str.contains("EOF while parsing")
1054        || err_str.contains("JSON")
1055        || err_str.contains("unexpected end")
1056}
1057
1058/// Check if error is "input too long" - context exceeds model limit
1059/// This happens when conversation history grows beyond what the model can handle.
1060/// Recovery: compact history and retry with reduced context.
1061fn is_input_too_long_error(err_str: &str) -> bool {
1062    err_str.contains("too long")
1063        || err_str.contains("Too long")
1064        || err_str.contains("context length")
1065        || err_str.contains("maximum context")
1066        || err_str.contains("exceeds the model")
1067        || err_str.contains("Input is too long")
1068}
1069
1070/// Build a continuation prompt that tells the AI what work was completed
1071/// and asks it to continue from where it left off
1072fn build_continuation_prompt(
1073    original_task: &str,
1074    completed_tools: &[ToolCallRecord],
1075    agent_thinking: &[String],
1076) -> String {
1077    use std::collections::HashSet;
1078
1079    // Group tools by type and extract unique files read
1080    let mut files_read: HashSet<String> = HashSet::new();
1081    let mut files_written: HashSet<String> = HashSet::new();
1082    let mut dirs_listed: HashSet<String> = HashSet::new();
1083    let mut other_tools: Vec<String> = Vec::new();
1084    let mut in_progress: Vec<String> = Vec::new();
1085
1086    for tool in completed_tools {
1087        let is_in_progress = tool.result_summary.contains("IN PROGRESS");
1088
1089        if is_in_progress {
1090            in_progress.push(format!("{}({})", tool.tool_name, tool.args_summary));
1091            continue;
1092        }
1093
1094        match tool.tool_name.as_str() {
1095            "read_file" => {
1096                // Extract path from args
1097                files_read.insert(tool.args_summary.clone());
1098            }
1099            "write_file" | "write_files" => {
1100                files_written.insert(tool.args_summary.clone());
1101            }
1102            "list_directory" => {
1103                dirs_listed.insert(tool.args_summary.clone());
1104            }
1105            _ => {
1106                other_tools.push(format!("{}({})", tool.tool_name, truncate_string(&tool.args_summary, 40)));
1107            }
1108        }
1109    }
1110
1111    let mut prompt = format!(
1112        "[CONTINUE] Your previous response was interrupted. DO NOT repeat completed work.\n\n\
1113        Original task: {}\n",
1114        truncate_string(original_task, 500)
1115    );
1116
1117    // Show files already read - CRITICAL for preventing re-reads
1118    if !files_read.is_empty() {
1119        prompt.push_str("\n== FILES ALREADY READ (do NOT read again) ==\n");
1120        for file in &files_read {
1121            prompt.push_str(&format!("  - {}\n", file));
1122        }
1123    }
1124
1125    if !dirs_listed.is_empty() {
1126        prompt.push_str("\n== DIRECTORIES ALREADY LISTED ==\n");
1127        for dir in &dirs_listed {
1128            prompt.push_str(&format!("  - {}\n", dir));
1129        }
1130    }
1131
1132    if !files_written.is_empty() {
1133        prompt.push_str("\n== FILES ALREADY WRITTEN ==\n");
1134        for file in &files_written {
1135            prompt.push_str(&format!("  - {}\n", file));
1136        }
1137    }
1138
1139    if !other_tools.is_empty() {
1140        prompt.push_str("\n== OTHER COMPLETED ACTIONS ==\n");
1141        for tool in other_tools.iter().take(20) {
1142            prompt.push_str(&format!("  - {}\n", tool));
1143        }
1144        if other_tools.len() > 20 {
1145            prompt.push_str(&format!("  ... and {} more\n", other_tools.len() - 20));
1146        }
1147    }
1148
1149    if !in_progress.is_empty() {
1150        prompt.push_str("\n== INTERRUPTED (may need re-run) ==\n");
1151        for tool in &in_progress {
1152            prompt.push_str(&format!("  โš  {}\n", tool));
1153        }
1154    }
1155
1156    // Include last thinking context if available
1157    if !agent_thinking.is_empty() {
1158        if let Some(last_thought) = agent_thinking.last() {
1159            prompt.push_str(&format!(
1160                "\n== YOUR LAST THOUGHTS ==\n\"{}\"\n",
1161                truncate_string(last_thought, 300)
1162            ));
1163        }
1164    }
1165
1166    prompt.push_str("\n== INSTRUCTIONS ==\n");
1167    prompt.push_str("IMPORTANT: Your previous response was too long and got cut off.\n");
1168    prompt.push_str("1. Do NOT re-read files listed above - they are already in context.\n");
1169    prompt.push_str("2. If writing a document, write it in SECTIONS - complete one section now, then continue.\n");
1170    prompt.push_str("3. Keep your response SHORT and focused. Better to complete small chunks than fail on large ones.\n");
1171    prompt.push_str("4. If the task involves writing a file, START WRITING NOW - don't explain what you'll do.\n");
1172
1173    prompt
1174}
1175
1176/// Run a single query and return the response
1177pub async fn run_query(
1178    project_path: &Path,
1179    query: &str,
1180    provider: ProviderType,
1181    model: Option<String>,
1182) -> AgentResult<String> {
1183    use tools::*;
1184
1185    let project_path_buf = project_path.to_path_buf();
1186    // Select prompt based on query type (analysis vs generation)
1187    // For single queries (non-interactive), always use standard mode
1188    let preamble = get_system_prompt(project_path, Some(query), PlanMode::default());
1189    let is_generation = prompts::is_generation_query(query);
1190
1191    match provider {
1192        ProviderType::OpenAI => {
1193            let client = openai::Client::from_env();
1194            let model_name = model.as_deref().unwrap_or("gpt-5.2");
1195
1196            // For GPT-5.x reasoning models, enable reasoning with summary output
1197            let reasoning_params = if model_name.starts_with("gpt-5") || model_name.starts_with("o1") {
1198                Some(serde_json::json!({
1199                    "reasoning": {
1200                        "effort": "medium",
1201                        "summary": "detailed"
1202                    }
1203                }))
1204            } else {
1205                None
1206            };
1207
1208            let mut builder = client
1209                .agent(model_name)
1210                .preamble(&preamble)
1211                .max_tokens(4096)
1212                .tool(AnalyzeTool::new(project_path_buf.clone()))
1213                .tool(SecurityScanTool::new(project_path_buf.clone()))
1214                .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1215                .tool(HadolintTool::new(project_path_buf.clone()))
1216                .tool(TerraformFmtTool::new(project_path_buf.clone()))
1217                .tool(TerraformValidateTool::new(project_path_buf.clone()))
1218                .tool(TerraformInstallTool::new())
1219                .tool(ReadFileTool::new(project_path_buf.clone()))
1220                .tool(ListDirectoryTool::new(project_path_buf.clone()));
1221
1222            // Add generation tools if this is a generation query
1223            if is_generation {
1224                builder = builder
1225                    .tool(WriteFileTool::new(project_path_buf.clone()))
1226                    .tool(WriteFilesTool::new(project_path_buf.clone()))
1227                    .tool(ShellTool::new(project_path_buf.clone()));
1228            }
1229
1230            if let Some(params) = reasoning_params {
1231                builder = builder.additional_params(params);
1232            }
1233
1234            let agent = builder.build();
1235
1236            agent
1237                .prompt(query)
1238                .multi_turn(50)
1239                .await
1240                .map_err(|e| AgentError::ProviderError(e.to_string()))
1241        }
1242        ProviderType::Anthropic => {
1243            let client = anthropic::Client::from_env();
1244            let model_name = model.as_deref().unwrap_or("claude-sonnet-4-5-20250929");
1245
1246            // TODO: Extended thinking for Claude is disabled because rig doesn't properly
1247            // handle thinking blocks in multi-turn conversations with tool use.
1248            // See: forge/crates/forge_services/src/provider/bedrock/provider.rs for reference.
1249
1250            let mut builder = client
1251                .agent(model_name)
1252                .preamble(&preamble)
1253                .max_tokens(4096)
1254                .tool(AnalyzeTool::new(project_path_buf.clone()))
1255                .tool(SecurityScanTool::new(project_path_buf.clone()))
1256                .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1257                .tool(HadolintTool::new(project_path_buf.clone()))
1258                .tool(TerraformFmtTool::new(project_path_buf.clone()))
1259                .tool(TerraformValidateTool::new(project_path_buf.clone()))
1260                .tool(TerraformInstallTool::new())
1261                .tool(ReadFileTool::new(project_path_buf.clone()))
1262                .tool(ListDirectoryTool::new(project_path_buf.clone()));
1263
1264            // Add generation tools if this is a generation query
1265            if is_generation {
1266                builder = builder
1267                    .tool(WriteFileTool::new(project_path_buf.clone()))
1268                    .tool(WriteFilesTool::new(project_path_buf.clone()))
1269                    .tool(ShellTool::new(project_path_buf.clone()));
1270            }
1271
1272            let agent = builder.build();
1273
1274            agent
1275                .prompt(query)
1276                .multi_turn(50)
1277                .await
1278                .map_err(|e| AgentError::ProviderError(e.to_string()))
1279        }
1280        ProviderType::Bedrock => {
1281            // Bedrock provider via rig-bedrock - same pattern as Anthropic
1282            let client = rig_bedrock::client::Client::from_env();
1283            let model_name = model.as_deref().unwrap_or("global.anthropic.claude-sonnet-4-5-20250929-v1:0");
1284
1285            // Extended thinking for Claude via Bedrock
1286            let thinking_params = serde_json::json!({
1287                "thinking": {
1288                    "type": "enabled",
1289                    "budget_tokens": 16000
1290                }
1291            });
1292
1293            let mut builder = client
1294                .agent(model_name)
1295                .preamble(&preamble)
1296                .max_tokens(64000)  // Max output tokens for Claude Sonnet on Bedrock
1297                .tool(AnalyzeTool::new(project_path_buf.clone()))
1298                .tool(SecurityScanTool::new(project_path_buf.clone()))
1299                .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1300                .tool(HadolintTool::new(project_path_buf.clone()))
1301                .tool(TerraformFmtTool::new(project_path_buf.clone()))
1302                .tool(TerraformValidateTool::new(project_path_buf.clone()))
1303                .tool(TerraformInstallTool::new())
1304                .tool(ReadFileTool::new(project_path_buf.clone()))
1305                .tool(ListDirectoryTool::new(project_path_buf.clone()));
1306
1307            // Add generation tools if this is a generation query
1308            if is_generation {
1309                builder = builder
1310                    .tool(WriteFileTool::new(project_path_buf.clone()))
1311                    .tool(WriteFilesTool::new(project_path_buf.clone()))
1312                    .tool(ShellTool::new(project_path_buf.clone()));
1313            }
1314
1315            let agent = builder
1316                .additional_params(thinking_params)
1317                .build();
1318
1319            agent
1320                .prompt(query)
1321                .multi_turn(50)
1322                .await
1323                .map_err(|e| AgentError::ProviderError(e.to_string()))
1324        }
1325    }
1326}