Skip to main content

zeph_agent_context/
state.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Borrow-lens view types used by [`crate::service::ContextService`].
5//!
6//! Each view holds `&`/`&mut` references to the exact sub-fields that the context
7//! service needs. By accepting lenses instead of `&mut Agent<C>`, this crate avoids
8//! depending on `zeph-core` while still letting the call site in `zeph-core` construct
9//! them from disjoint field projections.
10//!
11//! Views are constructed at the call site in `zeph-core` using one literal struct
12//! expression. The borrow checker proves disjointness at that level without additional
13//! helper methods — each `&mut` resolves to a unique field path under `Agent<C>`.
14
15use parking_lot::RwLock;
16use std::borrow::Cow;
17use std::collections::HashSet;
18use std::future::Future;
19use std::path::PathBuf;
20use std::pin::Pin;
21use std::sync::Arc;
22use zeph_common::SecurityEventCategory;
23use zeph_common::task_supervisor::{BlockingHandle, TaskSupervisor};
24use zeph_config::{
25    ContextStrategy, DocumentConfig, GraphConfig, PersonaConfig, ReasoningConfig, TrajectoryConfig,
26    TreeConfig,
27};
28use zeph_context::input::CorrectionConfig;
29use zeph_context::manager::ContextManager;
30use zeph_context::summarization::SummarizationDeps;
31use zeph_llm::any::AnyProvider;
32use zeph_llm::provider::Message;
33use zeph_memory::semantic::SemanticMemory;
34use zeph_memory::{ConversationId, TokenCounter};
35use zeph_sanitizer::ContentSanitizer;
36use zeph_sanitizer::quarantine::QuarantinedSummarizer;
37use zeph_skills::proactive::ProactiveExplorer;
38use zeph_skills::registry::SkillRegistry;
39
40use crate::compaction::{SubgoalExtractionResult, SubgoalRegistry};
41
42/// Borrow-lens over the agent's conversation window fields.
43///
44/// Holds `&mut` references to every message-list field that the context service
45/// needs to read or write. Constructed by the `zeph-core` shim from disjoint
46/// sub-fields of `Agent<C>::msg`.
47pub struct MessageWindowView<'a> {
48    /// Full message history. The context service reads and filters this list.
49    pub messages: &'a mut Vec<Message>,
50    /// `SQLite` row ID of the most recently persisted message.
51    pub last_persisted_message_id: &'a mut Option<i64>,
52    /// `SQLite` row IDs to be soft-deleted after context assembly completes.
53    pub deferred_db_hide_ids: &'a mut Vec<i64>,
54    /// Deferred summary strings to be appended after context assembly completes.
55    pub deferred_db_summaries: &'a mut Vec<String>,
56    /// Running token count for the current prompt window — updated after every
57    /// message-list mutation to keep provider call budgets accurate.
58    /// Maps to `Agent<C>::runtime.providers.cached_prompt_tokens`.
59    pub cached_prompt_tokens: &'a mut u64,
60    /// Shared token counter — cheap `Arc` clone from `Agent<C>::runtime.metrics.token_counter`.
61    pub token_counter: Arc<TokenCounter>,
62    /// Tool IDs that completed successfully in the current session.
63    /// Maps to `Agent<C>::services.tool_state.completed_tool_ids`.
64    /// Cleared by `clear_history` together with the message list.
65    pub completed_tool_ids: &'a mut HashSet<String>,
66}
67
68/// Accumulated metric deltas for one context-assembly pass.
69///
70/// Holds owned counters that the service increments during `prepare_context`.
71/// After the call returns, the `zeph-core` shim applies these deltas to the agent's
72/// metrics snapshot via `update_metrics`. Using owned values (not references) avoids
73/// borrowing into `MetricsSnapshot`, which lives behind a watch channel.
74#[derive(Debug, Default)]
75pub struct MetricsCounters {
76    /// Sanitizer checks performed during this pass.
77    pub sanitizer_runs: u64,
78    /// Injection flags raised during this pass.
79    pub sanitizer_injection_flags: u64,
80    /// Truncations applied during this pass.
81    pub sanitizer_truncations: u64,
82    /// Quarantine invocations during this pass.
83    pub quarantine_invocations: u64,
84    /// Quarantine failures during this pass.
85    pub quarantine_failures: u64,
86}
87
88/// Abstract sink for security events raised during context assembly.
89///
90/// Implemented in `zeph-core` by a stack-local adapter that appends to
91/// `Agent<C>::runtime.metrics.security_events`. Using a trait keeps this crate
92/// free of `zeph-core` internal types.
93pub trait SecurityEventSink: Send {
94    /// Record a security event.
95    fn push(&mut self, category: SecurityEventCategory, source: &'static str, detail: String);
96}
97
98/// Borrow-lens over all fields needed for `prepare_context` and `Agent<C>::rebuild_system_prompt`.
99///
100/// Every field maps to a single sub-field of `Agent<C>` and uses a type from a
101/// lower-level crate (`zeph-memory`, `zeph-skills`, `zeph-context`, `zeph-sanitizer`,
102/// `zeph-config`, `zeph-common`, `zeph-llm`). No `zeph-core`-internal `*State`
103/// aggregator ever crosses this boundary.
104///
105/// Constructed by the `zeph-core` shim using one literal struct expression. The
106/// borrow checker verifies disjointness because no two `&mut` paths share a prefix.
107pub struct ContextAssemblyView<'a> {
108    // ── Memory (one mut field; the rest are read-only clones/copies) ─────────────────
109    /// `services.memory.persistence.memory` — `Arc` clone is cheap.
110    pub memory: Option<Arc<SemanticMemory>>,
111    /// `services.memory.persistence.conversation_id`.
112    pub conversation_id: Option<ConversationId>,
113    /// `services.memory.persistence.recall_limit`.
114    pub recall_limit: usize,
115    /// `services.memory.persistence.cross_session_score_threshold`.
116    pub cross_session_score_threshold: f32,
117    /// `services.memory.persistence.context_format` — determines recall entry formatting.
118    pub context_format: zeph_config::ContextFormat,
119    /// `services.memory.persistence.last_recall_confidence` — written by apply path.
120    pub last_recall_confidence: &'a mut Option<f32>,
121
122    /// `services.memory.compaction.context_strategy` (Copy enum).
123    pub context_strategy: ContextStrategy,
124    /// `services.memory.compaction.crossover_turn_threshold`.
125    pub crossover_turn_threshold: u32,
126    /// `services.memory.compaction.cached_session_digest` — cloned into assembler input.
127    ///
128    /// The `usize` is the token count of the digest (used by `ContextMemoryView`).
129    pub cached_session_digest: Option<(String, usize)>,
130    /// `services.memory.compaction.digest_config.enabled`.
131    pub digest_enabled: bool,
132
133    /// `services.memory.extraction.graph_config` — cloned (small, `Clone`).
134    pub graph_config: GraphConfig,
135    /// `services.memory.extraction.document_config` — cloned.
136    pub document_config: DocumentConfig,
137    /// `services.memory.extraction.persona_config` — cloned.
138    pub persona_config: PersonaConfig,
139    /// `services.memory.extraction.trajectory_config` — cloned.
140    pub trajectory_config: TrajectoryConfig,
141    /// `services.memory.extraction.reasoning_config` — cloned.
142    pub reasoning_config: ReasoningConfig,
143    /// `services.memory.subsystems.tree_config` — cloned.
144    pub tree_config: TreeConfig,
145
146    // ── Skill ─────────────────────────────────────────────────────────────────────────
147    /// `services.skill.last_skills_prompt` — written by `Agent<C>::rebuild_system_prompt`.
148    pub last_skills_prompt: &'a mut String,
149    /// `services.skill.active_skill_names` — written by `Agent<C>::rebuild_system_prompt`.
150    pub active_skill_names: &'a mut Vec<String>,
151    /// `services.skill.registry` — `Arc` clone enables concurrent read access.
152    pub skill_registry: Arc<RwLock<SkillRegistry>>,
153    /// `services.skill.skill_paths` — read during proactive reload.
154    pub skill_paths: &'a [PathBuf],
155
156    // ── Index (feature-gated) ─────────────────────────────────────────────────────────
157    /// Built at the shim by `IndexState::as_index_access()`. The lifetime reflects
158    /// the borrow back into `services.index`.
159    ///
160    /// Only populated when the `index` feature is enabled.
161    #[cfg(feature = "index")]
162    pub index: Option<&'a dyn zeph_context::input::IndexAccess>,
163
164    // ── Learning / sidequest / proactive ──────────────────────────────────────────────
165    /// Built at the shim from `services.learning_engine.config` — the engine itself
166    /// never crosses the crate boundary.
167    pub correction_config: Option<CorrectionConfig>,
168    /// `services.sidequest.turn_counter`.
169    pub sidequest_turn_counter: u64,
170    /// `services.proactive_explorer` — `Arc` clone for async use without borrowing self.
171    pub proactive_explorer: Option<Arc<ProactiveExplorer>>,
172
173    // ── Security ──────────────────────────────────────────────────────────────────────
174    /// `services.security.sanitizer` — borrowed from `SecurityState`; not Arc-wrapped in `zeph-core`.
175    pub sanitizer: &'a ContentSanitizer,
176    /// `services.security.quarantine_summarizer` — borrowed from `SecurityState`.
177    pub quarantine_summarizer: Option<&'a QuarantinedSummarizer>,
178
179    // ── Context manager ───────────────────────────────────────────────────────────────
180    /// `self.context_manager` — mutably borrowed for token recompute hooks.
181    pub context_manager: &'a mut ContextManager,
182
183    // ── Runtime / metrics ─────────────────────────────────────────────────────────────
184    /// `runtime.metrics.token_counter` — `Arc` clone is cheap.
185    pub token_counter: Arc<zeph_memory::TokenCounter>,
186    /// Accumulated metric deltas — incremented during the pass, applied to the metrics
187    /// snapshot by the `zeph-core` shim after `prepare_context` returns.
188    pub metrics: MetricsCounters,
189    /// Abstract sink for security events raised during context assembly.
190    pub security_events: &'a mut dyn SecurityEventSink,
191    /// `runtime.providers.cached_prompt_tokens` — read for compression-spectrum ratio.
192    pub cached_prompt_tokens: u64,
193
194    // ── Config flags ──────────────────────────────────────────────────────────────────
195    /// `runtime.config.redact_credentials`.
196    pub redact_credentials: bool,
197    /// `runtime.config.channel_skills` — per-channel skill filter for system prompt rebuild.
198    pub channel_skills: &'a [String],
199
200    // ── Credential scrubber ───────────────────────────────────────────────────────────
201    /// Function pointer for scrubbing credentials from message content.
202    ///
203    /// Passed as a function pointer so `zeph-agent-context` does not need to depend on
204    /// `zeph-core::redact`. The shim in `zeph-core` sets this to `crate::redact::scrub_content`.
205    /// When `redact_credentials = false` the service does not call this function.
206    pub scrub: fn(&str) -> Cow<'_, str>,
207}
208
209/// Values produced by [`crate::service::ContextService::prepare_context`] that must be applied by the caller.
210///
211/// `ContextService` cannot inject code context directly because `inject_code_context` touches
212/// the system prompt (position-0 message), which involves subsystems beyond the context-window
213/// boundary. Instead, the service returns the code-context body and the caller applies it.
214#[derive(Debug, Default)]
215pub struct ContextDelta {
216    /// Sanitized code-context body to inject into the system prompt by the `Agent<C>` shim.
217    ///
218    /// `None` when no code context was fetched or the fetch returned empty.
219    pub code_context: Option<String>,
220}
221
222/// Borrow-lens over all fields needed for compaction and summarization operations.
223///
224/// Every field maps to a specific sub-field of `Agent<C>` and uses a type from a
225/// crate below `zeph-core` in the dependency graph. Constructed in `zeph-core` using
226/// one literal struct expression; the borrow checker verifies disjointness.
227///
228/// The view covers: message history mutation, deferred summary queues, context-manager
229/// compaction state, provider handles for LLM calls, memory persistence for flushing,
230/// subgoal registry for context-compression strategies, and background task handles for
231/// non-blocking goal/subgoal extraction.
232pub struct ContextSummarizationView<'a> {
233    // ── Message window ────────────────────────────────────────────────────────
234    /// Full conversation history. Mutated by pruning, compaction, and deferred summary
235    /// application.
236    pub messages: &'a mut Vec<Message>,
237    /// `SQLite` row IDs to be soft-deleted after deferred summaries are applied.
238    pub deferred_db_hide_ids: &'a mut Vec<i64>,
239    /// Summary strings paired with the hide IDs above — flushed to `SQLite` as a batch.
240    pub deferred_db_summaries: &'a mut Vec<String>,
241    /// Running token count for the current prompt window. Updated after every mutation
242    /// that changes message content.
243    pub cached_prompt_tokens: &'a mut u64,
244
245    // ── Context manager ───────────────────────────────────────────────────────
246    /// Full context manager — contains compaction state, thresholds, strategy config.
247    pub context_manager: &'a mut ContextManager,
248
249    // ── Runtime ───────────────────────────────────────────────────────────────
250    /// Whether server-side compaction is currently active (skip client compaction when
251    /// true, unless context has grown past the safety fallback threshold).
252    pub server_compaction_active: bool,
253    /// Token counter used for budget calculations and prompt recomputation.
254    pub token_counter: Arc<TokenCounter>,
255    /// Pre-built summarization deps (provider + timeout + `token_counter` + callbacks).
256    /// Built by the `zeph-core` shim from `build_summarization_deps()` before constructing
257    /// the view, so the view does not need to hold a raw `DebugDumper` reference.
258    pub summarization_deps: SummarizationDeps,
259    /// Background task supervisor for spawning non-blocking goal/subgoal extractions.
260    pub task_supervisor: Arc<TaskSupervisor>,
261
262    // ── Memory persistence ────────────────────────────────────────────────────
263    /// Semantic memory store — used to flush deferred summaries and store session digests.
264    pub memory: Option<Arc<SemanticMemory>>,
265    /// Conversation ID for all SQLite/Qdrant persistence calls.
266    pub conversation_id: Option<ConversationId>,
267    /// Maximum unsummarized tool-call pairs before forced deferred summarization kicks in.
268    pub tool_call_cutoff: usize,
269
270    // ── Context-compression (SubgoalRegistry + task handles) ─────────────────
271    /// In-memory registry of all subgoals in the current session.
272    pub subgoal_registry: &'a mut SubgoalRegistry,
273    /// Handle to the background task-goal extraction spawned last turn.
274    pub pending_task_goal: &'a mut Option<BlockingHandle<Option<String>>>,
275    /// Handle to the background subgoal extraction spawned last turn.
276    pub pending_subgoal: &'a mut Option<BlockingHandle<Option<SubgoalExtractionResult>>>,
277    /// Cached task goal for `TaskAware`/`MIG` pruning. `None` before first extraction.
278    pub current_task_goal: &'a mut Option<String>,
279    /// Hash of the last user message when `current_task_goal` was populated.
280    /// Used to detect when a new extraction is needed.
281    pub task_goal_user_msg_hash: &'a mut Option<u64>,
282    /// Hash of the last user message when subgoal extraction was scheduled.
283    pub subgoal_user_msg_hash: &'a mut Option<u64>,
284    /// TUI / channel status sender for spinner messages. `None` when TUI is disabled.
285    pub status_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
286
287    // ── Credential scrubber ───────────────────────────────────────────────────
288    /// Function pointer for scrubbing credentials from summary text.
289    ///
290    /// Set to `crate::redact::scrub_content` by the `zeph-core` shim when
291    /// `redact_credentials = true`, or to a no-op identity function otherwise.
292    pub scrub: fn(&str) -> Cow<'_, str>,
293
294    // ── Compaction callbacks (populated by zeph-core shim) ────────────────────
295    /// Compression guidelines text loaded from `SQLite` by the `zeph-core` shim.
296    ///
297    /// `None` when the feature is disabled or the caller does not load guidelines.
298    /// The service passes the contained string (or `""`) to `summarize_with_llm`. Closes #3528.
299    ///
300    /// Set via [`ContextSummarizationView::with_compression_guidelines`]. Both the reactive
301    /// (`compact_context`) and proactive (`maybe_proactive_compress`) paths populate this field.
302    pub compression_guidelines: Option<String>,
303
304    /// Optional probe-validation callback. When `Some`, the service invokes it after LLM
305    /// summarization and before draining/reinsert. See [`CompactionProbeCallback`] for the
306    /// full implementor contract.
307    pub probe: Option<&'a mut dyn CompactionProbeCallback>,
308
309    /// Optional pre-summary archive hook (Memex #2432). The service calls `archive(to_compact)`
310    /// BEFORE summarization and appends the returned reference list as a postfix AFTER the
311    /// LLM call so the LLM cannot destroy the `[archived:UUID]` markers.
312    pub archive: Option<&'a dyn ToolOutputArchive>,
313
314    /// Optional persistence completion callback. The service calls `after_compaction` once
315    /// the in-memory drain+reinsert is finalized. The optional Qdrant future returned by the
316    /// callback is bubbled back through [`CompactionOutcome::Compacted::qdrant_future`].
317    pub persistence: Option<&'a dyn CompactionPersistence>,
318
319    /// Metrics sink for compaction-related counter increments. Used for
320    /// `compaction_hard_count`, `tool_output_prunes`, and the four probe-outcome counters.
321    /// Closes #3527.
322    pub metrics: Option<&'a dyn MetricsCallback>,
323}
324
325impl ContextSummarizationView<'_> {
326    /// Set the compression guidelines text.
327    ///
328    /// Call this on the view returned by `Agent::summarization_view()` before passing it to
329    /// `ContextService::compact_context`. Using a builder method keeps construction uniform
330    /// and avoids direct field mutation.
331    #[must_use]
332    pub fn with_compression_guidelines(mut self, guidelines: Option<String>) -> Self {
333        self.compression_guidelines = guidelines;
334        self
335    }
336}
337
338/// Bundle of LLM provider handles needed for async context operations.
339///
340/// Each handle is an `Arc`-backed clone, suitable for moving into spawned tasks
341/// or passing across async boundaries.
342pub struct ProviderHandles {
343    /// Primary LLM provider used for completions and compaction.
344    pub primary: AnyProvider,
345    /// Dedicated embedding provider.
346    pub embedding: AnyProvider,
347}
348
349/// Abstract status sink for emitting short progress strings to the channel.
350///
351/// Implemented in `zeph-core` by a stack-local adapter wrapping `Channel::send_status`.
352/// Using a trait keeps this crate free of the `Channel` trait from `zeph-core`.
353pub trait StatusSink: Send + Sync {
354    /// Send a short status string to the active channel.
355    fn send_status(&self, msg: &str) -> impl Future<Output = ()> + Send + '_;
356}
357
358/// Abstract gate for applying a skill trust level to the tool executor.
359///
360/// Implemented in `zeph-core` by a thin adapter over `Arc<dyn ErasedToolExecutor>`.
361/// Using a trait keeps this crate free of the tool executor abstraction.
362pub trait TrustGate: Send + Sync {
363    /// Apply the given trust level to the underlying tool executor.
364    fn set_effective_trust(&self, level: zeph_common::SkillTrustLevel);
365}
366
367/// Boxed `'static` future for the off-thread Qdrant session-summary write.
368///
369/// Returned from [`CompactionPersistence::after_compaction`] and bubbled back through
370/// [`CompactionOutcome::Compacted`] / [`CompactionOutcome::CompactedWithPersistError`].
371/// The caller (shim in `zeph-core`) dispatches this through `BackgroundSupervisor::spawn_summarization`.
372/// The future must return `bool` (`false` = success, `true` = error) to match the supervisor API.
373pub type QdrantPersistFuture = Pin<Box<dyn Future<Output = bool> + Send + 'static>>;
374
375/// Return type from `compact_context()` that distinguishes between successful compaction,
376/// probe rejection, and no-op.
377///
378/// Gives `maybe_compact()` enough information to handle probe rejection without triggering
379/// the `Exhausted` state — which would only be correct if summarization itself is stuck.
380#[must_use]
381pub enum CompactionOutcome {
382    /// Messages were drained and replaced with a summary. `SQLite` persistence succeeded.
383    ///
384    /// `qdrant_future` is an optional `'static` future for the off-thread Qdrant write;
385    /// the shim must dispatch it through `BackgroundSupervisor::spawn_summarization` and
386    /// must not await it inline.
387    Compacted {
388        /// Optional Qdrant write future to dispatch via the supervisor.
389        qdrant_future: Option<QdrantPersistFuture>,
390    },
391    /// Messages were drained and replaced with a summary, but synchronous `SQLite` persistence
392    /// reported failure. The in-memory state is correct; only persistence failed.
393    CompactedWithPersistError {
394        /// Optional Qdrant write future to dispatch via the supervisor.
395        qdrant_future: Option<QdrantPersistFuture>,
396    },
397    /// Probe rejected the summary — original messages are preserved.
398    /// Caller must NOT check `freed_tokens` or transition to `Exhausted`.
399    ProbeRejected,
400    /// No compaction was performed (too few messages, empty `to_compact`, etc.).
401    NoChange,
402}
403
404impl PartialEq for CompactionOutcome {
405    fn eq(&self, other: &Self) -> bool {
406        // Compare variants only; qdrant_future is not comparable (it is a dyn Future).
407        matches!(
408            (self, other),
409            (Self::Compacted { .. }, Self::Compacted { .. })
410                | (
411                    Self::CompactedWithPersistError { .. },
412                    Self::CompactedWithPersistError { .. }
413                )
414                | (Self::ProbeRejected, Self::ProbeRejected)
415                | (Self::NoChange, Self::NoChange)
416        )
417    }
418}
419
420impl std::fmt::Debug for CompactionOutcome {
421    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
422        match self {
423            Self::Compacted { qdrant_future } => f
424                .debug_struct("Compacted")
425                .field("qdrant_future", &qdrant_future.as_ref().map(|_| "<future>"))
426                .finish(),
427            Self::CompactedWithPersistError { qdrant_future } => f
428                .debug_struct("CompactedWithPersistError")
429                .field("qdrant_future", &qdrant_future.as_ref().map(|_| "<future>"))
430                .finish(),
431            Self::ProbeRejected => write!(f, "ProbeRejected"),
432            Self::NoChange => write!(f, "NoChange"),
433        }
434    }
435}
436
437impl CompactionOutcome {
438    /// Remove and return the Qdrant persistence future embedded in `Compacted` or
439    /// `CompactedWithPersistError` variants. Returns `None` for `ProbeRejected` / `NoChange`.
440    ///
441    /// The shim calls this immediately after the service returns and dispatches the
442    /// future through `BackgroundSupervisor::spawn_summarization`.
443    pub fn qdrant_future_take(&mut self) -> Option<QdrantPersistFuture> {
444        match self {
445            Self::Compacted { qdrant_future }
446            | Self::CompactedWithPersistError { qdrant_future } => qdrant_future.take(),
447            _ => None,
448        }
449    }
450
451    /// Returns `true` when compaction succeeded (either variant of `Compacted`).
452    #[must_use]
453    pub fn is_compacted(&self) -> bool {
454        matches!(
455            self,
456            Self::Compacted { .. } | Self::CompactedWithPersistError { .. }
457        )
458    }
459}
460
461/// Verdict returned by a [`CompactionProbeCallback`] after evaluating a candidate summary.
462///
463/// The implementor — not the service — is responsible for routing the verdict-specific data
464/// (score, `category_scores`, thresholds) through [`MetricsCallback`] and for calling
465/// `dump_compaction_probe` before returning.
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum ProbeOutcome {
468    /// Probe accepted the summary; pipeline continues normally.
469    Pass,
470    /// Probe soft-rejected; pipeline continues but the summary is flagged as borderline.
471    SoftFail,
472    /// Probe hard-rejected; service must abort and return [`CompactionOutcome::ProbeRejected`].
473    HardFail,
474}
475
476/// Probe-validation callback invoked by `ContextService::compact_context` after the LLM
477/// produces a candidate summary.
478///
479/// # Contract (mandatory)
480///
481/// Implementations MUST, before returning:
482/// 1. Call `dump_compaction_probe(result)` if a debug dumper is configured.
483/// 2. Update verdict-specific metric counters via the appropriate
484///    `MetricsCallback::record_compaction_probe_*` method. The score, `category_scores`,
485///    threshold, and `hard_fail_threshold` travel through the metrics adapter and are not
486///    part of the `ProbeOutcome` payload.
487/// 3. On internal validation error (`validate_compaction` returns `Err`), call
488///    `MetricsCallback::record_compaction_probe_error()` and return `ProbeOutcome::Pass`.
489///    An error must not abort compaction.
490///
491/// The service treats the returned `ProbeOutcome` exclusively as routing:
492/// `HardFail` → abort with `ProbeRejected`; `Pass | SoftFail` → continue.
493pub trait CompactionProbeCallback: Send {
494    /// Validate the candidate `summary` produced from `to_compact` messages.
495    fn validate<'a>(
496        &'a mut self,
497        to_compact: &'a [Message],
498        summary: &'a str,
499    ) -> Pin<Box<dyn Future<Output = ProbeOutcome> + Send + 'a>>;
500}
501
502/// Pre-summary tool-output archiving hook (Memex #2432).
503///
504/// The service calls `archive(to_compact)` BEFORE summarization. The returned reference
505/// strings are appended as a postfix AFTER the LLM summary to prevent the LLM from
506/// destroying the `[archived:UUID]` markers.
507pub trait ToolOutputArchive: Send + Sync {
508    /// Archive tool output bodies from `to_compact` and return reference strings.
509    ///
510    /// Returns an empty `Vec` when archiving is disabled or no bodies are archived.
511    fn archive<'a>(
512        &'a self,
513        to_compact: &'a [Message],
514    ) -> Pin<Box<dyn Future<Output = Vec<String>> + Send + 'a>>;
515}
516
517/// Persistence completion hook invoked after the in-memory drain/reinsert is finalized.
518///
519/// Returns:
520/// - `persist_failed`: whether the synchronous `SQLite` persistence step failed.
521/// - `qdrant_future`: optional `'static` future for the off-thread Qdrant write, bubbled
522///   back to the caller via [`CompactionOutcome::Compacted::qdrant_future`].
523pub trait CompactionPersistence: Send + Sync {
524    /// Persist the compaction result and return the Qdrant write future.
525    fn after_compaction<'a>(
526        &'a self,
527        compacted_count: usize,
528        summary_content: &'a str,
529        summary: &'a str,
530    ) -> Pin<Box<dyn Future<Output = (bool, Option<QdrantPersistFuture>)> + Send + 'a>>;
531}
532
533/// Metrics-counter sink for `ContextService` increments.
534///
535/// Implemented in `zeph-core` by an adapter wrapping `Arc<MetricsCollector>`. Keeps
536/// `zeph-agent-context` free of `zeph-core` internal metrics types. Closes #3527.
537///
538/// All four `record_compaction_probe_*` methods are called from inside the
539/// [`CompactionProbeCallback`] implementation — not from the service itself — per the
540/// probe-callback contract.
541pub trait MetricsCallback: Send + Sync {
542    /// Record that a hard-compaction event occurred.
543    ///
544    /// `turns_since_last` is `None` on the first hard compaction of the session.
545    fn record_hard_compaction(&self, turns_since_last: Option<u32>);
546
547    /// Record that tool outputs were pruned.
548    ///
549    /// `count` is the number of tool-output bodies pruned in this pass.
550    fn record_tool_output_prune(&self, count: usize);
551
552    /// Record a probe pass verdict with full score data.
553    fn record_compaction_probe_pass(
554        &self,
555        score: f32,
556        category_scores: Vec<zeph_memory::CategoryScore>,
557        threshold: f32,
558        hard_fail_threshold: f32,
559    );
560
561    /// Record a probe soft-fail verdict with full score data.
562    fn record_compaction_probe_soft_fail(
563        &self,
564        score: f32,
565        category_scores: Vec<zeph_memory::CategoryScore>,
566        threshold: f32,
567        hard_fail_threshold: f32,
568    );
569
570    /// Record a probe hard-fail verdict with full score data.
571    fn record_compaction_probe_hard_fail(
572        &self,
573        score: f32,
574        category_scores: Vec<zeph_memory::CategoryScore>,
575        threshold: f32,
576        hard_fail_threshold: f32,
577    );
578
579    /// Record that the probe returned an error (non-fatal; compaction proceeded).
580    fn record_compaction_probe_error(&self);
581}