Skip to main content

zeph_core/
metrics.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::collections::VecDeque;
5
6use tokio::sync::watch;
7use zeph_common::SecurityEventCategory;
8
9pub use zeph_llm::{ClassifierMetricsSnapshot, TaskMetricsSnapshot};
10pub use zeph_memory::{CategoryScore, ProbeCategory, ProbeVerdict};
11
12/// A single security event record for TUI display.
13#[derive(Debug, Clone)]
14pub struct SecurityEvent {
15    /// Unix timestamp (seconds since epoch).
16    pub timestamp: u64,
17    pub category: SecurityEventCategory,
18    /// Source that triggered the event (e.g., `web_scrape`, `mcp_response`).
19    pub source: String,
20    /// Short description, capped at 128 chars.
21    pub detail: String,
22}
23
24impl SecurityEvent {
25    #[must_use]
26    pub fn new(
27        category: SecurityEventCategory,
28        source: impl Into<String>,
29        detail: impl Into<String>,
30    ) -> Self {
31        // IMP-1: cap source at 64 chars and strip ASCII control chars.
32        let source: String = source
33            .into()
34            .chars()
35            .filter(|c| !c.is_ascii_control())
36            .take(64)
37            .collect();
38        // CR-1: UTF-8 safe truncation using floor_char_boundary (stable since Rust 1.82).
39        let detail = detail.into();
40        let detail = if detail.len() > 128 {
41            let end = detail.floor_char_boundary(127);
42            format!("{}…", &detail[..end])
43        } else {
44            detail
45        };
46        Self {
47            timestamp: std::time::SystemTime::now()
48                .duration_since(std::time::UNIX_EPOCH)
49                .unwrap_or_default()
50                .as_secs(),
51            category,
52            source,
53            detail,
54        }
55    }
56}
57
58/// Ring buffer capacity for security events.
59pub const SECURITY_EVENT_CAP: usize = 100;
60
61/// Lightweight snapshot of a single task row for TUI display.
62///
63/// Captured from the task graph on each metrics tick; kept minimal on purpose.
64#[derive(Debug, Clone)]
65pub struct TaskSnapshotRow {
66    pub id: u32,
67    pub title: String,
68    /// Stringified `TaskStatus` (e.g. `"pending"`, `"running"`, `"completed"`).
69    pub status: String,
70    pub agent: Option<String>,
71    pub duration_ms: u64,
72    /// Truncated error message (first 80 chars) when the task failed.
73    pub error: Option<String>,
74}
75
76/// Lightweight snapshot of a `TaskGraph` for TUI display.
77#[derive(Debug, Clone, Default)]
78pub struct TaskGraphSnapshot {
79    pub graph_id: String,
80    pub goal: String,
81    /// Stringified `GraphStatus` (e.g. `"created"`, `"running"`, `"completed"`).
82    pub status: String,
83    pub tasks: Vec<TaskSnapshotRow>,
84    pub completed_at: Option<std::time::Instant>,
85}
86
87impl TaskGraphSnapshot {
88    /// Returns `true` if this snapshot represents a terminal plan that finished
89    /// more than 30 seconds ago and should no longer be shown in the TUI.
90    #[must_use]
91    pub fn is_stale(&self) -> bool {
92        self.completed_at
93            .is_some_and(|t| t.elapsed().as_secs() > 30)
94    }
95}
96
97/// Counters for the task orchestration subsystem.
98///
99/// Always present in [`MetricsSnapshot`]; zero-valued when orchestration is inactive.
100#[derive(Debug, Clone, Default)]
101pub struct OrchestrationMetrics {
102    pub plans_total: u64,
103    pub tasks_total: u64,
104    pub tasks_completed: u64,
105    pub tasks_failed: u64,
106    pub tasks_skipped: u64,
107}
108
109#[non_exhaustive]
110/// Connection status of a single MCP server for TUI display.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub enum McpServerConnectionStatus {
113    Connected,
114    Failed,
115}
116
117/// Per-server MCP status snapshot for TUI display.
118#[derive(Debug, Clone)]
119pub struct McpServerStatus {
120    pub id: String,
121    pub status: McpServerConnectionStatus,
122    /// Number of tools provided by this server (0 when failed).
123    pub tool_count: usize,
124    /// Human-readable failure reason. Empty when connected.
125    pub error: String,
126}
127
128/// Bayesian confidence data for a single skill, used by TUI confidence bar.
129#[derive(Debug, Clone, Default)]
130pub struct SkillConfidence {
131    pub name: String,
132    pub posterior: f64,
133    pub total_uses: u32,
134}
135
136/// Snapshot of a single sub-agent's runtime status.
137#[derive(Debug, Clone, Default)]
138pub struct SubAgentMetrics {
139    pub id: String,
140    pub name: String,
141    /// Stringified `TaskState`: "working", "completed", "failed", "canceled", etc.
142    pub state: String,
143    pub turns_used: u32,
144    pub max_turns: u32,
145    pub background: bool,
146    pub elapsed_secs: u64,
147    /// Stringified `PermissionMode`: `"default"`, `"accept_edits"`, `"dont_ask"`,
148    /// `"bypass_permissions"`, `"plan"`. Empty string when mode is `Default`.
149    pub permission_mode: String,
150    /// Path to the directory containing this agent's JSONL transcript file.
151    /// `None` when transcript writing is disabled for this agent.
152    pub transcript_dir: Option<String>,
153}
154
155/// Per-turn latency breakdown for the four agent hot-path phases.
156///
157/// Populated with `Instant`-based measurements at each phase boundary.
158/// All values are in milliseconds.
159#[derive(Debug, Clone, Default)]
160pub struct TurnTimings {
161    pub prepare_context_ms: u64,
162    pub llm_chat_ms: u64,
163    pub tool_exec_ms: u64,
164    pub persist_message_ms: u64,
165}
166
167/// Live snapshot of agent metrics broadcast via a [`tokio::sync::watch`] channel.
168///
169/// Fields are updated at different rates: some once at startup (static), others every turn
170/// (dynamic). For fields that are known at agent startup and do not change during the session,
171/// use [`StaticMetricsInit`] and `AgentBuilder::with_static_metrics` instead of
172/// adding a raw `send_modify` call in the runner.
173#[derive(Debug, Clone, Default)]
174#[allow(clippy::struct_excessive_bools)] // independent boolean flags; bitflags or enum would obscure semantics without reducing complexity
175pub struct MetricsSnapshot {
176    pub prompt_tokens: u64,
177    pub completion_tokens: u64,
178    pub total_tokens: u64,
179    /// Reasoning tokens from the last turn (`OpenAI` o-series only).
180    ///
181    /// This is a **subset** of `completion_tokens` and must not be added to cost separately.
182    pub reasoning_tokens: u64,
183    pub context_tokens: u64,
184    pub api_calls: u64,
185    pub active_skills: Vec<String>,
186    pub total_skills: usize,
187    /// Total configured MCP servers (connected + failed).
188    pub mcp_server_count: usize,
189    pub mcp_tool_count: usize,
190    /// Number of successfully connected MCP servers.
191    pub mcp_connected_count: usize,
192    /// Per-server connection status list.
193    pub mcp_servers: Vec<McpServerStatus>,
194    pub active_mcp_tools: Vec<String>,
195    pub sqlite_message_count: u64,
196    pub sqlite_conversation_id: Option<zeph_memory::ConversationId>,
197    pub qdrant_available: bool,
198    pub vector_backend: String,
199    pub embeddings_generated: u64,
200    pub last_llm_latency_ms: u64,
201    pub uptime_seconds: u64,
202    pub provider_name: String,
203    pub model_name: String,
204    pub summaries_count: u64,
205    pub context_compactions: u64,
206    /// Number of times the agent entered the Hard compaction tier, including cooldown-skipped
207    /// turns. Not equal to the actual LLM summarization count — reflects pressure, not action.
208    pub compaction_hard_count: u64,
209    /// User-message turns elapsed after each hard compaction event.
210    /// Entry i = turns between hard compaction i and hard compaction i+1 (or session end).
211    /// Empty when no hard compaction occurred during the session.
212    pub compaction_turns_after_hard: Vec<u64>,
213    pub compression_events: u64,
214    pub compression_tokens_saved: u64,
215    /// Tool results compressed by Acon (#4021) this session.
216    pub acon_results_compressed: u64,
217    /// Tokens saved by Acon tool-result compression (#4021) this session.
218    pub acon_tokens_saved: u64,
219    pub tool_output_prunes: u64,
220    /// Compaction probe outcomes (#1609).
221    pub compaction_probe_passes: u64,
222    /// Compaction probe soft failures (summary borderline — compaction proceeded with warning).
223    pub compaction_probe_soft_failures: u64,
224    /// Compaction probe hard failures (compaction blocked due to lossy summary).
225    pub compaction_probe_failures: u64,
226    /// Compaction probe errors (LLM/timeout — non-blocking, compaction proceeded).
227    pub compaction_probe_errors: u64,
228    /// Last compaction probe verdict. `None` before the first probe completes.
229    pub last_probe_verdict: Option<zeph_memory::ProbeVerdict>,
230    /// Last compaction probe score in [0.0, 1.0]. `None` before the first probe
231    /// completes or after an Error verdict (errors produce no score).
232    pub last_probe_score: Option<f32>,
233    /// Per-category scores from the last completed probe.
234    pub last_probe_category_scores: Option<Vec<zeph_memory::CategoryScore>>,
235    /// Configured pass threshold for the compaction probe. Used by TUI for category color-coding.
236    pub compaction_probe_threshold: f32,
237    /// Configured hard-fail threshold for the compaction probe.
238    pub compaction_probe_hard_fail_threshold: f32,
239    pub cache_read_tokens: u64,
240    pub cache_creation_tokens: u64,
241    pub cost_spent_cents: f64,
242    /// Cost per successful task in cents. `None` until at least one task completes.
243    pub cost_cps_cents: Option<f64>,
244    /// Number of successful tasks recorded today.
245    pub cost_successful_tasks: u64,
246    /// Per-provider cost breakdown, sorted by cost descending.
247    pub provider_cost_breakdown: Vec<(String, crate::cost::ProviderUsage)>,
248    pub filter_raw_tokens: u64,
249    pub filter_saved_tokens: u64,
250    pub filter_applications: u64,
251    pub filter_total_commands: u64,
252    pub filter_filtered_commands: u64,
253    pub filter_confidence_full: u64,
254    pub filter_confidence_partial: u64,
255    pub filter_confidence_fallback: u64,
256    pub cancellations: u64,
257    pub server_compaction_events: u64,
258    pub sanitizer_runs: u64,
259    pub sanitizer_injection_flags: u64,
260    /// Injection pattern hits on `ToolResult` (local) sources — likely false positives.
261    ///
262    /// Counts regex hits that fired on content from `shell`, `read_file`, `search_code`, etc.
263    /// These sources are user-owned and not adversarial; a non-zero value indicates a pattern
264    /// that needs tightening or a source reclassification.
265    pub sanitizer_injection_fp_local: u64,
266    pub sanitizer_truncations: u64,
267    pub quarantine_invocations: u64,
268    pub quarantine_failures: u64,
269    /// ML classifier hard-blocked tool outputs (`enforcement_mode=block` only).
270    pub classifier_tool_blocks: u64,
271    /// ML classifier suspicious tool outputs (both enforcement modes).
272    pub classifier_tool_suspicious: u64,
273    /// `TurnCausalAnalyzer` flags: behavioral deviation detected at tool-return boundary.
274    pub causal_ipi_flags: u64,
275    /// VIGIL pre-sanitizer flags: tool outputs matched injection patterns (any action).
276    pub vigil_flags_total: u64,
277    /// VIGIL pre-sanitizer blocks: tool outputs replaced with sentinel (`strict_mode=true`).
278    pub vigil_blocks_total: u64,
279    pub exfiltration_images_blocked: u64,
280    pub exfiltration_tool_urls_flagged: u64,
281    pub exfiltration_memory_guards: u64,
282    pub pii_scrub_count: u64,
283    /// Number of times the PII NER classifier timed out; input fell back to regex-only.
284    pub pii_ner_timeouts: u64,
285    /// Number of times the PII NER circuit breaker tripped (disabled NER for the session).
286    pub pii_ner_circuit_breaker_trips: u64,
287    pub memory_validation_failures: u64,
288    pub rate_limit_trips: u64,
289    pub pre_execution_blocks: u64,
290    pub pre_execution_warnings: u64,
291    /// `true` when a guardrail filter is active for this session.
292    pub guardrail_enabled: bool,
293    /// `true` when guardrail is in warn-only mode (action = warn).
294    pub guardrail_warn_mode: bool,
295    /// `true` when the SONAR NLI entailment stage is attached for this session.
296    pub nli_enabled: bool,
297    /// Number of NLI entailment checks performed (excludes circuit-breaker skips).
298    pub nli_checks: u64,
299    /// Number of NLI checks that returned a flagged verdict (observe-only, never blocks).
300    pub nli_flags: u64,
301    /// `true` when the PAAC secret masking registry is active for this session.
302    pub secret_masking_enabled: bool,
303    /// Number of vault secrets registered for masking this session.
304    pub secret_mask_registrations: u64,
305    /// Number of outbound LLM chat calls (across every dispatch site, not just the primary
306    /// turn-loop call) that had at least one secret masked.
307    pub secret_mask_applied: u64,
308    /// Number of secret placeholder tokens in tool arguments that failed to unmask (S1): the
309    /// model did not reproduce a `<SECRET:...>` token byte-for-byte, so the affected tool call
310    /// ran with the literal placeholder text instead of the real secret. Fail-safe (no leak),
311    /// but a non-zero count indicates a legitimate tool flow silently broke.
312    pub secret_unmask_misses: u64,
313    pub sub_agents: Vec<SubAgentMetrics>,
314    pub skill_confidence: Vec<SkillConfidence>,
315    /// Scheduled task summaries: `[name, kind, mode, next_run]`.
316    pub scheduled_tasks: Vec<[String; 4]>,
317    /// Thompson Sampling distribution snapshots: `(provider, alpha, beta)`.
318    pub router_thompson_stats: Vec<(String, f64, f64)>,
319    /// Ring buffer of recent security events (cap 100, FIFO eviction).
320    pub security_events: VecDeque<SecurityEvent>,
321    pub orchestration: OrchestrationMetrics,
322    /// Live snapshot of the currently active task graph. `None` when no plan is active.
323    pub orchestration_graph: Option<TaskGraphSnapshot>,
324    pub graph_community_detection_failures: u64,
325    pub graph_entities_total: u64,
326    pub graph_edges_total: u64,
327    pub graph_communities_total: u64,
328    pub graph_extraction_count: u64,
329    pub graph_extraction_failures: u64,
330    /// `true` when `config.llm.cloud.enable_extended_context = true`.
331    /// Never set for other providers to avoid false positives.
332    pub extended_context: bool,
333    /// Latest compression-guidelines version (0 = no guidelines yet).
334    pub guidelines_version: u32,
335    /// ISO 8601 timestamp of the latest guidelines update (empty if none).
336    pub guidelines_updated_at: String,
337    pub tool_cache_hits: u64,
338    pub tool_cache_misses: u64,
339    pub tool_cache_entries: usize,
340    /// Number of semantic-tier facts in memory (0 when tier promotion disabled).
341    pub semantic_fact_count: u64,
342    /// STT model name (e.g. "whisper-1"). `None` when STT is not configured.
343    pub stt_model: Option<String>,
344    /// Model used for context compaction/summarization. `None` when no summary provider is set.
345    pub compaction_model: Option<String>,
346    /// Temperature of the active provider when using Candle. `None` for API providers.
347    pub provider_temperature: Option<f32>,
348    /// Top-p of the active provider when using Candle. `None` for API providers.
349    pub provider_top_p: Option<f32>,
350    /// Embedding model name (e.g. `"nomic-embed-text"`). Empty when embeddings are disabled.
351    pub embedding_model: String,
352    /// Token budget for context window. `None` when not configured.
353    pub token_budget: Option<u64>,
354    /// Token threshold that triggers soft compaction. `None` when not configured.
355    pub compaction_threshold: Option<u32>,
356    /// Vault backend identifier: "age", "env", or "none".
357    pub vault_backend: String,
358    /// Active I/O channel name: `"cli"`, `"telegram"`, `"tui"`, `"discord"`, `"slack"`.
359    pub active_channel: String,
360    /// Background supervisor: inflight tasks across all classes.
361    pub bg_inflight: u64,
362    /// Background supervisor: total tasks dropped due to concurrency limit (all classes).
363    pub bg_dropped: u64,
364    /// Background supervisor: total tasks completed (all classes).
365    pub bg_completed: u64,
366    /// Background supervisor: inflight enrichment tasks.
367    pub bg_enrichment_inflight: u64,
368    /// Background supervisor: inflight telemetry tasks.
369    pub bg_telemetry_inflight: u64,
370    /// In-flight background shell runs. Empty when none are running or no `ShellExecutor` is wired.
371    pub shell_background_runs: Vec<ShellBackgroundRunRow>,
372    /// Whether self-learning (skill evolution) is enabled.
373    pub self_learning_enabled: bool,
374    /// Whether the semantic response cache is enabled.
375    pub semantic_cache_enabled: bool,
376    /// Whether semantic response caching is enabled (alias for `semantic_cache_enabled`).
377    pub cache_enabled: bool,
378    /// Whether assistant messages are auto-saved to memory.
379    pub autosave_enabled: bool,
380    /// Classifier p50/p95 latency metrics per task (injection, pii, feedback).
381    pub classifier: ClassifierMetricsSnapshot,
382    /// Latency breakdown for the most recently completed agent turn.
383    pub last_turn_timings: TurnTimings,
384    /// Rolling average of per-phase latency over the last 10 turns.
385    pub avg_turn_timings: TurnTimings,
386    /// Maximum per-phase latency observed within the rolling window (tail-latency visibility).
387    ///
388    /// M3: exposes `max_in_window` alongside the rolling average for operational monitoring.
389    pub max_turn_timings: TurnTimings,
390    /// Number of turns included in `avg_turn_timings` and `max_turn_timings` (capped at 10).
391    pub timing_sample_count: u64,
392    /// Total egress (outbound HTTP) requests attempted this session.
393    pub egress_requests_total: u64,
394    /// Egress events dropped due to bounded channel backpressure.
395    pub egress_dropped_total: u64,
396    /// Egress requests blocked by scheme/domain/SSRF policy.
397    pub egress_blocked_total: u64,
398    /// Runtime-resolved context window limit (tokens).
399    ///
400    /// Populated from `resolve_context_budget` after provider pool construction and refreshed on
401    /// every `/provider` switch. `0` means unknown (pre-init or provider has no declared window);
402    /// the TUI gauge renders `"—"` in this case to avoid divide-by-zero.
403    pub context_max_tokens: u64,
404    /// Token count at the time the most recent compaction was triggered. `0` = never compacted.
405    pub compaction_last_before: u64,
406    /// Token count after the most recent compaction completed. `0` = never compacted.
407    pub compaction_last_after: u64,
408    /// Unix epoch milliseconds when the most recent compaction occurred. `0` = never compacted.
409    pub compaction_last_at_ms: u64,
410    /// Active long-horizon goal for TUI display. `None` when no goal is active.
411    pub active_goal: Option<crate::goal::GoalSnapshot>,
412    /// Cocoon sidecar connection state. `None` when Cocoon is not configured.
413    /// `Some(true)` = proxy connected, `Some(false)` = unreachable or disconnected.
414    pub cocoon_connected: Option<bool>,
415    /// Worker count reported by the Cocoon sidecar. `0` when not connected or not configured.
416    pub cocoon_worker_count: u32,
417    /// Number of models available through the Cocoon sidecar.
418    pub cocoon_model_count: usize,
419    /// TON wallet balance in TON units. `None` when unknown or Cocoon not configured.
420    pub cocoon_ton_balance: Option<f64>,
421}
422
423/// Snapshot of a single in-flight background shell run for TUI display.
424///
425/// Populated from [`zeph_tools::BackgroundRunSnapshot`] during the per-turn metrics update in
426/// `reap_background_tasks_and_update_metrics`. The `run_id` is truncated to 8 hex chars for
427/// compact TUI rendering.
428#[derive(Debug, Clone, Default, serde::Serialize)]
429pub struct ShellBackgroundRunRow {
430    /// First 8 hex characters of the run's UUID, sufficient for unique identification in TUI.
431    pub run_id: String,
432    /// Original command, truncated to 80 characters.
433    pub command: String,
434    /// Wall-clock elapsed seconds since spawn.
435    pub elapsed_secs: u64,
436}
437
438/// Configuration-derived fields of [`MetricsSnapshot`] that are known at agent startup and do
439/// not change during the session.
440///
441/// Pass this struct to `AgentBuilder::with_static_metrics` immediately after
442/// `AgentBuilder::with_metrics` to initialize all static fields in one place rather than
443/// through scattered `send_modify` calls in the runner.
444///
445/// # Examples
446///
447/// ```no_run
448/// use zeph_core::metrics::StaticMetricsInit;
449///
450/// let init = StaticMetricsInit {
451///     active_channel: "cli".to_owned(),
452///     ..StaticMetricsInit::default()
453/// };
454/// ```
455#[derive(Debug, Default)]
456pub struct StaticMetricsInit {
457    /// STT model name (e.g. `"whisper-1"`). `None` when STT is not configured.
458    pub stt_model: Option<String>,
459    /// Model used for context compaction/summarization. `None` when no summary provider is set.
460    pub compaction_model: Option<String>,
461    /// Whether the semantic response cache is enabled.
462    ///
463    /// This value is also written to [`MetricsSnapshot::cache_enabled`] which is an alias for the
464    /// same concept.
465    pub semantic_cache_enabled: bool,
466    /// Embedding model name (e.g. `"nomic-embed-text"`). Empty when embeddings are disabled.
467    pub embedding_model: String,
468    /// Whether self-learning (skill evolution) is enabled.
469    pub self_learning_enabled: bool,
470    /// Active I/O channel name: `"cli"`, `"telegram"`, `"tui"`, `"discord"`, `"slack"`.
471    pub active_channel: String,
472    /// Token budget for context window. `None` when not configured.
473    pub token_budget: Option<u64>,
474    /// Token threshold that triggers soft compaction. `None` when not configured.
475    pub compaction_threshold: Option<u32>,
476    /// Vault backend identifier: `"age"`, `"env"`, or `"none"`.
477    pub vault_backend: String,
478    /// Whether assistant messages are auto-saved to memory.
479    pub autosave_enabled: bool,
480    /// Override for the active model name. When `Some`, replaces the model name set by the
481    /// builder from `runtime.model_name` (which may be a placeholder) with the effective model
482    /// resolved from the LLM provider configuration.
483    pub model_name_override: Option<String>,
484}
485
486/// Strip ASCII control characters and ANSI escape sequences from a string for safe TUI display.
487///
488/// Allows tab, LF, and CR; removes everything else in the `0x00–0x1F` range including full
489/// ANSI CSI sequences (`ESC[...`). This prevents escape-sequence injection from LLM planner
490/// output into the TUI.
491fn strip_ctrl(s: &str) -> String {
492    let mut out = String::with_capacity(s.len());
493    let mut chars = s.chars().peekable();
494    while let Some(c) = chars.next() {
495        if c == '\x1b' {
496            // Consume an ANSI CSI sequence: ESC [ <params> <final-byte in 0x40–0x7E>
497            if chars.peek() == Some(&'[') {
498                chars.next(); // consume '['
499                for inner in chars.by_ref() {
500                    if ('\x40'..='\x7e').contains(&inner) {
501                        break;
502                    }
503                }
504            }
505            // Drop ESC and any consumed sequence — write nothing.
506        } else if c.is_control() && c != '\t' && c != '\n' && c != '\r' {
507            // drop other control chars
508        } else {
509            out.push(c);
510        }
511    }
512    out
513}
514
515/// Convert a live `TaskGraph` into a lightweight snapshot for TUI display.
516impl From<&zeph_orchestration::TaskGraph> for TaskGraphSnapshot {
517    fn from(graph: &zeph_orchestration::TaskGraph) -> Self {
518        let tasks = graph
519            .tasks
520            .iter()
521            .map(|t| {
522                let error = t
523                    .result
524                    .as_ref()
525                    .filter(|_| t.status == zeph_orchestration::TaskStatus::Failed)
526                    .and_then(|r| {
527                        if r.output.is_empty() {
528                            None
529                        } else {
530                            // Strip control chars, then truncate at 80 chars (SEC-P6-01).
531                            let s = strip_ctrl(&r.output);
532                            if s.len() > 80 {
533                                let end = s.floor_char_boundary(79);
534                                Some(format!("{}…", &s[..end]))
535                            } else {
536                                Some(s)
537                            }
538                        }
539                    });
540                let duration_ms = t.result.as_ref().map_or(0, |r| r.duration_ms);
541                TaskSnapshotRow {
542                    id: t.id.as_u32(),
543                    title: strip_ctrl(&t.title),
544                    status: t.status.to_string(),
545                    agent: t.assigned_agent.as_deref().map(strip_ctrl),
546                    duration_ms,
547                    error,
548                }
549            })
550            .collect();
551        Self {
552            graph_id: graph.id.to_string(),
553            goal: strip_ctrl(&graph.goal),
554            status: graph.status.to_string(),
555            tasks,
556            completed_at: None,
557        }
558    }
559}
560
561pub struct MetricsCollector {
562    tx: watch::Sender<MetricsSnapshot>,
563}
564
565impl MetricsCollector {
566    #[must_use]
567    pub fn new() -> (Self, watch::Receiver<MetricsSnapshot>) {
568        let (tx, rx) = watch::channel(MetricsSnapshot::default());
569        (Self { tx }, rx)
570    }
571
572    pub fn update(&self, f: impl FnOnce(&mut MetricsSnapshot)) {
573        self.tx.send_modify(f);
574    }
575
576    /// Publish the runtime-resolved context window limit.
577    ///
578    /// Call after the provider pool is constructed (builder) and on every successful
579    /// `/provider` switch so the TUI context gauge reflects the active provider's window.
580    ///
581    /// # Examples
582    ///
583    /// ```rust
584    /// use zeph_core::metrics::MetricsCollector;
585    ///
586    /// let (collector, rx) = MetricsCollector::new();
587    /// collector.set_context_max_tokens(128_000);
588    /// assert_eq!(rx.borrow().context_max_tokens, 128_000);
589    /// ```
590    pub fn set_context_max_tokens(&self, max_tokens: u64) {
591        self.tx.send_modify(|m| m.context_max_tokens = max_tokens);
592    }
593
594    /// Record the outcome of the most recent compaction event.
595    ///
596    /// Sets all three `compaction_last_*` fields atomically. `at_ms` is the Unix epoch in
597    /// milliseconds — obtain via `SystemTime::UNIX_EPOCH.elapsed().unwrap_or_default().as_millis()`.
598    ///
599    /// # Examples
600    ///
601    /// ```rust
602    /// use zeph_core::metrics::MetricsCollector;
603    ///
604    /// let (collector, rx) = MetricsCollector::new();
605    /// collector.record_compaction(50_000, 12_000, 1_700_000_000_000);
606    /// let snap = rx.borrow();
607    /// assert_eq!(snap.compaction_last_before, 50_000);
608    /// assert_eq!(snap.compaction_last_after, 12_000);
609    /// assert_eq!(snap.compaction_last_at_ms, 1_700_000_000_000);
610    /// ```
611    pub fn record_compaction(&self, before: u64, after: u64, at_ms: u64) {
612        self.tx.send_modify(|m| {
613            m.compaction_last_before = before;
614            m.compaction_last_after = after;
615            m.compaction_last_at_ms = at_ms;
616        });
617    }
618
619    /// Returns a clone of the underlying [`watch::Sender`].
620    ///
621    /// Use this to pass the sender to code that requires a raw
622    /// `watch::Sender<MetricsSnapshot>` while the [`MetricsCollector`] is
623    /// also shared (e.g., passed to a `MetricsBridge` layer).
624    #[must_use]
625    pub fn sender(&self) -> watch::Sender<MetricsSnapshot> {
626        self.tx.clone()
627    }
628}
629
630// ---------------------------------------------------------------------------
631// HistogramRecorder
632// ---------------------------------------------------------------------------
633
634/// Per-event histogram recording contract for the agent loop.
635///
636/// Implementors record individual latency observations into Prometheus histograms
637/// (or any other backend). The trait is object-safe: the agent stores an
638/// `Option<Arc<dyn HistogramRecorder>>` and calls these methods at each measurement
639/// point. When `None`, recording is a no-op with zero overhead.
640///
641/// # Contract for implementors
642///
643/// - All methods must be non-blocking; they must not call async code or acquire
644///   mutexes that may block.
645/// - Implementations must be `Send + Sync` — the agent loop runs on the tokio
646///   thread pool and the recorder may be called from multiple tasks.
647///
648/// # Examples
649///
650/// ```rust
651/// use std::sync::Arc;
652/// use std::time::Duration;
653/// use zeph_core::metrics::HistogramRecorder;
654///
655/// struct NoOpRecorder;
656///
657/// impl HistogramRecorder for NoOpRecorder {
658///     fn observe_llm_latency(&self, _: Duration) {}
659///     fn observe_turn_duration(&self, _: Duration) {}
660///     fn observe_tool_execution(&self, _: Duration) {}
661///     fn observe_bg_task(&self, _: &str, _: Duration) {}
662/// }
663///
664/// let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
665/// recorder.observe_llm_latency(Duration::from_millis(500));
666/// ```
667pub trait HistogramRecorder: Send + Sync {
668    /// Record a single LLM API call latency observation.
669    fn observe_llm_latency(&self, duration: std::time::Duration);
670
671    /// Record a full agent turn duration observation (context prep + LLM + tools + persist).
672    fn observe_turn_duration(&self, duration: std::time::Duration);
673
674    /// Record a single tool execution latency observation.
675    fn observe_tool_execution(&self, duration: std::time::Duration);
676
677    /// Record a background task completion latency.
678    ///
679    /// `class_label` is `"enrichment"` or `"telemetry"` (from `TaskClass::name()`).
680    fn observe_bg_task(&self, class_label: &str, duration: std::time::Duration);
681}
682
683#[cfg(test)]
684mod tests {
685    #![allow(clippy::field_reassign_with_default)]
686
687    use super::*;
688
689    #[test]
690    fn default_metrics_snapshot() {
691        let m = MetricsSnapshot::default();
692        assert_eq!(m.total_tokens, 0);
693        assert_eq!(m.api_calls, 0);
694        assert!(m.active_skills.is_empty());
695        assert!(m.active_mcp_tools.is_empty());
696        assert_eq!(m.mcp_tool_count, 0);
697        assert_eq!(m.mcp_server_count, 0);
698        assert!(m.provider_name.is_empty());
699        assert_eq!(m.summaries_count, 0);
700        // Phase 2 fields
701        assert!(m.stt_model.is_none());
702        assert!(m.compaction_model.is_none());
703        assert!(m.provider_temperature.is_none());
704        assert!(m.provider_top_p.is_none());
705        assert!(m.active_channel.is_empty());
706        assert!(m.embedding_model.is_empty());
707        assert!(m.token_budget.is_none());
708        assert!(!m.self_learning_enabled);
709        assert!(!m.semantic_cache_enabled);
710    }
711
712    #[test]
713    fn metrics_collector_update_phase2_fields() {
714        let (collector, rx) = MetricsCollector::new();
715        collector.update(|m| {
716            m.stt_model = Some("whisper-1".into());
717            m.compaction_model = Some("haiku".into());
718            m.provider_temperature = Some(0.7);
719            m.provider_top_p = Some(0.95);
720            m.active_channel = "tui".into();
721            m.embedding_model = "nomic-embed-text".into();
722            m.token_budget = Some(200_000);
723            m.self_learning_enabled = true;
724            m.semantic_cache_enabled = true;
725        });
726        let s = rx.borrow();
727        assert_eq!(s.stt_model.as_deref(), Some("whisper-1"));
728        assert_eq!(s.compaction_model.as_deref(), Some("haiku"));
729        assert_eq!(s.provider_temperature, Some(0.7));
730        assert_eq!(s.provider_top_p, Some(0.95));
731        assert_eq!(s.active_channel, "tui");
732        assert_eq!(s.embedding_model, "nomic-embed-text");
733        assert_eq!(s.token_budget, Some(200_000));
734        assert!(s.self_learning_enabled);
735        assert!(s.semantic_cache_enabled);
736    }
737
738    #[test]
739    fn metrics_collector_update() {
740        let (collector, rx) = MetricsCollector::new();
741        collector.update(|m| {
742            m.api_calls = 5;
743            m.total_tokens = 1000;
744        });
745        let snapshot = rx.borrow().clone();
746        assert_eq!(snapshot.api_calls, 5);
747        assert_eq!(snapshot.total_tokens, 1000);
748    }
749
750    #[test]
751    fn metrics_collector_multiple_updates() {
752        let (collector, rx) = MetricsCollector::new();
753        collector.update(|m| m.api_calls = 1);
754        collector.update(|m| m.api_calls += 1);
755        assert_eq!(rx.borrow().api_calls, 2);
756    }
757
758    #[test]
759    fn metrics_snapshot_clone() {
760        let mut m = MetricsSnapshot::default();
761        m.provider_name = "ollama".into();
762        let cloned = m.clone();
763        assert_eq!(cloned.provider_name, "ollama");
764    }
765
766    #[test]
767    fn filter_metrics_tracking() {
768        let (collector, rx) = MetricsCollector::new();
769        collector.update(|m| {
770            m.filter_raw_tokens += 250;
771            m.filter_saved_tokens += 200;
772            m.filter_applications += 1;
773        });
774        collector.update(|m| {
775            m.filter_raw_tokens += 100;
776            m.filter_saved_tokens += 80;
777            m.filter_applications += 1;
778        });
779        let s = rx.borrow();
780        assert_eq!(s.filter_raw_tokens, 350);
781        assert_eq!(s.filter_saved_tokens, 280);
782        assert_eq!(s.filter_applications, 2);
783    }
784
785    #[test]
786    fn filter_confidence_and_command_metrics() {
787        let (collector, rx) = MetricsCollector::new();
788        collector.update(|m| {
789            m.filter_total_commands += 1;
790            m.filter_filtered_commands += 1;
791            m.filter_confidence_full += 1;
792        });
793        collector.update(|m| {
794            m.filter_total_commands += 1;
795            m.filter_confidence_partial += 1;
796        });
797        let s = rx.borrow();
798        assert_eq!(s.filter_total_commands, 2);
799        assert_eq!(s.filter_filtered_commands, 1);
800        assert_eq!(s.filter_confidence_full, 1);
801        assert_eq!(s.filter_confidence_partial, 1);
802        assert_eq!(s.filter_confidence_fallback, 0);
803    }
804
805    #[test]
806    fn summaries_count_tracks_summarizations() {
807        let (collector, rx) = MetricsCollector::new();
808        collector.update(|m| m.summaries_count += 1);
809        collector.update(|m| m.summaries_count += 1);
810        assert_eq!(rx.borrow().summaries_count, 2);
811    }
812
813    #[test]
814    fn cancellations_counter_increments() {
815        let (collector, rx) = MetricsCollector::new();
816        assert_eq!(rx.borrow().cancellations, 0);
817        collector.update(|m| m.cancellations += 1);
818        collector.update(|m| m.cancellations += 1);
819        assert_eq!(rx.borrow().cancellations, 2);
820    }
821
822    #[test]
823    fn security_event_detail_exact_128_not_truncated() {
824        let s = "a".repeat(128);
825        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s.clone());
826        assert_eq!(ev.detail, s, "128-char string must not be truncated");
827    }
828
829    #[test]
830    fn security_event_detail_129_is_truncated() {
831        let s = "a".repeat(129);
832        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
833        assert!(
834            ev.detail.ends_with('…'),
835            "129-char string must end with ellipsis"
836        );
837        assert!(
838            ev.detail.len() <= 130,
839            "truncated detail must be at most 130 bytes"
840        );
841    }
842
843    #[test]
844    fn security_event_detail_multibyte_utf8_no_panic() {
845        // Each '中' is 3 bytes. 43 chars = 129 bytes — triggers truncation at a multi-byte boundary.
846        let s = "中".repeat(43);
847        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, "src", s);
848        assert!(ev.detail.ends_with('…'));
849    }
850
851    #[test]
852    fn security_event_source_capped_at_64_chars() {
853        let long_source = "x".repeat(200);
854        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, long_source, "detail");
855        assert_eq!(ev.source.len(), 64);
856    }
857
858    #[test]
859    fn security_event_source_strips_control_chars() {
860        let source = "tool\x00name\x1b[31m";
861        let ev = SecurityEvent::new(SecurityEventCategory::InjectionFlag, source, "detail");
862        assert!(!ev.source.contains('\x00'));
863        assert!(!ev.source.contains('\x1b'));
864    }
865
866    #[test]
867    fn security_event_category_as_str() {
868        assert_eq!(SecurityEventCategory::InjectionFlag.as_str(), "injection");
869        assert_eq!(SecurityEventCategory::ExfiltrationBlock.as_str(), "exfil");
870        assert_eq!(SecurityEventCategory::Quarantine.as_str(), "quarantine");
871        assert_eq!(SecurityEventCategory::Truncation.as_str(), "truncation");
872        assert_eq!(
873            SecurityEventCategory::CrossBoundaryMcpToAcp.as_str(),
874            "cross_boundary_mcp_to_acp"
875        );
876    }
877
878    #[test]
879    fn ring_buffer_respects_cap_via_update() {
880        let (collector, rx) = MetricsCollector::new();
881        for i in 0..110u64 {
882            let event = SecurityEvent::new(
883                SecurityEventCategory::InjectionFlag,
884                "src",
885                format!("event {i}"),
886            );
887            collector.update(|m| {
888                if m.security_events.len() >= SECURITY_EVENT_CAP {
889                    m.security_events.pop_front();
890                }
891                m.security_events.push_back(event);
892            });
893        }
894        let snap = rx.borrow();
895        assert_eq!(snap.security_events.len(), SECURITY_EVENT_CAP);
896        // FIFO: earliest events evicted, last one present
897        assert!(snap.security_events.back().unwrap().detail.contains("109"));
898    }
899
900    #[test]
901    fn security_events_empty_by_default() {
902        let m = MetricsSnapshot::default();
903        assert!(m.security_events.is_empty());
904    }
905
906    #[test]
907    fn orchestration_metrics_default_zero() {
908        let m = OrchestrationMetrics::default();
909        assert_eq!(m.plans_total, 0);
910        assert_eq!(m.tasks_total, 0);
911        assert_eq!(m.tasks_completed, 0);
912        assert_eq!(m.tasks_failed, 0);
913        assert_eq!(m.tasks_skipped, 0);
914    }
915
916    #[test]
917    fn metrics_snapshot_includes_orchestration_default_zero() {
918        let m = MetricsSnapshot::default();
919        assert_eq!(m.orchestration.plans_total, 0);
920        assert_eq!(m.orchestration.tasks_total, 0);
921        assert_eq!(m.orchestration.tasks_completed, 0);
922    }
923
924    #[test]
925    fn orchestration_metrics_update_via_collector() {
926        let (collector, rx) = MetricsCollector::new();
927        collector.update(|m| {
928            m.orchestration.plans_total += 1;
929            m.orchestration.tasks_total += 5;
930            m.orchestration.tasks_completed += 3;
931            m.orchestration.tasks_failed += 1;
932            m.orchestration.tasks_skipped += 1;
933        });
934        let s = rx.borrow();
935        assert_eq!(s.orchestration.plans_total, 1);
936        assert_eq!(s.orchestration.tasks_total, 5);
937        assert_eq!(s.orchestration.tasks_completed, 3);
938        assert_eq!(s.orchestration.tasks_failed, 1);
939        assert_eq!(s.orchestration.tasks_skipped, 1);
940    }
941
942    #[test]
943    fn strip_ctrl_removes_escape_sequences() {
944        let input = "hello\x1b[31mworld\x00end";
945        let result = strip_ctrl(input);
946        assert_eq!(result, "helloworldend");
947    }
948
949    #[test]
950    fn strip_ctrl_allows_tab_lf_cr() {
951        let input = "a\tb\nc\rd";
952        let result = strip_ctrl(input);
953        assert_eq!(result, "a\tb\nc\rd");
954    }
955
956    #[test]
957    fn task_graph_snapshot_is_stale_after_30s() {
958        let mut snap = TaskGraphSnapshot::default();
959        // Not stale if no completed_at.
960        assert!(!snap.is_stale());
961        // Not stale if just completed.
962        snap.completed_at = Some(std::time::Instant::now());
963        assert!(!snap.is_stale());
964        // Stale if completed more than 30s ago.
965        snap.completed_at = Some(
966            std::time::Instant::now()
967                .checked_sub(std::time::Duration::from_secs(31))
968                .unwrap(),
969        );
970        assert!(snap.is_stale());
971    }
972
973    // T1: From<&TaskGraph> correctly maps fields including duration_ms and error truncation.
974    #[test]
975    fn task_graph_snapshot_from_task_graph_maps_fields() {
976        use zeph_orchestration::{GraphStatus, TaskGraph, TaskNode, TaskResult, TaskStatus};
977
978        let mut graph = TaskGraph::new("My goal");
979        let mut task = TaskNode::new(0, "Do work", "description");
980        task.status = TaskStatus::Failed;
981        task.assigned_agent = Some("agent-1".into());
982        task.result = Some(TaskResult {
983            output: "error occurred here".into(),
984            artifacts: vec![],
985            duration_ms: 1234,
986            agent_id: None,
987            agent_def: None,
988        });
989        graph.tasks.push(task);
990        graph.status = GraphStatus::Failed;
991
992        let snap = TaskGraphSnapshot::from(&graph);
993        assert_eq!(snap.goal, "My goal");
994        assert_eq!(snap.status, "failed");
995        assert_eq!(snap.tasks.len(), 1);
996        let row = &snap.tasks[0];
997        assert_eq!(row.title, "Do work");
998        assert_eq!(row.status, "failed");
999        assert_eq!(row.agent.as_deref(), Some("agent-1"));
1000        assert_eq!(row.duration_ms, 1234);
1001        assert!(row.error.as_deref().unwrap().contains("error occurred"));
1002    }
1003
1004    // T2: From impl compiles with orchestration feature active.
1005    #[test]
1006    fn task_graph_snapshot_from_compiles_with_feature() {
1007        use zeph_orchestration::TaskGraph;
1008        let graph = TaskGraph::new("feature flag test");
1009        let snap = TaskGraphSnapshot::from(&graph);
1010        assert_eq!(snap.goal, "feature flag test");
1011        assert!(snap.tasks.is_empty());
1012        assert!(!snap.is_stale());
1013    }
1014
1015    // T1-extra: long error is truncated with ellipsis.
1016    #[test]
1017    fn task_graph_snapshot_error_truncated_at_80_chars() {
1018        use zeph_orchestration::{TaskGraph, TaskNode, TaskResult, TaskStatus};
1019
1020        let mut graph = TaskGraph::new("goal");
1021        let mut task = TaskNode::new(0, "t", "d");
1022        task.status = TaskStatus::Failed;
1023        task.result = Some(TaskResult {
1024            output: "e".repeat(100),
1025            artifacts: vec![],
1026            duration_ms: 0,
1027            agent_id: None,
1028            agent_def: None,
1029        });
1030        graph.tasks.push(task);
1031
1032        let snap = TaskGraphSnapshot::from(&graph);
1033        let err = snap.tasks[0].error.as_ref().unwrap();
1034        assert!(err.ends_with('…'), "truncated error must end with ellipsis");
1035        assert!(
1036            err.len() <= 83,
1037            "truncated error must not exceed 80 chars + ellipsis"
1038        );
1039    }
1040
1041    // SEC-P6-01: control chars in task title are stripped.
1042    #[test]
1043    fn task_graph_snapshot_strips_control_chars_from_title() {
1044        use zeph_orchestration::{TaskGraph, TaskNode};
1045
1046        let mut graph = TaskGraph::new("goal\x1b[31m");
1047        let task = TaskNode::new(0, "title\x00injected", "d");
1048        graph.tasks.push(task);
1049
1050        let snap = TaskGraphSnapshot::from(&graph);
1051        assert!(!snap.goal.contains('\x1b'), "goal must not contain escape");
1052        assert!(
1053            !snap.tasks[0].title.contains('\x00'),
1054            "title must not contain null byte"
1055        );
1056    }
1057
1058    #[test]
1059    fn graph_metrics_default_zero() {
1060        let m = MetricsSnapshot::default();
1061        assert_eq!(m.graph_entities_total, 0);
1062        assert_eq!(m.graph_edges_total, 0);
1063        assert_eq!(m.graph_communities_total, 0);
1064        assert_eq!(m.graph_extraction_count, 0);
1065        assert_eq!(m.graph_extraction_failures, 0);
1066    }
1067
1068    #[test]
1069    fn graph_metrics_update_via_collector() {
1070        let (collector, rx) = MetricsCollector::new();
1071        collector.update(|m| {
1072            m.graph_entities_total = 5;
1073            m.graph_edges_total = 10;
1074            m.graph_communities_total = 2;
1075            m.graph_extraction_count = 7;
1076            m.graph_extraction_failures = 1;
1077        });
1078        let snapshot = rx.borrow().clone();
1079        assert_eq!(snapshot.graph_entities_total, 5);
1080        assert_eq!(snapshot.graph_edges_total, 10);
1081        assert_eq!(snapshot.graph_communities_total, 2);
1082        assert_eq!(snapshot.graph_extraction_count, 7);
1083        assert_eq!(snapshot.graph_extraction_failures, 1);
1084    }
1085
1086    #[test]
1087    fn histogram_recorder_trait_is_object_safe() {
1088        use std::sync::Arc;
1089        use std::time::Duration;
1090
1091        struct NoOpRecorder;
1092        impl HistogramRecorder for NoOpRecorder {
1093            fn observe_llm_latency(&self, _: Duration) {}
1094            fn observe_turn_duration(&self, _: Duration) {}
1095            fn observe_tool_execution(&self, _: Duration) {}
1096            fn observe_bg_task(&self, _: &str, _: Duration) {}
1097        }
1098
1099        // Verify the trait can be used as a trait object (object-safe).
1100        let recorder: Arc<dyn HistogramRecorder> = Arc::new(NoOpRecorder);
1101        recorder.observe_llm_latency(Duration::from_millis(500));
1102        recorder.observe_turn_duration(Duration::from_secs(3));
1103        recorder.observe_tool_execution(Duration::from_millis(100));
1104    }
1105}