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