1pub mod commands;
34pub mod compact;
35pub mod history;
36pub mod ide;
37pub mod prompts;
38pub mod session;
39pub mod tools;
40pub mod ui;
41use colored::Colorize;
42use history::{ConversationHistory, ToolCallRecord};
43use ide::IdeClient;
44use rig::{
45 client::{CompletionClient, ProviderClient},
46 completion::Prompt,
47 providers::{anthropic, openai},
48};
49use session::{ChatSession, PlanMode};
50use commands::TokenUsage;
51use std::path::Path;
52use std::sync::Arc;
53use tokio::sync::Mutex as TokioMutex;
54use ui::{ResponseFormatter, ToolDisplayHook};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum ProviderType {
59 #[default]
60 OpenAI,
61 Anthropic,
62 Bedrock,
63}
64
65impl std::fmt::Display for ProviderType {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 match self {
68 ProviderType::OpenAI => write!(f, "openai"),
69 ProviderType::Anthropic => write!(f, "anthropic"),
70 ProviderType::Bedrock => write!(f, "bedrock"),
71 }
72 }
73}
74
75impl std::str::FromStr for ProviderType {
76 type Err = String;
77
78 fn from_str(s: &str) -> Result<Self, Self::Err> {
79 match s.to_lowercase().as_str() {
80 "openai" => Ok(ProviderType::OpenAI),
81 "anthropic" => Ok(ProviderType::Anthropic),
82 "bedrock" | "aws" | "aws-bedrock" => Ok(ProviderType::Bedrock),
83 _ => Err(format!("Unknown provider: {}. Use: openai, anthropic, or bedrock", s)),
84 }
85 }
86}
87
88#[derive(Debug, thiserror::Error)]
90pub enum AgentError {
91 #[error("Missing API key. Set {0} environment variable.")]
92 MissingApiKey(String),
93
94 #[error("Provider error: {0}")]
95 ProviderError(String),
96
97 #[error("Tool error: {0}")]
98 ToolError(String),
99}
100
101pub type AgentResult<T> = Result<T, AgentError>;
102
103fn get_system_prompt(project_path: &Path, query: Option<&str>, plan_mode: PlanMode) -> String {
105 if plan_mode.is_planning() {
107 return prompts::get_planning_prompt(project_path);
108 }
109
110 if let Some(q) = query {
111 if prompts::is_code_development_query(q) {
113 return prompts::get_code_development_prompt(project_path);
114 }
115 if prompts::is_generation_query(q) {
117 return prompts::get_devops_prompt(project_path);
118 }
119 }
120 prompts::get_analysis_prompt(project_path)
122}
123
124pub async fn run_interactive(
126 project_path: &Path,
127 provider: ProviderType,
128 model: Option<String>,
129) -> AgentResult<()> {
130 use tools::*;
131
132 let mut session = ChatSession::new(project_path, provider, model);
133
134 let mut conversation_history = ConversationHistory::new();
136
137 let ide_client: Option<Arc<TokioMutex<IdeClient>>> = {
139 let mut client = IdeClient::new().await;
140 if client.is_ide_available() {
141 match client.connect().await {
142 Ok(()) => {
143 println!(
144 "{} Connected to {} IDE companion",
145 "โ".green(),
146 client.ide_name().unwrap_or("VS Code")
147 );
148 Some(Arc::new(TokioMutex::new(client)))
149 }
150 Err(e) => {
151 println!(
153 "{} IDE companion not connected: {}",
154 "!".yellow(),
155 e
156 );
157 None
158 }
159 }
160 } else {
161 println!("{} No IDE detected (TERM_PROGRAM={})", "ยท".dimmed(), std::env::var("TERM_PROGRAM").unwrap_or_default());
162 None
163 }
164 };
165
166 ChatSession::load_api_key_to_env(session.provider);
168
169 if !ChatSession::has_api_key(session.provider) {
171 ChatSession::prompt_api_key(session.provider)?;
172 }
173
174 session.print_banner();
175
176 let mut raw_chat_history: Vec<rig::completion::Message> = Vec::new();
179
180 let mut pending_input: Option<String> = None;
182 let mut auto_accept_writes = false;
184
185 loop {
186 if !conversation_history.is_empty() {
188 println!("{}", format!(" ๐ฌ Context: {}", conversation_history.status()).dimmed());
189 }
190
191 let input = if let Some(pending) = pending_input.take() {
193 println!("{} {}", "โ".cyan(), pending.dimmed());
195 pending
196 } else {
197 auto_accept_writes = false;
199
200 let input_result = match session.read_input() {
202 Ok(result) => result,
203 Err(_) => break,
204 };
205
206 match input_result {
208 ui::InputResult::Submit(text) => ChatSession::process_submitted_text(&text),
209 ui::InputResult::Cancel | ui::InputResult::Exit => break,
210 ui::InputResult::TogglePlanMode => {
211 let new_mode = session.toggle_plan_mode();
213 if new_mode.is_planning() {
214 println!("{}", "โ
plan mode".yellow());
215 } else {
216 println!("{}", "โถ standard mode".green());
217 }
218 continue;
219 }
220 }
221 };
222
223 if input.is_empty() {
224 continue;
225 }
226
227 if ChatSession::is_command(&input) {
229 if input.trim().to_lowercase() == "/clear" || input.trim().to_lowercase() == "/c" {
231 conversation_history.clear();
232 raw_chat_history.clear();
233 }
234 match session.process_command(&input) {
235 Ok(true) => continue,
236 Ok(false) => break, Err(e) => {
238 eprintln!("{}", format!("Error: {}", e).red());
239 continue;
240 }
241 }
242 }
243
244 if !ChatSession::has_api_key(session.provider) {
246 eprintln!("{}", "No API key configured. Use /provider to set one.".yellow());
247 continue;
248 }
249
250 if conversation_history.needs_compaction() {
252 println!("{}", " ๐ฆ Compacting conversation history...".dimmed());
253 if let Some(summary) = conversation_history.compact() {
254 println!("{}", format!(" โ Compressed {} turns", summary.matches("Turn").count()).dimmed());
255 }
256 }
257
258 let estimated_input_tokens = estimate_raw_history_tokens(&raw_chat_history)
262 + input.len() / 4 + 5000; if estimated_input_tokens > 150_000 {
266 println!("{}", " โ Large context detected. Pre-truncating...".yellow());
267
268 let old_count = raw_chat_history.len();
269 if raw_chat_history.len() > 20 {
271 let drain_count = raw_chat_history.len() - 20;
272 raw_chat_history.drain(0..drain_count);
273 conversation_history.clear(); println!("{}", format!(" โ Truncated {} โ {} messages", old_count, raw_chat_history.len()).dimmed());
275 }
276 }
277
278 const MAX_RETRIES: u32 = 3;
284 const MAX_CONTINUATIONS: u32 = 10;
285 const TOOL_CALL_CHECKPOINT: usize = 50;
286 const MAX_TOOL_CALLS: usize = 300;
287 let mut retry_attempt = 0;
288 let mut continuation_count = 0;
289 let mut total_tool_calls: usize = 0;
290 let mut auto_continue_tools = false; let mut current_input = input.clone();
292 let mut succeeded = false;
293
294 while retry_attempt < MAX_RETRIES && continuation_count < MAX_CONTINUATIONS && !succeeded {
295
296 if continuation_count > 0 {
298 eprintln!("{}", format!(" ๐ก Sending continuation request...").dimmed());
299 }
300
301 let hook = ToolDisplayHook::new();
303
304 let project_path_buf = session.project_path.clone();
305 let preamble = get_system_prompt(&session.project_path, Some(¤t_input), session.plan_mode);
307 let is_generation = prompts::is_generation_query(¤t_input);
308 let is_planning = session.plan_mode.is_planning();
309
310 let response = match session.provider {
314 ProviderType::OpenAI => {
315 let client = openai::Client::from_env();
316 let reasoning_params = if session.model.starts_with("gpt-5") || session.model.starts_with("o1") {
319 Some(serde_json::json!({
320 "reasoning": {
321 "effort": "medium",
322 "summary": "detailed"
323 }
324 }))
325 } else {
326 None
327 };
328
329 let mut builder = client
330 .agent(&session.model)
331 .preamble(&preamble)
332 .max_tokens(4096)
333 .tool(AnalyzeTool::new(project_path_buf.clone()))
334 .tool(SecurityScanTool::new(project_path_buf.clone()))
335 .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
336 .tool(HadolintTool::new(project_path_buf.clone()))
337 .tool(TerraformFmtTool::new(project_path_buf.clone()))
338 .tool(TerraformValidateTool::new(project_path_buf.clone()))
339 .tool(TerraformInstallTool::new())
340 .tool(ReadFileTool::new(project_path_buf.clone()))
341 .tool(ListDirectoryTool::new(project_path_buf.clone()));
342
343 if is_planning {
345 builder = builder
347 .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
348 .tool(PlanCreateTool::new(project_path_buf.clone()))
349 .tool(PlanListTool::new(project_path_buf.clone()));
350 } else if is_generation {
351 let (mut write_file_tool, mut write_files_tool) = if let Some(ref client) = ide_client {
353 (
354 WriteFileTool::new(project_path_buf.clone())
355 .with_ide_client(client.clone()),
356 WriteFilesTool::new(project_path_buf.clone())
357 .with_ide_client(client.clone()),
358 )
359 } else {
360 (
361 WriteFileTool::new(project_path_buf.clone()),
362 WriteFilesTool::new(project_path_buf.clone()),
363 )
364 };
365 if auto_accept_writes {
367 write_file_tool = write_file_tool.without_confirmation();
368 write_files_tool = write_files_tool.without_confirmation();
369 }
370 builder = builder
371 .tool(write_file_tool)
372 .tool(write_files_tool)
373 .tool(ShellTool::new(project_path_buf.clone()))
374 .tool(PlanListTool::new(project_path_buf.clone()))
375 .tool(PlanNextTool::new(project_path_buf.clone()))
376 .tool(PlanUpdateTool::new(project_path_buf.clone()));
377 }
378
379 if let Some(params) = reasoning_params {
380 builder = builder.additional_params(params);
381 }
382
383 let agent = builder.build();
384 agent.prompt(¤t_input)
388 .with_history(&mut raw_chat_history)
389 .with_hook(hook.clone())
390 .multi_turn(50)
391 .await
392 }
393 ProviderType::Anthropic => {
394 let client = anthropic::Client::from_env();
395
396 let mut builder = client
403 .agent(&session.model)
404 .preamble(&preamble)
405 .max_tokens(4096)
406 .tool(AnalyzeTool::new(project_path_buf.clone()))
407 .tool(SecurityScanTool::new(project_path_buf.clone()))
408 .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
409 .tool(HadolintTool::new(project_path_buf.clone()))
410 .tool(TerraformFmtTool::new(project_path_buf.clone()))
411 .tool(TerraformValidateTool::new(project_path_buf.clone()))
412 .tool(TerraformInstallTool::new())
413 .tool(ReadFileTool::new(project_path_buf.clone()))
414 .tool(ListDirectoryTool::new(project_path_buf.clone()));
415
416 if is_planning {
418 builder = builder
420 .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
421 .tool(PlanCreateTool::new(project_path_buf.clone()))
422 .tool(PlanListTool::new(project_path_buf.clone()));
423 } else if is_generation {
424 let (mut write_file_tool, mut write_files_tool) = if let Some(ref client) = ide_client {
426 (
427 WriteFileTool::new(project_path_buf.clone())
428 .with_ide_client(client.clone()),
429 WriteFilesTool::new(project_path_buf.clone())
430 .with_ide_client(client.clone()),
431 )
432 } else {
433 (
434 WriteFileTool::new(project_path_buf.clone()),
435 WriteFilesTool::new(project_path_buf.clone()),
436 )
437 };
438 if auto_accept_writes {
440 write_file_tool = write_file_tool.without_confirmation();
441 write_files_tool = write_files_tool.without_confirmation();
442 }
443 builder = builder
444 .tool(write_file_tool)
445 .tool(write_files_tool)
446 .tool(ShellTool::new(project_path_buf.clone()))
447 .tool(PlanListTool::new(project_path_buf.clone()))
448 .tool(PlanNextTool::new(project_path_buf.clone()))
449 .tool(PlanUpdateTool::new(project_path_buf.clone()));
450 }
451
452 let agent = builder.build();
453
454 agent.prompt(¤t_input)
458 .with_history(&mut raw_chat_history)
459 .with_hook(hook.clone())
460 .multi_turn(50)
461 .await
462 }
463 ProviderType::Bedrock => {
464 let client = rig_bedrock::client::Client::from_env();
466
467 let thinking_params = serde_json::json!({
473 "thinking": {
474 "type": "enabled",
475 "budget_tokens": 8000
476 }
477 });
478
479 let mut builder = client
480 .agent(&session.model)
481 .preamble(&preamble)
482 .max_tokens(64000) .tool(AnalyzeTool::new(project_path_buf.clone()))
484 .tool(SecurityScanTool::new(project_path_buf.clone()))
485 .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
486 .tool(HadolintTool::new(project_path_buf.clone()))
487 .tool(TerraformFmtTool::new(project_path_buf.clone()))
488 .tool(TerraformValidateTool::new(project_path_buf.clone()))
489 .tool(TerraformInstallTool::new())
490 .tool(ReadFileTool::new(project_path_buf.clone()))
491 .tool(ListDirectoryTool::new(project_path_buf.clone()));
492
493 if is_planning {
495 builder = builder
497 .tool(ShellTool::new(project_path_buf.clone()).with_read_only(true))
498 .tool(PlanCreateTool::new(project_path_buf.clone()))
499 .tool(PlanListTool::new(project_path_buf.clone()));
500 } else if is_generation {
501 let (mut write_file_tool, mut write_files_tool) = if let Some(ref client) = ide_client {
503 (
504 WriteFileTool::new(project_path_buf.clone())
505 .with_ide_client(client.clone()),
506 WriteFilesTool::new(project_path_buf.clone())
507 .with_ide_client(client.clone()),
508 )
509 } else {
510 (
511 WriteFileTool::new(project_path_buf.clone()),
512 WriteFilesTool::new(project_path_buf.clone()),
513 )
514 };
515 if auto_accept_writes {
517 write_file_tool = write_file_tool.without_confirmation();
518 write_files_tool = write_files_tool.without_confirmation();
519 }
520 builder = builder
521 .tool(write_file_tool)
522 .tool(write_files_tool)
523 .tool(ShellTool::new(project_path_buf.clone()))
524 .tool(PlanListTool::new(project_path_buf.clone()))
525 .tool(PlanNextTool::new(project_path_buf.clone()))
526 .tool(PlanUpdateTool::new(project_path_buf.clone()));
527 }
528
529 builder = builder.additional_params(thinking_params);
531
532 let agent = builder.build();
533
534 agent.prompt(¤t_input)
536 .with_history(&mut raw_chat_history)
537 .with_hook(hook.clone())
538 .multi_turn(50)
539 .await
540 }
541 };
542
543 match response {
544 Ok(text) => {
545 println!();
547 ResponseFormatter::print_response(&text);
548
549 let hook_usage = hook.get_usage().await;
551 if hook_usage.has_data() {
552 session.token_usage.add_actual(hook_usage.input_tokens, hook_usage.output_tokens);
554 } else {
555 let prompt_tokens = TokenUsage::estimate_tokens(&input);
557 let completion_tokens = TokenUsage::estimate_tokens(&text);
558 session.token_usage.add_estimated(prompt_tokens, completion_tokens);
559 }
560 hook.reset_usage().await;
562
563 let model_short = session.model.split('/').last()
565 .unwrap_or(&session.model)
566 .split(':').next()
567 .unwrap_or(&session.model);
568 println!();
569 println!(
570 " {}[{}/{}]{}",
571 ui::colors::ansi::DIM,
572 model_short,
573 session.token_usage.format_compact(),
574 ui::colors::ansi::RESET
575 );
576
577 let tool_calls = extract_tool_calls_from_hook(&hook).await;
579 let batch_tool_count = tool_calls.len();
580 total_tool_calls += batch_tool_count;
581
582 if batch_tool_count > 10 {
584 println!("{}", format!(" โ Completed with {} tool calls ({} total this session)", batch_tool_count, total_tool_calls).dimmed());
585 }
586
587 conversation_history.add_turn(input.clone(), text.clone(), tool_calls.clone());
589
590 if conversation_history.needs_compaction() {
593 println!("{}", " ๐ฆ Compacting conversation history...".dimmed());
594 if let Some(summary) = conversation_history.compact() {
595 println!("{}", format!(" โ Compressed {} turns", summary.matches("Turn").count()).dimmed());
596 }
597 }
598
599 session.history.push(("user".to_string(), input.clone()));
601 session.history.push(("assistant".to_string(), text.clone()));
602
603 if let Some(plan_info) = find_plan_create_call(&tool_calls) {
605 println!(); match ui::show_plan_action_menu(&plan_info.0, plan_info.1) {
609 ui::PlanActionResult::ExecuteAutoAccept => {
610 if session.plan_mode.is_planning() {
612 session.plan_mode = session.plan_mode.toggle();
613 }
614 auto_accept_writes = true;
615 pending_input = Some(format!(
616 "Execute the plan at '{}'. Use plan_next to get tasks and execute them in order. Auto-accept all file writes.",
617 plan_info.0
618 ));
619 succeeded = true;
620 }
621 ui::PlanActionResult::ExecuteWithReview => {
622 if session.plan_mode.is_planning() {
624 session.plan_mode = session.plan_mode.toggle();
625 }
626 pending_input = Some(format!(
627 "Execute the plan at '{}'. Use plan_next to get tasks and execute them in order.",
628 plan_info.0
629 ));
630 succeeded = true;
631 }
632 ui::PlanActionResult::ChangePlan(feedback) => {
633 pending_input = Some(format!(
635 "Please modify the plan at '{}'. User feedback: {}",
636 plan_info.0, feedback
637 ));
638 succeeded = true;
639 }
640 ui::PlanActionResult::Cancel => {
641 succeeded = true;
643 }
644 }
645 } else {
646 succeeded = true;
647 }
648 }
649 Err(e) => {
650 let err_str = e.to_string();
651
652 println!();
653
654 if err_str.contains("MaxDepth") || err_str.contains("max_depth") || err_str.contains("reached limit") {
656 let completed_tools = extract_tool_calls_from_hook(&hook).await;
658 let agent_thinking = extract_agent_messages_from_hook(&hook).await;
659 let batch_tool_count = completed_tools.len();
660 total_tool_calls += batch_tool_count;
661
662 eprintln!("{}", format!(
663 "โ Reached {} tool calls this batch ({} total). Maximum allowed: {}",
664 batch_tool_count, total_tool_calls, MAX_TOOL_CALLS
665 ).yellow());
666
667 if total_tool_calls >= MAX_TOOL_CALLS {
669 eprintln!("{}", format!("Maximum tool call limit ({}) reached.", MAX_TOOL_CALLS).red());
670 eprintln!("{}", "The task is too complex. Try breaking it into smaller parts.".dimmed());
671 break;
672 }
673
674 let should_continue = if auto_continue_tools {
676 eprintln!("{}", " Auto-continuing (you selected 'always')...".dimmed());
677 true
678 } else {
679 eprintln!("{}", "Excessive tool calls used. Want to continue?".yellow());
680 eprintln!("{}", " [y] Yes, continue [n] No, stop [a] Always continue".dimmed());
681 print!(" > ");
682 let _ = std::io::Write::flush(&mut std::io::stdout());
683
684 let mut response = String::new();
686 match std::io::stdin().read_line(&mut response) {
687 Ok(_) => {
688 let resp = response.trim().to_lowercase();
689 if resp == "a" || resp == "always" {
690 auto_continue_tools = true;
691 true
692 } else {
693 resp == "y" || resp == "yes" || resp.is_empty()
694 }
695 }
696 Err(_) => false,
697 }
698 };
699
700 if !should_continue {
701 eprintln!("{}", "Stopped by user. Type 'continue' to resume later.".dimmed());
702 if !completed_tools.is_empty() {
704 conversation_history.add_turn(
705 current_input.clone(),
706 format!("[Stopped at checkpoint - {} tools completed]", batch_tool_count),
707 vec![]
708 );
709 }
710 break;
711 }
712
713 eprintln!("{}", format!(
715 " โ Continuing... {} remaining tool calls available",
716 MAX_TOOL_CALLS - total_tool_calls
717 ).dimmed());
718
719 conversation_history.add_turn(
721 current_input.clone(),
722 format!("[Checkpoint - {} tools completed, continuing...]", batch_tool_count),
723 vec![]
724 );
725
726 current_input = build_continuation_prompt(&input, &completed_tools, &agent_thinking);
728
729 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
731 continue; } else if err_str.contains("rate") || err_str.contains("Rate") || err_str.contains("429")
733 || err_str.contains("Too many tokens") || err_str.contains("please wait")
734 || err_str.contains("throttl") || err_str.contains("Throttl") {
735 eprintln!("{}", "โ Rate limited by API provider.".yellow());
736 retry_attempt += 1;
738 let wait_secs = if err_str.contains("Too many tokens") { 30 } else { 5 };
739 eprintln!("{}", format!(" Waiting {} seconds before retry ({}/{})...", wait_secs, retry_attempt, MAX_RETRIES).dimmed());
740 tokio::time::sleep(tokio::time::Duration::from_secs(wait_secs)).await;
741 } else if is_input_too_long_error(&err_str) {
742 eprintln!("{}", "โ Context too large for model. Truncating history...".yellow());
746
747 let old_token_count = estimate_raw_history_tokens(&raw_chat_history);
748 let old_msg_count = raw_chat_history.len();
749
750 let keep_count = match retry_attempt {
753 0 => 10,
754 1 => 6,
755 _ => 4,
756 };
757
758 if raw_chat_history.len() > keep_count {
759 let drain_count = raw_chat_history.len() - keep_count;
761 raw_chat_history.drain(0..drain_count);
762 }
763
764 let new_token_count = estimate_raw_history_tokens(&raw_chat_history);
765 eprintln!("{}", format!(
766 " โ Truncated: {} messages (~{} tokens) โ {} messages (~{} tokens)",
767 old_msg_count, old_token_count, raw_chat_history.len(), new_token_count
768 ).green());
769
770 conversation_history.clear();
772
773 retry_attempt += 1;
775 if retry_attempt < MAX_RETRIES {
776 eprintln!("{}", format!(" โ Retrying with truncated context ({}/{})...", retry_attempt, MAX_RETRIES).dimmed());
777 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
778 } else {
779 eprintln!("{}", "Context still too large after truncation. Try /clear to reset.".red());
780 break;
781 }
782 } else if is_truncation_error(&err_str) {
783 let completed_tools = extract_tool_calls_from_hook(&hook).await;
785 let agent_thinking = extract_agent_messages_from_hook(&hook).await;
786
787 let completed_count = completed_tools.iter()
789 .filter(|t| !t.result_summary.contains("IN PROGRESS"))
790 .count();
791 let in_progress_count = completed_tools.len() - completed_count;
792
793 if !completed_tools.is_empty() && continuation_count < MAX_CONTINUATIONS {
794 continuation_count += 1;
796 let status_msg = if in_progress_count > 0 {
797 format!(
798 "โ Response truncated. {} completed, {} in-progress. Auto-continuing ({}/{})...",
799 completed_count, in_progress_count, continuation_count, MAX_CONTINUATIONS
800 )
801 } else {
802 format!(
803 "โ Response truncated. {} tool calls completed. Auto-continuing ({}/{})...",
804 completed_count, continuation_count, MAX_CONTINUATIONS
805 )
806 };
807 eprintln!("{}", status_msg.yellow());
808
809 conversation_history.add_turn(
814 current_input.clone(),
815 format!("[Partial response - {} tools completed, {} in-progress before truncation. See continuation prompt for details.]",
816 completed_count, in_progress_count),
817 vec![] );
819
820 if conversation_history.needs_compaction() {
823 eprintln!("{}", " ๐ฆ Compacting history before continuation...".dimmed());
824 if let Some(summary) = conversation_history.compact() {
825 eprintln!("{}", format!(" โ Compressed {} turns", summary.matches("Turn").count()).dimmed());
826 }
827 }
828
829 current_input = build_continuation_prompt(&input, &completed_tools, &agent_thinking);
831
832 eprintln!("{}", format!(
834 " โ Continuing with {} files read, {} written, {} other actions tracked",
835 completed_tools.iter().filter(|t| t.tool_name == "read_file").count(),
836 completed_tools.iter().filter(|t| t.tool_name == "write_file" || t.tool_name == "write_files").count(),
837 completed_tools.iter().filter(|t| t.tool_name != "read_file" && t.tool_name != "write_file" && t.tool_name != "write_files" && t.tool_name != "list_directory").count()
838 ).dimmed());
839
840 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
842 } else if retry_attempt < MAX_RETRIES {
844 retry_attempt += 1;
846 eprintln!("{}", format!("โ Response error (attempt {}/{}). Retrying...", retry_attempt, MAX_RETRIES).yellow());
847 tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
848 } else {
849 eprintln!("{}", format!("Error: {}", e).red());
851 if continuation_count >= MAX_CONTINUATIONS {
852 eprintln!("{}", format!("Max continuations ({}) reached. The task is too complex for one request.", MAX_CONTINUATIONS).dimmed());
853 } else {
854 eprintln!("{}", "Max retries reached. The response may be too complex.".dimmed());
855 }
856 eprintln!("{}", "Try breaking your request into smaller parts.".dimmed());
857 break;
858 }
859 } else if err_str.contains("timeout") || err_str.contains("Timeout") {
860 retry_attempt += 1;
862 if retry_attempt < MAX_RETRIES {
863 eprintln!("{}", format!("โ Request timed out (attempt {}/{}). Retrying...", retry_attempt, MAX_RETRIES).yellow());
864 tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
865 } else {
866 eprintln!("{}", "Request timed out. Please try again.".red());
867 break;
868 }
869 } else {
870 eprintln!("{}", format!("Error: {}", e).red());
872 if continuation_count > 0 {
873 eprintln!("{}", format!(" (occurred during continuation attempt {})", continuation_count).dimmed());
874 }
875 eprintln!("{}", "Error details for debugging:".dimmed());
876 eprintln!("{}", format!(" - retry_attempt: {}/{}", retry_attempt, MAX_RETRIES).dimmed());
877 eprintln!("{}", format!(" - continuation_count: {}/{}", continuation_count, MAX_CONTINUATIONS).dimmed());
878 break;
879 }
880 }
881 }
882 }
883 println!();
884 }
885
886 Ok(())
887}
888
889async fn extract_tool_calls_from_hook(hook: &ToolDisplayHook) -> Vec<ToolCallRecord> {
891 let state = hook.state();
892 let guard = state.lock().await;
893
894 guard.tool_calls.iter().enumerate().map(|(i, tc)| {
895 let result = if tc.is_running {
896 "[IN PROGRESS - may need to be re-run]".to_string()
898 } else if let Some(output) = &tc.output {
899 truncate_string(output, 200)
900 } else {
901 "completed".to_string()
902 };
903
904 ToolCallRecord {
905 tool_name: tc.name.clone(),
906 args_summary: truncate_string(&tc.args, 100),
907 result_summary: result,
908 tool_id: Some(format!("tool_{}_{}", tc.name, i)),
910 droppable: matches!(
912 tc.name.as_str(),
913 "read_file" | "list_directory" | "analyze_project"
914 ),
915 }
916 }).collect()
917}
918
919async fn extract_agent_messages_from_hook(hook: &ToolDisplayHook) -> Vec<String> {
921 let state = hook.state();
922 let guard = state.lock().await;
923 guard.agent_messages.clone()
924}
925
926fn truncate_string(s: &str, max_len: usize) -> String {
928 if s.len() <= max_len {
929 s.to_string()
930 } else {
931 format!("{}...", &s[..max_len.saturating_sub(3)])
932 }
933}
934
935fn estimate_raw_history_tokens(messages: &[rig::completion::Message]) -> usize {
939 use rig::completion::message::{AssistantContent, UserContent};
940
941 messages.iter().map(|msg| -> usize {
942 match msg {
943 rig::completion::Message::User { content } => {
944 content.iter().map(|c| -> usize {
945 match c {
946 UserContent::Text(t) => t.text.len() / 4,
947 _ => 100, }
949 }).sum::<usize>()
950 }
951 rig::completion::Message::Assistant { content, .. } => {
952 content.iter().map(|c| -> usize {
953 match c {
954 AssistantContent::Text(t) => t.text.len() / 4,
955 AssistantContent::ToolCall(tc) => {
956 let args_len = tc.function.arguments.to_string().len();
958 (tc.function.name.len() + args_len) / 4
959 }
960 _ => 100,
961 }
962 }).sum::<usize>()
963 }
964 }
965 }).sum()
966}
967
968fn find_plan_create_call(tool_calls: &[ToolCallRecord]) -> Option<(String, usize)> {
971 for tc in tool_calls {
972 if tc.tool_name == "plan_create" {
973 let plan_path = if let Ok(result) = serde_json::from_str::<serde_json::Value>(&tc.result_summary) {
976 result.get("plan_path")
977 .and_then(|v| v.as_str())
978 .map(|s| s.to_string())
979 } else {
980 None
981 };
982
983 let plan_path = plan_path.unwrap_or_else(|| {
986 find_most_recent_plan_file().unwrap_or_else(|| "plans/plan.md".to_string())
987 });
988
989 let task_count = count_tasks_in_plan_file(&plan_path).unwrap_or(0);
991
992 return Some((plan_path, task_count));
993 }
994 }
995 None
996}
997
998fn find_most_recent_plan_file() -> Option<String> {
1000 let plans_dir = std::env::current_dir().ok()?.join("plans");
1001 if !plans_dir.exists() {
1002 return None;
1003 }
1004
1005 let mut newest: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1006
1007 for entry in std::fs::read_dir(&plans_dir).ok()?.flatten() {
1008 let path = entry.path();
1009 if path.extension().map(|e| e == "md").unwrap_or(false) {
1010 if let Ok(metadata) = entry.metadata() {
1011 if let Ok(modified) = metadata.modified() {
1012 if newest.as_ref().map(|(_, t)| modified > *t).unwrap_or(true) {
1013 newest = Some((path, modified));
1014 }
1015 }
1016 }
1017 }
1018 }
1019
1020 newest.map(|(path, _)| {
1021 path.strip_prefix(std::env::current_dir().unwrap_or_default())
1023 .map(|p| p.display().to_string())
1024 .unwrap_or_else(|_| path.display().to_string())
1025 })
1026}
1027
1028fn count_tasks_in_plan_file(plan_path: &str) -> Option<usize> {
1030 use regex::Regex;
1031
1032 let path = std::path::Path::new(plan_path);
1034 let content = if path.exists() {
1035 std::fs::read_to_string(path).ok()?
1036 } else {
1037 std::fs::read_to_string(std::env::current_dir().ok()?.join(plan_path)).ok()?
1039 };
1040
1041 let task_regex = Regex::new(r"^\s*-\s*\[[ x~!]\]").ok()?;
1043 let count = content.lines()
1044 .filter(|line| task_regex.is_match(line))
1045 .count();
1046
1047 Some(count)
1048}
1049
1050fn is_truncation_error(err_str: &str) -> bool {
1052 err_str.contains("JsonError")
1053 || err_str.contains("EOF while parsing")
1054 || err_str.contains("JSON")
1055 || err_str.contains("unexpected end")
1056}
1057
1058fn is_input_too_long_error(err_str: &str) -> bool {
1062 err_str.contains("too long")
1063 || err_str.contains("Too long")
1064 || err_str.contains("context length")
1065 || err_str.contains("maximum context")
1066 || err_str.contains("exceeds the model")
1067 || err_str.contains("Input is too long")
1068}
1069
1070fn build_continuation_prompt(
1073 original_task: &str,
1074 completed_tools: &[ToolCallRecord],
1075 agent_thinking: &[String],
1076) -> String {
1077 use std::collections::HashSet;
1078
1079 let mut files_read: HashSet<String> = HashSet::new();
1081 let mut files_written: HashSet<String> = HashSet::new();
1082 let mut dirs_listed: HashSet<String> = HashSet::new();
1083 let mut other_tools: Vec<String> = Vec::new();
1084 let mut in_progress: Vec<String> = Vec::new();
1085
1086 for tool in completed_tools {
1087 let is_in_progress = tool.result_summary.contains("IN PROGRESS");
1088
1089 if is_in_progress {
1090 in_progress.push(format!("{}({})", tool.tool_name, tool.args_summary));
1091 continue;
1092 }
1093
1094 match tool.tool_name.as_str() {
1095 "read_file" => {
1096 files_read.insert(tool.args_summary.clone());
1098 }
1099 "write_file" | "write_files" => {
1100 files_written.insert(tool.args_summary.clone());
1101 }
1102 "list_directory" => {
1103 dirs_listed.insert(tool.args_summary.clone());
1104 }
1105 _ => {
1106 other_tools.push(format!("{}({})", tool.tool_name, truncate_string(&tool.args_summary, 40)));
1107 }
1108 }
1109 }
1110
1111 let mut prompt = format!(
1112 "[CONTINUE] Your previous response was interrupted. DO NOT repeat completed work.\n\n\
1113 Original task: {}\n",
1114 truncate_string(original_task, 500)
1115 );
1116
1117 if !files_read.is_empty() {
1119 prompt.push_str("\n== FILES ALREADY READ (do NOT read again) ==\n");
1120 for file in &files_read {
1121 prompt.push_str(&format!(" - {}\n", file));
1122 }
1123 }
1124
1125 if !dirs_listed.is_empty() {
1126 prompt.push_str("\n== DIRECTORIES ALREADY LISTED ==\n");
1127 for dir in &dirs_listed {
1128 prompt.push_str(&format!(" - {}\n", dir));
1129 }
1130 }
1131
1132 if !files_written.is_empty() {
1133 prompt.push_str("\n== FILES ALREADY WRITTEN ==\n");
1134 for file in &files_written {
1135 prompt.push_str(&format!(" - {}\n", file));
1136 }
1137 }
1138
1139 if !other_tools.is_empty() {
1140 prompt.push_str("\n== OTHER COMPLETED ACTIONS ==\n");
1141 for tool in other_tools.iter().take(20) {
1142 prompt.push_str(&format!(" - {}\n", tool));
1143 }
1144 if other_tools.len() > 20 {
1145 prompt.push_str(&format!(" ... and {} more\n", other_tools.len() - 20));
1146 }
1147 }
1148
1149 if !in_progress.is_empty() {
1150 prompt.push_str("\n== INTERRUPTED (may need re-run) ==\n");
1151 for tool in &in_progress {
1152 prompt.push_str(&format!(" โ {}\n", tool));
1153 }
1154 }
1155
1156 if !agent_thinking.is_empty() {
1158 if let Some(last_thought) = agent_thinking.last() {
1159 prompt.push_str(&format!(
1160 "\n== YOUR LAST THOUGHTS ==\n\"{}\"\n",
1161 truncate_string(last_thought, 300)
1162 ));
1163 }
1164 }
1165
1166 prompt.push_str("\n== INSTRUCTIONS ==\n");
1167 prompt.push_str("IMPORTANT: Your previous response was too long and got cut off.\n");
1168 prompt.push_str("1. Do NOT re-read files listed above - they are already in context.\n");
1169 prompt.push_str("2. If writing a document, write it in SECTIONS - complete one section now, then continue.\n");
1170 prompt.push_str("3. Keep your response SHORT and focused. Better to complete small chunks than fail on large ones.\n");
1171 prompt.push_str("4. If the task involves writing a file, START WRITING NOW - don't explain what you'll do.\n");
1172
1173 prompt
1174}
1175
1176pub async fn run_query(
1178 project_path: &Path,
1179 query: &str,
1180 provider: ProviderType,
1181 model: Option<String>,
1182) -> AgentResult<String> {
1183 use tools::*;
1184
1185 let project_path_buf = project_path.to_path_buf();
1186 let preamble = get_system_prompt(project_path, Some(query), PlanMode::default());
1189 let is_generation = prompts::is_generation_query(query);
1190
1191 match provider {
1192 ProviderType::OpenAI => {
1193 let client = openai::Client::from_env();
1194 let model_name = model.as_deref().unwrap_or("gpt-5.2");
1195
1196 let reasoning_params = if model_name.starts_with("gpt-5") || model_name.starts_with("o1") {
1198 Some(serde_json::json!({
1199 "reasoning": {
1200 "effort": "medium",
1201 "summary": "detailed"
1202 }
1203 }))
1204 } else {
1205 None
1206 };
1207
1208 let mut builder = client
1209 .agent(model_name)
1210 .preamble(&preamble)
1211 .max_tokens(4096)
1212 .tool(AnalyzeTool::new(project_path_buf.clone()))
1213 .tool(SecurityScanTool::new(project_path_buf.clone()))
1214 .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1215 .tool(HadolintTool::new(project_path_buf.clone()))
1216 .tool(TerraformFmtTool::new(project_path_buf.clone()))
1217 .tool(TerraformValidateTool::new(project_path_buf.clone()))
1218 .tool(TerraformInstallTool::new())
1219 .tool(ReadFileTool::new(project_path_buf.clone()))
1220 .tool(ListDirectoryTool::new(project_path_buf.clone()));
1221
1222 if is_generation {
1224 builder = builder
1225 .tool(WriteFileTool::new(project_path_buf.clone()))
1226 .tool(WriteFilesTool::new(project_path_buf.clone()))
1227 .tool(ShellTool::new(project_path_buf.clone()));
1228 }
1229
1230 if let Some(params) = reasoning_params {
1231 builder = builder.additional_params(params);
1232 }
1233
1234 let agent = builder.build();
1235
1236 agent
1237 .prompt(query)
1238 .multi_turn(50)
1239 .await
1240 .map_err(|e| AgentError::ProviderError(e.to_string()))
1241 }
1242 ProviderType::Anthropic => {
1243 let client = anthropic::Client::from_env();
1244 let model_name = model.as_deref().unwrap_or("claude-sonnet-4-5-20250929");
1245
1246 let mut builder = client
1251 .agent(model_name)
1252 .preamble(&preamble)
1253 .max_tokens(4096)
1254 .tool(AnalyzeTool::new(project_path_buf.clone()))
1255 .tool(SecurityScanTool::new(project_path_buf.clone()))
1256 .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1257 .tool(HadolintTool::new(project_path_buf.clone()))
1258 .tool(TerraformFmtTool::new(project_path_buf.clone()))
1259 .tool(TerraformValidateTool::new(project_path_buf.clone()))
1260 .tool(TerraformInstallTool::new())
1261 .tool(ReadFileTool::new(project_path_buf.clone()))
1262 .tool(ListDirectoryTool::new(project_path_buf.clone()));
1263
1264 if is_generation {
1266 builder = builder
1267 .tool(WriteFileTool::new(project_path_buf.clone()))
1268 .tool(WriteFilesTool::new(project_path_buf.clone()))
1269 .tool(ShellTool::new(project_path_buf.clone()));
1270 }
1271
1272 let agent = builder.build();
1273
1274 agent
1275 .prompt(query)
1276 .multi_turn(50)
1277 .await
1278 .map_err(|e| AgentError::ProviderError(e.to_string()))
1279 }
1280 ProviderType::Bedrock => {
1281 let client = rig_bedrock::client::Client::from_env();
1283 let model_name = model.as_deref().unwrap_or("global.anthropic.claude-sonnet-4-5-20250929-v1:0");
1284
1285 let thinking_params = serde_json::json!({
1287 "thinking": {
1288 "type": "enabled",
1289 "budget_tokens": 16000
1290 }
1291 });
1292
1293 let mut builder = client
1294 .agent(model_name)
1295 .preamble(&preamble)
1296 .max_tokens(64000) .tool(AnalyzeTool::new(project_path_buf.clone()))
1298 .tool(SecurityScanTool::new(project_path_buf.clone()))
1299 .tool(VulnerabilitiesTool::new(project_path_buf.clone()))
1300 .tool(HadolintTool::new(project_path_buf.clone()))
1301 .tool(TerraformFmtTool::new(project_path_buf.clone()))
1302 .tool(TerraformValidateTool::new(project_path_buf.clone()))
1303 .tool(TerraformInstallTool::new())
1304 .tool(ReadFileTool::new(project_path_buf.clone()))
1305 .tool(ListDirectoryTool::new(project_path_buf.clone()));
1306
1307 if is_generation {
1309 builder = builder
1310 .tool(WriteFileTool::new(project_path_buf.clone()))
1311 .tool(WriteFilesTool::new(project_path_buf.clone()))
1312 .tool(ShellTool::new(project_path_buf.clone()));
1313 }
1314
1315 let agent = builder
1316 .additional_params(thinking_params)
1317 .build();
1318
1319 agent
1320 .prompt(query)
1321 .multi_turn(50)
1322 .await
1323 .map_err(|e| AgentError::ProviderError(e.to_string()))
1324 }
1325 }
1326}