Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 11 additions & 0 deletions config/default.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
1 change: 1 addition & 0 deletions crates/zeph-agent-context/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 14 additions & 0 deletions crates/zeph-agent-context/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -669,6 +677,7 @@ impl ContextService {
query,
scrub: view.scrub,
active_levels,
active_types: &active_types,
router,
planned_next_tools: view.planned_next_tools,
};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions crates/zeph-agent-context/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,16 @@ pub struct ContextAssemblyView<'a> {
/// `None` means validation is skipped (evidence accepted as-is).
pub tiered_retrieval_validator: Option<Arc<zeph_llm::any::AnyProvider>>,

// ── 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]`.
///
Expand Down
152 changes: 152 additions & 0 deletions crates/zeph-agent-context/src/type_aware_compose.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
// 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<FunctionalType> {
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));
}
}
}
Loading
Loading