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(¤t_input),
470 session.plan_mode,
471 );
472 let is_generation = prompts::is_generation_query(¤t_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(¤t_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(¤t_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(¤t_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}