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 persistence;
38pub mod prompts;
39pub mod session;
40pub mod tools;
41pub mod ui;
42use colored::Colorize;
43use commands::TokenUsage;
44use history::{ConversationHistory, ToolCallRecord};
45use ide::IdeClient;
46use rig::{
47    client::{CompletionClient, ProviderClient},
48    completion::Prompt,
49    providers::{anthropic, openai},
50};
51use session::{ChatSession, PlanMode};
52use std::path::Path;
53use std::sync::Arc;
54use tokio::sync::Mutex as TokioMutex;
55use ui::{ResponseFormatter, ToolDisplayHook};
56
57/// Provider type for the agent
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum ProviderType {
60    #[default]
61    OpenAI,
62    Anthropic,
63    Bedrock,
64}
65
66impl std::fmt::Display for ProviderType {
67    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
68        match self {
69            ProviderType::OpenAI => write!(f, "openai"),
70            ProviderType::Anthropic => write!(f, "anthropic"),
71            ProviderType::Bedrock => write!(f, "bedrock"),
72        }
73    }
74}
75
76impl std::str::FromStr for ProviderType {
77    type Err = String;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        match s.to_lowercase().as_str() {
81            "openai" => Ok(ProviderType::OpenAI),
82            "anthropic" => Ok(ProviderType::Anthropic),
83            "bedrock" | "aws" | "aws-bedrock" => Ok(ProviderType::Bedrock),
84            _ => Err(format!(
85                "Unknown provider: {}. Use: openai, anthropic, or bedrock",
86                s
87            )),
88        }
89    }
90}
91
92/// Error types for the agent
93#[derive(Debug, thiserror::Error)]
94pub enum AgentError {
95    #[error("Missing API key. Set {0} environment variable.")]
96    MissingApiKey(String),
97
98    #[error("Provider error: {0}")]
99    ProviderError(String),
100
101    #[error("Tool error: {0}")]
102    ToolError(String),
103}
104
105pub type AgentResult<T> = Result<T, AgentError>;
106
107/// Get the system prompt for the agent based on query type and plan mode
108fn get_system_prompt(project_path: &Path, query: Option<&str>, plan_mode: PlanMode) -> String {
109    // In planning mode, use the read-only exploration prompt
110    if plan_mode.is_planning() {
111        return prompts::get_planning_prompt(project_path);
112    }
113
114    if let Some(q) = query {
115        // First check if it's a code development task (highest priority)
116        if prompts::is_code_development_query(q) {
117            return prompts::get_code_development_prompt(project_path);
118        }
119        // Then check if it's DevOps generation (Docker, Terraform, Helm)
120        if prompts::is_generation_query(q) {
121            return prompts::get_devops_prompt(project_path, Some(q));
122        }
123    }
124    // Default to analysis prompt
125    prompts::get_analysis_prompt(project_path)
126}
127
128/// Run the agent in interactive mode with custom REPL supporting /model and /provider commands
129pub async fn run_interactive(
130    project_path: &Path,
131    provider: ProviderType,
132    model: Option<String>,
133) -> AgentResult<()> {
134    use tools::*;
135
136    let mut session = ChatSession::new(project_path, provider, model);
137
138    // Terminal layout for split screen is disabled for now - see notes below
139    // let terminal_layout = ui::TerminalLayout::new();
140    // let layout_state = terminal_layout.state();
141
142    // Initialize conversation history with compaction support
143    let mut conversation_history = ConversationHistory::new();
144
145    // Initialize IDE client for native diff viewing
146    let ide_client: Option<Arc<TokioMutex<IdeClient>>> = {
147        let mut client = IdeClient::new().await;
148        if client.is_ide_available() {
149            match client.connect().await {
150                Ok(()) => {
151                    println!(
152                        "{} Connected to {} IDE companion",
153                        "โœ“".green(),
154                        client.ide_name().unwrap_or("VS Code")
155                    );
156                    Some(Arc::new(TokioMutex::new(client)))
157                }
158                Err(e) => {
159                    // IDE detected but companion not running or connection failed
160                    println!("{} IDE companion not connected: {}", "!".yellow(), e);
161                    None
162                }
163            }
164        } else {
165            println!(
166                "{} No IDE detected (TERM_PROGRAM={})",
167                "ยท".dimmed(),
168                std::env::var("TERM_PROGRAM").unwrap_or_default()
169            );
170            None
171        }
172    };
173
174    // Load API key from config file to env if not already set
175    ChatSession::load_api_key_to_env(session.provider);
176
177    // Check if API key is configured, prompt if not
178    if !ChatSession::has_api_key(session.provider) {
179        ChatSession::prompt_api_key(session.provider)?;
180    }
181
182    session.print_banner();
183
184    // NOTE: Terminal layout with ANSI scroll regions is disabled for now.
185    // The scroll region approach conflicts with the existing input/output flow.
186    // TODO: Implement proper scroll region support that integrates with the input handler.
187    // For now, we rely on the pause/resume mechanism in progress indicator.
188    //
189    // if let Err(e) = terminal_layout.init() {
190    //     eprintln!(
191    //         "{}",
192    //         format!("Note: Terminal layout initialization failed: {}. Using fallback mode.", e)
193    //             .dimmed()
194    //     );
195    // }
196
197    // Raw Rig messages for multi-turn - preserves Reasoning blocks for thinking
198    // Our ConversationHistory only stores text summaries, but rig needs full Message structure
199    let mut raw_chat_history: Vec<rig::completion::Message> = Vec::new();
200
201    // Pending input for auto-continue after plan creation
202    let mut pending_input: Option<String> = None;
203    // Auto-accept mode for plan execution (skips write confirmations)
204    let mut auto_accept_writes = false;
205
206    // Initialize session recorder for conversation persistence
207    let mut session_recorder = persistence::SessionRecorder::new(project_path);
208
209    loop {
210        // Show conversation status if we have history
211        if !conversation_history.is_empty() {
212            println!(
213                "{}",
214                format!("  ๐Ÿ’ฌ Context: {}", conversation_history.status()).dimmed()
215            );
216        }
217
218        // Check for pending input (from plan menu selection)
219        let input = if let Some(pending) = pending_input.take() {
220            // Show what we're executing
221            println!("{} {}", "โ†’".cyan(), pending.dimmed());
222            pending
223        } else {
224            // New user turn - reset auto-accept mode from previous plan execution
225            auto_accept_writes = false;
226
227            // Read user input (returns InputResult)
228            let input_result = match session.read_input() {
229                Ok(result) => result,
230                Err(_) => break,
231            };
232
233            // Handle the input result
234            match input_result {
235                ui::InputResult::Submit(text) => ChatSession::process_submitted_text(&text),
236                ui::InputResult::Cancel | ui::InputResult::Exit => break,
237                ui::InputResult::TogglePlanMode => {
238                    // Toggle planning mode - minimal feedback, no extra newlines
239                    let new_mode = session.toggle_plan_mode();
240                    if new_mode.is_planning() {
241                        println!("{}", "โ˜… plan mode".yellow());
242                    } else {
243                        println!("{}", "โ–ถ standard mode".green());
244                    }
245                    continue;
246                }
247            }
248        };
249
250        if input.is_empty() {
251            continue;
252        }
253
254        // Check for commands
255        if ChatSession::is_command(&input) {
256            // Special handling for /clear to also clear conversation history
257            if input.trim().to_lowercase() == "/clear" || input.trim().to_lowercase() == "/c" {
258                conversation_history.clear();
259                raw_chat_history.clear();
260            }
261            match session.process_command(&input) {
262                Ok(true) => {
263                    // Check if /resume loaded a session
264                    if let Some(record) = session.pending_resume.take() {
265                        // Display previous messages
266                        println!();
267                        println!("{}", "โ”€โ”€โ”€ Previous Conversation โ”€โ”€โ”€".dimmed());
268                        for msg in &record.messages {
269                            match msg.role {
270                                persistence::MessageRole::User => {
271                                    println!();
272                                    println!(
273                                        "{} {}",
274                                        "You:".cyan().bold(),
275                                        truncate_string(&msg.content, 500)
276                                    );
277                                }
278                                persistence::MessageRole::Assistant => {
279                                    println!();
280                                    // Show tool calls if any (same format as live display)
281                                    if let Some(ref tools) = msg.tool_calls {
282                                        for tc in tools {
283                                            // Match live tool display: green dot for completed, cyan bold name
284                                            if tc.args_summary.is_empty() {
285                                                println!(
286                                                    "{} {}",
287                                                    "โ—".green(),
288                                                    tc.name.cyan().bold()
289                                                );
290                                            } else {
291                                                println!(
292                                                    "{} {}({})",
293                                                    "โ—".green(),
294                                                    tc.name.cyan().bold(),
295                                                    truncate_string(&tc.args_summary, 50).dimmed()
296                                                );
297                                            }
298                                        }
299                                    }
300                                    // Show response (same ResponseFormatter as live)
301                                    if !msg.content.is_empty() {
302                                        ResponseFormatter::print_response(&truncate_string(
303                                            &msg.content,
304                                            1000,
305                                        ));
306                                    }
307                                }
308                                persistence::MessageRole::System => {
309                                    // Skip system messages in display
310                                }
311                            }
312                        }
313                        println!("{}", "โ”€โ”€โ”€ End of History โ”€โ”€โ”€".dimmed());
314                        println!();
315
316                        // Load messages into raw_chat_history for AI context
317                        for msg in &record.messages {
318                            match msg.role {
319                                persistence::MessageRole::User => {
320                                    raw_chat_history.push(rig::completion::Message::User {
321                                        content: rig::one_or_many::OneOrMany::one(
322                                            rig::completion::message::UserContent::text(
323                                                &msg.content,
324                                            ),
325                                        ),
326                                    });
327                                }
328                                persistence::MessageRole::Assistant => {
329                                    raw_chat_history.push(rig::completion::Message::Assistant {
330                                        id: Some(msg.id.clone()),
331                                        content: rig::one_or_many::OneOrMany::one(
332                                            rig::completion::message::AssistantContent::text(
333                                                &msg.content,
334                                            ),
335                                        ),
336                                    });
337                                }
338                                persistence::MessageRole::System => {}
339                            }
340                        }
341
342                        // Load into conversation_history for context tracking
343                        for msg in &record.messages {
344                            if msg.role == persistence::MessageRole::User {
345                                // Find the next assistant message
346                                let response = record
347                                    .messages
348                                    .iter()
349                                    .skip_while(|m| m.id != msg.id)
350                                    .skip(1)
351                                    .find(|m| m.role == persistence::MessageRole::Assistant)
352                                    .map(|m| m.content.clone())
353                                    .unwrap_or_default();
354
355                                conversation_history.add_turn(
356                                    msg.content.clone(),
357                                    response,
358                                    vec![], // Tool calls not loaded for simplicity
359                                );
360                            }
361                        }
362
363                        println!(
364                            "{}",
365                            format!(
366                                "  โœ“ Loaded {} messages. You can now continue the conversation.",
367                                record.messages.len()
368                            )
369                            .green()
370                        );
371                        println!();
372                    }
373                    continue;
374                }
375                Ok(false) => break, // /exit
376                Err(e) => {
377                    eprintln!("{}", format!("Error: {}", e).red());
378                    continue;
379                }
380            }
381        }
382
383        // Check API key before making request (in case provider changed)
384        if !ChatSession::has_api_key(session.provider) {
385            eprintln!(
386                "{}",
387                "No API key configured. Use /provider to set one.".yellow()
388            );
389            continue;
390        }
391
392        // Check if compaction is needed before making the request
393        if conversation_history.needs_compaction() {
394            println!("{}", "  ๐Ÿ“ฆ Compacting conversation history...".dimmed());
395            if let Some(summary) = conversation_history.compact() {
396                println!(
397                    "{}",
398                    format!("  โœ“ Compressed {} turns", summary.matches("Turn").count()).dimmed()
399                );
400            }
401        }
402
403        // Pre-request check: estimate if we're approaching context limit
404        // Check raw_chat_history (actual messages) not conversation_history
405        // because conversation_history may be out of sync
406        let estimated_input_tokens = estimate_raw_history_tokens(&raw_chat_history)
407            + input.len() / 4  // New input
408            + 5000; // System prompt overhead estimate
409
410        if estimated_input_tokens > 150_000 {
411            println!(
412                "{}",
413                "  โš  Large context detected. Pre-truncating...".yellow()
414            );
415
416            let old_count = raw_chat_history.len();
417            // Keep last 20 messages when approaching limit
418            if raw_chat_history.len() > 20 {
419                let drain_count = raw_chat_history.len() - 20;
420                raw_chat_history.drain(0..drain_count);
421                conversation_history.clear(); // Stay in sync
422                println!(
423                    "{}",
424                    format!(
425                        "  โœ“ Truncated {} โ†’ {} messages",
426                        old_count,
427                        raw_chat_history.len()
428                    )
429                    .dimmed()
430                );
431            }
432        }
433
434        // Retry loop for automatic error recovery
435        // MAX_RETRIES is for failures without progress
436        // MAX_CONTINUATIONS is for truncations WITH progress (more generous)
437        // TOOL_CALL_CHECKPOINT is the interval at which we ask user to confirm
438        // MAX_TOOL_CALLS is the absolute maximum (300 = 6 checkpoints x 50)
439        const MAX_RETRIES: u32 = 3;
440        const MAX_CONTINUATIONS: u32 = 10;
441        const _TOOL_CALL_CHECKPOINT: usize = 50;
442        const MAX_TOOL_CALLS: usize = 300;
443        let mut retry_attempt = 0;
444        let mut continuation_count = 0;
445        let mut total_tool_calls: usize = 0;
446        let mut auto_continue_tools = false; // User can select "always" to skip future prompts
447        let mut current_input = input.clone();
448        let mut succeeded = false;
449
450        while retry_attempt < MAX_RETRIES && continuation_count < MAX_CONTINUATIONS && !succeeded {
451            // Log if this is a continuation attempt
452            if continuation_count > 0 {
453                eprintln!("{}", "  ๐Ÿ“ก Sending continuation request...".dimmed());
454            }
455
456            // Create hook for Claude Code style tool display
457            let hook = ToolDisplayHook::new();
458
459            // Create progress indicator for visual feedback during generation
460            let progress = ui::GenerationIndicator::new();
461            // Layout connection disabled - using inline progress mode
462            // progress.state().set_layout(layout_state.clone());
463            hook.set_progress_state(progress.state()).await;
464
465            let project_path_buf = session.project_path.clone();
466            // Select prompt based on query type (analysis vs generation) and plan mode
467            let preamble = get_system_prompt(
468                &session.project_path,
469                Some(&current_input),
470                session.plan_mode,
471            );
472            let is_generation = prompts::is_generation_query(&current_input);
473            let is_planning = session.plan_mode.is_planning();
474
475            // Note: using raw_chat_history directly which preserves Reasoning blocks
476            // This is needed for extended thinking to work with multi-turn conversations
477
478            // Get progress state for interrupt detection
479            let progress_state = progress.state();
480
481            // Use tokio::select! to race the API call against Ctrl+C
482            // This allows immediate cancellation, not just between tool calls
483            let mut user_interrupted = false;
484
485            // API call with Ctrl+C interrupt support
486            let response = tokio::select! {
487                biased; // Check ctrl_c first for faster response
488
489                _ = tokio::signal::ctrl_c() => {
490                    user_interrupted = true;
491                    Err::<String, String>("User cancelled".to_string())
492                }
493
494                result = async {
495                    match session.provider {
496                ProviderType::OpenAI => {
497                    let client = openai::Client::from_env();
498                    // For GPT-5.x reasoning models, enable reasoning with summary output
499                    // so we can see the model's thinking process
500                    let reasoning_params =
501                        if session.model.starts_with("gpt-5") || session.model.starts_with("o1") {
502                            Some(serde_json::json!({
503                                "reasoning": {
504                                    "effort": "medium",
505                                    "summary": "detailed"
506                                }
507                            }))
508                        } else {
509                            None
510                        };
511
512                    let mut builder = client
513                        .agent(&session.model)
514                        .preamble(&preamble)
515                        .max_tokens(4096)
516                        .tool(AnalyzeTool::new(project_path_buf.clone()))
517                        .tool(SecurityScanTool::new(project_path_buf.clone()))
518                        .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
519                        .tool(HadolintTool::new(project_path_buf.clone()))
520                        .tool(DclintTool::new(project_path_buf.clone()))
521                        .tool(KubelintTool::new(project_path_buf.clone()))
522                        .tool(HelmlintTool::new(project_path_buf.clone()))
523                        .tool(TerraformFmtTool::new(project_path_buf.clone()))
524                        .tool(TerraformValidateTool::new(project_path_buf.clone()))
525                        .tool(TerraformInstallTool::new())
526                        .tool(ReadFileTool::new(project_path_buf.clone()))
527                        .tool(ListDirectoryTool::new(project_path_buf.clone()))
528                        .tool(WebFetchTool::new());
529
530                    // Add tools based on mode
531                    if is_planning {
532                        // Plan mode: read-only shell + plan creation tools
533                        builder = builder
534                            .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
535                            .tool(PlanCreateTool::new(project_path_buf.clone()))
536                            .tool(PlanListTool::new(project_path_buf.clone()));
537                    } else if is_generation {
538                        // Standard mode + generation query: all tools including file writes and plan execution
539                        let (mut write_file_tool, mut write_files_tool) =
540                            if let Some(ref client) = ide_client {
541                                (
542                                    WriteFileTool::new(project_path_buf.clone())
543                                        .with_ide_client(client.clone()),
544                                    WriteFilesTool::new(project_path_buf.clone())
545                                        .with_ide_client(client.clone()),
546                                )
547                            } else {
548                                (
549                                    WriteFileTool::new(project_path_buf.clone()),
550                                    WriteFilesTool::new(project_path_buf.clone()),
551                                )
552                            };
553                        // Disable confirmations if auto-accept mode is enabled (from plan menu)
554                        if auto_accept_writes {
555                            write_file_tool = write_file_tool.without_confirmation();
556                            write_files_tool = write_files_tool.without_confirmation();
557                        }
558                        builder = builder
559                            .tool(write_file_tool)
560                            .tool(write_files_tool)
561                            .tool(ShellTool::new(project_path_buf.clone()))
562                            .tool(PlanListTool::new(project_path_buf.clone()))
563                            .tool(PlanNextTool::new(project_path_buf.clone()))
564                            .tool(PlanUpdateTool::new(project_path_buf.clone()));
565                    }
566
567                    if let Some(params) = reasoning_params {
568                        builder = builder.additional_params(params);
569                    }
570
571                    let agent = builder.build();
572                    // Allow up to 50 tool call turns for complex generation tasks
573                    // Use hook to display tool calls as they happen
574                    // Pass conversation history for context continuity
575                    agent
576                        .prompt(&current_input)
577                        .with_history(&mut raw_chat_history)
578                        .with_hook(hook.clone())
579                        .multi_turn(50)
580                        .await
581                }
582                ProviderType::Anthropic => {
583                    let client = anthropic::Client::from_env();
584
585                    // TODO: Extended thinking for Claude is disabled because rig-bedrock/rig-anthropic
586                    // don't properly handle thinking blocks in multi-turn conversations with tool use.
587                    // When thinking is enabled, ALL assistant messages must start with thinking blocks
588                    // BEFORE tool_use blocks, but rig doesn't preserve/replay these.
589                    // See: forge/crates/forge_services/src/provider/bedrock/provider.rs for reference impl.
590
591                    let mut builder = client
592                        .agent(&session.model)
593                        .preamble(&preamble)
594                        .max_tokens(4096)
595                        .tool(AnalyzeTool::new(project_path_buf.clone()))
596                        .tool(SecurityScanTool::new(project_path_buf.clone()))
597                        .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
598                        .tool(HadolintTool::new(project_path_buf.clone()))
599                        .tool(DclintTool::new(project_path_buf.clone()))
600                        .tool(KubelintTool::new(project_path_buf.clone()))
601                        .tool(HelmlintTool::new(project_path_buf.clone()))
602                        .tool(TerraformFmtTool::new(project_path_buf.clone()))
603                        .tool(TerraformValidateTool::new(project_path_buf.clone()))
604                        .tool(TerraformInstallTool::new())
605                        .tool(ReadFileTool::new(project_path_buf.clone()))
606                        .tool(ListDirectoryTool::new(project_path_buf.clone()))
607                        .tool(WebFetchTool::new());
608
609                    // Add tools based on mode
610                    if is_planning {
611                        // Plan mode: read-only shell + plan creation tools
612                        builder = builder
613                            .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
614                            .tool(PlanCreateTool::new(project_path_buf.clone()))
615                            .tool(PlanListTool::new(project_path_buf.clone()));
616                    } else if is_generation {
617                        // Standard mode + generation query: all tools including file writes and plan execution
618                        let (mut write_file_tool, mut write_files_tool) =
619                            if let Some(ref client) = ide_client {
620                                (
621                                    WriteFileTool::new(project_path_buf.clone())
622                                        .with_ide_client(client.clone()),
623                                    WriteFilesTool::new(project_path_buf.clone())
624                                        .with_ide_client(client.clone()),
625                                )
626                            } else {
627                                (
628                                    WriteFileTool::new(project_path_buf.clone()),
629                                    WriteFilesTool::new(project_path_buf.clone()),
630                                )
631                            };
632                        // Disable confirmations if auto-accept mode is enabled (from plan menu)
633                        if auto_accept_writes {
634                            write_file_tool = write_file_tool.without_confirmation();
635                            write_files_tool = write_files_tool.without_confirmation();
636                        }
637                        builder = builder
638                            .tool(write_file_tool)
639                            .tool(write_files_tool)
640                            .tool(ShellTool::new(project_path_buf.clone()))
641                            .tool(PlanListTool::new(project_path_buf.clone()))
642                            .tool(PlanNextTool::new(project_path_buf.clone()))
643                            .tool(PlanUpdateTool::new(project_path_buf.clone()));
644                    }
645
646                    let agent = builder.build();
647
648                    // Allow up to 50 tool call turns for complex generation tasks
649                    // Use hook to display tool calls as they happen
650                    // Pass conversation history for context continuity
651                    agent
652                        .prompt(&current_input)
653                        .with_history(&mut raw_chat_history)
654                        .with_hook(hook.clone())
655                        .multi_turn(50)
656                        .await
657                }
658                ProviderType::Bedrock => {
659                    // Bedrock provider via rig-bedrock - same pattern as OpenAI/Anthropic
660                    let client = crate::bedrock::client::Client::from_env();
661
662                    // Extended thinking for Claude models via Bedrock
663                    // This enables Claude to show its reasoning process before responding.
664                    // Requires vendored rig-bedrock that preserves Reasoning blocks with tool calls.
665                    // Extended thinking budget - reduced to help with rate limits
666                    // 8000 is enough for most tasks, increase to 16000 for complex analysis
667                    let thinking_params = serde_json::json!({
668                        "thinking": {
669                            "type": "enabled",
670                            "budget_tokens": 8000
671                        }
672                    });
673
674                    let mut builder = client
675                        .agent(&session.model)
676                        .preamble(&preamble)
677                        .max_tokens(64000)  // Max output tokens for Claude Sonnet on Bedrock
678                        .tool(AnalyzeTool::new(project_path_buf.clone()))
679                        .tool(SecurityScanTool::new(project_path_buf.clone()))
680                        .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
681                        .tool(HadolintTool::new(project_path_buf.clone()))
682                        .tool(DclintTool::new(project_path_buf.clone()))
683                        .tool(KubelintTool::new(project_path_buf.clone()))
684                        .tool(HelmlintTool::new(project_path_buf.clone()))
685                        .tool(TerraformFmtTool::new(project_path_buf.clone()))
686                        .tool(TerraformValidateTool::new(project_path_buf.clone()))
687                        .tool(TerraformInstallTool::new())
688                        .tool(ReadFileTool::new(project_path_buf.clone()))
689                        .tool(ListDirectoryTool::new(project_path_buf.clone()))
690                        .tool(WebFetchTool::new());
691
692                    // Add tools based on mode
693                    if is_planning {
694                        // Plan mode: read-only shell + plan creation tools
695                        builder = builder
696                            .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
697                            .tool(PlanCreateTool::new(project_path_buf.clone()))
698                            .tool(PlanListTool::new(project_path_buf.clone()));
699                    } else if is_generation {
700                        // Standard mode + generation query: all tools including file writes and plan execution
701                        let (mut write_file_tool, mut write_files_tool) =
702                            if let Some(ref client) = ide_client {
703                                (
704                                    WriteFileTool::new(project_path_buf.clone())
705                                        .with_ide_client(client.clone()),
706                                    WriteFilesTool::new(project_path_buf.clone())
707                                        .with_ide_client(client.clone()),
708                                )
709                            } else {
710                                (
711                                    WriteFileTool::new(project_path_buf.clone()),
712                                    WriteFilesTool::new(project_path_buf.clone()),
713                                )
714                            };
715                        // Disable confirmations if auto-accept mode is enabled (from plan menu)
716                        if auto_accept_writes {
717                            write_file_tool = write_file_tool.without_confirmation();
718                            write_files_tool = write_files_tool.without_confirmation();
719                        }
720                        builder = builder
721                            .tool(write_file_tool)
722                            .tool(write_files_tool)
723                            .tool(ShellTool::new(project_path_buf.clone()))
724                            .tool(PlanListTool::new(project_path_buf.clone()))
725                            .tool(PlanNextTool::new(project_path_buf.clone()))
726                            .tool(PlanUpdateTool::new(project_path_buf.clone()));
727                    }
728
729                    // Add thinking params for extended reasoning
730                    builder = builder.additional_params(thinking_params);
731
732                    let agent = builder.build();
733
734                    // Use same multi-turn pattern as OpenAI/Anthropic
735                    agent
736                        .prompt(&current_input)
737                        .with_history(&mut raw_chat_history)
738                        .with_hook(hook.clone())
739                        .multi_turn(50)
740                        .await
741                    }
742                }.map_err(|e| e.to_string())
743            } => result
744            };
745
746            // Stop the progress indicator before handling the response
747            progress.stop().await;
748
749            // Suppress unused variable warnings
750            let _ = (&progress_state, user_interrupted);
751
752            match response {
753                Ok(text) => {
754                    // Show final response
755                    println!();
756                    ResponseFormatter::print_response(&text);
757
758                    // Track token usage - use actual from hook if available, else estimate
759                    let hook_usage = hook.get_usage().await;
760                    if hook_usage.has_data() {
761                        // Use actual token counts from API response
762                        session
763                            .token_usage
764                            .add_actual(hook_usage.input_tokens, hook_usage.output_tokens);
765                    } else {
766                        // Fall back to estimation when API doesn't provide usage
767                        let prompt_tokens = TokenUsage::estimate_tokens(&input);
768                        let completion_tokens = TokenUsage::estimate_tokens(&text);
769                        session
770                            .token_usage
771                            .add_estimated(prompt_tokens, completion_tokens);
772                    }
773                    // Reset hook usage for next request batch
774                    hook.reset_usage().await;
775
776                    // Show context indicator like Forge: [model/~tokens]
777                    let model_short = session
778                        .model
779                        .split('/')
780                        .next_back()
781                        .unwrap_or(&session.model)
782                        .split(':')
783                        .next()
784                        .unwrap_or(&session.model);
785                    println!();
786                    println!(
787                        "  {}[{}/{}]{}",
788                        ui::colors::ansi::DIM,
789                        model_short,
790                        session.token_usage.format_compact(),
791                        ui::colors::ansi::RESET
792                    );
793
794                    // Extract tool calls from the hook state for history tracking
795                    let tool_calls = extract_tool_calls_from_hook(&hook).await;
796                    let batch_tool_count = tool_calls.len();
797                    total_tool_calls += batch_tool_count;
798
799                    // Show tool call summary if significant
800                    if batch_tool_count > 10 {
801                        println!(
802                            "{}",
803                            format!(
804                                "  โœ“ Completed with {} tool calls ({} total this session)",
805                                batch_tool_count, total_tool_calls
806                            )
807                            .dimmed()
808                        );
809                    }
810
811                    // Add to conversation history with tool call records
812                    conversation_history.add_turn(input.clone(), text.clone(), tool_calls.clone());
813
814                    // Check if this heavy turn requires immediate compaction
815                    // This helps prevent context overflow in subsequent requests
816                    if conversation_history.needs_compaction() {
817                        println!("{}", "  ๐Ÿ“ฆ Compacting conversation history...".dimmed());
818                        if let Some(summary) = conversation_history.compact() {
819                            println!(
820                                "{}",
821                                format!("  โœ“ Compressed {} turns", summary.matches("Turn").count())
822                                    .dimmed()
823                            );
824                        }
825                    }
826
827                    // Also update legacy session history for compatibility
828                    session.history.push(("user".to_string(), input.clone()));
829                    session
830                        .history
831                        .push(("assistant".to_string(), text.clone()));
832
833                    // Record to persistent session storage
834                    session_recorder.record_user_message(&input);
835                    session_recorder.record_assistant_message(&text, Some(&tool_calls));
836                    if let Err(e) = session_recorder.save() {
837                        eprintln!(
838                            "{}",
839                            format!("  Warning: Failed to save session: {}", e).dimmed()
840                        );
841                    }
842
843                    // Check if plan_create was called - show interactive menu
844                    if let Some(plan_info) = find_plan_create_call(&tool_calls) {
845                        println!(); // Space before menu
846
847                        // Show the plan action menu (don't switch modes yet - let user choose)
848                        match ui::show_plan_action_menu(&plan_info.0, plan_info.1) {
849                            ui::PlanActionResult::ExecuteAutoAccept => {
850                                // Now switch to standard mode for execution
851                                if session.plan_mode.is_planning() {
852                                    session.plan_mode = session.plan_mode.toggle();
853                                }
854                                auto_accept_writes = true;
855                                pending_input = Some(format!(
856                                    "Execute the plan at '{}'. Use plan_next to get tasks and execute them in order. Auto-accept all file writes.",
857                                    plan_info.0
858                                ));
859                                succeeded = true;
860                            }
861                            ui::PlanActionResult::ExecuteWithReview => {
862                                // Now switch to standard mode for execution
863                                if session.plan_mode.is_planning() {
864                                    session.plan_mode = session.plan_mode.toggle();
865                                }
866                                pending_input = Some(format!(
867                                    "Execute the plan at '{}'. Use plan_next to get tasks and execute them in order.",
868                                    plan_info.0
869                                ));
870                                succeeded = true;
871                            }
872                            ui::PlanActionResult::ChangePlan(feedback) => {
873                                // Stay in plan mode for modifications
874                                pending_input = Some(format!(
875                                    "Please modify the plan at '{}'. User feedback: {}",
876                                    plan_info.0, feedback
877                                ));
878                                succeeded = true;
879                            }
880                            ui::PlanActionResult::Cancel => {
881                                // Just complete normally, don't execute
882                                succeeded = true;
883                            }
884                        }
885                    } else {
886                        succeeded = true;
887                    }
888                }
889                Err(e) => {
890                    let err_str = e.to_string();
891
892                    println!();
893
894                    // Check if this was a user-initiated cancellation (Ctrl+C)
895                    if err_str.contains("cancelled") || err_str.contains("Cancelled") {
896                        // Extract any completed work before cancellation
897                        let completed_tools = extract_tool_calls_from_hook(&hook).await;
898                        let tool_count = completed_tools.len();
899
900                        eprintln!("{}", "โš  Generation interrupted.".yellow());
901                        if tool_count > 0 {
902                            eprintln!(
903                                "{}",
904                                format!("  {} tool calls completed before interrupt.", tool_count)
905                                    .dimmed()
906                            );
907                            // Add partial progress to history
908                            conversation_history.add_turn(
909                                current_input.clone(),
910                                format!("[Interrupted after {} tool calls]", tool_count),
911                                completed_tools,
912                            );
913                        }
914                        eprintln!("{}", "  Type your next message to continue.".dimmed());
915
916                        // Don't retry, don't mark as succeeded - just break to return to prompt
917                        break;
918                    }
919
920                    // Check if this is a max depth error - handle as checkpoint
921                    if err_str.contains("MaxDepth")
922                        || err_str.contains("max_depth")
923                        || err_str.contains("reached limit")
924                    {
925                        // Extract what was done before hitting the limit
926                        let completed_tools = extract_tool_calls_from_hook(&hook).await;
927                        let agent_thinking = extract_agent_messages_from_hook(&hook).await;
928                        let batch_tool_count = completed_tools.len();
929                        total_tool_calls += batch_tool_count;
930
931                        eprintln!("{}", format!(
932                            "โš  Reached {} tool calls this batch ({} total). Maximum allowed: {}",
933                            batch_tool_count, total_tool_calls, MAX_TOOL_CALLS
934                        ).yellow());
935
936                        // Check if we've hit the absolute maximum
937                        if total_tool_calls >= MAX_TOOL_CALLS {
938                            eprintln!(
939                                "{}",
940                                format!("Maximum tool call limit ({}) reached.", MAX_TOOL_CALLS)
941                                    .red()
942                            );
943                            eprintln!(
944                                "{}",
945                                "The task is too complex. Try breaking it into smaller parts."
946                                    .dimmed()
947                            );
948                            break;
949                        }
950
951                        // Ask user if they want to continue (unless auto-continue is enabled)
952                        let should_continue = if auto_continue_tools {
953                            eprintln!(
954                                "{}",
955                                "  Auto-continuing (you selected 'always')...".dimmed()
956                            );
957                            true
958                        } else {
959                            eprintln!(
960                                "{}",
961                                "Excessive tool calls used. Want to continue?".yellow()
962                            );
963                            eprintln!(
964                                "{}",
965                                "  [y] Yes, continue  [n] No, stop  [a] Always continue".dimmed()
966                            );
967                            print!("  > ");
968                            let _ = std::io::Write::flush(&mut std::io::stdout());
969
970                            // Read user input
971                            let mut response = String::new();
972                            match std::io::stdin().read_line(&mut response) {
973                                Ok(_) => {
974                                    let resp = response.trim().to_lowercase();
975                                    if resp == "a" || resp == "always" {
976                                        auto_continue_tools = true;
977                                        true
978                                    } else {
979                                        resp == "y" || resp == "yes" || resp.is_empty()
980                                    }
981                                }
982                                Err(_) => false,
983                            }
984                        };
985
986                        if !should_continue {
987                            eprintln!(
988                                "{}",
989                                "Stopped by user. Type 'continue' to resume later.".dimmed()
990                            );
991                            // Add partial progress to history
992                            if !completed_tools.is_empty() {
993                                conversation_history.add_turn(
994                                    current_input.clone(),
995                                    format!(
996                                        "[Stopped at checkpoint - {} tools completed]",
997                                        batch_tool_count
998                                    ),
999                                    vec![],
1000                                );
1001                            }
1002                            break;
1003                        }
1004
1005                        // Continue from checkpoint
1006                        eprintln!(
1007                            "{}",
1008                            format!(
1009                                "  โ†’ Continuing... {} remaining tool calls available",
1010                                MAX_TOOL_CALLS - total_tool_calls
1011                            )
1012                            .dimmed()
1013                        );
1014
1015                        // Add partial progress to history (without duplicating tool calls)
1016                        conversation_history.add_turn(
1017                            current_input.clone(),
1018                            format!(
1019                                "[Checkpoint - {} tools completed, continuing...]",
1020                                batch_tool_count
1021                            ),
1022                            vec![],
1023                        );
1024
1025                        // Build continuation prompt
1026                        current_input =
1027                            build_continuation_prompt(&input, &completed_tools, &agent_thinking);
1028
1029                        // Brief delay before continuation
1030                        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
1031                        continue; // Continue the loop without incrementing retry_attempt
1032                    } else if err_str.contains("rate")
1033                        || err_str.contains("Rate")
1034                        || err_str.contains("429")
1035                        || err_str.contains("Too many tokens")
1036                        || err_str.contains("please wait")
1037                        || err_str.contains("throttl")
1038                        || err_str.contains("Throttl")
1039                    {
1040                        eprintln!("{}", "โš  Rate limited by API provider.".yellow());
1041                        // Wait before retry for rate limits (longer wait for "too many tokens")
1042                        retry_attempt += 1;
1043                        let wait_secs = if err_str.contains("Too many tokens") {
1044                            30
1045                        } else {
1046                            5
1047                        };
1048                        eprintln!(
1049                            "{}",
1050                            format!(
1051                                "  Waiting {} seconds before retry ({}/{})...",
1052                                wait_secs, retry_attempt, MAX_RETRIES
1053                            )
1054                            .dimmed()
1055                        );
1056                        tokio::time::sleep(tokio::time::Duration::from_secs(wait_secs)).await;
1057                    } else if is_input_too_long_error(&err_str) {
1058                        // Context too large - truncate raw_chat_history directly
1059                        // NOTE: We truncate raw_chat_history (actual messages) not conversation_history
1060                        // because conversation_history may be empty/stale during errors
1061                        eprintln!(
1062                            "{}",
1063                            "โš  Context too large for model. Truncating history...".yellow()
1064                        );
1065
1066                        let old_token_count = estimate_raw_history_tokens(&raw_chat_history);
1067                        let old_msg_count = raw_chat_history.len();
1068
1069                        // Strategy: Keep only the last N messages (user/assistant pairs)
1070                        // More aggressive truncation on each retry: 10 โ†’ 6 โ†’ 4 messages
1071                        let keep_count = match retry_attempt {
1072                            0 => 10,
1073                            1 => 6,
1074                            _ => 4,
1075                        };
1076
1077                        if raw_chat_history.len() > keep_count {
1078                            // Drain older messages, keep the most recent ones
1079                            let drain_count = raw_chat_history.len() - keep_count;
1080                            raw_chat_history.drain(0..drain_count);
1081                        }
1082
1083                        let new_token_count = estimate_raw_history_tokens(&raw_chat_history);
1084                        eprintln!("{}", format!(
1085                            "  โœ“ Truncated: {} messages (~{} tokens) โ†’ {} messages (~{} tokens)",
1086                            old_msg_count, old_token_count, raw_chat_history.len(), new_token_count
1087                        ).green());
1088
1089                        // Also clear conversation_history to stay in sync
1090                        conversation_history.clear();
1091
1092                        // Retry with truncated context
1093                        retry_attempt += 1;
1094                        if retry_attempt < MAX_RETRIES {
1095                            eprintln!(
1096                                "{}",
1097                                format!(
1098                                    "  โ†’ Retrying with truncated context ({}/{})...",
1099                                    retry_attempt, MAX_RETRIES
1100                                )
1101                                .dimmed()
1102                            );
1103                            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
1104                        } else {
1105                            eprintln!(
1106                                "{}",
1107                                "Context still too large after truncation. Try /clear to reset."
1108                                    .red()
1109                            );
1110                            break;
1111                        }
1112                    } else if is_truncation_error(&err_str) {
1113                        // Truncation error - try intelligent continuation
1114                        let completed_tools = extract_tool_calls_from_hook(&hook).await;
1115                        let agent_thinking = extract_agent_messages_from_hook(&hook).await;
1116
1117                        // Count actually completed tools (not in-progress)
1118                        let completed_count = completed_tools
1119                            .iter()
1120                            .filter(|t| !t.result_summary.contains("IN PROGRESS"))
1121                            .count();
1122                        let in_progress_count = completed_tools.len() - completed_count;
1123
1124                        if !completed_tools.is_empty() && continuation_count < MAX_CONTINUATIONS {
1125                            // We have partial progress - continue from where we left off
1126                            continuation_count += 1;
1127                            let status_msg = if in_progress_count > 0 {
1128                                format!(
1129                                    "โš  Response truncated. {} completed, {} in-progress. Auto-continuing ({}/{})...",
1130                                    completed_count,
1131                                    in_progress_count,
1132                                    continuation_count,
1133                                    MAX_CONTINUATIONS
1134                                )
1135                            } else {
1136                                format!(
1137                                    "โš  Response truncated. {} tool calls completed. Auto-continuing ({}/{})...",
1138                                    completed_count, continuation_count, MAX_CONTINUATIONS
1139                                )
1140                            };
1141                            eprintln!("{}", status_msg.yellow());
1142
1143                            // Add partial progress to conversation history
1144                            // NOTE: We intentionally pass empty tool_calls here because the
1145                            // continuation prompt already contains the detailed file list.
1146                            // Including them in history would duplicate the context and waste tokens.
1147                            conversation_history.add_turn(
1148                                current_input.clone(),
1149                                format!("[Partial response - {} tools completed, {} in-progress before truncation. See continuation prompt for details.]",
1150                                    completed_count, in_progress_count),
1151                                vec![]  // Don't duplicate - continuation prompt has the details
1152                            );
1153
1154                            // Check if we need compaction after adding this heavy turn
1155                            // This is important for long multi-turn sessions with many tool calls
1156                            if conversation_history.needs_compaction() {
1157                                eprintln!(
1158                                    "{}",
1159                                    "  ๐Ÿ“ฆ Compacting history before continuation...".dimmed()
1160                                );
1161                                if let Some(summary) = conversation_history.compact() {
1162                                    eprintln!(
1163                                        "{}",
1164                                        format!(
1165                                            "  โœ“ Compressed {} turns",
1166                                            summary.matches("Turn").count()
1167                                        )
1168                                        .dimmed()
1169                                    );
1170                                }
1171                            }
1172
1173                            // Build continuation prompt with context
1174                            current_input = build_continuation_prompt(
1175                                &input,
1176                                &completed_tools,
1177                                &agent_thinking,
1178                            );
1179
1180                            // Log continuation details for debugging
1181                            eprintln!("{}", format!(
1182                                "  โ†’ Continuing with {} files read, {} written, {} other actions tracked",
1183                                completed_tools.iter().filter(|t| t.tool_name == "read_file").count(),
1184                                completed_tools.iter().filter(|t| t.tool_name == "write_file" || t.tool_name == "write_files").count(),
1185                                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()
1186                            ).dimmed());
1187
1188                            // Brief delay before continuation
1189                            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
1190                            // Don't increment retry_attempt - this is progress via continuation
1191                        } else if retry_attempt < MAX_RETRIES {
1192                            // No tool calls completed - simple retry
1193                            retry_attempt += 1;
1194                            eprintln!(
1195                                "{}",
1196                                format!(
1197                                    "โš  Response error (attempt {}/{}). Retrying...",
1198                                    retry_attempt, MAX_RETRIES
1199                                )
1200                                .yellow()
1201                            );
1202                            tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
1203                        } else {
1204                            // Max retries/continuations reached
1205                            eprintln!("{}", format!("Error: {}", e).red());
1206                            if continuation_count >= MAX_CONTINUATIONS {
1207                                eprintln!("{}", format!("Max continuations ({}) reached. The task is too complex for one request.", MAX_CONTINUATIONS).dimmed());
1208                            } else {
1209                                eprintln!(
1210                                    "{}",
1211                                    "Max retries reached. The response may be too complex."
1212                                        .dimmed()
1213                                );
1214                            }
1215                            eprintln!(
1216                                "{}",
1217                                "Try breaking your request into smaller parts.".dimmed()
1218                            );
1219                            break;
1220                        }
1221                    } else if err_str.contains("timeout") || err_str.contains("Timeout") {
1222                        // Timeout - simple retry
1223                        retry_attempt += 1;
1224                        if retry_attempt < MAX_RETRIES {
1225                            eprintln!(
1226                                "{}",
1227                                format!(
1228                                    "โš  Request timed out (attempt {}/{}). Retrying...",
1229                                    retry_attempt, MAX_RETRIES
1230                                )
1231                                .yellow()
1232                            );
1233                            tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
1234                        } else {
1235                            eprintln!("{}", "Request timed out. Please try again.".red());
1236                            break;
1237                        }
1238                    } else {
1239                        // Unknown error - show details and break
1240                        eprintln!("{}", format!("Error: {}", e).red());
1241                        if continuation_count > 0 {
1242                            eprintln!(
1243                                "{}",
1244                                format!(
1245                                    "  (occurred during continuation attempt {})",
1246                                    continuation_count
1247                                )
1248                                .dimmed()
1249                            );
1250                        }
1251                        eprintln!("{}", "Error details for debugging:".dimmed());
1252                        eprintln!(
1253                            "{}",
1254                            format!("  - retry_attempt: {}/{}", retry_attempt, MAX_RETRIES)
1255                                .dimmed()
1256                        );
1257                        eprintln!(
1258                            "{}",
1259                            format!(
1260                                "  - continuation_count: {}/{}",
1261                                continuation_count, MAX_CONTINUATIONS
1262                            )
1263                            .dimmed()
1264                        );
1265                        break;
1266                    }
1267                }
1268            }
1269        }
1270        println!();
1271    }
1272
1273    // Clean up terminal layout before exiting (disabled - layout not initialized)
1274    // if let Err(e) = terminal_layout.cleanup() {
1275    //     eprintln!(
1276    //         "{}",
1277    //         format!("Warning: Terminal cleanup failed: {}", e).dimmed()
1278    //     );
1279    // }
1280
1281    Ok(())
1282}
1283
1284// NOTE: wait_for_interrupt function removed - ESC interrupt feature disabled
1285// due to terminal corruption issues with spawn_blocking raw mode handling.
1286// TODO: Re-implement using tool hook callbacks for cleaner interruption.
1287
1288/// Extract tool call records from the hook state for history tracking
1289async fn extract_tool_calls_from_hook(hook: &ToolDisplayHook) -> Vec<ToolCallRecord> {
1290    let state = hook.state();
1291    let guard = state.lock().await;
1292
1293    guard
1294        .tool_calls
1295        .iter()
1296        .enumerate()
1297        .map(|(i, tc)| {
1298            let result = if tc.is_running {
1299                // Tool was in progress when error occurred
1300                "[IN PROGRESS - may need to be re-run]".to_string()
1301            } else if let Some(output) = &tc.output {
1302                truncate_string(output, 200)
1303            } else {
1304                "completed".to_string()
1305            };
1306
1307            ToolCallRecord {
1308                tool_name: tc.name.clone(),
1309                args_summary: truncate_string(&tc.args, 100),
1310                result_summary: result,
1311                // Generate a unique tool ID for proper message pairing
1312                tool_id: Some(format!("tool_{}_{}", tc.name, i)),
1313                // Mark read-only tools as droppable (their results can be re-fetched)
1314                droppable: matches!(
1315                    tc.name.as_str(),
1316                    "read_file" | "list_directory" | "analyze_project"
1317                ),
1318            }
1319        })
1320        .collect()
1321}
1322
1323/// Extract any agent thinking/messages from the hook for context
1324async fn extract_agent_messages_from_hook(hook: &ToolDisplayHook) -> Vec<String> {
1325    let state = hook.state();
1326    let guard = state.lock().await;
1327    guard.agent_messages.clone()
1328}
1329
1330/// Helper to truncate strings for summaries
1331fn truncate_string(s: &str, max_len: usize) -> String {
1332    if s.len() <= max_len {
1333        s.to_string()
1334    } else {
1335        format!("{}...", &s[..max_len.saturating_sub(3)])
1336    }
1337}
1338
1339/// Estimate token count from raw rig Messages
1340/// This is used for context length management to prevent "input too long" errors.
1341/// Estimates ~4 characters per token.
1342fn estimate_raw_history_tokens(messages: &[rig::completion::Message]) -> usize {
1343    use rig::completion::message::{AssistantContent, UserContent};
1344
1345    messages
1346        .iter()
1347        .map(|msg| -> usize {
1348            match msg {
1349                rig::completion::Message::User { content } => {
1350                    content
1351                        .iter()
1352                        .map(|c| -> usize {
1353                            match c {
1354                                UserContent::Text(t) => t.text.len() / 4,
1355                                _ => 100, // Estimate for images/documents
1356                            }
1357                        })
1358                        .sum::<usize>()
1359                }
1360                rig::completion::Message::Assistant { content, .. } => {
1361                    content
1362                        .iter()
1363                        .map(|c| -> usize {
1364                            match c {
1365                                AssistantContent::Text(t) => t.text.len() / 4,
1366                                AssistantContent::ToolCall(tc) => {
1367                                    // arguments is serde_json::Value, convert to string for length estimate
1368                                    let args_len = tc.function.arguments.to_string().len();
1369                                    (tc.function.name.len() + args_len) / 4
1370                                }
1371                                _ => 100,
1372                            }
1373                        })
1374                        .sum::<usize>()
1375                }
1376            }
1377        })
1378        .sum()
1379}
1380
1381/// Find a plan_create tool call in the list and extract plan info
1382/// Returns (plan_path, task_count) if found
1383fn find_plan_create_call(tool_calls: &[ToolCallRecord]) -> Option<(String, usize)> {
1384    for tc in tool_calls {
1385        if tc.tool_name == "plan_create" {
1386            // Try to parse the result_summary as JSON to extract plan_path
1387            // Note: result_summary may be truncated, so we have multiple fallbacks
1388            let plan_path =
1389                if let Ok(result) = serde_json::from_str::<serde_json::Value>(&tc.result_summary) {
1390                    result
1391                        .get("plan_path")
1392                        .and_then(|v| v.as_str())
1393                        .map(|s| s.to_string())
1394                } else {
1395                    None
1396                };
1397
1398            // If JSON parsing failed, find the most recently created plan file
1399            // This is more reliable than trying to reconstruct the path from truncated args
1400            let plan_path = plan_path.unwrap_or_else(|| {
1401                find_most_recent_plan_file().unwrap_or_else(|| "plans/plan.md".to_string())
1402            });
1403
1404            // Count tasks by reading the plan file directly
1405            let task_count = count_tasks_in_plan_file(&plan_path).unwrap_or(0);
1406
1407            return Some((plan_path, task_count));
1408        }
1409    }
1410    None
1411}
1412
1413/// Find the most recently created plan file in the plans directory
1414fn find_most_recent_plan_file() -> Option<String> {
1415    let plans_dir = std::env::current_dir().ok()?.join("plans");
1416    if !plans_dir.exists() {
1417        return None;
1418    }
1419
1420    let mut newest: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1421
1422    for entry in std::fs::read_dir(&plans_dir).ok()?.flatten() {
1423        let path = entry.path();
1424        if path.extension().is_some_and(|e| e == "md")
1425            && let Ok(metadata) = entry.metadata()
1426            && let Ok(modified) = metadata.modified()
1427            && newest.as_ref().map(|(_, t)| modified > *t).unwrap_or(true)
1428        {
1429            newest = Some((path, modified));
1430        }
1431    }
1432
1433    newest.map(|(path, _)| {
1434        // Return relative path
1435        path.strip_prefix(std::env::current_dir().unwrap_or_default())
1436            .map(|p| p.display().to_string())
1437            .unwrap_or_else(|_| path.display().to_string())
1438    })
1439}
1440
1441/// Count tasks (checkbox items) in a plan file
1442fn count_tasks_in_plan_file(plan_path: &str) -> Option<usize> {
1443    use regex::Regex;
1444
1445    // Try both relative and absolute paths
1446    let path = std::path::Path::new(plan_path);
1447    let content = if path.exists() {
1448        std::fs::read_to_string(path).ok()?
1449    } else {
1450        // Try with current directory
1451        std::fs::read_to_string(std::env::current_dir().ok()?.join(plan_path)).ok()?
1452    };
1453
1454    // Count task checkboxes: - [ ], - [x], - [~], - [!]
1455    let task_regex = Regex::new(r"^\s*-\s*\[[ x~!]\]").ok()?;
1456    let count = content
1457        .lines()
1458        .filter(|line| task_regex.is_match(line))
1459        .count();
1460
1461    Some(count)
1462}
1463
1464/// Check if an error is a truncation/JSON parsing error that can be recovered via continuation
1465fn is_truncation_error(err_str: &str) -> bool {
1466    err_str.contains("JsonError")
1467        || err_str.contains("EOF while parsing")
1468        || err_str.contains("JSON")
1469        || err_str.contains("unexpected end")
1470}
1471
1472/// Check if error is "input too long" - context exceeds model limit
1473/// This happens when conversation history grows beyond what the model can handle.
1474/// Recovery: compact history and retry with reduced context.
1475fn is_input_too_long_error(err_str: &str) -> bool {
1476    err_str.contains("too long")
1477        || err_str.contains("Too long")
1478        || err_str.contains("context length")
1479        || err_str.contains("maximum context")
1480        || err_str.contains("exceeds the model")
1481        || err_str.contains("Input is too long")
1482}
1483
1484/// Build a continuation prompt that tells the AI what work was completed
1485/// and asks it to continue from where it left off
1486fn build_continuation_prompt(
1487    original_task: &str,
1488    completed_tools: &[ToolCallRecord],
1489    agent_thinking: &[String],
1490) -> String {
1491    use std::collections::HashSet;
1492
1493    // Group tools by type and extract unique files read
1494    let mut files_read: HashSet<String> = HashSet::new();
1495    let mut files_written: HashSet<String> = HashSet::new();
1496    let mut dirs_listed: HashSet<String> = HashSet::new();
1497    let mut other_tools: Vec<String> = Vec::new();
1498    let mut in_progress: Vec<String> = Vec::new();
1499
1500    for tool in completed_tools {
1501        let is_in_progress = tool.result_summary.contains("IN PROGRESS");
1502
1503        if is_in_progress {
1504            in_progress.push(format!("{}({})", tool.tool_name, tool.args_summary));
1505            continue;
1506        }
1507
1508        match tool.tool_name.as_str() {
1509            "read_file" => {
1510                // Extract path from args
1511                files_read.insert(tool.args_summary.clone());
1512            }
1513            "write_file" | "write_files" => {
1514                files_written.insert(tool.args_summary.clone());
1515            }
1516            "list_directory" => {
1517                dirs_listed.insert(tool.args_summary.clone());
1518            }
1519            _ => {
1520                other_tools.push(format!(
1521                    "{}({})",
1522                    tool.tool_name,
1523                    truncate_string(&tool.args_summary, 40)
1524                ));
1525            }
1526        }
1527    }
1528
1529    let mut prompt = format!(
1530        "[CONTINUE] Your previous response was interrupted. DO NOT repeat completed work.\n\n\
1531        Original task: {}\n",
1532        truncate_string(original_task, 500)
1533    );
1534
1535    // Show files already read - CRITICAL for preventing re-reads
1536    if !files_read.is_empty() {
1537        prompt.push_str("\n== FILES ALREADY READ (do NOT read again) ==\n");
1538        for file in &files_read {
1539            prompt.push_str(&format!("  - {}\n", file));
1540        }
1541    }
1542
1543    if !dirs_listed.is_empty() {
1544        prompt.push_str("\n== DIRECTORIES ALREADY LISTED ==\n");
1545        for dir in &dirs_listed {
1546            prompt.push_str(&format!("  - {}\n", dir));
1547        }
1548    }
1549
1550    if !files_written.is_empty() {
1551        prompt.push_str("\n== FILES ALREADY WRITTEN ==\n");
1552        for file in &files_written {
1553            prompt.push_str(&format!("  - {}\n", file));
1554        }
1555    }
1556
1557    if !other_tools.is_empty() {
1558        prompt.push_str("\n== OTHER COMPLETED ACTIONS ==\n");
1559        for tool in other_tools.iter().take(20) {
1560            prompt.push_str(&format!("  - {}\n", tool));
1561        }
1562        if other_tools.len() > 20 {
1563            prompt.push_str(&format!("  ... and {} more\n", other_tools.len() - 20));
1564        }
1565    }
1566
1567    if !in_progress.is_empty() {
1568        prompt.push_str("\n== INTERRUPTED (may need re-run) ==\n");
1569        for tool in &in_progress {
1570            prompt.push_str(&format!("  โš  {}\n", tool));
1571        }
1572    }
1573
1574    // Include last thinking context if available
1575    if let Some(last_thought) = agent_thinking.last() {
1576        prompt.push_str(&format!(
1577            "\n== YOUR LAST THOUGHTS ==\n\"{}\"\n",
1578            truncate_string(last_thought, 300)
1579        ));
1580    }
1581
1582    prompt.push_str("\n== INSTRUCTIONS ==\n");
1583    prompt.push_str("IMPORTANT: Your previous response was too long and got cut off.\n");
1584    prompt.push_str("1. Do NOT re-read files listed above - they are already in context.\n");
1585    prompt.push_str("2. If writing a document, write it in SECTIONS - complete one section now, then continue.\n");
1586    prompt.push_str("3. Keep your response SHORT and focused. Better to complete small chunks than fail on large ones.\n");
1587    prompt.push_str("4. If the task involves writing a file, START WRITING NOW - don't explain what you'll do.\n");
1588
1589    prompt
1590}
1591
1592/// Run a single query and return the response
1593pub async fn run_query(
1594    project_path: &Path,
1595    query: &str,
1596    provider: ProviderType,
1597    model: Option<String>,
1598) -> AgentResult<String> {
1599    use tools::*;
1600
1601    let project_path_buf = project_path.to_path_buf();
1602    // Select prompt based on query type (analysis vs generation)
1603    // For single queries (non-interactive), always use standard mode
1604    let preamble = get_system_prompt(project_path, Some(query), PlanMode::default());
1605    let is_generation = prompts::is_generation_query(query);
1606
1607    match provider {
1608        ProviderType::OpenAI => {
1609            let client = openai::Client::from_env();
1610            let model_name = model.as_deref().unwrap_or("gpt-5.2");
1611
1612            // For GPT-5.x reasoning models, enable reasoning with summary output
1613            let reasoning_params =
1614                if model_name.starts_with("gpt-5") || model_name.starts_with("o1") {
1615                    Some(serde_json::json!({
1616                        "reasoning": {
1617                            "effort": "medium",
1618                            "summary": "detailed"
1619                        }
1620                    }))
1621                } else {
1622                    None
1623                };
1624
1625            let mut builder = client
1626                .agent(model_name)
1627                .preamble(&preamble)
1628                .max_tokens(4096)
1629                .tool(AnalyzeTool::new(project_path_buf.clone()))
1630                .tool(SecurityScanTool::new(project_path_buf.clone()))
1631                .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1632                .tool(HadolintTool::new(project_path_buf.clone()))
1633                .tool(DclintTool::new(project_path_buf.clone()))
1634                .tool(KubelintTool::new(project_path_buf.clone()))
1635                .tool(HelmlintTool::new(project_path_buf.clone()))
1636                .tool(TerraformFmtTool::new(project_path_buf.clone()))
1637                .tool(TerraformValidateTool::new(project_path_buf.clone()))
1638                .tool(TerraformInstallTool::new())
1639                .tool(ReadFileTool::new(project_path_buf.clone()))
1640                .tool(ListDirectoryTool::new(project_path_buf.clone()))
1641                .tool(WebFetchTool::new());
1642
1643            // Add generation tools if this is a generation query
1644            if is_generation {
1645                builder = builder
1646                    .tool(WriteFileTool::new(project_path_buf.clone()))
1647                    .tool(WriteFilesTool::new(project_path_buf.clone()))
1648                    .tool(ShellTool::new(project_path_buf.clone()));
1649            }
1650
1651            if let Some(params) = reasoning_params {
1652                builder = builder.additional_params(params);
1653            }
1654
1655            let agent = builder.build();
1656
1657            agent
1658                .prompt(query)
1659                .multi_turn(50)
1660                .await
1661                .map_err(|e| AgentError::ProviderError(e.to_string()))
1662        }
1663        ProviderType::Anthropic => {
1664            let client = anthropic::Client::from_env();
1665            let model_name = model.as_deref().unwrap_or("claude-sonnet-4-5-20250929");
1666
1667            // TODO: Extended thinking for Claude is disabled because rig doesn't properly
1668            // handle thinking blocks in multi-turn conversations with tool use.
1669            // See: forge/crates/forge_services/src/provider/bedrock/provider.rs for reference.
1670
1671            let mut builder = client
1672                .agent(model_name)
1673                .preamble(&preamble)
1674                .max_tokens(4096)
1675                .tool(AnalyzeTool::new(project_path_buf.clone()))
1676                .tool(SecurityScanTool::new(project_path_buf.clone()))
1677                .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1678                .tool(HadolintTool::new(project_path_buf.clone()))
1679                .tool(DclintTool::new(project_path_buf.clone()))
1680                .tool(KubelintTool::new(project_path_buf.clone()))
1681                .tool(HelmlintTool::new(project_path_buf.clone()))
1682                .tool(TerraformFmtTool::new(project_path_buf.clone()))
1683                .tool(TerraformValidateTool::new(project_path_buf.clone()))
1684                .tool(TerraformInstallTool::new())
1685                .tool(ReadFileTool::new(project_path_buf.clone()))
1686                .tool(ListDirectoryTool::new(project_path_buf.clone()))
1687                .tool(WebFetchTool::new());
1688
1689            // Add generation tools if this is a generation query
1690            if is_generation {
1691                builder = builder
1692                    .tool(WriteFileTool::new(project_path_buf.clone()))
1693                    .tool(WriteFilesTool::new(project_path_buf.clone()))
1694                    .tool(ShellTool::new(project_path_buf.clone()));
1695            }
1696
1697            let agent = builder.build();
1698
1699            agent
1700                .prompt(query)
1701                .multi_turn(50)
1702                .await
1703                .map_err(|e| AgentError::ProviderError(e.to_string()))
1704        }
1705        ProviderType::Bedrock => {
1706            // Bedrock provider via rig-bedrock - same pattern as Anthropic
1707            let client = crate::bedrock::client::Client::from_env();
1708            let model_name = model
1709                .as_deref()
1710                .unwrap_or("global.anthropic.claude-sonnet-4-5-20250929-v1:0");
1711
1712            // Extended thinking for Claude via Bedrock
1713            let thinking_params = serde_json::json!({
1714                "thinking": {
1715                    "type": "enabled",
1716                    "budget_tokens": 16000
1717                }
1718            });
1719
1720            let mut builder = client
1721                .agent(model_name)
1722                .preamble(&preamble)
1723                .max_tokens(64000)  // Max output tokens for Claude Sonnet on Bedrock
1724                .tool(AnalyzeTool::new(project_path_buf.clone()))
1725                .tool(SecurityScanTool::new(project_path_buf.clone()))
1726                .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1727                .tool(HadolintTool::new(project_path_buf.clone()))
1728                .tool(DclintTool::new(project_path_buf.clone()))
1729                .tool(KubelintTool::new(project_path_buf.clone()))
1730                .tool(HelmlintTool::new(project_path_buf.clone()))
1731                .tool(TerraformFmtTool::new(project_path_buf.clone()))
1732                .tool(TerraformValidateTool::new(project_path_buf.clone()))
1733                .tool(TerraformInstallTool::new())
1734                .tool(ReadFileTool::new(project_path_buf.clone()))
1735                .tool(ListDirectoryTool::new(project_path_buf.clone()))
1736                .tool(WebFetchTool::new());
1737
1738            // Add generation tools if this is a generation query
1739            if is_generation {
1740                builder = builder
1741                    .tool(WriteFileTool::new(project_path_buf.clone()))
1742                    .tool(WriteFilesTool::new(project_path_buf.clone()))
1743                    .tool(ShellTool::new(project_path_buf.clone()));
1744            }
1745
1746            let agent = builder.additional_params(thinking_params).build();
1747
1748            agent
1749                .prompt(query)
1750                .multi_turn(50)
1751                .await
1752                .map_err(|e| AgentError::ProviderError(e.to_string()))
1753        }
1754    }
1755}