1use zeph_llm::provider::{LlmProvider, Message, MessagePart, Role};
5
6use super::{Agent, CODE_CONTEXT_PREFIX};
7use crate::channel::Channel;
8use crate::metrics::{MetricsSnapshot, SECURITY_EVENT_CAP, SecurityEvent};
9use zeph_common::SecurityEventCategory;
10use zeph_tools::FilterStats;
11
12pub(super) async fn fetch_graph_counts(store: &zeph_memory::graph::GraphStore) -> (u64, u64, u64) {
18 let (entities, edges, communities) = tokio::join!(
19 store.entity_count(),
20 store.active_edge_count(),
21 store.community_count()
22 );
23 (
24 entities.unwrap_or(0).cast_unsigned(),
25 edges.unwrap_or(0).cast_unsigned(),
26 communities.unwrap_or(0).cast_unsigned(),
27 )
28}
29
30impl<C: Channel> Agent<C> {
31 pub fn sync_community_detection_failures(&self) {
33 if let Some(memory) = self.services.memory.persistence.memory.as_ref() {
34 let failures = memory.community_detection_failures();
35 self.update_metrics(|m| {
36 m.graph_community_detection_failures = failures;
37 });
38 }
39 }
40
41 pub fn sync_graph_extraction_metrics(&self) {
43 if let Some(memory) = self.services.memory.persistence.memory.as_ref() {
44 let count = memory.graph_extraction_count();
45 let failures = memory.graph_extraction_failures();
46 self.update_metrics(|m| {
47 m.graph_extraction_count = count;
48 m.graph_extraction_failures = failures;
49 });
50 }
51 }
52
53 pub async fn sync_graph_counts(&self) {
55 let Some(memory) = self.services.memory.persistence.memory.as_ref() else {
56 return;
57 };
58 let Some(store) = memory.graph_store.as_ref() else {
59 return;
60 };
61 let (entities, edges, communities) = fetch_graph_counts(store).await;
62 self.update_metrics(|m| {
63 m.graph_entities_total = entities;
64 m.graph_edges_total = edges;
65 m.graph_communities_total = communities;
66 });
67 }
68
69 pub async fn check_vector_store_health(&self, backend_name: &str) {
71 let connected = match self.services.memory.persistence.memory.as_ref() {
72 Some(m) => m.is_vector_store_connected().await,
73 None => false,
74 };
75 let name = backend_name.to_owned();
76 self.update_metrics(|m| {
77 m.qdrant_available = connected;
78 m.vector_backend = name;
79 });
80 }
81
82 pub async fn sync_guidelines_status(&self) {
87 let Some(memory) = self.services.memory.persistence.memory.as_ref() else {
88 return;
89 };
90 let cid = self.services.memory.persistence.conversation_id;
91 match memory.sqlite().load_compression_guidelines_meta(cid).await {
92 Ok((version, created_at)) => {
93 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
94 let version_u32 = u32::try_from(version).unwrap_or(0);
95 self.update_metrics(|m| {
96 m.guidelines_version = version_u32;
97 m.guidelines_updated_at = created_at;
98 });
99 }
100 Err(e) => {
101 tracing::warn!("failed to sync guidelines status: {e:#}");
102 }
103 }
104 }
105
106 pub(super) fn record_filter_metrics(&mut self, fs: &FilterStats) {
107 let saved = fs.estimated_tokens_saved() as u64;
108 let raw = (fs.raw_chars / 4) as u64;
109 let confidence = fs.confidence;
110 let was_filtered = fs.filtered_chars < fs.raw_chars;
111 self.update_metrics(|m| {
112 m.filter_raw_tokens += raw;
113 m.filter_saved_tokens += saved;
114 m.filter_applications += 1;
115 m.filter_total_commands += 1;
116 if was_filtered {
117 m.filter_filtered_commands += 1;
118 }
119 if let Some(c) = confidence {
120 match c {
121 zeph_tools::FilterConfidence::Full => {
122 m.filter_confidence_full += 1;
123 }
124 zeph_tools::FilterConfidence::Partial => {
125 m.filter_confidence_partial += 1;
126 }
127 zeph_tools::FilterConfidence::Fallback => {
128 m.filter_confidence_fallback += 1;
129 }
130 _ => {}
131 }
132 }
133 });
134 }
135
136 pub(super) fn update_metrics(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
137 if let Some(ref tx) = self.runtime.metrics.metrics_tx {
138 let elapsed = self.runtime.lifecycle.start_time.elapsed().as_secs();
139 tx.send_modify(|m| {
140 m.uptime_seconds = elapsed;
141 f(m);
142 });
143 }
144 }
145
146 pub(crate) fn publish_context_budget(&self) {
153 let max_tokens = self
154 .context_manager
155 .budget
156 .as_ref()
157 .map_or(0, |b| b.max_tokens() as u64);
158 self.update_metrics(|m| m.context_max_tokens = max_tokens);
159 }
160
161 pub(super) fn flush_turn_timings(&mut self) {
172 #[cfg_attr(not(feature = "profiling"), allow(unused_mut))]
173 let mut timings = std::mem::take(&mut self.runtime.metrics.pending_timings);
174 tracing::debug!(
175 prepare_context_ms = timings.prepare_context_ms,
176 llm_chat_ms = timings.llm_chat_ms,
177 tool_exec_ms = timings.tool_exec_ms,
178 persist_message_ms = timings.persist_message_ms,
179 "turn timings"
180 );
181
182 #[cfg(feature = "profiling")]
189 self.update_metrics(|m| {
190 let mask = m.bridge_timings_written;
191 if mask & crate::metrics_bridge::TimingField::PrepareContext.bridge_bit() != 0 {
192 timings.prepare_context_ms = m.last_turn_timings.prepare_context_ms;
193 }
194 if mask & crate::metrics_bridge::TimingField::LlmChat.bridge_bit() != 0 {
195 timings.llm_chat_ms = m.last_turn_timings.llm_chat_ms;
196 }
197 if mask & crate::metrics_bridge::TimingField::ToolExec.bridge_bit() != 0 {
198 timings.tool_exec_ms = m.last_turn_timings.tool_exec_ms;
199 }
200 m.bridge_timings_written = 0;
204 m.last_turn_timings.llm_chat_ms = 0;
209 });
210
211 if self.runtime.metrics.timing_window.len() >= 10 {
212 self.runtime.metrics.timing_window.pop_front();
213 }
214 self.runtime
215 .metrics
216 .timing_window
217 .push_back(timings.clone());
218
219 let count = self.runtime.metrics.timing_window.len();
220 let mut avg = crate::metrics::TurnTimings::default();
221 let mut max = crate::metrics::TurnTimings::default();
222 for t in &self.runtime.metrics.timing_window {
223 avg.prepare_context_ms = avg.prepare_context_ms.saturating_add(t.prepare_context_ms);
224 avg.llm_chat_ms = avg.llm_chat_ms.saturating_add(t.llm_chat_ms);
225 avg.tool_exec_ms = avg.tool_exec_ms.saturating_add(t.tool_exec_ms);
226 avg.persist_message_ms = avg.persist_message_ms.saturating_add(t.persist_message_ms);
227
228 max.prepare_context_ms = max.prepare_context_ms.max(t.prepare_context_ms);
229 max.llm_chat_ms = max.llm_chat_ms.max(t.llm_chat_ms);
230 max.tool_exec_ms = max.tool_exec_ms.max(t.tool_exec_ms);
231 max.persist_message_ms = max.persist_message_ms.max(t.persist_message_ms);
232 }
233 let n = count as u64;
234 avg.prepare_context_ms /= n;
235 avg.llm_chat_ms /= n;
236 avg.tool_exec_ms /= n;
237 avg.persist_message_ms /= n;
238
239 let total_ms = timings
240 .prepare_context_ms
241 .saturating_add(timings.llm_chat_ms)
242 .saturating_add(timings.tool_exec_ms)
243 .saturating_add(timings.persist_message_ms);
244
245 self.update_metrics(|m| {
246 m.last_turn_timings = timings;
247 m.avg_turn_timings = avg;
248 m.max_turn_timings = max;
249 m.timing_sample_count = n;
250 });
251
252 if let Some(ref recorder) = self.runtime.metrics.histogram_recorder {
253 recorder.observe_turn_duration(std::time::Duration::from_millis(total_ms));
254 }
255 }
256
257 pub(super) fn push_classifier_metrics(&self) {
262 if let Some(ref m) = self.runtime.metrics.classifier_metrics {
263 let snapshot = m.snapshot();
264 self.update_metrics(|ms| ms.classifier = snapshot);
265 }
266 }
267
268 pub(super) fn push_security_event(
269 &self,
270 category: SecurityEventCategory,
271 source: &str,
272 detail: impl Into<String>,
273 ) {
274 if let Some(ref tx) = self.runtime.metrics.metrics_tx {
275 let event = SecurityEvent::new(category, source, detail);
276 let elapsed = self.runtime.lifecycle.start_time.elapsed().as_secs();
277 tx.send_modify(|m| {
278 m.uptime_seconds = elapsed;
279 if m.security_events.len() >= SECURITY_EVENT_CAP {
280 m.security_events.pop_front();
281 }
282 m.security_events.push_back(event);
283 });
284 }
285 }
286
287 pub(super) fn recompute_prompt_tokens(&mut self) {
288 self.runtime.providers.cached_prompt_tokens = self
289 .msg
290 .messages
291 .iter()
292 .map(|m| self.runtime.metrics.token_counter.count_message_tokens(m) as u64)
293 .sum();
294 }
295
296 pub(super) fn push_message(&mut self, msg: Message) {
297 self.runtime.providers.cached_prompt_tokens +=
298 self.runtime
299 .metrics
300 .token_counter
301 .count_message_tokens(&msg) as u64;
302 if msg.role == zeph_llm::provider::Role::Assistant {
303 self.services.session.last_assistant_at = Some(std::time::Instant::now());
304 }
305 self.msg.track_single_message(msg.role, true);
306 self.msg.messages.push(msg);
307 self.detect_magic_docs_in_messages();
309 }
310
311 pub(super) fn insert_message(&mut self, index: usize, msg: Message) {
318 self.runtime.providers.cached_prompt_tokens +=
319 self.runtime
320 .metrics
321 .token_counter
322 .count_message_tokens(&msg) as u64;
323 if msg.role == zeph_llm::provider::Role::Assistant {
324 self.services.session.last_assistant_at = Some(std::time::Instant::now());
325 }
326 self.msg.track_single_message(msg.role, true);
327 self.msg.messages.insert(index, msg);
328 self.detect_magic_docs_in_messages();
329 }
330
331 pub(crate) fn record_cost_and_cache(&self, input_tokens: u64, output_tokens: u64) {
332 let (cache_write, cache_read) = self.provider.last_cache_usage().unwrap_or((0, 0));
333
334 if let Some(ref tracker) = self.runtime.metrics.cost_tracker {
335 let provider_name = if self.runtime.config.active_provider_name.is_empty() {
336 self.provider.name()
337 } else {
338 self.runtime.config.active_provider_name.as_str()
339 };
340 tracker.record_usage(
341 provider_name,
342 self.provider.provider_kind_str(),
343 &self.runtime.config.model_name,
344 input_tokens,
345 cache_read,
346 cache_write,
347 output_tokens,
348 );
349 let breakdown = tracker.provider_breakdown();
350 self.update_metrics(|m| {
351 m.cost_spent_cents = tracker.current_spend();
352 m.cache_creation_tokens += cache_write;
353 m.cache_read_tokens += cache_read;
354 m.provider_cost_breakdown = breakdown;
355 });
356 } else if cache_write > 0 || cache_read > 0 {
357 self.update_metrics(|m| {
358 m.cache_creation_tokens += cache_write;
359 m.cache_read_tokens += cache_read;
360 });
361 }
362 }
363
364 #[allow(clippy::too_many_arguments)] pub(crate) fn build_usage_record(
386 &self,
387 provider: &zeph_llm::any::AnyProvider,
388 source: zeph_memory::UsageSource,
389 message_id: Option<zeph_memory::MessageId>,
390 conversation_id: Option<zeph_memory::ConversationId>,
391 input_tokens: u64,
392 output_tokens: u64,
393 latency_ms: u64,
394 stream_ttft_ms: Option<u64>,
395 ) -> Option<zeph_memory::UsageRecord> {
396 let tracker = self.runtime.metrics.cost_tracker.as_ref()?;
397 let (cache_write, cache_read) = provider.last_cache_usage().unwrap_or((0, 0));
398 let reasoning_tokens = provider.last_reasoning_tokens();
399 let ttft_ms = stream_ttft_ms.or_else(|| provider.last_ttft_ms());
403 let provider_name = if self.runtime.config.active_provider_name.is_empty() {
404 self.provider.name()
405 } else {
406 self.runtime.config.active_provider_name.as_str()
407 };
408 let cost_cents = tracker.price_of(
409 &self.runtime.config.model_name,
410 input_tokens,
411 cache_read,
412 cache_write,
413 output_tokens,
414 );
415 let tokens_per_sec = ttft_ms.and_then(|ttft| {
418 if latency_ms <= ttft {
419 return None;
420 }
421 #[allow(clippy::cast_precision_loss)]
422 let window_secs = (latency_ms - ttft) as f64 / 1000.0;
423 #[allow(clippy::cast_precision_loss)]
424 Some(output_tokens as f64 / window_secs)
425 });
426 Some(zeph_memory::UsageRecord {
427 message_id,
428 conversation_id,
429 source,
430 provider_name: provider_name.to_owned(),
431 model_name: self.runtime.config.model_name.clone(),
432 input_tokens,
433 output_tokens,
434 cache_read_tokens: cache_read,
435 cache_write_tokens: cache_write,
436 reasoning_tokens,
437 cost_cents,
438 latency_ms,
439 ttft_ms,
440 tokens_per_sec,
441 })
442 }
443
444 pub(crate) fn record_successful_task(&self) {
445 if let Some(ref tracker) = self.runtime.metrics.cost_tracker {
446 tracker.record_successful_task();
447 self.update_metrics(|m| {
448 m.cost_cps_cents = tracker.cps();
449 m.cost_successful_tasks = tracker.successful_tasks();
450 });
451 }
452 }
453
454 pub(super) fn last_assistant_preview(&self, max_chars: usize) -> String {
462 let raw = self
463 .msg
464 .messages
465 .iter()
466 .rev()
467 .find(|m| m.role == Role::Assistant)
468 .map_or("", |m| m.content.as_str());
469
470 if raw.is_empty() {
471 return String::new();
472 }
473
474 let truncated: &str = if raw.chars().count() > max_chars {
476 let end = raw
477 .char_indices()
478 .nth(max_chars)
479 .map_or(raw.len(), |(i, _)| i);
480 &raw[..end]
481 } else {
482 raw
483 };
484
485 crate::redact::scrub_content(truncated).into_owned()
486 }
487
488 pub fn inject_code_context(&mut self, text: &str) {
491 self.remove_code_context_messages();
492 if text.is_empty() || self.msg.messages.len() <= 1 {
493 return;
494 }
495 let content = format!("{CODE_CONTEXT_PREFIX}{text}");
496 self.msg.track_single_message(Role::System, true);
497 self.msg.messages.insert(
498 1,
499 Message::from_parts(
500 Role::System,
501 vec![MessagePart::CodeContext { text: content }],
502 ),
503 );
504 }
505
506 #[must_use]
507 pub fn context_messages(&self) -> &[Message] {
508 &self.msg.messages
509 }
510
511 pub(super) fn truncate_old_tool_results(&mut self) {
521 const LIMIT: usize = 2048;
522 const SUFFIX: &str = "…[truncated]";
523
524 let len = self.msg.messages.len();
525 if len <= 2 {
526 return;
527 }
528 for msg in &mut self.msg.messages[..len - 2] {
529 for part in &mut msg.parts {
530 match part {
531 MessagePart::ToolResult { content, .. } if content.len() > LIMIT => {
532 content.truncate(content.floor_char_boundary(LIMIT));
533 content.push_str(SUFFIX);
534 }
535 MessagePart::ToolOutput { body, .. } if body.len() > LIMIT => {
536 body.truncate(body.floor_char_boundary(LIMIT));
537 body.push_str(SUFFIX);
538 }
539 _ => {}
540 }
541 }
542 }
543 }
544}
545
546#[cfg(test)]
547mod tests {
548 use super::super::agent_tests::{
549 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
550 };
551 use super::*;
552 use zeph_llm::provider::{MessageMetadata, MessagePart};
553 use zeph_memory::graph::GraphStore;
554 use zeph_memory::graph::types::EntityType;
555 use zeph_memory::store::SqliteStore;
556
557 async fn setup_graph_store() -> GraphStore {
558 let sqlite = SqliteStore::new(":memory:").await.unwrap();
559 GraphStore::new(sqlite.pool().clone())
560 }
561
562 #[tokio::test]
563 async fn fetch_graph_counts_empty_store_returns_zeros() {
564 let store = setup_graph_store().await;
565 assert_eq!(fetch_graph_counts(&store).await, (0, 0, 0));
566 }
567
568 #[tokio::test]
569 async fn fetch_graph_counts_reflects_actual_counts() {
570 let store = setup_graph_store().await;
571 let a = store
572 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
573 .await
574 .unwrap()
575 .0;
576 let b = store
577 .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
578 .await
579 .unwrap()
580 .0;
581 store
582 .insert_edge(a, b, "knows", "Alice knows Bob", 1.0, None, None)
583 .await
584 .unwrap();
585 store
586 .upsert_community("cluster", "summary", &[a, b], None)
587 .await
588 .unwrap();
589
590 assert_eq!(fetch_graph_counts(&store).await, (2, 1, 1));
591 }
592
593 #[tokio::test]
597 async fn fetch_graph_counts_falls_back_to_zero_per_field_on_error() {
598 let sqlite = SqliteStore::new(":memory:").await.unwrap();
599 let pool = sqlite.pool().clone();
600 let store = GraphStore::new(pool.clone());
601 let a = store
602 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
603 .await
604 .unwrap()
605 .0;
606 let b = store
607 .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
608 .await
609 .unwrap()
610 .0;
611 store
612 .insert_edge(a, b, "knows", "Alice knows Bob", 1.0, None, None)
613 .await
614 .unwrap();
615
616 sqlx::query("DROP TABLE graph_communities")
617 .execute(&pool)
618 .await
619 .unwrap();
620
621 assert_eq!(fetch_graph_counts(&store).await, (2, 1, 0));
622 }
623
624 #[test]
625 fn push_message_increments_cached_tokens() {
626 let provider = mock_provider(vec![]);
627 let channel = MockChannel::new(vec![]);
628 let registry = create_test_registry();
629 let executor = MockToolExecutor::no_tools();
630 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
631
632 let before = agent.runtime.providers.cached_prompt_tokens;
633 let msg = Message {
634 role: Role::User,
635 content: "hello world!!".to_string(),
636 parts: vec![],
637 metadata: MessageMetadata::default(),
638 };
639 let expected_delta = agent
640 .runtime
641 .metrics
642 .token_counter
643 .count_message_tokens(&msg) as u64;
644 agent.push_message(msg);
645 assert_eq!(
646 agent.runtime.providers.cached_prompt_tokens,
647 before + expected_delta
648 );
649 }
650
651 #[test]
657 fn insert_message_splices_at_index_and_tracks_tokens() {
658 let provider = mock_provider(vec![]);
659 let channel = MockChannel::new(vec![]);
660 let registry = create_test_registry();
661 let executor = MockToolExecutor::no_tools();
662 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
663
664 agent.msg.messages.push(Message {
665 role: Role::User,
666 content: "first".to_string(),
667 parts: vec![],
668 metadata: MessageMetadata::default(),
669 });
670 agent.msg.messages.push(Message {
671 role: Role::User,
672 content: "third".to_string(),
673 parts: vec![],
674 metadata: MessageMetadata::default(),
675 });
676 let insert_idx = agent.msg.messages.len() - 1;
677 let before_tokens = agent.runtime.providers.cached_prompt_tokens;
678
679 let msg = Message {
680 role: Role::User,
681 content: "second".to_string(),
682 parts: vec![],
683 metadata: MessageMetadata::default(),
684 };
685 let expected_delta = agent
686 .runtime
687 .metrics
688 .token_counter
689 .count_message_tokens(&msg) as u64;
690 agent.insert_message(insert_idx, msg);
691
692 assert_eq!(
693 agent.msg.messages[insert_idx].content, "second",
694 "message must be spliced at the given index"
695 );
696 assert_eq!(
697 agent.msg.messages[insert_idx + 1].content,
698 "third",
699 "the message previously at insert_idx must be pushed one slot forward"
700 );
701 assert_eq!(
702 agent.runtime.providers.cached_prompt_tokens,
703 before_tokens + expected_delta,
704 "insert_message must track token accounting identically to push_message"
705 );
706 }
707
708 #[test]
709 fn recompute_prompt_tokens_matches_sum() {
710 let provider = mock_provider(vec![]);
711 let channel = MockChannel::new(vec![]);
712 let registry = create_test_registry();
713 let executor = MockToolExecutor::no_tools();
714 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
715
716 agent.msg.messages.push(Message {
717 role: Role::User,
718 content: "1234".to_string(),
719 parts: vec![],
720 metadata: MessageMetadata::default(),
721 });
722 agent.msg.messages.push(Message {
723 role: Role::Assistant,
724 content: "5678".to_string(),
725 parts: vec![],
726 metadata: MessageMetadata::default(),
727 });
728
729 agent.recompute_prompt_tokens();
730
731 let expected: u64 = agent
732 .msg
733 .messages
734 .iter()
735 .map(|m| agent.runtime.metrics.token_counter.count_message_tokens(m) as u64)
736 .sum();
737 assert_eq!(agent.runtime.providers.cached_prompt_tokens, expected);
738 }
739
740 #[test]
741 fn inject_code_context_into_messages_with_existing_content() {
742 let provider = mock_provider(vec![]);
743 let channel = MockChannel::new(vec![]);
744 let registry = create_test_registry();
745 let executor = MockToolExecutor::no_tools();
746 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
747
748 agent.push_message(Message {
750 role: Role::User,
751 content: "question".to_string(),
752 parts: vec![],
753 metadata: MessageMetadata::default(),
754 });
755
756 agent.inject_code_context("some code here");
757
758 let found = agent.msg.messages.iter().any(|m| {
759 m.parts.iter().any(|p| {
760 matches!(p, MessagePart::CodeContext { text } if text.contains("some code here"))
761 })
762 });
763 assert!(found, "code context should be injected into messages");
764 }
765
766 #[test]
767 fn inject_code_context_empty_text_is_noop() {
768 let provider = mock_provider(vec![]);
769 let channel = MockChannel::new(vec![]);
770 let registry = create_test_registry();
771 let executor = MockToolExecutor::no_tools();
772 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
773
774 agent.push_message(Message {
775 role: Role::User,
776 content: "question".to_string(),
777 parts: vec![],
778 metadata: MessageMetadata::default(),
779 });
780 let count_before = agent.msg.messages.len();
781
782 agent.inject_code_context("");
783
784 assert_eq!(agent.msg.messages.len(), count_before);
786 }
787
788 #[test]
789 fn inject_code_context_with_single_message_is_noop() {
790 let provider = mock_provider(vec![]);
791 let channel = MockChannel::new(vec![]);
792 let registry = create_test_registry();
793 let executor = MockToolExecutor::no_tools();
794 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
795 let count_before = agent.msg.messages.len();
797
798 agent.inject_code_context("some code");
799
800 assert_eq!(agent.msg.messages.len(), count_before);
801 }
802
803 #[test]
804 fn context_messages_returns_all_messages() {
805 let provider = mock_provider(vec![]);
806 let channel = MockChannel::new(vec![]);
807 let registry = create_test_registry();
808 let executor = MockToolExecutor::no_tools();
809 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
810
811 agent.push_message(Message {
812 role: Role::User,
813 content: "test".to_string(),
814 parts: vec![],
815 metadata: MessageMetadata::default(),
816 });
817
818 assert_eq!(agent.context_messages().len(), agent.msg.messages.len());
819 }
820
821 #[test]
822 fn truncate_old_tool_results_truncates_stale_content() {
823 let provider = mock_provider(vec![]);
824 let channel = MockChannel::new(vec![]);
825 let registry = create_test_registry();
826 let executor = MockToolExecutor::no_tools();
827 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
828
829 let big_content = "x".repeat(4096);
830
831 agent.msg.messages.push(Message {
833 role: Role::User,
834 content: String::new(),
835 parts: vec![MessagePart::ToolResult {
836 tool_use_id: "id1".to_string(),
837 content: big_content.clone(),
838 is_error: false,
839 }],
840 metadata: MessageMetadata::default(),
841 });
842 agent.msg.messages.push(Message {
844 role: Role::User,
845 content: String::new(),
846 parts: vec![MessagePart::ToolOutput {
847 tool_name: "shell".into(),
848 body: big_content.clone(),
849 compacted_at: None,
850 }],
851 metadata: MessageMetadata::default(),
852 });
853 agent.msg.messages.push(Message {
855 role: Role::Assistant,
856 content: "reply".to_string(),
857 parts: vec![MessagePart::ToolResult {
858 tool_use_id: "id3".to_string(),
859 content: big_content.clone(),
860 is_error: false,
861 }],
862 metadata: MessageMetadata::default(),
863 });
864 agent.msg.messages.push(Message {
866 role: Role::User,
867 content: "last".to_string(),
868 parts: vec![MessagePart::ToolResult {
869 tool_use_id: "id4".to_string(),
870 content: big_content.clone(),
871 is_error: false,
872 }],
873 metadata: MessageMetadata::default(),
874 });
875
876 let base = agent.msg.messages.len() - 4;
878
879 agent.truncate_old_tool_results();
880
881 if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base].parts[0] {
883 assert!(
884 content.ends_with("…[truncated]"),
885 "msg[base] should be truncated"
886 );
887 assert!(content.len() <= 2048 + 16);
888 } else {
889 panic!("expected ToolResult at msg[base]");
890 }
891
892 if let MessagePart::ToolOutput { body, .. } = &agent.msg.messages[base + 1].parts[0] {
894 assert!(
895 body.ends_with("…[truncated]"),
896 "msg[base+1] should be truncated"
897 );
898 } else {
899 panic!("expected ToolOutput at msg[base+1]");
900 }
901
902 if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base + 2].parts[0] {
904 assert_eq!(content.len(), 4096, "msg[base+2] should NOT be truncated");
905 } else {
906 panic!("expected ToolResult at msg[base+2]");
907 }
908 if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base + 3].parts[0] {
909 assert_eq!(content.len(), 4096, "msg[base+3] should NOT be truncated");
910 } else {
911 panic!("expected ToolResult at msg[base+3]");
912 }
913 }
914
915 #[test]
916 fn truncate_old_tool_results_noop_when_few_messages() {
917 let provider = mock_provider(vec![]);
918 let channel = MockChannel::new(vec![]);
919 let registry = create_test_registry();
920 let executor = MockToolExecutor::no_tools();
921 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
922
923 let big = "y".repeat(4096);
924 agent.msg.messages.push(Message {
925 role: Role::User,
926 content: String::new(),
927 parts: vec![MessagePart::ToolResult {
928 tool_use_id: "id".to_string(),
929 content: big.clone(),
930 is_error: false,
931 }],
932 metadata: MessageMetadata::default(),
933 });
934 agent.msg.messages.push(Message {
935 role: Role::Assistant,
936 content: "ok".to_string(),
937 parts: vec![MessagePart::ToolResult {
938 tool_use_id: "id2".to_string(),
939 content: big.clone(),
940 is_error: false,
941 }],
942 metadata: MessageMetadata::default(),
943 });
944
945 let len_before = agent.msg.messages.len();
947 agent.truncate_old_tool_results();
948
949 assert_eq!(agent.msg.messages.len(), len_before);
951 if let MessagePart::ToolResult { content, .. } =
952 &agent.msg.messages[len_before - 2].parts[0]
953 {
954 assert_eq!(
955 content.len(),
956 4096,
957 "second-to-last should not be truncated"
958 );
959 } else {
960 panic!("expected ToolResult");
961 }
962 if let MessagePart::ToolResult { content, .. } =
963 &agent.msg.messages[len_before - 1].parts[0]
964 {
965 assert_eq!(content.len(), 4096, "last should not be truncated");
966 } else {
967 panic!("expected ToolResult");
968 }
969 }
970
971 fn make_timings(ctx: u64, llm: u64, tool: u64, persist: u64) -> crate::metrics::TurnTimings {
972 crate::metrics::TurnTimings {
973 prepare_context_ms: ctx,
974 llm_chat_ms: llm,
975 tool_exec_ms: tool,
976 persist_message_ms: persist,
977 }
978 }
979
980 fn agent_with_metrics_watch() -> (
981 Agent<MockChannel>,
982 tokio::sync::watch::Receiver<crate::metrics::MetricsSnapshot>,
983 ) {
984 let provider = mock_provider(vec![]);
985 let channel = MockChannel::new(vec![]);
986 let registry = create_test_registry();
987 let executor = MockToolExecutor::no_tools();
988 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
989
990 let (tx, rx) = tokio::sync::watch::channel(crate::metrics::MetricsSnapshot::default());
991 agent.runtime.metrics.metrics_tx = Some(tx);
992 (agent, rx)
993 }
994
995 #[test]
997 fn flush_turn_timings_single_flush() {
998 let (mut agent, rx) = agent_with_metrics_watch();
999
1000 agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
1001 agent.flush_turn_timings();
1002
1003 let snap = rx.borrow();
1004 assert_eq!(snap.last_turn_timings.prepare_context_ms, 10);
1005 assert_eq!(snap.last_turn_timings.llm_chat_ms, 200);
1006 assert_eq!(snap.last_turn_timings.tool_exec_ms, 50);
1007 assert_eq!(snap.last_turn_timings.persist_message_ms, 5);
1008 assert_eq!(snap.timing_sample_count, 1);
1009 assert_eq!(snap.avg_turn_timings.llm_chat_ms, 200);
1011 }
1012
1013 #[test]
1015 fn flush_turn_timings_resets_pending() {
1016 let provider = mock_provider(vec![]);
1017 let channel = MockChannel::new(vec![]);
1018 let registry = create_test_registry();
1019 let executor = MockToolExecutor::no_tools();
1020 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
1021
1022 agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
1023 agent.flush_turn_timings();
1024
1025 let p = &agent.runtime.metrics.pending_timings;
1026 assert_eq!(p.prepare_context_ms, 0);
1027 assert_eq!(p.llm_chat_ms, 0);
1028 assert_eq!(p.tool_exec_ms, 0);
1029 assert_eq!(p.persist_message_ms, 0);
1030 }
1031
1032 #[test]
1034 fn flush_turn_timings_window_capped_at_10() {
1035 let (mut agent, rx) = agent_with_metrics_watch();
1036
1037 for i in 1_u64..=12 {
1039 agent.runtime.metrics.pending_timings = make_timings(0, i * 10, 0, 0);
1040 agent.flush_turn_timings();
1041 }
1042
1043 let snap = rx.borrow();
1044 assert_eq!(snap.timing_sample_count, 10);
1046 assert_eq!(snap.max_turn_timings.llm_chat_ms, 120);
1048 assert_eq!(snap.avg_turn_timings.llm_chat_ms, 75);
1050 }
1051
1052 #[cfg(feature = "profiling")]
1056 #[test]
1057 fn flush_turn_timings_prefers_bridge_value_for_marked_fields() {
1058 let (mut agent, rx) = agent_with_metrics_watch();
1059
1060 if let Some(tx) = agent.runtime.metrics.metrics_tx.as_ref() {
1061 tx.send_modify(|m| {
1062 m.last_turn_timings.llm_chat_ms = 999;
1063 m.bridge_timings_written = crate::metrics_bridge::TimingField::LlmChat.bridge_bit();
1064 });
1065 }
1066
1067 agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
1068 agent.flush_turn_timings();
1069
1070 let snap = rx.borrow();
1071 assert_eq!(
1072 snap.last_turn_timings.llm_chat_ms, 999,
1073 "bridge-marked field must keep the bridge value, not the manual one"
1074 );
1075 assert_eq!(snap.last_turn_timings.prepare_context_ms, 10);
1076 assert_eq!(snap.last_turn_timings.tool_exec_ms, 50);
1077 assert_eq!(snap.last_turn_timings.persist_message_ms, 5);
1078 assert_eq!(
1079 snap.bridge_timings_written, 0,
1080 "bitmask must be cleared after flush"
1081 );
1082 }
1083
1084 #[cfg(feature = "profiling")]
1090 #[test]
1091 fn flush_turn_timings_resets_bridge_llm_chat_ms_across_turns() {
1092 let (mut agent, rx) = agent_with_metrics_watch();
1093
1094 if let Some(tx) = agent.runtime.metrics.metrics_tx.as_ref() {
1096 tx.send_modify(|m| {
1097 m.last_turn_timings.llm_chat_ms = 500;
1098 m.bridge_timings_written = crate::metrics_bridge::TimingField::LlmChat.bridge_bit();
1099 });
1100 }
1101 agent.runtime.metrics.pending_timings = make_timings(0, 0, 0, 0);
1102 agent.flush_turn_timings();
1103 assert_eq!(rx.borrow().last_turn_timings.llm_chat_ms, 500);
1104
1105 agent.runtime.metrics.pending_timings = make_timings(0, 0, 0, 0);
1110 agent.flush_turn_timings();
1111
1112 assert_eq!(
1113 rx.borrow().last_turn_timings.llm_chat_ms,
1114 0,
1115 "llm_chat_ms must not leak turn 1's bridge value into turn 2 (#6275)"
1116 );
1117 }
1118}