1use crate::context::{CompactionConfig, Compactor, TokenTracker};
2use crate::core::PromptCache;
3pub use crate::core::retry_config::RetryConfig;
4use crate::events::{
5 AgentCommand, AgentEvent, AgentObserver, Command, ContextEvent, ContextUsage, LlmCallOutcome, LlmCallPurpose,
6 ModelEvent, ToolEvent, TurnEvent, TurnOutcome, UserCommand,
7};
8use crate::mcp::run_mcp_task::{McpCommand, ToolExecutionEvent};
9use futures::Stream;
10use llm::types::IsoString;
11use llm::{
12 AssistantReasoning, ChatMessage, Context, EncryptedReasoningContent, LlmError, LlmResponse, StopReason,
13 StreamingModelProvider, TokenUsage, ToolCallError, ToolCallRequest, ToolCallResult,
14};
15use std::collections::{HashMap, HashSet, VecDeque};
16use std::pin::Pin;
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::sync::mpsc;
20use tokio::time::sleep;
21use tokio_stream::StreamExt;
22use tokio_stream::StreamMap;
23use tokio_stream::wrappers::ReceiverStream;
24
25#[derive(Debug)]
27enum StreamEvent {
28 LlmRequestStarted { attempt: u32 },
29 Llm(Result<LlmResponse, LlmError>),
30 ToolExecution(ToolExecutionEvent),
31 Command(Command),
32}
33
34type EventStream = Pin<Box<dyn Stream<Item = StreamEvent> + Send>>;
35
36const USER_STREAM_KEY: &str = "user";
37const LLM_STREAM_KEY: &str = "llm";
38
39pub(crate) struct AgentConfig {
40 pub llm: Arc<dyn StreamingModelProvider>,
41 pub context: Context,
42 pub mcp_command_tx: Option<mpsc::Sender<McpCommand>>,
43 pub tool_timeout: Duration,
44 pub compaction_config: Option<CompactionConfig>,
45 pub auto_continue: AutoContinue,
46 pub retry_config: RetryConfig,
47 pub context_window: Option<u32>,
48 pub prompt_cache: PromptCache,
49 pub observers: Vec<Box<dyn AgentObserver>>,
50}
51
52pub struct Agent {
53 llm: Arc<dyn StreamingModelProvider>,
54 context: Context,
55 mcp_command_tx: Option<mpsc::Sender<McpCommand>>,
56 message_tx: mpsc::Sender<AgentEvent>,
57 observers: Vec<Box<dyn AgentObserver>>,
58 streams: StreamMap<String, EventStream>,
59 tool_timeout: Duration,
60 token_tracker: TokenTracker,
61 compaction_config: Option<CompactionConfig>,
62 auto_continue: AutoContinue,
63 retry_config: RetryConfig,
64 active_requests: HashMap<String, ToolCallRequest>,
65 queued_user_messages: VecDeque<Vec<llm::ContentBlock>>,
66 context_window: Option<u32>,
67 prompt_cache: PromptCache,
68 turn_active: bool,
69 llm_call_active: bool,
70}
71
72impl Agent {
73 pub(crate) fn new(
74 config: AgentConfig,
75 command_rx: mpsc::Receiver<Command>,
76 message_tx: mpsc::Sender<AgentEvent>,
77 ) -> Self {
78 let mut streams: StreamMap<String, EventStream> = StreamMap::new();
79 streams
80 .insert(USER_STREAM_KEY.to_string(), Box::pin(ReceiverStream::new(command_rx).map(StreamEvent::Command)));
81
82 let context_limit = config.context_window.or_else(|| config.llm.context_window());
83
84 Self {
85 llm: config.llm,
86 context: config.context,
87 mcp_command_tx: config.mcp_command_tx,
88 message_tx,
89 observers: config.observers,
90 streams,
91 tool_timeout: config.tool_timeout,
92 token_tracker: TokenTracker::new(context_limit),
93 compaction_config: config.compaction_config,
94 auto_continue: config.auto_continue,
95 retry_config: config.retry_config,
96 active_requests: HashMap::new(),
97 queued_user_messages: VecDeque::new(),
98 context_window: config.context_window,
99 prompt_cache: config.prompt_cache,
100 turn_active: false,
101 llm_call_active: false,
102 }
103 }
104
105 pub fn current_model_display_name(&self) -> String {
106 self.llm.display_name()
107 }
108
109 pub fn token_tracker(&self) -> &TokenTracker {
111 &self.token_tracker
112 }
113
114 pub async fn run(mut self) {
115 let mut state = IterationState::new();
116 self.emit_tool_definitions().await;
117
118 while let Some((_, event)) = self.streams.next().await {
119 match event {
120 StreamEvent::Command(Command::UserCommand(UserCommand::Cancel)) => {
121 self.on_user_cancel(&mut state).await;
122 }
123
124 StreamEvent::Command(Command::UserCommand(UserCommand::ClearContext)) => {
125 self.on_user_clear_context(&mut state).await;
126 }
127
128 StreamEvent::Command(Command::UserCommand(UserCommand::Text { content })) => {
129 if self.is_busy() {
130 self.queued_user_messages.push_back(content);
131 } else {
132 state = IterationState::new();
133 self.on_user_text(content).await;
134 }
135 }
136
137 StreamEvent::Command(Command::AgentCommand(AgentCommand::SwitchModel(new_provider))) => {
138 self.on_switch_model(new_provider).await;
139 }
140
141 StreamEvent::Command(Command::AgentCommand(AgentCommand::UpdateTools(tools))) => {
142 self.context.set_tools(tools);
143 self.emit_tool_definitions().await;
144 }
145
146 StreamEvent::Command(Command::AgentCommand(AgentCommand::UpdateMcpInstructions { server, body })) => {
147 self.on_update_instruction(server, body).await;
148 }
149
150 StreamEvent::Command(Command::AgentCommand(AgentCommand::SetReasoningEffort(effort))) => {
151 self.context.set_reasoning_effort(effort);
152 }
153
154 StreamEvent::Command(Command::AgentCommand(AgentCommand::ReplaceConversation(messages))) => {
155 self.on_replace_conversation(messages, &mut state).await;
156 }
157
158 StreamEvent::LlmRequestStarted { attempt } => {
159 self.begin_chat_call(attempt).await;
160 }
161
162 StreamEvent::Llm(llm_event) => {
163 self.on_llm_event(llm_event, &mut state).await;
164 }
165
166 StreamEvent::ToolExecution(tool_event) => {
167 self.on_tool_execution_event(tool_event, &mut state).await;
168 }
169 }
170
171 if state.is_complete() {
172 let Some(id) = state.current_message_id.take() else {
173 continue;
174 };
175 let iteration = std::mem::replace(&mut state, IterationState::new());
176 self.on_iteration_complete(id, iteration).await;
177 }
178 }
179
180 tracing::debug!("Agent task shutting down - input channel closed");
181 }
182
183 async fn on_iteration_complete(&mut self, id: String, iteration: IterationState) {
184 let IterationState {
185 message_content,
186 reasoning_summary_text,
187 encrypted_reasoning,
188 completed_tool_calls,
189 stop_reason,
190 ..
191 } = iteration;
192 let has_tool_calls = !completed_tool_calls.is_empty();
193 let has_content = !message_content.is_empty() || has_tool_calls;
194 let should_auto_continue = self.auto_continue.should_continue(stop_reason.as_ref());
195
196 if has_content {
197 let reasoning = AssistantReasoning::from_parts(reasoning_summary_text.clone(), encrypted_reasoning);
198 self.update_context(&message_content, reasoning, completed_tool_calls);
199
200 self.emit(AgentEvent::text(&id, &message_content, true)).await;
201
202 if !reasoning_summary_text.is_empty() {
203 self.emit(AgentEvent::thought(&id, &reasoning_summary_text, true)).await;
204 }
205 }
206
207 let has_queued_text = !self.queued_user_messages.is_empty();
208 if has_queued_text {
209 let content: Vec<_> = self.queued_user_messages.drain(..).flatten().collect();
210 self.context.add_message(ChatMessage::User { content, timestamp: IsoString::now() });
211 }
212
213 if has_queued_text || has_tool_calls {
214 self.auto_continue.reset();
215 self.start_next_turn().await;
216 } else if should_auto_continue {
217 self.auto_continue.advance();
218 tracing::info!(
219 "LLM stopped with {:?}, auto-continuing (attempt {}/{})",
220 stop_reason,
221 self.auto_continue.count(),
222 self.auto_continue.max()
223 );
224
225 self.emit(AgentEvent::Turn(TurnEvent::AutoContinue {
226 attempt: self.auto_continue.count(),
227 max_attempts: self.auto_continue.max(),
228 }))
229 .await;
230
231 self.inject_continuation_prompt(&message_content, stop_reason.as_ref());
232 self.start_next_turn().await;
233 } else {
234 tracing::debug!("LLM completed turn with stop reason: {:?}", stop_reason);
235 self.auto_continue.reset();
236 self.finish_turn(TurnOutcome::Completed).await;
237 }
238 }
239
240 async fn start_next_turn(&mut self) {
241 self.maybe_preflight_compact().await;
242 self.start_llm_stream(None, 0).await;
243 }
244
245 async fn on_user_cancel(&mut self, state: &mut IterationState) {
246 self.abort_in_flight_work().await;
247 *state = IterationState::new();
248 self.finish_turn(TurnOutcome::Cancelled).await;
249 }
250
251 async fn on_user_clear_context(&mut self, state: &mut IterationState) {
252 self.abort_in_flight_work().await;
253 self.context.clear_conversation();
254 self.token_tracker.reset_current_usage();
255 self.auto_continue.reset();
256 *state = IterationState::new();
257
258 self.emit(AgentEvent::Context(ContextEvent::Cleared)).await;
259 self.finish_turn(TurnOutcome::Cancelled).await;
260 }
261
262 async fn on_replace_conversation(&mut self, messages: Vec<ChatMessage>, state: &mut IterationState) {
263 self.abort_in_flight_work().await;
264 self.context.replace_conversation(messages);
265 self.auto_continue.reset();
266 *state = IterationState::new();
267 self.emit(self.context_usage_message()).await;
268 self.finish_turn(TurnOutcome::Cancelled).await;
269 }
270
271 async fn on_user_text(&mut self, content: Vec<llm::ContentBlock>) {
272 self.context.add_message(ChatMessage::User { content: content.clone(), timestamp: IsoString::now() });
273 self.auto_continue.reset();
274 self.turn_active = true;
275 self.emit(AgentEvent::Turn(TurnEvent::Started { content })).await;
276 self.start_llm_stream(None, 0).await;
277 }
278
279 async fn on_update_instruction(&mut self, server: String, body: Option<String>) {
280 self.prompt_cache.update_mcp_instruction(server, body);
281 match self.prompt_cache.render().await {
282 Ok(content) => self.context.set_system_content(content),
283 Err(e) => tracing::warn!("Failed to rebuild system prompt after instructions update: {e}"),
284 }
285 }
286
287 async fn on_switch_model(&mut self, new_provider: Box<dyn StreamingModelProvider>) {
288 let previous = self.llm.display_name();
289 let new_context_limit = self.context_window.or_else(|| new_provider.context_window());
290 self.llm = Arc::from(new_provider);
291 self.token_tracker.reset_current_usage();
292 self.token_tracker.set_context_limit(new_context_limit);
293 let new = self.llm.display_name();
294 self.emit(AgentEvent::Model(ModelEvent::Switched { previous, new })).await;
295
296 self.emit(self.context_usage_message()).await;
297 }
298
299 async fn start_llm_stream(&mut self, delay: Option<Duration>, attempt: u32) {
300 self.streams.remove(LLM_STREAM_KEY);
301 let stream: EventStream = match delay {
302 None => {
303 self.begin_chat_call(attempt).await;
304 Box::pin(self.llm.stream_response(&self.context).map(StreamEvent::Llm))
305 }
306 Some(delay) => {
307 self.emit(AgentEvent::Turn(TurnEvent::RetryScheduled {
308 purpose: LlmCallPurpose::Chat,
309 attempt,
310 max_attempts: self.retry_config.max_attempts,
311 delay_ms: u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
312 }))
313 .await;
314 let llm = Arc::clone(&self.llm);
315 let context = self.context.clone();
316 Box::pin(async_stream::stream! {
317 sleep(delay).await;
318 yield StreamEvent::LlmRequestStarted { attempt };
319 let mut inner = llm.stream_response(&context);
320 while let Some(item) = inner.next().await {
321 yield StreamEvent::Llm(item);
322 }
323 })
324 }
325 };
326 self.streams.insert(LLM_STREAM_KEY.to_string(), stream);
327 }
328
329 async fn on_llm_error(&mut self, error: LlmError, state: &mut IterationState) {
330 let will_retry = error.is_retryable() && state.retry_attempt < self.retry_config.max_attempts;
331 let error_message = error.to_string();
332 self.finish_chat_call(LlmCallOutcome::Failed { error: error_message.clone(), will_retry }).await;
333
334 if !will_retry {
335 self.finish_turn(TurnOutcome::Failed { error: error_message }).await;
336 return;
337 }
338
339 state.retry_attempt += 1;
340 let delay = self.retry_config.compute_delay(state.retry_attempt);
341
342 tracing::warn!(
343 attempt = state.retry_attempt,
344 max_attempts = self.retry_config.max_attempts,
345 delay_ms = u64::try_from(delay.as_millis()).unwrap_or(u64::MAX),
346 error = %error,
347 "Retrying LLM request after transient failure"
348 );
349
350 self.active_requests.clear();
353 self.start_llm_stream(Some(delay), state.retry_attempt).await;
354 }
355
356 fn is_busy(&self) -> bool {
357 self.streams.contains_key(LLM_STREAM_KEY) || !self.active_requests.is_empty()
358 }
359
360 async fn abort_in_flight_work(&mut self) {
361 if self.llm_call_active {
362 self.finish_chat_call(LlmCallOutcome::Cancelled).await;
363 }
364 self.streams.remove(LLM_STREAM_KEY);
365 for stream_key in self.active_requests.keys().cloned().collect::<Vec<_>>() {
366 self.streams.remove(&stream_key);
367 }
368 self.active_requests.clear();
369 self.queued_user_messages.clear();
370 }
371
372 fn inject_continuation_prompt(&mut self, previous_response: &str, stop_reason: Option<&StopReason>) {
374 if !previous_response.is_empty() {
375 self.context.add_message(ChatMessage::Assistant {
376 content: previous_response.to_string(),
377 reasoning: AssistantReasoning::default(),
378 timestamp: IsoString::now(),
379 tool_calls: Vec::new(),
380 });
381 }
382
383 let reason = stop_reason.map_or_else(|| "Unknown".to_string(), |reason| format!("{reason:?}"));
384
385 self.context.add_message(ChatMessage::User {
386 content: vec![llm::ContentBlock::text(format!(
387 "<system-notification>The LLM API stopped with reason '{reason}'. Continue from where you left off and finish your task.</system-notification>"
388 ))],
389 timestamp: IsoString::now(),
390 });
391 }
392
393 async fn on_llm_event(&mut self, result: Result<LlmResponse, LlmError>, state: &mut IterationState) {
394 use LlmResponse::{
395 Done, EncryptedReasoning, Error, Reasoning, Start, Text, ToolRequestArg, ToolRequestComplete,
396 ToolRequestStart, Usage,
397 };
398
399 let response = match result {
400 Ok(response) => response,
401 Err(e) => {
402 self.on_llm_error(e, state).await;
403 return;
404 }
405 };
406
407 match response {
408 Start { message_id } => {
409 state.on_llm_start(message_id);
410 }
411
412 Text { chunk } => {
413 self.handle_llm_text(chunk, state).await;
414 }
415
416 Reasoning { chunk } => {
417 state.reasoning_summary_text.push_str(&chunk);
418 if let Some(id) = state.current_message_id.clone() {
419 self.emit(AgentEvent::thought(&id, &chunk, false)).await;
420 }
421 }
422
423 EncryptedReasoning { id, content } => {
424 if let Some(model) = self.llm.model() {
425 state.encrypted_reasoning = Some(EncryptedReasoningContent { id, model, content });
426 }
427 }
428
429 ToolRequestStart { id, name } => {
430 self.handle_tool_request_start(id, name).await;
431 }
432
433 ToolRequestArg { id, chunk } => {
434 self.handle_tool_request_arg(id, chunk).await;
435 }
436
437 ToolRequestComplete { tool_call } => {
438 self.handle_tool_completion(tool_call, state).await;
439 }
440
441 Done { stop_reason } => {
442 state.llm_done = true;
443 state.stop_reason = stop_reason;
444 self.finish_chat_call(LlmCallOutcome::Completed {
445 stop_reason: state.stop_reason.clone(),
446 usage: state.call_usage.take(),
447 })
448 .await;
449 }
450
451 Error { message } => {
452 self.finish_chat_call(LlmCallOutcome::Failed { error: message.clone(), will_retry: false }).await;
453 self.finish_turn(TurnOutcome::Failed { error: message }).await;
454 }
455
456 Usage { tokens: sample } => {
457 self.handle_llm_usage(sample, state).await;
458 }
459 }
460 }
461
462 async fn handle_llm_text(&mut self, chunk: String, state: &mut IterationState) {
463 state.message_content.push_str(&chunk);
464
465 if let Some(id) = state.current_message_id.clone() {
466 self.emit(AgentEvent::text(&id, &chunk, false)).await;
467 }
468 }
469
470 async fn handle_tool_request_start(&mut self, id: String, name: String) {
471 let request = ToolCallRequest { id: id.clone(), name, arguments: String::new() };
472 self.active_requests.insert(id, request.clone());
473
474 self.emit(AgentEvent::Tool(ToolEvent::Call { request })).await;
475 }
476
477 async fn handle_tool_request_arg(&mut self, id: String, chunk: String) {
478 let Some(request) = self.active_requests.get_mut(&id) else {
479 return;
480 };
481 request.arguments.push_str(&chunk);
482
483 self.emit(AgentEvent::Tool(ToolEvent::CallUpdate { tool_call_id: id, chunk })).await;
484 }
485
486 async fn handle_tool_completion(&mut self, tool_call: ToolCallRequest, state: &mut IterationState) {
487 state.pending_tool_ids.insert(tool_call.id.clone());
488 debug_assert!(
489 self.active_requests.contains_key(&tool_call.id),
490 "tool call {} should already be in active_requests from handle_tool_request_start",
491 tool_call.id
492 );
493
494 let (tx, rx) = mpsc::channel(100);
495 let stream = ReceiverStream::new(rx).map(StreamEvent::ToolExecution);
496 let stream_key = tool_call.id.clone();
497 self.streams.insert(stream_key, Box::pin(stream));
498
499 if let Some(ref mcp_command_tx) = self.mcp_command_tx {
500 let mcp_future =
501 mcp_command_tx.send(McpCommand::ExecuteTool { request: tool_call, timeout: self.tool_timeout, tx });
502 if let Err(e) = mcp_future.await {
503 tracing::warn!("Failed to send tool request to MCP task: {:?}", e);
504 }
505 }
506 }
507
508 async fn handle_llm_usage(&mut self, sample: TokenUsage, state: &mut IterationState) {
509 state.call_usage = Some(sample);
510 self.token_tracker.record_usage(sample);
511 let ratio_pct = self.token_tracker.usage_ratio().map(|r| r * 100.0);
512 let remaining = self.token_tracker.tokens_remaining();
513 tracing::debug!(?sample, ?ratio_pct, ?remaining, "Token usage");
514
515 self.emit(self.context_usage_message()).await;
516
517 self.maybe_compact_context().await;
518 }
519
520 fn context_usage_message(&self) -> AgentEvent {
521 let last = self.token_tracker.last_usage();
522 AgentEvent::Context(ContextEvent::UsageUpdated {
523 usage: ContextUsage {
524 usage_ratio: self.token_tracker.usage_ratio(),
525 context_limit: self.token_tracker.context_limit(),
526 input_tokens: last.input_tokens,
527 output_tokens: last.output_tokens,
528 cache_read_tokens: last.cache_read_tokens,
529 cache_creation_tokens: last.cache_creation_tokens,
530 reasoning_tokens: last.reasoning_tokens,
531 total_input_tokens: self.token_tracker.total_input_tokens(),
532 total_output_tokens: self.token_tracker.total_output_tokens(),
533 total_cache_read_tokens: self.token_tracker.total_cache_read_tokens(),
534 total_cache_creation_tokens: self.token_tracker.total_cache_creation_tokens(),
535 total_reasoning_tokens: self.token_tracker.total_reasoning_tokens(),
536 },
537 })
538 }
539
540 async fn maybe_preflight_compact(&mut self) {
544 let Some(context_limit) = self.token_tracker.context_limit() else {
545 return;
546 };
547 let Some(config) = self.compaction_config.as_ref() else {
548 return;
549 };
550 let estimated = self.context.estimated_token_count();
551 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
552 let threshold = (f64::from(context_limit) * config.threshold).ceil() as u32;
553 if estimated >= threshold {
554 tracing::info!(
555 "Pre-flight compaction triggered: estimated {estimated} tokens >= {:.1}% of {context_limit} limit",
556 config.threshold * 100.0
557 );
558 if let CompactionOutcome::Failed(e) = self.compact_context().await {
559 tracing::warn!("Pre-flight compaction failed: {e}");
560 }
561 }
562 }
563
564 async fn maybe_compact_context(&mut self) {
566 if !self.compaction_config.as_ref().is_some_and(|config| self.token_tracker.should_compact(config.threshold)) {
567 return;
568 }
569
570 if let CompactionOutcome::Failed(error_message) = self.compact_context().await {
571 tracing::warn!("Context compaction failed: {}", error_message);
572 }
573 }
574
575 async fn compact_context(&mut self) -> CompactionOutcome {
576 let Some(ref _config) = self.compaction_config else {
577 tracing::warn!("Context compaction requested but compaction is disabled");
578 return CompactionOutcome::SkippedDisabled;
579 };
580
581 match self.token_tracker.usage_ratio() {
582 Some(usage_ratio) => {
583 tracing::info!(
584 "Starting context compaction - {} messages, {:.1}% of context limit",
585 self.context.message_count(),
586 usage_ratio * 100.0
587 );
588 }
589 None => {
590 tracing::info!(
591 "Starting context compaction - {} messages (context limit unknown)",
592 self.context.message_count(),
593 );
594 }
595 }
596
597 self.emit(AgentEvent::Context(ContextEvent::CompactionStarted { message_count: self.context.message_count() }))
598 .await;
599
600 let compactor = Compactor::new(self.llm.clone());
601
602 self.emit(self.llm_call_started(LlmCallPurpose::Compaction, 0)).await;
603 match compactor.compact(&self.context).await {
604 Ok(result) => {
605 tracing::info!("Context compacted: {} messages removed", result.messages_removed);
606 self.emit(AgentEvent::Turn(TurnEvent::LlmCallEnded {
607 purpose: LlmCallPurpose::Compaction,
608 outcome: LlmCallOutcome::Completed { stop_reason: None, usage: result.usage },
609 }))
610 .await;
611
612 self.context = result.context;
613 self.token_tracker.reset_current_usage();
614
615 self.emit(AgentEvent::Context(ContextEvent::CompactionResult {
616 summary: result.summary,
617 messages_removed: result.messages_removed,
618 }))
619 .await;
620 CompactionOutcome::Compacted
621 }
622 Err(e) => {
623 self.emit(AgentEvent::Turn(TurnEvent::LlmCallEnded {
624 purpose: LlmCallPurpose::Compaction,
625 outcome: LlmCallOutcome::Failed { error: e.to_string(), will_retry: false },
626 }))
627 .await;
628 CompactionOutcome::Failed(e.to_string())
629 }
630 }
631 }
632
633 async fn on_tool_execution_event(&mut self, event: ToolExecutionEvent, state: &mut IterationState) {
634 match event {
635 ToolExecutionEvent::Started { tool_id, tool_name } => {
636 tracing::debug!("Tool execution started: {} ({})", tool_name, tool_id);
637 self.emit(AgentEvent::Tool(ToolEvent::ExecutionStarted { tool_id, tool_name })).await;
638 }
639
640 ToolExecutionEvent::Progress { tool_id, progress } => {
641 tracing::debug!(
642 "Tool progress for {}: {}/{}",
643 tool_id,
644 progress.progress,
645 progress.total.unwrap_or(0.0)
646 );
647
648 if let Some(request) = self.active_requests.get(&tool_id).cloned() {
649 self.emit(AgentEvent::Tool(ToolEvent::Progress {
650 request,
651 progress: progress.progress,
652 total: progress.total,
653 message: progress.message.clone(),
654 }))
655 .await;
656 }
657 }
658
659 ToolExecutionEvent::Complete { tool_id: _, result, result_meta } => match result {
660 Ok(tool_result) => {
661 tracing::debug!("Tool result received: {} -> {}", tool_result.name, tool_result.result.len());
662
663 if state.pending_tool_ids.remove(&tool_result.id) {
664 self.active_requests.remove(&tool_result.id);
665 state.completed_tool_calls.push(Ok(tool_result.clone()));
666
667 self.emit(AgentEvent::Tool(ToolEvent::Result { result: tool_result, result_meta })).await;
668 } else {
669 tracing::debug!("Ignoring stale tool result for id: {}", tool_result.id);
670 }
671 }
672
673 Err(tool_error) => {
674 if state.pending_tool_ids.remove(&tool_error.id) {
675 self.active_requests.remove(&tool_error.id);
676 state.completed_tool_calls.push(Err(tool_error.clone()));
677
678 self.emit(AgentEvent::Tool(ToolEvent::Error { error: tool_error })).await;
679 }
680 }
681 },
682 }
683 }
684
685 fn update_context(
686 &mut self,
687 message_content: &str,
688 reasoning: AssistantReasoning,
689 completed_tools: Vec<Result<ToolCallResult, ToolCallError>>,
690 ) {
691 self.context.push_assistant_turn(message_content, reasoning, completed_tools);
692 }
693
694 async fn emit_tool_definitions(&mut self) {
695 let tools = self.context.tools().clone();
696 if !tools.is_empty() {
697 self.emit(AgentEvent::Tool(ToolEvent::DefinitionsUpdated { tools })).await;
698 }
699 }
700
701 async fn emit(&mut self, message: AgentEvent) {
702 for observer in &mut self.observers {
703 observer.on_event(&message);
704 }
705
706 if let Err(e) = self.message_tx.send(message).await {
707 tracing::warn!("Failed to send agent message: {e:?}");
708 }
709 }
710
711 async fn finish_turn(&mut self, outcome: TurnOutcome) {
712 if std::mem::take(&mut self.turn_active) {
713 self.emit(AgentEvent::turn_ended(outcome)).await;
714 }
715 }
716
717 async fn begin_chat_call(&mut self, attempt: u32) {
718 self.llm_call_active = true;
719 self.emit(self.llm_call_started(LlmCallPurpose::Chat, attempt)).await;
720 }
721
722 async fn finish_chat_call(&mut self, outcome: LlmCallOutcome) {
723 if std::mem::take(&mut self.llm_call_active) {
724 self.emit(AgentEvent::Turn(TurnEvent::LlmCallEnded { purpose: LlmCallPurpose::Chat, outcome })).await;
725 }
726 }
727
728 fn llm_call_started(&self, purpose: LlmCallPurpose, attempt: u32) -> AgentEvent {
729 let model = self.llm.model();
730 AgentEvent::Turn(TurnEvent::LlmCallStarted {
731 purpose,
732 provider: model.as_ref().map(|m| m.provider().to_string()),
733 model: model.map(|m| m.model_id().into_owned()),
734 display_name: self.llm.display_name(),
735 attempt,
736 max_attempts: self.retry_config.max_attempts,
737 })
738 }
739}
740
741#[derive(Debug, Clone, PartialEq, Eq)]
742enum CompactionOutcome {
743 Compacted,
744 SkippedDisabled,
745 Failed(String),
746}
747
748pub(crate) struct AutoContinue {
749 max: u32,
750 count: u32,
751}
752
753impl AutoContinue {
754 pub(crate) fn new(max: u32) -> Self {
755 Self { max, count: 0 }
756 }
757
758 fn reset(&mut self) {
759 self.count = 0;
760 }
761
762 fn should_continue(&self, stop_reason: Option<&StopReason>) -> bool {
763 matches!(stop_reason, Some(StopReason::Length)) && self.count < self.max
764 }
765
766 fn advance(&mut self) {
767 self.count += 1;
768 }
769
770 fn count(&self) -> u32 {
771 self.count
772 }
773
774 fn max(&self) -> u32 {
775 self.max
776 }
777}
778
779#[derive(Debug)]
780struct IterationState {
781 current_message_id: Option<String>,
782 message_content: String,
783 reasoning_summary_text: String,
784 encrypted_reasoning: Option<EncryptedReasoningContent>,
785 pending_tool_ids: HashSet<String>,
786 completed_tool_calls: Vec<Result<ToolCallResult, ToolCallError>>,
787 llm_done: bool,
788 stop_reason: Option<StopReason>,
789 retry_attempt: u32,
790 call_usage: Option<TokenUsage>,
791}
792
793impl IterationState {
794 fn new() -> Self {
795 Self {
796 current_message_id: None,
797 message_content: String::new(),
798 reasoning_summary_text: String::new(),
799 encrypted_reasoning: None,
800 pending_tool_ids: HashSet::new(),
801 completed_tool_calls: Vec::new(),
802 llm_done: false,
803 stop_reason: None,
804 retry_attempt: 0,
805 call_usage: None,
806 }
807 }
808
809 fn on_llm_start(&mut self, message_id: String) {
810 self.current_message_id = Some(message_id);
811 self.message_content.clear();
812 self.reasoning_summary_text.clear();
813 self.encrypted_reasoning = None;
814 self.stop_reason = None;
815 self.call_usage = None;
816 }
817
818 fn is_complete(&self) -> bool {
819 self.llm_done && self.pending_tool_ids.is_empty()
820 }
821}
822
823#[cfg(test)]
824mod tests {
825 use crate::core::{AgentBuilder, Prompt};
826
827 use super::*;
828 use llm::{ContentBlock, testing::FakeLlmProvider};
829 use tokio::sync::mpsc;
830
831 #[tokio::test]
832 async fn replace_conversation_preserves_system_prompt_for_next_request() {
833 let llm = FakeLlmProvider::with_single_response(vec![LlmResponse::start("msg"), LlmResponse::done()]);
834
835 let captured_contexts = llm.captured_contexts();
836 let (tx, mut rx, handle) =
837 AgentBuilder::new(Arc::new(llm)).system_prompt(Prompt::text("original system")).spawn().await.unwrap();
838
839 tx.send(Command::AgentCommand(AgentCommand::ReplaceConversation(vec![
840 ChatMessage::User { content: vec![ContentBlock::text("old user")], timestamp: IsoString::now() },
841 ChatMessage::Assistant {
842 content: "old assistant".to_string(),
843 reasoning: AssistantReasoning::default(),
844 timestamp: IsoString::now(),
845 tool_calls: vec![],
846 },
847 ])))
848 .await
849 .unwrap();
850
851 tx.send(Command::UserCommand(UserCommand::Text { content: vec![ContentBlock::text("new user")] }))
852 .await
853 .unwrap();
854
855 while let Some(message) = rx.recv().await {
856 if matches!(message, AgentEvent::Turn(TurnEvent::Ended { .. })) {
857 break;
858 }
859 }
860
861 let contexts = captured_contexts.lock().unwrap();
862 let messages = contexts.last().expect("provider should receive a context").messages();
863 assert!(matches!(messages[0], ChatMessage::System { ref content, .. } if content == "original system"));
864 assert!(
865 matches!(messages[1], ChatMessage::User { ref content, .. } if content == &vec![llm::ContentBlock::text("old user")])
866 );
867 assert!(matches!(messages[2], ChatMessage::Assistant { ref content, .. } if content == "old assistant"));
868 assert!(
869 matches!(messages[3], ChatMessage::User { ref content, .. } if content == &vec![llm::ContentBlock::text("new user")])
870 );
871 handle.abort();
872 }
873
874 #[tokio::test]
875 async fn replace_conversation_preserves_token_usage() {
876 let llm = FakeLlmProvider::new(vec![vec![
877 LlmResponse::start("msg"),
878 LlmResponse::usage(800, 10),
879 LlmResponse::done(),
880 ]])
881 .with_context_window(Some(1000));
882 let (tx, mut rx, handle) = AgentBuilder::new(Arc::new(llm)).spawn().await.unwrap();
883
884 tx.send(Command::UserCommand(UserCommand::Text { content: vec![llm::ContentBlock::text("first user")] }))
885 .await
886 .unwrap();
887
888 while let Some(message) = rx.recv().await {
889 if matches!(message, AgentEvent::Turn(TurnEvent::Ended { .. })) {
890 break;
891 }
892 }
893
894 tx.send(Command::AgentCommand(AgentCommand::ReplaceConversation(vec![ChatMessage::User {
895 content: vec![ContentBlock::text("replacement user")],
896 timestamp: IsoString::now(),
897 }])))
898 .await
899 .unwrap();
900
901 let Some(AgentEvent::Context(ContextEvent::UsageUpdated { usage })) = rx.recv().await else {
902 panic!("expected context usage update after conversation replacement");
903 };
904
905 assert_eq!(usage.input_tokens, 800);
906 assert_eq!(usage.usage_ratio, Some(0.8));
907 handle.abort();
908 }
909
910 #[tokio::test]
911 async fn test_preflight_compaction_uses_configured_threshold() {
912 let llm = Arc::new(
913 FakeLlmProvider::with_single_response(vec![
914 LlmResponse::start("summary"),
915 LlmResponse::text("summary"),
916 LlmResponse::done(),
917 ])
918 .with_context_window(Some(100)),
919 );
920 let context = Context::new(
921 vec![ChatMessage::User {
922 content: vec![llm::ContentBlock::text("x".repeat(344))],
923 timestamp: IsoString::now(),
924 }],
925 vec![],
926 );
927 let (user_tx, user_rx) = mpsc::channel(1);
928 let (message_tx, _message_rx) = mpsc::channel(8);
929 drop(user_tx);
930
931 let mut agent = Agent::new(
932 AgentConfig {
933 llm,
934 context,
935 mcp_command_tx: None,
936 tool_timeout: Duration::from_secs(1),
937 compaction_config: Some(CompactionConfig::with_threshold(0.85)),
938 auto_continue: AutoContinue::new(0),
939 retry_config: RetryConfig::disabled(),
940 context_window: None,
941 prompt_cache: PromptCache::new(vec![]),
942 observers: Vec::new(),
943 },
944 user_rx,
945 message_tx,
946 );
947
948 agent.maybe_preflight_compact().await;
949
950 assert!(
951 matches!(
952 agent.context.messages().as_slice(),
953 [ChatMessage::Summary { content, .. }] if content == "summary"
954 ),
955 "expected context to be compacted, got {:?}",
956 agent.context.messages()
957 );
958 }
959}