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 pub(crate) fn record_successful_task(&self) {
365 if let Some(ref tracker) = self.runtime.metrics.cost_tracker {
366 tracker.record_successful_task();
367 self.update_metrics(|m| {
368 m.cost_cps_cents = tracker.cps();
369 m.cost_successful_tasks = tracker.successful_tasks();
370 });
371 }
372 }
373
374 pub(super) fn last_assistant_preview(&self, max_chars: usize) -> String {
382 let raw = self
383 .msg
384 .messages
385 .iter()
386 .rev()
387 .find(|m| m.role == Role::Assistant)
388 .map_or("", |m| m.content.as_str());
389
390 if raw.is_empty() {
391 return String::new();
392 }
393
394 let truncated: &str = if raw.chars().count() > max_chars {
396 let end = raw
397 .char_indices()
398 .nth(max_chars)
399 .map_or(raw.len(), |(i, _)| i);
400 &raw[..end]
401 } else {
402 raw
403 };
404
405 crate::redact::scrub_content(truncated).into_owned()
406 }
407
408 pub fn inject_code_context(&mut self, text: &str) {
411 self.remove_code_context_messages();
412 if text.is_empty() || self.msg.messages.len() <= 1 {
413 return;
414 }
415 let content = format!("{CODE_CONTEXT_PREFIX}{text}");
416 self.msg.track_single_message(Role::System, true);
417 self.msg.messages.insert(
418 1,
419 Message::from_parts(
420 Role::System,
421 vec![MessagePart::CodeContext { text: content }],
422 ),
423 );
424 }
425
426 #[must_use]
427 pub fn context_messages(&self) -> &[Message] {
428 &self.msg.messages
429 }
430
431 pub(super) fn truncate_old_tool_results(&mut self) {
441 const LIMIT: usize = 2048;
442 const SUFFIX: &str = "…[truncated]";
443
444 let len = self.msg.messages.len();
445 if len <= 2 {
446 return;
447 }
448 for msg in &mut self.msg.messages[..len - 2] {
449 for part in &mut msg.parts {
450 match part {
451 MessagePart::ToolResult { content, .. } if content.len() > LIMIT => {
452 content.truncate(content.floor_char_boundary(LIMIT));
453 content.push_str(SUFFIX);
454 }
455 MessagePart::ToolOutput { body, .. } if body.len() > LIMIT => {
456 body.truncate(body.floor_char_boundary(LIMIT));
457 body.push_str(SUFFIX);
458 }
459 _ => {}
460 }
461 }
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::super::agent_tests::{
469 MockChannel, MockToolExecutor, create_test_registry, mock_provider,
470 };
471 use super::*;
472 use zeph_llm::provider::{MessageMetadata, MessagePart};
473 use zeph_memory::graph::GraphStore;
474 use zeph_memory::graph::types::EntityType;
475 use zeph_memory::store::SqliteStore;
476
477 async fn setup_graph_store() -> GraphStore {
478 let sqlite = SqliteStore::new(":memory:").await.unwrap();
479 GraphStore::new(sqlite.pool().clone())
480 }
481
482 #[tokio::test]
483 async fn fetch_graph_counts_empty_store_returns_zeros() {
484 let store = setup_graph_store().await;
485 assert_eq!(fetch_graph_counts(&store).await, (0, 0, 0));
486 }
487
488 #[tokio::test]
489 async fn fetch_graph_counts_reflects_actual_counts() {
490 let store = setup_graph_store().await;
491 let a = store
492 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
493 .await
494 .unwrap()
495 .0;
496 let b = store
497 .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
498 .await
499 .unwrap()
500 .0;
501 store
502 .insert_edge(a, b, "knows", "Alice knows Bob", 1.0, None, None)
503 .await
504 .unwrap();
505 store
506 .upsert_community("cluster", "summary", &[a, b], None)
507 .await
508 .unwrap();
509
510 assert_eq!(fetch_graph_counts(&store).await, (2, 1, 1));
511 }
512
513 #[tokio::test]
517 async fn fetch_graph_counts_falls_back_to_zero_per_field_on_error() {
518 let sqlite = SqliteStore::new(":memory:").await.unwrap();
519 let pool = sqlite.pool().clone();
520 let store = GraphStore::new(pool.clone());
521 let a = store
522 .upsert_entity("Alice", "Alice", EntityType::Person, None, None)
523 .await
524 .unwrap()
525 .0;
526 let b = store
527 .upsert_entity("Bob", "Bob", EntityType::Person, None, None)
528 .await
529 .unwrap()
530 .0;
531 store
532 .insert_edge(a, b, "knows", "Alice knows Bob", 1.0, None, None)
533 .await
534 .unwrap();
535
536 sqlx::query("DROP TABLE graph_communities")
537 .execute(&pool)
538 .await
539 .unwrap();
540
541 assert_eq!(fetch_graph_counts(&store).await, (2, 1, 0));
542 }
543
544 #[test]
545 fn push_message_increments_cached_tokens() {
546 let provider = mock_provider(vec![]);
547 let channel = MockChannel::new(vec![]);
548 let registry = create_test_registry();
549 let executor = MockToolExecutor::no_tools();
550 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
551
552 let before = agent.runtime.providers.cached_prompt_tokens;
553 let msg = Message {
554 role: Role::User,
555 content: "hello world!!".to_string(),
556 parts: vec![],
557 metadata: MessageMetadata::default(),
558 };
559 let expected_delta = agent
560 .runtime
561 .metrics
562 .token_counter
563 .count_message_tokens(&msg) as u64;
564 agent.push_message(msg);
565 assert_eq!(
566 agent.runtime.providers.cached_prompt_tokens,
567 before + expected_delta
568 );
569 }
570
571 #[test]
577 fn insert_message_splices_at_index_and_tracks_tokens() {
578 let provider = mock_provider(vec![]);
579 let channel = MockChannel::new(vec![]);
580 let registry = create_test_registry();
581 let executor = MockToolExecutor::no_tools();
582 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
583
584 agent.msg.messages.push(Message {
585 role: Role::User,
586 content: "first".to_string(),
587 parts: vec![],
588 metadata: MessageMetadata::default(),
589 });
590 agent.msg.messages.push(Message {
591 role: Role::User,
592 content: "third".to_string(),
593 parts: vec![],
594 metadata: MessageMetadata::default(),
595 });
596 let insert_idx = agent.msg.messages.len() - 1;
597 let before_tokens = agent.runtime.providers.cached_prompt_tokens;
598
599 let msg = Message {
600 role: Role::User,
601 content: "second".to_string(),
602 parts: vec![],
603 metadata: MessageMetadata::default(),
604 };
605 let expected_delta = agent
606 .runtime
607 .metrics
608 .token_counter
609 .count_message_tokens(&msg) as u64;
610 agent.insert_message(insert_idx, msg);
611
612 assert_eq!(
613 agent.msg.messages[insert_idx].content, "second",
614 "message must be spliced at the given index"
615 );
616 assert_eq!(
617 agent.msg.messages[insert_idx + 1].content,
618 "third",
619 "the message previously at insert_idx must be pushed one slot forward"
620 );
621 assert_eq!(
622 agent.runtime.providers.cached_prompt_tokens,
623 before_tokens + expected_delta,
624 "insert_message must track token accounting identically to push_message"
625 );
626 }
627
628 #[test]
629 fn recompute_prompt_tokens_matches_sum() {
630 let provider = mock_provider(vec![]);
631 let channel = MockChannel::new(vec![]);
632 let registry = create_test_registry();
633 let executor = MockToolExecutor::no_tools();
634 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
635
636 agent.msg.messages.push(Message {
637 role: Role::User,
638 content: "1234".to_string(),
639 parts: vec![],
640 metadata: MessageMetadata::default(),
641 });
642 agent.msg.messages.push(Message {
643 role: Role::Assistant,
644 content: "5678".to_string(),
645 parts: vec![],
646 metadata: MessageMetadata::default(),
647 });
648
649 agent.recompute_prompt_tokens();
650
651 let expected: u64 = agent
652 .msg
653 .messages
654 .iter()
655 .map(|m| agent.runtime.metrics.token_counter.count_message_tokens(m) as u64)
656 .sum();
657 assert_eq!(agent.runtime.providers.cached_prompt_tokens, expected);
658 }
659
660 #[test]
661 fn inject_code_context_into_messages_with_existing_content() {
662 let provider = mock_provider(vec![]);
663 let channel = MockChannel::new(vec![]);
664 let registry = create_test_registry();
665 let executor = MockToolExecutor::no_tools();
666 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
667
668 agent.push_message(Message {
670 role: Role::User,
671 content: "question".to_string(),
672 parts: vec![],
673 metadata: MessageMetadata::default(),
674 });
675
676 agent.inject_code_context("some code here");
677
678 let found = agent.msg.messages.iter().any(|m| {
679 m.parts.iter().any(|p| {
680 matches!(p, MessagePart::CodeContext { text } if text.contains("some code here"))
681 })
682 });
683 assert!(found, "code context should be injected into messages");
684 }
685
686 #[test]
687 fn inject_code_context_empty_text_is_noop() {
688 let provider = mock_provider(vec![]);
689 let channel = MockChannel::new(vec![]);
690 let registry = create_test_registry();
691 let executor = MockToolExecutor::no_tools();
692 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
693
694 agent.push_message(Message {
695 role: Role::User,
696 content: "question".to_string(),
697 parts: vec![],
698 metadata: MessageMetadata::default(),
699 });
700 let count_before = agent.msg.messages.len();
701
702 agent.inject_code_context("");
703
704 assert_eq!(agent.msg.messages.len(), count_before);
706 }
707
708 #[test]
709 fn inject_code_context_with_single_message_is_noop() {
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 let count_before = agent.msg.messages.len();
717
718 agent.inject_code_context("some code");
719
720 assert_eq!(agent.msg.messages.len(), count_before);
721 }
722
723 #[test]
724 fn context_messages_returns_all_messages() {
725 let provider = mock_provider(vec![]);
726 let channel = MockChannel::new(vec![]);
727 let registry = create_test_registry();
728 let executor = MockToolExecutor::no_tools();
729 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
730
731 agent.push_message(Message {
732 role: Role::User,
733 content: "test".to_string(),
734 parts: vec![],
735 metadata: MessageMetadata::default(),
736 });
737
738 assert_eq!(agent.context_messages().len(), agent.msg.messages.len());
739 }
740
741 #[test]
742 fn truncate_old_tool_results_truncates_stale_content() {
743 let provider = mock_provider(vec![]);
744 let channel = MockChannel::new(vec![]);
745 let registry = create_test_registry();
746 let executor = MockToolExecutor::no_tools();
747 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
748
749 let big_content = "x".repeat(4096);
750
751 agent.msg.messages.push(Message {
753 role: Role::User,
754 content: String::new(),
755 parts: vec![MessagePart::ToolResult {
756 tool_use_id: "id1".to_string(),
757 content: big_content.clone(),
758 is_error: false,
759 }],
760 metadata: MessageMetadata::default(),
761 });
762 agent.msg.messages.push(Message {
764 role: Role::User,
765 content: String::new(),
766 parts: vec![MessagePart::ToolOutput {
767 tool_name: "shell".into(),
768 body: big_content.clone(),
769 compacted_at: None,
770 }],
771 metadata: MessageMetadata::default(),
772 });
773 agent.msg.messages.push(Message {
775 role: Role::Assistant,
776 content: "reply".to_string(),
777 parts: vec![MessagePart::ToolResult {
778 tool_use_id: "id3".to_string(),
779 content: big_content.clone(),
780 is_error: false,
781 }],
782 metadata: MessageMetadata::default(),
783 });
784 agent.msg.messages.push(Message {
786 role: Role::User,
787 content: "last".to_string(),
788 parts: vec![MessagePart::ToolResult {
789 tool_use_id: "id4".to_string(),
790 content: big_content.clone(),
791 is_error: false,
792 }],
793 metadata: MessageMetadata::default(),
794 });
795
796 let base = agent.msg.messages.len() - 4;
798
799 agent.truncate_old_tool_results();
800
801 if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base].parts[0] {
803 assert!(
804 content.ends_with("…[truncated]"),
805 "msg[base] should be truncated"
806 );
807 assert!(content.len() <= 2048 + 16);
808 } else {
809 panic!("expected ToolResult at msg[base]");
810 }
811
812 if let MessagePart::ToolOutput { body, .. } = &agent.msg.messages[base + 1].parts[0] {
814 assert!(
815 body.ends_with("…[truncated]"),
816 "msg[base+1] should be truncated"
817 );
818 } else {
819 panic!("expected ToolOutput at msg[base+1]");
820 }
821
822 if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base + 2].parts[0] {
824 assert_eq!(content.len(), 4096, "msg[base+2] should NOT be truncated");
825 } else {
826 panic!("expected ToolResult at msg[base+2]");
827 }
828 if let MessagePart::ToolResult { content, .. } = &agent.msg.messages[base + 3].parts[0] {
829 assert_eq!(content.len(), 4096, "msg[base+3] should NOT be truncated");
830 } else {
831 panic!("expected ToolResult at msg[base+3]");
832 }
833 }
834
835 #[test]
836 fn truncate_old_tool_results_noop_when_few_messages() {
837 let provider = mock_provider(vec![]);
838 let channel = MockChannel::new(vec![]);
839 let registry = create_test_registry();
840 let executor = MockToolExecutor::no_tools();
841 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
842
843 let big = "y".repeat(4096);
844 agent.msg.messages.push(Message {
845 role: Role::User,
846 content: String::new(),
847 parts: vec![MessagePart::ToolResult {
848 tool_use_id: "id".to_string(),
849 content: big.clone(),
850 is_error: false,
851 }],
852 metadata: MessageMetadata::default(),
853 });
854 agent.msg.messages.push(Message {
855 role: Role::Assistant,
856 content: "ok".to_string(),
857 parts: vec![MessagePart::ToolResult {
858 tool_use_id: "id2".to_string(),
859 content: big.clone(),
860 is_error: false,
861 }],
862 metadata: MessageMetadata::default(),
863 });
864
865 let len_before = agent.msg.messages.len();
867 agent.truncate_old_tool_results();
868
869 assert_eq!(agent.msg.messages.len(), len_before);
871 if let MessagePart::ToolResult { content, .. } =
872 &agent.msg.messages[len_before - 2].parts[0]
873 {
874 assert_eq!(
875 content.len(),
876 4096,
877 "second-to-last should not be truncated"
878 );
879 } else {
880 panic!("expected ToolResult");
881 }
882 if let MessagePart::ToolResult { content, .. } =
883 &agent.msg.messages[len_before - 1].parts[0]
884 {
885 assert_eq!(content.len(), 4096, "last should not be truncated");
886 } else {
887 panic!("expected ToolResult");
888 }
889 }
890
891 fn make_timings(ctx: u64, llm: u64, tool: u64, persist: u64) -> crate::metrics::TurnTimings {
892 crate::metrics::TurnTimings {
893 prepare_context_ms: ctx,
894 llm_chat_ms: llm,
895 tool_exec_ms: tool,
896 persist_message_ms: persist,
897 }
898 }
899
900 fn agent_with_metrics_watch() -> (
901 Agent<MockChannel>,
902 tokio::sync::watch::Receiver<crate::metrics::MetricsSnapshot>,
903 ) {
904 let provider = mock_provider(vec![]);
905 let channel = MockChannel::new(vec![]);
906 let registry = create_test_registry();
907 let executor = MockToolExecutor::no_tools();
908 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
909
910 let (tx, rx) = tokio::sync::watch::channel(crate::metrics::MetricsSnapshot::default());
911 agent.runtime.metrics.metrics_tx = Some(tx);
912 (agent, rx)
913 }
914
915 #[test]
917 fn flush_turn_timings_single_flush() {
918 let (mut agent, rx) = agent_with_metrics_watch();
919
920 agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
921 agent.flush_turn_timings();
922
923 let snap = rx.borrow();
924 assert_eq!(snap.last_turn_timings.prepare_context_ms, 10);
925 assert_eq!(snap.last_turn_timings.llm_chat_ms, 200);
926 assert_eq!(snap.last_turn_timings.tool_exec_ms, 50);
927 assert_eq!(snap.last_turn_timings.persist_message_ms, 5);
928 assert_eq!(snap.timing_sample_count, 1);
929 assert_eq!(snap.avg_turn_timings.llm_chat_ms, 200);
931 }
932
933 #[test]
935 fn flush_turn_timings_resets_pending() {
936 let provider = mock_provider(vec![]);
937 let channel = MockChannel::new(vec![]);
938 let registry = create_test_registry();
939 let executor = MockToolExecutor::no_tools();
940 let mut agent = Agent::new(provider, channel, registry, None, 5, executor);
941
942 agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
943 agent.flush_turn_timings();
944
945 let p = &agent.runtime.metrics.pending_timings;
946 assert_eq!(p.prepare_context_ms, 0);
947 assert_eq!(p.llm_chat_ms, 0);
948 assert_eq!(p.tool_exec_ms, 0);
949 assert_eq!(p.persist_message_ms, 0);
950 }
951
952 #[test]
954 fn flush_turn_timings_window_capped_at_10() {
955 let (mut agent, rx) = agent_with_metrics_watch();
956
957 for i in 1_u64..=12 {
959 agent.runtime.metrics.pending_timings = make_timings(0, i * 10, 0, 0);
960 agent.flush_turn_timings();
961 }
962
963 let snap = rx.borrow();
964 assert_eq!(snap.timing_sample_count, 10);
966 assert_eq!(snap.max_turn_timings.llm_chat_ms, 120);
968 assert_eq!(snap.avg_turn_timings.llm_chat_ms, 75);
970 }
971
972 #[cfg(feature = "profiling")]
976 #[test]
977 fn flush_turn_timings_prefers_bridge_value_for_marked_fields() {
978 let (mut agent, rx) = agent_with_metrics_watch();
979
980 if let Some(tx) = agent.runtime.metrics.metrics_tx.as_ref() {
981 tx.send_modify(|m| {
982 m.last_turn_timings.llm_chat_ms = 999;
983 m.bridge_timings_written = crate::metrics_bridge::TimingField::LlmChat.bridge_bit();
984 });
985 }
986
987 agent.runtime.metrics.pending_timings = make_timings(10, 200, 50, 5);
988 agent.flush_turn_timings();
989
990 let snap = rx.borrow();
991 assert_eq!(
992 snap.last_turn_timings.llm_chat_ms, 999,
993 "bridge-marked field must keep the bridge value, not the manual one"
994 );
995 assert_eq!(snap.last_turn_timings.prepare_context_ms, 10);
996 assert_eq!(snap.last_turn_timings.tool_exec_ms, 50);
997 assert_eq!(snap.last_turn_timings.persist_message_ms, 5);
998 assert_eq!(
999 snap.bridge_timings_written, 0,
1000 "bitmask must be cleared after flush"
1001 );
1002 }
1003
1004 #[cfg(feature = "profiling")]
1010 #[test]
1011 fn flush_turn_timings_resets_bridge_llm_chat_ms_across_turns() {
1012 let (mut agent, rx) = agent_with_metrics_watch();
1013
1014 if let Some(tx) = agent.runtime.metrics.metrics_tx.as_ref() {
1016 tx.send_modify(|m| {
1017 m.last_turn_timings.llm_chat_ms = 500;
1018 m.bridge_timings_written = crate::metrics_bridge::TimingField::LlmChat.bridge_bit();
1019 });
1020 }
1021 agent.runtime.metrics.pending_timings = make_timings(0, 0, 0, 0);
1022 agent.flush_turn_timings();
1023 assert_eq!(rx.borrow().last_turn_timings.llm_chat_ms, 500);
1024
1025 agent.runtime.metrics.pending_timings = make_timings(0, 0, 0, 0);
1030 agent.flush_turn_timings();
1031
1032 assert_eq!(
1033 rx.borrow().last_turn_timings.llm_chat_ms,
1034 0,
1035 "llm_chat_ms must not leak turn 1's bridge value into turn 2 (#6275)"
1036 );
1037 }
1038}