diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a7a3bc27..f222f65f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Added +- **Memory**: added MemGuard-inspired type-aware retrieval composition (`[memory.type_aware_compose]`, + spec 064, #6086). Retrieval-only, fetch-time gate on `schedule_context_fetchers`: a new + `FunctionalType` enum (`episodic` / `user_fact` / `behavioral_rule` / `reasoning_strategy` / + `cross_session_summary` / `graph_fact`) names each functional memory source composed during + context assembly. When `enabled = true`, only the types in `default_compose_types` (optionally + widened per classified query intent via `intent_scoped`, reusing the existing heuristic memory + router — no new LLM call) are fetched; an unrequested type is not retrieved at all, so the + cost is genuinely avoided, not just hidden from injection. Past-correction recall + (`behavioral_rule`) stays always-composed regardless of the active set — safety-critical, + never gated. No new Qdrant collection, no write-path or stored-data change; `enabled = false` + (the default) and an empty `default_compose_types` are both byte-for-byte no-ops identical to + pre-#6086 behaviour. Config-only `--init` wizard prompt and `--migrate-config` step added. - **CLI**: added `--safe-mode` (and `ZEPH_SAFE_MODE` environment variable) — starts a session with `ZEPH.md`/`CLAUDE.md`/`AGENTS.md` project instructions, plugins, skills, hooks, and MCP servers all disabled at once, so a user can quickly confirm whether one of those diff --git a/config/default.toml b/config/default.toml index 92e09d08c..0b7ad3aac 100644 --- a/config/default.toml +++ b/config/default.toml @@ -1468,6 +1468,17 @@ sweep_batch_size = 100 # self_judge_window = 2 # max recent messages to self-judge evaluator (#3383) # min_assistant_chars = 50 # skip self-judge for short replies (#3383) # +# [memory.type_aware_compose] +# # MemGuard-inspired type-aware retrieval composition — off by default (#6086) +# # Retrieval-only: no new Qdrant collection, no write-path or stored-data change. +# enabled = false +# # Functional types composed under an un-specialized retrieval need. Empty = all types. +# # Values: "episodic" | "user_fact" | "behavioral_rule" | "reasoning_strategy" +# # | "cross_session_summary" | "graph_fact". Unknown strings are a hard config error. +# default_compose_types = [] +# # Widen the active set per classified query intent (no new LLM call; reuses HeuristicRouter). +# intent_scoped = false +# # [learning] # feedback_provider = "fast" # SLM: three-class classification # diff --git a/crates/zeph-agent-context/src/lib.rs b/crates/zeph-agent-context/src/lib.rs index 0d20fac3d..e6dc02047 100644 --- a/crates/zeph-agent-context/src/lib.rs +++ b/crates/zeph-agent-context/src/lib.rs @@ -45,6 +45,7 @@ pub mod retrieved; pub mod service; pub mod state; pub mod summarization; +pub mod type_aware_compose; pub use compaction::{ BlockScore, ContentDensity, SubgoalExtractionResult, SubgoalId, SubgoalRegistry, SubgoalState, diff --git a/crates/zeph-agent-context/src/service.rs b/crates/zeph-agent-context/src/service.rs index fe4028334..adb6b3ac6 100644 --- a/crates/zeph-agent-context/src/service.rs +++ b/crates/zeph-agent-context/src/service.rs @@ -657,6 +657,14 @@ impl ContextService { let router = crate::memory_backend::build_memory_router(view.context_manager); + // Type-aware retrieval composition (spec 064, #6086): resolve once per turn from + // config; `enabled = false` (default) resolves to an empty slice, which + // `schedule_context_fetchers` treats identically to today's unfiltered composition. + let active_types = crate::type_aware_compose::resolve_active_functional_types( + &view.type_aware_compose_config, + query, + ); + let input = zeph_context::input::ContextAssemblyInput { memory: &memory_view, context_manager: view.context_manager, @@ -669,6 +677,7 @@ impl ContextService { query, scrub: view.scrub, active_levels, + active_types: &active_types, router, planned_next_tools: view.planned_next_tools, }; @@ -2029,6 +2038,7 @@ mod tests { }, tiered_retrieval_classifier: None, tiered_retrieval_validator: None, + type_aware_compose_config: zeph_config::memory::TypeAwareComposeConfig::default(), fidelity_config: None, fidelity_semantic_provider: None, fidelity_compress_provider: None, @@ -2192,6 +2202,7 @@ mod tests { }, tiered_retrieval_classifier: None, tiered_retrieval_validator: None, + type_aware_compose_config: zeph_config::memory::TypeAwareComposeConfig::default(), fidelity_config: None, fidelity_semantic_provider: None, fidelity_compress_provider: None, @@ -2273,6 +2284,7 @@ mod tests { }, tiered_retrieval_classifier: None, tiered_retrieval_validator: None, + type_aware_compose_config: zeph_config::memory::TypeAwareComposeConfig::default(), fidelity_config: None, fidelity_semantic_provider: None, fidelity_compress_provider: None, @@ -2361,6 +2373,7 @@ mod tests { }, tiered_retrieval_classifier: None, tiered_retrieval_validator: None, + type_aware_compose_config: zeph_config::memory::TypeAwareComposeConfig::default(), fidelity_config: None, fidelity_semantic_provider: None, fidelity_compress_provider: None, @@ -2600,6 +2613,7 @@ mod tests { }, tiered_retrieval_classifier: None, tiered_retrieval_validator: None, + type_aware_compose_config: zeph_config::memory::TypeAwareComposeConfig::default(), fidelity_config: None, fidelity_semantic_provider: None, fidelity_compress_provider: None, diff --git a/crates/zeph-agent-context/src/state.rs b/crates/zeph-agent-context/src/state.rs index 87854cf93..264cc6523 100644 --- a/crates/zeph-agent-context/src/state.rs +++ b/crates/zeph-agent-context/src/state.rs @@ -231,6 +231,16 @@ pub struct ContextAssemblyView<'a> { /// `None` means validation is skipped (evidence accepted as-is). pub tiered_retrieval_validator: Option>, + // ── MemGuard type-aware retrieval composition (spec 064, #6086) ─────────────────── + /// Type-aware retrieval composition configuration (`[memory.type_aware_compose]`). + /// + /// When `enabled = true`, `schedule_context_fetchers` composes only the functional memory + /// types in the active set (resolved from `default_compose_types` and, when + /// `intent_scoped`, a static per-intent widening) instead of every source unconditionally. + /// Retrieval-only: no write-path or storage change. `enabled = false` (default) is a + /// byte-for-byte no-op. + pub type_aware_compose_config: zeph_config::memory::TypeAwareComposeConfig, + // ── CAM: Context-Adaptive Memory (#4547) ───────────────────────────────── /// Fidelity scoring configuration resolved from `[memory.fidelity]`. /// diff --git a/crates/zeph-agent-context/src/type_aware_compose.rs b/crates/zeph-agent-context/src/type_aware_compose.rs new file mode 100644 index 000000000..e30ddf73c --- /dev/null +++ b/crates/zeph-agent-context/src/type_aware_compose.rs @@ -0,0 +1,152 @@ +// SPDX-FileCopyrightText: 2026 Andrei G +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! Active-set resolution for MemGuard-inspired type-aware retrieval composition (spec 064, +//! issue #6086). +//! +//! This module resolves the [`zeph_common::memory::FunctionalType`] set that +//! `zeph_context::assembler::schedule_context_fetchers` gates on, from +//! [`zeph_config::memory::TypeAwareComposeConfig`]. It never touches storage or write paths — +//! retrieval-only, fetch-time composition. + +use zeph_common::memory::{FunctionalType, MemoryRoute, MemoryRouter}; +use zeph_config::memory::TypeAwareComposeConfig; +use zeph_memory::{HeuristicRouter, IntentClass}; + +/// Static `IntentClass -> FunctionalType[]` widening table (spec 064 §3 Q3). +/// +/// Used only when `intent_scoped = true`: it *adds* types to an already-resolved active set, +/// it never narrows. `IntentClass` is `#[non_exhaustive]`, so an unrecognised future variant +/// falls back to widening with nothing (conservative: no accidental over-composition). +fn intent_functional_types(intent: IntentClass) -> &'static [FunctionalType] { + match intent { + IntentClass::ProfileLookup => &[FunctionalType::UserFact], + IntentClass::TargetedRetrieval => &[ + FunctionalType::Episodic, + FunctionalType::UserFact, + FunctionalType::CrossSessionSummary, + FunctionalType::GraphFact, + ], + IntentClass::DeepReasoning => &[ + FunctionalType::Episodic, + FunctionalType::ReasoningStrategy, + FunctionalType::CrossSessionSummary, + FunctionalType::GraphFact, + ], + _ => &[], + } +} + +/// Resolve the active `FunctionalType` set for the current turn. +/// +/// Returns an empty `Vec` when `config.enabled` is `false` or when `default_compose_types` +/// is empty and `intent_scoped` is `false` — both cases mean "no type gating", which +/// `schedule_context_fetchers` treats identically to today's unfiltered composition +/// (spec 064 edge cases: `enabled = false` and empty `default_compose_types` are the same +/// no-op code path). +/// +/// `intent_scoped = true` uses [`HeuristicRouter`] — a pure, synchronous, no-I/O function of +/// `query` — to classify the query into an [`IntentClass`] and widen the set via the static +/// table above. This adds no new LLM call (spec 064 §5 Multi-Model note): it reuses the same +/// heuristic router `MemFlow` tiered retrieval already uses for its no-LLM fallback path. +#[must_use] +pub fn resolve_active_functional_types( + config: &TypeAwareComposeConfig, + query: &str, +) -> Vec { + if !config.enabled { + return Vec::new(); + } + + let mut active = config.default_compose_types.clone(); + + if config.intent_scoped { + let route: MemoryRoute = HeuristicRouter.route(query); + let intent = IntentClass::from_route(route); + for t in intent_functional_types(intent) { + if !active.contains(t) { + active.push(*t); + } + } + } + + tracing::debug!( + enabled = config.enabled, + intent_scoped = config.intent_scoped, + ?active, + "type-aware compose: resolved active set" + ); + + active +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn disabled_config_resolves_to_empty_set() { + let config = TypeAwareComposeConfig { + enabled: false, + default_compose_types: vec![FunctionalType::UserFact], + intent_scoped: true, + }; + assert!(resolve_active_functional_types(&config, "anything").is_empty()); + } + + #[test] + fn enabled_with_empty_default_and_no_intent_scoping_resolves_to_empty_set() { + let config = TypeAwareComposeConfig { + enabled: true, + default_compose_types: Vec::new(), + intent_scoped: false, + }; + assert!(resolve_active_functional_types(&config, "anything").is_empty()); + } + + #[test] + fn enabled_with_default_types_and_no_intent_scoping_returns_default_types() { + let config = TypeAwareComposeConfig { + enabled: true, + default_compose_types: vec![FunctionalType::UserFact], + intent_scoped: false, + }; + let active = resolve_active_functional_types(&config, "what is my name"); + assert_eq!(active, vec![FunctionalType::UserFact]); + } + + #[test] + fn intent_scoped_widens_default_set_without_duplicates() { + let config = TypeAwareComposeConfig { + enabled: true, + default_compose_types: vec![FunctionalType::UserFact], + intent_scoped: true, + }; + // A graph-style query routes to IntentClass::DeepReasoning via HeuristicRouter, which + // widens with Episodic/ReasoningStrategy/CrossSessionSummary/GraphFact. + let active = resolve_active_functional_types(&config, "why did the deploy fail?"); + assert!(active.contains(&FunctionalType::UserFact)); + // UserFact must appear exactly once even though the widening table for some intents + // could otherwise duplicate an already-present type. + assert_eq!( + active + .iter() + .filter(|t| **t == FunctionalType::UserFact) + .count(), + 1 + ); + } + + #[test] + fn intent_functional_types_never_include_behavioral_rule() { + // BehavioralRule is always-on/ungated (fetch_corrections) — the widening table must + // never need to name it, since it is composed regardless of the active set. + for intent in [ + IntentClass::ProfileLookup, + IntentClass::TargetedRetrieval, + IntentClass::DeepReasoning, + ] { + assert!(!intent_functional_types(intent).contains(&FunctionalType::BehavioralRule)); + } + } +} diff --git a/crates/zeph-common/src/memory.rs b/crates/zeph-common/src/memory.rs index f4cc89a68..f47f3b7af 100644 --- a/crates/zeph-common/src/memory.rs +++ b/crates/zeph-common/src/memory.rs @@ -330,6 +330,95 @@ impl FromStr for EdgeType { } } +// ── FunctionalType ──────────────────────────────────────────────────────────── + +/// MemGuard-inspired functional-role classification of a memory source (spec 064, #6086). +/// +/// Each variant names one of the memory sources composed during context assembly +/// (`schedule_context_fetchers` in `zeph-context`) — not a storage tier +/// ([`CompressionLevel`]) and not a routing backend ([`MemoryRoute`]). The two axes are +/// orthogonal: a `zeph_conversations` vector is `Episodic`-tier *and* the `Episodic` +/// functional type, while a `Semantic`-tier consolidated fact lives under the `UserFact` +/// functional type. Placed here (rather than in `zeph-memory`) because `zeph-context` — the +/// crate that gates fetchers by this type — deliberately has no `zeph-memory` dependency +/// (see the module doc above and issue #3665); `zeph-memory` re-exports this type at its +/// crate root for taxonomy discoverability. +/// +/// `#[non_exhaustive]`: additional functional sources may be added later without a breaking +/// change; an unrecognised variant is always-composed until explicitly gated (never silently +/// dropped). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum FunctionalType { + /// Raw episodic conversation recall (`fetch_semantic_recall` → `zeph_conversations`). + Episodic, + /// User preference/attribute facts (`fetch_persona_facts` → SQL `persona_memory`, + /// **not** the `zeph_key_facts` collection — that surface is out of scope, see spec 064 §4). + UserFact, + /// Past user corrections (`fetch_corrections` → `zeph_corrections`). + /// + /// Safety-critical: this type is never gated out by type-aware composition — context + /// assembly always schedules `fetch_corrections` regardless of the active set. + BehavioralRule, + /// Distilled `ReasoningBank` strategies (`fetch_reasoning_strategies` → `reasoning_strategies`). + ReasoningStrategy, + /// Cross-session summaries (`fetch_summaries` / `fetch_cross_session` → `zeph_session_summaries`). + CrossSessionSummary, + /// Knowledge graph facts (`fetch_graph_facts` → `zeph_graph_entities`). + GraphFact, +} + +impl FunctionalType { + /// Return the canonical lowercase string for this functional type. + /// + /// # Examples + /// + /// ``` + /// use zeph_common::memory::FunctionalType; + /// + /// assert_eq!(FunctionalType::UserFact.as_str(), "user_fact"); + /// assert_eq!(FunctionalType::BehavioralRule.as_str(), "behavioral_rule"); + /// ``` + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Episodic => "episodic", + Self::UserFact => "user_fact", + Self::BehavioralRule => "behavioral_rule", + Self::ReasoningStrategy => "reasoning_strategy", + Self::CrossSessionSummary => "cross_session_summary", + Self::GraphFact => "graph_fact", + } + } +} + +impl fmt::Display for FunctionalType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.pad(self.as_str()) + } +} + +impl FromStr for FunctionalType { + type Err = String; + + /// Strict parse: an unrecognised string is a hard error, never a silent fallback. + /// + /// This is deliberate (spec 064 §4, critic finding S4): a config typo in + /// `default_compose_types` must fail config load, not silently widen to "all types". + fn from_str(s: &str) -> Result { + match s { + "episodic" => Ok(Self::Episodic), + "user_fact" => Ok(Self::UserFact), + "behavioral_rule" => Ok(Self::BehavioralRule), + "reasoning_strategy" => Ok(Self::ReasoningStrategy), + "cross_session_summary" => Ok(Self::CrossSessionSummary), + "graph_fact" => Ok(Self::GraphFact), + other => Err(format!("unknown functional memory type: {other}")), + } + } +} + // ── Marker constants ────────────────────────────────────────────────────────── /// MAGMA causal edge markers used by `classify_graph_subgraph`. @@ -761,7 +850,8 @@ pub trait ContextMemoryBackend: Send + Sync { #[cfg(test)] mod tests { - use super::{EdgeType, MemoryRoute}; + use super::{EdgeType, FunctionalType, MemoryRoute}; + use std::str::FromStr; /// Locks in the `f.pad` fix (#6066): `f.write_str` ignores width/fill/align flags. /// `f.pad` must reproduce the same padding a plain `&str` would get under an @@ -800,4 +890,62 @@ mod tests { fn memory_route_default_is_hybrid() { assert_eq!(MemoryRoute::default(), MemoryRoute::Hybrid); } + + #[test] + fn functional_type_from_str_round_trips_every_variant() { + let all = [ + FunctionalType::Episodic, + FunctionalType::UserFact, + FunctionalType::BehavioralRule, + FunctionalType::ReasoningStrategy, + FunctionalType::CrossSessionSummary, + FunctionalType::GraphFact, + ]; + for variant in all { + assert_eq!(FunctionalType::from_str(variant.as_str()), Ok(variant)); + } + } + + #[test] + fn functional_type_from_str_rejects_unknown_string() { + // S4: unknown/typo'd type strings must fail closed, not silently widen. + assert!(FunctionalType::from_str("user_facts").is_err()); + assert!(FunctionalType::from_str("").is_err()); + } + + #[test] + fn functional_type_serde_roundtrip() { + let cases = [ + ("\"episodic\"", FunctionalType::Episodic), + ("\"user_fact\"", FunctionalType::UserFact), + ("\"behavioral_rule\"", FunctionalType::BehavioralRule), + ("\"reasoning_strategy\"", FunctionalType::ReasoningStrategy), + ( + "\"cross_session_summary\"", + FunctionalType::CrossSessionSummary, + ), + ("\"graph_fact\"", FunctionalType::GraphFact), + ]; + for (json_str, expected) in cases { + let got: FunctionalType = serde_json::from_str(json_str).unwrap(); + assert_eq!(got, expected); + let serialized = serde_json::to_string(&got).unwrap(); + let roundtrip: FunctionalType = serde_json::from_str(&serialized).unwrap(); + assert_eq!(roundtrip, expected); + } + } + + #[test] + fn functional_type_serde_rejects_unknown_variant() { + let result: Result = serde_json::from_str("\"user_facts\""); + assert!(result.is_err()); + } + + #[test] + fn functional_type_display_respects_width() { + assert_eq!( + format!("{:<12}", FunctionalType::UserFact), + format!("{:<12}", "user_fact") + ); + } } diff --git a/crates/zeph-config/src/memory/retrieval.rs b/crates/zeph-config/src/memory/retrieval.rs index 73b7a173f..3c5ec2f4d 100644 --- a/crates/zeph-config/src/memory/retrieval.rs +++ b/crates/zeph-config/src/memory/retrieval.rs @@ -8,7 +8,7 @@ use crate::providers::ProviderName; use serde::{Deserialize, Serialize}; -use zeph_common::memory::MemoryRoute; +use zeph_common::memory::{FunctionalType, MemoryRoute}; use super::default_embed_timeout_secs; @@ -293,6 +293,46 @@ impl Default for TieredRetrievalConfig { } } +// ── MemGuard type-aware retrieval-composition config (spec 064, issue #6086) ────────────────── + +/// `MemGuard`-inspired type-aware memory retrieval composition (spec 064, issue #6086). +/// +/// Retrieval-only, fetch-time gate: it does **not** touch write paths, does not add a new +/// Qdrant collection, and does not migrate stored data. When `enabled = false` (the default), +/// `schedule_context_fetchers` in `zeph-context` composes exactly the same memory sources it +/// does today — the empty active-type set produced by `enabled = false` is treated identically +/// to an empty `default_compose_types`, both meaning "all types" (byte-for-byte no-op). +/// +/// `BehavioralRule` (past-correction) recall is deliberately excluded from gating: it is +/// safety-critical and is always composed regardless of this config. +/// +/// # Example (TOML) +/// +/// ```toml +/// [memory.type_aware_compose] +/// enabled = false +/// default_compose_types = [] +/// intent_scoped = false +/// ``` +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(default)] +pub struct TypeAwareComposeConfig { + /// Master switch. When `false` (default), context assembly composes all memory types + /// exactly as today. When `true`, only the types in the active set (`default_compose_types` + /// plus, when `intent_scoped`, the classified-intent widening) are composed. + pub enabled: bool, + /// Functional types composed under an un-specialized retrieval need. + /// + /// Empty (default) means *all types* — the same composition as today. Strict parse: + /// an unknown/typo'd type string is a hard config-load error, never a silent fallback + /// to "all types" (spec 064 §4, critic finding S4). + pub default_compose_types: Vec, + /// When `true`, additionally widen the active set per classified query intent using the + /// static `IntentClass -> FunctionalType[]` table (reuses the existing heuristic memory + /// router; adds no new LLM call). Default: `false`. + pub intent_scoped: bool, +} + fn default_retrieval_failures_low_confidence_threshold() -> f32 { 0.3 } diff --git a/crates/zeph-config/src/memory/root.rs b/crates/zeph-config/src/memory/root.rs index 17b0c65d3..bbb745f9d 100644 --- a/crates/zeph-config/src/memory/root.rs +++ b/crates/zeph-config/src/memory/root.rs @@ -19,7 +19,7 @@ use super::{ OpticalForgettingConfig, PersonaConfig, ReasoningConfig, RetrievalConfig, RetrievalFailuresConfig, SemanticConfig, SessionsConfig, SidequestConfig, StoreRoutingConfig, TierConfig, TieredRetrievalConfig, TrajectoryConfig, TrajectoryRiskAccumulatorConfig, - TreeConfig, WriteQualityGateConfig, + TreeConfig, TypeAwareComposeConfig, WriteQualityGateConfig, }; fn default_sqlite_pool_size() -> u32 { @@ -455,6 +455,14 @@ pub struct MemoryConfig { /// `DeepReasoning`) with optional validation and tier escalation. #[serde(default)] pub tiered_retrieval: TieredRetrievalConfig, + /// `MemGuard`-inspired type-aware retrieval composition (spec 064, issue #6086). + /// + /// When `type_aware_compose.enabled = true`, `schedule_context_fetchers` composes only the + /// functional memory types in the active set instead of unconditionally injecting all of + /// them. Retrieval-only: no new Qdrant collection, no write-path change. Default: disabled + /// (byte-for-byte no-op). + #[serde(default)] + pub type_aware_compose: TypeAwareComposeConfig, /// `ScrapMem` optical forgetting (issue #3713). /// /// When `optical_forgetting.enabled = true`, a background sweep progressively compresses diff --git a/crates/zeph-config/src/migrate/memory.rs b/crates/zeph-config/src/migrate/memory.rs index 96629a007..9dfe75140 100644 --- a/crates/zeph-config/src/migrate/memory.rs +++ b/crates/zeph-config/src/migrate/memory.rs @@ -801,3 +801,51 @@ pub fn migrate_fidelity_timeout_defaults(toml_src: &str) -> Result Result { + // Idempotency: comments are invisible to toml_edit, so check the raw source. + if section_header_present(toml_src, "memory.type_aware_compose") + || toml_src.contains("# [memory.type_aware_compose]") + { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let doc = toml_src.parse::()?; + if !doc.contains_key("memory") { + return Ok(MigrationResult { + output: toml_src.to_owned(), + changed_count: 0, + sections_changed: Vec::new(), + }); + } + + let comment = "\n# MemGuard-inspired type-aware retrieval composition — off by default (#6086)\n\ + # Retrieval-only: no new Qdrant collection, no write-path or stored-data change.\n\ + # [memory.type_aware_compose]\n\ + # enabled = false\n\ + # default_compose_types = [] # empty = all types; unknown strings are a hard config error\n\ + # intent_scoped = false # widen active set per classified intent (no new LLM call)\n"; + let raw = doc.to_string(); + let output = format!("{raw}{comment}"); + + Ok(MigrationResult { + output, + changed_count: 1, + sections_changed: vec!["memory.type_aware_compose".to_owned()], + }) +} diff --git a/crates/zeph-config/src/migrate/mod.rs b/crates/zeph-config/src/migrate/mod.rs index 8b258b5a7..734a7a918 100644 --- a/crates/zeph-config/src/migrate/mod.rs +++ b/crates/zeph-config/src/migrate/mod.rs @@ -611,8 +611,8 @@ use steps::{ MigrateMemoryGraphRecallIncludeImported, MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread, MigrateMemoryPersonaConfig, MigrateMemoryReasoning, MigrateMemoryReasoningJudge, MigrateMemoryRetrieval, - MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig, MigrateNliConfig, - MigrateOrchestrationAssetSensitivity, MigrateOrchestrationPersistence, + MigrateMemoryRetrievalQueryBias, MigrateMemoryTypeAwareCompose, MigrateMicrocompactConfig, + MigrateNliConfig, MigrateOrchestrationAssetSensitivity, MigrateOrchestrationPersistence, MigrateOrchestratorProvider, MigrateOtelFilter, MigratePiiFilterNames, MigratePlannerModelToProvider, MigratePolicyProviderAndUtilityWindow, MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQdrantTimeoutSecs, @@ -783,6 +783,9 @@ pub static MIGRATIONS: std::sync::LazyLock> // Step 84 — drop the inert require_tls/ssrf_protection keys from an existing // active [a2a] table (#5885) Box::new(MigrateA2aServerRemoveInertFields), + // Step 85 — add [memory.type_aware_compose] advisory block for MemGuard + // type-aware retrieval composition (spec 064, #6086) + Box::new(MigrateMemoryTypeAwareCompose), ] }); diff --git a/crates/zeph-config/src/migrate/steps.rs b/crates/zeph-config/src/migrate/steps.rs index 035b966c0..3c214dd52 100644 --- a/crates/zeph-config/src/migrate/steps.rs +++ b/crates/zeph-config/src/migrate/steps.rs @@ -49,7 +49,9 @@ //! step 83 adds commented `max_worktrees`/`disk_quota_mb`/`auto_reconcile_secs`/ //! `reconcile_on_startup` fields to an existing active `[worktree]` table (#5924); //! step 84 drops the inert `require_tls`/`ssrf_protection` keys from an existing active -//! `[a2a]` table (#5885). +//! `[a2a]` table (#5885); +//! step 85 adds a commented `[memory.type_aware_compose]` advisory block for `MemGuard` +//! type-aware retrieval composition (spec 064, #6086). //! //! Each struct is a zero-size type that delegates to the corresponding free function in //! `super`. They exist solely to satisfy the object-safe [`super::Migration`] trait so the @@ -73,13 +75,14 @@ use super::{ migrate_memory_hebbian_consolidation_config, migrate_memory_hebbian_spread_config, migrate_memory_persona_config, migrate_memory_reasoning_config, migrate_memory_reasoning_judge_config, migrate_memory_retrieval_config, - migrate_memory_retrieval_query_bias, migrate_microcompact_config, migrate_nli_config, - migrate_orchestration_asset_sensitivity, migrate_orchestration_orchestrator_provider, - migrate_orchestration_persistence, migrate_otel_filter, migrate_pii_filter_names, - migrate_planner_model_to_provider, migrate_policy_provider_and_utility_window, - migrate_provider_max_concurrent, migrate_qdrant_api_key, migrate_qdrant_timeout_secs, - migrate_quality_config, migrate_sandbox_config, migrate_sandbox_egress_filter, - migrate_scheduler_daemon_config, migrate_secret_masking_config, migrate_serve_config, + migrate_memory_retrieval_query_bias, migrate_memory_type_aware_compose_config, + migrate_microcompact_config, migrate_nli_config, migrate_orchestration_asset_sensitivity, + migrate_orchestration_orchestrator_provider, migrate_orchestration_persistence, + migrate_otel_filter, migrate_pii_filter_names, migrate_planner_model_to_provider, + migrate_policy_provider_and_utility_window, migrate_provider_max_concurrent, + migrate_qdrant_api_key, migrate_qdrant_timeout_secs, migrate_quality_config, + migrate_sandbox_config, migrate_sandbox_egress_filter, migrate_scheduler_daemon_config, + migrate_secret_masking_config, migrate_serve_config, migrate_session_persist_provider_overrides, migrate_session_persistence_config, migrate_session_provider_persistence, migrate_session_recap_config, migrate_shadow_sentinel_config, migrate_shell_checkpoints_config, migrate_shell_transactional, @@ -1042,3 +1045,16 @@ impl Migration for MigrateA2aServerRemoveInertFields { migrate_a2a_server_remove_inert_fields(toml_src) } } + +/// Step 85 — adds a commented `[memory.type_aware_compose]` advisory block for `MemGuard` +/// type-aware retrieval composition (spec 064, #6086). +pub(super) struct MigrateMemoryTypeAwareCompose; +impl Migration for MigrateMemoryTypeAwareCompose { + fn name(&self) -> &'static str { + "migrate_memory_type_aware_compose_config" + } + + fn apply(&self, toml_src: &str) -> Result { + migrate_memory_type_aware_compose_config(toml_src) + } +} diff --git a/crates/zeph-config/src/migrate/tests.rs b/crates/zeph-config/src/migrate/tests.rs index 5cf036c2f..788a2ab0a 100644 --- a/crates/zeph-config/src/migrate/tests.rs +++ b/crates/zeph-config/src/migrate/tests.rs @@ -9,8 +9,8 @@ use super::*; fn migrations_registry_has_all_steps() { assert_eq!( MIGRATIONS.len(), - 84, - "MIGRATIONS registry must contain all 84 sequential steps" + 85, + "MIGRATIONS registry must contain all 85 sequential steps" ); for m in MIGRATIONS.iter() { assert!( @@ -1082,6 +1082,34 @@ fn migrate_forgetting_config_idempotent_on_commented_output() { assert_eq!(second.output, first.output); } +#[test] +fn migrate_memory_type_aware_compose_config_idempotent_on_commented_output() { + let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n"; + let first = migrate_memory_type_aware_compose_config(base).unwrap(); + assert_eq!(first.changed_count, 1); + assert!(first.output.contains("# [memory.type_aware_compose]")); + assert!(first.output.contains("# enabled = false")); + let second = migrate_memory_type_aware_compose_config(&first.output).unwrap(); + assert_eq!(second.changed_count, 0, "second run must not double-append"); + assert_eq!(second.output, first.output); +} + +#[test] +fn migrate_memory_type_aware_compose_config_noop_when_memory_section_absent() { + let base = "[agent]\nname = \"Zeph\"\n"; + let result = migrate_memory_type_aware_compose_config(base).unwrap(); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, base); +} + +#[test] +fn migrate_memory_type_aware_compose_config_noop_when_active_section_present() { + let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n\n[memory.type_aware_compose]\nenabled = true\n"; + let result = migrate_memory_type_aware_compose_config(base).unwrap(); + assert_eq!(result.changed_count, 0); + assert_eq!(result.output, base); +} + #[test] fn migrate_microcompact_config_idempotent_on_commented_output() { let base = "[memory]\ndb_path = \"~/.zeph/memory.db\"\n"; @@ -1753,7 +1781,7 @@ fn migrate_focus_auto_consolidate_noop_when_only_commented_section() { #[test] fn registry_has_fifty_entries() { - assert_eq!(MIGRATIONS.len(), 84); + assert_eq!(MIGRATIONS.len(), 85); } #[test] @@ -1877,6 +1905,7 @@ fn registry_preserves_order_matches_dispatch() { "migrate_a2a_card_trust_config", "migrate_worktree_quota_fields", "migrate_a2a_server_remove_inert_fields", + "migrate_memory_type_aware_compose_config", ]; let actual: Vec<&str> = MIGRATIONS.iter().map(|m| m.name()).collect(); assert_eq!(actual, expected); diff --git a/crates/zeph-config/src/root.rs b/crates/zeph-config/src/root.rs index dc9be36b0..ebeb4b1a0 100644 --- a/crates/zeph-config/src/root.rs +++ b/crates/zeph-config/src/root.rs @@ -337,6 +337,7 @@ impl Default for Config { retrieval_failures: crate::memory::RetrievalFailuresConfig::default(), quality_gate: crate::memory::WriteQualityGateConfig::default(), tiered_retrieval: crate::memory::TieredRetrievalConfig::default(), + type_aware_compose: crate::memory::TypeAwareComposeConfig::default(), optical_forgetting: crate::memory::OpticalForgettingConfig::default(), em_graph: crate::memory::EmGraphConfig::default(), episodic_consolidation: crate::memory::EpisodicConsolidationConfig::default(), diff --git a/crates/zeph-context/src/assembler.rs b/crates/zeph-context/src/assembler.rs index 822aef72d..90a20f245 100644 --- a/crates/zeph-context/src/assembler.rs +++ b/crates/zeph-context/src/assembler.rs @@ -19,7 +19,9 @@ use std::pin::Pin; use futures::StreamExt as _; use futures::stream::FuturesUnordered; -use zeph_common::memory::{AsyncMemoryRouter, CompressionLevel, GraphRecallParams, TokenCounting}; +use zeph_common::memory::{ + AsyncMemoryRouter, CompressionLevel, FunctionalType, GraphRecallParams, TokenCounting, +}; use zeph_llm::provider::{Message, MessageMetadata, MessagePart, Role}; use crate::error::AssemblerError; @@ -43,6 +45,18 @@ pub(crate) fn levels_to_flags(levels: &[CompressionLevel]) -> (bool, bool, bool) (episodic, procedural, declarative) } +/// Whether `t` is in the active `FunctionalType` set (spec 064, `MemGuard` type-aware retrieval, +/// #6086). +/// +/// An empty `active` slice means "no type filtering": every type is active. This mirrors +/// [`levels_to_flags`]'s empty-slice-is-permissive default and is what makes both +/// `type_aware_compose.enabled = false` and `default_compose_types = []` resolve to today's +/// unfiltered composition — `zeph-agent-context` maps both cases to an empty slice, so this +/// function only needs one code path to be behavior-preserving for either. +pub(crate) fn type_active(active: &[FunctionalType], t: FunctionalType) -> bool { + active.is_empty() || active.contains(&t) +} + /// Prefix for past-session summary injections. pub const SUMMARY_PREFIX: &str = "[conversation summaries]\n"; /// Prefix for cross-session context injections. @@ -150,7 +164,7 @@ fn correction_params(cfg: Option<&crate::input::CorrectionConfig>) -> (usize, f3 /// lifetime `'r` for `router_ref` avoids tying it to `'a` (the input lifetime), which would /// require `router` to outlive `input`. All `usize` budget values are passed by copy so the /// returned futures do not borrow from `alloc`. -#[allow(clippy::too_many_arguments)] +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] fn schedule_context_fetchers<'r>( memory: &'r crate::input::ContextMemoryView, tc: &'r dyn TokenCounting, @@ -166,47 +180,69 @@ fn schedule_context_fetchers<'r>( recall_limit: usize, min_sim: f32, active_levels: &[CompressionLevel], + active_types: &[FunctionalType], ) -> FuturesUnordered> { - // TODO(critic): episodic_active currently gates summaries + cross-session + recall + doc_rag - // together. If future RetrievalPolicy variants ever drop Episodic, the cheap summary fetchers - // will be silently disabled — split into raw vs compressed sub-tiers. (#3455 follow-up) + // episodic_active gates summaries + cross-session + recall + doc_rag together at the + // compression-tier level. If future RetrievalPolicy variants ever drop Episodic, the cheap + // summary fetchers will be silently disabled — split into raw vs compressed sub-tiers + // (#3455 follow-up; unrelated to the FunctionalType gate below). + // + // The semantic-recall vs document-RAG bundling that this TODO originally flagged has been + // split (#6086, spec 064 N2): the FunctionalType::Episodic gate below applies only to the + // semantic-recall push, so doc_rag stays scheduled whenever `episodic_active` is true + // regardless of whether Episodic is in the active functional-type set. let (episodic_active, procedural_active, declarative_active) = levels_to_flags(active_levels); let fetchers: FuturesUnordered> = FuturesUnordered::new(); - if episodic_active && summaries_budget > 0 { + if episodic_active + && summaries_budget > 0 + && type_active(active_types, FunctionalType::CrossSessionSummary) + { fetchers.push(Box::pin(async move { fetch_summaries(memory, summaries_budget, tc) .await .map(ContextSlot::Summaries) })); } - if episodic_active && cross_session_budget > 0 { + if episodic_active + && cross_session_budget > 0 + && type_active(active_types, FunctionalType::CrossSessionSummary) + { fetchers.push(Box::pin(async move { fetch_cross_session(memory, query, cross_session_budget, tc) .await .map(ContextSlot::CrossSession) })); } - if episodic_active && semantic_recall_budget > 0 { + if episodic_active + && semantic_recall_budget > 0 + && type_active(active_types, FunctionalType::Episodic) + { fetchers.push(Box::pin(async move { fetch_semantic_recall(memory, query, semantic_recall_budget, tc, Some(router_ref)) .await .map(|(msg, score)| ContextSlot::SemanticRecall(msg, score)) })); + } + // Document RAG is not yet a gated FunctionalType (spec 064 §4: v2 extension) — it stays + // always-composed within its existing `episodic_active` activity gate, independent of + // whether FunctionalType::Episodic is in the active set (N2: must not be silently disabled + // by the Episodic gate above). + if episodic_active && semantic_recall_budget > 0 { fetchers.push(Box::pin(async move { fetch_document_rag(memory, query, semantic_recall_budget, tc) .await .map(ContextSlot::DocumentRag) })); } - // Corrections are safety-critical and never budget-gated or tier-gated. + // Corrections are safety-critical and never budget-gated, tier-gated, or type-gated. fetchers.push(Box::pin(async move { fetch_corrections(memory, query, recall_limit, min_sim, scrub) .await .map(ContextSlot::Corrections) })); - // Code RAG is request-driven, not memory-tier; exempt from tier filtering. + // Code RAG is request-driven, not memory-tier; exempt from tier and type filtering. if code_context_budget > 0 && let Some(idx) = index { @@ -226,14 +262,20 @@ fn schedule_context_fetchers<'r>( result.map(ContextSlot::CodeContext) })); } - if declarative_active && graph_facts_budget > 0 { + if declarative_active + && graph_facts_budget > 0 + && type_active(active_types, FunctionalType::GraphFact) + { fetchers.push(Box::pin(async move { fetch_graph_facts(memory, query, graph_facts_budget, tc) .await .map(ContextSlot::GraphFacts) })); } - if declarative_active && memory.persona_config.context_budget_tokens > 0 { + if declarative_active + && memory.persona_config.context_budget_tokens > 0 + && type_active(active_types, FunctionalType::UserFact) + { fetchers.push(Box::pin(async move { let persona_budget = memory.persona_config.context_budget_tokens; fetch_persona_facts(memory, persona_budget, tc) @@ -241,6 +283,8 @@ fn schedule_context_fetchers<'r>( .map(ContextSlot::PersonaFacts) })); } + // Trajectory hints are not yet a gated FunctionalType (spec 064 §4: v2 extension) — stays + // always-composed within its existing `procedural_active` activity gate. if procedural_active && memory.trajectory_config.context_budget_tokens > 0 { fetchers.push(Box::pin(async move { let tbudget = memory.trajectory_config.context_budget_tokens; @@ -249,6 +293,8 @@ fn schedule_context_fetchers<'r>( .map(ContextSlot::TrajectoryHints) })); } + // Tree memory is not yet a gated FunctionalType (spec 064 §4: v2 extension) — stays + // always-composed within its existing `declarative_active` activity gate. if declarative_active && memory.tree_config.context_budget_tokens > 0 { fetchers.push(Box::pin(async move { let tbudget = memory.tree_config.context_budget_tokens; @@ -260,6 +306,7 @@ fn schedule_context_fetchers<'r>( if procedural_active && memory.reasoning_config.enabled && memory.reasoning_config.context_budget_tokens > 0 + && type_active(active_types, FunctionalType::ReasoningStrategy) { fetchers.push(Box::pin(async move { let rbudget = memory.reasoning_config.context_budget_tokens; @@ -314,7 +361,11 @@ impl ContextAssembler { /// # Errors /// /// Propagates errors from any async fetch operation. - #[tracing::instrument(name = "context.assembler.gather", skip_all)] + #[tracing::instrument( + name = "context.assembler.gather", + skip_all, + fields(active_types = ?input.active_types) + )] pub async fn gather( input: &ContextAssemblyInput<'_>, ) -> Result { @@ -373,6 +424,7 @@ impl ContextAssembler { recall_limit, min_sim, input.active_levels, + input.active_types, ); let mut prepared = empty_prepared_context(); @@ -1335,6 +1387,214 @@ mod tests { assert!(d); } + // ── type_active (spec 064, MemGuard type-aware retrieval, #6086) ─────────── + + #[test] + fn type_active_empty_active_set_means_all_types() { + assert!(type_active(&[], FunctionalType::Episodic)); + assert!(type_active(&[], FunctionalType::GraphFact)); + assert!(type_active(&[], FunctionalType::BehavioralRule)); + } + + #[test] + fn type_active_nonempty_set_gates_by_membership() { + let active = [FunctionalType::UserFact]; + assert!(type_active(&active, FunctionalType::UserFact)); + assert!(!type_active(&active, FunctionalType::Episodic)); + assert!(!type_active(&active, FunctionalType::GraphFact)); + } + + // ── schedule_context_fetchers type gating (spec 064, #6086) ──────────────── + + struct NoopRouter; + impl zeph_common::memory::MemoryRouter for NoopRouter { + fn route(&self, _query: &str) -> zeph_common::memory::MemoryRoute { + zeph_common::memory::MemoryRoute::default() + } + } + impl AsyncMemoryRouter for NoopRouter { + fn route_async<'a>( + &'a self, + _query: &'a str, + ) -> std::pin::Pin< + Box + Send + 'a>, + > { + Box::pin(async move { + zeph_common::memory::RoutingDecision { + route: zeph_common::memory::MemoryRoute::default(), + confidence: 1.0, + reasoning: None, + } + }) + } + } + + /// View with every budget-gated fetcher's own config enabled, so that whether a fetcher is + /// *scheduled* depends only on the tier/type gate under test, not on the fetcher's own + /// budget/enabled guard. Mirrors `empty_view` but flips every relevant flag on. + fn full_active_view() -> ContextMemoryView { + let mut view = empty_view(); + view.persona_config.context_budget_tokens = 100; + view.trajectory_config.context_budget_tokens = 100; + view.tree_config.context_budget_tokens = 100; + view.reasoning_config.enabled = true; + view.reasoning_config.context_budget_tokens = 100; + view.document_config.rag_enabled = true; + view + } + + #[allow(clippy::too_many_arguments)] + fn schedule_all_budgeted<'r>( + view: &'r ContextMemoryView, + tc: &'r NaiveTokenCounter, + router: &'r NoopRouter, + active_types: &'r [FunctionalType], + ) -> FuturesUnordered> { + schedule_context_fetchers( + view, + tc, + "query", + |s| s.into(), + None, + router, + 100, + 100, + 100, + 100, + 100, + 10, + 0.5, + &[], + active_types, + ) + } + + #[test] + fn schedule_context_fetchers_schedules_everything_when_active_types_empty() { + let view = full_active_view(); + let tc = NaiveTokenCounter; + let router = NoopRouter; + let fetchers = schedule_all_budgeted(&view, &tc, &router, &[]); + // summaries, cross_session, semantic_recall, document_rag, corrections, graph_facts, + // persona_facts, trajectory_hints, tree_memory, reasoning_strategies (code RAG excluded: + // no IndexAccess passed). + assert_eq!(fetchers.len(), 10); + } + + #[test] + fn schedule_context_fetchers_gates_to_user_fact_only_sc1() { + // SC#1: with an active set of [UserFact], only fetch_persona_facts (plus the always-on + // fetch_corrections) should be scheduled among the type-gated sources. Un-type-gated v2 + // slots (trajectory_hints, tree_memory, document_rag) still schedule under their own + // existing activity/budget gate — they are not yet a FunctionalType axis (spec 064 §4). + let view = full_active_view(); + let tc = NaiveTokenCounter; + let router = NoopRouter; + let active = [FunctionalType::UserFact]; + let fetchers = schedule_all_budgeted(&view, &tc, &router, &active); + // persona_facts + corrections + trajectory_hints + tree_memory + document_rag. + assert_eq!(fetchers.len(), 5); + } + + #[test] + fn schedule_context_fetchers_document_rag_survives_episodic_exclusion_n2() { + // N2 regression: excluding Episodic from the active set must not silently disable + // document_rag — it shares a budget gate with semantic_recall but is not yet a gated + // FunctionalType. Use GraphFact as the sole active type so Episodic is excluded. + let view = full_active_view(); + let tc = NaiveTokenCounter; + let router = NoopRouter; + let active = [FunctionalType::GraphFact]; + let fetchers = schedule_all_budgeted(&view, &tc, &router, &active); + // graph_facts + corrections + trajectory_hints + tree_memory + document_rag. + // Crucially: semantic_recall is absent (Episodic excluded) while document_rag is present. + assert_eq!(fetchers.len(), 5); + } + + #[test] + fn schedule_context_fetchers_gates_cross_session_summary_both_slots() { + // CrossSessionSummary gates both fetch_summaries and fetch_cross_session (spec 064 §4). + let view = full_active_view(); + let tc = NaiveTokenCounter; + let router = NoopRouter; + let active = [FunctionalType::CrossSessionSummary]; + let fetchers = schedule_all_budgeted(&view, &tc, &router, &active); + // summaries + cross_session + corrections + trajectory_hints + tree_memory + document_rag. + assert_eq!(fetchers.len(), 6); + } + + // ── ContextAssembler::gather (SC#4, spec 064 §12.4) ───────────────────────── + // + // SC#4 requires the type-exclusion half to be measured at the PreparedContext/token layer, + // not re-derived from `schedule_context_fetchers`'s scheduling counts alone (round-1 critic + // finding S3: a count-proxy would stay green even if the gate scheduled the wrong fetcher). + // This test drives the real `gather()` entry point end-to-end and asserts on the resulting + // `PreparedContext` slots directly. + + #[tokio::test] + async fn gather_with_user_fact_active_type_excludes_other_slots_sc4() { + let mock = MockMemoryBackend { + persona_facts: vec![MemPersonaFact { + category: "preference".to_string(), + content: "prefers concise answers".to_string(), + }], + ..Default::default() + }; + let mut memory = mock_view(mock); + memory.persona_config.enabled = true; + memory.persona_config.context_budget_tokens = 1000; + memory.graph_config.enabled = true; + memory.reasoning_config.enabled = true; + memory.reasoning_config.context_budget_tokens = 500; + memory.document_config.rag_enabled = false; + + let mut context_manager = crate::manager::ContextManager::new(); + context_manager.budget = Some(crate::budget::ContextBudget::new(128_000, 0.1)); + + let tc = NaiveTokenCounter; + let active_types = [FunctionalType::UserFact]; + + let input = crate::input::ContextAssemblyInput { + memory: &memory, + context_manager: &context_manager, + token_counter: &tc, + skills_prompt: "", + index: None, + correction_config: None, + sidequest_turn_counter: 0, + messages: &[], + query: "what do you know about me?", + scrub: |s| s.into(), + active_levels: &[], + active_types: &active_types, + router: Box::new(NoopRouter), + planned_next_tools: &[], + }; + + let prepared = ContextAssembler::gather(&input).await.unwrap(); + + assert!( + prepared.recall.is_none(), + "Episodic excluded from active set: recall must be None" + ); + assert!( + prepared.reasoning_hints.is_none(), + "ReasoningStrategy excluded from active set: reasoning_hints must be None" + ); + assert!( + prepared.graph_facts.is_none(), + "GraphFact excluded from active set: graph_facts must be None" + ); + assert!( + prepared.summaries.is_none(), + "CrossSessionSummary excluded from active set: summaries must be None" + ); + assert!( + prepared.persona_facts.is_some(), + "UserFact is in the active set: persona_facts must be Some" + ); + } + // ── fetch_reasoning_strategies ──────────────────────────────────────────── #[tokio::test] diff --git a/crates/zeph-context/src/input.rs b/crates/zeph-context/src/input.rs index 2d351747b..1b7c5efeb 100644 --- a/crates/zeph-context/src/input.rs +++ b/crates/zeph-context/src/input.rs @@ -12,7 +12,7 @@ use std::borrow::Cow; use std::sync::Arc; use zeph_common::PlannedToolHint; -use zeph_common::memory::{CompressionLevel, ContextMemoryBackend}; +use zeph_common::memory::{CompressionLevel, ContextMemoryBackend, FunctionalType}; use zeph_config::{ DocumentConfig, GraphConfig, PersonaConfig, ReasoningConfig, TrajectoryConfig, TreeConfig, }; @@ -57,6 +57,15 @@ pub struct ContextAssemblyInput<'a> { /// A caller computing this from a config-driven policy must guarantee non-empty intent or /// accept that an empty slice disables tier-based filtering entirely. pub active_levels: &'a [CompressionLevel], + /// Active functional memory types for this turn's type-aware retrieval composition + /// (spec 064, #6086), resolved by `zeph-agent-context` from `TypeAwareComposeConfig`. + /// + /// An empty slice means "no type gating" — every gated fetcher runs subject to its + /// existing activity/budget guard, identical to pre-#6086 behaviour. This is both the + /// `enabled = false` no-op case and the `default_compose_types = []` ("all types") case; + /// resolving both to an empty slice keeps `schedule_context_fetchers`'s gate a single + /// `active.is_empty() || active.contains(&type)` check. + pub active_types: &'a [FunctionalType], /// Pre-built memory router for this turn. Built by `zeph-core` via `build_memory_router()` /// and passed in to avoid a `zeph-memory` dependency inside `zeph-context`. pub router: Box, diff --git a/crates/zeph-core/src/agent/builder.rs b/crates/zeph-core/src/agent/builder.rs index 245788831..93ce03138 100644 --- a/crates/zeph-core/src/agent/builder.rs +++ b/crates/zeph-core/src/agent/builder.rs @@ -302,6 +302,19 @@ impl Agent { self } + /// Wire `MemGuard` type-aware retrieval composition config snapshot (spec 064, #6086). + /// + /// No LLM providers involved — v1 resolves the active `FunctionalType` set from static + /// config plus, when `intent_scoped`, the existing no-LLM `HeuristicRouter` (spec 064 §5). + #[must_use] + pub fn with_type_aware_compose_config( + mut self, + config: zeph_config::memory::TypeAwareComposeConfig, + ) -> Self { + self.services.memory.persistence.type_aware_compose_config = config; + self + } + /// Configure memory formatting: compression guidelines, digest, and context strategy. #[must_use] pub fn with_memory_formatting_config( diff --git a/crates/zeph-core/src/agent/context/assembly.rs b/crates/zeph-core/src/agent/context/assembly.rs index d8cafcbc5..890f89ace 100644 --- a/crates/zeph-core/src/agent/context/assembly.rs +++ b/crates/zeph-core/src/agent/context/assembly.rs @@ -659,6 +659,12 @@ impl Agent { .persistence .tiered_retrieval_validator .clone(), + type_aware_compose_config: self + .services + .memory + .persistence + .type_aware_compose_config + .clone(), fidelity_config: self.services.memory.compaction.fidelity_config.as_ref(), fidelity_semantic_provider: self .services diff --git a/crates/zeph-core/src/agent/state/persistence.rs b/crates/zeph-core/src/agent/state/persistence.rs index 3626eafa8..c9f498da4 100644 --- a/crates/zeph-core/src/agent/state/persistence.rs +++ b/crates/zeph-core/src/agent/state/persistence.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use zeph_config::ContextFormat; -use zeph_config::memory::TieredRetrievalConfig; +use zeph_config::memory::{TieredRetrievalConfig, TypeAwareComposeConfig}; use zeph_llm::any::AnyProvider; use zeph_memory::semantic::SemanticMemory; @@ -63,6 +63,14 @@ pub(crate) struct MemoryPersistenceState { /// `None` when `tiered_retrieval.validator_provider` is empty. Resolved at agent /// construction, never changed at runtime. pub(crate) tiered_retrieval_validator: Option>, + + // ── MemGuard type-aware retrieval composition (spec 064, #6086) ────────────── + /// Type-aware retrieval composition configuration snapshot (`[memory.type_aware_compose]`). + /// + /// Stored here so `ContextAssemblyView` can read it without accessing the full config + /// tree. Set by `with_type_aware_compose_config`. No LLM providers are involved (v1 has + /// no LLM classifier — see spec 064 §5). + pub(crate) type_aware_compose_config: TypeAwareComposeConfig, } impl Default for MemoryPersistenceState { @@ -82,6 +90,7 @@ impl Default for MemoryPersistenceState { tiered_retrieval_config: TieredRetrievalConfig::default(), tiered_retrieval_classifier: None, tiered_retrieval_validator: None, + type_aware_compose_config: TypeAwareComposeConfig::default(), } } } diff --git a/crates/zeph-memory/src/lib.rs b/crates/zeph-memory/src/lib.rs index f7ded4ae9..c143cfda7 100644 --- a/crates/zeph-memory/src/lib.rs +++ b/crates/zeph-memory/src/lib.rs @@ -229,7 +229,8 @@ pub use vector_store::{ VectorStoreError, }; pub use zeph_common::config::memory::HebbianConsolidationConfig; -pub use zeph_common::memory::TokenCounting; +pub use zeph_common::memory::{FunctionalType, TokenCounting}; pub use zeph_config::memory::CompressionGuidelinesConfig; pub use zeph_config::memory::EvictionConfig; +pub use zeph_config::memory::TypeAwareComposeConfig; pub use zeph_config::memory::{CompactionProbeConfig, ProbeCategory}; diff --git a/crates/zeph-memory/src/tiered_retrieval.rs b/crates/zeph-memory/src/tiered_retrieval.rs index bcbb64219..992f94130 100644 --- a/crates/zeph-memory/src/tiered_retrieval.rs +++ b/crates/zeph-memory/src/tiered_retrieval.rs @@ -58,7 +58,25 @@ pub enum IntentClass { } impl IntentClass { - fn from_route(route: MemoryRoute) -> Self { + /// Classify a routing decision into an intent tier. + /// + /// Pure function, no I/O — reused by `zeph-agent-context`'s type-aware retrieval + /// composition (spec 064, #6086) to widen the active `FunctionalType` set per classified + /// intent without adding a new LLM call: pass the result of `HeuristicRouter::route`. + /// + /// # Examples + /// + /// ``` + /// use zeph_common::memory::MemoryRoute; + /// use zeph_memory::IntentClass; + /// + /// assert_eq!( + /// IntentClass::from_route(MemoryRoute::Graph), + /// IntentClass::DeepReasoning + /// ); + /// ``` + #[must_use] + pub fn from_route(route: MemoryRoute) -> Self { match route { MemoryRoute::Keyword | MemoryRoute::Episodic => Self::ProfileLookup, MemoryRoute::Graph => Self::DeepReasoning, diff --git a/src/init/memory.rs b/src/init/memory.rs index 79cede712..caac41ec7 100644 --- a/src/init/memory.rs +++ b/src/init/memory.rs @@ -200,6 +200,26 @@ pub(super) fn step_memory(state: &mut WizardState) -> anyhow::Result<()> { .default(false) .interact()?; + state.type_aware_compose_enabled = Confirm::new() + .with_prompt( + "Enable MemGuard-inspired type-aware retrieval composition? (retrieval-only: gates \ + which functional memory types — facts, corrections, reasoning strategies, etc. — \ + are composed per turn instead of injecting all of them; #6086)", + ) + .default(false) + .interact()?; + + if state.type_aware_compose_enabled { + state.type_aware_compose_intent_scoped = Confirm::new() + .with_prompt( + "Widen the composed type set per classified query intent? (no new LLM call — \ + reuses the existing heuristic memory router; advanced per-type selection via \ + default_compose_types remains config-file-only)", + ) + .default(false) + .interact()?; + } + println!(); Ok(()) } diff --git a/src/init/mod.rs b/src/init/mod.rs index ef7c8e3de..027643861 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -286,6 +286,11 @@ pub(crate) struct WizardState { // CAM fidelity (#4547) /// Enable heuristic fidelity scoring (Full/Compressed/Placeholder). pub(crate) fidelity_enabled: bool, + // MemGuard type-aware retrieval composition (spec 064, #6086) + /// Enable type-aware retrieval composition (`[memory.type_aware_compose]`). + pub(crate) type_aware_compose_enabled: bool, + /// Widen the active set per classified query intent when type-aware composition is enabled. + pub(crate) type_aware_compose_intent_scoped: bool, // Worktree isolation for sub-agents (#4656) pub(crate) worktree_enabled: bool, pub(crate) worktree_bg_isolation: BgIsolation, @@ -522,6 +527,8 @@ impl Default for WizardState { cocoon_wants_access_hash: false, cocoon_show_balance: true, fidelity_enabled: false, + type_aware_compose_enabled: false, + type_aware_compose_intent_scoped: false, worktree_enabled: false, worktree_bg_isolation: BgIsolation::Worktree, worktree_base_ref: WorktreeBaseRef::Head, @@ -958,6 +965,10 @@ pub(crate) fn build_config(state: &WizardState) -> Config { }); } + config.memory.type_aware_compose.enabled = state.type_aware_compose_enabled; + config.memory.type_aware_compose.intent_scoped = + state.type_aware_compose_enabled && state.type_aware_compose_intent_scoped; + // MM-F1/F2/F5 retrieval tuning defaults — no interactive question needed; // all fields have sensible defaults. Surfaced here per CLAUDE.md rule #4. println!( @@ -2335,6 +2346,41 @@ mod tests { assert_eq!(config.llm.providers[0].provider_type, ProviderKind::Ollama); } + #[test] + fn build_config_type_aware_compose_disabled_by_default() { + let state = single_provider_state(); + let config = build_config(&state); + assert!(!config.memory.type_aware_compose.enabled); + assert!(!config.memory.type_aware_compose.intent_scoped); + } + + #[test] + fn build_config_type_aware_compose_enabled_wires_intent_scoped() { + let state = WizardState { + type_aware_compose_enabled: true, + type_aware_compose_intent_scoped: true, + ..single_provider_state() + }; + let config = build_config(&state); + assert!(config.memory.type_aware_compose.enabled); + assert!(config.memory.type_aware_compose.intent_scoped); + } + + #[test] + fn build_config_type_aware_compose_intent_scoped_ignored_when_disabled() { + // intent_scoped must not leak true when the master switch is off — the wizard only + // asks the intent_scoped question when enabled is confirmed, but build_config must be + // defensive against stale WizardState too. + let state = WizardState { + type_aware_compose_enabled: false, + type_aware_compose_intent_scoped: true, + ..single_provider_state() + }; + let config = build_config(&state); + assert!(!config.memory.type_aware_compose.enabled); + assert!(!config.memory.type_aware_compose.intent_scoped); + } + #[test] fn build_config_claude_skips_embedding_model() { let state = WizardState { diff --git a/src/runner.rs b/src/runner.rs index 59a2d82ad..eccb21200 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -361,6 +361,7 @@ where deps.tiered_retrieval_classifier_provider, deps.tiered_retrieval_validator_provider, ) + .with_type_aware_compose_config(config.memory.type_aware_compose.clone()) .with_focus_and_sidequest_config(config.agent.focus.clone(), config.memory.sidequest.clone()) .with_trajectory_and_category_config( config.memory.trajectory.clone(),