1use std::sync::Arc;
2
3use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
4use tracing::{debug, info, warn};
5
6use crate::error::{AgentId, SdkError, SdkResult};
7use crate::types::chat::ChatMessage;
8use crate::traits::llm_client::LlmClient;
9use crate::tools::registry::ToolRegistry;
10
11use super::events::AgentEvent;
12
13#[derive(Debug, Clone)]
17pub struct BackgroundResult {
18 pub name: String,
20 pub kind: BackgroundResultKind,
22 pub content: String,
24 pub tokens_used: u64,
26}
27
28#[derive(Debug, Clone)]
29pub enum BackgroundResultKind {
30 SubAgent,
31 AgentTeam,
32}
33
34const CHARS_PER_ESTIMATED_TOKEN: usize = 4;
35const DEFAULT_MAX_CONTEXT_TOKENS: usize = 200_000;
36const MAX_TOOL_RESULT_CHARS: usize = 12_000;
37const COMPACT_KEEP_RECENT: usize = 10;
38
39#[derive(Debug)]
40pub struct AgentLoopResult {
41 pub final_content: String,
42 pub messages: Vec<ChatMessage>,
43 pub total_tokens: u64,
44 pub iterations: usize,
45 pub tool_calls_count: usize,
46}
47
48#[derive(Debug, Clone)]
49pub enum CompactionStrategy {
50 Auto,
52 Default,
54 Conservative,
56 Aggressive,
58 Custom {
60 keep_recent: usize,
61 tool_result_chars_limit: usize,
62 assistant_content_limit: usize,
63 fallback_truncate_chars: usize,
64 },
65}
66
67impl Default for CompactionStrategy {
68 fn default() -> Self {
69 CompactionStrategy::Auto
70 }
71}
72
73#[derive(Debug, Clone, Copy)]
74struct CompactionProfile {
75 keep_recent: usize,
76 tool_result_chars_limit: usize,
77 assistant_content_limit: usize,
78 fallback_truncate_chars: usize,
79 compress_user_messages: bool,
80}
81
82impl CompactionProfile {
83 const DEFAULT: Self = Self {
84 keep_recent: COMPACT_KEEP_RECENT,
85 tool_result_chars_limit: 200,
86 assistant_content_limit: 500,
87 fallback_truncate_chars: 2000,
88 compress_user_messages: false,
89 };
90
91 const CONSERVATIVE: Self = Self {
92 keep_recent: 15,
93 tool_result_chars_limit: 500,
94 assistant_content_limit: 1000,
95 fallback_truncate_chars: 5000,
96 compress_user_messages: false,
97 };
98
99 const AGGRESSIVE: Self = Self {
100 keep_recent: 5,
101 tool_result_chars_limit: 100,
102 assistant_content_limit: 100,
103 fallback_truncate_chars: 500,
104 compress_user_messages: true,
105 };
106}
107
108pub struct AgentLoop {
109 agent_id: AgentId,
110 agent_name: String,
111 llm_client: Arc<dyn LlmClient>,
112 tools: ToolRegistry,
113 messages: Vec<ChatMessage>,
114 max_iterations: usize,
115 max_context_tokens: usize,
116 total_tokens: u64,
117 tool_calls_count: usize,
118 event_tx: Option<UnboundedSender<AgentEvent>>,
119 compaction_strategy: CompactionStrategy,
120 background_rx: Option<UnboundedReceiver<BackgroundResult>>,
123}
124
125impl AgentLoop {
126 pub fn new(
127 agent_id: AgentId,
128 llm_client: Arc<dyn LlmClient>,
129 tools: ToolRegistry,
130 system_prompt: String,
131 max_iterations: usize,
132 ) -> Self {
133 let messages = vec![ChatMessage::system(system_prompt)];
134 Self {
135 agent_id,
136 agent_name: String::new(),
137 llm_client,
138 tools,
139 messages,
140 max_iterations,
141 max_context_tokens: DEFAULT_MAX_CONTEXT_TOKENS,
142 total_tokens: 0,
143 tool_calls_count: 0,
144 event_tx: None,
145 compaction_strategy: CompactionStrategy::default(),
146 background_rx: None,
147 }
148 }
149
150 pub fn with_max_context_tokens(mut self, tokens: usize) -> Self {
152 self.max_context_tokens = tokens;
153 self
154 }
155
156 pub fn with_compaction_strategy(mut self, strategy: CompactionStrategy) -> Self {
158 self.compaction_strategy = strategy;
159 self
160 }
161
162 pub fn with_agent_name(mut self, name: impl Into<String>) -> Self {
164 self.agent_name = name.into();
165 self
166 }
167
168 pub fn with_messages(
170 agent_id: AgentId,
171 llm_client: Arc<dyn LlmClient>,
172 tools: ToolRegistry,
173 messages: Vec<ChatMessage>,
174 max_iterations: usize,
175 ) -> Self {
176 Self {
177 agent_id,
178 agent_name: String::new(),
179 llm_client,
180 tools,
181 messages,
182 max_iterations,
183 max_context_tokens: DEFAULT_MAX_CONTEXT_TOKENS,
184 total_tokens: 0,
185 tool_calls_count: 0,
186 event_tx: None,
187 compaction_strategy: CompactionStrategy::default(),
188 background_rx: None,
189 }
190 }
191
192 pub fn set_event_sink(&mut self, tx: UnboundedSender<AgentEvent>) {
193 self.event_tx = Some(tx);
194 }
195
196 pub fn set_background_rx(&mut self, rx: UnboundedReceiver<BackgroundResult>) {
200 self.background_rx = Some(rx);
201 }
202
203 pub fn messages(&self) -> &[ChatMessage] {
205 &self.messages
206 }
207
208 pub async fn run(&mut self, initial_user_message: String) -> SdkResult<AgentLoopResult> {
209 self.messages
210 .push(ChatMessage::user(initial_user_message));
211
212 let tool_defs = self.tools.definitions();
213
214 for iteration in 0..self.max_iterations {
215 self.drain_background_results();
218
219 self.compact_if_needed();
220
221 debug!(
222 agent_id = %self.agent_id,
223 iteration,
224 messages = self.messages.len(),
225 context_tokens = self.estimate_context_tokens(),
226 "Agent loop iteration"
227 );
228
229 let (response, tokens) = self
230 .llm_client
231 .chat(&self.messages, &tool_defs)
232 .await?;
233 self.total_tokens += tokens;
234
235 match &response {
236 ChatMessage::Assistant {
237 content,
238 tool_calls,
239 } if !tool_calls.is_empty() => {
240 if let Some(text) = content {
241 if !text.is_empty() {
242 self.emit(AgentEvent::Thinking {
243 agent_id: self.agent_id,
244 name: self.agent_name.clone(),
245 content: truncate(text, 200),
246 iteration,
247 });
248 }
249 }
250
251 self.messages.push(response.clone());
252
253 for tool_call in tool_calls {
254 self.emit(AgentEvent::ToolCall {
255 agent_id: self.agent_id,
256 name: self.agent_name.clone(),
257 tool_name: tool_call.function.name.clone(),
258 arguments: tool_call.function.arguments.clone(),
261 iteration,
262 });
263
264 let result = self
265 .tools
266 .execute(
267 &tool_call.function.name,
268 serde_json::from_str(&tool_call.function.arguments)
269 .unwrap_or_default(),
270 )
271 .await;
272
273 let (result_content, result_preview) = match &result {
274 Ok(val) => {
275 let preview = build_result_preview(val);
279 let full = serde_json::to_string(val).unwrap_or_default();
280 (truncate_tool_result(&full), preview)
281 }
282 Err(e) => {
283 let err = serde_json::json!({"error": e.to_string()}).to_string();
284 (err.clone(), err)
285 }
286 };
287
288 self.emit(AgentEvent::ToolResult {
289 agent_id: self.agent_id,
290 name: self.agent_name.clone(),
291 tool_name: tool_call.function.name.clone(),
292 result_preview,
293 iteration,
294 });
295
296 self.messages.push(ChatMessage::tool_result(
297 &tool_call.id,
298 &result_content,
299 ));
300
301 self.tool_calls_count += 1;
302 }
303 }
304 ChatMessage::Assistant { content, .. } => {
305 let final_content = content.clone().unwrap_or_default();
306 self.messages.push(response);
307
308 info!(
309 agent_id = %self.agent_id,
310 iterations = iteration + 1,
311 tool_calls = self.tool_calls_count,
312 tokens = self.total_tokens,
313 "Agent loop completed"
314 );
315
316 return Ok(AgentLoopResult {
317 final_content,
318 messages: self.messages.clone(),
319 total_tokens: self.total_tokens,
320 iterations: iteration + 1,
321 tool_calls_count: self.tool_calls_count,
322 });
323 }
324 other => {
325 warn!(
326 agent_id = %self.agent_id,
327 "Unexpected message type from LLM, treating as final"
328 );
329 let final_content = other.text_content().unwrap_or("").to_string();
330 self.messages.push(response);
331 return Ok(AgentLoopResult {
332 final_content,
333 messages: self.messages.clone(),
334 total_tokens: self.total_tokens,
335 iterations: iteration + 1,
336 tool_calls_count: self.tool_calls_count,
337 });
338 }
339 }
340 }
341
342 Err(SdkError::MaxIterationsExceeded {
343 max_iterations: self.max_iterations,
344 })
345 }
346
347 fn estimate_context_tokens(&self) -> usize {
348 self.messages
349 .iter()
350 .map(|m| m.char_len().div_ceil(CHARS_PER_ESTIMATED_TOKEN))
351 .sum()
352 }
353
354 fn compact_if_needed(&mut self) {
355 let size = self.estimate_context_tokens();
356 if size <= self.max_context_tokens {
357 return;
358 }
359
360 warn!(
361 agent_id = %self.agent_id,
362 estimated_tokens = size,
363 max_tokens = self.max_context_tokens,
364 messages = self.messages.len(),
365 "Context too large, compacting"
366 );
367
368 let selected = self.resolve_compaction_strategy(size);
369 debug!(
370 agent_id = %self.agent_id,
371 configured = ?self.compaction_strategy,
372 selected = ?selected,
373 "Selected compaction strategy"
374 );
375
376 match selected {
377 CompactionStrategy::Auto | CompactionStrategy::Default => {
378 self.compact_with_profile(CompactionProfile::DEFAULT)
379 }
380 CompactionStrategy::Conservative => {
381 self.compact_with_profile(CompactionProfile::CONSERVATIVE)
382 }
383 CompactionStrategy::Aggressive => {
384 self.compact_with_profile(CompactionProfile::AGGRESSIVE)
385 }
386 CompactionStrategy::Custom {
387 keep_recent,
388 tool_result_chars_limit,
389 assistant_content_limit,
390 fallback_truncate_chars,
391 } => {
392 self.compact_with_custom_strategy(
393 keep_recent,
394 tool_result_chars_limit,
395 assistant_content_limit,
396 fallback_truncate_chars,
397 );
398 }
399 }
400
401 let new_size = self.estimate_context_tokens();
402 debug!(
403 agent_id = %self.agent_id,
404 before = size,
405 after = new_size,
406 "Context compacted"
407 );
408 }
409
410 fn resolve_compaction_strategy(&self, size: usize) -> CompactionStrategy {
411 match &self.compaction_strategy {
412 CompactionStrategy::Auto => self.select_dynamic_strategy(size),
413 other => other.clone(),
414 }
415 }
416
417 fn select_dynamic_strategy(&self, size: usize) -> CompactionStrategy {
418 let total = self.messages.len().max(1);
419 let overflow_ratio = size as f64 / self.max_context_tokens.max(1) as f64;
420 let tool_count = self.messages.iter().filter(|m| matches!(m, ChatMessage::Tool { .. })).count();
421 let assistant_count = self
422 .messages
423 .iter()
424 .filter(|m| matches!(m, ChatMessage::Assistant { .. }))
425 .count();
426 let tool_ratio = tool_count as f64 / total as f64;
427 let assistant_ratio = assistant_count as f64 / total as f64;
428
429 if overflow_ratio >= 1.8 || total >= 80 {
430 return CompactionStrategy::Aggressive;
431 }
432
433 if tool_ratio >= 0.35 {
434 return if overflow_ratio >= 1.25 {
435 CompactionStrategy::Aggressive
436 } else {
437 CompactionStrategy::Default
438 };
439 }
440
441 if assistant_ratio >= 0.45 && overflow_ratio < 1.2 {
442 return CompactionStrategy::Conservative;
443 }
444
445 if overflow_ratio >= 1.35 {
446 CompactionStrategy::Default
447 } else {
448 CompactionStrategy::Conservative
449 }
450 }
451
452 fn compact_with_profile(&mut self, profile: CompactionProfile) {
453 let total = self.messages.len();
454 if total <= profile.keep_recent + 2 {
455 self.truncate_all_tool_results(profile.fallback_truncate_chars);
456 return;
457 }
458
459 let keep_after = total - profile.keep_recent;
460
461 for i in 1..keep_after {
462 match &self.messages[i] {
463 ChatMessage::Tool {
464 tool_call_id,
465 content,
466 } => {
467 if content.len() > profile.tool_result_chars_limit {
468 let summary = format!(
469 "[compacted: {} chars] {}",
470 content.len(),
471 safe_prefix(content, profile.tool_result_chars_limit.saturating_sub(50))
472 );
473 self.messages[i] = ChatMessage::Tool {
474 tool_call_id: tool_call_id.clone(),
475 content: summary,
476 };
477 }
478 }
479 ChatMessage::Assistant {
480 content,
481 tool_calls,
482 } if content
483 .as_ref()
484 .is_some_and(|c| c.len() > profile.assistant_content_limit) =>
485 {
486 let short = content
487 .as_ref()
488 .map(|c| truncate(c, profile.assistant_content_limit.saturating_sub(100)));
489 self.messages[i] = ChatMessage::Assistant {
490 content: short,
491 tool_calls: tool_calls.clone(),
492 };
493 }
494 ChatMessage::User { content } if profile.compress_user_messages && content.len() > 200 => {
495 let short = truncate(content, 150);
496 self.messages[i] = ChatMessage::User { content: short };
497 }
498 _ => {}
499 }
500 }
501 }
502
503 fn compact_with_custom_strategy(
504 &mut self,
505 keep_recent: usize,
506 tool_result_chars_limit: usize,
507 assistant_content_limit: usize,
508 fallback_truncate_chars: usize,
509 ) {
510 self.compact_with_profile(CompactionProfile {
511 keep_recent,
512 tool_result_chars_limit,
513 assistant_content_limit,
514 fallback_truncate_chars,
515 compress_user_messages: false,
516 });
517 }
518
519 fn truncate_all_tool_results(&mut self, max_chars: usize) {
520 for msg in &mut self.messages {
521 if let ChatMessage::Tool {
522 tool_call_id,
523 content,
524 } = msg
525 {
526 if content.len() > max_chars {
527 let summary = format!(
528 "[truncated: {} chars] {}",
529 content.len(),
530 safe_prefix(content, max_chars)
531 );
532 *msg = ChatMessage::Tool {
533 tool_call_id: tool_call_id.clone(),
534 content: summary,
535 };
536 }
537 }
538 }
539 }
540
541 fn drain_background_results(&mut self) {
545 let rx = match self.background_rx.as_mut() {
546 Some(rx) => rx,
547 None => return,
548 };
549
550 while let Ok(result) = rx.try_recv() {
551 let kind_label = match result.kind {
552 BackgroundResultKind::SubAgent => "subagent",
553 BackgroundResultKind::AgentTeam => "agent team",
554 };
555 let notification = format!(
556 "[Background {} '{}' completed — {} tokens]\n\n{}",
557 kind_label, result.name, result.tokens_used, result.content,
558 );
559 info!(
560 agent_id = %self.agent_id,
561 background_agent = %result.name,
562 tokens = result.tokens_used,
563 "Background agent result injected into conversation"
564 );
565 self.messages.push(ChatMessage::user(notification));
566 }
567 }
568
569 fn emit(&self, event: AgentEvent) {
570 if let Some(ref tx) = self.event_tx {
571 let _ = tx.send(event);
572 }
573 }
574}
575
576fn build_result_preview(val: &serde_json::Value) -> String {
582 let obj = match val.as_object() {
583 Some(o) => o,
584 None => return truncate(&val.to_string(), 300),
585 };
586
587 let mut preview = serde_json::Map::new();
588 for (key, value) in obj {
589 match key.as_str() {
590 "content" | "stdout" | "stderr" => {
592 if let Some(s) = value.as_str() {
593 let lines = s.lines().count();
594 preview.insert(
595 key.clone(),
596 serde_json::Value::String(format!("[{} lines]", lines)),
597 );
598 }
599 }
600 _ => {
601 preview.insert(key.clone(), value.clone());
602 }
603 }
604 }
605
606 serde_json::to_string(&serde_json::Value::Object(preview)).unwrap_or_default()
607}
608
609fn truncate_tool_result(s: &str) -> String {
610 if s.len() <= MAX_TOOL_RESULT_CHARS {
611 return s.to_string();
612 }
613
614 if let Ok(mut val) = serde_json::from_str::<serde_json::Value>(s) {
615 if let Some(content) = val.get_mut("content") {
616 if let Some(text) = content.as_str() {
617 if text.len() > MAX_TOOL_RESULT_CHARS - 200 {
618 let limit = MAX_TOOL_RESULT_CHARS - 200;
619 let truncated = format!(
620 "{}...\n\n[truncated: showing {}/{} chars. Use offset parameter to read more.]",
621 safe_prefix(text, limit),
622 limit,
623 text.len()
624 );
625 *content = serde_json::Value::String(truncated);
626 return serde_json::to_string(&val)
627 .unwrap_or_else(|_| safe_prefix(s, MAX_TOOL_RESULT_CHARS).to_string());
628 }
629 }
630 }
631 }
632
633 format!(
634 "{}...[truncated: {}/{} chars]",
635 safe_prefix(s, MAX_TOOL_RESULT_CHARS),
636 MAX_TOOL_RESULT_CHARS,
637 s.len()
638 )
639}
640
641fn truncate(s: &str, max_len: usize) -> String {
642 if s.len() <= max_len {
643 s.to_string()
644 } else {
645 format!("{}...", safe_prefix(s, max_len))
646 }
647}
648
649fn safe_prefix(s: &str, max_len: usize) -> &str {
650 if s.len() <= max_len {
651 return s;
652 }
653
654 match s.char_indices().map(|(idx, _)| idx).take_while(|&idx| idx <= max_len).last() {
655 Some(0) | None => "",
656 Some(idx) => &s[..idx],
657 }
658}