Skip to main content

limit_cli/
agent_bridge.rs

1use crate::error::CliError;
2use crate::system_prompt::get_system_prompt;
3use crate::tools::{
4    AstGrepTool, BashTool, BrowserTool, FileEditTool, FileReadTool, FileWriteTool, GitAddTool,
5    GitCloneTool, GitCommitTool, GitDiffTool, GitLogTool, GitPullTool, GitPushTool, GitStatusTool,
6    WebFetchTool, WebSearchTool,
7};
8use chrono::Datelike;
9use futures::StreamExt;
10use limit_agent::executor::{ToolCall, ToolExecutor};
11use limit_agent::registry::ToolRegistry;
12use limit_llm::apply_cache_control;
13use limit_llm::providers::LlmProvider;
14use limit_llm::types::{Message, MessageContent, Role, Tool as LlmTool, ToolCall as LlmToolCall};
15use limit_llm::ModelHandoff;
16use limit_llm::ProviderFactory;
17use limit_llm::ProviderResponseChunk;
18use limit_llm::Summarizer;
19use limit_llm::TrackingDb;
20use serde_json::json;
21use std::cell::RefCell;
22use std::collections::hash_map::DefaultHasher;
23use std::hash::{Hash, Hasher};
24use tokio::sync::mpsc;
25use tokio_util::sync::CancellationToken;
26use tracing::{debug, instrument, trace};
27
28/// Event types for streaming from agent to REPL
29#[derive(Debug, Clone)]
30#[allow(dead_code)]
31pub enum AgentEvent {
32    Thinking {
33        operation_id: u64,
34    },
35    ToolStart {
36        operation_id: u64,
37        name: String,
38        args: serde_json::Value,
39    },
40    ToolComplete {
41        operation_id: u64,
42        name: String,
43        result: String,
44    },
45    ResponseStart {
46        operation_id: u64,
47    },
48    ContentChunk {
49        operation_id: u64,
50        chunk: String,
51    },
52    Done {
53        operation_id: u64,
54    },
55    Cancelled {
56        operation_id: u64,
57    },
58    Error {
59        operation_id: u64,
60        message: String,
61    },
62    TokenUsage {
63        operation_id: u64,
64        input_tokens: u64,
65        output_tokens: u64,
66    },
67}
68
69/// Result from processing a message
70#[derive(Debug, Clone, Default)]
71pub struct ProcessResult {
72    pub response: String,
73    pub input_tokens: u64,
74    pub output_tokens: u64,
75}
76
77/// Maximum number of recent tool calls to keep for deduplication
78const MAX_RECENT_TOOL_CALLS: usize = 20;
79
80/// Maximum characters in tool result before truncation
81const MAX_TOOL_RESULT_CHARS: usize = 10000;
82
83/// Bridge connecting limit-cli REPL to limit-agent executor and limit-llm client
84pub struct AgentBridge {
85    /// LLM client for communicating with LLM providers
86    llm_client: Box<dyn LlmProvider>,
87    /// Tool executor for running tool calls
88    executor: ToolExecutor,
89    /// List of registered tool names
90    tool_names: Vec<&'static str>,
91    /// Configuration loaded from ~/.limit/config.toml
92    config: limit_llm::Config,
93    /// Event sender for streaming events to REPL
94    event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
95    /// Token usage tracking database
96    tracking_db: TrackingDb,
97    cancellation_token: Option<CancellationToken>,
98    operation_id: u64,
99    recent_tool_calls: RefCell<Vec<(String, u64)>>,
100    handoff: ModelHandoff,
101    summarizer: Option<Summarizer>,
102    last_context_percent: RefCell<usize>,
103}
104
105impl AgentBridge {
106    /// Create a new AgentBridge with the given configuration
107    /// Create a new AgentBridge with the given configuration
108    /// Create a new AgentBridge with the given configuration
109    ///
110    /// # Arguments
111    /// * `config` - LLM configuration (API key, model, etc.)
112    ///
113    /// # Returns
114    /// A new AgentBridge instance or an error if initialization fails
115    pub fn new(config: limit_llm::Config) -> Result<Self, CliError> {
116        let tracking_db = TrackingDb::new().map_err(|e| CliError::ConfigError(e.to_string()))?;
117        Self::with_tracking_db(config, tracking_db)
118    }
119
120    /// Create a new AgentBridge for testing with an in-memory tracking database
121    #[cfg(test)]
122    pub fn new_for_test(config: limit_llm::Config) -> Result<Self, CliError> {
123        let tracking_db =
124            TrackingDb::new_in_memory().map_err(|e| CliError::ConfigError(e.to_string()))?;
125        Self::with_tracking_db(config, tracking_db)
126    }
127
128    /// Create a new AgentBridge with a custom tracking database
129    pub fn with_tracking_db(
130        config: limit_llm::Config,
131        tracking_db: TrackingDb,
132    ) -> Result<Self, CliError> {
133        let llm_client = ProviderFactory::create_provider(&config)
134            .map_err(|e| CliError::ConfigError(e.to_string()))?;
135
136        let mut tool_registry = ToolRegistry::new();
137        Self::register_tools(&mut tool_registry, &config);
138
139        let executor = ToolExecutor::new(tool_registry);
140
141        let tool_names = vec![
142            "file_read",
143            "file_write",
144            "file_edit",
145            "bash",
146            "git_status",
147            "git_diff",
148            "git_log",
149            "git_add",
150            "git_commit",
151            "git_push",
152            "git_pull",
153            "git_clone",
154            // "grep",           // TEMP: disabled for ast_grep testing
155            "ast_grep",
156            // "lsp",            // TEMP: disabled for ast_grep testing
157            "web_search",
158            "web_fetch",
159            "browser",
160        ];
161
162        Ok(Self {
163            llm_client,
164            executor,
165            tool_names,
166            config,
167            event_tx: None,
168            tracking_db,
169            cancellation_token: None,
170            operation_id: 0,
171            recent_tool_calls: RefCell::new(Vec::new()),
172            handoff: ModelHandoff::new(),
173            summarizer: None,
174            last_context_percent: RefCell::new(0),
175        })
176    }
177
178    /// Set the event channel sender for streaming events
179    pub fn set_event_tx(&mut self, tx: mpsc::UnboundedSender<AgentEvent>) {
180        self.event_tx = Some(tx);
181    }
182
183    /// Set the cancellation token and operation ID for this operation
184    pub fn set_cancellation_token(&mut self, token: CancellationToken, operation_id: u64) {
185        debug!("set_cancellation_token: operation_id={}", operation_id);
186        self.cancellation_token = Some(token);
187        self.operation_id = operation_id;
188    }
189
190    /// Clear the cancellation token
191    pub fn clear_cancellation_token(&mut self) {
192        self.cancellation_token = None;
193    }
194
195    async fn maybe_compact(&self, messages: &mut Vec<Message>) {
196        if !self.config.compaction.enabled {
197            return;
198        }
199
200        let context_window: usize = 200_000;
201        let target_tokens = (context_window * 6) / 10;
202        let warn_tokens = context_window / 2;
203        let current_tokens = self.handoff.count_total_tokens(messages);
204        let current_pct = (current_tokens * 100) / context_window;
205
206        if current_tokens > warn_tokens && current_tokens <= target_tokens {
207            let last_pct = *self.last_context_percent.borrow();
208            if current_pct != last_pct {
209                tracing::warn!(
210                    "Context at {}% ({} tokens). Compaction will trigger at 60%.",
211                    current_pct,
212                    current_tokens
213                );
214                *self.last_context_percent.borrow_mut() = current_pct;
215            }
216        } else if current_tokens <= warn_tokens {
217            *self.last_context_percent.borrow_mut() = 0;
218        }
219
220        if current_tokens <= target_tokens {
221            return;
222        }
223
224        let keep_recent = self.config.compaction.keep_recent_tokens as usize;
225
226        if let Some(ref summarizer) = self.summarizer {
227            if let Some(cut_idx) = self.handoff.find_cut_point(messages, keep_recent) {
228                if cut_idx > 0 {
229                    let to_summarize = &messages[..cut_idx];
230
231                    match summarizer.summarize(to_summarize, None).await {
232                        Ok(summary) => {
233                            let summary_msg = Message {
234                                role: Role::User,
235                                content: Some(MessageContent::text(format!(
236                                    "<context_summary>\n{}\n</context_summary>",
237                                    summary
238                                ))),
239                                tool_calls: None,
240                                tool_call_id: None,
241                                cache_control: None,
242                            };
243
244                            let mut new_messages = vec![summary_msg];
245                            new_messages.extend(messages[cut_idx..].to_vec());
246                            *messages = new_messages;
247
248                            debug!(
249                                "Compacted via summarization: {} messages -> {} messages",
250                                cut_idx,
251                                messages.len()
252                            );
253                            return;
254                        }
255                        Err(e) => {
256                            debug!("Summarization failed, falling back to truncation: {}", e);
257                        }
258                    }
259                }
260            }
261        }
262
263        let compacted = self.handoff.compact_messages(messages, target_tokens);
264        *messages = compacted;
265    }
266
267    fn hash_tool_call(tool_name: &str, args: &serde_json::Value) -> u64 {
268        let mut hasher = DefaultHasher::new();
269        tool_name.hash(&mut hasher);
270        args.to_string().hash(&mut hasher);
271        hasher.finish()
272    }
273
274    fn check_duplicate_tool_call(&self, tool_name: &str, args: &serde_json::Value) -> bool {
275        let hash = Self::hash_tool_call(tool_name, args);
276        self.recent_tool_calls
277            .borrow()
278            .iter()
279            .any(|(name, h)| *name == tool_name && *h == hash)
280    }
281
282    fn record_tool_call(&self, tool_name: &str, args: &serde_json::Value) {
283        let hash = Self::hash_tool_call(tool_name, args);
284        self.recent_tool_calls
285            .borrow_mut()
286            .push((tool_name.to_string(), hash));
287        if self.recent_tool_calls.borrow().len() > MAX_RECENT_TOOL_CALLS {
288            self.recent_tool_calls.borrow_mut().remove(0);
289        }
290    }
291
292    /// Register all CLI tools into the tool registry
293    fn register_tools(registry: &mut ToolRegistry, config: &limit_llm::Config) {
294        // File tools
295        registry
296            .register(FileReadTool::new())
297            .expect("Failed to register file_read");
298        registry
299            .register(FileWriteTool::new())
300            .expect("Failed to register file_write");
301        registry
302            .register(FileEditTool::new())
303            .expect("Failed to register file_edit");
304
305        // Bash tool
306        registry
307            .register(BashTool::new())
308            .expect("Failed to register bash");
309
310        // Git tools
311        registry
312            .register(GitStatusTool::new())
313            .expect("Failed to register git_status");
314        registry
315            .register(GitDiffTool::new())
316            .expect("Failed to register git_diff");
317        registry
318            .register(GitLogTool::new())
319            .expect("Failed to register git_log");
320        registry
321            .register(GitAddTool::new())
322            .expect("Failed to register git_add");
323        registry
324            .register(GitCommitTool::new())
325            .expect("Failed to register git_commit");
326        registry
327            .register(GitPushTool::new())
328            .expect("Failed to register git_push");
329        registry
330            .register(GitPullTool::new())
331            .expect("Failed to register git_pull");
332        registry
333            .register(GitCloneTool::new())
334            .expect("Failed to register git_clone");
335
336        // Analysis tools
337        // TEMP: disabled for ast_grep testing
338        // registry
339        //     .register(GrepTool::new())
340        //     .expect("Failed to register grep");
341        registry
342            .register(AstGrepTool::new())
343            .expect("Failed to register ast_grep");
344        // registry
345        //     .register(LspTool::new())
346        //     .expect("Failed to register lsp");
347
348        // Web tools
349        registry
350            .register(WebSearchTool::new())
351            .expect("Failed to register web_search");
352        registry
353            .register(WebFetchTool::new())
354            .expect("Failed to register web_fetch");
355
356        // Browser tool
357        let browser_config = crate::tools::browser::BrowserConfig::from(&config.browser);
358        registry
359            .register(BrowserTool::with_config(browser_config))
360            .expect("Failed to register browser");
361    }
362
363    /// Process a user message through the LLM and execute any tool calls
364    ///
365    /// # Arguments
366    /// * `user_input` - The user's message to process
367    /// * `messages` - The conversation history (will be updated in place)
368    ///
369    /// # Returns
370    /// The final response from the LLM or an error
371    #[instrument(skip(self, _messages, user_input))]
372    pub async fn process_message(
373        &mut self,
374        user_input: &str,
375        _messages: &mut Vec<Message>,
376    ) -> Result<ProcessResult, CliError> {
377        // Add system message if this is the first message in the conversation
378        // Note: Some providers (z.ai) don't support system role, but OpenAI-compatible APIs generally do
379        if _messages.is_empty() {
380            let system_message = Message {
381                role: Role::System,
382                content: Some(MessageContent::text(get_system_prompt())),
383                tool_calls: None,
384                tool_call_id: None,
385                cache_control: None,
386            };
387            _messages.push(system_message);
388        }
389
390        // Add user message to history
391        let user_message = Message {
392            role: Role::User,
393            content: Some(MessageContent::text(user_input.to_string())),
394            tool_calls: None,
395            tool_call_id: None,
396            cache_control: None,
397        };
398        _messages.push(user_message);
399
400        // Get tool definitions
401        let tool_definitions = self.get_tool_definitions();
402
403        // Main processing loop
404        let mut full_response = String::new();
405        let mut tool_calls: Vec<LlmToolCall> = Vec::new();
406        let max_iterations = self
407            .config
408            .providers
409            .get(&self.config.provider)
410            .map(|p| p.max_iterations)
411            .unwrap_or(100); // Allow enough iterations for complex tasks
412        let mut iteration = 0;
413        let mut consecutive_no_exec = 0;
414        let mut total_input_tokens: u64 = 0;
415        let mut total_output_tokens: u64 = 0;
416
417        while max_iterations == 0 || iteration < max_iterations {
418            iteration += 1;
419            debug!("Agent loop iteration {}", iteration);
420
421            // Send thinking event
422            debug!(
423                "Sending Thinking event with operation_id={}",
424                self.operation_id
425            );
426            self.send_event(AgentEvent::Thinking {
427                operation_id: self.operation_id,
428            });
429
430            let request_start = std::time::Instant::now();
431
432            self.maybe_compact(_messages).await;
433
434            let cached_messages = apply_cache_control(_messages, &self.config.cache);
435            let cache_count = cached_messages
436                .iter()
437                .filter(|m| m.cache_control.is_some())
438                .count();
439            debug!(
440                "Cache control applied to {} of {} messages",
441                cache_count,
442                cached_messages.len()
443            );
444
445            let mut stream = self
446                .llm_client
447                .send(cached_messages, tool_definitions.clone())
448                .await
449                .map_err(|e| CliError::ConfigError(e.to_string()))?;
450
451            tool_calls.clear();
452            let mut current_content = String::new();
453            // Track tool calls: (id) -> (name, args)
454            let mut accumulated_calls: std::collections::HashMap<
455                String,
456                (String, serde_json::Value),
457            > = std::collections::HashMap::new();
458
459            self.send_event(AgentEvent::ResponseStart {
460                operation_id: self.operation_id,
461            });
462
463            // Process stream chunks with cancellation support
464            loop {
465                // Check for cancellation FIRST (before waiting for stream)
466                if let Some(ref token) = self.cancellation_token {
467                    if token.is_cancelled() {
468                        debug!("Operation cancelled by user (pre-stream check)");
469                        self.send_event(AgentEvent::Cancelled {
470                            operation_id: self.operation_id,
471                        });
472                        return Err(CliError::ConfigError(
473                            "Operation cancelled by user".to_string(),
474                        ));
475                    }
476                }
477
478                // Use tokio::select! to check cancellation while waiting for stream
479                // Using cancellation_token.cancelled() for immediate cancellation detection
480                let chunk_result = if let Some(ref token) = self.cancellation_token {
481                    tokio::select! {
482                        chunk = stream.next() => chunk,
483                        _ = token.cancelled() => {
484                            debug!("Operation cancelled via token while waiting for stream");
485                            self.send_event(AgentEvent::Cancelled {
486                                operation_id: self.operation_id,
487                            });
488                            return Err(CliError::ConfigError("Operation cancelled by user".to_string()));
489                        }
490                    }
491                } else {
492                    stream.next().await
493                };
494
495                let Some(chunk_result) = chunk_result else {
496                    // Stream ended
497                    break;
498                };
499
500                match chunk_result {
501                    Ok(ProviderResponseChunk::ContentDelta(text)) => {
502                        current_content.push_str(&text);
503                        trace!(
504                            "ContentDelta: {} chars (total: {})",
505                            text.len(),
506                            current_content.len()
507                        );
508                        self.send_event(AgentEvent::ContentChunk {
509                            operation_id: self.operation_id,
510                            chunk: text,
511                        });
512                    }
513                    Ok(ProviderResponseChunk::ReasoningDelta(_)) => {
514                        // Ignore reasoning chunks for now
515                    }
516                    Ok(ProviderResponseChunk::ToolCallDelta {
517                        id,
518                        name,
519                        arguments,
520                    }) => {
521                        trace!(
522                            "ToolCallDelta: id={}, name={}, args_len={}",
523                            id,
524                            name,
525                            arguments.to_string().len()
526                        );
527                        // Store/merge tool call arguments
528                        accumulated_calls.insert(id.clone(), (name.clone(), arguments.clone()));
529                    }
530                    Ok(ProviderResponseChunk::Done(usage)) => {
531                        let duration_ms = request_start.elapsed().as_millis() as u64;
532                        let cost =
533                            calculate_cost(self.model(), usage.input_tokens, usage.output_tokens);
534
535                        if usage.cache_read_tokens > 0 || usage.cache_write_tokens > 0 {
536                            debug!(
537                                "Cache tokens: read={}, write={}, input={}, output={}",
538                                usage.cache_read_tokens,
539                                usage.cache_write_tokens,
540                                usage.input_tokens,
541                                usage.output_tokens
542                            );
543                        } else {
544                            debug!(
545                                "No cache tokens in response: input={}, output={}",
546                                usage.input_tokens, usage.output_tokens
547                            );
548                        }
549
550                        let _ = self.tracking_db.track_request(
551                            self.model(),
552                            usage.input_tokens,
553                            usage.output_tokens,
554                            usage.cache_read_tokens,
555                            usage.cache_write_tokens,
556                            cost,
557                            duration_ms,
558                        );
559                        total_input_tokens += usage.input_tokens;
560                        total_output_tokens += usage.output_tokens;
561                        // Emit token usage event for TUI display
562                        self.send_event(AgentEvent::TokenUsage {
563                            operation_id: self.operation_id,
564                            input_tokens: usage.input_tokens,
565                            output_tokens: usage.output_tokens,
566                        });
567                        break;
568                    }
569                    Err(e) => {
570                        let error_msg = format!("LLM error: {}", e);
571                        self.send_event(AgentEvent::Error {
572                            operation_id: self.operation_id,
573                            message: error_msg.clone(),
574                        });
575                        return Err(CliError::ConfigError(error_msg));
576                    }
577                }
578            }
579
580            // Convert accumulated calls to Vec<ToolCall> and filter invalid ones
581            let raw_tool_calls: Vec<LlmToolCall> = accumulated_calls
582                .into_iter()
583                .map(|(id, (name, args))| LlmToolCall {
584                    id,
585                    tool_type: "function".to_string(),
586                    function: limit_llm::types::FunctionCall {
587                        name,
588                        arguments: args.to_string(),
589                    },
590                })
591                .collect();
592
593            // Filter out invalid tool calls (empty names or unregistered tools)
594            let raw_count = raw_tool_calls.len();
595            tool_calls = raw_tool_calls
596                .into_iter()
597                .filter(|tc| {
598                    let is_valid = !tc.function.name.is_empty()
599                        && self.tool_names.contains(&tc.function.name.as_str());
600                    if !is_valid {
601                        debug!(
602                            "Filtered invalid tool call: id={}, name='{}'",
603                            tc.id, tc.function.name
604                        );
605                    }
606                    is_valid
607                })
608                .collect();
609
610            if tool_calls.len() != raw_count {
611                debug!(
612                    "Filtered {}/{} tool calls (empty names or unregistered tools)",
613                    raw_count - tool_calls.len(),
614                    raw_count
615                );
616            }
617
618            // BUG FIX: Don't accumulate content across iterations
619            // Only store content from the current iteration
620            // If there are tool calls, we'll continue the loop and the LLM will see the tool results
621            // If there are NO tool calls, this is the final response
622            full_response = current_content.clone();
623
624            trace!(
625                "After iter {}: content.len()={}, tool_calls={}, response.len()={}",
626                iteration,
627                current_content.len(),
628                tool_calls.len(),
629                full_response.len()
630            );
631
632            // If no tool calls, we're done
633            if tool_calls.is_empty() {
634                debug!("No tool calls, breaking loop after iteration {}", iteration);
635                break;
636            }
637
638            trace!(
639                "Tool calls found (count={}), continuing to iteration {}",
640                tool_calls.len(),
641                iteration + 1
642            );
643
644            // Execute tool calls - add assistant message with tool_calls
645            // Note: Per OpenAI API spec, when tool_calls are present, content should be null
646            let assistant_message = Message {
647                role: Role::Assistant,
648                content: None, // Don't include content when tool_calls are present
649                tool_calls: Some(tool_calls.clone()),
650                tool_call_id: None,
651                cache_control: None,
652            };
653            _messages.push(assistant_message);
654
655            // Check for duplicate tool calls and filter them out
656            let mut filtered_calls = Vec::new();
657            let mut duplicate_calls = Vec::new();
658            let mut calls_to_record = Vec::new();
659            for tc in &tool_calls {
660                let args: serde_json::Value =
661                    serde_json::from_str(&tc.function.arguments).unwrap_or_default();
662                if self.check_duplicate_tool_call(&tc.function.name, &args) {
663                    duplicate_calls.push((tc.id.clone(), tc.function.name.clone(), args));
664                } else {
665                    calls_to_record.push((tc.function.name.clone(), args.clone()));
666                    filtered_calls.push(tc.clone());
667                }
668            }
669
670            // Record new tool calls
671            for (name, args) in calls_to_record {
672                self.record_tool_call(&name, &args);
673            }
674
675            // Report duplicate calls to the model
676            if !duplicate_calls.is_empty() {
677                for (id, name, args) in &duplicate_calls {
678                    debug!(
679                        "Duplicate tool call blocked: {} with args: {}",
680                        name,
681                        serde_json::to_string(&args).unwrap_or_default()
682                    );
683                    self.send_event(AgentEvent::ToolStart {
684                        operation_id: self.operation_id,
685                        name: name.clone(),
686                        args: args.clone(),
687                    });
688                    let duplicate_msg = json!({
689                        "error": "DUPLICATE_CALL_BLOCKED",
690                        "message": format!(
691                            "You already called {} with these exact arguments in a recent turn. \
692                            Check your conversation history for the previous result. \
693                            Do not repeat the same query - use the existing data instead.",
694                            name
695                        ),
696                        "tool": name,
697                        "args": args
698                    });
699                    let result_str = serde_json::to_string(&duplicate_msg).unwrap_or_default();
700                    self.send_event(AgentEvent::ToolComplete {
701                        operation_id: self.operation_id,
702                        name: name.clone(),
703                        result: result_str.clone(),
704                    });
705                    let tool_result_message = Message {
706                        role: Role::Tool,
707                        content: Some(MessageContent::text(result_str)),
708                        tool_calls: None,
709                        tool_call_id: Some(id.clone()),
710                        cache_control: None,
711                    };
712                    _messages.push(tool_result_message);
713                }
714            }
715
716            // Send ToolStart event for each non-duplicate tool BEFORE execution
717            for tc in &filtered_calls {
718                let args: serde_json::Value =
719                    serde_json::from_str(&tc.function.arguments).unwrap_or_default();
720                debug!(
721                    "ToolStart: {} with args: {}",
722                    tc.function.name,
723                    serde_json::to_string(&args).unwrap_or_default()
724                );
725                self.send_event(AgentEvent::ToolStart {
726                    operation_id: self.operation_id,
727                    name: tc.function.name.clone(),
728                    args,
729                });
730            }
731            // Execute tools (only non-duplicates)
732            let filtered_executor_calls: Vec<ToolCall> = filtered_calls
733                .iter()
734                .map(|tc| {
735                    let args: serde_json::Value =
736                        serde_json::from_str(&tc.function.arguments).unwrap_or_default();
737                    ToolCall::new(&tc.id, &tc.function.name, args)
738                })
739                .collect();
740            let results = self.executor.execute_tools(filtered_executor_calls).await;
741            let results_count = results.len();
742
743            // Add tool results to messages (OpenAI format: role=tool, tool_call_id, content)
744            for result in results {
745                let tool_call = filtered_calls.iter().find(|tc| tc.id == result.call_id);
746                if let Some(tool_call) = tool_call {
747                    let output_json = match &result.output {
748                        Ok(value) => {
749                            serde_json::to_string(value).unwrap_or_else(|_| "{}".to_string())
750                        }
751                        Err(e) => json!({ "error": e.to_string() }).to_string(),
752                    };
753
754                    debug!(
755                        "ToolComplete: {} result ({} chars): {}",
756                        tool_call.function.name,
757                        output_json.len(),
758                        output_json
759                    );
760
761                    self.send_event(AgentEvent::ToolComplete {
762                        operation_id: self.operation_id,
763                        name: tool_call.function.name.clone(),
764                        result: output_json.clone(),
765                    });
766
767                    // OpenAI tool result format
768                    let tool_result_message = Message {
769                        role: Role::Tool,
770                        content: Some(MessageContent::text(truncate_tool_result(&output_json))),
771                        tool_calls: None,
772                        tool_call_id: Some(result.call_id),
773                        cache_control: None,
774                    };
775                    _messages.push(tool_result_message);
776                }
777            }
778
779            // Safety valve: break if too many consecutive iterations without tool execution
780            if results_count == 0 && !tool_calls.is_empty() {
781                consecutive_no_exec += 1;
782                if consecutive_no_exec >= 3 {
783                    debug!(
784                        "Safety valve: {} consecutive iterations with tool calls but no executions",
785                        consecutive_no_exec
786                    );
787                    break;
788                }
789            } else {
790                consecutive_no_exec = 0;
791            }
792        }
793
794        // If we hit max iterations, make one final request to get a response (no tools = forced text)
795        // IMPORTANT: Only do this if max_iterations > 0 (0 means unlimited, so we never "hit" the limit)
796        if max_iterations > 0 && iteration >= max_iterations && !_messages.is_empty() {
797            debug!("Making final LLM call after hitting max iterations (forcing text response)");
798
799            // Add constraint message to force text response
800            let constraint_message = Message {
801                role: Role::User,
802                content: Some(MessageContent::text(
803                    "We've reached the iteration limit. Please provide a summary of:\n\
804                    1. What you've completed so far\n\
805                    2. What remains to be done\n\
806                    3. Recommended next steps for the user to continue",
807                )),
808                tool_calls: None,
809                tool_call_id: None,
810                cache_control: None,
811            };
812            _messages.push(constraint_message);
813
814            // Send with NO tools to force text response
815            let no_tools: Vec<LlmTool> = vec![];
816            let mut stream = self
817                .llm_client
818                .send(_messages.clone(), no_tools)
819                .await
820                .map_err(|e| CliError::ConfigError(e.to_string()))?;
821
822            // BUG FIX: Replace full_response instead of appending
823            full_response.clear();
824            self.send_event(AgentEvent::ResponseStart {
825                operation_id: self.operation_id,
826            });
827            loop {
828                // Check for cancellation FIRST (before waiting for stream)
829                if let Some(ref token) = self.cancellation_token {
830                    if token.is_cancelled() {
831                        debug!("Operation cancelled by user in final loop (pre-stream check)");
832                        self.send_event(AgentEvent::Cancelled {
833                            operation_id: self.operation_id,
834                        });
835                        return Err(CliError::ConfigError(
836                            "Operation cancelled by user".to_string(),
837                        ));
838                    }
839                }
840
841                // Use tokio::select! to check cancellation while waiting for stream
842                // Using cancellation_token.cancelled() for immediate cancellation detection
843                let chunk_result = if let Some(ref token) = self.cancellation_token {
844                    tokio::select! {
845                        chunk = stream.next() => chunk,
846                        _ = token.cancelled() => {
847                            debug!("Operation cancelled via token while waiting for stream");
848                            self.send_event(AgentEvent::Cancelled {
849                                operation_id: self.operation_id,
850                            });
851                            return Err(CliError::ConfigError("Operation cancelled by user".to_string()));
852                        }
853                    }
854                } else {
855                    stream.next().await
856                };
857
858                let Some(chunk_result) = chunk_result else {
859                    // Stream ended
860                    break;
861                };
862
863                match chunk_result {
864                    Ok(ProviderResponseChunk::ContentDelta(text)) => {
865                        full_response.push_str(&text);
866                        self.send_event(AgentEvent::ContentChunk {
867                            operation_id: self.operation_id,
868                            chunk: text,
869                        });
870                    }
871                    Ok(ProviderResponseChunk::Done(_)) => {
872                        break;
873                    }
874                    Err(e) => {
875                        debug!("Error in final LLM call: {}", e);
876                        break;
877                    }
878                    _ => {}
879                }
880            }
881        }
882
883        // IMPORTANT: Add final assistant response to message history for session persistence
884        // This is crucial for session export/share to work correctly
885        // Only add if we have content AND we haven't already added this response
886        if !full_response.is_empty() {
887            // Find the last assistant message and check if it has content
888            // If it has tool_calls but no content, UPDATE it instead of adding a new one
889            // This prevents accumulation of empty assistant messages in the history
890            let last_assistant_idx = _messages.iter().rposition(|m| m.role == Role::Assistant);
891
892            if let Some(idx) = last_assistant_idx {
893                let last_assistant = &mut _messages[idx];
894
895                // If the last assistant message has no content (tool_calls only), update it
896                if last_assistant.content.is_none()
897                    || last_assistant
898                        .content
899                        .as_ref()
900                        .map(|c| c.to_text().is_empty())
901                        .unwrap_or(true)
902                {
903                    last_assistant.content = Some(MessageContent::text(full_response.clone()));
904                    debug!("Updated last assistant message with final response content");
905                } else {
906                    // Last assistant already has content, this shouldn't happen normally
907                    // but we add a new message to be safe
908                    debug!("Last assistant already has content, adding new message");
909                    let final_assistant_message = Message {
910                        role: Role::Assistant,
911                        content: Some(MessageContent::text(full_response.clone())),
912                        tool_calls: None,
913                        tool_call_id: None,
914                        cache_control: None,
915                    };
916                    _messages.push(final_assistant_message);
917                }
918            } else {
919                // No assistant message found, add a new one
920                debug!("No assistant message found, adding new message");
921                let final_assistant_message = Message {
922                    role: Role::Assistant,
923                    content: Some(MessageContent::text(full_response.clone())),
924                    tool_calls: None,
925                    tool_call_id: None,
926                    cache_control: None,
927                };
928                _messages.push(final_assistant_message);
929            }
930        }
931
932        self.send_event(AgentEvent::Done {
933            operation_id: self.operation_id,
934        });
935        Ok(ProcessResult {
936            response: full_response,
937            input_tokens: total_input_tokens,
938            output_tokens: total_output_tokens,
939        })
940    }
941
942    /// Get tool definitions formatted for the LLM
943    pub fn get_tool_definitions(&self) -> Vec<LlmTool> {
944        self.tool_names
945            .iter()
946            .map(|name| {
947                let (description, parameters) = Self::get_tool_schema(name);
948                LlmTool {
949                    tool_type: "function".to_string(),
950                    function: limit_llm::types::ToolFunction {
951                        name: name.to_string(),
952                        description,
953                        parameters,
954                    },
955                }
956            })
957            .collect()
958    }
959
960    /// Get the schema (description and parameters) for a tool
961    fn get_tool_schema(name: &str) -> (String, serde_json::Value) {
962        match name {
963            "file_read" => (
964                "Read the contents of a file".to_string(),
965                json!({
966                    "type": "object",
967                    "properties": {
968                        "path": {
969                            "type": "string",
970                            "description": "Path to the file to read"
971                        }
972                    },
973                    "required": ["path"]
974                }),
975            ),
976            "file_write" => (
977                "Write content to a file, creating parent directories if needed".to_string(),
978                json!({
979                    "type": "object",
980                    "properties": {
981                        "path": {
982                            "type": "string",
983                            "description": "Path to the file to write"
984                        },
985                        "content": {
986                            "type": "string",
987                            "description": "Content to write to the file"
988                        }
989                    },
990                    "required": ["path", "content"]
991                }),
992            ),
993            "file_edit" => (
994                "Replace text in a file with new text".to_string(),
995                json!({
996                    "type": "object",
997                    "properties": {
998                        "path": {
999                            "type": "string",
1000                            "description": "Path to the file to edit"
1001                        },
1002                        "old_text": {
1003                            "type": "string",
1004                            "description": "Text to find and replace"
1005                        },
1006                        "new_text": {
1007                            "type": "string",
1008                            "description": "New text to replace with"
1009                        }
1010                    },
1011                    "required": ["path", "old_text", "new_text"]
1012                }),
1013            ),
1014            "bash" => (
1015                "Execute a bash command in a shell".to_string(),
1016                json!({
1017                    "type": "object",
1018                    "properties": {
1019                        "command": {
1020                            "type": "string",
1021                            "description": "Bash command to execute"
1022                        },
1023                        "workdir": {
1024                            "type": "string",
1025                            "description": "Working directory (default: current directory)"
1026                        },
1027                        "timeout": {
1028                            "type": "integer",
1029                            "description": "Timeout in seconds (default: 60)"
1030                        }
1031                    },
1032                    "required": ["command"]
1033                }),
1034            ),
1035            "git_status" => (
1036                "Get git repository status".to_string(),
1037                json!({
1038                    "type": "object",
1039                    "properties": {},
1040                    "required": []
1041                }),
1042            ),
1043            "git_diff" => (
1044                "Get git diff".to_string(),
1045                json!({
1046                    "type": "object",
1047                    "properties": {},
1048                    "required": []
1049                }),
1050            ),
1051            "git_log" => (
1052                "Get git commit log".to_string(),
1053                json!({
1054                    "type": "object",
1055                    "properties": {
1056                        "count": {
1057                            "type": "integer",
1058                            "description": "Number of commits to show (default: 10)"
1059                        }
1060                    },
1061                    "required": []
1062                }),
1063            ),
1064            "git_add" => (
1065                "Add files to git staging area".to_string(),
1066                json!({
1067                    "type": "object",
1068                    "properties": {
1069                        "files": {
1070                            "type": "array",
1071                            "items": {"type": "string"},
1072                            "description": "List of file paths to add"
1073                        }
1074                    },
1075                    "required": ["files"]
1076                }),
1077            ),
1078            "git_commit" => (
1079                "Create a git commit".to_string(),
1080                json!({
1081                    "type": "object",
1082                    "properties": {
1083                        "message": {
1084                            "type": "string",
1085                            "description": "Commit message"
1086                        }
1087                    },
1088                    "required": ["message"]
1089                }),
1090            ),
1091            "git_push" => (
1092                "Push commits to remote repository".to_string(),
1093                json!({
1094                    "type": "object",
1095                    "properties": {
1096                        "remote": {
1097                            "type": "string",
1098                            "description": "Remote name (default: origin)"
1099                        },
1100                        "branch": {
1101                            "type": "string",
1102                            "description": "Branch name (default: current branch)"
1103                        }
1104                    },
1105                    "required": []
1106                }),
1107            ),
1108            "git_pull" => (
1109                "Pull changes from remote repository".to_string(),
1110                json!({
1111                    "type": "object",
1112                    "properties": {
1113                        "remote": {
1114                            "type": "string",
1115                            "description": "Remote name (default: origin)"
1116                        },
1117                        "branch": {
1118                            "type": "string",
1119                            "description": "Branch name (default: current branch)"
1120                        }
1121                    },
1122                    "required": []
1123                }),
1124            ),
1125            "git_clone" => (
1126                "Clone a git repository".to_string(),
1127                json!({
1128                    "type": "object",
1129                    "properties": {
1130                        "url": {
1131                            "type": "string",
1132                            "description": "Repository URL to clone"
1133                        },
1134                        "directory": {
1135                            "type": "string",
1136                            "description": "Directory to clone into (optional)"
1137                        }
1138                    },
1139                    "required": ["url"]
1140                }),
1141            ),
1142            "grep" => (
1143                "Search for text patterns in files using regex".to_string(),
1144                json!({
1145                    "type": "object",
1146                    "properties": {
1147                        "pattern": {
1148                            "type": "string",
1149                            "description": "Regex pattern to search for"
1150                        },
1151                        "path": {
1152                            "type": "string",
1153                            "description": "Path to search in (default: current directory)"
1154                        }
1155                    },
1156                    "required": ["pattern"]
1157                }),
1158            ),
1159            "ast_grep" => (
1160                "AST-aware code search and transformation. Supports search, replace, and scan commands across 25+ languages. Use meta-variables: $VAR (single node), $$$VAR (multiple nodes). Search finds patterns, replace transforms code, scan runs lint rules.".to_string(),
1161                json!({
1162                    "type": "object",
1163                    "properties": {
1164                        "command": {
1165                            "type": "string",
1166                            "enum": ["search", "replace", "scan"],
1167                            "description": "Command to execute. Default: search"
1168                        },
1169                        "pattern": {
1170                            "type": "string",
1171                            "description": "AST pattern to match (e.g., 'fn $NAME() {}'). Required for search and replace."
1172                        },
1173                        "language": {
1174                            "type": "string",
1175                            "description": "Programming language. Supported: bash, c, cpp, csharp, css, elixir, go, haskell, html, java, javascript, json, kotlin, lua, nix, php, python, ruby, rust, scala, solidity, swift, typescript, tsx, yaml. Required for search and replace."
1176                        },
1177                        "path": {
1178                            "type": "string",
1179                            "description": "Path to search in (default: current directory)"
1180                        },
1181                        "rewrite": {
1182                            "type": "string",
1183                            "description": "Replacement pattern for replace command (e.g., 'logger.info($MSG)'). Required for replace."
1184                        },
1185                        "dry_run": {
1186                            "type": "boolean",
1187                            "description": "Preview replacements without modifying files (default: false). Only for replace command."
1188                        },
1189                        "globs": {
1190                            "type": "array",
1191                            "items": {"type": "string"},
1192                            "description": "Include/exclude file patterns (e.g., ['*.rs', '!*.test.rs']). Prefix with ! to exclude."
1193                        },
1194                        "context_after": {
1195                            "type": "integer",
1196                            "description": "Show N lines after each match (default: 0). Only for search."
1197                        },
1198                        "context_before": {
1199                            "type": "integer",
1200                            "description": "Show N lines before each match (default: 0). Only for search."
1201                        },
1202                        "rule": {
1203                            "type": "string",
1204                            "description": "Path to YAML rule file for scan command."
1205                        },
1206                        "inline_rules": {
1207                            "type": "string",
1208                            "description": "Inline YAML rule text for scan command."
1209                        },
1210                        "filter": {
1211                            "type": "string",
1212                            "description": "Regex to filter rules by ID for scan command."
1213                        }
1214                    },
1215                    "required": ["pattern", "language"]
1216                }),
1217            ),
1218            "lsp" => (
1219                "Perform Language Server Protocol operations (goto_definition, find_references)"
1220                    .to_string(),
1221                json!({
1222                    "type": "object",
1223                    "properties": {
1224                        "command": {
1225                            "type": "string",
1226                            "description": "LSP command: goto_definition or find_references"
1227                        },
1228                        "file_path": {
1229                            "type": "string",
1230                            "description": "Path to the file"
1231                        },
1232                        "position": {
1233                            "type": "object",
1234                            "description": "Position in the file (line, character)",
1235                            "properties": {
1236                                "line": {"type": "integer"},
1237                                "character": {"type": "integer"}
1238                            },
1239                            "required": ["line", "character"]
1240                        }
1241                    },
1242                    "required": ["command", "file_path", "position"]
1243                }),
1244            ),
1245            "web_search" => (
1246                format!("Search the web using Exa AI. Returns results with titles, URLs, and content snippets. Use for current information beyond knowledge cutoff. The current year is {} - use this year when searching for recent information.", chrono::Local::now().year()),
1247                json!({
1248                    "type": "object",
1249                    "properties": {
1250                        "query": {
1251                            "type": "string",
1252                            "description": format!("Search query. Be specific for better results (e.g., 'Rust async tutorial {}' rather than 'Rust')", chrono::Local::now().year())
1253                        },
1254                        "numResults": {
1255                            "type": "integer",
1256                            "description": "Number of results to return (default: 8, max: 20)",
1257                            "default": 8
1258                        }
1259                    },
1260                    "required": ["query"]
1261                }),
1262            ),
1263            "web_fetch" => (
1264                "Fetch content from a URL. Converts HTML to markdown format by default. Use when user provides a URL or after web_search to read full content of a specific result.".to_string(),
1265                json!({
1266                    "type": "object",
1267                    "properties": {
1268                        "url": {
1269                            "type": "string",
1270                            "description": "URL to fetch (must start with http:// or https://)"
1271                        },
1272                        "format": {
1273                            "type": "string",
1274                            "enum": ["markdown", "text", "html"],
1275                            "default": "markdown",
1276                            "description": "Output format (default: markdown)"
1277                        }
1278                    },
1279                    "required": ["url"]
1280                }),
1281            ),
1282            "browser" => (
1283                "Browser automation for testing, scraping, and web interaction. Use snapshot-ref workflow: open URL, take snapshot, use refs from snapshot for interactions. Supports Chrome and Lightpanda engines.".to_string(),
1284                json!({
1285                    "type": "object",
1286                    "properties": {
1287                        "action": {
1288                            "type": "string",
1289                            "enum": [
1290                                // Core
1291                                "open", "close", "snapshot",
1292                                // Interaction
1293                                "click", "dblclick", "fill", "type", "press", "hover", "select",
1294                                "focus", "check", "uncheck", "scrollintoview", "drag", "upload",
1295                                // Navigation
1296                                "back", "forward", "reload",
1297                                // Query
1298                                "screenshot", "pdf", "eval", "get", "get_attr", "get_count", "get_box", "get_styles",
1299                                "find", "is", "download",
1300                                // Waiting
1301                                "wait", "wait_for_text", "wait_for_url", "wait_for_load", "wait_for_download", "wait_for_fn", "wait_for_state",
1302                                // Tabs & Dialogs
1303                                "tab_list", "tab_new", "tab_close", "tab_select", "dialog_accept", "dialog_dismiss",
1304                                // Storage & Network
1305                                "cookies", "cookies_set", "storage_get", "storage_set", "network_requests",
1306                                // Settings
1307                                "set_viewport", "set_device", "set_geo",
1308                                // State
1309                                "scroll"
1310                            ],
1311                            "description": "Browser action to perform"
1312                        },
1313                        // Core
1314                        "url": {
1315                            "type": "string",
1316                            "description": "URL to open (required for 'open' action)"
1317                        },
1318                        // Interaction
1319                        "selector": {
1320                            "type": "string",
1321                            "description": "Element selector or ref (for click, fill, type, hover, select, focus, check, uncheck, scrollintoview, get_attr, get_count, get_box, get_styles, is, download, upload)"
1322                        },
1323                        "text": {
1324                            "type": "string",
1325                            "description": "Text to input (for fill, type actions)"
1326                        },
1327                        "key": {
1328                            "type": "string",
1329                            "description": "Key to press (required for 'press' action)"
1330                        },
1331                        "value": {
1332                            "type": "string",
1333                            "description": "Value (for select, cookies_set, storage_set)"
1334                        },
1335                        "target": {
1336                            "type": "string",
1337                            "description": "Target selector (for drag action)"
1338                        },
1339                        "files": {
1340                            "type": "array",
1341                            "items": {"type": "string"},
1342                            "description": "File paths to upload (for upload action)"
1343                        },
1344                        // Query
1345                        "path": {
1346                            "type": "string",
1347                            "description": "File path (for screenshot, pdf, download actions)"
1348                        },
1349                        "script": {
1350                            "type": "string",
1351                            "description": "JavaScript to evaluate (required for 'eval' and 'wait_for_fn' actions)"
1352                        },
1353                        "get_what": {
1354                            "type": "string",
1355                            "enum": ["text", "html", "value", "url", "title"],
1356                            "description": "What to get (required for 'get' action)"
1357                        },
1358                        "attr": {
1359                            "type": "string",
1360                            "description": "Attribute name (for get_attr action)"
1361                        },
1362                        // Find
1363                        "locator_type": {
1364                            "type": "string",
1365                            "enum": ["role", "text", "label", "placeholder", "alt", "title", "testid", "css", "xpath"],
1366                            "description": "Locator strategy (for find action)"
1367                        },
1368                        "locator_value": {
1369                            "type": "string",
1370                            "description": "Locator value (for find action)"
1371                        },
1372                        "find_action": {
1373                            "type": "string",
1374                            "enum": ["click", "fill", "text", "count", "first", "last", "nth", "hover", "focus", "check", "uncheck"],
1375                            "description": "Action to perform on found element (for find action)"
1376                        },
1377                        "action_value": {
1378                            "type": "string",
1379                            "description": "Value for find action (optional)"
1380                        },
1381                        // Waiting
1382                        "wait_for": {
1383                            "type": "string",
1384                            "description": "Wait condition (for wait action)"
1385                        },
1386                        "state": {
1387                            "type": "string",
1388                            "enum": ["visible", "hidden", "attached", "detached", "enabled", "disabled", "networkidle", "domcontentloaded", "load"],
1389                            "description": "State to wait for (for wait_for_state, wait_for_load actions)"
1390                        },
1391                        // State check
1392                        "what": {
1393                            "type": "string",
1394                            "enum": ["visible", "hidden", "enabled", "disabled", "editable"],
1395                            "description": "State to check (required for 'is' action)"
1396                        },
1397                        // Scroll
1398                        "direction": {
1399                            "type": "string",
1400                            "enum": ["up", "down", "left", "right"],
1401                            "description": "Scroll direction (for scroll action)"
1402                        },
1403                        "pixels": {
1404                            "type": "integer",
1405                            "description": "Pixels to scroll (optional for scroll action)"
1406                        },
1407                        // Tabs
1408                        "index": {
1409                            "type": "integer",
1410                            "description": "Tab index (for tab_close, tab_select actions)"
1411                        },
1412                        // Dialogs
1413                        "dialog_text": {
1414                            "type": "string",
1415                            "description": "Text for prompt dialog (for dialog_accept action)"
1416                        },
1417                        // Storage
1418                        "storage_type": {
1419                            "type": "string",
1420                            "enum": ["local", "session"],
1421                            "description": "Storage type (for storage_get, storage_set actions)"
1422                        },
1423                        "key_name": {
1424                            "type": "string",
1425                            "description": "Storage key name (for storage_get, storage_set actions)"
1426                        },
1427                        // Network
1428                        "filter": {
1429                            "type": "string",
1430                            "description": "Network request filter (optional for network_requests action)"
1431                        },
1432                        // Settings
1433                        "width": {
1434                            "type": "integer",
1435                            "description": "Viewport width (for set_viewport action)"
1436                        },
1437                        "height": {
1438                            "type": "integer",
1439                            "description": "Viewport height (for set_viewport action)"
1440                        },
1441                        "scale": {
1442                            "type": "number",
1443                            "description": "Device scale factor (optional for set_viewport action)"
1444                        },
1445                        "device_name": {
1446                            "type": "string",
1447                            "description": "Device name to emulate (for set_device action)"
1448                        },
1449                        "latitude": {
1450                            "type": "number",
1451                            "description": "Latitude (for set_geo action)"
1452                        },
1453                        "longitude": {
1454                            "type": "number",
1455                            "description": "Longitude (for set_geo action)"
1456                        },
1457                        // Cookie
1458                        "name": {
1459                            "type": "string",
1460                            "description": "Cookie name (for cookies_set action)"
1461                        },
1462                        // Engine
1463                        "engine": {
1464                            "type": "string",
1465                            "enum": ["chrome", "lightpanda"],
1466                            "default": "chrome",
1467                            "description": "Browser engine to use"
1468                        }
1469                    },
1470                    "required": ["action"]
1471                }),
1472            ),
1473            _ => (
1474                format!("Tool: {}", name),
1475                json!({
1476                    "type": "object",
1477                    "properties": {},
1478                    "required": []
1479                }),
1480            ),
1481        }
1482    }
1483
1484    /// Send an event through the event channel
1485    fn send_event(&self, event: AgentEvent) {
1486        if let Some(ref tx) = self.event_tx {
1487            let _ = tx.send(event);
1488        }
1489    }
1490
1491    /// Check if the bridge is ready to process messages
1492    #[allow(dead_code)]
1493    pub fn is_ready(&self) -> bool {
1494        self.config
1495            .providers
1496            .get(&self.config.provider)
1497            .map(|p| p.api_key_or_env(&self.config.provider).is_some())
1498            .unwrap_or(false)
1499    }
1500
1501    /// Get the current model name
1502    pub fn model(&self) -> &str {
1503        self.config
1504            .providers
1505            .get(&self.config.provider)
1506            .map(|p| p.model.as_str())
1507            .unwrap_or("")
1508    }
1509
1510    /// Get the current provider name
1511    pub fn provider_name(&self) -> &str {
1512        &self.config.provider
1513    }
1514
1515    /// Get the max tokens setting
1516    pub fn max_tokens(&self) -> u32 {
1517        self.config
1518            .providers
1519            .get(&self.config.provider)
1520            .map(|p| p.max_tokens)
1521            .unwrap_or(4096)
1522    }
1523
1524    /// Get the timeout setting
1525    pub fn timeout(&self) -> u64 {
1526        self.config
1527            .providers
1528            .get(&self.config.provider)
1529            .map(|p| p.timeout)
1530            .unwrap_or(60)
1531    }
1532}
1533/// Calculate cost based on model pricing (per 1M tokens)
1534fn calculate_cost(model: &str, input_tokens: u64, output_tokens: u64) -> f64 {
1535    let (input_price, output_price) = match model {
1536        // Claude 3.5 Sonnet: $3/1M input, $15/1M output
1537        "claude-3-5-sonnet-20241022" | "claude-3-5-sonnet" => (3.0, 15.0),
1538        // GPT-4: $30/1M input, $60/1M output
1539        "gpt-4" => (30.0, 60.0),
1540        // GPT-4 Turbo: $10/1M input, $30/1M output
1541        "gpt-4-turbo" | "gpt-4-turbo-preview" => (10.0, 30.0),
1542        // Default: no cost tracking
1543        _ => (0.0, 0.0),
1544    };
1545    (input_tokens as f64 * input_price / 1_000_000.0)
1546        + (output_tokens as f64 * output_price / 1_000_000.0)
1547}
1548
1549fn truncate_tool_result(result: &str) -> String {
1550    if result.len() > MAX_TOOL_RESULT_CHARS {
1551        let truncated = &result[..MAX_TOOL_RESULT_CHARS];
1552        format!(
1553            "{}\n\n... [TRUNCATED: {} chars total, showing first {}]",
1554            truncated,
1555            result.len(),
1556            MAX_TOOL_RESULT_CHARS
1557        )
1558    } else {
1559        result.to_string()
1560    }
1561}
1562
1563#[cfg(test)]
1564mod tests {
1565    use super::*;
1566    use limit_llm::{BrowserConfigSection, Config as LlmConfig, ProviderConfig};
1567    use std::collections::HashMap;
1568
1569    #[tokio::test]
1570    async fn test_agent_bridge_new() {
1571        let mut providers = HashMap::new();
1572        providers.insert(
1573            "anthropic".to_string(),
1574            ProviderConfig {
1575                api_key: Some("test-key".to_string()),
1576                model: "claude-3-5-sonnet-20241022".to_string(),
1577                base_url: None,
1578                max_tokens: 4096,
1579                timeout: 60,
1580                max_iterations: 100,
1581                thinking_enabled: false,
1582                clear_thinking: true,
1583            },
1584        );
1585        let config = LlmConfig {
1586            provider: "anthropic".to_string(),
1587            providers,
1588            browser: BrowserConfigSection::default(),
1589            compaction: limit_llm::CompactionSettings::default(),
1590            cache: limit_llm::CacheSettings::default(),
1591        };
1592
1593        let bridge = AgentBridge::new(config).unwrap();
1594        assert!(bridge.is_ready());
1595    }
1596
1597    #[tokio::test]
1598    async fn test_agent_bridge_new_no_api_key() {
1599        let mut providers = HashMap::new();
1600        providers.insert(
1601            "anthropic".to_string(),
1602            ProviderConfig {
1603                api_key: None,
1604                model: "claude-3-5-sonnet-20241022".to_string(),
1605                base_url: None,
1606                max_tokens: 4096,
1607                timeout: 60,
1608                max_iterations: 100,
1609                thinking_enabled: false,
1610                clear_thinking: true,
1611            },
1612        );
1613        let config = LlmConfig {
1614            provider: "anthropic".to_string(),
1615            providers,
1616            browser: BrowserConfigSection::default(),
1617            compaction: limit_llm::CompactionSettings::default(),
1618            cache: limit_llm::CacheSettings::default(),
1619        };
1620
1621        let result = AgentBridge::new(config);
1622        assert!(result.is_err());
1623    }
1624
1625    #[tokio::test]
1626    async fn test_get_tool_definitions() {
1627        let mut providers = HashMap::new();
1628        providers.insert(
1629            "anthropic".to_string(),
1630            ProviderConfig {
1631                api_key: Some("test-key".to_string()),
1632                model: "claude-3-5-sonnet-20241022".to_string(),
1633                base_url: None,
1634                max_tokens: 4096,
1635                timeout: 60,
1636                max_iterations: 100,
1637                thinking_enabled: false,
1638                clear_thinking: true,
1639            },
1640        );
1641        let config = LlmConfig {
1642            provider: "anthropic".to_string(),
1643            providers,
1644            browser: BrowserConfigSection::default(),
1645            compaction: limit_llm::CompactionSettings::default(),
1646            cache: limit_llm::CacheSettings::default(),
1647        };
1648
1649        let bridge = AgentBridge::new(config).unwrap();
1650        let definitions = bridge.get_tool_definitions();
1651
1652        assert_eq!(definitions.len(), 16); // grep, lsp disabled
1653
1654        // Check file_read tool definition
1655        let file_read = definitions
1656            .iter()
1657            .find(|d| d.function.name == "file_read")
1658            .unwrap();
1659        assert_eq!(file_read.tool_type, "function");
1660        assert_eq!(file_read.function.name, "file_read");
1661        assert!(file_read.function.description.contains("Read"));
1662
1663        // Check bash tool definition
1664        let bash = definitions
1665            .iter()
1666            .find(|d| d.function.name == "bash")
1667            .unwrap();
1668        assert_eq!(bash.function.name, "bash");
1669        assert!(bash.function.parameters["required"]
1670            .as_array()
1671            .unwrap()
1672            .contains(&"command".into()));
1673    }
1674
1675    #[test]
1676    fn test_get_tool_schema() {
1677        let (desc, params) = AgentBridge::get_tool_schema("file_read");
1678        assert!(desc.contains("Read"));
1679        assert_eq!(params["properties"]["path"]["type"], "string");
1680        assert!(params["required"]
1681            .as_array()
1682            .unwrap()
1683            .contains(&"path".into()));
1684
1685        let (desc, params) = AgentBridge::get_tool_schema("bash");
1686        assert!(desc.contains("bash"));
1687        assert_eq!(params["properties"]["command"]["type"], "string");
1688
1689        let (desc, _params) = AgentBridge::get_tool_schema("unknown_tool");
1690        assert!(desc.contains("unknown_tool"));
1691    }
1692
1693    #[test]
1694    fn test_is_ready() {
1695        let mut providers = HashMap::new();
1696        providers.insert(
1697            "anthropic".to_string(),
1698            ProviderConfig {
1699                api_key: Some("test-key".to_string()),
1700                model: "claude-3-5-sonnet-20241022".to_string(),
1701                base_url: None,
1702                max_tokens: 4096,
1703                timeout: 60,
1704                max_iterations: 100,
1705                thinking_enabled: false,
1706                clear_thinking: true,
1707            },
1708        );
1709        let config_with_key = LlmConfig {
1710            provider: "anthropic".to_string(),
1711            providers,
1712            browser: BrowserConfigSection::default(),
1713            compaction: limit_llm::CompactionSettings::default(),
1714            cache: limit_llm::CacheSettings::default(),
1715        };
1716
1717        let bridge = AgentBridge::new(config_with_key).unwrap();
1718        assert!(bridge.is_ready());
1719    }
1720
1721    #[test]
1722    fn test_handoff_compaction_preserves_system() {
1723        let handoff = ModelHandoff::new();
1724
1725        let mut messages = vec![Message {
1726            role: Role::System,
1727            content: Some(MessageContent::text("System prompt")),
1728            tool_calls: None,
1729            tool_call_id: None,
1730            cache_control: None,
1731        }];
1732
1733        for i in 0..50 {
1734            messages.push(Message {
1735                role: if i % 2 == 0 {
1736                    Role::User
1737                } else {
1738                    Role::Assistant
1739                },
1740                content: Some(MessageContent::text(format!(
1741                    "Message {} with enough content to consume tokens",
1742                    i
1743                ))),
1744                tool_calls: None,
1745                tool_call_id: None,
1746                cache_control: None,
1747            });
1748        }
1749
1750        let target = 500;
1751        let compacted = handoff.compact_messages(&messages, target);
1752
1753        assert_eq!(compacted[0].role, Role::System);
1754        assert!(compacted.len() < messages.len());
1755    }
1756
1757    #[test]
1758    fn test_handoff_compaction_keeps_recent() {
1759        let handoff = ModelHandoff::new();
1760
1761        let mut messages = vec![Message {
1762            role: Role::System,
1763            content: Some(MessageContent::text("System")),
1764            tool_calls: None,
1765            tool_call_id: None,
1766            cache_control: None,
1767        }];
1768
1769        for i in 0..100 {
1770            messages.push(Message {
1771                role: if i % 2 == 0 {
1772                    Role::User
1773                } else {
1774                    Role::Assistant
1775                },
1776                content: Some(MessageContent::text(format!("Message {}", i))),
1777                tool_calls: None,
1778                tool_call_id: None,
1779                cache_control: None,
1780            });
1781        }
1782
1783        let target = 200;
1784        let compacted = handoff.compact_messages(&messages, target);
1785
1786        assert!(compacted.len() < messages.len());
1787        let last_content = compacted.last().unwrap().content.clone();
1788        assert_eq!(last_content, Some(MessageContent::text("Message 99")));
1789    }
1790
1791    #[test]
1792    fn test_compaction_config_respects_settings() {
1793        let mut providers = HashMap::new();
1794        providers.insert(
1795            "anthropic".to_string(),
1796            ProviderConfig {
1797                api_key: Some("test-key".to_string()),
1798                model: "claude-3-5-sonnet-20241022".to_string(),
1799                base_url: None,
1800                max_tokens: 4096,
1801                timeout: 60,
1802                max_iterations: 100,
1803                thinking_enabled: false,
1804                clear_thinking: true,
1805            },
1806        );
1807
1808        let config = LlmConfig {
1809            provider: "anthropic".to_string(),
1810            providers,
1811            browser: BrowserConfigSection::default(),
1812            compaction: limit_llm::CompactionSettings {
1813                enabled: true,
1814                reserve_tokens: 8192,
1815                keep_recent_tokens: 10000,
1816                use_summarization: true,
1817            },
1818            cache: limit_llm::CacheSettings::default(),
1819        };
1820
1821        let bridge = AgentBridge::new(config).unwrap();
1822        assert!(bridge.config.compaction.enabled);
1823        assert_eq!(bridge.config.compaction.reserve_tokens, 8192);
1824        assert_eq!(bridge.config.compaction.keep_recent_tokens, 10000);
1825    }
1826}