vtcode_core/core/agent/runner.rs
1//! Agent runner for executing individual agent instances
2
3use crate::config::VTCodeConfig;
4use crate::config::constants::tools;
5use crate::config::loader::ConfigManager;
6use crate::config::models::{ModelId, Provider as ModelProvider};
7use crate::config::types::ReasoningEffortLevel;
8use crate::core::agent::types::AgentType;
9use crate::gemini::{Content, Part, Tool};
10use crate::llm::factory::create_provider_for_model;
11use crate::llm::provider as uni_provider;
12use crate::llm::provider::{FunctionDefinition, LLMRequest, Message, MessageRole, ToolDefinition};
13use crate::llm::{AnyClient, make_client};
14use crate::mcp_client::McpClient;
15use crate::tools::{ToolRegistry, build_function_declarations};
16use anyhow::{Result, anyhow};
17use console::style;
18use serde::{Deserialize, Serialize};
19use serde_json::Value;
20use std::path::PathBuf;
21use std::sync::Arc;
22use tokio::time::{Duration, timeout};
23use tracing::{info, warn};
24
25/// Individual agent runner for executing specialized agent tasks
26pub struct AgentRunner {
27 /// Agent type and configuration
28 agent_type: AgentType,
29 /// LLM client for this agent
30 client: AnyClient,
31 /// Unified provider client (OpenAI/Anthropic/Gemini) for tool-calling
32 provider_client: Box<dyn uni_provider::LLMProvider>,
33 /// Tool registry with restricted access
34 tool_registry: ToolRegistry,
35 /// System prompt content
36 system_prompt: String,
37 /// Session information
38 _session_id: String,
39 /// Workspace path
40 _workspace: PathBuf,
41 /// Model identifier
42 model: String,
43 /// API key (for provider client construction in future flows)
44 _api_key: String,
45 /// Reasoning effort level for models that support it
46 reasoning_effort: Option<ReasoningEffortLevel>,
47}
48
49impl AgentRunner {
50 fn print_compact_response(agent: AgentType, text: &str) {
51 use console::style;
52 const MAX_CHARS: usize = 1200;
53 const HEAD_CHARS: usize = 800;
54 const TAIL_CHARS: usize = 200;
55 let clean = text.trim();
56 if clean.chars().count() <= MAX_CHARS {
57 println!(
58 "{} [{}]: {}",
59 style("[RESPONSE]").cyan().bold(),
60 agent,
61 clean
62 );
63 return;
64 }
65 let mut out = String::new();
66 let mut count = 0;
67 for ch in clean.chars() {
68 if count >= HEAD_CHARS {
69 break;
70 }
71 out.push(ch);
72 count += 1;
73 }
74 out.push_str("\n…\n");
75 // tail
76 let total = clean.chars().count();
77 let start_tail = total.saturating_sub(TAIL_CHARS);
78 let tail: String = clean.chars().skip(start_tail).collect();
79 out.push_str(&tail);
80 println!("{} [{}]: {}", style("[RESPONSE]").cyan().bold(), agent, out);
81 println!(
82 "{} truncated long response ({} chars).",
83 style("[NOTE]").dim(),
84 total
85 );
86 }
87 /// Create informative progress message based on operation type
88 fn create_progress_message(&self, operation: &str, details: Option<&str>) -> String {
89 match operation {
90 "thinking" => "Analyzing request and planning approach...".to_string(),
91 "processing" => format!("Processing turn with {} model", self.client.model_id()),
92 "tool_call" => {
93 if let Some(tool) = details {
94 format!("Executing {} tool for task completion", tool)
95 } else {
96 "Executing tool to gather information".to_string()
97 }
98 }
99 "file_read" => {
100 if let Some(file) = details {
101 format!("Reading {} to understand structure", file)
102 } else {
103 "Reading file to analyze content".to_string()
104 }
105 }
106 "file_write" => {
107 if let Some(file) = details {
108 format!("Writing changes to {}", file)
109 } else {
110 "Writing file with requested changes".to_string()
111 }
112 }
113 "search" => {
114 if let Some(pattern) = details {
115 format!("Searching codebase for '{}'", pattern)
116 } else {
117 "Searching codebase for relevant information".to_string()
118 }
119 }
120 "terminal" => {
121 if let Some(cmd) = details {
122 format!(
123 "Running terminal command: {}",
124 cmd.split(' ').next().unwrap_or(cmd)
125 )
126 } else {
127 "Executing terminal command".to_string()
128 }
129 }
130 "completed" => "Task completed successfully!".to_string(),
131 "error" => {
132 if let Some(err) = details {
133 format!("Error encountered: {}", err)
134 } else {
135 "An error occurred during execution".to_string()
136 }
137 }
138 _ => format!("{}...", operation),
139 }
140 }
141
142 /// Create a new agent runner
143 pub fn new(
144 agent_type: AgentType,
145 model: ModelId,
146 api_key: String,
147 workspace: PathBuf,
148 session_id: String,
149 reasoning_effort: Option<ReasoningEffortLevel>,
150 ) -> Result<Self> {
151 // Create client based on model
152 let client: AnyClient = make_client(api_key.clone(), model.clone());
153
154 // Create unified provider client for tool calling
155 let provider_client = create_provider_for_model(model.as_str(), api_key.clone(), None)
156 .map_err(|e| anyhow!("Failed to create provider client: {}", e))?;
157
158 // Create system prompt for single agent
159 let system_prompt = crate::prompts::read_system_prompt_from_md()
160 .unwrap_or_else(|_| crate::prompts::system::default_system_prompt().to_string());
161
162 Ok(Self {
163 agent_type,
164 client,
165 provider_client,
166 tool_registry: ToolRegistry::new(workspace.clone()),
167 system_prompt,
168 _session_id: session_id,
169 _workspace: workspace,
170 model: model.as_str().to_string(),
171 _api_key: api_key,
172 reasoning_effort,
173 })
174 }
175
176 /// Enable full-auto execution with the provided allow-list.
177 pub fn enable_full_auto(&mut self, allowed_tools: &[String]) {
178 self.tool_registry.enable_full_auto_mode(allowed_tools);
179 }
180
181 /// Apply workspace configuration to the tool registry, including tool policies and MCP setup.
182 pub async fn apply_workspace_configuration(&mut self, vt_cfg: &VTCodeConfig) -> Result<()> {
183 self.tool_registry.initialize_async().await?;
184
185 if let Err(err) = self.tool_registry.apply_config_policies(&vt_cfg.tools) {
186 eprintln!(
187 "Warning: Failed to apply tool policies from config: {}",
188 err
189 );
190 }
191
192 if vt_cfg.mcp.enabled {
193 let mut mcp_client = McpClient::new(vt_cfg.mcp.clone());
194 match timeout(Duration::from_secs(30), mcp_client.initialize()).await {
195 Ok(Ok(())) => {
196 let mcp_client = Arc::new(mcp_client);
197 self.tool_registry.set_mcp_client(Arc::clone(&mcp_client));
198 if let Err(err) = self.tool_registry.refresh_mcp_tools().await {
199 warn!("Failed to refresh MCP tools: {}", err);
200 }
201 }
202 Ok(Err(err)) => {
203 warn!("MCP client initialization failed: {}", err);
204 }
205 Err(_) => {
206 warn!("MCP client initialization timed out after 30 seconds");
207 }
208 }
209 }
210
211 Ok(())
212 }
213
214 /// Execute a task with this agent
215 pub async fn execute_task(
216 &mut self,
217 task: &Task,
218 contexts: &[ContextItem],
219 ) -> Result<TaskResults> {
220 // Agent execution status
221 let agent_prefix = format!("[{}]", self.agent_type);
222 println!(
223 "{} {}",
224 agent_prefix,
225 self.create_progress_message("thinking", None)
226 );
227
228 println!(
229 "{} Executing {} task: {}",
230 style("[AGENT]").blue().bold().on_black(),
231 self.agent_type,
232 task.title
233 );
234
235 // Prepare conversation with task context
236 let mut conversation = Vec::new();
237
238 // Add system instruction as the first message
239 let system_content = self.build_system_instruction(task, contexts)?;
240 conversation.push(Content::user_text(system_content));
241
242 // Add task description
243 conversation.push(Content::user_text(format!(
244 "Task: {}\nDescription: {}",
245 task.title, task.description
246 )));
247
248 if let Some(instructions) = task.instructions.as_ref() {
249 conversation.push(Content::user_text(instructions.clone()));
250 }
251
252 // Add context items if any
253 if !contexts.is_empty() {
254 let context_content: Vec<String> = contexts
255 .iter()
256 .map(|ctx| format!("Context [{}]: {}", ctx.id, ctx.content))
257 .collect();
258 conversation.push(Content::user_text(format!(
259 "Relevant Context:\n{}",
260 context_content.join("\n")
261 )));
262 }
263
264 // Build available tools for this agent
265 let gemini_tools = self.build_agent_tools()?;
266
267 // Convert Gemini tools to universal ToolDefinition format
268 let tools: Vec<ToolDefinition> = gemini_tools
269 .into_iter()
270 .flat_map(|tool| tool.function_declarations)
271 .map(|decl| ToolDefinition {
272 tool_type: "function".to_string(),
273 function: FunctionDefinition {
274 name: decl.name,
275 description: decl.description,
276 parameters: decl.parameters,
277 },
278 })
279 .collect();
280
281 // Track execution results
282 let created_contexts = Vec::new();
283 let mut modified_files = Vec::new();
284 let mut executed_commands = Vec::new();
285 let mut warnings = Vec::new();
286 let mut has_completed = false;
287
288 // Determine max loops via configuration
289 let cfg = ConfigManager::load()
290 .or_else(|_| ConfigManager::load_from_workspace("."))
291 .or_else(|_| ConfigManager::load_from_file("vtcode.toml"))
292 .map(|cm| cm.config().clone())
293 .unwrap_or_default();
294 let max_tool_loops = cfg.tools.max_tool_loops.max(1);
295
296 // Agent execution loop uses global tool loop guard
297 for turn in 0..max_tool_loops {
298 if has_completed {
299 break;
300 }
301
302 println!(
303 "{} {} is processing turn {}...",
304 agent_prefix,
305 style("(PROC)").yellow().bold(),
306 turn + 1
307 );
308
309 let request = LLMRequest {
310 messages: conversation
311 .iter()
312 .map(|content| {
313 // Convert Gemini Content to LLM Message
314 let role = match content.role.as_str() {
315 "user" => MessageRole::User,
316 "model" => MessageRole::Assistant,
317 _ => MessageRole::User,
318 };
319 let content_text = content
320 .parts
321 .iter()
322 .filter_map(|part| match part {
323 crate::gemini::Part::Text { text } => Some(text.clone()),
324 _ => None,
325 })
326 .collect::<Vec<_>>()
327 .join("\n");
328 Message {
329 role,
330 content: content_text,
331 tool_calls: None,
332 tool_call_id: None,
333 }
334 })
335 .collect(),
336 system_prompt: None,
337 tools: Some(tools.clone()),
338 model: self.model.clone(),
339 max_tokens: Some(2000),
340 temperature: Some(0.7),
341 stream: false,
342 tool_choice: None,
343 parallel_tool_calls: None,
344 parallel_tool_config: Some(
345 crate::llm::provider::ParallelToolConfig::anthropic_optimized(),
346 ),
347 reasoning_effort: if self.provider_client.supports_reasoning_effort(&self.model) {
348 self.reasoning_effort
349 } else {
350 None
351 },
352 };
353
354 // Use provider-specific client for OpenAI/Anthropic (and generic support for others)
355 // Prepare for provider-specific vs Gemini handling
356 #[allow(unused_assignments)]
357 let mut response_opt: Option<crate::llm::types::LLMResponse> = None;
358 let provider_kind = self
359 .model
360 .parse::<ModelId>()
361 .map(|m| m.provider())
362 .unwrap_or(ModelProvider::Gemini);
363
364 if matches!(
365 provider_kind,
366 ModelProvider::OpenAI | ModelProvider::Anthropic | ModelProvider::DeepSeek
367 ) {
368 let resp = self
369 .provider_client
370 .generate(request.clone())
371 .await
372 .map_err(|e| {
373 println!(
374 "{} {} Failed",
375 agent_prefix,
376 style("(ERROR)").red().bold().on_black()
377 );
378 anyhow!(
379 "Agent {} execution failed at turn {}: {}",
380 self.agent_type,
381 turn,
382 e
383 )
384 })?;
385
386 // Update progress for successful response
387 println!(
388 "{} {}",
389 agent_prefix,
390 format!(
391 "{} {} received response, processing...",
392 self.agent_type,
393 style("(RECV)").green().bold()
394 )
395 );
396
397 let mut had_tool_call = false;
398
399 if let Some(tool_calls) = resp.tool_calls.as_ref() {
400 if !tool_calls.is_empty() {
401 had_tool_call = true;
402 for call in tool_calls {
403 let name = call.function.name.as_str();
404 println!(
405 "{} [{}] Calling tool: {}",
406 style("[TOOL_CALL]").blue().bold(),
407 self.agent_type,
408 name
409 );
410 let args = call
411 .parsed_arguments()
412 .unwrap_or_else(|_| serde_json::json!({}));
413 match self.execute_tool(name, &args).await {
414 Ok(result) => {
415 println!(
416 "{} {}",
417 agent_prefix,
418 format!(
419 "{} {} tool executed successfully",
420 style("(OK)").green(),
421 name
422 )
423 );
424 let tool_result = serde_json::to_string(&result)?;
425 conversation.push(Content {
426 role: "user".to_string(),
427 parts: vec![Part::Text {
428 text: format!("Tool {} result: {}", name, tool_result),
429 }],
430 });
431 executed_commands.push(name.to_string());
432 if name == tools::WRITE_FILE {
433 if let Some(filepath) =
434 args.get("path").and_then(|p| p.as_str())
435 {
436 modified_files.push(filepath.to_string());
437 }
438 }
439 }
440 Err(e) => {
441 println!(
442 "{} {}",
443 agent_prefix,
444 format!(
445 "{} {} tool failed: {}",
446 style("(ERR)").red(),
447 name,
448 e
449 )
450 );
451 warnings.push(format!("Tool {} failed: {}", name, e));
452 conversation.push(Content {
453 role: "user".to_string(),
454 parts: vec![Part::Text {
455 text: format!("Tool {} failed: {}", name, e),
456 }],
457 });
458 }
459 }
460 }
461 }
462 }
463
464 // If no tool calls, treat as regular content
465 let response_text = resp.content.clone().unwrap_or_default();
466 if !had_tool_call {
467 if !response_text.trim().is_empty() {
468 Self::print_compact_response(self.agent_type, &response_text);
469 conversation.push(Content {
470 role: "model".to_string(),
471 parts: vec![Part::Text {
472 text: response_text.clone(),
473 }],
474 });
475 }
476 }
477
478 // Completion detection
479 if !has_completed {
480 let response_lower = response_text.to_lowercase();
481 let completion_indicators = [
482 "task completed",
483 "task done",
484 "finished",
485 "complete",
486 "summary",
487 "i have successfully",
488 "i've completed",
489 "i have finished",
490 "task accomplished",
491 "mission accomplished",
492 "objective achieved",
493 "work is done",
494 "all done",
495 "completed successfully",
496 "task execution complete",
497 "operation finished",
498 ];
499 let is_completed = completion_indicators
500 .iter()
501 .any(|&indicator| response_lower.contains(indicator));
502 let has_explicit_completion = response_lower.contains("the task is complete")
503 || response_lower.contains("task has been completed")
504 || response_lower.contains("i am done")
505 || response_lower.contains("that's all")
506 || response_lower.contains("no more actions needed");
507 if is_completed || has_explicit_completion {
508 has_completed = true;
509 println!(
510 "{} {}",
511 agent_prefix,
512 format!(
513 "{} {} completed task successfully",
514 self.agent_type,
515 style("(SUCCESS)").green().bold()
516 )
517 );
518 }
519 }
520
521 let should_continue = had_tool_call || (!has_completed && turn < 9);
522 if !should_continue {
523 if has_completed {
524 println!(
525 "{} {}",
526 agent_prefix,
527 format!(
528 "{} {} finished - task completed",
529 self.agent_type,
530 style("(SUCCESS)").green().bold()
531 )
532 );
533 } else if turn >= 9 {
534 println!(
535 "{} {}",
536 agent_prefix,
537 format!(
538 "{} {} finished - maximum turns reached",
539 self.agent_type,
540 style("(TIME)").yellow().bold()
541 )
542 );
543 } else {
544 println!(
545 "{} {}",
546 agent_prefix,
547 format!(
548 "{} {} finished",
549 self.agent_type,
550 style("(FINISH)").blue().bold()
551 )
552 );
553 }
554 break;
555 }
556
557 // Continue loop for tool results
558 continue;
559 } else {
560 // Gemini path (existing flow)
561 let response = self
562 .client
563 .generate(&serde_json::to_string(&request)?)
564 .await
565 .map_err(|e| {
566 println!(
567 "{} {} Failed",
568 agent_prefix,
569 style("(ERROR)").red().bold().on_black()
570 );
571 anyhow!(
572 "Agent {} execution failed at turn {}: {}",
573 self.agent_type,
574 turn,
575 e
576 )
577 })?;
578 response_opt = Some(response);
579 }
580
581 // For Gemini path: use original response handling
582 let response = response_opt.expect("response should be set for Gemini path");
583
584 // Update progress for successful response
585 println!(
586 "{} {}",
587 agent_prefix,
588 format!(
589 "{} {} received response, processing...",
590 self.agent_type,
591 style("(RECV)").green().bold()
592 )
593 );
594
595 // Use response content directly
596 if !response.content.is_empty() {
597 // Try to parse the response as JSON to check for tool calls
598 let mut had_tool_call = false;
599
600 // Try to parse as a tool call response
601 if let Ok(tool_call_response) = serde_json::from_str::<Value>(&response.content) {
602 // Check for standard tool_calls format
603 if let Some(tool_calls) = tool_call_response
604 .get("tool_calls")
605 .and_then(|tc| tc.as_array())
606 {
607 had_tool_call = true;
608
609 // Process each tool call
610 for tool_call in tool_calls {
611 if let Some(function) = tool_call.get("function") {
612 if let (Some(name), Some(arguments)) = (
613 function.get("name").and_then(|n| n.as_str()),
614 function.get("arguments"),
615 ) {
616 println!(
617 "{} [{}] Calling tool: {}",
618 style("[TOOL_CALL]").blue().bold(),
619 self.agent_type,
620 name
621 );
622
623 // Execute the tool
624 match self.execute_tool(name, &arguments.clone()).await {
625 Ok(result) => {
626 println!(
627 "{} {}",
628 agent_prefix,
629 format!(
630 "{} {} tool executed successfully",
631 style("(OK)").green(),
632 name
633 )
634 );
635
636 // Add tool result to conversation
637 let tool_result = serde_json::to_string(&result)?;
638 conversation.push(Content {
639 role: "user".to_string(), // Gemini API only accepts "user" and "model"
640 parts: vec![Part::Text {
641 text: format!(
642 "Tool {} result: {}",
643 name, tool_result
644 ),
645 }],
646 });
647
648 // Track what the agent did
649 executed_commands.push(name.to_string());
650
651 // Special handling for certain tools
652 if name == tools::WRITE_FILE {
653 if let Some(filepath) =
654 arguments.get("path").and_then(|p| p.as_str())
655 {
656 modified_files.push(filepath.to_string());
657 }
658 }
659 }
660 Err(e) => {
661 println!(
662 "{} {}",
663 agent_prefix,
664 format!(
665 "{} {} tool failed: {}",
666 style("(ERR)").red(),
667 name,
668 e
669 )
670 );
671 warnings.push(format!("Tool {} failed: {}", name, e));
672 conversation.push(Content {
673 role: "user".to_string(), // Gemini API only accepts "user" and "model"
674 parts: vec![Part::Text {
675 text: format!("Tool {} failed: {}", name, e),
676 }],
677 });
678 }
679 }
680 }
681 }
682 }
683 }
684 // Check for Gemini functionCall format
685 else if let Some(function_call) = tool_call_response.get("functionCall") {
686 had_tool_call = true;
687
688 if let (Some(name), Some(args)) = (
689 function_call.get("name").and_then(|n| n.as_str()),
690 function_call.get("args"),
691 ) {
692 println!(
693 "{} [{}] Calling tool: {}",
694 style("[TOOL_CALL]").blue().bold(),
695 self.agent_type,
696 name
697 );
698
699 // Execute the tool
700 match self.execute_tool(name, args).await {
701 Ok(result) => {
702 println!(
703 "{} {}",
704 agent_prefix,
705 format!(
706 "{} {} tool executed successfully",
707 style("(OK)").green(),
708 name
709 )
710 );
711
712 // Add tool result to conversation
713 let tool_result = serde_json::to_string(&result)?;
714 conversation.push(Content {
715 role: "user".to_string(), // Gemini API only accepts "user" and "model"
716 parts: vec![Part::Text {
717 text: format!("Tool {} result: {}", name, tool_result),
718 }],
719 });
720
721 // Track what the agent did
722 executed_commands.push(name.to_string());
723
724 // Special handling for certain tools
725 if name == tools::WRITE_FILE {
726 if let Some(filepath) =
727 args.get("path").and_then(|p| p.as_str())
728 {
729 modified_files.push(filepath.to_string());
730 }
731 }
732 }
733 Err(e) => {
734 println!(
735 "{} {}",
736 agent_prefix,
737 format!(
738 "{} {} tool failed: {}",
739 style("(ERR)").red().bold(),
740 name,
741 e
742 )
743 );
744 warnings.push(format!("Tool {} failed: {}", name, e));
745 conversation.push(Content {
746 role: "user".to_string(), // Gemini API only accepts "user" and "model"
747 parts: vec![Part::Text {
748 text: format!("Tool {} failed: {}", name, e),
749 }],
750 });
751 }
752 }
753 }
754 }
755 // Check for tool_code format (what agents are actually producing)
756 else if let Some(tool_code) = tool_call_response
757 .get("tool_code")
758 .and_then(|tc| tc.as_str())
759 {
760 had_tool_call = true;
761
762 println!(
763 "{} [{}] Executing tool code: {}",
764 style("[TOOL_EXEC]").cyan().bold().on_black(),
765 self.agent_type,
766 tool_code
767 );
768
769 // Try to parse the tool_code as a function call
770 // This is a simplified parser for the format: function_name(args)
771 if let Some((func_name, args_str)) = parse_tool_code(tool_code) {
772 println!(
773 "{} [{}] Parsed tool: {} with args: {}",
774 style("[TOOL_PARSE]").yellow().bold().on_black(),
775 self.agent_type,
776 func_name,
777 args_str
778 );
779
780 // Parse arguments as JSON
781 match serde_json::from_str::<Value>(&args_str) {
782 Ok(arguments) => {
783 // Execute the tool
784 match self.execute_tool(&func_name, &arguments).await {
785 Ok(result) => {
786 println!(
787 "{} {}",
788 agent_prefix,
789 format!(
790 "{} {} tool executed successfully",
791 style("(OK)").green(),
792 func_name
793 )
794 );
795
796 // Add tool result to conversation
797 let tool_result = serde_json::to_string(&result)?;
798 conversation.push(Content {
799 role: "user".to_string(), // Gemini API only accepts "user" and "model"
800 parts: vec![Part::Text {
801 text: format!(
802 "Tool {} result: {}",
803 func_name, tool_result
804 ),
805 }],
806 });
807
808 // Track what the agent did
809 executed_commands.push(func_name.to_string());
810
811 // Special handling for certain tools
812 if func_name == tools::WRITE_FILE {
813 if let Some(filepath) =
814 arguments.get("path").and_then(|p| p.as_str())
815 {
816 modified_files.push(filepath.to_string());
817 }
818 }
819 }
820 Err(e) => {
821 println!(
822 "{} {}",
823 agent_prefix,
824 format!(
825 "{} {} tool failed: {}",
826 style("(ERROR)").red().bold(),
827 func_name,
828 e
829 )
830 );
831 warnings
832 .push(format!("Tool {} failed: {}", func_name, e));
833 conversation.push(Content {
834 role: "user".to_string(), // Gemini API only accepts "user" and "model"
835 parts: vec![Part::Text {
836 text: format!(
837 "Tool {} failed: {}",
838 func_name, e
839 ),
840 }],
841 });
842 }
843 }
844 }
845 Err(e) => {
846 let error_msg = format!(
847 "Failed to parse tool arguments '{}': {}",
848 args_str, e
849 );
850 warnings.push(error_msg.clone());
851 conversation.push(Content {
852 role: "user".to_string(), // Gemini API only accepts "user" and "model"
853 parts: vec![Part::Text { text: error_msg }],
854 });
855 }
856 }
857 } else {
858 let error_msg = format!("Failed to parse tool code: {}", tool_code);
859 warnings.push(error_msg.clone());
860 conversation.push(Content {
861 role: "user".to_string(), // Gemini API only accepts "user" and "model"
862 parts: vec![Part::Text { text: error_msg }],
863 });
864 }
865 }
866 // Check for tool_name format (alternative format)
867 else if let Some(tool_name) = tool_call_response
868 .get("tool_name")
869 .and_then(|tn| tn.as_str())
870 {
871 had_tool_call = true;
872
873 println!(
874 "{} [{}] Calling tool: {}",
875 style("[TOOL_CALL]").blue().bold().on_black(),
876 self.agent_type,
877 tool_name
878 );
879
880 if let Some(parameters) = tool_call_response.get("parameters") {
881 // Execute the tool
882 match self.execute_tool(tool_name, parameters).await {
883 Ok(result) => {
884 println!(
885 "{} {}",
886 agent_prefix,
887 format!(
888 "{} {} tool executed successfully",
889 style("(SUCCESS)").green().bold(),
890 tool_name
891 )
892 );
893
894 // Add tool result to conversation
895 let tool_result = serde_json::to_string(&result)?;
896 conversation.push(Content {
897 role: "user".to_string(), // Gemini API only accepts "user" and "model"
898 parts: vec![Part::Text {
899 text: format!(
900 "Tool {} result: {}",
901 tool_name, tool_result
902 ),
903 }],
904 });
905
906 // Track what the agent did
907 executed_commands.push(tool_name.to_string());
908
909 // Special handling for certain tools
910 if tool_name == tools::WRITE_FILE {
911 if let Some(filepath) =
912 parameters.get("path").and_then(|p| p.as_str())
913 {
914 modified_files.push(filepath.to_string());
915 }
916 }
917 }
918 Err(e) => {
919 println!(
920 "{} {}",
921 agent_prefix,
922 format!(
923 "{} {} tool failed: {}",
924 style("(ERROR)").red().bold(),
925 tool_name,
926 e
927 )
928 );
929 warnings.push(format!("Tool {} failed: {}", tool_name, e));
930 conversation.push(Content {
931 role: "user".to_string(), // Gemini API only accepts "user" and "model"
932 parts: vec![Part::Text {
933 text: format!("Tool {} failed: {}", tool_name, e),
934 }],
935 });
936 }
937 }
938 }
939 } else {
940 // Regular content response
941 Self::print_compact_response(self.agent_type, response.content.trim());
942 conversation.push(Content {
943 role: "model".to_string(),
944 parts: vec![Part::Text {
945 text: response.content.clone(),
946 }],
947 });
948 }
949 } else {
950 // Regular text response
951 Self::print_compact_response(self.agent_type, response.content.trim());
952 conversation.push(Content {
953 role: "model".to_string(),
954 parts: vec![Part::Text {
955 text: response.content.clone(),
956 }],
957 });
958 }
959
960 // Check for task completion indicators in the response
961 if !has_completed {
962 let response_lower = response.content.to_lowercase();
963
964 // More comprehensive completion detection
965 let completion_indicators = [
966 "task completed",
967 "task done",
968 "finished",
969 "complete",
970 "summary",
971 "i have successfully",
972 "i've completed",
973 "i have finished",
974 "task accomplished",
975 "mission accomplished",
976 "objective achieved",
977 "work is done",
978 "all done",
979 "completed successfully",
980 "task execution complete",
981 "operation finished",
982 ];
983
984 // Check if any completion indicator is present
985 let is_completed = completion_indicators
986 .iter()
987 .any(|&indicator| response_lower.contains(indicator));
988
989 // Also check for explicit completion statements
990 let has_explicit_completion = response_lower.contains("the task is complete")
991 || response_lower.contains("task has been completed")
992 || response_lower.contains("i am done")
993 || response_lower.contains("that's all")
994 || response_lower.contains("no more actions needed");
995
996 if is_completed || has_explicit_completion {
997 has_completed = true;
998 println!(
999 "{} {}",
1000 agent_prefix,
1001 format!(
1002 "{} {} completed task successfully",
1003 self.agent_type,
1004 style("(SUCCESS)").green().bold()
1005 )
1006 );
1007 }
1008 }
1009
1010 // Improved loop termination logic
1011 // Continue if: we had tool calls, task is not completed, and we haven't exceeded max turns
1012 let should_continue = had_tool_call || (!has_completed && turn < 9);
1013
1014 if !should_continue {
1015 if has_completed {
1016 println!(
1017 "{} {}",
1018 agent_prefix,
1019 format!(
1020 "{} {} finished - task completed",
1021 self.agent_type,
1022 style("(SUCCESS)").green().bold()
1023 )
1024 );
1025 } else if turn >= 9 {
1026 println!(
1027 "{} {}",
1028 agent_prefix,
1029 format!(
1030 "{} {} finished - maximum turns reached",
1031 self.agent_type,
1032 style("(TIME)").yellow().bold()
1033 )
1034 );
1035 } else {
1036 println!(
1037 "{} {}",
1038 agent_prefix,
1039 format!(
1040 "{} {} finished - no more actions needed",
1041 self.agent_type,
1042 style("(FINISH)").blue().bold()
1043 )
1044 );
1045 }
1046 break;
1047 }
1048 } else {
1049 // Empty response - check if we should continue or if task is actually complete
1050 if has_completed {
1051 println!(
1052 "{} {}",
1053 agent_prefix,
1054 format!(
1055 "{} {} finished - task was completed earlier",
1056 self.agent_type,
1057 style("(SUCCESS)").green().bold()
1058 )
1059 );
1060 break;
1061 } else if turn >= 9 {
1062 println!(
1063 "{} {}",
1064 agent_prefix,
1065 format!(
1066 "{} {} finished - maximum turns reached with empty response",
1067 self.agent_type,
1068 style("(TIME)").yellow().bold()
1069 )
1070 );
1071 break;
1072 } else {
1073 // Empty response but task not complete - this might indicate an issue
1074 println!(
1075 "{} {}",
1076 agent_prefix,
1077 format!(
1078 "{} {} received empty response, continuing...",
1079 self.agent_type,
1080 style("(EMPTY)").yellow()
1081 )
1082 );
1083 // Don't break here, let the loop continue to give the agent another chance
1084 }
1085 }
1086 }
1087
1088 // Agent execution completed
1089 println!("{} Done", agent_prefix);
1090
1091 // Generate meaningful summary based on agent actions
1092 let summary = self.generate_task_summary(
1093 &modified_files,
1094 &executed_commands,
1095 &warnings,
1096 &conversation,
1097 );
1098
1099 // Return task results
1100 Ok(TaskResults {
1101 created_contexts,
1102 modified_files,
1103 executed_commands,
1104 summary,
1105 warnings,
1106 })
1107 }
1108
1109 /// Build system instruction for agent based on task and contexts
1110 fn build_system_instruction(&self, task: &Task, contexts: &[ContextItem]) -> Result<String> {
1111 let mut instruction = self.system_prompt.clone();
1112
1113 // Add task-specific information
1114 instruction.push_str(&format!("\n\nTask: {}\n{}", task.title, task.description));
1115
1116 // Add context information if any
1117 if !contexts.is_empty() {
1118 instruction.push_str("\n\nRelevant Context:");
1119 for ctx in contexts {
1120 instruction.push_str(&format!("\n[{}] {}", ctx.id, ctx.content));
1121 }
1122 }
1123
1124 Ok(instruction)
1125 }
1126
1127 /// Build available tools for this agent type
1128 fn build_agent_tools(&self) -> Result<Vec<Tool>> {
1129 // Build function declarations based on available tools
1130 let declarations = build_function_declarations();
1131
1132 // Filter tools based on agent type and permissions
1133 let allowed_tools: Vec<Tool> = declarations
1134 .into_iter()
1135 .filter(|decl| self.is_tool_allowed(&decl.name))
1136 .map(|decl| Tool {
1137 function_declarations: vec![decl],
1138 })
1139 .collect();
1140
1141 Ok(allowed_tools)
1142 }
1143
1144 /// Check if a tool is allowed for this agent
1145 fn is_tool_allowed(&self, tool_name: &str) -> bool {
1146 if let Ok(policy_manager) = self.tool_registry.policy_manager() {
1147 match policy_manager.get_policy(tool_name) {
1148 crate::tool_policy::ToolPolicy::Allow | crate::tool_policy::ToolPolicy::Prompt => {
1149 true
1150 }
1151 crate::tool_policy::ToolPolicy::Deny => false,
1152 }
1153 } else {
1154 true
1155 }
1156 }
1157
1158 /// Execute a tool by name with given arguments
1159 async fn execute_tool(&self, tool_name: &str, args: &Value) -> Result<Value> {
1160 // Enforce per-agent shell policies for RUN_TERMINAL_CMD/BASH
1161 let is_shell = tool_name == tools::RUN_TERMINAL_CMD || tool_name == tools::BASH;
1162 if is_shell {
1163 let cfg = ConfigManager::load()
1164 .or_else(|_| ConfigManager::load_from_workspace("."))
1165 .or_else(|_| ConfigManager::load_from_file("vtcode.toml"))
1166 .map(|cm| cm.config().clone())
1167 .unwrap_or_default();
1168
1169 let cmd_text = if let Some(cmd_val) = args.get("command") {
1170 if cmd_val.is_array() {
1171 cmd_val
1172 .as_array()
1173 .unwrap()
1174 .iter()
1175 .filter_map(|v| v.as_str())
1176 .collect::<Vec<_>>()
1177 .join(" ")
1178 } else {
1179 cmd_val.as_str().unwrap_or("").to_string()
1180 }
1181 } else {
1182 String::new()
1183 };
1184
1185 let agent_prefix = format!(
1186 "VTCODE_{}_COMMANDS_",
1187 self.agent_type.to_string().to_uppercase()
1188 );
1189
1190 let mut deny_regex = cfg.commands.deny_regex.clone();
1191 if let Ok(extra) = std::env::var(format!("{}DENY_REGEX", agent_prefix)) {
1192 deny_regex.extend(extra.split(',').map(|s| s.trim().to_string()));
1193 }
1194 for pat in &deny_regex {
1195 if regex::Regex::new(pat)
1196 .ok()
1197 .map(|re| re.is_match(&cmd_text))
1198 .unwrap_or(false)
1199 {
1200 return Err(anyhow!("Shell command denied by regex: {}", pat));
1201 }
1202 }
1203
1204 let mut deny_glob = cfg.commands.deny_glob.clone();
1205 if let Ok(extra) = std::env::var(format!("{}DENY_GLOB", agent_prefix)) {
1206 deny_glob.extend(extra.split(',').map(|s| s.trim().to_string()));
1207 }
1208 for pat in &deny_glob {
1209 let re = format!("^{}$", regex::escape(pat).replace(r"\*", ".*"));
1210 if regex::Regex::new(&re)
1211 .ok()
1212 .map(|re| re.is_match(&cmd_text))
1213 .unwrap_or(false)
1214 {
1215 return Err(anyhow!("Shell command denied by glob: {}", pat));
1216 }
1217 }
1218 info!(target = "policy", agent = ?self.agent_type, tool = tool_name, cmd = %cmd_text, "shell_policy_checked");
1219 }
1220 // Clone the tool registry for this execution
1221 let mut registry = self.tool_registry.clone();
1222
1223 // Initialize async components
1224 registry.initialize_async().await?;
1225
1226 // Try with simple adaptive retry (up to 2 retries)
1227 let mut delay = std::time::Duration::from_millis(200);
1228 for attempt in 0..3 {
1229 match registry.execute_tool(tool_name, args.clone()).await {
1230 Ok(result) => return Ok(result),
1231 Err(_e) if attempt < 2 => {
1232 tokio::time::sleep(delay).await;
1233 delay = delay.saturating_mul(2);
1234 continue;
1235 }
1236 Err(e) => {
1237 return Err(anyhow!(
1238 "Tool '{}' not found or failed to execute: {}",
1239 tool_name,
1240 e
1241 ));
1242 }
1243 }
1244 }
1245 unreachable!()
1246 }
1247
1248 /// Generate a meaningful summary of the task execution
1249 fn generate_task_summary(
1250 &self,
1251 modified_files: &[String],
1252 executed_commands: &[String],
1253 warnings: &[String],
1254 conversation: &[Content],
1255 ) -> String {
1256 let mut summary = vec![];
1257
1258 // Add task title and agent type
1259 summary.push(format!(
1260 "Task: {}",
1261 conversation
1262 .get(0)
1263 .and_then(|c| c.parts.get(0))
1264 .and_then(|p| p.as_text())
1265 .unwrap_or(&"".to_string())
1266 ));
1267 summary.push(format!("Agent Type: {:?}", self.agent_type));
1268
1269 // Add executed commands
1270 if !executed_commands.is_empty() {
1271 summary.push("Executed Commands:".to_string());
1272 for command in executed_commands {
1273 summary.push(format!(" - {}", command));
1274 }
1275 }
1276
1277 // Add modified files
1278 if !modified_files.is_empty() {
1279 summary.push("Modified Files:".to_string());
1280 for file in modified_files {
1281 summary.push(format!(" - {}", file));
1282 }
1283 }
1284
1285 // Add warnings if any
1286 if !warnings.is_empty() {
1287 summary.push("Warnings:".to_string());
1288 for warning in warnings {
1289 summary.push(format!(" - {}", warning));
1290 }
1291 }
1292
1293 // Add final status
1294 let final_status = if conversation.last().map_or(false, |c| {
1295 c.role == "model"
1296 && c.parts.iter().any(|p| {
1297 p.as_text().map_or(false, |t| {
1298 t.contains("completed") || t.contains("done") || t.contains("finished")
1299 })
1300 })
1301 }) {
1302 "Task completed successfully".to_string()
1303 } else {
1304 "Task did not complete as expected".to_string()
1305 };
1306 summary.push(final_status);
1307
1308 // Join all parts with new lines
1309 summary.join("\n")
1310 }
1311}
1312
1313/// Parse tool code in the format: function_name(arg1=value1, arg2=value2)
1314fn parse_tool_code(tool_code: &str) -> Option<(String, String)> {
1315 // Remove any markdown code blocks
1316 let code = tool_code.trim();
1317 let code = if code.starts_with("```") && code.ends_with("```") {
1318 code.trim_start_matches("```")
1319 .trim_end_matches("```")
1320 .trim()
1321 } else {
1322 code
1323 };
1324
1325 // Try to match function call pattern: name(args)
1326 if let Some(open_paren) = code.find('(') {
1327 if let Some(close_paren) = code.rfind(')') {
1328 let func_name = code[..open_paren].trim().to_string();
1329 let args_str = &code[open_paren + 1..close_paren];
1330
1331 // Convert Python-style arguments to JSON
1332 let json_args = convert_python_args_to_json(args_str)?;
1333 return Some((func_name, json_args));
1334 }
1335 }
1336
1337 None
1338}
1339
1340/// Convert Python-style function arguments to JSON
1341fn convert_python_args_to_json(args_str: &str) -> Option<String> {
1342 if args_str.trim().is_empty() {
1343 return Some("{}".to_string());
1344 }
1345
1346 let mut json_parts = Vec::new();
1347
1348 for arg in args_str.split(',').map(|s| s.trim()) {
1349 if arg.is_empty() {
1350 continue;
1351 }
1352
1353 // Handle key=value format
1354 if let Some(eq_pos) = arg.find('=') {
1355 let key = arg[..eq_pos].trim().trim_matches('"').trim_matches('\'');
1356 let value = arg[eq_pos + 1..].trim();
1357
1358 // Convert value to JSON format
1359 let json_value = if value.starts_with('"') && value.ends_with('"') {
1360 value.to_string()
1361 } else if value.starts_with('\'') && value.ends_with('\'') {
1362 format!("\"{}\"", value.trim_matches('\''))
1363 } else if value == "True" || value == "true" {
1364 "true".to_string()
1365 } else if value == "False" || value == "false" {
1366 "false".to_string()
1367 } else if value == "None" || value == "null" {
1368 "null".to_string()
1369 } else if let Ok(num) = value.parse::<f64>() {
1370 num.to_string()
1371 } else {
1372 // Assume it's a string that needs quotes
1373 format!("\"{}\"", value)
1374 };
1375
1376 json_parts.push(format!("\"{}\": {}", key, json_value));
1377 } else {
1378 // Handle positional arguments (not supported well, but try)
1379 return None;
1380 }
1381 }
1382
1383 Some(format!("{{{}}}", json_parts.join(", ")))
1384}
1385
1386/// Task specification consumed by the benchmark/autonomous runner.
1387#[derive(Debug, Clone, Serialize, Deserialize)]
1388pub struct Task {
1389 /// Stable identifier for reporting.
1390 pub id: String,
1391 /// Human-readable task title displayed in progress messages.
1392 pub title: String,
1393 /// High-level description of the task objective.
1394 pub description: String,
1395 /// Optional explicit instructions appended to the conversation.
1396 #[serde(default, skip_serializing_if = "Option::is_none")]
1397 pub instructions: Option<String>,
1398}
1399
1400impl Task {
1401 /// Construct a task with the provided metadata.
1402 pub fn new(id: String, title: String, description: String) -> Self {
1403 Self {
1404 id,
1405 title,
1406 description,
1407 instructions: None,
1408 }
1409 }
1410}
1411
1412/// Context entry supplied alongside the benchmark task.
1413#[derive(Debug, Clone, Serialize, Deserialize)]
1414pub struct ContextItem {
1415 /// Identifier used when referencing the context in prompts.
1416 pub id: String,
1417 /// Raw textual content exposed to the agent.
1418 pub content: String,
1419}
1420
1421/// Aggregated results returned by the autonomous agent runner.
1422#[derive(Debug, Clone, Serialize, Deserialize)]
1423pub struct TaskResults {
1424 /// Identifiers of any contexts created during execution.
1425 #[serde(default)]
1426 pub created_contexts: Vec<String>,
1427 /// File paths modified during the task.
1428 #[serde(default)]
1429 pub modified_files: Vec<String>,
1430 /// Terminal commands executed while solving the task.
1431 #[serde(default)]
1432 pub executed_commands: Vec<String>,
1433 /// Natural-language summary of the run assembled by the agent.
1434 pub summary: String,
1435 /// Collected warnings emitted while processing the task.
1436 #[serde(default)]
1437 pub warnings: Vec<String>,
1438}