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#[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#[derive(Debug, Clone, Default)]
71pub struct ProcessResult {
72 pub response: String,
73 pub input_tokens: u64,
74 pub output_tokens: u64,
75}
76
77const MAX_RECENT_TOOL_CALLS: usize = 20;
79
80const MAX_TOOL_RESULT_CHARS: usize = 10000;
82
83pub struct AgentBridge {
85 llm_client: Box<dyn LlmProvider>,
87 executor: ToolExecutor,
89 tool_names: Vec<&'static str>,
91 config: limit_llm::Config,
93 event_tx: Option<mpsc::UnboundedSender<AgentEvent>>,
95 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 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 #[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 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 "ast_grep",
156 "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 pub fn set_event_tx(&mut self, tx: mpsc::UnboundedSender<AgentEvent>) {
180 self.event_tx = Some(tx);
181 }
182
183 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 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 fn register_tools(registry: &mut ToolRegistry, config: &limit_llm::Config) {
294 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 registry
307 .register(BashTool::new())
308 .expect("Failed to register bash");
309
310 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 registry
342 .register(AstGrepTool::new())
343 .expect("Failed to register ast_grep");
344 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 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 #[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 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 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 let tool_definitions = self.get_tool_definitions();
402
403 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); 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 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 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 loop {
465 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 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 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 }
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 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 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 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 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 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 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 let assistant_message = Message {
647 role: Role::Assistant,
648 content: None, tool_calls: Some(tool_calls.clone()),
650 tool_call_id: None,
651 cache_control: None,
652 };
653 _messages.push(assistant_message);
654
655 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 for (name, args) in calls_to_record {
672 self.record_tool_call(&name, &args);
673 }
674
675 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 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 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 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 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 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 max_iterations > 0 && iteration >= max_iterations && !_messages.is_empty() {
797 debug!("Making final LLM call after hitting max iterations (forcing text response)");
798
799 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 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 full_response.clear();
824 self.send_event(AgentEvent::ResponseStart {
825 operation_id: self.operation_id,
826 });
827 loop {
828 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 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 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 if !full_response.is_empty() {
887 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 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 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 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 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 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 "open", "close", "snapshot",
1292 "click", "dblclick", "fill", "type", "press", "hover", "select",
1294 "focus", "check", "uncheck", "scrollintoview", "drag", "upload",
1295 "back", "forward", "reload",
1297 "screenshot", "pdf", "eval", "get", "get_attr", "get_count", "get_box", "get_styles",
1299 "find", "is", "download",
1300 "wait", "wait_for_text", "wait_for_url", "wait_for_load", "wait_for_download", "wait_for_fn", "wait_for_state",
1302 "tab_list", "tab_new", "tab_close", "tab_select", "dialog_accept", "dialog_dismiss",
1304 "cookies", "cookies_set", "storage_get", "storage_set", "network_requests",
1306 "set_viewport", "set_device", "set_geo",
1308 "scroll"
1310 ],
1311 "description": "Browser action to perform"
1312 },
1313 "url": {
1315 "type": "string",
1316 "description": "URL to open (required for 'open' action)"
1317 },
1318 "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 "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 "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 "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 "what": {
1393 "type": "string",
1394 "enum": ["visible", "hidden", "enabled", "disabled", "editable"],
1395 "description": "State to check (required for 'is' action)"
1396 },
1397 "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 "index": {
1409 "type": "integer",
1410 "description": "Tab index (for tab_close, tab_select actions)"
1411 },
1412 "dialog_text": {
1414 "type": "string",
1415 "description": "Text for prompt dialog (for dialog_accept action)"
1416 },
1417 "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 "filter": {
1429 "type": "string",
1430 "description": "Network request filter (optional for network_requests action)"
1431 },
1432 "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 "name": {
1459 "type": "string",
1460 "description": "Cookie name (for cookies_set action)"
1461 },
1462 "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 fn send_event(&self, event: AgentEvent) {
1486 if let Some(ref tx) = self.event_tx {
1487 let _ = tx.send(event);
1488 }
1489 }
1490
1491 #[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 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 pub fn provider_name(&self) -> &str {
1512 &self.config.provider
1513 }
1514
1515 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 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}
1533fn calculate_cost(model: &str, input_tokens: u64, output_tokens: u64) -> f64 {
1535 let (input_price, output_price) = match model {
1536 "claude-3-5-sonnet-20241022" | "claude-3-5-sonnet" => (3.0, 15.0),
1538 "gpt-4" => (30.0, 60.0),
1540 "gpt-4-turbo" | "gpt-4-turbo-preview" => (10.0, 30.0),
1542 _ => (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); 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 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}