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
348 .provider_client
349 .supports_reasoning_effort(&self.model)
350 {
351 self.reasoning_effort
352 } else {
353 None
354 },
355 };
356
357 // Use provider-specific client for OpenAI/Anthropic (and generic support for others)
358 // Prepare for provider-specific vs Gemini handling
359 #[allow(unused_assignments)]
360 let mut response_opt: Option<crate::llm::types::LLMResponse> = None;
361 let provider_kind = self
362 .model
363 .parse::<ModelId>()
364 .map(|m| m.provider())
365 .unwrap_or(ModelProvider::Gemini);
366
367 if matches!(
368 provider_kind,
369 ModelProvider::OpenAI | ModelProvider::Anthropic | ModelProvider::DeepSeek
370 ) {
371 let resp = self
372 .provider_client
373 .generate(request.clone())
374 .await
375 .map_err(|e| {
376 println!(
377 "{} {} Failed",
378 agent_prefix,
379 style("(ERROR)").red().bold().on_black()
380 );
381 anyhow!(
382 "Agent {} execution failed at turn {}: {}",
383 self.agent_type,
384 turn,
385 e
386 )
387 })?;
388
389 // Update progress for successful response
390 println!(
391 "{} {}",
392 agent_prefix,
393 format!(
394 "{} {} received response, processing...",
395 self.agent_type,
396 style("(RECV)").green().bold()
397 )
398 );
399
400 let mut had_tool_call = false;
401
402 if let Some(tool_calls) = resp.tool_calls.as_ref() {
403 if !tool_calls.is_empty() {
404 had_tool_call = true;
405 for call in tool_calls {
406 let name = call.function.name.as_str();
407 println!(
408 "{} [{}] Calling tool: {}",
409 style("[TOOL_CALL]").blue().bold(),
410 self.agent_type,
411 name
412 );
413 let args = call
414 .parsed_arguments()
415 .unwrap_or_else(|_| serde_json::json!({}));
416 match self.execute_tool(name, &args).await {
417 Ok(result) => {
418 println!(
419 "{} {}",
420 agent_prefix,
421 format!(
422 "{} {} tool executed successfully",
423 style("(OK)").green(),
424 name
425 )
426 );
427 let tool_result = serde_json::to_string(&result)?;
428 conversation.push(Content {
429 role: "user".to_string(),
430 parts: vec![Part::Text {
431 text: format!("Tool {} result: {}", name, tool_result),
432 }],
433 });
434 executed_commands.push(name.to_string());
435 if name == tools::WRITE_FILE {
436 if let Some(filepath) =
437 args.get("path").and_then(|p| p.as_str())
438 {
439 modified_files.push(filepath.to_string());
440 }
441 }
442 }
443 Err(e) => {
444 println!(
445 "{} {}",
446 agent_prefix,
447 format!(
448 "{} {} tool failed: {}",
449 style("(ERR)").red(),
450 name,
451 e
452 )
453 );
454 warnings.push(format!("Tool {} failed: {}", name, e));
455 conversation.push(Content {
456 role: "user".to_string(),
457 parts: vec![Part::Text {
458 text: format!("Tool {} failed: {}", name, e),
459 }],
460 });
461 }
462 }
463 }
464 }
465 }
466
467 // If no tool calls, treat as regular content
468 let response_text = resp.content.clone().unwrap_or_default();
469 if !had_tool_call {
470 if !response_text.trim().is_empty() {
471 Self::print_compact_response(self.agent_type, &response_text);
472 conversation.push(Content {
473 role: "model".to_string(),
474 parts: vec![Part::Text {
475 text: response_text.clone(),
476 }],
477 });
478 }
479 }
480
481 // Completion detection
482 if !has_completed {
483 let response_lower = response_text.to_lowercase();
484 let completion_indicators = [
485 "task completed",
486 "task done",
487 "finished",
488 "complete",
489 "summary",
490 "i have successfully",
491 "i've completed",
492 "i have finished",
493 "task accomplished",
494 "mission accomplished",
495 "objective achieved",
496 "work is done",
497 "all done",
498 "completed successfully",
499 "task execution complete",
500 "operation finished",
501 ];
502 let is_completed = completion_indicators
503 .iter()
504 .any(|&indicator| response_lower.contains(indicator));
505 let has_explicit_completion = response_lower.contains("the task is complete")
506 || response_lower.contains("task has been completed")
507 || response_lower.contains("i am done")
508 || response_lower.contains("that's all")
509 || response_lower.contains("no more actions needed");
510 if is_completed || has_explicit_completion {
511 has_completed = true;
512 println!(
513 "{} {}",
514 agent_prefix,
515 format!(
516 "{} {} completed task successfully",
517 self.agent_type,
518 style("(SUCCESS)").green().bold()
519 )
520 );
521 }
522 }
523
524 let should_continue = had_tool_call || (!has_completed && turn < 9);
525 if !should_continue {
526 if has_completed {
527 println!(
528 "{} {}",
529 agent_prefix,
530 format!(
531 "{} {} finished - task completed",
532 self.agent_type,
533 style("(SUCCESS)").green().bold()
534 )
535 );
536 } else if turn >= 9 {
537 println!(
538 "{} {}",
539 agent_prefix,
540 format!(
541 "{} {} finished - maximum turns reached",
542 self.agent_type,
543 style("(TIME)").yellow().bold()
544 )
545 );
546 } else {
547 println!(
548 "{} {}",
549 agent_prefix,
550 format!(
551 "{} {} finished",
552 self.agent_type,
553 style("(FINISH)").blue().bold()
554 )
555 );
556 }
557 break;
558 }
559
560 // Continue loop for tool results
561 continue;
562 } else {
563 // Gemini path (existing flow)
564 let response = self
565 .client
566 .generate(&serde_json::to_string(&request)?)
567 .await
568 .map_err(|e| {
569 println!(
570 "{} {} Failed",
571 agent_prefix,
572 style("(ERROR)").red().bold().on_black()
573 );
574 anyhow!(
575 "Agent {} execution failed at turn {}: {}",
576 self.agent_type,
577 turn,
578 e
579 )
580 })?;
581 response_opt = Some(response);
582 }
583
584 // For Gemini path: use original response handling
585 let response = response_opt.expect("response should be set for Gemini path");
586
587 // Update progress for successful response
588 println!(
589 "{} {}",
590 agent_prefix,
591 format!(
592 "{} {} received response, processing...",
593 self.agent_type,
594 style("(RECV)").green().bold()
595 )
596 );
597
598 // Use response content directly
599 if !response.content.is_empty() {
600 // Try to parse the response as JSON to check for tool calls
601 let mut had_tool_call = false;
602
603 // Try to parse as a tool call response
604 if let Ok(tool_call_response) = serde_json::from_str::<Value>(&response.content) {
605 // Check for standard tool_calls format
606 if let Some(tool_calls) = tool_call_response
607 .get("tool_calls")
608 .and_then(|tc| tc.as_array())
609 {
610 had_tool_call = true;
611
612 // Process each tool call
613 for tool_call in tool_calls {
614 if let Some(function) = tool_call.get("function") {
615 if let (Some(name), Some(arguments)) = (
616 function.get("name").and_then(|n| n.as_str()),
617 function.get("arguments"),
618 ) {
619 println!(
620 "{} [{}] Calling tool: {}",
621 style("[TOOL_CALL]").blue().bold(),
622 self.agent_type,
623 name
624 );
625
626 // Execute the tool
627 match self.execute_tool(name, &arguments.clone()).await {
628 Ok(result) => {
629 println!(
630 "{} {}",
631 agent_prefix,
632 format!(
633 "{} {} tool executed successfully",
634 style("(OK)").green(),
635 name
636 )
637 );
638
639 // Add tool result to conversation
640 let tool_result = serde_json::to_string(&result)?;
641 conversation.push(Content {
642 role: "user".to_string(), // Gemini API only accepts "user" and "model"
643 parts: vec![Part::Text {
644 text: format!(
645 "Tool {} result: {}",
646 name, tool_result
647 ),
648 }],
649 });
650
651 // Track what the agent did
652 executed_commands.push(name.to_string());
653
654 // Special handling for certain tools
655 if name == tools::WRITE_FILE {
656 if let Some(filepath) =
657 arguments.get("path").and_then(|p| p.as_str())
658 {
659 modified_files.push(filepath.to_string());
660 }
661 }
662 }
663 Err(e) => {
664 println!(
665 "{} {}",
666 agent_prefix,
667 format!(
668 "{} {} tool failed: {}",
669 style("(ERR)").red(),
670 name,
671 e
672 )
673 );
674 warnings.push(format!("Tool {} failed: {}", name, e));
675 conversation.push(Content {
676 role: "user".to_string(), // Gemini API only accepts "user" and "model"
677 parts: vec![Part::Text {
678 text: format!("Tool {} failed: {}", name, e),
679 }],
680 });
681 }
682 }
683 }
684 }
685 }
686 }
687 // Check for Gemini functionCall format
688 else if let Some(function_call) = tool_call_response.get("functionCall") {
689 had_tool_call = true;
690
691 if let (Some(name), Some(args)) = (
692 function_call.get("name").and_then(|n| n.as_str()),
693 function_call.get("args"),
694 ) {
695 println!(
696 "{} [{}] Calling tool: {}",
697 style("[TOOL_CALL]").blue().bold(),
698 self.agent_type,
699 name
700 );
701
702 // Execute the tool
703 match self.execute_tool(name, args).await {
704 Ok(result) => {
705 println!(
706 "{} {}",
707 agent_prefix,
708 format!(
709 "{} {} tool executed successfully",
710 style("(OK)").green(),
711 name
712 )
713 );
714
715 // Add tool result to conversation
716 let tool_result = serde_json::to_string(&result)?;
717 conversation.push(Content {
718 role: "user".to_string(), // Gemini API only accepts "user" and "model"
719 parts: vec![Part::Text {
720 text: format!("Tool {} result: {}", name, tool_result),
721 }],
722 });
723
724 // Track what the agent did
725 executed_commands.push(name.to_string());
726
727 // Special handling for certain tools
728 if name == tools::WRITE_FILE {
729 if let Some(filepath) =
730 args.get("path").and_then(|p| p.as_str())
731 {
732 modified_files.push(filepath.to_string());
733 }
734 }
735 }
736 Err(e) => {
737 println!(
738 "{} {}",
739 agent_prefix,
740 format!(
741 "{} {} tool failed: {}",
742 style("(ERR)").red().bold(),
743 name,
744 e
745 )
746 );
747 warnings.push(format!("Tool {} failed: {}", name, e));
748 conversation.push(Content {
749 role: "user".to_string(), // Gemini API only accepts "user" and "model"
750 parts: vec![Part::Text {
751 text: format!("Tool {} failed: {}", name, e),
752 }],
753 });
754 }
755 }
756 }
757 }
758 // Check for tool_code format (what agents are actually producing)
759 else if let Some(tool_code) = tool_call_response
760 .get("tool_code")
761 .and_then(|tc| tc.as_str())
762 {
763 had_tool_call = true;
764
765 println!(
766 "{} [{}] Executing tool code: {}",
767 style("[TOOL_EXEC]").cyan().bold().on_black(),
768 self.agent_type,
769 tool_code
770 );
771
772 // Try to parse the tool_code as a function call
773 // This is a simplified parser for the format: function_name(args)
774 if let Some((func_name, args_str)) = parse_tool_code(tool_code) {
775 println!(
776 "{} [{}] Parsed tool: {} with args: {}",
777 style("[TOOL_PARSE]").yellow().bold().on_black(),
778 self.agent_type,
779 func_name,
780 args_str
781 );
782
783 // Parse arguments as JSON
784 match serde_json::from_str::<Value>(&args_str) {
785 Ok(arguments) => {
786 // Execute the tool
787 match self.execute_tool(&func_name, &arguments).await {
788 Ok(result) => {
789 println!(
790 "{} {}",
791 agent_prefix,
792 format!(
793 "{} {} tool executed successfully",
794 style("(OK)").green(),
795 func_name
796 )
797 );
798
799 // Add tool result to conversation
800 let tool_result = serde_json::to_string(&result)?;
801 conversation.push(Content {
802 role: "user".to_string(), // Gemini API only accepts "user" and "model"
803 parts: vec![Part::Text {
804 text: format!(
805 "Tool {} result: {}",
806 func_name, tool_result
807 ),
808 }],
809 });
810
811 // Track what the agent did
812 executed_commands.push(func_name.to_string());
813
814 // Special handling for certain tools
815 if func_name == tools::WRITE_FILE {
816 if let Some(filepath) =
817 arguments.get("path").and_then(|p| p.as_str())
818 {
819 modified_files.push(filepath.to_string());
820 }
821 }
822 }
823 Err(e) => {
824 println!(
825 "{} {}",
826 agent_prefix,
827 format!(
828 "{} {} tool failed: {}",
829 style("(ERROR)").red().bold(),
830 func_name,
831 e
832 )
833 );
834 warnings
835 .push(format!("Tool {} failed: {}", func_name, e));
836 conversation.push(Content {
837 role: "user".to_string(), // Gemini API only accepts "user" and "model"
838 parts: vec![Part::Text {
839 text: format!(
840 "Tool {} failed: {}",
841 func_name, e
842 ),
843 }],
844 });
845 }
846 }
847 }
848 Err(e) => {
849 let error_msg = format!(
850 "Failed to parse tool arguments '{}': {}",
851 args_str, e
852 );
853 warnings.push(error_msg.clone());
854 conversation.push(Content {
855 role: "user".to_string(), // Gemini API only accepts "user" and "model"
856 parts: vec![Part::Text { text: error_msg }],
857 });
858 }
859 }
860 } else {
861 let error_msg = format!("Failed to parse tool code: {}", tool_code);
862 warnings.push(error_msg.clone());
863 conversation.push(Content {
864 role: "user".to_string(), // Gemini API only accepts "user" and "model"
865 parts: vec![Part::Text { text: error_msg }],
866 });
867 }
868 }
869 // Check for tool_name format (alternative format)
870 else if let Some(tool_name) = tool_call_response
871 .get("tool_name")
872 .and_then(|tn| tn.as_str())
873 {
874 had_tool_call = true;
875
876 println!(
877 "{} [{}] Calling tool: {}",
878 style("[TOOL_CALL]").blue().bold().on_black(),
879 self.agent_type,
880 tool_name
881 );
882
883 if let Some(parameters) = tool_call_response.get("parameters") {
884 // Execute the tool
885 match self.execute_tool(tool_name, parameters).await {
886 Ok(result) => {
887 println!(
888 "{} {}",
889 agent_prefix,
890 format!(
891 "{} {} tool executed successfully",
892 style("(SUCCESS)").green().bold(),
893 tool_name
894 )
895 );
896
897 // Add tool result to conversation
898 let tool_result = serde_json::to_string(&result)?;
899 conversation.push(Content {
900 role: "user".to_string(), // Gemini API only accepts "user" and "model"
901 parts: vec![Part::Text {
902 text: format!(
903 "Tool {} result: {}",
904 tool_name, tool_result
905 ),
906 }],
907 });
908
909 // Track what the agent did
910 executed_commands.push(tool_name.to_string());
911
912 // Special handling for certain tools
913 if tool_name == tools::WRITE_FILE {
914 if let Some(filepath) =
915 parameters.get("path").and_then(|p| p.as_str())
916 {
917 modified_files.push(filepath.to_string());
918 }
919 }
920 }
921 Err(e) => {
922 println!(
923 "{} {}",
924 agent_prefix,
925 format!(
926 "{} {} tool failed: {}",
927 style("(ERROR)").red().bold(),
928 tool_name,
929 e
930 )
931 );
932 warnings.push(format!("Tool {} failed: {}", tool_name, e));
933 conversation.push(Content {
934 role: "user".to_string(), // Gemini API only accepts "user" and "model"
935 parts: vec![Part::Text {
936 text: format!("Tool {} failed: {}", tool_name, e),
937 }],
938 });
939 }
940 }
941 }
942 } else {
943 // Regular content response
944 Self::print_compact_response(self.agent_type, response.content.trim());
945 conversation.push(Content {
946 role: "model".to_string(),
947 parts: vec![Part::Text {
948 text: response.content.clone(),
949 }],
950 });
951 }
952 } else {
953 // Regular text response
954 Self::print_compact_response(self.agent_type, response.content.trim());
955 conversation.push(Content {
956 role: "model".to_string(),
957 parts: vec![Part::Text {
958 text: response.content.clone(),
959 }],
960 });
961 }
962
963 // Check for task completion indicators in the response
964 if !has_completed {
965 let response_lower = response.content.to_lowercase();
966
967 // More comprehensive completion detection
968 let completion_indicators = [
969 "task completed",
970 "task done",
971 "finished",
972 "complete",
973 "summary",
974 "i have successfully",
975 "i've completed",
976 "i have finished",
977 "task accomplished",
978 "mission accomplished",
979 "objective achieved",
980 "work is done",
981 "all done",
982 "completed successfully",
983 "task execution complete",
984 "operation finished",
985 ];
986
987 // Check if any completion indicator is present
988 let is_completed = completion_indicators
989 .iter()
990 .any(|&indicator| response_lower.contains(indicator));
991
992 // Also check for explicit completion statements
993 let has_explicit_completion = response_lower.contains("the task is complete")
994 || response_lower.contains("task has been completed")
995 || response_lower.contains("i am done")
996 || response_lower.contains("that's all")
997 || response_lower.contains("no more actions needed");
998
999 if is_completed || has_explicit_completion {
1000 has_completed = true;
1001 println!(
1002 "{} {}",
1003 agent_prefix,
1004 format!(
1005 "{} {} completed task successfully",
1006 self.agent_type,
1007 style("(SUCCESS)").green().bold()
1008 )
1009 );
1010 }
1011 }
1012
1013 // Improved loop termination logic
1014 // Continue if: we had tool calls, task is not completed, and we haven't exceeded max turns
1015 let should_continue = had_tool_call || (!has_completed && turn < 9);
1016
1017 if !should_continue {
1018 if has_completed {
1019 println!(
1020 "{} {}",
1021 agent_prefix,
1022 format!(
1023 "{} {} finished - task completed",
1024 self.agent_type,
1025 style("(SUCCESS)").green().bold()
1026 )
1027 );
1028 } else if turn >= 9 {
1029 println!(
1030 "{} {}",
1031 agent_prefix,
1032 format!(
1033 "{} {} finished - maximum turns reached",
1034 self.agent_type,
1035 style("(TIME)").yellow().bold()
1036 )
1037 );
1038 } else {
1039 println!(
1040 "{} {}",
1041 agent_prefix,
1042 format!(
1043 "{} {} finished - no more actions needed",
1044 self.agent_type,
1045 style("(FINISH)").blue().bold()
1046 )
1047 );
1048 }
1049 break;
1050 }
1051 } else {
1052 // Empty response - check if we should continue or if task is actually complete
1053 if has_completed {
1054 println!(
1055 "{} {}",
1056 agent_prefix,
1057 format!(
1058 "{} {} finished - task was completed earlier",
1059 self.agent_type,
1060 style("(SUCCESS)").green().bold()
1061 )
1062 );
1063 break;
1064 } else if turn >= 9 {
1065 println!(
1066 "{} {}",
1067 agent_prefix,
1068 format!(
1069 "{} {} finished - maximum turns reached with empty response",
1070 self.agent_type,
1071 style("(TIME)").yellow().bold()
1072 )
1073 );
1074 break;
1075 } else {
1076 // Empty response but task not complete - this might indicate an issue
1077 println!(
1078 "{} {}",
1079 agent_prefix,
1080 format!(
1081 "{} {} received empty response, continuing...",
1082 self.agent_type,
1083 style("(EMPTY)").yellow()
1084 )
1085 );
1086 // Don't break here, let the loop continue to give the agent another chance
1087 }
1088 }
1089 }
1090
1091 // Agent execution completed
1092 println!("{} Done", agent_prefix);
1093
1094 // Generate meaningful summary based on agent actions
1095 let summary = self.generate_task_summary(
1096 &modified_files,
1097 &executed_commands,
1098 &warnings,
1099 &conversation,
1100 );
1101
1102 // Return task results
1103 Ok(TaskResults {
1104 created_contexts,
1105 modified_files,
1106 executed_commands,
1107 summary,
1108 warnings,
1109 })
1110 }
1111
1112 /// Build system instruction for agent based on task and contexts
1113 fn build_system_instruction(&self, task: &Task, contexts: &[ContextItem]) -> Result<String> {
1114 let mut instruction = self.system_prompt.clone();
1115
1116 // Add task-specific information
1117 instruction.push_str(&format!("\n\nTask: {}\n{}", task.title, task.description));
1118
1119 // Add context information if any
1120 if !contexts.is_empty() {
1121 instruction.push_str("\n\nRelevant Context:");
1122 for ctx in contexts {
1123 instruction.push_str(&format!("\n[{}] {}", ctx.id, ctx.content));
1124 }
1125 }
1126
1127 Ok(instruction)
1128 }
1129
1130 /// Build available tools for this agent type
1131 fn build_agent_tools(&self) -> Result<Vec<Tool>> {
1132 // Build function declarations based on available tools
1133 let declarations = build_function_declarations();
1134
1135 // Filter tools based on agent type and permissions
1136 let allowed_tools: Vec<Tool> = declarations
1137 .into_iter()
1138 .filter(|decl| self.is_tool_allowed(&decl.name))
1139 .map(|decl| Tool {
1140 function_declarations: vec![decl],
1141 })
1142 .collect();
1143
1144 Ok(allowed_tools)
1145 }
1146
1147 /// Check if a tool is allowed for this agent
1148 fn is_tool_allowed(&self, tool_name: &str) -> bool {
1149 if let Ok(policy_manager) = self.tool_registry.policy_manager() {
1150 match policy_manager.get_policy(tool_name) {
1151 crate::tool_policy::ToolPolicy::Allow | crate::tool_policy::ToolPolicy::Prompt => {
1152 true
1153 }
1154 crate::tool_policy::ToolPolicy::Deny => false,
1155 }
1156 } else {
1157 true
1158 }
1159 }
1160
1161 /// Execute a tool by name with given arguments
1162 async fn execute_tool(&self, tool_name: &str, args: &Value) -> Result<Value> {
1163 // Enforce per-agent shell policies for RUN_TERMINAL_CMD/BASH
1164 let is_shell = tool_name == tools::RUN_TERMINAL_CMD || tool_name == tools::BASH;
1165 if is_shell {
1166 let cfg = ConfigManager::load()
1167 .or_else(|_| ConfigManager::load_from_workspace("."))
1168 .or_else(|_| ConfigManager::load_from_file("vtcode.toml"))
1169 .map(|cm| cm.config().clone())
1170 .unwrap_or_default();
1171
1172 let cmd_text = if let Some(cmd_val) = args.get("command") {
1173 if cmd_val.is_array() {
1174 cmd_val
1175 .as_array()
1176 .unwrap()
1177 .iter()
1178 .filter_map(|v| v.as_str())
1179 .collect::<Vec<_>>()
1180 .join(" ")
1181 } else {
1182 cmd_val.as_str().unwrap_or("").to_string()
1183 }
1184 } else {
1185 String::new()
1186 };
1187
1188 let agent_prefix = format!(
1189 "VTCODE_{}_COMMANDS_",
1190 self.agent_type.to_string().to_uppercase()
1191 );
1192
1193 let mut deny_regex = cfg.commands.deny_regex.clone();
1194 if let Ok(extra) = std::env::var(format!("{}DENY_REGEX", agent_prefix)) {
1195 deny_regex.extend(extra.split(',').map(|s| s.trim().to_string()));
1196 }
1197 for pat in &deny_regex {
1198 if regex::Regex::new(pat)
1199 .ok()
1200 .map(|re| re.is_match(&cmd_text))
1201 .unwrap_or(false)
1202 {
1203 return Err(anyhow!("Shell command denied by regex: {}", pat));
1204 }
1205 }
1206
1207 let mut deny_glob = cfg.commands.deny_glob.clone();
1208 if let Ok(extra) = std::env::var(format!("{}DENY_GLOB", agent_prefix)) {
1209 deny_glob.extend(extra.split(',').map(|s| s.trim().to_string()));
1210 }
1211 for pat in &deny_glob {
1212 let re = format!("^{}$", regex::escape(pat).replace(r"\*", ".*"));
1213 if regex::Regex::new(&re)
1214 .ok()
1215 .map(|re| re.is_match(&cmd_text))
1216 .unwrap_or(false)
1217 {
1218 return Err(anyhow!("Shell command denied by glob: {}", pat));
1219 }
1220 }
1221 info!(target = "policy", agent = ?self.agent_type, tool = tool_name, cmd = %cmd_text, "shell_policy_checked");
1222 }
1223 // Clone the tool registry for this execution
1224 let mut registry = self.tool_registry.clone();
1225
1226 // Initialize async components
1227 registry.initialize_async().await?;
1228
1229 // Try with simple adaptive retry (up to 2 retries)
1230 let mut delay = std::time::Duration::from_millis(200);
1231 for attempt in 0..3 {
1232 match registry.execute_tool(tool_name, args.clone()).await {
1233 Ok(result) => return Ok(result),
1234 Err(_e) if attempt < 2 => {
1235 tokio::time::sleep(delay).await;
1236 delay = delay.saturating_mul(2);
1237 continue;
1238 }
1239 Err(e) => {
1240 return Err(anyhow!(
1241 "Tool '{}' not found or failed to execute: {}",
1242 tool_name,
1243 e
1244 ));
1245 }
1246 }
1247 }
1248 unreachable!()
1249 }
1250
1251 /// Generate a meaningful summary of the task execution
1252 fn generate_task_summary(
1253 &self,
1254 modified_files: &[String],
1255 executed_commands: &[String],
1256 warnings: &[String],
1257 conversation: &[Content],
1258 ) -> String {
1259 let mut summary = vec![];
1260
1261 // Add task title and agent type
1262 summary.push(format!(
1263 "Task: {}",
1264 conversation
1265 .get(0)
1266 .and_then(|c| c.parts.get(0))
1267 .and_then(|p| p.as_text())
1268 .unwrap_or(&"".to_string())
1269 ));
1270 summary.push(format!("Agent Type: {:?}", self.agent_type));
1271
1272 // Add executed commands
1273 if !executed_commands.is_empty() {
1274 summary.push("Executed Commands:".to_string());
1275 for command in executed_commands {
1276 summary.push(format!(" - {}", command));
1277 }
1278 }
1279
1280 // Add modified files
1281 if !modified_files.is_empty() {
1282 summary.push("Modified Files:".to_string());
1283 for file in modified_files {
1284 summary.push(format!(" - {}", file));
1285 }
1286 }
1287
1288 // Add warnings if any
1289 if !warnings.is_empty() {
1290 summary.push("Warnings:".to_string());
1291 for warning in warnings {
1292 summary.push(format!(" - {}", warning));
1293 }
1294 }
1295
1296 // Add final status
1297 let final_status = if conversation.last().map_or(false, |c| {
1298 c.role == "model"
1299 && c.parts.iter().any(|p| {
1300 p.as_text().map_or(false, |t| {
1301 t.contains("completed") || t.contains("done") || t.contains("finished")
1302 })
1303 })
1304 }) {
1305 "Task completed successfully".to_string()
1306 } else {
1307 "Task did not complete as expected".to_string()
1308 };
1309 summary.push(final_status);
1310
1311 // Join all parts with new lines
1312 summary.join("\n")
1313 }
1314}
1315
1316/// Parse tool code in the format: function_name(arg1=value1, arg2=value2)
1317fn parse_tool_code(tool_code: &str) -> Option<(String, String)> {
1318 // Remove any markdown code blocks
1319 let code = tool_code.trim();
1320 let code = if code.starts_with("```") && code.ends_with("```") {
1321 code.trim_start_matches("```")
1322 .trim_end_matches("```")
1323 .trim()
1324 } else {
1325 code
1326 };
1327
1328 // Try to match function call pattern: name(args)
1329 if let Some(open_paren) = code.find('(') {
1330 if let Some(close_paren) = code.rfind(')') {
1331 let func_name = code[..open_paren].trim().to_string();
1332 let args_str = &code[open_paren + 1..close_paren];
1333
1334 // Convert Python-style arguments to JSON
1335 let json_args = convert_python_args_to_json(args_str)?;
1336 return Some((func_name, json_args));
1337 }
1338 }
1339
1340 None
1341}
1342
1343/// Convert Python-style function arguments to JSON
1344fn convert_python_args_to_json(args_str: &str) -> Option<String> {
1345 if args_str.trim().is_empty() {
1346 return Some("{}".to_string());
1347 }
1348
1349 let mut json_parts = Vec::new();
1350
1351 for arg in args_str.split(',').map(|s| s.trim()) {
1352 if arg.is_empty() {
1353 continue;
1354 }
1355
1356 // Handle key=value format
1357 if let Some(eq_pos) = arg.find('=') {
1358 let key = arg[..eq_pos].trim().trim_matches('"').trim_matches('\'');
1359 let value = arg[eq_pos + 1..].trim();
1360
1361 // Convert value to JSON format
1362 let json_value = if value.starts_with('"') && value.ends_with('"') {
1363 value.to_string()
1364 } else if value.starts_with('\'') && value.ends_with('\'') {
1365 format!("\"{}\"", value.trim_matches('\''))
1366 } else if value == "True" || value == "true" {
1367 "true".to_string()
1368 } else if value == "False" || value == "false" {
1369 "false".to_string()
1370 } else if value == "None" || value == "null" {
1371 "null".to_string()
1372 } else if let Ok(num) = value.parse::<f64>() {
1373 num.to_string()
1374 } else {
1375 // Assume it's a string that needs quotes
1376 format!("\"{}\"", value)
1377 };
1378
1379 json_parts.push(format!("\"{}\": {}", key, json_value));
1380 } else {
1381 // Handle positional arguments (not supported well, but try)
1382 return None;
1383 }
1384 }
1385
1386 Some(format!("{{{}}}", json_parts.join(", ")))
1387}
1388
1389/// Task specification consumed by the benchmark/autonomous runner.
1390#[derive(Debug, Clone, Serialize, Deserialize)]
1391pub struct Task {
1392 /// Stable identifier for reporting.
1393 pub id: String,
1394 /// Human-readable task title displayed in progress messages.
1395 pub title: String,
1396 /// High-level description of the task objective.
1397 pub description: String,
1398 /// Optional explicit instructions appended to the conversation.
1399 #[serde(default, skip_serializing_if = "Option::is_none")]
1400 pub instructions: Option<String>,
1401}
1402
1403impl Task {
1404 /// Construct a task with the provided metadata.
1405 pub fn new(id: String, title: String, description: String) -> Self {
1406 Self {
1407 id,
1408 title,
1409 description,
1410 instructions: None,
1411 }
1412 }
1413}
1414
1415/// Context entry supplied alongside the benchmark task.
1416#[derive(Debug, Clone, Serialize, Deserialize)]
1417pub struct ContextItem {
1418 /// Identifier used when referencing the context in prompts.
1419 pub id: String,
1420 /// Raw textual content exposed to the agent.
1421 pub content: String,
1422}
1423
1424/// Aggregated results returned by the autonomous agent runner.
1425#[derive(Debug, Clone, Serialize, Deserialize)]
1426pub struct TaskResults {
1427 /// Identifiers of any contexts created during execution.
1428 #[serde(default)]
1429 pub created_contexts: Vec<String>,
1430 /// File paths modified during the task.
1431 #[serde(default)]
1432 pub modified_files: Vec<String>,
1433 /// Terminal commands executed while solving the task.
1434 #[serde(default)]
1435 pub executed_commands: Vec<String>,
1436 /// Natural-language summary of the run assembled by the agent.
1437 pub summary: String,
1438 /// Collected warnings emitted while processing the task.
1439 #[serde(default)]
1440 pub warnings: Vec<String>,
1441}