Skip to main content

agent_sdk/agent/
agent_loop.rs

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/// A result delivered from a background agent (subagent or team) back to the
14/// parent agent's conversation.  The agent loop drains these before each LLM
15/// call and injects them as user-role messages so the model can reference them.
16#[derive(Debug, Clone)]
17pub struct BackgroundResult {
18    /// Human-readable name (e.g. "explore", "backend-team").
19    pub name: String,
20    /// Whether this was a subagent or a team.
21    pub kind: BackgroundResultKind,
22    /// The final content / summary produced by the background agent.
23    pub content: String,
24    /// Token usage.
25    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: dynamically select a strategy based on overflow severity and message mix
51    Auto,
52    /// Default: Keep recent messages, compress older ones
53    Default,
54    /// Conservative: Preserve more context at cost of higher memory
55    Conservative,
56    /// Aggressive: More aggressive compression for resource-constrained environments
57    Aggressive,
58    /// Custom: User-defined compaction rules with specific parameters
59    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    /// Receives results from background subagents / teams.
121    /// Drained before each LLM call and injected as user messages.
122    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    /// Set the maximum context window size in tokens.
151    pub fn with_max_context_tokens(mut self, tokens: usize) -> Self {
152        self.max_context_tokens = tokens;
153        self
154    }
155
156    /// Set the compaction strategy to use.
157    pub fn with_compaction_strategy(mut self, strategy: CompactionStrategy) -> Self {
158        self.compaction_strategy = strategy;
159        self
160    }
161
162    /// Set a human-readable name for this agent (used in events).
163    pub fn with_agent_name(mut self, name: impl Into<String>) -> Self {
164        self.agent_name = name.into();
165        self
166    }
167
168    /// Create an AgentLoop with existing conversation history (for multi-turn).
169    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    /// Set the receiver for background agent results.
197    /// Results arriving on this channel are injected as user messages before
198    /// each LLM call, mirroring Claude Code's background agent notification.
199    pub fn set_background_rx(&mut self, rx: UnboundedReceiver<BackgroundResult>) {
200        self.background_rx = Some(rx);
201    }
202
203    /// Get a clone of the current conversation messages.
204    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            // Drain any completed background agent results and inject them
216            // as user messages so the LLM can reference them.
217            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                            // Keep full arguments for event consumers so they can
259                            // reliably extract fields like path/command.
260                            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                                // Build a metadata-only preview before serializing,
276                                // so event consumers get parseable JSON even for
277                                // large tool results like read_file.
278                                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    /// Drain all pending background results and inject them as user messages.
542    /// This mirrors Claude Code's behavior: when a background agent finishes,
543    /// the parent agent is automatically notified with the result content.
544    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
576/// Build a compact JSON preview of a tool result for the `ToolResult` event.
577///
578/// Unlike raw truncation, this extracts metadata fields and omits large body
579/// fields like `content` / `stdout`, so event consumers (CLI display) can
580/// reliably parse the preview.
581fn 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            // Skip large body fields — include everything else
591            "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}