Skip to main content

tycode_core/agents/
runner.rs

1use std::collections::BTreeMap;
2use std::sync::Arc;
3
4use anyhow::{anyhow, Result};
5use tracing::{debug, info, warn};
6
7use crate::agents::agent::ActiveAgent;
8use crate::ai::provider::AiProvider;
9use crate::ai::types::{Content, ContentBlock, Message, MessageRole, ToolResultData};
10use crate::chat::request::prepare_request;
11use crate::chat::tool_extraction::extract_all_tool_calls;
12use crate::module::ContextBuilder;
13use crate::module::Module;
14use crate::module::PromptBuilder;
15use crate::settings::SettingsManager;
16use crate::steering::SteeringDocuments;
17use crate::tools::r#trait::{ToolExecutor, ToolOutput, ToolRequest};
18
19/// A sub-agent runner.
20///
21/// This runs autonomous agents that do not require user input. Agents are run
22/// until "complete_task" is called. This currently has some duplicated logic
23/// with chat/ai.rs & chat/tools.rs. We need to refactor to better abstract out
24/// execution. This currently exists separately from ChatActor for background
25/// tasks like memory management.
26pub struct AgentRunner {
27    ai_provider: Arc<dyn AiProvider>,
28    settings: SettingsManager,
29    tools: BTreeMap<String, Arc<dyn ToolExecutor + Send + Sync>>,
30    modules: Vec<Arc<dyn Module>>,
31    steering: SteeringDocuments,
32    prompt_builder: PromptBuilder,
33    context_builder: ContextBuilder,
34}
35
36impl AgentRunner {
37    pub fn new(
38        ai_provider: Arc<dyn AiProvider>,
39        settings: SettingsManager,
40        tools: BTreeMap<String, Arc<dyn ToolExecutor + Send + Sync>>,
41        modules: Vec<Arc<dyn Module>>,
42        steering: SteeringDocuments,
43        prompt_builder: PromptBuilder,
44        context_builder: ContextBuilder,
45    ) -> Self {
46        Self {
47            ai_provider,
48            settings,
49            tools,
50            modules,
51            steering,
52            prompt_builder,
53            context_builder,
54        }
55    }
56
57    /// Run an agent until completion or max iterations.
58    /// The ActiveAgent should already have its conversation populated.
59    /// Returns the result string from complete_task on success.
60    pub async fn run(
61        &self,
62        mut active_agent: ActiveAgent,
63        max_iterations: usize,
64    ) -> Result<String> {
65        for iteration in 0..max_iterations {
66            debug!(iteration, "AgentRunner iteration");
67
68            let (request, _model_settings) = prepare_request(
69                active_agent.agent.as_ref(),
70                &active_agent.conversation,
71                self.ai_provider.as_ref(),
72                self.settings.clone(),
73                &self.steering,
74                Vec::new(),
75                &self.prompt_builder,
76                &self.context_builder,
77                &self.modules,
78            )
79            .await?;
80
81            let response = self.ai_provider.converse(request).await?;
82            log_response_text(&response.content);
83
84            let extraction = extract_all_tool_calls(&response.content);
85            let tool_uses = extraction.tool_calls;
86
87            info!(
88                tool_count = tool_uses.len(),
89                tools = ?tool_uses.iter().map(|t| &t.name).collect::<Vec<_>>(),
90                "Extracted tools from response"
91            );
92
93            // Surface parse errors by adding to conversation for AI to retry
94            if let Some(parse_error) = extraction.xml_parse_error {
95                warn!("XML tool call parse error: {parse_error}");
96                active_agent.conversation.push(Message {
97                    role: MessageRole::User,
98                    content: Content::text_only(format!(
99                        "Error parsing XML tool calls: {}. Please check your XML format and retry.",
100                        parse_error
101                    )),
102                });
103            }
104            if let Some(parse_error) = extraction.json_parse_error {
105                warn!("JSON tool call parse error: {parse_error}");
106                active_agent.conversation.push(Message {
107                    role: MessageRole::User,
108                    content: Content::text_only(format!(
109                        "Error parsing JSON tool calls: {}. Please check your JSON format and retry.",
110                        parse_error
111                    )),
112                });
113            }
114
115            active_agent
116                .conversation
117                .push(Message::assistant(response.content.clone()));
118
119            if tool_uses.is_empty() {
120                warn!("AgentRunner completed - no more tool calls, but never got complete_task. This is likely a model error.");
121                break;
122            }
123
124            let mut tool_results = Vec::new();
125            let mut completion_result: Option<(bool, String)> = None;
126            for tool_use in &tool_uses {
127                info!(tool = %tool_use.name, args = %tool_use.arguments, "Runner calling tool");
128                let result = self
129                    .execute_tool(&tool_use.name, &tool_use.id, &tool_use.arguments)
130                    .await;
131
132                let (content, is_error) = match &result {
133                    Ok((output, _)) => (output.clone(), false),
134                    Err(e) => (format!("Error: {e:?}"), true),
135                };
136
137                if let Ok((_, tool_output)) = &result {
138                    completion_result = completion_result.or(Self::extract_completion(tool_output));
139                }
140
141                let result_preview: String = content.chars().take(300).collect();
142                info!(tool = %tool_use.name, result = %result_preview, is_error, "Tool result");
143
144                tool_results.push(ContentBlock::ToolResult(ToolResultData {
145                    tool_use_id: tool_use.id.to_string(),
146                    content,
147                    is_error,
148                }));
149            }
150
151            active_agent
152                .conversation
153                .push(Message::user(Content::new(tool_results)));
154
155            if let Some((success, result)) = completion_result {
156                if success {
157                    debug!("AgentRunner completed via complete_task");
158                    return Ok(result);
159                }
160                return Err(anyhow!("Task failed: {}", result));
161            }
162        }
163
164        Err(anyhow!(
165            "Agent did not complete task within {} iterations",
166            max_iterations
167        ))
168    }
169
170    fn extract_completion(output: &ToolOutput) -> Option<(bool, String)> {
171        if let ToolOutput::PopAgent { success, result } = output {
172            Some((*success, result.clone()))
173        } else {
174            None
175        }
176    }
177
178    async fn execute_tool(
179        &self,
180        name: &str,
181        tool_use_id: &str,
182        arguments: &serde_json::Value,
183    ) -> Result<(String, ToolOutput)> {
184        debug!(name, "Executing tool");
185
186        let executor = self
187            .tools
188            .get(name)
189            .ok_or_else(|| anyhow!("No executor for tool: {}", name))?;
190
191        let schema = executor.input_schema();
192        let coerced_arguments = crate::tools::fuzzy_json::coerce_to_schema(arguments, &schema)
193            .map_err(|e| anyhow!("Failed to coerce tool arguments: {e:?}"))?;
194        let request = ToolRequest::new(coerced_arguments, tool_use_id.to_string());
195        let handle = executor.process(&request).await?;
196
197        let tool_output = handle.execute().await;
198
199        let output_string = match &tool_output {
200            ToolOutput::Result {
201                content, is_error, ..
202            } => {
203                if *is_error {
204                    return Err(anyhow!("{}", content));
205                }
206                content.clone()
207            }
208            ToolOutput::PopAgent { success, result } => {
209                format!("Task completed (success={}): {}", success, result)
210            }
211            ToolOutput::PushAgent { .. } | ToolOutput::PromptUser { .. } => {
212                return Err(anyhow!(
213                    "Tool '{}' returned unsupported action for AgentRunner context",
214                    name
215                ))
216            }
217        };
218
219        Ok((output_string, tool_output))
220    }
221}
222
223fn log_response_text(content: &Content) {
224    for block in content.blocks() {
225        let ContentBlock::Text(text) = block else {
226            continue;
227        };
228        let preview: String = text.chars().take(500).collect();
229        info!(response = %preview, "Agent reasoning");
230    }
231}